@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,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
|
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { recoverSettlementTransfers } from "../settlement/reconciliation.js";
|
|
2
|
+
import { executeResolvedTransfers } from "../settlement/execution.js";
|
|
1
3
|
import type Stripe from "stripe";
|
|
2
4
|
|
|
3
5
|
import type { SplitRecipient } from "../../component/lib/fees.js";
|
|
@@ -303,6 +305,7 @@ export async function reverseTransfers(
|
|
|
303
305
|
throw new Error("reverseTransfers: amount must be a non-negative integer");
|
|
304
306
|
}
|
|
305
307
|
|
|
308
|
+
await recoverSettlementTransfers(stripe, component, ctx, opts.sourceChargeId);
|
|
306
309
|
if (opts.operationId) {
|
|
307
310
|
const claim = (await runMutationOrThrow(
|
|
308
311
|
ctx,
|
|
@@ -421,6 +424,7 @@ export async function reverseTransfersForRefund(
|
|
|
421
424
|
return { reversals: [] };
|
|
422
425
|
}
|
|
423
426
|
|
|
427
|
+
await recoverSettlementTransfers(stripe, component, ctx, opts.sourceChargeId);
|
|
424
428
|
const claim = (await runMutationOrThrow(
|
|
425
429
|
ctx,
|
|
426
430
|
componentRef(component, "connect/mutations/claimReversalSlices"),
|
|
@@ -460,48 +464,15 @@ export async function createSplitTransfers(
|
|
|
460
464
|
): Promise<SplitResult> {
|
|
461
465
|
const result = computeSplit(opts.amount, opts.feeConfig, opts.split);
|
|
462
466
|
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
);
|
|
473
|
-
|
|
474
|
-
for (const t of result.transfers) {
|
|
475
|
-
if (t.amount <= 0) continue;
|
|
476
|
-
if (done.has(`${t.destinationAccountId}:${t.role}`)) continue;
|
|
477
|
-
const transfer = await stripe.transfers.create(
|
|
478
|
-
{
|
|
479
|
-
amount: t.amount,
|
|
480
|
-
currency: opts.currency,
|
|
481
|
-
destination: t.destinationAccountId,
|
|
482
|
-
source_transaction: opts.sourceChargeId,
|
|
483
|
-
metadata: { bsRole: t.role, bsSourceCharge: opts.sourceChargeId },
|
|
484
|
-
},
|
|
485
|
-
// Deterministic key so a webhook retry can't double-transfer (BTS-24).
|
|
486
|
-
{
|
|
487
|
-
idempotencyKey: `bs_split_${opts.sourceChargeId}_${t.destinationAccountId}_${t.role}`,
|
|
488
|
-
},
|
|
489
|
-
);
|
|
490
|
-
await runMutationOrThrow(
|
|
491
|
-
ctx,
|
|
492
|
-
componentRef(component, "connect/mutations/upsertTransfer"),
|
|
493
|
-
{
|
|
494
|
-
stripeTransferId: transfer.id,
|
|
495
|
-
sourceChargeId: opts.sourceChargeId,
|
|
496
|
-
destinationAccountId: t.destinationAccountId,
|
|
497
|
-
amount: t.amount,
|
|
498
|
-
currency: opts.currency,
|
|
499
|
-
role: t.role,
|
|
500
|
-
status: "paid" as const,
|
|
501
|
-
paymentId: opts.paymentId,
|
|
502
|
-
},
|
|
503
|
-
);
|
|
504
|
-
}
|
|
467
|
+
await executeResolvedTransfers(stripe, component, ctx, {
|
|
468
|
+
sourceChargeId: opts.sourceChargeId,
|
|
469
|
+
gross: opts.amount,
|
|
470
|
+
currency: opts.currency,
|
|
471
|
+
processingFee: 0,
|
|
472
|
+
platformNet: result.platformRetained,
|
|
473
|
+
transfers: result.transfers,
|
|
474
|
+
paymentId: opts.paymentId,
|
|
475
|
+
});
|
|
505
476
|
|
|
506
477
|
return result;
|
|
507
478
|
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// @vitest-environment edge-runtime
|
|
2
|
+
import type Stripe from "stripe";
|
|
3
|
+
import { convexTest } from "convex-test";
|
|
4
|
+
import { componentsGeneric, defineSchema } from "convex/server";
|
|
5
|
+
import { expect, it, vi } from "vitest";
|
|
6
|
+
import componentSchema from "../../component/schema.js";
|
|
7
|
+
import type { Component, RunCtx } from "../helpers.js";
|
|
8
|
+
import { createAccount, getOrCreateAccount } from "./accounts.js";
|
|
9
|
+
const modules = import.meta.glob("../../component/**/*.*s");
|
|
10
|
+
it("reuses one provider account for concurrent calls and recovers a persistence failure", async () => {
|
|
11
|
+
const t = convexTest(defineSchema({}), modules);
|
|
12
|
+
t.registerComponent("betterStripe", componentSchema, modules);
|
|
13
|
+
const component = componentsGeneric().betterStripe as unknown as Component;
|
|
14
|
+
const provider = new Map<string, { params: string; id: string }>();
|
|
15
|
+
let created = 0;
|
|
16
|
+
const sdk = {
|
|
17
|
+
v2: {
|
|
18
|
+
core: {
|
|
19
|
+
accounts: {
|
|
20
|
+
create: vi.fn(
|
|
21
|
+
async (params: unknown, options?: { idempotencyKey?: string }) => {
|
|
22
|
+
const key = options?.idempotencyKey ?? `unkeyed-${++created}`,
|
|
23
|
+
existing = provider.get(key),
|
|
24
|
+
fingerprint = JSON.stringify(params);
|
|
25
|
+
if (existing && existing.params !== fingerprint)
|
|
26
|
+
throw new Error("idempotency_error");
|
|
27
|
+
if (existing) return { id: existing.id };
|
|
28
|
+
const value = { params: fingerprint, id: `acct_${++created}` };
|
|
29
|
+
provider.set(key, value);
|
|
30
|
+
return { id: value.id };
|
|
31
|
+
},
|
|
32
|
+
),
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
let fail = true;
|
|
38
|
+
const ctx: RunCtx = {
|
|
39
|
+
runQuery: t.query as RunCtx["runQuery"],
|
|
40
|
+
runMutation: (async (
|
|
41
|
+
ref: Parameters<typeof t.mutation>[0],
|
|
42
|
+
args: Record<string, unknown>,
|
|
43
|
+
) => {
|
|
44
|
+
if (fail) {
|
|
45
|
+
fail = false;
|
|
46
|
+
throw new Error("persistence unavailable");
|
|
47
|
+
}
|
|
48
|
+
return t.mutation(ref, args);
|
|
49
|
+
}) as RunCtx["runMutation"],
|
|
50
|
+
};
|
|
51
|
+
const opts = {
|
|
52
|
+
userId: "buyer",
|
|
53
|
+
email: "buyer@example.com",
|
|
54
|
+
idempotencyKey: "account:buyer:v1",
|
|
55
|
+
};
|
|
56
|
+
await expect(
|
|
57
|
+
createAccount(sdk as unknown as Stripe, component, ctx, opts),
|
|
58
|
+
).rejects.toThrow("persistence unavailable");
|
|
59
|
+
const [a, b] = await Promise.all([
|
|
60
|
+
getOrCreateAccount(sdk as unknown as Stripe, component, ctx, opts),
|
|
61
|
+
getOrCreateAccount(sdk as unknown as Stripe, component, ctx, opts),
|
|
62
|
+
]);
|
|
63
|
+
expect(a.stripeAccountId).toBe(b.stripeAccountId);
|
|
64
|
+
expect(provider.size).toBe(1);
|
|
65
|
+
expect(sdk.v2.core.accounts.create).toHaveBeenCalledWith(expect.anything(), {
|
|
66
|
+
idempotencyKey: opts.idempotencyKey,
|
|
67
|
+
});
|
|
68
|
+
await expect(
|
|
69
|
+
createAccount(sdk as unknown as Stripe, component, ctx, {
|
|
70
|
+
...opts,
|
|
71
|
+
email: "changed@example.com",
|
|
72
|
+
}),
|
|
73
|
+
).rejects.toThrow("idempotency_error");
|
|
74
|
+
const row = await t.query(component.core.queries.getAccountByUserId, {
|
|
75
|
+
userId: "buyer",
|
|
76
|
+
});
|
|
77
|
+
expect(row?.stripeAccountId).toBe(a.stripeAccountId);
|
|
78
|
+
});
|
|
@@ -1,3 +1,14 @@
|
|
|
1
|
+
import { idempotencyOptions } from "./idempotency.js";
|
|
2
|
+
export type CreateAccountOptions = {
|
|
3
|
+
userId: string;
|
|
4
|
+
email?: string;
|
|
5
|
+
name?: string;
|
|
6
|
+
country?: string;
|
|
7
|
+
orgId?: string;
|
|
8
|
+
metadata?: Record<string, string>;
|
|
9
|
+
/** Stable account-provisioning key; use a different key for a different account. */
|
|
10
|
+
idempotencyKey?: string;
|
|
11
|
+
};
|
|
1
12
|
import type Stripe from "stripe";
|
|
2
13
|
|
|
3
14
|
import type { Component, RunCtx } from "../helpers.js";
|
|
@@ -31,14 +42,7 @@ export async function createAccount(
|
|
|
31
42
|
stripe: Stripe,
|
|
32
43
|
component: Component,
|
|
33
44
|
ctx: RunCtx,
|
|
34
|
-
opts:
|
|
35
|
-
userId: string;
|
|
36
|
-
email?: string;
|
|
37
|
-
name?: string;
|
|
38
|
-
country?: string;
|
|
39
|
-
orgId?: string;
|
|
40
|
-
metadata?: Record<string, string>;
|
|
41
|
-
},
|
|
45
|
+
opts: CreateAccountOptions,
|
|
42
46
|
) {
|
|
43
47
|
const metadata = {
|
|
44
48
|
...(opts.metadata ?? {}),
|
|
@@ -56,7 +60,10 @@ export async function createAccount(
|
|
|
56
60
|
};
|
|
57
61
|
}
|
|
58
62
|
|
|
59
|
-
const
|
|
63
|
+
const requestOptions = idempotencyOptions(opts.idempotencyKey);
|
|
64
|
+
const account = requestOptions
|
|
65
|
+
? await stripe.v2.core.accounts.create(createParams, requestOptions)
|
|
66
|
+
: await stripe.v2.core.accounts.create(createParams);
|
|
60
67
|
|
|
61
68
|
await runMutationOrThrow(
|
|
62
69
|
ctx,
|
|
@@ -191,14 +198,7 @@ export async function getOrCreateAccount(
|
|
|
191
198
|
stripe: Stripe,
|
|
192
199
|
component: Component,
|
|
193
200
|
ctx: RunCtx,
|
|
194
|
-
opts:
|
|
195
|
-
userId: string;
|
|
196
|
-
email?: string;
|
|
197
|
-
name?: string;
|
|
198
|
-
country?: string;
|
|
199
|
-
orgId?: string;
|
|
200
|
-
metadata?: Record<string, string>;
|
|
201
|
-
},
|
|
201
|
+
opts: CreateAccountOptions,
|
|
202
202
|
) {
|
|
203
203
|
const existing = opts.orgId
|
|
204
204
|
? await ctx.runQuery(
|
|
@@ -238,14 +238,7 @@ export async function ensureCustomerAccount(
|
|
|
238
238
|
stripe: Stripe,
|
|
239
239
|
component: Component,
|
|
240
240
|
ctx: RunCtx,
|
|
241
|
-
opts:
|
|
242
|
-
userId: string;
|
|
243
|
-
email?: string;
|
|
244
|
-
name?: string;
|
|
245
|
-
country?: string;
|
|
246
|
-
orgId?: string;
|
|
247
|
-
metadata?: Record<string, string>;
|
|
248
|
-
},
|
|
241
|
+
opts: CreateAccountOptions,
|
|
249
242
|
): Promise<{
|
|
250
243
|
stripeAccountId: string;
|
|
251
244
|
accountId: string | null;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { expect, it } from "vitest";
|
|
2
|
+
import { idempotencyOptions } from "./idempotency.js";
|
|
3
|
+
it("preserves omitted options and validates explicit request identities", () => {
|
|
4
|
+
expect(idempotencyOptions(undefined)).toBeUndefined();
|
|
5
|
+
expect(idempotencyOptions("logical-order:123")).toEqual({
|
|
6
|
+
idempotencyKey: "logical-order:123",
|
|
7
|
+
});
|
|
8
|
+
for (const key of ["", " ", "bad\nkey", "x".repeat(256)])
|
|
9
|
+
expect(() => idempotencyOptions(key)).toThrow();
|
|
10
|
+
});
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { throwStripeError } from "../errors.js";
|
|
2
|
+
/** Explicit request identity. Omission preserves the SDK's existing behavior. */
|
|
3
|
+
export function idempotencyOptions(key: string | undefined) {
|
|
4
|
+
if (key === undefined) return undefined;
|
|
5
|
+
if (!key.trim() || key.length > 255 || /[\r\n]/.test(key))
|
|
6
|
+
throwStripeError(
|
|
7
|
+
"INVALID_CONFIGURATION",
|
|
8
|
+
"Idempotency keys must be nonempty, at most 255 characters, and contain no line breaks.",
|
|
9
|
+
);
|
|
10
|
+
return { idempotencyKey: key };
|
|
11
|
+
}
|