@fantastic.dev/better-stripe 0.4.0 → 0.5.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.
Files changed (162) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/MIGRATION.md +135 -0
  3. package/README.md +23 -2
  4. package/dist/client/billing/checkout.d.ts.map +1 -1
  5. package/dist/client/billing/checkout.js +22 -8
  6. package/dist/client/billing/checkout.js.map +1 -1
  7. package/dist/client/billing/subscriptionRouting.d.ts +24 -0
  8. package/dist/client/billing/subscriptionRouting.d.ts.map +1 -0
  9. package/dist/client/billing/subscriptionRouting.js +19 -0
  10. package/dist/client/billing/subscriptionRouting.js.map +1 -0
  11. package/dist/client/billing/subscriptions.d.ts +6 -6
  12. package/dist/client/billing/subscriptions.d.ts.map +1 -1
  13. package/dist/client/billing/subscriptions.js +31 -12
  14. package/dist/client/billing/subscriptions.js.map +1 -1
  15. package/dist/client/connect/disputes.js.map +1 -1
  16. package/dist/client/constants.js +1 -1
  17. package/dist/client/core/accountLinks.d.ts +1 -0
  18. package/dist/client/core/accountLinks.d.ts.map +1 -1
  19. package/dist/client/core/accountLinks.js +0 -6
  20. package/dist/client/core/accountLinks.js.map +1 -1
  21. package/dist/client/core/accountStatus.d.ts.map +1 -1
  22. package/dist/client/core/accountStatus.js +23 -8
  23. package/dist/client/core/accountStatus.js.map +1 -1
  24. package/dist/client/index.d.ts +5 -2
  25. package/dist/client/index.d.ts.map +1 -1
  26. package/dist/client/index.js.map +1 -1
  27. package/dist/client/types/documents.d.ts +1 -0
  28. package/dist/client/types/documents.d.ts.map +1 -1
  29. package/dist/client/types/events.d.ts +2 -1
  30. package/dist/client/types/events.d.ts.map +1 -1
  31. package/dist/client/types/options.d.ts +7 -0
  32. package/dist/client/types/options.d.ts.map +1 -1
  33. package/dist/client/utils/stripeDashboardUrl.d.ts.map +1 -1
  34. package/dist/client/utils/webhookEndpoints.d.ts.map +1 -1
  35. package/dist/client/webhooks/handler.d.ts.map +1 -1
  36. package/dist/client/webhooks/handler.js +35 -1
  37. package/dist/client/webhooks/handler.js.map +1 -1
  38. package/dist/client/webhooks/hooks.d.ts +1 -1
  39. package/dist/client/webhooks/hooks.d.ts.map +1 -1
  40. package/dist/client/webhooks/hooks.js +5 -1
  41. package/dist/client/webhooks/hooks.js.map +1 -1
  42. package/dist/client/webhooks/processors.d.ts.map +1 -1
  43. package/dist/client/webhooks/processors.js +12 -2
  44. package/dist/client/webhooks/processors.js.map +1 -1
  45. package/dist/client/webhooks/v2.d.ts.map +1 -1
  46. package/dist/client/webhooks/v2.js +11 -3
  47. package/dist/client/webhooks/v2.js.map +1 -1
  48. package/dist/component/_generated/component.d.ts +8 -0
  49. package/dist/component/_generated/component.d.ts.map +1 -1
  50. package/dist/component/_generated/server.d.ts +7 -1
  51. package/dist/component/_generated/server.d.ts.map +1 -1
  52. package/dist/component/_generated/server.js +2 -1
  53. package/dist/component/_generated/server.js.map +1 -1
  54. package/dist/component/billing/mutations.d.ts +29 -28
  55. package/dist/component/billing/mutations.d.ts.map +1 -1
  56. package/dist/component/billing/mutations.js +1 -0
  57. package/dist/component/billing/mutations.js.map +1 -1
  58. package/dist/component/billing/queries.d.ts +138 -131
  59. package/dist/component/billing/queries.d.ts.map +1 -1
  60. package/dist/component/billing/schema.d.ts +67 -65
  61. package/dist/component/billing/schema.d.ts.map +1 -1
  62. package/dist/component/billing/validators.d.ts +94 -91
  63. package/dist/component/billing/validators.d.ts.map +1 -1
  64. package/dist/component/billing/validators.js +1 -0
  65. package/dist/component/billing/validators.js.map +1 -1
  66. package/dist/component/connect/mutations.d.ts +37 -37
  67. package/dist/component/connect/queries.d.ts +115 -115
  68. package/dist/component/connect/queries.d.ts.map +1 -1
  69. package/dist/component/connect/queries.js +34 -71
  70. package/dist/component/connect/queries.js.map +1 -1
  71. package/dist/component/connect/schema.d.ts +73 -73
  72. package/dist/component/connect/validators.d.ts +119 -119
  73. package/dist/component/connect/validators.d.ts.map +1 -1
  74. package/dist/component/convex.config.d.ts +1 -0
  75. package/dist/component/convex.config.d.ts.map +1 -1
  76. package/dist/component/convex.config.js +2 -1
  77. package/dist/component/convex.config.js.map +1 -1
  78. package/dist/component/core/mutations.d.ts +12 -12
  79. package/dist/component/core/queries.d.ts +34 -34
  80. package/dist/component/core/schema.d.ts +11 -11
  81. package/dist/component/core/validators.d.ts +14 -14
  82. package/dist/component/core/validators.d.ts.map +1 -1
  83. package/dist/component/lib/fees.d.ts +12 -12
  84. package/dist/component/lib/fees.d.ts.map +1 -1
  85. package/dist/component/lib/fees.js +1 -1
  86. package/dist/component/lib/reversals.js.map +1 -1
  87. package/dist/component/products/mutations.d.ts +7 -7
  88. package/dist/component/products/queries.d.ts +33 -33
  89. package/dist/component/products/schema.d.ts +15 -15
  90. package/dist/component/products/validators.d.ts +16 -16
  91. package/dist/component/products/validators.d.ts.map +1 -1
  92. package/dist/component/schema.d.ts +173 -171
  93. package/dist/component/schema.d.ts.map +1 -1
  94. package/dist/component/webhooks/mutations.d.ts +3 -3
  95. package/dist/component/webhooks/queries.d.ts +12 -12
  96. package/dist/component/webhooks/schema.d.ts +7 -7
  97. package/dist/component/webhooks/validators.d.ts +10 -10
  98. package/dist/component/webhooks/validators.d.ts.map +1 -1
  99. package/dist/react/components/AccountCreateCard.js.map +1 -1
  100. package/dist/react/components/BuyerBillingView.js +1 -1
  101. package/dist/react/components/CheckoutSessionProviderWithKey.d.ts +1 -1
  102. package/dist/react/components/DisputeDetail.js +1 -1
  103. package/dist/react/components/DisputesList.js +1 -1
  104. package/dist/react/components/EarningsSummary.js +1 -1
  105. package/dist/react/components/EmbeddedCheckout.d.ts.map +1 -1
  106. package/dist/react/components/EmbeddedCheckout.js +1 -1
  107. package/dist/react/components/EmbeddedCheckout.js.map +1 -1
  108. package/dist/react/components/PayoutSchedule.js +1 -1
  109. package/dist/react/components/SplitBreakdown.js +1 -1
  110. package/dist/react/components/StripeProvider.d.ts +1 -1
  111. package/dist/react/components/StripeProviderWithKey.d.ts +1 -1
  112. package/dist/react/hooks/useAccount.d.ts.map +1 -1
  113. package/dist/react/hooks/useAccountOnboarding.d.ts.map +1 -1
  114. package/dist/react/hooks/useCheckout.d.ts.map +1 -1
  115. package/dist/react/hooks/useDisputeWithCountdown.d.ts.map +1 -1
  116. package/dist/react/hooks/useDisputes.d.ts.map +1 -1
  117. package/dist/react/hooks/useEarnings.d.ts.map +1 -1
  118. package/dist/react/hooks/useInvoices.d.ts.map +1 -1
  119. package/dist/react/hooks/usePaymentMethodActions.js.map +1 -1
  120. package/dist/react/hooks/usePaymentMethods.d.ts.map +1 -1
  121. package/dist/react/hooks/usePrices.d.ts.map +1 -1
  122. package/dist/react/hooks/useProducts.d.ts.map +1 -1
  123. package/dist/react/hooks/useSplitBreakdown.d.ts.map +1 -1
  124. package/dist/react/hooks/useSubscription.d.ts.map +1 -1
  125. package/dist/react/hooks/useSubscriptions.d.ts.map +1 -1
  126. package/dist/react/lib/stripe-element-styles.d.ts.map +1 -1
  127. package/dist/react/lib/stripe-element-styles.js.map +1 -1
  128. package/package.json +25 -23
  129. package/src/client/billing/checkout.test.ts +62 -14
  130. package/src/client/billing/checkout.ts +26 -10
  131. package/src/client/billing/subscriptionRouting.ts +25 -0
  132. package/src/client/billing/subscriptions.test.ts +90 -28
  133. package/src/client/billing/subscriptions.ts +53 -18
  134. package/src/client/constants.ts +1 -1
  135. package/src/client/core/accountLinks.test.ts +20 -13
  136. package/src/client/core/accountLinks.ts +2 -10
  137. package/src/client/core/accountStatus.test.ts +67 -0
  138. package/src/client/core/accountStatus.ts +30 -13
  139. package/src/client/helpers.test.ts +2 -2
  140. package/src/client/index.ts +49 -44
  141. package/src/client/stripe-version.test.ts +47 -0
  142. package/src/client/types/documents.ts +1 -0
  143. package/src/client/types/events.ts +2 -1
  144. package/src/client/types/options.ts +7 -0
  145. package/src/client/webhooks/handler.ts +38 -1
  146. package/src/client/webhooks/hooks.ts +3 -0
  147. package/src/client/webhooks/processors.test.ts +201 -40
  148. package/src/client/webhooks/processors.ts +21 -17
  149. package/src/client/webhooks/retry.integration.test.ts +196 -0
  150. package/src/client/webhooks/v2.test.ts +36 -0
  151. package/src/client/webhooks/v2.ts +14 -4
  152. package/src/component/_generated/component.ts +15 -28
  153. package/src/component/_generated/server.ts +9 -2
  154. package/src/component/billing/mutations.ts +1 -0
  155. package/src/component/billing/validators.ts +1 -0
  156. package/src/component/config-queries.test.ts +8 -4
  157. package/src/component/connect/queries.ts +31 -66
  158. package/src/component/convex.config.ts +2 -1
  159. package/src/component/earnings-totals.test.ts +27 -1
  160. package/src/component/lib/fees.ts +1 -1
  161. package/src/react/components/EmbeddedCheckout.render.test.tsx +57 -0
  162. package/src/react/components/EmbeddedCheckout.tsx +1 -0
