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