@hearthkit/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.
Files changed (46) hide show
  1. package/package.json +50 -0
  2. package/src/create-checkout-session.test.ts +268 -0
  3. package/src/create-checkout-session.ts +216 -0
  4. package/src/create-customer-portal-session.test.ts +160 -0
  5. package/src/create-customer-portal-session.ts +66 -0
  6. package/src/create-payments-client.test.ts +231 -0
  7. package/src/create-payments-client.ts +90 -0
  8. package/src/handle-stripe-webhook-ignored.test.ts +293 -0
  9. package/src/handle-stripe-webhook-purchase.test.ts +279 -0
  10. package/src/handle-stripe-webhook-signature.test.ts +194 -0
  11. package/src/handle-stripe-webhook-subscription.test.ts +376 -0
  12. package/src/handle-stripe-webhook.ts +133 -0
  13. package/src/hearthkit-payments-drizzle-schema.test.ts +214 -0
  14. package/src/hearthkit-payments-drizzle-schema.ts +80 -0
  15. package/src/index.ts +267 -0
  16. package/src/list-payments-purchases.test.ts +132 -0
  17. package/src/list-payments-purchases.ts +57 -0
  18. package/src/payments-catalog-lookup.ts +23 -0
  19. package/src/payments-catalog-validation.ts +190 -0
  20. package/src/payments-client-secrets.ts +47 -0
  21. package/src/payments-contract.ts +1038 -0
  22. package/src/payments-customer-record.ts +89 -0
  23. package/src/payments-database-unavailable.test.ts +152 -0
  24. package/src/payments-env-schema-fragment.test.ts +197 -0
  25. package/src/payments-failure-results.ts +185 -0
  26. package/src/payments-input-invalid.test.ts +201 -0
  27. package/src/payments-purchase-record.ts +63 -0
  28. package/src/payments-row-identifier.ts +10 -0
  29. package/src/payments-stripe-unreachable.test.ts +91 -0
  30. package/src/payments-subscription-record.ts +77 -0
  31. package/src/read-payments-subscription.test.ts +126 -0
  32. package/src/read-payments-subscription.ts +61 -0
  33. package/src/redact-payments-secrets.ts +18 -0
  34. package/src/stripe-catalog-price-sync.ts +101 -0
  35. package/src/stripe-catalog-product-sync.ts +75 -0
  36. package/src/stripe-checkout-session-event.ts +204 -0
  37. package/src/stripe-event-payload-fields.ts +39 -0
  38. package/src/stripe-price-lookup-key.ts +26 -0
  39. package/src/stripe-subscription-event.ts +112 -0
  40. package/src/stripe-webhook-delivery-results.ts +46 -0
  41. package/src/sync-payments-catalog.test.ts +208 -0
  42. package/src/sync-payments-catalog.ts +65 -0
  43. package/src/thrown-payments-error-details.ts +134 -0
  44. package/src/thrown-payments-error-failure.ts +74 -0
  45. package/src/verify-payments-tables-exist.test.ts +84 -0
  46. package/src/verify-payments-tables-exist.ts +67 -0
