@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
|
@@ -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
|
+
})
|