@@ -80,6 +80,9 @@ function makeCtx(queryResult?: unknown) {
80
80
 
81
81
  function makeStripe() {
82
82
  return {
83
+ prices: {
84
+ retrieve: vi.fn().mockRejectedValue({ code: "resource_missing" }),
85
+ },
83
86
  checkout: {
84
87
  sessions: {
85
88
  create: vi.fn(),
@@ -283,7 +286,9 @@ describe("createCheckoutSession", () => {
283
286
  });
284
287
 
285
288
  const createArg = stripe.checkout.sessions.create.mock.calls[0][0];
286
- expect(createArg.line_items).toEqual([{ price: "price_seat", quantity: 5 }]);
289
+ expect(createArg.line_items).toEqual([
290
+ { price: "price_seat", quantity: 5 },
291
+ ]);
287
292
  });
288
293
 
289
294
  it("sets customer_account when accountId is provided", async () => {
@@ -679,8 +684,16 @@ describe("createCheckoutSession — N-recipient split (BTS-21)", () => {
679
684
  stripe.checkout.sessions.create.mockResolvedValue(sessionResponse());
680
685
  const ctx = makeCtx();
681
686
  const split = [
682
- { destinationAccountId: "acct_store", role: "store" as const, percent: 80 },
683
- { destinationAccountId: "acct_aff", role: "affiliate" as const, percent: 5 },
687
+ {
688
+ destinationAccountId: "acct_store",
689
+ role: "store" as const,
690
+ percent: 80,
691
+ },
692
+ {
693
+ destinationAccountId: "acct_aff",
694
+ role: "affiliate" as const,
695
+ percent: 5,
696
+ },
684
697
  ];
685
698
 
686
699
  await createCheckoutSession(asStripe(stripe), makeComponent(), ctx, {
@@ -695,8 +708,12 @@ describe("createCheckoutSession — N-recipient split (BTS-21)", () => {
695
708
 
696
709
  const createArg = stripe.checkout.sessions.create.mock.calls[0][0];
697
710
  expect(createArg.payment_intent_data.transfer_data).toBeUndefined();
698
- expect(createArg.payment_intent_data.metadata.bsChargeType).toBe("separate");
699
- expect(JSON.parse(createArg.payment_intent_data.metadata.bsSplit)).toHaveLength(2);
711
+ expect(createArg.payment_intent_data.metadata.bsChargeType).toBe(
712
+ "separate",
713
+ );
714
+ expect(
715
+ JSON.parse(createArg.payment_intent_data.metadata.bsSplit),
716
+ ).toHaveLength(2);
700
717
 
701
718
  const [, upsertArgs] = ctx.runMutation.mock.calls[0];
702
719
  expect(upsertArgs.chargeType).toBe("separate");
@@ -725,7 +742,9 @@ describe("createCheckoutSession — N-recipient split (BTS-21)", () => {
725
742
  expect(createArg.subscription_data.transfer_data).toBeUndefined();
726
743
  expect(createArg.subscription_data.application_fee_percent).toBeUndefined();
727
744
  expect(createArg.subscription_data.metadata.bsChargeType).toBe("separate");
728
- expect(JSON.parse(createArg.subscription_data.metadata.bsSplit)).toHaveLength(2);
745
+ expect(
746
+ JSON.parse(createArg.subscription_data.metadata.bsSplit),
747
+ ).toHaveLength(2);
729
748
 
730
749
  const [, upsertArgs] = ctx.runMutation.mock.calls[0];
731
750
  expect(upsertArgs.chargeType).toBe("separate");
@@ -743,7 +762,9 @@ describe("createCheckoutSession — N-recipient split (BTS-21)", () => {
743
762
  mode: "subscription",
744
763
  returnUrl: "https://app.test/return",
745
764
  accountId: "acct_buyer",
746
- split: [{ destinationAccountId: "acct_store", role: "store", percent: 90 }],
765
+ split: [
766
+ { destinationAccountId: "acct_store", role: "store", percent: 90 },
767
+ ],
747
768
  feeConfig: { percent: 10 },
748
769
  });
749
770
 
@@ -775,7 +796,9 @@ describe("createCheckoutSession — N-recipient split (BTS-21)", () => {
775
796
 
776
797
  const createArg = stripe.checkout.sessions.create.mock.calls[0][0];
777
798
  expect(createArg.payment_intent_data.transfer_data).toBeUndefined();
778
- expect(createArg.payment_intent_data.metadata.bsChargeType).toBe("separate");
799
+ expect(createArg.payment_intent_data.metadata.bsChargeType).toBe(
800
+ "separate",
801
+ );
779
802
  });
780
803
 
781
804
  it("rejects split/destination/fee in setup mode", async () => {
@@ -789,7 +812,9 @@ describe("createCheckoutSession — N-recipient split (BTS-21)", () => {
789
812
  mode: "setup",
790
813
  returnUrl: "https://app.test/return",
791
814
  accountId: "acct_buyer",
792
- split: [{ destinationAccountId: "acct_store", role: "store", amount: 100 }],
815
+ split: [
816
+ { destinationAccountId: "acct_store", role: "store", amount: 100 },
817
+ ],
793
818
  }),
794
819
  ).rejects.toThrow(/setup/i);
795
820
  expect(stripe.checkout.sessions.create).not.toHaveBeenCalled();
@@ -809,7 +834,8 @@ describe("createCheckoutSession — N-recipient split (BTS-21)", () => {
809
834
  // A non-split caller trying to forge webhook instructions.
810
835
  metadata: {
811
836
  bsChargeType: "separate",
812
- bsSplit: '[{"destinationAccountId":"acct_evil","role":"store","amount":9999}]',
837
+ bsSplit:
838
+ '[{"destinationAccountId":"acct_evil","role":"store","amount":9999}]',
813
839
  plan: "pro",
814
840
  },
815
841
  });
@@ -881,6 +907,25 @@ describe("createCheckoutSession — price-on-platform validation (BTS-15)", () =
881
907
  });
882
908
  });
883
909
 
910
+ it("allows a platform price tagged to a store in the component catalog", async () => {
911
+ const stripe = makeStripe();
912
+ stripe.prices.retrieve.mockResolvedValue({ id: "price_1" });
913
+ stripe.checkout.sessions.create.mockResolvedValue(sessionResponse());
914
+ const ctx = routedCtx({
915
+ "products/queries/getPriceByStripeId": { stripeProductId: "prod_1" },
916
+ "products/queries/getProductByStripeId": { accountId: "acct_store" },
917
+ });
918
+ await createCheckoutSession(asStripe(stripe), makeComponent(), ctx, {
919
+ userId: "buyer_1",
920
+ stripePriceId: "price_1",
921
+ mode: "payment",
922
+ returnUrl: "https://app.test/return",
923
+ destinationAccountId: "acct_store",
924
+ });
925
+ expect(stripe.prices.retrieve).toHaveBeenCalledWith("price_1");
926
+ expect(stripe.checkout.sessions.create).toHaveBeenCalled();
927
+ });
928
+
884
929
  it("allows a destination charge for a platform-owned price", async () => {
885
930
  const stripe = makeStripe();
886
931
  stripe.checkout.sessions.create.mockResolvedValue(sessionResponse());
@@ -1005,7 +1050,10 @@ describe("getCheckoutSessionByStripeId", () => {
1005
1050
 
1006
1051
  describe("listCheckoutSessionsByUser", () => {
1007
1052
  it("forwards userId and optional status filter and returns the rows", async () => {
1008
- const ctx = makeCtx([{ stripeSessionId: "cs_a" }, { stripeSessionId: "cs_b" }]);
1053
+ const ctx = makeCtx([
1054
+ { stripeSessionId: "cs_a" },
1055
+ { stripeSessionId: "cs_b" },
1056
+ ]);
1009
1057
  const result = await listCheckoutSessionsByUser(makeComponent(), ctx, {
1010
1058
  userId: "user_1",
1011
1059
  status: "complete",
@@ -1157,9 +1205,9 @@ describe("createCheckoutSession — per-store statement descriptor (BTS-32)", ()
1157
1205
  });
1158
1206
 
1159
1207
  const createArg = stripe.checkout.sessions.create.mock.calls[0][0];
1160
- expect(
1161
- "statement_descriptor_suffix" in createArg.payment_intent_data,
1162
- ).toBe(false);
1208
+ expect("statement_descriptor_suffix" in createArg.payment_intent_data).toBe(
1209
+ false,
1210
+ );
1163
1211
  });
1164
1212
 
1165
1213
  it("subscription mode: stashes bsStatementDescriptor for the invoice.created processor without clobbering fee markers", async () => {
@@ -58,12 +58,13 @@ function deriveFeeRow(params: CheckoutSessionCreateParams): {
58
58
  }
59
59
 
60
60
  /**
61
- * Destination charges require the price to live on the PLATFORM account, not a
62
- * connected account. Best-effort guard: if the price is in the component catalog
63
- * and its product belongs to a connected account, throw a clear error before
64
- * hitting Stripe. A price absent from the catalog is left to Stripe to validate.
61
+ * Destination and separate charges use platform prices. Catalog accountId is a
62
+ * store attribution tag, not Stripe ownership (createProduct uses the platform).
63
+ * For tagged products, verify the price in the platform Stripe context before
64
+ * rejecting it. Untagged/unknown catalog entries are validated by Checkout.
65
65
  */
66
66
  async function assertPlatformPrice(
67
+ stripe: Stripe,
67
68
  component: Component,
68
69
  ctx: RunCtx,
69
70
  stripePriceId: string,
@@ -80,10 +81,23 @@ async function assertPlatformPrice(
80
81
  )) as { accountId?: string } | null;
81
82
 
82
83
  if (product?.accountId) {
83
- throwStripeError(
84
- "INVALID_CONFIGURATION",
85
- `Price ${stripePriceId} belongs to connected account ${product.accountId}; destination charges require a platform-owned price.`,
86
- );
84
+ try {
85
+ await stripe.prices.retrieve(stripePriceId);
86
+ } catch (error) {
87
+ // Only absence on the platform means the ownership check failed.
88
+ // Auth/network/rate-limit failures must not be misreported as ownership.
89
+ if (
90
+ !error ||
91
+ typeof error !== "object" ||
92
+ !("code" in error) ||
93
+ error.code !== "resource_missing"
94
+ )
95
+ throw error;
96
+ throwStripeError(
97
+ "INVALID_CONFIGURATION",
98
+ `Price ${stripePriceId} is unavailable on the platform account; destination charges require a platform-owned price.`,
99
+ );
100
+ }
87
101
  }
88
102
  }
89
103
 
@@ -171,7 +185,9 @@ export async function createCheckoutSession(
171
185
  ? {
172
186
  bsChargeType: "separate",
173
187
  bsSplit: JSON.stringify(opts.split),
174
- ...(opts.feeConfig ? { bsFeeConfig: JSON.stringify(opts.feeConfig) } : {}),
188
+ ...(opts.feeConfig
189
+ ? { bsFeeConfig: JSON.stringify(opts.feeConfig) }
190
+ : {}),
175
191
  }
176
192
  : {};
177
193
 
@@ -314,7 +330,7 @@ export async function createCheckoutSession(
314
330
  (isSeparate || feeRow.chargeType === "destination") &&
315
331
  typeof finalPriceId === "string"
316
332
  ) {
317
- await assertPlatformPrice(component, ctx, finalPriceId);
333
+ await assertPlatformPrice(stripe, component, ctx, finalPriceId);
318
334
  }
319
335
 
320
336
  const session = await stripe.checkout.sessions.create(finalSessionParams);
@@ -0,0 +1,25 @@
1
+ import type Stripe from "stripe";
2
+
3
+ import { validateSplit } from "../core/fees.js";
4
+ import type { SplitRecipient } from "../../component/lib/fees.js";
5
+
6
+ /** Recover routing from Stripe for both webhook delivery and explicit syncs. */
7
+ export function subscriptionRouting(subscription: Stripe.Subscription) {
8
+ const destination = subscription.transfer_data?.destination;
9
+ if (destination) {
10
+ return {
11
+ chargeType: "destination" as const,
12
+ destinationAccountId:
13
+ typeof destination === "string" ? destination : destination.id,
14
+ applicationFeePercent: subscription.application_fee_percent ?? undefined,
15
+ };
16
+ }
17
+ if (subscription.metadata?.bsChargeType === "separate") {
18
+ const split = JSON.parse(
19
+ subscription.metadata.bsSplit ?? "null",
20
+ ) as SplitRecipient[];
21
+ validateSplit(split);
22
+ return { chargeType: "separate" as const, splitRecipients: split };
23
+ }
24
+ return {};
25
+ }
@@ -110,7 +110,9 @@ describe("getSubscription", () => {
110
110
  expect(refPath(ctx.runQuery.mock.calls[0])).toBe(
111
111
  "betterStripe/billing/queries/getSubscription",
112
112
  );
113
- expect(ctx.runQuery.mock.calls[0][1]).toEqual({ subscriptionId: "internal_1" });
113
+ expect(ctx.runQuery.mock.calls[0][1]).toEqual({
114
+ subscriptionId: "internal_1",
115
+ });
114
116
  });
115
117
 
116
118
  it("returns null when the row is missing", async () => {
@@ -492,6 +494,10 @@ describe("syncAllSubscriptions", () => {
492
494
  {
493
495
  id: "sub_1",
494
496
  customer: { id: "cus_1" },
497
+ customer_account: "acct_buyer",
498
+ transfer_data: { destination: { id: "acct_store" } },
499
+ application_fee_percent: 10,
500
+ pause_collection: { behavior: "void" },
495
501
  status: "trialing",
496
502
  cancel_at_period_end: false,
497
503
  canceled_at: null,
@@ -527,7 +533,10 @@ describe("syncAllSubscriptions", () => {
527
533
  );
528
534
  expect(ctx.runMutation.mock.calls[0][1]).toEqual({
529
535
  stripeSubscriptionId: "sub_1",
530
- accountId: "cus_1",
536
+ accountId: "acct_buyer",
537
+ chargeType: "destination",
538
+ destinationAccountId: "acct_store",
539
+ applicationFeePercent: 10,
531
540
  userId: "user_1",
532
541
  orgId: "org_1",
533
542
  status: "trialing",
@@ -536,6 +545,7 @@ describe("syncAllSubscriptions", () => {
536
545
  currentPeriodStart: "2021-01-01T00:00:00.000Z",
537
546
  currentPeriodEnd: "2021-02-01T00:00:00.000Z",
538
547
  cancelAtPeriodEnd: false,
548
+ isCollectionPaused: true,
539
549
  canceledAt: undefined,
540
550
  isTrialing: true,
541
551
  trialStart: "2021-01-01T00:00:00.000Z",
@@ -557,7 +567,9 @@ describe("syncAllSubscriptions", () => {
557
567
  trial_start: null,
558
568
  trial_end: null,
559
569
  metadata: { user_id: "user_2", org_id: "org_2" },
560
- items: { data: [{ id: "si_2", price: { id: "price_2" }, quantity: 1 }] },
570
+ items: {
571
+ data: [{ id: "si_2", price: { id: "price_2" }, quantity: 1 }],
572
+ },
561
573
  },
562
574
  ]),
563
575
  );
@@ -719,13 +731,18 @@ describe("createSubscription (BTS-17)", () => {
719
731
  stripe.subscriptions.create.mockResolvedValue(createdSub());
720
732
  const ctx = makeCtx();
721
733
 
722
- const result = await createSubscription(asStripe(stripe), makeComponent(), ctx, {
723
- userId: "buyer_1",
724
- customerAccount: "acct_buyer",
725
- stripePriceId: "price_1",
726
- destinationAccountId: "acct_store",
727
- feeConfig: { percent: 10 },
728
- });
734
+ const result = await createSubscription(
735
+ asStripe(stripe),
736
+ makeComponent(),
737
+ ctx,
738
+ {
739
+ userId: "buyer_1",
740
+ customerAccount: "acct_buyer",
741
+ stripePriceId: "price_1",
742
+ destinationAccountId: "acct_store",
743
+ feeConfig: { percent: 10 },
744
+ },
745
+ );
729
746
 
730
747
  const createArg = stripe.subscriptions.create.mock.calls[0][0];
731
748
  expect(createArg.customer_account).toBe("acct_buyer");
@@ -744,7 +761,26 @@ describe("createSubscription (BTS-17)", () => {
744
761
  destinationAccountId: "acct_store",
745
762
  applicationFeePercent: 10,
746
763
  });
747
- expect(result).toEqual({ stripeSubscriptionId: "sub_new", status: "active" });
764
+ expect(result).toEqual({
765
+ stripeSubscriptionId: "sub_new",
766
+ status: "active",
767
+ });
768
+ });
769
+
770
+ it("rejects an unknown Stripe status before writing the ledger", async () => {
771
+ const stripe = makeStripe();
772
+ stripe.subscriptions.create.mockResolvedValue(
773
+ createdSub({ status: "future_status" }),
774
+ );
775
+ const ctx = makeCtx();
776
+ await expect(
777
+ createSubscription(asStripe(stripe), makeComponent(), ctx, {
778
+ userId: "buyer_1",
779
+ customerAccount: "acct_buyer",
780
+ stripePriceId: "price_1",
781
+ }),
782
+ ).rejects.toThrow("Unsupported subscription status: future_status");
783
+ expect(ctx.runMutation).not.toHaveBeenCalled();
748
784
  });
749
785
 
750
786
  it("flags percent+fixed fees for per-invoice computation", async () => {
@@ -841,7 +877,9 @@ describe("createSubscription (BTS-17)", () => {
841
877
  userId: "buyer_1",
842
878
  customerAccount: "acct_buyer",
843
879
  stripePriceId: "price_1",
844
- split: [{ destinationAccountId: "acct_store", role: "store", percent: 90 }],
880
+ split: [
881
+ { destinationAccountId: "acct_store", role: "store", percent: 90 },
882
+ ],
845
883
  feeConfig: { percent: 10 },
846
884
  });
847
885
 
@@ -882,13 +920,15 @@ describe("createSubscription (BTS-17)", () => {
882
920
 
883
921
  describe("groupSubscriptionsByStore (BTS-19)", () => {
884
922
  it("groups a buyer's subscriptions by store, preserving first-seen order", () => {
885
- const subs: { stripeSubscriptionId: string; destinationAccountId?: string }[] =
886
- [
887
- { stripeSubscriptionId: "s1", destinationAccountId: "store_a" },
888
- { stripeSubscriptionId: "s2", destinationAccountId: "store_b" },
889
- { stripeSubscriptionId: "s3", destinationAccountId: "store_a" },
890
- { stripeSubscriptionId: "s4" }, // no store → platform-direct
891
- ];
923
+ const subs: {
924
+ stripeSubscriptionId: string;
925
+ destinationAccountId?: string;
926
+ }[] = [
927
+ { stripeSubscriptionId: "s1", destinationAccountId: "store_a" },
928
+ { stripeSubscriptionId: "s2", destinationAccountId: "store_b" },
929
+ { stripeSubscriptionId: "s3", destinationAccountId: "store_a" },
930
+ { stripeSubscriptionId: "s4" }, // no store → platform-direct
931
+ ];
892
932
 
893
933
  const grouped = groupSubscriptionsByStore(subs);
894
934
 
@@ -897,15 +937,15 @@ describe("groupSubscriptionsByStore (BTS-19)", () => {
897
937
  "store_b",
898
938
  null,
899
939
  ]);
900
- expect(
901
- grouped[0].subscriptions.map((s) => s.stripeSubscriptionId),
902
- ).toEqual(["s1", "s3"]);
903
- expect(grouped[1].subscriptions.map((s) => s.stripeSubscriptionId)).toEqual([
904
- "s2",
905
- ]);
906
- expect(grouped[2].subscriptions.map((s) => s.stripeSubscriptionId)).toEqual([
907
- "s4",
908
- ]);
940
+ expect(grouped[0].subscriptions.map((s) => s.stripeSubscriptionId)).toEqual(
941
+ ["s1", "s3"],
942
+ );
943
+ expect(grouped[1].subscriptions.map((s) => s.stripeSubscriptionId)).toEqual(
944
+ ["s2"],
945
+ );
946
+ expect(grouped[2].subscriptions.map((s) => s.stripeSubscriptionId)).toEqual(
947
+ ["s4"],
948
+ );
909
949
  });
910
950
 
911
951
  it("returns an empty array for no subscriptions", () => {
@@ -985,3 +1025,25 @@ describe("createSubscription — per-store statement descriptor (BTS-32)", () =>
985
1025
  expect(ctx.runQuery).not.toHaveBeenCalled();
986
1026
  });
987
1027
  });
1028
+
1029
+ it("passes an explicit attached payment method to Stripe for the first invoice", async () => {
1030
+ const stripe = makeStripe();
1031
+ stripe.subscriptions.create.mockResolvedValue({
1032
+ id: "sub_card",
1033
+ status: "active",
1034
+ items: { data: [] },
1035
+ });
1036
+ await createSubscription(asStripe(stripe), makeComponent(), makeCtx(), {
1037
+ userId: "user",
1038
+ customerAccount: "acct_buyer",
1039
+ stripePriceId: "price_monthly",
1040
+ defaultPaymentMethod: "pm_attached",
1041
+ });
1042
+ expect(stripe.subscriptions.create).toHaveBeenCalledWith(
1043
+ expect.objectContaining({
1044
+ customer_account: "acct_buyer",
1045
+ default_payment_method: "pm_attached",
1046
+ }),
1047
+ undefined,
1048
+ );
1049
+ });
@@ -1,3 +1,5 @@
1
+ import { subscriptionRouting } from "./subscriptionRouting.js";
2
+ import { resolveOwnerAccount } from "../utils/owner.js";
1
3
  import type Stripe from "stripe";
2
4
 
3
5
  import type { SubscriptionStatus } from "../../component/billing/validators.js";
@@ -10,7 +12,10 @@ import {
10
12
  stripReservedMetadata,
11
13
  } from "../helpers.js";
12
14
  import { throwStripeError } from "../errors.js";
13
- import type { PlatformFeeConfig, StripeComponentSubscription } from "../types.js";
15
+ import type {
16
+ PlatformFeeConfig,
17
+ StripeComponentSubscription,
18
+ } from "../types.js";
14
19
  import { componentRef } from "../webhooks/helpers.js";
15
20
 
16
21
  // =============================================================================
@@ -23,7 +28,10 @@ import { componentRef } from "../webhooks/helpers.js";
23
28
  * the Convex action/mutation boundary instead of propagating as a raw `Error`.
24
29
  * This is the Plan 006 convention; see `src/client/errors.ts`.
25
30
  */
26
- async function callStripe<T>(message: string, fn: () => Promise<T>): Promise<T> {
31
+ async function callStripe<T>(
32
+ message: string,
33
+ fn: () => Promise<T>,
34
+ ): Promise<T> {
27
35
  try {
28
36
  return await fn();
29
37
  } catch (err) {
@@ -53,6 +61,8 @@ export async function createSubscription(
53
61
  split?: SplitRecipient[];
54
62
  feeConfig?: PlatformFeeConfig;
55
63
  trialDays?: number;
64
+ /** Attached payment method to use for this subscription. */
65
+ defaultPaymentMethod?: string;
56
66
  /** Platform-default statement-descriptor suffix (BTS-32); see checkout.ts. */
57
67
  defaultStatementDescriptorSuffix?: string;
58
68
  metadata?: Record<string, string>;
@@ -105,6 +115,9 @@ export async function createSubscription(
105
115
 
106
116
  const params: Stripe.SubscriptionCreateParams = {
107
117
  customer_account: opts.customerAccount,
118
+ ...(opts.defaultPaymentMethod
119
+ ? { default_payment_method: opts.defaultPaymentMethod }
120
+ : {}),
108
121
  items: [{ price: opts.stripePriceId }],
109
122
  metadata,
110
123
  };
@@ -125,7 +138,9 @@ export async function createSubscription(
125
138
  ...metadata,
126
139
  bsChargeType: "separate",
127
140
  bsSplit: JSON.stringify(opts.split),
128
- ...(opts.feeConfig ? { bsFeeConfig: JSON.stringify(opts.feeConfig) } : {}),
141
+ ...(opts.feeConfig
142
+ ? { bsFeeConfig: JSON.stringify(opts.feeConfig) }
143
+ : {}),
129
144
  };
130
145
  feeRow.chargeType = "separate";
131
146
  feeRow.splitRecipients = opts.split;
@@ -162,6 +177,25 @@ export async function createSubscription(
162
177
  stripe.subscriptions.create(params, requestOpts),
163
178
  );
164
179
 
180
+ // Stripe 23 permits future status strings; our ledger accepts only these
181
+ // known statuses. Reject an unknown value before attempting to persist it.
182
+ const isKnownStatus = (value: string): value is SubscriptionStatus =>
183
+ value === "active" ||
184
+ value === "canceled" ||
185
+ value === "incomplete" ||
186
+ value === "incomplete_expired" ||
187
+ value === "past_due" ||
188
+ value === "paused" ||
189
+ value === "trialing" ||
190
+ value === "unpaid";
191
+ const status = sub.status;
192
+ if (!isKnownStatus(status)) {
193
+ throwStripeError(
194
+ "STRIPE_API_ERROR",
195
+ `Unsupported subscription status: ${status}`,
196
+ );
197
+ }
198
+
165
199
  const firstItem = sub.items?.data?.[0];
166
200
  await runMutationOrThrow(
167
201
  ctx,
@@ -171,10 +205,12 @@ export async function createSubscription(
171
205
  accountId: opts.customerAccount,
172
206
  userId: opts.userId,
173
207
  orgId: opts.orgId,
174
- status: sub.status,
208
+ status,
175
209
  priceId: firstItem?.price?.id ?? opts.stripePriceId,
176
210
  quantity: firstItem?.quantity ?? undefined,
177
- currentPeriodStart: epochToIso(firstItem?.current_period_start ?? undefined),
211
+ currentPeriodStart: epochToIso(
212
+ firstItem?.current_period_start ?? undefined,
213
+ ),
178
214
  currentPeriodEnd: epochToIso(firstItem?.current_period_end ?? undefined),
179
215
  cancelAtPeriodEnd: sub.cancel_at_period_end,
180
216
  canceledAt: epochToIso(sub.canceled_at),
@@ -186,7 +222,7 @@ export async function createSubscription(
186
222
  },
187
223
  );
188
224
 
189
- return { stripeSubscriptionId: sub.id, status: sub.status };
225
+ return { stripeSubscriptionId: sub.id, status };
190
226
  }
191
227
 
192
228
  export async function getSubscription(
@@ -255,7 +291,8 @@ export async function updateSubscriptionQuantity(
255
291
  stripe.subscriptions.retrieve(opts.stripeSubscriptionId),
256
292
  );
257
293
  const itemId = sub.items?.data?.[0]?.id;
258
- if (!itemId) throwStripeError("SUBSCRIPTION_UPDATE_FAILED", "Subscription has no items");
294
+ if (!itemId)
295
+ throwStripeError("SUBSCRIPTION_UPDATE_FAILED", "Subscription has no items");
259
296
  await callStripe("Failed to update subscription quantity", () =>
260
297
  stripe.subscriptionItems.update(itemId, { quantity: opts.quantity }),
261
298
  );
@@ -263,8 +300,8 @@ export async function updateSubscriptionQuantity(
263
300
  }
264
301
 
265
302
  /**
266
- * Pause collection on a subscription. Sets `pause_collection` (Stripe transitions
267
- * the subscription to `status: "paused"`); the component DB is updated when the
303
+ * Pause collection on a subscription. Sets `pause_collection` without changing
304
+ * the subscription status; the component DB is updated when the
268
305
  * resulting `customer.subscription.updated` webhook arrives.
269
306
  */
270
307
  export async function pauseSubscription(
@@ -288,10 +325,8 @@ export async function pauseSubscription(
288
325
  }
289
326
 
290
327
  /**
291
- * Resume a paused subscription by clearing `pause_collection`. There is no
292
- * dedicated Stripe resume endpoint — passing the empty-string sentinel
293
- * (`Emptyable<PauseCollection>`) clears the field and Stripe returns the
294
- * subscription to its prior active status.
328
+ * Resume payment collection by clearing `pause_collection`. The empty-string
329
+ * sentinel clears the field without changing the subscription status.
295
330
  */
296
331
  export async function resumeSubscription(
297
332
  stripe: Stripe,
@@ -326,7 +361,8 @@ export async function updateSubscriptionPrice(
326
361
  stripe.subscriptions.retrieve(opts.stripeSubscriptionId),
327
362
  );
328
363
  const itemId = sub.items?.data?.[0]?.id;
329
- if (!itemId) throwStripeError("SUBSCRIPTION_UPDATE_FAILED", "Subscription has no items");
364
+ if (!itemId)
365
+ throwStripeError("SUBSCRIPTION_UPDATE_FAILED", "Subscription has no items");
330
366
  await callStripe("Failed to update subscription price", () =>
331
367
  stripe.subscriptions.update(opts.stripeSubscriptionId, {
332
368
  items: [{ id: itemId, price: opts.stripePriceId }],
@@ -546,10 +582,8 @@ export async function syncAllSubscriptions(
546
582
  componentRef(component, "billing/mutations/upsertSubscription"),
547
583
  {
548
584
  stripeSubscriptionId: sub.id,
549
- accountId:
550
- typeof sub.customer === "string"
551
- ? sub.customer
552
- : (sub.customer?.id ?? ""),
585
+ ...subscriptionRouting(sub),
586
+ accountId: resolveOwnerAccount(sub) ?? "",
553
587
  userId,
554
588
  orgId,
555
589
  status: sub.status,
@@ -558,6 +592,7 @@ export async function syncAllSubscriptions(
558
592
  currentPeriodStart: epochToIso(periodStart),
559
593
  currentPeriodEnd: epochToIso(periodEnd),
560
594
  cancelAtPeriodEnd: sub.cancel_at_period_end,
595
+ isCollectionPaused: !!sub.pause_collection,
561
596
  canceledAt: epochToIso(sub.canceled_at),
562
597
  isTrialing: sub.status === "trialing",
563
598
  trialStart: epochToIso(sub.trial_start),
@@ -12,4 +12,4 @@ import type { StripeApiVersion } from "./stripe-types.js";
12
12
  * component (it pins every Stripe client it creates), so it warrants a
13
13
  * component release and changelog entry.
14
14
  */
15
- export const STRIPE_API_VERSION: StripeApiVersion = "2026-05-27.dahlia";
15
+ export const STRIPE_API_VERSION: StripeApiVersion = "2026-09-30.endive";