@fantastic.dev/better-stripe 0.9.0 → 0.10.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +12 -0
- package/MIGRATION.md +26 -0
- package/README.md +38 -1
- package/dist/client/apiVersion.d.ts +6 -0
- package/dist/client/apiVersion.d.ts.map +1 -0
- package/dist/client/apiVersion.js +13 -0
- package/dist/client/apiVersion.js.map +1 -0
- package/dist/client/billing/checkout.d.ts +1 -0
- package/dist/client/billing/checkout.d.ts.map +1 -1
- package/dist/client/billing/checkout.js +5 -1
- package/dist/client/billing/checkout.js.map +1 -1
- package/dist/client/billing/providerReads.d.ts +18 -0
- package/dist/client/billing/providerReads.d.ts.map +1 -0
- package/dist/client/billing/providerReads.js +15 -0
- package/dist/client/billing/providerReads.js.map +1 -0
- package/dist/client/billing/subscriptions.d.ts +1 -1
- package/dist/client/billing/subscriptions.d.ts.map +1 -1
- package/dist/client/billing/subscriptions.js.map +1 -1
- package/dist/client/connect/payoutSnapshot.d.ts +28 -0
- package/dist/client/connect/payoutSnapshot.d.ts.map +1 -0
- package/dist/client/connect/payoutSnapshot.js +81 -0
- package/dist/client/connect/payoutSnapshot.js.map +1 -0
- package/dist/client/connect/transfers.d.ts.map +1 -1
- package/dist/client/connect/transfers.js +13 -32
- package/dist/client/connect/transfers.js.map +1 -1
- package/dist/client/core/accounts.d.ts +12 -23
- package/dist/client/core/accounts.d.ts.map +1 -1
- package/dist/client/core/accounts.js +5 -1
- package/dist/client/core/accounts.js.map +1 -1
- package/dist/client/core/idempotency.d.ts +5 -0
- package/dist/client/core/idempotency.d.ts.map +1 -0
- package/dist/client/core/idempotency.js +10 -0
- package/dist/client/core/idempotency.js.map +1 -0
- package/dist/client/index.d.ts +39 -37
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +47 -56
- package/dist/client/index.js.map +1 -1
- package/dist/client/previewRoutes.d.ts +9 -0
- package/dist/client/previewRoutes.d.ts.map +1 -0
- package/dist/client/previewRoutes.js +15 -0
- package/dist/client/previewRoutes.js.map +1 -0
- package/dist/client/settlement/allocation.d.ts +30 -0
- package/dist/client/settlement/allocation.d.ts.map +1 -0
- package/dist/client/settlement/allocation.js +71 -0
- package/dist/client/settlement/allocation.js.map +1 -0
- package/dist/client/settlement/commissions.d.ts +27 -0
- package/dist/client/settlement/commissions.d.ts.map +1 -0
- package/dist/client/settlement/commissions.js +69 -0
- package/dist/client/settlement/commissions.js.map +1 -0
- package/dist/client/settlement/execution.d.ts +27 -0
- package/dist/client/settlement/execution.d.ts.map +1 -0
- package/dist/client/settlement/execution.js +70 -0
- package/dist/client/settlement/execution.js.map +1 -0
- package/dist/client/settlement/reconciliation.d.ts +10 -0
- package/dist/client/settlement/reconciliation.d.ts.map +1 -0
- package/dist/client/settlement/reconciliation.js +73 -0
- package/dist/client/settlement/reconciliation.js.map +1 -0
- package/dist/client/settlement/settleSale.d.ts +21 -0
- package/dist/client/settlement/settleSale.d.ts.map +1 -0
- package/dist/client/settlement/settleSale.js +27 -0
- package/dist/client/settlement/settleSale.js.map +1 -0
- package/dist/client/settlement/verifiedSale.d.ts +25 -0
- package/dist/client/settlement/verifiedSale.d.ts.map +1 -0
- package/dist/client/settlement/verifiedSale.js +100 -0
- package/dist/client/settlement/verifiedSale.js.map +1 -0
- package/dist/client/types/options.d.ts +3 -0
- package/dist/client/types/options.d.ts.map +1 -1
- package/dist/client/utils/webhookEndpoints.d.ts +12 -8
- package/dist/client/utils/webhookEndpoints.d.ts.map +1 -1
- package/dist/client/utils/webhookEndpoints.js +41 -2
- package/dist/client/utils/webhookEndpoints.js.map +1 -1
- package/dist/client/webhooks/asyncHooks.d.ts +5 -0
- package/dist/client/webhooks/asyncHooks.d.ts.map +1 -0
- package/dist/client/webhooks/asyncHooks.js +20 -0
- package/dist/client/webhooks/asyncHooks.js.map +1 -0
- package/dist/client/webhooks/v2.d.ts.map +1 -1
- package/dist/client/webhooks/v2.js +10 -7
- package/dist/client/webhooks/v2.js.map +1 -1
- package/dist/component/_generated/component.d.ts +80 -0
- package/dist/component/_generated/component.d.ts.map +1 -1
- package/dist/component/lib/stripeFactory.d.ts +3 -2
- package/dist/component/lib/stripeFactory.d.ts.map +1 -1
- package/dist/component/lib/stripeFactory.js +6 -5
- package/dist/component/lib/stripeFactory.js.map +1 -1
- package/dist/component/schema.d.ts +67 -0
- package/dist/component/schema.js +4 -0
- package/dist/component/schema.js.map +1 -1
- package/dist/component/settlement/mutations.d.ts +47 -0
- package/dist/component/settlement/mutations.d.ts.map +1 -0
- package/dist/component/settlement/mutations.js +104 -0
- package/dist/component/settlement/mutations.js.map +1 -0
- package/dist/component/settlement/queries.d.ts +29 -0
- package/dist/component/settlement/queries.d.ts.map +1 -0
- package/dist/component/settlement/queries.js +35 -0
- package/dist/component/settlement/queries.js.map +1 -0
- package/dist/component/settlement/schema.d.ts +99 -0
- package/dist/component/settlement/schema.d.ts.map +1 -0
- package/dist/component/settlement/schema.js +35 -0
- package/dist/component/settlement/schema.js.map +1 -0
- package/dist/react/workflows/Checkout.d.ts +3 -1
- package/dist/react/workflows/Checkout.d.ts.map +1 -1
- package/dist/react/workflows/Checkout.js +3 -2
- package/dist/react/workflows/Checkout.js.map +1 -1
- package/dist/react/workflows/Payouts.d.ts +3 -18
- package/dist/react/workflows/Payouts.d.ts.map +1 -1
- package/dist/react/workflows/Payouts.js +2 -2
- package/dist/react/workflows/Payouts.js.map +1 -1
- package/docs/accounts-v2-preview.md +132 -0
- package/docs/react-workflows.md +5 -2
- package/docs/settlement.md +158 -0
- package/package.json +3 -1
- package/src/client/apiVersion.ts +20 -0
- package/src/client/billing/checkout-idempotency.test.ts +82 -0
- package/src/client/billing/checkout.ts +6 -1
- package/src/client/billing/providerReads.test.ts +60 -0
- package/src/client/billing/providerReads.ts +30 -0
- package/src/client/billing/subscriptions.ts +1 -8
- package/src/client/connect/payoutSnapshot.test.ts +91 -0
- package/src/client/connect/payoutSnapshot.ts +130 -0
- package/src/client/connect/transfers.test.ts +15 -4
- package/src/client/connect/transfers.ts +13 -42
- package/src/client/core/account-idempotency.test.ts +78 -0
- package/src/client/core/accounts.ts +18 -25
- package/src/client/core/idempotency.test.ts +10 -0
- package/src/client/core/idempotency.ts +11 -0
- package/src/client/customer-preview-thin.test.ts +41 -0
- package/src/client/customer-preview-webhooks.test.ts +150 -0
- package/src/client/customer-preview.test.ts +185 -0
- package/src/client/index.ts +115 -119
- package/src/client/previewRoutes.ts +26 -0
- package/src/client/settlement/allocation.test.ts +98 -0
- package/src/client/settlement/allocation.ts +110 -0
- package/src/client/settlement/commissions.test.ts +103 -0
- package/src/client/settlement/commissions.ts +125 -0
- package/src/client/settlement/execution.test.ts +186 -0
- package/src/client/settlement/execution.ts +134 -0
- package/src/client/settlement/reconciliation.ts +119 -0
- package/src/client/settlement/settleSale.ts +59 -0
- package/src/client/settlement/verifiedSale.test.ts +139 -0
- package/src/client/settlement/verifiedSale.ts +159 -0
- package/src/client/stripe-version.test.ts +10 -4
- package/src/client/types/options.ts +3 -0
- package/src/client/utils/webhookEndpoints.ts +70 -15
- package/src/client/webhooks/asyncHooks.ts +38 -0
- package/src/client/webhooks/processors.test.ts +9 -9
- package/src/client/webhooks/v2.ts +14 -10
- package/src/component/_generated/api.ts +4 -0
- package/src/component/_generated/component.ts +100 -0
- package/src/component/lib/stripeFactory.ts +13 -5
- package/src/component/schema.ts +8 -0
- package/src/component/settlement/mutations.ts +119 -0
- package/src/component/settlement/queries.ts +38 -0
- package/src/component/settlement/schema.ts +45 -0
- package/src/component/settlement-claims.test.ts +77 -0
- package/src/react/components/EmbeddedDisputes.render.test.tsx +2 -2
- package/src/react/index.payment-element.test.ts +22 -37
- package/src/react/workflows/Checkout.test.tsx +30 -0
- package/src/react/workflows/Checkout.tsx +10 -3
- package/src/react/workflows/Payouts.test.tsx +33 -0
- package/src/react/workflows/Payouts.tsx +21 -15
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { componentsGeneric } from "convex/server";
|
|
2
|
+
import { expect, it, vi } from "vitest";
|
|
3
|
+
import { handleV2Event } from "./webhooks/v2.js";
|
|
4
|
+
import type { WebhookContext } from "./webhooks/helpers.js";
|
|
5
|
+
import type { V2ThinEvent } from "./types.js";
|
|
6
|
+
it("syncs a preview customer without requesting merchant, recipient or Connect requirements", async () => {
|
|
7
|
+
const retrieve = vi.fn().mockResolvedValue({
|
|
8
|
+
id: "acct_buyer",
|
|
9
|
+
metadata: { userId: "buyer" },
|
|
10
|
+
configuration: { customer: { applied: true } },
|
|
11
|
+
applied_configurations: ["customer"],
|
|
12
|
+
identity: { country: "us" },
|
|
13
|
+
});
|
|
14
|
+
const runMutation = vi.fn();
|
|
15
|
+
const ctx = {
|
|
16
|
+
component: componentsGeneric().betterStripe,
|
|
17
|
+
ctx: { runMutation },
|
|
18
|
+
stripe: { v2: { core: { accounts: { retrieve } } } },
|
|
19
|
+
config: { stripeApiVersion: "2026-09-30.preview" },
|
|
20
|
+
} as unknown as WebhookContext;
|
|
21
|
+
await handleV2Event(ctx, {
|
|
22
|
+
id: "evt_preview",
|
|
23
|
+
type: "v2.core.account[configuration.customer].updated",
|
|
24
|
+
context: "acct_merchant",
|
|
25
|
+
related_object: { id: "acct_buyer", type: "v2.core.account" },
|
|
26
|
+
} as V2ThinEvent);
|
|
27
|
+
expect(retrieve).toHaveBeenCalledWith(
|
|
28
|
+
"acct_buyer",
|
|
29
|
+
{ include: ["configuration.customer", "identity"] },
|
|
30
|
+
{ stripeContext: "acct_merchant" },
|
|
31
|
+
);
|
|
32
|
+
expect(runMutation).toHaveBeenCalledWith(
|
|
33
|
+
expect.anything(),
|
|
34
|
+
expect.objectContaining({
|
|
35
|
+
userId: "buyer",
|
|
36
|
+
stripeAccountId: "acct_buyer",
|
|
37
|
+
appliedConfigurations: ["customer"],
|
|
38
|
+
onboardingStatus: "complete",
|
|
39
|
+
}),
|
|
40
|
+
);
|
|
41
|
+
});
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import type Stripe from "stripe";
|
|
2
|
+
import { componentsGeneric, type HttpRouter } from "convex/server";
|
|
3
|
+
import { expect, it, vi } from "vitest";
|
|
4
|
+
import { BetterStripe } from "./index.js";
|
|
5
|
+
import type { Component, RunCtx } from "./helpers.js";
|
|
6
|
+
import {
|
|
7
|
+
createWebhookEndpoint,
|
|
8
|
+
setupEventDestination,
|
|
9
|
+
} from "./utils/webhookEndpoints.js";
|
|
10
|
+
const handle = vi.hoisted(() => vi.fn(async () => new Response("ok")));
|
|
11
|
+
vi.mock("./webhooks/handler.js", () => ({ handleWebhookRequest: handle }));
|
|
12
|
+
const version = "2026-09-30.preview";
|
|
13
|
+
const component = componentsGeneric().betterStripe as unknown as Component;
|
|
14
|
+
it("instance routes inherit the selected client version and reject conflicting configuration", async () => {
|
|
15
|
+
const bs = new BetterStripe(component, {
|
|
16
|
+
STRIPE_SECRET_KEY: "sk_test_preview_routes",
|
|
17
|
+
stripeApiVersion: version,
|
|
18
|
+
});
|
|
19
|
+
const route = vi.fn();
|
|
20
|
+
bs.registerRoutes({ route } as unknown as HttpRouter, {
|
|
21
|
+
webhookSecret: "whsec_fixture",
|
|
22
|
+
});
|
|
23
|
+
const handler = route.mock.calls[0][0].handler as {
|
|
24
|
+
_handler: (ctx: unknown, request: Request) => Promise<Response>;
|
|
25
|
+
};
|
|
26
|
+
await handler._handler(
|
|
27
|
+
{},
|
|
28
|
+
new Request("https://example.com/stripe/webhook", { method: "POST" }),
|
|
29
|
+
);
|
|
30
|
+
expect(handle).toHaveBeenCalledWith(
|
|
31
|
+
expect.anything(),
|
|
32
|
+
expect.anything(),
|
|
33
|
+
component,
|
|
34
|
+
expect.objectContaining({
|
|
35
|
+
stripeApiVersion: version,
|
|
36
|
+
stripeSecretKey: "sk_test_preview_routes",
|
|
37
|
+
webhookSecret: "whsec_fixture",
|
|
38
|
+
}),
|
|
39
|
+
);
|
|
40
|
+
expect(() =>
|
|
41
|
+
bs.registerRoutes({ route } as unknown as HttpRouter, {
|
|
42
|
+
stripeApiVersion: "2026-09-30.endive",
|
|
43
|
+
}),
|
|
44
|
+
).toThrow("match");
|
|
45
|
+
expect(route).toHaveBeenCalledTimes(1);
|
|
46
|
+
});
|
|
47
|
+
function sdk() {
|
|
48
|
+
const raw = {
|
|
49
|
+
webhookEndpoints: {
|
|
50
|
+
list: vi.fn().mockResolvedValue({ data: [] }),
|
|
51
|
+
create: vi.fn().mockResolvedValue({
|
|
52
|
+
id: "we_preview",
|
|
53
|
+
url: "https://example.com/events",
|
|
54
|
+
secret: "whsec_fixture",
|
|
55
|
+
}),
|
|
56
|
+
},
|
|
57
|
+
v2: {
|
|
58
|
+
core: {
|
|
59
|
+
eventDestinations: {
|
|
60
|
+
list: vi.fn().mockResolvedValue({ data: [] }),
|
|
61
|
+
create: vi.fn().mockResolvedValue({
|
|
62
|
+
id: "ed_preview",
|
|
63
|
+
enabled_events: [],
|
|
64
|
+
webhook_endpoint: { signing_secret: "whsec_fixture" },
|
|
65
|
+
}),
|
|
66
|
+
update: vi.fn(),
|
|
67
|
+
},
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
return { raw, stripe: raw as unknown as Stripe };
|
|
72
|
+
}
|
|
73
|
+
it("pins snapshot payloads, leaves thin payloads unversioned, and uses canonical preview scopes", async () => {
|
|
74
|
+
const { raw, stripe } = sdk(),
|
|
75
|
+
ctx = {} as RunCtx;
|
|
76
|
+
await createWebhookEndpoint(stripe, ctx, {
|
|
77
|
+
url: "https://example.com/events",
|
|
78
|
+
apiVersion: version,
|
|
79
|
+
});
|
|
80
|
+
expect(raw.webhookEndpoints.create).toHaveBeenCalledWith(
|
|
81
|
+
expect.objectContaining({ api_version: version }),
|
|
82
|
+
);
|
|
83
|
+
await setupEventDestination(stripe, ctx, {
|
|
84
|
+
url: "https://example.com/events",
|
|
85
|
+
eventPayload: "snapshot",
|
|
86
|
+
apiVersion: version,
|
|
87
|
+
});
|
|
88
|
+
expect(raw.v2.core.eventDestinations.create).toHaveBeenLastCalledWith(
|
|
89
|
+
expect.objectContaining({
|
|
90
|
+
snapshot_api_version: version,
|
|
91
|
+
events_from: ["@self"],
|
|
92
|
+
}),
|
|
93
|
+
);
|
|
94
|
+
await setupEventDestination(stripe, ctx, {
|
|
95
|
+
url: "https://example.com/events",
|
|
96
|
+
eventPayload: "thin",
|
|
97
|
+
apiVersion: version,
|
|
98
|
+
});
|
|
99
|
+
const thin = raw.v2.core.eventDestinations.create.mock.calls[1][0];
|
|
100
|
+
expect(thin).not.toHaveProperty("snapshot_api_version");
|
|
101
|
+
expect(thin.events_from).toEqual(["@self", "@accounts"]);
|
|
102
|
+
expect(thin.enabled_events).toEqual([
|
|
103
|
+
"v2.core.account.created",
|
|
104
|
+
"v2.core.account[configuration.customer].updated",
|
|
105
|
+
"v2.core.account[identity].updated",
|
|
106
|
+
]);
|
|
107
|
+
});
|
|
108
|
+
it("refuses to reuse a snapshot destination whose immutable version differs", async () => {
|
|
109
|
+
const { raw, stripe } = sdk();
|
|
110
|
+
raw.v2.core.eventDestinations.list.mockResolvedValue({
|
|
111
|
+
data: [
|
|
112
|
+
{
|
|
113
|
+
id: "ed_old",
|
|
114
|
+
event_payload: "snapshot",
|
|
115
|
+
snapshot_api_version: "2026-09-30.endive",
|
|
116
|
+
webhook_endpoint: { url: "https://example.com/events" },
|
|
117
|
+
},
|
|
118
|
+
],
|
|
119
|
+
});
|
|
120
|
+
await expect(
|
|
121
|
+
setupEventDestination(stripe, {} as RunCtx, {
|
|
122
|
+
url: "https://example.com/events",
|
|
123
|
+
eventPayload: "snapshot",
|
|
124
|
+
apiVersion: version,
|
|
125
|
+
}),
|
|
126
|
+
).rejects.toThrow("version");
|
|
127
|
+
expect(raw.v2.core.eventDestinations.update).not.toHaveBeenCalled();
|
|
128
|
+
expect(raw.v2.core.eventDestinations.create).not.toHaveBeenCalled();
|
|
129
|
+
});
|
|
130
|
+
it("refuses a preview thin destination that cannot deliver managed customer account events", async () => {
|
|
131
|
+
const { raw, stripe } = sdk();
|
|
132
|
+
raw.v2.core.eventDestinations.list.mockResolvedValue({
|
|
133
|
+
data: [
|
|
134
|
+
{
|
|
135
|
+
id: "ed_old",
|
|
136
|
+
event_payload: "thin",
|
|
137
|
+
events_from: ["@self"],
|
|
138
|
+
webhook_endpoint: { url: "https://example.com/events" },
|
|
139
|
+
},
|
|
140
|
+
],
|
|
141
|
+
});
|
|
142
|
+
await expect(
|
|
143
|
+
setupEventDestination(stripe, {} as RunCtx, {
|
|
144
|
+
url: "https://example.com/events",
|
|
145
|
+
eventPayload: "thin",
|
|
146
|
+
apiVersion: version,
|
|
147
|
+
}),
|
|
148
|
+
).rejects.toThrow("scope");
|
|
149
|
+
expect(raw.v2.core.eventDestinations.update).not.toHaveBeenCalled();
|
|
150
|
+
});
|