@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,133 @@
1
+ import type Stripe from 'stripe'
2
+ import { redactPaymentsClientSecrets } from './payments-client-secrets.ts'
3
+ import {
4
+ paymentsClientSchema,
5
+ paymentsRequestHeadersSchema,
6
+ stripeEventIdSchema,
7
+ stripeSignatureHeaderName,
8
+ type HandleStripeWebhookOptions,
9
+ type HandleStripeWebhookResult,
10
+ type PaymentsClient,
11
+ } from './payments-contract.ts'
12
+ import {
13
+ paymentsInputInvalidFailure,
14
+ paymentsRequestFailedFailure,
15
+ paymentsWebhookSignatureInvalidFailure,
16
+ } from './payments-failure-results.ts'
17
+ import { recordStripeCheckoutSessionDelivery } from './stripe-checkout-session-event.ts'
18
+ import { recordStripeSubscriptionDelivery } from './stripe-subscription-event.ts'
19
+ import {
20
+ paymentsWebhookIgnoredResult,
21
+ type StripeWebhookDeliveryIdentity,
22
+ } from './stripe-webhook-delivery-results.ts'
23
+ import {
24
+ readThrownPaymentsErrorDetails,
25
+ stripeSignatureVerificationErrorTypeName,
26
+ } from './thrown-payments-error-details.ts'
27
+ import { thrownPaymentsErrorToFailure } from './thrown-payments-error-failure.ts'
28
+
29
+ /**
30
+ * Verifies a delivery and then records what Stripe reported. It contacts Stripe over the network
31
+ * never: verification is a local HMAC, and every handled event carries everything the write needs on
32
+ * the payload itself, so this whole surface runs with no Stripe key.
33
+ *
34
+ * rawRequestBody must be the exact bytes Stripe sent. In a Next.js route handler that means
35
+ * `await request.text()`, never `await request.json()`, and never a body some framework middleware
36
+ * has already parsed and re-serialised — re-serialising changes the bytes the HMAC covers and
37
+ * surfaces as a signature mismatch rather than as the body-handling mistake it is.
38
+ */
39
+
40
+ function verifyStripeWebhookDelivery(
41
+ paymentsClient: PaymentsClient,
42
+ rawRequestBody: string,
43
+ signatureHeader: string,
44
+ ): { kind: 'stripe-event-verified'; stripeEvent: Stripe.Event } | HandleStripeWebhookResult {
45
+ try {
46
+ return {
47
+ kind: 'stripe-event-verified',
48
+ stripeEvent: paymentsClient.stripeClient.webhooks.constructEvent(
49
+ rawRequestBody,
50
+ signatureHeader,
51
+ String(paymentsClient.stripeWebhookSecret),
52
+ ),
53
+ }
54
+ } catch (thrownValue) {
55
+ const details = readThrownPaymentsErrorDetails(thrownValue)
56
+ if (details.stripeErrorTypeName !== stripeSignatureVerificationErrorTypeName) {
57
+ return thrownPaymentsErrorToFailure(thrownValue, paymentsClient)
58
+ }
59
+ // The SDK error also carries `.payload`, which is the raw webhook body and therefore whatever
60
+ // customer data the event held. Only the message is ever quoted.
61
+ return paymentsWebhookSignatureInvalidFailure(
62
+ 'signature-verification-failed',
63
+ redactPaymentsClientSecrets(details.paymentsFailureDetail, paymentsClient),
64
+ )
65
+ }
66
+ }
67
+
68
+ async function routeStripeWebhookDelivery(
69
+ paymentsClient: PaymentsClient,
70
+ stripeEvent: Stripe.Event,
71
+ delivery: StripeWebhookDeliveryIdentity,
72
+ ): Promise<HandleStripeWebhookResult> {
73
+ switch (stripeEvent.type) {
74
+ case 'checkout.session.completed':
75
+ return recordStripeCheckoutSessionDelivery(paymentsClient, stripeEvent.data.object, delivery)
76
+ case 'customer.subscription.created':
77
+ case 'customer.subscription.updated':
78
+ case 'customer.subscription.deleted':
79
+ return recordStripeSubscriptionDelivery(paymentsClient, stripeEvent.data.object, delivery)
80
+ default:
81
+ // Stripe delivers every event type an endpoint is subscribed to and most of them are none of
82
+ // this package's business, so this is a result rather than an error.
83
+ return paymentsWebhookIgnoredResult(delivery, 'event-type-not-handled')
84
+ }
85
+ }
86
+
87
+ /** Verifies the stripe-signature header locally and records the delivery; it never calls Stripe and never throws. */
88
+ export async function handleStripeWebhook(
89
+ options: HandleStripeWebhookOptions,
90
+ ): Promise<HandleStripeWebhookResult> {
91
+ if (!paymentsClientSchema.safeParse(options.paymentsClient).success) {
92
+ return paymentsInputInvalidFailure('drizzle-client')
93
+ }
94
+ if (typeof options.rawRequestBody !== 'string' || options.rawRequestBody.length === 0) {
95
+ return paymentsInputInvalidFailure('raw-request-body')
96
+ }
97
+ if (!paymentsRequestHeadersSchema.safeParse(options.requestHeaders).success) {
98
+ return paymentsInputInvalidFailure('request-headers')
99
+ }
100
+
101
+ const signatureHeader = options.requestHeaders.get(stripeSignatureHeaderName)
102
+ if (signatureHeader === null || signatureHeader.length === 0) {
103
+ return paymentsWebhookSignatureInvalidFailure('signature-header-missing')
104
+ }
105
+
106
+ const verified = verifyStripeWebhookDelivery(
107
+ options.paymentsClient,
108
+ options.rawRequestBody,
109
+ signatureHeader,
110
+ )
111
+ if (verified.kind !== 'stripe-event-verified') {
112
+ return verified
113
+ }
114
+
115
+ const stripeEventId = stripeEventIdSchema.safeParse(verified.stripeEvent.id)
116
+ if (!stripeEventId.success) {
117
+ return paymentsRequestFailedFailure({
118
+ paymentsFailureDetail:
119
+ 'a verified Stripe delivery carried no event id, so nothing can be traced back to it',
120
+ })
121
+ }
122
+ const delivery: StripeWebhookDeliveryIdentity = {
123
+ stripeEventId: stripeEventId.data,
124
+ stripeEventType: verified.stripeEvent.type,
125
+ eventCreatedAt: new Date(verified.stripeEvent.created * 1000),
126
+ }
127
+
128
+ try {
129
+ return await routeStripeWebhookDelivery(options.paymentsClient, verified.stripeEvent, delivery)
130
+ } catch (thrownValue) {
131
+ return thrownPaymentsErrorToFailure(thrownValue, options.paymentsClient)
132
+ }
133
+ }
@@ -0,0 +1,214 @@
1
+ import { getTableConfig, type PgTable } from 'drizzle-orm/pg-core'
2
+ import { describe, expect, it } from 'vitest'
3
+ import { sortedGateNames } from '../test-fixtures/payments-gate-expectations.ts'
4
+ import { loadHearthkitPaymentsEntry } from '../test-fixtures/hearthkit-payments-entry.ts'
5
+ import { hearthkitPaymentsTableNames } from './payments-contract.ts'
6
+
7
+ /**
8
+ * The conformance gate for the three tables CONTRACT.md specifies, and it touches no database at all.
9
+ * Whether those tables were ever created is verifyPaymentsTablesExist's question, not this one: a
10
+ * schema that conforms perfectly and was never migrated passes here and fails there, which is the
11
+ * correct division.
12
+ *
13
+ * Assertions are on the Drizzle table object's PROPERTY names, not on SQL column names. The app runs
14
+ * drizzle-kit over this schema and CONTRACT.md leaves the SQL spelling unconstrained, so a package
15
+ * that names its column `billing_reference_id` underneath is conforming. What must match is the
16
+ * property a caller reads, plus each column's nullability and uniqueness, which are the constraints
17
+ * the contract does fix — the unique keys especially, because they are what makes a replayed webhook
18
+ * delivery an upsert rather than a duplicate row.
19
+ */
20
+
21
+ type GateExpectedColumn = {
22
+ propertyName: string
23
+ sqlTypeFamily: string
24
+ notNull: boolean
25
+ primary?: boolean
26
+ unique?: boolean
27
+ }
28
+
29
+ const timestampColumn = (propertyName: string, notNull: boolean): GateExpectedColumn => ({
30
+ propertyName,
31
+ sqlTypeFamily: 'timestamp',
32
+ notNull,
33
+ })
34
+
35
+ const expectedPaymentsColumns: Record<string, readonly GateExpectedColumn[]> = {
36
+ payments_customer: [
37
+ { propertyName: 'id', sqlTypeFamily: 'text', notNull: true, primary: true },
38
+ {
39
+ propertyName: 'billingReferenceId',
40
+ sqlTypeFamily: 'text',
41
+ notNull: true,
42
+ // One Stripe customer per reference. Without it, two concurrent checkouts for one user each
43
+ // create a Stripe customer and the second row silently orphans the first customer's money.
44
+ unique: true,
45
+ },
46
+ { propertyName: 'billingScope', sqlTypeFamily: 'text', notNull: true },
47
+ { propertyName: 'stripeCustomerId', sqlTypeFamily: 'text', notNull: true, unique: true },
48
+ { propertyName: 'billingContactEmail', sqlTypeFamily: 'text', notNull: true },
49
+ timestampColumn('createdAt', true),
50
+ timestampColumn('updatedAt', true),
51
+ ],
52
+ payments_subscription: [
53
+ { propertyName: 'id', sqlTypeFamily: 'text', notNull: true, primary: true },
54
+ { propertyName: 'billingReferenceId', sqlTypeFamily: 'text', notNull: true },
55
+ { propertyName: 'stripeCustomerId', sqlTypeFamily: 'text', notNull: true },
56
+ // The idempotency key of the whole subscription path: a replayed customer.subscription.* event
57
+ // writes the same values to the same row and the row count does not move.
58
+ { propertyName: 'stripeSubscriptionId', sqlTypeFamily: 'text', notNull: true, unique: true },
59
+ { propertyName: 'priceName', sqlTypeFamily: 'text', notNull: true },
60
+ { propertyName: 'stripePriceId', sqlTypeFamily: 'text', notNull: true },
61
+ { propertyName: 'status', sqlTypeFamily: 'text', notNull: true },
62
+ { propertyName: 'quantity', sqlTypeFamily: 'integer', notNull: true },
63
+ // Nullable because they come from the subscription ITEM, and a subscription can be reported
64
+ // before an item exists. stripe@22.6.1's Subscription has no current_period_* field at all.
65
+ timestampColumn('currentPeriodStart', false),
66
+ timestampColumn('currentPeriodEnd', false),
67
+ { propertyName: 'cancelAtPeriodEnd', sqlTypeFamily: 'boolean', notNull: true },
68
+ timestampColumn('canceledAt', false),
69
+ timestampColumn('endedAt', false),
70
+ timestampColumn('trialStart', false),
71
+ timestampColumn('trialEnd', false),
72
+ timestampColumn('createdAt', true),
73
+ timestampColumn('updatedAt', true),
74
+ ],
75
+ payments_purchase: [
76
+ { propertyName: 'id', sqlTypeFamily: 'text', notNull: true, primary: true },
77
+ { propertyName: 'billingReferenceId', sqlTypeFamily: 'text', notNull: true },
78
+ { propertyName: 'stripeCustomerId', sqlTypeFamily: 'text', notNull: true },
79
+ // The idempotency key of the purchase path, for the same reason.
80
+ {
81
+ propertyName: 'stripeCheckoutSessionId',
82
+ sqlTypeFamily: 'text',
83
+ notNull: true,
84
+ unique: true,
85
+ },
86
+ // Nullable: a fully discounted order has no payment intent at all.
87
+ { propertyName: 'stripePaymentIntentId', sqlTypeFamily: 'text', notNull: false },
88
+ { propertyName: 'priceName', sqlTypeFamily: 'text', notNull: true },
89
+ { propertyName: 'stripePriceId', sqlTypeFamily: 'text', notNull: true },
90
+ { propertyName: 'currency', sqlTypeFamily: 'text', notNull: true },
91
+ { propertyName: 'amountTotalMinorUnits', sqlTypeFamily: 'integer', notNull: true },
92
+ { propertyName: 'quantity', sqlTypeFamily: 'integer', notNull: true },
93
+ timestampColumn('purchasedAt', true),
94
+ timestampColumn('createdAt', true),
95
+ timestampColumn('updatedAt', true),
96
+ ],
97
+ }
98
+
99
+ type GateDrizzleColumn = {
100
+ name: string
101
+ notNull: boolean
102
+ primary: boolean
103
+ isUnique: boolean
104
+ getSQLType: () => string
105
+ }
106
+
107
+ /** Every SQL column name the table declares unique, whether on the column or as a table constraint. */
108
+ function uniqueSqlColumnNames(paymentsTable: unknown): Set<string> {
109
+ const tableConfig = getTableConfig(paymentsTable as PgTable)
110
+ const uniqueNames = new Set<string>()
111
+ for (const column of tableConfig.columns) {
112
+ if (column.isUnique) {
113
+ uniqueNames.add(column.name)
114
+ }
115
+ }
116
+ for (const uniqueConstraint of tableConfig.uniqueConstraints) {
117
+ // Destructured rather than indexed after a length check, because a length check narrows nothing
118
+ // for the type checker. The condition is the same one: exactly one column, so a composite unique
119
+ // constraint is still not counted — a two-column key would not make either column unique.
120
+ const [onlyColumn, ...furtherColumns] = uniqueConstraint.columns
121
+ if (onlyColumn !== undefined && furtherColumns.length === 0) {
122
+ uniqueNames.add(onlyColumn.name)
123
+ }
124
+ }
125
+ return uniqueNames
126
+ }
127
+
128
+ describe('hearthkitPaymentsDrizzleSchema', () => {
129
+ it('always defines all three tables under keys equal to their SQL table names, in both user-scoped and org-scoped mode', async () => {
130
+ const { hearthkitPaymentsDrizzleSchema } = await loadHearthkitPaymentsEntry()
131
+ const shippedSchema = hearthkitPaymentsDrizzleSchema as Record<string, unknown>
132
+
133
+ // All three exist in every project whatever organizationsEnabled is, because the flag decides
134
+ // the billingScope written on a customer row and never which tables exist; changing the tables
135
+ // with the flag later would be a data migration.
136
+ expect(sortedGateNames(Object.keys(shippedSchema))).toEqual(
137
+ sortedGateNames(hearthkitPaymentsTableNames),
138
+ )
139
+
140
+ // verifyPaymentsTablesExist looks these same names up in information_schema.tables, so the SQL
141
+ // table name has to equal the schema key even though the column names underneath are free.
142
+ const sqlTableNames = Object.values(shippedSchema).map(
143
+ (paymentsTable) => getTableConfig(paymentsTable as PgTable).name,
144
+ )
145
+ expect(sortedGateNames(sqlTableNames)).toEqual(sortedGateNames(hearthkitPaymentsTableNames))
146
+ })
147
+
148
+ it('carries every column CONTRACT.md lists, with the stated nullability and the three unique keys idempotency depends on', async () => {
149
+ const { hearthkitPaymentsDrizzleSchema } = await loadHearthkitPaymentsEntry()
150
+ const shippedSchema = hearthkitPaymentsDrizzleSchema as Record<
151
+ string,
152
+ Record<string, GateDrizzleColumn>
153
+ >
154
+
155
+ const missingColumns: string[] = []
156
+ const wrongTypeColumns: string[] = []
157
+ const wrongNullabilityColumns: string[] = []
158
+ const missingUniqueColumns: string[] = []
159
+ const missingPrimaryKeyColumns: string[] = []
160
+
161
+ for (const [paymentsTableName, expectedColumns] of Object.entries(expectedPaymentsColumns)) {
162
+ const shippedTable = shippedSchema[paymentsTableName]
163
+ if (typeof shippedTable !== 'object' || shippedTable === null) {
164
+ missingColumns.push(`${paymentsTableName} (whole table)`)
165
+ continue
166
+ }
167
+ const uniqueNames = uniqueSqlColumnNames(shippedTable)
168
+
169
+ for (const expectedColumn of expectedColumns) {
170
+ const column = shippedTable[expectedColumn.propertyName]
171
+ if (typeof column !== 'object' || column === null) {
172
+ missingColumns.push(`${paymentsTableName}.${expectedColumn.propertyName}`)
173
+ continue
174
+ }
175
+ if (!column.getSQLType().startsWith(expectedColumn.sqlTypeFamily)) {
176
+ wrongTypeColumns.push(
177
+ `${paymentsTableName}.${expectedColumn.propertyName} is ${column.getSQLType()}, expected ${expectedColumn.sqlTypeFamily}`,
178
+ )
179
+ }
180
+ // A primary key column is not-null by definition, and Drizzle reports notNull false on some
181
+ // primary key declarations, so nullability is only asserted where the contract has a choice.
182
+ if (expectedColumn.primary !== true && column.notNull !== expectedColumn.notNull) {
183
+ wrongNullabilityColumns.push(
184
+ `${paymentsTableName}.${expectedColumn.propertyName} notNull is ${column.notNull}, expected ${expectedColumn.notNull}`,
185
+ )
186
+ }
187
+ if (expectedColumn.primary === true && !column.primary) {
188
+ missingPrimaryKeyColumns.push(`${paymentsTableName}.${expectedColumn.propertyName}`)
189
+ }
190
+ if (expectedColumn.unique === true && !uniqueNames.has(column.name)) {
191
+ missingUniqueColumns.push(`${paymentsTableName}.${expectedColumn.propertyName}`)
192
+ }
193
+ }
194
+ }
195
+
196
+ expect(
197
+ missingColumns,
198
+ 'CONTRACT.md lists these columns and the shipped schema has none',
199
+ ).toEqual([])
200
+ expect(
201
+ wrongTypeColumns,
202
+ 'these columns are a different type family than CONTRACT.md states',
203
+ ).toEqual([])
204
+ expect(
205
+ wrongNullabilityColumns,
206
+ 'CONTRACT.md fixes each column as nullable or not, and these disagree',
207
+ ).toEqual([])
208
+ expect(missingPrimaryKeyColumns, 'each table needs its id as the primary key').toEqual([])
209
+ expect(
210
+ missingUniqueColumns,
211
+ 'idempotency here is structural rather than a bookkeeping table, so these unique keys are what stops a replayed delivery writing a second row',
212
+ ).toEqual([])
213
+ })
214
+ })
@@ -0,0 +1,80 @@
1
+ import { boolean, integer, pgTable, text, timestamp } from 'drizzle-orm/pg-core'
2
+ import type { HearthkitPaymentsDrizzleSchema } from './payments-contract.ts'
3
+
4
+ /**
5
+ * The three payments tables, defined once for every project. The organizations scaffold flag decides
6
+ * the `billingScope` written on a customer row and never which tables exist, for the same reason
7
+ * `@hearthkit/auth` defines its organization tables in both modes: changing the flag later re-homes
8
+ * existing rows, which is a data migration rather than a schema change.
9
+ *
10
+ * Every SQL table name equals the Drizzle schema key, because `verifyPaymentsTablesExist` looks these
11
+ * same strings up in `information_schema.tables`. The SQL column names underneath are Drizzle's
12
+ * business and are left equal to the property keys, which is the spelling a caller reads.
13
+ *
14
+ * There are deliberately no foreign keys to the auth tables. `billingReferenceId` points at `user` in
15
+ * user-scoped mode and at `organization` in org-scoped mode, so one foreign key cannot express it and
16
+ * adding one would couple this package's migrations to auth's table objects.
17
+ */
18
+
19
+ /** The join between a hearthkit billing reference and a Stripe customer; one row per reference, and nothing else. */
20
+ export const paymentsCustomerTable = pgTable('payments_customer', {
21
+ id: text().primaryKey(),
22
+ billingReferenceId: text().notNull().unique(),
23
+ billingScope: text().notNull(),
24
+ stripeCustomerId: text().notNull().unique(),
25
+ billingContactEmail: text().notNull(),
26
+ createdAt: timestamp().notNull(),
27
+ updatedAt: timestamp().notNull(),
28
+ })
29
+
30
+ // stripeSubscriptionId is unique because idempotency here is structural rather than a bookkeeping
31
+ // table: a replayed customer.subscription.* delivery writes the same values to the same row and the
32
+ // row count does not move. currentPeriodStart and currentPeriodEnd are nullable because they come
33
+ // from the subscription ITEM — stripe@22.6.1's Subscription object carries neither — and a
34
+ // subscription can be reported before an item that names a catalog price exists.
35
+ /** What Stripe last said about one subscription; `status` is a plain text column so a status Stripe adds later cannot break a write. */
36
+ export const paymentsSubscriptionTable = pgTable('payments_subscription', {
37
+ id: text().primaryKey(),
38
+ billingReferenceId: text().notNull(),
39
+ stripeCustomerId: text().notNull(),
40
+ stripeSubscriptionId: text().notNull().unique(),
41
+ priceName: text().notNull(),
42
+ stripePriceId: text().notNull(),
43
+ status: text().notNull(),
44
+ quantity: integer().notNull(),
45
+ currentPeriodStart: timestamp(),
46
+ currentPeriodEnd: timestamp(),
47
+ cancelAtPeriodEnd: boolean().notNull().default(false),
48
+ canceledAt: timestamp(),
49
+ endedAt: timestamp(),
50
+ trialStart: timestamp(),
51
+ trialEnd: timestamp(),
52
+ createdAt: timestamp().notNull(),
53
+ updatedAt: timestamp().notNull(),
54
+ })
55
+
56
+ // stripeCheckoutSessionId is the idempotency key of the purchase path, for the same reason.
57
+ // stripePaymentIntentId is nullable because a fully discounted order has no payment intent at all.
58
+ /** One completed one-time checkout, recorded from the session's own fields and the metadata this package wrote. */
59
+ export const paymentsPurchaseTable = pgTable('payments_purchase', {
60
+ id: text().primaryKey(),
61
+ billingReferenceId: text().notNull(),
62
+ stripeCustomerId: text().notNull(),
63
+ stripeCheckoutSessionId: text().notNull().unique(),
64
+ stripePaymentIntentId: text(),
65
+ priceName: text().notNull(),
66
+ stripePriceId: text().notNull(),
67
+ currency: text().notNull(),
68
+ amountTotalMinorUnits: integer().notNull(),
69
+ quantity: integer().notNull(),
70
+ purchasedAt: timestamp().notNull(),
71
+ createdAt: timestamp().notNull(),
72
+ updatedAt: timestamp().notNull(),
73
+ })
74
+
75
+ /** The Drizzle table map this package ships; spread it into the app's schema so one drizzle-kit run covers every table. */
76
+ export const hearthkitPaymentsDrizzleSchema = {
77
+ payments_customer: paymentsCustomerTable,
78
+ payments_subscription: paymentsSubscriptionTable,
79
+ payments_purchase: paymentsPurchaseTable,
80
+ } satisfies HearthkitPaymentsDrizzleSchema