@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,194 @@
1
+ import type { NodePgDatabase } from 'drizzle-orm/node-postgres'
2
+ import { afterAll, describe, expect, it } from 'vitest'
3
+ import {
4
+ expectContractStringExport,
5
+ expectPaymentsFailure,
6
+ } from '../test-fixtures/payments-gate-expectations.ts'
7
+ import { defineGateFileContext } from '../test-fixtures/payments-gate-file-context.ts'
8
+ import {
9
+ createGateDrizzleClientForUrl,
10
+ unreachableGateDatabaseUrl,
11
+ } from '../test-fixtures/payments-gate-postgres-database.ts'
12
+ import {
13
+ buildGateCheckoutSessionObject,
14
+ buildGateStripeWebhookDelivery,
15
+ gateBillingReferenceMetadata,
16
+ uniqueGateStripeId,
17
+ } from '../test-fixtures/payments-gate-stripe-events.ts'
18
+ import {
19
+ gatePaymentsCatalog,
20
+ gateWrongStripeWebhookSecret,
21
+ uniqueGateBillingContactEmail,
22
+ uniqueGateBillingReferenceId,
23
+ uniqueGatePaymentsCatalogNames,
24
+ } from '../test-fixtures/payments-gate-values.ts'
25
+ import {
26
+ createGatePaymentsClient,
27
+ loadHearthkitPaymentsEntry,
28
+ type HearthkitPaymentsEntry,
29
+ } from '../test-fixtures/hearthkit-payments-entry.ts'
30
+ import {
31
+ handleStripeWebhookResultSchema,
32
+ stripeSignatureHeaderName,
33
+ stripeWrongSchemeSignatureMessage,
34
+ stripeWrongSecretSignatureMessagePrefix,
35
+ type PaymentsClient,
36
+ } from './payments-contract.ts'
37
+
38
+ /**
39
+ * Both halves of payments-webhook-signature-invalid, with no Stripe key, no network and no database:
40
+ * verification is a local HMAC, and the Drizzle client here points at a closed port on purpose, so a
41
+ * handler that queried before verifying would answer payments-database-unavailable and fail these
42
+ * gates rather than pass them.
43
+ */
44
+
45
+ const gateFile = defineGateFileContext<{
46
+ paymentsEntry: HearthkitPaymentsEntry
47
+ paymentsClient: PaymentsClient
48
+ closeDatabaseClient: () => Promise<void>
49
+ }>(async () => {
50
+ const paymentsEntry = await loadHearthkitPaymentsEntry()
51
+ const { drizzleClient, closeDatabaseClient } = createGateDrizzleClientForUrl(
52
+ unreachableGateDatabaseUrl,
53
+ paymentsEntry.hearthkitPaymentsDrizzleSchema,
54
+ )
55
+ const paymentsClient = createGatePaymentsClient({
56
+ paymentsEntry,
57
+ drizzleClient: drizzleClient as NodePgDatabase<Record<string, unknown>>,
58
+ paymentsCatalog: gatePaymentsCatalog(uniqueGatePaymentsCatalogNames('signature')),
59
+ })
60
+ return { paymentsEntry, paymentsClient, closeDatabaseClient }
61
+ })
62
+
63
+ afterAll(async () => {
64
+ await gateFile.releaseIfCreated(({ closeDatabaseClient }) => closeDatabaseClient())
65
+ })
66
+
67
+ function buildGateSignedSubscriptionCheckout(
68
+ paymentsClient: PaymentsClient,
69
+ billingReferenceId: string,
70
+ billingContactEmail: string,
71
+ signingSecret?: string,
72
+ ) {
73
+ return buildGateStripeWebhookDelivery(
74
+ paymentsClient,
75
+ {
76
+ stripeEventType: 'checkout.session.completed',
77
+ eventDataObject: buildGateCheckoutSessionObject({
78
+ stripeCheckoutSessionId: uniqueGateStripeId('cs'),
79
+ stripeCustomerId: uniqueGateStripeId('cus'),
80
+ checkoutMode: 'subscription',
81
+ paymentStatus: 'paid',
82
+ billingContactEmail,
83
+ stripeSubscriptionId: uniqueGateStripeId('sub'),
84
+ metadata: gateBillingReferenceMetadata(billingReferenceId),
85
+ }),
86
+ },
87
+ signingSecret,
88
+ )
89
+ }
90
+
91
+ describe('payments-webhook-signature-invalid', () => {
92
+ it('reports signature-header-missing when the request carries no stripe-signature header at all', async () => {
93
+ const { paymentsEntry, paymentsClient } = await gateFile.read()
94
+ const delivery = buildGateSignedSubscriptionCheckout(
95
+ paymentsClient,
96
+ uniqueGateBillingReferenceId('sig-missing'),
97
+ uniqueGateBillingContactEmail('sig-missing'),
98
+ )
99
+
100
+ const result = await paymentsEntry.handleStripeWebhook({
101
+ paymentsClient,
102
+ rawRequestBody: delivery.rawRequestBody,
103
+ // Everything else about this request is right, including a body that would verify. The header
104
+ // is read off the request by the one name this package spells once, so a request without it
105
+ // did not come from Stripe whatever the body says.
106
+ requestHeaders: new Headers({ 'content-type': 'application/json' }),
107
+ })
108
+ handleStripeWebhookResultSchema.parse(result)
109
+ const failure = expectPaymentsFailure(result, 'payments-webhook-signature-invalid')
110
+ expect(failure.signatureFailureReason).toBe('signature-header-missing')
111
+ expect(expectContractStringExport(stripeSignatureHeaderName, 'stripeSignatureHeaderName')).toBe(
112
+ 'stripe-signature',
113
+ )
114
+ })
115
+
116
+ it('reports signature-verification-failed when the header was signed with a different secret, and the detail is the wrong-secret message rather than its one-line-away decoy', async () => {
117
+ const { paymentsEntry, paymentsClient } = await gateFile.read()
118
+ const billingReferenceId = uniqueGateBillingReferenceId('sig-wrong')
119
+ const billingContactEmail = uniqueGateBillingContactEmail('sig-wrong')
120
+ const delivery = buildGateSignedSubscriptionCheckout(
121
+ paymentsClient,
122
+ billingReferenceId,
123
+ billingContactEmail,
124
+ gateWrongStripeWebhookSecret,
125
+ )
126
+
127
+ const result = await paymentsEntry.handleStripeWebhook({
128
+ paymentsClient,
129
+ rawRequestBody: delivery.rawRequestBody,
130
+ requestHeaders: delivery.requestHeaders,
131
+ })
132
+ handleStripeWebhookResultSchema.parse(result)
133
+ const failure = expectPaymentsFailure(result, 'payments-webhook-signature-invalid')
134
+ expect(failure.signatureFailureReason).toBe('signature-verification-failed')
135
+
136
+ // The trap this assertion exists to avoid: `No signatures found matching the expected signature
137
+ // for payload.` is the wrong-secret message, and `No signatures found with expected scheme` is
138
+ // thrown one line away for a header carrying no v1 entry, which is a different cause. A
139
+ // substring test on `No signatures found` would satisfy both, so this matches the whole prefix
140
+ // and then proves the decoy is not what came back.
141
+ const stripeFailureDetail = failure.stripeFailureDetail ?? ''
142
+ expect(stripeFailureDetail).toContain(
143
+ expectContractStringExport(
144
+ stripeWrongSecretSignatureMessagePrefix,
145
+ 'stripeWrongSecretSignatureMessagePrefix',
146
+ ),
147
+ )
148
+ expect(stripeFailureDetail).not.toContain(
149
+ expectContractStringExport(
150
+ stripeWrongSchemeSignatureMessage,
151
+ 'stripeWrongSchemeSignatureMessage',
152
+ ),
153
+ )
154
+
155
+ // StripeSignatureVerificationError carries the raw webhook body on `.payload`, so it holds
156
+ // whatever customer data the event held. The detail quotes the message and never the payload.
157
+ const serializedFailure = JSON.stringify(failure)
158
+ expect(serializedFailure).not.toContain(billingContactEmail)
159
+ expect(serializedFailure).not.toContain(billingReferenceId)
160
+ })
161
+
162
+ it('reports signature-verification-failed for a body that was parsed and re-serialised, because that changes the bytes the HMAC covers', async () => {
163
+ const { paymentsEntry, paymentsClient } = await gateFile.read()
164
+ const delivery = buildGateSignedSubscriptionCheckout(
165
+ paymentsClient,
166
+ uniqueGateBillingReferenceId('sig-reserialised'),
167
+ uniqueGateBillingContactEmail('sig-reserialised'),
168
+ )
169
+ // The same event, byte for byte different. This is what framework middleware does when it parses
170
+ // a JSON body and hands the route a re-encoded copy, and the symptom names the wrong cause: it
171
+ // arrives as a signature mismatch rather than as the body-handling mistake it is.
172
+ const reserializedRequestBody = JSON.stringify(JSON.parse(delivery.rawRequestBody))
173
+ expect(reserializedRequestBody).not.toBe(delivery.rawRequestBody)
174
+
175
+ const result = await paymentsEntry.handleStripeWebhook({
176
+ paymentsClient,
177
+ rawRequestBody: reserializedRequestBody,
178
+ requestHeaders: delivery.requestHeaders,
179
+ })
180
+ handleStripeWebhookResultSchema.parse(result)
181
+ expect(
182
+ expectPaymentsFailure(result, 'payments-webhook-signature-invalid').signatureFailureReason,
183
+ ).toBe('signature-verification-failed')
184
+
185
+ // And the same bytes it signed still verify, so the gate above failed for the re-encoding and
186
+ // not because nothing this fixture signs can ever verify.
187
+ const untouched = await paymentsEntry.handleStripeWebhook({
188
+ paymentsClient,
189
+ rawRequestBody: delivery.rawRequestBody,
190
+ requestHeaders: delivery.requestHeaders,
191
+ })
192
+ expect(untouched.kind).not.toBe('payments-webhook-signature-invalid')
193
+ })
194
+ })
@@ -0,0 +1,376 @@
1
+ import { afterAll, describe, expect, it } from 'vitest'
2
+ import {
3
+ expectResultKind,
4
+ gateDateFromStripeSeconds,
5
+ } from '../test-fixtures/payments-gate-expectations.ts'
6
+ import { defineGateFileContext } from '../test-fixtures/payments-gate-file-context.ts'
7
+ import {
8
+ countGatePaymentsRows,
9
+ createVerifiedGatePaymentsDatabase,
10
+ readGatePaymentsTableRows,
11
+ type GatePaymentsDatabase,
12
+ } from '../test-fixtures/payments-gate-postgres-database.ts'
13
+ import {
14
+ buildGateCheckoutSessionObject,
15
+ buildGateStripeWebhookDelivery,
16
+ buildGateSubscriptionObject,
17
+ gateBillingReferenceMetadata,
18
+ gateNowSecondsSinceEpoch,
19
+ uniqueGateStripeId,
20
+ type GateSubscriptionItemOptions,
21
+ } from '../test-fixtures/payments-gate-stripe-events.ts'
22
+ import {
23
+ gatePaymentsCatalog,
24
+ uniqueGateBillingContactEmail,
25
+ uniqueGateBillingReferenceId,
26
+ uniqueGatePaymentsCatalogNames,
27
+ } from '../test-fixtures/payments-gate-values.ts'
28
+ import {
29
+ createGatePaymentsClient,
30
+ loadHearthkitPaymentsEntry,
31
+ type HearthkitPaymentsEntry,
32
+ } from '../test-fixtures/hearthkit-payments-entry.ts'
33
+ import {
34
+ handleStripeWebhookResultSchema,
35
+ paymentsActiveSubscriptionStatuses,
36
+ paymentsCustomerSchema,
37
+ paymentsKnownSubscriptionStatuses,
38
+ readPaymentsSubscriptionResultSchema,
39
+ type PaymentsClient,
40
+ } from './payments-contract.ts'
41
+
42
+ /**
43
+ * Plan 4.8's gate line reads "replay a checkout.session.completed event through the webhook handler,
44
+ * confirm the subscription row exists". That sequence cannot pass against this contract, and
45
+ * CONTRACT.md says so in as many words under "Deviation from plan 4.8's gate wording": a
46
+ * subscription-mode checkout.session.completed upserts the CUSTOMER row and reports
47
+ * 'customer-linked', because a delivery carries no line_items and this package declines the retrieve
48
+ * call that would be needed to do what the plan literally describes. The subscription row only ever
49
+ * comes from customer.subscription.*, which Stripe sends for the same checkout anyway.
50
+ *
51
+ * So the sequence below is the contract's three-event replacement: checkout, then created, then
52
+ * created again. All of it is synthesised and signed locally, so it needs no Stripe key.
53
+ */
54
+
55
+ const gateFile = defineGateFileContext<{
56
+ paymentsEntry: HearthkitPaymentsEntry
57
+ paymentsClient: PaymentsClient
58
+ gateDatabase: GatePaymentsDatabase
59
+ subscriptionPriceName: string
60
+ }>(async () => {
61
+ const paymentsEntry = await loadHearthkitPaymentsEntry()
62
+ const gateDatabase = await createVerifiedGatePaymentsDatabase('subscription', paymentsEntry)
63
+ const catalogNames = uniqueGatePaymentsCatalogNames('subscription')
64
+ const paymentsClient = createGatePaymentsClient({
65
+ paymentsEntry,
66
+ drizzleClient: gateDatabase.drizzleClient,
67
+ paymentsCatalog: gatePaymentsCatalog(catalogNames),
68
+ })
69
+ return {
70
+ paymentsEntry,
71
+ paymentsClient,
72
+ gateDatabase,
73
+ subscriptionPriceName: catalogNames.subscriptionPriceName,
74
+ }
75
+ })
76
+
77
+ afterAll(async () => {
78
+ await gateFile.releaseIfCreated(({ gateDatabase }) => gateDatabase.removeGatePaymentsDatabase())
79
+ })
80
+
81
+ const thirtyDaysInSeconds = 2_592_000
82
+
83
+ function catalogSubscriptionItem(
84
+ subscriptionPriceName: string,
85
+ stripePriceId: string,
86
+ currentPeriodStartSeconds: number,
87
+ quantity = 3,
88
+ ): GateSubscriptionItemOptions {
89
+ return {
90
+ stripePriceId,
91
+ priceLookupKey: subscriptionPriceName,
92
+ quantity,
93
+ currentPeriodStartSeconds,
94
+ currentPeriodEndSeconds: currentPeriodStartSeconds + thirtyDaysInSeconds,
95
+ }
96
+ }
97
+
98
+ describe('handleStripeWebhook subscription path', () => {
99
+ it('links the customer on a subscription checkout and then records the subscription row on customer.subscription.created', async () => {
100
+ const { paymentsEntry, paymentsClient, gateDatabase, subscriptionPriceName } =
101
+ await gateFile.read()
102
+ const billingReferenceId = uniqueGateBillingReferenceId('sub-sequence')
103
+ const billingContactEmail = uniqueGateBillingContactEmail('sub-sequence')
104
+ const stripeCustomerId = uniqueGateStripeId('cus')
105
+ const stripeSubscriptionId = uniqueGateStripeId('sub')
106
+ const stripePriceId = uniqueGateStripeId('price')
107
+ const periodStartSeconds = gateNowSecondsSinceEpoch()
108
+
109
+ // Step one. A subscription-mode completed session links the customer; it does NOT write the
110
+ // subscription row, and expecting it to is the plan's wording rather than this contract's.
111
+ const checkoutDelivery = buildGateStripeWebhookDelivery(paymentsClient, {
112
+ stripeEventType: 'checkout.session.completed',
113
+ eventDataObject: buildGateCheckoutSessionObject({
114
+ stripeCheckoutSessionId: uniqueGateStripeId('cs'),
115
+ stripeCustomerId,
116
+ checkoutMode: 'subscription',
117
+ paymentStatus: 'paid',
118
+ billingContactEmail,
119
+ stripeSubscriptionId,
120
+ metadata: gateBillingReferenceMetadata(billingReferenceId),
121
+ }),
122
+ })
123
+ const checkoutResult = await paymentsEntry.handleStripeWebhook({
124
+ paymentsClient,
125
+ rawRequestBody: checkoutDelivery.rawRequestBody,
126
+ requestHeaders: checkoutDelivery.requestHeaders,
127
+ })
128
+ handleStripeWebhookResultSchema.parse(checkoutResult)
129
+ expect(expectResultKind(checkoutResult, 'payments-webhook-processed').webhookOutcome).toBe(
130
+ 'customer-linked',
131
+ )
132
+
133
+ const customerRows = await readGatePaymentsTableRows(
134
+ gateDatabase.drizzleClient,
135
+ paymentsEntry.hearthkitPaymentsDrizzleSchema.payments_customer,
136
+ )
137
+ const writtenRow = customerRows.find(
138
+ (row) => String(row.billingReferenceId) === billingReferenceId,
139
+ )
140
+ expect(
141
+ writtenRow,
142
+ 'a subscription checkout must leave a customer row for its reference',
143
+ ).toBeDefined()
144
+ const customerRow = paymentsCustomerSchema.parse(writtenRow)
145
+ expect(String(customerRow.stripeCustomerId)).toBe(stripeCustomerId)
146
+ // The scaffold flag decides this one value; the client was built with organizationsEnabled false.
147
+ expect(customerRow.billingScope).toBe('user')
148
+ // CONTRACT.md names no source for this column on the webhook path — the purchase path has a
149
+ // column-to-source table and the customer path does not — so the synthesised session carries the
150
+ // same address in both `customer_email` and `customer_details.email` and either read satisfies
151
+ // this. What the column cannot be is empty: it is text NOT NULL and it is where receipts go.
152
+ expect(String(customerRow.billingContactEmail)).toBe(billingContactEmail)
153
+
154
+ // Step two, which is the assertion plan 4.8 asks for, one event later than it says.
155
+ const createdDelivery = buildGateStripeWebhookDelivery(paymentsClient, {
156
+ stripeEventType: 'customer.subscription.created',
157
+ eventDataObject: buildGateSubscriptionObject({
158
+ stripeSubscriptionId,
159
+ stripeCustomerId,
160
+ subscriptionStatus: 'trialing',
161
+ metadata: gateBillingReferenceMetadata(billingReferenceId),
162
+ items: [catalogSubscriptionItem(subscriptionPriceName, stripePriceId, periodStartSeconds)],
163
+ trialStartSeconds: periodStartSeconds,
164
+ trialEndSeconds: periodStartSeconds + thirtyDaysInSeconds,
165
+ }),
166
+ })
167
+ const createdResult = await paymentsEntry.handleStripeWebhook({
168
+ paymentsClient,
169
+ rawRequestBody: createdDelivery.rawRequestBody,
170
+ requestHeaders: createdDelivery.requestHeaders,
171
+ })
172
+ handleStripeWebhookResultSchema.parse(createdResult)
173
+ expect(expectResultKind(createdResult, 'payments-webhook-processed').webhookOutcome).toBe(
174
+ 'subscription-upserted',
175
+ )
176
+
177
+ const read = await paymentsEntry.readPaymentsSubscription({
178
+ paymentsClient,
179
+ billingReferenceId,
180
+ })
181
+ readPaymentsSubscriptionResultSchema.parse(read)
182
+ const found = expectResultKind(read, 'payments-subscription-found').paymentsSubscription
183
+ expect(String(found.stripeSubscriptionId)).toBe(stripeSubscriptionId)
184
+ expect(String(found.stripeCustomerId)).toBe(stripeCustomerId)
185
+ expect(String(found.stripePriceId)).toBe(stripePriceId)
186
+ // Resolved from the price's lookup_key, which syncPaymentsCatalog set to the catalog price name.
187
+ // SubscriptionItem.price is typed Price and never a string id, so this needs no second API call.
188
+ expect(String(found.priceName)).toBe(subscriptionPriceName)
189
+ expect(found.status).toBe('trialing')
190
+ expect([...paymentsActiveSubscriptionStatuses]).toContain(found.status)
191
+ expect(found.quantity).toBe(3)
192
+ // The period dates come from the subscription ITEM. stripe@22.6.1's Subscription object has no
193
+ // current_period_start or current_period_end at all, so reaching for one finds nothing.
194
+ expect(found.currentPeriodStart?.getTime()).toBe(
195
+ gateDateFromStripeSeconds(periodStartSeconds).getTime(),
196
+ )
197
+ expect(found.currentPeriodEnd?.getTime()).toBe(
198
+ gateDateFromStripeSeconds(periodStartSeconds + thirtyDaysInSeconds).getTime(),
199
+ )
200
+ // These five are subscription-level fields that really do exist, unlike the period pair.
201
+ expect(found.cancelAtPeriodEnd).toBe(false)
202
+ expect(found.canceledAt).toBeNull()
203
+ expect(found.endedAt).toBeNull()
204
+ expect(found.trialStart?.getTime()).toBe(
205
+ gateDateFromStripeSeconds(periodStartSeconds).getTime(),
206
+ )
207
+ expect(found.trialEnd?.getTime()).toBe(
208
+ gateDateFromStripeSeconds(periodStartSeconds + thirtyDaysInSeconds).getTime(),
209
+ )
210
+ })
211
+
212
+ it('leaves exactly one subscription row when the same customer.subscription.created delivery arrives twice', async () => {
213
+ const { paymentsEntry, paymentsClient, gateDatabase, subscriptionPriceName } =
214
+ await gateFile.read()
215
+ const billingReferenceId = uniqueGateBillingReferenceId('sub-replay')
216
+ const before = await countGatePaymentsRows(
217
+ gateDatabase.drizzleClient,
218
+ paymentsEntry.hearthkitPaymentsDrizzleSchema,
219
+ )
220
+
221
+ const delivery = buildGateStripeWebhookDelivery(paymentsClient, {
222
+ stripeEventType: 'customer.subscription.created',
223
+ eventDataObject: buildGateSubscriptionObject({
224
+ stripeSubscriptionId: uniqueGateStripeId('sub'),
225
+ stripeCustomerId: uniqueGateStripeId('cus'),
226
+ subscriptionStatus: 'active',
227
+ metadata: gateBillingReferenceMetadata(billingReferenceId),
228
+ items: [
229
+ catalogSubscriptionItem(
230
+ subscriptionPriceName,
231
+ uniqueGateStripeId('price'),
232
+ gateNowSecondsSinceEpoch(),
233
+ ),
234
+ ],
235
+ }),
236
+ })
237
+
238
+ // The replay half of plan 4.8's gate. Deliver the same event twice, then assert exactly one row,
239
+ // which is stronger than asserting the second delivery was refused because it holds even if the
240
+ // two deliveries interleave.
241
+ for (const attempt of [1, 2]) {
242
+ const result = await paymentsEntry.handleStripeWebhook({
243
+ paymentsClient,
244
+ rawRequestBody: delivery.rawRequestBody,
245
+ requestHeaders: delivery.requestHeaders,
246
+ })
247
+ handleStripeWebhookResultSchema.parse(result)
248
+ expect(
249
+ expectResultKind(result, 'payments-webhook-processed').webhookOutcome,
250
+ `delivery attempt ${attempt}`,
251
+ ).toBe('subscription-upserted')
252
+ }
253
+
254
+ const after = await countGatePaymentsRows(
255
+ gateDatabase.drizzleClient,
256
+ paymentsEntry.hearthkitPaymentsDrizzleSchema,
257
+ )
258
+ expect(after.payments_subscription - before.payments_subscription).toBe(1)
259
+ })
260
+
261
+ it('upserts the same row when Stripe reports an update and again when it reports a deletion, ending with the terminal status and endedAt', async () => {
262
+ const { paymentsEntry, paymentsClient, gateDatabase, subscriptionPriceName } =
263
+ await gateFile.read()
264
+ const billingReferenceId = uniqueGateBillingReferenceId('sub-lifecycle')
265
+ const stripeSubscriptionId = uniqueGateStripeId('sub')
266
+ const stripeCustomerId = uniqueGateStripeId('cus')
267
+ const catalogPriceId = uniqueGateStripeId('price')
268
+ const periodStartSeconds = gateNowSecondsSinceEpoch()
269
+ const before = await countGatePaymentsRows(
270
+ gateDatabase.drizzleClient,
271
+ paymentsEntry.hearthkitPaymentsDrizzleSchema,
272
+ )
273
+
274
+ async function deliverSubscriptionEvent(
275
+ stripeEventType: string,
276
+ subscriptionOptions: Parameters<typeof buildGateSubscriptionObject>[0],
277
+ ): Promise<void> {
278
+ const delivery = buildGateStripeWebhookDelivery(paymentsClient, {
279
+ stripeEventType,
280
+ eventDataObject: buildGateSubscriptionObject(subscriptionOptions),
281
+ })
282
+ const result = await paymentsEntry.handleStripeWebhook({
283
+ paymentsClient,
284
+ rawRequestBody: delivery.rawRequestBody,
285
+ requestHeaders: delivery.requestHeaders,
286
+ })
287
+ handleStripeWebhookResultSchema.parse(result)
288
+ expect(
289
+ expectResultKind(result, 'payments-webhook-processed').webhookOutcome,
290
+ stripeEventType,
291
+ ).toBe('subscription-upserted')
292
+ }
293
+
294
+ await deliverSubscriptionEvent('customer.subscription.created', {
295
+ stripeSubscriptionId,
296
+ stripeCustomerId,
297
+ subscriptionStatus: 'active',
298
+ metadata: gateBillingReferenceMetadata(billingReferenceId),
299
+ items: [catalogSubscriptionItem(subscriptionPriceName, catalogPriceId, periodStartSeconds)],
300
+ })
301
+
302
+ // The update carries two items and the first one is not ours, which is the case CONTRACT.md
303
+ // names: the item this package reads is the first whose price.lookup_key names a catalog price,
304
+ // not simply items.data[0].
305
+ await deliverSubscriptionEvent('customer.subscription.updated', {
306
+ stripeSubscriptionId,
307
+ stripeCustomerId,
308
+ subscriptionStatus: 'past_due',
309
+ metadata: gateBillingReferenceMetadata(billingReferenceId),
310
+ items: [
311
+ {
312
+ stripePriceId: uniqueGateStripeId('price'),
313
+ priceLookupKey: 'gate-price-belonging-to-some-other-catalog',
314
+ quantity: 9,
315
+ currentPeriodStartSeconds: periodStartSeconds,
316
+ currentPeriodEndSeconds: periodStartSeconds + thirtyDaysInSeconds,
317
+ },
318
+ catalogSubscriptionItem(subscriptionPriceName, catalogPriceId, periodStartSeconds, 5),
319
+ ],
320
+ cancelAtPeriodEnd: true,
321
+ })
322
+
323
+ const afterUpdate = expectResultKind(
324
+ await paymentsEntry.readPaymentsSubscription({ paymentsClient, billingReferenceId }),
325
+ 'payments-subscription-found',
326
+ ).paymentsSubscription
327
+ expect(afterUpdate.status).toBe('past_due')
328
+ expect(afterUpdate.cancelAtPeriodEnd).toBe(true)
329
+ expect(String(afterUpdate.priceName)).toBe(subscriptionPriceName)
330
+ expect(String(afterUpdate.stripePriceId)).toBe(catalogPriceId)
331
+ expect(afterUpdate.quantity).toBe(5)
332
+
333
+ const endedAtSeconds = periodStartSeconds + 60
334
+ await deliverSubscriptionEvent('customer.subscription.deleted', {
335
+ stripeSubscriptionId,
336
+ stripeCustomerId,
337
+ subscriptionStatus: 'canceled',
338
+ metadata: gateBillingReferenceMetadata(billingReferenceId),
339
+ items: [
340
+ catalogSubscriptionItem(subscriptionPriceName, catalogPriceId, periodStartSeconds, 5),
341
+ ],
342
+ cancelAtPeriodEnd: false,
343
+ canceledAtSeconds: endedAtSeconds,
344
+ endedAtSeconds,
345
+ })
346
+
347
+ // The row is returned whatever its status: readPaymentsSubscription reports what Stripe last
348
+ // said and the caller decides what counts as entitled.
349
+ const afterDeletion = expectResultKind(
350
+ await paymentsEntry.readPaymentsSubscription({ paymentsClient, billingReferenceId }),
351
+ 'payments-subscription-found',
352
+ ).paymentsSubscription
353
+ expect(afterDeletion.status).toBe('canceled')
354
+ expect(afterDeletion.endedAt?.getTime()).toBe(
355
+ gateDateFromStripeSeconds(endedAtSeconds).getTime(),
356
+ )
357
+ expect(afterDeletion.canceledAt?.getTime()).toBe(
358
+ gateDateFromStripeSeconds(endedAtSeconds).getTime(),
359
+ )
360
+ expect([...paymentsActiveSubscriptionStatuses]).not.toContain(afterDeletion.status)
361
+
362
+ // Three deliveries, one row: created, updated and deleted all upsert on the Stripe subscription id.
363
+ const after = await countGatePaymentsRows(
364
+ gateDatabase.drizzleClient,
365
+ paymentsEntry.hearthkitPaymentsDrizzleSchema,
366
+ )
367
+ expect(after.payments_subscription - before.payments_subscription).toBe(1)
368
+
369
+ // 'ended' and 'all' are members of SubscriptionListParams.Status, one union away from the real
370
+ // one in the same file, and a status column holding either would be wrong in a way nothing else
371
+ // would catch.
372
+ expect([...paymentsKnownSubscriptionStatuses]).toContain('canceled')
373
+ expect([...paymentsKnownSubscriptionStatuses]).not.toContain('ended')
374
+ expect([...paymentsKnownSubscriptionStatuses]).not.toContain('all')
375
+ })
376
+ })