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