@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
package/package.json ADDED
@@ -0,0 +1,50 @@
1
+ {
2
+ "name": "@hearthkit/payments",
3
+ "version": "0.1.0",
4
+ "description": "Stripe subscriptions and one-time purchases: catalog sync, hosted checkout, the hosted customer portal, and the webhook handler",
5
+ "homepage": "https://github.com/chrisdevelops/hearthkit/tree/main/packages/payments",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/chrisdevelops/hearthkit.git",
9
+ "directory": "packages/payments"
10
+ },
11
+ "type": "module",
12
+ "exports": {
13
+ ".": {
14
+ "types": "./src/index.ts",
15
+ "default": "./src/index.ts"
16
+ },
17
+ "./payments-contract": {
18
+ "types": "./src/payments-contract.ts",
19
+ "default": "./src/payments-contract.ts"
20
+ }
21
+ },
22
+ "files": [
23
+ "src"
24
+ ],
25
+ "publishConfig": {
26
+ "access": "public"
27
+ },
28
+ "engines": {
29
+ "node": "24.20.0"
30
+ },
31
+ "dependencies": {
32
+ "drizzle-orm": "0.45.2",
33
+ "stripe": "22.6.1",
34
+ "zod": "4.4.3",
35
+ "@hearthkit/auth": "0.1.0",
36
+ "@hearthkit/db": "0.1.0",
37
+ "@hearthkit/config": "0.1.0"
38
+ },
39
+ "devDependencies": {
40
+ "@types/node": "24.13.3",
41
+ "@types/pg": "8.23.1",
42
+ "pg": "8.23.0",
43
+ "typescript": "7.0.2",
44
+ "vitest": "4.1.11"
45
+ },
46
+ "scripts": {
47
+ "test": "vitest run",
48
+ "typecheck": "tsc --noEmit"
49
+ }
50
+ }
@@ -0,0 +1,268 @@
1
+ import { afterAll, describe, expect, it } from 'vitest'
2
+ import {
3
+ expectPaymentsFailure,
4
+ expectResultKind,
5
+ } from '../test-fixtures/payments-gate-expectations.ts'
6
+ import { defineGateFileContext } from '../test-fixtures/payments-gate-file-context.ts'
7
+ import {
8
+ createVerifiedGatePaymentsDatabase,
9
+ type GatePaymentsDatabase,
10
+ } from '../test-fixtures/payments-gate-postgres-database.ts'
11
+ import {
12
+ archiveGateStripeCatalog,
13
+ assertGateStripeIsTestMode,
14
+ deleteGateStripeCustomer,
15
+ readGateStripeCheckoutSession,
16
+ } from '../test-fixtures/payments-gate-stripe-account.ts'
17
+ import {
18
+ deadStripeApiBaseUrl,
19
+ gateCancelUrl,
20
+ gatePaymentsCatalog,
21
+ gateStripeSecretKey,
22
+ gateSuccessUrl,
23
+ gateSuccessUrlWithCheckoutSessionPlaceholder,
24
+ hasGateStripeSecretKey,
25
+ reserveDeadLoopbackPort,
26
+ uniqueGateBillingContactEmail,
27
+ uniqueGateBillingReferenceId,
28
+ uniqueGatePaymentsCatalogNames,
29
+ type GatePaymentsCatalogNames,
30
+ } from '../test-fixtures/payments-gate-values.ts'
31
+ import {
32
+ createGatePaymentsClient,
33
+ loadHearthkitPaymentsEntry,
34
+ type HearthkitPaymentsEntry,
35
+ } from '../test-fixtures/hearthkit-payments-entry.ts'
36
+ import {
37
+ createCheckoutSessionResultSchema,
38
+ stripeOneTimeCheckoutMode,
39
+ stripeSubscriptionCheckoutMode,
40
+ type PaymentsClient,
41
+ type PaymentsSyncedPrice,
42
+ } from './payments-contract.ts'
43
+
44
+ /**
45
+ * The catalog half of the price lookup is a local map read and needs no network at all, so it is
46
+ * gated offline; the Stripe half needs a real test-mode account. Neither asks Stripe to tell us the
47
+ * price is missing: the Stripe arm is an empty prices.list result, which is a 200 with no data rather
48
+ * than an error, so depending on an API error code was never necessary.
49
+ */
50
+
51
+ const createdStripeCustomerIds: string[] = []
52
+
53
+ const gateFile = defineGateFileContext<{
54
+ paymentsEntry: HearthkitPaymentsEntry
55
+ gateDatabase: GatePaymentsDatabase
56
+ offlineClient: PaymentsClient
57
+ catalogNames: GatePaymentsCatalogNames
58
+ }>(async () => {
59
+ const paymentsEntry = await loadHearthkitPaymentsEntry()
60
+ const gateDatabase = await createVerifiedGatePaymentsDatabase('checkout', paymentsEntry)
61
+ const catalogNames = uniqueGatePaymentsCatalogNames('checkout')
62
+ const offlineClient = createGatePaymentsClient({
63
+ paymentsEntry,
64
+ drizzleClient: gateDatabase.drizzleClient,
65
+ paymentsCatalog: gatePaymentsCatalog(catalogNames),
66
+ stripeApiBaseUrl: deadStripeApiBaseUrl(await reserveDeadLoopbackPort()),
67
+ })
68
+ return { paymentsEntry, gateDatabase, offlineClient, catalogNames }
69
+ })
70
+
71
+ /**
72
+ * The live account, with the catalog pushed to it once and the test-mode guard asserted on the very
73
+ * first object that came back. Everything else in this file that touches Stripe reads this, so
74
+ * nothing is created after a live key is detected.
75
+ */
76
+ const liveGateFile = defineGateFileContext<{
77
+ liveClient: PaymentsClient
78
+ syncedPrices: readonly PaymentsSyncedPrice[]
79
+ }>(async () => {
80
+ const { paymentsEntry, gateDatabase, catalogNames } = await gateFile.read()
81
+ const liveClient = createGatePaymentsClient({
82
+ paymentsEntry,
83
+ drizzleClient: gateDatabase.drizzleClient,
84
+ paymentsCatalog: gatePaymentsCatalog(catalogNames),
85
+ stripeSecretKey: gateStripeSecretKey,
86
+ })
87
+ const synced = expectResultKind(
88
+ await paymentsEntry.syncPaymentsCatalog({ paymentsClient: liveClient }),
89
+ 'payments-catalog-synced',
90
+ )
91
+ assertGateStripeIsTestMode(synced.stripeLivemode)
92
+ return { liveClient, syncedPrices: synced.syncedPrices }
93
+ })
94
+
95
+ afterAll(async () => {
96
+ await liveGateFile.releaseIfCreated(async ({ liveClient, syncedPrices }) => {
97
+ for (const stripeCustomerId of createdStripeCustomerIds) {
98
+ await deleteGateStripeCustomer(liveClient, stripeCustomerId)
99
+ }
100
+ await archiveGateStripeCatalog(liveClient, syncedPrices)
101
+ })
102
+ await gateFile.releaseIfCreated(({ gateDatabase }) => gateDatabase.removeGatePaymentsDatabase())
103
+ })
104
+
105
+ describe('createCheckoutSession', () => {
106
+ it('reports payments-price-not-found with absent-from-catalog for a name the catalog does not have, contacting Stripe never', async () => {
107
+ const { paymentsEntry, offlineClient } = await gateFile.read()
108
+ const priceName = 'gate-price-no-catalog-ever-had'
109
+
110
+ // The client's Stripe API base URL is a closed local port, so a lookup that reached the network
111
+ // would answer payments-stripe-unreachable. absent-from-catalog is decided before that: it is a
112
+ // code mistake, and the operator's next step is to fix payments-catalog.ts rather than run sync.
113
+ const result = await paymentsEntry.createCheckoutSession({
114
+ paymentsClient: offlineClient,
115
+ billingReferenceId: uniqueGateBillingReferenceId('checkout-nocatalog'),
116
+ billingContactEmail: uniqueGateBillingContactEmail('checkout-nocatalog'),
117
+ priceName,
118
+ successUrl: gateSuccessUrl,
119
+ cancelUrl: gateCancelUrl,
120
+ })
121
+ createCheckoutSessionResultSchema.parse(result)
122
+ const failure = expectPaymentsFailure(result, 'payments-price-not-found')
123
+ expect(failure.priceLookupFailure).toBe('absent-from-catalog')
124
+ expect(failure.priceName).toBe(priceName)
125
+ })
126
+
127
+ it('accepts a success URL carrying Stripe placeholder braces rather than rejecting it as an invalid URL', async () => {
128
+ const { paymentsEntry, offlineClient, catalogNames } = await gateFile.read()
129
+
130
+ // CONTRACT.md lists "whether z.url() at zod@4.4.3 accepts a URL containing {CHECKOUT_SESSION_ID}"
131
+ // under Still not verified. If the schema rejects it this comes back payments-input-invalid
132
+ // naming success-url, and the schema has to loosen to a protocol-and-authority check. Reaching
133
+ // the closed Stripe port instead is what proves validation let the placeholder through.
134
+ const result = await paymentsEntry.createCheckoutSession({
135
+ paymentsClient: offlineClient,
136
+ billingReferenceId: uniqueGateBillingReferenceId('checkout-placeholder'),
137
+ billingContactEmail: uniqueGateBillingContactEmail('checkout-placeholder'),
138
+ priceName: catalogNames.subscriptionPriceName,
139
+ successUrl: gateSuccessUrlWithCheckoutSessionPlaceholder,
140
+ cancelUrl: gateCancelUrl,
141
+ })
142
+ createCheckoutSessionResultSchema.parse(result)
143
+ expect(result.kind).not.toBe('payments-input-invalid')
144
+ expectPaymentsFailure(result, 'payments-stripe-unreachable')
145
+ })
146
+
147
+ it.skipIf(!hasGateStripeSecretKey)(
148
+ 'creates a hosted checkout session for a subscription price in test mode, handing the success URL to Stripe byte for byte',
149
+ async () => {
150
+ const { paymentsEntry, catalogNames } = await gateFile.read()
151
+ const { liveClient } = await liveGateFile.read()
152
+
153
+ const result = await paymentsEntry.createCheckoutSession({
154
+ paymentsClient: liveClient,
155
+ billingReferenceId: uniqueGateBillingReferenceId('checkout-live'),
156
+ billingContactEmail: uniqueGateBillingContactEmail('checkout-live'),
157
+ priceName: catalogNames.subscriptionPriceName,
158
+ quantity: 2,
159
+ successUrl: gateSuccessUrlWithCheckoutSessionPlaceholder,
160
+ cancelUrl: gateCancelUrl,
161
+ })
162
+ createCheckoutSessionResultSchema.parse(result)
163
+ const created = expectResultKind(result, 'payments-checkout-session-created')
164
+ createdStripeCustomerIds.push(String(created.stripeCustomerId))
165
+
166
+ // Read off the object Stripe answered with, which is a measurement rather than a match on an
167
+ // API key prefix.
168
+ expect(created.stripeLivemode).toBe(false)
169
+ expect(String(created.priceName)).toBe(catalogNames.subscriptionPriceName)
170
+ // Stripe types Session.url as `string | null` because it is only present while a hosted
171
+ // session is active; this package always creates hosted sessions, so a null there is not a
172
+ // contract state and would have come back as payments-request-failed instead.
173
+ expect(created.checkoutUrl.startsWith('https://')).toBe(true)
174
+
175
+ const storedSession = await readGateStripeCheckoutSession(
176
+ liveClient,
177
+ String(created.stripeCheckoutSessionId),
178
+ )
179
+ // A subscription price maps to checkout mode `subscription`, which happens to be the same word
180
+ // this package uses; the one-time case below is where the spellings diverge.
181
+ expect(storedSession.mode).toBe(stripeSubscriptionCheckoutMode)
182
+ // The URL is handed over unchanged, never round-tripped through `new URL(value).href`, which
183
+ // percent-encodes the braces and turns Stripe's placeholder into literal text it never
184
+ // substitutes. The symptom would be a success page receiving %7BCHECKOUT_SESSION_ID%7D.
185
+ expect(storedSession.success_url).toBe(gateSuccessUrlWithCheckoutSessionPlaceholder)
186
+ },
187
+ )
188
+
189
+ it.skipIf(!hasGateStripeSecretKey)(
190
+ 'reuses the Stripe customer already on file when the same reference buys again, this time a one-time price',
191
+ async () => {
192
+ const { paymentsEntry, catalogNames } = await gateFile.read()
193
+ const { liveClient } = await liveGateFile.read()
194
+ const billingReferenceId = uniqueGateBillingReferenceId('checkout-reuse')
195
+ const billingContactEmail = uniqueGateBillingContactEmail('checkout-reuse')
196
+
197
+ const first = expectResultKind(
198
+ await paymentsEntry.createCheckoutSession({
199
+ paymentsClient: liveClient,
200
+ billingReferenceId,
201
+ billingContactEmail,
202
+ priceName: catalogNames.subscriptionPriceName,
203
+ successUrl: gateSuccessUrl,
204
+ cancelUrl: gateCancelUrl,
205
+ }),
206
+ 'payments-checkout-session-created',
207
+ )
208
+ createdStripeCustomerIds.push(String(first.stripeCustomerId))
209
+
210
+ const second = expectResultKind(
211
+ await paymentsEntry.createCheckoutSession({
212
+ paymentsClient: liveClient,
213
+ billingReferenceId,
214
+ billingContactEmail,
215
+ priceName: catalogNames.oneTimePriceName,
216
+ successUrl: gateSuccessUrl,
217
+ cancelUrl: gateCancelUrl,
218
+ }),
219
+ 'payments-checkout-session-created',
220
+ )
221
+
222
+ // One Stripe customer per billing reference. A second customer here would orphan the first
223
+ // one's payment methods and split one person's billing history in two.
224
+ expect(String(second.stripeCustomerId)).toBe(String(first.stripeCustomerId))
225
+ expect(String(second.priceName)).toBe(catalogNames.oneTimePriceName)
226
+
227
+ const storedSession = await readGateStripeCheckoutSession(
228
+ liveClient,
229
+ String(second.stripeCheckoutSessionId),
230
+ )
231
+ // Note the spelling: a one-time price is Stripe price type `one_time` with an underscore, but
232
+ // its checkout mode is `payment`. Three names for one idea, and all three are real.
233
+ expect(storedSession.mode).toBe(stripeOneTimeCheckoutMode)
234
+ },
235
+ )
236
+
237
+ it.skipIf(!hasGateStripeSecretKey)(
238
+ 'reports payments-price-not-found with absent-from-stripe when the catalog has the price but sync has never run for it',
239
+ async () => {
240
+ const { paymentsEntry, gateDatabase } = await gateFile.read()
241
+ // A catalog whose names have never been pushed anywhere. Every name a gate run makes up is
242
+ // unique, so nothing in the shared test-mode account can be carrying this lookup key.
243
+ const unsyncedNames = uniqueGatePaymentsCatalogNames('checkoutunsynced')
244
+ const unsyncedClient = createGatePaymentsClient({
245
+ paymentsEntry,
246
+ drizzleClient: gateDatabase.drizzleClient,
247
+ paymentsCatalog: gatePaymentsCatalog(unsyncedNames),
248
+ stripeSecretKey: gateStripeSecretKey,
249
+ })
250
+
251
+ const result = await paymentsEntry.createCheckoutSession({
252
+ paymentsClient: unsyncedClient,
253
+ billingReferenceId: uniqueGateBillingReferenceId('checkout-unsynced'),
254
+ billingContactEmail: uniqueGateBillingContactEmail('checkout-unsynced'),
255
+ priceName: unsyncedNames.subscriptionPriceName,
256
+ successUrl: gateSuccessUrl,
257
+ cancelUrl: gateCancelUrl,
258
+ })
259
+ createCheckoutSessionResultSchema.parse(result)
260
+ const failure = expectPaymentsFailure(result, 'payments-price-not-found')
261
+ // The other half of the same variant. The caller does the same thing with both — tell the
262
+ // buyer this plan is unavailable — and only the operator's next step differs, which here is
263
+ // to run hearthkit payments sync against this account.
264
+ expect(failure.priceLookupFailure).toBe('absent-from-stripe')
265
+ expect(failure.priceName).toBe(unsyncedNames.subscriptionPriceName)
266
+ },
267
+ )
268
+ })
@@ -0,0 +1,216 @@
1
+ import type Stripe from 'stripe'
2
+ import { findPaymentsCatalogPrice } from './payments-catalog-lookup.ts'
3
+ import {
4
+ billingContactEmailSchema,
5
+ billingReferenceIdSchema,
6
+ defaultCheckoutQuantity,
7
+ hearthkitBillingReferenceMetadataKey,
8
+ hearthkitBillingScopeMetadataKey,
9
+ hearthkitPriceNameMetadataKey,
10
+ hearthkitQuantityMetadataKey,
11
+ hearthkitStripePriceIdMetadataKey,
12
+ paymentsClientSchema,
13
+ paymentsPriceNameSchema,
14
+ paymentsQuantitySchema,
15
+ paymentsRedirectUrlSchema,
16
+ stripeCheckoutSessionIdSchema,
17
+ stripeCustomerIdSchema,
18
+ stripeOneTimeCheckoutMode,
19
+ stripeSubscriptionCheckoutMode,
20
+ type CreateCheckoutSessionOptions,
21
+ type CreateCheckoutSessionResult,
22
+ type PaymentsCatalogPrice,
23
+ type PaymentsClient,
24
+ } from './payments-contract.ts'
25
+ import {
26
+ readPaymentsCustomerRow,
27
+ upsertPaymentsCustomerFromCheckout,
28
+ } from './payments-customer-record.ts'
29
+ import {
30
+ paymentsInputInvalidFailure,
31
+ paymentsPriceNotFoundFailure,
32
+ paymentsRequestFailedFailure,
33
+ } from './payments-failure-results.ts'
34
+ import { findActiveStripePriceByLookupKey } from './stripe-price-lookup-key.ts'
35
+ import { thrownPaymentsErrorToFailure } from './thrown-payments-error-failure.ts'
36
+
37
+ /**
38
+ * Creates a hosted Checkout Session, creating the Stripe customer and the local customer row when
39
+ * neither exists yet. That is why this function can never report payments-customer-not-found: it is
40
+ * the thing that puts a customer on file, and only the portal call refuses to.
41
+ *
42
+ * Every caller-supplied value is a plain string or number on the options object and is validated here
43
+ * before any service is contacted, because these values always originate from user input or from an
44
+ * HTTP request and validating them late produces a diagnostic that points at the wrong cause.
45
+ */
46
+
47
+ // Three spellings of one idea, and all three are real: this package's priceKind, Stripe's Price.type,
48
+ // and Stripe's checkout mode. Note that the one-time case is `payment` here and `one_time` on the
49
+ // price object, and that Stripe's own word changes between the two.
50
+ function checkoutModeForCatalogPrice(
51
+ catalogPrice: PaymentsCatalogPrice,
52
+ ): Stripe.Checkout.SessionCreateParams.Mode {
53
+ return catalogPrice.priceKind === 'subscription'
54
+ ? stripeSubscriptionCheckoutMode
55
+ : stripeOneTimeCheckoutMode
56
+ }
57
+
58
+ type ValidatedCheckoutInput = {
59
+ paymentsClient: PaymentsClient
60
+ billingReferenceId: string
61
+ billingContactEmail: string
62
+ priceName: string
63
+ quantity: number
64
+ successUrl: string
65
+ cancelUrl: string
66
+ }
67
+
68
+ function validateCheckoutInput(
69
+ options: CreateCheckoutSessionOptions,
70
+ ): ValidatedCheckoutInput | CreateCheckoutSessionResult {
71
+ if (!paymentsClientSchema.safeParse(options.paymentsClient).success) {
72
+ return paymentsInputInvalidFailure('drizzle-client')
73
+ }
74
+ if (!billingReferenceIdSchema.safeParse(options.billingReferenceId).success) {
75
+ return paymentsInputInvalidFailure('billing-reference-id')
76
+ }
77
+ if (!billingContactEmailSchema.safeParse(options.billingContactEmail).success) {
78
+ return paymentsInputInvalidFailure('billing-contact-email')
79
+ }
80
+ // A well-formed name the catalog does not have is payments-price-not-found, not this: a name that
81
+ // is not lowercase kebab-case could never have been a Stripe lookup key in the first place.
82
+ if (!paymentsPriceNameSchema.safeParse(options.priceName).success) {
83
+ return paymentsInputInvalidFailure('price-name')
84
+ }
85
+ const quantity = options.quantity ?? defaultCheckoutQuantity
86
+ if (!paymentsQuantitySchema.safeParse(quantity).success) {
87
+ return paymentsInputInvalidFailure('quantity')
88
+ }
89
+ if (!paymentsRedirectUrlSchema.safeParse(options.successUrl).success) {
90
+ return paymentsInputInvalidFailure('success-url')
91
+ }
92
+ if (!paymentsRedirectUrlSchema.safeParse(options.cancelUrl).success) {
93
+ return paymentsInputInvalidFailure('cancel-url')
94
+ }
95
+ return {
96
+ paymentsClient: options.paymentsClient,
97
+ billingReferenceId: options.billingReferenceId,
98
+ billingContactEmail: options.billingContactEmail,
99
+ priceName: options.priceName,
100
+ quantity,
101
+ // Handed to Stripe byte for byte, never round-tripped through `new URL(value).href`: Stripe
102
+ // supports a {CHECKOUT_SESSION_ID} placeholder in success_url, and normalising percent-encodes the
103
+ // braces, which turns the placeholder into literal text Stripe never substitutes.
104
+ successUrl: options.successUrl,
105
+ cancelUrl: options.cancelUrl,
106
+ }
107
+ }
108
+
109
+ async function resolveCheckoutStripeCustomerId(input: ValidatedCheckoutInput): Promise<string> {
110
+ const existingCustomerRow = await readPaymentsCustomerRow(
111
+ input.paymentsClient.drizzleClient,
112
+ input.billingReferenceId,
113
+ )
114
+ const stripeCustomerId =
115
+ existingCustomerRow?.stripeCustomerId ??
116
+ (
117
+ await input.paymentsClient.stripeClient.customers.create({
118
+ email: input.billingContactEmail,
119
+ metadata: {
120
+ [hearthkitBillingReferenceMetadataKey]: input.billingReferenceId,
121
+ [hearthkitBillingScopeMetadataKey]: input.paymentsClient.billingScope,
122
+ },
123
+ })
124
+ ).id
125
+
126
+ // One Stripe customer per billing reference: a second one would orphan the first one's payment
127
+ // methods and split one person's billing history in two.
128
+ await upsertPaymentsCustomerFromCheckout(input.paymentsClient.drizzleClient, {
129
+ billingReferenceId: input.billingReferenceId,
130
+ billingScope: input.paymentsClient.billingScope,
131
+ stripeCustomerId,
132
+ billingContactEmail: input.billingContactEmail,
133
+ writtenAt: new Date(),
134
+ })
135
+ return stripeCustomerId
136
+ }
137
+
138
+ /** Resolves the price, puts a Stripe customer on file, and opens a hosted Checkout Session. */
139
+ export async function createCheckoutSession(
140
+ options: CreateCheckoutSessionOptions,
141
+ ): Promise<CreateCheckoutSessionResult> {
142
+ const input = validateCheckoutInput(options)
143
+ if ('kind' in input) {
144
+ return input
145
+ }
146
+
147
+ const catalogPrice = findPaymentsCatalogPrice(
148
+ input.paymentsClient.paymentsCatalog,
149
+ input.priceName,
150
+ )
151
+ if (catalogPrice === undefined) {
152
+ return paymentsPriceNotFoundFailure(input.priceName, 'absent-from-catalog')
153
+ }
154
+
155
+ try {
156
+ const stripePrice = await findActiveStripePriceByLookupKey(
157
+ input.paymentsClient.stripeClient,
158
+ input.priceName,
159
+ )
160
+ if (stripePrice === undefined) {
161
+ return paymentsPriceNotFoundFailure(input.priceName, 'absent-from-stripe')
162
+ }
163
+
164
+ const stripeCustomerId = await resolveCheckoutStripeCustomerId(input)
165
+ const referenceMetadata = {
166
+ [hearthkitBillingReferenceMetadataKey]: input.billingReferenceId,
167
+ [hearthkitBillingScopeMetadataKey]: input.paymentsClient.billingScope,
168
+ }
169
+ const checkoutMode = checkoutModeForCatalogPrice(catalogPrice)
170
+
171
+ const checkoutSession = await input.paymentsClient.stripeClient.checkout.sessions.create({
172
+ mode: checkoutMode,
173
+ customer: stripeCustomerId,
174
+ line_items: [{ price: stripePrice.id, quantity: input.quantity }],
175
+ success_url: input.successUrl,
176
+ cancel_url: input.cancelUrl,
177
+ // The price trio goes on the SESSION and never on subscription_data.metadata: a portal upgrade
178
+ // changes a subscription's price without touching metadata stamped at creation, so a stamped
179
+ // price name would go stale and then be reported as fact. It is here because a webhook delivery
180
+ // carries no line_items, so the purchase path has no other source for what it sold.
181
+ metadata: {
182
+ ...referenceMetadata,
183
+ [hearthkitPriceNameMetadataKey]: input.priceName,
184
+ [hearthkitStripePriceIdMetadataKey]: stripePrice.id,
185
+ [hearthkitQuantityMetadataKey]: String(input.quantity),
186
+ },
187
+ // The second copy of the two reference keys, so customer.subscription.* events carry them on the
188
+ // subscription object itself; without it those events have no reference at all.
189
+ ...(checkoutMode === stripeSubscriptionCheckoutMode
190
+ ? { subscription_data: { metadata: referenceMetadata } }
191
+ : {}),
192
+ })
193
+
194
+ // Stripe types Session.url as `string | null` because it is only present while a hosted session is
195
+ // active; this package always creates hosted sessions, so a null there is not a contract state.
196
+ if (checkoutSession.url === null) {
197
+ return paymentsRequestFailedFailure({
198
+ paymentsFailureDetail:
199
+ 'Stripe created the checkout session without a hosted page URL, which a hosted session always has while it is active',
200
+ })
201
+ }
202
+
203
+ return {
204
+ kind: 'payments-checkout-session-created',
205
+ stripeCheckoutSessionId: stripeCheckoutSessionIdSchema.parse(checkoutSession.id),
206
+ checkoutUrl: checkoutSession.url,
207
+ stripeCustomerId: stripeCustomerIdSchema.parse(stripeCustomerId),
208
+ priceName: paymentsPriceNameSchema.parse(input.priceName),
209
+ // Read off the object Stripe answered with, whose own generated type says it is true in live
210
+ // mode and false in test mode. That is a measurement, not a match on an API key prefix.
211
+ stripeLivemode: checkoutSession.livemode,
212
+ }
213
+ } catch (thrownValue) {
214
+ return thrownPaymentsErrorToFailure(thrownValue, input.paymentsClient)
215
+ }
216
+ }