@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.
- package/package.json +50 -0
- package/src/create-checkout-session.test.ts +268 -0
- package/src/create-checkout-session.ts +216 -0
- package/src/create-customer-portal-session.test.ts +160 -0
- package/src/create-customer-portal-session.ts +66 -0
- package/src/create-payments-client.test.ts +231 -0
- package/src/create-payments-client.ts +90 -0
- package/src/handle-stripe-webhook-ignored.test.ts +293 -0
- package/src/handle-stripe-webhook-purchase.test.ts +279 -0
- package/src/handle-stripe-webhook-signature.test.ts +194 -0
- package/src/handle-stripe-webhook-subscription.test.ts +376 -0
- package/src/handle-stripe-webhook.ts +133 -0
- package/src/hearthkit-payments-drizzle-schema.test.ts +214 -0
- package/src/hearthkit-payments-drizzle-schema.ts +80 -0
- package/src/index.ts +267 -0
- package/src/list-payments-purchases.test.ts +132 -0
- package/src/list-payments-purchases.ts +57 -0
- package/src/payments-catalog-lookup.ts +23 -0
- package/src/payments-catalog-validation.ts +190 -0
- package/src/payments-client-secrets.ts +47 -0
- package/src/payments-contract.ts +1038 -0
- package/src/payments-customer-record.ts +89 -0
- package/src/payments-database-unavailable.test.ts +152 -0
- package/src/payments-env-schema-fragment.test.ts +197 -0
- package/src/payments-failure-results.ts +185 -0
- package/src/payments-input-invalid.test.ts +201 -0
- package/src/payments-purchase-record.ts +63 -0
- package/src/payments-row-identifier.ts +10 -0
- package/src/payments-stripe-unreachable.test.ts +91 -0
- package/src/payments-subscription-record.ts +77 -0
- package/src/read-payments-subscription.test.ts +126 -0
- package/src/read-payments-subscription.ts +61 -0
- package/src/redact-payments-secrets.ts +18 -0
- package/src/stripe-catalog-price-sync.ts +101 -0
- package/src/stripe-catalog-product-sync.ts +75 -0
- package/src/stripe-checkout-session-event.ts +204 -0
- package/src/stripe-event-payload-fields.ts +39 -0
- package/src/stripe-price-lookup-key.ts +26 -0
- package/src/stripe-subscription-event.ts +112 -0
- package/src/stripe-webhook-delivery-results.ts +46 -0
- package/src/sync-payments-catalog.test.ts +208 -0
- package/src/sync-payments-catalog.ts +65 -0
- package/src/thrown-payments-error-details.ts +134 -0
- package/src/thrown-payments-error-failure.ts +74 -0
- package/src/verify-payments-tables-exist.test.ts +84 -0
- 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
|
+
}
|