@fantastic.dev/better-stripe 0.4.0 → 0.6.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 +61 -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
@@ -0,0 +1,196 @@
1
+ // @vitest-environment edge-runtime
2
+ import { getFunctionAddress } from "convex/server";
3
+ import Stripe from "stripe";
4
+ import { afterEach, describe, expect, it, vi } from "vitest";
5
+
6
+ import schema from "../../component/schema.js";
7
+ import { components, initConvexTest } from "../setup.test.js";
8
+ import type { RegisterRoutesConfig } from "../types.js";
9
+ import { handleWebhookRequest } from "./handler.js";
10
+
11
+ const signingSecret = "whsec_retry_regression";
12
+ const modules = import.meta.glob("../../component/**/*.{ts,js,tsx,jsx}");
13
+
14
+ function fixture() {
15
+ const t = initConvexTest();
16
+ t.registerComponent("betterStripe", schema, modules);
17
+ let attempts = 0;
18
+ let unavailable = "";
19
+ const event = {
20
+ id: "evt_checkout_retry",
21
+ object: "event",
22
+ api_version: "2026-09-30.endive",
23
+ type: "checkout.session.completed",
24
+ livemode: false,
25
+ data: {
26
+ object: {
27
+ id: "cs_retry",
28
+ mode: "payment",
29
+ status: "complete",
30
+ payment_status: "paid",
31
+ metadata: { userId: "buyer", purchaseType: "communityMembership" },
32
+ },
33
+ },
34
+ };
35
+ const fulfill = async () => {
36
+ attempts++;
37
+ if (attempts === 1) throw new Error("Transient fulfillment failure");
38
+ };
39
+ const config: RegisterRoutesConfig = {
40
+ stripeSecretKey: "sk_test_local_signature_only",
41
+ webhookSecret: signingSecret,
42
+ retryOnHookError: (verified) => verified.type === event.type,
43
+ events: { [event.type]: fulfill },
44
+ };
45
+ const deliver = async (tamper = false) => {
46
+ const body = JSON.stringify(event);
47
+ const signature = await Stripe.webhooks.generateTestHeaderStringAsync({
48
+ payload: body,
49
+ secret: signingSecret,
50
+ });
51
+ return t.action(async (ctx) => {
52
+ const runMutation: typeof ctx.runMutation = async (ref, args) => {
53
+ if (
54
+ unavailable &&
55
+ getFunctionAddress(ref).reference?.endsWith(`/${unavailable}`)
56
+ ) {
57
+ throw new Error("Ledger unavailable");
58
+ }
59
+ return ctx.runMutation(ref, args);
60
+ };
61
+ const response = await handleWebhookRequest(
62
+ { ...ctx, runMutation },
63
+ new Request("https://example.test/stripe/webhook", {
64
+ method: "POST",
65
+ body: tamper ? body.replace("cs_retry", "cs_tampered") : body,
66
+ headers: { "stripe-signature": signature },
67
+ }),
68
+ components.betterStripe,
69
+ config,
70
+ );
71
+ return { status: response.status, body: await response.json() };
72
+ });
73
+ };
74
+ const ledger = () =>
75
+ t.query(components.betterStripe.webhooks.queries.getWebhookEvent, {
76
+ stripeEventId: event.id,
77
+ });
78
+ return {
79
+ t,
80
+ event,
81
+ config,
82
+ fulfill,
83
+ deliver,
84
+ ledger,
85
+ attempts: () => attempts,
86
+ fail: (operation: string) => {
87
+ unavailable = operation;
88
+ },
89
+ };
90
+ }
91
+
92
+ afterEach(() => vi.useRealTimers());
93
+
94
+ describe("required V1 hooks with the real SDK signature verifier and component ledger", () => {
95
+ it("persists checkout before fulfillment, retries a failed hook, then deduplicates", async () => {
96
+ const f = fixture();
97
+ expect((await f.deliver()).status).toBe(500);
98
+ expect(await f.ledger()).toMatchObject({
99
+ status: "failed",
100
+ lastError: "Transient fulfillment failure",
101
+ });
102
+ expect(
103
+ await f.t.query(
104
+ components.betterStripe.billing.queries.getCheckoutSessionByStripeId,
105
+ { stripeSessionId: "cs_retry" },
106
+ ),
107
+ ).toMatchObject({ status: "complete" });
108
+ expect((await f.deliver()).status).toBe(200);
109
+ expect(await f.ledger()).toMatchObject({ status: "processed" });
110
+ expect(await f.deliver()).toMatchObject({
111
+ status: 200,
112
+ body: { deduplicated: true },
113
+ });
114
+ expect(f.attempts()).toBe(2);
115
+ });
116
+
117
+ it("requires onEvent success as well as the event-specific hook", async () => {
118
+ const f = fixture();
119
+ f.config.onEvent = f.fulfill;
120
+ f.config.events = { [f.event.type]: vi.fn().mockResolvedValue(undefined) };
121
+ expect((await f.deliver()).status).toBe(500);
122
+ expect(f.config.events[f.event.type]).not.toHaveBeenCalled();
123
+ expect((await f.deliver()).status).toBe(200);
124
+ expect(f.config.events[f.event.type]).toHaveBeenCalledTimes(1);
125
+ });
126
+
127
+ it("verifies the signature before running the selector or touching the ledger", async () => {
128
+ const f = fixture();
129
+ f.config.retryOnHookError = vi.fn(() => true);
130
+ expect((await f.deliver(true)).status).toBe(400);
131
+ expect(f.config.retryOnHookError).not.toHaveBeenCalled();
132
+ expect(f.attempts()).toBe(0);
133
+ expect(await f.ledger()).toBeNull();
134
+ });
135
+
136
+ it.each([undefined, () => false])(
137
+ "preserves best-effort hooks when the selector is %s",
138
+ async (selector) => {
139
+ const f = fixture();
140
+ f.config.retryOnHookError = selector;
141
+ expect((await f.deliver()).status).toBe(200);
142
+ expect(await f.ledger()).toMatchObject({ status: "processed" });
143
+ await f.deliver();
144
+ expect(f.attempts()).toBe(1);
145
+ },
146
+ );
147
+
148
+ it("returns 503 for an in-flight delivery without concurrent fulfillment", async () => {
149
+ const f = fixture();
150
+ await f.t.mutation(
151
+ components.betterStripe.webhooks.mutations.insertWebhookEvent,
152
+ {
153
+ stripeEventId: f.event.id,
154
+ eventType: f.event.type,
155
+ livemode: false,
156
+ },
157
+ );
158
+ expect((await f.deliver()).status).toBe(503);
159
+ expect(f.attempts()).toBe(0);
160
+ });
161
+
162
+ it("fails closed if reservation is unavailable", async () => {
163
+ const f = fixture();
164
+ f.fail("insertWebhookEvent");
165
+ expect((await f.deliver()).status).toBe(500);
166
+ expect(await f.ledger()).toBeNull();
167
+ expect(f.attempts()).toBe(0);
168
+ });
169
+
170
+ it.each(["markWebhookEventProcessed", "markWebhookEventFailed"])(
171
+ "recovers a stale reservation after %s fails",
172
+ async (operation) => {
173
+ const f = fixture();
174
+ if (operation === "markWebhookEventProcessed") await f.deliver();
175
+ f.fail(operation);
176
+ expect((await f.deliver()).status).toBe(500);
177
+ expect(await f.ledger()).toMatchObject({ status: "processing" });
178
+ f.fail("");
179
+ expect((await f.deliver()).status).toBe(503);
180
+ vi.useFakeTimers({ toFake: ["Date"] });
181
+ vi.setSystemTime(Date.now() + 11 * 60 * 1000);
182
+ expect((await f.deliver()).status).toBe(200);
183
+ expect(await f.ledger()).toMatchObject({ status: "processed" });
184
+ },
185
+ );
186
+
187
+ it("returns a retryable response when the selection policy throws", async () => {
188
+ const f = fixture();
189
+ f.config.retryOnHookError = () => {
190
+ throw new Error("Policy unavailable");
191
+ };
192
+ expect((await f.deliver()).status).toBe(500);
193
+ expect(await f.ledger()).toBeNull();
194
+ expect(f.attempts()).toBe(0);
195
+ });
196
+ });
@@ -155,6 +155,42 @@ describe("verifyV2Event", () => {
155
155
  });
