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