@fantastic.dev/better-stripe 0.23.1 → 0.24.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +26 -2
- package/README.md +40 -5
- package/dist/client/affiliateRecovery/disputes.d.ts +18 -0
- package/dist/client/affiliateRecovery/disputes.d.ts.map +1 -0
- package/dist/client/affiliateRecovery/disputes.js +33 -0
- package/dist/client/affiliateRecovery/disputes.js.map +1 -0
- package/dist/client/affiliateRecovery/engine.d.ts +29 -0
- package/dist/client/affiliateRecovery/engine.d.ts.map +1 -0
- package/dist/client/affiliateRecovery/engine.js +116 -0
- package/dist/client/affiliateRecovery/engine.js.map +1 -0
- package/dist/client/affiliateRecovery/payoutGate.d.ts +24 -0
- package/dist/client/affiliateRecovery/payoutGate.d.ts.map +1 -0
- package/dist/client/affiliateRecovery/payoutGate.js +30 -0
- package/dist/client/affiliateRecovery/payoutGate.js.map +1 -0
- package/dist/client/affiliateRecovery/provider.d.ts +14 -0
- package/dist/client/affiliateRecovery/provider.d.ts.map +1 -0
- package/dist/client/affiliateRecovery/provider.js +96 -0
- package/dist/client/affiliateRecovery/provider.js.map +1 -0
- package/dist/client/affiliateRecovery/review.d.ts +28 -0
- package/dist/client/affiliateRecovery/review.d.ts.map +1 -0
- package/dist/client/affiliateRecovery/review.js +86 -0
- package/dist/client/affiliateRecovery/review.js.map +1 -0
- package/dist/client/affiliateRecovery/validators.d.ts +402 -0
- package/dist/client/affiliateRecovery/validators.d.ts.map +1 -0
- package/dist/client/affiliateRecovery/validators.js +27 -0
- package/dist/client/affiliateRecovery/validators.js.map +1 -0
- package/dist/client/affiliateRecovery.d.ts +131 -0
- package/dist/client/affiliateRecovery.d.ts.map +1 -0
- package/dist/client/affiliateRecovery.js +69 -0
- package/dist/client/affiliateRecovery.js.map +1 -0
- package/dist/client/connect/commissionReceipts.d.ts +4 -0
- package/dist/client/connect/commissionReceipts.d.ts.map +1 -0
- package/dist/client/connect/commissionReceipts.js +17 -0
- package/dist/client/connect/commissionReceipts.js.map +1 -0
- package/dist/client/connect/payoutAdoptionTestHelpers.d.ts +536 -2
- package/dist/client/connect/payoutAdoptionTestHelpers.d.ts.map +1 -1
- package/dist/client/connect/payoutSync.d.ts +32 -0
- package/dist/client/connect/payoutSync.d.ts.map +1 -0
- package/dist/client/connect/payoutSync.js +48 -0
- package/dist/client/connect/payoutSync.js.map +1 -0
- package/dist/client/connect/payoutSyncTestHelpers.d.ts +1509 -0
- package/dist/client/connect/payoutSyncTestHelpers.d.ts.map +1 -0
- package/dist/client/connect/payoutSyncTestHelpers.js +62 -0
- package/dist/client/connect/payoutSyncTestHelpers.js.map +1 -0
- package/dist/client/connect/payoutSyncValidation.d.ts +16 -0
- package/dist/client/connect/payoutSyncValidation.d.ts.map +1 -0
- package/dist/client/connect/payoutSyncValidation.js +54 -0
- package/dist/client/connect/payoutSyncValidation.js.map +1 -0
- package/dist/client/connect/payouts.d.ts +3 -1
- package/dist/client/connect/payouts.d.ts.map +1 -1
- package/dist/client/connect/payouts.js +45 -12
- package/dist/client/connect/payouts.js.map +1 -1
- package/dist/client/connect/refundPrincipal.d.ts +4 -0
- package/dist/client/connect/refundPrincipal.d.ts.map +1 -0
- package/dist/client/connect/refundPrincipal.js +21 -0
- package/dist/client/connect/refundPrincipal.js.map +1 -0
- package/dist/client/connect/reversalExecution.d.ts +47 -0
- package/dist/client/connect/reversalExecution.d.ts.map +1 -0
- package/dist/client/connect/reversalExecution.js +84 -0
- package/dist/client/connect/reversalExecution.js.map +1 -0
- package/dist/client/connect/transfers.d.ts +8 -4
- package/dist/client/connect/transfers.d.ts.map +1 -1
- package/dist/client/connect/transfers.js +52 -57
- package/dist/client/connect/transfers.js.map +1 -1
- package/dist/client/index.d.ts +29 -9
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +9 -6
- package/dist/client/index.js.map +1 -1
- package/dist/client/readContracts.d.ts +8 -0
- package/dist/client/readContracts.d.ts.map +1 -1
- package/dist/client/readContracts.js +7 -0
- package/dist/client/readContracts.js.map +1 -1
- package/dist/client/settlement/execution.d.ts.map +1 -1
- package/dist/client/settlement/execution.js +30 -3
- package/dist/client/settlement/execution.js.map +1 -1
- package/dist/client/settlement/reconciliation.d.ts.map +1 -1
- package/dist/client/settlement/reconciliation.js +16 -2
- package/dist/client/settlement/reconciliation.js.map +1 -1
- package/dist/client/webhooks/disputeRecovery.d.ts +4 -0
- package/dist/client/webhooks/disputeRecovery.d.ts.map +1 -0
- package/dist/client/webhooks/disputeRecovery.js +40 -0
- package/dist/client/webhooks/disputeRecovery.js.map +1 -0
- package/dist/client/webhooks/processors.d.ts.map +1 -1
- package/dist/client/webhooks/processors.js +14 -29
- package/dist/client/webhooks/processors.js.map +1 -1
- package/dist/component/_generated/component.d.ts +560 -0
- package/dist/component/_generated/component.d.ts.map +1 -1
- package/dist/component/affiliateEarnings/mutations.d.ts +22 -0
- package/dist/component/affiliateEarnings/mutations.d.ts.map +1 -0
- package/dist/component/affiliateEarnings/mutations.js +160 -0
- package/dist/component/affiliateEarnings/mutations.js.map +1 -0
- package/dist/component/affiliatePayouts/mutations.d.ts +25 -0
- package/dist/component/affiliatePayouts/mutations.d.ts.map +1 -0
- package/dist/component/affiliatePayouts/mutations.js +104 -0
- package/dist/component/affiliatePayouts/mutations.js.map +1 -0
- package/dist/component/affiliatePayouts/queries.d.ts +23 -0
- package/dist/component/affiliatePayouts/queries.d.ts.map +1 -0
- package/dist/component/affiliatePayouts/queries.js +23 -0
- package/dist/component/affiliatePayouts/queries.js.map +1 -0
- package/dist/component/affiliatePayouts/recipientGuard.d.ts +12 -0
- package/dist/component/affiliatePayouts/recipientGuard.d.ts.map +1 -0
- package/dist/component/affiliatePayouts/recipientGuard.js +26 -0
- package/dist/component/affiliatePayouts/recipientGuard.js.map +1 -0
- package/dist/component/affiliateRecovery/accounting.d.ts +28 -0
- package/dist/component/affiliateRecovery/accounting.d.ts.map +1 -0
- package/dist/component/affiliateRecovery/accounting.js +71 -0
- package/dist/component/affiliateRecovery/accounting.js.map +1 -0
- package/dist/component/affiliateRecovery/mutations.d.ts +65 -0
- package/dist/component/affiliateRecovery/mutations.d.ts.map +1 -0
- package/dist/component/affiliateRecovery/mutations.js +250 -0
- package/dist/component/affiliateRecovery/mutations.js.map +1 -0
- package/dist/component/affiliateRecovery/queries.d.ts +305 -0
- package/dist/component/affiliateRecovery/queries.d.ts.map +1 -0
- package/dist/component/affiliateRecovery/queries.js +113 -0
- package/dist/component/affiliateRecovery/queries.js.map +1 -0
- package/dist/component/affiliateRecovery/schema.d.ts +171 -0
- package/dist/component/affiliateRecovery/schema.d.ts.map +1 -0
- package/dist/component/affiliateRecovery/schema.js +71 -0
- package/dist/component/affiliateRecovery/schema.js.map +1 -0
- package/dist/component/connect/mutations.d.ts +36 -23
- package/dist/component/connect/mutations.d.ts.map +1 -1
- package/dist/component/connect/mutations.js +11 -151
- package/dist/component/connect/mutations.js.map +1 -1
- package/dist/component/connect/payoutWrites.d.ts +21 -0
- package/dist/component/connect/payoutWrites.d.ts.map +1 -0
- package/dist/component/connect/payoutWrites.js +19 -0
- package/dist/component/connect/payoutWrites.js.map +1 -0
- package/dist/component/connect/queries.d.ts +10 -0
- package/dist/component/connect/queries.d.ts.map +1 -1
- package/dist/component/connect/queries.js +3 -40
- package/dist/component/connect/queries.js.map +1 -1
- package/dist/component/connect/reversalClaims.d.ts +169 -0
- package/dist/component/connect/reversalClaims.d.ts.map +1 -0
- package/dist/component/connect/reversalClaims.js +176 -0
- package/dist/component/connect/reversalClaims.js.map +1 -0
- package/dist/component/connect/reversalDiagnostics.d.ts +59 -0
- package/dist/component/connect/reversalDiagnostics.d.ts.map +1 -0
- package/dist/component/connect/reversalDiagnostics.js +50 -0
- package/dist/component/connect/reversalDiagnostics.js.map +1 -0
- package/dist/component/connect/reversalReceipts.d.ts +27 -0
- package/dist/component/connect/reversalReceipts.d.ts.map +1 -0
- package/dist/component/connect/reversalReceipts.js +85 -0
- package/dist/component/connect/reversalReceipts.js.map +1 -0
- package/dist/component/connect/schema.d.ts +31 -1
- package/dist/component/connect/schema.d.ts.map +1 -1
- package/dist/component/connect/schema.js +3 -1
- package/dist/component/connect/schema.js.map +1 -1
- package/dist/component/connect/validators.d.ts +49 -1
- package/dist/component/connect/validators.d.ts.map +1 -1
- package/dist/component/connect/validators.js +2 -0
- package/dist/component/connect/validators.js.map +1 -1
- package/dist/component/earnings/triggers.d.ts +454 -2
- package/dist/component/earnings/triggers.d.ts.map +1 -1
- package/dist/component/payoutRelease/mutations.d.ts.map +1 -1
- package/dist/component/payoutRelease/mutations.js +6 -0
- package/dist/component/payoutRelease/mutations.js.map +1 -1
- package/dist/component/payoutRelease/queries.d.ts.map +1 -1
- package/dist/component/payoutRelease/queries.js +3 -1
- package/dist/component/payoutRelease/queries.js.map +1 -1
- package/dist/component/payoutSync/helpers.d.ts +44 -0
- package/dist/component/payoutSync/helpers.d.ts.map +1 -0
- package/dist/component/payoutSync/helpers.js +60 -0
- package/dist/component/payoutSync/helpers.js.map +1 -0
- package/dist/component/payoutSync/mutations.d.ts +51 -0
- package/dist/component/payoutSync/mutations.d.ts.map +1 -0
- package/dist/component/payoutSync/mutations.js +88 -0
- package/dist/component/payoutSync/mutations.js.map +1 -0
- package/dist/component/payoutSync/queries.d.ts +16 -0
- package/dist/component/payoutSync/queries.d.ts.map +1 -0
- package/dist/component/payoutSync/queries.js +11 -0
- package/dist/component/payoutSync/queries.js.map +1 -0
- package/dist/component/payoutSync/schema.d.ts +46 -0
- package/dist/component/payoutSync/schema.d.ts.map +1 -0
- package/dist/component/payoutSync/schema.js +22 -0
- package/dist/component/payoutSync/schema.js.map +1 -0
- package/dist/component/payoutSync/validators.d.ts +137 -0
- package/dist/component/payoutSync/validators.d.ts.map +1 -0
- package/dist/component/payoutSync/validators.js +19 -0
- package/dist/component/payoutSync/validators.js.map +1 -0
- package/dist/component/schema.d.ts +268 -1
- package/dist/component/schema.js +11 -0
- package/dist/component/schema.js.map +1 -1
- package/dist/component/settlement/clawbackSchema.d.ts +161 -0
- package/dist/component/settlement/clawbackSchema.d.ts.map +1 -0
- package/dist/component/settlement/clawbackSchema.js +73 -0
- package/dist/component/settlement/clawbackSchema.js.map +1 -0
- package/dist/component/settlement/clawbacks.d.ts +55 -0
- package/dist/component/settlement/clawbacks.d.ts.map +1 -0
- package/dist/component/settlement/clawbacks.js +146 -0
- package/dist/component/settlement/clawbacks.js.map +1 -0
- package/dist/component/settlement/mutations.d.ts +16 -0
- package/dist/component/settlement/mutations.d.ts.map +1 -1
- package/dist/component/settlement/mutations.js +48 -0
- package/dist/component/settlement/mutations.js.map +1 -1
- package/dist/component/settlement/queries.d.ts +53 -0
- package/dist/component/settlement/queries.d.ts.map +1 -1
- package/dist/component/settlement/queries.js +26 -0
- package/dist/component/settlement/queries.js.map +1 -1
- package/dist/react/workflows/AffiliateRecovery.d.ts +46 -0
- package/dist/react/workflows/AffiliateRecovery.d.ts.map +1 -0
- package/dist/react/workflows/AffiliateRecovery.js +84 -0
- package/dist/react/workflows/AffiliateRecovery.js.map +1 -0
- package/dist/react/workflows/index.d.ts +2 -0
- package/dist/react/workflows/index.d.ts.map +1 -1
- package/dist/react/workflows/index.js +1 -0
- package/dist/react/workflows/index.js.map +1 -1
- package/dist/testing/affiliate-recovery.d.ts +10 -0
- package/dist/testing/affiliate-recovery.d.ts.map +1 -0
- package/dist/testing/affiliate-recovery.js +126 -0
- package/dist/testing/affiliate-recovery.js.map +1 -0
- package/dist/testing/index.d.ts +2 -0
- package/dist/testing/index.d.ts.map +1 -1
- package/dist/testing/index.js +1 -0
- package/dist/testing/index.js.map +1 -1
- package/docs/affiliate-clawbacks.md +306 -0
- package/docs/read-contracts.md +40 -5
- package/docs/settlement.md +17 -0
- package/package.json +2 -1
- package/src/client/affiliateRecovery/disputes.ts +62 -0
- package/src/client/affiliateRecovery/engine.ts +196 -0
- package/src/client/affiliateRecovery/payoutGate.ts +65 -0
- package/src/client/affiliateRecovery/provider.ts +127 -0
- package/src/client/affiliateRecovery/review.ts +117 -0
- package/src/client/affiliateRecovery/validators.ts +38 -0
- package/src/client/affiliateRecovery.ts +173 -0
- package/src/client/connect/commissionReceipts.test.ts +157 -0
- package/src/client/connect/commissionReceipts.ts +23 -0
- package/src/client/connect/payoutSync.test.ts +313 -0
- package/src/client/connect/payoutSync.ts +93 -0
- package/src/client/connect/payoutSyncTestHelpers.ts +82 -0
- package/src/client/connect/payoutSyncValidation.ts +74 -0
- package/src/client/connect/payouts.ts +75 -15
- package/src/client/connect/refundPrincipal.test.ts +46 -0
- package/src/client/connect/refundPrincipal.ts +26 -0
- package/src/client/connect/reversalExecution.ts +130 -0
- package/src/client/connect/transfers.test.ts +3 -3
- package/src/client/connect/transfers.ts +93 -92
- package/src/client/index.ts +20 -18
- package/src/client/readContracts.ts +12 -0
- package/src/client/settlement/execution.test.ts +5 -0
- package/src/client/settlement/execution.ts +50 -3
- package/src/client/settlement/reconciliation.ts +20 -2
- package/src/client/webhooks/disputeRecovery.ts +74 -0
- package/src/client/webhooks/processors.test.ts +36 -53
- package/src/client/webhooks/processors.ts +18 -31
- package/src/component/_generated/api.ts +34 -0
- package/src/component/_generated/component.ts +696 -1
- package/src/component/affiliate-clawbacks.test.ts +370 -0
- package/src/component/affiliate-component-pagination.test.ts +83 -0
- package/src/component/affiliate-direct-payout.test.ts +160 -0
- package/src/component/affiliate-recovery-lifecycle.test.ts +587 -0
- package/src/component/affiliateEarnings/mutations.ts +196 -0
- package/src/component/affiliatePayouts/mutations.ts +121 -0
- package/src/component/affiliatePayouts/queries.ts +28 -0
- package/src/component/affiliatePayouts/recipientGuard.ts +42 -0
- package/src/component/affiliateRecovery/accounting.ts +111 -0
- package/src/component/affiliateRecovery/mutations.ts +275 -0
- package/src/component/affiliateRecovery/queries.ts +138 -0
- package/src/component/affiliateRecovery/schema.ts +86 -0
- package/src/component/connect/mutations.ts +13 -190
- package/src/component/connect/payoutWrites.ts +25 -0
- package/src/component/connect/queries.ts +4 -47
- package/src/component/connect/reversalClaims.ts +232 -0
- package/src/component/connect/reversalDiagnostics.ts +61 -0
- package/src/component/connect/reversalReceipts.ts +147 -0
- package/src/component/connect/schema.ts +3 -3
- package/src/component/connect/validators.ts +2 -0
- package/src/component/payout-recovery-integration.test.ts +172 -0
- package/src/component/payout-sync.test.ts +117 -0
- package/src/component/payoutRelease/mutations.ts +9 -0
- package/src/component/payoutRelease/queries.ts +3 -1
- package/src/component/payoutSync/helpers.ts +86 -0
- package/src/component/payoutSync/mutations.ts +102 -0
- package/src/component/payoutSync/queries.ts +10 -0
- package/src/component/payoutSync/schema.ts +25 -0
- package/src/component/payoutSync/validators.ts +21 -0
- package/src/component/schema.ts +20 -0
- package/src/component/settlement/clawbackSchema.ts +78 -0
- package/src/component/settlement/clawbacks.ts +233 -0
- package/src/component/settlement/mutations.ts +54 -0
- package/src/component/settlement/queries.ts +35 -0
- package/src/component/summary-arithmetic.test.ts +9 -4
- package/src/react/workflows/AffiliateRecovery.test.tsx +106 -0
- package/src/react/workflows/AffiliateRecovery.tsx +273 -0
- package/src/react/workflows/index.ts +9 -0
- package/src/testing/affiliate-recovery.ts +137 -0
- package/src/testing/index.ts +2 -0
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { v } from "convex/values";
|
|
2
|
+
import { mutation } from "../_generated/server.js";
|
|
3
|
+
import { earningFields } from "../affiliateRecovery/schema.js";
|
|
4
|
+
import {
|
|
5
|
+
balance,
|
|
6
|
+
changeBalance,
|
|
7
|
+
due,
|
|
8
|
+
updateObligation,
|
|
9
|
+
} from "../affiliateRecovery/accounting.js";
|
|
10
|
+
const earningDoc = v.object({
|
|
11
|
+
...earningFields,
|
|
12
|
+
_id: v.id("affiliateEarnings"),
|
|
13
|
+
_creationTime: v.number(),
|
|
14
|
+
});
|
|
15
|
+
export const reserveEarning = mutation({
|
|
16
|
+
args: { sourceChargeId: v.string(), affiliateAccountId: v.string() },
|
|
17
|
+
returns: earningDoc,
|
|
18
|
+
handler: async (ctx, args) => {
|
|
19
|
+
const existing = await ctx.db
|
|
20
|
+
.query("affiliateEarnings")
|
|
21
|
+
.withIndex("by_charge_affiliate", (q) =>
|
|
22
|
+
q
|
|
23
|
+
.eq("sourceChargeId", args.sourceChargeId)
|
|
24
|
+
.eq("affiliateAccountId", args.affiliateAccountId),
|
|
25
|
+
)
|
|
26
|
+
.unique();
|
|
27
|
+
if (existing) return existing;
|
|
28
|
+
const allocation = await ctx.db
|
|
29
|
+
.query("settlementAllocations")
|
|
30
|
+
.withIndex("by_source_charge", (q) =>
|
|
31
|
+
q.eq("sourceChargeId", args.sourceChargeId),
|
|
32
|
+
)
|
|
33
|
+
.unique();
|
|
34
|
+
const leg = allocation?.transfers.find(
|
|
35
|
+
(t) =>
|
|
36
|
+
t.role === "affiliate" &&
|
|
37
|
+
t.destinationAccountId === args.affiliateAccountId,
|
|
38
|
+
);
|
|
39
|
+
if (!allocation || !leg)
|
|
40
|
+
throw new Error(
|
|
41
|
+
"Affiliate earning requires an immutable settlement allocation.",
|
|
42
|
+
);
|
|
43
|
+
const totals = await balance(
|
|
44
|
+
ctx,
|
|
45
|
+
args.affiliateAccountId,
|
|
46
|
+
allocation.currency,
|
|
47
|
+
);
|
|
48
|
+
if (totals?.activePayoutKey)
|
|
49
|
+
throw new Error("Affiliate payout is pending reconciliation.");
|
|
50
|
+
if (
|
|
51
|
+
(totals?.reservedRecovery ?? 0) > 0 ||
|
|
52
|
+
(totals?.reservedRestoration ?? 0) > 0
|
|
53
|
+
)
|
|
54
|
+
throw new Error(
|
|
55
|
+
"Affiliate provider recovery is pending reconciliation; retry settlement.",
|
|
56
|
+
);
|
|
57
|
+
const debts = await ctx.db
|
|
58
|
+
.query("affiliateObligations")
|
|
59
|
+
.withIndex("by_affiliate_currency_debt", (q) =>
|
|
60
|
+
q
|
|
61
|
+
.eq("affiliateAccountId", args.affiliateAccountId)
|
|
62
|
+
.eq("currency", allocation.currency)
|
|
63
|
+
.eq("debtStatus", "open"),
|
|
64
|
+
)
|
|
65
|
+
.take(1001);
|
|
66
|
+
if (debts.length > 1000)
|
|
67
|
+
throw new Error(
|
|
68
|
+
"Affiliate debt batch limit exceeded; reconcile before releasing funds.",
|
|
69
|
+
);
|
|
70
|
+
let remaining = leg.amount;
|
|
71
|
+
const offsets = [];
|
|
72
|
+
for (const debt of debts) {
|
|
73
|
+
if (
|
|
74
|
+
debt.sourceChargeId === args.sourceChargeId ||
|
|
75
|
+
debt.needsReconciliation
|
|
76
|
+
)
|
|
77
|
+
continue;
|
|
78
|
+
if (debt.activeOperationId)
|
|
79
|
+
throw new Error("Affiliate debt has an unresolved provider operation.");
|
|
80
|
+
const amount = Math.min(remaining, due(debt));
|
|
81
|
+
if (amount > 0) {
|
|
82
|
+
offsets.push({ debt, amount });
|
|
83
|
+
remaining -= amount;
|
|
84
|
+
}
|
|
85
|
+
if (!remaining) break;
|
|
86
|
+
}
|
|
87
|
+
const earningId = await ctx.db.insert("affiliateEarnings", {
|
|
88
|
+
...args,
|
|
89
|
+
currency: allocation.currency,
|
|
90
|
+
originalCommission: leg.amount,
|
|
91
|
+
withheldAmount: leg.amount - remaining,
|
|
92
|
+
releaseAmount: remaining,
|
|
93
|
+
status: "reserved",
|
|
94
|
+
sourceInvoiceId: allocation.sourceInvoiceId,
|
|
95
|
+
paymentIntentId: allocation.paymentIntentId,
|
|
96
|
+
});
|
|
97
|
+
for (const { debt, amount } of offsets) {
|
|
98
|
+
await ctx.db.insert("affiliateOffsets", {
|
|
99
|
+
earningId,
|
|
100
|
+
obligationId: debt._id,
|
|
101
|
+
amount,
|
|
102
|
+
});
|
|
103
|
+
await updateObligation(ctx, debt, {
|
|
104
|
+
offsetAmount: (debt.offsetAmount ?? 0) + amount,
|
|
105
|
+
lastState: "offset",
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
const obligationId = await ctx.db.insert("affiliateObligations", {
|
|
109
|
+
sourceChargeId: allocation.sourceChargeId,
|
|
110
|
+
sourceInvoiceId: allocation.sourceInvoiceId,
|
|
111
|
+
paymentIntentId: allocation.paymentIntentId,
|
|
112
|
+
paymentId: allocation.paymentId,
|
|
113
|
+
storeAccountId: allocation.transfers.find((leg) => leg.role === "store")
|
|
114
|
+
?.destinationAccountId,
|
|
115
|
+
affiliateAccountId: args.affiliateAccountId,
|
|
116
|
+
currency: allocation.currency,
|
|
117
|
+
chargeAmount: allocation.gross,
|
|
118
|
+
originalCommission: leg.amount,
|
|
119
|
+
releasedCommission: remaining,
|
|
120
|
+
earningId,
|
|
121
|
+
refundedPrincipal: 0,
|
|
122
|
+
disputedPrincipal: 0,
|
|
123
|
+
lostPrincipal: 0,
|
|
124
|
+
finalizedAmount: 0,
|
|
125
|
+
provisionalAmount: 0,
|
|
126
|
+
recoveredAmount: 0,
|
|
127
|
+
needsReconciliation: false,
|
|
128
|
+
});
|
|
129
|
+
await updateObligation(
|
|
130
|
+
ctx,
|
|
131
|
+
(await ctx.db.get("affiliateObligations", obligationId))!,
|
|
132
|
+
{},
|
|
133
|
+
);
|
|
134
|
+
await changeBalance(ctx, args.affiliateAccountId, allocation.currency, {
|
|
135
|
+
withheldAmount: leg.amount - remaining,
|
|
136
|
+
netPayable: remaining,
|
|
137
|
+
});
|
|
138
|
+
return (await ctx.db.get("affiliateEarnings", earningId))!;
|
|
139
|
+
},
|
|
140
|
+
});
|
|
141
|
+
export const confirmEarning = mutation({
|
|
142
|
+
args: {
|
|
143
|
+
earningId: v.id("affiliateEarnings"),
|
|
144
|
+
stripeTransferId: v.optional(v.string()),
|
|
145
|
+
},
|
|
146
|
+
returns: v.null(),
|
|
147
|
+
handler: async (ctx, args) => {
|
|
148
|
+
const earning = await ctx.db.get("affiliateEarnings", args.earningId);
|
|
149
|
+
if (!earning) throw new Error("Affiliate earning not found.");
|
|
150
|
+
if (earning.status === "paid") {
|
|
151
|
+
if (earning.stripeTransferId !== args.stripeTransferId)
|
|
152
|
+
throw new Error("Affiliate earning receipt cannot change.");
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
if (earning.releaseAmount > 0 !== !!args.stripeTransferId)
|
|
156
|
+
throw new Error("Affiliate release requires an exact transfer receipt.");
|
|
157
|
+
if (args.stripeTransferId) {
|
|
158
|
+
const transfer = await ctx.db
|
|
159
|
+
.query("transfers")
|
|
160
|
+
.withIndex("by_stripe_transfer_id", (q) =>
|
|
161
|
+
q.eq("stripeTransferId", args.stripeTransferId!),
|
|
162
|
+
)
|
|
163
|
+
.unique();
|
|
164
|
+
if (
|
|
165
|
+
!transfer ||
|
|
166
|
+
transfer.amount !== earning.releaseAmount ||
|
|
167
|
+
transfer.currency !== earning.currency ||
|
|
168
|
+
transfer.destinationAccountId !== earning.affiliateAccountId ||
|
|
169
|
+
transfer.sourceChargeId !== earning.sourceChargeId
|
|
170
|
+
)
|
|
171
|
+
throw new Error(
|
|
172
|
+
"Affiliate release receipt does not match its earning.",
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
const obligation = await ctx.db
|
|
176
|
+
.query("affiliateObligations")
|
|
177
|
+
.withIndex("by_charge", (q) =>
|
|
178
|
+
q.eq("sourceChargeId", earning.sourceChargeId),
|
|
179
|
+
)
|
|
180
|
+
.take(1001);
|
|
181
|
+
const own = obligation.find((row) => row.earningId === earning._id);
|
|
182
|
+
if (!own || obligation.length > 1000)
|
|
183
|
+
throw new Error("Affiliate earning obligation requires reconciliation.");
|
|
184
|
+
await ctx.db.patch("affiliateObligations", own._id, {
|
|
185
|
+
stripeTransferId: args.stripeTransferId,
|
|
186
|
+
});
|
|
187
|
+
await ctx.db.patch("affiliateEarnings", earning._id, {
|
|
188
|
+
status: "paid",
|
|
189
|
+
stripeTransferId: args.stripeTransferId,
|
|
190
|
+
});
|
|
191
|
+
await changeBalance(ctx, earning.affiliateAccountId, earning.currency, {
|
|
192
|
+
netPayable: -earning.releaseAmount,
|
|
193
|
+
});
|
|
194
|
+
return null;
|
|
195
|
+
},
|
|
196
|
+
});
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { v } from "convex/values";
|
|
2
|
+
import { mutation } from "../_generated/server.js";
|
|
3
|
+
import { balance } from "../affiliateRecovery/accounting.js";
|
|
4
|
+
import { payoutReleaseFields } from "../affiliateRecovery/schema.js";
|
|
5
|
+
const doc = v.object({
|
|
6
|
+
...payoutReleaseFields,
|
|
7
|
+
_id: v.id("affiliatePayoutReleases"),
|
|
8
|
+
_creationTime: v.number(),
|
|
9
|
+
});
|
|
10
|
+
export const reservePayout = mutation({
|
|
11
|
+
args: {
|
|
12
|
+
affiliateAccountId: v.string(),
|
|
13
|
+
currency: v.string(),
|
|
14
|
+
amount: v.number(),
|
|
15
|
+
minimumAmount: v.number(),
|
|
16
|
+
operationKey: v.optional(v.string()),
|
|
17
|
+
now: v.number(),
|
|
18
|
+
},
|
|
19
|
+
returns: v.union(doc, v.null()),
|
|
20
|
+
handler: async (ctx, args) => {
|
|
21
|
+
const totals = await balance(ctx, args.affiliateAccountId, args.currency);
|
|
22
|
+
if (!totals) return null;
|
|
23
|
+
if (!args.operationKey?.trim())
|
|
24
|
+
throw new Error("Affiliate payouts require a stable idempotency key.");
|
|
25
|
+
const existing = await ctx.db
|
|
26
|
+
.query("affiliatePayoutReleases")
|
|
27
|
+
.withIndex("by_key", (q) => q.eq("operationKey", args.operationKey!))
|
|
28
|
+
.unique();
|
|
29
|
+
if (existing) {
|
|
30
|
+
if (
|
|
31
|
+
existing.amount !== args.amount ||
|
|
32
|
+
existing.affiliateAccountId !== args.affiliateAccountId ||
|
|
33
|
+
existing.currency !== args.currency ||
|
|
34
|
+
existing.minimumAmount !== args.minimumAmount
|
|
35
|
+
)
|
|
36
|
+
throw new Error("Affiliate payout operation identity cannot change.");
|
|
37
|
+
if (existing.state === "pending" || existing.state === "unknown") {
|
|
38
|
+
if (
|
|
39
|
+
totals.outstandingDebt > 0 ||
|
|
40
|
+
totals.reservedRecovery > 0 ||
|
|
41
|
+
totals.reservedRestoration > 0 ||
|
|
42
|
+
totals.activePayoutKey !== existing.operationKey
|
|
43
|
+
)
|
|
44
|
+
throw new Error(
|
|
45
|
+
"Affiliate recovery must be reconciled before payout release.",
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
return existing;
|
|
49
|
+
}
|
|
50
|
+
if (
|
|
51
|
+
![args.amount, args.minimumAmount].every(
|
|
52
|
+
(n) => Number.isSafeInteger(n) && n > 0,
|
|
53
|
+
) ||
|
|
54
|
+
args.amount < args.minimumAmount
|
|
55
|
+
)
|
|
56
|
+
throw new Error("Affiliate payout does not meet its currency minimum.");
|
|
57
|
+
if (
|
|
58
|
+
totals.outstandingDebt > 0 ||
|
|
59
|
+
totals.reservedRecovery > 0 ||
|
|
60
|
+
totals.reservedRestoration > 0 ||
|
|
61
|
+
totals.activePayoutKey
|
|
62
|
+
)
|
|
63
|
+
throw new Error(
|
|
64
|
+
"Affiliate recovery must be reconciled before payout release.",
|
|
65
|
+
);
|
|
66
|
+
const id = await ctx.db.insert("affiliatePayoutReleases", {
|
|
67
|
+
affiliateAccountId: args.affiliateAccountId,
|
|
68
|
+
currency: args.currency,
|
|
69
|
+
amount: args.amount,
|
|
70
|
+
minimumAmount: args.minimumAmount,
|
|
71
|
+
operationKey: args.operationKey,
|
|
72
|
+
createdAt: args.now,
|
|
73
|
+
state: "pending",
|
|
74
|
+
});
|
|
75
|
+
await ctx.db.patch("affiliateRecoveryBalances", totals._id, {
|
|
76
|
+
activePayoutKey: args.operationKey,
|
|
77
|
+
pendingPayoutAmount: args.amount,
|
|
78
|
+
});
|
|
79
|
+
return (await ctx.db.get("affiliatePayoutReleases", id))!;
|
|
80
|
+
},
|
|
81
|
+
});
|
|
82
|
+
export const finishPayout = mutation({
|
|
83
|
+
args: {
|
|
84
|
+
operationKey: v.string(),
|
|
85
|
+
state: v.union(
|
|
86
|
+
v.literal("unknown"),
|
|
87
|
+
v.literal("submitted"),
|
|
88
|
+
v.literal("failed"),
|
|
89
|
+
),
|
|
90
|
+
stripePayoutId: v.optional(v.string()),
|
|
91
|
+
},
|
|
92
|
+
returns: v.null(),
|
|
93
|
+
handler: async (ctx, args) => {
|
|
94
|
+
const row = await ctx.db
|
|
95
|
+
.query("affiliatePayoutReleases")
|
|
96
|
+
.withIndex("by_key", (q) => q.eq("operationKey", args.operationKey))
|
|
97
|
+
.unique();
|
|
98
|
+
if (!row) throw new Error("Affiliate payout operation not found.");
|
|
99
|
+
if (row.state === "submitted") {
|
|
100
|
+
if (
|
|
101
|
+
args.state === "submitted" &&
|
|
102
|
+
args.stripePayoutId !== row.stripePayoutId
|
|
103
|
+
)
|
|
104
|
+
throw new Error("Affiliate payout receipt cannot change.");
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
if (args.state === "submitted" && !args.stripePayoutId)
|
|
108
|
+
throw new Error("Affiliate payout requires a provider receipt.");
|
|
109
|
+
const totals = (await balance(ctx, row.affiliateAccountId, row.currency))!;
|
|
110
|
+
if (args.state !== "unknown" && totals.activePayoutKey === row.operationKey)
|
|
111
|
+
await ctx.db.patch("affiliateRecoveryBalances", totals._id, {
|
|
112
|
+
activePayoutKey: undefined,
|
|
113
|
+
pendingPayoutAmount: 0,
|
|
114
|
+
});
|
|
115
|
+
await ctx.db.patch("affiliatePayoutReleases", row._id, {
|
|
116
|
+
state: args.state,
|
|
117
|
+
stripePayoutId: args.stripePayoutId,
|
|
118
|
+
});
|
|
119
|
+
return null;
|
|
120
|
+
},
|
|
121
|
+
});
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import {
|
|
2
|
+
paginationOptsValidator,
|
|
3
|
+
paginationResultValidator,
|
|
4
|
+
} from "convex/server";
|
|
5
|
+
import { v } from "convex/values";
|
|
6
|
+
import { query } from "../_generated/server.js";
|
|
7
|
+
import { payoutReleaseFields } from "../affiliateRecovery/schema.js";
|
|
8
|
+
export const pendingPayouts = query({
|
|
9
|
+
args: {
|
|
10
|
+
state: v.union(v.literal("pending"), v.literal("unknown")),
|
|
11
|
+
paginationOpts: paginationOptsValidator,
|
|
12
|
+
},
|
|
13
|
+
returns: paginationResultValidator(
|
|
14
|
+
v.object({
|
|
15
|
+
...payoutReleaseFields,
|
|
16
|
+
_id: v.id("affiliatePayoutReleases"),
|
|
17
|
+
_creationTime: v.number(),
|
|
18
|
+
}),
|
|
19
|
+
),
|
|
20
|
+
handler: (ctx, args) =>
|
|
21
|
+
ctx.db
|
|
22
|
+
.query("affiliatePayoutReleases")
|
|
23
|
+
.withIndex("by_state", (q) => q.eq("state", args.state))
|
|
24
|
+
.paginate({
|
|
25
|
+
...args.paginationOpts,
|
|
26
|
+
numItems: Math.min(10, args.paginationOpts.numItems),
|
|
27
|
+
}),
|
|
28
|
+
});
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { MutationCtx } from "../_generated/server.js";
|
|
2
|
+
import { balance, changeBalance } from "../affiliateRecovery/accounting.js";
|
|
3
|
+
|
|
4
|
+
type Identity = { accountId: string; currency: string; operationKey: string };
|
|
5
|
+
const key = (identity: Identity) =>
|
|
6
|
+
`bs:recipient-payout:${identity.operationKey}`;
|
|
7
|
+
|
|
8
|
+
/** Share the recovery lock with combined seller/affiliate payout claims. */
|
|
9
|
+
export async function reserveRecoveryGuard(
|
|
10
|
+
ctx: MutationCtx,
|
|
11
|
+
identity: Identity,
|
|
12
|
+
amount: number,
|
|
13
|
+
) {
|
|
14
|
+
await changeBalance(ctx, identity.accountId, identity.currency, {});
|
|
15
|
+
const totals = (await balance(ctx, identity.accountId, identity.currency))!;
|
|
16
|
+
if (
|
|
17
|
+
totals.outstandingDebt > 0 ||
|
|
18
|
+
totals.reservedRecovery > 0 ||
|
|
19
|
+
totals.reservedRestoration > 0 ||
|
|
20
|
+
(totals.activePayoutKey && totals.activePayoutKey !== key(identity))
|
|
21
|
+
)
|
|
22
|
+
throw new Error(
|
|
23
|
+
"Affiliate recovery must be reconciled before payout release.",
|
|
24
|
+
);
|
|
25
|
+
await ctx.db.patch("affiliateRecoveryBalances", totals._id, {
|
|
26
|
+
activePayoutKey: key(identity),
|
|
27
|
+
pendingPayoutAmount: amount,
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Only a verified outcome or a never-attempted cancellation releases the lock. */
|
|
32
|
+
export async function releaseRecoveryGuard(
|
|
33
|
+
ctx: MutationCtx,
|
|
34
|
+
identity: Identity,
|
|
35
|
+
) {
|
|
36
|
+
const totals = await balance(ctx, identity.accountId, identity.currency);
|
|
37
|
+
if (totals?.activePayoutKey === key(identity))
|
|
38
|
+
await ctx.db.patch("affiliateRecoveryBalances", totals._id, {
|
|
39
|
+
activePayoutKey: undefined,
|
|
40
|
+
pendingPayoutAmount: 0,
|
|
41
|
+
});
|
|
42
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import type { Doc } from "../_generated/dataModel.js";
|
|
2
|
+
import type { MutationCtx } from "../_generated/server.js";
|
|
3
|
+
export type Obligation = Omit<
|
|
4
|
+
Doc<"affiliateObligations">,
|
|
5
|
+
"_id" | "_creationTime"
|
|
6
|
+
>;
|
|
7
|
+
export const target = (row: Obligation) =>
|
|
8
|
+
row.finalizedAmount + row.provisionalAmount;
|
|
9
|
+
export const netRecovery = (row: Obligation) =>
|
|
10
|
+
row.recoveredAmount + (row.offsetAmount ?? 0) - (row.restoredAmount ?? 0);
|
|
11
|
+
export const due = (row: Obligation) =>
|
|
12
|
+
Math.max(0, target(row) - netRecovery(row));
|
|
13
|
+
export const credit = (row: Obligation) =>
|
|
14
|
+
row.needsReconciliation ? 0 : Math.max(0, netRecovery(row) - target(row));
|
|
15
|
+
export async function balance(
|
|
16
|
+
ctx: MutationCtx,
|
|
17
|
+
account: string,
|
|
18
|
+
currency: string,
|
|
19
|
+
) {
|
|
20
|
+
return ctx.db
|
|
21
|
+
.query("affiliateRecoveryBalances")
|
|
22
|
+
.withIndex("by_affiliate_currency", (q) =>
|
|
23
|
+
q.eq("affiliateAccountId", account).eq("currency", currency),
|
|
24
|
+
)
|
|
25
|
+
.unique();
|
|
26
|
+
}
|
|
27
|
+
export async function changeBalance(
|
|
28
|
+
ctx: MutationCtx,
|
|
29
|
+
account: string,
|
|
30
|
+
currency: string,
|
|
31
|
+
changes: Partial<
|
|
32
|
+
Record<
|
|
33
|
+
| "originalCommission"
|
|
34
|
+
| "recoveredAmount"
|
|
35
|
+
| "offsetAmount"
|
|
36
|
+
| "restoredAmount"
|
|
37
|
+
| "outstandingDebt"
|
|
38
|
+
| "reservedRecovery"
|
|
39
|
+
| "reservedRestoration"
|
|
40
|
+
| "restorationDue"
|
|
41
|
+
| "withheldAmount"
|
|
42
|
+
| "netPayable",
|
|
43
|
+
number
|
|
44
|
+
>
|
|
45
|
+
>,
|
|
46
|
+
) {
|
|
47
|
+
const existing = await balance(ctx, account, currency);
|
|
48
|
+
const base = existing ?? {
|
|
49
|
+
affiliateAccountId: account,
|
|
50
|
+
currency,
|
|
51
|
+
originalCommission: 0,
|
|
52
|
+
recoveredAmount: 0,
|
|
53
|
+
offsetAmount: 0,
|
|
54
|
+
restoredAmount: 0,
|
|
55
|
+
outstandingDebt: 0,
|
|
56
|
+
reservedRecovery: 0,
|
|
57
|
+
reservedRestoration: 0,
|
|
58
|
+
restorationDue: 0,
|
|
59
|
+
withheldAmount: 0,
|
|
60
|
+
netPayable: 0,
|
|
61
|
+
};
|
|
62
|
+
const patch: Record<string, number> = {};
|
|
63
|
+
for (const key of Object.keys(changes) as (keyof typeof changes)[]) {
|
|
64
|
+
const next = base[key] + (changes[key] ?? 0);
|
|
65
|
+
if (!Number.isSafeInteger(next) || next < 0)
|
|
66
|
+
throw new Error(`Invalid affiliate balance ${key}.`);
|
|
67
|
+
patch[key] = next;
|
|
68
|
+
}
|
|
69
|
+
if (existing)
|
|
70
|
+
await ctx.db.patch("affiliateRecoveryBalances", existing._id, patch);
|
|
71
|
+
else await ctx.db.insert("affiliateRecoveryBalances", { ...base, ...patch });
|
|
72
|
+
}
|
|
73
|
+
export async function updateObligation(
|
|
74
|
+
ctx: MutationCtx,
|
|
75
|
+
previous: Doc<"affiliateObligations">,
|
|
76
|
+
patch: Partial<Obligation>,
|
|
77
|
+
) {
|
|
78
|
+
if (!previous.accounted) {
|
|
79
|
+
await changeBalance(ctx, previous.affiliateAccountId, previous.currency, {
|
|
80
|
+
originalCommission: previous.originalCommission,
|
|
81
|
+
recoveredAmount: previous.recoveredAmount,
|
|
82
|
+
offsetAmount: previous.offsetAmount ?? 0,
|
|
83
|
+
restoredAmount: previous.restoredAmount ?? 0,
|
|
84
|
+
outstandingDebt: due(previous),
|
|
85
|
+
restorationDue: credit(previous),
|
|
86
|
+
reservedRecovery: previous.reservedRecovery ?? 0,
|
|
87
|
+
reservedRestoration: previous.reservedRestoration ?? 0,
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
const next = { ...previous, ...patch };
|
|
91
|
+
const debtAmount = due(next),
|
|
92
|
+
creditAmount = credit(next);
|
|
93
|
+
await changeBalance(ctx, next.affiliateAccountId, next.currency, {
|
|
94
|
+
outstandingDebt: debtAmount - (previous.debtAmount ?? due(previous)),
|
|
95
|
+
restorationDue: creditAmount - (previous.creditAmount ?? credit(previous)),
|
|
96
|
+
recoveredAmount: next.recoveredAmount - previous.recoveredAmount,
|
|
97
|
+
offsetAmount: (next.offsetAmount ?? 0) - (previous.offsetAmount ?? 0),
|
|
98
|
+
restoredAmount: (next.restoredAmount ?? 0) - (previous.restoredAmount ?? 0),
|
|
99
|
+
reservedRecovery:
|
|
100
|
+
(next.reservedRecovery ?? 0) - (previous.reservedRecovery ?? 0),
|
|
101
|
+
reservedRestoration:
|
|
102
|
+
(next.reservedRestoration ?? 0) - (previous.reservedRestoration ?? 0),
|
|
103
|
+
});
|
|
104
|
+
await ctx.db.patch("affiliateObligations", previous._id, {
|
|
105
|
+
...patch,
|
|
106
|
+
accounted: true,
|
|
107
|
+
debtAmount,
|
|
108
|
+
creditAmount,
|
|
109
|
+
debtStatus: debtAmount > 0 ? "open" : "settled",
|
|
110
|
+
});
|
|
111
|
+
}
|