Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 88 additions & 0 deletions __tests__/lib/payouts/engine.test.ts
Original file line number Diff line number Diff line change
@@ -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<string>()
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()
})
})
12 changes: 5 additions & 7 deletions app/api/admin/privacy/holds/[id]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,19 +15,19 @@ const releaseSchema = z.object({
export async function DELETE(
request: Request,
{ params }: { params: Promise<{ id: string }> },
) {
): Promise<void | NextResponse> {
try {
const { id } = await params
const authContext = await requireAuthenticatedUser(request, ["admin"])
if ("response" in authContext) return authContext.response

let parsed: { data: z.infer<typeof releaseSchema> } | { response: NextResponse }
let parsed: z.infer<typeof releaseSchema>
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(
Expand All @@ -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" },
})

Expand Down
12 changes: 5 additions & 7 deletions app/api/admin/privacy/holds/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,10 +45,10 @@
reference: z.string().trim().max(200).optional(),
})

export async function GET(request: Request) {
export async function GET(request: Request): Promise<NextResponse> {
try {
const authContext = await requireAuthenticatedUser(request, ["admin"])
if ("response" in authContext) return authContext.response

Check failure on line 51 in app/api/admin/privacy/holds/route.ts

View workflow job for this annotation

GitHub Actions / Pull request checks

Type 'NextResponse<{ message: string; }> | undefined' is not assignable to type 'NextResponse<unknown>'.

const url = new URL(request.url)
const status = url.searchParams.get("status") as "ACTIVE" | "RELEASED" | "EXPIRED" | null
Expand All @@ -63,15 +63,15 @@
}
}

export async function POST(request: Request) {
export async function POST(request: Request): Promise<NextResponse> {
try {
const authContext = await requireAuthenticatedUser(request, ["admin"])
if ("response" in authContext) return authContext.response

Check failure on line 69 in app/api/admin/privacy/holds/route.ts

View workflow job for this annotation

GitHub Actions / Pull request checks

Type 'NextResponse<{ message: string; }> | undefined' is not assignable to type 'NextResponse<unknown>'.

let parsed: { data: z.infer<typeof createHoldSchema> } | { response: NextResponse }
let parsed: z.infer<typeof createHoldSchema>
try {
const json = await request.json()
parsed = { data: createHoldSchema.parse(json) }
parsed = createHoldSchema.parse(json)
} catch (error) {
return NextResponse.json(
{
Expand All @@ -81,9 +81,7 @@
{ 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." },
Expand Down
67 changes: 67 additions & 0 deletions lib/payouts/engine.ts
Original file line number Diff line number Diff line change
@@ -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<string, number>()
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 }

Check warning on line 67 in lib/payouts/engine.ts

View workflow job for this annotation

GitHub Actions / Pull request checks

Assign object to a variable before exporting as module default
145 changes: 145 additions & 0 deletions lib/payouts/service.ts
Original file line number Diff line number Diff line change
@@ -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<PaymentResult>
}

export class DistributionService {
private store = new Map<string, Distribution>()
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
Loading
Loading