@fantastic.dev/better-stripe 0.9.0 → 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 +6 -0
- package/MIGRATION.md +12 -0
- package/README.md +29 -1
- 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 +80 -0
- package/dist/component/_generated/component.d.ts.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/react-workflows.md +5 -2
- package/docs/settlement.md +158 -0
- package/package.json +2 -1
- 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 +100 -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/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,158 @@
|
|
|
1
|
+
# Verified settlement and recipient payouts
|
|
2
|
+
|
|
3
|
+
BetterStripe owns provider reads, immutable settlement claims, transfer execution,
|
|
4
|
+
and recovery. Your server authenticates the caller, resolves recipient accounts,
|
|
5
|
+
selects rates, and authorizes the allocation. Never accept these values directly
|
|
6
|
+
from a browser. Amounts below are integer minor units in one currency.
|
|
7
|
+
|
|
8
|
+
## Read, allocate, settle
|
|
9
|
+
|
|
10
|
+
```ts
|
|
11
|
+
import { allocateSale } from "@fantastic.dev/better-stripe";
|
|
12
|
+
|
|
13
|
+
const sale = await stripe.getVerifiedSale(ctx, {
|
|
14
|
+
paymentIntentId,
|
|
15
|
+
invoiceId, // Required for a subscription payment
|
|
16
|
+
subscriptionId,
|
|
17
|
+
});
|
|
18
|
+
if (sale.status !== "ready") {
|
|
19
|
+
// Only retry when sale.retryable is true. Missing processing fees are not zero.
|
|
20
|
+
return sale;
|
|
21
|
+
}
|
|
22
|
+
const allocation = allocateSale({
|
|
23
|
+
gross: sale.gross,
|
|
24
|
+
processingFee: sale.processingFee,
|
|
25
|
+
feeBasisPoints: 900,
|
|
26
|
+
creatorAccountId,
|
|
27
|
+
affiliate: { destinationAccountId: affiliateAccountId, basisPoints: 2000 },
|
|
28
|
+
collaborators: [
|
|
29
|
+
{ destinationAccountId: collaboratorAccountId, basisPoints: 2500 },
|
|
30
|
+
],
|
|
31
|
+
});
|
|
32
|
+
return stripe.settleSale(ctx, {
|
|
33
|
+
paymentIntentId,
|
|
34
|
+
invoiceId,
|
|
35
|
+
subscriptionId,
|
|
36
|
+
allocation: {
|
|
37
|
+
gross: sale.gross,
|
|
38
|
+
currency: sale.currency,
|
|
39
|
+
processingFee: sale.processingFee,
|
|
40
|
+
platformNet: allocation.platformNet,
|
|
41
|
+
transfers: allocation.transfers,
|
|
42
|
+
},
|
|
43
|
+
});
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
`allocateSale` is pure. The inclusive sale fee is the greater of actual Stripe
|
|
47
|
+
processing and the selected gross percentage. The platform keeps the difference.
|
|
48
|
+
An affiliate receives a percentage of the amount after that fee, or a
|
|
49
|
+
`fixedAmount` in minor units, capped at the amount available. Collaborators
|
|
50
|
+
receive their percentages of the creator remainder. Rounding residue stays with
|
|
51
|
+
the creator. Omit the affiliate for zero commission; no application rate is built in.
|
|
52
|
+
|
|
53
|
+
`getVerifiedSale` expands the charge's actual balance transaction, verifies
|
|
54
|
+
currency and paid invoice linkage, and reads the subscription's current price.
|
|
55
|
+
It refuses refunded/disputed charges, converted settlement currencies, partial
|
|
56
|
+
invoice payments, and multi-price subscriptions rather than guessing allocations.
|
|
57
|
+
A `pending` result carries a retryable reason; `not_payable` must not trigger a transfer.
|
|
58
|
+
|
|
59
|
+
`settleSale` verifies the provider data again, then pins the original allocation
|
|
60
|
+
by charge. Retries use that original allocation even if rates have changed. The
|
|
61
|
+
shared transfer engine records legs in the existing component transfer ledger;
|
|
62
|
+
there is no required application `storeSales` table. Optional signed
|
|
63
|
+
`adjustmentAmount` accounts for an authorized application credit or recovered
|
|
64
|
+
cost; it must reconcile with all other amounts. An optional `onSettled` callback
|
|
65
|
+
runs after execution and can run again on retries: make application writes
|
|
66
|
+
idempotent by source charge.
|
|
67
|
+
|
|
68
|
+
Provider transfers carry the source charge and recipient role. If Stripe succeeds
|
|
69
|
+
and persistence fails, retries reconcile provider records before creating missing
|
|
70
|
+
legs, including legacy ungrouped transfers. Incomplete provider history fails
|
|
71
|
+
closed. Existing refund/dispute reversal APIs recover these original legs too.
|
|
72
|
+
Stripe API idempotency is an additional safeguard, not the only recovery record.
|
|
73
|
+
Do not delete financial component records while payments can be retried or reversed.
|
|
74
|
+
|
|
75
|
+
The Vite example exposes a [fee-first allocation preview](http://localhost:5173/demo/allocation)
|
|
76
|
+
from Admin → Testing Utilities. Its illustrative inputs use the public helper
|
|
77
|
+
and the package React `SplitBreakdown`; they do not alter store settings.
|
|
78
|
+
|
|
79
|
+
## Subscription commission terms
|
|
80
|
+
|
|
81
|
+
```ts
|
|
82
|
+
const commission = await stripe.selectCommissionInvoice(ctx, {
|
|
83
|
+
subscriptionId,
|
|
84
|
+
invoiceId,
|
|
85
|
+
affiliateAccountId, // Server-validated identity
|
|
86
|
+
term: "one_time", // Default: "forever"
|
|
87
|
+
});
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
This selects eligibility, not a rate or a money movement. `one_time` selects the
|
|
91
|
+
earliest paid nonzero invoice from complete provider history, even if a renewal
|
|
92
|
+
arrives first. Failed invoices and zero-value trials do not consume eligibility.
|
|
93
|
+
The component pins the term and affiliate identity. Refunds do not reset the
|
|
94
|
+
first invoice. A replay returns the same `claimKey` and eligibility so interrupted
|
|
95
|
+
settlement can finish; deduplicate actual payment execution with `settleSale`.
|
|
96
|
+
If historical pagination exceeds the configured bound, selection refuses to guess.
|
|
97
|
+
|
|
98
|
+
The Vite example migrates its old subscription claims with
|
|
99
|
+
`pnpm exec convex run commissionMigration:run '{}'` from `example/`. Repeat until
|
|
100
|
+
it returns `0`. Run this after deploying the new component and before replaying
|
|
101
|
+
historical subscription events. The migration preserves terms and first invoices;
|
|
102
|
+
its batch deletes are transactional with component writes.
|
|
103
|
+
|
|
104
|
+
## Payout snapshots
|
|
105
|
+
|
|
106
|
+
```ts
|
|
107
|
+
const snapshot = await stripe.getPayoutSnapshot(ctx, {
|
|
108
|
+
stripeAccountId,
|
|
109
|
+
cursor,
|
|
110
|
+
limit: 25,
|
|
111
|
+
});
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
The snapshot contains per-currency `balances`, cursor-paginated `payouts`,
|
|
115
|
+
deduplicated pending/in-transit `upcoming`, and `nextCursor`. Arrival dates are
|
|
116
|
+
provider estimates, nullable, in milliseconds. `upcomingComplete: false` means
|
|
117
|
+
only a bounded preview was retrieved. An unavailable account returns
|
|
118
|
+
`status: "not_ready"` with empty data and a reason; it does not imply a zero balance.
|
|
119
|
+
The React `PayoutSnapshot` contract accepts this shape directly.
|
|
120
|
+
|
|
121
|
+
A transfer moves money to a connected account; a payout moves that account's
|
|
122
|
+
available balance to its external bank. These boundaries have different controls.
|
|
123
|
+
|
|
124
|
+
## Combined payout minimums: supported boundary
|
|
125
|
+
|
|
126
|
+
A single user-level recipient account naturally combines creator and affiliate
|
|
127
|
+
funds across communities. Keep currency balances separate. Only available funds
|
|
128
|
+
are mature; Stripe's available balance already reflects provider debits and known
|
|
129
|
+
reversals. A platform source ledger is still necessary to explain which earnings
|
|
130
|
+
remain owed and distinguish unrelated funds in an already-mixed account.
|
|
131
|
+
|
|
132
|
+
The supported default is Stripe-managed automatic payouts with no additional
|
|
133
|
+
application minimum. Stripe enforces its applicable currency/method floor. The
|
|
134
|
+
Balance Settings API does not expose a numeric payout-floor field; this package
|
|
135
|
+
does not invent one. `minimum_balance_by_currency` is a retained reserve after a
|
|
136
|
+
payout, **not a minimum payout trigger**. [Stripe reserve documentation](https://docs.stripe.com/payouts/minimum-balances-for-automatic-payouts).
|
|
137
|
+
|
|
138
|
+
Custom application thresholds need a separately controlled release boundary:
|
|
139
|
+
|
|
140
|
+
- Before transfer: retain funds on the platform and maintain a durable payable
|
|
141
|
+
ledger, maturity, adjustments, and release claims. This changes settlement timing
|
|
142
|
+
and must account for existing transferred balances.
|
|
143
|
+
- Before bank payout: use a manual payout schedule where the platform has
|
|
144
|
+
permission, and run an automatic application job. Otherwise provider automatic
|
|
145
|
+
sweeps can bypass the application threshold. A retained reserve cannot fix that.
|
|
146
|
+
|
|
147
|
+
Manual holding limits depend on country: Thailand 10 days, US 2 years, other
|
|
148
|
+
countries generally 90 days. Schedule permissions depend on account controls and
|
|
149
|
+
liability. Negative balances, refunds, and in-flight payouts must reduce any
|
|
150
|
+
release amount; source-category thresholds and currency netting are unsafe.
|
|
151
|
+
See [payout schedule permissions](https://docs.stripe.com/connect/manage-payout-schedule)
|
|
152
|
+
and [manual payout restrictions](https://docs.stripe.com/connect/manual-payouts).
|
|
153
|
+
|
|
154
|
+
Custom threshold enforcement is not provided by the snapshot or settlement APIs.
|
|
155
|
+
It requires a selected retention boundary and migration policy. Applications must
|
|
156
|
+
not describe a displayed threshold as enforced while Stripe controls automatic
|
|
157
|
+
payouts. A numeric below/exact/above provider-floor demonstration is unavailable
|
|
158
|
+
without a supported provider floor read; surface this capability limitation.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fantastic.dev/better-stripe",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.0",
|
|
4
4
|
"description": "A Convex component for Stripe V2 Accounts API integration.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"stripe",
|
|
@@ -58,6 +58,7 @@
|
|
|
58
58
|
"dist",
|
|
59
59
|
"src",
|
|
60
60
|
"docs/react-workflows.md",
|
|
61
|
+
"docs/settlement.md",
|
|
61
62
|
"docs/react-workflows-audit.md",
|
|
62
63
|
"CHANGELOG.md",
|
|
63
64
|
"MIGRATION.md"
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { expect, it, vi } from "vitest";
|
|
2
|
+
import type Stripe from "stripe";
|
|
3
|
+
import { componentsGeneric } from "convex/server";
|
|
4
|
+
import type { Component, RunCtx } from "../helpers.js";
|
|
5
|
+
import { createCheckoutSession } from "./checkout.js";
|
|
6
|
+
function makeStripe() {
|
|
7
|
+
return {
|
|
8
|
+
prices: {
|
|
9
|
+
retrieve: vi.fn().mockRejectedValue({ code: "resource_missing" }),
|
|
10
|
+
},
|
|
11
|
+
checkout: { sessions: { create: vi.fn() } },
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
function makeCtx() {
|
|
15
|
+
return {
|
|
16
|
+
runQuery: vi.fn().mockResolvedValue(null),
|
|
17
|
+
runMutation: vi.fn().mockResolvedValue(undefined),
|
|
18
|
+
} as unknown as RunCtx & { runMutation: ReturnType<typeof vi.fn> };
|
|
19
|
+
}
|
|
20
|
+
const makeComponent = () =>
|
|
21
|
+
componentsGeneric().betterStripe as unknown as Component;
|
|
22
|
+
const asStripe = (value: ReturnType<typeof makeStripe>) =>
|
|
23
|
+
value as unknown as Stripe;
|
|
24
|
+
function sessionResponse(overrides: Record<string, unknown> = {}) {
|
|
25
|
+
return {
|
|
26
|
+
id: "cs_test",
|
|
27
|
+
status: "open",
|
|
28
|
+
client_secret: "secret",
|
|
29
|
+
url: null,
|
|
30
|
+
...overrides,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
it("forwards an order key as request options and recovers persistence without another session", async () => {
|
|
35
|
+
const stripe = makeStripe(),
|
|
36
|
+
ctx = makeCtx();
|
|
37
|
+
const cache = new Map<
|
|
38
|
+
string,
|
|
39
|
+
{ params: string; session: ReturnType<typeof sessionResponse> }
|
|
40
|
+
>();
|
|
41
|
+
stripe.checkout.sessions.create.mockImplementation(
|
|
42
|
+
async (params, options?: { idempotencyKey?: string }) => {
|
|
43
|
+
const key = options?.idempotencyKey ?? `new-${cache.size}`,
|
|
44
|
+
prior = cache.get(key),
|
|
45
|
+
serialized = JSON.stringify(params);
|
|
46
|
+
if (prior && prior.params !== serialized)
|
|
47
|
+
throw new Error("idempotency_error");
|
|
48
|
+
if (prior) return prior.session;
|
|
49
|
+
const session = sessionResponse({ id: `cs_${cache.size}` });
|
|
50
|
+
cache.set(key, { params: serialized, session });
|
|
51
|
+
return session;
|
|
52
|
+
},
|
|
53
|
+
);
|
|
54
|
+
const opts = {
|
|
55
|
+
userId: "buyer",
|
|
56
|
+
stripePriceId: "price_1",
|
|
57
|
+
mode: "payment" as const,
|
|
58
|
+
returnUrl: "https://app.test/receipt",
|
|
59
|
+
idempotencyKey: "order:one",
|
|
60
|
+
};
|
|
61
|
+
ctx.runMutation.mockRejectedValueOnce(new Error("persistence unavailable"));
|
|
62
|
+
await expect(
|
|
63
|
+
createCheckoutSession(asStripe(stripe), makeComponent(), ctx, opts),
|
|
64
|
+
).rejects.toThrow("persistence unavailable");
|
|
65
|
+
await createCheckoutSession(asStripe(stripe), makeComponent(), ctx, opts);
|
|
66
|
+
expect(cache.size).toBe(1);
|
|
67
|
+
expect(stripe.checkout.sessions.create).toHaveBeenLastCalledWith(
|
|
68
|
+
expect.anything(),
|
|
69
|
+
{ idempotencyKey: "order:one" },
|
|
70
|
+
);
|
|
71
|
+
await createCheckoutSession(asStripe(stripe), makeComponent(), ctx, {
|
|
72
|
+
...opts,
|
|
73
|
+
idempotencyKey: "order:two",
|
|
74
|
+
});
|
|
75
|
+
expect(cache.size).toBe(2);
|
|
76
|
+
await expect(
|
|
77
|
+
createCheckoutSession(asStripe(stripe), makeComponent(), ctx, {
|
|
78
|
+
...opts,
|
|
79
|
+
stripePriceId: "price_other",
|
|
80
|
+
}),
|
|
81
|
+
).rejects.toThrow("idempotency_error");
|
|
82
|
+
});
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { idempotencyOptions } from "../core/idempotency.js";
|
|
1
2
|
import type Stripe from "stripe";
|
|
2
3
|
|
|
3
4
|
import type { SplitRecipient } from "../../component/lib/fees.js";
|
|
@@ -110,6 +111,7 @@ export async function createCheckoutSession(
|
|
|
110
111
|
component: Component,
|
|
111
112
|
ctx: RunCtx,
|
|
112
113
|
opts: {
|
|
114
|
+
idempotencyKey?: string;
|
|
113
115
|
userId: string;
|
|
114
116
|
orgId?: string;
|
|
115
117
|
stripePriceId: string;
|
|
@@ -338,7 +340,10 @@ export async function createCheckoutSession(
|
|
|
338
340
|
await assertPlatformPrice(stripe, component, ctx, finalPriceId);
|
|
339
341
|
}
|
|
340
342
|
|
|
341
|
-
const
|
|
343
|
+
const requestOptions = idempotencyOptions(opts.idempotencyKey);
|
|
344
|
+
const session = requestOptions
|
|
345
|
+
? await stripe.checkout.sessions.create(finalSessionParams, requestOptions)
|
|
346
|
+
: await stripe.checkout.sessions.create(finalSessionParams);
|
|
342
347
|
|
|
343
348
|
await runMutationOrThrow(
|
|
344
349
|
ctx,
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import type Stripe from "stripe";
|
|
2
|
+
import { expect, it, vi } from "vitest";
|
|
3
|
+
import {
|
|
4
|
+
getCheckoutSessionFromStripe,
|
|
5
|
+
getSubscriptionFromStripe,
|
|
6
|
+
getCouponFromStripe,
|
|
7
|
+
} from "./providerReads.js";
|
|
8
|
+
it("retrieves authoritative expanded provider objects without consulting the component cache", async () => {
|
|
9
|
+
const raw = {
|
|
10
|
+
checkout: {
|
|
11
|
+
sessions: {
|
|
12
|
+
retrieve: vi
|
|
13
|
+
.fn()
|
|
14
|
+
.mockResolvedValue({ id: "cs_1", payment_status: "paid" }),
|
|
15
|
+
},
|
|
16
|
+
},
|
|
17
|
+
subscriptions: {
|
|
18
|
+
retrieve: vi.fn().mockResolvedValue({ id: "sub_1", status: "active" }),
|
|
19
|
+
},
|
|
20
|
+
coupons: {
|
|
21
|
+
retrieve: vi.fn().mockResolvedValue({ id: "coupon_1", valid: true }),
|
|
22
|
+
},
|
|
23
|
+
};
|
|
24
|
+
const stripe = raw as unknown as Stripe;
|
|
25
|
+
expect(
|
|
26
|
+
await getCheckoutSessionFromStripe(stripe, {
|
|
27
|
+
stripeSessionId: "cs_1",
|
|
28
|
+
expand: ["subscription", "payment_intent"],
|
|
29
|
+
}),
|
|
30
|
+
).toMatchObject({ payment_status: "paid" });
|
|
31
|
+
expect(raw.checkout.sessions.retrieve).toHaveBeenCalledWith("cs_1", {
|
|
32
|
+
expand: ["subscription", "payment_intent"],
|
|
33
|
+
});
|
|
34
|
+
await getSubscriptionFromStripe(stripe, {
|
|
35
|
+
stripeSubscriptionId: "sub_1",
|
|
36
|
+
expand: ["latest_invoice"],
|
|
37
|
+
});
|
|
38
|
+
expect(raw.subscriptions.retrieve).toHaveBeenCalledWith("sub_1", {
|
|
39
|
+
expand: ["latest_invoice"],
|
|
40
|
+
});
|
|
41
|
+
await getCouponFromStripe(stripe, { stripeCouponId: "coupon_1" });
|
|
42
|
+
expect(raw.coupons.retrieve).toHaveBeenCalledWith("coupon_1", {
|
|
43
|
+
expand: undefined,
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("preserves Stripe error identity and codes for server reconciliation", async () => {
|
|
48
|
+
const failure = Object.assign(new Error("No such Checkout Session"), {
|
|
49
|
+
code: "resource_missing",
|
|
50
|
+
statusCode: 404,
|
|
51
|
+
});
|
|
52
|
+
const raw = {
|
|
53
|
+
checkout: { sessions: { retrieve: vi.fn().mockRejectedValue(failure) } },
|
|
54
|
+
};
|
|
55
|
+
await expect(
|
|
56
|
+
getCheckoutSessionFromStripe(raw as unknown as Stripe, {
|
|
57
|
+
stripeSessionId: "cs_missing",
|
|
58
|
+
}),
|
|
59
|
+
).rejects.toBe(failure);
|
|
60
|
+
});
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type Stripe from "stripe";
|
|
2
|
+
export type CheckoutSessionReadOptions = {
|
|
3
|
+
stripeSessionId: string;
|
|
4
|
+
expand?: string[];
|
|
5
|
+
};
|
|
6
|
+
export type SubscriptionReadOptions = {
|
|
7
|
+
stripeSubscriptionId: string;
|
|
8
|
+
expand?: string[];
|
|
9
|
+
};
|
|
10
|
+
export type CouponReadOptions = { stripeCouponId: string; expand?: string[] };
|
|
11
|
+
/** Authoritative Stripe objects for authorized server fulfillment/reconciliation. */
|
|
12
|
+
export function getCheckoutSessionFromStripe(
|
|
13
|
+
stripe: Stripe,
|
|
14
|
+
opts: CheckoutSessionReadOptions,
|
|
15
|
+
) {
|
|
16
|
+
return stripe.checkout.sessions.retrieve(opts.stripeSessionId, {
|
|
17
|
+
expand: opts.expand,
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
export function getSubscriptionFromStripe(
|
|
21
|
+
stripe: Stripe,
|
|
22
|
+
opts: SubscriptionReadOptions,
|
|
23
|
+
) {
|
|
24
|
+
return stripe.subscriptions.retrieve(opts.stripeSubscriptionId, {
|
|
25
|
+
expand: opts.expand,
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
export function getCouponFromStripe(stripe: Stripe, opts: CouponReadOptions) {
|
|
29
|
+
return stripe.coupons.retrieve(opts.stripeCouponId, { expand: opts.expand });
|
|
30
|
+
}
|
|
@@ -511,14 +511,7 @@ export async function upsertSubscription(
|
|
|
511
511
|
accountId?: string;
|
|
512
512
|
userId: string;
|
|
513
513
|
orgId?: string;
|
|
514
|
-
status:
|
|
515
|
-
| "active"
|
|
516
|
-
| "trialing"
|
|
517
|
-
| "past_due"
|
|
518
|
-
| "canceled"
|
|
519
|
-
| "incomplete"
|
|
520
|
-
| "unpaid"
|
|
521
|
-
| "paused";
|
|
514
|
+
status: SubscriptionStatus;
|
|
522
515
|
priceId?: string;
|
|
523
516
|
quantity?: number;
|
|
524
517
|
currentPeriodStart?: string;
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import type Stripe from "stripe";
|
|
2
|
+
import { expect, it, vi } from "vitest";
|
|
3
|
+
import { getPayoutSnapshot } from "./payoutSnapshot.js";
|
|
4
|
+
const payout = (id: string, status = "paid") => ({
|
|
5
|
+
id,
|
|
6
|
+
status,
|
|
7
|
+
amount: 1000,
|
|
8
|
+
currency: "usd",
|
|
9
|
+
arrival_date: 0,
|
|
10
|
+
created: 1,
|
|
11
|
+
});
|
|
12
|
+
it("scopes every read and uses a real cursor for history while completing upcoming pages", async () => {
|
|
13
|
+
const list = vi.fn(
|
|
14
|
+
async (
|
|
15
|
+
args: { status?: string; starting_after?: string },
|
|
16
|
+
_options: { stripeAccount: string },
|
|
17
|
+
) => {
|
|
18
|
+
void _options;
|
|
19
|
+
if (args.status === "pending")
|
|
20
|
+
return args.starting_after
|
|
21
|
+
? { data: [payout("po_2", "pending")], has_more: false }
|
|
22
|
+
: { data: [payout("po_1", "pending")], has_more: true };
|
|
23
|
+
if (args.status === "in_transit")
|
|
24
|
+
return { data: [payout("po_1", "pending")], has_more: false };
|
|
25
|
+
return { data: [payout("po_history")], has_more: true };
|
|
26
|
+
},
|
|
27
|
+
);
|
|
28
|
+
const raw = {
|
|
29
|
+
balance: {
|
|
30
|
+
retrieve: vi.fn().mockResolvedValue({
|
|
31
|
+
available: [{ currency: "usd", amount: 10 }],
|
|
32
|
+
pending: [{ currency: "eur", amount: 20 }],
|
|
33
|
+
}),
|
|
34
|
+
},
|
|
35
|
+
payouts: { list },
|
|
36
|
+
};
|
|
37
|
+
const result = await getPayoutSnapshot(raw as unknown as Stripe, {
|
|
38
|
+
stripeAccountId: "acct_a",
|
|
39
|
+
cursor: "po_before",
|
|
40
|
+
});
|
|
41
|
+
expect(result).toMatchObject({
|
|
42
|
+
status: "ready",
|
|
43
|
+
balances: [
|
|
44
|
+
{ currency: "usd", available: 10, pending: 0 },
|
|
45
|
+
{ currency: "eur", available: 0, pending: 20 },
|
|
46
|
+
],
|
|
47
|
+
nextCursor: "po_history",
|
|
48
|
+
upcomingComplete: true,
|
|
49
|
+
});
|
|
50
|
+
expect(result.upcoming.map((r) => r.id)).toEqual(["po_1", "po_2"]);
|
|
51
|
+
expect(result.upcoming[0].arrivalDate).toBeNull();
|
|
52
|
+
expect(list).toHaveBeenCalledWith(
|
|
53
|
+
expect.objectContaining({ starting_after: "po_before" }),
|
|
54
|
+
{ stripeAccount: "acct_a" },
|
|
55
|
+
);
|
|
56
|
+
expect(
|
|
57
|
+
list.mock.calls.every(
|
|
58
|
+
(call) =>
|
|
59
|
+
(call[1] as { stripeAccount: string }).stripeAccount === "acct_a",
|
|
60
|
+
),
|
|
61
|
+
).toBe(true);
|
|
62
|
+
});
|
|
63
|
+
it("reports bounded upcoming previews and not-ready accounts explicitly", async () => {
|
|
64
|
+
const raw = {
|
|
65
|
+
balance: {
|
|
66
|
+
retrieve: vi.fn().mockResolvedValue({ available: [], pending: [] }),
|
|
67
|
+
},
|
|
68
|
+
payouts: {
|
|
69
|
+
list: vi.fn().mockResolvedValue({
|
|
70
|
+
data: [payout("po_1", "pending")],
|
|
71
|
+
has_more: true,
|
|
72
|
+
}),
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
expect(
|
|
76
|
+
await getPayoutSnapshot(raw as unknown as Stripe, {
|
|
77
|
+
stripeAccountId: "acct_a",
|
|
78
|
+
maxUpcomingPages: 1,
|
|
79
|
+
}),
|
|
80
|
+
).toMatchObject({ upcomingComplete: false });
|
|
81
|
+
raw.balance.retrieve.mockRejectedValue({ code: "account_invalid" });
|
|
82
|
+
expect(
|
|
83
|
+
await getPayoutSnapshot(raw as unknown as Stripe, {
|
|
84
|
+
stripeAccountId: "acct_a",
|
|
85
|
+
}),
|
|
86
|
+
).toMatchObject({
|
|
87
|
+
status: "not_ready",
|
|
88
|
+
balances: [],
|
|
89
|
+
reason: "account_unavailable",
|
|
90
|
+
});
|
|
91
|
+
});
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import type Stripe from "stripe";
|
|
2
|
+
import { throwStripeError } from "../errors.js";
|
|
3
|
+
import { getAccountBalance, type AccountBalance } from "./payouts.js";
|
|
4
|
+
export type RecipientPayoutRow = {
|
|
5
|
+
id: string;
|
|
6
|
+
amount: number;
|
|
7
|
+
currency: string;
|
|
8
|
+
status: string;
|
|
9
|
+
arrivalDate: number | null;
|
|
10
|
+
created: number;
|
|
11
|
+
};
|
|
12
|
+
export type RecipientPayoutSnapshot = {
|
|
13
|
+
balances: AccountBalance[];
|
|
14
|
+
payouts: RecipientPayoutRow[];
|
|
15
|
+
upcoming: RecipientPayoutRow[];
|
|
16
|
+
nextCursor: string | null;
|
|
17
|
+
status: "ready" | "not_ready";
|
|
18
|
+
reason?: string;
|
|
19
|
+
upcomingComplete: boolean;
|
|
20
|
+
};
|
|
21
|
+
export type PayoutSnapshotOptions = {
|
|
22
|
+
stripeAccountId: string;
|
|
23
|
+
cursor?: string;
|
|
24
|
+
limit?: number;
|
|
25
|
+
maxUpcomingPages?: number;
|
|
26
|
+
};
|
|
27
|
+
const row = (p: Stripe.Payout): RecipientPayoutRow => ({
|
|
28
|
+
id: p.id,
|
|
29
|
+
amount: p.amount,
|
|
30
|
+
currency: p.currency,
|
|
31
|
+
status: p.status,
|
|
32
|
+
arrivalDate: p.arrival_date ? p.arrival_date * 1000 : null,
|
|
33
|
+
created: p.created * 1000,
|
|
34
|
+
});
|
|
35
|
+
/** Account-scoped live balance and cursor history. Upcoming truncation is explicit. */
|
|
36
|
+
export async function getPayoutSnapshot(
|
|
37
|
+
stripe: Stripe,
|
|
38
|
+
opts: PayoutSnapshotOptions,
|
|
39
|
+
): Promise<RecipientPayoutSnapshot> {
|
|
40
|
+
const limit = opts.limit ?? 100,
|
|
41
|
+
maxPages = opts.maxUpcomingPages ?? 10;
|
|
42
|
+
if (
|
|
43
|
+
!opts.stripeAccountId ||
|
|
44
|
+
!Number.isInteger(limit) ||
|
|
45
|
+
limit < 1 ||
|
|
46
|
+
limit > 100 ||
|
|
47
|
+
!Number.isInteger(maxPages) ||
|
|
48
|
+
maxPages < 1 ||
|
|
49
|
+
maxPages > 100
|
|
50
|
+
)
|
|
51
|
+
throwStripeError(
|
|
52
|
+
"INVALID_CONFIGURATION",
|
|
53
|
+
"Invalid payout account or page bounds.",
|
|
54
|
+
);
|
|
55
|
+
const options = { stripeAccount: opts.stripeAccountId };
|
|
56
|
+
async function upcoming(status: "pending" | "in_transit") {
|
|
57
|
+
const rows: Stripe.Payout[] = [];
|
|
58
|
+
let cursor: string | undefined;
|
|
59
|
+
for (let page = 0; page < maxPages; page++) {
|
|
60
|
+
const batch = await stripe.payouts.list(
|
|
61
|
+
{ status, limit: 100, starting_after: cursor },
|
|
62
|
+
options,
|
|
63
|
+
);
|
|
64
|
+
rows.push(...batch.data);
|
|
65
|
+
if (!batch.has_more) return { rows, complete: true };
|
|
66
|
+
const next = batch.data[batch.data.length - 1]?.id;
|
|
67
|
+
if (!next || next === cursor)
|
|
68
|
+
throwStripeError(
|
|
69
|
+
"STRIPE_API_ERROR",
|
|
70
|
+
"Stripe payout pagination did not advance.",
|
|
71
|
+
);
|
|
72
|
+
cursor = next;
|
|
73
|
+
}
|
|
74
|
+
return { rows, complete: false };
|
|
75
|
+
}
|
|
76
|
+
try {
|
|
77
|
+
const [balances, history, pending, transit] = await Promise.all([
|
|
78
|
+
getAccountBalance(stripe, opts),
|
|
79
|
+
stripe.payouts.list({ limit, starting_after: opts.cursor }, options),
|
|
80
|
+
upcoming("pending"),
|
|
81
|
+
upcoming("in_transit"),
|
|
82
|
+
]);
|
|
83
|
+
const rows = [
|
|
84
|
+
...new Map(
|
|
85
|
+
[...pending.rows, ...transit.rows]
|
|
86
|
+
.filter((p) => p.status === "pending" || p.status === "in_transit")
|
|
87
|
+
.map((p) => [p.id, row(p)]),
|
|
88
|
+
).values(),
|
|
89
|
+
].sort(
|
|
90
|
+
(a, b) =>
|
|
91
|
+
(a.arrivalDate ?? Infinity) - (b.arrivalDate ?? Infinity) ||
|
|
92
|
+
a.created - b.created,
|
|
93
|
+
);
|
|
94
|
+
if (history.has_more && !history.data.length)
|
|
95
|
+
throwStripeError(
|
|
96
|
+
"STRIPE_API_ERROR",
|
|
97
|
+
"Stripe payout history did not advance.",
|
|
98
|
+
);
|
|
99
|
+
return {
|
|
100
|
+
status: "ready",
|
|
101
|
+
balances,
|
|
102
|
+
payouts: history.data.map(row),
|
|
103
|
+
upcoming: rows,
|
|
104
|
+
nextCursor: history.has_more
|
|
105
|
+
? history.data[history.data.length - 1]!.id
|
|
106
|
+
: null,
|
|
107
|
+
upcomingComplete: pending.complete && transit.complete,
|
|
108
|
+
};
|
|
109
|
+
} catch (error) {
|
|
110
|
+
if (
|
|
111
|
+
error &&
|
|
112
|
+
typeof error === "object" &&
|
|
113
|
+
"code" in error &&
|
|
114
|
+
["account_invalid", "resource_missing"].includes(String(error.code))
|
|
115
|
+
)
|
|
116
|
+
return {
|
|
117
|
+
status: "not_ready",
|
|
118
|
+
reason: "account_unavailable",
|
|
119
|
+
balances: [],
|
|
120
|
+
payouts: [],
|
|
121
|
+
upcoming: [],
|
|
122
|
+
nextCursor: null,
|
|
123
|
+
upcomingComplete: true,
|
|
124
|
+
};
|
|
125
|
+
throwStripeError(
|
|
126
|
+
"STRIPE_API_ERROR",
|
|
127
|
+
"Could not retrieve recipient payout snapshot.",
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
@@ -28,7 +28,11 @@ function makeComponent(): Component {
|
|
|
28
28
|
function plainCtx() {
|
|
29
29
|
return {
|
|
30
30
|
runQuery: vi.fn().mockResolvedValue(null),
|
|
31
|
-
runMutation: vi.fn(
|
|
31
|
+
runMutation: vi.fn(async (ref: Record<symbol, string>, args: unknown) =>
|
|
32
|
+
ref[TO_REF].endsWith("settlement/mutations/reserveSettlementAllocation")
|
|
33
|
+
? args
|
|
34
|
+
: undefined,
|
|
35
|
+
),
|
|
32
36
|
} as unknown as RunCtx & {
|
|
33
37
|
runQuery: ReturnType<typeof vi.fn>;
|
|
34
38
|
runMutation: ReturnType<typeof vi.fn>;
|
|
@@ -47,6 +51,8 @@ function makeCtx(transfers: unknown[]) {
|
|
|
47
51
|
const runMutation = vi.fn(
|
|
48
52
|
async (refObj: Record<symbol, string>, args: Record<string, unknown>) => {
|
|
49
53
|
const path = refObj[TO_REF].replace(/^betterStripe\//, "");
|
|
54
|
+
if (path === "settlement/mutations/reserveSettlementAllocation")
|
|
55
|
+
return args;
|
|
50
56
|
if (path !== "connect/mutations/claimReversalSlices") return undefined;
|
|
51
57
|
const legs = transfers as {
|
|
52
58
|
stripeTransferId: string;
|
|
@@ -88,7 +94,9 @@ function makeCtx(transfers: unknown[]) {
|
|
|
88
94
|
let trSeq = 0;
|
|
89
95
|
function makeStripe() {
|
|
90
96
|
return {
|
|
97
|
+
charges: { retrieve: vi.fn().mockResolvedValue({ created: 1 }) },
|
|
91
98
|
transfers: {
|
|
99
|
+
list: vi.fn().mockResolvedValue({ data: [], has_more: false }),
|
|
92
100
|
createReversal: vi.fn().mockResolvedValue({ id: "trr_1" }),
|
|
93
101
|
create: vi.fn(async (_params: unknown) => ({ id: `tr_new_${++trSeq}` })),
|
|
94
102
|
},
|
|
@@ -342,8 +350,8 @@ describe("createSplitTransfers engine (BTS-22)", () => {
|
|
|
342
350
|
destination: "acct_aff",
|
|
343
351
|
});
|
|
344
352
|
// ledger rows
|
|
345
|
-
expect(ctx.runMutation).toHaveBeenCalledTimes(
|
|
346
|
-
expect(ctx.runMutation.mock.calls[
|
|
353
|
+
expect(ctx.runMutation).toHaveBeenCalledTimes(3);
|
|
354
|
+
expect(ctx.runMutation.mock.calls[1][1]).toMatchObject({
|
|
347
355
|
sourceChargeId: "ch_1",
|
|
348
356
|
destinationAccountId: "acct_store",
|
|
349
357
|
amount: 8000,
|
|
@@ -372,7 +380,7 @@ describe("createSplitTransfers engine (BTS-22)", () => {
|
|
|
372
380
|
});
|
|
373
381
|
|
|
374
382
|
expect(stripe.transfers.create).toHaveBeenCalledTimes(3);
|
|
375
|
-
expect(ctx.runMutation).toHaveBeenCalledTimes(
|
|
383
|
+
expect(ctx.runMutation).toHaveBeenCalledTimes(4);
|
|
376
384
|
});
|
|
377
385
|
|
|
378
386
|
it("skips zero-amount transfers", async () => {
|
|
@@ -403,6 +411,7 @@ describe("createSplitTransfers idempotency (BTS-24)", () => {
|
|
|
403
411
|
destinationAccountId: "acct_store",
|
|
404
412
|
role: "store",
|
|
405
413
|
amount: 8000,
|
|
414
|
+
currency: "usd",
|
|
406
415
|
},
|
|
407
416
|
]);
|
|
408
417
|
|
|
@@ -431,12 +440,14 @@ describe("createSplitTransfers idempotency (BTS-24)", () => {
|
|
|
431
440
|
destinationAccountId: "acct_store",
|
|
432
441
|
role: "store",
|
|
433
442
|
amount: 8000,
|
|
443
|
+
currency: "usd",
|
|
434
444
|
},
|
|
435
445
|
{
|
|
436
446
|
stripeTransferId: "tr_aff",
|
|
437
447
|
destinationAccountId: "acct_aff",
|
|
438
448
|
role: "affiliate",
|
|
439
449
|
amount: 1000,
|
|
450
|
+
currency: "usd",
|
|
440
451
|
},
|
|
441
452
|
]);
|
|
442
453
|
|