@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,293 @@
1
+ import { afterAll, describe, expect, it } from 'vitest'
2
+ import { expectResultKind } from '../test-fixtures/payments-gate-expectations.ts'
3
+ import { defineGateFileContext } from '../test-fixtures/payments-gate-file-context.ts'
4
+ import {
5
+ countGatePaymentsRows,
6
+ createVerifiedGatePaymentsDatabase,
7
+ type GatePaymentsDatabase,
8
+ } from '../test-fixtures/payments-gate-postgres-database.ts'
9
+ import {
10
+ buildGateCheckoutSessionObject,
11
+ buildGateStripeWebhookDelivery,
12
+ buildGateSubscriptionObject,
13
+ gateBillingReferenceMetadata,
14
+ gateCheckoutPriceMetadata,
15
+ gateNowSecondsSinceEpoch,
16
+ uniqueGateStripeId,
17
+ } from '../test-fixtures/payments-gate-stripe-events.ts'
18
+ import {
19
+ gatePaymentsCatalog,
20
+ uniqueGateBillingContactEmail,
21
+ uniqueGateBillingReferenceId,
22
+ uniqueGatePaymentsCatalogNames,
23
+ } from '../test-fixtures/payments-gate-values.ts'
24
+ import {
25
+ createGatePaymentsClient,
26
+ loadHearthkitPaymentsEntry,
27
+ type HearthkitPaymentsEntry,
28
+ } from '../test-fixtures/hearthkit-payments-entry.ts'
29
+ import {
30
+ handleStripeWebhookResultSchema,
31
+ type PaymentsClient,
32
+ type PaymentsWebhookIgnoredReason,
33
+ } from './payments-contract.ts'
34
+
35
+ /**
36
+ * Every value of ignoredReason, all six, with no Stripe key and no network. An ignored delivery is a
37
+ * RESULT and not a failure: Stripe delivers every event type an endpoint is subscribed to and most of
38
+ * them are none of this package's business, so modelling that as an error would make a webhook route
39
+ * log a stack trace on an ordinary Tuesday.
40
+ *
41
+ * Every gate here also asserts that nothing was written. "Ignored" has to mean ignored: a delivery
42
+ * that half-wrote a customer row and then declined to record the rest would pass a kind check while
43
+ * leaving the database in a state nobody asked for.
44
+ */
45
+
46
+ const gateFile = defineGateFileContext<{
47
+ paymentsEntry: HearthkitPaymentsEntry
48
+ paymentsClient: PaymentsClient
49
+ gateDatabase: GatePaymentsDatabase
50
+ subscriptionPriceName: string
51
+ }>(async () => {
52
+ const paymentsEntry = await loadHearthkitPaymentsEntry()
53
+ const gateDatabase = await createVerifiedGatePaymentsDatabase('ignored', paymentsEntry)
54
+ const catalogNames = uniqueGatePaymentsCatalogNames('ignored')
55
+ const paymentsClient = createGatePaymentsClient({
56
+ paymentsEntry,
57
+ drizzleClient: gateDatabase.drizzleClient,
58
+ paymentsCatalog: gatePaymentsCatalog(catalogNames),
59
+ })
60
+ return {
61
+ paymentsEntry,
62
+ paymentsClient,
63
+ gateDatabase,
64
+ subscriptionPriceName: catalogNames.subscriptionPriceName,
65
+ }
66
+ })
67
+
68
+ afterAll(async () => {
69
+ await gateFile.releaseIfCreated(({ gateDatabase }) => gateDatabase.removeGatePaymentsDatabase())
70
+ })
71
+
72
+ /** Delivers one synthesised, locally signed event and asserts it was ignored for the stated reason, writing nothing. */
73
+ async function expectDeliveryIgnored(
74
+ stripeEventType: string,
75
+ eventDataObject: Record<string, unknown>,
76
+ ignoredReason: PaymentsWebhookIgnoredReason,
77
+ ): Promise<void> {
78
+ const { paymentsEntry, paymentsClient, gateDatabase } = await gateFile.read()
79
+ const delivery = buildGateStripeWebhookDelivery(paymentsClient, {
80
+ stripeEventType,
81
+ eventDataObject,
82
+ })
83
+
84
+ const result = await paymentsEntry.handleStripeWebhook({
85
+ paymentsClient,
86
+ rawRequestBody: delivery.rawRequestBody,
87
+ requestHeaders: delivery.requestHeaders,
88
+ })
89
+ handleStripeWebhookResultSchema.parse(result)
90
+ const ignored = expectResultKind(result, 'payments-webhook-ignored')
91
+
92
+ expect(ignored.ignoredReason).toBe(ignoredReason)
93
+ // Both webhook results carry the event id, so a log line traces back to a delivery in the Stripe
94
+ // dashboard even when nothing was written.
95
+ expect(String(ignored.stripeEventId)).toBe(delivery.stripeEventId)
96
+ expect(ignored.stripeEventType).toBe(stripeEventType)
97
+
98
+ expect(
99
+ await countGatePaymentsRows(
100
+ gateDatabase.drizzleClient,
101
+ paymentsEntry.hearthkitPaymentsDrizzleSchema,
102
+ ),
103
+ ).toEqual({ payments_customer: 0, payments_subscription: 0, payments_purchase: 0 })
104
+ }
105
+
106
+ describe('payments-webhook-ignored', () => {
107
+ it('ignores an event type it does not handle, reporting event-type-not-handled rather than failing', async () => {
108
+ // The handled set is exactly the four @better-auth/stripe handles. invoice.paid is a real event
109
+ // an endpoint can easily be subscribed to and is none of this package's business.
110
+ await expectDeliveryIgnored(
111
+ 'invoice.paid',
112
+ { id: uniqueGateStripeId('in'), object: 'invoice', livemode: false },
113
+ 'event-type-not-handled',
114
+ )
115
+ })
116
+
117
+ it('ignores a completed session whose mode is setup, and one whose payment status is unpaid', async () => {
118
+ const billingReferenceId = uniqueGateBillingReferenceId('ignored-mode')
119
+
120
+ // Stripe's mode union is payment | setup | subscription. This package never creates a setup
121
+ // session, so one arriving here came from somewhere else.
122
+ await expectDeliveryIgnored(
123
+ 'checkout.session.completed',
124
+ buildGateCheckoutSessionObject({
125
+ stripeCheckoutSessionId: uniqueGateStripeId('cs'),
126
+ stripeCustomerId: uniqueGateStripeId('cus'),
127
+ checkoutMode: 'setup',
128
+ paymentStatus: 'no_payment_required',
129
+ billingContactEmail: uniqueGateBillingContactEmail('ignored-mode'),
130
+ metadata: gateBillingReferenceMetadata(billingReferenceId),
131
+ }),
132
+ 'checkout-mode-not-handled',
133
+ )
134
+
135
+ // A purchase is recorded for paid and for no_payment_required, which is what a fully discounted
136
+ // order reports; unpaid is the third member of that union and the one that records nothing.
137
+ await expectDeliveryIgnored(
138
+ 'checkout.session.completed',
139
+ buildGateCheckoutSessionObject({
140
+ stripeCheckoutSessionId: uniqueGateStripeId('cs'),
141
+ stripeCustomerId: uniqueGateStripeId('cus'),
142
+ checkoutMode: 'payment',
143
+ paymentStatus: 'unpaid',
144
+ billingContactEmail: uniqueGateBillingContactEmail('ignored-unpaid'),
145
+ stripePaymentIntentId: uniqueGateStripeId('pi'),
146
+ metadata: {
147
+ ...gateBillingReferenceMetadata(billingReferenceId),
148
+ ...gateCheckoutPriceMetadata(
149
+ 'gate-price-name-that-is-present',
150
+ uniqueGateStripeId('price'),
151
+ 1,
152
+ ),
153
+ },
154
+ }),
155
+ 'checkout-session-unpaid',
156
+ )
157
+ })
158
+
159
+ it('ignores a session and a subscription that carry no billing reference metadata, because inventing one would attach someone else money to a hearthkit account', async () => {
160
+ const { subscriptionPriceName } = await gateFile.read()
161
+
162
+ await expectDeliveryIgnored(
163
+ 'checkout.session.completed',
164
+ buildGateCheckoutSessionObject({
165
+ stripeCheckoutSessionId: uniqueGateStripeId('cs'),
166
+ stripeCustomerId: uniqueGateStripeId('cus'),
167
+ checkoutMode: 'subscription',
168
+ paymentStatus: 'paid',
169
+ billingContactEmail: uniqueGateBillingContactEmail('ignored-noref'),
170
+ stripeSubscriptionId: uniqueGateStripeId('sub'),
171
+ metadata: {},
172
+ }),
173
+ 'billing-reference-missing',
174
+ )
175
+
176
+ // One reason serves both paths because it means the same thing and has the same fix on each. A
177
+ // subscription created by hand in the Stripe dashboard is exactly this: a real subscription on a
178
+ // catalog price, with no hearthkit metadata on it anywhere.
179
+ const nowSeconds = gateNowSecondsSinceEpoch()
180
+ await expectDeliveryIgnored(
181
+ 'customer.subscription.created',
182
+ buildGateSubscriptionObject({
183
+ stripeSubscriptionId: uniqueGateStripeId('sub'),
184
+ stripeCustomerId: uniqueGateStripeId('cus'),
185
+ subscriptionStatus: 'active',
186
+ metadata: {},
187
+ items: [
188
+ {
189
+ stripePriceId: uniqueGateStripeId('price'),
190
+ priceLookupKey: subscriptionPriceName,
191
+ quantity: 1,
192
+ currentPeriodStartSeconds: nowSeconds,
193
+ currentPeriodEndSeconds: nowSeconds + 2_592_000,
194
+ },
195
+ ],
196
+ }),
197
+ 'billing-reference-missing',
198
+ )
199
+ })
200
+
201
+ it('ignores a paid one-time session whose price metadata is absent, and one whose quantity is not a positive integer', async () => {
202
+ const billingReferenceId = uniqueGateBillingReferenceId('ignored-price')
203
+
204
+ // A delivery carries no line_items, so session metadata is the purchase path's only source for
205
+ // what was sold. Without it there is no row to write, and this reason says to look at whatever
206
+ // created the session rather than at the catalog.
207
+ await expectDeliveryIgnored(
208
+ 'checkout.session.completed',
209
+ buildGateCheckoutSessionObject({
210
+ stripeCheckoutSessionId: uniqueGateStripeId('cs'),
211
+ stripeCustomerId: uniqueGateStripeId('cus'),
212
+ checkoutMode: 'payment',
213
+ paymentStatus: 'paid',
214
+ billingContactEmail: uniqueGateBillingContactEmail('ignored-price'),
215
+ stripePaymentIntentId: uniqueGateStripeId('pi'),
216
+ metadata: gateBillingReferenceMetadata(billingReferenceId),
217
+ }),
218
+ 'checkout-price-metadata-missing',
219
+ )
220
+
221
+ // A quantity that is not a positive integer counts as missing rather than defaulting to one,
222
+ // because silently billing one unit for an order of five is worse than declining to record it.
223
+ await expectDeliveryIgnored(
224
+ 'checkout.session.completed',
225
+ buildGateCheckoutSessionObject({
226
+ stripeCheckoutSessionId: uniqueGateStripeId('cs'),
227
+ stripeCustomerId: uniqueGateStripeId('cus'),
228
+ checkoutMode: 'payment',
229
+ paymentStatus: 'paid',
230
+ billingContactEmail: uniqueGateBillingContactEmail('ignored-quantity'),
231
+ stripePaymentIntentId: uniqueGateStripeId('pi'),
232
+ metadata: {
233
+ ...gateBillingReferenceMetadata(billingReferenceId),
234
+ ...gateCheckoutPriceMetadata(
235
+ 'gate-price-name-that-is-present',
236
+ uniqueGateStripeId('price'),
237
+ 'not-a-number',
238
+ ),
239
+ },
240
+ }),
241
+ 'checkout-price-metadata-missing',
242
+ )
243
+ })
244
+
245
+ it('ignores a subscription whose items carry no lookup key naming a catalog price, whether the key is foreign or absent', async () => {
246
+ const billingReferenceId = uniqueGateBillingReferenceId('ignored-catalog')
247
+ const nowSeconds = gateNowSecondsSinceEpoch()
248
+
249
+ // The subscription path must consult the catalog, because priceName is resolved from lookup_key
250
+ // and there is no metadata to fall back on. This reason says to look at the catalog, which is
251
+ // exactly what the checkout path's reason does not say.
252
+ await expectDeliveryIgnored(
253
+ 'customer.subscription.created',
254
+ buildGateSubscriptionObject({
255
+ stripeSubscriptionId: uniqueGateStripeId('sub'),
256
+ stripeCustomerId: uniqueGateStripeId('cus'),
257
+ subscriptionStatus: 'active',
258
+ metadata: gateBillingReferenceMetadata(billingReferenceId),
259
+ items: [
260
+ {
261
+ stripePriceId: uniqueGateStripeId('price'),
262
+ priceLookupKey: 'gate-price-belonging-to-some-other-catalog',
263
+ quantity: 1,
264
+ currentPeriodStartSeconds: nowSeconds,
265
+ currentPeriodEndSeconds: nowSeconds + 2_592_000,
266
+ },
267
+ ],
268
+ }),
269
+ 'subscription-price-not-in-catalog',
270
+ )
271
+
272
+ // Price.lookup_key is `string | null`, and a price created by hand in the dashboard has none.
273
+ await expectDeliveryIgnored(
274
+ 'customer.subscription.updated',
275
+ buildGateSubscriptionObject({
276
+ stripeSubscriptionId: uniqueGateStripeId('sub'),
277
+ stripeCustomerId: uniqueGateStripeId('cus'),
278
+ subscriptionStatus: 'active',
279
+ metadata: gateBillingReferenceMetadata(billingReferenceId),
280
+ items: [
281
+ {
282
+ stripePriceId: uniqueGateStripeId('price'),
283
+ priceLookupKey: null,
284
+ quantity: 1,
285
+ currentPeriodStartSeconds: nowSeconds,
286
+ currentPeriodEndSeconds: nowSeconds + 2_592_000,
287
+ },
288
+ ],
289
+ }),
290
+ 'subscription-price-not-in-catalog',
291
+ )
292
+ })
293
+ })
@@ -0,0 +1,279 @@
1
+ import { afterAll, describe, expect, it } from 'vitest'
2
+ import {
3
+ expectOnlyGateElement,
4
+ expectPaymentsFailure,
5
+ expectResultKind,
6
+ gateDateFromStripeSeconds,
7
+ } from '../test-fixtures/payments-gate-expectations.ts'
8
+ import { defineGateFileContext } from '../test-fixtures/payments-gate-file-context.ts'
9
+ import {
10
+ countGatePaymentsRows,
11
+ createVerifiedGatePaymentsDatabase,
12
+ type GatePaymentsDatabase,
13
+ } from '../test-fixtures/payments-gate-postgres-database.ts'
14
+ import {
15
+ buildGateCheckoutSessionObject,
16
+ buildGateStripeWebhookDelivery,
17
+ gateBillingReferenceMetadata,
18
+ gateCheckoutPriceMetadata,
19
+ uniqueGateStripeId,
20
+ } from '../test-fixtures/payments-gate-stripe-events.ts'
21
+ import {
22
+ gatePaymentsCatalog,
23
+ uniqueGateBillingContactEmail,
24
+ uniqueGateBillingReferenceId,
25
+ uniqueGatePaymentsCatalogNames,
26
+ } from '../test-fixtures/payments-gate-values.ts'
27
+ import {
28
+ createGatePaymentsClient,
29
+ loadHearthkitPaymentsEntry,
30
+ type HearthkitPaymentsEntry,
31
+ } from '../test-fixtures/hearthkit-payments-entry.ts'
32
+ import {
33
+ handleStripeWebhookResultSchema,
34
+ listPaymentsPurchasesResultSchema,
35
+ type PaymentsClient,
36
+ } from './payments-contract.ts'
37
+
38
+ /**
39
+ * The purchase path, with no Stripe key and no network. Every column comes from the session's own
40
+ * plain fields or from session metadata this package wrote, and none from line_items, which a webhook
41
+ * delivery does not carry — the field is declared optional and the SDK calls it "includable" on a
42
+ * retrieve, and nothing expands it on a delivery.
43
+ *
44
+ * The price name every gate here sells is deliberately NOT in the client's catalog. A purchase is a
45
+ * historical fact: deleting a price from payments-catalog.ts must not make a completed order
46
+ * unrecordable, so the checkout path does not consult the catalog at all.
47
+ */
48
+
49
+ const priceNameNotInTheCatalog = 'gate-price-deleted-from-the-catalog'
50
+
51
+ const gateFile = defineGateFileContext<{
52
+ paymentsEntry: HearthkitPaymentsEntry
53
+ paymentsClient: PaymentsClient
54
+ gateDatabase: GatePaymentsDatabase
55
+ }>(async () => {
56
+ const paymentsEntry = await loadHearthkitPaymentsEntry()
57
+ const gateDatabase = await createVerifiedGatePaymentsDatabase('purchase', paymentsEntry)
58
+ const paymentsClient = createGatePaymentsClient({
59
+ paymentsEntry,
60
+ drizzleClient: gateDatabase.drizzleClient,
61
+ paymentsCatalog: gatePaymentsCatalog(uniqueGatePaymentsCatalogNames('purchase')),
62
+ })
63
+ return { paymentsEntry, paymentsClient, gateDatabase }
64
+ })
65
+
66
+ afterAll(async () => {
67
+ await gateFile.releaseIfCreated(({ gateDatabase }) => gateDatabase.removeGatePaymentsDatabase())
68
+ })
69
+
70
+ describe('handleStripeWebhook purchase path', () => {
71
+ it('records a purchase for a paid one-time session and for a fully discounted one, from session metadata rather than line items', async () => {
72
+ const { paymentsEntry, paymentsClient } = await gateFile.read()
73
+
74
+ const paidReferenceId = uniqueGateBillingReferenceId('purchase-paid')
75
+ const paidSessionId = uniqueGateStripeId('cs')
76
+ const paidCustomerId = uniqueGateStripeId('cus')
77
+ const paidPaymentIntentId = uniqueGateStripeId('pi')
78
+ const paidPriceId = uniqueGateStripeId('price')
79
+ const paidDelivery = buildGateStripeWebhookDelivery(paymentsClient, {
80
+ stripeEventType: 'checkout.session.completed',
81
+ eventDataObject: buildGateCheckoutSessionObject({
82
+ stripeCheckoutSessionId: paidSessionId,
83
+ stripeCustomerId: paidCustomerId,
84
+ checkoutMode: 'payment',
85
+ paymentStatus: 'paid',
86
+ billingContactEmail: uniqueGateBillingContactEmail('purchase-paid'),
87
+ stripePaymentIntentId: paidPaymentIntentId,
88
+ currency: 'usd',
89
+ amountTotalMinorUnits: 59_800,
90
+ metadata: {
91
+ ...gateBillingReferenceMetadata(paidReferenceId),
92
+ // Quantity is a decimal STRING, because every Stripe metadata value is a string, and it is
93
+ // parsed back here rather than defaulted.
94
+ ...gateCheckoutPriceMetadata(priceNameNotInTheCatalog, paidPriceId, 2),
95
+ },
96
+ }),
97
+ })
98
+
99
+ const paidResult = await paymentsEntry.handleStripeWebhook({
100
+ paymentsClient,
101
+ rawRequestBody: paidDelivery.rawRequestBody,
102
+ requestHeaders: paidDelivery.requestHeaders,
103
+ })
104
+ handleStripeWebhookResultSchema.parse(paidResult)
105
+ const paidProcessed = expectResultKind(paidResult, 'payments-webhook-processed')
106
+ expect(paidProcessed.webhookOutcome).toBe('purchase-recorded')
107
+ expect(String(paidProcessed.stripeEventId)).toBe(paidDelivery.stripeEventId)
108
+
109
+ const paidList = await paymentsEntry.listPaymentsPurchases({
110
+ paymentsClient,
111
+ billingReferenceId: paidReferenceId,
112
+ })
113
+ listPaymentsPurchasesResultSchema.parse(paidList)
114
+ const paidPurchases = expectResultKind(paidList, 'payments-purchases-listed').paymentsPurchases
115
+ expect(paidPurchases).toHaveLength(1)
116
+ const paidPurchase = expectOnlyGateElement(paidPurchases, 'purchase for the paid reference')
117
+ expect(String(paidPurchase.stripeCheckoutSessionId)).toBe(paidSessionId)
118
+ expect(String(paidPurchase.stripeCustomerId)).toBe(paidCustomerId)
119
+ expect(String(paidPurchase.stripePaymentIntentId)).toBe(paidPaymentIntentId)
120
+ expect(String(paidPurchase.billingReferenceId)).toBe(paidReferenceId)
121
+ expect(String(paidPurchase.priceName)).toBe(priceNameNotInTheCatalog)
122
+ expect(String(paidPurchase.stripePriceId)).toBe(paidPriceId)
123
+ expect(String(paidPurchase.currency)).toBe('usd')
124
+ expect(paidPurchase.amountTotalMinorUnits).toBe(59_800)
125
+ expect(paidPurchase.quantity).toBe(2)
126
+ // purchasedAt is the EVENT's created, seconds since the epoch, not the moment the row was written.
127
+ expect(paidPurchase.purchasedAt.getTime()).toBe(
128
+ gateDateFromStripeSeconds(paidDelivery.createdSecondsSinceEpoch).getTime(),
129
+ )
130
+
131
+ // A fully discounted order: no payment intent at all, which is why that column is nullable, and
132
+ // a zero total, which is why amountTotalMinorUnits allows zero. payment_status is
133
+ // no_payment_required, the third member of that union and the second one that records a purchase.
134
+ const discountedReferenceId = uniqueGateBillingReferenceId('purchase-free')
135
+ const discountedSessionId = uniqueGateStripeId('cs')
136
+ const discountedDelivery = buildGateStripeWebhookDelivery(paymentsClient, {
137
+ stripeEventType: 'checkout.session.completed',
138
+ eventDataObject: buildGateCheckoutSessionObject({
139
+ stripeCheckoutSessionId: discountedSessionId,
140
+ stripeCustomerId: uniqueGateStripeId('cus'),
141
+ checkoutMode: 'payment',
142
+ paymentStatus: 'no_payment_required',
143
+ billingContactEmail: uniqueGateBillingContactEmail('purchase-free'),
144
+ stripePaymentIntentId: null,
145
+ currency: 'usd',
146
+ amountTotalMinorUnits: 0,
147
+ metadata: {
148
+ ...gateBillingReferenceMetadata(discountedReferenceId),
149
+ ...gateCheckoutPriceMetadata(priceNameNotInTheCatalog, uniqueGateStripeId('price'), 1),
150
+ },
151
+ }),
152
+ })
153
+
154
+ const discountedResult = await paymentsEntry.handleStripeWebhook({
155
+ paymentsClient,
156
+ rawRequestBody: discountedDelivery.rawRequestBody,
157
+ requestHeaders: discountedDelivery.requestHeaders,
158
+ })
159
+ handleStripeWebhookResultSchema.parse(discountedResult)
160
+ expect(expectResultKind(discountedResult, 'payments-webhook-processed').webhookOutcome).toBe(
161
+ 'purchase-recorded',
162
+ )
163
+
164
+ const discountedList = expectResultKind(
165
+ await paymentsEntry.listPaymentsPurchases({
166
+ paymentsClient,
167
+ billingReferenceId: discountedReferenceId,
168
+ }),
169
+ 'payments-purchases-listed',
170
+ )
171
+ expect(discountedList.paymentsPurchases).toHaveLength(1)
172
+ const discountedPurchase = expectOnlyGateElement(
173
+ discountedList.paymentsPurchases,
174
+ 'purchase for the fully discounted reference',
175
+ )
176
+ expect(discountedPurchase.stripePaymentIntentId).toBeNull()
177
+ expect(discountedPurchase.amountTotalMinorUnits).toBe(0)
178
+ })
179
+
180
+ it('writes no second row when the same purchase delivery arrives twice, because the write is an upsert on the checkout session id', async () => {
181
+ const { paymentsEntry, paymentsClient, gateDatabase } = await gateFile.read()
182
+ const billingReferenceId = uniqueGateBillingReferenceId('purchase-replay')
183
+ const before = await countGatePaymentsRows(
184
+ gateDatabase.drizzleClient,
185
+ paymentsEntry.hearthkitPaymentsDrizzleSchema,
186
+ )
187
+
188
+ const delivery = buildGateStripeWebhookDelivery(paymentsClient, {
189
+ stripeEventType: 'checkout.session.completed',
190
+ eventDataObject: buildGateCheckoutSessionObject({
191
+ stripeCheckoutSessionId: uniqueGateStripeId('cs'),
192
+ stripeCustomerId: uniqueGateStripeId('cus'),
193
+ checkoutMode: 'payment',
194
+ paymentStatus: 'paid',
195
+ billingContactEmail: uniqueGateBillingContactEmail('purchase-replay'),
196
+ stripePaymentIntentId: uniqueGateStripeId('pi'),
197
+ currency: 'usd',
198
+ amountTotalMinorUnits: 29_900,
199
+ metadata: {
200
+ ...gateBillingReferenceMetadata(billingReferenceId),
201
+ ...gateCheckoutPriceMetadata(priceNameNotInTheCatalog, uniqueGateStripeId('price'), 1),
202
+ },
203
+ }),
204
+ })
205
+
206
+ // Stripe delivers events more than once. Idempotency here is structural rather than a
207
+ // bookkeeping table: the same bytes write the same values to the same row.
208
+ for (const attempt of [1, 2]) {
209
+ const result = await paymentsEntry.handleStripeWebhook({
210
+ paymentsClient,
211
+ rawRequestBody: delivery.rawRequestBody,
212
+ requestHeaders: delivery.requestHeaders,
213
+ })
214
+ handleStripeWebhookResultSchema.parse(result)
215
+ expect(
216
+ expectResultKind(result, 'payments-webhook-processed').webhookOutcome,
217
+ `delivery attempt ${attempt}`,
218
+ ).toBe('purchase-recorded')
219
+ }
220
+
221
+ const after = await countGatePaymentsRows(
222
+ gateDatabase.drizzleClient,
223
+ paymentsEntry.hearthkitPaymentsDrizzleSchema,
224
+ )
225
+ // Asserting exactly one new row is stronger than asserting the second delivery was refused,
226
+ // because it holds even if the two deliveries interleave.
227
+ expect(after.payments_purchase - before.payments_purchase).toBe(1)
228
+
229
+ const listed = expectResultKind(
230
+ await paymentsEntry.listPaymentsPurchases({ paymentsClient, billingReferenceId }),
231
+ 'payments-purchases-listed',
232
+ )
233
+ expect(listed.paymentsPurchases).toHaveLength(1)
234
+ })
235
+
236
+ it('reports payments-request-failed when a paid session carries no currency, which is not a contract state', async () => {
237
+ const { paymentsEntry, paymentsClient, gateDatabase } = await gateFile.read()
238
+ const before = await countGatePaymentsRows(
239
+ gateDatabase.drizzleClient,
240
+ paymentsEntry.hearthkitPaymentsDrizzleSchema,
241
+ )
242
+
243
+ // Session.currency and Session.amount_total are plain nullable fields, not expandable ones, so a
244
+ // paid payment-mode session has both. A null here means something the contract does not model,
245
+ // and it lands in the catch-all rather than being written as an empty string or a zero.
246
+ const delivery = buildGateStripeWebhookDelivery(paymentsClient, {
247
+ stripeEventType: 'checkout.session.completed',
248
+ eventDataObject: buildGateCheckoutSessionObject({
249
+ stripeCheckoutSessionId: uniqueGateStripeId('cs'),
250
+ stripeCustomerId: uniqueGateStripeId('cus'),
251
+ checkoutMode: 'payment',
252
+ paymentStatus: 'paid',
253
+ billingContactEmail: uniqueGateBillingContactEmail('purchase-nocurrency'),
254
+ stripePaymentIntentId: uniqueGateStripeId('pi'),
255
+ currency: null,
256
+ amountTotalMinorUnits: null,
257
+ metadata: {
258
+ ...gateBillingReferenceMetadata(uniqueGateBillingReferenceId('purchase-nocurrency')),
259
+ ...gateCheckoutPriceMetadata(priceNameNotInTheCatalog, uniqueGateStripeId('price'), 1),
260
+ },
261
+ }),
262
+ })
263
+
264
+ const result = await paymentsEntry.handleStripeWebhook({
265
+ paymentsClient,
266
+ rawRequestBody: delivery.rawRequestBody,
267
+ requestHeaders: delivery.requestHeaders,
268
+ })
269
+ handleStripeWebhookResultSchema.parse(result)
270
+ const failure = expectPaymentsFailure(result, 'payments-request-failed')
271
+ expect(failure.paymentsFailureDetail.length).toBeGreaterThan(0)
272
+
273
+ const after = await countGatePaymentsRows(
274
+ gateDatabase.drizzleClient,
275
+ paymentsEntry.hearthkitPaymentsDrizzleSchema,
276
+ )
277
+ expect(after.payments_purchase).toBe(before.payments_purchase)
278
+ })
279
+ })