@@ -0,0 +1,1038 @@
1
+ import type { AuthOrganizationId, AuthUserId } from '@hearthkit/auth/auth-contract'
2
+ import type { NodePgDatabase } from 'drizzle-orm/node-postgres'
3
+ import type Stripe from 'stripe'
4
+ import { z } from 'zod'
5
+
6
+ /** Unique literal prefix of the failure message when a caller-supplied value is rejected before any service is contacted. */
7
+ export const paymentsInputInvalidErrorPrefix = 'hearthkit payments input invalid:'
8
+
9
+ /** Unique literal prefix of the failure message when the app's catalog is malformed or names something twice. */
10
+ export const paymentsCatalogInvalidErrorPrefix = 'hearthkit payments catalog invalid:'
11
+
12
+ /** Unique literal prefix of the failure message when a named price is in neither the catalog nor Stripe. */
13
+ export const paymentsPriceNotFoundErrorPrefix = 'hearthkit payments price not found:'
14
+
15
+ /** Unique literal prefix of the failure message when no customer row exists for the billing reference. */
16
+ export const paymentsCustomerNotFoundErrorPrefix = 'hearthkit payments customer not found:'
17
+
18
+ /** Unique literal prefix of the failure message when a webhook request carries no signature or a signature that does not verify. */
19
+ export const paymentsWebhookSignatureInvalidErrorPrefix =
20
+ 'hearthkit payments webhook signature invalid:'
21
+
22
+ /** Unique literal prefix of the failure message when Stripe refuses the API key or its permissions. */
23
+ export const paymentsStripeUnauthorizedErrorPrefix = 'hearthkit payments stripe unauthorized:'
24
+
25
+ /** Unique literal prefix of the failure message when the Stripe API could not be reached or the request timed out. */
26
+ export const paymentsStripeUnreachableErrorPrefix = 'hearthkit payments stripe unreachable:'
27
+
28
+ /** Unique literal prefix of the failure message when Postgres refuses, the password or database is wrong, or the tables are missing. */
29
+ export const paymentsDatabaseUnavailableErrorPrefix = 'hearthkit payments database unavailable:'
30
+
31
+ /** Unique literal prefix of the failure message when a payments call failed in a way this package does not name. */
32
+ export const paymentsRequestFailedErrorPrefix = 'hearthkit payments request failed:'
33
+
34
+ /** HTTP header Stripe signs a webhook delivery with; this package reads it off the request headers so the name is spelled once. */
35
+ export const stripeSignatureHeaderName = 'stripe-signature'
36
+
37
+ /** Signature scheme inside that header, read off stripe@22.6.1's EXPECTED_SCHEME; every entry this package verifies is a v1 entry. */
38
+ export const stripeSignatureScheme = 'v1'
39
+
40
+ /** Seconds a signed webhook stays acceptable, read off stripe@22.6.1's DEFAULT_TOLERANCE; an older signature is rejected as invalid. */
41
+ export const stripeSignatureToleranceSeconds = 300
42
+
43
+ // Measured in stripe@22.6.1's esm/Webhooks.js:184. THE DECOY IS ONE LINE AWAY IN THE SAME FILE:
44
+ // `No signatures found with expected scheme` (line 130) is thrown when the header parses but carries
45
+ // no v1 entry, which is a DIFFERENT cause. A gate matching the substring `No signatures found`
46
+ // satisfies both, so a wrong-secret gate must match this whole prefix and not the shared words.
47
+ /** Opening words of the message stripe throws when the signing secret is wrong; the value a wrong-secret gate matches on. */
48
+ export const stripeWrongSecretSignatureMessagePrefix =
49
+ 'No signatures found matching the expected signature for payload.'
50
+
51
+ /** Message stripe throws when the header parses but carries no v1 entry; named only so nobody mistakes it for the wrong-secret one. */
52
+ export const stripeWrongSchemeSignatureMessage = 'No signatures found with expected scheme'
53
+
54
+ /** Opening words of the message stripe throws when a parsed object was passed instead of the raw request body. */
55
+ export const stripeParsedBodySignatureMessagePrefix =
56
+ 'Webhook payload must be provided as a string or a Buffer'
57
+
58
+ /** HTTP status Stripe answers with when the API key is wrong or revoked; mapped to payments-stripe-unauthorized. */
59
+ export const stripeUnauthorizedHttpStatus = 401
60
+
61
+ /** HTTP status Stripe answers with when a restricted key lacks the permission; mapped to the same failure as 401. */
62
+ export const stripeForbiddenHttpStatus = 403
63
+
64
+ /** Stripe checkout mode this package uses for a subscription price; Stripe spells the one-time case differently, see below. */
65
+ export const stripeSubscriptionCheckoutMode = 'subscription'
66
+
67
+ /** Stripe checkout mode this package uses for a one-time price; note it is `payment`, not `one-time` or `one_time`. */
68
+ export const stripeOneTimeCheckoutMode = 'payment'
69
+
70
+ /** Stripe price type for a subscription price, as Price.type reports it; not the same word as the checkout mode. */
71
+ export const stripeRecurringPriceType = 'recurring'
72
+
73
+ /** Stripe price type for a one-time price, as Price.type reports it; note the underscore, which the catalog spelling does not have. */
74
+ export const stripeOneTimePriceType = 'one_time'
75
+
76
+ /** Stripe metadata key carrying the billing reference on every session and subscription this package creates. */
77
+ export const hearthkitBillingReferenceMetadataKey = 'hearthkit_billing_reference_id'
78
+
79
+ /** Stripe metadata key carrying the billing scope on every session and subscription this package creates. */
80
+ export const hearthkitBillingScopeMetadataKey = 'hearthkit_billing_scope'
81
+
82
+ // THESE THREE GO ON THE SESSION ONLY, NEVER ON subscription_data.metadata. A webhook payload does not
83
+ // carry Checkout.Session.line_items — the field is declared optional and the SDK calls it "includable"
84
+ // on a retrieve — so the purchase path has no other source for the price it sold. The subscription
85
+ // path must NOT use them: a portal upgrade changes the subscription's price without touching metadata
86
+ // stamped at creation, so a stamped price name goes stale and becomes actively wrong. The subscription
87
+ // path reads its price live from items.data[].price.lookup_key instead, which cannot go stale.
88
+ /** Stripe session metadata key carrying the catalog price name a one-time purchase was for. */
89
+ export const hearthkitPriceNameMetadataKey = 'hearthkit_price_name'
90
+
91
+ /** Stripe session metadata key carrying the Stripe price id a one-time purchase was for. */
92
+ export const hearthkitStripePriceIdMetadataKey = 'hearthkit_stripe_price_id'
93
+
94
+ /** Stripe session metadata key carrying the quantity bought, written as a decimal string because Stripe metadata values are strings. */
95
+ export const hearthkitQuantityMetadataKey = 'hearthkit_quantity'
96
+
97
+ // The same four @better-auth/stripe handles, read out of its dist. Matching the set is deliberate:
98
+ // it is sufficient for subscription state, and it keeps a later move to that plugin a data question.
99
+ /** Stripe event types handleStripeWebhook acts on; every other type is reported as ignored, which is a result and not a failure. */
100
+ export const handledStripeWebhookEventTypes = [
101
+ 'checkout.session.completed',
102
+ 'customer.subscription.created',
103
+ 'customer.subscription.updated',
104
+ 'customer.subscription.deleted',
105
+ ] as const
106
+
107
+ /** One handled Stripe event type; the literal union, so a typo cannot pass as an event type. */
108
+ export const handledStripeWebhookEventTypeSchema = z.enum(handledStripeWebhookEventTypes)
109
+
110
+ /** Name of a Stripe event this package acts on. */
111
+ export type HandledStripeWebhookEventType = z.infer<typeof handledStripeWebhookEventTypeSchema>
112
+
113
+ // Read off stripe@22.6.1's Subscription.Status at esm/resources/Subscriptions.d.ts:478. THE DECOY IS
114
+ // ONE UNION AWAY IN THE SAME FILE: SubscriptionListParams.Status at :2703 adds `all` and `ended`, and
115
+ // neither is a status a subscription can hold. The stored column is a plain string rather than an
116
+ // enum, because Stripe's own union ends in OtherString and can grow; this list is for comparison.
117
+ /** Subscription statuses Stripe reports at the pinned SDK version; the stored column accepts any string, so a new one cannot break a write. */
118
+ export const paymentsKnownSubscriptionStatuses = [
119
+ 'active',
120
+ 'canceled',
121
+ 'incomplete',
122
+ 'incomplete_expired',
123
+ 'past_due',
124
+ 'paused',
125
+ 'trialing',
126
+ 'unpaid',
127
+ ] as const
128
+
129
+ /** The two statuses that mean a customer is currently entitled; the same pair @better-auth/stripe's isActiveOrTrialing tests. */
130
+ export const paymentsActiveSubscriptionStatuses = ['active', 'trialing'] as const
131
+
132
+ // The same allowlist @hearthkit/auth uses for auth-database-unavailable, and the reasoning transfers
133
+ // with it. Deliberately NOT "any cause carrying a code": 23505 is a unique violation, which is a
134
+ // caller error rather than an unavailable database, and this package has three unique constraints a
135
+ // concurrent webhook delivery can race into. Everything outside these four stays in the catch-all.
136
+ /** Postgres and socket codes, found one .cause hop below a DrizzleQueryError, that mean the database is unavailable rather than the caller wrong. */
137
+ export const paymentsDatabaseUnavailableCauseCodes = [
138
+ 'ECONNREFUSED',
139
+ '42P01',
140
+ '3D000',
141
+ '28P01',
142
+ ] as const
143
+
144
+ /** Longest catalog product name this package accepts; it is also the Stripe product id, which must be unique in the account. */
145
+ export const maximumPaymentsProductNameLength = 100
146
+
147
+ /** Longest catalog price name this package accepts; it is also the Stripe price lookup_key, documented at 200 characters. */
148
+ export const maximumPaymentsPriceNameLength = 200
149
+
150
+ /** Quantity createCheckoutSession uses when the caller does not choose one. */
151
+ export const defaultCheckoutQuantity = 1
152
+
153
+ // The Drizzle schema key equals the SQL table name, the same rule @hearthkit/auth follows, so
154
+ // verifyPaymentsTablesExist and the schema share one list and there is nothing to keep in step.
155
+ /** Every table the shipped Drizzle schema defines, in both user-scoped and org-scoped mode; the flag never removes one. */
156
+ export const hearthkitPaymentsTableNames = [
157
+ 'payments_customer',
158
+ 'payments_subscription',
159
+ 'payments_purchase',
160
+ ] as const
161
+
162
+ /** One of the three payments table names; used by the table presence check and by anything asserting the migration ran. */
163
+ export const hearthkitPaymentsTableNameSchema = z.enum(hearthkitPaymentsTableNames)
164
+
165
+ /** Name of one payments table; the literal union, so a typo cannot pass as a table name. */
166
+ export type HearthkitPaymentsTableName = z.infer<typeof hearthkitPaymentsTableNameSchema>
167
+
168
+ /** The Drizzle table map this package ships; spread it into the app's schema so one drizzle-kit run covers every table. */
169
+ export type HearthkitPaymentsDrizzleSchema = Record<HearthkitPaymentsTableName, unknown>
170
+
171
+ // Both secrets are validated as non-empty with no whitespace, and NOT by an `sk_` or `whsec_` prefix.
172
+ // Measured: stripe@22.6.1 computes `/\s/.test(secret)` and warns that whitespace "often indicates an
173
+ // extra newline or space is in the value", so whitespace is the mistake upstream itself names. A
174
+ // prefix, by contrast, would have to come from documentation, would reject a legitimate restricted
175
+ // key, and would break the day Stripe issues a different one. Test mode is asserted from `livemode`
176
+ // on a returned object instead, which is behaviour rather than a string match.
177
+ const noWhitespacePattern = /^\S+$/
178
+
179
+ /** Value of STRIPE_SECRET_KEY; a secret, so it never appears in a failure, a log line, or any returned value. */
180
+ export const stripeSecretKeySchema = z
181
+ .string()
182
+ .regex(noWhitespacePattern)
183
+ .brand<'StripeSecretKey'>()
184
+
185
+ /** Branded Stripe API key; treat every value of this type as a secret that must not be printed. */
186
+ export type StripeSecretKey = z.infer<typeof stripeSecretKeySchema>
187
+
188
+ /** Value of STRIPE_WEBHOOK_SECRET; a secret, and the gates choose it rather than obtaining it from Stripe. */
189
+ export const stripeWebhookSecretSchema = z
190
+ .string()
191
+ .regex(noWhitespacePattern)
192
+ .brand<'StripeWebhookSecret'>()
193
+
194
+ /** Branded webhook signing secret; treat every value of this type as a secret that must not be printed. */
195
+ export type StripeWebhookSecret = z.infer<typeof stripeWebhookSecretSchema>
196
+
197
+ /** Env schema fragment this package contributes to config; both variables are required, per plan 4.8's input line. */
198
+ export const paymentsEnvSchemaFragment = z.object({
199
+ STRIPE_SECRET_KEY: stripeSecretKeySchema,
200
+ STRIPE_WEBHOOK_SECRET: stripeWebhookSecretSchema,
201
+ })
202
+
203
+ /** Validated values of this package's variables, as config returns them; the input to createPaymentsClient. */
204
+ export type PaymentsEnvValues = z.output<typeof paymentsEnvSchemaFragment>
205
+
206
+ /** Whether billing is keyed on a user or on an organization; the same two words @better-auth/stripe uses for customerType. */
207
+ export const billingScopeSchema = z.enum(['user', 'organization'])
208
+
209
+ /** Billing scope of a project, decided by the auth scaffold flag and stored on every customer row. */
210
+ export type BillingScope = z.infer<typeof billingScopeSchema>
211
+
212
+ /** Identifier of whoever is billed; opaque and generated by Better Auth, so never parse or construct one by hand. */
213
+ export const billingReferenceIdSchema = z.string().min(1).brand<'BillingReferenceId'>()
214
+
215
+ /** Branded billing reference; one name for the value that is a user id in user scope and an organization id in org scope. */
216
+ export type BillingReferenceId = z.infer<typeof billingReferenceIdSchema>
217
+
218
+ /** The two auth ids a billing reference is parsed from; stated so the seam between the packages is visible in the types. */
219
+ export type BillingReferenceOwnerId = AuthUserId | AuthOrganizationId
220
+
221
+ /** Address Stripe sends receipts to; the same brand email and auth use, so one parsed address flows through all three packages. */
222
+ export const billingContactEmailSchema = z.email().brand<'EmailAddress'>()
223
+
224
+ /** Branded billing contact address; produced by parsing an untrusted string, never by casting one. */
225
+ export type BillingContactEmail = z.infer<typeof billingContactEmailSchema>
226
+
227
+ /** Catalog product name; lowercase kebab-case, and it is also the Stripe product id, which makes sync idempotent without a search. */
228
+ export const paymentsProductNameSchema = z
229
+ .string()
230
+ .regex(/^[a-z][a-z0-9-]*$/)
231
+ .max(maximumPaymentsProductNameLength)
232
+ .brand<'PaymentsProductName'>()
233
+
234
+ /** Branded catalog product name; stable across syncs, because renaming it creates a second Stripe product. */
235
+ export type PaymentsProductName = z.infer<typeof paymentsProductNameSchema>
236
+
237
+ /** Catalog price name; lowercase kebab-case, unique across the whole catalog, and also the Stripe price lookup_key. */
238
+ export const paymentsPriceNameSchema = z
239
+ .string()
240
+ .regex(/^[a-z][a-z0-9-]*$/)
241
+ .max(maximumPaymentsPriceNameLength)
242
+ .brand<'PaymentsPriceName'>()
243
+
244
+ /** Branded catalog price name; the one value checkout takes, sync writes, and the webhook handler reads back off the price. */
245
+ export type PaymentsPriceName = z.infer<typeof paymentsPriceNameSchema>
246
+
247
+ /** Currency of a price; three lowercase letters, which is how Stripe spells ISO 4217. */
248
+ export const paymentsCurrencyCodeSchema = z
249
+ .string()
250
+ .regex(/^[a-z]{3}$/)
251
+ .brand<'PaymentsCurrencyCode'>()
252
+
253
+ /** Branded currency code; lowercase, because Stripe returns it lowercase and a mixed-case comparison would silently fail. */
254
+ export type PaymentsCurrencyCode = z.infer<typeof paymentsCurrencyCodeSchema>
255
+
256
+ /** Price amount in the currency's smallest unit, as Stripe's unit_amount; 1900 is nineteen dollars, not nineteen hundred. */
257
+ export const paymentsUnitAmountMinorUnitsSchema = z.number().int().positive()
258
+
259
+ /** How often a subscription price recurs; Stripe's four intervals and no others. */
260
+ export const paymentsRecurringIntervalSchema = z.enum(['day', 'week', 'month', 'year'])
261
+
262
+ /** Recurrence of a subscription price; absent from a one-time price, which is why the catalog price is a discriminated union. */
263
+ export type PaymentsRecurringInterval = z.infer<typeof paymentsRecurringIntervalSchema>
264
+
265
+ /** How many units of a price are being bought; a positive integer, defaulting to one. */
266
+ export const paymentsQuantitySchema = z.number().int().positive()
267
+
268
+ /** Identifier of a Stripe customer; opaque, generated by Stripe, so never parse or construct one. */
269
+ export const stripeCustomerIdSchema = z.string().min(1).brand<'StripeCustomerId'>()
270
+
271
+ /** Branded Stripe customer id; separately branded from the other Stripe ids because swapping two of them is the mistake a brand stops. */
272
+ export type StripeCustomerId = z.infer<typeof stripeCustomerIdSchema>
273
+
274
+ /** Identifier of a Stripe subscription; opaque, generated by Stripe, so never parse or construct one. */
275
+ export const stripeSubscriptionIdSchema = z.string().min(1).brand<'StripeSubscriptionId'>()
276
+
277
+ /** Branded Stripe subscription id; the unique key that makes a replayed webhook delivery an upsert rather than a duplicate row. */
278
+ export type StripeSubscriptionId = z.infer<typeof stripeSubscriptionIdSchema>
279
+
280
+ /** Identifier of a Stripe product; equal to the catalog product name, because this package supplies the id at create time. */
281
+ export const stripeProductIdSchema = z.string().min(1).brand<'StripeProductId'>()
282
+
283
+ /** Branded Stripe product id. */
284
+ export type StripeProductId = z.infer<typeof stripeProductIdSchema>
285
+
286
+ /** Identifier of a Stripe price; opaque, generated by Stripe, and it changes whenever a price is replaced. */
287
+ export const stripePriceIdSchema = z.string().min(1).brand<'StripePriceId'>()
288
+
289
+ /** Branded Stripe price id; never stable across a price change, which is why the catalog keys on the lookup key instead. */
290
+ export type StripePriceId = z.infer<typeof stripePriceIdSchema>
291
+
292
+ /** Identifier of a Stripe checkout session; opaque, generated by Stripe, so never parse or construct one. */
293
+ export const stripeCheckoutSessionIdSchema = z.string().min(1).brand<'StripeCheckoutSessionId'>()
294
+
295
+ /** Branded Stripe checkout session id; the unique key that makes a replayed purchase webhook an upsert rather than a duplicate row. */
296
+ export type StripeCheckoutSessionId = z.infer<typeof stripeCheckoutSessionIdSchema>
297
+
298
+ /** Identifier of a Stripe payment intent; opaque, and absent on a fully discounted order, which is why the column is nullable. */
299
+ export const stripePaymentIntentIdSchema = z.string().min(1).brand<'StripePaymentIntentId'>()
300
+
301
+ /** Branded Stripe payment intent id. */
302
+ export type StripePaymentIntentId = z.infer<typeof stripePaymentIntentIdSchema>
303
+
304
+ /** Identifier of a Stripe event; opaque, generated by Stripe, and reported back on every webhook result. */
305
+ export const stripeEventIdSchema = z.string().min(1).brand<'StripeEventId'>()
306
+
307
+ /** Branded Stripe event id; carried on both webhook results so a log line can be traced to a delivery in the Stripe dashboard. */
308
+ export type StripeEventId = z.infer<typeof stripeEventIdSchema>
309
+
310
+ /** Identifier of a customer row this package wrote; generated here, not by Stripe and not by Better Auth. */
311
+ export const paymentsCustomerIdSchema = z.string().min(1).brand<'PaymentsCustomerId'>()
312
+
313
+ /** Branded customer row id. */
314
+ export type PaymentsCustomerId = z.infer<typeof paymentsCustomerIdSchema>
315
+
316
+ /** Identifier of a subscription row this package wrote; generated here, not by Stripe. */
317
+ export const paymentsSubscriptionIdSchema = z.string().min(1).brand<'PaymentsSubscriptionId'>()
318
+
319
+ /** Branded subscription row id; not the Stripe subscription id, which is a separate column and a separate brand. */
320
+ export type PaymentsSubscriptionId = z.infer<typeof paymentsSubscriptionIdSchema>
321
+
322
+ /** Identifier of a purchase row this package wrote; generated here, not by Stripe. */
323
+ export const paymentsPurchaseIdSchema = z.string().min(1).brand<'PaymentsPurchaseId'>()
324
+
325
+ /** Branded purchase row id. */
326
+ export type PaymentsPurchaseId = z.infer<typeof paymentsPurchaseIdSchema>
327
+
328
+ // Stored as a plain string rather than an enum on purpose: Stripe's own union ends in OtherString and
329
+ // can grow, and a strict enum would turn a newly added status into a failed write in production.
330
+ // Compare against paymentsKnownSubscriptionStatuses instead of narrowing this.
331
+ /** Status of a subscription exactly as Stripe reported it; any string, so a status Stripe adds later cannot break a read or a write. */
332
+ export const paymentsSubscriptionStatusSchema = z.string().min(1)
333
+
334
+ /** Absolute http(s) URL a Stripe hosted page redirects back to; Stripe rejects a relative path, so this is checked before the call. */
335
+ export const paymentsRedirectUrlSchema = z.url({ protocol: /^https?$/ })
336
+
337
+ /** Base URL overriding the Stripe API host, port and protocol; omit it in every real deployment, it exists for tests and proxies. */
338
+ export const paymentsStripeApiBaseUrlSchema = z.url({ protocol: /^https?$/ })
339
+
340
+ /** A catalog price billed on a recurring schedule; maps to Stripe price type `recurring` and checkout mode `subscription`. */
341
+ export const paymentsSubscriptionCatalogPriceSchema = z.object({
342
+ priceName: paymentsPriceNameSchema,
343
+ currency: paymentsCurrencyCodeSchema,
344
+ unitAmountMinorUnits: paymentsUnitAmountMinorUnitsSchema,
345
+ priceKind: z.literal('subscription'),
346
+ recurringInterval: paymentsRecurringIntervalSchema,
347
+ recurringIntervalCount: z.number().int().positive().optional(),
348
+ })
349
+
350
+ /** A catalog price billed once; maps to Stripe price type `one_time` and checkout mode `payment`, neither of which is spelled this way. */
351
+ export const paymentsOneTimeCatalogPriceSchema = z.object({
352
+ priceName: paymentsPriceNameSchema,
353
+ currency: paymentsCurrencyCodeSchema,
354
+ unitAmountMinorUnits: paymentsUnitAmountMinorUnitsSchema,
355
+ priceKind: z.literal('one-time'),
356
+ })
357
+
358
+ // A discriminated union rather than an optional interval field, so a one-time price carrying a
359
+ // recurring interval is unrepresentable. It is also what keeps plan section 13's usage-based billing
360
+ // open: a metered price is a new member with its own fields, not a nullable column on this one.
361
+ /** One price in the catalog; the discriminant is priceKind, deliberately not billingMode, which Stripe already uses for something else. */
362
+ export const paymentsCatalogPriceSchema = z.discriminatedUnion('priceKind', [
363
+ paymentsSubscriptionCatalogPriceSchema,
364
+ paymentsOneTimeCatalogPriceSchema,
365
+ ])
366
+
367
+ /** One catalog price; a subscription price or a one-time price, never a half-configured mixture of the two. */
368
+ export type PaymentsCatalogPrice = z.infer<typeof paymentsCatalogPriceSchema>
369
+
370
+ /** One product in the catalog; productName is the Stripe product id and displayName is what a buyer sees on the checkout page. */
371
+ export const paymentsCatalogProductSchema = z.object({
372
+ productName: paymentsProductNameSchema,
373
+ displayName: z.string().min(1).max(250),
374
+ description: z.string().min(1).max(1000).optional(),
375
+ prices: z.array(paymentsCatalogPriceSchema).min(1),
376
+ })
377
+
378
+ /** One catalog product with at least one price; a product with no prices is nothing anyone can buy. */
379
+ export type PaymentsCatalogProduct = z.infer<typeof paymentsCatalogProductSchema>
380
+
381
+ /** The whole catalog the app defines in payments-catalog.ts; price names must be unique across every product, not merely within one. */
382
+ export const paymentsCatalogSchema = z.object({
383
+ products: z.array(paymentsCatalogProductSchema).min(1),
384
+ })
385
+
386
+ /** The app's product and price catalog; validated once by createPaymentsClient so a mistake fails at boot rather than at checkout. */
387
+ export type PaymentsCatalog = z.infer<typeof paymentsCatalogSchema>
388
+
389
+ // billingContactEmail is text NOT NULL and has two writers with different rules. createCheckoutSession
390
+ // sets it from the caller's value. The customer-linked webhook path sets it ONLY when it inserts a row,
391
+ // from `session.customer_details.email ?? session.customer_email`, and NEVER on an update. Both Stripe
392
+ // fields are influenced by the buyer on a page the app does not control, so allowing an update would
393
+ // let a buyer silently redirect where receipts and dunning go; seeding a row that did not exist is the
394
+ // only case where nothing better is available. Read both, in that order: customer_email is a PREFILL
395
+ // field and is null on every session created with a customer id, while customer_details.email is the
396
+ // one documented as populated after completion — but its own SECOND sentence widens it to "the most
397
+ // recent valid email provided by the customer on the Checkout form" when they consented to promotional
398
+ // content, which is why it may seed a row and may not overwrite one. Both null on an insert is
399
+ // payments-request-failed, not an ignore: a completed checkout this package cannot record is not a
400
+ // normal state. No gate covers that branch.
401
+ /** A customer row as this package reports it; one row per billing reference, holding the Stripe customer it maps to. */
402
+ export const paymentsCustomerSchema = z.object({
403
+ id: paymentsCustomerIdSchema,
404
+ billingReferenceId: billingReferenceIdSchema,
405
+ billingScope: billingScopeSchema,
406
+ stripeCustomerId: stripeCustomerIdSchema,
407
+ billingContactEmail: billingContactEmailSchema,
408
+ createdAt: z.coerce.date(),
409
+ updatedAt: z.coerce.date(),
410
+ })
411
+
412
+ /** Customer record; the join between a hearthkit billing reference and a Stripe customer, and nothing else. */
413
+ export type PaymentsCustomer = z.infer<typeof paymentsCustomerSchema>
414
+
415
+ // currentPeriodStart and currentPeriodEnd come from the subscription ITEM, not the subscription:
416
+ // stripe@22.6.1's Subscription object has no current_period_* field at all, and @better-auth/stripe's
417
+ // dist reads them off the item in all four of its handlers. Reaching for subscription.current_period_end
418
+ // finds nothing. They are nullable here because a subscription can be reported before an item exists.
419
+ //
420
+ // quantity comes from that same item's `quantity`, which the SDK types OPTIONAL (`quantity?: number`),
421
+ // and it defaults to 1 when absent. That is not in tension with the hearthkit_quantity rule, which
422
+ // refuses to default: absence has a defined meaning on Stripe's own object — the item has no explicit
423
+ // quantity, which is one unit — and no defined meaning in a metadata string this package wrote and
424
+ // read back, where a bad value means the data is untrustworthy. Default where absence means something.
425
+ /** A subscription row as this package reports it; every field is what Stripe last told us, not a local interpretation of it. */
426
+ export const paymentsSubscriptionSchema = z.object({
427
+ id: paymentsSubscriptionIdSchema,
428
+ billingReferenceId: billingReferenceIdSchema,
429
+ stripeCustomerId: stripeCustomerIdSchema,
430
+ stripeSubscriptionId: stripeSubscriptionIdSchema,
431
+ priceName: paymentsPriceNameSchema,
432
+ stripePriceId: stripePriceIdSchema,
433
+ status: paymentsSubscriptionStatusSchema,
434
+ quantity: paymentsQuantitySchema,
435
+ currentPeriodStart: z.coerce.date().nullable(),
436
+ currentPeriodEnd: z.coerce.date().nullable(),
437
+ cancelAtPeriodEnd: z.boolean(),
438
+ canceledAt: z.coerce.date().nullable(),
439
+ endedAt: z.coerce.date().nullable(),
440
+ trialStart: z.coerce.date().nullable(),
441
+ trialEnd: z.coerce.date().nullable(),
442
+ createdAt: z.coerce.date(),
443
+ updatedAt: z.coerce.date(),
444
+ })
445
+
446
+ /** Subscription record; compare status against paymentsActiveSubscriptionStatuses rather than testing for the row's presence. */
447
+ export type PaymentsSubscription = z.infer<typeof paymentsSubscriptionSchema>
448
+
449
+ // Every column here comes from the webhook payload's own session object or from session metadata this
450
+ // package wrote, and NONE from line_items, which a webhook delivery does not carry. currency and
451
+ // amountTotalMinorUnits come from Session.currency and Session.amount_total, both of which Stripe
452
+ // types `| null`; a paid payment-mode session has them, and a null on this path is not a contract
453
+ // state and becomes payments-request-failed, the same treatment a null checkoutUrl gets.
454
+ /** A purchase row as this package reports it; one completed one-time checkout, keyed on the Stripe checkout session. */
455
+ export const paymentsPurchaseSchema = z.object({
456
+ id: paymentsPurchaseIdSchema,
457
+ billingReferenceId: billingReferenceIdSchema,
458
+ stripeCustomerId: stripeCustomerIdSchema,
459
+ stripeCheckoutSessionId: stripeCheckoutSessionIdSchema,
460
+ stripePaymentIntentId: stripePaymentIntentIdSchema.nullable(),
461
+ priceName: paymentsPriceNameSchema,
462
+ stripePriceId: stripePriceIdSchema,
463
+ currency: paymentsCurrencyCodeSchema,
464
+ amountTotalMinorUnits: z.number().int().min(0),
465
+ quantity: paymentsQuantitySchema,
466
+ purchasedAt: z.coerce.date(),
467
+ createdAt: z.coerce.date(),
468
+ updatedAt: z.coerce.date(),
469
+ })
470
+
471
+ /** Purchase record; amountTotalMinorUnits is Stripe's amount_total and can be zero when a coupon covered the whole order. */
472
+ export type PaymentsPurchase = z.infer<typeof paymentsPurchaseSchema>
473
+
474
+ /** Which caller-supplied value a call was rejected for; the reason never repeats the value itself. */
475
+ export const paymentsInvalidFieldNameSchema = z.enum([
476
+ 'billing-reference-id',
477
+ 'billing-contact-email',
478
+ 'price-name',
479
+ 'quantity',
480
+ 'success-url',
481
+ 'cancel-url',
482
+ 'return-url',
483
+ 'raw-request-body',
484
+ 'request-headers',
485
+ 'stripe-api-base-url',
486
+ 'payments-env',
487
+ 'drizzle-client',
488
+ ])
489
+
490
+ /** Name of the field a call was rejected for; a gate asserts on this rather than on message text. */
491
+ export type PaymentsInvalidFieldName = z.infer<typeof paymentsInvalidFieldNameSchema>
492
+
493
+ /** What is wrong with one catalog entry; entry-invalid covers every schema rejection and the reason states which rule broke. */
494
+ export const paymentsCatalogIssueKindSchema = z.enum([
495
+ 'catalog-has-no-products',
496
+ 'product-has-no-prices',
497
+ 'duplicate-product-name',
498
+ 'duplicate-price-name',
499
+ 'entry-invalid',
500
+ ])
501
+
502
+ /** Kind of catalog problem; a gate asserts on this enum rather than on the reason text. */
503
+ export type PaymentsCatalogIssueKind = z.infer<typeof paymentsCatalogIssueKindSchema>
504
+
505
+ /** One problem with the catalog, naming the offending product or price and the rule it broke, never the rejected value. */
506
+ export const paymentsCatalogIssueSchema = z.object({
507
+ catalogIssueKind: paymentsCatalogIssueKindSchema,
508
+ catalogEntryName: z.string(),
509
+ catalogIssueReason: z.string().min(1),
510
+ })
511
+
512
+ /** One catalog problem; reported in a list so one boot names every mistake instead of one per restart. */
513
+ export type PaymentsCatalogIssue = z.infer<typeof paymentsCatalogIssueSchema>
514
+
515
+ // Two causes, one variant, because the caller does the same thing with both — tell the buyer the plan
516
+ // is unavailable — and only the operator's next step differs. Same rule that produced auth's single
517
+ // auth-input-invalid with an invalidFieldName. absent-from-catalog needs no network at all.
518
+ /** Why a price could not be resolved: it is in no catalog entry, or the catalog has it but Stripe has never been synced. */
519
+ export const priceLookupFailureSchema = z.enum(['absent-from-catalog', 'absent-from-stripe'])
520
+
521
+ /** Which half of the price lookup failed; a gate asserts on this enum rather than on message text. */
522
+ export type PriceLookupFailure = z.infer<typeof priceLookupFailureSchema>
523
+
524
+ /** Why a webhook request was rejected: no signature header at all, or a header whose HMAC does not verify. */
525
+ export const webhookSignatureFailureReasonSchema = z.enum([
526
+ 'signature-header-missing',
527
+ 'signature-verification-failed',
528
+ ])
529
+
530
+ /** Which half of signature checking failed; both mean the request did not come from Stripe, so both are the same failure kind. */
531
+ export type WebhookSignatureFailureReason = z.infer<typeof webhookSignatureFailureReasonSchema>
532
+
533
+ /** Failure when a caller-supplied value is rejected; produced before any database or Stripe call is made. */
534
+ export const paymentsInputInvalidFailureSchema = z.object({
535
+ kind: z.literal('payments-input-invalid'),
536
+ invalidFieldName: paymentsInvalidFieldNameSchema,
537
+ invalidFieldReason: z.string().min(1),
538
+ message: z.string().startsWith(paymentsInputInvalidErrorPrefix),
539
+ })
540
+
541
+ /** Failure when the app's catalog is malformed or names a product or price twice; every problem in one list. */
542
+ export const paymentsCatalogInvalidFailureSchema = z.object({
543
+ kind: z.literal('payments-catalog-invalid'),
544
+ catalogIssues: z.array(paymentsCatalogIssueSchema).min(1),
545
+ message: z.string().startsWith(paymentsCatalogInvalidErrorPrefix),
546
+ })
547
+
548
+ /** Failure when the named price is in neither the catalog nor Stripe; priceLookupFailure says which, because the fixes differ. */
549
+ export const paymentsPriceNotFoundFailureSchema = z.object({
550
+ kind: z.literal('payments-price-not-found'),
551
+ priceName: z.string().min(1),
552
+ priceLookupFailure: priceLookupFailureSchema,
553
+ message: z.string().startsWith(paymentsPriceNotFoundErrorPrefix),
554
+ })
555
+
556
+ /** Failure when no customer row exists for the billing reference; only the portal call produces it, because checkout creates the row. */
557
+ export const paymentsCustomerNotFoundFailureSchema = z.object({
558
+ kind: z.literal('payments-customer-not-found'),
559
+ billingReferenceId: billingReferenceIdSchema,
560
+ message: z.string().startsWith(paymentsCustomerNotFoundErrorPrefix),
561
+ })
562
+
563
+ // stripeFailureDetail is the SDK error's `message` and nothing else. NEVER quote the error's
564
+ // `payload` property: StripeSignatureVerificationError carries the raw webhook body on it, which
565
+ // holds whatever customer data the event held.
566
+ /** Failure when a webhook request carries no signature header or one that does not verify; the caller answers 400 and writes nothing. */
567
+ export const paymentsWebhookSignatureInvalidFailureSchema = z.object({
568
+ kind: z.literal('payments-webhook-signature-invalid'),
569
+ signatureFailureReason: webhookSignatureFailureReasonSchema,
570
+ stripeFailureDetail: z.string().min(1).optional(),
571
+ message: z.string().startsWith(paymentsWebhookSignatureInvalidErrorPrefix),
572
+ })
573
+
574
+ /** Failure when Stripe refused the API key with 401 or its permissions with 403; one kind, because the caller does the same thing. */
575
+ export const paymentsStripeUnauthorizedFailureSchema = z.object({
576
+ kind: z.literal('payments-stripe-unauthorized'),
577
+ stripeErrorStatus: z.number().int(),
578
+ stripeFailureDetail: z.string().min(1),
579
+ message: z.string().startsWith(paymentsStripeUnauthorizedErrorPrefix),
580
+ })
581
+
582
+ /** Failure when the Stripe API could not be reached or the request timed out; the sibling of db's server-unreachable variant. */
583
+ export const paymentsStripeUnreachableFailureSchema = z.object({
584
+ kind: z.literal('payments-stripe-unreachable'),
585
+ stripeFailureDetail: z.string().min(1),
586
+ message: z.string().startsWith(paymentsStripeUnreachableErrorPrefix),
587
+ })
588
+
589
+ // The detail is built from the `.cause`, never from the DrizzleQueryError wrapper: the wrapper's
590
+ // message repeats the failing SQL AND its bound parameters, which here would put a customer's email
591
+ // address and Stripe ids into a returned failure that a caller is likely to log.
592
+ /** Failure when Postgres refuses, the password or database is wrong, or migrations never ran; one of four allowlisted cause codes. */
593
+ export const paymentsDatabaseUnavailableFailureSchema = z.object({
594
+ kind: z.literal('payments-database-unavailable'),
595
+ databaseFailureDetail: z.string().min(1),
596
+ message: z.string().startsWith(paymentsDatabaseUnavailableErrorPrefix),
597
+ })
598
+
599
+ // stripeErrorCode comes from `error.code` and stripeErrorStatus from `error.statusCode`. DO NOT read
600
+ // `error.type` expecting Stripe's own type string: measured at stripe@22.6.1, the constructor sets
601
+ // `this.type = type || this.constructor.name`, so it holds the SDK CLASS NAME such as
602
+ // 'StripeInvalidRequestError'. Stripe's own `invalid_request_error` is at `error.rawType`.
603
+ //
604
+ // THERE IS NO DISCRIMINATOR HERE AND THAT IS DELIBERATE. Several producers land in this variant — a
605
+ // null checkoutUrl, a null session.currency, a null session.amount_total — and a caller cannot tell
606
+ // them apart, because the three optional Stripe fields are absent on all of those paths and only
607
+ // paymentsFailureDetail differs. The detail is for a human reading a log. A gate asserts the kind and
608
+ // a non-empty detail, never which producer fired. Anything needing a branch gets its own variant.
609
+ /** Catch-all failure for anything this package cannot classify; it exists so no call ever throws instead of returning. */
610
+ export const paymentsRequestFailedFailureSchema = z.object({
611
+ kind: z.literal('payments-request-failed'),
612
+ stripeErrorCode: z.string().min(1).optional(),
613
+ stripeErrorStatus: z.number().int().optional(),
614
+ stripeErrorParam: z.string().min(1).optional(),
615
+ paymentsFailureDetail: z.string().min(1),
616
+ message: z.string().startsWith(paymentsRequestFailedErrorPrefix),
617
+ })
618
+
619
+ /** Every way a payments call can fail; each variant's message starts with its unique prefix and names the value at fault. */
620
+ export const paymentsFailureSchema = z.discriminatedUnion('kind', [
621
+ paymentsInputInvalidFailureSchema,
622
+ paymentsCatalogInvalidFailureSchema,
623
+ paymentsPriceNotFoundFailureSchema,
624
+ paymentsCustomerNotFoundFailureSchema,
625
+ paymentsWebhookSignatureInvalidFailureSchema,
626
+ paymentsStripeUnauthorizedFailureSchema,
627
+ paymentsStripeUnreachableFailureSchema,
628
+ paymentsDatabaseUnavailableFailureSchema,
629
+ paymentsRequestFailedFailureSchema,
630
+ ])
631
+
632
+ /** Discriminated failure union returned, never thrown, by every public function of this package. */
633
+ export type PaymentsFailure = z.infer<typeof paymentsFailureSchema>
634
+
635
+ /** Runtime check that a value is a Drizzle node-postgres client; the precise schema type lives on the option types. */
636
+ export const paymentsDrizzleClientSchema = z.custom<NodePgDatabase<Record<string, unknown>>>(
637
+ (value) => typeof value === 'object' && value !== null,
638
+ )
639
+
640
+ /** Runtime check that a value behaves like a web Headers object; duck-typed so Next's read-only headers pass, as auth does it. */
641
+ export const paymentsRequestHeadersSchema = z.custom<Headers>(
642
+ (value) =>
643
+ typeof value === 'object' &&
644
+ value !== null &&
645
+ 'get' in value &&
646
+ typeof value.get === 'function',
647
+ )
648
+
649
+ // This is the one value that legitimately carries the webhook secret, because carrying it to
650
+ // handleStripeWebhook is its entire job. It must never be logged, serialised or returned in a
651
+ // failure. The API key is not on it: the Stripe client already holds that internally.
652
+ /** The handle createPaymentsClient returns; every other function takes it, and it holds one secret, so never log or serialise it. */
653
+ export type PaymentsClient = {
654
+ stripeClient: Stripe
655
+ drizzleClient: NodePgDatabase<Record<string, unknown>>
656
+ paymentsCatalog: PaymentsCatalog
657
+ stripeWebhookSecret: StripeWebhookSecret
658
+ organizationsEnabled: boolean
659
+ billingScope: BillingScope
660
+ }
661
+
662
+ // Unlike auth's browser client, this handle is a plain object rather than a Proxy, so these property
663
+ // checks are real rather than vacuous. They are still shallow on purpose: asserting the shape of the
664
+ // Stripe client or the Drizzle client would pin someone else's internals.
665
+ /** Runtime check that a value is a payments client handle; the members this package relies on are present and of the right sort. */
666
+ export const paymentsClientSchema = z.custom<PaymentsClient>(
667
+ (value) =>
668
+ typeof value === 'object' &&
669
+ value !== null &&
670
+ 'stripeClient' in value &&
671
+ typeof value.stripeClient === 'object' &&
672
+ 'drizzleClient' in value &&
673
+ typeof value.drizzleClient === 'object' &&
674
+ 'paymentsCatalog' in value &&
675
+ typeof value.paymentsCatalog === 'object' &&
676
+ 'billingScope' in value &&
677
+ typeof value.billingScope === 'string',
678
+ )
679
+
680
+ /** Runtime shape of createPaymentsClient options; organizationsEnabled is the auth scaffold flag, never an env variable. */
681
+ export const createPaymentsClientOptionsSchema = z.object({
682
+ paymentsEnv: paymentsEnvSchemaFragment,
683
+ drizzleClient: paymentsDrizzleClientSchema,
684
+ paymentsCatalog: paymentsCatalogSchema,
685
+ organizationsEnabled: z.boolean(),
686
+ stripeApiBaseUrl: paymentsStripeApiBaseUrlSchema.optional(),
687
+ })
688
+
689
+ /** Options type for createPaymentsClient; pass the whole config object as paymentsEnv, extra keys are ignored. */
690
+ export type CreatePaymentsClientOptions = {
691
+ paymentsEnv: PaymentsEnvValues
692
+ drizzleClient: NodePgDatabase<Record<string, unknown>>
693
+ paymentsCatalog: PaymentsCatalog
694
+ organizationsEnabled: boolean
695
+ stripeApiBaseUrl?: string
696
+ }
697
+
698
+ /** Success shape of createPaymentsClient; the flag and the scope are echoed back so a caller holding the result knows which mode it built. */
699
+ export const paymentsClientCreatedSchema = z.object({
700
+ kind: z.literal('payments-client-created'),
701
+ paymentsClient: paymentsClientSchema,
702
+ organizationsEnabled: z.boolean(),
703
+ billingScope: billingScopeSchema,
704
+ })
705
+
706
+ /** Full result union of createPaymentsClient for runtime validation in gates. */
707
+ export const createPaymentsClientResultSchema = z.union([
708
+ paymentsClientCreatedSchema,
709
+ paymentsFailureSchema,
710
+ ])
711
+
712
+ /** Result type of createPaymentsClient. */
713
+ export type CreatePaymentsClientResult = z.infer<typeof createPaymentsClientResultSchema>
714
+
715
+ /** Signature of createPaymentsClient: synchronous so an app can build it at module scope, and it opens no connection. */
716
+ export type CreatePaymentsClient = (
717
+ options: CreatePaymentsClientOptions,
718
+ ) => CreatePaymentsClientResult
719
+
720
+ /** What sync did to one price: nothing, created it, or replaced it because Stripe prices are immutable in amount and currency. */
721
+ export const paymentsPriceSyncActionSchema = z.enum(['unchanged', 'created', 'replaced'])
722
+
723
+ /** Outcome of syncing one price; a second sync of an unchanged catalog reports every price unchanged. */
724
+ export type PaymentsPriceSyncAction = z.infer<typeof paymentsPriceSyncActionSchema>
725
+
726
+ /** One price after sync, with the Stripe ids it now maps to and what sync had to do to get there. */
727
+ export const paymentsSyncedPriceSchema = z.object({
728
+ priceName: paymentsPriceNameSchema,
729
+ stripeProductId: stripeProductIdSchema,
730
+ stripePriceId: stripePriceIdSchema,
731
+ syncAction: paymentsPriceSyncActionSchema,
732
+ })
733
+
734
+ /** One synced price; the stripePriceId changes whenever syncAction is replaced, which is why nothing stores it as an identity. */
735
+ export type PaymentsSyncedPrice = z.infer<typeof paymentsSyncedPriceSchema>
736
+
737
+ /** Runtime shape of syncPaymentsCatalog options; the catalog is the one held by the client, not a second one passed here. */
738
+ export const syncPaymentsCatalogOptionsSchema = z.object({
739
+ paymentsClient: paymentsClientSchema,
740
+ })
741
+
742
+ /** Options type for syncPaymentsCatalog. */
743
+ export type SyncPaymentsCatalogOptions = {
744
+ paymentsClient: PaymentsClient
745
+ }
746
+
747
+ // stripeLivemode is read off the Stripe object the API answered with, whose own generated type says
748
+ // it is true in live mode and false in test mode. It is here so a gate can refuse to touch a live
749
+ // account without matching on an API key prefix, which is a constant nothing offline can verify.
750
+ /** Success shape of syncPaymentsCatalog; stripeLivemode is the measured guard a gate asserts false before creating anything. */
751
+ export const paymentsCatalogSyncedSchema = z.object({
752
+ kind: z.literal('payments-catalog-synced'),
753
+ syncedPrices: z.array(paymentsSyncedPriceSchema).min(1),
754
+ stripeLivemode: z.boolean(),
755
+ })
756
+
757
+ /** Full result union of syncPaymentsCatalog for runtime validation in gates. */
758
+ export const syncPaymentsCatalogResultSchema = z.union([
759
+ paymentsCatalogSyncedSchema,
760
+ paymentsFailureSchema,
761
+ ])
762
+
763
+ /** Result type of syncPaymentsCatalog. */
764
+ export type SyncPaymentsCatalogResult = z.infer<typeof syncPaymentsCatalogResultSchema>
765
+
766
+ /** Signature of syncPaymentsCatalog: idempotent, so a second run over an unchanged catalog reports every price unchanged. */
767
+ export type SyncPaymentsCatalog = (
768
+ options: SyncPaymentsCatalogOptions,
769
+ ) => Promise<SyncPaymentsCatalogResult>
770
+
771
+ /** Runtime shape of createCheckoutSession options; the caller-supplied values are plain strings because they come from a request. */
772
+ export const createCheckoutSessionOptionsSchema = z.object({
773
+ paymentsClient: paymentsClientSchema,
774
+ billingReferenceId: z.string(),
775
+ billingContactEmail: z.string(),
776
+ priceName: z.string(),
777
+ quantity: z.number().optional(),
778
+ successUrl: z.string(),
779
+ cancelUrl: z.string(),
780
+ })
781
+
782
+ /** Options type for createCheckoutSession; quantity defaults to one, and both URLs must be absolute because Stripe requires it. */
783
+ export type CreateCheckoutSessionOptions = {
784
+ paymentsClient: PaymentsClient
785
+ billingReferenceId: string
786
+ billingContactEmail: string
787
+ priceName: string
788
+ quantity?: number
789
+ successUrl: string
790
+ cancelUrl: string
791
+ }
792
+
793
+ /** Success shape of createCheckoutSession; checkoutUrl is the hosted page to redirect the buyer to. */
794
+ export const paymentsCheckoutSessionCreatedSchema = z.object({
795
+ kind: z.literal('payments-checkout-session-created'),
796
+ stripeCheckoutSessionId: stripeCheckoutSessionIdSchema,
797
+ checkoutUrl: z.url(),
798
+ stripeCustomerId: stripeCustomerIdSchema,
799
+ priceName: paymentsPriceNameSchema,
800
+ stripeLivemode: z.boolean(),
801
+ })
802
+
803
+ /** Full result union of createCheckoutSession for runtime validation in gates. */
804
+ export const createCheckoutSessionResultSchema = z.union([
805
+ paymentsCheckoutSessionCreatedSchema,
806
+ paymentsFailureSchema,
807
+ ])
808
+
809
+ /** Result type of createCheckoutSession. */
810
+ export type CreateCheckoutSessionResult = z.infer<typeof createCheckoutSessionResultSchema>
811
+
812
+ /** Signature of createCheckoutSession: creates the Stripe customer and the customer row when neither exists, so it never reports customer-not-found. */
813
+ export type CreateCheckoutSession = (
814
+ options: CreateCheckoutSessionOptions,
815
+ ) => Promise<CreateCheckoutSessionResult>
816
+
817
+ /** Runtime shape of createCustomerPortalSession options; returnUrl is where Stripe sends the customer back to. */
818
+ export const createCustomerPortalSessionOptionsSchema = z.object({
819
+ paymentsClient: paymentsClientSchema,
820
+ billingReferenceId: z.string(),
821
+ returnUrl: z.string(),
822
+ })
823
+
824
+ /** Options type for createCustomerPortalSession. */
825
+ export type CreateCustomerPortalSessionOptions = {
826
+ paymentsClient: PaymentsClient
827
+ billingReferenceId: string
828
+ returnUrl: string
829
+ }
830
+
831
+ /** Success shape of createCustomerPortalSession; portalUrl is short-lived, so it is redirected to rather than stored. */
832
+ export const paymentsPortalSessionCreatedSchema = z.object({
833
+ kind: z.literal('payments-portal-session-created'),
834
+ portalUrl: z.url(),
835
+ stripeCustomerId: stripeCustomerIdSchema,
836
+ })
837
+
838
+ /** Full result union of createCustomerPortalSession for runtime validation in gates. */
839
+ export const createCustomerPortalSessionResultSchema = z.union([
840
+ paymentsPortalSessionCreatedSchema,
841
+ paymentsFailureSchema,
842
+ ])
843
+
844
+ /** Result type of createCustomerPortalSession. */
845
+ export type CreateCustomerPortalSessionResult = z.infer<
846
+ typeof createCustomerPortalSessionResultSchema
847
+ >
848
+
849
+ /** Signature of createCustomerPortalSession: never creates a customer, which is why it is the only producer of payments-customer-not-found. */
850
+ export type CreateCustomerPortalSession = (
851
+ options: CreateCustomerPortalSessionOptions,
852
+ ) => Promise<CreateCustomerPortalSessionResult>
853
+
854
+ /** What the webhook handler wrote: it linked a customer, upserted a subscription, or recorded a one-time purchase. */
855
+ export const paymentsWebhookOutcomeSchema = z.enum([
856
+ 'customer-linked',
857
+ 'subscription-upserted',
858
+ 'purchase-recorded',
859
+ ])
860
+
861
+ /** Outcome of a processed webhook delivery; created, updated and deleted subscriptions all report subscription-upserted. */
862
+ export type PaymentsWebhookOutcome = z.infer<typeof paymentsWebhookOutcomeSchema>
863
+
864
+ // The two price reasons are path-specific because the two paths read the price from different places
865
+ // and a reader must be able to tell which one fired. The subscription path resolves the catalog price
866
+ // from items.data[].price.lookup_key, so its reason is about the catalog. The checkout path has no
867
+ // line items in the payload at all and reads the price from session metadata, so its reason is about
868
+ // the metadata. A completed purchase is never ignored merely because the catalog entry was deleted
869
+ // afterwards: a purchase is a historical fact, and the metadata already carries everything the row
870
+ // needs, so the checkout path does not consult the catalog.
871
+ /** Why a delivered event was not acted on; every value here is a normal state, not a mistake by anyone. */
872
+ export const paymentsWebhookIgnoredReasonSchema = z.enum([
873
+ 'event-type-not-handled',
874
+ 'checkout-mode-not-handled',
875
+ 'checkout-session-unpaid',
876
+ 'billing-reference-missing',
877
+ 'checkout-price-metadata-missing',
878
+ 'subscription-price-not-in-catalog',
879
+ ])
880
+
881
+ /** Reason a delivery was ignored; a gate asserts on this enum rather than on message text. */
882
+ export type PaymentsWebhookIgnoredReason = z.infer<typeof paymentsWebhookIgnoredReasonSchema>
883
+
884
+ // rawRequestBody must be the exact bytes Stripe sent. Measured at stripe@22.6.1, a parsed object
885
+ // throws `Webhook payload must be provided as a string or a Buffer …`, and a re-serialised body
886
+ // verifies as a signature mismatch rather than as the body-handling mistake it is. In a Next route
887
+ // handler that means `await request.text()`, never `await request.json()`.
888
+ /** Runtime shape of handleStripeWebhook options; the body is the raw request text and the headers carry the stripe-signature entry. */
889
+ export const handleStripeWebhookOptionsSchema = z.object({
890
+ paymentsClient: paymentsClientSchema,
891
+ rawRequestBody: z.string(),
892
+ requestHeaders: paymentsRequestHeadersSchema,
893
+ })
894
+
895
+ /** Options type for handleStripeWebhook; requestHeaders is whatever the route handler's Request carried. */
896
+ export type HandleStripeWebhookOptions = {
897
+ paymentsClient: PaymentsClient
898
+ rawRequestBody: string
899
+ requestHeaders: Headers
900
+ }
901
+
902
+ /** Result shape when the delivery was acted on; a replay of the same event writes the same row and moves no count. */
903
+ export const paymentsWebhookProcessedSchema = z.object({
904
+ kind: z.literal('payments-webhook-processed'),
905
+ stripeEventId: stripeEventIdSchema,
906
+ stripeEventType: z.string().min(1),
907
+ webhookOutcome: paymentsWebhookOutcomeSchema,
908
+ })
909
+
910
+ /** Result shape when the delivery was verified but not acted on; a successful answer, not a failure, because most events are none of our business. */
911
+ export const paymentsWebhookIgnoredSchema = z.object({
912
+ kind: z.literal('payments-webhook-ignored'),
913
+ stripeEventId: stripeEventIdSchema,
914
+ stripeEventType: z.string().min(1),
915
+ ignoredReason: paymentsWebhookIgnoredReasonSchema,
916
+ })
917
+
918
+ /** Full result union of handleStripeWebhook for runtime validation in gates. */
919
+ export const handleStripeWebhookResultSchema = z.union([
920
+ paymentsWebhookProcessedSchema,
921
+ paymentsWebhookIgnoredSchema,
922
+ paymentsFailureSchema,
923
+ ])
924
+
925
+ /** Result type of handleStripeWebhook. */
926
+ export type HandleStripeWebhookResult = z.infer<typeof handleStripeWebhookResultSchema>
927
+
928
+ /** Signature of handleStripeWebhook: verifies locally with an HMAC and never calls Stripe, so it needs Postgres but no network. */
929
+ export type HandleStripeWebhook = (
930
+ options: HandleStripeWebhookOptions,
931
+ ) => Promise<HandleStripeWebhookResult>
932
+
933
+ /** Runtime shape of readPaymentsSubscription options. */
934
+ export const readPaymentsSubscriptionOptionsSchema = z.object({
935
+ paymentsClient: paymentsClientSchema,
936
+ billingReferenceId: z.string(),
937
+ })
938
+
939
+ /** Options type for readPaymentsSubscription. */
940
+ export type ReadPaymentsSubscriptionOptions = {
941
+ paymentsClient: PaymentsClient
942
+ billingReferenceId: string
943
+ }
944
+
945
+ /** Result shape when a subscription row exists; the row is returned whatever its status, and the caller decides what counts as entitled. */
946
+ export const paymentsSubscriptionFoundSchema = z.object({
947
+ kind: z.literal('payments-subscription-found'),
948
+ paymentsSubscription: paymentsSubscriptionSchema,
949
+ })
950
+
951
+ /** Result shape when the reference has no subscription row at all; nobody having a subscription is a normal answer, not a failure. */
952
+ export const paymentsSubscriptionAbsentSchema = z.object({
953
+ kind: z.literal('payments-subscription-absent'),
954
+ })
955
+
956
+ /** Full result union of readPaymentsSubscription for runtime validation in gates. */
957
+ export const readPaymentsSubscriptionResultSchema = z.union([
958
+ paymentsSubscriptionFoundSchema,
959
+ paymentsSubscriptionAbsentSchema,
960
+ paymentsFailureSchema,
961
+ ])
962
+
963
+ /** Result type of readPaymentsSubscription. */
964
+ export type ReadPaymentsSubscriptionResult = z.infer<typeof readPaymentsSubscriptionResultSchema>
965
+
966
+ /** Signature of readPaymentsSubscription: returns the greatest createdAt for the reference, with id descending as the tiebreak. */
967
+ export type ReadPaymentsSubscription = (
968
+ options: ReadPaymentsSubscriptionOptions,
969
+ ) => Promise<ReadPaymentsSubscriptionResult>
970
+
971
+ /** Runtime shape of listPaymentsPurchases options. */
972
+ export const listPaymentsPurchasesOptionsSchema = z.object({
973
+ paymentsClient: paymentsClientSchema,
974
+ billingReferenceId: z.string(),
975
+ })
976
+
977
+ /** Options type for listPaymentsPurchases. */
978
+ export type ListPaymentsPurchasesOptions = {
979
+ paymentsClient: PaymentsClient
980
+ billingReferenceId: string
981
+ }
982
+
983
+ /** Success shape of listPaymentsPurchases; an empty array is a success, because "has bought nothing" is a correct answer. */
984
+ export const paymentsPurchasesListedSchema = z.object({
985
+ kind: z.literal('payments-purchases-listed'),
986
+ paymentsPurchases: z.array(paymentsPurchaseSchema),
987
+ })
988
+
989
+ /** Full result union of listPaymentsPurchases for runtime validation in gates. */
990
+ export const listPaymentsPurchasesResultSchema = z.union([
991
+ paymentsPurchasesListedSchema,
992
+ paymentsFailureSchema,
993
+ ])
994
+
995
+ /** Result type of listPaymentsPurchases. */
996
+ export type ListPaymentsPurchasesResult = z.infer<typeof listPaymentsPurchasesResultSchema>
997
+
998
+ /** Signature of listPaymentsPurchases: newest first by purchasedAt, with id descending as the tiebreak. */
999
+ export type ListPaymentsPurchases = (
1000
+ options: ListPaymentsPurchasesOptions,
1001
+ ) => Promise<ListPaymentsPurchasesResult>
1002
+
1003
+ /** Runtime shape of verifyPaymentsTablesExist options; it takes the Drizzle client so a health check needs no Stripe key. */
1004
+ export const verifyPaymentsTablesExistOptionsSchema = z.object({
1005
+ drizzleClient: paymentsDrizzleClientSchema,
1006
+ })
1007
+
1008
+ /** Options type for verifyPaymentsTablesExist. */
1009
+ export type VerifyPaymentsTablesExistOptions = {
1010
+ drizzleClient: NodePgDatabase<Record<string, unknown>>
1011
+ }
1012
+
1013
+ /** Result shape when every payments table is present in the public schema, in both user-scoped and org-scoped mode. */
1014
+ export const paymentsTablesPresentSchema = z.object({
1015
+ kind: z.literal('payments-tables-present'),
1016
+ presentTableNames: z.array(hearthkitPaymentsTableNameSchema).min(1),
1017
+ })
1018
+
1019
+ /** Result shape when at least one payments table is absent; this is a successful check reporting a negative answer. */
1020
+ export const paymentsTablesMissingSchema = z.object({
1021
+ kind: z.literal('payments-tables-missing'),
1022
+ missingTableNames: z.array(hearthkitPaymentsTableNameSchema).min(1),
1023
+ })
1024
+
1025
+ /** Full result union of verifyPaymentsTablesExist for runtime validation in gates. */
1026
+ export const verifyPaymentsTablesExistResultSchema = z.union([
1027
+ paymentsTablesPresentSchema,
1028
+ paymentsTablesMissingSchema,
1029
+ paymentsFailureSchema,
1030
+ ])
1031
+
1032
+ /** Result type of verifyPaymentsTablesExist. */
1033
+ export type VerifyPaymentsTablesExistResult = z.infer<typeof verifyPaymentsTablesExistResultSchema>
1034
+
1035
+ /** Signature of verifyPaymentsTablesExist: one query against information_schema, so it is safe to call from a health check. */
1036
+ export type VerifyPaymentsTablesExist = (
1037
+ options: VerifyPaymentsTablesExistOptions,
1038
+ ) => Promise<VerifyPaymentsTablesExistResult>