@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.
Files changed (160) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/MIGRATION.md +26 -0
  3. package/README.md +38 -1
  4. package/dist/client/apiVersion.d.ts +6 -0
  5. package/dist/client/apiVersion.d.ts.map +1 -0
  6. package/dist/client/apiVersion.js +13 -0
  7. package/dist/client/apiVersion.js.map +1 -0
  8. package/dist/client/billing/checkout.d.ts +1 -0
  9. package/dist/client/billing/checkout.d.ts.map +1 -1
  10. package/dist/client/billing/checkout.js +5 -1
  11. package/dist/client/billing/checkout.js.map +1 -1
  12. package/dist/client/billing/providerReads.d.ts +18 -0
  13. package/dist/client/billing/providerReads.d.ts.map +1 -0
  14. package/dist/client/billing/providerReads.js +15 -0
  15. package/dist/client/billing/providerReads.js.map +1 -0
  16. package/dist/client/billing/subscriptions.d.ts +1 -1
  17. package/dist/client/billing/subscriptions.d.ts.map +1 -1
  18. package/dist/client/billing/subscriptions.js.map +1 -1
  19. package/dist/client/connect/payoutSnapshot.d.ts +28 -0
  20. package/dist/client/connect/payoutSnapshot.d.ts.map +1 -0
  21. package/dist/client/connect/payoutSnapshot.js +81 -0
  22. package/dist/client/connect/payoutSnapshot.js.map +1 -0
  23. package/dist/client/connect/transfers.d.ts.map +1 -1
  24. package/dist/client/connect/transfers.js +13 -32
  25. package/dist/client/connect/transfers.js.map +1 -1
  26. package/dist/client/core/accounts.d.ts +12 -23
  27. package/dist/client/core/accounts.d.ts.map +1 -1
  28. package/dist/client/core/accounts.js +5 -1
  29. package/dist/client/core/accounts.js.map +1 -1
  30. package/dist/client/core/idempotency.d.ts +5 -0
  31. package/dist/client/core/idempotency.d.ts.map +1 -0
  32. package/dist/client/core/idempotency.js +10 -0
  33. package/dist/client/core/idempotency.js.map +1 -0
  34. package/dist/client/index.d.ts +39 -37
  35. package/dist/client/index.d.ts.map +1 -1
  36. package/dist/client/index.js +47 -56
  37. package/dist/client/index.js.map +1 -1
  38. package/dist/client/previewRoutes.d.ts +9 -0
  39. package/dist/client/previewRoutes.d.ts.map +1 -0
  40. package/dist/client/previewRoutes.js +15 -0
  41. package/dist/client/previewRoutes.js.map +1 -0
  42. package/dist/client/settlement/allocation.d.ts +30 -0
  43. package/dist/client/settlement/allocation.d.ts.map +1 -0
  44. package/dist/client/settlement/allocation.js +71 -0
  45. package/dist/client/settlement/allocation.js.map +1 -0
  46. package/dist/client/settlement/commissions.d.ts +27 -0
  47. package/dist/client/settlement/commissions.d.ts.map +1 -0
  48. package/dist/client/settlement/commissions.js +69 -0
  49. package/dist/client/settlement/commissions.js.map +1 -0
  50. package/dist/client/settlement/execution.d.ts +27 -0
  51. package/dist/client/settlement/execution.d.ts.map +1 -0
  52. package/dist/client/settlement/execution.js +70 -0
  53. package/dist/client/settlement/execution.js.map +1 -0
  54. package/dist/client/settlement/reconciliation.d.ts +10 -0
  55. package/dist/client/settlement/reconciliation.d.ts.map +1 -0
  56. package/dist/client/settlement/reconciliation.js +73 -0
  57. package/dist/client/settlement/reconciliation.js.map +1 -0
  58. package/dist/client/settlement/settleSale.d.ts +21 -0
  59. package/dist/client/settlement/settleSale.d.ts.map +1 -0
  60. package/dist/client/settlement/settleSale.js +27 -0
  61. package/dist/client/settlement/settleSale.js.map +1 -0
  62. package/dist/client/settlement/verifiedSale.d.ts +25 -0
  63. package/dist/client/settlement/verifiedSale.d.ts.map +1 -0
  64. package/dist/client/settlement/verifiedSale.js +100 -0
  65. package/dist/client/settlement/verifiedSale.js.map +1 -0
  66. package/dist/client/types/options.d.ts +3 -0
  67. package/dist/client/types/options.d.ts.map +1 -1
  68. package/dist/client/utils/webhookEndpoints.d.ts +12 -8
  69. package/dist/client/utils/webhookEndpoints.d.ts.map +1 -1
  70. package/dist/client/utils/webhookEndpoints.js +41 -2
  71. package/dist/client/utils/webhookEndpoints.js.map +1 -1
  72. package/dist/client/webhooks/asyncHooks.d.ts +5 -0
  73. package/dist/client/webhooks/asyncHooks.d.ts.map +1 -0
  74. package/dist/client/webhooks/asyncHooks.js +20 -0
  75. package/dist/client/webhooks/asyncHooks.js.map +1 -0
  76. package/dist/client/webhooks/v2.d.ts.map +1 -1
  77. package/dist/client/webhooks/v2.js +10 -7
  78. package/dist/client/webhooks/v2.js.map +1 -1
  79. package/dist/component/_generated/component.d.ts +80 -0
  80. package/dist/component/_generated/component.d.ts.map +1 -1
  81. package/dist/component/lib/stripeFactory.d.ts +3 -2
  82. package/dist/component/lib/stripeFactory.d.ts.map +1 -1
  83. package/dist/component/lib/stripeFactory.js +6 -5
  84. package/dist/component/lib/stripeFactory.js.map +1 -1
  85. package/dist/component/schema.d.ts +67 -0
  86. package/dist/component/schema.js +4 -0
  87. package/dist/component/schema.js.map +1 -1
  88. package/dist/component/settlement/mutations.d.ts +47 -0
  89. package/dist/component/settlement/mutations.d.ts.map +1 -0
  90. package/dist/component/settlement/mutations.js +104 -0
  91. package/dist/component/settlement/mutations.js.map +1 -0
  92. package/dist/component/settlement/queries.d.ts +29 -0
  93. package/dist/component/settlement/queries.d.ts.map +1 -0
  94. package/dist/component/settlement/queries.js +35 -0
  95. package/dist/component/settlement/queries.js.map +1 -0
  96. package/dist/component/settlement/schema.d.ts +99 -0
  97. package/dist/component/settlement/schema.d.ts.map +1 -0
  98. package/dist/component/settlement/schema.js +35 -0
  99. package/dist/component/settlement/schema.js.map +1 -0
  100. package/dist/react/workflows/Checkout.d.ts +3 -1
  101. package/dist/react/workflows/Checkout.d.ts.map +1 -1
  102. package/dist/react/workflows/Checkout.js +3 -2
  103. package/dist/react/workflows/Checkout.js.map +1 -1
  104. package/dist/react/workflows/Payouts.d.ts +3 -18
  105. package/dist/react/workflows/Payouts.d.ts.map +1 -1
  106. package/dist/react/workflows/Payouts.js +2 -2
  107. package/dist/react/workflows/Payouts.js.map +1 -1
  108. package/docs/accounts-v2-preview.md +132 -0
  109. package/docs/react-workflows.md +5 -2
  110. package/docs/settlement.md +158 -0
  111. package/package.json +3 -1
  112. package/src/client/apiVersion.ts +20 -0
  113. package/src/client/billing/checkout-idempotency.test.ts +82 -0
  114. package/src/client/billing/checkout.ts +6 -1
  115. package/src/client/billing/providerReads.test.ts +60 -0
  116. package/src/client/billing/providerReads.ts +30 -0
  117. package/src/client/billing/subscriptions.ts +1 -8
  118. package/src/client/connect/payoutSnapshot.test.ts +91 -0
  119. package/src/client/connect/payoutSnapshot.ts +130 -0
  120. package/src/client/connect/transfers.test.ts +15 -4
  121. package/src/client/connect/transfers.ts +13 -42
  122. package/src/client/core/account-idempotency.test.ts +78 -0
  123. package/src/client/core/accounts.ts +18 -25
  124. package/src/client/core/idempotency.test.ts +10 -0
  125. package/src/client/core/idempotency.ts +11 -0
  126. package/src/client/customer-preview-thin.test.ts +41 -0
  127. package/src/client/customer-preview-webhooks.test.ts +150 -0
  128. package/src/client/customer-preview.test.ts +185 -0
  129. package/src/client/index.ts +115 -119
  130. package/src/client/previewRoutes.ts +26 -0
  131. package/src/client/settlement/allocation.test.ts +98 -0
  132. package/src/client/settlement/allocation.ts +110 -0
  133. package/src/client/settlement/commissions.test.ts +103 -0
  134. package/src/client/settlement/commissions.ts +125 -0
  135. package/src/client/settlement/execution.test.ts +186 -0
  136. package/src/client/settlement/execution.ts +134 -0
  137. package/src/client/settlement/reconciliation.ts +119 -0
  138. package/src/client/settlement/settleSale.ts +59 -0
  139. package/src/client/settlement/verifiedSale.test.ts +139 -0
  140. package/src/client/settlement/verifiedSale.ts +159 -0
  141. package/src/client/stripe-version.test.ts +10 -4
  142. package/src/client/types/options.ts +3 -0
  143. package/src/client/utils/webhookEndpoints.ts +70 -15
  144. package/src/client/webhooks/asyncHooks.ts +38 -0
  145. package/src/client/webhooks/processors.test.ts +9 -9
  146. package/src/client/webhooks/v2.ts +14 -10
  147. package/src/component/_generated/api.ts +4 -0
  148. package/src/component/_generated/component.ts +100 -0
  149. package/src/component/lib/stripeFactory.ts +13 -5
  150. package/src/component/schema.ts +8 -0
  151. package/src/component/settlement/mutations.ts +119 -0
  152. package/src/component/settlement/queries.ts +38 -0
  153. package/src/component/settlement/schema.ts +45 -0
  154. package/src/component/settlement-claims.test.ts +77 -0
  155. package/src/react/components/EmbeddedDisputes.render.test.tsx +2 -2
  156. package/src/react/index.payment-element.test.ts +22 -37
  157. package/src/react/workflows/Checkout.test.tsx +30 -0
  158. package/src/react/workflows/Checkout.tsx +10 -3
  159. package/src/react/workflows/Payouts.test.tsx +33 -0
  160. 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().mockResolvedValue(undefined),
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(2);
346
- expect(ctx.runMutation.mock.calls[0][1]).toMatchObject({
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(3);
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
- // Ledger-level idempotency (BTS-24): skip any (charge, destination, role) leg
464
- // already recorded, so a webhook retry — even after a partial run — never
465
- // double-transfers. Complements the deterministic Stripe idempotency key.
466
- const existing = ((await ctx.runQuery(
467
- componentRef(component, "connect/queries/listTransfersByCharge"),
468
- { sourceChargeId: opts.sourceChargeId },
469
- )) ?? []) as LedgerTransfer[];
470
- const done = new Set(
471
- existing.map((t) => `${t.destinationAccountId}:${t.role ?? ""}`),
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 account = await stripe.v2.core.accounts.create(createParams);
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
+ });