@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,208 @@
1
+ import { afterAll, describe, expect, it } from 'vitest'
2
+ import {
3
+ expectPaymentsFailure,
4
+ expectResultKind,
5
+ sortedGateNames,
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
+ archiveGateStripeCatalog,
14
+ assertGateStripeIsTestMode,
15
+ } from '../test-fixtures/payments-gate-stripe-account.ts'
16
+ import {
17
+ gatePaymentsCatalog,
18
+ gateStripeSecretKey,
19
+ gateWrongStripeSecretKey,
20
+ hasGateStripeSecretKey,
21
+ uniqueGatePaymentsCatalogNames,
22
+ type GatePaymentsCatalogNames,
23
+ } from '../test-fixtures/payments-gate-values.ts'
24
+ import {
25
+ createGatePaymentsClient,
26
+ loadHearthkitPaymentsEntry,
27
+ type HearthkitPaymentsEntry,
28
+ } from '../test-fixtures/hearthkit-payments-entry.ts'
29
+ import {
30
+ stripeForbiddenHttpStatus,
31
+ stripeUnauthorizedHttpStatus,
32
+ syncPaymentsCatalogResultSchema,
33
+ type PaymentsClient,
34
+ type PaymentsSyncedPrice,
35
+ } from './payments-contract.ts'
36
+
37
+ /**
38
+ * All of syncPaymentsCatalog is live: it is the one function whose whole job is to change a Stripe
39
+ * account, and nothing offline can stand in for that. It also settles the one thing CONTRACT.md lists
40
+ * under Still not verified that everything else in sync depends on — whether Stripe accepts a
41
+ * lowercase kebab-case custom product id, which is what makes sync idempotent without a search call.
42
+ *
43
+ * No database is needed here at all, so the Drizzle client points at a closed port: sync writes
44
+ * nothing locally, and a sync that queried would fail these gates rather than pass them.
45
+ */
46
+
47
+ const createdStripePrices: PaymentsSyncedPrice[] = []
48
+
49
+ const gateFile = defineGateFileContext<{
50
+ paymentsEntry: HearthkitPaymentsEntry
51
+ catalogNames: GatePaymentsCatalogNames
52
+ buildLiveClient: (unitAmountMinorUnits?: number, stripeSecretKey?: string) => PaymentsClient
53
+ closeDatabaseClient: () => Promise<void>
54
+ }>(async () => {
55
+ const paymentsEntry = await loadHearthkitPaymentsEntry()
56
+ const { drizzleClient, closeDatabaseClient } = createGateDrizzleClientForUrl(
57
+ unreachableGateDatabaseUrl,
58
+ paymentsEntry.hearthkitPaymentsDrizzleSchema,
59
+ )
60
+ const catalogNames = uniqueGatePaymentsCatalogNames('sync')
61
+ return {
62
+ paymentsEntry,
63
+ catalogNames,
64
+ buildLiveClient: (unitAmountMinorUnits, stripeSecretKey = gateStripeSecretKey) =>
65
+ createGatePaymentsClient({
66
+ paymentsEntry,
67
+ drizzleClient,
68
+ paymentsCatalog: gatePaymentsCatalog(catalogNames, unitAmountMinorUnits),
69
+ stripeSecretKey,
70
+ }),
71
+ closeDatabaseClient,
72
+ }
73
+ })
74
+
75
+ /**
76
+ * The first sync of this run's catalog, with the test-mode guard asserted on the object Stripe
77
+ * answered with before anything else is created. Every live gate below reads this, so the guard runs
78
+ * once and holds for all of them however the file is ordered.
79
+ */
80
+ const liveGateFile = defineGateFileContext<{
81
+ liveClient: PaymentsClient
82
+ firstSyncedPrices: readonly PaymentsSyncedPrice[]
83
+ }>(async () => {
84
+ const { paymentsEntry, buildLiveClient } = await gateFile.read()
85
+ const liveClient = buildLiveClient()
86
+ const synced = expectResultKind(
87
+ await paymentsEntry.syncPaymentsCatalog({ paymentsClient: liveClient }),
88
+ 'payments-catalog-synced',
89
+ )
90
+ assertGateStripeIsTestMode(synced.stripeLivemode)
91
+ createdStripePrices.push(...synced.syncedPrices)
92
+ return { liveClient, firstSyncedPrices: synced.syncedPrices }
93
+ })
94
+
95
+ afterAll(async () => {
96
+ await liveGateFile.releaseIfCreated(({ liveClient }) =>
97
+ archiveGateStripeCatalog(liveClient, createdStripePrices),
98
+ )
99
+ await gateFile.releaseIfCreated(({ closeDatabaseClient }) => closeDatabaseClient())
100
+ })
101
+
102
+ describe('syncPaymentsCatalog', () => {
103
+ it.skipIf(!hasGateStripeSecretKey)(
104
+ 'creates a Stripe price for every catalog price on a first run, under a product whose id is the catalog product name',
105
+ async () => {
106
+ const { catalogNames } = await gateFile.read()
107
+ const { firstSyncedPrices } = await liveGateFile.read()
108
+
109
+ syncPaymentsCatalogResultSchema.parse({
110
+ kind: 'payments-catalog-synced',
111
+ syncedPrices: firstSyncedPrices,
112
+ stripeLivemode: false,
113
+ })
114
+ expect(sortedGateNames(firstSyncedPrices.map((price) => price.priceName))).toEqual(
115
+ sortedGateNames([catalogNames.subscriptionPriceName, catalogNames.oneTimePriceName]),
116
+ )
117
+ for (const syncedPrice of firstSyncedPrices) {
118
+ expect(syncedPrice.syncAction).toBe('created')
119
+ // The measurement CONTRACT.md says everything else in sync depends on: products.create
120
+ // accepts a caller-supplied id, and this asserts Stripe really accepts this id SHAPE. If it
121
+ // does not, sync needs a different identity mechanism and the contract comes back for a line.
122
+ expect(String(syncedPrice.stripeProductId)).toBe(catalogNames.productName)
123
+ expect(String(syncedPrice.stripePriceId).length).toBeGreaterThan(0)
124
+ }
125
+ },
126
+ )
127
+
128
+ it.skipIf(!hasGateStripeSecretKey)(
129
+ 'reports every price unchanged on a second run of the same catalog, reusing the product rather than duplicating it',
130
+ async () => {
131
+ const { paymentsEntry } = await gateFile.read()
132
+ const { liveClient, firstSyncedPrices } = await liveGateFile.read()
133
+
134
+ const result = await paymentsEntry.syncPaymentsCatalog({ paymentsClient: liveClient })
135
+ syncPaymentsCatalogResultSchema.parse(result)
136
+ const synced = expectResultKind(result, 'payments-catalog-synced')
137
+ expect(synced.stripeLivemode).toBe(false)
138
+
139
+ // Idempotent means every price reports unchanged, and it means the product id was supplied
140
+ // rather than searched for: a second products.create on an id that already exists must not
141
+ // become a second product or a resource_already_exists failure.
142
+ for (const syncedPrice of synced.syncedPrices) {
143
+ expect(syncedPrice.syncAction).toBe('unchanged')
144
+ }
145
+ const firstPriceIdByName = new Map(
146
+ firstSyncedPrices.map((price) => [String(price.priceName), String(price.stripePriceId)]),
147
+ )
148
+ for (const syncedPrice of synced.syncedPrices) {
149
+ expect(String(syncedPrice.stripePriceId)).toBe(
150
+ firstPriceIdByName.get(String(syncedPrice.priceName)),
151
+ )
152
+ }
153
+ },
154
+ )
155
+
156
+ it.skipIf(!hasGateStripeSecretKey)(
157
+ 'replaces a price whose amount changed, moving the lookup key onto a new Stripe price id',
158
+ async () => {
159
+ const { paymentsEntry, buildLiveClient } = await gateFile.read()
160
+ const { firstSyncedPrices } = await liveGateFile.read()
161
+
162
+ // Stripe prices are immutable in amount and currency, so the same lookup key at a new amount
163
+ // cannot be an update: a new price is created with transfer_lookup_key and the superseded one
164
+ // is archived. Existing subscriptions stay on the old price, which is Stripe's behaviour and
165
+ // not something this package migrates.
166
+ const repricedClient = buildLiveClient(2900)
167
+ const result = await paymentsEntry.syncPaymentsCatalog({ paymentsClient: repricedClient })
168
+ syncPaymentsCatalogResultSchema.parse(result)
169
+ const synced = expectResultKind(result, 'payments-catalog-synced')
170
+ createdStripePrices.push(...synced.syncedPrices)
171
+
172
+ const firstPriceIdByName = new Map(
173
+ firstSyncedPrices.map((price) => [String(price.priceName), String(price.stripePriceId)]),
174
+ )
175
+ for (const syncedPrice of synced.syncedPrices) {
176
+ expect(syncedPrice.syncAction, String(syncedPrice.priceName)).toBe('replaced')
177
+ // Nothing stores a Stripe price id as an identity, precisely because this happens. The
178
+ // catalog keys on the lookup key instead, which is stable across a replacement.
179
+ expect(String(syncedPrice.stripePriceId)).not.toBe(
180
+ firstPriceIdByName.get(String(syncedPrice.priceName)),
181
+ )
182
+ }
183
+ },
184
+ )
185
+
186
+ it.skipIf(!hasGateStripeSecretKey)(
187
+ 'reports payments-stripe-unauthorized when Stripe refuses the API key, without echoing the key back',
188
+ async () => {
189
+ const { paymentsEntry, buildLiveClient } = await gateFile.read()
190
+ // A well-formed value that is not a key any account issued. This is the first-run state of
191
+ // every project that pasted the wrong key, and without a name it would arrive as an opaque
192
+ // catch-all in the one package that is holding somebody's money.
193
+ const wrongKeyClient = buildLiveClient(undefined, gateWrongStripeSecretKey)
194
+
195
+ const result = await paymentsEntry.syncPaymentsCatalog({ paymentsClient: wrongKeyClient })
196
+ syncPaymentsCatalogResultSchema.parse(result)
197
+ const failure = expectPaymentsFailure(result, 'payments-stripe-unauthorized')
198
+
199
+ // 401 is a wrong or revoked key and 403 is a restricted key without the permission. One
200
+ // variant, because the caller does the same thing with both; stripeErrorStatus is carried so
201
+ // a reader sees which. CONTRACT.md lists which one Stripe actually answers with as unmeasured.
202
+ expect([stripeUnauthorizedHttpStatus, stripeForbiddenHttpStatus]).toContain(
203
+ failure.stripeErrorStatus,
204
+ )
205
+ expect(failure.stripeFailureDetail.length).toBeGreaterThan(0)
206
+ },
207
+ )
208
+ })
@@ -0,0 +1,65 @@
1
+ import {
2
+ paymentsClientSchema,
3
+ paymentsPriceNameSchema,
4
+ stripePriceIdSchema,
5
+ stripeProductIdSchema,
6
+ type PaymentsSyncedPrice,
7
+ type SyncPaymentsCatalogOptions,
8
+ type SyncPaymentsCatalogResult,
9
+ } from './payments-contract.ts'
10
+ import { paymentsInputInvalidFailure } from './payments-failure-results.ts'
11
+ import { ensureStripeCatalogProduct } from './stripe-catalog-product-sync.ts'
12
+ import { syncStripeCatalogPrice } from './stripe-catalog-price-sync.ts'
13
+ import { thrownPaymentsErrorToFailure } from './thrown-payments-error-failure.ts'
14
+
15
+ /**
16
+ * Pushes the catalog the client holds to Stripe and reports what it did to each price. It writes
17
+ * nothing locally: sync changes a Stripe account and nothing else, so it needs no database at all.
18
+ *
19
+ * Idempotent, because both halves are keyed on names the app chose: the product name is the Stripe
20
+ * product id and the price name is the Stripe price lookup key. A second run over an unchanged
21
+ * catalog therefore reports every price unchanged and creates nothing.
22
+ *
23
+ * stripeLivemode is read off the first object Stripe answered with, whose own generated type says it
24
+ * is true in live mode and false in test mode. That is the guard a caller checks before creating
25
+ * anything else, and it is a measurement rather than a match on an API key prefix.
26
+ */
27
+ export async function syncPaymentsCatalog(
28
+ options: SyncPaymentsCatalogOptions,
29
+ ): Promise<SyncPaymentsCatalogResult> {
30
+ if (!paymentsClientSchema.safeParse(options.paymentsClient).success) {
31
+ return paymentsInputInvalidFailure('drizzle-client')
32
+ }
33
+
34
+ const { stripeClient, paymentsCatalog } = options.paymentsClient
35
+ const syncedPrices: PaymentsSyncedPrice[] = []
36
+ let stripeLivemode: boolean | undefined
37
+
38
+ try {
39
+ for (const catalogProduct of paymentsCatalog.products) {
40
+ const stripeProduct = await ensureStripeCatalogProduct(stripeClient, catalogProduct)
41
+ stripeLivemode ??= stripeProduct.livemode
42
+
43
+ for (const catalogPrice of catalogProduct.prices) {
44
+ const priceSync = await syncStripeCatalogPrice(stripeClient, stripeProduct.id, catalogPrice)
45
+ stripeLivemode ??= priceSync.stripePrice.livemode
46
+ syncedPrices.push({
47
+ priceName: paymentsPriceNameSchema.parse(String(catalogPrice.priceName)),
48
+ stripeProductId: stripeProductIdSchema.parse(stripeProduct.id),
49
+ stripePriceId: stripePriceIdSchema.parse(priceSync.stripePrice.id),
50
+ syncAction: priceSync.syncAction,
51
+ })
52
+ }
53
+ }
54
+ } catch (thrownValue) {
55
+ return thrownPaymentsErrorToFailure(thrownValue, options.paymentsClient)
56
+ }
57
+
58
+ return {
59
+ kind: 'payments-catalog-synced',
60
+ syncedPrices,
61
+ // A validated catalog always has at least one product with at least one price, so an object came
62
+ // back from Stripe and this is never the fallback in practice.
63
+ stripeLivemode: stripeLivemode ?? false,
64
+ }
65
+ }
@@ -0,0 +1,134 @@
1
+ import { paymentsDatabaseUnavailableCauseCodes } from './payments-contract.ts'
2
+
3
+ /**
4
+ * Reads a thrown Stripe or Drizzle value without asserting anything about its shape. Every access
5
+ * path here is measured at stripe@22.6.1 and drizzle-orm@0.45.2, and the obvious spelling beside each
6
+ * one returns a plausible wrong answer rather than throwing, which is why they are read in one place
7
+ * instead of at each call site:
8
+ *
9
+ * - `error.type` is the SDK CLASS NAME, because StripeError's constructor sets
10
+ * `this.type = type || this.constructor.name`. Stripe's own type string — `invalid_request_error`
11
+ * and friends — is at `error.rawType`, so switching on `error.type` expecting the API's vocabulary
12
+ * matches nothing and routes every case to the catch-all, silently;
13
+ * - the HTTP status is at `error.statusCode`, the API error code at `error.code`, and the offending
14
+ * parameter name at `error.param`, all straight off the raw error body;
15
+ * - a database failure carries no code on the thrown value at all; it sits one `.cause` hop down, and
16
+ * one call can throw from two unrelated families — an AggregateError and pg's DatabaseError.
17
+ */
18
+
19
+ // The SDK class names, read off stripe@22.6.1's esm/Error.js where each subclass passes its own name
20
+ // to the base constructor: StripeConnectionError at :164 and StripeSignatureVerificationError at :176.
21
+ /** SDK class name of the error a request-level network failure or a timeout produces. */
22
+ export const stripeConnectionErrorTypeName = 'StripeConnectionError'
23
+
24
+ /** SDK class name of the error webhook signature verification throws; it also carries the raw body on `.payload`, which is never read. */
25
+ export const stripeSignatureVerificationErrorTypeName = 'StripeSignatureVerificationError'
26
+
27
+ // Deep enough for a DrizzleQueryError wrapping a pg DatabaseError, or an AggregateError listing the
28
+ // attempts it made, and bounded so a self-referencing cause cannot spin.
29
+ const maximumErrorCauseHops = 6
30
+
31
+ /** What a thrown payments call yielded, with each value read from the spelling that actually carries it. */
32
+ export type ThrownPaymentsErrorDetails = {
33
+ stripeErrorTypeName: string | undefined
34
+ stripeErrorCode: string | undefined
35
+ stripeErrorStatus: number | undefined
36
+ stripeErrorParam: string | undefined
37
+ databaseFailureDetail: string | undefined
38
+ paymentsFailureDetail: string
39
+ }
40
+
41
+ function readErrorMessage(error: unknown): string | undefined {
42
+ if (typeof error === 'object' && error !== null && 'message' in error) {
43
+ return typeof error.message === 'string' && error.message.length > 0 ? error.message : undefined
44
+ }
45
+ return undefined
46
+ }
47
+
48
+ function readErrorName(error: unknown): string | undefined {
49
+ if (typeof error === 'object' && error !== null && 'name' in error) {
50
+ return typeof error.name === 'string' && error.name.length > 0 ? error.name : undefined
51
+ }
52
+ return undefined
53
+ }
54
+
55
+ /** True when a thrown value is an object carrying the named property, which is all reading it by name needs. */
56
+ function hasErrorProperty(error: unknown, propertyName: string): error is Record<string, unknown> {
57
+ return typeof error === 'object' && error !== null && propertyName in error
58
+ }
59
+
60
+ function readStringProperty(error: unknown, propertyName: string): string | undefined {
61
+ if (!hasErrorProperty(error, propertyName)) {
62
+ return undefined
63
+ }
64
+ const value = error[propertyName]
65
+ return typeof value === 'string' && value.length > 0 ? value : undefined
66
+ }
67
+
68
+ function readIntegerProperty(error: unknown, propertyName: string): number | undefined {
69
+ if (!hasErrorProperty(error, propertyName)) {
70
+ return undefined
71
+ }
72
+ const value = error[propertyName]
73
+ return typeof value === 'number' && Number.isInteger(value) ? value : undefined
74
+ }
75
+
76
+ /** Every value reachable from a thrown error by `.cause` hops and by an AggregateError's own list. */
77
+ function relatedThrownValues(thrownValue: unknown): unknown[] {
78
+ const related: unknown[] = []
79
+ const pending: unknown[] = [thrownValue]
80
+ while (pending.length > 0 && related.length < maximumErrorCauseHops) {
81
+ const current = pending.shift()
82
+ if (typeof current !== 'object' || current === null) {
83
+ continue
84
+ }
85
+ related.push(current)
86
+ if ('cause' in current && current.cause !== undefined) {
87
+ pending.push(current.cause)
88
+ }
89
+ // A dead port arrives as an AggregateError whose own `code` is ECONNREFUSED, while an absent
90
+ // table arrives as a pg DatabaseError; one handler has to read both families.
91
+ if ('errors' in current && Array.isArray(current.errors)) {
92
+ for (const aggregated of current.errors) {
93
+ pending.push(aggregated)
94
+ }
95
+ }
96
+ }
97
+ return related
98
+ }
99
+
100
+ function readDatabaseFailureDetail(thrownValue: unknown): string | undefined {
101
+ for (const related of relatedThrownValues(thrownValue)) {
102
+ const code = readStringProperty(related, 'code')
103
+ if (
104
+ code !== undefined &&
105
+ (paymentsDatabaseUnavailableCauseCodes as readonly string[]).includes(code)
106
+ ) {
107
+ // Built from the cause and never from the DrizzleQueryError that wraps it: the wrapper's
108
+ // message repeats the failing statement AND its bound parameters, which on these tables would
109
+ // put a customer's address and Stripe ids into a failure a caller is likely to log.
110
+ return `Postgres reported ${code}: ${readErrorMessage(related) ?? readErrorName(related) ?? 'no message'}`
111
+ }
112
+ }
113
+ return undefined
114
+ }
115
+
116
+ /** Reads a thrown value once, so no call site has to remember which of the plausible spellings is real. */
117
+ export function readThrownPaymentsErrorDetails(thrownValue: unknown): ThrownPaymentsErrorDetails {
118
+ const stripeErrorStatus = readIntegerProperty(thrownValue, 'statusCode')
119
+ const statusSuffix =
120
+ stripeErrorStatus === undefined ? '' : ` with status ${String(stripeErrorStatus)}`
121
+
122
+ return {
123
+ stripeErrorTypeName: readStringProperty(thrownValue, 'type'),
124
+ stripeErrorCode: readStringProperty(thrownValue, 'code'),
125
+ stripeErrorStatus,
126
+ stripeErrorParam: readStringProperty(thrownValue, 'param'),
127
+ databaseFailureDetail: readDatabaseFailureDetail(thrownValue),
128
+ // Never empty: a failure variant carrying an empty detail would fail its own schema, and some
129
+ // thrown values carry only a name.
130
+ paymentsFailureDetail:
131
+ readErrorMessage(thrownValue) ??
132
+ `${readErrorName(thrownValue) ?? 'an error with no message'}${statusSuffix}`,
133
+ }
134
+ }
@@ -0,0 +1,74 @@
1
+ import { redactPaymentsClientSecrets } from './payments-client-secrets.ts'
2
+ import {
3
+ paymentsDatabaseUnavailableFailure,
4
+ paymentsRequestFailedFailure,
5
+ paymentsStripeUnauthorizedFailure,
6
+ paymentsStripeUnreachableFailure,
7
+ } from './payments-failure-results.ts'
8
+ import {
9
+ stripeForbiddenHttpStatus,
10
+ stripeUnauthorizedHttpStatus,
11
+ type PaymentsClient,
12
+ type PaymentsFailure,
13
+ } from './payments-contract.ts'
14
+ import {
15
+ readThrownPaymentsErrorDetails,
16
+ stripeConnectionErrorTypeName,
17
+ } from './thrown-payments-error-details.ts'
18
+
19
+ /**
20
+ * Turns any thrown value into the failure CONTRACT.md maps it to, so no public function of this
21
+ * package ever throws. The mapping is fixed there rather than chosen here: a database cause code from
22
+ * the four-code allowlist wins first, then Stripe's 401 or 403, then a connection failure, and
23
+ * everything else lands in the catch-all carrying whatever Stripe supplied.
24
+ *
25
+ * Every quoted detail passes through the client's secret redaction first, because the words are the
26
+ * SDK's and the rule that neither secret leaves this package has no exceptions.
27
+ */
28
+ export function thrownPaymentsErrorToFailure(
29
+ thrownValue: unknown,
30
+ paymentsClient: PaymentsClient | undefined,
31
+ ): PaymentsFailure {
32
+ const details = readThrownPaymentsErrorDetails(thrownValue)
33
+
34
+ if (details.databaseFailureDetail !== undefined) {
35
+ return paymentsDatabaseUnavailableFailure(
36
+ redactPaymentsClientSecrets(details.databaseFailureDetail, paymentsClient),
37
+ )
38
+ }
39
+
40
+ const paymentsFailureDetail = redactPaymentsClientSecrets(
41
+ details.paymentsFailureDetail,
42
+ paymentsClient,
43
+ )
44
+ // Redaction can only shorten text, and a variant carrying an empty detail would fail its own
45
+ // schema, so a detail that was nothing but a secret still has to say something.
46
+ const safeDetail =
47
+ paymentsFailureDetail.length > 0 ? paymentsFailureDetail : 'no detail available'
48
+
49
+ if (
50
+ details.stripeErrorStatus === stripeUnauthorizedHttpStatus ||
51
+ details.stripeErrorStatus === stripeForbiddenHttpStatus
52
+ ) {
53
+ return paymentsStripeUnauthorizedFailure(details.stripeErrorStatus, safeDetail)
54
+ }
55
+
56
+ if (details.stripeErrorTypeName === stripeConnectionErrorTypeName) {
57
+ return paymentsStripeUnreachableFailure(safeDetail)
58
+ }
59
+
60
+ return paymentsRequestFailedFailure({
61
+ paymentsFailureDetail: safeDetail,
62
+ ...(details.stripeErrorCode === undefined
63
+ ? {}
64
+ : { stripeErrorCode: redactPaymentsClientSecrets(details.stripeErrorCode, paymentsClient) }),
65
+ ...(details.stripeErrorStatus === undefined
66
+ ? {}
67
+ : { stripeErrorStatus: details.stripeErrorStatus }),
68
+ ...(details.stripeErrorParam === undefined
69
+ ? {}
70
+ : {
71
+ stripeErrorParam: redactPaymentsClientSecrets(details.stripeErrorParam, paymentsClient),
72
+ }),
73
+ })
74
+ }
@@ -0,0 +1,84 @@
1
+ import { afterAll, describe, expect, it } from 'vitest'
2
+ import { expectResultKind, sortedGateNames } from '../test-fixtures/payments-gate-expectations.ts'
3
+ import { defineGateFileContext } from '../test-fixtures/payments-gate-file-context.ts'
4
+ import {
5
+ createGatePaymentsDatabase,
6
+ createGatePaymentsDatabaseWithoutTables,
7
+ gateDatabaseExists,
8
+ type GatePaymentsDatabase,
9
+ } from '../test-fixtures/payments-gate-postgres-database.ts'
10
+ import {
11
+ loadHearthkitPaymentsEntry,
12
+ type HearthkitPaymentsEntry,
13
+ } from '../test-fixtures/hearthkit-payments-entry.ts'
14
+ import {
15
+ hearthkitPaymentsTableNames,
16
+ verifyPaymentsTablesExistResultSchema,
17
+ } from './payments-contract.ts'
18
+
19
+ const gateFile = defineGateFileContext<{
20
+ paymentsEntry: HearthkitPaymentsEntry
21
+ migratedDatabase: GatePaymentsDatabase
22
+ unmigratedDatabase: GatePaymentsDatabase
23
+ }>(async () => {
24
+ const paymentsEntry = await loadHearthkitPaymentsEntry()
25
+ // Not createVerifiedGatePaymentsDatabase: this file is where that check itself is the subject, so
26
+ // it builds the tables and then asks the question rather than assuming the answer during setup.
27
+ const migratedDatabase = await createGatePaymentsDatabase(
28
+ 'tables',
29
+ paymentsEntry.hearthkitPaymentsDrizzleSchema,
30
+ )
31
+ const unmigratedDatabase = await createGatePaymentsDatabaseWithoutTables(
32
+ 'notables',
33
+ paymentsEntry.hearthkitPaymentsDrizzleSchema,
34
+ )
35
+ return { paymentsEntry, migratedDatabase, unmigratedDatabase }
36
+ })
37
+
38
+ afterAll(async () => {
39
+ await gateFile.releaseIfCreated(async ({ migratedDatabase, unmigratedDatabase }) => {
40
+ await migratedDatabase.removeGatePaymentsDatabase()
41
+ await unmigratedDatabase.removeGatePaymentsDatabase()
42
+ // Both scratch databases are gone when this file is done, so a repeated run starts from nothing.
43
+ for (const gateDatabase of [migratedDatabase, unmigratedDatabase]) {
44
+ if (await gateDatabaseExists(String(gateDatabase.projectDatabaseName))) {
45
+ throw new Error(
46
+ `gate left the scratch database ${String(gateDatabase.projectDatabaseName)} behind`,
47
+ )
48
+ }
49
+ }
50
+ })
51
+ })
52
+
53
+ describe('verifyPaymentsTablesExist', () => {
54
+ it('reports all three tables present in a database built from the shipped Drizzle schema', async () => {
55
+ const { paymentsEntry, migratedDatabase } = await gateFile.read()
56
+
57
+ // It takes the Drizzle client rather than the payments client on purpose, so observability's
58
+ // /health can call it with no Stripe key at all. No payments client is built in this gate.
59
+ const result = await paymentsEntry.verifyPaymentsTablesExist({
60
+ drizzleClient: migratedDatabase.drizzleClient,
61
+ })
62
+ verifyPaymentsTablesExistResultSchema.parse(result)
63
+ const present = expectResultKind(result, 'payments-tables-present')
64
+
65
+ expect(sortedGateNames(present.presentTableNames)).toEqual(
66
+ sortedGateNames(hearthkitPaymentsTableNames),
67
+ )
68
+ })
69
+
70
+ it('reports payments-tables-missing, which is a successful check and not a failure, when migrations never ran', async () => {
71
+ const { paymentsEntry, unmigratedDatabase } = await gateFile.read()
72
+
73
+ // The function's whole job is to report presence, so a negative answer is a result. A database
74
+ // that is reachable and empty is the ordinary first-run state of every project.
75
+ const result = await paymentsEntry.verifyPaymentsTablesExist({
76
+ drizzleClient: unmigratedDatabase.drizzleClient,
77
+ })
78
+ verifyPaymentsTablesExistResultSchema.parse(result)
79
+ const missing = expectResultKind(result, 'payments-tables-missing')
80
+ expect(sortedGateNames(missing.missingTableNames)).toEqual(
81
+ sortedGateNames(hearthkitPaymentsTableNames),
82
+ )
83
+ })
84
+ })
@@ -0,0 +1,67 @@
1
+ import { sql } from 'drizzle-orm'
2
+ import { z } from 'zod'
3
+ import {
4
+ hearthkitPaymentsTableNames,
5
+ paymentsDrizzleClientSchema,
6
+ type HearthkitPaymentsTableName,
7
+ type VerifyPaymentsTablesExistOptions,
8
+ type VerifyPaymentsTablesExistResult,
9
+ } from './payments-contract.ts'
10
+ import { paymentsInputInvalidFailure } from './payments-failure-results.ts'
11
+ import { thrownPaymentsErrorToFailure } from './thrown-payments-error-failure.ts'
12
+
13
+ // node-postgres answers with a QueryResult carrying `rows`; a driver that answers the rows directly
14
+ // is read the same way, so neither shape has to be assumed.
15
+ const tableNameRowsSchema = z.array(z.object({ table_name: z.string() }))
16
+ const queryResultSchema = z.union([
17
+ tableNameRowsSchema,
18
+ z.object({ rows: tableNameRowsSchema }).transform((queryResult) => queryResult.rows),
19
+ ])
20
+
21
+ /**
22
+ * One query against information_schema, so it is safe to call from a health check. It takes the
23
+ * Drizzle client rather than the payments client precisely so observability's /health can call it
24
+ * with no Stripe key at all.
25
+ *
26
+ * payments-tables-missing is a result rather than a failure: the function's whole job is to report
27
+ * presence, and a reachable database with no payments tables in it is the ordinary first-run state of
28
+ * every project. A database that cannot be reached is a different answer, and that one is a failure.
29
+ *
30
+ * Names are read back and compared here rather than filtered in SQL, because information_schema types
31
+ * its identifiers as a domain and a bound array comparison against one needs a cast that adds
32
+ * nothing: the three names are known and the result set is small either way.
33
+ */
34
+ export async function verifyPaymentsTablesExist(
35
+ options: VerifyPaymentsTablesExistOptions,
36
+ ): Promise<VerifyPaymentsTablesExistResult> {
37
+ if (!paymentsDrizzleClientSchema.safeParse(options.drizzleClient).success) {
38
+ return paymentsInputInvalidFailure('drizzle-client')
39
+ }
40
+
41
+ let queryResult: unknown
42
+ try {
43
+ queryResult = await options.drizzleClient.execute(
44
+ sql`select table_name from information_schema.tables where table_schema = 'public'`,
45
+ )
46
+ } catch (thrownValue) {
47
+ return thrownPaymentsErrorToFailure(thrownValue, undefined)
48
+ }
49
+
50
+ const parsedRows = queryResultSchema.safeParse(queryResult)
51
+ const tableNamesInDatabase = new Set((parsedRows.data ?? []).map((row) => row.table_name))
52
+
53
+ const presentTableNames: HearthkitPaymentsTableName[] = []
54
+ const missingTableNames: HearthkitPaymentsTableName[] = []
55
+ for (const paymentsTableName of hearthkitPaymentsTableNames) {
56
+ if (tableNamesInDatabase.has(paymentsTableName)) {
57
+ presentTableNames.push(paymentsTableName)
58
+ } else {
59
+ missingTableNames.push(paymentsTableName)
60
+ }
61
+ }
62
+
63
+ if (missingTableNames.length > 0) {
64
+ return { kind: 'payments-tables-missing', missingTableNames }
65
+ }
66
+ return { kind: 'payments-tables-present', presentTableNames }
67
+ }