@fantastic.dev/better-stripe 0.10.0 → 0.10.2

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 (55) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/MIGRATION.md +23 -0
  3. package/README.md +33 -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/subscriptionCancellation.d.ts +4 -0
  9. package/dist/client/billing/subscriptionCancellation.d.ts.map +1 -0
  10. package/dist/client/billing/subscriptionCancellation.js +7 -0
  11. package/dist/client/billing/subscriptionCancellation.js.map +1 -0
  12. package/dist/client/billing/subscriptions.d.ts.map +1 -1
  13. package/dist/client/billing/subscriptions.js +3 -2
  14. package/dist/client/billing/subscriptions.js.map +1 -1
  15. package/dist/client/index.d.ts +8 -12
  16. package/dist/client/index.d.ts.map +1 -1
  17. package/dist/client/index.js +18 -4
  18. package/dist/client/index.js.map +1 -1
  19. package/dist/client/previewRoutes.d.ts +9 -0
  20. package/dist/client/previewRoutes.d.ts.map +1 -0
  21. package/dist/client/previewRoutes.js +15 -0
  22. package/dist/client/previewRoutes.js.map +1 -0
  23. package/dist/client/types/options.d.ts +3 -0
  24. package/dist/client/types/options.d.ts.map +1 -1
  25. package/dist/client/utils/webhookEndpoints.d.ts +12 -8
  26. package/dist/client/utils/webhookEndpoints.d.ts.map +1 -1
  27. package/dist/client/utils/webhookEndpoints.js +41 -2
  28. package/dist/client/utils/webhookEndpoints.js.map +1 -1
  29. package/dist/client/webhooks/processors.d.ts.map +1 -1
  30. package/dist/client/webhooks/processors.js +2 -1
  31. package/dist/client/webhooks/processors.js.map +1 -1
  32. package/dist/client/webhooks/v2.d.ts.map +1 -1
  33. package/dist/client/webhooks/v2.js +10 -7
  34. package/dist/client/webhooks/v2.js.map +1 -1
  35. package/dist/component/lib/stripeFactory.d.ts +3 -2
  36. package/dist/component/lib/stripeFactory.d.ts.map +1 -1
  37. package/dist/component/lib/stripeFactory.js +6 -5
  38. package/dist/component/lib/stripeFactory.js.map +1 -1
  39. package/docs/accounts-v2-preview.md +142 -0
  40. package/package.json +2 -1
  41. package/src/client/apiVersion.ts +20 -0
  42. package/src/client/billing/subscriptionCancellation.test.ts +68 -0
  43. package/src/client/billing/subscriptionCancellation.ts +10 -0
  44. package/src/client/billing/subscriptions.ts +3 -2
  45. package/src/client/customer-preview-thin.test.ts +41 -0
  46. package/src/client/customer-preview-webhooks.test.ts +150 -0
  47. package/src/client/customer-preview.test.ts +185 -0
  48. package/src/client/index.ts +31 -16
  49. package/src/client/previewRoutes.ts +26 -0
  50. package/src/client/stripe-version.test.ts +10 -4
  51. package/src/client/types/options.ts +3 -0
  52. package/src/client/utils/webhookEndpoints.ts +70 -15
  53. package/src/client/webhooks/processors.ts +2 -1
  54. package/src/client/webhooks/v2.ts +14 -10
  55. package/src/component/lib/stripeFactory.ts +13 -5
