@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.
- package/CHANGELOG.md +12 -0
- package/MIGRATION.md +23 -0
- package/README.md +33 -1
- package/dist/client/apiVersion.d.ts +6 -0
- package/dist/client/apiVersion.d.ts.map +1 -0
- package/dist/client/apiVersion.js +13 -0
- package/dist/client/apiVersion.js.map +1 -0
- package/dist/client/billing/subscriptionCancellation.d.ts +4 -0
- package/dist/client/billing/subscriptionCancellation.d.ts.map +1 -0
- package/dist/client/billing/subscriptionCancellation.js +7 -0
- package/dist/client/billing/subscriptionCancellation.js.map +1 -0
- package/dist/client/billing/subscriptions.d.ts.map +1 -1
- package/dist/client/billing/subscriptions.js +3 -2
- package/dist/client/billing/subscriptions.js.map +1 -1
- package/dist/client/index.d.ts +8 -12
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +18 -4
- package/dist/client/index.js.map +1 -1
- package/dist/client/previewRoutes.d.ts +9 -0
- package/dist/client/previewRoutes.d.ts.map +1 -0
- package/dist/client/previewRoutes.js +15 -0
- package/dist/client/previewRoutes.js.map +1 -0
- package/dist/client/types/options.d.ts +3 -0
- package/dist/client/types/options.d.ts.map +1 -1
- package/dist/client/utils/webhookEndpoints.d.ts +12 -8
- package/dist/client/utils/webhookEndpoints.d.ts.map +1 -1
- package/dist/client/utils/webhookEndpoints.js +41 -2
- package/dist/client/utils/webhookEndpoints.js.map +1 -1
- package/dist/client/webhooks/processors.d.ts.map +1 -1
- package/dist/client/webhooks/processors.js +2 -1
- package/dist/client/webhooks/processors.js.map +1 -1
- package/dist/client/webhooks/v2.d.ts.map +1 -1
- package/dist/client/webhooks/v2.js +10 -7
- package/dist/client/webhooks/v2.js.map +1 -1
- package/dist/component/lib/stripeFactory.d.ts +3 -2
- package/dist/component/lib/stripeFactory.d.ts.map +1 -1
- package/dist/component/lib/stripeFactory.js +6 -5
- package/dist/component/lib/stripeFactory.js.map +1 -1
- package/docs/accounts-v2-preview.md +142 -0
- package/package.json +2 -1
- package/src/client/apiVersion.ts +20 -0
- package/src/client/billing/subscriptionCancellation.test.ts +68 -0
- package/src/client/billing/subscriptionCancellation.ts +10 -0
- package/src/client/billing/subscriptions.ts +3 -2
- package/src/client/customer-preview-thin.test.ts +41 -0
- package/src/client/customer-preview-webhooks.test.ts +150 -0
- package/src/client/customer-preview.test.ts +185 -0
- package/src/client/index.ts +31 -16
- package/src/client/previewRoutes.ts +26 -0
- package/src/client/stripe-version.test.ts +10 -4
- package/src/client/types/options.ts +3 -0
- package/src/client/utils/webhookEndpoints.ts +70 -15
- package/src/client/webhooks/processors.ts +2 -1
- package/src/client/webhooks/v2.ts +14 -10
- 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.
|
|
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
|
|
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
|
|
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
|
+
);
|