@fantastic.dev/better-stripe 0.14.0 → 0.16.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 +6 -0
- package/README.md +40 -1
- package/dist/client/billing/checkoutOptions.d.ts +27 -0
- package/dist/client/billing/checkoutOptions.d.ts.map +1 -0
- package/dist/client/billing/checkoutOptions.js +2 -0
- package/dist/client/billing/checkoutOptions.js.map +1 -0
- package/dist/client/billing/intents.d.ts +32 -0
- package/dist/client/billing/intents.d.ts.map +1 -0
- package/dist/client/billing/intents.js +71 -0
- package/dist/client/billing/intents.js.map +1 -0
- package/dist/client/connect/payoutSnapshot.d.ts +2 -0
- package/dist/client/connect/payoutSnapshot.d.ts.map +1 -1
- package/dist/client/connect/payoutSnapshot.js +2 -0
- package/dist/client/connect/payoutSnapshot.js.map +1 -1
- package/dist/client/core/fees.d.ts +2 -0
- package/dist/client/core/fees.d.ts.map +1 -1
- package/dist/client/core/fees.js +7 -0
- package/dist/client/core/fees.js.map +1 -1
- package/dist/client/index.d.ts +18 -26
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +18 -16
- package/dist/client/index.js.map +1 -1
- package/dist/client/readContracts.d.ts +50 -0
- package/dist/client/readContracts.d.ts.map +1 -0
- package/dist/client/readContracts.js +24 -0
- package/dist/client/readContracts.js.map +1 -0
- package/dist/client/types/documents.d.ts +2 -0
- package/dist/client/types/documents.d.ts.map +1 -1
- package/dist/client/webhooks/processors.js +2 -0
- package/dist/client/webhooks/processors.js.map +1 -1
- package/dist/component/_generated/component.d.ts +177 -0
- package/dist/component/_generated/component.d.ts.map +1 -1
- package/dist/component/connect/mutations.d.ts +2 -0
- package/dist/component/connect/mutations.d.ts.map +1 -1
- package/dist/component/connect/mutations.js +8 -8
- package/dist/component/connect/mutations.js.map +1 -1
- package/dist/component/connect/queries.d.ts +102 -2
- package/dist/component/connect/queries.d.ts.map +1 -1
- package/dist/component/connect/queries.js +88 -45
- package/dist/component/connect/queries.js.map +1 -1
- package/dist/component/connect/schema.d.ts +12 -2
- package/dist/component/connect/schema.d.ts.map +1 -1
- package/dist/component/connect/schema.js +10 -1
- package/dist/component/connect/schema.js.map +1 -1
- package/dist/component/connect/validators.d.ts +13 -2
- package/dist/component/connect/validators.d.ts.map +1 -1
- package/dist/component/connect/validators.js +4 -0
- package/dist/component/connect/validators.js.map +1 -1
- package/dist/component/earnings/legacyReads.d.ts +51 -0
- package/dist/component/earnings/legacyReads.d.ts.map +1 -0
- package/dist/component/earnings/legacyReads.js +43 -0
- package/dist/component/earnings/legacyReads.js.map +1 -0
- package/dist/component/earnings/mutations.d.ts +9 -0
- package/dist/component/earnings/mutations.d.ts.map +1 -0
- package/dist/component/earnings/mutations.js +23 -0
- package/dist/component/earnings/mutations.js.map +1 -0
- package/dist/component/earnings/reads.d.ts +14 -0
- package/dist/component/earnings/reads.d.ts.map +1 -0
- package/dist/component/earnings/reads.js +28 -0
- package/dist/component/earnings/reads.js.map +1 -0
- package/dist/component/earnings/schema.d.ts +20 -0
- package/dist/component/earnings/schema.d.ts.map +1 -0
- package/dist/component/earnings/schema.js +12 -0
- package/dist/component/earnings/schema.js.map +1 -0
- package/dist/component/earnings/triggers.d.ts +1290 -0
- package/dist/component/earnings/triggers.d.ts.map +1 -0
- package/dist/component/earnings/triggers.js +68 -0
- package/dist/component/earnings/triggers.js.map +1 -0
- package/dist/component/products/queries.d.ts +47 -0
- package/dist/component/products/queries.d.ts.map +1 -1
- package/dist/component/products/queries.js +59 -0
- package/dist/component/products/queries.js.map +1 -1
- package/dist/component/schema.d.ts +31 -2
- package/dist/component/schema.js +2 -0
- package/dist/component/schema.js.map +1 -1
- package/dist/react/components/EarningsSummary.d.ts +3 -1
- package/dist/react/components/EarningsSummary.d.ts.map +1 -1
- package/dist/react/components/EarningsSummary.js +3 -3
- package/dist/react/components/EarningsSummary.js.map +1 -1
- package/dist/react/hooks/useConfirmPayment.d.ts +1 -1
- package/dist/react/hooks/useConfirmPayment.d.ts.map +1 -1
- package/dist/react/hooks/useConfirmPayment.js +13 -2
- package/dist/react/hooks/useConfirmPayment.js.map +1 -1
- package/dist/react/hooks/useCurrencyEarnings.d.ts +31 -0
- package/dist/react/hooks/useCurrencyEarnings.d.ts.map +1 -0
- package/dist/react/hooks/useCurrencyEarnings.js +30 -0
- package/dist/react/hooks/useCurrencyEarnings.js.map +1 -0
- package/dist/react/index.d.ts +5 -0
- package/dist/react/index.d.ts.map +1 -1
- package/dist/react/index.js +4 -0
- package/dist/react/index.js.map +1 -1
- package/dist/react/workflows/SaveCard.d.ts +27 -0
- package/dist/react/workflows/SaveCard.d.ts.map +1 -0
- package/dist/react/workflows/SaveCard.js +56 -0
- package/dist/react/workflows/SaveCard.js.map +1 -0
- package/docs/react-workflows.md +47 -0
- package/docs/read-contracts.md +136 -0
- package/package.json +3 -2
- package/src/client/billing/checkoutOptions.ts +27 -0
- package/src/client/billing/intents.test.ts +216 -0
- package/src/client/billing/intents.ts +120 -0
- package/src/client/billing/intentsTransport.test.ts +94 -0
- package/src/client/connect/payoutSnapshot.test.ts +27 -0
- package/src/client/connect/payoutSnapshot.ts +4 -0
- package/src/client/core/fees.ts +8 -0
- package/src/client/index.ts +40 -43
- package/src/client/readContracts.ts +90 -0
- package/src/client/readPagination.test.ts +62 -0
- package/src/client/types/documents.ts +2 -0
- package/src/client/webhooks/payout-canceled.integration.test.ts +25 -0
- package/src/client/webhooks/processors.ts +2 -0
- package/src/component/_generated/api.ts +8 -0
- package/src/component/_generated/component.ts +201 -0
- package/src/component/connect/mutations.ts +8 -8
- package/src/component/connect/queries.ts +110 -48
- package/src/component/connect/schema.ts +10 -1
- package/src/component/connect/validators.ts +4 -0
- package/src/component/currency-migration.test.ts +119 -0
- package/src/component/currency-totals.test.ts +95 -0
- package/src/component/earnings/legacyReads.ts +59 -0
- package/src/component/earnings/mutations.ts +21 -0
- package/src/component/earnings/reads.ts +41 -0
- package/src/component/earnings/schema.ts +11 -0
- package/src/component/earnings/triggers.ts +91 -0
- package/src/component/products/queries.ts +74 -0
- package/src/component/public.test.ts +1 -1
- package/src/component/read-pagination.test.ts +193 -0
- package/src/component/schema.ts +2 -0
- package/src/react/components/EarningsSummary.tsx +5 -2
- package/src/react/hooks/useConfirmPayment.test.tsx +47 -0
- package/src/react/hooks/useConfirmPayment.ts +16 -2
- package/src/react/hooks/useCurrencyEarnings.test.ts +59 -0
- package/src/react/hooks/useCurrencyEarnings.ts +73 -0
- package/src/react/index.ts +15 -0
- package/src/react/read-contracts.render.test.tsx +20 -0
- package/src/react/workflows/SaveCard.test.tsx +127 -0
- package/src/react/workflows/SaveCard.tsx +117 -0
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import type Stripe from "stripe";
|
|
2
|
+
import type { Component, RunCtx } from "../helpers.js";
|
|
3
|
+
import { stripReservedMetadata } from "../helpers.js";
|
|
4
|
+
import { idempotencyOptions } from "../core/idempotency.js";
|
|
5
|
+
import { componentRef } from "../webhooks/helpers.js";
|
|
6
|
+
|
|
7
|
+
/** Host must derive userId from authentication and authorize the purchase. */
|
|
8
|
+
export type IntentScope = {
|
|
9
|
+
userId: string;
|
|
10
|
+
customerAccount?: string;
|
|
11
|
+
/** Persist the logical request and its immutable inputs before calling. */
|
|
12
|
+
idempotencyKey: string;
|
|
13
|
+
/** Server-selected attribution only. Never forward arbitrary browser metadata. */
|
|
14
|
+
metadata?: Record<string, string>;
|
|
15
|
+
};
|
|
16
|
+
export type CreatePaymentIntentOptions = IntentScope & {
|
|
17
|
+
stripePriceId: string;
|
|
18
|
+
receiptEmail?: string;
|
|
19
|
+
/** Card-only by default; automatic preserves host-selected local payment methods. */
|
|
20
|
+
paymentMethods?: "card" | "automatic";
|
|
21
|
+
};
|
|
22
|
+
export type CreateSetupIntentOptions = IntentScope & {
|
|
23
|
+
customerAccount: string;
|
|
24
|
+
usage: "on_session" | "off_session";
|
|
25
|
+
/** Host records consent for the selected usage before provisioning. */
|
|
26
|
+
consent: true;
|
|
27
|
+
};
|
|
28
|
+
async function authorize(component: Component, ctx: RunCtx, opts: IntentScope) {
|
|
29
|
+
if (typeof opts.idempotencyKey !== "string")
|
|
30
|
+
throw new Error("An explicit idempotency key is required.");
|
|
31
|
+
const request = idempotencyOptions(opts.idempotencyKey)!;
|
|
32
|
+
if (opts.customerAccount === undefined) return request;
|
|
33
|
+
if (!opts.userId || !/^acct_[a-zA-Z0-9]+$/.test(opts.customerAccount))
|
|
34
|
+
throw new Error(
|
|
35
|
+
"An authenticated user and V2 customer account are required.",
|
|
36
|
+
);
|
|
37
|
+
const account = await ctx.runQuery(
|
|
38
|
+
componentRef(component, "core/queries/getAccountByStripeId"),
|
|
39
|
+
{ stripeAccountId: opts.customerAccount },
|
|
40
|
+
);
|
|
41
|
+
if (!account || account.userId !== opts.userId)
|
|
42
|
+
throw new Error(
|
|
43
|
+
"Customer account does not belong to the authenticated user.",
|
|
44
|
+
);
|
|
45
|
+
return request;
|
|
46
|
+
}
|
|
47
|
+
export async function createPaymentIntent(
|
|
48
|
+
stripe: Stripe,
|
|
49
|
+
component: Component,
|
|
50
|
+
ctx: RunCtx,
|
|
51
|
+
opts: CreatePaymentIntentOptions,
|
|
52
|
+
) {
|
|
53
|
+
const request = await authorize(component, ctx, opts);
|
|
54
|
+
const price = await stripe.prices.retrieve(opts.stripePriceId, {
|
|
55
|
+
expand: ["product"],
|
|
56
|
+
});
|
|
57
|
+
const product = price.product;
|
|
58
|
+
if (
|
|
59
|
+
!price.active ||
|
|
60
|
+
price.type !== "one_time" ||
|
|
61
|
+
!Number.isSafeInteger(price.unit_amount) ||
|
|
62
|
+
price.unit_amount! <= 0 ||
|
|
63
|
+
typeof product === "string" ||
|
|
64
|
+
product.deleted ||
|
|
65
|
+
!product.active
|
|
66
|
+
)
|
|
67
|
+
throw new Error(
|
|
68
|
+
"Payment Intents require an active product and a positive fixed one-time price.",
|
|
69
|
+
);
|
|
70
|
+
const intent = await stripe.paymentIntents.create(
|
|
71
|
+
{
|
|
72
|
+
amount: price.unit_amount!,
|
|
73
|
+
currency: price.currency,
|
|
74
|
+
customer_account: opts.customerAccount,
|
|
75
|
+
receipt_email: opts.receiptEmail,
|
|
76
|
+
...(opts.paymentMethods === "automatic"
|
|
77
|
+
? { automatic_payment_methods: { enabled: true } }
|
|
78
|
+
: { allowed_payment_method_types: ["card" as const] }),
|
|
79
|
+
metadata: {
|
|
80
|
+
...stripReservedMetadata(opts.metadata),
|
|
81
|
+
userId: opts.userId,
|
|
82
|
+
stripePriceId: price.id,
|
|
83
|
+
stripeProductId: product.id,
|
|
84
|
+
},
|
|
85
|
+
},
|
|
86
|
+
request,
|
|
87
|
+
);
|
|
88
|
+
if (!intent.client_secret)
|
|
89
|
+
throw new Error("Stripe did not return a payment secret.");
|
|
90
|
+
return { paymentIntentId: intent.id, clientSecret: intent.client_secret };
|
|
91
|
+
}
|
|
92
|
+
export async function createSetupIntent(
|
|
93
|
+
stripe: Stripe,
|
|
94
|
+
component: Component,
|
|
95
|
+
ctx: RunCtx,
|
|
96
|
+
opts: CreateSetupIntentOptions,
|
|
97
|
+
) {
|
|
98
|
+
if (!opts.customerAccount)
|
|
99
|
+
throw new Error("A customer account is required for setup.");
|
|
100
|
+
const request = await authorize(component, ctx, opts);
|
|
101
|
+
if (opts.consent !== true)
|
|
102
|
+
throw new Error("Explicit saved-card consent is required.");
|
|
103
|
+
if (opts.usage !== "on_session" && opts.usage !== "off_session")
|
|
104
|
+
throw new Error("Unsupported SetupIntent usage.");
|
|
105
|
+
const intent = await stripe.setupIntents.create(
|
|
106
|
+
{
|
|
107
|
+
customer_account: opts.customerAccount,
|
|
108
|
+
usage: opts.usage,
|
|
109
|
+
allowed_payment_method_types: ["card"],
|
|
110
|
+
metadata: {
|
|
111
|
+
...stripReservedMetadata(opts.metadata),
|
|
112
|
+
userId: opts.userId,
|
|
113
|
+
},
|
|
114
|
+
},
|
|
115
|
+
request,
|
|
116
|
+
);
|
|
117
|
+
if (!intent.client_secret)
|
|
118
|
+
throw new Error("Stripe did not return a setup secret.");
|
|
119
|
+
return { setupIntentId: intent.id, clientSecret: intent.client_secret };
|
|
120
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import type Stripe from "stripe";
|
|
2
|
+
import { componentsGeneric } from "convex/server";
|
|
3
|
+
import { expect, it, vi } from "vitest";
|
|
4
|
+
import { BetterStripe } from "../index.js";
|
|
5
|
+
import { getStripeClient, type Component, type RunCtx } from "../helpers.js";
|
|
6
|
+
it.each([undefined, "2026-09-30.preview"] as const)(
|
|
7
|
+
"sends customer-bound intents using configured Stripe version %s",
|
|
8
|
+
async (version) => {
|
|
9
|
+
const key = `sk_test_intent_transport_${crypto.randomUUID()}`;
|
|
10
|
+
const client = new BetterStripe(
|
|
11
|
+
componentsGeneric().betterStripe as unknown as Component,
|
|
12
|
+
{ STRIPE_SECRET_KEY: key, stripeApiVersion: version },
|
|
13
|
+
);
|
|
14
|
+
const sdk = getStripeClient(key, version);
|
|
15
|
+
const calls: {
|
|
16
|
+
path: string;
|
|
17
|
+
version: string;
|
|
18
|
+
key?: string;
|
|
19
|
+
body: string;
|
|
20
|
+
}[] = [];
|
|
21
|
+
(
|
|
22
|
+
sdk as unknown as { _setApiField: (name: string, value: unknown) => void }
|
|
23
|
+
)._setApiField("httpClient", {
|
|
24
|
+
getClientName: () => "intent-contract-test",
|
|
25
|
+
makeRequest: async (
|
|
26
|
+
...[, , path, , headers, body]: Parameters<
|
|
27
|
+
ReturnType<typeof Stripe.createNodeHttpClient>["makeRequest"]
|
|
28
|
+
>
|
|
29
|
+
) => {
|
|
30
|
+
calls.push({
|
|
31
|
+
path,
|
|
32
|
+
version: String(headers["Stripe-Version"]),
|
|
33
|
+
key: headers["Idempotency-Key"] as string | undefined,
|
|
34
|
+
body,
|
|
35
|
+
});
|
|
36
|
+
const data = path.startsWith("/v1/prices/")
|
|
37
|
+
? {
|
|
38
|
+
id: "price_one",
|
|
39
|
+
active: true,
|
|
40
|
+
type: "one_time",
|
|
41
|
+
unit_amount: 100,
|
|
42
|
+
currency: "usd",
|
|
43
|
+
product: { id: "prod_one", active: true },
|
|
44
|
+
}
|
|
45
|
+
: {
|
|
46
|
+
id: path.includes("setup_intents") ? "seti_one" : "pi_one",
|
|
47
|
+
client_secret: "secret",
|
|
48
|
+
};
|
|
49
|
+
return {
|
|
50
|
+
getStatusCode: () => 200,
|
|
51
|
+
getHeaders: () => ({}),
|
|
52
|
+
getRawResponse: () => ({}),
|
|
53
|
+
toJSON: async () => data,
|
|
54
|
+
toStream: () => {
|
|
55
|
+
throw new Error("Unexpected stream");
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
},
|
|
59
|
+
});
|
|
60
|
+
const ctx = {
|
|
61
|
+
runQuery: vi.fn().mockResolvedValue({ userId: "buyer" }),
|
|
62
|
+
} as unknown as RunCtx;
|
|
63
|
+
const scope = {
|
|
64
|
+
userId: "buyer",
|
|
65
|
+
customerAccount: "acct_buyer",
|
|
66
|
+
idempotencyKey: "save-one",
|
|
67
|
+
};
|
|
68
|
+
await client.createSetupIntent(ctx, {
|
|
69
|
+
...scope,
|
|
70
|
+
usage: "off_session",
|
|
71
|
+
consent: true,
|
|
72
|
+
});
|
|
73
|
+
await client.createPaymentIntent(ctx, {
|
|
74
|
+
...scope,
|
|
75
|
+
idempotencyKey: "pay-one",
|
|
76
|
+
stripePriceId: "price_one",
|
|
77
|
+
});
|
|
78
|
+
expect(calls.map((c) => c.version)).toEqual(
|
|
79
|
+
Array(3).fill(version ?? "2026-09-30.endive"),
|
|
80
|
+
);
|
|
81
|
+
const writes = calls.filter((c) => c.key);
|
|
82
|
+
expect(writes.map((c) => c.key)).toEqual(["save-one", "pay-one"]);
|
|
83
|
+
for (const call of writes) {
|
|
84
|
+
const params = new URLSearchParams(call.body);
|
|
85
|
+
expect(params.get("customer_account")).toBe("acct_buyer");
|
|
86
|
+
expect(params.get("allowed_payment_method_types[0]")).toBe("card");
|
|
87
|
+
expect(params.has("customer")).toBe(false);
|
|
88
|
+
}
|
|
89
|
+
expect(new URLSearchParams(writes[0].body).get("usage")).toBe(
|
|
90
|
+
"off_session",
|
|
91
|
+
);
|
|
92
|
+
expect(new URLSearchParams(writes[1].body).get("amount")).toBe("100");
|
|
93
|
+
},
|
|
94
|
+
);
|
|
@@ -89,3 +89,30 @@ it("reports bounded upcoming previews and not-ready accounts explicitly", async
|
|
|
89
89
|
reason: "account_unavailable",
|
|
90
90
|
});
|
|
91
91
|
});
|
|
92
|
+
|
|
93
|
+
it("exposes optional provider failure details in history", async () => {
|
|
94
|
+
const stripe = {
|
|
95
|
+
balance: { retrieve: async () => ({ available: [], pending: [] }) },
|
|
96
|
+
payouts: {
|
|
97
|
+
list: async (args: { status?: string }) => ({
|
|
98
|
+
data: args.status
|
|
99
|
+
? []
|
|
100
|
+
: [
|
|
101
|
+
{
|
|
102
|
+
...payout("po_failed", "failed"),
|
|
103
|
+
failure_code: "account_closed",
|
|
104
|
+
failure_message: "Bank account closed",
|
|
105
|
+
},
|
|
106
|
+
],
|
|
107
|
+
has_more: false,
|
|
108
|
+
}),
|
|
109
|
+
},
|
|
110
|
+
};
|
|
111
|
+
const result = await getPayoutSnapshot(stripe as unknown as Stripe, {
|
|
112
|
+
stripeAccountId: "acct_a",
|
|
113
|
+
});
|
|
114
|
+
expect(result.payouts[0]).toMatchObject({
|
|
115
|
+
failureCode: "account_closed",
|
|
116
|
+
failureMessage: "Bank account closed",
|
|
117
|
+
});
|
|
118
|
+
});
|
|
@@ -8,6 +8,8 @@ export type RecipientPayoutRow = {
|
|
|
8
8
|
status: string;
|
|
9
9
|
arrivalDate: number | null;
|
|
10
10
|
created: number;
|
|
11
|
+
failureCode?: string;
|
|
12
|
+
failureMessage?: string;
|
|
11
13
|
};
|
|
12
14
|
export type RecipientPayoutSnapshot = {
|
|
13
15
|
balances: AccountBalance[];
|
|
@@ -31,6 +33,8 @@ const row = (p: Stripe.Payout): RecipientPayoutRow => ({
|
|
|
31
33
|
status: p.status,
|
|
32
34
|
arrivalDate: p.arrival_date ? p.arrival_date * 1000 : null,
|
|
33
35
|
created: p.created * 1000,
|
|
36
|
+
...(p.failure_code != null ? { failureCode: p.failure_code } : {}),
|
|
37
|
+
...(p.failure_message != null ? { failureMessage: p.failure_message } : {}),
|
|
34
38
|
});
|
|
35
39
|
/** Account-scoped live balance and cursor history. Upcoming truncation is explicit. */
|
|
36
40
|
export async function getPayoutSnapshot(
|
package/src/client/core/fees.ts
CHANGED
|
@@ -273,3 +273,11 @@ export function validateSplit(recipients: SplitRecipient[]): void {
|
|
|
273
273
|
throw new Error(`split percents sum to ${percentSum}, which exceeds 100`);
|
|
274
274
|
}
|
|
275
275
|
}
|
|
276
|
+
|
|
277
|
+
/** Deep-copy a platform fee config so stored/returned values can't be mutated. */
|
|
278
|
+
export function clonePlatformFee(config: PlatformFeeConfig): PlatformFeeConfig {
|
|
279
|
+
return {
|
|
280
|
+
...config,
|
|
281
|
+
tiers: config.tiers?.map((tier) => ({ ...tier })),
|
|
282
|
+
};
|
|
283
|
+
}
|
package/src/client/index.ts
CHANGED
|
@@ -1,3 +1,8 @@
|
|
|
1
|
+
import type { CreateCheckoutSessionOptions } from "./billing/checkoutOptions.js";
|
|
2
|
+
export type { CreateCheckoutSessionOptions } from "./billing/checkoutOptions.js";
|
|
3
|
+
import * as intentsImpl from "./billing/intents.js";
|
|
4
|
+
export type * from "./billing/intents.js";
|
|
5
|
+
import { ReadContractClient } from "./readContracts.js";
|
|
1
6
|
import { CostRecoveryClient } from "./costRecovery.js";
|
|
2
7
|
export * from "./charge-currency.js";
|
|
3
8
|
export * from "./costRecovery.js";
|
|
@@ -88,7 +93,7 @@ import * as transfersImpl from "./connect/transfers.js";
|
|
|
88
93
|
import * as accountLinksImpl from "./core/accountLinks.js";
|
|
89
94
|
import * as accountsImpl from "./core/accounts.js";
|
|
90
95
|
import * as configImpl from "./core/config.js";
|
|
91
|
-
import
|
|
96
|
+
import * as fees from "./core/fees.js";
|
|
92
97
|
import { validateStatementDescriptorSuffix } from "./core/descriptors.js";
|
|
93
98
|
export { computeFee, computeSplit } from "./core/fees.js";
|
|
94
99
|
export type { FeeBreakdown, SplitResult, SplitTransfer } from "./core/fees.js";
|
|
@@ -103,7 +108,7 @@ export type { RefundActor } from "./connect/refunds.js";
|
|
|
103
108
|
import type { RefundActor } from "./connect/refunds.js";
|
|
104
109
|
export type { AccountBalance } from "./connect/payouts.js";
|
|
105
110
|
export type { WedgedReversalClaim } from "./connect/transfers.js";
|
|
106
|
-
import type {
|
|
111
|
+
import type { RunCtx } from "./helpers.js";
|
|
107
112
|
import { getStripeClient } from "./helpers.js";
|
|
108
113
|
import type {
|
|
109
114
|
V2AccountRetrieveInclude,
|
|
@@ -204,18 +209,9 @@ export type {
|
|
|
204
209
|
|
|
205
210
|
export type BetterStripeComponent = ComponentApi;
|
|
206
211
|
|
|
207
|
-
/** Deep-copy a platform fee config so stored/returned values can't be mutated. */
|
|
208
|
-
function clonePlatformFee(config: PlatformFeeConfig): PlatformFeeConfig {
|
|
209
|
-
return {
|
|
210
|
-
...config,
|
|
211
|
-
tiers: config.tiers?.map((tier) => ({ ...tier })),
|
|
212
|
-
};
|
|
213
|
-
}
|
|
214
|
-
|
|
215
212
|
// BETTER STRIPE CLIENT
|
|
216
213
|
|
|
217
|
-
export class BetterStripe {
|
|
218
|
-
public component: Component;
|
|
214
|
+
export class BetterStripe extends ReadContractClient {
|
|
219
215
|
readonly stripeApiVersion: SupportedStripeApiVersion;
|
|
220
216
|
private _apiKey?: string;
|
|
221
217
|
private _triggers?: SyncTriggers;
|
|
@@ -224,16 +220,16 @@ export class BetterStripe {
|
|
|
224
220
|
private _statementDescriptorSuffix?: string;
|
|
225
221
|
|
|
226
222
|
constructor(component: BetterStripeComponent, options?: BetterStripeOptions) {
|
|
227
|
-
|
|
223
|
+
super(component);
|
|
228
224
|
this.stripeApiVersion = resolveStripeApiVersion(options?.stripeApiVersion);
|
|
229
225
|
this._apiKey = options?.STRIPE_SECRET_KEY;
|
|
230
226
|
this._triggers = options?.triggers;
|
|
231
227
|
this._hooks = options?.hooks;
|
|
232
228
|
if (options?.platformFee !== undefined) {
|
|
233
|
-
validatePlatformFee(options.platformFee);
|
|
229
|
+
fees.validatePlatformFee(options.platformFee);
|
|
234
230
|
// Store a defensive copy so later mutation of the caller's object can't
|
|
235
231
|
// silently bypass validation or change runtime behavior.
|
|
236
|
-
this._platformFee = clonePlatformFee(options.platformFee);
|
|
232
|
+
this._platformFee = fees.clonePlatformFee(options.platformFee);
|
|
237
233
|
}
|
|
238
234
|
if (options?.statementDescriptorSuffix !== undefined) {
|
|
239
235
|
this._statementDescriptorSuffix = validateStatementDescriptorSuffix(
|
|
@@ -252,7 +248,7 @@ export class BetterStripe {
|
|
|
252
248
|
|
|
253
249
|
/** The configured default platform fee (the platform's take), if any. */
|
|
254
250
|
get platformFee(): PlatformFeeConfig | undefined {
|
|
255
|
-
return this._platformFee && clonePlatformFee(this._platformFee);
|
|
251
|
+
return this._platformFee && fees.clonePlatformFee(this._platformFee);
|
|
256
252
|
}
|
|
257
253
|
|
|
258
254
|
/**
|
|
@@ -260,8 +256,8 @@ export class BetterStripe {
|
|
|
260
256
|
* default. The fee math itself is applied at charge time (M2).
|
|
261
257
|
*/
|
|
262
258
|
resolveFee(override?: FeeOverride): PlatformFeeConfig | undefined {
|
|
263
|
-
const resolved = resolveFeeConfig(this._platformFee, override);
|
|
264
|
-
return resolved && clonePlatformFee(resolved);
|
|
259
|
+
const resolved = fees.resolveFeeConfig(this._platformFee, override);
|
|
260
|
+
return resolved && fees.clonePlatformFee(resolved);
|
|
265
261
|
}
|
|
266
262
|
|
|
267
263
|
get apiKey(): string {
|
|
@@ -657,34 +653,35 @@ export class BetterStripe {
|
|
|
657
653
|
return pricesImpl.listStripePrices(this.stripe(), ctx, opts);
|
|
658
654
|
}
|
|
659
655
|
|
|
660
|
-
|
|
656
|
+
/** Card-only one-time checkout. Host authorizes price and authenticated customer. */
|
|
657
|
+
async createPaymentIntent(
|
|
658
|
+
ctx: RunCtx,
|
|
659
|
+
opts: intentsImpl.CreatePaymentIntentOptions,
|
|
660
|
+
) {
|
|
661
|
+
return intentsImpl.createPaymentIntent(
|
|
662
|
+
this.stripe(),
|
|
663
|
+
this.component,
|
|
664
|
+
ctx,
|
|
665
|
+
opts,
|
|
666
|
+
);
|
|
667
|
+
}
|
|
661
668
|
|
|
662
|
-
|
|
669
|
+
/** Provision consented card setup; confirmation and authentication happen in Stripe.js. */
|
|
670
|
+
async createSetupIntent(
|
|
663
671
|
ctx: RunCtx,
|
|
664
|
-
opts:
|
|
665
|
-
idempotencyKey?: string;
|
|
666
|
-
userId: string;
|
|
667
|
-
orgId?: string;
|
|
668
|
-
stripePriceId: string;
|
|
669
|
-
mode: "payment" | "subscription" | "setup";
|
|
670
|
-
uiMode?: "embedded" | "redirect" | "custom" | "form";
|
|
671
|
-
quantity?: number;
|
|
672
|
-
returnUrl: string;
|
|
673
|
-
trialDays?: number;
|
|
674
|
-
accountId?: string;
|
|
675
|
-
customerEmail?: string;
|
|
676
|
-
/** Seller/recipient connected account to route funds to (destination charge). */
|
|
677
|
-
destinationAccountId?: string;
|
|
678
|
-
/** Multiple recipients (store + affiliate[s]); >1 routes via separate charges & transfers. */
|
|
679
|
-
split?: SplitRecipient[];
|
|
680
|
-
/** Per-call platform fee override; falls back to the configured default. */
|
|
681
|
-
fee?: FeeOverride;
|
|
682
|
-
/** Charge total (minor units) for one-time payment fee computation. */
|
|
683
|
-
amount?: number;
|
|
684
|
-
metadata?: Record<string, string>;
|
|
685
|
-
sessionOverrides?: Record<string, unknown>;
|
|
686
|
-
},
|
|
672
|
+
opts: intentsImpl.CreateSetupIntentOptions,
|
|
687
673
|
) {
|
|
674
|
+
return intentsImpl.createSetupIntent(
|
|
675
|
+
this.stripe(),
|
|
676
|
+
this.component,
|
|
677
|
+
ctx,
|
|
678
|
+
opts,
|
|
679
|
+
);
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
// CHECKOUT METHODS
|
|
683
|
+
|
|
684
|
+
async createCheckoutSession(ctx: RunCtx, opts: CreateCheckoutSessionOptions) {
|
|
688
685
|
const { fee, ...rest } = opts;
|
|
689
686
|
return checkoutImpl.createCheckoutSession(
|
|
690
687
|
this.stripe(),
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import type { ComponentApi } from "../component/_generated/component.js";
|
|
2
|
+
import type { PaginationOptions, PaginationResult } from "convex/server";
|
|
3
|
+
import type { Component, RunCtx } from "./helpers.js";
|
|
4
|
+
import type { PayoutStatus } from "../component/connect/validators.js";
|
|
5
|
+
import type {
|
|
6
|
+
StripeComponentPayout,
|
|
7
|
+
StripeComponentProduct,
|
|
8
|
+
StripeComponentPrice,
|
|
9
|
+
} from "./types/documents.js";
|
|
10
|
+
import { componentRef } from "./webhooks/helpers.js";
|
|
11
|
+
/** Shared component readers inherited by BetterStripe. */
|
|
12
|
+
export class ReadContractClient {
|
|
13
|
+
constructor(public component: Component) {}
|
|
14
|
+
async paginateProducts(
|
|
15
|
+
ctx: RunCtx,
|
|
16
|
+
opts: {
|
|
17
|
+
accountId?: string;
|
|
18
|
+
active?: boolean;
|
|
19
|
+
paginationOpts: PaginationOptions;
|
|
20
|
+
},
|
|
21
|
+
): Promise<PaginationResult<StripeComponentProduct>> {
|
|
22
|
+
return ctx.runQuery(
|
|
23
|
+
componentRef(this.component, "products/queries/paginateProducts"),
|
|
24
|
+
opts,
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
async paginatePrices(
|
|
28
|
+
ctx: RunCtx,
|
|
29
|
+
opts: {
|
|
30
|
+
productId?: string;
|
|
31
|
+
active?: boolean;
|
|
32
|
+
paginationOpts: PaginationOptions;
|
|
33
|
+
},
|
|
34
|
+
): Promise<PaginationResult<StripeComponentPrice>> {
|
|
35
|
+
return ctx.runQuery(
|
|
36
|
+
componentRef(this.component, "products/queries/paginatePrices"),
|
|
37
|
+
opts,
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
async paginatePayouts(
|
|
41
|
+
ctx: RunCtx,
|
|
42
|
+
opts: {
|
|
43
|
+
accountId: string;
|
|
44
|
+
status?: PayoutStatus;
|
|
45
|
+
paginationOpts: PaginationOptions;
|
|
46
|
+
},
|
|
47
|
+
): Promise<PaginationResult<StripeComponentPayout>> {
|
|
48
|
+
return ctx.runQuery(
|
|
49
|
+
componentRef(this.component, "connect/queries/paginatePayouts"),
|
|
50
|
+
opts,
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async getAccountEarningsByCurrency(
|
|
55
|
+
ctx: RunCtx,
|
|
56
|
+
opts: { destinationAccountId: string; previewLimit?: number },
|
|
57
|
+
): Promise<{
|
|
58
|
+
totals: {
|
|
59
|
+
currency: string;
|
|
60
|
+
gross: number;
|
|
61
|
+
reversed: number;
|
|
62
|
+
net: number;
|
|
63
|
+
transferCount: number;
|
|
64
|
+
}[];
|
|
65
|
+
transfers: ComponentApi["connect"]["queries"]["getAccountEarningsByCurrency"]["_returnType"]["transfers"];
|
|
66
|
+
}> {
|
|
67
|
+
return ctx.runQuery(
|
|
68
|
+
componentRef(
|
|
69
|
+
this.component,
|
|
70
|
+
"connect/queries/getAccountEarningsByCurrency",
|
|
71
|
+
),
|
|
72
|
+
opts,
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
async getAccountPayoutsByCurrency(
|
|
76
|
+
ctx: RunCtx,
|
|
77
|
+
opts: { accountId: string; previewLimit?: number },
|
|
78
|
+
): Promise<{
|
|
79
|
+
totals: { currency: string; paidOut: number; payoutCount: number }[];
|
|
80
|
+
payouts: StripeComponentPayout[];
|
|
81
|
+
}> {
|
|
82
|
+
return ctx.runQuery(
|
|
83
|
+
componentRef(
|
|
84
|
+
this.component,
|
|
85
|
+
"connect/queries/getAccountPayoutsByCurrency",
|
|
86
|
+
),
|
|
87
|
+
opts,
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { expect, it, vi } from "vitest";
|
|
2
|
+
import { BetterStripe } from "./index.js";
|
|
3
|
+
import { components } from "./setup.test.js";
|
|
4
|
+
it("forwards typed component cursors and account scope through public readers", async () => {
|
|
5
|
+
const bs = new BetterStripe(components.betterStripe, {
|
|
6
|
+
STRIPE_SECRET_KEY: "sk_test_reader_fixture",
|
|
7
|
+
});
|
|
8
|
+
const page = { page: [], isDone: true, continueCursor: "end" };
|
|
9
|
+
const ctx = { runQuery: vi.fn().mockResolvedValue(page) };
|
|
10
|
+
const paginationOpts = { numItems: 10, cursor: "opaque" };
|
|
11
|
+
expect(
|
|
12
|
+
await bs.paginatePayouts(ctx, {
|
|
13
|
+
accountId: "acct_a",
|
|
14
|
+
status: "paid",
|
|
15
|
+
paginationOpts,
|
|
16
|
+
}),
|
|
17
|
+
).toBe(page);
|
|
18
|
+
expect(ctx.runQuery).toHaveBeenLastCalledWith(
|
|
19
|
+
components.betterStripe.connect.queries.paginatePayouts,
|
|
20
|
+
{ accountId: "acct_a", status: "paid", paginationOpts },
|
|
21
|
+
);
|
|
22
|
+
expect(
|
|
23
|
+
await bs.paginateProducts(ctx, {
|
|
24
|
+
accountId: "acct_a",
|
|
25
|
+
active: true,
|
|
26
|
+
paginationOpts,
|
|
27
|
+
}),
|
|
28
|
+
).toBe(page);
|
|
29
|
+
expect(ctx.runQuery).toHaveBeenLastCalledWith(
|
|
30
|
+
components.betterStripe.products.queries.paginateProducts,
|
|
31
|
+
{ accountId: "acct_a", active: true, paginationOpts },
|
|
32
|
+
);
|
|
33
|
+
expect(
|
|
34
|
+
await bs.paginatePrices(ctx, {
|
|
35
|
+
productId: "prod_a",
|
|
36
|
+
active: false,
|
|
37
|
+
paginationOpts,
|
|
38
|
+
}),
|
|
39
|
+
).toBe(page);
|
|
40
|
+
expect(ctx.runQuery).toHaveBeenLastCalledWith(
|
|
41
|
+
components.betterStripe.products.queries.paginatePrices,
|
|
42
|
+
{ productId: "prod_a", active: false, paginationOpts },
|
|
43
|
+
);
|
|
44
|
+
});
|
|
45
|
+
it("forwards exact currency summary reads through the public client", async () => {
|
|
46
|
+
const bs = new BetterStripe(components.betterStripe);
|
|
47
|
+
const ctx = { runQuery: vi.fn().mockResolvedValue({ totals: [] }) };
|
|
48
|
+
expect(
|
|
49
|
+
await bs.getAccountEarningsByCurrency(ctx, {
|
|
50
|
+
destinationAccountId: "acct_a",
|
|
51
|
+
}),
|
|
52
|
+
).toEqual({ totals: [] });
|
|
53
|
+
expect(ctx.runQuery).toHaveBeenLastCalledWith(
|
|
54
|
+
components.betterStripe.connect.queries.getAccountEarningsByCurrency,
|
|
55
|
+
{ destinationAccountId: "acct_a" },
|
|
56
|
+
);
|
|
57
|
+
await bs.getAccountPayoutsByCurrency(ctx, { accountId: "acct_a" });
|
|
58
|
+
expect(ctx.runQuery).toHaveBeenLastCalledWith(
|
|
59
|
+
components.betterStripe.connect.queries.getAccountPayoutsByCurrency,
|
|
60
|
+
{ accountId: "acct_a" },
|
|
61
|
+
);
|
|
62
|
+
});
|
|
@@ -190,3 +190,28 @@ it.each([
|
|
|
190
190
|
expect(transport).not.toHaveBeenCalled();
|
|
191
191
|
},
|
|
192
192
|
);
|
|
193
|
+
|
|
194
|
+
it("persists signed failure details and clears them on a later snapshot", async () => {
|
|
195
|
+
const f = fixture("payout.failed", "failed");
|
|
196
|
+
f.event.data.object.failure_code = "account_closed";
|
|
197
|
+
f.event.data.object.failure_message = "Bank account closed";
|
|
198
|
+
expect((await f.deliver()).status).toBe(200);
|
|
199
|
+
const failed = await f.payout();
|
|
200
|
+
expect(failed).toMatchObject({
|
|
201
|
+
failureCode: "account_closed",
|
|
202
|
+
failureMessage: "Bank account closed",
|
|
203
|
+
accountId: "acct_qa_recipient",
|
|
204
|
+
});
|
|
205
|
+
expect((await f.deliver()).body).toMatchObject({ deduplicated: true });
|
|
206
|
+
expect(await f.payout()).toEqual(failed);
|
|
207
|
+
f.event.id = "evt_failure_cleared";
|
|
208
|
+
f.event.type = "payout.updated";
|
|
209
|
+
f.event.data.object.status = "pending";
|
|
210
|
+
f.event.data.object.failure_code = null;
|
|
211
|
+
f.event.data.object.failure_message = null;
|
|
212
|
+
expect((await f.deliver()).status).toBe(200);
|
|
213
|
+
const cleared = await f.payout();
|
|
214
|
+
expect(cleared?._id).toBe(failed?._id);
|
|
215
|
+
expect(cleared).not.toHaveProperty("failureCode");
|
|
216
|
+
expect(cleared).not.toHaveProperty("failureMessage");
|
|
217
|
+
});
|
|
@@ -921,6 +921,8 @@ async function handlePayoutEvent(
|
|
|
921
921
|
"pending" | "paid" | "failed" | "canceled" | "in_transit",
|
|
922
922
|
arrivalDate: epochToIso(payout.arrival_date),
|
|
923
923
|
method: payout.method ?? undefined,
|
|
924
|
+
failureCode: payout.failure_code ?? undefined,
|
|
925
|
+
failureMessage: payout.failure_message ?? undefined,
|
|
924
926
|
metadata: payout.metadata ?? undefined,
|
|
925
927
|
});
|
|
926
928
|
}
|
|
@@ -24,6 +24,10 @@ import type * as costRecovery_imports from "../costRecovery/imports.js";
|
|
|
24
24
|
import type * as costRecovery_mutations from "../costRecovery/mutations.js";
|
|
25
25
|
import type * as costRecovery_queries from "../costRecovery/queries.js";
|
|
26
26
|
import type * as costRecovery_reservations from "../costRecovery/reservations.js";
|
|
27
|
+
import type * as earnings_legacyReads from "../earnings/legacyReads.js";
|
|
28
|
+
import type * as earnings_mutations from "../earnings/mutations.js";
|
|
29
|
+
import type * as earnings_reads from "../earnings/reads.js";
|
|
30
|
+
import type * as earnings_triggers from "../earnings/triggers.js";
|
|
27
31
|
import type * as lib_fees from "../lib/fees.js";
|
|
28
32
|
import type * as lib_idempotency from "../lib/idempotency.js";
|
|
29
33
|
import type * as lib_paymentSeller from "../lib/paymentSeller.js";
|
|
@@ -62,6 +66,10 @@ const fullApi: ApiFromModules<{
|
|
|
62
66
|
"costRecovery/mutations": typeof costRecovery_mutations;
|
|
63
67
|
"costRecovery/queries": typeof costRecovery_queries;
|
|
64
68
|
"costRecovery/reservations": typeof costRecovery_reservations;
|
|
69
|
+
"earnings/legacyReads": typeof earnings_legacyReads;
|
|
70
|
+
"earnings/mutations": typeof earnings_mutations;
|
|
71
|
+
"earnings/reads": typeof earnings_reads;
|
|
72
|
+
"earnings/triggers": typeof earnings_triggers;
|
|
65
73
|
"lib/fees": typeof lib_fees;
|
|
66
74
|
"lib/idempotency": typeof lib_idempotency;
|
|
67
75
|
"lib/paymentSeller": typeof lib_paymentSeller;
|