@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
package/src/index.ts
ADDED
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
/** Public entry point of @hearthkit/payments: a named re-export of exactly the surface the contract lists, so no internal module is importable by consumers. */
|
|
2
|
+
|
|
3
|
+
/** Validates the catalog and builds the Stripe client; synchronous, and contacts nothing. */
|
|
4
|
+
export { createPaymentsClient } from './create-payments-client.ts'
|
|
5
|
+
|
|
6
|
+
/** Pushes the held catalog to Stripe; idempotent, so a second run reports every price unchanged. */
|
|
7
|
+
export { syncPaymentsCatalog } from './sync-payments-catalog.ts'
|
|
8
|
+
|
|
9
|
+
/** Creates a hosted Checkout Session, putting a Stripe customer on file when the reference has none. */
|
|
10
|
+
export { createCheckoutSession } from './create-checkout-session.ts'
|
|
11
|
+
|
|
12
|
+
/** Opens Stripe's hosted billing portal; it creates nothing, so it is the only producer of customer-not-found. */
|
|
13
|
+
export { createCustomerPortalSession } from './create-customer-portal-session.ts'
|
|
14
|
+
|
|
15
|
+
/** Verifies a delivery with a local HMAC and records it; it needs Postgres but never the network. */
|
|
16
|
+
export { handleStripeWebhook } from './handle-stripe-webhook.ts'
|
|
17
|
+
|
|
18
|
+
/** The most recent subscription row for a reference, whatever its status; having none is a normal answer. */
|
|
19
|
+
export { readPaymentsSubscription } from './read-payments-subscription.ts'
|
|
20
|
+
|
|
21
|
+
/** Every completed one-time purchase for a reference, newest first; an empty list is a success. */
|
|
22
|
+
export { listPaymentsPurchases } from './list-payments-purchases.ts'
|
|
23
|
+
|
|
24
|
+
/** One query against information_schema; it takes the Drizzle client so a health check needs no Stripe key. */
|
|
25
|
+
export { verifyPaymentsTablesExist } from './verify-payments-tables-exist.ts'
|
|
26
|
+
|
|
27
|
+
/** The Drizzle table map, always carrying all three tables whatever the organizations flag is. */
|
|
28
|
+
export { hearthkitPaymentsDrizzleSchema } from './hearthkit-payments-drizzle-schema.ts'
|
|
29
|
+
|
|
30
|
+
/** Contract values: the unique literal prefix every returned failure message starts with. */
|
|
31
|
+
export {
|
|
32
|
+
paymentsCatalogInvalidErrorPrefix,
|
|
33
|
+
paymentsCustomerNotFoundErrorPrefix,
|
|
34
|
+
paymentsDatabaseUnavailableErrorPrefix,
|
|
35
|
+
paymentsInputInvalidErrorPrefix,
|
|
36
|
+
paymentsPriceNotFoundErrorPrefix,
|
|
37
|
+
paymentsRequestFailedErrorPrefix,
|
|
38
|
+
paymentsStripeUnauthorizedErrorPrefix,
|
|
39
|
+
paymentsStripeUnreachableErrorPrefix,
|
|
40
|
+
paymentsWebhookSignatureInvalidErrorPrefix,
|
|
41
|
+
} from './payments-contract.ts'
|
|
42
|
+
|
|
43
|
+
/** Contract values: the Stripe literals this package matches by exact equality, each read off stripe@22.6.1 rather than from documentation. */
|
|
44
|
+
export {
|
|
45
|
+
stripeForbiddenHttpStatus,
|
|
46
|
+
stripeOneTimeCheckoutMode,
|
|
47
|
+
stripeOneTimePriceType,
|
|
48
|
+
stripeParsedBodySignatureMessagePrefix,
|
|
49
|
+
stripeRecurringPriceType,
|
|
50
|
+
stripeSignatureHeaderName,
|
|
51
|
+
stripeSignatureScheme,
|
|
52
|
+
stripeSignatureToleranceSeconds,
|
|
53
|
+
stripeSubscriptionCheckoutMode,
|
|
54
|
+
stripeUnauthorizedHttpStatus,
|
|
55
|
+
stripeWrongSchemeSignatureMessage,
|
|
56
|
+
stripeWrongSecretSignatureMessagePrefix,
|
|
57
|
+
} from './payments-contract.ts'
|
|
58
|
+
|
|
59
|
+
/** Contract values: the Stripe metadata keys createCheckoutSession writes and the webhook handler reads back. */
|
|
60
|
+
export {
|
|
61
|
+
hearthkitBillingReferenceMetadataKey,
|
|
62
|
+
hearthkitBillingScopeMetadataKey,
|
|
63
|
+
hearthkitPriceNameMetadataKey,
|
|
64
|
+
hearthkitQuantityMetadataKey,
|
|
65
|
+
hearthkitStripePriceIdMetadataKey,
|
|
66
|
+
} from './payments-contract.ts'
|
|
67
|
+
|
|
68
|
+
/** Contract values: the name lists, status lists and limits an app and a gate compare against. */
|
|
69
|
+
export {
|
|
70
|
+
defaultCheckoutQuantity,
|
|
71
|
+
handledStripeWebhookEventTypeSchema,
|
|
72
|
+
handledStripeWebhookEventTypes,
|
|
73
|
+
hearthkitPaymentsTableNameSchema,
|
|
74
|
+
hearthkitPaymentsTableNames,
|
|
75
|
+
maximumPaymentsPriceNameLength,
|
|
76
|
+
maximumPaymentsProductNameLength,
|
|
77
|
+
paymentsActiveSubscriptionStatuses,
|
|
78
|
+
paymentsDatabaseUnavailableCauseCodes,
|
|
79
|
+
paymentsKnownSubscriptionStatuses,
|
|
80
|
+
} from './payments-contract.ts'
|
|
81
|
+
|
|
82
|
+
/** Contract values: this package's env fragment and the two secret schemas it is built from. */
|
|
83
|
+
export {
|
|
84
|
+
paymentsEnvSchemaFragment,
|
|
85
|
+
stripeSecretKeySchema,
|
|
86
|
+
stripeWebhookSecretSchema,
|
|
87
|
+
} from './payments-contract.ts'
|
|
88
|
+
|
|
89
|
+
/** Contract values: the branded vocabulary schemas, so an app can parse an untrusted value before calling anything here. */
|
|
90
|
+
export {
|
|
91
|
+
billingContactEmailSchema,
|
|
92
|
+
billingReferenceIdSchema,
|
|
93
|
+
billingScopeSchema,
|
|
94
|
+
paymentsCurrencyCodeSchema,
|
|
95
|
+
paymentsCustomerIdSchema,
|
|
96
|
+
paymentsPriceNameSchema,
|
|
97
|
+
paymentsProductNameSchema,
|
|
98
|
+
paymentsPurchaseIdSchema,
|
|
99
|
+
paymentsQuantitySchema,
|
|
100
|
+
paymentsRecurringIntervalSchema,
|
|
101
|
+
paymentsRedirectUrlSchema,
|
|
102
|
+
paymentsStripeApiBaseUrlSchema,
|
|
103
|
+
paymentsSubscriptionIdSchema,
|
|
104
|
+
paymentsSubscriptionStatusSchema,
|
|
105
|
+
paymentsUnitAmountMinorUnitsSchema,
|
|
106
|
+
stripeCheckoutSessionIdSchema,
|
|
107
|
+
stripeCustomerIdSchema,
|
|
108
|
+
stripeEventIdSchema,
|
|
109
|
+
stripePaymentIntentIdSchema,
|
|
110
|
+
stripePriceIdSchema,
|
|
111
|
+
stripeProductIdSchema,
|
|
112
|
+
stripeSubscriptionIdSchema,
|
|
113
|
+
} from './payments-contract.ts'
|
|
114
|
+
|
|
115
|
+
/** Contract values: the catalog schemas an app validates payments-catalog.ts with, and the three row shapes this package reports. */
|
|
116
|
+
export {
|
|
117
|
+
paymentsCatalogPriceSchema,
|
|
118
|
+
paymentsCatalogProductSchema,
|
|
119
|
+
paymentsCatalogSchema,
|
|
120
|
+
paymentsCustomerSchema,
|
|
121
|
+
paymentsOneTimeCatalogPriceSchema,
|
|
122
|
+
paymentsPurchaseSchema,
|
|
123
|
+
paymentsSubscriptionCatalogPriceSchema,
|
|
124
|
+
paymentsSubscriptionSchema,
|
|
125
|
+
} from './payments-contract.ts'
|
|
126
|
+
|
|
127
|
+
/** Contract values: each failure variant's schema, the discriminator enums, and the union of all nine. */
|
|
128
|
+
export {
|
|
129
|
+
paymentsCatalogInvalidFailureSchema,
|
|
130
|
+
paymentsCatalogIssueKindSchema,
|
|
131
|
+
paymentsCatalogIssueSchema,
|
|
132
|
+
paymentsCustomerNotFoundFailureSchema,
|
|
133
|
+
paymentsDatabaseUnavailableFailureSchema,
|
|
134
|
+
paymentsFailureSchema,
|
|
135
|
+
paymentsInputInvalidFailureSchema,
|
|
136
|
+
paymentsInvalidFieldNameSchema,
|
|
137
|
+
paymentsPriceNotFoundFailureSchema,
|
|
138
|
+
paymentsRequestFailedFailureSchema,
|
|
139
|
+
paymentsStripeUnauthorizedFailureSchema,
|
|
140
|
+
paymentsStripeUnreachableFailureSchema,
|
|
141
|
+
paymentsWebhookSignatureInvalidFailureSchema,
|
|
142
|
+
priceLookupFailureSchema,
|
|
143
|
+
webhookSignatureFailureReasonSchema,
|
|
144
|
+
} from './payments-contract.ts'
|
|
145
|
+
|
|
146
|
+
/** Contract values: the runtime checks for the three structural values this package takes in or hands back. */
|
|
147
|
+
export {
|
|
148
|
+
paymentsClientSchema,
|
|
149
|
+
paymentsDrizzleClientSchema,
|
|
150
|
+
paymentsRequestHeadersSchema,
|
|
151
|
+
} from './payments-contract.ts'
|
|
152
|
+
|
|
153
|
+
/** Contract values: each function's options, success-only and full result schemas, so a narrowed result can be validated without rebuilding the schema. */
|
|
154
|
+
export {
|
|
155
|
+
createCheckoutSessionOptionsSchema,
|
|
156
|
+
createCheckoutSessionResultSchema,
|
|
157
|
+
createCustomerPortalSessionOptionsSchema,
|
|
158
|
+
createCustomerPortalSessionResultSchema,
|
|
159
|
+
createPaymentsClientOptionsSchema,
|
|
160
|
+
createPaymentsClientResultSchema,
|
|
161
|
+
handleStripeWebhookOptionsSchema,
|
|
162
|
+
handleStripeWebhookResultSchema,
|
|
163
|
+
listPaymentsPurchasesOptionsSchema,
|
|
164
|
+
listPaymentsPurchasesResultSchema,
|
|
165
|
+
paymentsCatalogSyncedSchema,
|
|
166
|
+
paymentsCheckoutSessionCreatedSchema,
|
|
167
|
+
paymentsClientCreatedSchema,
|
|
168
|
+
paymentsPortalSessionCreatedSchema,
|
|
169
|
+
paymentsPriceSyncActionSchema,
|
|
170
|
+
paymentsPurchasesListedSchema,
|
|
171
|
+
paymentsSubscriptionAbsentSchema,
|
|
172
|
+
paymentsSubscriptionFoundSchema,
|
|
173
|
+
paymentsSyncedPriceSchema,
|
|
174
|
+
paymentsTablesMissingSchema,
|
|
175
|
+
paymentsTablesPresentSchema,
|
|
176
|
+
paymentsWebhookIgnoredReasonSchema,
|
|
177
|
+
paymentsWebhookIgnoredSchema,
|
|
178
|
+
paymentsWebhookOutcomeSchema,
|
|
179
|
+
paymentsWebhookProcessedSchema,
|
|
180
|
+
readPaymentsSubscriptionOptionsSchema,
|
|
181
|
+
readPaymentsSubscriptionResultSchema,
|
|
182
|
+
syncPaymentsCatalogOptionsSchema,
|
|
183
|
+
syncPaymentsCatalogResultSchema,
|
|
184
|
+
verifyPaymentsTablesExistOptionsSchema,
|
|
185
|
+
verifyPaymentsTablesExistResultSchema,
|
|
186
|
+
} from './payments-contract.ts'
|
|
187
|
+
|
|
188
|
+
/** Contract types: the branded vocabulary and the two scope words billing is keyed on. */
|
|
189
|
+
export type {
|
|
190
|
+
BillingContactEmail,
|
|
191
|
+
BillingReferenceId,
|
|
192
|
+
BillingReferenceOwnerId,
|
|
193
|
+
BillingScope,
|
|
194
|
+
HandledStripeWebhookEventType,
|
|
195
|
+
HearthkitPaymentsTableName,
|
|
196
|
+
PaymentsCurrencyCode,
|
|
197
|
+
PaymentsCustomerId,
|
|
198
|
+
PaymentsPriceName,
|
|
199
|
+
PaymentsProductName,
|
|
200
|
+
PaymentsPurchaseId,
|
|
201
|
+
PaymentsRecurringInterval,
|
|
202
|
+
PaymentsSubscriptionId,
|
|
203
|
+
StripeCheckoutSessionId,
|
|
204
|
+
StripeCustomerId,
|
|
205
|
+
StripeEventId,
|
|
206
|
+
StripePaymentIntentId,
|
|
207
|
+
StripePriceId,
|
|
208
|
+
StripeProductId,
|
|
209
|
+
StripeSecretKey,
|
|
210
|
+
StripeSubscriptionId,
|
|
211
|
+
StripeWebhookSecret,
|
|
212
|
+
} from './payments-contract.ts'
|
|
213
|
+
|
|
214
|
+
/** Contract types: the env values, the catalog, the three records this package reports, and the Drizzle table map. */
|
|
215
|
+
export type {
|
|
216
|
+
HearthkitPaymentsDrizzleSchema,
|
|
217
|
+
PaymentsCatalog,
|
|
218
|
+
PaymentsCatalogPrice,
|
|
219
|
+
PaymentsCatalogProduct,
|
|
220
|
+
PaymentsClient,
|
|
221
|
+
PaymentsCustomer,
|
|
222
|
+
PaymentsEnvValues,
|
|
223
|
+
PaymentsPurchase,
|
|
224
|
+
PaymentsSubscription,
|
|
225
|
+
PaymentsSyncedPrice,
|
|
226
|
+
} from './payments-contract.ts'
|
|
227
|
+
|
|
228
|
+
/** Contract types: the failure union and every discriminator carried on one. */
|
|
229
|
+
export type {
|
|
230
|
+
PaymentsCatalogIssue,
|
|
231
|
+
PaymentsCatalogIssueKind,
|
|
232
|
+
PaymentsFailure,
|
|
233
|
+
PaymentsInvalidFieldName,
|
|
234
|
+
PaymentsPriceSyncAction,
|
|
235
|
+
PaymentsWebhookIgnoredReason,
|
|
236
|
+
PaymentsWebhookOutcome,
|
|
237
|
+
PriceLookupFailure,
|
|
238
|
+
WebhookSignatureFailureReason,
|
|
239
|
+
} from './payments-contract.ts'
|
|
240
|
+
|
|
241
|
+
/** Contract types: the option, result and function shapes of every function above. */
|
|
242
|
+
export type {
|
|
243
|
+
CreateCheckoutSession,
|
|
244
|
+
CreateCheckoutSessionOptions,
|
|
245
|
+
CreateCheckoutSessionResult,
|
|
246
|
+
CreateCustomerPortalSession,
|
|
247
|
+
CreateCustomerPortalSessionOptions,
|
|
248
|
+
CreateCustomerPortalSessionResult,
|
|
249
|
+
CreatePaymentsClient,
|
|
250
|
+
CreatePaymentsClientOptions,
|
|
251
|
+
CreatePaymentsClientResult,
|
|
252
|
+
HandleStripeWebhook,
|
|
253
|
+
HandleStripeWebhookOptions,
|
|
254
|
+
HandleStripeWebhookResult,
|
|
255
|
+
ListPaymentsPurchases,
|
|
256
|
+
ListPaymentsPurchasesOptions,
|
|
257
|
+
ListPaymentsPurchasesResult,
|
|
258
|
+
ReadPaymentsSubscription,
|
|
259
|
+
ReadPaymentsSubscriptionOptions,
|
|
260
|
+
ReadPaymentsSubscriptionResult,
|
|
261
|
+
SyncPaymentsCatalog,
|
|
262
|
+
SyncPaymentsCatalogOptions,
|
|
263
|
+
SyncPaymentsCatalogResult,
|
|
264
|
+
VerifyPaymentsTablesExist,
|
|
265
|
+
VerifyPaymentsTablesExistOptions,
|
|
266
|
+
VerifyPaymentsTablesExistResult,
|
|
267
|
+
} from './payments-contract.ts'
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { afterAll, describe, expect, it } from 'vitest'
|
|
2
|
+
import { expectResultKind } from '../test-fixtures/payments-gate-expectations.ts'
|
|
3
|
+
import { defineGateFileContext } from '../test-fixtures/payments-gate-file-context.ts'
|
|
4
|
+
import {
|
|
5
|
+
createVerifiedGatePaymentsDatabase,
|
|
6
|
+
type GatePaymentsDatabase,
|
|
7
|
+
} from '../test-fixtures/payments-gate-postgres-database.ts'
|
|
8
|
+
import {
|
|
9
|
+
buildGateCheckoutSessionObject,
|
|
10
|
+
buildGateStripeWebhookDelivery,
|
|
11
|
+
gateBillingReferenceMetadata,
|
|
12
|
+
gateCheckoutPriceMetadata,
|
|
13
|
+
gateNowSecondsSinceEpoch,
|
|
14
|
+
uniqueGateStripeId,
|
|
15
|
+
} from '../test-fixtures/payments-gate-stripe-events.ts'
|
|
16
|
+
import {
|
|
17
|
+
gatePaymentsCatalog,
|
|
18
|
+
uniqueGateBillingContactEmail,
|
|
19
|
+
uniqueGateBillingReferenceId,
|
|
20
|
+
uniqueGatePaymentsCatalogNames,
|
|
21
|
+
} from '../test-fixtures/payments-gate-values.ts'
|
|
22
|
+
import {
|
|
23
|
+
createGatePaymentsClient,
|
|
24
|
+
loadHearthkitPaymentsEntry,
|
|
25
|
+
type HearthkitPaymentsEntry,
|
|
26
|
+
} from '../test-fixtures/hearthkit-payments-entry.ts'
|
|
27
|
+
import { listPaymentsPurchasesResultSchema, type PaymentsClient } from './payments-contract.ts'
|
|
28
|
+
|
|
29
|
+
const gateFile = defineGateFileContext<{
|
|
30
|
+
paymentsEntry: HearthkitPaymentsEntry
|
|
31
|
+
paymentsClient: PaymentsClient
|
|
32
|
+
gateDatabase: GatePaymentsDatabase
|
|
33
|
+
oneTimePriceName: string
|
|
34
|
+
}>(async () => {
|
|
35
|
+
const paymentsEntry = await loadHearthkitPaymentsEntry()
|
|
36
|
+
const gateDatabase = await createVerifiedGatePaymentsDatabase('listbuys', paymentsEntry)
|
|
37
|
+
const catalogNames = uniqueGatePaymentsCatalogNames('listbuys')
|
|
38
|
+
const paymentsClient = createGatePaymentsClient({
|
|
39
|
+
paymentsEntry,
|
|
40
|
+
drizzleClient: gateDatabase.drizzleClient,
|
|
41
|
+
paymentsCatalog: gatePaymentsCatalog(catalogNames),
|
|
42
|
+
})
|
|
43
|
+
return {
|
|
44
|
+
paymentsEntry,
|
|
45
|
+
paymentsClient,
|
|
46
|
+
gateDatabase,
|
|
47
|
+
oneTimePriceName: catalogNames.oneTimePriceName,
|
|
48
|
+
}
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
afterAll(async () => {
|
|
52
|
+
await gateFile.releaseIfCreated(({ gateDatabase }) => gateDatabase.removeGatePaymentsDatabase())
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
describe('listPaymentsPurchases', () => {
|
|
56
|
+
it('reports an empty list as a success for a reference that has bought nothing', async () => {
|
|
57
|
+
const { paymentsEntry, paymentsClient } = await gateFile.read()
|
|
58
|
+
|
|
59
|
+
// An empty array is a success rather than an -absent variant: "what has this customer bought"
|
|
60
|
+
// has a correct empty answer, whereas "which subscription is current" has no meaningful empty row.
|
|
61
|
+
const result = await paymentsEntry.listPaymentsPurchases({
|
|
62
|
+
paymentsClient,
|
|
63
|
+
billingReferenceId: uniqueGateBillingReferenceId('listbuys-none'),
|
|
64
|
+
})
|
|
65
|
+
listPaymentsPurchasesResultSchema.parse(result)
|
|
66
|
+
expect(expectResultKind(result, 'payments-purchases-listed').paymentsPurchases).toEqual([])
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('lists every completed purchase for a reference, newest first', async () => {
|
|
70
|
+
const { paymentsEntry, paymentsClient, oneTimePriceName } = await gateFile.read()
|
|
71
|
+
const billingReferenceId = uniqueGateBillingReferenceId('listbuys-two')
|
|
72
|
+
const nowSeconds = gateNowSecondsSinceEpoch()
|
|
73
|
+
|
|
74
|
+
async function deliverPurchase(
|
|
75
|
+
stripeCheckoutSessionId: string,
|
|
76
|
+
purchasedAtSeconds: number,
|
|
77
|
+
): Promise<void> {
|
|
78
|
+
const delivery = buildGateStripeWebhookDelivery(paymentsClient, {
|
|
79
|
+
stripeEventType: 'checkout.session.completed',
|
|
80
|
+
// purchasedAt comes from the EVENT's created, so the ordering this gate asserts is chosen
|
|
81
|
+
// here rather than raced for: no sleeping, and no dependence on write order.
|
|
82
|
+
createdSecondsSinceEpoch: purchasedAtSeconds,
|
|
83
|
+
eventDataObject: buildGateCheckoutSessionObject({
|
|
84
|
+
stripeCheckoutSessionId,
|
|
85
|
+
stripeCustomerId: uniqueGateStripeId('cus'),
|
|
86
|
+
checkoutMode: 'payment',
|
|
87
|
+
paymentStatus: 'paid',
|
|
88
|
+
billingContactEmail: uniqueGateBillingContactEmail('listbuys'),
|
|
89
|
+
stripePaymentIntentId: uniqueGateStripeId('pi'),
|
|
90
|
+
currency: 'usd',
|
|
91
|
+
amountTotalMinorUnits: 29_900,
|
|
92
|
+
metadata: {
|
|
93
|
+
...gateBillingReferenceMetadata(billingReferenceId),
|
|
94
|
+
...gateCheckoutPriceMetadata(oneTimePriceName, uniqueGateStripeId('price'), 1),
|
|
95
|
+
},
|
|
96
|
+
}),
|
|
97
|
+
})
|
|
98
|
+
const result = await paymentsEntry.handleStripeWebhook({
|
|
99
|
+
paymentsClient,
|
|
100
|
+
rawRequestBody: delivery.rawRequestBody,
|
|
101
|
+
requestHeaders: delivery.requestHeaders,
|
|
102
|
+
})
|
|
103
|
+
expect(expectResultKind(result, 'payments-webhook-processed').webhookOutcome).toBe(
|
|
104
|
+
'purchase-recorded',
|
|
105
|
+
)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const olderSessionId = uniqueGateStripeId('cs')
|
|
109
|
+
const newerSessionId = uniqueGateStripeId('cs')
|
|
110
|
+
// Delivered oldest last, so a list that came back in insertion order would be wrong in the same
|
|
111
|
+
// direction as one that came back unsorted.
|
|
112
|
+
await deliverPurchase(newerSessionId, nowSeconds)
|
|
113
|
+
await deliverPurchase(olderSessionId, nowSeconds - 3_600)
|
|
114
|
+
|
|
115
|
+
const result = await paymentsEntry.listPaymentsPurchases({
|
|
116
|
+
paymentsClient,
|
|
117
|
+
billingReferenceId,
|
|
118
|
+
})
|
|
119
|
+
listPaymentsPurchasesResultSchema.parse(result)
|
|
120
|
+
const listed = expectResultKind(result, 'payments-purchases-listed').paymentsPurchases
|
|
121
|
+
|
|
122
|
+
expect(listed.map((purchase) => String(purchase.stripeCheckoutSessionId))).toEqual([
|
|
123
|
+
newerSessionId,
|
|
124
|
+
olderSessionId,
|
|
125
|
+
])
|
|
126
|
+
// Only this reference's purchases: the other files' references are in other databases, but the
|
|
127
|
+
// empty-list gate above shares this one, so a query that ignored the reference would show here.
|
|
128
|
+
for (const purchase of listed) {
|
|
129
|
+
expect(String(purchase.billingReferenceId)).toBe(billingReferenceId)
|
|
130
|
+
}
|
|
131
|
+
})
|
|
132
|
+
})
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { desc, eq } from 'drizzle-orm'
|
|
2
|
+
import { paymentsPurchaseTable } from './hearthkit-payments-drizzle-schema.ts'
|
|
3
|
+
import {
|
|
4
|
+
billingReferenceIdSchema,
|
|
5
|
+
paymentsClientSchema,
|
|
6
|
+
paymentsPurchaseSchema,
|
|
7
|
+
type ListPaymentsPurchasesOptions,
|
|
8
|
+
type ListPaymentsPurchasesResult,
|
|
9
|
+
type PaymentsPurchase,
|
|
10
|
+
} from './payments-contract.ts'
|
|
11
|
+
import {
|
|
12
|
+
paymentsInputInvalidFailure,
|
|
13
|
+
paymentsRequestFailedFailure,
|
|
14
|
+
} from './payments-failure-results.ts'
|
|
15
|
+
import { thrownPaymentsErrorToFailure } from './thrown-payments-error-failure.ts'
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Every completed one-time purchase for a reference, newest first by purchasedAt with id descending
|
|
19
|
+
* as the tiebreak. An empty array is a success rather than an -absent variant: "what has this
|
|
20
|
+
* customer bought" has a correct empty answer, whereas "which subscription is current" has no
|
|
21
|
+
* meaningful empty row.
|
|
22
|
+
*/
|
|
23
|
+
export async function listPaymentsPurchases(
|
|
24
|
+
options: ListPaymentsPurchasesOptions,
|
|
25
|
+
): Promise<ListPaymentsPurchasesResult> {
|
|
26
|
+
if (!paymentsClientSchema.safeParse(options.paymentsClient).success) {
|
|
27
|
+
return paymentsInputInvalidFailure('drizzle-client')
|
|
28
|
+
}
|
|
29
|
+
const parsedReference = billingReferenceIdSchema.safeParse(options.billingReferenceId)
|
|
30
|
+
if (!parsedReference.success) {
|
|
31
|
+
return paymentsInputInvalidFailure('billing-reference-id')
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
let purchaseRows: (typeof paymentsPurchaseTable.$inferSelect)[]
|
|
35
|
+
try {
|
|
36
|
+
purchaseRows = await options.paymentsClient.drizzleClient
|
|
37
|
+
.select()
|
|
38
|
+
.from(paymentsPurchaseTable)
|
|
39
|
+
.where(eq(paymentsPurchaseTable.billingReferenceId, String(parsedReference.data)))
|
|
40
|
+
.orderBy(desc(paymentsPurchaseTable.purchasedAt), desc(paymentsPurchaseTable.id))
|
|
41
|
+
} catch (thrownValue) {
|
|
42
|
+
return thrownPaymentsErrorToFailure(thrownValue, options.paymentsClient)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const paymentsPurchases: PaymentsPurchase[] = []
|
|
46
|
+
for (const purchaseRow of purchaseRows) {
|
|
47
|
+
const parsedPurchase = paymentsPurchaseSchema.safeParse(purchaseRow)
|
|
48
|
+
if (!parsedPurchase.success) {
|
|
49
|
+
return paymentsRequestFailedFailure({
|
|
50
|
+
paymentsFailureDetail:
|
|
51
|
+
'a payments_purchase row does not match the shape this package writes, so the table was written by something else',
|
|
52
|
+
})
|
|
53
|
+
}
|
|
54
|
+
paymentsPurchases.push(parsedPurchase.data)
|
|
55
|
+
}
|
|
56
|
+
return { kind: 'payments-purchases-listed', paymentsPurchases }
|
|
57
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { PaymentsCatalog, PaymentsCatalogPrice } from './payments-contract.ts'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Resolving a price name against the catalog the client was built with. It is a local map read that
|
|
5
|
+
* touches no network at all, which is what makes the absent-from-catalog half of
|
|
6
|
+
* payments-price-not-found deterministic offline: nothing here asks Stripe to tell us a price is
|
|
7
|
+
* missing, because that answer would be an API error code this repo cannot measure without a key.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** The catalog price a name refers to, or nothing when the catalog has never carried that name. */
|
|
11
|
+
export function findPaymentsCatalogPrice(
|
|
12
|
+
paymentsCatalog: PaymentsCatalog,
|
|
13
|
+
priceName: string,
|
|
14
|
+
): PaymentsCatalogPrice | undefined {
|
|
15
|
+
for (const catalogProduct of paymentsCatalog.products) {
|
|
16
|
+
for (const catalogPrice of catalogProduct.prices) {
|
|
17
|
+
if (String(catalogPrice.priceName) === priceName) {
|
|
18
|
+
return catalogPrice
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
return undefined
|
|
23
|
+
}
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import { z } from 'zod'
|
|
2
|
+
import {
|
|
3
|
+
maximumPaymentsPriceNameLength,
|
|
4
|
+
maximumPaymentsProductNameLength,
|
|
5
|
+
paymentsCatalogPriceSchema,
|
|
6
|
+
paymentsCatalogProductSchema,
|
|
7
|
+
paymentsCatalogSchema,
|
|
8
|
+
type PaymentsCatalog,
|
|
9
|
+
type PaymentsCatalogIssue,
|
|
10
|
+
} from './payments-contract.ts'
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Validates the catalog an app wrote in payments-catalog.ts and names every problem in one pass, the
|
|
14
|
+
* way config reports every bad variable at once, because fixing a catalog one error per boot is
|
|
15
|
+
* miserable.
|
|
16
|
+
*
|
|
17
|
+
* Each reason states the rule that was broken and never the value that broke it, matching
|
|
18
|
+
* payments-input-invalid's discipline. A catalog is app source rather than user input, but a boot log
|
|
19
|
+
* is just as public and the rule is cheaper to keep than to reason about case by case.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
// The product schema with its "at least one price" rule lifted, so a product with an empty price list
|
|
23
|
+
// is reported once as product-has-no-prices rather than a second time as a schema rejection, while
|
|
24
|
+
// every other problem in that same entry is still found in this pass.
|
|
25
|
+
const paymentsCatalogProductEntrySchema = paymentsCatalogProductSchema.extend({
|
|
26
|
+
prices: z.array(paymentsCatalogPriceSchema),
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
// A hand-written rule per field, for the same reason auth writes authInvalidFieldReasons out by hand:
|
|
30
|
+
// a library's own message is free to quote the input it rejected, and one that did would put a
|
|
31
|
+
// catalog value into a returned failure.
|
|
32
|
+
const paymentsCatalogFieldRules: Record<string, string> = {
|
|
33
|
+
products: 'must be a list of catalog products',
|
|
34
|
+
productName: `must be lowercase kebab-case of at most ${String(maximumPaymentsProductNameLength)} characters, because it is also the Stripe product id`,
|
|
35
|
+
displayName: 'must be from 1 to 250 characters, because a buyer reads it on the checkout page',
|
|
36
|
+
description: 'must be from 1 to 1000 characters when it is present at all',
|
|
37
|
+
prices: 'must be a list of catalog prices',
|
|
38
|
+
priceName: `must be lowercase kebab-case of at most ${String(maximumPaymentsPriceNameLength)} characters, because it is also the Stripe price lookup key`,
|
|
39
|
+
currency: 'must be exactly three lowercase letters, which is how Stripe spells ISO 4217',
|
|
40
|
+
unitAmountMinorUnits: 'must be a positive integer of minor currency units',
|
|
41
|
+
priceKind: 'must be subscription or one-time',
|
|
42
|
+
recurringInterval: 'must be day, week, month or year, and only on a subscription price',
|
|
43
|
+
recurringIntervalCount: 'must be a positive integer when it is present at all',
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const unknownCatalogFieldRule = 'must match the shape paymentsCatalogSchema describes'
|
|
47
|
+
|
|
48
|
+
/** True for any non-null object, which is all a catalog entry needs to be before its properties are read one by one. */
|
|
49
|
+
function isCatalogEntryRecord(candidate: unknown): candidate is Record<string, unknown> {
|
|
50
|
+
return typeof candidate === 'object' && candidate !== null
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function readCatalogEntryName(candidate: unknown, propertyName: string): string | undefined {
|
|
54
|
+
if (!isCatalogEntryRecord(candidate)) {
|
|
55
|
+
return undefined
|
|
56
|
+
}
|
|
57
|
+
const value = candidate[propertyName]
|
|
58
|
+
return typeof value === 'string' && value.length > 0 ? value : undefined
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function readCatalogFieldRule(issuePath: readonly PropertyKey[]): string {
|
|
62
|
+
for (let index = issuePath.length - 1; index >= 0; index -= 1) {
|
|
63
|
+
const segment = issuePath[index]
|
|
64
|
+
if (typeof segment === 'string' && segment in paymentsCatalogFieldRules) {
|
|
65
|
+
return paymentsCatalogFieldRules[segment] ?? unknownCatalogFieldRule
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return unknownCatalogFieldRule
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function readCatalogProductEntries(candidate: unknown): unknown[] | undefined {
|
|
72
|
+
if (typeof candidate !== 'object' || candidate === null) {
|
|
73
|
+
return undefined
|
|
74
|
+
}
|
|
75
|
+
const { products } = candidate as { products?: unknown }
|
|
76
|
+
return Array.isArray(products) ? products : undefined
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function collectDuplicateNameIssues(
|
|
80
|
+
names: readonly string[],
|
|
81
|
+
catalogIssueKind: 'duplicate-product-name' | 'duplicate-price-name',
|
|
82
|
+
catalogIssueReason: string,
|
|
83
|
+
): PaymentsCatalogIssue[] {
|
|
84
|
+
const seenCounts = new Map<string, number>()
|
|
85
|
+
for (const entryName of names) {
|
|
86
|
+
seenCounts.set(entryName, (seenCounts.get(entryName) ?? 0) + 1)
|
|
87
|
+
}
|
|
88
|
+
return [...seenCounts.entries()]
|
|
89
|
+
.filter(([, count]) => count > 1)
|
|
90
|
+
.map(([entryName]) => ({ catalogIssueKind, catalogEntryName: entryName, catalogIssueReason }))
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** A catalog this package will hold, or every reason it will not; never a first failure and never the rejected value. */
|
|
94
|
+
export type PaymentsCatalogValidation =
|
|
95
|
+
| { kind: 'catalog-valid'; paymentsCatalog: PaymentsCatalog }
|
|
96
|
+
| { kind: 'catalog-invalid'; catalogIssues: PaymentsCatalogIssue[] }
|
|
97
|
+
|
|
98
|
+
/** Checks an app-supplied catalog once at boot, so a malformed one fails there rather than at a buyer's checkout. */
|
|
99
|
+
export function validatePaymentsCatalogEntries(candidate: unknown): PaymentsCatalogValidation {
|
|
100
|
+
const productEntries = readCatalogProductEntries(candidate)
|
|
101
|
+
if (productEntries === undefined || productEntries.length === 0) {
|
|
102
|
+
return {
|
|
103
|
+
kind: 'catalog-invalid',
|
|
104
|
+
catalogIssues: [
|
|
105
|
+
{
|
|
106
|
+
catalogIssueKind: 'catalog-has-no-products',
|
|
107
|
+
catalogEntryName: 'products',
|
|
108
|
+
catalogIssueReason:
|
|
109
|
+
'must carry at least one product, because an empty catalog sells nothing',
|
|
110
|
+
},
|
|
111
|
+
],
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const catalogIssues: PaymentsCatalogIssue[] = []
|
|
116
|
+
const productNames: string[] = []
|
|
117
|
+
const priceNames: string[] = []
|
|
118
|
+
|
|
119
|
+
for (const [entryIndex, productEntry] of productEntries.entries()) {
|
|
120
|
+
const productName = readCatalogEntryName(productEntry, 'productName')
|
|
121
|
+
const catalogEntryName = productName ?? `products[${String(entryIndex)}]`
|
|
122
|
+
if (productName !== undefined) {
|
|
123
|
+
productNames.push(productName)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const priceEntries = isCatalogEntryRecord(productEntry) ? productEntry.prices : undefined
|
|
127
|
+
if (Array.isArray(priceEntries)) {
|
|
128
|
+
if (priceEntries.length === 0) {
|
|
129
|
+
catalogIssues.push({
|
|
130
|
+
catalogIssueKind: 'product-has-no-prices',
|
|
131
|
+
catalogEntryName,
|
|
132
|
+
catalogIssueReason:
|
|
133
|
+
'must carry at least one price, because a product with none is nothing anyone can buy',
|
|
134
|
+
})
|
|
135
|
+
}
|
|
136
|
+
for (const priceEntry of priceEntries) {
|
|
137
|
+
const priceName = readCatalogEntryName(priceEntry, 'priceName')
|
|
138
|
+
if (priceName !== undefined) {
|
|
139
|
+
priceNames.push(priceName)
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const parsedEntry = paymentsCatalogProductEntrySchema.safeParse(productEntry)
|
|
145
|
+
if (!parsedEntry.success) {
|
|
146
|
+
for (const issue of parsedEntry.error.issues) {
|
|
147
|
+
catalogIssues.push({
|
|
148
|
+
catalogIssueKind: 'entry-invalid',
|
|
149
|
+
catalogEntryName: `${catalogEntryName}.${issue.path.map(String).join('.') || 'products'}`,
|
|
150
|
+
catalogIssueReason: readCatalogFieldRule(issue.path),
|
|
151
|
+
})
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
catalogIssues.push(
|
|
157
|
+
...collectDuplicateNameIssues(
|
|
158
|
+
productNames,
|
|
159
|
+
'duplicate-product-name',
|
|
160
|
+
'must name each product once, because the product name is also the Stripe product id',
|
|
161
|
+
),
|
|
162
|
+
)
|
|
163
|
+
// Across the whole catalog and not merely within one product: the price name is also the Stripe
|
|
164
|
+
// price lookup key, which is unique among active prices in an account, so a per-product check
|
|
165
|
+
// would pass a catalog Stripe will refuse.
|
|
166
|
+
catalogIssues.push(
|
|
167
|
+
...collectDuplicateNameIssues(
|
|
168
|
+
priceNames,
|
|
169
|
+
'duplicate-price-name',
|
|
170
|
+
'must name each price once across the whole catalog, because the price name is also the Stripe price lookup key',
|
|
171
|
+
),
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
if (catalogIssues.length > 0) {
|
|
175
|
+
return { kind: 'catalog-invalid', catalogIssues }
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const parsedCatalog = paymentsCatalogSchema.safeParse(candidate)
|
|
179
|
+
if (!parsedCatalog.success) {
|
|
180
|
+
return {
|
|
181
|
+
kind: 'catalog-invalid',
|
|
182
|
+
catalogIssues: parsedCatalog.error.issues.map((issue) => ({
|
|
183
|
+
catalogIssueKind: 'entry-invalid' as const,
|
|
184
|
+
catalogEntryName: issue.path.map(String).join('.') || 'products',
|
|
185
|
+
catalogIssueReason: readCatalogFieldRule(issue.path),
|
|
186
|
+
})),
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
return { kind: 'catalog-valid', paymentsCatalog: parsedCatalog.data }
|
|
190
|
+
}
|