@stravigor/cashier 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 ADDED
@@ -0,0 +1,22 @@
1
+ {
2
+ "name": "@stravigor/cashier",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "description": "Stripe billing for the Strav framework",
6
+ "license": "MIT",
7
+ "exports": {
8
+ ".": "./src/index.ts",
9
+ "./*": "./src/*.ts"
10
+ },
11
+ "files": ["src/", "stubs/", "package.json", "tsconfig.json"],
12
+ "peerDependencies": {
13
+ "@stravigor/core": "0.2.5"
14
+ },
15
+ "dependencies": {
16
+ "stripe": "^17.4.0"
17
+ },
18
+ "scripts": {
19
+ "test": "bun test tests/",
20
+ "typecheck": "tsc --noEmit"
21
+ }
22
+ }
@@ -0,0 +1,291 @@
1
+ import type Stripe from 'stripe'
2
+ import type BaseModel from '@stravigor/core/orm/base_model'
3
+ import type { NormalizeConstructor } from '@stravigor/core/helpers'
4
+ import { extractUserId } from '@stravigor/core/helpers'
5
+ import Customer from './customer.ts'
6
+ import Subscription from './subscription.ts'
7
+ import SubscriptionBuilder from './subscription_builder.ts'
8
+ import CheckoutBuilder from './checkout_builder.ts'
9
+ import Invoice from './invoice.ts'
10
+ import PaymentMethod from './payment_method.ts'
11
+ import CashierManager from './cashier_manager.ts'
12
+ import type { CustomerData, SubscriptionData } from './types.ts'
13
+
14
+ // ---------------------------------------------------------------------------
15
+ // Bound builders (auto-pass user to .create())
16
+ // ---------------------------------------------------------------------------
17
+
18
+ class BoundSubscriptionBuilder extends SubscriptionBuilder {
19
+ private _user: unknown
20
+
21
+ constructor(user: unknown, name: string, ...prices: string[]) {
22
+ super(name, ...prices)
23
+ this._user = user
24
+ }
25
+
26
+ override async create(user?: unknown): Promise<SubscriptionData> {
27
+ return super.create(user ?? this._user)
28
+ }
29
+ }
30
+
31
+ class BoundCheckoutBuilder extends CheckoutBuilder {
32
+ private _user: unknown
33
+
34
+ constructor(user: unknown) {
35
+ super()
36
+ this._user = user
37
+ }
38
+
39
+ override async create(user?: unknown): Promise<Stripe.Checkout.Session> {
40
+ return super.create(user ?? this._user)
41
+ }
42
+ }
43
+
44
+ // ---------------------------------------------------------------------------
45
+ // Mixin
46
+ // ---------------------------------------------------------------------------
47
+
48
+ /**
49
+ * Mixin that adds billing methods to a BaseModel subclass.
50
+ *
51
+ * @example
52
+ * import { BaseModel } from '@stravigor/core/orm'
53
+ * import { billable } from '@stravigor/cashier'
54
+ *
55
+ * class User extends billable(BaseModel) {
56
+ * declare id: number
57
+ * declare email: string
58
+ * }
59
+ *
60
+ * // Composable with other mixins:
61
+ * import { compose } from '@stravigor/core/helpers'
62
+ * class User extends compose(BaseModel, softDeletes, billable) { }
63
+ *
64
+ * const user = await User.find(1)
65
+ * await user.subscribe('pro', 'price_xxx')
66
+ * await user.subscribed('pro') // true
67
+ */
68
+ export function billable<T extends NormalizeConstructor<typeof BaseModel>>(Base: T) {
69
+ return class Billable extends Base {
70
+ // ----- Customer -----
71
+
72
+ /** Get or create the Stripe customer record for this user. */
73
+ async createOrGetStripeCustomer(
74
+ params?: Stripe.CustomerCreateParams
75
+ ): Promise<CustomerData> {
76
+ return Customer.createOrGet(this, params)
77
+ }
78
+
79
+ /** Get the local customer record. */
80
+ async customer(): Promise<CustomerData | null> {
81
+ return Customer.findByUser(this)
82
+ }
83
+
84
+ /** Get the Stripe customer ID. */
85
+ async stripeId(): Promise<string | null> {
86
+ const customer = await Customer.findByUser(this)
87
+ return customer?.stripeId ?? null
88
+ }
89
+
90
+ /** Check if the user has a Stripe customer record. */
91
+ async hasStripeId(): Promise<boolean> {
92
+ return (await Customer.findByUser(this)) !== null
93
+ }
94
+
95
+ // ----- Subscriptions -----
96
+
97
+ /**
98
+ * Start building a new subscription.
99
+ *
100
+ * @example
101
+ * await user.newSubscription('pro', 'price_xxx').trialDays(14).create()
102
+ * await user.newSubscription('enterprise', 'price_a', 'price_b').create()
103
+ */
104
+ newSubscription(name: string, ...prices: string[]): BoundSubscriptionBuilder {
105
+ return new BoundSubscriptionBuilder(this, name, ...prices)
106
+ }
107
+
108
+ /**
109
+ * Create a subscription immediately (shorthand).
110
+ *
111
+ * @example
112
+ * await user.subscribe('pro', 'price_xxx')
113
+ */
114
+ async subscribe(name: string, priceId: string): Promise<SubscriptionData> {
115
+ return new BoundSubscriptionBuilder(this, name, priceId).create()
116
+ }
117
+
118
+ /** Get a specific subscription by name. */
119
+ async subscription(name: string = 'default'): Promise<SubscriptionData | null> {
120
+ return Subscription.findByName(this, name)
121
+ }
122
+
123
+ /** Get all subscriptions. */
124
+ async subscriptions(): Promise<SubscriptionData[]> {
125
+ return Subscription.findByUser(this)
126
+ }
127
+
128
+ /** Check if the user has a valid subscription with the given name. */
129
+ async subscribed(name: string = 'default'): Promise<boolean> {
130
+ const sub = await Subscription.findByName(this, name)
131
+ return sub !== null && Subscription.valid(sub)
132
+ }
133
+
134
+ /** Check if the user is on a trial for the given subscription. */
135
+ async onTrial(name: string = 'default'): Promise<boolean> {
136
+ const sub = await Subscription.findByName(this, name)
137
+ if (sub) return Subscription.onTrial(sub)
138
+
139
+ // Also check customer-level trial (generic trial)
140
+ const customer = await Customer.findByUser(this)
141
+ return (
142
+ customer?.trialEndsAt !== null &&
143
+ customer?.trialEndsAt !== undefined &&
144
+ customer.trialEndsAt.getTime() > Date.now()
145
+ )
146
+ }
147
+
148
+ /** Check if the user has an active subscription to a specific price ID. */
149
+ async subscribedToPrice(priceId: string): Promise<boolean> {
150
+ const subs = await Subscription.findByUser(this)
151
+ return subs.some((sub) => Subscription.valid(sub) && sub.stripePriceId === priceId)
152
+ }
153
+
154
+ /** Check if the subscription is on a grace period. */
155
+ async onGracePeriod(name: string = 'default'): Promise<boolean> {
156
+ const sub = await Subscription.findByName(this, name)
157
+ return sub !== null && Subscription.onGracePeriod(sub)
158
+ }
159
+
160
+ // ----- One-time Charges -----
161
+
162
+ /**
163
+ * Create a one-time charge.
164
+ *
165
+ * @example
166
+ * await user.charge(2500, 'pm_xxx', { description: 'Pro-rated upgrade' })
167
+ */
168
+ async charge(
169
+ amount: number,
170
+ paymentMethodId: string,
171
+ options?: {
172
+ currency?: string
173
+ description?: string
174
+ metadata?: Record<string, string>
175
+ }
176
+ ): Promise<Stripe.PaymentIntent> {
177
+ const customer = await Customer.createOrGet(this)
178
+ return CashierManager.stripe.paymentIntents.create({
179
+ amount,
180
+ currency: options?.currency ?? CashierManager.config.currency,
181
+ customer: customer.stripeId,
182
+ payment_method: paymentMethodId,
183
+ confirm: true,
184
+ automatic_payment_methods: {
185
+ enabled: true,
186
+ allow_redirects: 'never',
187
+ },
188
+ description: options?.description,
189
+ metadata: {
190
+ strav_user_id: String(extractUserId(this)),
191
+ ...options?.metadata,
192
+ },
193
+ })
194
+ }
195
+
196
+ /** Refund a payment intent (fully or partially). */
197
+ async refund(paymentIntentId: string, amount?: number): Promise<Stripe.Refund> {
198
+ return CashierManager.stripe.refunds.create({
199
+ payment_intent: paymentIntentId,
200
+ ...(amount ? { amount } : {}),
201
+ })
202
+ }
203
+
204
+ // ----- Payment Methods -----
205
+
206
+ /** List all payment methods for this user. */
207
+ async paymentMethods(
208
+ type?: Stripe.PaymentMethodListParams.Type
209
+ ): Promise<Stripe.PaymentMethod[]> {
210
+ return PaymentMethod.list(this, type)
211
+ }
212
+
213
+ /** Get the default payment method. */
214
+ async defaultPaymentMethod(): Promise<Stripe.PaymentMethod | null> {
215
+ const customer = await Customer.findByUser(this)
216
+ if (!customer) return null
217
+ const methods = await PaymentMethod.list(this)
218
+ return methods[0] ?? null
219
+ }
220
+
221
+ /** Set a payment method as default. */
222
+ async setDefaultPaymentMethod(paymentMethodId: string): Promise<void> {
223
+ return PaymentMethod.setDefault(this, paymentMethodId)
224
+ }
225
+
226
+ /** Create a Stripe SetupIntent for collecting payment info without charging. */
227
+ async createSetupIntent(
228
+ params?: Stripe.SetupIntentCreateParams
229
+ ): Promise<Stripe.SetupIntent> {
230
+ return Customer.createSetupIntent(this, params)
231
+ }
232
+
233
+ // ----- Checkout -----
234
+
235
+ /**
236
+ * Create a Stripe Checkout session.
237
+ *
238
+ * @example
239
+ * const session = await user.checkout([{ price: 'price_xxx', quantity: 1 }])
240
+ */
241
+ async checkout(
242
+ items: Array<{ price: string; quantity?: number }>
243
+ ): Promise<Stripe.Checkout.Session> {
244
+ const builder = new CheckoutBuilder(items)
245
+ return builder.create(this)
246
+ }
247
+
248
+ /**
249
+ * Start building a checkout session fluently.
250
+ *
251
+ * @example
252
+ * const session = await user.newCheckout()
253
+ * .item('price_xxx', 2)
254
+ * .mode('subscription')
255
+ * .subscriptionName('pro')
256
+ * .create()
257
+ */
258
+ newCheckout(): BoundCheckoutBuilder {
259
+ return new BoundCheckoutBuilder(this)
260
+ }
261
+
262
+ // ----- Invoices -----
263
+
264
+ /** List Stripe invoices for this user. */
265
+ async invoices(params?: Stripe.InvoiceListParams): Promise<Stripe.Invoice[]> {
266
+ return Invoice.list(this, params)
267
+ }
268
+
269
+ /** Preview the upcoming invoice. */
270
+ async upcomingInvoice(
271
+ params?: Stripe.InvoiceRetrieveUpcomingParams
272
+ ): Promise<Stripe.UpcomingInvoice | null> {
273
+ return Invoice.upcoming(this, params)
274
+ }
275
+
276
+ // ----- Billing Portal -----
277
+
278
+ /** Create a Stripe Customer Portal session URL. */
279
+ async billingPortalUrl(returnUrl?: string): Promise<string> {
280
+ const customer = await Customer.createOrGet(this)
281
+ const session = await CashierManager.stripe.billingPortal.sessions.create({
282
+ customer: customer.stripeId,
283
+ return_url: returnUrl ?? CashierManager.config.urls.success,
284
+ })
285
+ return session.url
286
+ }
287
+ }
288
+ }
289
+
290
+ /** The instance type of any billable model. */
291
+ export type BillableInstance = InstanceType<ReturnType<typeof billable>>
@@ -0,0 +1,80 @@
1
+ import Stripe from 'stripe'
2
+ import { inject } from '@stravigor/core/core'
3
+ import type Configuration from '@stravigor/core/config/configuration'
4
+ import type Database from '@stravigor/core/database/database'
5
+ import { ConfigurationError } from '@stravigor/core/exceptions/errors'
6
+ import { toSnakeCase } from '@stravigor/core/schema'
7
+ import type { CashierConfig } from './types.ts'
8
+
9
+ @inject
10
+ export default class CashierManager {
11
+ private static _db: Database
12
+ private static _config: CashierConfig
13
+ private static _stripe: Stripe
14
+ private static _userFkColumn: string
15
+
16
+ constructor(db: Database, config: Configuration) {
17
+ CashierManager._db = db
18
+
19
+ const userKey = config.get('cashier.userKey', 'id') as string
20
+ CashierManager._userFkColumn = `user_${toSnakeCase(userKey)}`
21
+
22
+ const secret = config.get('cashier.secret', '') as string
23
+
24
+ CashierManager._config = {
25
+ secret,
26
+ key: config.get('cashier.key', '') as string,
27
+ webhookSecret: config.get('cashier.webhookSecret', '') as string,
28
+ currency: config.get('cashier.currency', 'usd') as string,
29
+ userKey,
30
+ urls: {
31
+ success: config.get('cashier.urls.success', '/billing/success') as string,
32
+ cancel: config.get('cashier.urls.cancel', '/billing/cancel') as string,
33
+ },
34
+ }
35
+
36
+ if (secret) {
37
+ CashierManager._stripe = new Stripe(secret)
38
+ }
39
+ }
40
+
41
+ static get db(): Database {
42
+ if (!CashierManager._db) {
43
+ throw new ConfigurationError(
44
+ 'CashierManager not configured. Resolve it through the container first.'
45
+ )
46
+ }
47
+ return CashierManager._db
48
+ }
49
+
50
+ static get config(): CashierConfig {
51
+ if (!CashierManager._config) {
52
+ throw new ConfigurationError(
53
+ 'CashierManager not configured. Resolve it through the container first.'
54
+ )
55
+ }
56
+ return CashierManager._config
57
+ }
58
+
59
+ static get stripe(): Stripe {
60
+ if (!CashierManager._stripe) {
61
+ throw new ConfigurationError(
62
+ 'CashierManager not configured. Ensure STRIPE_SECRET is set.'
63
+ )
64
+ }
65
+ return CashierManager._stripe
66
+ }
67
+
68
+ /** The FK column name on cashier tables (e.g. `user_id`, `user_uid`). */
69
+ static get userFkColumn(): string {
70
+ return CashierManager._userFkColumn ?? 'user_id'
71
+ }
72
+
73
+ /** Reset internal state (useful for testing). */
74
+ static reset(): void {
75
+ CashierManager._db = undefined as any
76
+ CashierManager._config = undefined as any
77
+ CashierManager._stripe = undefined as any
78
+ CashierManager._userFkColumn = undefined as any
79
+ }
80
+ }
@@ -0,0 +1,130 @@
1
+ import type Stripe from 'stripe'
2
+ import { extractUserId } from '@stravigor/core/helpers'
3
+ import CashierManager from './cashier_manager.ts'
4
+ import Customer from './customer.ts'
5
+
6
+ interface CheckoutLineItem {
7
+ price: string
8
+ quantity?: number
9
+ }
10
+
11
+ /**
12
+ * Fluent builder for creating Stripe Checkout sessions.
13
+ *
14
+ * @example
15
+ * const session = await new CheckoutBuilder()
16
+ * .item('price_xxx', 2)
17
+ * .mode('subscription')
18
+ * .subscriptionName('pro')
19
+ * .create(user)
20
+ */
21
+ export default class CheckoutBuilder {
22
+ private _items: CheckoutLineItem[] = []
23
+ private _mode: Stripe.Checkout.SessionCreateParams.Mode = 'payment'
24
+ private _successUrl?: string
25
+ private _cancelUrl?: string
26
+ private _allowPromotionCodes = false
27
+ private _metadata: Record<string, string> = {}
28
+ private _subscriptionName?: string
29
+ private _trialDays?: number
30
+ private _customerEmail?: string
31
+
32
+ constructor(items?: CheckoutLineItem[]) {
33
+ if (items) this._items = items
34
+ }
35
+
36
+ /** Add a line item. */
37
+ item(price: string, quantity?: number): this {
38
+ this._items.push({ price, quantity: quantity ?? 1 })
39
+ return this
40
+ }
41
+
42
+ /** Set checkout mode: 'payment' | 'subscription' | 'setup'. */
43
+ mode(mode: Stripe.Checkout.SessionCreateParams.Mode): this {
44
+ this._mode = mode
45
+ return this
46
+ }
47
+
48
+ /** Set success URL. Overrides config default. */
49
+ successUrl(url: string): this {
50
+ this._successUrl = url
51
+ return this
52
+ }
53
+
54
+ /** Set cancel URL. Overrides config default. */
55
+ cancelUrl(url: string): this {
56
+ this._cancelUrl = url
57
+ return this
58
+ }
59
+
60
+ /** Allow promotion codes in the checkout page. */
61
+ allowPromotionCodes(allow = true): this {
62
+ this._allowPromotionCodes = allow
63
+ return this
64
+ }
65
+
66
+ /** Set custom metadata. */
67
+ metadata(data: Record<string, string>): this {
68
+ this._metadata = { ...this._metadata, ...data }
69
+ return this
70
+ }
71
+
72
+ /** Name the subscription (stored in metadata, used by webhook handler). */
73
+ subscriptionName(name: string): this {
74
+ this._subscriptionName = name
75
+ this._mode = 'subscription'
76
+ return this
77
+ }
78
+
79
+ /** Add trial days (subscription mode only). */
80
+ trialDays(days: number): this {
81
+ this._trialDays = days
82
+ return this
83
+ }
84
+
85
+ /** Pre-fill customer email (for guest users without a Stripe customer). */
86
+ email(email: string): this {
87
+ this._customerEmail = email
88
+ return this
89
+ }
90
+
91
+ /**
92
+ * Create the Stripe Checkout Session.
93
+ * If user is provided, attaches to their Stripe customer.
94
+ */
95
+ async create(user?: unknown): Promise<Stripe.Checkout.Session> {
96
+ const config = CashierManager.config
97
+
98
+ const params: Stripe.Checkout.SessionCreateParams = {
99
+ mode: this._mode,
100
+ line_items: this._items.map((i) => ({
101
+ price: i.price,
102
+ quantity: i.quantity,
103
+ })),
104
+ success_url: this._successUrl ?? config.urls.success,
105
+ cancel_url: this._cancelUrl ?? config.urls.cancel,
106
+ allow_promotion_codes: this._allowPromotionCodes || undefined,
107
+ metadata: {
108
+ ...(this._subscriptionName ? { strav_name: this._subscriptionName } : {}),
109
+ ...this._metadata,
110
+ },
111
+ }
112
+
113
+ if (user) {
114
+ const customer = await Customer.createOrGet(user)
115
+ params.customer = customer.stripeId
116
+ params.metadata!.strav_user_id = String(extractUserId(user))
117
+ } else if (this._customerEmail) {
118
+ params.customer_email = this._customerEmail
119
+ }
120
+
121
+ if (this._trialDays && this._mode === 'subscription') {
122
+ params.subscription_data = {
123
+ trial_period_days: this._trialDays,
124
+ metadata: params.metadata,
125
+ }
126
+ }
127
+
128
+ return CashierManager.stripe.checkout.sessions.create(params)
129
+ }
130
+ }
@@ -0,0 +1,157 @@
1
+ import type Stripe from 'stripe'
2
+ import { extractUserId } from '@stravigor/core/helpers'
3
+ import CashierManager from './cashier_manager.ts'
4
+ import type { CustomerData } from './types.ts'
5
+
6
+ /**
7
+ * Static helper for managing Stripe customer records.
8
+ *
9
+ * All methods are static. Database access goes through CashierManager.db.
10
+ *
11
+ * @example
12
+ * const customer = await Customer.createOrGet(user)
13
+ * const methods = await Customer.paymentMethods(user)
14
+ */
15
+ export default class Customer {
16
+ private static get sql() {
17
+ return CashierManager.db.sql
18
+ }
19
+
20
+ private static get fk() {
21
+ return CashierManager.userFkColumn
22
+ }
23
+
24
+ /** Find a customer record by user (model instance or ID). */
25
+ static async findByUser(user: unknown): Promise<CustomerData | null> {
26
+ const userId = extractUserId(user)
27
+ const fk = Customer.fk
28
+ const rows = await Customer.sql.unsafe(
29
+ `SELECT * FROM "customer" WHERE "${fk}" = $1 LIMIT 1`,
30
+ [userId]
31
+ )
32
+ return rows.length > 0 ? Customer.hydrate(rows[0] as Record<string, unknown>) : null
33
+ }
34
+
35
+ /** Find a customer record by Stripe customer ID. */
36
+ static async findByStripeId(stripeId: string): Promise<CustomerData | null> {
37
+ const rows = await Customer.sql`
38
+ SELECT * FROM "customer" WHERE "stripe_id" = ${stripeId} LIMIT 1
39
+ `
40
+ return rows.length > 0 ? Customer.hydrate(rows[0] as Record<string, unknown>) : null
41
+ }
42
+
43
+ /**
44
+ * Get or create the Stripe customer + local record for a user.
45
+ * Optionally pass params forwarded to `stripe.customers.create()`.
46
+ */
47
+ static async createOrGet(
48
+ user: unknown,
49
+ params?: Stripe.CustomerCreateParams
50
+ ): Promise<CustomerData> {
51
+ const existing = await Customer.findByUser(user)
52
+ if (existing) return existing
53
+
54
+ const userId = extractUserId(user)
55
+ const stripeCustomer = await CashierManager.stripe.customers.create({
56
+ metadata: { strav_user_id: String(userId) },
57
+ ...params,
58
+ })
59
+
60
+ const fk = Customer.fk
61
+ const rows = await Customer.sql.unsafe(
62
+ `INSERT INTO "customer" ("${fk}", "stripe_id")
63
+ VALUES ($1, $2)
64
+ RETURNING *`,
65
+ [userId, stripeCustomer.id]
66
+ )
67
+ return Customer.hydrate(rows[0] as Record<string, unknown>)
68
+ }
69
+
70
+ /** Update the default payment method on the local record. */
71
+ static async updateDefaultPaymentMethod(
72
+ stripeCustomerId: string,
73
+ paymentMethod: Stripe.PaymentMethod
74
+ ): Promise<void> {
75
+ await Customer.sql`
76
+ UPDATE "customer"
77
+ SET "pm_type" = ${paymentMethod.type},
78
+ "pm_last_four" = ${paymentMethod.card?.last4 ?? null},
79
+ "updated_at" = NOW()
80
+ WHERE "stripe_id" = ${stripeCustomerId}
81
+ `
82
+ }
83
+
84
+ /** Create a Stripe SetupIntent for the customer. */
85
+ static async createSetupIntent(
86
+ user: unknown,
87
+ params?: Stripe.SetupIntentCreateParams
88
+ ): Promise<Stripe.SetupIntent> {
89
+ const customer = await Customer.createOrGet(user)
90
+ return CashierManager.stripe.setupIntents.create({
91
+ customer: customer.stripeId,
92
+ ...params,
93
+ })
94
+ }
95
+
96
+ /** List all payment methods for a user's Stripe customer. */
97
+ static async paymentMethods(
98
+ user: unknown,
99
+ type: Stripe.PaymentMethodListParams.Type = 'card'
100
+ ): Promise<Stripe.PaymentMethod[]> {
101
+ const customer = await Customer.findByUser(user)
102
+ if (!customer) return []
103
+ const result = await CashierManager.stripe.paymentMethods.list({
104
+ customer: customer.stripeId,
105
+ type,
106
+ })
107
+ return result.data
108
+ }
109
+
110
+ /** Detach a payment method from Stripe. */
111
+ static async deletePaymentMethod(paymentMethodId: string): Promise<void> {
112
+ await CashierManager.stripe.paymentMethods.detach(paymentMethodId)
113
+ }
114
+
115
+ /** Sync trial_ends_at on the local record. */
116
+ static async updateTrialEndsAt(
117
+ stripeCustomerId: string,
118
+ trialEndsAt: Date | null
119
+ ): Promise<void> {
120
+ await Customer.sql`
121
+ UPDATE "customer"
122
+ SET "trial_ends_at" = ${trialEndsAt},
123
+ "updated_at" = NOW()
124
+ WHERE "stripe_id" = ${stripeCustomerId}
125
+ `
126
+ }
127
+
128
+ /** Delete the local customer record. Does NOT delete from Stripe. */
129
+ static async deleteByUser(user: unknown): Promise<void> {
130
+ const userId = extractUserId(user)
131
+ const fk = Customer.fk
132
+ await Customer.sql.unsafe(`DELETE FROM "customer" WHERE "${fk}" = $1`, [userId])
133
+ }
134
+
135
+ /** Delete the local customer record by Stripe ID. */
136
+ static async deleteByStripeId(stripeId: string): Promise<void> {
137
+ await Customer.sql`DELETE FROM "customer" WHERE "stripe_id" = ${stripeId}`
138
+ }
139
+
140
+ // ---------------------------------------------------------------------------
141
+ // Internal
142
+ // ---------------------------------------------------------------------------
143
+
144
+ private static hydrate(row: Record<string, unknown>): CustomerData {
145
+ const fk = Customer.fk
146
+ return {
147
+ id: row.id as number,
148
+ userId: row[fk] as string | number,
149
+ stripeId: row.stripe_id as string,
150
+ pmType: (row.pm_type as string) ?? null,
151
+ pmLastFour: (row.pm_last_four as string) ?? null,
152
+ trialEndsAt: (row.trial_ends_at as Date) ?? null,
153
+ createdAt: row.created_at as Date,
154
+ updatedAt: row.updated_at as Date,
155
+ }
156
+ }
157
+ }
package/src/errors.ts ADDED
@@ -0,0 +1,25 @@
1
+ import { StravError } from '@stravigor/core/exceptions/strav_error'
2
+
3
+ /** Base error class for all Cashier errors. */
4
+ export class CashierError extends StravError {}
5
+
6
+ /** Thrown when webhook signature verification fails. */
7
+ export class WebhookSignatureError extends CashierError {
8
+ constructor() {
9
+ super('Stripe webhook signature verification failed.')
10
+ }
11
+ }
12
+
13
+ /** Thrown when a user has no Stripe customer record. */
14
+ export class CustomerNotFoundError extends CashierError {
15
+ constructor() {
16
+ super('No Stripe customer found for this user.')
17
+ }
18
+ }
19
+
20
+ /** Thrown when a subscription is not found. */
21
+ export class SubscriptionNotFoundError extends CashierError {
22
+ constructor(name: string) {
23
+ super(`No subscription named "${name}" found for this user.`)
24
+ }
25
+ }