156
156
 
157
157
  describe("handleV2Event — routing / early returns", () => {
158
+ it("resolves an account pointer without id from its URL", async () => {
159
+ const whCtx = makeWhCtx();
160
+ whCtx.stripe.v2.core.accounts.retrieve.mockResolvedValue(makeAccount());
161
+ expect(
162
+ await handleV2Event(
163
+ whCtx,
164
+ makeThinEvent({
165
+ related_object: {
166
+ type: "v2.core.account",
167
+ url: "/v2/core/accounts/acct_123",
168
+ },
169
+ }),
170
+ ),
171
+ ).toBe("acct_123");
172
+ });
173
+
174
+ it("preserves the verified notification context when refreshing account capabilities", async () => {
175
+ const whCtx = makeWhCtx();
176
+ whCtx.stripe.v2.core.accounts.retrieve.mockResolvedValue(
177
+ makeAccount({
178
+ configuration: {
179
+ merchant: {
180
+ applied: true,
181
+ capabilities: { card_payments: { status: "rejected" } },
182
+ },
183
+ },
184
+ }),
185
+ );
186
+ await handleV2Event(whCtx, makeThinEvent({ context: "org_123/acct_123" }));
187
+ expect(whCtx.stripe.v2.core.accounts.retrieve).toHaveBeenCalledWith(
188
+ "acct_123",
189
+ expect.objectContaining({ include: expect.any(Array) }),
190
+ { stripeContext: "org_123/acct_123" },
191
+ );
192
+ expect(dispatched(whCtx.ctx).data.onboardingStatus).toBe("restricted");
193
+ });
158
194
  it("skips and returns null for a non-account V2 event type", async () => {
159
195
  const info = vi.spyOn(console, "info").mockImplementation(() => {});
160
196
  const whCtx = makeWhCtx();
@@ -58,7 +58,7 @@ export async function handleV2Event(
58
58
  // (type: 'v2.core.account') or a sub-resource (type: 'v2.core.account_person').
59
59
  // For sub-resources, parse the account ID from the URL (/v2/core/accounts/acct_xxx/...).
60
60
  let accountId: string;
61
- if (relatedObject.type === "v2.core.account") {
61
+ if (relatedObject.type === "v2.core.account" && relatedObject.id) {
62
62
  accountId = relatedObject.id;
63
63
  } else if (relatedObject.url) {
64
64
  const match = relatedObject.url.match(/\/accounts\/(acct_[a-zA-Z0-9]+)/);
@@ -74,15 +74,25 @@ export async function handleV2Event(
74
74
  return null;
75
75
  }
76
76
 
77
- const account = await stripe.v2.core.accounts.retrieve(accountId, {
77
+ const retrieveParams = {
78
78
  include: [
79
79
  "configuration.merchant",
80
80
  "configuration.recipient",
81
81
  "configuration.customer",
82
82
  "identity",
83
83
  "requirements",
84
- ],
85
- });
84
+ ] satisfies NonNullable<
85
+ Parameters<Stripe["v2"]["core"]["accounts"]["retrieve"]>[1]
86
+ >["include"],
87
+ };
88
+ // Preserve the verified notification's account/organization context when
89
+ // fetching the latest resource; otherwise organization keys can fetch from
90
+ // the wrong account. Keep the raw string in the public thin-event payload.
91
+ const account = thinEvent.context
92
+ ? await stripe.v2.core.accounts.retrieve(accountId, retrieveParams, {
93
+ stripeContext: thinEvent.context,
94
+ })
95
+ : await stripe.v2.core.accounts.retrieve(accountId, retrieveParams);
86
96
  const identity = account.identity;
87
97
  const metadata = (account.metadata ?? {}) as Record<string, string>;
88
98
  const { userId, orgId } = extractIdentifiers(metadata);
@@ -108,6 +108,7 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
108
108
  destinationAccountId?: string;
109
109
  feeCollectedAmount?: number;
110
110
  feeRefundedAmount?: number;
111
+ isCollectionPaused?: boolean;
111
112
  isTrialing: boolean;
112
113
  metadata?: any;
113
114
  orgId?: string;
@@ -157,6 +158,7 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
157
158
  destinationAccountId?: string;
158
159
  feeCollectedAmount?: number;
159
160
  feeRefundedAmount?: number;
161
+ isCollectionPaused?: boolean;
160
162
  isTrialing: boolean;
161
163
  metadata?: any;
162
164
  orgId?: string;
@@ -307,6 +309,7 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
307
309
  destinationAccountId?: string;
308
310
  feeCollectedAmount?: number;
309
311
  feeRefundedAmount?: number;
312
+ isCollectionPaused?: boolean;
310
313
  isTrialing: boolean;
311
314
  metadata?: any;
312
315
  orgId?: string;
@@ -353,6 +356,7 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
353
356
  destinationAccountId?: string;
354
357
  feeCollectedAmount?: number;
355
358
  feeRefundedAmount?: number;
359
+ isCollectionPaused?: boolean;
356
360
  isTrialing: boolean;
357
361
  metadata?: any;
358
362
  orgId?: string;
@@ -513,6 +517,7 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
513
517
  destinationAccountId?: string;
514
518
  feeCollectedAmount?: number;
515
519
  feeRefundedAmount?: number;
520
+ isCollectionPaused?: boolean;
516
521
  isTrialing: boolean;
517
522
  metadata?: any;
518
523
  orgId?: string;
@@ -559,6 +564,7 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
559
564
  destinationAccountId?: string;
560
565
  feeCollectedAmount?: number;
561
566
  feeRefundedAmount?: number;
567
+ isCollectionPaused?: boolean;
562
568
  isTrialing: boolean;
563
569
  metadata?: any;
564
570
  orgId?: string;
@@ -605,6 +611,7 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
605
611
  destinationAccountId?: string;
606
612
  feeCollectedAmount?: number;
607
613
  feeRefundedAmount?: number;
614
+ isCollectionPaused?: boolean;
608
615
  isTrialing: boolean;
609
616
  metadata?: any;
610
617
  orgId?: string;
@@ -663,6 +670,7 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
663
670
  destinationAccountId?: string;
664
671
  feeCollectedAmount?: number;
665
672
  feeRefundedAmount?: number;
673
+ isCollectionPaused?: boolean;
666
674
  isTrialing: boolean;
667
675
  metadata?: any;
668
676
  orgId?: string;
@@ -1325,10 +1333,7 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
1325
1333
  missingRequirements?: Array<string>;
1326
1334
  name?: string;
1327
1335
  onboardingStatus?:
1328
- | "pending"
1329
- | "in_progress"
1330
- | "complete"
1331
- | "restricted";
1336
+ "pending" | "in_progress" | "complete" | "restricted";
1332
1337
  orgId?: string;
1333
1338
  requirements?: any;
1334
1339
  stripeAccountId: string;
@@ -1352,10 +1357,7 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
1352
1357
  missingRequirements?: Array<string>;
1353
1358
  name?: string;
1354
1359
  onboardingStatus:
1355
- | "pending"
1356
- | "in_progress"
1357
- | "complete"
1358
- | "restricted";
1360
+ "pending" | "in_progress" | "complete" | "restricted";
1359
1361
  orgId?: string;
1360
1362
  requirements?: any;
1361
1363
  stripeAccountId: string;
@@ -1384,10 +1386,7 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
1384
1386
  missingRequirements?: Array<string>;
1385
1387
  name?: string;
1386
1388
  onboardingStatus:
1387
- | "pending"
1388
- | "in_progress"
1389
- | "complete"
1390
- | "restricted";
1389
+ "pending" | "in_progress" | "complete" | "restricted";
1391
1390
  orgId?: string;
1392
1391
  requirements?: any;
1393
1392
  statementDescriptor?: string;
@@ -1414,10 +1413,7 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
1414
1413
  missingRequirements?: Array<string>;
1415
1414
  name?: string;
1416
1415
  onboardingStatus:
1417
- | "pending"
1418
- | "in_progress"
1419
- | "complete"
1420
- | "restricted";
1416
+ "pending" | "in_progress" | "complete" | "restricted";
1421
1417
  orgId?: string;
1422
1418
  requirements?: any;
1423
1419
  statementDescriptor?: string;
@@ -1444,10 +1440,7 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
1444
1440
  missingRequirements?: Array<string>;
1445
1441
  name?: string;
1446
1442
  onboardingStatus:
1447
- | "pending"
1448
- | "in_progress"
1449
- | "complete"
1450
- | "restricted";
1443
+ "pending" | "in_progress" | "complete" | "restricted";
1451
1444
  orgId?: string;
1452
1445
  requirements?: any;
1453
1446
  statementDescriptor?: string;
@@ -1474,10 +1467,7 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
1474
1467
  missingRequirements?: Array<string>;
1475
1468
  name?: string;
1476
1469
  onboardingStatus:
1477
- | "pending"
1478
- | "in_progress"
1479
- | "complete"
1480
- | "restricted";
1470
+ "pending" | "in_progress" | "complete" | "restricted";
1481
1471
  orgId?: string;
1482
1472
  requirements?: any;
1483
1473
  statementDescriptor?: string;
@@ -1495,10 +1485,7 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
1495
1485
  isReady: boolean;
1496
1486
  missingRequirements: Array<string>;
1497
1487
  onboardingStatus:
1498
- | "pending"
1499
- | "in_progress"
1500
- | "complete"
1501
- | "restricted";
1488
+ "pending" | "in_progress" | "complete" | "restricted";
1502
1489
  } | null,
1503
1490
  Name
1504
1491
  >;
@@ -31,9 +31,15 @@ import {
31
31
  import type { DataModel } from "./dataModel.js";
32
32
 
33
33
  /**
34
- * Typesafe environment variables declared in `convex.config.ts`.
34
+ * Typesafe environment variables.
35
+ *
36
+ * This includes platform-provided env vars and any variables declared in
37
+ * `convex.config.ts`.
35
38
  */
36
39
  type Env = {
40
+ readonly CONVEX_CLOUD_URL: string;
41
+ readonly CONVEX_SITE_URL: string;
42
+ readonly STRIPE_PUBLISHABLE_KEY: string | undefined;
37
43
  readonly STRIPE_SECRET_KEY: string;
38
44
  };
39
45
 
@@ -113,7 +119,8 @@ export const internalAction: ActionBuilder<DataModel, "internal"> =
113
119
  * @returns The wrapped function. Import this function from `convex/http.js` and route it to hook it up.
114
120
  */
115
121
  export const httpAction: HttpActionBuilder = httpActionGeneric;
116
- export const env: Env = process.env as unknown as Env;
122
+ export const env: Env = (globalThis as unknown as { process: { env: Env } })
123
+ .process.env;
117
124
 
118
125
  /**
119
126
  * A set of services for use within Convex query functions.
@@ -25,6 +25,7 @@ export const upsertSubscription = mutation({
25
25
  currentPeriodStart: v.optional(v.string()),
26
26
  currentPeriodEnd: v.optional(v.string()),
27
27
  cancelAtPeriodEnd: v.boolean(),
28
+ isCollectionPaused: v.optional(v.boolean()),
28
29
  canceledAt: v.optional(v.string()),
29
30
  isTrialing: v.boolean(),
30
31
  trialStart: v.optional(v.string()),
@@ -46,6 +46,7 @@ export const subscriptionFields = {
46
46
  currentPeriodStart: v.optional(v.string()),
47
47
  currentPeriodEnd: v.optional(v.string()),
48
48
  cancelAtPeriodEnd: v.boolean(),
49
+ isCollectionPaused: v.optional(v.boolean()),
49
50
  canceledAt: v.optional(v.string()),
50
51
  isTrialing: v.boolean(),
51
52
  trialStart: v.optional(v.string()),
@@ -7,10 +7,7 @@ import schema from "./schema.js";
7
7
 
8
8
  const modules = import.meta.glob("./**/*.*s");
9
9
 
10
- // These queries read the component's Stripe env vars. `STRIPE_SECRET_KEY` is
11
- // declared (required) in `convex.config.ts`; `STRIPE_PUBLISHABLE_KEY` is
12
- // intentionally NOT declared (it is a frontend-only value), so the component
13
- // never receives it and `getPublishableKey` always resolves to null.
10
+ // Both keys are declared in the component environment and passed by the app.
14
11
  describe("core/queries — Stripe config from declared env", () => {
15
12
  const originalSecret = process.env.STRIPE_SECRET_KEY;
16
13
  const originalPublishable = process.env.STRIPE_PUBLISHABLE_KEY;
@@ -44,4 +41,11 @@ describe("core/queries — Stripe config from declared env", () => {
44
41
  const t = convexTest(schema, modules);
45
42
  expect(await t.query(api.core.queries.getPublishableKey, {})).toBeNull();
46
43
  });
44
+ it("getPublishableKey: returns the configured test publishable key", async () => {
45
+ process.env.STRIPE_PUBLISHABLE_KEY = "pk_test_abc123";
46
+ const t = convexTest(schema, modules);
47
+ expect(await t.query(api.core.queries.getPublishableKey, {})).toBe(
48
+ "pk_test_abc123",
49
+ );
50
+ });
47
51
  });
@@ -339,9 +339,6 @@ export const listTransfersByAccount = query({
339
339
  // REVERSAL-CLAIM RECLAIM (BTS-74) — ops diagnostics for wedged legs
340
340
  // =============================================================================
341
341
 
342
- /** Page size for the wedged-claim scan. */
343
- const RECLAIM_PAGE_SIZE = 200;
344
-
345
342
  /**
346
343
  * Fetch one reversal operation's claim record (BTS-63/74) by its id, or null.
347
344
  * The `_creationTime` lets the reclaim path age-gate a release against Stripe's
@@ -367,7 +364,7 @@ export const getReversalOp = query({
367
364
  * successor. For each wedged leg, returns the recorded ops holding an
368
365
  * unexecuted slice on it (the blockers ops must reclaim).
369
366
  *
370
- * An admin/ops diagnostic, not a hot path. It paginates the `transfers` and
367
+ * An admin/ops diagnostic, not a hot path. It iterates the `transfers` and
371
368
  * `transferReversalOps` tables to completion, so it is exact up to Convex's
372
369
  * per-query document-read limit (16,384) — beyond which it throws (loud) rather
373
370
  * than silently truncating, matching the BTS-64 aggregate convention.
@@ -378,18 +375,10 @@ export const listWedgedReversalClaims = query({
378
375
  handler: async (ctx) => {
379
376
  // Collect the wedged legs first.
380
377
  const wedged: Doc<"transfers">[] = [];
381
- let cursor: string | null = null;
382
- for (;;) {
383
- const page = await ctx.db
384
- .query("transfers")
385
- .paginate({ cursor, numItems: RECLAIM_PAGE_SIZE });
386
- for (const tr of page.page) {
387
- if ((tr.reversalClaimedAmount ?? 0) > (tr.reversedAmount ?? 0)) {
388
- wedged.push(tr);
389
- }
378
+ for await (const tr of ctx.db.query("transfers")) {
379
+ if ((tr.reversalClaimedAmount ?? 0) > (tr.reversedAmount ?? 0)) {
380
+ wedged.push(tr);
390
381
  }
391
- if (page.isDone) break;
392
- cursor = page.continueCursor;
393
382
  }
394
383
  if (wedged.length === 0) return [];
395
384
 
@@ -404,22 +393,14 @@ export const listWedgedReversalClaims = query({
404
393
  string,
405
394
  { operationId: string; from: number; to: number }[]
406
395
  >();
407
- cursor = null;
408
- for (;;) {
409
- const page = await ctx.db
410
- .query("transferReversalOps")
411
- .paginate({ cursor, numItems: RECLAIM_PAGE_SIZE });
412
- for (const op of page.page) {
413
- for (const s of op.slices) {
414
- if (!wedgedIds.has(s.stripeTransferId)) continue;
415
- if (s.to <= (confirmedById.get(s.stripeTransferId) ?? 0)) continue;
416
- const list = blockersByLeg.get(s.stripeTransferId) ?? [];
417
- list.push({ operationId: op.operationId, from: s.from, to: s.to });
418
- blockersByLeg.set(s.stripeTransferId, list);
419
- }
396
+ for await (const op of ctx.db.query("transferReversalOps")) {
397
+ for (const s of op.slices) {
398
+ if (!wedgedIds.has(s.stripeTransferId)) continue;
399
+ if (s.to <= (confirmedById.get(s.stripeTransferId) ?? 0)) continue;
400
+ const list = blockersByLeg.get(s.stripeTransferId) ?? [];
401
+ list.push({ operationId: op.operationId, from: s.from, to: s.to });
402
+ blockersByLeg.set(s.stripeTransferId, list);
420
403
  }
421
- if (page.isDone) break;
422
- cursor = page.continueCursor;
423
404
  }
424
405
 
425
406
  return wedged.map((t) => ({
@@ -441,20 +422,18 @@ export const listWedgedReversalClaims = query({
441
422
  //
442
423
  // The row-list queries above `.take(50)`, so summing their rows client-side
443
424
  // (as the earnings hooks did) silently understated totals for accounts with
444
- // more than 50 transfers/payouts. These aggregates paginate the account's
425
+ // more than 50 transfers/payouts. These aggregates iterate the account's
445
426
  // ledger to completion and return EXACT totals regardless of row count, plus a
446
427
  // bounded preview of rows for drill-down UIs. Exact up to Convex's per-query
447
- // document-read limit (a single query may scan at most 16,384 documents);
428
+ // document/byte read limits (currently 32,000 documents or 16 MiB; see
429
+ // https://docs.convex.dev/production/state/limits);
448
430
  // beyond that the query throws (loud) rather than silently truncating —
449
431
  // strictly safer than a fixed cap for financial figures.
450
432
 
451
- /** Page size for the internal pagination loops. */
452
- const EARNINGS_PAGE_SIZE = 200;
453
-
454
433
  /**
455
434
  * Exact earnings totals for one connected account (BTS-64): `gross` (sum of all
456
435
  * transfer amounts, incl. reinstatements — the earnings view) and `reversed`
457
- * (sum of all `reversedAmount`), paginated to completion so totals are never
436
+ * (sum of all `reversedAmount`), iterated to completion so totals are never
458
437
  * capped. `transfers` is a bounded preview (`previewLimit ?? 50` rows) for
459
438
  * drill-down; use `gross`/`reversed`/`transferCount` for the money figures.
460
439
  */
@@ -476,22 +455,15 @@ export const getAccountEarnings = query({
476
455
  let transferCount = 0;
477
456
  const transfers: Doc<"transfers">[] = [];
478
457
 
479
- let cursor: string | null = null;
480
- for (;;) {
481
- const page = await ctx.db
482
- .query("transfers")
483
- .withIndex("by_destination_account_id", (q) =>
484
- q.eq("destinationAccountId", args.destinationAccountId),
485
- )
486
- .paginate({ cursor, numItems: EARNINGS_PAGE_SIZE });
487
- for (const tr of page.page) {
488
- gross += tr.amount;
489
- reversed += tr.reversedAmount ?? 0;
490
- transferCount += 1;
491
- if (transfers.length < previewLimit) transfers.push(tr);
492
- }
493
- if (page.isDone) break;
494
- cursor = page.continueCursor;
458
+ for await (const tr of ctx.db
459
+ .query("transfers")
460
+ .withIndex("by_destination_account_id", (q) =>
461
+ q.eq("destinationAccountId", args.destinationAccountId),
462
+ )) {
463
+ gross += tr.amount;
464
+ reversed += tr.reversedAmount ?? 0;
465
+ transferCount += 1;
466
+ if (transfers.length < previewLimit) transfers.push(tr);
495
467
  }
496
468
 
497
469
  return { gross, reversed, transferCount, transfers };
@@ -500,7 +472,7 @@ export const getAccountEarnings = query({
500
472
 
501
473
  /**
502
474
  * Exact payout totals for one connected account (BTS-64): `paidOut` (sum of all
503
- * `paid` payout amounts), paginated to completion so it is never capped.
475
+ * `paid` payout amounts), iterated to completion so it is never capped.
504
476
  * `payouts` is a bounded preview (`previewLimit ?? 50` rows) for drill-down; use
505
477
  * `paidOut`/`payoutCount` for the money figures.
506
478
  */
@@ -517,19 +489,12 @@ export const getAccountPayouts = query({
517
489
  let payoutCount = 0;
518
490
  const payouts: Doc<"payouts">[] = [];
519
491
 
520
- let cursor: string | null = null;
521
- for (;;) {
522
- const page = await ctx.db
523
- .query("payouts")
524
- .withIndex("by_account_id", (q) => q.eq("accountId", args.accountId))
525
- .paginate({ cursor, numItems: EARNINGS_PAGE_SIZE });
526
- for (const p of page.page) {
527
- payoutCount += 1;
528
- if (p.status === "paid") paidOut += p.amount;
529
- if (payouts.length < previewLimit) payouts.push(p);
530
- }
531
- if (page.isDone) break;
532
- cursor = page.continueCursor;
492
+ for await (const p of ctx.db
493
+ .query("payouts")
494
+ .withIndex("by_account_id", (q) => q.eq("accountId", args.accountId))) {
495
+ payoutCount += 1;
496
+ if (p.status === "paid") paidOut += p.amount;
497
+ if (payouts.length < previewLimit) payouts.push(p);
533
498
  }
534
499
 
535
500
  return { paidOut, payoutCount, payouts };
@@ -3,7 +3,7 @@ import { v } from "convex/values";
3
3
 
4
4
  const component = defineComponent("betterStripe", {
5
5
  env: {
6
- // The only env var the component itself reads (core/queries.ts: getStripeMode).
6
+ // Server-side credentials used by core/queries.ts: getStripeMode.
7
7
  // Declared as required so Convex validates its presence at push time; the
8
8
  // installing app wires it in via
9
9
  // `app.use(betterStripe, { env: { STRIPE_SECRET_KEY: app.env.STRIPE_SECRET_KEY } })`.
@@ -12,6 +12,7 @@ const component = defineComponent("betterStripe", {
12
12
  // here: they are read by the app layer (registerRoutes) directly from the
13
13
  // deployment env, never by the component.
14
14
  STRIPE_SECRET_KEY: v.string(),
15
+ STRIPE_PUBLISHABLE_KEY: v.optional(v.string()),
15
16
  },
16
17
  });
17
18
  export default component;