@@ -0,0 +1,142 @@
1
+ # Customer-only Accounts v2 preview
2
+
3
+ Connect platforms keep the default `2026-09-30.endive` API version. Non-Connect
4
+ merchants can explicitly opt into `2026-09-30.preview` for customer-configured
5
+ Accounts. The package does not enable Connect or onboard a merchant/recipient
6
+ when creating a buyer with `ensureCustomerAccount`.
7
+
8
+ Connect users do not need the **Reusable payment methods for Global Payouts**
9
+ toggle merely to use GA Accounts v2, ordinary Connect payouts, or marketplace
10
+ splits. Non-Connect merchants using customer-configured Accounts do need that
11
+ preview enabled, plus the supported preview request version. Actual Global
12
+ Payouts credential reuse has separate preview eligibility for all users; see
13
+ [Stripe's credential-reuse guide](https://docs.stripe.com/global-payouts/credential-reuse).
14
+ Selecting a different API-reference version in Stripe's documentation does not
15
+ enable the account feature or extend BetterStripe's supported-version list.
16
+
17
+ ## Enable the sandbox feature first
18
+
19
+ In the intended sandbox, the account owner must enable **Reusable payment
20
+ methods for Global Payouts** under **Account previews and features**. If the toggle
21
+ is unavailable or disabled, check account-owner permissions and request access
22
+ using Stripe's form. A disabled toggle alone does not identify the cause. This also enables Stripe's
23
+ reusable payout credentials preview; it is not a Connect onboarding flow.
24
+ Changing an API version cannot enable the feature on the Stripe account.
25
+
26
+ Enabling the preview synchronizes existing Customers with customer-configured
27
+ Accounts and changes how Stripe-hosted integrations create customers. Review
28
+ Stripe's limitations and migration behavior before changing any live account.
29
+ See [Stripe's customer Account guide](https://docs.stripe.com/accounts-v2/use-accounts-as-customers).
30
+
31
+ ## Configure one client and its webhook route
32
+
33
+ ```ts
34
+ // convex/stripe.ts
35
+ import {
36
+ BetterStripe,
37
+ STRIPE_CUSTOMER_PREVIEW_API_VERSION,
38
+ } from "@fantastic.dev/better-stripe";
39
+ import { components } from "./_generated/api";
40
+
41
+ export const stripe = new BetterStripe(components.betterStripe, {
42
+ STRIPE_SECRET_KEY: process.env.STRIPE_SECRET_KEY,
43
+ stripeApiVersion: STRIPE_CUSTOMER_PREVIEW_API_VERSION,
44
+ });
45
+ ```
46
+
47
+ ```ts
48
+ // convex/http.ts
49
+ import { httpRouter } from "convex/server";
50
+ import { stripe } from "./stripe";
51
+
52
+ const http = httpRouter();
53
+ stripe.registerRoutes(http, {
54
+ webhookPath: "/stripe/webhook",
55
+ webhookSecret: process.env.STRIPE_WEBHOOK_SECRET,
56
+ webhookSecretV2: process.env.STRIPE_WEBHOOK_SECRET_V2,
57
+ // Preserve your existing `webhooks` handler references and retry policy here.
58
+ });
59
+ export default http;
60
+ ```
61
+
62
+ The instance route inherits its client's key and version. Conflicting explicit
63
+ values fail at registration. The standalone `registerRoutes` function remains
64
+ available; when using it, pass `stripeApiVersion: stripe.stripeApiVersion` and the
65
+ same secret key explicitly. The client accepts only the supported GA and customer
66
+ preview versions; unsupported values fail with `INVALID_CONFIGURATION`.
67
+
68
+ All client operations use the selected version: account creation/configuration,
69
+ Checkout, subscriptions, portal sessions, refunds, authoritative reads, and the
70
+ provider calls made while handling webhooks. Component database writes remain
71
+ provider-version independent. SDK caches separate clients by key and version.
72
+
73
+ ## Create a buyer and Checkout Session
74
+
75
+ Authenticate the caller and validate ownership/prices on the server. The client
76
+ is not an authorization boundary. Persist logical request identities in your host
77
+ app so retries keep the same provider keys and new purchases use distinct keys.
78
+
79
+ ```ts
80
+ const buyer = await stripe.ensureCustomerAccount(ctx, {
81
+ userId: identity.subject,
82
+ email: verifiedEmail,
83
+ idempotencyKey: accountProvisioningKey,
84
+ });
85
+ const checkout = await stripe.createCheckoutSession(ctx, {
86
+ userId: identity.subject,
87
+ accountId: buyer.stripeAccountId,
88
+ stripePriceId: authorizedPriceId,
89
+ mode: "subscription",
90
+ uiMode: "redirect",
91
+ returnUrl: "https://example.com/receipt?session_id={CHECKOUT_SESSION_ID}",
92
+ idempotencyKey: orderKey,
93
+ });
94
+ ```
95
+
96
+ The buyer is an `acct_` object with the customer configuration. Checkout uses
97
+ `customer_account`, not a connected-account destination. No seller, recipient,
98
+ transfer, or application fee is added by this configuration.
99
+
100
+ After verifying buyer/order ownership, use `getCheckoutSessionFromStripe` to
101
+ check current payment status. Completion alone does not prove payment: fulfill
102
+ only when your payment policy accepts the authoritative status, and deduplicate
103
+ fulfillment by a durable order key. Subscription and coupon reads use the same
104
+ configured version. Cancellation/reactivation and refunds keep their existing
105
+ public API signatures and caller idempotency behavior.
106
+
107
+ ## Match webhook payload versions and scope
108
+
109
+ Snapshot destinations created through this client pin `snapshot_api_version` to
110
+ the selected version. V1 webhook endpoints pin their `api_version`. Thin events
111
+ are unversioned; their related customer Account is fetched with the selected
112
+ request version and only customer/identity includes in customer-preview mode.
113
+
114
+ The preview's default thin subscription covers Account creation, customer
115
+ configuration changes, and identity changes, using Stripe's `@self` and
116
+ `@accounts` scopes. Subscription and invoice changes continue to use V1 snapshot
117
+ events. No merchant or recipient capability events are subscribed by default in
118
+ customer-preview mode. The GA defaults retain their existing event selection.
119
+
120
+ Stripe cannot update a destination's snapshot version or event scopes in place.
121
+ `setupEventDestination` refuses an incompatible existing destination rather than
122
+ silently reusing it. Create a replacement at a new webhook path, register the
123
+ matching route and signing secret, verify deliveries, then retire the old
124
+ endpoint. A low-level `createWebhookEndpoint` call retains its existing same-URL
125
+ secret-rotation behavior; do not use it as an implicit migration plan.
126
+
127
+ See [event destination creation](https://docs.stripe.com/api/v2/core/event-destinations/create)
128
+ and [update restrictions](https://docs.stripe.com/api/v2/core/event-destinations/update).
129
+
130
+ ## Verification boundary
131
+
132
+ Package tests exercise serialized SDK request headers, customer-only account
133
+ configuration, idempotency keys, Checkout buyer binding, authoritative reads,
134
+ subscription cancellation/reactivation, portal and refund calls, webhook route
135
+ configuration, thin-event synchronization, and snapshot version/scope checks.
136
+ The GA version has regression coverage alongside the preview version.
137
+
138
+ A real non-Connect sandbox test additionally requires the owner-enabled preview
139
+ and sandbox credentials. A Connect sandbox, mocked transport, or successful
140
+ TypeScript compilation does not prove that separate eligibility requirement.
141
+ Do not report checkout, receipt, renewal, portal or refund acceptance as verified
142
+ for a non-Connect merchant until those flows run in its enabled sandbox.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fantastic.dev/better-stripe",
3
- "version": "0.10.0",
3
+ "version": "0.10.2",
4
4
  "description": "A Convex component for Stripe V2 Accounts API integration.",
5
5
  "keywords": [
6
6
  "stripe",
@@ -59,6 +59,7 @@
59
59
  "src",
60
60
  "docs/react-workflows.md",
61
61
  "docs/settlement.md",
62
+ "docs/accounts-v2-preview.md",
62
63
  "docs/react-workflows-audit.md",
63
64
  "CHANGELOG.md",
64
65
  "MIGRATION.md"
@@ -0,0 +1,20 @@
1
+ import { STRIPE_API_VERSION } from "./constants.js";
2
+ import { throwStripeError } from "./errors.js";
3
+ /** Explicit opt-in for Stripe's customer-only Accounts v2 public preview. */
4
+ export const STRIPE_CUSTOMER_PREVIEW_API_VERSION = "2026-09-30.preview";
5
+ export type SupportedStripeApiVersion =
6
+ typeof STRIPE_API_VERSION | typeof STRIPE_CUSTOMER_PREVIEW_API_VERSION;
7
+ export function resolveStripeApiVersion(
8
+ version?: string,
9
+ ): SupportedStripeApiVersion {
10
+ if (version === undefined) return STRIPE_API_VERSION;
11
+ if (
12
+ version === STRIPE_API_VERSION ||
13
+ version === STRIPE_CUSTOMER_PREVIEW_API_VERSION
14
+ )
15
+ return version;
16
+ throwStripeError(
17
+ "INVALID_CONFIGURATION",
18
+ `Unsupported Stripe API version. Use ${STRIPE_API_VERSION} or ${STRIPE_CUSTOMER_PREVIEW_API_VERSION}.`,
19
+ );
20
+ }
@@ -0,0 +1,68 @@
1
+ import type Stripe from "stripe";
2
+ import { componentsGeneric } from "convex/server";
3
+ import { expect, it, vi } from "vitest";
4
+ import type { Component, RunCtx } from "../helpers.js";
5
+ import type { WebhookContext } from "../webhooks/helpers.js";
6
+ import { processEvent } from "../webhooks/processors.js";
7
+ import { syncAllSubscriptions } from "./subscriptions.js";
8
+
9
+ const component = componentsGeneric().betterStripe as unknown as Component;
10
+ function subscription(cancelAt: number | null) {
11
+ return {
12
+ id: "sub_portal",
13
+ customer_account: "acct_buyer",
14
+ status: "active",
15
+ cancel_at_period_end: false,
16
+ cancel_at: cancelAt,
17
+ metadata: { userId: "buyer" },
18
+ items: { data: [{ current_period_end: 2000 }] },
19
+ } as unknown as Stripe.Subscription;
20
+ }
21
+ it.each([
22
+ [2000, true],
23
+ [1900, false],
24
+ [null, false],
25
+ ] as const)(
26
+ "normalizes portal cancel_at=%s without misclassifying other dates",
27
+ async (cancelAt, expected) => {
28
+ const runMutation = vi.fn();
29
+ const whCtx = {
30
+ ctx: { runMutation },
31
+ component,
32
+ stripe: {} as Stripe,
33
+ } as unknown as WebhookContext;
34
+ await processEvent(whCtx, {
35
+ type: "customer.subscription.updated",
36
+ data: { object: subscription(cancelAt) },
37
+ } as Stripe.Event);
38
+ expect(runMutation).toHaveBeenCalledWith(
39
+ expect.anything(),
40
+ expect.objectContaining({
41
+ stripeSubscriptionId: "sub_portal",
42
+ cancelAtPeriodEnd: expected,
43
+ }),
44
+ );
45
+ },
46
+ );
47
+ it("keeps portal-scheduled cancellation when rebuilding the component store", async () => {
48
+ const stripe = {
49
+ subscriptions: {
50
+ list: () => ({
51
+ async *[Symbol.asyncIterator]() {
52
+ yield subscription(2000);
53
+ },
54
+ }),
55
+ },
56
+ } as unknown as Stripe;
57
+ const runMutation = vi.fn();
58
+ await syncAllSubscriptions(stripe, component, {
59
+ runMutation,
60
+ } as unknown as RunCtx);
61
+ expect(runMutation).toHaveBeenCalledWith(
62
+ expect.anything(),
63
+ expect.objectContaining({
64
+ stripeSubscriptionId: "sub_portal",
65
+ cancelAtPeriodEnd: true,
66
+ }),
67
+ );
68
+ });
@@ -0,0 +1,10 @@
1
+ import type Stripe from "stripe";
2
+
3
+ /** Portal cancellation can use cancel_at instead of cancel_at_period_end. */
4
+ export function cancelsAtPeriodEnd(subscription: Stripe.Subscription): boolean {
5
+ const periodEnd = subscription.items?.data?.[0]?.current_period_end;
6
+ return (
7
+ subscription.cancel_at_period_end ||
8
+ (periodEnd != null && subscription.cancel_at === periodEnd)
9
+ );
10
+ }
@@ -1,4 +1,5 @@
1
1
  import { subscriptionRouting } from "./subscriptionRouting.js";
2
+ import { cancelsAtPeriodEnd } from "./subscriptionCancellation.js";
2
3
  import { resolveOwnerAccount } from "../utils/owner.js";
3
4
  import type Stripe from "stripe";
4
5
 
@@ -212,7 +213,7 @@ export async function createSubscription(
212
213
  firstItem?.current_period_start ?? undefined,
213
214
  ),
214
215
  currentPeriodEnd: epochToIso(firstItem?.current_period_end ?? undefined),
215
- cancelAtPeriodEnd: sub.cancel_at_period_end,
216
+ cancelAtPeriodEnd: cancelsAtPeriodEnd(sub),
216
217
  canceledAt: epochToIso(sub.canceled_at),
217
218
  isTrialing: sub.status === "trialing",
218
219
  trialStart: epochToIso(sub.trial_start),
@@ -584,7 +585,7 @@ export async function syncAllSubscriptions(
584
585
  quantity: firstItem?.quantity ?? undefined,
585
586
  currentPeriodStart: epochToIso(periodStart),
586
587
  currentPeriodEnd: epochToIso(periodEnd),
587
- cancelAtPeriodEnd: sub.cancel_at_period_end,
588
+ cancelAtPeriodEnd: cancelsAtPeriodEnd(sub),
588
589
  isCollectionPaused: !!sub.pause_collection,
589
590
  canceledAt: epochToIso(sub.canceled_at),
590
591
  isTrialing: sub.status === "trialing",
@@ -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
+ });
@@ -0,0 +1,185 @@
1
+ import Stripe from "stripe";
2
+ import { componentsGeneric } from "convex/server";
3
+ import { expect, it, vi } from "vitest";
4
+ import { BetterStripe, type BetterStripeOptions } from "./index.js";
5
+ import { getStripeClient, type Component, type RunCtx } from "./helpers.js";
6
+
7
+ const preview = "2026-09-30.preview";
8
+ const component = componentsGeneric().betterStripe as unknown as Component;
9
+ const ctx = {
10
+ runQuery: vi.fn().mockImplementation(async (_ref, args) =>
11
+ args?.stripeAccountId
12
+ ? {
13
+ _id: "row",
14
+ userId: "buyer",
15
+ stripeAccountId: "acct_buyer",
16
+ appliedConfigurations: [],
17
+ }
18
+ : null,
19
+ ),
20
+ runMutation: vi.fn().mockResolvedValue("row"),
21
+ } as unknown as RunCtx;
22
+ function fixture(version: string | undefined) {
23
+ const key = `sk_test_preview_transport_${crypto.randomUUID()}`;
24
+ const bs = new BetterStripe(component, {
25
+ STRIPE_SECRET_KEY: key,
26
+ ...(version === undefined ? {} : { stripeApiVersion: version }),
27
+ } as BetterStripeOptions);
28
+ const calls: {
29
+ path: string;
30
+ version: string;
31
+ idempotency: string | undefined;
32
+ body: string;
33
+ }[] = [];
34
+ for (const selected of new Set([version, "2026-09-30.endive"])) {
35
+ const sdk = getStripeClient(key, selected);
36
+ (
37
+ sdk as unknown as { _setApiField: (name: string, value: unknown) => void }
38
+ )._setApiField("httpClient", {
39
+ getClientName: () => "preview-contract-test",
40
+ makeRequest: async (
41
+ ...[, , path, , headers, body]: Parameters<
42
+ ReturnType<typeof Stripe.createNodeHttpClient>["makeRequest"]
43
+ >
44
+ ) => {
45
+ calls.push({
46
+ path,
47
+ version: String(headers["Stripe-Version"]),
48
+ idempotency: headers["Idempotency-Key"] as string | undefined,
49
+ body: String(body),
50
+ });
51
+ const data = path.includes("/checkout/sessions")
52
+ ? {
53
+ id: "cs_preview",
54
+ object: "checkout.session",
55
+ client_secret: "test_secret",
56
+ status: "open",
57
+ }
58
+ : path.includes("/v2/core/accounts")
59
+ ? {
60
+ id: "acct_buyer",
61
+ object: "v2.core.account",
62
+ applied_configurations: ["customer"],
63
+ configuration: { customer: {} },
64
+ metadata: { userId: "buyer" },
65
+ }
66
+ : path.includes("/prices/")
67
+ ? {
68
+ id: "price_preview",
69
+ active: true,
70
+ unit_amount: 1000,
71
+ currency: "usd",
72
+ type: "one_time",
73
+ product: "prod_preview",
74
+ }
75
+ : path.includes("/products/")
76
+ ? { id: "prod_preview", active: true }
77
+ : path.includes("/billing_portal/sessions")
78
+ ? {
79
+ id: "bps_preview",
80
+ url: "https://billing.stripe.com/test",
81
+ }
82
+ : path.includes("/refunds")
83
+ ? { id: "re_preview", status: "succeeded" }
84
+ : path.includes("/payment_intents/")
85
+ ? {
86
+ id: "pi_preview",
87
+ latest_charge: {
88
+ id: "ch_preview",
89
+ amount: 1000,
90
+ currency: "usd",
91
+ },
92
+ }
93
+ : path.includes("/charges/")
94
+ ? { id: "ch_preview", amount: 1000, currency: "usd" }
95
+ : path.includes("/coupons/")
96
+ ? { id: "coupon_preview", valid: true }
97
+ : {
98
+ id: "sub_preview",
99
+ status: "active",
100
+ customer_account: "acct_buyer",
101
+ items: { data: [] },
102
+ metadata: {},
103
+ };
104
+ return {
105
+ getStatusCode: () => 200,
106
+ getHeaders: () => ({}),
107
+ getRawResponse: () => ({}),
108
+ toJSON: async () => data,
109
+ toStream: () => {
110
+ throw new Error("Unexpected stream");
111
+ },
112
+ };
113
+ },
114
+ });
115
+ }
116
+ return { bs, calls };
117
+ }
118
+ it.each([undefined, preview])(
119
+ "uses one selected version for account, Checkout, billing, refunds and authoritative reads (%s)",
120
+ async (version) => {
121
+ const { bs, calls } = fixture(version);
122
+ await bs.ensureCustomerAccount(ctx, {
123
+ userId: "buyer",
124
+ email: "buyer@example.com",
125
+ idempotencyKey: "account:buyer",
126
+ });
127
+ await bs.createCheckoutSession(ctx, {
128
+ userId: "buyer",
129
+ accountId: "acct_buyer",
130
+ stripePriceId: "price_preview",
131
+ mode: "payment",
132
+ returnUrl: "https://example.com/receipt",
133
+ idempotencyKey: "order:one",
134
+ });
135
+ await bs.getCheckoutSessionFromStripe(ctx, {
136
+ stripeSessionId: "cs_preview",
137
+ });
138
+ await bs.getSubscriptionFromStripe(ctx, {
139
+ stripeSubscriptionId: "sub_preview",
140
+ });
141
+ await bs.getCouponFromStripe(ctx, { stripeCouponId: "coupon_preview" });
142
+ await bs.cancelSubscription(ctx, { stripeSubscriptionId: "sub_preview" });
143
+ await bs.reactivateSubscription(ctx, {
144
+ stripeSubscriptionId: "sub_preview",
145
+ });
146
+ await bs.createBillingPortalSession(ctx, {
147
+ stripeAccountId: "acct_buyer",
148
+ returnUrl: "https://example.com/billing",
149
+ });
150
+ await bs.createRefund(ctx, {
151
+ stripeChargeId: "ch_preview",
152
+ idempotencyKey: "refund:one",
153
+ });
154
+ expect(calls.length).toBeGreaterThanOrEqual(8);
155
+ expect(new Set(calls.map((c) => c.version))).toEqual(
156
+ new Set([version ?? "2026-09-30.endive"]),
157
+ );
158
+ expect(calls.find((c) => c.path === "/v2/core/accounts")?.idempotency).toBe(
159
+ "account:buyer",
160
+ );
161
+ expect(
162
+ calls.find((c) => c.path === "/v1/checkout/sessions" && c.idempotency)
163
+ ?.idempotency,
164
+ ).toBe("order:one");
165
+ expect(
166
+ calls.find((c) => c.path === "/v1/checkout/sessions" && c.idempotency)
167
+ ?.body,
168
+ ).toContain("customer_account=acct_buyer");
169
+ expect(
170
+ calls.find((c) => c.path === "/v2/core/accounts")?.body,
171
+ ).not.toContain("merchant");
172
+ expect(bs.stripeApiVersion).toBe(version ?? "2026-09-30.endive");
173
+ },
174
+ );
175
+ it.each(["", "2020-08-27", "2026-09-30.preview; unsafe", "not-a-version"])(
176
+ "rejects unsupported client versions clearly (%s)",
177
+ (version) => {
178
+ expect(
179
+ () =>
180
+ new BetterStripe(component, {
181
+ stripeApiVersion: version,
182
+ } as unknown as BetterStripeOptions),
183
+ ).toThrow("Unsupported Stripe API version");
184
+ },
185
+ );