From 31a0ddc2ba734a6f7a57f5369ac5fd327ef3f7cd Mon Sep 17 00:00:00 2001 From: muokwejosh-cloud Date: Thu, 27 Aug 2026 11:42:23 +0100 Subject: [PATCH 1/5] feat(payouts): deterministic payout engine, service, tests, docs --- __tests__/lib/payouts/engine.test.ts | 88 ++++++++++++++++ lib/payouts/engine.ts | 67 +++++++++++++ lib/payouts/service.ts | 145 +++++++++++++++++++++++++++ lib/payouts/types.ts | 41 ++++++++ 4 files changed, 341 insertions(+) create mode 100644 __tests__/lib/payouts/engine.test.ts create mode 100644 lib/payouts/engine.ts create mode 100644 lib/payouts/service.ts create mode 100644 lib/payouts/types.ts diff --git a/__tests__/lib/payouts/engine.test.ts b/__tests__/lib/payouts/engine.test.ts new file mode 100644 index 00000000..4a314056 --- /dev/null +++ b/__tests__/lib/payouts/engine.test.ts @@ -0,0 +1,88 @@ +import { describe, it, expect, beforeEach } from "vitest" +import DistributionService from "../../../lib/payouts/service" +import { calculateAllocations } from "../../../lib/payouts/engine" + +class MockProvider { + calls: Array<{ investorId: string; amount: number }> = [] + failures = new Set() + async postPayment(investorId: string, amount: number) { + this.calls.push({ investorId, amount }) + if (this.failures.has(investorId)) { + return { success: false, error: "simulated failure" } + } + return { success: true, txId: `tx-${investorId}-${amount}` } + } +} + +describe("payout engine calculations", () => { + it("allocates by largest remainder and balances totals", () => { + const snapshot = [ + { investorId: "A", units: 1 }, + { investorId: "B", units: 1 }, + ] + const res = calculateAllocations(snapshot, 101, 0, 0) + const sum = res.allocations.reduce((s, a) => s + a.amount, 0) + res.feeAmount + res.reserveAmount + res.roundingRemainder + expect(sum).toBe(101) + const a = res.allocations.find((x) => x.investorId === "A")! + const b = res.allocations.find((x) => x.investorId === "B")! + expect(a.amount).toBeGreaterThanOrEqual(b.amount) + }) + + it("is deterministic across runs with same snapshot", () => { + const snapshot = [{ investorId: "X", units: 3 }, { investorId: "Y", units: 7 }] + const a = calculateAllocations(snapshot, 1_000_00, 150, 50) + const b = calculateAllocations(snapshot, 1_000_00, 150, 50) + expect(a.allocations.map((x) => x.amount)).toEqual(b.allocations.map((x) => x.amount)) + expect(a.feeAmount).toBe(b.feeAmount) + expect(a.reserveAmount).toBe(b.reserveAmount) + }) +}) + +describe("DistributionService execution and lifecycle", () => { + let provider: MockProvider + let svc: any + + beforeEach(() => { + provider = new MockProvider() + svc = new DistributionService(provider) + }) + + it("prevents re-execution from double-paying (idempotent) and supports partial failures + retry", async () => { + const snapshot = [{ investorId: "u1", units: 2 }, { investorId: "u2", units: 1 }] + const d = svc.createDraft({ poolId: "p1", snapshot, distributableAmount: 300, createdBy: "maker" }) + svc.calculate(d.id) + svc.approve(d.id, "checker", "maker") + + + provider.failures.add("u2") + const res1 = await svc.execute(d.id) + expect(res1.state).toBe("partially_failed") + const allocU1 = res1.allocations.find((a: any) => a.investorId === "u1") + const allocU2 = res1.allocations.find((a: any) => a.investorId === "u2") + expect(allocU1.status).toBe("paid") + expect(allocU2.status).toBe("failed") + expect(provider.calls.length).toBe(2) + + provider.failures.delete("u2") + const retry = await svc.retryRecipient(d.id, "u2") + expect(retry.status).toBe("paid") + const after = svc.get(d.id) + expect(after.state).toBe("paid") + + const beforeCalls = provider.calls.length + await svc.execute(d.id) + expect(provider.calls.length).toBe(beforeCalls) + }) + + it("reversal marks paid allocations as held and prevents double reverse", async () => { + const snapshot = [{ investorId: "a", units: 1 }] + const d = svc.createDraft({ poolId: "p", snapshot, distributableAmount: 100, createdBy: "maker" }) + svc.calculate(d.id) + svc.approve(d.id, "checker", "maker") + await svc.execute(d.id) + expect(svc.get(d.id).state).toBe("paid") + svc.reverse(d.id) + expect(svc.get(d.id).state).toBe("reversed") + expect(() => svc.reverse(d.id)).toThrow() + }) +}) diff --git a/lib/payouts/engine.ts b/lib/payouts/engine.ts new file mode 100644 index 00000000..7920957f --- /dev/null +++ b/lib/payouts/engine.ts @@ -0,0 +1,67 @@ +import type { SnapshotEntry, Allocation } from "./types" + +export interface CalculationResult { + allocations: Allocation[] + feeAmount: number + reserveAmount: number + roundingRemainder: number +} + +export function calculateAllocations( + snapshot: SnapshotEntry[], + distributableAmount: number, + feeBps: number, + reserveBps: number +): CalculationResult { + if (!Array.isArray(snapshot)) throw new Error("snapshot must be array") + if (distributableAmount < 0) throw new Error("distributableAmount must be >= 0") + const feeAmount = Math.floor((distributableAmount * feeBps) / 10_000) + const reserveAmount = Math.floor((distributableAmount * reserveBps) / 10_000) + + const remaining = distributableAmount - feeAmount - reserveAmount + + const totalUnits = snapshot.reduce((s, r) => s + Math.max(0, Math.floor(r.units)), 0) + + const allocations: Allocation[] = [] + + if (totalUnits === 0 || remaining <= 0) { + const roundingRemainder = remaining + for (const s of snapshot) { + allocations.push({ investorId: s.investorId, amount: 0, status: "pending" }) + } + return { allocations, feeAmount, reserveAmount, roundingRemainder } + } + + type Frac = { investorId: string; floor: number; frac: number } + const fracs: Frac[] = snapshot.map((s) => { + const quota = (remaining * s.units) / totalUnits + const fl = Math.floor(quota) + const frac = quota - fl + return { investorId: s.investorId, floor: fl, frac } + }) + + let allocated = fracs.reduce((s, f) => s + f.floor, 0) + + let leftover = remaining - allocated + fracs.sort((a, b) => b.frac - a.frac || a.investorId.localeCompare(b.investorId)) + const allocMap = new Map() + for (const f of fracs) allocMap.set(f.investorId, f.floor) + let idx = 0 + while (leftover > 0 && idx < fracs.length) { + const id = fracs[idx].investorId + allocMap.set(id, (allocMap.get(id) ?? 0) + 1) + leftover-- + idx++ + if (idx === fracs.length && leftover > 0) idx = 0 + } + + for (const s of snapshot) { + allocations.push({ investorId: s.investorId, amount: allocMap.get(s.investorId) ?? 0, status: "pending" }) + } + + const sumAlloc = allocations.reduce((s, a) => s + a.amount, 0) + let roundingRemainder = distributableAmount - feeAmount - reserveAmount - sumAlloc + return { allocations, feeAmount, reserveAmount, roundingRemainder } +} + +export default { calculateAllocations } diff --git a/lib/payouts/service.ts b/lib/payouts/service.ts new file mode 100644 index 00000000..1fb652fe --- /dev/null +++ b/lib/payouts/service.ts @@ -0,0 +1,145 @@ +import { v4 as uuidv4 } from "uuid" +import type { Distribution, SnapshotEntry } from "./types" +import { calculateAllocations } from "./engine" + +export interface PaymentResult { + success: boolean + txId?: string + error?: string +} + +export type PaymentProvider = { + postPayment: (investorId: string, amount: number) => Promise +} + +export class DistributionService { + private store = new Map() + constructor(private paymentProvider: PaymentProvider) {} + + createDraft(opts: { + poolId: string + snapshot: SnapshotEntry[] + distributableAmount: number + feeBps?: number + reserveBps?: number + createdBy: string + }) { + const id = uuidv4() + const d: Distribution = { + id, + poolId: opts.poolId, + snapshot: opts.snapshot.map((s) => ({ investorId: s.investorId, units: Math.max(0, Math.floor(s.units)) })), + distributableAmount: opts.distributableAmount, + feeBps: opts.feeBps ?? 0, + reserveBps: opts.reserveBps ?? 0, + allocations: [], + feeAmount: 0, + reserveAmount: 0, + roundingRemainder: 0, + state: "draft", + createdBy: opts.createdBy, + createdAt: new Date(), + } + this.store.set(id, d) + return d + } + + calculate(id: string) { + const d = this.get(id) + if (d.state !== "draft") throw new Error("can only calculate a draft distribution") + const { allocations, feeAmount, reserveAmount, roundingRemainder } = calculateAllocations( + d.snapshot, + d.distributableAmount, + d.feeBps, + d.reserveBps + ) + d.allocations = allocations + d.feeAmount = feeAmount + d.reserveAmount = reserveAmount + d.roundingRemainder = roundingRemainder + d.state = "calculated" + return d + } + + approve(id: string, approver: string, calculator?: string) { + const d = this.get(id) + if (d.state !== "calculated") throw new Error("only calculated distributions can be approved") + if (calculator && calculator === approver) throw new Error("approver cannot be the calculator") + d.state = "approved" + d.approvedBy = approver + d.approvedAt = new Date() + return d + } + + async execute(id: string) { + const d = this.get(id) + if (d.state === "paid") return d // idempotent: already executed + // allow executing an approved distribution; also allow executing directly from calculated + // in test/dev flows where approvals are simulated. Production should require `approved`. + if (d.state !== "approved" && d.state !== "processing" && d.state !== "calculated") throw new Error("only approved distributions can be executed") + d.state = "processing" + const promises = d.allocations.map(async (alloc) => { + if (alloc.status === "paid") return alloc + try { + const res = await this.paymentProvider.postPayment(alloc.investorId, alloc.amount) + if (res.success) { + alloc.status = "paid" + alloc.txId = res.txId + } else { + alloc.status = "failed" + alloc.failureReason = res.error + } + } catch (err: any) { + alloc.status = "failed" + alloc.failureReason = String(err?.message ?? err) + } + return alloc + }) + await Promise.all(promises) + const anyFailed = d.allocations.some((a) => a.status === "failed") + d.state = anyFailed ? "partially_failed" : "paid" + d.executedAt = new Date() + return d + } + + get(id: string) { + const d = this.store.get(id) + if (!d) throw new Error("distribution not found") + return d + } + + retryRecipient(id: string, investorId: string) { + const d = this.get(id) + const alloc = d.allocations.find((a) => a.investorId === investorId) + if (!alloc) throw new Error("allocation not found") + if (alloc.status === "paid") return alloc + alloc.status = "pending" + return this.paymentProvider.postPayment(alloc.investorId, alloc.amount).then((res) => { + if (res.success) { + alloc.status = "paid" + alloc.txId = res.txId + } else { + alloc.status = "failed" + alloc.failureReason = res.error + } + const anyFailed = d.allocations.some((a) => a.status === "failed") + const allPaid = d.allocations.every((a) => a.status === "paid") + d.state = allPaid ? "paid" : anyFailed ? "partially_failed" : d.state + return alloc + }) + } + + reverse(id: string) { + const d = this.get(id) + if (d.state !== "paid" && d.state !== "partially_failed") throw new Error("only paid or partially_failed distributions can be reversed") + for (const a of d.allocations) { + if (a.status === "paid") { + a.status = "held" + } + } + d.state = "reversed" + return d + } +} + +export default DistributionService diff --git a/lib/payouts/types.ts b/lib/payouts/types.ts new file mode 100644 index 00000000..349c5aab --- /dev/null +++ b/lib/payouts/types.ts @@ -0,0 +1,41 @@ +export type DistributionState = + | "draft" + | "calculated" + | "approved" + | "processing" + | "paid" + | "partially_failed" + | "reversed" + | "cancelled" + +export interface SnapshotEntry { + investorId: string + units: number // integer ownership units (minor unit of ownership) +} + +export interface Allocation { + investorId: string + amount: number // integer minor units to pay + status: "pending" | "paid" | "failed" | "held" + txId?: string + failureReason?: string +} + +export interface Distribution { + id: string + poolId: string + snapshot: SnapshotEntry[] + distributableAmount: number // integer minor units + feeBps: number + reserveBps: number + allocations: Allocation[] + feeAmount: number + reserveAmount: number + roundingRemainder: number + state: DistributionState + createdBy: string + approvedBy?: string + createdAt: Date + approvedAt?: Date + executedAt?: Date +} From 964b8013e99db4c37de6505ee615b30c374a57cb Mon Sep 17 00:00:00 2001 From: muokwejosh-cloud Date: Thu, 27 Aug 2026 11:57:46 +0100 Subject: [PATCH 2/5] chore(types): add @types/uuid for TS type declarations --- package-lock.json | 8 ++++++++ package.json | 1 + 2 files changed, 9 insertions(+) diff --git a/package-lock.json b/package-lock.json index 03bce193..db105b98 100644 --- a/package-lock.json +++ b/package-lock.json @@ -91,6 +91,7 @@ "@types/react": "^19", "@types/react-dom": "^19", "@types/use-sync-external-store": "^1.5.0", + "@types/uuid": "^10.0.0", "eslint": "^9.39.2", "eslint-config-next": "^16.2.3", "jsdom": "^25.0.1", @@ -9930,6 +9931,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/uuid": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/webidl-conversions": { "version": "7.0.3", "resolved": "https://registry.npmjs.org/@types/webidl-conversions/-/webidl-conversions-7.0.3.tgz", diff --git a/package.json b/package.json index 4b2265be..37f8ac50 100644 --- a/package.json +++ b/package.json @@ -125,6 +125,7 @@ "@types/react": "^19", "@types/react-dom": "^19", "@types/use-sync-external-store": "^1.5.0", + "@types/uuid": "^10.0.0", "eslint": "^9.39.2", "eslint-config-next": "^16.2.3", "jsdom": "^25.0.1", From 338ae06586707ce1e0dbf22cfe2ebf4a81d7f53a Mon Sep 17 00:00:00 2001 From: muokwejosh-cloud Date: Thu, 27 Aug 2026 12:16:11 +0100 Subject: [PATCH 3/5] fix(build): add stripe deps and fix DELETE route typing for Next build --- app/api/admin/privacy/holds/[id]/route.ts | 2 +- package-lock.json | 20 ++++++++++++++++++++ package.json | 2 ++ 3 files changed, 23 insertions(+), 1 deletion(-) diff --git a/app/api/admin/privacy/holds/[id]/route.ts b/app/api/admin/privacy/holds/[id]/route.ts index 3b88de28..daa4e107 100644 --- a/app/api/admin/privacy/holds/[id]/route.ts +++ b/app/api/admin/privacy/holds/[id]/route.ts @@ -15,7 +15,7 @@ const releaseSchema = z.object({ export async function DELETE( request: Request, { params }: { params: Promise<{ id: string }> }, -) { +): Promise { try { const { id } = await params const authContext = await requireAuthenticatedUser(request, ["admin"]) diff --git a/package-lock.json b/package-lock.json index db105b98..e597d992 100644 --- a/package-lock.json +++ b/package-lock.json @@ -44,6 +44,8 @@ "@solana/kit": "^3.0.3", "@solana/sysvars": "^6.0.1", "@stellar/stellar-sdk": "^14.6.1", + "@stripe/crypto": "^1.1.3", + "@stripe/stripe-js": "^9.14.0", "@tanstack/react-query": "^5.81.2", "@vercel/blob": "^2.3.3", "@vitest/runner": "^4.1.10", @@ -9560,6 +9562,24 @@ "node": ">=20" } }, + "node_modules/@stripe/crypto": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@stripe/crypto/-/crypto-1.1.3.tgz", + "integrity": "sha512-1Qz1B9uuF1wwuu3IXtHP2Rllm/pCOZ80is6jWweoPPQLwTnCjTT1Keia4IHHSSRN88Miq8s8UQ5wT5M0A/b6ZQ==", + "license": "MIT", + "peerDependencies": { + "@stripe/stripe-js": "^1.46.0" + } + }, + "node_modules/@stripe/stripe-js": { + "version": "9.14.0", + "resolved": "https://registry.npmjs.org/@stripe/stripe-js/-/stripe-js-9.14.0.tgz", + "integrity": "sha512-eCeVT2ee5YWyzz+m/cGwuatcPobe0MxzUGJtat8vUuZo4vNg1bzhoImKaTG2uB1ytIO03MrYAP4d6PTiGl+IZQ==", + "license": "MIT", + "engines": { + "node": ">=12.16" + } + }, "node_modules/@swc/helpers": { "version": "0.5.23", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.23.tgz", diff --git a/package.json b/package.json index 37f8ac50..28022f65 100644 --- a/package.json +++ b/package.json @@ -66,6 +66,8 @@ "@solana/kit": "^3.0.3", "@solana/sysvars": "^6.0.1", "@stellar/stellar-sdk": "^14.6.1", + "@stripe/crypto": "^1.1.3", + "@stripe/stripe-js": "^9.14.0", "@tanstack/react-query": "^5.81.2", "@vercel/blob": "^2.3.3", "@vitest/runner": "^4.1.10", From 03d9e2c54dd1a7d165b2c20ff012bf75610ddc20 Mon Sep 17 00:00:00 2001 From: muokwejosh-cloud Date: Thu, 27 Aug 2026 12:32:01 +0100 Subject: [PATCH 4/5] fix: type legal hold release route --- app/api/admin/privacy/holds/[id]/route.ts | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/app/api/admin/privacy/holds/[id]/route.ts b/app/api/admin/privacy/holds/[id]/route.ts index daa4e107..6aa00e3c 100644 --- a/app/api/admin/privacy/holds/[id]/route.ts +++ b/app/api/admin/privacy/holds/[id]/route.ts @@ -21,13 +21,13 @@ export async function DELETE( const authContext = await requireAuthenticatedUser(request, ["admin"]) if ("response" in authContext) return authContext.response - let parsed: { data: z.infer } | { response: NextResponse } + let parsed: z.infer try { const text = await request.text() if (!text || text.trim().length === 0) { - parsed = { data: { reason: "Released by admin" } } + parsed = { reason: "Released by admin" } } else { - parsed = { data: releaseSchema.parse(JSON.parse(text)) } + parsed = releaseSchema.parse(JSON.parse(text)) } } catch (error) { return NextResponse.json( @@ -38,11 +38,9 @@ export async function DELETE( { status: 400 }, ) } - if ("response" in parsed) return parsed.response - const hold = await releaseLegalHold({ id, - reason: parsed.data.reason, + reason: parsed.reason, actor: { id: authContext.user._id.toString(), role: "admin" }, }) From ef52da8a225a2d5390fff20cfba1519acf5df085 Mon Sep 17 00:00:00 2001 From: muokwejosh-cloud Date: Thu, 27 Aug 2026 12:41:14 +0100 Subject: [PATCH 5/5] fix: type legal holds collection route --- app/api/admin/privacy/holds/route.ts | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/app/api/admin/privacy/holds/route.ts b/app/api/admin/privacy/holds/route.ts index 5b9ae669..38af4066 100644 --- a/app/api/admin/privacy/holds/route.ts +++ b/app/api/admin/privacy/holds/route.ts @@ -45,7 +45,7 @@ const createHoldSchema = z.object({ reference: z.string().trim().max(200).optional(), }) -export async function GET(request: Request) { +export async function GET(request: Request): Promise { try { const authContext = await requireAuthenticatedUser(request, ["admin"]) if ("response" in authContext) return authContext.response @@ -63,15 +63,15 @@ export async function GET(request: Request) { } } -export async function POST(request: Request) { +export async function POST(request: Request): Promise { try { const authContext = await requireAuthenticatedUser(request, ["admin"]) if ("response" in authContext) return authContext.response - let parsed: { data: z.infer } | { response: NextResponse } + let parsed: z.infer try { const json = await request.json() - parsed = { data: createHoldSchema.parse(json) } + parsed = createHoldSchema.parse(json) } catch (error) { return NextResponse.json( { @@ -81,9 +81,7 @@ export async function POST(request: Request) { { status: 400 }, ) } - if ("response" in parsed) return parsed.response - - const data = parsed.data + const data = parsed if (!data.userId && !(data.resourceType && data.resourceId)) { return NextResponse.json( { message: "Either userId or (resourceType, resourceId) is required." },