@pithy-sh/payments 0.1.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.
- package/LICENSE +21 -0
- package/README.md +157 -0
- package/dist/paddle-prices.iife.js +1 -0
- package/dist/pithy.manifest.json +68 -0
- package/docs/lemon-squeezy.md +167 -0
- package/docs/paddle.md +486 -0
- package/package.json +73 -0
- package/pithy.manifest.json +68 -0
- package/src/admin/coverage.ts +107 -0
- package/src/admin/read.ts +360 -0
- package/src/audit/actions.ts +214 -0
- package/src/capability.ts +377 -0
- package/src/client/api.ts +734 -0
- package/src/client/checkout.ts +252 -0
- package/src/client/fixtures/price-preview-de.json +54 -0
- package/src/client/fixtures/price-preview-gb.json +54 -0
- package/src/client/fixtures/price-preview-jp-yen.json +54 -0
- package/src/client/fixtures/price-preview-us-country-only.json +54 -0
- package/src/client/fixtures/price-preview-us-new-york.json +54 -0
- package/src/client/fixtures/pricePreview.ts +67 -0
- package/src/client/hooks.ts +590 -0
- package/src/client/paddle.ts +733 -0
- package/src/client/paddleCache.ts +265 -0
- package/src/client/paddlePrices.iife.ts +43 -0
- package/src/client/paddlePrices.ts +143 -0
- package/src/client/paddlePricesTag.ts +332 -0
- package/src/client/projection.ts +133 -0
- package/src/client/test-utils/cacheStore.ts +41 -0
- package/src/client/wholeUnits.ts +147 -0
- package/src/cloudflare-test.d.ts +16 -0
- package/src/config/config.ts +563 -0
- package/src/data/discount.ts +238 -0
- package/src/data/entitlement.ts +67 -0
- package/src/data/money.ts +75 -0
- package/src/data/pause.ts +148 -0
- package/src/data/providerAccount.ts +47 -0
- package/src/data/purchase.ts +131 -0
- package/src/data/rail.ts +74 -0
- package/src/data/reconcileRun.ts +208 -0
- package/src/data/renderMoney.ts +180 -0
- package/src/data/status.ts +67 -0
- package/src/data/subject.ts +148 -0
- package/src/data/subscription.ts +675 -0
- package/src/data/syncCursor.ts +44 -0
- package/src/data/tables.ts +61 -0
- package/src/data/webhookEvent.ts +196 -0
- package/src/entitlement/manual.ts +209 -0
- package/src/entitlement/resolver.ts +125 -0
- package/src/entitlement/subjectSeam.ts +171 -0
- package/src/error/errors.ts +421 -0
- package/src/grants/apply.ts +207 -0
- package/src/grants/clawback.ts +128 -0
- package/src/grants/ledgerSeam.ts +141 -0
- package/src/http/dispatch.ts +48 -0
- package/src/http/guards.ts +58 -0
- package/src/http/manifestConfig.ts +56 -0
- package/src/http/responses.ts +1147 -0
- package/src/http/routes.ts +2911 -0
- package/src/http/schemas.ts +577 -0
- package/src/http/scopes.ts +260 -0
- package/src/http/view.ts +188 -0
- package/src/http/webhookGuard.ts +341 -0
- package/src/index.ts +231 -0
- package/src/migrations/0001_purchases.ts +312 -0
- package/src/pricing/location.ts +175 -0
- package/src/pricing/visitor.ts +82 -0
- package/src/projection/event.ts +101 -0
- package/src/projection/orphans.ts +226 -0
- package/src/projection/owner.ts +247 -0
- package/src/projection/resolve.ts +57 -0
- package/src/projection/writer.ts +554 -0
- package/src/provision/resolvePaymentsConfig.ts +91 -0
- package/src/rails/apple/certs.ts +66 -0
- package/src/rails/apple/der.ts +249 -0
- package/src/rails/apple/fixtures/chain.ts +412 -0
- package/src/rails/apple/fixtures/did-fail-to-renew-grace.json +43 -0
- package/src/rails/apple/fixtures/did-renew.json +48 -0
- package/src/rails/apple/fixtures/one-time-charge.json +32 -0
- package/src/rails/apple/fixtures/refund.json +33 -0
- package/src/rails/apple/fixtures/subscribed-initial-buy-sandbox.json +38 -0
- package/src/rails/apple/fixtures/test.json +14 -0
- package/src/rails/apple/http.ts +128 -0
- package/src/rails/apple/jws.ts +123 -0
- package/src/rails/apple/notification.ts +357 -0
- package/src/rails/apple/rail.ts +114 -0
- package/src/rails/apple/serverApi.ts +412 -0
- package/src/rails/apple/verify.ts +93 -0
- package/src/rails/apple/x509.ts +424 -0
- package/src/rails/contract.ts +939 -0
- package/src/rails/google/fixtures/play-product-purchased.json +14 -0
- package/src/rails/google/fixtures/play-subscription-active.json +23 -0
- package/src/rails/google/fixtures/push.ts +152 -0
- package/src/rails/google/fixtures/rtdn-one-time-purchased.json +11 -0
- package/src/rails/google/fixtures/rtdn-subscription-renewed.json +11 -0
- package/src/rails/google/fixtures/rtdn-subscription-revoked.json +11 -0
- package/src/rails/google/fixtures/rtdn-test.json +8 -0
- package/src/rails/google/fixtures/rtdn-voided-purchase.json +11 -0
- package/src/rails/google/http.ts +124 -0
- package/src/rails/google/jwt.ts +127 -0
- package/src/rails/google/oidc.ts +308 -0
- package/src/rails/google/playApi.ts +598 -0
- package/src/rails/google/rail.ts +174 -0
- package/src/rails/google/rtdn.ts +331 -0
- package/src/rails/google/verify.ts +127 -0
- package/src/rails/lemonSqueezy/api.ts +192 -0
- package/src/rails/lemonSqueezy/checkout.ts +186 -0
- package/src/rails/lemonSqueezy/discounts.ts +166 -0
- package/src/rails/lemonSqueezy/fixtures/events.ts +180 -0
- package/src/rails/lemonSqueezy/objects.ts +511 -0
- package/src/rails/lemonSqueezy/portal.ts +82 -0
- package/src/rails/lemonSqueezy/pricing.ts +124 -0
- package/src/rails/lemonSqueezy/rail.ts +137 -0
- package/src/rails/lemonSqueezy/read.ts +72 -0
- package/src/rails/lemonSqueezy/refresh.ts +86 -0
- package/src/rails/lemonSqueezy/signature.ts +119 -0
- package/src/rails/lemonSqueezy/verify.ts +45 -0
- package/src/rails/lemonSqueezy/webhook.ts +326 -0
- package/src/rails/paddle/adjustments.ts +94 -0
- package/src/rails/paddle/api.ts +266 -0
- package/src/rails/paddle/checkout.ts +240 -0
- package/src/rails/paddle/discounts.ts +176 -0
- package/src/rails/paddle/events.ts +285 -0
- package/src/rails/paddle/fixtures/browserForged.ts +54 -0
- package/src/rails/paddle/fixtures/pausedSubscription.ts +62 -0
- package/src/rails/paddle/objects.ts +883 -0
- package/src/rails/paddle/portal.ts +130 -0
- package/src/rails/paddle/rail.ts +219 -0
- package/src/rails/paddle/read.ts +108 -0
- package/src/rails/paddle/recorded.ts +83 -0
- package/src/rails/paddle/refresh.ts +153 -0
- package/src/rails/paddle/refund.ts +297 -0
- package/src/rails/paddle/signature.ts +225 -0
- package/src/rails/paddle/subscription.ts +793 -0
- package/src/rails/paddle/verify.ts +120 -0
- package/src/rails/paddle/webhook.ts +405 -0
- package/src/rails/providers.ts +168 -0
- package/src/rails/stripe/api.ts +253 -0
- package/src/rails/stripe/checkout.ts +175 -0
- package/src/rails/stripe/discounts.ts +169 -0
- package/src/rails/stripe/fixtures/event-charge-refunded.json +23 -0
- package/src/rails/stripe/fixtures/event-invoice-paid.json +19 -0
- package/src/rails/stripe/fixtures/event-session-completed-payment.json +27 -0
- package/src/rails/stripe/fixtures/event-session-completed-subscription.json +27 -0
- package/src/rails/stripe/fixtures/event-subscription-canceled.json +43 -0
- package/src/rails/stripe/fixtures/event-subscription-created.json +42 -0
- package/src/rails/stripe/fixtures/event-subscription-deleted.json +44 -0
- package/src/rails/stripe/fixtures/events.ts +111 -0
- package/src/rails/stripe/objects.ts +642 -0
- package/src/rails/stripe/portal.ts +45 -0
- package/src/rails/stripe/pricing.ts +123 -0
- package/src/rails/stripe/rail.ts +139 -0
- package/src/rails/stripe/refresh.ts +79 -0
- package/src/rails/stripe/signature.ts +127 -0
- package/src/rails/stripe/verify.ts +105 -0
- package/src/rails/stripe/webhook.ts +79 -0
- package/src/secret/registry.ts +275 -0
- package/src/seeds/example.ts +181 -0
- package/src/version.generated.ts +16 -0
- package/src/workflows/paddleSweep.ts +717 -0
- package/src/workflows/railAccess.ts +87 -0
- package/src/workflows/reconcile.ts +648 -0
- package/src/workflows/report.ts +64 -0
- package/src/workflows/retryPolicy.ts +40 -0
- package/src/workflows/specs.ts +143 -0
- package/src/workflows/worker.ts +166 -0
- package/src/workflows/workerConfig.ts +73 -0
- package/src/workflows/wrangler.jsonc +55 -0
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import type { PaymentsConfig } from "../config/config";
|
|
5
|
+
import { productForProviderSku, railEnabled } from "../config/config";
|
|
6
|
+
import type { PaymentsRail } from "../data/rail";
|
|
7
|
+
import { PaymentsRailNotConfiguredError } from "../error/errors";
|
|
8
|
+
import { type PaymentsProviderCredentials, railCredentials } from "../secret/registry";
|
|
9
|
+
import type { AppleHttpFetch } from "./apple/http";
|
|
10
|
+
import { appleRail } from "./apple/rail";
|
|
11
|
+
import type { PaymentsRailProvider } from "./contract";
|
|
12
|
+
import type { GoogleHttpFetch } from "./google/http";
|
|
13
|
+
import type { GoogleJwk } from "./google/oidc";
|
|
14
|
+
import { googleRail } from "./google/rail";
|
|
15
|
+
import type { LemonSqueezyHttpFetch } from "./lemonSqueezy/api";
|
|
16
|
+
import { lemonSqueezyRail } from "./lemonSqueezy/rail";
|
|
17
|
+
import type { PaddleHttpFetch } from "./paddle/api";
|
|
18
|
+
import { paddleRail } from "./paddle/rail";
|
|
19
|
+
import type { StripeHttpFetch } from "./stripe/api";
|
|
20
|
+
import { stripeRail } from "./stripe/rail";
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Which rails this build can speak to, and how one is resolved for a request.
|
|
24
|
+
*
|
|
25
|
+
* This is the single composition point. A rail arrives as one entry here plus its module — nothing else in
|
|
26
|
+
* the package counts rails, and nothing branches on how many there are.
|
|
27
|
+
*
|
|
28
|
+
* **Two independent conditions, one answer.** A rail may be off in `pithy.config.ts`, or its credentials may
|
|
29
|
+
* never have been provisioned, or this build may not implement it yet. All three are
|
|
30
|
+
* `payments/rail_not_configured` (404), because from the caller's side they are the same statement — that
|
|
31
|
+
* payment method is not available here — and distinguishing them in a response would describe our deployment
|
|
32
|
+
* to a stranger. `detail` distinguishes them for the operator, which is where the distinction is useful.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The per-rail verification seams a caller may supply. Two kinds, and both are narrow on purpose.
|
|
37
|
+
*
|
|
38
|
+
* **Trust is additive only**, so nothing here can narrow production's trust: extra roots and extra keys are
|
|
39
|
+
* *added* to each rail's pinned set, and a payload the extras do not cover is still checked against the store's
|
|
40
|
+
* own. Each exists for callers with genuinely non-store signing material — the tests, which mint their own so
|
|
41
|
+
* verification is exercised for real rather than stubbed, and the local development tools that do the same:
|
|
42
|
+
* Xcode's StoreKit testing signs with a per-machine root, and a Pub/Sub emulator signs with a key Google never
|
|
43
|
+
* saw.
|
|
44
|
+
*
|
|
45
|
+
* **Transport is a seam rather than a policy.** Google's rail cannot verify offline — Play hands out pointers,
|
|
46
|
+
* not signed state — so it must reach the network, and a network call that could not be substituted could only be
|
|
47
|
+
* tested through a stub of the module under test. One explicit parameter, defaulting to the runtime's `fetch`.
|
|
48
|
+
* Every rail now has one, because every rail reaches its store on the reconciliation path even where it verifies
|
|
49
|
+
* offline: a subscription nobody was notified about can only be learned by asking.
|
|
50
|
+
*
|
|
51
|
+
* **The two batch tokens are a cost seam, not a trust one.** Apple's API token and Google's OAuth token are each
|
|
52
|
+
* bought with a signature and a round-trip, so a reconciliation pass over hundreds of purchases mints one and
|
|
53
|
+
* passes it down rather than paying twice per purchase. They are parameters rather than a module cache because a
|
|
54
|
+
* bearer credential derived from a secret is exactly what CLAUDE.md forbids caching in a module variable.
|
|
55
|
+
*/
|
|
56
|
+
export interface RailTrustOptions {
|
|
57
|
+
/** Roots the Apple rail accepts in addition to Apple's pinned ones, base64 DER. */
|
|
58
|
+
appleTrustedRoots?: readonly string[];
|
|
59
|
+
/** The HTTP transport the Apple rail reaches the App Store Server API through. Defaults to `fetch`. */
|
|
60
|
+
appleTransport?: AppleHttpFetch;
|
|
61
|
+
/** An App Store Connect token already minted, so a batch of Apple refreshes pays for one. */
|
|
62
|
+
appleApiToken?: string;
|
|
63
|
+
/** Keys the Google rail accepts in addition to Google's published set, matched by `kid`. */
|
|
64
|
+
googleTrustedKeys?: readonly GoogleJwk[];
|
|
65
|
+
/** The HTTP transport the Google rail reaches Google's endpoints through. Defaults to `fetch`. */
|
|
66
|
+
googleTransport?: GoogleHttpFetch;
|
|
67
|
+
/** A Play access token already minted, so a batch of Google refreshes pays for one. */
|
|
68
|
+
googleAccessToken?: string;
|
|
69
|
+
/** The HTTP transport the Stripe rail reaches Stripe's API through. Defaults to `fetch`. */
|
|
70
|
+
stripeTransport?: StripeHttpFetch;
|
|
71
|
+
/** The HTTP transport the Lemon Squeezy rail reaches its API through. Defaults to `fetch`. */
|
|
72
|
+
lemonSqueezyTransport?: LemonSqueezyHttpFetch;
|
|
73
|
+
/** The HTTP transport the Paddle rail reaches its API through. Defaults to `fetch`. */
|
|
74
|
+
paddleTransport?: PaddleHttpFetch;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Build a rail provider from the credential bundle. Each factory takes only its own rail's block. */
|
|
78
|
+
type RailFactory = (
|
|
79
|
+
credentials: PaymentsProviderCredentials,
|
|
80
|
+
trust: RailTrustOptions,
|
|
81
|
+
config: PaymentsConfig,
|
|
82
|
+
) => PaymentsRailProvider;
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Every rail this build implements. A rail arrives as one entry here plus its module; an entry with no module
|
|
86
|
+
* behind it would be a rail that reports itself available and then fails, which is worse than one that reports
|
|
87
|
+
* itself absent.
|
|
88
|
+
*/
|
|
89
|
+
const RAIL_FACTORIES: Partial<Record<PaymentsRail, RailFactory>> = {
|
|
90
|
+
apple: (credentials, trust) =>
|
|
91
|
+
appleRail(railCredentials(credentials, "apple"), {
|
|
92
|
+
trustedRoots: trust.appleTrustedRoots,
|
|
93
|
+
transport: trust.appleTransport,
|
|
94
|
+
apiToken: trust.appleApiToken,
|
|
95
|
+
}),
|
|
96
|
+
google: (credentials, trust) =>
|
|
97
|
+
googleRail(railCredentials(credentials, "google"), {
|
|
98
|
+
trustedKeys: trust.googleTrustedKeys,
|
|
99
|
+
transport: trust.googleTransport,
|
|
100
|
+
accessToken: trust.googleAccessToken,
|
|
101
|
+
}),
|
|
102
|
+
stripe: (credentials, trust) =>
|
|
103
|
+
stripeRail(railCredentials(credentials, "stripe"), { transport: trust.stripeTransport }),
|
|
104
|
+
lemonSqueezy: (credentials, trust, config) =>
|
|
105
|
+
lemonSqueezyRail(railCredentials(credentials, "lemonSqueezy"), {
|
|
106
|
+
transport: trust.lemonSqueezyTransport,
|
|
107
|
+
// The catalog answers what the store's order object cannot: whether this variant is a subscription.
|
|
108
|
+
// A Lemon Squeezy variant is one or the other and never both, so the product's declared type is the
|
|
109
|
+
// whole answer — see `orderNotification` for why the rail must not guess it.
|
|
110
|
+
sellsSubscription: (variantId) =>
|
|
111
|
+
productForProviderSku(config, "lemonSqueezy", variantId)?.product.type === "subscription",
|
|
112
|
+
// Without this the fixed-amount currency guard in `discounts.ts` never runs: it is written to skip
|
|
113
|
+
// the check when the store's currency is unknown, and nothing was ever telling it. The catalog knows
|
|
114
|
+
// — a `grants` clause names the currency a product's economy is denominated in.
|
|
115
|
+
storeCurrency: config.lemonSqueezy?.storeCurrency,
|
|
116
|
+
}),
|
|
117
|
+
paddle: (credentials, trust, config) => {
|
|
118
|
+
// Config is checked before this runs — `resolveRailProvider` refuses a disabled rail, and
|
|
119
|
+
// `PaymentsConfig` refuses to parse with the rail on and the block absent — so this is the
|
|
120
|
+
// "enabled, parsed, and somehow still absent" case rather than an adopter's missing three lines.
|
|
121
|
+
const settings = config.paddle;
|
|
122
|
+
if (settings === undefined) {
|
|
123
|
+
throw new PaymentsRailNotConfiguredError({
|
|
124
|
+
detail:
|
|
125
|
+
"The paddle rail is on with no `paddle` settings block. Declare `clientToken`, `environment`, and `successUrl`.",
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
return paddleRail(railCredentials(credentials, "paddle"), {
|
|
129
|
+
environment: settings.environment,
|
|
130
|
+
clientToken: settings.clientToken,
|
|
131
|
+
checkout: settings.checkout,
|
|
132
|
+
freshnessSeconds: settings.webhookFreshnessSeconds,
|
|
133
|
+
storeCurrency: settings.storeCurrency,
|
|
134
|
+
transport: trust.paddleTransport,
|
|
135
|
+
});
|
|
136
|
+
},
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
/** The rails this build can serve at all, whatever a project's config says. */
|
|
140
|
+
export function implementedRails(): readonly PaymentsRail[] {
|
|
141
|
+
return Object.keys(RAIL_FACTORIES) as PaymentsRail[];
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* The provider for one rail, or a 404.
|
|
146
|
+
*
|
|
147
|
+
* Config is checked before credentials, so a project that has not enabled a rail never triggers a credential
|
|
148
|
+
* read for it — a read is a Secrets Store round-trip, and a disabled rail should not cost one.
|
|
149
|
+
*/
|
|
150
|
+
export function resolveRailProvider(
|
|
151
|
+
rail: PaymentsRail,
|
|
152
|
+
config: PaymentsConfig,
|
|
153
|
+
credentials: PaymentsProviderCredentials,
|
|
154
|
+
trust: RailTrustOptions = {},
|
|
155
|
+
): PaymentsRailProvider {
|
|
156
|
+
if (!railEnabled(config, rail)) {
|
|
157
|
+
throw new PaymentsRailNotConfiguredError({
|
|
158
|
+
detail: `The ${rail} rail is off in this project's config. Set \`rails.${rail}: true\` and redeploy.`,
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
const factory = RAIL_FACTORIES[rail];
|
|
162
|
+
if (!factory) {
|
|
163
|
+
throw new PaymentsRailNotConfiguredError({
|
|
164
|
+
detail: `This build of @pithy-sh/payments implements ${implementedRails().join(", ")}, not ${rail}.`,
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
return factory(credentials, trust, config);
|
|
168
|
+
}
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import { PaymentsProviderUnavailableError, PaymentsRailNotConfiguredError } from "../../error/errors";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The one door out to Stripe, and the mapping from how it answered to what that means.
|
|
9
|
+
*
|
|
10
|
+
* Stripe is reached with `fetch` and form encoding rather than through the `stripe` npm package, deliberately.
|
|
11
|
+
* Three calls are needed for hosted-only payments — create a Checkout Session, create a Billing Portal session,
|
|
12
|
+
* retrieve a Checkout Session — and each is one form POST or GET. The SDK would add a dependency with its own
|
|
13
|
+
* runtime shims, its own retry policy, and its own idea of what an error is, to save perhaps forty lines. Every
|
|
14
|
+
* other third-party API in this repo is spoken to the same way: `fetch` out, Zod in.
|
|
15
|
+
*
|
|
16
|
+
* **The API version is pinned.** Stripe decides response shapes from the *account's* default version unless a
|
|
17
|
+
* request names one, so an account upgraded in the dashboard would silently change what this code parses. Pinning
|
|
18
|
+
* it here means an upgrade is a deliberate edit with a test run behind it. Webhook payload shapes are a separate
|
|
19
|
+
* matter — those follow the endpoint's own configured version, which is why `objects.ts` accepts both spellings of
|
|
20
|
+
* the fields Stripe has moved.
|
|
21
|
+
*
|
|
22
|
+
* ## How an answer is read
|
|
23
|
+
*
|
|
24
|
+
* **A 5xx or a 429 is `payments/provider_unavailable` (503).** Stripe is up but not answering, or is asking us to
|
|
25
|
+
* slow down. The caller retries and the reconciliation pass covers whatever a retry never fixes.
|
|
26
|
+
*
|
|
27
|
+
* **Every other 4xx is `payments/rail_not_configured` (404), and that is the deliberate part.** A 4xx from Stripe
|
|
28
|
+
* is never the buyer's fault — we build these requests entirely from config and from rows we wrote, so "No such
|
|
29
|
+
* price", "No such customer", a rejected key, and a missing Billing Portal configuration are all one statement:
|
|
30
|
+
* this project's Stripe rail is not set up to do what it was asked. 503 would tell a caller to retry something
|
|
31
|
+
* that will never succeed; 400 would blame a request the caller did not make. 404 says the payment method is not
|
|
32
|
+
* available here, which is true, and `detail` carries Stripe's own sentence for the operator who can fix it.
|
|
33
|
+
*
|
|
34
|
+
* **A 404 is an absent resource only for a caller that said it was probing.** `absentOn404` is how a Checkout
|
|
35
|
+
* Session retrieve distinguishes "no such session" from "our Stripe account is wrong", and only a caller that
|
|
36
|
+
* knows the difference may claim it.
|
|
37
|
+
*
|
|
38
|
+
* **Nothing in a refusal carries the secret key.** Stripe redacts keys in its own messages, but `detail` is
|
|
39
|
+
* written to an operator's logs, so anything key-shaped in a message is redacted again here before it gets there.
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
/** Stripe's REST base. Public and stable, so it is pinned rather than configured. */
|
|
43
|
+
export const STRIPE_API_BASE = "https://api.stripe.com/v1";
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The API version every request pins.
|
|
47
|
+
*
|
|
48
|
+
* To move it: read Stripe's changelog for the shape changes between the two, adjust `objects.ts`, run the suite,
|
|
49
|
+
* and change this line. Never bump it to match an account — the point of pinning is that the account cannot
|
|
50
|
+
* decide what this code parses.
|
|
51
|
+
*/
|
|
52
|
+
export const STRIPE_API_VERSION = "2025-04-30.basil";
|
|
53
|
+
|
|
54
|
+
/** One outbound request, narrowed to what Stripe's three endpoints need. */
|
|
55
|
+
export interface StripeHttpRequest {
|
|
56
|
+
/** The HTTP method. */
|
|
57
|
+
method?: string;
|
|
58
|
+
/** Request headers — the bearer key, the pinned version, and a form content type on a POST. */
|
|
59
|
+
headers?: Record<string, string>;
|
|
60
|
+
/** The form-encoded body, on a POST. */
|
|
61
|
+
body?: string;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** The response shape this module reads. Structural, so a test's transport need not be a whole `Response`. */
|
|
65
|
+
export interface StripeHttpResponse {
|
|
66
|
+
/** Whether the status is 2xx. */
|
|
67
|
+
ok: boolean;
|
|
68
|
+
/** The status code, which is what decides the mapping. */
|
|
69
|
+
status: number;
|
|
70
|
+
/** The body as text. Read as text rather than JSON so a non-JSON answer is a diagnosis, not a throw. */
|
|
71
|
+
text(): Promise<string>;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* The HTTP seam for Stripe's endpoints.
|
|
76
|
+
*
|
|
77
|
+
* Injectable for one reason: **no test may reach a live Stripe account**, and a rail whose network call could not
|
|
78
|
+
* be substituted would have to be tested through a stub of itself. One explicit parameter beats reassigning a
|
|
79
|
+
* global, which leaks between suites and hides which module was exercised.
|
|
80
|
+
*/
|
|
81
|
+
export type StripeHttpFetch = (url: string, init?: StripeHttpRequest) => Promise<StripeHttpResponse>;
|
|
82
|
+
|
|
83
|
+
/** The default transport: the runtime's own `fetch`. */
|
|
84
|
+
export const stripeHttpFetch: StripeHttpFetch = (url, init) =>
|
|
85
|
+
fetch(url, init) as unknown as Promise<StripeHttpResponse>;
|
|
86
|
+
|
|
87
|
+
/** A value Stripe's form encoding can carry. Nested objects and arrays become bracketed keys. */
|
|
88
|
+
export type StripeFormValue = string | number | boolean | null | undefined | StripeFormObject | StripeFormValue[];
|
|
89
|
+
|
|
90
|
+
/** One level of a Stripe form body. */
|
|
91
|
+
export interface StripeFormObject {
|
|
92
|
+
[key: string]: StripeFormValue;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Encode a nested object the way Stripe's API reads a form body: `line_items[0][price]=price_1`.
|
|
97
|
+
*
|
|
98
|
+
* `URLSearchParams` alone cannot produce that, which is the whole reason this exists. `undefined` and `null` are
|
|
99
|
+
* dropped rather than sent empty, because Stripe reads an empty value as "unset this field" — so omitting an
|
|
100
|
+
* optional parameter and sending it blank are two different requests.
|
|
101
|
+
*/
|
|
102
|
+
export function stripeForm(input: StripeFormObject): string {
|
|
103
|
+
const params = new URLSearchParams();
|
|
104
|
+
append(params, "", input);
|
|
105
|
+
return params.toString();
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** What one request is for, and how its answers should be read. */
|
|
109
|
+
export interface StripeJsonOptions {
|
|
110
|
+
/** What is being fetched, in a `detail` line: "a Checkout Session". Never the URL, never the body. */
|
|
111
|
+
what: string;
|
|
112
|
+
/** The Stripe secret key. Read through the secrets store by the caller, never off an env binding. */
|
|
113
|
+
secretKey: string;
|
|
114
|
+
/** The form body. Its presence is what makes the request a POST. */
|
|
115
|
+
form?: StripeFormObject;
|
|
116
|
+
/** Query parameters, encoded in Stripe's own bracket notation — `expand[0]=subscription`. */
|
|
117
|
+
query?: StripeFormObject;
|
|
118
|
+
/**
|
|
119
|
+
* Whether a 404 means "no such resource" rather than a misconfigured account. Set only by a caller that is
|
|
120
|
+
* deliberately probing for something that may legitimately not exist.
|
|
121
|
+
*/
|
|
122
|
+
absentOn404?: boolean;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Stripe's error envelope, as much of it as a `detail` line needs. */
|
|
126
|
+
const StripeApiError = z
|
|
127
|
+
.object({
|
|
128
|
+
error: z
|
|
129
|
+
.object({
|
|
130
|
+
type: z.string().min(1).optional().describe("Stripe's error class — `invalid_request_error`, `api_error`."),
|
|
131
|
+
code: z.string().min(1).optional().describe("The machine-readable reason — `resource_missing`."),
|
|
132
|
+
param: z.string().min(1).optional().describe("Which request parameter Stripe blamed."),
|
|
133
|
+
message: z.string().min(1).optional().describe("Stripe's own sentence. Redacted before it reaches a log."),
|
|
134
|
+
})
|
|
135
|
+
.loose()
|
|
136
|
+
.describe("The error Stripe reported."),
|
|
137
|
+
})
|
|
138
|
+
.loose()
|
|
139
|
+
.describe("Stripe's error response envelope.");
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* One request to Stripe, and its parsed JSON body — or `undefined` when a 404 was a legitimate answer.
|
|
143
|
+
*
|
|
144
|
+
* The body comes back as `unknown`. Reaching an endpoint proves who answered, never what they said, so every
|
|
145
|
+
* caller Zod-parses its own shape.
|
|
146
|
+
*/
|
|
147
|
+
export async function stripeJson(
|
|
148
|
+
transport: StripeHttpFetch,
|
|
149
|
+
path: string,
|
|
150
|
+
options: StripeJsonOptions,
|
|
151
|
+
): Promise<unknown | undefined> {
|
|
152
|
+
const query = options.query === undefined ? "" : `?${stripeForm(options.query)}`;
|
|
153
|
+
const url = `${STRIPE_API_BASE}${path}${query}`;
|
|
154
|
+
const headers: Record<string, string> = {
|
|
155
|
+
authorization: `Bearer ${options.secretKey}`,
|
|
156
|
+
"stripe-version": STRIPE_API_VERSION,
|
|
157
|
+
};
|
|
158
|
+
if (options.form !== undefined) headers["content-type"] = "application/x-www-form-urlencoded";
|
|
159
|
+
|
|
160
|
+
let response: StripeHttpResponse;
|
|
161
|
+
try {
|
|
162
|
+
response = await transport(url, {
|
|
163
|
+
method: options.form === undefined ? "GET" : "POST",
|
|
164
|
+
headers,
|
|
165
|
+
body: options.form === undefined ? undefined : stripeForm(options.form),
|
|
166
|
+
});
|
|
167
|
+
} catch (cause) {
|
|
168
|
+
throw new PaymentsProviderUnavailableError(
|
|
169
|
+
{ detail: `Stripe did not answer when asked for ${options.what}.` },
|
|
170
|
+
{ cause },
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const text = await response.text();
|
|
175
|
+
|
|
176
|
+
if (!response.ok) {
|
|
177
|
+
if (response.status === 404 && options.absentOn404) return undefined;
|
|
178
|
+
throw refusal(response.status, text, options);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
try {
|
|
182
|
+
return JSON.parse(text) as unknown;
|
|
183
|
+
} catch (cause) {
|
|
184
|
+
throw new PaymentsProviderUnavailableError(
|
|
185
|
+
{ detail: `Stripe answered with a non-JSON body when asked for ${options.what}.` },
|
|
186
|
+
{ cause },
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** The refusal for a non-2xx answer: retryable if Stripe is struggling, a configuration failure otherwise. */
|
|
192
|
+
function refusal(status: number, body: string, options: StripeJsonOptions): PaymentsProviderUnavailableError {
|
|
193
|
+
if (status === 429 || status >= 500) {
|
|
194
|
+
return new PaymentsProviderUnavailableError({
|
|
195
|
+
detail: `Stripe answered ${status} when asked for ${options.what}. ${stripeSaid(body)}`,
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const key = status === 401 || status === 403 ? " Check the secret key stored for this environment." : "";
|
|
200
|
+
return new PaymentsRailNotConfiguredError({
|
|
201
|
+
detail: `Stripe refused the request for ${options.what} with ${status}.${key} ${stripeSaid(body)}`,
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** Stripe's own account of the failure, redacted. Empty when the body was not a Stripe error envelope. */
|
|
206
|
+
function stripeSaid(body: string): string {
|
|
207
|
+
let parsed: unknown;
|
|
208
|
+
try {
|
|
209
|
+
parsed = JSON.parse(body) as unknown;
|
|
210
|
+
} catch {
|
|
211
|
+
return "";
|
|
212
|
+
}
|
|
213
|
+
const envelope = StripeApiError.safeParse(parsed);
|
|
214
|
+
if (!envelope.success) return "";
|
|
215
|
+
const { type, code, param, message } = envelope.data.error;
|
|
216
|
+
const parts = [type, code, param === undefined ? undefined : `param ${param}`, message].filter(
|
|
217
|
+
(part): part is string => part !== undefined,
|
|
218
|
+
);
|
|
219
|
+
return parts.length === 0 ? "" : `Stripe said: ${redactStripeSecrets(parts.join(" / "))}.`;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Blank out anything key-shaped in text bound for a log.
|
|
224
|
+
*
|
|
225
|
+
* Stripe already redacts keys in its messages, so this is belt and braces — but `detail` is written to an
|
|
226
|
+
* operator's logs and read by whoever has them, and a credential that reaches one has to be rotated. Cheap
|
|
227
|
+
* insurance against a message format changing.
|
|
228
|
+
*/
|
|
229
|
+
export function redactStripeSecrets(text: string): string {
|
|
230
|
+
return text.replace(
|
|
231
|
+
/\b(sk|rk|whsec)_(test_|live_)?[A-Za-z0-9]+/g,
|
|
232
|
+
(_match, prefix, mode) => `${prefix}_${mode ?? ""}…`,
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** Append one value under a bracketed key path, recursing into objects and arrays. */
|
|
237
|
+
function append(params: URLSearchParams, prefix: string, value: StripeFormValue): void {
|
|
238
|
+
if (value === undefined || value === null) return;
|
|
239
|
+
|
|
240
|
+
if (Array.isArray(value)) {
|
|
241
|
+
for (const [index, entry] of value.entries()) append(params, `${prefix}[${index}]`, entry);
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
if (typeof value === "object") {
|
|
246
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
247
|
+
append(params, prefix === "" ? key : `${prefix}[${key}]`, entry);
|
|
248
|
+
}
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
params.append(prefix, String(value));
|
|
253
|
+
}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import { encodeSubjectReference } from "../../data/subject";
|
|
6
|
+
import { PaymentsDiscountInvalidError, PaymentsProviderUnavailableError } from "../../error/errors";
|
|
7
|
+
import type { PaymentsStripeCredentials } from "../../secret/registry";
|
|
8
|
+
import type { CheckoutHandoff, CheckoutSessionInput } from "../contract";
|
|
9
|
+
import { type StripeFormObject, type StripeHttpFetch, stripeHttpFetch, stripeJson } from "./api";
|
|
10
|
+
import { STRIPE_METADATA_ACCOUNT_REFERENCE, STRIPE_METADATA_PRICE } from "./objects";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Hosted Checkout, and nothing else.
|
|
14
|
+
*
|
|
15
|
+
* Stripe presents the payment page, handles the card, and owns SCA, tax, and every regulatory surface that comes
|
|
16
|
+
* with taking money. Pithy sends a browser there and hears the outcome on a webhook. There is no Payment Element
|
|
17
|
+
* here and no card fields, and nothing computes proration or tax — that half of issue #79's locked decision 2
|
|
18
|
+
* still stands, and it is the difference between a backend kit and a payments processor.
|
|
19
|
+
*
|
|
20
|
+
* **The other half was amended on 2026-08-28: the kit may now *invoke* a plan change**, passing the store's own
|
|
21
|
+
* figures through unmodified — see {@link SubscriptionRail} in `rails/contract.ts` for the line between asking a
|
|
22
|
+
* store what a change costs and answering that question ourselves. **Stripe does not implement it.** Paddle is
|
|
23
|
+
* the only rail that does, so a Stripe subscriber changes plan in the Billing Portal exactly as before, and
|
|
24
|
+
* `providers.test.ts` is where that stops being a claim. The amendment permits the seam; it does not oblige
|
|
25
|
+
* every rail to have one.
|
|
26
|
+
*
|
|
27
|
+
* ## What this call puts on the session, and why each of them
|
|
28
|
+
*
|
|
29
|
+
* **`client_reference_id`, the resolved subject as `encodeSubjectReference` writes it.** A Stripe purchase is only
|
|
30
|
+
* ever heard about through a webhook, and that webhook carries `cus_…` and no Pithy holder. This is the pairing,
|
|
31
|
+
* and it is the reason the `/checkout` route resolves the subject through the configured seam and never from a
|
|
32
|
+
* request body: a client that could name it could attach its purchase to somebody else's account — or, worse,
|
|
33
|
+
* attach somebody else's purchase to its own.
|
|
34
|
+
*
|
|
35
|
+
* **Both halves of the subject, always — `user:ada`, never `ada`.** Nothing keeps an organization id from
|
|
36
|
+
* equalling some user's, so an id alone paired with a kind at the far end would eventually grant one holder's
|
|
37
|
+
* subscription to the other. The bare id is also what this rail stamped before subjects existed, and
|
|
38
|
+
* `decodeSubjectReference` refuses it on purpose: a lenient read would attribute a stranger's renewal to whoever
|
|
39
|
+
* holds that id. So a stamp that is not the encoding orphans its purchase, which is the safe direction and the
|
|
40
|
+
* reason nothing here builds the string by hand.
|
|
41
|
+
*
|
|
42
|
+
* **The same reference in `metadata`, and again in `subscription_data[metadata]`.** Stripe copies subscription
|
|
43
|
+
* metadata onto the subscription it creates, so every later `customer.subscription.*` event carries the pairing
|
|
44
|
+
* too. Without that, Stripe delivering `customer.subscription.created` before `checkout.session.completed` — which
|
|
45
|
+
* it may — would leave the first renewal notification with nobody to project it against.
|
|
46
|
+
*
|
|
47
|
+
* **The price id in `metadata`.** A Checkout Session's `line_items` are expandable, and a webhook payload never
|
|
48
|
+
* expands anything, so the price a session was created for is otherwise unknowable from its own notification.
|
|
49
|
+
*
|
|
50
|
+
* **The customer, when the buyer already has one.** One buyer, one Stripe customer. Left out, every checkout mints
|
|
51
|
+
* a fresh customer and a buyer's second purchase would be invisible from the billing portal their first one made.
|
|
52
|
+
* On a first one-time purchase there is no customer to reuse, so Stripe is asked to create one — payment mode
|
|
53
|
+
* makes none by default, and a one-time buyer would otherwise never enter the account map at all.
|
|
54
|
+
*/
|
|
55
|
+
|
|
56
|
+
/** What creating a session needs beyond the input: the API key, and the transport to reach Stripe through. */
|
|
57
|
+
export interface StripeCheckoutOptions {
|
|
58
|
+
/** Stripe's credential block. Only `secretKey` is used here. */
|
|
59
|
+
credentials: PaymentsStripeCredentials;
|
|
60
|
+
/** The HTTP seam. Defaults to the runtime's `fetch`. */
|
|
61
|
+
transport?: StripeHttpFetch;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** A created hosted session, narrowed to the one field a browser needs. */
|
|
65
|
+
const StripeHostedSession = z
|
|
66
|
+
.object({
|
|
67
|
+
id: z.string().min(1).describe("The session id. Returned to the browser in the success URL's template token."),
|
|
68
|
+
url: z
|
|
69
|
+
.string()
|
|
70
|
+
.min(1)
|
|
71
|
+
.describe("The hosted page to send the browser to. Absent only for an embedded session, which this is not."),
|
|
72
|
+
})
|
|
73
|
+
.loose()
|
|
74
|
+
.describe("A hosted Stripe session — Checkout or Billing Portal — as much of it as a redirect needs.");
|
|
75
|
+
|
|
76
|
+
/** Create a hosted Checkout Session for one product, and return where to send the browser. */
|
|
77
|
+
export async function createStripeCheckoutSession(
|
|
78
|
+
input: CheckoutSessionInput,
|
|
79
|
+
options: StripeCheckoutOptions,
|
|
80
|
+
): Promise<CheckoutHandoff> {
|
|
81
|
+
// Resolved before the session is created, so an unusable code is refused as a *code* rather than
|
|
82
|
+
// surfacing later as a failed checkout. See {@link resolvePromotionCode}.
|
|
83
|
+
const promotionCodeId =
|
|
84
|
+
input.discountCode === undefined ? undefined : await resolvePromotionCode(input.discountCode, options);
|
|
85
|
+
|
|
86
|
+
// One encoding, from the one function. See `data/subject.ts` — the value goes out here and comes back
|
|
87
|
+
// through `decodeSubjectReference` on the webhook, and a second spelling anywhere breaks that loop.
|
|
88
|
+
const accountReference = encodeSubjectReference(input.subject);
|
|
89
|
+
const reference = { [STRIPE_METADATA_ACCOUNT_REFERENCE]: accountReference };
|
|
90
|
+
const metadata = { ...reference, [STRIPE_METADATA_PRICE]: input.providerProductId };
|
|
91
|
+
|
|
92
|
+
const form: StripeFormObject = {
|
|
93
|
+
mode: input.subscription ? "subscription" : "payment",
|
|
94
|
+
line_items: [{ price: input.providerProductId, quantity: 1 }],
|
|
95
|
+
success_url: input.successUrl,
|
|
96
|
+
cancel_url: input.cancelUrl,
|
|
97
|
+
// The resolved subject, both halves. Never a value from a request body — see the module doc.
|
|
98
|
+
client_reference_id: accountReference,
|
|
99
|
+
metadata,
|
|
100
|
+
customer: input.providerAccountId ?? undefined,
|
|
101
|
+
// Stripe refuses `customer` and `customer_creation` together, and there is nothing to create when the buyer
|
|
102
|
+
// already has one.
|
|
103
|
+
customer_creation: input.subscription || input.providerAccountId ? undefined : "always",
|
|
104
|
+
// Each is legal only in its own mode, so they are mutually exclusive rather than both set.
|
|
105
|
+
subscription_data: input.subscription ? { metadata: reference } : undefined,
|
|
106
|
+
payment_intent_data: input.subscription ? undefined : { metadata },
|
|
107
|
+
// The code, handed over exactly as the caller typed it. Stripe resolves it to a promotion code and
|
|
108
|
+
// computes the price; nothing here validates it, looks it up, or multiplies anything — the provider is
|
|
109
|
+
// the authority on what is owed, and a second calculation would be a second answer to the one question a
|
|
110
|
+
// customer checks against their statement.
|
|
111
|
+
//
|
|
112
|
+
// `allow_promotion_codes` is deliberately NOT set alongside it: Stripe refuses a session carrying both,
|
|
113
|
+
// and the two are different products anyway — one applies a code this project chose, the other puts a
|
|
114
|
+
// box on Stripe's page for the customer to fill in.
|
|
115
|
+
discounts: promotionCodeId === undefined ? undefined : [{ promotion_code: promotionCodeId }],
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
const created = await stripeJson(options.transport ?? stripeHttpFetch, "/checkout/sessions", {
|
|
119
|
+
what: "a Checkout Session",
|
|
120
|
+
secretKey: options.credentials.secretKey,
|
|
121
|
+
form,
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
return hostedSession(created, "a Checkout Session");
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Read a created session's URL, or refuse. Shared with the Billing Portal, which answers the same shape. */
|
|
128
|
+
export function hostedSession(created: unknown, what: string): CheckoutHandoff & { kind: "redirect" } {
|
|
129
|
+
const parsed = StripeHostedSession.safeParse(created);
|
|
130
|
+
if (!parsed.success) {
|
|
131
|
+
// Stripe created something and did not tell us where to send the browser. Nothing here can recover from that,
|
|
132
|
+
// and a redirect to an absent URL is worse than a refusal the caller can retry.
|
|
133
|
+
throw new PaymentsProviderUnavailableError({
|
|
134
|
+
detail: `Stripe created ${what} with no URL to redirect to.`,
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
return { kind: "redirect", url: parsed.data.url };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Turn the code a customer typed into the promotion code id Stripe's session wants.
|
|
142
|
+
*
|
|
143
|
+
* Stripe's `discounts[].promotion_code` takes a `promo_…` id, not the customer-facing string, so this lookup
|
|
144
|
+
* is unavoidable. It is also where the code's validity is learned, and learning it *here* is what makes the
|
|
145
|
+
* refusal a good one: an unknown, expired or exhausted code is refused before a session exists, as
|
|
146
|
+
* `payments/discount_invalid` naming the code, rather than becoming a checkout that fails in front of the
|
|
147
|
+
* customer with nothing to say but that something went wrong.
|
|
148
|
+
*
|
|
149
|
+
* `active: true` is part of the query rather than a check afterwards, so an expired or exhausted code simply
|
|
150
|
+
* does not come back. Stripe decides what active means — Pithy does not read `expires_at` or count
|
|
151
|
+
* redemptions itself, for the same reason it does not compute the discounted price.
|
|
152
|
+
*/
|
|
153
|
+
async function resolvePromotionCode(code: string, options: StripeCheckoutOptions): Promise<string> {
|
|
154
|
+
const found = await stripeJson(options.transport ?? stripeHttpFetch, "/promotion_codes", {
|
|
155
|
+
what: `the discount code ${code}`,
|
|
156
|
+
secretKey: options.credentials.secretKey,
|
|
157
|
+
query: { code, active: true, limit: 1 },
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
const parsed = StripePromotionCodes.safeParse(found);
|
|
161
|
+
const promotion = parsed.success ? parsed.data.data[0] : undefined;
|
|
162
|
+
if (promotion === undefined) {
|
|
163
|
+
throw new PaymentsDiscountInvalidError({
|
|
164
|
+
message: `"${code}" is not a discount code we can accept.`,
|
|
165
|
+
detail: `Stripe has no active promotion code matching "${code}" — unknown, expired, or fully redeemed.`,
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
return promotion.id;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Stripe's promotion-code list, narrowed to the id a session needs. */
|
|
172
|
+
const StripePromotionCodes = z
|
|
173
|
+
.object({ data: z.array(z.object({ id: z.string().min(1) }).loose()) })
|
|
174
|
+
.loose()
|
|
175
|
+
.describe("A Stripe promotion-code list response.");
|