@fantastic.dev/better-stripe 0.9.0 → 0.10.1
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 +26 -0
- package/README.md +38 -1
- package/dist/client/apiVersion.d.ts +6 -0
- package/dist/client/apiVersion.d.ts.map +1 -0
- package/dist/client/apiVersion.js +13 -0
- package/dist/client/apiVersion.js.map +1 -0
- 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 +39 -37
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +47 -56
- package/dist/client/index.js.map +1 -1
- package/dist/client/previewRoutes.d.ts +9 -0
- package/dist/client/previewRoutes.d.ts.map +1 -0
- package/dist/client/previewRoutes.js +15 -0
- package/dist/client/previewRoutes.js.map +1 -0
- 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/types/options.d.ts +3 -0
- package/dist/client/types/options.d.ts.map +1 -1
- package/dist/client/utils/webhookEndpoints.d.ts +12 -8
- package/dist/client/utils/webhookEndpoints.d.ts.map +1 -1
- package/dist/client/utils/webhookEndpoints.js +41 -2
- package/dist/client/utils/webhookEndpoints.js.map +1 -1
- 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/client/webhooks/v2.d.ts.map +1 -1
- package/dist/client/webhooks/v2.js +10 -7
- package/dist/client/webhooks/v2.js.map +1 -1
- package/dist/component/_generated/component.d.ts +80 -0
- package/dist/component/_generated/component.d.ts.map +1 -1
- package/dist/component/lib/stripeFactory.d.ts +3 -2
- package/dist/component/lib/stripeFactory.d.ts.map +1 -1
- package/dist/component/lib/stripeFactory.js +6 -5
- package/dist/component/lib/stripeFactory.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/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/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/docs/accounts-v2-preview.md +132 -0
- package/docs/react-workflows.md +5 -2
- package/docs/settlement.md +158 -0
- package/package.json +3 -1
- package/src/client/apiVersion.ts +20 -0
- 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/customer-preview-thin.test.ts +41 -0
- package/src/client/customer-preview-webhooks.test.ts +150 -0
- package/src/client/customer-preview.test.ts +185 -0
- package/src/client/index.ts +115 -119
- package/src/client/previewRoutes.ts +26 -0
- 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/stripe-version.test.ts +10 -4
- package/src/client/types/options.ts +3 -0
- package/src/client/utils/webhookEndpoints.ts +70 -15
- package/src/client/webhooks/asyncHooks.ts +38 -0
- package/src/client/webhooks/processors.test.ts +9 -9
- package/src/client/webhooks/v2.ts +14 -10
- package/src/component/_generated/api.ts +4 -0
- package/src/component/_generated/component.ts +100 -0
- package/src/component/lib/stripeFactory.ts +13 -5
- 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/react/components/EmbeddedDisputes.render.test.tsx +2 -2
- package/src/react/index.payment-element.test.ts +22 -37
- package/src/react/workflows/Checkout.test.tsx +30 -0
- package/src/react/workflows/Checkout.tsx +10 -3
- package/src/react/workflows/Payouts.test.tsx +33 -0
- package/src/react/workflows/Payouts.tsx +21 -15
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { expect, it } from "vitest";
|
|
2
|
+
import { allocateSale } from "./allocation.js";
|
|
3
|
+
it("allocates processing and plan fees before affiliate and collaborators", () => {
|
|
4
|
+
const result = allocateSale({
|
|
5
|
+
gross: 10000,
|
|
6
|
+
processingFee: 320,
|
|
7
|
+
feeBasisPoints: 900,
|
|
8
|
+
creatorAccountId: "acct_creator",
|
|
9
|
+
affiliate: { destinationAccountId: "acct_affiliate", basisPoints: 2000 },
|
|
10
|
+
collaborators: [{ destinationAccountId: "acct_other", basisPoints: 2500 }],
|
|
11
|
+
});
|
|
12
|
+
expect(result).toMatchObject({
|
|
13
|
+
sellingFee: 900,
|
|
14
|
+
platformNet: 580,
|
|
15
|
+
affiliateNet: 1820,
|
|
16
|
+
creatorNet: 5460,
|
|
17
|
+
collaboratorNet: 1820,
|
|
18
|
+
});
|
|
19
|
+
expect(
|
|
20
|
+
result.transfers.reduce((n, t) => n + t.amount, 0) + result.sellingFee,
|
|
21
|
+
).toBe(result.gross);
|
|
22
|
+
});
|
|
23
|
+
it("caps fixed commissions and applies the processing fee floor", () => {
|
|
24
|
+
expect(
|
|
25
|
+
allocateSale({
|
|
26
|
+
gross: 10000,
|
|
27
|
+
processingFee: 700,
|
|
28
|
+
feeBasisPoints: 490,
|
|
29
|
+
creatorAccountId: "acct_creator",
|
|
30
|
+
affiliate: { destinationAccountId: "acct_a", fixedAmount: 99999 },
|
|
31
|
+
}),
|
|
32
|
+
).toMatchObject({ affiliateNet: 9300, creatorNet: 0, platformNet: 0 });
|
|
33
|
+
expect(
|
|
34
|
+
allocateSale({
|
|
35
|
+
gross: 12900,
|
|
36
|
+
processingFee: 404,
|
|
37
|
+
feeBasisPoints: 490,
|
|
38
|
+
creatorAccountId: "acct_c",
|
|
39
|
+
affiliate: { destinationAccountId: "acct_a", basisPoints: 2000 },
|
|
40
|
+
}),
|
|
41
|
+
).toMatchObject({ affiliateNet: 2453, creatorNet: 9815 });
|
|
42
|
+
});
|
|
43
|
+
it("retains rounding residue for the creator and rejects invalid or duplicate shares", () => {
|
|
44
|
+
const opts = {
|
|
45
|
+
gross: 101,
|
|
46
|
+
processingFee: 0,
|
|
47
|
+
creatorAccountId: "acct_c",
|
|
48
|
+
collaborators: [
|
|
49
|
+
{ destinationAccountId: "acct_a", basisPoints: 3333 },
|
|
50
|
+
{ destinationAccountId: "acct_b", basisPoints: 3333 },
|
|
51
|
+
],
|
|
52
|
+
};
|
|
53
|
+
expect(allocateSale(opts)).toMatchObject({
|
|
54
|
+
creatorNet: 35,
|
|
55
|
+
collaboratorNet: 66,
|
|
56
|
+
affiliateNet: 0,
|
|
57
|
+
});
|
|
58
|
+
expect(() =>
|
|
59
|
+
allocateSale({
|
|
60
|
+
...opts,
|
|
61
|
+
collaborators: [{ destinationAccountId: "acct_a", basisPoints: 10001 }],
|
|
62
|
+
}),
|
|
63
|
+
).toThrow();
|
|
64
|
+
expect(() =>
|
|
65
|
+
allocateSale({
|
|
66
|
+
...opts,
|
|
67
|
+
collaborators: [opts.collaborators[0], opts.collaborators[0]],
|
|
68
|
+
}),
|
|
69
|
+
).toThrow();
|
|
70
|
+
expect(() => allocateSale({ ...opts, gross: 1.5 })).toThrow();
|
|
71
|
+
expect(() =>
|
|
72
|
+
allocateSale({
|
|
73
|
+
...opts,
|
|
74
|
+
affiliate: {
|
|
75
|
+
destinationAccountId: "acct_z",
|
|
76
|
+
fixedAmount: 1,
|
|
77
|
+
basisPoints: 1,
|
|
78
|
+
},
|
|
79
|
+
}),
|
|
80
|
+
).toThrow();
|
|
81
|
+
});
|
|
82
|
+
it("supports zero-dollar previews and zero affiliate shares", () => {
|
|
83
|
+
expect(
|
|
84
|
+
allocateSale({
|
|
85
|
+
gross: 0,
|
|
86
|
+
processingFee: 0,
|
|
87
|
+
creatorAccountId: "acct_creator",
|
|
88
|
+
}),
|
|
89
|
+
).toMatchObject({ gross: 0, creatorNet: 0, affiliateNet: 0, platformNet: 0 });
|
|
90
|
+
expect(
|
|
91
|
+
allocateSale({
|
|
92
|
+
gross: 100,
|
|
93
|
+
processingFee: 3,
|
|
94
|
+
creatorAccountId: "acct_creator",
|
|
95
|
+
affiliate: { destinationAccountId: "acct_a", basisPoints: 0 },
|
|
96
|
+
}),
|
|
97
|
+
).toMatchObject({ creatorNet: 97, affiliateNet: 0 });
|
|
98
|
+
});
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import type { SplitTransfer } from "../core/fees.js";
|
|
2
|
+
export type SaleAllocationOptions = {
|
|
3
|
+
gross: number;
|
|
4
|
+
processingFee: number;
|
|
5
|
+
/** Inclusive sale fee, with actual Stripe processing as its floor. */
|
|
6
|
+
feeBasisPoints?: number | null;
|
|
7
|
+
creatorAccountId: string;
|
|
8
|
+
affiliate?: {
|
|
9
|
+
destinationAccountId: string;
|
|
10
|
+
basisPoints?: number;
|
|
11
|
+
fixedAmount?: number;
|
|
12
|
+
};
|
|
13
|
+
collaborators?: { destinationAccountId: string; basisPoints: number }[];
|
|
14
|
+
};
|
|
15
|
+
export type SaleAllocation = {
|
|
16
|
+
gross: number;
|
|
17
|
+
processingFee: number;
|
|
18
|
+
sellingFee: number;
|
|
19
|
+
platformNet: number;
|
|
20
|
+
affiliateNet: number;
|
|
21
|
+
creatorNet: number;
|
|
22
|
+
collaboratorNet: number;
|
|
23
|
+
transfers: SplitTransfer[];
|
|
24
|
+
};
|
|
25
|
+
const basisPoints = (n: number) => {
|
|
26
|
+
if (!Number.isSafeInteger(n) || n < 0 || n > 10000)
|
|
27
|
+
throw new Error("Share must be an integer from 0 to 10000 basis points.");
|
|
28
|
+
return n;
|
|
29
|
+
};
|
|
30
|
+
const units = (n: number) => {
|
|
31
|
+
if (!Number.isSafeInteger(n) || n < 0)
|
|
32
|
+
throw new Error(
|
|
33
|
+
"Amounts must be non-negative safe integer currency units.",
|
|
34
|
+
);
|
|
35
|
+
return n;
|
|
36
|
+
};
|
|
37
|
+
const share = (amount: number, rate: number) =>
|
|
38
|
+
Number((BigInt(amount) * BigInt(basisPoints(rate))) / 10000n);
|
|
39
|
+
/** Pure fee-first allocation. Fixed amounts are minor units of the sale currency. */
|
|
40
|
+
export function allocateSale(opts: SaleAllocationOptions): SaleAllocation {
|
|
41
|
+
const gross = units(opts.gross),
|
|
42
|
+
processingFee = units(opts.processingFee);
|
|
43
|
+
if (processingFee > gross || !opts.creatorAccountId)
|
|
44
|
+
throw new Error("Invalid gross amount, processing fee or creator account.");
|
|
45
|
+
const rate = basisPoints(opts.feeBasisPoints ?? 0);
|
|
46
|
+
const sellingFee = Math.max(
|
|
47
|
+
processingFee,
|
|
48
|
+
Number((BigInt(gross) * BigInt(rate) + 5000n) / 10000n),
|
|
49
|
+
);
|
|
50
|
+
const net = gross - sellingFee;
|
|
51
|
+
const affiliate = opts.affiliate;
|
|
52
|
+
if (
|
|
53
|
+
affiliate &&
|
|
54
|
+
(!affiliate.destinationAccountId ||
|
|
55
|
+
(affiliate.fixedAmount !== undefined &&
|
|
56
|
+
affiliate.basisPoints !== undefined))
|
|
57
|
+
)
|
|
58
|
+
throw new Error(
|
|
59
|
+
"Choose one affiliate amount or percentage and a destination.",
|
|
60
|
+
);
|
|
61
|
+
const affiliateNet = affiliate
|
|
62
|
+
? Math.min(
|
|
63
|
+
net,
|
|
64
|
+
affiliate.fixedAmount !== undefined
|
|
65
|
+
? units(affiliate.fixedAmount)
|
|
66
|
+
: share(net, affiliate.basisPoints ?? 0),
|
|
67
|
+
)
|
|
68
|
+
: 0;
|
|
69
|
+
const creatorRemainder = net - affiliateNet;
|
|
70
|
+
const collaborators = opts.collaborators ?? [];
|
|
71
|
+
if (
|
|
72
|
+
collaborators.reduce((sum, r) => sum + basisPoints(r.basisPoints), 0) >
|
|
73
|
+
10000
|
|
74
|
+
)
|
|
75
|
+
throw new Error("Collaborator shares exceed the creator remainder.");
|
|
76
|
+
const seen = new Set<string>();
|
|
77
|
+
const transfers: SplitTransfer[] = collaborators.map((r) => {
|
|
78
|
+
if (!r.destinationAccountId || seen.has(r.destinationAccountId))
|
|
79
|
+
throw new Error("Collaborator destinations must be nonempty and unique.");
|
|
80
|
+
seen.add(r.destinationAccountId);
|
|
81
|
+
return {
|
|
82
|
+
destinationAccountId: r.destinationAccountId,
|
|
83
|
+
role: "other",
|
|
84
|
+
amount: share(creatorRemainder, r.basisPoints),
|
|
85
|
+
};
|
|
86
|
+
});
|
|
87
|
+
const collaboratorNet = transfers.reduce((sum, r) => sum + r.amount, 0);
|
|
88
|
+
const creatorNet = creatorRemainder - collaboratorNet;
|
|
89
|
+
transfers.unshift({
|
|
90
|
+
destinationAccountId: opts.creatorAccountId,
|
|
91
|
+
role: "store",
|
|
92
|
+
amount: creatorNet,
|
|
93
|
+
});
|
|
94
|
+
if (affiliate)
|
|
95
|
+
transfers.push({
|
|
96
|
+
destinationAccountId: affiliate.destinationAccountId,
|
|
97
|
+
role: "affiliate",
|
|
98
|
+
amount: affiliateNet,
|
|
99
|
+
});
|
|
100
|
+
return {
|
|
101
|
+
gross,
|
|
102
|
+
processingFee,
|
|
103
|
+
sellingFee,
|
|
104
|
+
platformNet: sellingFee - processingFee,
|
|
105
|
+
affiliateNet,
|
|
106
|
+
creatorNet,
|
|
107
|
+
collaboratorNet,
|
|
108
|
+
transfers,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import type Stripe from "stripe";
|
|
2
|
+
import { expect, it, vi } from "vitest";
|
|
3
|
+
import type { Component, RunCtx } from "../helpers.js";
|
|
4
|
+
import { selectCommissionInvoice } from "./commissions.js";
|
|
5
|
+
const ref = (path: string) => ({
|
|
6
|
+
[Symbol.for("toReferencePath")]: `betterStripe/${path}`,
|
|
7
|
+
});
|
|
8
|
+
const component = {
|
|
9
|
+
settlement: {
|
|
10
|
+
commissions: {
|
|
11
|
+
get: ref("settlement/queries/getCommissionTerm"),
|
|
12
|
+
claim: ref("settlement/mutations/claimCommissionInvoice"),
|
|
13
|
+
},
|
|
14
|
+
},
|
|
15
|
+
} as unknown as Component;
|
|
16
|
+
it("finds the first paid nonzero invoice across provider pages and ignores trial invoices", async () => {
|
|
17
|
+
const list = vi
|
|
18
|
+
.fn()
|
|
19
|
+
.mockResolvedValueOnce({
|
|
20
|
+
data: [
|
|
21
|
+
{
|
|
22
|
+
id: "in_later",
|
|
23
|
+
amount_paid: 100,
|
|
24
|
+
status_transitions: { paid_at: 20 },
|
|
25
|
+
created: 20,
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
id: "in_trial",
|
|
29
|
+
amount_paid: 0,
|
|
30
|
+
status_transitions: { paid_at: 1 },
|
|
31
|
+
created: 1,
|
|
32
|
+
},
|
|
33
|
+
],
|
|
34
|
+
has_more: true,
|
|
35
|
+
})
|
|
36
|
+
.mockResolvedValueOnce({
|
|
37
|
+
data: [
|
|
38
|
+
{
|
|
39
|
+
id: "in_first",
|
|
40
|
+
amount_paid: 100,
|
|
41
|
+
status_transitions: { paid_at: 10 },
|
|
42
|
+
created: 2,
|
|
43
|
+
},
|
|
44
|
+
],
|
|
45
|
+
has_more: false,
|
|
46
|
+
});
|
|
47
|
+
const raw = {
|
|
48
|
+
invoices: {
|
|
49
|
+
list,
|
|
50
|
+
retrieve: vi.fn().mockResolvedValue({
|
|
51
|
+
id: "in_later",
|
|
52
|
+
status: "paid",
|
|
53
|
+
amount_paid: 100,
|
|
54
|
+
parent: { subscription_details: { subscription: "sub_1" } },
|
|
55
|
+
}),
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
const runMutation = vi.fn(async (_ref, args) => ({
|
|
59
|
+
...args,
|
|
60
|
+
eligible: false,
|
|
61
|
+
replay: false,
|
|
62
|
+
claimKey: "key",
|
|
63
|
+
}));
|
|
64
|
+
const ctx = {
|
|
65
|
+
runQuery: vi.fn().mockResolvedValue(null),
|
|
66
|
+
runMutation,
|
|
67
|
+
} as unknown as RunCtx;
|
|
68
|
+
await selectCommissionInvoice(raw as unknown as Stripe, component, ctx, {
|
|
69
|
+
subscriptionId: "sub_1",
|
|
70
|
+
invoiceId: "in_later",
|
|
71
|
+
affiliateAccountId: "acct_a",
|
|
72
|
+
term: "one_time",
|
|
73
|
+
});
|
|
74
|
+
expect(runMutation).toHaveBeenCalledWith(
|
|
75
|
+
expect.anything(),
|
|
76
|
+
expect.objectContaining({
|
|
77
|
+
firstPaidInvoiceId: "in_first",
|
|
78
|
+
term: "one_time",
|
|
79
|
+
}),
|
|
80
|
+
);
|
|
81
|
+
expect(list).toHaveBeenCalledTimes(2);
|
|
82
|
+
});
|
|
83
|
+
it("does not consume a failed or zero-dollar invoice", async () => {
|
|
84
|
+
const raw = {
|
|
85
|
+
invoices: {
|
|
86
|
+
retrieve: vi.fn().mockResolvedValue({
|
|
87
|
+
id: "in_trial",
|
|
88
|
+
status: "paid",
|
|
89
|
+
amount_paid: 0,
|
|
90
|
+
parent: { subscription_details: { subscription: "sub_1" } },
|
|
91
|
+
}),
|
|
92
|
+
},
|
|
93
|
+
};
|
|
94
|
+
const mutation = vi.fn(),
|
|
95
|
+
ctx = { runQuery: vi.fn(), runMutation: mutation } as unknown as RunCtx;
|
|
96
|
+
expect(
|
|
97
|
+
await selectCommissionInvoice(raw as unknown as Stripe, component, ctx, {
|
|
98
|
+
subscriptionId: "sub_1",
|
|
99
|
+
invoiceId: "in_trial",
|
|
100
|
+
}),
|
|
101
|
+
).toMatchObject({ status: "not_payable", eligible: false });
|
|
102
|
+
expect(mutation).not.toHaveBeenCalled();
|
|
103
|
+
});
|
|
@@ -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
|
+
});
|