@fantastic.dev/better-stripe 0.8.1 → 0.10.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 +12 -0
- package/MIGRATION.md +12 -0
- package/README.md +46 -2
- package/dist/client/billing/checkout.d.ts +1 -0
- package/dist/client/billing/checkout.d.ts.map +1 -1
- package/dist/client/billing/checkout.js +5 -1
- package/dist/client/billing/checkout.js.map +1 -1
- package/dist/client/billing/providerReads.d.ts +18 -0
- package/dist/client/billing/providerReads.d.ts.map +1 -0
- package/dist/client/billing/providerReads.js +15 -0
- package/dist/client/billing/providerReads.js.map +1 -0
- package/dist/client/billing/subscriptions.d.ts +1 -1
- package/dist/client/billing/subscriptions.d.ts.map +1 -1
- package/dist/client/billing/subscriptions.js.map +1 -1
- package/dist/client/connect/payoutSnapshot.d.ts +28 -0
- package/dist/client/connect/payoutSnapshot.d.ts.map +1 -0
- package/dist/client/connect/payoutSnapshot.js +81 -0
- package/dist/client/connect/payoutSnapshot.js.map +1 -0
- package/dist/client/connect/transfers.d.ts.map +1 -1
- package/dist/client/connect/transfers.js +13 -32
- package/dist/client/connect/transfers.js.map +1 -1
- package/dist/client/core/accounts.d.ts +12 -23
- package/dist/client/core/accounts.d.ts.map +1 -1
- package/dist/client/core/accounts.js +5 -1
- package/dist/client/core/accounts.js.map +1 -1
- package/dist/client/core/idempotency.d.ts +5 -0
- package/dist/client/core/idempotency.d.ts.map +1 -0
- package/dist/client/core/idempotency.js +10 -0
- package/dist/client/core/idempotency.js.map +1 -0
- package/dist/client/index.d.ts +31 -25
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +29 -52
- package/dist/client/index.js.map +1 -1
- package/dist/client/settlement/allocation.d.ts +30 -0
- package/dist/client/settlement/allocation.d.ts.map +1 -0
- package/dist/client/settlement/allocation.js +71 -0
- package/dist/client/settlement/allocation.js.map +1 -0
- package/dist/client/settlement/commissions.d.ts +27 -0
- package/dist/client/settlement/commissions.d.ts.map +1 -0
- package/dist/client/settlement/commissions.js +69 -0
- package/dist/client/settlement/commissions.js.map +1 -0
- package/dist/client/settlement/execution.d.ts +27 -0
- package/dist/client/settlement/execution.d.ts.map +1 -0
- package/dist/client/settlement/execution.js +70 -0
- package/dist/client/settlement/execution.js.map +1 -0
- package/dist/client/settlement/reconciliation.d.ts +10 -0
- package/dist/client/settlement/reconciliation.d.ts.map +1 -0
- package/dist/client/settlement/reconciliation.js +73 -0
- package/dist/client/settlement/reconciliation.js.map +1 -0
- package/dist/client/settlement/settleSale.d.ts +21 -0
- package/dist/client/settlement/settleSale.d.ts.map +1 -0
- package/dist/client/settlement/settleSale.js +27 -0
- package/dist/client/settlement/settleSale.js.map +1 -0
- package/dist/client/settlement/verifiedSale.d.ts +25 -0
- package/dist/client/settlement/verifiedSale.d.ts.map +1 -0
- package/dist/client/settlement/verifiedSale.js +100 -0
- package/dist/client/settlement/verifiedSale.js.map +1 -0
- package/dist/client/webhooks/asyncHooks.d.ts +5 -0
- package/dist/client/webhooks/asyncHooks.d.ts.map +1 -0
- package/dist/client/webhooks/asyncHooks.js +20 -0
- package/dist/client/webhooks/asyncHooks.js.map +1 -0
- package/dist/component/_generated/component.d.ts +115 -0
- package/dist/component/_generated/component.d.ts.map +1 -1
- package/dist/component/connect/queries.d.ts +30 -1
- package/dist/component/connect/queries.d.ts.map +1 -1
- package/dist/component/connect/queries.js +19 -2
- package/dist/component/connect/queries.js.map +1 -1
- package/dist/component/schema.d.ts +67 -0
- package/dist/component/schema.js +4 -0
- package/dist/component/schema.js.map +1 -1
- package/dist/component/settlement/mutations.d.ts +47 -0
- package/dist/component/settlement/mutations.d.ts.map +1 -0
- package/dist/component/settlement/mutations.js +104 -0
- package/dist/component/settlement/mutations.js.map +1 -0
- package/dist/component/settlement/queries.d.ts +29 -0
- package/dist/component/settlement/queries.d.ts.map +1 -0
- package/dist/component/settlement/queries.js +35 -0
- package/dist/component/settlement/queries.js.map +1 -0
- package/dist/component/settlement/schema.d.ts +99 -0
- package/dist/component/settlement/schema.d.ts.map +1 -0
- package/dist/component/settlement/schema.js +35 -0
- package/dist/component/settlement/schema.js.map +1 -0
- package/dist/react/workflows/AffiliatePayoutEditor.d.ts +41 -0
- package/dist/react/workflows/AffiliatePayoutEditor.d.ts.map +1 -0
- package/dist/react/workflows/AffiliatePayoutEditor.js +80 -0
- package/dist/react/workflows/AffiliatePayoutEditor.js.map +1 -0
- package/dist/react/workflows/AffiliateRateEditor.d.ts +26 -0
- package/dist/react/workflows/AffiliateRateEditor.d.ts.map +1 -0
- package/dist/react/workflows/AffiliateRateEditor.js +49 -0
- package/dist/react/workflows/AffiliateRateEditor.js.map +1 -0
- package/dist/react/workflows/Checkout.d.ts +3 -1
- package/dist/react/workflows/Checkout.d.ts.map +1 -1
- package/dist/react/workflows/Checkout.js +3 -2
- package/dist/react/workflows/Checkout.js.map +1 -1
- package/dist/react/workflows/CheckoutForms.d.ts +2 -0
- package/dist/react/workflows/CheckoutForms.d.ts.map +1 -1
- package/dist/react/workflows/CheckoutForms.js +22 -16
- package/dist/react/workflows/CheckoutForms.js.map +1 -1
- package/dist/react/workflows/IntegrationSwitch.js +2 -2
- package/dist/react/workflows/IntegrationSwitch.js.map +1 -1
- package/dist/react/workflows/Pagination.d.ts +32 -0
- package/dist/react/workflows/Pagination.d.ts.map +1 -0
- package/dist/react/workflows/Pagination.js +49 -0
- package/dist/react/workflows/Pagination.js.map +1 -0
- package/dist/react/workflows/Payouts.d.ts +3 -18
- package/dist/react/workflows/Payouts.d.ts.map +1 -1
- package/dist/react/workflows/Payouts.js +2 -2
- package/dist/react/workflows/Payouts.js.map +1 -1
- package/dist/react/workflows/PlanPricingEditor.d.ts +35 -0
- package/dist/react/workflows/PlanPricingEditor.d.ts.map +1 -0
- package/dist/react/workflows/PlanPricingEditor.js +64 -0
- package/dist/react/workflows/PlanPricingEditor.js.map +1 -0
- package/dist/react/workflows/index.d.ts +8 -0
- package/dist/react/workflows/index.d.ts.map +1 -1
- package/dist/react/workflows/index.js +4 -0
- package/dist/react/workflows/index.js.map +1 -1
- package/docs/react-workflows-audit.md +19 -0
- package/docs/react-workflows.md +94 -3
- package/docs/settlement.md +158 -0
- package/package.json +6 -4
- package/src/client/billing/checkout-idempotency.test.ts +82 -0
- package/src/client/billing/checkout.ts +6 -1
- package/src/client/billing/providerReads.test.ts +60 -0
- package/src/client/billing/providerReads.ts +30 -0
- package/src/client/billing/subscriptions.ts +1 -8
- package/src/client/connect/payoutSnapshot.test.ts +91 -0
- package/src/client/connect/payoutSnapshot.ts +130 -0
- package/src/client/connect/transfers.test.ts +15 -4
- package/src/client/connect/transfers.ts +13 -42
- package/src/client/core/account-idempotency.test.ts +78 -0
- package/src/client/core/accounts.ts +18 -25
- package/src/client/core/idempotency.test.ts +10 -0
- package/src/client/core/idempotency.ts +11 -0
- package/src/client/index.ts +84 -103
- package/src/client/settlement/allocation.test.ts +98 -0
- package/src/client/settlement/allocation.ts +110 -0
- package/src/client/settlement/commissions.test.ts +103 -0
- package/src/client/settlement/commissions.ts +125 -0
- package/src/client/settlement/execution.test.ts +186 -0
- package/src/client/settlement/execution.ts +134 -0
- package/src/client/settlement/reconciliation.ts +119 -0
- package/src/client/settlement/settleSale.ts +59 -0
- package/src/client/settlement/verifiedSale.test.ts +139 -0
- package/src/client/settlement/verifiedSale.ts +159 -0
- package/src/client/webhooks/asyncHooks.ts +38 -0
- package/src/client/webhooks/processors.test.ts +9 -9
- package/src/component/_generated/api.ts +4 -0
- package/src/component/_generated/component.ts +141 -0
- package/src/component/connect/queries.ts +26 -2
- package/src/component/earnings-totals.test.ts +13 -0
- package/src/component/schema.ts +8 -0
- package/src/component/settlement/mutations.ts +119 -0
- package/src/component/settlement/queries.ts +38 -0
- package/src/component/settlement/schema.ts +45 -0
- package/src/component/settlement-claims.test.ts +77 -0
- package/src/component/transfers.test.ts +48 -0
- package/src/react/components/EmbeddedDisputes.render.test.tsx +2 -2
- package/src/react/index.payment-element.test.ts +22 -37
- package/src/react/workflows/AffiliatePayoutEditor.test.tsx +90 -0
- package/src/react/workflows/AffiliatePayoutEditor.tsx +201 -0
- package/src/react/workflows/AffiliateRateEditor.test.tsx +36 -0
- package/src/react/workflows/AffiliateRateEditor.tsx +92 -0
- package/src/react/workflows/Checkout.test.tsx +30 -0
- package/src/react/workflows/Checkout.tsx +10 -3
- package/src/react/workflows/CheckoutForms.test.tsx +40 -0
- package/src/react/workflows/CheckoutForms.tsx +37 -16
- package/src/react/workflows/IntegrationSwitch.test.tsx +25 -0
- package/src/react/workflows/IntegrationSwitch.tsx +2 -2
- package/src/react/workflows/Pagination.test.tsx +53 -0
- package/src/react/workflows/Pagination.tsx +93 -0
- package/src/react/workflows/Payouts.test.tsx +33 -0
- package/src/react/workflows/Payouts.tsx +21 -15
- package/src/react/workflows/PlanPricingEditor.test.tsx +79 -0
- package/src/react/workflows/PlanPricingEditor.tsx +156 -0
- package/src/react/workflows/index.ts +22 -0
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import type Stripe from "stripe";
|
|
2
|
+
import type { Component, RunCtx } from "../helpers.js";
|
|
3
|
+
import { runMutationOrThrow } from "../helpers.js";
|
|
4
|
+
import { componentRef } from "../webhooks/helpers.js";
|
|
5
|
+
import { isBetterStripeError, throwStripeError } from "../errors.js";
|
|
6
|
+
export type CommissionTerm = "forever" | "one_time";
|
|
7
|
+
export type CommissionSelectionOptions = {
|
|
8
|
+
subscriptionId: string;
|
|
9
|
+
invoiceId: string;
|
|
10
|
+
affiliateAccountId?: string;
|
|
11
|
+
term?: CommissionTerm;
|
|
12
|
+
maxHistoryPages?: number;
|
|
13
|
+
};
|
|
14
|
+
export type CommissionDecision = {
|
|
15
|
+
eligible: boolean;
|
|
16
|
+
term: CommissionTerm;
|
|
17
|
+
firstPaidInvoiceId: string;
|
|
18
|
+
claimKey: string;
|
|
19
|
+
replay: boolean;
|
|
20
|
+
};
|
|
21
|
+
export type CommissionSelection =
|
|
22
|
+
| ({ status: "ready" } & CommissionDecision)
|
|
23
|
+
| { status: "not_payable"; eligible: false; reason: string };
|
|
24
|
+
type Stored = {
|
|
25
|
+
subscriptionId: string;
|
|
26
|
+
term: CommissionTerm;
|
|
27
|
+
affiliateAccountId?: string;
|
|
28
|
+
firstPaidInvoiceId: string;
|
|
29
|
+
};
|
|
30
|
+
/** Durable selection only. Replay returns the same award key; execute through settleSale. */
|
|
31
|
+
export async function selectCommissionInvoice(
|
|
32
|
+
stripe: Stripe,
|
|
33
|
+
component: Component,
|
|
34
|
+
ctx: RunCtx,
|
|
35
|
+
opts: CommissionSelectionOptions,
|
|
36
|
+
): Promise<CommissionSelection> {
|
|
37
|
+
try {
|
|
38
|
+
const invoice = await stripe.invoices.retrieve(opts.invoiceId);
|
|
39
|
+
const pointer = invoice.parent?.subscription_details?.subscription;
|
|
40
|
+
if (
|
|
41
|
+
(typeof pointer === "string" ? pointer : pointer?.id) !==
|
|
42
|
+
opts.subscriptionId
|
|
43
|
+
)
|
|
44
|
+
throwStripeError(
|
|
45
|
+
"INVALID_CONFIGURATION",
|
|
46
|
+
"Invoice does not belong to this subscription.",
|
|
47
|
+
);
|
|
48
|
+
if (invoice.status !== "paid" || invoice.amount_paid <= 0)
|
|
49
|
+
return {
|
|
50
|
+
status: "not_payable",
|
|
51
|
+
eligible: false,
|
|
52
|
+
reason: "invoice_not_paid_or_zero",
|
|
53
|
+
};
|
|
54
|
+
const stored = (await ctx.runQuery(
|
|
55
|
+
componentRef(component, "settlement/queries/getCommissionTerm"),
|
|
56
|
+
{ subscriptionId: opts.subscriptionId },
|
|
57
|
+
)) as Stored | null;
|
|
58
|
+
let firstPaidInvoiceId = stored?.firstPaidInvoiceId ?? invoice.id;
|
|
59
|
+
const term = stored?.term ?? opts.term ?? "forever";
|
|
60
|
+
if (!stored && term === "one_time") {
|
|
61
|
+
const max = opts.maxHistoryPages ?? 100;
|
|
62
|
+
if (!Number.isInteger(max) || max < 1 || max > 1000)
|
|
63
|
+
throwStripeError(
|
|
64
|
+
"INVALID_CONFIGURATION",
|
|
65
|
+
"Invalid invoice history page bound.",
|
|
66
|
+
);
|
|
67
|
+
let cursor: string | undefined,
|
|
68
|
+
earliest: number = Infinity,
|
|
69
|
+
complete = false;
|
|
70
|
+
for (let i = 0; i < max; i++) {
|
|
71
|
+
const batch = await stripe.invoices.list({
|
|
72
|
+
subscription: opts.subscriptionId,
|
|
73
|
+
status: "paid",
|
|
74
|
+
limit: 100,
|
|
75
|
+
starting_after: cursor,
|
|
76
|
+
});
|
|
77
|
+
for (const row of batch.data)
|
|
78
|
+
if (row.amount_paid > 0) {
|
|
79
|
+
const paid = row.status_transitions.paid_at ?? row.created;
|
|
80
|
+
if (
|
|
81
|
+
paid < earliest ||
|
|
82
|
+
(paid === earliest && row.id < firstPaidInvoiceId)
|
|
83
|
+
) {
|
|
84
|
+
earliest = paid;
|
|
85
|
+
firstPaidInvoiceId = row.id;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
if (!batch.has_more) {
|
|
89
|
+
complete = true;
|
|
90
|
+
break;
|
|
91
|
+
}
|
|
92
|
+
const next = batch.data[batch.data.length - 1]?.id;
|
|
93
|
+
if (!next || next === cursor)
|
|
94
|
+
throwStripeError(
|
|
95
|
+
"STRIPE_API_ERROR",
|
|
96
|
+
"Invoice pagination did not advance.",
|
|
97
|
+
);
|
|
98
|
+
cursor = next;
|
|
99
|
+
}
|
|
100
|
+
if (!complete || !Number.isFinite(earliest))
|
|
101
|
+
throwStripeError(
|
|
102
|
+
"INVALID_CONFIGURATION",
|
|
103
|
+
"First paid invoice could not be established from complete history.",
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
const decision = (await runMutationOrThrow(
|
|
107
|
+
ctx,
|
|
108
|
+
componentRef(component, "settlement/mutations/claimCommissionInvoice"),
|
|
109
|
+
{
|
|
110
|
+
subscriptionId: opts.subscriptionId,
|
|
111
|
+
invoiceId: invoice.id,
|
|
112
|
+
firstPaidInvoiceId,
|
|
113
|
+
affiliateAccountId: opts.affiliateAccountId,
|
|
114
|
+
term,
|
|
115
|
+
},
|
|
116
|
+
)) as CommissionDecision;
|
|
117
|
+
return { status: "ready", ...decision };
|
|
118
|
+
} catch (error) {
|
|
119
|
+
if (isBetterStripeError(error)) throw error;
|
|
120
|
+
throwStripeError(
|
|
121
|
+
"STRIPE_API_ERROR",
|
|
122
|
+
"Could not select subscription commission invoice.",
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import type Stripe from "stripe";
|
|
2
|
+
import { expect, it, vi } from "vitest";
|
|
3
|
+
import type { Component, RunCtx } from "../helpers.js";
|
|
4
|
+
import { executeResolvedTransfers } from "./execution.js";
|
|
5
|
+
const TO_REF = Symbol.for("toReferencePath");
|
|
6
|
+
const ref = (path: string) => ({ [TO_REF]: `betterStripe/${path}` });
|
|
7
|
+
const component = {
|
|
8
|
+
settlement: {
|
|
9
|
+
claims: {
|
|
10
|
+
reserve: ref("settlement/mutations/reserveSettlementAllocation"),
|
|
11
|
+
},
|
|
12
|
+
},
|
|
13
|
+
connect: {
|
|
14
|
+
queries: {
|
|
15
|
+
listTransfersByCharge: ref("connect/queries/listTransfersByCharge"),
|
|
16
|
+
},
|
|
17
|
+
mutations: { upsertTransfer: ref("connect/mutations/upsertTransfer") },
|
|
18
|
+
},
|
|
19
|
+
} as unknown as Component;
|
|
20
|
+
it("recovers provider success after a ledger failure even without an idempotency cache", async () => {
|
|
21
|
+
let claimed: unknown;
|
|
22
|
+
const ledger: unknown[] = [],
|
|
23
|
+
provider: Record<string, unknown>[] = [];
|
|
24
|
+
let fail = true;
|
|
25
|
+
const runMutation = vi.fn(
|
|
26
|
+
async (r: Record<symbol, string>, args: Record<string, unknown>) => {
|
|
27
|
+
if (r[TO_REF].endsWith("/reserveSettlementAllocation"))
|
|
28
|
+
return claimed ?? (claimed = { ...args });
|
|
29
|
+
if (fail) {
|
|
30
|
+
fail = false;
|
|
31
|
+
throw new Error("ledger unavailable");
|
|
32
|
+
}
|
|
33
|
+
ledger.push(args);
|
|
34
|
+
},
|
|
35
|
+
);
|
|
36
|
+
const ctx = {
|
|
37
|
+
runMutation,
|
|
38
|
+
runQuery: vi.fn(async () => ledger),
|
|
39
|
+
} as unknown as RunCtx;
|
|
40
|
+
const raw = {
|
|
41
|
+
charges: { retrieve: vi.fn().mockResolvedValue({ created: 1 }) },
|
|
42
|
+
transfers: {
|
|
43
|
+
list: vi.fn(async () => ({ data: provider, has_more: false })),
|
|
44
|
+
create: vi.fn(async (args) => {
|
|
45
|
+
const transfer = {
|
|
46
|
+
id: `tr_${provider.length}`,
|
|
47
|
+
destination: args.destination,
|
|
48
|
+
...args,
|
|
49
|
+
};
|
|
50
|
+
provider.push(transfer);
|
|
51
|
+
return transfer;
|
|
52
|
+
}),
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
const opts = {
|
|
56
|
+
sourceChargeId: "ch_1",
|
|
57
|
+
gross: 100,
|
|
58
|
+
currency: "usd",
|
|
59
|
+
processingFee: 3,
|
|
60
|
+
platformNet: 7,
|
|
61
|
+
transfers: [
|
|
62
|
+
{ destinationAccountId: "acct_c", role: "store" as const, amount: 80 },
|
|
63
|
+
{
|
|
64
|
+
destinationAccountId: "acct_a",
|
|
65
|
+
role: "affiliate" as const,
|
|
66
|
+
amount: 10,
|
|
67
|
+
},
|
|
68
|
+
],
|
|
69
|
+
};
|
|
70
|
+
await expect(
|
|
71
|
+
executeResolvedTransfers(raw as unknown as Stripe, component, ctx, opts),
|
|
72
|
+
).rejects.toThrow("ledger unavailable");
|
|
73
|
+
const result = await executeResolvedTransfers(
|
|
74
|
+
raw as unknown as Stripe,
|
|
75
|
+
component,
|
|
76
|
+
ctx,
|
|
77
|
+
{
|
|
78
|
+
...opts,
|
|
79
|
+
platformNet: 17,
|
|
80
|
+
transfers: [{ ...opts.transfers[0], amount: 70 }, opts.transfers[1]],
|
|
81
|
+
},
|
|
82
|
+
);
|
|
83
|
+
expect(raw.transfers.create).toHaveBeenCalledTimes(2);
|
|
84
|
+
expect(result.transfers.map((t) => t.amount)).toEqual([80, 10]);
|
|
85
|
+
expect(ledger).toHaveLength(2);
|
|
86
|
+
await executeResolvedTransfers(
|
|
87
|
+
raw as unknown as Stripe,
|
|
88
|
+
component,
|
|
89
|
+
ctx,
|
|
90
|
+
opts,
|
|
91
|
+
);
|
|
92
|
+
expect(raw.transfers.create).toHaveBeenCalledTimes(2);
|
|
93
|
+
});
|
|
94
|
+
it("recovers an orphaned original leg for refund/dispute clawback without creating more transfers", async () => {
|
|
95
|
+
const { recoverSettlementTransfers } = await import("./reconciliation.js");
|
|
96
|
+
const claim = {
|
|
97
|
+
sourceChargeId: "ch_refund",
|
|
98
|
+
gross: 100,
|
|
99
|
+
currency: "usd",
|
|
100
|
+
processingFee: 3,
|
|
101
|
+
platformNet: 7,
|
|
102
|
+
transfers: [
|
|
103
|
+
{ destinationAccountId: "acct_c", role: "store" as const, amount: 90 },
|
|
104
|
+
],
|
|
105
|
+
};
|
|
106
|
+
const provider = {
|
|
107
|
+
id: "tr_orphan",
|
|
108
|
+
destination: "acct_c",
|
|
109
|
+
amount: 90,
|
|
110
|
+
currency: "usd",
|
|
111
|
+
source_transaction: "ch_refund",
|
|
112
|
+
amount_reversed: 20,
|
|
113
|
+
metadata: { bsSourceCharge: "ch_refund", bsRole: "store" },
|
|
114
|
+
};
|
|
115
|
+
const raw = {
|
|
116
|
+
transfers: {
|
|
117
|
+
list: vi.fn().mockResolvedValue({ data: [provider], has_more: false }),
|
|
118
|
+
create: vi.fn(),
|
|
119
|
+
},
|
|
120
|
+
};
|
|
121
|
+
const runMutation = vi.fn(),
|
|
122
|
+
ctx = {
|
|
123
|
+
runQuery: vi.fn().mockResolvedValue(claim),
|
|
124
|
+
runMutation,
|
|
125
|
+
} as unknown as RunCtx;
|
|
126
|
+
await recoverSettlementTransfers(
|
|
127
|
+
raw as unknown as Stripe,
|
|
128
|
+
component,
|
|
129
|
+
ctx,
|
|
130
|
+
"ch_refund",
|
|
131
|
+
);
|
|
132
|
+
expect(runMutation).toHaveBeenCalledWith(
|
|
133
|
+
expect.anything(),
|
|
134
|
+
expect.objectContaining({
|
|
135
|
+
stripeTransferId: "tr_orphan",
|
|
136
|
+
sourceChargeId: "ch_refund",
|
|
137
|
+
amount: 90,
|
|
138
|
+
reversedAmount: 20,
|
|
139
|
+
}),
|
|
140
|
+
);
|
|
141
|
+
expect(raw.transfers.create).not.toHaveBeenCalled();
|
|
142
|
+
});
|
|
143
|
+
it("finds legacy transfers without a transfer group before issuing a new request", async () => {
|
|
144
|
+
const legacy = {
|
|
145
|
+
id: "tr_legacy",
|
|
146
|
+
destination: "acct_c",
|
|
147
|
+
amount: 90,
|
|
148
|
+
currency: "usd",
|
|
149
|
+
source_transaction: "ch_old",
|
|
150
|
+
metadata: { bsSourceCharge: "ch_old", bsRole: "store" },
|
|
151
|
+
};
|
|
152
|
+
const raw = {
|
|
153
|
+
charges: { retrieve: vi.fn().mockResolvedValue({ created: 1 }) },
|
|
154
|
+
transfers: {
|
|
155
|
+
list: vi.fn(async (params: { transfer_group?: string }) => ({
|
|
156
|
+
data: params.transfer_group ? [] : [legacy],
|
|
157
|
+
has_more: false,
|
|
158
|
+
})),
|
|
159
|
+
create: vi.fn().mockResolvedValue({ id: "tr_duplicate" }),
|
|
160
|
+
},
|
|
161
|
+
};
|
|
162
|
+
const opts = {
|
|
163
|
+
sourceChargeId: "ch_old",
|
|
164
|
+
gross: 100,
|
|
165
|
+
currency: "usd",
|
|
166
|
+
processingFee: 3,
|
|
167
|
+
platformNet: 7,
|
|
168
|
+
transfers: [
|
|
169
|
+
{ destinationAccountId: "acct_c", role: "store" as const, amount: 90 },
|
|
170
|
+
],
|
|
171
|
+
};
|
|
172
|
+
const ctx = {
|
|
173
|
+
runQuery: vi.fn().mockResolvedValue([]),
|
|
174
|
+
runMutation: vi.fn(async (r: Record<symbol, string>, args: unknown) =>
|
|
175
|
+
r[TO_REF].endsWith("reserveSettlementAllocation") ? args : null,
|
|
176
|
+
),
|
|
177
|
+
} as unknown as RunCtx;
|
|
178
|
+
const result = await executeResolvedTransfers(
|
|
179
|
+
raw as unknown as Stripe,
|
|
180
|
+
component,
|
|
181
|
+
ctx,
|
|
182
|
+
opts,
|
|
183
|
+
);
|
|
184
|
+
expect(result.executedTransfers[0].stripeTransferId).toBe("tr_legacy");
|
|
185
|
+
expect(raw.transfers.create).not.toHaveBeenCalled();
|
|
186
|
+
});
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import {
|
|
2
|
+
sourceTransfers,
|
|
3
|
+
matchingTransfer,
|
|
4
|
+
recordSettlementTransfer,
|
|
5
|
+
} from "./reconciliation.js";
|
|
6
|
+
import type Stripe from "stripe";
|
|
7
|
+
import type { SplitTransfer } from "../core/fees.js";
|
|
8
|
+
import type { Component, RunCtx } from "../helpers.js";
|
|
9
|
+
import { runMutationOrThrow } from "../helpers.js";
|
|
10
|
+
import { componentRef } from "../webhooks/helpers.js";
|
|
11
|
+
export type ResolvedSettlement = {
|
|
12
|
+
sourceChargeId: string;
|
|
13
|
+
paymentIntentId?: string;
|
|
14
|
+
sourceInvoiceId?: string;
|
|
15
|
+
gross: number;
|
|
16
|
+
currency: string;
|
|
17
|
+
processingFee: number;
|
|
18
|
+
platformNet: number;
|
|
19
|
+
adjustmentAmount?: number;
|
|
20
|
+
transfers: SplitTransfer[];
|
|
21
|
+
paymentId?: string;
|
|
22
|
+
};
|
|
23
|
+
export type SettlementResult = ResolvedSettlement & {
|
|
24
|
+
status: "settled";
|
|
25
|
+
executedTransfers: {
|
|
26
|
+
stripeTransferId: string;
|
|
27
|
+
destinationAccountId: string;
|
|
28
|
+
role: string;
|
|
29
|
+
amount: number;
|
|
30
|
+
}[];
|
|
31
|
+
};
|
|
32
|
+
type Ledger = {
|
|
33
|
+
stripeTransferId: string;
|
|
34
|
+
destinationAccountId: string;
|
|
35
|
+
role?: string;
|
|
36
|
+
amount: number;
|
|
37
|
+
currency: string;
|
|
38
|
+
};
|
|
39
|
+
/** Shared transfer engine for webhook splits and verified, host-resolved allocations. */
|
|
40
|
+
export async function executeResolvedTransfers(
|
|
41
|
+
stripe: Stripe,
|
|
42
|
+
component: Component,
|
|
43
|
+
ctx: RunCtx,
|
|
44
|
+
opts: ResolvedSettlement,
|
|
45
|
+
): Promise<SettlementResult> {
|
|
46
|
+
const saved = (await runMutationOrThrow(
|
|
47
|
+
ctx,
|
|
48
|
+
componentRef(component, "settlement/mutations/reserveSettlementAllocation"),
|
|
49
|
+
opts,
|
|
50
|
+
)) as ResolvedSettlement;
|
|
51
|
+
const claim: ResolvedSettlement = {
|
|
52
|
+
sourceChargeId: saved.sourceChargeId,
|
|
53
|
+
paymentIntentId: saved.paymentIntentId,
|
|
54
|
+
sourceInvoiceId: saved.sourceInvoiceId,
|
|
55
|
+
gross: saved.gross,
|
|
56
|
+
currency: saved.currency,
|
|
57
|
+
processingFee: saved.processingFee,
|
|
58
|
+
platformNet: saved.platformNet,
|
|
59
|
+
adjustmentAmount: saved.adjustmentAmount,
|
|
60
|
+
transfers: saved.transfers,
|
|
61
|
+
paymentId: saved.paymentId,
|
|
62
|
+
};
|
|
63
|
+
const existing = ((await ctx.runQuery(
|
|
64
|
+
componentRef(component, "connect/queries/listTransfersByCharge"),
|
|
65
|
+
{ sourceChargeId: claim.sourceChargeId },
|
|
66
|
+
)) ?? []) as Ledger[];
|
|
67
|
+
let provider = await sourceTransfers(stripe, claim.sourceChargeId);
|
|
68
|
+
if (
|
|
69
|
+
claim.transfers.some(
|
|
70
|
+
(leg) =>
|
|
71
|
+
leg.amount > 0 &&
|
|
72
|
+
!existing.some(
|
|
73
|
+
(t) =>
|
|
74
|
+
t.destinationAccountId === leg.destinationAccountId &&
|
|
75
|
+
t.role === leg.role,
|
|
76
|
+
) &&
|
|
77
|
+
!matchingTransfer(provider, claim, leg),
|
|
78
|
+
)
|
|
79
|
+
) {
|
|
80
|
+
provider = [
|
|
81
|
+
...new Map(
|
|
82
|
+
[
|
|
83
|
+
...provider,
|
|
84
|
+
...(await sourceTransfers(stripe, claim.sourceChargeId, true)),
|
|
85
|
+
].map((t) => [t.id, t]),
|
|
86
|
+
).values(),
|
|
87
|
+
];
|
|
88
|
+
}
|
|
89
|
+
const executedTransfers: SettlementResult["executedTransfers"] = [];
|
|
90
|
+
for (const leg of claim.transfers) {
|
|
91
|
+
if (leg.amount === 0) continue;
|
|
92
|
+
const recorded = existing.find(
|
|
93
|
+
(t) =>
|
|
94
|
+
t.destinationAccountId === leg.destinationAccountId &&
|
|
95
|
+
t.role === leg.role,
|
|
96
|
+
);
|
|
97
|
+
if (
|
|
98
|
+
recorded &&
|
|
99
|
+
(recorded.amount !== leg.amount || recorded.currency !== claim.currency)
|
|
100
|
+
)
|
|
101
|
+
throw new Error("Recorded transfer conflicts with immutable settlement.");
|
|
102
|
+
let transferId = recorded?.stripeTransferId;
|
|
103
|
+
if (!transferId) {
|
|
104
|
+
const recovered = matchingTransfer(provider, claim, leg);
|
|
105
|
+
const transfer =
|
|
106
|
+
recovered ??
|
|
107
|
+
(await stripe.transfers.create(
|
|
108
|
+
{
|
|
109
|
+
amount: leg.amount,
|
|
110
|
+
currency: claim.currency,
|
|
111
|
+
destination: leg.destinationAccountId,
|
|
112
|
+
source_transaction: claim.sourceChargeId,
|
|
113
|
+
transfer_group: claim.sourceChargeId,
|
|
114
|
+
metadata: {
|
|
115
|
+
bsRole: leg.role,
|
|
116
|
+
bsSourceCharge: claim.sourceChargeId,
|
|
117
|
+
},
|
|
118
|
+
},
|
|
119
|
+
{
|
|
120
|
+
idempotencyKey: `bs_split_${claim.sourceChargeId}_${leg.destinationAccountId}_${leg.role}`,
|
|
121
|
+
},
|
|
122
|
+
));
|
|
123
|
+
transferId = transfer.id;
|
|
124
|
+
await recordSettlementTransfer(component, ctx, claim, leg, transfer);
|
|
125
|
+
}
|
|
126
|
+
executedTransfers.push({
|
|
127
|
+
stripeTransferId: transferId,
|
|
128
|
+
destinationAccountId: leg.destinationAccountId,
|
|
129
|
+
role: leg.role,
|
|
130
|
+
amount: leg.amount,
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
return { ...claim, status: "settled", executedTransfers };
|
|
134
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import type Stripe from "stripe";
|
|
2
|
+
import type { SplitTransfer } from "../core/fees.js";
|
|
3
|
+
import type { Component, RunCtx } from "../helpers.js";
|
|
4
|
+
import { runMutationOrThrow } from "../helpers.js";
|
|
5
|
+
import { componentRef } from "../webhooks/helpers.js";
|
|
6
|
+
import type { ResolvedSettlement } from "./execution.js";
|
|
7
|
+
export async function sourceTransfers(
|
|
8
|
+
stripe: Stripe,
|
|
9
|
+
sourceChargeId: string,
|
|
10
|
+
legacy = false,
|
|
11
|
+
): Promise<Stripe.Transfer[]> {
|
|
12
|
+
const rows: Stripe.Transfer[] = [];
|
|
13
|
+
let cursor: string | undefined;
|
|
14
|
+
const charge = legacy ? await stripe.charges.retrieve(sourceChargeId) : null;
|
|
15
|
+
const created =
|
|
16
|
+
charge && charge.created <= Math.floor(Date.now() / 1000)
|
|
17
|
+
? { gte: charge.created }
|
|
18
|
+
: undefined;
|
|
19
|
+
for (let page = 0; page < 100; page++) {
|
|
20
|
+
const batch = await stripe.transfers.list({
|
|
21
|
+
...(legacy ? { created } : { transfer_group: sourceChargeId }),
|
|
22
|
+
limit: 100,
|
|
23
|
+
starting_after: cursor,
|
|
24
|
+
});
|
|
25
|
+
rows.push(...batch.data);
|
|
26
|
+
if (!batch.has_more) return rows;
|
|
27
|
+
const next = batch.data[batch.data.length - 1]?.id;
|
|
28
|
+
if (!next || next === cursor)
|
|
29
|
+
throw new Error("Settlement transfer reconciliation did not advance.");
|
|
30
|
+
cursor = next;
|
|
31
|
+
}
|
|
32
|
+
throw new Error(
|
|
33
|
+
"Settlement transfer reconciliation exceeded its page bound; review before retrying.",
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
export function matchingTransfer(
|
|
37
|
+
provider: Stripe.Transfer[],
|
|
38
|
+
claim: ResolvedSettlement,
|
|
39
|
+
leg: SplitTransfer,
|
|
40
|
+
) {
|
|
41
|
+
const matches = provider.filter(
|
|
42
|
+
(t) =>
|
|
43
|
+
(typeof t.destination === "string"
|
|
44
|
+
? t.destination
|
|
45
|
+
: t.destination?.id) === leg.destinationAccountId &&
|
|
46
|
+
(t.metadata.bsRole ?? t.metadata.role) === leg.role &&
|
|
47
|
+
(t.metadata.bsSourceCharge ?? t.metadata.storeSaleChargeId) ===
|
|
48
|
+
claim.sourceChargeId,
|
|
49
|
+
);
|
|
50
|
+
if (
|
|
51
|
+
matches.length > 1 ||
|
|
52
|
+
matches.some(
|
|
53
|
+
(t) =>
|
|
54
|
+
t.amount !== leg.amount ||
|
|
55
|
+
t.currency !== claim.currency ||
|
|
56
|
+
t.source_transaction !== claim.sourceChargeId,
|
|
57
|
+
)
|
|
58
|
+
)
|
|
59
|
+
throw new Error(
|
|
60
|
+
"Provider transfers conflict with claimed settlement; manual review required.",
|
|
61
|
+
);
|
|
62
|
+
return matches[0];
|
|
63
|
+
}
|
|
64
|
+
export async function recordSettlementTransfer(
|
|
65
|
+
component: Component,
|
|
66
|
+
ctx: RunCtx,
|
|
67
|
+
claim: ResolvedSettlement,
|
|
68
|
+
leg: SplitTransfer,
|
|
69
|
+
transfer: Stripe.Transfer,
|
|
70
|
+
) {
|
|
71
|
+
await runMutationOrThrow(
|
|
72
|
+
ctx,
|
|
73
|
+
componentRef(component, "connect/mutations/upsertTransfer"),
|
|
74
|
+
{
|
|
75
|
+
stripeTransferId: transfer.id,
|
|
76
|
+
sourceChargeId: claim.sourceChargeId,
|
|
77
|
+
sourceInvoiceId: claim.sourceInvoiceId,
|
|
78
|
+
destinationAccountId: leg.destinationAccountId,
|
|
79
|
+
amount: leg.amount,
|
|
80
|
+
currency: claim.currency,
|
|
81
|
+
role: leg.role,
|
|
82
|
+
status: "paid",
|
|
83
|
+
paymentId: claim.paymentId,
|
|
84
|
+
reversedAmount: transfer.amount_reversed ?? 0,
|
|
85
|
+
},
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
/** Recover provider-success/ledger-failure legs before sizing refunds or disputes. */
|
|
89
|
+
export async function recoverSettlementTransfers(
|
|
90
|
+
stripe: Stripe,
|
|
91
|
+
component: Component,
|
|
92
|
+
ctx: RunCtx,
|
|
93
|
+
sourceChargeId: string,
|
|
94
|
+
) {
|
|
95
|
+
const claim = (await ctx.runQuery(
|
|
96
|
+
componentRef(component, "settlement/queries/getSettlementAllocation"),
|
|
97
|
+
{ sourceChargeId },
|
|
98
|
+
)) as ResolvedSettlement | null;
|
|
99
|
+
if (!claim || typeof claim.sourceChargeId !== "string") return;
|
|
100
|
+
let provider = await sourceTransfers(stripe, sourceChargeId);
|
|
101
|
+
if (
|
|
102
|
+
claim.transfers.some(
|
|
103
|
+
(leg) => leg.amount > 0 && !matchingTransfer(provider, claim, leg),
|
|
104
|
+
)
|
|
105
|
+
)
|
|
106
|
+
provider = [
|
|
107
|
+
...new Map(
|
|
108
|
+
[
|
|
109
|
+
...provider,
|
|
110
|
+
...(await sourceTransfers(stripe, sourceChargeId, true)),
|
|
111
|
+
].map((t) => [t.id, t]),
|
|
112
|
+
).values(),
|
|
113
|
+
];
|
|
114
|
+
for (const leg of claim.transfers) {
|
|
115
|
+
const transfer = matchingTransfer(provider, claim, leg);
|
|
116
|
+
if (transfer)
|
|
117
|
+
await recordSettlementTransfer(component, ctx, claim, leg, transfer);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type Stripe from "stripe";
|
|
2
|
+
import type { SplitTransfer } from "../core/fees.js";
|
|
3
|
+
import type { Component, RunCtx } from "../helpers.js";
|
|
4
|
+
import { componentRef } from "../webhooks/helpers.js";
|
|
5
|
+
import { throwStripeError } from "../errors.js";
|
|
6
|
+
import { getVerifiedSale, type VerifiedSaleOptions } from "./verifiedSale.js";
|
|
7
|
+
import {
|
|
8
|
+
executeResolvedTransfers,
|
|
9
|
+
type SettlementResult,
|
|
10
|
+
} from "./execution.js";
|
|
11
|
+
export type SettleSaleOptions = VerifiedSaleOptions & {
|
|
12
|
+
allocation: {
|
|
13
|
+
gross: number;
|
|
14
|
+
currency: string;
|
|
15
|
+
processingFee: number;
|
|
16
|
+
platformNet: number;
|
|
17
|
+
adjustmentAmount?: number;
|
|
18
|
+
transfers: SplitTransfer[];
|
|
19
|
+
};
|
|
20
|
+
paymentId?: string;
|
|
21
|
+
onSettled?: (result: SettlementResult) => Promise<void>;
|
|
22
|
+
};
|
|
23
|
+
export async function settleSale(
|
|
24
|
+
stripe: Stripe,
|
|
25
|
+
component: Component,
|
|
26
|
+
ctx: RunCtx,
|
|
27
|
+
opts: SettleSaleOptions,
|
|
28
|
+
): Promise<
|
|
29
|
+
| SettlementResult
|
|
30
|
+
| Exclude<Awaited<ReturnType<typeof getVerifiedSale>>, { status: "ready" }>
|
|
31
|
+
> {
|
|
32
|
+
const sale = await getVerifiedSale(stripe, opts);
|
|
33
|
+
if (sale.status !== "ready") return sale;
|
|
34
|
+
const prior = (await ctx.runQuery(
|
|
35
|
+
componentRef(component, "settlement/queries/getSettlementAllocation"),
|
|
36
|
+
{
|
|
37
|
+
sourceChargeId: sale.chargeId,
|
|
38
|
+
},
|
|
39
|
+
)) as SettlementResult | null;
|
|
40
|
+
const allocation = prior ?? opts.allocation;
|
|
41
|
+
if (
|
|
42
|
+
allocation.gross !== sale.gross ||
|
|
43
|
+
allocation.currency !== sale.currency ||
|
|
44
|
+
allocation.processingFee !== sale.processingFee
|
|
45
|
+
)
|
|
46
|
+
throwStripeError(
|
|
47
|
+
"INVALID_CONFIGURATION",
|
|
48
|
+
"Allocation must use verified payment currency, gross and actual processing cost.",
|
|
49
|
+
);
|
|
50
|
+
const result = await executeResolvedTransfers(stripe, component, ctx, {
|
|
51
|
+
...opts.allocation,
|
|
52
|
+
sourceChargeId: sale.chargeId,
|
|
53
|
+
paymentIntentId: sale.paymentIntentId,
|
|
54
|
+
sourceInvoiceId: sale.invoiceId,
|
|
55
|
+
paymentId: opts.paymentId,
|
|
56
|
+
});
|
|
57
|
+
await opts.onSettled?.(result);
|
|
58
|
+
return result;
|
|
59
|
+
}
|