@fantastic.dev/better-stripe 0.23.2 → 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 +20 -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 +494 -2
- package/dist/client/connect/payoutAdoptionTestHelpers.d.ts.map +1 -1
- package/dist/client/connect/payoutSyncTestHelpers.d.ts +247 -1
- package/dist/client/connect/payoutSyncTestHelpers.d.ts.map +1 -1
- 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 +3 -0
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +6 -1
- package/dist/client/index.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 +491 -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 +7 -124
- package/dist/component/connect/mutations.js.map +1 -1
- 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 +410 -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/schema.d.ts +247 -1
- package/dist/component/schema.js +9 -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/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/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 +6 -7
- 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 +24 -0
- package/src/component/_generated/component.ts +614 -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 +9 -160
- 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/payoutRelease/mutations.ts +9 -0
- package/src/component/payoutRelease/queries.ts +3 -1
- package/src/component/schema.ts +18 -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
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { listWedgedReversalClaimsDefinition } from "./reversalDiagnostics.js";
|
|
1
2
|
import {
|
|
2
3
|
getAccountEarnings as getAccountEarningsLegacy,
|
|
3
4
|
getAccountPayouts as getAccountPayoutsLegacy,
|
|
@@ -24,7 +25,6 @@ import {
|
|
|
24
25
|
refundStatusValidator,
|
|
25
26
|
transferDocValidator,
|
|
26
27
|
transferReversalOpDocValidator,
|
|
27
|
-
wedgedReversalClaimValidator,
|
|
28
28
|
} from "./validators";
|
|
29
29
|
|
|
30
30
|
// PAYMENT QUERIES
|
|
@@ -394,52 +394,9 @@ export const getReversalOp = query({
|
|
|
394
394
|
* per-query document-read limit (16,384) — beyond which it throws (loud) rather
|
|
395
395
|
* than silently truncating, matching the BTS-64 aggregate convention.
|
|
396
396
|
*/
|
|
397
|
-
export const listWedgedReversalClaims = query(
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
handler: async (ctx) => {
|
|
401
|
-
// Collect the wedged legs first.
|
|
402
|
-
const wedged: Doc<"transfers">[] = [];
|
|
403
|
-
for await (const tr of ctx.db.query("transfers")) {
|
|
404
|
-
if ((tr.reversalClaimedAmount ?? 0) > (tr.reversedAmount ?? 0)) {
|
|
405
|
-
wedged.push(tr);
|
|
406
|
-
}
|
|
407
|
-
}
|
|
408
|
-
if (wedged.length === 0) return [];
|
|
409
|
-
|
|
410
|
-
const wedgedIds = new Set(wedged.map((t) => t.stripeTransferId));
|
|
411
|
-
|
|
412
|
-
// Index the ops that hold an unexecuted slice on a wedged leg. A slice is
|
|
413
|
-
// unexecuted when its `to` exceeds the leg's confirmed `reversedAmount`.
|
|
414
|
-
const confirmedById = new Map(
|
|
415
|
-
wedged.map((t) => [t.stripeTransferId, t.reversedAmount ?? 0]),
|
|
416
|
-
);
|
|
417
|
-
const blockersByLeg = new Map<
|
|
418
|
-
string,
|
|
419
|
-
{ operationId: string; from: number; to: number }[]
|
|
420
|
-
>();
|
|
421
|
-
for await (const op of ctx.db.query("transferReversalOps")) {
|
|
422
|
-
for (const s of op.slices) {
|
|
423
|
-
if (!wedgedIds.has(s.stripeTransferId)) continue;
|
|
424
|
-
if (s.to <= (confirmedById.get(s.stripeTransferId) ?? 0)) continue;
|
|
425
|
-
const list = blockersByLeg.get(s.stripeTransferId) ?? [];
|
|
426
|
-
list.push({ operationId: op.operationId, from: s.from, to: s.to });
|
|
427
|
-
blockersByLeg.set(s.stripeTransferId, list);
|
|
428
|
-
}
|
|
429
|
-
}
|
|
430
|
-
|
|
431
|
-
return wedged.map((t) => ({
|
|
432
|
-
stripeTransferId: t.stripeTransferId,
|
|
433
|
-
sourceChargeId: t.sourceChargeId,
|
|
434
|
-
amount: t.amount,
|
|
435
|
-
reversedAmount: t.reversedAmount ?? 0,
|
|
436
|
-
reversalClaimedAmount: t.reversalClaimedAmount ?? 0,
|
|
437
|
-
blockingOps: (blockersByLeg.get(t.stripeTransferId) ?? []).sort(
|
|
438
|
-
(a, b) => a.from - b.from,
|
|
439
|
-
),
|
|
440
|
-
}));
|
|
441
|
-
},
|
|
442
|
-
});
|
|
397
|
+
export const listWedgedReversalClaims = query(
|
|
398
|
+
listWedgedReversalClaimsDefinition,
|
|
399
|
+
);
|
|
443
400
|
|
|
444
401
|
// EARNINGS AGGREGATES (BTS-64)
|
|
445
402
|
//
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
import { v } from "convex/values";
|
|
2
|
+
import type { MutationCtx } from "../_generated/server.js";
|
|
3
|
+
import { computeReversalSlices } from "../lib/reversals.js";
|
|
4
|
+
import {
|
|
5
|
+
applyCommissionExposure,
|
|
6
|
+
applyObligationExposure,
|
|
7
|
+
} from "../settlement/clawbacks.js";
|
|
8
|
+
import { commissionExposureValidator } from "../settlement/clawbackSchema.js";
|
|
9
|
+
import type { Infer } from "convex/values";
|
|
10
|
+
/** How `claimReversalSlices` sizes each leg's slice. */
|
|
11
|
+
const reversalModeValidator = v.union(
|
|
12
|
+
v.object({ kind: v.literal("full") }),
|
|
13
|
+
v.object({ kind: v.literal("percent"), percent: v.number() }),
|
|
14
|
+
v.object({ kind: v.literal("amount"), amount: v.number() }),
|
|
15
|
+
v.object({
|
|
16
|
+
kind: v.literal("fraction"),
|
|
17
|
+
chargeAmount: v.number(),
|
|
18
|
+
amountRefunded: v.number(),
|
|
19
|
+
}),
|
|
20
|
+
);
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Atomically claim reversal slices for one operation (a dispute or refund
|
|
24
|
+
* clawback) BEFORE any money moves (BTS-63). Runs in a single transaction, so
|
|
25
|
+
* concurrent operations serialize here instead of racing between the ledger
|
|
26
|
+
* read and the Stripe call:
|
|
27
|
+
*
|
|
28
|
+
* - First claim for an `operationId`: computes slices against each leg's
|
|
29
|
+
* claim frontier (`max(reversedAmount, reversalClaimedAmount)`), advances
|
|
30
|
+
* the frontier, and records the slices — atomically. A concurrent distinct
|
|
31
|
+
* operation claiming the same target gets an EMPTY claim, not a duplicate.
|
|
32
|
+
* - Any later call with the same `operationId` (webhook redelivery, retry
|
|
33
|
+
* after a partial failure) replays the recorded slices verbatim, so the
|
|
34
|
+
* caller re-sends byte-identical Stripe params under the same idempotency
|
|
35
|
+
* keys no matter how the ledger moved in between.
|
|
36
|
+
*
|
|
37
|
+
* Each returned slice carries the leg's current CONFIRMED `reversedAmount` so
|
|
38
|
+
* the executor can skip slices whose money already moved and was recorded.
|
|
39
|
+
*/
|
|
40
|
+
export const claimReversalSlicesDefinition = {
|
|
41
|
+
args: {
|
|
42
|
+
operationId: v.string(),
|
|
43
|
+
sourceChargeId: v.string(),
|
|
44
|
+
mode: reversalModeValidator,
|
|
45
|
+
commissionExposure: v.optional(commissionExposureValidator),
|
|
46
|
+
},
|
|
47
|
+
returns: v.object({
|
|
48
|
+
replay: v.boolean(),
|
|
49
|
+
slices: v.array(
|
|
50
|
+
v.object({
|
|
51
|
+
stripeTransferId: v.string(),
|
|
52
|
+
from: v.number(),
|
|
53
|
+
to: v.number(),
|
|
54
|
+
confirmed: v.number(),
|
|
55
|
+
commissionTracked: v.optional(v.boolean()),
|
|
56
|
+
}),
|
|
57
|
+
),
|
|
58
|
+
}),
|
|
59
|
+
handler: async (
|
|
60
|
+
ctx: MutationCtx,
|
|
61
|
+
args: {
|
|
62
|
+
operationId: string;
|
|
63
|
+
sourceChargeId: string;
|
|
64
|
+
mode: Infer<typeof reversalModeValidator>;
|
|
65
|
+
commissionExposure?: Infer<typeof commissionExposureValidator>;
|
|
66
|
+
},
|
|
67
|
+
) => {
|
|
68
|
+
const confirmedFor = async (stripeTransferId: string) => {
|
|
69
|
+
const row = await ctx.db
|
|
70
|
+
.query("transfers")
|
|
71
|
+
.withIndex("by_stripe_transfer_id", (q) =>
|
|
72
|
+
q.eq("stripeTransferId", stripeTransferId),
|
|
73
|
+
)
|
|
74
|
+
.first();
|
|
75
|
+
return row?.reversedAmount ?? 0;
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const isAffiliate = async (stripeTransferId: string) => {
|
|
79
|
+
const row = await ctx.db
|
|
80
|
+
.query("transfers")
|
|
81
|
+
.withIndex("by_stripe_transfer_id", (q) =>
|
|
82
|
+
q.eq("stripeTransferId", stripeTransferId),
|
|
83
|
+
)
|
|
84
|
+
.unique();
|
|
85
|
+
return row?.role === "affiliate";
|
|
86
|
+
};
|
|
87
|
+
// Fail atomically rather than commit a partial recovery beyond the bound.
|
|
88
|
+
const chargeRows = await ctx.db
|
|
89
|
+
.query("transfers")
|
|
90
|
+
.withIndex("by_source_charge_id", (q) =>
|
|
91
|
+
q.eq("sourceChargeId", args.sourceChargeId),
|
|
92
|
+
)
|
|
93
|
+
.take(1001);
|
|
94
|
+
if (chargeRows.length > 1000)
|
|
95
|
+
throw new Error("Charge transfer recovery limit exceeded.");
|
|
96
|
+
const rows = chargeRows.filter((t) => !t.reinstatement);
|
|
97
|
+
|
|
98
|
+
const existingOp = await ctx.db
|
|
99
|
+
.query("transferReversalOps")
|
|
100
|
+
.withIndex("by_operation_id", (q) =>
|
|
101
|
+
q.eq("operationId", args.operationId),
|
|
102
|
+
)
|
|
103
|
+
.first();
|
|
104
|
+
const exposure =
|
|
105
|
+
existingOp?.commissionExposure && args.commissionExposure
|
|
106
|
+
? {
|
|
107
|
+
...existingOp.commissionExposure,
|
|
108
|
+
finalized:
|
|
109
|
+
existingOp.commissionExposure.finalized ||
|
|
110
|
+
args.commissionExposure.finalized,
|
|
111
|
+
disputeState:
|
|
112
|
+
args.commissionExposure.disputeState ??
|
|
113
|
+
existingOp.commissionExposure.disputeState,
|
|
114
|
+
refundedPrincipal: args.commissionExposure.refundedPrincipal,
|
|
115
|
+
}
|
|
116
|
+
: existingOp
|
|
117
|
+
? undefined
|
|
118
|
+
: args.commissionExposure;
|
|
119
|
+
const targets = new Map<string, number>();
|
|
120
|
+
if (exposure) {
|
|
121
|
+
for (const transfer of rows)
|
|
122
|
+
if (transfer.role === "affiliate")
|
|
123
|
+
targets.set(
|
|
124
|
+
transfer.stripeTransferId,
|
|
125
|
+
await applyCommissionExposure(ctx, transfer, exposure),
|
|
126
|
+
);
|
|
127
|
+
const untransferred = await ctx.db
|
|
128
|
+
.query("affiliateObligations")
|
|
129
|
+
.withIndex("by_charge", (q) =>
|
|
130
|
+
q.eq("sourceChargeId", args.sourceChargeId),
|
|
131
|
+
)
|
|
132
|
+
.take(1001);
|
|
133
|
+
if (untransferred.length > 1000)
|
|
134
|
+
throw new Error("Affiliate obligation limit exceeded.");
|
|
135
|
+
for (const row of untransferred)
|
|
136
|
+
if (!row.stripeTransferId)
|
|
137
|
+
await applyObligationExposure(ctx, row, exposure);
|
|
138
|
+
}
|
|
139
|
+
if (existingOp) {
|
|
140
|
+
if (existingOp.sourceChargeId !== args.sourceChargeId)
|
|
141
|
+
throw new Error("Reversal operation source identity cannot change.");
|
|
142
|
+
if (existingOp.commissionExposure && args.commissionExposure) {
|
|
143
|
+
if (
|
|
144
|
+
existingOp.commissionExposure.kind !== args.commissionExposure.kind ||
|
|
145
|
+
existingOp.commissionExposure.chargeAmount !==
|
|
146
|
+
args.commissionExposure.chargeAmount
|
|
147
|
+
)
|
|
148
|
+
throw new Error("Commission operation identity cannot change.");
|
|
149
|
+
if (exposure)
|
|
150
|
+
await ctx.db.patch("transferReversalOps", existingOp._id, {
|
|
151
|
+
commissionExposure: exposure,
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
const slices = [];
|
|
155
|
+
for (const s of existingOp.slices) {
|
|
156
|
+
slices.push({
|
|
157
|
+
...s,
|
|
158
|
+
confirmed: await confirmedFor(s.stripeTransferId),
|
|
159
|
+
...(existingOp.commissionExposure
|
|
160
|
+
? { commissionTracked: await isAffiliate(s.stripeTransferId) }
|
|
161
|
+
: {}),
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
return { replay: true, slices };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const byId = new Map(rows.map((r) => [r.stripeTransferId, r]));
|
|
168
|
+
let slices = computeReversalSlices(
|
|
169
|
+
rows.map((t) => ({
|
|
170
|
+
stripeTransferId: t.stripeTransferId,
|
|
171
|
+
amount: t.amount,
|
|
172
|
+
frontier: Math.max(t.reversedAmount ?? 0, t.reversalClaimedAmount ?? 0),
|
|
173
|
+
})),
|
|
174
|
+
args.mode,
|
|
175
|
+
);
|
|
176
|
+
|
|
177
|
+
if (args.commissionExposure) {
|
|
178
|
+
// Keep the shipped seller/other paths; affiliates converge on original
|
|
179
|
+
// commission exposure rather than a second incremental dispute debit.
|
|
180
|
+
const affiliates = rows.filter((t) => t.role === "affiliate");
|
|
181
|
+
slices = slices.filter(
|
|
182
|
+
(s) => byId.get(s.stripeTransferId)!.role !== "affiliate",
|
|
183
|
+
);
|
|
184
|
+
for (const transfer of affiliates) {
|
|
185
|
+
const target = Math.min(
|
|
186
|
+
transfer.amount,
|
|
187
|
+
targets.get(transfer.stripeTransferId) ?? 0,
|
|
188
|
+
);
|
|
189
|
+
const from = Math.max(
|
|
190
|
+
transfer.reversedAmount ?? 0,
|
|
191
|
+
transfer.reversalClaimedAmount ?? 0,
|
|
192
|
+
);
|
|
193
|
+
if (target > from)
|
|
194
|
+
slices.push({
|
|
195
|
+
stripeTransferId: transfer.stripeTransferId,
|
|
196
|
+
from,
|
|
197
|
+
to: target,
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
for (const s of slices) {
|
|
203
|
+
await ctx.db.patch("transfers", byId.get(s.stripeTransferId)!._id, {
|
|
204
|
+
reversalClaimedAmount: s.to,
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
// Record the operation even when it claims nothing, so a redelivery is a
|
|
208
|
+
// deterministic no-op instead of a recomputation against newer state.
|
|
209
|
+
await ctx.db.insert("transferReversalOps", {
|
|
210
|
+
operationId: args.operationId,
|
|
211
|
+
sourceChargeId: args.sourceChargeId,
|
|
212
|
+
slices,
|
|
213
|
+
...(args.commissionExposure
|
|
214
|
+
? { commissionExposure: args.commissionExposure }
|
|
215
|
+
: {}),
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
return {
|
|
219
|
+
replay: false,
|
|
220
|
+
slices: slices.map((s) => ({
|
|
221
|
+
...s,
|
|
222
|
+
confirmed: byId.get(s.stripeTransferId)!.reversedAmount ?? 0,
|
|
223
|
+
...(args.commissionExposure
|
|
224
|
+
? {
|
|
225
|
+
commissionTracked:
|
|
226
|
+
byId.get(s.stripeTransferId)!.role === "affiliate",
|
|
227
|
+
}
|
|
228
|
+
: {}),
|
|
229
|
+
})),
|
|
230
|
+
};
|
|
231
|
+
},
|
|
232
|
+
};
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { v } from "convex/values";
|
|
2
|
+
import type { QueryCtx } from "../_generated/server.js";
|
|
3
|
+
import type { Doc } from "../_generated/dataModel.js";
|
|
4
|
+
import { wedgedReversalClaimValidator } from "./validators.js";
|
|
5
|
+
|
|
6
|
+
export const listWedgedReversalClaimsDefinition = {
|
|
7
|
+
args: {},
|
|
8
|
+
returns: v.array(wedgedReversalClaimValidator),
|
|
9
|
+
handler: async (ctx: QueryCtx) => {
|
|
10
|
+
// Collect the wedged legs first.
|
|
11
|
+
const wedged: Doc<"transfers">[] = [];
|
|
12
|
+
for await (const tr of ctx.db.query("transfers")) {
|
|
13
|
+
const tracked =
|
|
14
|
+
tr.role === "affiliate"
|
|
15
|
+
? await ctx.db
|
|
16
|
+
.query("affiliateObligations")
|
|
17
|
+
.withIndex("by_transfer", (q) =>
|
|
18
|
+
q.eq("stripeTransferId", tr.stripeTransferId),
|
|
19
|
+
)
|
|
20
|
+
.unique()
|
|
21
|
+
: null;
|
|
22
|
+
if (tracked?.accounted) continue;
|
|
23
|
+
if ((tr.reversalClaimedAmount ?? 0) > (tr.reversedAmount ?? 0)) {
|
|
24
|
+
wedged.push(tr);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
if (wedged.length === 0) return [];
|
|
28
|
+
|
|
29
|
+
const wedgedIds = new Set(wedged.map((t) => t.stripeTransferId));
|
|
30
|
+
|
|
31
|
+
// Index the ops that hold an unexecuted slice on a wedged leg. A slice is
|
|
32
|
+
// unexecuted when its `to` exceeds the leg's confirmed `reversedAmount`.
|
|
33
|
+
const confirmedById = new Map(
|
|
34
|
+
wedged.map((t) => [t.stripeTransferId, t.reversedAmount ?? 0]),
|
|
35
|
+
);
|
|
36
|
+
const blockersByLeg = new Map<
|
|
37
|
+
string,
|
|
38
|
+
{ operationId: string; from: number; to: number }[]
|
|
39
|
+
>();
|
|
40
|
+
for await (const op of ctx.db.query("transferReversalOps")) {
|
|
41
|
+
for (const s of op.slices) {
|
|
42
|
+
if (!wedgedIds.has(s.stripeTransferId)) continue;
|
|
43
|
+
if (s.to <= (confirmedById.get(s.stripeTransferId) ?? 0)) continue;
|
|
44
|
+
const list = blockersByLeg.get(s.stripeTransferId) ?? [];
|
|
45
|
+
list.push({ operationId: op.operationId, from: s.from, to: s.to });
|
|
46
|
+
blockersByLeg.set(s.stripeTransferId, list);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return wedged.map((t) => ({
|
|
51
|
+
stripeTransferId: t.stripeTransferId,
|
|
52
|
+
sourceChargeId: t.sourceChargeId,
|
|
53
|
+
amount: t.amount,
|
|
54
|
+
reversedAmount: t.reversedAmount ?? 0,
|
|
55
|
+
reversalClaimedAmount: t.reversalClaimedAmount ?? 0,
|
|
56
|
+
blockingOps: (blockersByLeg.get(t.stripeTransferId) ?? []).sort(
|
|
57
|
+
(a, b) => a.from - b.from,
|
|
58
|
+
),
|
|
59
|
+
}));
|
|
60
|
+
},
|
|
61
|
+
};
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { updateObligation } from "../affiliateRecovery/accounting.js";
|
|
2
|
+
import { v } from "convex/values";
|
|
3
|
+
import type { Doc } from "../_generated/dataModel.js";
|
|
4
|
+
import type { MutationCtx } from "../_generated/server.js";
|
|
5
|
+
import { deriveReversalState } from "../lib/reversals.js";
|
|
6
|
+
|
|
7
|
+
async function recordCommissionReceipt(
|
|
8
|
+
ctx: MutationCtx,
|
|
9
|
+
transfer: Doc<"transfers">,
|
|
10
|
+
reversedAmount: number,
|
|
11
|
+
receipt: { stripeReversalId: string; operationId: string; amount: number },
|
|
12
|
+
) {
|
|
13
|
+
const op = await ctx.db
|
|
14
|
+
.query("transferReversalOps")
|
|
15
|
+
.withIndex("by_operation_id", (q) =>
|
|
16
|
+
q.eq("operationId", receipt.operationId),
|
|
17
|
+
)
|
|
18
|
+
.unique();
|
|
19
|
+
const slice = op?.slices.find(
|
|
20
|
+
(s) => s.stripeTransferId === transfer.stripeTransferId,
|
|
21
|
+
);
|
|
22
|
+
if (
|
|
23
|
+
transfer.role !== "affiliate" ||
|
|
24
|
+
!op?.commissionExposure ||
|
|
25
|
+
!slice ||
|
|
26
|
+
receipt.amount !== slice.to - slice.from ||
|
|
27
|
+
reversedAmount !== slice.to ||
|
|
28
|
+
!receipt.stripeReversalId
|
|
29
|
+
)
|
|
30
|
+
throw new Error(
|
|
31
|
+
"Commission reversal receipt does not match its reserved operation.",
|
|
32
|
+
);
|
|
33
|
+
const priorOperationReceipt = await ctx.db
|
|
34
|
+
.query("affiliateReversalReceipts")
|
|
35
|
+
.withIndex("by_transfer_operation", (q) =>
|
|
36
|
+
q
|
|
37
|
+
.eq("stripeTransferId", transfer.stripeTransferId)
|
|
38
|
+
.eq("operationId", receipt.operationId),
|
|
39
|
+
)
|
|
40
|
+
.unique();
|
|
41
|
+
if (
|
|
42
|
+
priorOperationReceipt &&
|
|
43
|
+
priorOperationReceipt.stripeReversalId !== receipt.stripeReversalId
|
|
44
|
+
)
|
|
45
|
+
throw new Error(
|
|
46
|
+
"Commission operation already has a different reversal receipt.",
|
|
47
|
+
);
|
|
48
|
+
const existing = await ctx.db
|
|
49
|
+
.query("affiliateReversalReceipts")
|
|
50
|
+
.withIndex("by_receipt", (q) =>
|
|
51
|
+
q.eq("stripeReversalId", receipt.stripeReversalId),
|
|
52
|
+
)
|
|
53
|
+
.unique();
|
|
54
|
+
if (existing) {
|
|
55
|
+
if (
|
|
56
|
+
existing.stripeTransferId !== transfer.stripeTransferId ||
|
|
57
|
+
existing.operationId !== receipt.operationId ||
|
|
58
|
+
existing.amount !== receipt.amount
|
|
59
|
+
)
|
|
60
|
+
throw new Error("Commission reversal receipt identity cannot change.");
|
|
61
|
+
} else {
|
|
62
|
+
await ctx.db.insert("affiliateReversalReceipts", {
|
|
63
|
+
...receipt,
|
|
64
|
+
stripeTransferId: transfer.stripeTransferId,
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export const recordTransferReversalDefinition = {
|
|
70
|
+
args: {
|
|
71
|
+
stripeTransferId: v.string(),
|
|
72
|
+
reversedAmount: v.number(),
|
|
73
|
+
receipt: v.optional(
|
|
74
|
+
v.object({
|
|
75
|
+
stripeReversalId: v.string(),
|
|
76
|
+
operationId: v.string(),
|
|
77
|
+
amount: v.number(),
|
|
78
|
+
}),
|
|
79
|
+
),
|
|
80
|
+
},
|
|
81
|
+
returns: v.null(),
|
|
82
|
+
handler: async (
|
|
83
|
+
ctx: MutationCtx,
|
|
84
|
+
args: {
|
|
85
|
+
stripeTransferId: string;
|
|
86
|
+
reversedAmount: number;
|
|
87
|
+
receipt?: {
|
|
88
|
+
stripeReversalId: string;
|
|
89
|
+
operationId: string;
|
|
90
|
+
amount: number;
|
|
91
|
+
};
|
|
92
|
+
},
|
|
93
|
+
) => {
|
|
94
|
+
const transfer = await ctx.db
|
|
95
|
+
.query("transfers")
|
|
96
|
+
.withIndex("by_stripe_transfer_id", (q) =>
|
|
97
|
+
q.eq("stripeTransferId", args.stripeTransferId),
|
|
98
|
+
)
|
|
99
|
+
.first();
|
|
100
|
+
|
|
101
|
+
if (!transfer) {
|
|
102
|
+
throw new Error(
|
|
103
|
+
`Cannot record reversal: no transfer found for ${args.stripeTransferId}`,
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// `reversedAmount` is a cumulative total (matches Stripe's
|
|
108
|
+
// `Transfer.amount_reversed`). Keep it monotonic so a stale/out-of-order
|
|
109
|
+
// event can't shrink it or undo a full reversal.
|
|
110
|
+
const reversedAmount = Math.max(
|
|
111
|
+
transfer.reversedAmount ?? 0,
|
|
112
|
+
args.reversedAmount,
|
|
113
|
+
);
|
|
114
|
+
const derived = deriveReversalState(
|
|
115
|
+
transfer.amount,
|
|
116
|
+
reversedAmount,
|
|
117
|
+
transfer.status,
|
|
118
|
+
);
|
|
119
|
+
|
|
120
|
+
if (
|
|
121
|
+
!Number.isSafeInteger(reversedAmount) ||
|
|
122
|
+
reversedAmount < 0 ||
|
|
123
|
+
reversedAmount > transfer.amount
|
|
124
|
+
)
|
|
125
|
+
throw new Error("Invalid confirmed transfer reversal amount.");
|
|
126
|
+
if (args.receipt)
|
|
127
|
+
await recordCommissionReceipt(
|
|
128
|
+
ctx,
|
|
129
|
+
transfer,
|
|
130
|
+
args.reversedAmount,
|
|
131
|
+
args.receipt,
|
|
132
|
+
);
|
|
133
|
+
await ctx.db.patch("transfers", transfer._id, derived);
|
|
134
|
+
const obligation = await ctx.db
|
|
135
|
+
.query("affiliateObligations")
|
|
136
|
+
.withIndex("by_transfer", (q) =>
|
|
137
|
+
q.eq("stripeTransferId", args.stripeTransferId),
|
|
138
|
+
)
|
|
139
|
+
.unique();
|
|
140
|
+
if (obligation)
|
|
141
|
+
await updateObligation(ctx, obligation, {
|
|
142
|
+
recoveredAmount: reversedAmount,
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
return null;
|
|
146
|
+
},
|
|
147
|
+
};
|
|
@@ -90,9 +90,9 @@ export const transfersTable = defineTable({
|
|
|
90
90
|
* delivery replays byte-identical Stripe requests instead of recomputing
|
|
91
91
|
* amounts from mutable ledger state.
|
|
92
92
|
*/
|
|
93
|
-
export const transferReversalOpsTable = defineTable(
|
|
94
|
-
|
|
95
|
-
|
|
93
|
+
export const transferReversalOpsTable = defineTable(transferReversalOpFields)
|
|
94
|
+
.index("by_operation_id", ["operationId"])
|
|
95
|
+
.index("by_source_charge", ["sourceChargeId"]);
|
|
96
96
|
|
|
97
97
|
/**
|
|
98
98
|
* Disputes (chargebacks). Same indexing strategy as refunds.
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { commissionExposureValidator } from "../settlement/clawbackSchema.js";
|
|
1
2
|
import { type Infer, v } from "convex/values";
|
|
2
3
|
|
|
3
4
|
import { feeRoutingFields, splitRecipientRoleValidator } from "../lib/fees";
|
|
@@ -305,6 +306,7 @@ export const transferReversalOpFields = {
|
|
|
305
306
|
operationId: v.string(),
|
|
306
307
|
sourceChargeId: v.string(),
|
|
307
308
|
slices: v.array(reversalSliceValidator),
|
|
309
|
+
commissionExposure: v.optional(commissionExposureValidator),
|
|
308
310
|
};
|
|
309
311
|
|
|
310
312
|
/** Full `transferReversalOps` document, including system fields. */
|