@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/src/helpers.ts ADDED
@@ -0,0 +1,110 @@
1
+ import type Stripe from 'stripe'
2
+ import CashierManager from './cashier_manager.ts'
3
+ import Customer from './customer.ts'
4
+ import Subscription from './subscription.ts'
5
+ import SubscriptionBuilder from './subscription_builder.ts'
6
+ import CheckoutBuilder from './checkout_builder.ts'
7
+ import Invoice from './invoice.ts'
8
+ import PaymentMethod from './payment_method.ts'
9
+ import Receipt from './receipt.ts'
10
+ import type { CustomerData, SubscriptionData, ReceiptData } from './types.ts'
11
+
12
+ /**
13
+ * Cashier helper object — the primary convenience API.
14
+ *
15
+ * @example
16
+ * import { cashier } from '@stravigor/cashier'
17
+ *
18
+ * // Direct Stripe instance access
19
+ * cashier.stripe.customers.list()
20
+ *
21
+ * // Subscription builder
22
+ * await cashier.newSubscription('pro', 'price_xxx').trialDays(14).create(user)
23
+ *
24
+ * // Checkout builder
25
+ * const session = await cashier.newCheckout().item('price_xxx').mode('payment').create(user)
26
+ */
27
+ export const cashier = {
28
+ /** Direct access to the configured Stripe SDK instance. */
29
+ get stripe(): Stripe {
30
+ return CashierManager.stripe
31
+ },
32
+
33
+ /** The Stripe publishable key (for frontend use). */
34
+ get key(): string {
35
+ return CashierManager.config.key
36
+ },
37
+
38
+ /** The configured default currency. */
39
+ get currency(): string {
40
+ return CashierManager.config.currency
41
+ },
42
+
43
+ // ----- Customer -----
44
+
45
+ /** Get or create a Stripe customer for a user. */
46
+ createOrGetCustomer(user: unknown, params?: Stripe.CustomerCreateParams): Promise<CustomerData> {
47
+ return Customer.createOrGet(user, params)
48
+ },
49
+
50
+ /** Find the local customer record for a user. */
51
+ findCustomer(user: unknown): Promise<CustomerData | null> {
52
+ return Customer.findByUser(user)
53
+ },
54
+
55
+ // ----- Subscriptions -----
56
+
57
+ /** Start building a new subscription. */
58
+ newSubscription(name: string, ...prices: string[]): SubscriptionBuilder {
59
+ return new SubscriptionBuilder(name, ...prices)
60
+ },
61
+
62
+ /** Find a user's subscription by name. */
63
+ subscription(user: unknown, name: string = 'default'): Promise<SubscriptionData | null> {
64
+ return Subscription.findByName(user, name)
65
+ },
66
+
67
+ /** Check if a user has a valid subscription. */
68
+ async subscribed(user: unknown, name: string = 'default'): Promise<boolean> {
69
+ const sub = await Subscription.findByName(user, name)
70
+ return sub !== null && Subscription.valid(sub)
71
+ },
72
+
73
+ // ----- Checkout -----
74
+
75
+ /** Start building a Stripe Checkout session. */
76
+ newCheckout(): CheckoutBuilder {
77
+ return new CheckoutBuilder()
78
+ },
79
+
80
+ // ----- Invoices -----
81
+
82
+ /** List invoices for a user. */
83
+ invoices(user: unknown, params?: Stripe.InvoiceListParams): Promise<Stripe.Invoice[]> {
84
+ return Invoice.list(user, params)
85
+ },
86
+
87
+ /** Preview the upcoming invoice. */
88
+ upcomingInvoice(user: unknown): Promise<Stripe.UpcomingInvoice | null> {
89
+ return Invoice.upcoming(user)
90
+ },
91
+
92
+ // ----- Payment Methods -----
93
+
94
+ /** List payment methods for a user. */
95
+ paymentMethods(user: unknown): Promise<Stripe.PaymentMethod[]> {
96
+ return PaymentMethod.list(user)
97
+ },
98
+
99
+ /** Set a payment method as default. */
100
+ setDefaultPaymentMethod(user: unknown, paymentMethodId: string): Promise<void> {
101
+ return PaymentMethod.setDefault(user, paymentMethodId)
102
+ },
103
+
104
+ // ----- Receipts -----
105
+
106
+ /** List receipts for a user. */
107
+ receipts(user: unknown): Promise<ReceiptData[]> {
108
+ return Receipt.findByUser(user)
109
+ },
110
+ }
package/src/index.ts ADDED
@@ -0,0 +1,44 @@
1
+ // Manager
2
+ export { default, default as CashierManager } from './cashier_manager.ts'
3
+
4
+ // Static helpers
5
+ export { default as Customer } from './customer.ts'
6
+ export { default as Subscription } from './subscription.ts'
7
+ export { default as SubscriptionItem } from './subscription_item.ts'
8
+ export { default as Invoice } from './invoice.ts'
9
+ export { default as PaymentMethod } from './payment_method.ts'
10
+ export { default as Receipt } from './receipt.ts'
11
+
12
+ // Builders
13
+ export { default as SubscriptionBuilder } from './subscription_builder.ts'
14
+ export { default as CheckoutBuilder } from './checkout_builder.ts'
15
+
16
+ // Mixin
17
+ export { billable } from './billable.ts'
18
+ export type { BillableInstance } from './billable.ts'
19
+
20
+ // Helper
21
+ export { cashier } from './helpers.ts'
22
+
23
+ // Webhook
24
+ export { stripeWebhook, onWebhookEvent } from './webhook.ts'
25
+
26
+ // Errors
27
+ export {
28
+ CashierError,
29
+ WebhookSignatureError,
30
+ CustomerNotFoundError,
31
+ SubscriptionNotFoundError,
32
+ } from './errors.ts'
33
+
34
+ // Types
35
+ export type {
36
+ CashierConfig,
37
+ CustomerData,
38
+ SubscriptionData,
39
+ SubscriptionItemData,
40
+ ReceiptData,
41
+ SubscriptionStatusValue,
42
+ WebhookEventHandler,
43
+ } from './types.ts'
44
+ export { SubscriptionStatus } from './types.ts'
package/src/invoice.ts ADDED
@@ -0,0 +1,69 @@
1
+ import type Stripe from 'stripe'
2
+ import CashierManager from './cashier_manager.ts'
3
+ import Customer from './customer.ts'
4
+
5
+ /**
6
+ * Static helper for Stripe invoice operations.
7
+ *
8
+ * @example
9
+ * const invoices = await Invoice.list(user)
10
+ * const upcoming = await Invoice.upcoming(user)
11
+ * const pdf = await Invoice.pdfUrl('in_xxx')
12
+ */
13
+ export default class Invoice {
14
+ /** List invoices for a user. */
15
+ static async list(
16
+ user: unknown,
17
+ params?: Stripe.InvoiceListParams
18
+ ): Promise<Stripe.Invoice[]> {
19
+ const customer = await Customer.findByUser(user)
20
+ if (!customer) return []
21
+
22
+ const result = await CashierManager.stripe.invoices.list({
23
+ customer: customer.stripeId,
24
+ limit: 24,
25
+ ...params,
26
+ })
27
+ return result.data
28
+ }
29
+
30
+ /** Get the upcoming invoice preview (prorations, next charge). */
31
+ static async upcoming(
32
+ user: unknown,
33
+ params?: Stripe.InvoiceRetrieveUpcomingParams
34
+ ): Promise<Stripe.UpcomingInvoice | null> {
35
+ const customer = await Customer.findByUser(user)
36
+ if (!customer) return null
37
+
38
+ try {
39
+ return await CashierManager.stripe.invoices.retrieveUpcoming({
40
+ customer: customer.stripeId,
41
+ ...params,
42
+ })
43
+ } catch {
44
+ return null
45
+ }
46
+ }
47
+
48
+ /** Retrieve a specific invoice by Stripe ID. */
49
+ static async find(invoiceId: string): Promise<Stripe.Invoice> {
50
+ return CashierManager.stripe.invoices.retrieve(invoiceId)
51
+ }
52
+
53
+ /** Get the hosted invoice URL for payment. */
54
+ static async hostedUrl(invoiceId: string): Promise<string | null> {
55
+ const invoice = await CashierManager.stripe.invoices.retrieve(invoiceId)
56
+ return invoice.hosted_invoice_url ?? null
57
+ }
58
+
59
+ /** Get the invoice PDF download URL. */
60
+ static async pdfUrl(invoiceId: string): Promise<string | null> {
61
+ const invoice = await CashierManager.stripe.invoices.retrieve(invoiceId)
62
+ return invoice.invoice_pdf ?? null
63
+ }
64
+
65
+ /** Void an invoice. */
66
+ static async void_(invoiceId: string): Promise<Stripe.Invoice> {
67
+ return CashierManager.stripe.invoices.voidInvoice(invoiceId)
68
+ }
69
+ }
@@ -0,0 +1,53 @@
1
+ import type Stripe from 'stripe'
2
+ import CashierManager from './cashier_manager.ts'
3
+ import Customer from './customer.ts'
4
+
5
+ /**
6
+ * Static helper for managing Stripe payment methods.
7
+ *
8
+ * @example
9
+ * const methods = await PaymentMethod.list(user)
10
+ * await PaymentMethod.setDefault(user, 'pm_xxx')
11
+ */
12
+ export default class PaymentMethod {
13
+ /** List all payment methods for a user. */
14
+ static async list(
15
+ user: unknown,
16
+ type: Stripe.PaymentMethodListParams.Type = 'card'
17
+ ): Promise<Stripe.PaymentMethod[]> {
18
+ return Customer.paymentMethods(user, type)
19
+ }
20
+
21
+ /** Retrieve a single payment method from Stripe. */
22
+ static async find(paymentMethodId: string): Promise<Stripe.PaymentMethod> {
23
+ return CashierManager.stripe.paymentMethods.retrieve(paymentMethodId)
24
+ }
25
+
26
+ /** Set a payment method as the customer's default. */
27
+ static async setDefault(user: unknown, paymentMethodId: string): Promise<void> {
28
+ const customer = await Customer.createOrGet(user)
29
+
30
+ // Attach if not already attached
31
+ try {
32
+ await CashierManager.stripe.paymentMethods.attach(paymentMethodId, {
33
+ customer: customer.stripeId,
34
+ })
35
+ } catch {
36
+ // Already attached — ignore
37
+ }
38
+
39
+ // Set as default on Stripe
40
+ await CashierManager.stripe.customers.update(customer.stripeId, {
41
+ invoice_settings: { default_payment_method: paymentMethodId },
42
+ })
43
+
44
+ // Update local record
45
+ const pm = await CashierManager.stripe.paymentMethods.retrieve(paymentMethodId)
46
+ await Customer.updateDefaultPaymentMethod(customer.stripeId, pm)
47
+ }
48
+
49
+ /** Detach a payment method from the customer. */
50
+ static async delete(paymentMethodId: string): Promise<void> {
51
+ return Customer.deletePaymentMethod(paymentMethodId)
52
+ }
53
+ }
package/src/receipt.ts ADDED
@@ -0,0 +1,84 @@
1
+ import { extractUserId } from '@stravigor/core/helpers'
2
+ import CashierManager from './cashier_manager.ts'
3
+ import type { ReceiptData } from './types.ts'
4
+
5
+ /**
6
+ * Static helper for managing one-time payment receipts.
7
+ *
8
+ * @example
9
+ * const receipt = await Receipt.create({ user, stripeId: pi.id, amount: 2500, currency: 'usd' })
10
+ * const receipts = await Receipt.findByUser(user)
11
+ */
12
+ export default class Receipt {
13
+ private static get sql() {
14
+ return CashierManager.db.sql
15
+ }
16
+
17
+ private static get fk() {
18
+ return CashierManager.userFkColumn
19
+ }
20
+
21
+ /** Record a one-time payment receipt. */
22
+ static async create(data: {
23
+ user: unknown
24
+ stripeId: string
25
+ amount: number
26
+ currency: string
27
+ description?: string | null
28
+ receiptUrl?: string | null
29
+ }): Promise<ReceiptData> {
30
+ const userId = extractUserId(data.user)
31
+ const fk = Receipt.fk
32
+ const rows = await Receipt.sql.unsafe(
33
+ `INSERT INTO "receipt" ("${fk}", "stripe_id", "amount", "currency", "description", "receipt_url")
34
+ VALUES ($1, $2, $3, $4, $5, $6)
35
+ RETURNING *`,
36
+ [
37
+ userId,
38
+ data.stripeId,
39
+ data.amount,
40
+ data.currency,
41
+ data.description ?? null,
42
+ data.receiptUrl ?? null,
43
+ ]
44
+ )
45
+ return Receipt.hydrate(rows[0] as Record<string, unknown>)
46
+ }
47
+
48
+ /** Find all receipts for a user, newest first. */
49
+ static async findByUser(user: unknown): Promise<ReceiptData[]> {
50
+ const userId = extractUserId(user)
51
+ const fk = Receipt.fk
52
+ const rows = await Receipt.sql.unsafe(
53
+ `SELECT * FROM "receipt" WHERE "${fk}" = $1 ORDER BY "created_at" DESC`,
54
+ [userId]
55
+ )
56
+ return rows.map((r: any) => Receipt.hydrate(r))
57
+ }
58
+
59
+ /** Find a receipt by Stripe payment intent ID. */
60
+ static async findByStripeId(stripeId: string): Promise<ReceiptData | null> {
61
+ const rows = await Receipt.sql`
62
+ SELECT * FROM "receipt" WHERE "stripe_id" = ${stripeId} LIMIT 1
63
+ `
64
+ return rows.length > 0 ? Receipt.hydrate(rows[0] as Record<string, unknown>) : null
65
+ }
66
+
67
+ // ---------------------------------------------------------------------------
68
+ // Internal
69
+ // ---------------------------------------------------------------------------
70
+
71
+ private static hydrate(row: Record<string, unknown>): ReceiptData {
72
+ const fk = Receipt.fk
73
+ return {
74
+ id: row.id as number,
75
+ userId: row[fk] as string | number,
76
+ stripeId: row.stripe_id as string,
77
+ amount: row.amount as number,
78
+ currency: row.currency as string,
79
+ description: (row.description as string) ?? null,
80
+ receiptUrl: (row.receipt_url as string) ?? null,
81
+ createdAt: row.created_at as Date,
82
+ }
83
+ }
84
+ }
@@ -0,0 +1,276 @@
1
+ import { extractUserId } from '@stravigor/core/helpers'
2
+ import CashierManager from './cashier_manager.ts'
3
+ import type { SubscriptionData } from './types.ts'
4
+ import { SubscriptionStatus } from './types.ts'
5
+
6
+ /**
7
+ * Static helper for managing subscription records.
8
+ *
9
+ * @example
10
+ * const sub = await Subscription.findByName(user, 'pro')
11
+ * if (sub && Subscription.active(sub)) { ... }
12
+ */
13
+ export default class Subscription {
14
+ private static get sql() {
15
+ return CashierManager.db.sql
16
+ }
17
+
18
+ private static get fk() {
19
+ return CashierManager.userFkColumn
20
+ }
21
+
22
+ // ---------------------------------------------------------------------------
23
+ // Queries
24
+ // ---------------------------------------------------------------------------
25
+
26
+ /** Find a subscription by user + name (e.g. 'default', 'pro'). */
27
+ static async findByName(user: unknown, name: string): Promise<SubscriptionData | null> {
28
+ const userId = extractUserId(user)
29
+ const fk = Subscription.fk
30
+ const rows = await Subscription.sql.unsafe(
31
+ `SELECT * FROM "subscription" WHERE "${fk}" = $1 AND "name" = $2 LIMIT 1`,
32
+ [userId, name]
33
+ )
34
+ return rows.length > 0 ? Subscription.hydrate(rows[0] as Record<string, unknown>) : null
35
+ }
36
+
37
+ /** Find all subscriptions for a user. */
38
+ static async findByUser(user: unknown): Promise<SubscriptionData[]> {
39
+ const userId = extractUserId(user)
40
+ const fk = Subscription.fk
41
+ const rows = await Subscription.sql.unsafe(
42
+ `SELECT * FROM "subscription" WHERE "${fk}" = $1 ORDER BY "created_at" DESC`,
43
+ [userId]
44
+ )
45
+ return rows.map((r: any) => Subscription.hydrate(r))
46
+ }
47
+
48
+ /** Find by Stripe subscription ID. */
49
+ static async findByStripeId(stripeId: string): Promise<SubscriptionData | null> {
50
+ const rows = await Subscription.sql`
51
+ SELECT * FROM "subscription" WHERE "stripe_id" = ${stripeId} LIMIT 1
52
+ `
53
+ return rows.length > 0 ? Subscription.hydrate(rows[0] as Record<string, unknown>) : null
54
+ }
55
+
56
+ // ---------------------------------------------------------------------------
57
+ // Create / Update
58
+ // ---------------------------------------------------------------------------
59
+
60
+ /** Create a local subscription record. */
61
+ static async create(data: {
62
+ user: unknown
63
+ name: string
64
+ stripeId: string
65
+ stripeStatus: string
66
+ stripePriceId?: string | null
67
+ quantity?: number | null
68
+ trialEndsAt?: Date | null
69
+ endsAt?: Date | null
70
+ }): Promise<SubscriptionData> {
71
+ const userId = extractUserId(data.user)
72
+ const fk = Subscription.fk
73
+ const rows = await Subscription.sql.unsafe(
74
+ `INSERT INTO "subscription"
75
+ ("${fk}", "name", "stripe_id", "stripe_status", "stripe_price_id", "quantity", "trial_ends_at", "ends_at")
76
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
77
+ RETURNING *`,
78
+ [
79
+ userId,
80
+ data.name,
81
+ data.stripeId,
82
+ data.stripeStatus,
83
+ data.stripePriceId ?? null,
84
+ data.quantity ?? null,
85
+ data.trialEndsAt ?? null,
86
+ data.endsAt ?? null,
87
+ ]
88
+ )
89
+ return Subscription.hydrate(rows[0] as Record<string, unknown>)
90
+ }
91
+
92
+ /** Update subscription status from a Stripe webhook event. */
93
+ static async syncStripeStatus(
94
+ stripeId: string,
95
+ status: string,
96
+ endsAt?: Date | null
97
+ ): Promise<void> {
98
+ if (endsAt !== undefined) {
99
+ await Subscription.sql`
100
+ UPDATE "subscription"
101
+ SET "stripe_status" = ${status}, "ends_at" = ${endsAt}, "updated_at" = NOW()
102
+ WHERE "stripe_id" = ${stripeId}
103
+ `
104
+ } else {
105
+ await Subscription.sql`
106
+ UPDATE "subscription"
107
+ SET "stripe_status" = ${status}, "updated_at" = NOW()
108
+ WHERE "stripe_id" = ${stripeId}
109
+ `
110
+ }
111
+ }
112
+
113
+ // ---------------------------------------------------------------------------
114
+ // Status Checks (pure functions on SubscriptionData)
115
+ // ---------------------------------------------------------------------------
116
+
117
+ /** Whether the subscription is valid (active, trialing, or on grace period). */
118
+ static valid(sub: SubscriptionData): boolean {
119
+ return Subscription.active(sub) || Subscription.onTrial(sub) || Subscription.onGracePeriod(sub)
120
+ }
121
+
122
+ /** Whether the Stripe status is active, trialing, or past_due. */
123
+ static active(sub: SubscriptionData): boolean {
124
+ return (
125
+ sub.stripeStatus === SubscriptionStatus.Active ||
126
+ sub.stripeStatus === SubscriptionStatus.Trialing ||
127
+ sub.stripeStatus === SubscriptionStatus.PastDue
128
+ )
129
+ }
130
+
131
+ /** Whether the subscription is currently in a trial period. */
132
+ static onTrial(sub: SubscriptionData): boolean {
133
+ return sub.trialEndsAt !== null && sub.trialEndsAt.getTime() > Date.now()
134
+ }
135
+
136
+ /** Whether the subscription is canceled but still within its grace period. */
137
+ static onGracePeriod(sub: SubscriptionData): boolean {
138
+ return sub.endsAt !== null && sub.endsAt.getTime() > Date.now()
139
+ }
140
+
141
+ /** Whether the subscription has been canceled (ends_at is set). */
142
+ static canceled(sub: SubscriptionData): boolean {
143
+ return sub.endsAt !== null
144
+ }
145
+
146
+ /** Whether the subscription has ended (canceled and past grace period). */
147
+ static ended(sub: SubscriptionData): boolean {
148
+ return Subscription.canceled(sub) && !Subscription.onGracePeriod(sub)
149
+ }
150
+
151
+ /** Whether the subscription is past due. */
152
+ static pastDue(sub: SubscriptionData): boolean {
153
+ return sub.stripeStatus === SubscriptionStatus.PastDue
154
+ }
155
+
156
+ /** Whether the subscription is recurring (not trial, not canceled). */
157
+ static recurring(sub: SubscriptionData): boolean {
158
+ return !Subscription.onTrial(sub) && !Subscription.canceled(sub)
159
+ }
160
+
161
+ // ---------------------------------------------------------------------------
162
+ // Mutations (Stripe API + local DB)
163
+ // ---------------------------------------------------------------------------
164
+
165
+ /** Cancel the subscription at period end (grace period). */
166
+ static async cancel(sub: SubscriptionData): Promise<SubscriptionData> {
167
+ const stripeSub = await CashierManager.stripe.subscriptions.update(sub.stripeId, {
168
+ cancel_at_period_end: true,
169
+ })
170
+
171
+ const endsAt = new Date(stripeSub.current_period_end * 1000)
172
+ await Subscription.sql`
173
+ UPDATE "subscription"
174
+ SET "stripe_status" = ${stripeSub.status}, "ends_at" = ${endsAt}, "updated_at" = NOW()
175
+ WHERE "stripe_id" = ${sub.stripeId}
176
+ `
177
+ return { ...sub, stripeStatus: stripeSub.status, endsAt }
178
+ }
179
+
180
+ /** Cancel immediately (no grace period). */
181
+ static async cancelNow(sub: SubscriptionData): Promise<SubscriptionData> {
182
+ await CashierManager.stripe.subscriptions.cancel(sub.stripeId)
183
+ const now = new Date()
184
+ await Subscription.sql`
185
+ UPDATE "subscription"
186
+ SET "stripe_status" = 'canceled', "ends_at" = ${now}, "updated_at" = NOW()
187
+ WHERE "stripe_id" = ${sub.stripeId}
188
+ `
189
+ return { ...sub, stripeStatus: 'canceled', endsAt: now }
190
+ }
191
+
192
+ /** Resume a canceled-but-on-grace-period subscription. */
193
+ static async resume(sub: SubscriptionData): Promise<SubscriptionData> {
194
+ if (!Subscription.onGracePeriod(sub)) {
195
+ throw new Error('Cannot resume a subscription that is not within its grace period.')
196
+ }
197
+
198
+ const stripeSub = await CashierManager.stripe.subscriptions.update(sub.stripeId, {
199
+ cancel_at_period_end: false,
200
+ })
201
+
202
+ await Subscription.sql`
203
+ UPDATE "subscription"
204
+ SET "stripe_status" = ${stripeSub.status}, "ends_at" = ${null}, "updated_at" = NOW()
205
+ WHERE "stripe_id" = ${sub.stripeId}
206
+ `
207
+ return { ...sub, stripeStatus: stripeSub.status, endsAt: null }
208
+ }
209
+
210
+ /** Swap the subscription to a different price (prorates by default). */
211
+ static async swap(sub: SubscriptionData, newPriceId: string): Promise<SubscriptionData> {
212
+ const stripeSub = await CashierManager.stripe.subscriptions.retrieve(sub.stripeId)
213
+ const itemId = stripeSub.items.data[0]?.id
214
+ if (!itemId) throw new Error('Subscription has no items to swap.')
215
+
216
+ const updated = await CashierManager.stripe.subscriptions.update(sub.stripeId, {
217
+ items: [{ id: itemId, price: newPriceId }],
218
+ proration_behavior: 'create_prorations',
219
+ cancel_at_period_end: false,
220
+ })
221
+
222
+ await Subscription.sql`
223
+ UPDATE "subscription"
224
+ SET "stripe_price_id" = ${newPriceId},
225
+ "stripe_status" = ${updated.status},
226
+ "ends_at" = ${null},
227
+ "updated_at" = NOW()
228
+ WHERE "stripe_id" = ${sub.stripeId}
229
+ `
230
+ return { ...sub, stripePriceId: newPriceId, stripeStatus: updated.status, endsAt: null }
231
+ }
232
+
233
+ /** Update the subscription quantity on Stripe and locally. */
234
+ static async updateQuantity(sub: SubscriptionData, quantity: number): Promise<SubscriptionData> {
235
+ const stripeSub = await CashierManager.stripe.subscriptions.retrieve(sub.stripeId)
236
+ const itemId = stripeSub.items.data[0]?.id
237
+ if (!itemId) throw new Error('Subscription has no items to update quantity for.')
238
+
239
+ await CashierManager.stripe.subscriptions.update(sub.stripeId, {
240
+ items: [{ id: itemId, quantity }],
241
+ })
242
+
243
+ await Subscription.sql`
244
+ UPDATE "subscription"
245
+ SET "quantity" = ${quantity}, "updated_at" = NOW()
246
+ WHERE "stripe_id" = ${sub.stripeId}
247
+ `
248
+ return { ...sub, quantity }
249
+ }
250
+
251
+ /** Delete the local subscription record. */
252
+ static async delete(id: number): Promise<void> {
253
+ await Subscription.sql`DELETE FROM "subscription" WHERE "id" = ${id}`
254
+ }
255
+
256
+ // ---------------------------------------------------------------------------
257
+ // Internal
258
+ // ---------------------------------------------------------------------------
259
+
260
+ private static hydrate(row: Record<string, unknown>): SubscriptionData {
261
+ const fk = Subscription.fk
262
+ return {
263
+ id: row.id as number,
264
+ userId: row[fk] as string | number,
265
+ name: row.name as string,
266
+ stripeId: row.stripe_id as string,
267
+ stripeStatus: row.stripe_status as string,
268
+ stripePriceId: (row.stripe_price_id as string) ?? null,
269
+ quantity: (row.quantity as number) ?? null,
270
+ trialEndsAt: (row.trial_ends_at as Date) ?? null,
271
+ endsAt: (row.ends_at as Date) ?? null,
272
+ createdAt: row.created_at as Date,
273
+ updatedAt: row.updated_at as Date,
274
+ }
275
+ }
276
+ }