@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,89 @@
1
+ import { eq } from 'drizzle-orm'
2
+ import type { NodePgDatabase } from 'drizzle-orm/node-postgres'
3
+ import { paymentsCustomerTable } from './hearthkit-payments-drizzle-schema.ts'
4
+ import { generatePaymentsRowId } from './payments-row-identifier.ts'
5
+ import type { BillingScope } from './payments-contract.ts'
6
+
7
+ /**
8
+ * Every read and write of payments_customer, in one place because the column billingContactEmail has
9
+ * two writers with different rules and keeping them apart is the whole point.
10
+ *
11
+ * createCheckoutSession sets the address from the value the app supplied, on insert and on update.
12
+ * The webhook path sets it only when it inserts a row: both Stripe fields it could read are
13
+ * influenced by the buyer on a page the app does not control, so letting a delivery overwrite the row
14
+ * would let a buyer silently redirect where receipts and dunning go.
15
+ */
16
+
17
+ /** The values either writer supplies for one customer row; the row id and timestamps are decided here. */
18
+ export type PaymentsCustomerRowValues = {
19
+ billingReferenceId: string
20
+ billingScope: BillingScope
21
+ stripeCustomerId: string
22
+ billingContactEmail: string
23
+ writtenAt: Date
24
+ }
25
+
26
+ /** One customer row as Postgres holds it, keyed by the Drizzle property names a caller reads. */
27
+ export type PaymentsCustomerRow = typeof paymentsCustomerTable.$inferSelect
28
+
29
+ /** The Stripe customer already on file for a reference, or nothing; an empty result is what makes customer-not-found deterministic offline. */
30
+ export async function readPaymentsCustomerRow(
31
+ drizzleClient: NodePgDatabase<Record<string, unknown>>,
32
+ billingReferenceId: string,
33
+ ): Promise<PaymentsCustomerRow | undefined> {
34
+ const rows = await drizzleClient
35
+ .select()
36
+ .from(paymentsCustomerTable)
37
+ .where(eq(paymentsCustomerTable.billingReferenceId, billingReferenceId))
38
+ .limit(1)
39
+ return rows[0]
40
+ }
41
+
42
+ function customerInsertValues(values: PaymentsCustomerRowValues) {
43
+ return {
44
+ id: generatePaymentsRowId('paycus'),
45
+ billingReferenceId: values.billingReferenceId,
46
+ billingScope: values.billingScope,
47
+ stripeCustomerId: values.stripeCustomerId,
48
+ billingContactEmail: values.billingContactEmail,
49
+ createdAt: values.writtenAt,
50
+ updatedAt: values.writtenAt,
51
+ }
52
+ }
53
+
54
+ /** Upsert from a checkout call, which may set the receipt address on an existing row because the app supplied it. */
55
+ export async function upsertPaymentsCustomerFromCheckout(
56
+ drizzleClient: NodePgDatabase<Record<string, unknown>>,
57
+ values: PaymentsCustomerRowValues,
58
+ ): Promise<void> {
59
+ await drizzleClient
60
+ .insert(paymentsCustomerTable)
61
+ .values(customerInsertValues(values))
62
+ .onConflictDoUpdate({
63
+ target: paymentsCustomerTable.billingReferenceId,
64
+ set: {
65
+ stripeCustomerId: values.stripeCustomerId,
66
+ billingContactEmail: values.billingContactEmail,
67
+ updatedAt: values.writtenAt,
68
+ },
69
+ })
70
+ }
71
+
72
+ // billingContactEmail is absent from the set clause on purpose, and that absence is the rule: on an
73
+ // existing row the address stays exactly as createCheckoutSession wrote it, from what the app said.
74
+ /** Upsert from a webhook delivery, which may seed the receipt address on a new row but never change one. */
75
+ export async function upsertPaymentsCustomerFromWebhook(
76
+ drizzleClient: NodePgDatabase<Record<string, unknown>>,
77
+ values: PaymentsCustomerRowValues,
78
+ ): Promise<void> {
79
+ await drizzleClient
80
+ .insert(paymentsCustomerTable)
81
+ .values(customerInsertValues(values))
82
+ .onConflictDoUpdate({
83
+ target: paymentsCustomerTable.billingReferenceId,
84
+ set: {
85
+ stripeCustomerId: values.stripeCustomerId,
86
+ updatedAt: values.writtenAt,
87
+ },
88
+ })
89
+ }
@@ -0,0 +1,152 @@
1
+ import type { PostgresConnectionString } from '@hearthkit/db'
2
+ import type { NodePgDatabase } from 'drizzle-orm/node-postgres'
3
+ import { afterAll, describe, expect, it } from 'vitest'
4
+ import { expectPaymentsFailure } from '../test-fixtures/payments-gate-expectations.ts'
5
+ import { defineGateFileContext } from '../test-fixtures/payments-gate-file-context.ts'
6
+ import {
7
+ absentGateDatabaseUrl,
8
+ createGateDrizzleClientForUrl,
9
+ createGatePaymentsDatabaseWithoutTables,
10
+ unreachableGateDatabaseUrl,
11
+ wrongPasswordGateDatabaseUrl,
12
+ type GatePaymentsDatabase,
13
+ } from '../test-fixtures/payments-gate-postgres-database.ts'
14
+ import {
15
+ gatePaymentsCatalog,
16
+ uniqueGateBillingReferenceId,
17
+ uniqueGatePaymentsCatalogNames,
18
+ } from '../test-fixtures/payments-gate-values.ts'
19
+ import {
20
+ createGatePaymentsClient,
21
+ loadHearthkitPaymentsEntry,
22
+ type HearthkitPaymentsEntry,
23
+ } from '../test-fixtures/hearthkit-payments-entry.ts'
24
+ import type { PaymentsClient } from './payments-contract.ts'
25
+
26
+ /**
27
+ * The four Postgres codes CONTRACT.md allowlists, reused verbatim from @hearthkit/auth, each produced
28
+ * the cheapest way it can be: two need only a changed connection string, one needs a closed port, one
29
+ * needs a real database with no tables. Every producer arrives as a DrizzleQueryError carrying no
30
+ * code of its own, with the real code exactly one .cause hop down and arriving from two unrelated
31
+ * error families — an AggregateError for the refused connection and pg's DatabaseError for the rest —
32
+ * so classifying any of these as payments-database-unavailable at all is what proves the
33
+ * implementation looked there.
34
+ */
35
+
36
+ const gateFile = defineGateFileContext<{
37
+ paymentsEntry: HearthkitPaymentsEntry
38
+ unmigratedDatabase: GatePaymentsDatabase
39
+ }>(async () => {
40
+ const paymentsEntry = await loadHearthkitPaymentsEntry()
41
+ const unmigratedDatabase = await createGatePaymentsDatabaseWithoutTables(
42
+ 'dbfail',
43
+ paymentsEntry.hearthkitPaymentsDrizzleSchema,
44
+ )
45
+ return { paymentsEntry, unmigratedDatabase }
46
+ })
47
+
48
+ afterAll(async () => {
49
+ await gateFile.releaseIfCreated(({ unmigratedDatabase }) =>
50
+ unmigratedDatabase.removeGatePaymentsDatabase(),
51
+ )
52
+ })
53
+
54
+ /** A lazy client over a connection string that is expected to fail, plus the close the pool needs. */
55
+ async function withDrizzleClientForUrl<TResult>(
56
+ databaseUrl: PostgresConnectionString,
57
+ run: (drizzleClient: NodePgDatabase<Record<string, unknown>>) => Promise<TResult>,
58
+ ): Promise<TResult> {
59
+ const { paymentsEntry } = await gateFile.read()
60
+ const { drizzleClient, closeDatabaseClient } = createGateDrizzleClientForUrl(
61
+ databaseUrl,
62
+ paymentsEntry.hearthkitPaymentsDrizzleSchema,
63
+ )
64
+ try {
65
+ return await run(drizzleClient)
66
+ } finally {
67
+ await closeDatabaseClient()
68
+ }
69
+ }
70
+
71
+ /** A payments client over a connection string that is expected to fail; building it opens no connection. */
72
+ async function withPaymentsClientForUrl<TResult>(
73
+ databaseUrl: PostgresConnectionString,
74
+ run: (paymentsClient: PaymentsClient) => Promise<TResult>,
75
+ ): Promise<TResult> {
76
+ const { paymentsEntry } = await gateFile.read()
77
+ return withDrizzleClientForUrl(databaseUrl, (drizzleClient) =>
78
+ // The failure appears on the first call that queries, which is where the gate wants it.
79
+ run(
80
+ createGatePaymentsClient({
81
+ paymentsEntry,
82
+ drizzleClient,
83
+ paymentsCatalog: gatePaymentsCatalog(uniqueGatePaymentsCatalogNames('dbfail')),
84
+ }),
85
+ ),
86
+ )
87
+ }
88
+
89
+ describe('payments-database-unavailable', () => {
90
+ it('reports it when the connection string names a database that does not exist, and when its password is wrong', async () => {
91
+ const { paymentsEntry } = await gateFile.read()
92
+
93
+ // Postgres 3D000, from the table check, which needs no payments client and no Stripe key at all.
94
+ // Nothing about the environment is changed but the database name in the URL.
95
+ const absentDatabase = await withDrizzleClientForUrl(absentGateDatabaseUrl, (drizzleClient) =>
96
+ paymentsEntry.verifyPaymentsTablesExist({ drizzleClient }),
97
+ )
98
+ expect(
99
+ expectPaymentsFailure(absentDatabase, 'payments-database-unavailable').databaseFailureDetail
100
+ .length,
101
+ ).toBeGreaterThan(0)
102
+
103
+ // Postgres 28P01, on the same server and the same database, one wrong password apart. Read from
104
+ // a different function on purpose: every database call maps this the same way.
105
+ const wrongPassword = await withPaymentsClientForUrl(
106
+ wrongPasswordGateDatabaseUrl,
107
+ (paymentsClient) =>
108
+ paymentsEntry.listPaymentsPurchases({
109
+ paymentsClient,
110
+ billingReferenceId: uniqueGateBillingReferenceId('dbfail-password'),
111
+ }),
112
+ )
113
+ expectPaymentsFailure(wrongPassword, 'payments-database-unavailable')
114
+ })
115
+
116
+ it('reports it when Postgres refuses the connection outright', async () => {
117
+ const { paymentsEntry } = await gateFile.read()
118
+
119
+ // ECONNREFUSED, which arrives on an AggregateError rather than the pg DatabaseError the other
120
+ // three produce, so one handler has to read two unrelated error families.
121
+ const result = await withPaymentsClientForUrl(unreachableGateDatabaseUrl, (paymentsClient) =>
122
+ paymentsEntry.readPaymentsSubscription({
123
+ paymentsClient,
124
+ billingReferenceId: uniqueGateBillingReferenceId('dbfail-refused'),
125
+ }),
126
+ )
127
+ expect(
128
+ expectPaymentsFailure(result, 'payments-database-unavailable').databaseFailureDetail.length,
129
+ ).toBeGreaterThan(0)
130
+ })
131
+
132
+ it('reports it when the database is reachable but the payments tables were never created, without quoting the query parameters back', async () => {
133
+ const { paymentsEntry, unmigratedDatabase } = await gateFile.read()
134
+ const billingReferenceId = uniqueGateBillingReferenceId('dbfail-notables')
135
+
136
+ // Postgres 42P01, the first-run state of every project before hearthkit db migrate. The same
137
+ // database answers verifyPaymentsTablesExist with payments-tables-missing, which is a result and
138
+ // not a failure: asking whether the tables are there is not the same call as trying to use them.
139
+ const result = await withPaymentsClientForUrl(
140
+ unmigratedDatabase.connectionString,
141
+ (paymentsClient) =>
142
+ paymentsEntry.readPaymentsSubscription({ paymentsClient, billingReferenceId }),
143
+ )
144
+ const failure = expectPaymentsFailure(result, 'payments-database-unavailable')
145
+ expect(failure.databaseFailureDetail.length).toBeGreaterThan(0)
146
+
147
+ // The detail is built from the .cause, never from the DrizzleQueryError wrapper: the wrapper's
148
+ // message repeats the failing SQL AND its bound parameters, which on this package's tables would
149
+ // put a customer's email address and Stripe ids into a failure a caller is likely to log.
150
+ expect(JSON.stringify(failure)).not.toContain(billingReferenceId)
151
+ })
152
+ })
@@ -0,0 +1,197 @@
1
+ import { loadHearthkitConfig } from '@hearthkit/config'
2
+ import { describe, expect, it } from 'vitest'
3
+ import { expectResultKind } from '../test-fixtures/payments-gate-expectations.ts'
4
+ import {
5
+ readPaymentsPackageManifest,
6
+ runPaymentsContractUnderBareNode,
7
+ } from '../test-fixtures/payments-package-entry-points.ts'
8
+ import {
9
+ importHearthkitPaymentsNamespace,
10
+ loadHearthkitPaymentsEntry,
11
+ } from '../test-fixtures/hearthkit-payments-entry.ts'
12
+ import * as paymentsContract from './payments-contract.ts'
13
+
14
+ const everyPaymentsVariableName = ['STRIPE_SECRET_KEY', 'STRIPE_WEBHOOK_SECRET'] as const
15
+
16
+ const completePaymentsEnv = {
17
+ STRIPE_SECRET_KEY: 'sk_test_51GateSecretKeyThatIsNotRealAndHasNoWhitespace',
18
+ STRIPE_WEBHOOK_SECRET: 'whsec_GateSigningSecretThatIsNotRealEither',
19
+ }
20
+
21
+ // CONTRACT.md's entry point rule is mechanical: src/index.ts re-exports every value
22
+ // src/payments-contract.ts exports, with no exceptions. So the required list is read off the contract
23
+ // module's own namespace rather than typed out here. A hand-written list would fall behind the day
24
+ // someone forgot to extend it; a derived list cannot.
25
+ //
26
+ // A module namespace carries value exports only. Every `export type` in payments-contract.ts is
27
+ // erased before this file runs, so the types CONTRACT.md also asks the entry point to re-export are
28
+ // outside what this gate can see and are covered by typecheck instead.
29
+ function contractValueExportNames(contractModule: Record<string, unknown>): string[] {
30
+ return Object.keys(contractModule).toSorted()
31
+ }
32
+
33
+ // Named in so many words by CONTRACT.md: the nine message prefixes, the env fragment, the failure
34
+ // union, the table name list, the two subscription status lists and the eight per-function result
35
+ // schemas. Spelled out as strings so renaming one in the contract fails this gate loudly, instead of
36
+ // quietly shrinking the derived list above to a set an entry point already satisfies.
37
+ const contractExportNamesTheContractNamesOutright = [
38
+ 'paymentsInputInvalidErrorPrefix',
39
+ 'paymentsCatalogInvalidErrorPrefix',
40
+ 'paymentsPriceNotFoundErrorPrefix',
41
+ 'paymentsCustomerNotFoundErrorPrefix',
42
+ 'paymentsWebhookSignatureInvalidErrorPrefix',
43
+ 'paymentsStripeUnauthorizedErrorPrefix',
44
+ 'paymentsStripeUnreachableErrorPrefix',
45
+ 'paymentsDatabaseUnavailableErrorPrefix',
46
+ 'paymentsRequestFailedErrorPrefix',
47
+ 'paymentsEnvSchemaFragment',
48
+ 'paymentsFailureSchema',
49
+ 'hearthkitPaymentsTableNames',
50
+ 'paymentsKnownSubscriptionStatuses',
51
+ 'paymentsActiveSubscriptionStatuses',
52
+ 'createPaymentsClientResultSchema',
53
+ 'syncPaymentsCatalogResultSchema',
54
+ 'createCheckoutSessionResultSchema',
55
+ 'createCustomerPortalSessionResultSchema',
56
+ 'handleStripeWebhookResultSchema',
57
+ 'readPaymentsSubscriptionResultSchema',
58
+ 'listPaymentsPurchasesResultSchema',
59
+ 'verifyPaymentsTablesExistResultSchema',
60
+ ] as const
61
+
62
+ // Not exported by payments-contract.ts, so the derived list cannot cover them: the eight public
63
+ // functions and the Drizzle table map.
64
+ const entryPointOnlyExportNames = [
65
+ 'createPaymentsClient',
66
+ 'syncPaymentsCatalog',
67
+ 'createCheckoutSession',
68
+ 'createCustomerPortalSession',
69
+ 'handleStripeWebhook',
70
+ 'readPaymentsSubscription',
71
+ 'listPaymentsPurchases',
72
+ 'verifyPaymentsTablesExist',
73
+ 'hearthkitPaymentsDrizzleSchema',
74
+ ] as const
75
+
76
+ describe('paymentsEnvSchemaFragment', () => {
77
+ it('declares two required variables, names both at load when neither is set, treats an empty value as unset, and rejects one carrying whitespace', async () => {
78
+ const { paymentsEnvSchemaFragment } = await loadHearthkitPaymentsEntry()
79
+ expect(Object.keys(paymentsEnvSchemaFragment.shape).toSorted()).toEqual([
80
+ ...everyPaymentsVariableName,
81
+ ])
82
+
83
+ const nothingSet = expectResultKind(
84
+ loadHearthkitConfig({ fragments: [paymentsEnvSchemaFragment], env: {} }),
85
+ 'config-validation-failed',
86
+ )
87
+ expect(nothingSet.message).toContain('STRIPE_SECRET_KEY')
88
+ expect(nothingSet.message).toContain('STRIPE_WEBHOOK_SECRET')
89
+ expect(nothingSet.issues).toHaveLength(2)
90
+
91
+ // An empty string is unset, per config's contract: a compose file carrying `STRIPE_SECRET_KEY=`
92
+ // is the accident that rule exists to catch, and both variables are required here.
93
+ const bothEmpty = expectResultKind(
94
+ loadHearthkitConfig({
95
+ fragments: [paymentsEnvSchemaFragment],
96
+ env: { STRIPE_SECRET_KEY: '', STRIPE_WEBHOOK_SECRET: '' },
97
+ }),
98
+ 'config-validation-failed',
99
+ )
100
+ expect(bothEmpty.issues).toHaveLength(2)
101
+
102
+ // Whitespace is the mistake upstream itself names: stripe@22.6.1 tests `/\s/.test(secret)` and
103
+ // warns that whitespace "often indicates an extra newline or space is in the value". A copied
104
+ // secret arrives with a trailing newline, which is why the rule is /^\S+$/ and not a prefix.
105
+ const trailingNewline = expectResultKind(
106
+ loadHearthkitConfig({
107
+ fragments: [paymentsEnvSchemaFragment],
108
+ env: {
109
+ STRIPE_SECRET_KEY: `${completePaymentsEnv.STRIPE_SECRET_KEY}\n`,
110
+ STRIPE_WEBHOOK_SECRET: completePaymentsEnv.STRIPE_WEBHOOK_SECRET,
111
+ },
112
+ }),
113
+ 'config-validation-failed',
114
+ )
115
+ expect(trailingNewline.message).toContain('STRIPE_SECRET_KEY')
116
+ expect(trailingNewline.issues).toHaveLength(1)
117
+
118
+ const innerSpace = expectResultKind(
119
+ loadHearthkitConfig({
120
+ fragments: [paymentsEnvSchemaFragment],
121
+ env: {
122
+ STRIPE_SECRET_KEY: completePaymentsEnv.STRIPE_SECRET_KEY,
123
+ STRIPE_WEBHOOK_SECRET: 'whsec_Gate Signing Secret',
124
+ },
125
+ }),
126
+ 'config-validation-failed',
127
+ )
128
+ expect(innerSpace.message).toContain('STRIPE_WEBHOOK_SECRET')
129
+ expect(innerSpace.issues).toHaveLength(1)
130
+
131
+ const loaded = expectResultKind(
132
+ loadHearthkitConfig({ fragments: [paymentsEnvSchemaFragment], env: completePaymentsEnv }),
133
+ 'config-loaded',
134
+ )
135
+ expect(String(loaded.config.STRIPE_SECRET_KEY)).toBe(completePaymentsEnv.STRIPE_SECRET_KEY)
136
+ expect(String(loaded.config.STRIPE_WEBHOOK_SECRET)).toBe(
137
+ completePaymentsEnv.STRIPE_WEBHOOK_SECRET,
138
+ )
139
+ })
140
+ })
141
+
142
+ describe('@hearthkit/payments entry point', () => {
143
+ it('re-exports by name every value payments-contract.ts exports, plus the eight functions and the Drizzle schema', async () => {
144
+ const namespace = await importHearthkitPaymentsNamespace()
145
+ const contractModule = paymentsContract as unknown as Record<string, unknown>
146
+ const requiredExportNames = contractValueExportNames(contractModule)
147
+
148
+ const renamedInTheContract = contractExportNamesTheContractNamesOutright.filter(
149
+ (exportName) => !requiredExportNames.includes(exportName),
150
+ )
151
+ expect(
152
+ renamedInTheContract,
153
+ 'payments-contract.ts must still export the constants and schemas CONTRACT.md names outright',
154
+ ).toEqual([])
155
+
156
+ // Both lists are compared whole rather than one name at a time, so a failure names every export
157
+ // that is wrong instead of stopping at the first and hiding the rest behind a rerun.
158
+ const missingFromTheEntryPoint = requiredExportNames.filter(
159
+ (exportName) => namespace[exportName] === undefined,
160
+ )
161
+ expect(
162
+ missingFromTheEntryPoint,
163
+ 'src/index.ts must re-export these by name from payments-contract.ts',
164
+ ).toEqual([])
165
+
166
+ const rebuiltInsteadOfReExported = requiredExportNames.filter(
167
+ (exportName) => namespace[exportName] !== contractModule[exportName],
168
+ )
169
+ expect(
170
+ rebuiltInsteadOfReExported,
171
+ 'these must be the identical value payments-contract.ts exports, not a second copy of it',
172
+ ).toEqual([])
173
+
174
+ const missingImplementationExports = entryPointOnlyExportNames.filter(
175
+ (exportName) => namespace[exportName] === undefined,
176
+ )
177
+ expect(
178
+ missingImplementationExports,
179
+ 'src/index.ts must re-export the public functions and the Drizzle schema by name',
180
+ ).toEqual([])
181
+ })
182
+
183
+ it('publishes ./payments-contract as a second subpath that a bare node process can load', async () => {
184
+ const manifest = await readPaymentsPackageManifest()
185
+ expect(manifest.packageName).toBe('@hearthkit/payments')
186
+ expect(Object.keys(manifest.exportsMap).toSorted()).toEqual(['.', './payments-contract'])
187
+ expect(JSON.stringify(manifest.exportsMap['./payments-contract'])).toContain(
188
+ './src/payments-contract.ts',
189
+ )
190
+
191
+ // The reason the subpath exists: the `.` entry imports the Stripe SDK, drizzle-orm/pg-core and
192
+ // the table definitions, while this file imports zod at runtime and nothing else — its stripe,
193
+ // drizzle-orm/node-postgres and @hearthkit/auth/auth-contract imports are type-only and erased.
194
+ const bareNodeRun = await runPaymentsContractUnderBareNode()
195
+ expect(bareNodeRun.exitCode, bareNodeRun.standardError).toBe(0)
196
+ })
197
+ })
@@ -0,0 +1,185 @@
1
+ import {
2
+ maximumPaymentsPriceNameLength,
3
+ paymentsCatalogInvalidErrorPrefix,
4
+ paymentsCustomerNotFoundErrorPrefix,
5
+ paymentsDatabaseUnavailableErrorPrefix,
6
+ paymentsInputInvalidErrorPrefix,
7
+ paymentsPriceNotFoundErrorPrefix,
8
+ paymentsRequestFailedErrorPrefix,
9
+ paymentsStripeUnauthorizedErrorPrefix,
10
+ paymentsStripeUnreachableErrorPrefix,
11
+ paymentsWebhookSignatureInvalidErrorPrefix,
12
+ type BillingReferenceId,
13
+ type PaymentsCatalogIssue,
14
+ type PaymentsFailure,
15
+ type PaymentsInvalidFieldName,
16
+ type PriceLookupFailure,
17
+ type WebhookSignatureFailureReason,
18
+ } from './payments-contract.ts'
19
+
20
+ /**
21
+ * The one place a failure value is built, so every message keeps its unique literal prefix and no
22
+ * variant is ever assembled twice with two different wordings.
23
+ */
24
+
25
+ /** One variant of the failure union, selected by its kind, so a producer states which failure it returns. */
26
+ export type PaymentsFailureOfKind<TKind extends PaymentsFailure['kind']> = Extract<
27
+ PaymentsFailure,
28
+ { kind: TKind }
29
+ >
30
+
31
+ // The rule that was broken, never the value that broke it. A billing reference, an address and a
32
+ // redirect URL are all things a caller may log, and a reason that quoted its input would put one of
33
+ // them back in front of whoever reads the failure.
34
+ /** Why each caller-supplied field was rejected, stated as the rule and never as the rejected value. */
35
+ export const paymentsInvalidFieldReasons: Record<PaymentsInvalidFieldName, string> = {
36
+ 'billing-reference-id': 'must be a non-empty identifier that Better Auth generated',
37
+ 'billing-contact-email': 'must be a single valid mailbox',
38
+ 'price-name': `must be lowercase kebab-case of at most ${String(maximumPaymentsPriceNameLength)} characters`,
39
+ quantity: 'must be a positive whole number of units',
40
+ 'success-url': 'must be an absolute http or https URL, because Stripe rejects a relative path',
41
+ 'cancel-url': 'must be an absolute http or https URL, because Stripe rejects a relative path',
42
+ 'return-url': 'must be an absolute http or https URL, because Stripe rejects a relative path',
43
+ 'raw-request-body':
44
+ 'must be the exact request text Stripe sent, so read request.text() and never request.json()',
45
+ 'request-headers': 'must be a Headers-like object carrying a get method',
46
+ 'stripe-api-base-url': 'must be an absolute http or https URL when it is supplied at all',
47
+ 'payments-env':
48
+ 'must carry STRIPE_SECRET_KEY and STRIPE_WEBHOOK_SECRET as non-empty values with no whitespace',
49
+ 'drizzle-client': 'must be the Drizzle client @hearthkit/db built',
50
+ }
51
+
52
+ /** Failure for a caller-supplied value rejected before any database or Stripe call is made. */
53
+ export function paymentsInputInvalidFailure(
54
+ invalidFieldName: PaymentsInvalidFieldName,
55
+ ): PaymentsFailureOfKind<'payments-input-invalid'> {
56
+ const invalidFieldReason = paymentsInvalidFieldReasons[invalidFieldName]
57
+ return {
58
+ kind: 'payments-input-invalid',
59
+ invalidFieldName,
60
+ invalidFieldReason,
61
+ message: `${paymentsInputInvalidErrorPrefix} ${invalidFieldName} ${invalidFieldReason}`,
62
+ }
63
+ }
64
+
65
+ /** Failure naming every problem in the app's catalog in one list, because fixing one error per boot is miserable. */
66
+ export function paymentsCatalogInvalidFailure(
67
+ catalogIssues: readonly PaymentsCatalogIssue[],
68
+ ): PaymentsFailureOfKind<'payments-catalog-invalid'> {
69
+ const perIssue = catalogIssues.map(
70
+ (issue) =>
71
+ `${issue.catalogIssueKind} at ${issue.catalogEntryName}: ${issue.catalogIssueReason}`,
72
+ )
73
+ return {
74
+ kind: 'payments-catalog-invalid',
75
+ catalogIssues: catalogIssues.map((issue) => ({ ...issue })),
76
+ message: `${paymentsCatalogInvalidErrorPrefix} ${perIssue.join('; ')}`,
77
+ }
78
+ }
79
+
80
+ // One variant with a discriminator rather than two kinds: the caller does the same thing with both,
81
+ // which is to tell the buyer this plan is unavailable, and only the operator's next step differs.
82
+ /** Failure when the named price is in neither the catalog nor Stripe; priceLookupFailure says which. */
83
+ export function paymentsPriceNotFoundFailure(
84
+ priceName: string,
85
+ priceLookupFailure: PriceLookupFailure,
86
+ ): PaymentsFailureOfKind<'payments-price-not-found'> {
87
+ const nextStep =
88
+ priceLookupFailure === 'absent-from-catalog'
89
+ ? 'no price in payments-catalog.ts carries that name'
90
+ : 'the catalog carries that name but no active Stripe price does, so run the catalog sync against this account'
91
+ return {
92
+ kind: 'payments-price-not-found',
93
+ priceName,
94
+ priceLookupFailure,
95
+ message: `${paymentsPriceNotFoundErrorPrefix} ${priceName} is ${priceLookupFailure}, ${nextStep}`,
96
+ }
97
+ }
98
+
99
+ /** Failure when no customer row exists for the reference; only the portal call produces it, because checkout creates the row. */
100
+ export function paymentsCustomerNotFoundFailure(
101
+ billingReferenceId: BillingReferenceId,
102
+ ): PaymentsFailureOfKind<'payments-customer-not-found'> {
103
+ return {
104
+ kind: 'payments-customer-not-found',
105
+ billingReferenceId,
106
+ message: `${paymentsCustomerNotFoundErrorPrefix} that billing reference has never completed a checkout, so there is no Stripe customer to open a portal for`,
107
+ }
108
+ }
109
+
110
+ // stripeFailureDetail is the SDK error's message and nothing else. StripeSignatureVerificationError
111
+ // also carries `.payload`, which is the raw webhook body and therefore whatever customer data the
112
+ // event held, so nothing here ever reads it.
113
+ /** Failure when a webhook request carried no signature header or one that does not verify. */
114
+ export function paymentsWebhookSignatureInvalidFailure(
115
+ signatureFailureReason: WebhookSignatureFailureReason,
116
+ stripeFailureDetail?: string,
117
+ ): PaymentsFailureOfKind<'payments-webhook-signature-invalid'> {
118
+ return {
119
+ kind: 'payments-webhook-signature-invalid',
120
+ signatureFailureReason,
121
+ ...(stripeFailureDetail === undefined || stripeFailureDetail.length === 0
122
+ ? {}
123
+ : { stripeFailureDetail }),
124
+ message: `${paymentsWebhookSignatureInvalidErrorPrefix} ${signatureFailureReason}, so this request did not come from Stripe`,
125
+ }
126
+ }
127
+
128
+ /** Failure when Stripe refused the API key with 401 or its permissions with 403; the status says which. */
129
+ export function paymentsStripeUnauthorizedFailure(
130
+ stripeErrorStatus: number,
131
+ stripeFailureDetail: string,
132
+ ): PaymentsFailureOfKind<'payments-stripe-unauthorized'> {
133
+ return {
134
+ kind: 'payments-stripe-unauthorized',
135
+ stripeErrorStatus,
136
+ stripeFailureDetail,
137
+ message: `${paymentsStripeUnauthorizedErrorPrefix} Stripe answered ${String(stripeErrorStatus)}: ${stripeFailureDetail}`,
138
+ }
139
+ }
140
+
141
+ /** Failure when the Stripe API could not be reached or the request timed out. */
142
+ export function paymentsStripeUnreachableFailure(
143
+ stripeFailureDetail: string,
144
+ ): PaymentsFailureOfKind<'payments-stripe-unreachable'> {
145
+ return {
146
+ kind: 'payments-stripe-unreachable',
147
+ stripeFailureDetail,
148
+ message: `${paymentsStripeUnreachableErrorPrefix} ${stripeFailureDetail}`,
149
+ }
150
+ }
151
+
152
+ /** Failure when Postgres refuses, the password or database is wrong, or the migrations never ran. */
153
+ export function paymentsDatabaseUnavailableFailure(
154
+ databaseFailureDetail: string,
155
+ ): PaymentsFailureOfKind<'payments-database-unavailable'> {
156
+ return {
157
+ kind: 'payments-database-unavailable',
158
+ databaseFailureDetail,
159
+ message: `${paymentsDatabaseUnavailableErrorPrefix} ${databaseFailureDetail}`,
160
+ }
161
+ }
162
+
163
+ // No discriminator, deliberately: several producers land here — a null checkoutUrl, a null
164
+ // session.currency, a null session.amount_total — and a caller cannot tell them apart. The detail is
165
+ // for a human reading a log; anything that genuinely needs a branch gets its own variant instead.
166
+ /** Catch-all failure carrying whatever Stripe supplied, so no call ever throws instead of returning. */
167
+ export function paymentsRequestFailedFailure(details: {
168
+ paymentsFailureDetail: string
169
+ stripeErrorCode?: string
170
+ stripeErrorStatus?: number
171
+ stripeErrorParam?: string
172
+ }): PaymentsFailureOfKind<'payments-request-failed'> {
173
+ return {
174
+ kind: 'payments-request-failed',
175
+ ...(details.stripeErrorCode === undefined ? {} : { stripeErrorCode: details.stripeErrorCode }),
176
+ ...(details.stripeErrorStatus === undefined
177
+ ? {}
178
+ : { stripeErrorStatus: details.stripeErrorStatus }),
179
+ ...(details.stripeErrorParam === undefined
180
+ ? {}
181
+ : { stripeErrorParam: details.stripeErrorParam }),
182
+ paymentsFailureDetail: details.paymentsFailureDetail,
183
+ message: `${paymentsRequestFailedErrorPrefix} ${details.paymentsFailureDetail}`,
184
+ }
185
+ }