@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,101 @@
1
+ import type Stripe from 'stripe'
2
+ import {
3
+ stripeOneTimePriceType,
4
+ stripeRecurringPriceType,
5
+ type PaymentsCatalogPrice,
6
+ type PaymentsPriceSyncAction,
7
+ } from './payments-contract.ts'
8
+ import { readStripeReferenceId } from './stripe-event-payload-fields.ts'
9
+ import { findActiveStripePriceByLookupKey } from './stripe-price-lookup-key.ts'
10
+
11
+ /**
12
+ * Pushing one catalog price to Stripe, keyed on the price name, which is also the Stripe lookup key.
13
+ *
14
+ * Stripe prices are immutable in amount and currency, so a catalog price whose terms changed cannot be
15
+ * an update: a new price is created with transfer_lookup_key, which atomically moves the key off the
16
+ * old one, and the superseded price is archived. Existing subscriptions stay on the old price. That is
17
+ * Stripe's behaviour and this package does not migrate them.
18
+ */
19
+
20
+ /** Interval count Stripe applies when a subscription price does not name one; the catalog default matches it. */
21
+ const defaultRecurringIntervalCount = 1
22
+
23
+ // Note the underscore in Stripe's one_time, and that Stripe's word for the same idea changes between
24
+ // the price object and the checkout session. Neither spelling is this package's priceKind.
25
+ function stripeRecurringParams(
26
+ catalogPrice: PaymentsCatalogPrice,
27
+ ): Pick<Stripe.PriceCreateParams, 'recurring'> {
28
+ if (catalogPrice.priceKind !== 'subscription') {
29
+ return {}
30
+ }
31
+ return {
32
+ recurring: {
33
+ interval: catalogPrice.recurringInterval,
34
+ interval_count: catalogPrice.recurringIntervalCount ?? defaultRecurringIntervalCount,
35
+ },
36
+ }
37
+ }
38
+
39
+ function stripePriceMatchesCatalogPrice(
40
+ stripePrice: Stripe.Price,
41
+ stripeProductId: string,
42
+ catalogPrice: PaymentsCatalogPrice,
43
+ ): boolean {
44
+ if (
45
+ readStripeReferenceId(stripePrice.product) !== stripeProductId ||
46
+ stripePrice.currency !== String(catalogPrice.currency) ||
47
+ stripePrice.unit_amount !== catalogPrice.unitAmountMinorUnits
48
+ ) {
49
+ return false
50
+ }
51
+ if (catalogPrice.priceKind !== 'subscription') {
52
+ return stripePrice.type === stripeOneTimePriceType && stripePrice.recurring === null
53
+ }
54
+ return (
55
+ stripePrice.type === stripeRecurringPriceType &&
56
+ stripePrice.recurring?.interval === catalogPrice.recurringInterval &&
57
+ stripePrice.recurring.interval_count ===
58
+ (catalogPrice.recurringIntervalCount ?? defaultRecurringIntervalCount)
59
+ )
60
+ }
61
+
62
+ /** What sync did to one price, and the Stripe price it now maps to; the id changes whenever the action is replaced. */
63
+ export type StripeCatalogPriceSyncOutcome = {
64
+ stripePrice: Stripe.Price
65
+ syncAction: PaymentsPriceSyncAction
66
+ }
67
+
68
+ /** Creates, reuses or replaces the Stripe price carrying this catalog price's name as its lookup key. */
69
+ export async function syncStripeCatalogPrice(
70
+ stripeClient: Stripe,
71
+ stripeProductId: string,
72
+ catalogPrice: PaymentsCatalogPrice,
73
+ ): Promise<StripeCatalogPriceSyncOutcome> {
74
+ const priceLookupKey = String(catalogPrice.priceName)
75
+ const existingPrice = await findActiveStripePriceByLookupKey(stripeClient, priceLookupKey)
76
+
77
+ if (existingPrice !== undefined) {
78
+ if (stripePriceMatchesCatalogPrice(existingPrice, stripeProductId, catalogPrice)) {
79
+ return { stripePrice: existingPrice, syncAction: 'unchanged' }
80
+ }
81
+ const replacementPrice = await stripeClient.prices.create({
82
+ product: stripeProductId,
83
+ currency: String(catalogPrice.currency),
84
+ unit_amount: catalogPrice.unitAmountMinorUnits,
85
+ lookup_key: priceLookupKey,
86
+ transfer_lookup_key: true,
87
+ ...stripeRecurringParams(catalogPrice),
88
+ })
89
+ await stripeClient.prices.update(existingPrice.id, { active: false })
90
+ return { stripePrice: replacementPrice, syncAction: 'replaced' }
91
+ }
92
+
93
+ const createdPrice = await stripeClient.prices.create({
94
+ product: stripeProductId,
95
+ currency: String(catalogPrice.currency),
96
+ unit_amount: catalogPrice.unitAmountMinorUnits,
97
+ lookup_key: priceLookupKey,
98
+ ...stripeRecurringParams(catalogPrice),
99
+ })
100
+ return { stripePrice: createdPrice, syncAction: 'created' }
101
+ }
@@ -0,0 +1,75 @@
1
+ import type Stripe from 'stripe'
2
+ import type { PaymentsCatalogProduct } from './payments-contract.ts'
3
+ import { readThrownPaymentsErrorDetails } from './thrown-payments-error-details.ts'
4
+
5
+ /**
6
+ * Pushing one catalog product to Stripe. The catalog product name IS the Stripe product id, because
7
+ * products.create accepts a caller-supplied id, and that is what makes sync idempotent without a
8
+ * search call: a second run retrieves the same id rather than hunting for a product by name.
9
+ *
10
+ * The retrieve-then-create shape is used rather than create-then-catch on purpose. Catching would
11
+ * mean depending on `resource_already_exists`, an API-level string this repo cannot measure offline
12
+ * whose near miss `resource_missing` sits in the same generated union. The HTTP status is measurable:
13
+ * stripe@22.6.1's generateV1Error maps 400 OR 404 to StripeInvalidRequestError, so a missing resource
14
+ * is not a distinct error class and only `statusCode` says which happened.
15
+ */
16
+
17
+ /** HTTP status Stripe answers a retrieve of an id no object carries; read off generateV1Error's own mapping. */
18
+ const stripeNotFoundHttpStatus = 404
19
+
20
+ function readThrownStripeStatus(thrownValue: unknown): number | undefined {
21
+ return readThrownPaymentsErrorDetails(thrownValue).stripeErrorStatus
22
+ }
23
+
24
+ // Only the two fields a buyer sees are kept in step. The product's `active` flag is deliberately left
25
+ // alone: an operator who archived a product in the Stripe dashboard meant it, and a sync that quietly
26
+ // republished it would undo a decision nothing here can see the reason for.
27
+ function stripeProductNeedsUpdate(
28
+ stripeProduct: Stripe.Product,
29
+ catalogProduct: PaymentsCatalogProduct,
30
+ ): boolean {
31
+ if (stripeProduct.name !== catalogProduct.displayName) {
32
+ return true
33
+ }
34
+ return (
35
+ catalogProduct.description !== undefined &&
36
+ stripeProduct.description !== catalogProduct.description
37
+ )
38
+ }
39
+
40
+ /** The Stripe product for one catalog entry, created on a first run and reused on every run after it. */
41
+ export async function ensureStripeCatalogProduct(
42
+ stripeClient: Stripe,
43
+ catalogProduct: PaymentsCatalogProduct,
44
+ ): Promise<Stripe.Product> {
45
+ const stripeProductId = String(catalogProduct.productName)
46
+
47
+ let existingProduct: Stripe.Product | undefined
48
+ try {
49
+ existingProduct = await stripeClient.products.retrieve(stripeProductId)
50
+ } catch (thrownValue) {
51
+ if (readThrownStripeStatus(thrownValue) !== stripeNotFoundHttpStatus) {
52
+ throw thrownValue
53
+ }
54
+ }
55
+
56
+ if (existingProduct === undefined) {
57
+ return stripeClient.products.create({
58
+ id: stripeProductId,
59
+ name: catalogProduct.displayName,
60
+ ...(catalogProduct.description === undefined
61
+ ? {}
62
+ : { description: catalogProduct.description }),
63
+ })
64
+ }
65
+
66
+ if (!stripeProductNeedsUpdate(existingProduct, catalogProduct)) {
67
+ return existingProduct
68
+ }
69
+ return stripeClient.products.update(stripeProductId, {
70
+ name: catalogProduct.displayName,
71
+ ...(catalogProduct.description === undefined
72
+ ? {}
73
+ : { description: catalogProduct.description }),
74
+ })
75
+ }
@@ -0,0 +1,204 @@
1
+ import type Stripe from 'stripe'
2
+ import {
3
+ billingContactEmailSchema,
4
+ billingScopeSchema,
5
+ hearthkitBillingReferenceMetadataKey,
6
+ hearthkitBillingScopeMetadataKey,
7
+ hearthkitPriceNameMetadataKey,
8
+ hearthkitQuantityMetadataKey,
9
+ hearthkitStripePriceIdMetadataKey,
10
+ paymentsCurrencyCodeSchema,
11
+ paymentsPriceNameSchema,
12
+ paymentsQuantitySchema,
13
+ stripeOneTimeCheckoutMode,
14
+ stripePriceIdSchema,
15
+ stripeSubscriptionCheckoutMode,
16
+ type HandleStripeWebhookResult,
17
+ type PaymentsClient,
18
+ } from './payments-contract.ts'
19
+ import {
20
+ readPaymentsCustomerRow,
21
+ upsertPaymentsCustomerFromWebhook,
22
+ } from './payments-customer-record.ts'
23
+ import { paymentsRequestFailedFailure } from './payments-failure-results.ts'
24
+ import { upsertPaymentsPurchaseRow } from './payments-purchase-record.ts'
25
+ import { readStripeMetadataValue, readStripeReferenceId } from './stripe-event-payload-fields.ts'
26
+ import {
27
+ paymentsWebhookIgnoredResult,
28
+ paymentsWebhookProcessedResult,
29
+ type StripeWebhookDeliveryIdentity,
30
+ } from './stripe-webhook-delivery-results.ts'
31
+
32
+ /**
33
+ * What this package does with a `checkout.session.completed` delivery. A subscription-mode session
34
+ * links the customer; a paid payment-mode session records a purchase; anything else is ignored, which
35
+ * is a result and not a failure.
36
+ *
37
+ * The purchase path reads what was sold from the session's own metadata and never from line_items,
38
+ * because a delivery does not carry them: Checkout.Session.line_items is declared optional and the SDK
39
+ * calls it "includable" on a retrieve, and nothing expands it on a delivery. Doing it any other way
40
+ * would need a retrieve call, which would put every webhook gate behind a live Stripe key.
41
+ *
42
+ * It also does not consult the catalog at all, deliberately: a purchase is a historical fact, so
43
+ * deleting a price from payments-catalog.ts must not make a completed order unrecordable.
44
+ */
45
+
46
+ // Stripe metadata values are strings, so the quantity written by createCheckoutSession is read back
47
+ // from a decimal string. A value that is not a positive integer counts as MISSING rather than
48
+ // defaulting to one, because silently billing one unit for an order of five is worse than declining
49
+ // to record it. That is not in tension with the subscription item default: absence has a defined
50
+ // meaning on Stripe's own object and none in a string this package wrote and read back.
51
+ const decimalDigitsPattern = /^\d+$/
52
+
53
+ function readCheckoutQuantityMetadata(metadata: Stripe.Metadata | null): number | undefined {
54
+ const rawQuantity = readStripeMetadataValue(metadata, hearthkitQuantityMetadataKey)
55
+ if (rawQuantity === undefined || !decimalDigitsPattern.test(rawQuantity)) {
56
+ return undefined
57
+ }
58
+ const parsedQuantity = paymentsQuantitySchema.safeParse(Number(rawQuantity))
59
+ return parsedQuantity.success ? parsedQuantity.data : undefined
60
+ }
61
+
62
+ // Both fields are read, in that order, and neither alone is enough. customer_email is a PREFILL field
63
+ // and is null on every session created with a customer id, which is every session this package
64
+ // creates after the first; customer_details.email is the one documented as populated after
65
+ // completion, but its own second sentence widens it to a promotional-consent address typed on the
66
+ // Checkout form, which is why it may only ever seed a row that did not exist.
67
+ function readCheckoutContactEmail(session: Stripe.Checkout.Session): string | undefined {
68
+ const parsed = billingContactEmailSchema.safeParse(
69
+ session.customer_details?.email ?? session.customer_email ?? undefined,
70
+ )
71
+ return parsed.success ? String(parsed.data) : undefined
72
+ }
73
+
74
+ async function linkCheckoutCustomer(
75
+ paymentsClient: PaymentsClient,
76
+ session: Stripe.Checkout.Session,
77
+ delivery: StripeWebhookDeliveryIdentity,
78
+ ): Promise<HandleStripeWebhookResult> {
79
+ const billingReferenceId = readStripeMetadataValue(
80
+ session.metadata,
81
+ hearthkitBillingReferenceMetadataKey,
82
+ )
83
+ if (billingReferenceId === undefined) {
84
+ return paymentsWebhookIgnoredResult(delivery, 'billing-reference-missing')
85
+ }
86
+
87
+ const stripeCustomerId = readStripeReferenceId(session.customer)
88
+ if (stripeCustomerId === undefined) {
89
+ return paymentsRequestFailedFailure({
90
+ paymentsFailureDetail:
91
+ 'a completed subscription checkout carried no customer, so there is nothing to link the billing reference to',
92
+ })
93
+ }
94
+
95
+ const existingCustomerRow = await readPaymentsCustomerRow(
96
+ paymentsClient.drizzleClient,
97
+ billingReferenceId,
98
+ )
99
+ // Only the insert branch needs an address, because billingContactEmail is text NOT NULL and nothing
100
+ // else can supply one for a session this package did not create. On an existing row the address
101
+ // stays exactly as createCheckoutSession wrote it, and this value never reaches the update clause.
102
+ const billingContactEmail =
103
+ readCheckoutContactEmail(session) ?? existingCustomerRow?.billingContactEmail
104
+ if (billingContactEmail === undefined) {
105
+ return paymentsRequestFailedFailure({
106
+ paymentsFailureDetail:
107
+ 'a completed subscription checkout for an unknown billing reference carried no usable email on customer_details or customer_email, so no customer row could be inserted',
108
+ })
109
+ }
110
+
111
+ const billingScope =
112
+ billingScopeSchema.safeParse(
113
+ readStripeMetadataValue(session.metadata, hearthkitBillingScopeMetadataKey),
114
+ ).data ?? paymentsClient.billingScope
115
+
116
+ await upsertPaymentsCustomerFromWebhook(paymentsClient.drizzleClient, {
117
+ billingReferenceId,
118
+ billingScope,
119
+ stripeCustomerId,
120
+ billingContactEmail,
121
+ writtenAt: delivery.eventCreatedAt,
122
+ })
123
+ return paymentsWebhookProcessedResult(delivery, 'customer-linked')
124
+ }
125
+
126
+ async function recordCheckoutPurchase(
127
+ paymentsClient: PaymentsClient,
128
+ session: Stripe.Checkout.Session,
129
+ delivery: StripeWebhookDeliveryIdentity,
130
+ ): Promise<HandleStripeWebhookResult> {
131
+ // A purchase is recorded for `paid` and for `no_payment_required`, which is what a fully discounted
132
+ // order reports; `unpaid` is the third member of that union and the one that records nothing.
133
+ if (session.payment_status === 'unpaid') {
134
+ return paymentsWebhookIgnoredResult(delivery, 'checkout-session-unpaid')
135
+ }
136
+
137
+ const billingReferenceId = readStripeMetadataValue(
138
+ session.metadata,
139
+ hearthkitBillingReferenceMetadataKey,
140
+ )
141
+ if (billingReferenceId === undefined) {
142
+ return paymentsWebhookIgnoredResult(delivery, 'billing-reference-missing')
143
+ }
144
+
145
+ const priceName = paymentsPriceNameSchema.safeParse(
146
+ readStripeMetadataValue(session.metadata, hearthkitPriceNameMetadataKey),
147
+ )
148
+ const stripePriceId = stripePriceIdSchema.safeParse(
149
+ readStripeMetadataValue(session.metadata, hearthkitStripePriceIdMetadataKey),
150
+ )
151
+ const quantity = readCheckoutQuantityMetadata(session.metadata)
152
+ if (!priceName.success || !stripePriceId.success || quantity === undefined) {
153
+ return paymentsWebhookIgnoredResult(delivery, 'checkout-price-metadata-missing')
154
+ }
155
+
156
+ const stripeCustomerId = readStripeReferenceId(session.customer)
157
+ // currency and amount_total are plain nullable fields rather than expandable ones, so a paid
158
+ // payment-mode session has both and a null here is not a contract state.
159
+ const currency = paymentsCurrencyCodeSchema.safeParse(session.currency)
160
+ const amountTotalMinorUnits = session.amount_total
161
+ if (
162
+ stripeCustomerId === undefined ||
163
+ !currency.success ||
164
+ typeof amountTotalMinorUnits !== 'number' ||
165
+ !Number.isInteger(amountTotalMinorUnits) ||
166
+ amountTotalMinorUnits < 0
167
+ ) {
168
+ return paymentsRequestFailedFailure({
169
+ paymentsFailureDetail:
170
+ 'a completed one-time checkout carried no customer, no currency or no amount total, none of which is a state this package models',
171
+ })
172
+ }
173
+
174
+ await upsertPaymentsPurchaseRow(paymentsClient.drizzleClient, {
175
+ billingReferenceId,
176
+ stripeCustomerId,
177
+ stripeCheckoutSessionId: session.id,
178
+ stripePaymentIntentId: readStripeReferenceId(session.payment_intent) ?? null,
179
+ priceName: String(priceName.data),
180
+ stripePriceId: String(stripePriceId.data),
181
+ currency: String(currency.data),
182
+ amountTotalMinorUnits,
183
+ quantity,
184
+ // The event's own `created`, seconds since the epoch, not the moment this row was written.
185
+ purchasedAt: delivery.eventCreatedAt,
186
+ writtenAt: new Date(),
187
+ })
188
+ return paymentsWebhookProcessedResult(delivery, 'purchase-recorded')
189
+ }
190
+
191
+ /** Routes one completed Checkout Session by its mode; a setup-mode session came from somewhere else and is ignored. */
192
+ export async function recordStripeCheckoutSessionDelivery(
193
+ paymentsClient: PaymentsClient,
194
+ session: Stripe.Checkout.Session,
195
+ delivery: StripeWebhookDeliveryIdentity,
196
+ ): Promise<HandleStripeWebhookResult> {
197
+ if (session.mode === stripeSubscriptionCheckoutMode) {
198
+ return linkCheckoutCustomer(paymentsClient, session, delivery)
199
+ }
200
+ if (session.mode === stripeOneTimeCheckoutMode) {
201
+ return recordCheckoutPurchase(paymentsClient, session, delivery)
202
+ }
203
+ return paymentsWebhookIgnoredResult(delivery, 'checkout-mode-not-handled')
204
+ }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * The three readers every webhook path needs, written once because each one has an obvious spelling
3
+ * beside it that answers wrongly rather than throwing.
4
+ *
5
+ * An expandable Stripe field is a string id in a delivery and an object only after a retrieve, and
6
+ * the SDK types it as the union of both; reading `.id` off a string yields undefined and reading a
7
+ * string off an object yields "[object Object]". Every Stripe timestamp is seconds since the epoch,
8
+ * so `new Date(value)` without the multiplication lands in January 1970 and nothing complains.
9
+ */
10
+
11
+ /** One metadata value, or nothing when the key is absent or empty; Stripe metadata values are always strings. */
12
+ export function readStripeMetadataValue(
13
+ metadata: Record<string, string> | null | undefined,
14
+ metadataKey: string,
15
+ ): string | undefined {
16
+ const value = metadata?.[metadataKey]
17
+ return typeof value === 'string' && value.length > 0 ? value : undefined
18
+ }
19
+
20
+ /** The id of an expandable Stripe reference, whether the payload carried the string or the whole object. */
21
+ export function readStripeReferenceId(
22
+ reference: string | { id?: string } | null | undefined,
23
+ ): string | undefined {
24
+ if (typeof reference === 'string') {
25
+ return reference.length > 0 ? reference : undefined
26
+ }
27
+ if (typeof reference === 'object' && reference !== null && typeof reference.id === 'string') {
28
+ return reference.id.length > 0 ? reference.id : undefined
29
+ }
30
+ return undefined
31
+ }
32
+
33
+ /** A Stripe timestamp as a Date; Stripe counts seconds since the epoch and JavaScript counts milliseconds. */
34
+ export function readStripeSecondsAsDate(secondsSinceEpoch: number | null | undefined): Date | null {
35
+ if (typeof secondsSinceEpoch !== 'number' || !Number.isFinite(secondsSinceEpoch)) {
36
+ return null
37
+ }
38
+ return new Date(secondsSinceEpoch * 1000)
39
+ }
@@ -0,0 +1,26 @@
1
+ import type Stripe from 'stripe'
2
+
3
+ /**
4
+ * Resolving a catalog price name against Stripe, through the lookup key syncPaymentsCatalog set it
5
+ * to. An unknown lookup key comes back as an EMPTY LIST — a 200 response with no data rather than an
6
+ * error — which is why the absent-from-stripe half of payments-price-not-found never depends on an
7
+ * API-level error code. `resource_missing` sits one union entry away from `resource_already_exists`,
8
+ * and neither is a string this repo can measure offline.
9
+ */
10
+
11
+ // PriceListParams accepts up to ten lookup_keys, and a lookup key is unique among ACTIVE prices in an
12
+ // account, so one key with active: true can match at most one price.
13
+ const activePriceListLimit = 1
14
+
15
+ /** The active Stripe price carrying this lookup key, or nothing when sync has never run for it. */
16
+ export async function findActiveStripePriceByLookupKey(
17
+ stripeClient: Stripe,
18
+ priceLookupKey: string,
19
+ ): Promise<Stripe.Price | undefined> {
20
+ const listedPrices = await stripeClient.prices.list({
21
+ lookup_keys: [priceLookupKey],
22
+ active: true,
23
+ limit: activePriceListLimit,
24
+ })
25
+ return listedPrices.data[0]
26
+ }
@@ -0,0 +1,112 @@
1
+ import type Stripe from 'stripe'
2
+ import {
3
+ hearthkitBillingReferenceMetadataKey,
4
+ type HandleStripeWebhookResult,
5
+ type PaymentsCatalog,
6
+ type PaymentsClient,
7
+ } from './payments-contract.ts'
8
+ import { paymentsRequestFailedFailure } from './payments-failure-results.ts'
9
+ import { upsertPaymentsSubscriptionRow } from './payments-subscription-record.ts'
10
+ import {
11
+ readStripeMetadataValue,
12
+ readStripeReferenceId,
13
+ readStripeSecondsAsDate,
14
+ } from './stripe-event-payload-fields.ts'
15
+ import {
16
+ paymentsWebhookIgnoredResult,
17
+ paymentsWebhookProcessedResult,
18
+ type StripeWebhookDeliveryIdentity,
19
+ } from './stripe-webhook-delivery-results.ts'
20
+
21
+ /**
22
+ * What this package does with a `customer.subscription.*` delivery: created, updated and deleted all
23
+ * upsert the same row on the Stripe subscription id and all report subscription-upserted.
24
+ *
25
+ * Unlike the checkout path this one must consult the catalog, because priceName is resolved from the
26
+ * price's lookup_key and there is no metadata to fall back on. That resolution is deliberate: a
27
+ * portal upgrade changes a subscription's price without touching metadata stamped at creation, so a
28
+ * stamped price name would go stale and then be reported as fact. The lookup_key cannot go stale.
29
+ *
30
+ * It needs no API call. SubscriptionItem.price is typed Price rather than `string | Price`, so it is
31
+ * always the full object in a delivery and never an id, which is what makes this path offline rather
32
+ * than merely usually offline.
33
+ */
34
+
35
+ function catalogPriceNames(paymentsCatalog: PaymentsCatalog): Set<string> {
36
+ const priceNames = new Set<string>()
37
+ for (const catalogProduct of paymentsCatalog.products) {
38
+ for (const catalogPrice of catalogProduct.prices) {
39
+ priceNames.add(String(catalogPrice.priceName))
40
+ }
41
+ }
42
+ return priceNames
43
+ }
44
+
45
+ // The first item whose price.lookup_key names a catalog price, not simply items.data[0]: a
46
+ // subscription can carry an item this catalog knows nothing about, and reading position zero would
47
+ // then store somebody else's price against our reference.
48
+ function findCatalogSubscriptionItem(
49
+ subscription: Stripe.Subscription,
50
+ paymentsCatalog: PaymentsCatalog,
51
+ ): Stripe.SubscriptionItem | undefined {
52
+ const knownPriceNames = catalogPriceNames(paymentsCatalog)
53
+ return subscription.items.data.find((subscriptionItem) => {
54
+ const lookupKey = subscriptionItem.price.lookup_key
55
+ return typeof lookupKey === 'string' && knownPriceNames.has(lookupKey)
56
+ })
57
+ }
58
+
59
+ /** Records what Stripe last said about a subscription; a delivery with no reference or no catalog price is ignored. */
60
+ export async function recordStripeSubscriptionDelivery(
61
+ paymentsClient: PaymentsClient,
62
+ subscription: Stripe.Subscription,
63
+ delivery: StripeWebhookDeliveryIdentity,
64
+ ): Promise<HandleStripeWebhookResult> {
65
+ // A subscription created by hand in the Stripe dashboard carries no hearthkit metadata at all, and
66
+ // inventing a reference for it would attach someone else's money to a hearthkit account.
67
+ const billingReferenceId = readStripeMetadataValue(
68
+ subscription.metadata,
69
+ hearthkitBillingReferenceMetadataKey,
70
+ )
71
+ if (billingReferenceId === undefined) {
72
+ return paymentsWebhookIgnoredResult(delivery, 'billing-reference-missing')
73
+ }
74
+
75
+ const catalogItem = findCatalogSubscriptionItem(subscription, paymentsClient.paymentsCatalog)
76
+ if (catalogItem === undefined) {
77
+ return paymentsWebhookIgnoredResult(delivery, 'subscription-price-not-in-catalog')
78
+ }
79
+
80
+ const stripeCustomerId = readStripeReferenceId(subscription.customer)
81
+ if (stripeCustomerId === undefined) {
82
+ return paymentsRequestFailedFailure({
83
+ paymentsFailureDetail:
84
+ 'a subscription delivery carried no customer, so the row would name nobody Stripe bills',
85
+ })
86
+ }
87
+
88
+ await upsertPaymentsSubscriptionRow(paymentsClient.drizzleClient, {
89
+ billingReferenceId,
90
+ stripeCustomerId,
91
+ stripeSubscriptionId: subscription.id,
92
+ priceName: String(catalogItem.price.lookup_key),
93
+ stripePriceId: catalogItem.price.id,
94
+ status: subscription.status,
95
+ // SubscriptionItem.quantity is typed optional and Stripe omits it for prices with no explicit
96
+ // quantity, metered ones among them; absence there means one unit of the thing, which is a
97
+ // defined meaning and therefore a default this package is allowed to apply.
98
+ quantity: catalogItem.quantity ?? 1,
99
+ // Both period dates come from the ITEM. stripe@22.6.1's Subscription object has no
100
+ // current_period_start or current_period_end at all, so reaching for one finds nothing.
101
+ currentPeriodStart: readStripeSecondsAsDate(catalogItem.current_period_start),
102
+ currentPeriodEnd: readStripeSecondsAsDate(catalogItem.current_period_end),
103
+ // These five are subscription-level fields that really do exist, unlike the period pair.
104
+ cancelAtPeriodEnd: subscription.cancel_at_period_end,
105
+ canceledAt: readStripeSecondsAsDate(subscription.canceled_at),
106
+ endedAt: readStripeSecondsAsDate(subscription.ended_at),
107
+ trialStart: readStripeSecondsAsDate(subscription.trial_start),
108
+ trialEnd: readStripeSecondsAsDate(subscription.trial_end),
109
+ writtenAt: new Date(),
110
+ })
111
+ return paymentsWebhookProcessedResult(delivery, 'subscription-upserted')
112
+ }
@@ -0,0 +1,46 @@
1
+ import type {
2
+ HandleStripeWebhookResult,
3
+ PaymentsWebhookIgnoredReason,
4
+ PaymentsWebhookOutcome,
5
+ StripeEventId,
6
+ } from './payments-contract.ts'
7
+
8
+ /**
9
+ * The two answers a verified delivery can end in, built in one place so both carry the event id and
10
+ * the event type. An ignored delivery is a RESULT and not a failure: Stripe sends every event type an
11
+ * endpoint is subscribed to and most of them are none of this package's business, so modelling that
12
+ * as an error would make a webhook route log a stack trace on an ordinary Tuesday.
13
+ */
14
+
15
+ /** What a verified delivery is, before this package decides what to do with it; the event's own clock, not ours. */
16
+ export type StripeWebhookDeliveryIdentity = {
17
+ stripeEventId: StripeEventId
18
+ stripeEventType: string
19
+ eventCreatedAt: Date
20
+ }
21
+
22
+ /** Result when the delivery was acted on; a replay writes the same row and moves no count. */
23
+ export function paymentsWebhookProcessedResult(
24
+ delivery: StripeWebhookDeliveryIdentity,
25
+ webhookOutcome: PaymentsWebhookOutcome,
26
+ ): HandleStripeWebhookResult {
27
+ return {
28
+ kind: 'payments-webhook-processed',
29
+ stripeEventId: delivery.stripeEventId,
30
+ stripeEventType: delivery.stripeEventType,
31
+ webhookOutcome,
32
+ }
33
+ }
34
+
35
+ /** Result when the delivery was verified but not acted on; every reason here is a normal state, not a mistake by anyone. */
36
+ export function paymentsWebhookIgnoredResult(
37
+ delivery: StripeWebhookDeliveryIdentity,
38
+ ignoredReason: PaymentsWebhookIgnoredReason,
39
+ ): HandleStripeWebhookResult {
40
+ return {
41
+ kind: 'payments-webhook-ignored',
42
+ stripeEventId: delivery.stripeEventId,
43
+ stripeEventType: delivery.stripeEventType,
44
+ ignoredReason,
45
+ }
46
+ }