Skip to content

Commit 0cfafe8

Browse files
authored
Merge pull request #104 from pope-h/feat/claimable-balances-40
feat: claimable balances for unregistered employees
2 parents c57d9ef + 47f90c9 commit 0cfafe8

9 files changed

Lines changed: 1650 additions & 1042 deletions

File tree

backend/package-lock.json

Lines changed: 1148 additions & 1023 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

backend/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
"dependencies": {
2323
"@sentry/node": "^10.42.0",
2424
"@sentry/profiling-node": "^10.42.0",
25+
"@stellar/stellar-sdk": "^14.3.3",
2526
"cors": "^2.8.6",
2627
"dotenv": "^17.3.1",
2728
"express": "^5.2.1",
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import type { Request, Response } from 'express';
2+
import { z } from 'zod';
3+
import { ClaimableBalanceService } from '../services/claimableBalanceService.js';
4+
5+
const createForEmployeeSchema = z.object({
6+
organizationId: z.number().int().positive(),
7+
employeeId: z.number().int().positive(),
8+
amount: z.string().min(1),
9+
assetIssuer: z.string().length(56),
10+
});
11+
12+
const listPendingSchema = z.object({
13+
organizationId: z.string().regex(/^\d+$/).transform(Number).optional(),
14+
walletAddress: z.string().length(56),
15+
});
16+
17+
export class ClaimableBalanceController {
18+
static async createForEmployee(req: Request, res: Response) {
19+
try {
20+
const { organizationId, employeeId, amount, assetIssuer } = createForEmployeeSchema.parse(
21+
req.body
22+
);
23+
24+
const wallet = await ClaimableBalanceService.ensureCustodialWallet({
25+
organizationId,
26+
employeeId,
27+
});
28+
29+
const claim = await ClaimableBalanceService.createOrgUsdClaimableBalance({
30+
organizationId,
31+
employeeId,
32+
amount,
33+
assetIssuer,
34+
claimantPublicKey: wallet.publicKey,
35+
});
36+
37+
res.status(201).json({
38+
success: true,
39+
data: {
40+
...claim,
41+
claimantPublicKey: wallet.publicKey,
42+
instructions:
43+
'A claimable balance was created for your custodial wallet. Add the ORGUSD trustline, then claim the balance using your wallet application.',
44+
},
45+
});
46+
} catch (error) {
47+
if (error instanceof z.ZodError) {
48+
return res.status(400).json({ error: 'Validation Error', details: error.issues });
49+
}
50+
res.status(500).json({ error: (error as Error).message || 'Failed to create claimable balance' });
51+
}
52+
}
53+
54+
static async listPendingForWallet(req: Request, res: Response) {
55+
try {
56+
const { organizationId, walletAddress } = listPendingSchema.parse(req.query);
57+
58+
const claims = await ClaimableBalanceService.listPendingClaimsForWallet({
59+
organizationId: organizationId ?? null,
60+
walletAddress,
61+
});
62+
63+
res.json({ success: true, data: claims });
64+
} catch (error) {
65+
if (error instanceof z.ZodError) {
66+
return res.status(400).json({ error: 'Validation Error', details: error.issues });
67+
}
68+
res.status(500).json({ error: (error as Error).message || 'Failed to list pending claims' });
69+
}
70+
}
71+
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
-- =============================================================================
2+
-- Migration 017: Claimable balances + custodial wallet secrets
3+
-- Purpose : Track claimable balances for employees who cannot receive ORGUSD
4+
-- and store custodial wallet secrets (encrypted at rest).
5+
-- =============================================================================
6+
7+
CREATE TABLE IF NOT EXISTS custodial_wallet_secrets (
8+
wallet_id UUID PRIMARY KEY REFERENCES wallets(id) ON DELETE CASCADE,
9+
encrypted_secret TEXT NOT NULL,
10+
encryption_version INTEGER NOT NULL DEFAULT 1,
11+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
12+
);
13+
14+
CREATE TABLE IF NOT EXISTS claimable_balance_claims (
15+
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
16+
organization_id INTEGER NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
17+
employee_id INTEGER REFERENCES employees(id) ON DELETE SET NULL,
18+
claimant_wallet_id UUID REFERENCES wallets(id) ON DELETE SET NULL,
19+
20+
asset_code VARCHAR(12) NOT NULL DEFAULT 'ORGUSD',
21+
asset_issuer VARCHAR(56) NOT NULL,
22+
amount DECIMAL(20, 7) NOT NULL CHECK (amount > 0),
23+
24+
status VARCHAR(20) NOT NULL DEFAULT 'pending'
25+
CHECK (status IN ('pending', 'claimed', 'expired', 'cancelled')),
26+
27+
stellar_balance_id VARCHAR(128),
28+
create_tx_hash VARCHAR(64),
29+
claim_tx_hash VARCHAR(64),
30+
31+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
32+
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
33+
claimed_at TIMESTAMPTZ
34+
);
35+
36+
CREATE INDEX IF NOT EXISTS idx_cb_claims_org_id ON claimable_balance_claims(organization_id);
37+
CREATE INDEX IF NOT EXISTS idx_cb_claims_employee_id ON claimable_balance_claims(employee_id);
38+
CREATE INDEX IF NOT EXISTS idx_cb_claims_status ON claimable_balance_claims(status);
39+
CREATE INDEX IF NOT EXISTS idx_cb_claims_balance_id ON claimable_balance_claims(stellar_balance_id);
40+
41+
CREATE TRIGGER update_claimable_balance_claims_updated_at
42+
BEFORE UPDATE ON claimable_balance_claims
43+
FOR EACH ROW
44+
EXECUTE FUNCTION update_updated_at_column();
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import { Router } from 'express';
2+
import { ClaimableBalanceController } from '../controllers/claimableBalanceController.js';
3+
4+
const router = Router();
5+
6+
router.post('/create-for-employee', ClaimableBalanceController.createForEmployee);
7+
router.get('/pending', ClaimableBalanceController.listPendingForWallet);
8+
9+
export default router;

backend/src/routes/v1/index.ts

Lines changed: 21 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,24 @@
11
import { Router } from 'express';
2-
import { throttlingMiddleware } from '../../middlewares/throttlingMiddleware';
3-
import { authRateLimit, apiRateLimit, dataRateLimit } from '../../middlewares/rateLimitMiddleware';
4-
import searchRoutes from '../searchRoutes';
5-
import employeeRoutes from '../employeeRoutes';
6-
import paymentRoutes from '../paymentRoutes';
7-
import authRoutes from '../authRoutes';
8-
import assetRoutes from '../assetRoutes';
9-
import throttlingRoutes from '../throttlingRoutes';
10-
import payrollBonusRoutes from '../payrollBonusRoutes';
11-
import payrollAuditRoutes from '../payrollAuditRoutes';
12-
import auditRoutes from '../auditRoutes';
13-
import balanceRoutes from '../balanceRoutes';
14-
import trustlineRoutes from '../trustlineRoutes';
15-
import payrollRoutes from '../payroll.routes';
16-
import exportRoutes from '../exportRoutes';
17-
import taxRoutes from '../taxRoutes';
18-
import multiSigRoutes from '../multiSigRoutes';
19-
import rateLimitRoutes from '../rateLimitRoutes';
20-
import freezeRoutes from '../freezeRoutes';
2+
import { throttlingMiddleware } from '../../middlewares/throttlingMiddleware.js';
3+
import { authRateLimit, apiRateLimit, dataRateLimit } from '../../middlewares/rateLimitMiddleware.js';
4+
import searchRoutes from '../searchRoutes.js';
5+
import employeeRoutes from '../employeeRoutes.js';
6+
import paymentRoutes from '../paymentRoutes.js';
7+
import authRoutes from '../authRoutes.js';
8+
import assetRoutes from '../assetRoutes.js';
9+
import throttlingRoutes from '../throttlingRoutes.js';
10+
import payrollBonusRoutes from '../payrollBonusRoutes.js';
11+
import payrollAuditRoutes from '../payrollAuditRoutes.js';
12+
import auditRoutes from '../auditRoutes.js';
13+
import balanceRoutes from '../balanceRoutes.js';
14+
import trustlineRoutes from '../trustlineRoutes.js';
15+
import payrollRoutes from '../payroll.routes.js';
16+
import exportRoutes from '../exportRoutes.js';
17+
import taxRoutes from '../taxRoutes.js';
18+
import multiSigRoutes from '../multiSigRoutes.js';
19+
import rateLimitRoutes from '../rateLimitRoutes.js';
20+
import freezeRoutes from '../freezeRoutes.js';
21+
import claimableBalanceRoutes from '../claimableBalanceRoutes.js';
2122

2223
const router = Router();
2324

@@ -38,5 +39,6 @@ router.use('/taxes', dataRateLimit(), taxRoutes);
3839
router.use('/multisig', apiRateLimit(), multiSigRoutes);
3940
router.use('/rate-limit', apiRateLimit(), rateLimitRoutes);
4041
router.use('/freeze', apiRateLimit(), freezeRoutes);
42+
router.use('/claims', apiRateLimit(), claimableBalanceRoutes);
4143

4244
export default router;

0 commit comments

Comments
 (0)