@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,160 @@
|
|
|
1
|
+
import { afterAll, describe, expect, it } from 'vitest'
|
|
2
|
+
import {
|
|
3
|
+
expectPaymentsFailure,
|
|
4
|
+
expectResultKind,
|
|
5
|
+
} from '../test-fixtures/payments-gate-expectations.ts'
|
|
6
|
+
import { defineGateFileContext } from '../test-fixtures/payments-gate-file-context.ts'
|
|
7
|
+
import {
|
|
8
|
+
createVerifiedGatePaymentsDatabase,
|
|
9
|
+
type GatePaymentsDatabase,
|
|
10
|
+
} from '../test-fixtures/payments-gate-postgres-database.ts'
|
|
11
|
+
import {
|
|
12
|
+
archiveGateStripeCatalog,
|
|
13
|
+
assertGateStripeIsTestMode,
|
|
14
|
+
deleteGateStripeCustomer,
|
|
15
|
+
} from '../test-fixtures/payments-gate-stripe-account.ts'
|
|
16
|
+
import {
|
|
17
|
+
deadStripeApiBaseUrl,
|
|
18
|
+
gateCancelUrl,
|
|
19
|
+
gatePaymentsCatalog,
|
|
20
|
+
gateReturnUrl,
|
|
21
|
+
gateStripeSecretKey,
|
|
22
|
+
gateSuccessUrl,
|
|
23
|
+
hasGateStripeSecretKey,
|
|
24
|
+
reserveDeadLoopbackPort,
|
|
25
|
+
uniqueGateBillingContactEmail,
|
|
26
|
+
uniqueGateBillingReferenceId,
|
|
27
|
+
uniqueGatePaymentsCatalogNames,
|
|
28
|
+
} from '../test-fixtures/payments-gate-values.ts'
|
|
29
|
+
import {
|
|
30
|
+
createGatePaymentsClient,
|
|
31
|
+
loadHearthkitPaymentsEntry,
|
|
32
|
+
type HearthkitPaymentsEntry,
|
|
33
|
+
} from '../test-fixtures/hearthkit-payments-entry.ts'
|
|
34
|
+
import {
|
|
35
|
+
createCustomerPortalSessionResultSchema,
|
|
36
|
+
type PaymentsClient,
|
|
37
|
+
type PaymentsSyncedPrice,
|
|
38
|
+
} from './payments-contract.ts'
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* createCustomerPortalSession never creates anything — opening a billing portal for a person who has
|
|
42
|
+
* never paid is not a thing to do quietly — so it is the only producer of
|
|
43
|
+
* payments-customer-not-found. That half needs no Stripe key. Opening a real portal does.
|
|
44
|
+
*/
|
|
45
|
+
|
|
46
|
+
const createdStripePrices: PaymentsSyncedPrice[] = []
|
|
47
|
+
const createdStripeCustomerIds: string[] = []
|
|
48
|
+
|
|
49
|
+
const gateFile = defineGateFileContext<{
|
|
50
|
+
paymentsEntry: HearthkitPaymentsEntry
|
|
51
|
+
gateDatabase: GatePaymentsDatabase
|
|
52
|
+
offlineClient: PaymentsClient
|
|
53
|
+
liveClient: PaymentsClient | undefined
|
|
54
|
+
subscriptionPriceName: string
|
|
55
|
+
}>(async () => {
|
|
56
|
+
const paymentsEntry = await loadHearthkitPaymentsEntry()
|
|
57
|
+
const gateDatabase = await createVerifiedGatePaymentsDatabase('portal', paymentsEntry)
|
|
58
|
+
const catalogNames = uniqueGatePaymentsCatalogNames('portal')
|
|
59
|
+
const paymentsCatalog = gatePaymentsCatalog(catalogNames)
|
|
60
|
+
const offlineClient = createGatePaymentsClient({
|
|
61
|
+
paymentsEntry,
|
|
62
|
+
drizzleClient: gateDatabase.drizzleClient,
|
|
63
|
+
paymentsCatalog,
|
|
64
|
+
stripeApiBaseUrl: deadStripeApiBaseUrl(await reserveDeadLoopbackPort()),
|
|
65
|
+
})
|
|
66
|
+
const liveClient = hasGateStripeSecretKey
|
|
67
|
+
? createGatePaymentsClient({
|
|
68
|
+
paymentsEntry,
|
|
69
|
+
drizzleClient: gateDatabase.drizzleClient,
|
|
70
|
+
paymentsCatalog,
|
|
71
|
+
stripeSecretKey: gateStripeSecretKey,
|
|
72
|
+
})
|
|
73
|
+
: undefined
|
|
74
|
+
return {
|
|
75
|
+
paymentsEntry,
|
|
76
|
+
gateDatabase,
|
|
77
|
+
offlineClient,
|
|
78
|
+
liveClient,
|
|
79
|
+
subscriptionPriceName: catalogNames.subscriptionPriceName,
|
|
80
|
+
}
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
afterAll(async () => {
|
|
84
|
+
await gateFile.releaseIfCreated(async ({ gateDatabase, liveClient }) => {
|
|
85
|
+
if (liveClient !== undefined) {
|
|
86
|
+
for (const stripeCustomerId of createdStripeCustomerIds) {
|
|
87
|
+
await deleteGateStripeCustomer(liveClient, stripeCustomerId)
|
|
88
|
+
}
|
|
89
|
+
await archiveGateStripeCatalog(liveClient, createdStripePrices)
|
|
90
|
+
}
|
|
91
|
+
await gateDatabase.removeGatePaymentsDatabase()
|
|
92
|
+
})
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
describe('createCustomerPortalSession', () => {
|
|
96
|
+
it('reports payments-customer-not-found for a reference with no customer row, without contacting Stripe', async () => {
|
|
97
|
+
const { paymentsEntry, offlineClient } = await gateFile.read()
|
|
98
|
+
const billingReferenceId = uniqueGateBillingReferenceId('portal-none')
|
|
99
|
+
|
|
100
|
+
// The client's Stripe API base URL is a closed local port, so a call that asked Stripe about the
|
|
101
|
+
// customer would answer payments-stripe-unreachable. This failure is an empty query result
|
|
102
|
+
// instead: no API-level error code is consulted, which is what makes it deterministic offline.
|
|
103
|
+
const result = await paymentsEntry.createCustomerPortalSession({
|
|
104
|
+
paymentsClient: offlineClient,
|
|
105
|
+
billingReferenceId,
|
|
106
|
+
returnUrl: gateReturnUrl,
|
|
107
|
+
})
|
|
108
|
+
createCustomerPortalSessionResultSchema.parse(result)
|
|
109
|
+
const failure = expectPaymentsFailure(result, 'payments-customer-not-found')
|
|
110
|
+
expect(String(failure.billingReferenceId)).toBe(billingReferenceId)
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
it.skipIf(!hasGateStripeSecretKey)(
|
|
114
|
+
'opens the hosted billing portal for a customer a checkout put on file',
|
|
115
|
+
async () => {
|
|
116
|
+
const { paymentsEntry, liveClient, subscriptionPriceName } = await gateFile.read()
|
|
117
|
+
expect(liveClient, 'this gate runs only with STRIPE_SECRET_KEY set').toBeDefined()
|
|
118
|
+
if (liveClient === undefined) {
|
|
119
|
+
return
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const synced = expectResultKind(
|
|
123
|
+
await paymentsEntry.syncPaymentsCatalog({ paymentsClient: liveClient }),
|
|
124
|
+
'payments-catalog-synced',
|
|
125
|
+
)
|
|
126
|
+
createdStripePrices.push(...synced.syncedPrices)
|
|
127
|
+
assertGateStripeIsTestMode(synced.stripeLivemode)
|
|
128
|
+
|
|
129
|
+
const billingReferenceId = uniqueGateBillingReferenceId('portal-live')
|
|
130
|
+
const checkout = expectResultKind(
|
|
131
|
+
await paymentsEntry.createCheckoutSession({
|
|
132
|
+
paymentsClient: liveClient,
|
|
133
|
+
billingReferenceId,
|
|
134
|
+
billingContactEmail: uniqueGateBillingContactEmail('portal-live'),
|
|
135
|
+
priceName: subscriptionPriceName,
|
|
136
|
+
successUrl: gateSuccessUrl,
|
|
137
|
+
cancelUrl: gateCancelUrl,
|
|
138
|
+
}),
|
|
139
|
+
'payments-checkout-session-created',
|
|
140
|
+
)
|
|
141
|
+
createdStripeCustomerIds.push(String(checkout.stripeCustomerId))
|
|
142
|
+
|
|
143
|
+
// If this fails with a Stripe error about a portal configuration, the test-mode account needs
|
|
144
|
+
// its billing portal settings saved once in the Stripe dashboard. CONTRACT.md flags that under
|
|
145
|
+
// Still not verified precisely so the failure does not read as a bug in this package.
|
|
146
|
+
const result = await paymentsEntry.createCustomerPortalSession({
|
|
147
|
+
paymentsClient: liveClient,
|
|
148
|
+
billingReferenceId,
|
|
149
|
+
returnUrl: gateReturnUrl,
|
|
150
|
+
})
|
|
151
|
+
createCustomerPortalSessionResultSchema.parse(result)
|
|
152
|
+
const opened = expectResultKind(result, 'payments-portal-session-created')
|
|
153
|
+
|
|
154
|
+
// BillingPortal.Session.url is typed non-nullable, so unlike checkoutUrl there is no null case.
|
|
155
|
+
expect(opened.portalUrl.startsWith('https://')).toBe(true)
|
|
156
|
+
// The customer already on file, not a second one: this call never creates anything.
|
|
157
|
+
expect(String(opened.stripeCustomerId)).toBe(String(checkout.stripeCustomerId))
|
|
158
|
+
},
|
|
159
|
+
)
|
|
160
|
+
})
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import {
|
|
2
|
+
billingReferenceIdSchema,
|
|
3
|
+
paymentsClientSchema,
|
|
4
|
+
paymentsRedirectUrlSchema,
|
|
5
|
+
stripeCustomerIdSchema,
|
|
6
|
+
type CreateCustomerPortalSessionOptions,
|
|
7
|
+
type CreateCustomerPortalSessionResult,
|
|
8
|
+
} from './payments-contract.ts'
|
|
9
|
+
import { readPaymentsCustomerRow } from './payments-customer-record.ts'
|
|
10
|
+
import {
|
|
11
|
+
paymentsCustomerNotFoundFailure,
|
|
12
|
+
paymentsInputInvalidFailure,
|
|
13
|
+
} from './payments-failure-results.ts'
|
|
14
|
+
import { thrownPaymentsErrorToFailure } from './thrown-payments-error-failure.ts'
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Opens Stripe's hosted billing portal for the customer already on file. It never creates one —
|
|
18
|
+
* opening a billing portal for a person who has never paid is not a thing to do quietly — which is
|
|
19
|
+
* why this is the only producer of payments-customer-not-found in the whole package.
|
|
20
|
+
*
|
|
21
|
+
* That failure is an empty query result rather than an API-level error code, so it is deterministic
|
|
22
|
+
* and needs no network at all: nothing here asks Stripe to tell us the customer is missing.
|
|
23
|
+
*
|
|
24
|
+
* If a live call fails with a Stripe error about a portal configuration, the account needs its
|
|
25
|
+
* test-mode billing portal settings saved once in the Stripe dashboard. That is a one-off human step
|
|
26
|
+
* rather than a bug in this package.
|
|
27
|
+
*/
|
|
28
|
+
export async function createCustomerPortalSession(
|
|
29
|
+
options: CreateCustomerPortalSessionOptions,
|
|
30
|
+
): Promise<CreateCustomerPortalSessionResult> {
|
|
31
|
+
if (!paymentsClientSchema.safeParse(options.paymentsClient).success) {
|
|
32
|
+
return paymentsInputInvalidFailure('drizzle-client')
|
|
33
|
+
}
|
|
34
|
+
const parsedReference = billingReferenceIdSchema.safeParse(options.billingReferenceId)
|
|
35
|
+
if (!parsedReference.success) {
|
|
36
|
+
return paymentsInputInvalidFailure('billing-reference-id')
|
|
37
|
+
}
|
|
38
|
+
if (!paymentsRedirectUrlSchema.safeParse(options.returnUrl).success) {
|
|
39
|
+
return paymentsInputInvalidFailure('return-url')
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
try {
|
|
43
|
+
const customerRow = await readPaymentsCustomerRow(
|
|
44
|
+
options.paymentsClient.drizzleClient,
|
|
45
|
+
String(parsedReference.data),
|
|
46
|
+
)
|
|
47
|
+
if (customerRow === undefined) {
|
|
48
|
+
return paymentsCustomerNotFoundFailure(parsedReference.data)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// BillingPortal.Session.url is typed non-nullable, so unlike a checkout session's url there is no
|
|
52
|
+
// null case to rule out here.
|
|
53
|
+
const portalSession = await options.paymentsClient.stripeClient.billingPortal.sessions.create({
|
|
54
|
+
customer: customerRow.stripeCustomerId,
|
|
55
|
+
return_url: options.returnUrl,
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
return {
|
|
59
|
+
kind: 'payments-portal-session-created',
|
|
60
|
+
portalUrl: portalSession.url,
|
|
61
|
+
stripeCustomerId: stripeCustomerIdSchema.parse(customerRow.stripeCustomerId),
|
|
62
|
+
}
|
|
63
|
+
} catch (thrownValue) {
|
|
64
|
+
return thrownPaymentsErrorToFailure(thrownValue, options.paymentsClient)
|
|
65
|
+
}
|
|
66
|
+
}
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import type { NodePgDatabase } from 'drizzle-orm/node-postgres'
|
|
2
|
+
import { afterAll, describe, expect, it } from 'vitest'
|
|
3
|
+
import {
|
|
4
|
+
expectPaymentsFailure,
|
|
5
|
+
expectResultKind,
|
|
6
|
+
sortedGateNames,
|
|
7
|
+
} from '../test-fixtures/payments-gate-expectations.ts'
|
|
8
|
+
import { defineGateFileContext } from '../test-fixtures/payments-gate-file-context.ts'
|
|
9
|
+
import {
|
|
10
|
+
createGateDrizzleClientForUrl,
|
|
11
|
+
unreachableGateDatabaseUrl,
|
|
12
|
+
} from '../test-fixtures/payments-gate-postgres-database.ts'
|
|
13
|
+
import {
|
|
14
|
+
gatePaymentsCatalog,
|
|
15
|
+
gatePaymentsEnv,
|
|
16
|
+
uniqueGatePaymentsCatalogNames,
|
|
17
|
+
} from '../test-fixtures/payments-gate-values.ts'
|
|
18
|
+
import {
|
|
19
|
+
loadHearthkitPaymentsEntry,
|
|
20
|
+
type HearthkitPaymentsEntry,
|
|
21
|
+
} from '../test-fixtures/hearthkit-payments-entry.ts'
|
|
22
|
+
import {
|
|
23
|
+
createPaymentsClientResultSchema,
|
|
24
|
+
type PaymentsCatalog,
|
|
25
|
+
type PaymentsEnvValues,
|
|
26
|
+
} from './payments-contract.ts'
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* createPaymentsClient contacts nothing: building a Stripe instance does no I/O and the Drizzle
|
|
30
|
+
* client is lazy. So this whole file runs with no service anywhere, and the Drizzle client it uses
|
|
31
|
+
* points at a closed port on purpose — if any gate here reached the database it would fail with
|
|
32
|
+
* payments-database-unavailable rather than the answer it asserts.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
const gateFile = defineGateFileContext<{
|
|
36
|
+
paymentsEntry: HearthkitPaymentsEntry
|
|
37
|
+
drizzleClient: NodePgDatabase<Record<string, unknown>>
|
|
38
|
+
closeDatabaseClient: () => Promise<void>
|
|
39
|
+
}>(async () => {
|
|
40
|
+
const paymentsEntry = await loadHearthkitPaymentsEntry()
|
|
41
|
+
const { drizzleClient, closeDatabaseClient } = createGateDrizzleClientForUrl(
|
|
42
|
+
unreachableGateDatabaseUrl,
|
|
43
|
+
paymentsEntry.hearthkitPaymentsDrizzleSchema,
|
|
44
|
+
)
|
|
45
|
+
return { paymentsEntry, drizzleClient, closeDatabaseClient }
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
afterAll(async () => {
|
|
49
|
+
await gateFile.releaseIfCreated(({ closeDatabaseClient }) => closeDatabaseClient())
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
// Four problems in one catalog, which is the point: CONTRACT.md says catalogIssues names every
|
|
53
|
+
// problem in one pass, the way config reports every bad variable at once, because fixing a catalog
|
|
54
|
+
// one error per boot is miserable. `gate-shared-price` appears under two different products, which
|
|
55
|
+
// is the case a per-product uniqueness check would miss.
|
|
56
|
+
const catalogWithEveryProblem = {
|
|
57
|
+
products: [
|
|
58
|
+
{
|
|
59
|
+
productName: 'gate-duplicated',
|
|
60
|
+
displayName: 'Gate Duplicated',
|
|
61
|
+
prices: [
|
|
62
|
+
{
|
|
63
|
+
priceName: 'gate-shared-price',
|
|
64
|
+
currency: 'usd',
|
|
65
|
+
unitAmountMinorUnits: 1900,
|
|
66
|
+
priceKind: 'subscription',
|
|
67
|
+
recurringInterval: 'month',
|
|
68
|
+
},
|
|
69
|
+
],
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
productName: 'gate-duplicated',
|
|
73
|
+
displayName: 'Gate Duplicated Again',
|
|
74
|
+
prices: [
|
|
75
|
+
{
|
|
76
|
+
priceName: 'gate-other-price',
|
|
77
|
+
currency: 'usd',
|
|
78
|
+
unitAmountMinorUnits: 500,
|
|
79
|
+
priceKind: 'one-time',
|
|
80
|
+
},
|
|
81
|
+
],
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
productName: 'gate-third',
|
|
85
|
+
displayName: 'Gate Third',
|
|
86
|
+
prices: [
|
|
87
|
+
{
|
|
88
|
+
priceName: 'gate-shared-price',
|
|
89
|
+
currency: 'usd',
|
|
90
|
+
unitAmountMinorUnits: 2900,
|
|
91
|
+
priceKind: 'one-time',
|
|
92
|
+
},
|
|
93
|
+
],
|
|
94
|
+
},
|
|
95
|
+
{ productName: 'gate-empty', displayName: 'Gate Empty', prices: [] },
|
|
96
|
+
{
|
|
97
|
+
productName: 'Gate Invalid Name',
|
|
98
|
+
displayName: 'Gate Invalid',
|
|
99
|
+
prices: [
|
|
100
|
+
{
|
|
101
|
+
priceName: 'gate-invalid-price',
|
|
102
|
+
currency: 'USD',
|
|
103
|
+
unitAmountMinorUnits: 19.5,
|
|
104
|
+
priceKind: 'one-time',
|
|
105
|
+
},
|
|
106
|
+
],
|
|
107
|
+
},
|
|
108
|
+
],
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
describe('createPaymentsClient', () => {
|
|
112
|
+
it('builds a client in both scaffold modes and echoes the flag back with the billing scope it decided', async () => {
|
|
113
|
+
const { paymentsEntry, drizzleClient } = await gateFile.read()
|
|
114
|
+
const paymentsCatalog = gatePaymentsCatalog(uniqueGatePaymentsCatalogNames('client'))
|
|
115
|
+
|
|
116
|
+
const userScoped = paymentsEntry.createPaymentsClient({
|
|
117
|
+
...gatePaymentsEnv(),
|
|
118
|
+
drizzleClient,
|
|
119
|
+
paymentsCatalog,
|
|
120
|
+
organizationsEnabled: false,
|
|
121
|
+
})
|
|
122
|
+
// Not run through expectValueCarriesNoSecret: the client handle is the one value CONTRACT.md
|
|
123
|
+
// allows to carry the webhook secret, because carrying it to handleStripeWebhook is its job.
|
|
124
|
+
createPaymentsClientResultSchema.parse(userScoped)
|
|
125
|
+
const userMode = expectResultKind(userScoped, 'payments-client-created')
|
|
126
|
+
expect(userMode.organizationsEnabled).toBe(false)
|
|
127
|
+
expect(userMode.billingScope).toBe('user')
|
|
128
|
+
expect(userMode.paymentsClient.billingScope).toBe('user')
|
|
129
|
+
// The client carries the validated catalog, which is what makes the price lookup a local map
|
|
130
|
+
// read needing no network at all.
|
|
131
|
+
expect(
|
|
132
|
+
sortedGateNames(userMode.paymentsClient.paymentsCatalog.products.map((p) => p.productName)),
|
|
133
|
+
).toEqual(sortedGateNames(paymentsCatalog.products.map((product) => product.productName)))
|
|
134
|
+
|
|
135
|
+
const orgScoped = paymentsEntry.createPaymentsClient({
|
|
136
|
+
...gatePaymentsEnv(),
|
|
137
|
+
drizzleClient,
|
|
138
|
+
paymentsCatalog,
|
|
139
|
+
organizationsEnabled: true,
|
|
140
|
+
})
|
|
141
|
+
createPaymentsClientResultSchema.parse(orgScoped)
|
|
142
|
+
const orgMode = expectResultKind(orgScoped, 'payments-client-created')
|
|
143
|
+
expect(orgMode.organizationsEnabled).toBe(true)
|
|
144
|
+
// The flag decides this one value and nothing else. It never decides which tables exist, for the
|
|
145
|
+
// same reason auth defines the organization tables in both modes: changing the flag later
|
|
146
|
+
// re-homes existing rows, which is a data migration.
|
|
147
|
+
expect(orgMode.billingScope).toBe('organization')
|
|
148
|
+
expect(orgMode.paymentsClient.billingScope).toBe('organization')
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
it('reports every catalog problem in one pass, and reports catalog-has-no-products for an empty catalog', async () => {
|
|
152
|
+
const { paymentsEntry, drizzleClient } = await gateFile.read()
|
|
153
|
+
|
|
154
|
+
const result = paymentsEntry.createPaymentsClient({
|
|
155
|
+
...gatePaymentsEnv(),
|
|
156
|
+
drizzleClient,
|
|
157
|
+
// Deliberately not parsed through paymentsCatalogSchema: the whole point of this gate is a
|
|
158
|
+
// catalog an app got wrong, which the schema would refuse to build.
|
|
159
|
+
paymentsCatalog: catalogWithEveryProblem as unknown as PaymentsCatalog,
|
|
160
|
+
organizationsEnabled: false,
|
|
161
|
+
})
|
|
162
|
+
const failure = expectPaymentsFailure(result, 'payments-catalog-invalid')
|
|
163
|
+
|
|
164
|
+
expect(
|
|
165
|
+
sortedGateNames([...new Set(failure.catalogIssues.map((issue) => issue.catalogIssueKind))]),
|
|
166
|
+
).toEqual(
|
|
167
|
+
sortedGateNames([
|
|
168
|
+
'duplicate-price-name',
|
|
169
|
+
'duplicate-product-name',
|
|
170
|
+
'entry-invalid',
|
|
171
|
+
'product-has-no-prices',
|
|
172
|
+
]),
|
|
173
|
+
)
|
|
174
|
+
for (const issue of failure.catalogIssues) {
|
|
175
|
+
expect(issue.catalogIssueReason.length).toBeGreaterThan(0)
|
|
176
|
+
}
|
|
177
|
+
// The reason states the rule that was broken, never the value, matching payments-input-invalid's
|
|
178
|
+
// discipline. A catalog is app source rather than user input, but the rule is the same one.
|
|
179
|
+
const everyReason = failure.catalogIssues.map((issue) => issue.catalogIssueReason).join(' ')
|
|
180
|
+
expect(everyReason).not.toContain('19.5')
|
|
181
|
+
|
|
182
|
+
const emptyCatalog = paymentsEntry.createPaymentsClient({
|
|
183
|
+
...gatePaymentsEnv(),
|
|
184
|
+
drizzleClient,
|
|
185
|
+
paymentsCatalog: { products: [] } as unknown as PaymentsCatalog,
|
|
186
|
+
organizationsEnabled: false,
|
|
187
|
+
})
|
|
188
|
+
const emptyFailure = expectPaymentsFailure(emptyCatalog, 'payments-catalog-invalid')
|
|
189
|
+
expect(emptyFailure.catalogIssues.map((issue) => issue.catalogIssueKind)).toContain(
|
|
190
|
+
'catalog-has-no-products',
|
|
191
|
+
)
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
it('rejects a Stripe API base URL, an environment object and a Drizzle client it cannot use, naming the field', async () => {
|
|
195
|
+
const { paymentsEntry, drizzleClient } = await gateFile.read()
|
|
196
|
+
const paymentsCatalog = gatePaymentsCatalog(uniqueGatePaymentsCatalogNames('badinput'))
|
|
197
|
+
|
|
198
|
+
const badBaseUrl = paymentsEntry.createPaymentsClient({
|
|
199
|
+
...gatePaymentsEnv(),
|
|
200
|
+
drizzleClient,
|
|
201
|
+
paymentsCatalog,
|
|
202
|
+
organizationsEnabled: false,
|
|
203
|
+
// Absolute http(s) only: it overrides the Stripe API host, port and protocol, and a relative
|
|
204
|
+
// value would silently point the SDK at nothing.
|
|
205
|
+
stripeApiBaseUrl: 'api.stripe.test/v1',
|
|
206
|
+
})
|
|
207
|
+
expect(expectPaymentsFailure(badBaseUrl, 'payments-input-invalid').invalidFieldName).toBe(
|
|
208
|
+
'stripe-api-base-url',
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
const badEnv = paymentsEntry.createPaymentsClient({
|
|
212
|
+
paymentsEnv: { STRIPE_SECRET_KEY: '' } as unknown as PaymentsEnvValues,
|
|
213
|
+
drizzleClient,
|
|
214
|
+
paymentsCatalog,
|
|
215
|
+
organizationsEnabled: false,
|
|
216
|
+
})
|
|
217
|
+
expect(expectPaymentsFailure(badEnv, 'payments-input-invalid').invalidFieldName).toBe(
|
|
218
|
+
'payments-env',
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
const badDrizzleClient = paymentsEntry.createPaymentsClient({
|
|
222
|
+
...gatePaymentsEnv(),
|
|
223
|
+
drizzleClient: undefined as unknown as NodePgDatabase<Record<string, unknown>>,
|
|
224
|
+
paymentsCatalog,
|
|
225
|
+
organizationsEnabled: false,
|
|
226
|
+
})
|
|
227
|
+
expect(expectPaymentsFailure(badDrizzleClient, 'payments-input-invalid').invalidFieldName).toBe(
|
|
228
|
+
'drizzle-client',
|
|
229
|
+
)
|
|
230
|
+
})
|
|
231
|
+
})
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import Stripe from 'stripe'
|
|
2
|
+
import { validatePaymentsCatalogEntries } from './payments-catalog-validation.ts'
|
|
3
|
+
import { rememberPaymentsClientSecrets } from './payments-client-secrets.ts'
|
|
4
|
+
import {
|
|
5
|
+
paymentsCatalogInvalidFailure,
|
|
6
|
+
paymentsInputInvalidFailure,
|
|
7
|
+
} from './payments-failure-results.ts'
|
|
8
|
+
import {
|
|
9
|
+
paymentsDrizzleClientSchema,
|
|
10
|
+
paymentsEnvSchemaFragment,
|
|
11
|
+
paymentsStripeApiBaseUrlSchema,
|
|
12
|
+
type CreatePaymentsClientOptions,
|
|
13
|
+
type CreatePaymentsClientResult,
|
|
14
|
+
type PaymentsClient,
|
|
15
|
+
} from './payments-contract.ts'
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Builds the handle every other function here takes. It contacts nothing: constructing a Stripe
|
|
19
|
+
* instance does no I/O and the Drizzle client is lazy, so an app can call this at module scope in
|
|
20
|
+
* lib/payments.ts and a wrong key surfaces on the first request rather than at import.
|
|
21
|
+
*
|
|
22
|
+
* The catalog is validated here so a malformed one fails at boot rather than at a buyer's checkout,
|
|
23
|
+
* and organizationsEnabled is a parameter rather than an environment variable, exactly as in
|
|
24
|
+
* @hearthkit/auth, so it stays a literal in the generated app's source.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
// host, port and protocol are first-class StripeConfig options. They are set from one absolute URL so
|
|
28
|
+
// a caller has one thing to supply, and left unset entirely when none was given, which is every real
|
|
29
|
+
// deployment.
|
|
30
|
+
function stripeApiBaseUrlConfig(stripeApiBaseUrl: string): Stripe.StripeConfig {
|
|
31
|
+
const baseUrl = new URL(stripeApiBaseUrl)
|
|
32
|
+
return {
|
|
33
|
+
host: baseUrl.hostname,
|
|
34
|
+
protocol: baseUrl.protocol === 'https:' ? 'https' : 'http',
|
|
35
|
+
...(baseUrl.port === '' ? {} : { port: baseUrl.port }),
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Validates the catalog and builds the Stripe client; synchronous, and opens no connection. */
|
|
40
|
+
export function createPaymentsClient(
|
|
41
|
+
options: CreatePaymentsClientOptions,
|
|
42
|
+
): CreatePaymentsClientResult {
|
|
43
|
+
const parsedEnv = paymentsEnvSchemaFragment.safeParse(options.paymentsEnv)
|
|
44
|
+
if (!parsedEnv.success) {
|
|
45
|
+
return paymentsInputInvalidFailure('payments-env')
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (!paymentsDrizzleClientSchema.safeParse(options.drizzleClient).success) {
|
|
49
|
+
return paymentsInputInvalidFailure('drizzle-client')
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
let stripeApiConfig: Stripe.StripeConfig = {}
|
|
53
|
+
if (options.stripeApiBaseUrl !== undefined) {
|
|
54
|
+
if (!paymentsStripeApiBaseUrlSchema.safeParse(options.stripeApiBaseUrl).success) {
|
|
55
|
+
return paymentsInputInvalidFailure('stripe-api-base-url')
|
|
56
|
+
}
|
|
57
|
+
stripeApiConfig = stripeApiBaseUrlConfig(options.stripeApiBaseUrl)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const validatedCatalog = validatePaymentsCatalogEntries(options.paymentsCatalog)
|
|
61
|
+
if (validatedCatalog.kind === 'catalog-invalid') {
|
|
62
|
+
return paymentsCatalogInvalidFailure(validatedCatalog.catalogIssues)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// No apiVersion and no maxNetworkRetries: the SDK's generated types describe only its own default
|
|
66
|
+
// version, so pinning a different one would make every Stripe type in this package a lie, and the
|
|
67
|
+
// default retry count is the behaviour the SDK's own idempotency logic was written against.
|
|
68
|
+
const stripeClient = new Stripe(String(parsedEnv.data.STRIPE_SECRET_KEY), stripeApiConfig)
|
|
69
|
+
const billingScope = options.organizationsEnabled ? 'organization' : 'user'
|
|
70
|
+
|
|
71
|
+
const paymentsClient: PaymentsClient = {
|
|
72
|
+
stripeClient,
|
|
73
|
+
drizzleClient: options.drizzleClient,
|
|
74
|
+
paymentsCatalog: validatedCatalog.paymentsCatalog,
|
|
75
|
+
stripeWebhookSecret: parsedEnv.data.STRIPE_WEBHOOK_SECRET,
|
|
76
|
+
organizationsEnabled: options.organizationsEnabled,
|
|
77
|
+
billingScope,
|
|
78
|
+
}
|
|
79
|
+
rememberPaymentsClientSecrets(paymentsClient, {
|
|
80
|
+
stripeSecretKey: String(parsedEnv.data.STRIPE_SECRET_KEY),
|
|
81
|
+
stripeWebhookSecret: String(parsedEnv.data.STRIPE_WEBHOOK_SECRET),
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
return {
|
|
85
|
+
kind: 'payments-client-created',
|
|
86
|
+
paymentsClient,
|
|
87
|
+
organizationsEnabled: options.organizationsEnabled,
|
|
88
|
+
billingScope,
|
|
89
|
+
}
|
|
90
|
+
}
|