@strav/stripe 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.
@@ -0,0 +1,276 @@
1
+ import { extractUserId } from '@stravigor/database'
2
+ import StripeManager from './stripe_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 StripeManager.db.sql
16
+ }
17
+
18
+ private static get fk() {
19
+ return StripeManager.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 StripeManager.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 StripeManager.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 StripeManager.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 StripeManager.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 StripeManager.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 StripeManager.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 StripeManager.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
+ }
@@ -0,0 +1,182 @@
1
+ import type Stripe from 'stripe'
2
+ import { extractUserId } from '@stravigor/database'
3
+ import StripeManager from './stripe_manager.ts'
4
+ import Customer from './customer.ts'
5
+ import Subscription from './subscription.ts'
6
+ import SubscriptionItem from './subscription_item.ts'
7
+ import { SubscriptionCreationError } from './errors.ts'
8
+ import type { SubscriptionData } from './types.ts'
9
+
10
+ interface PendingItem {
11
+ price: string
12
+ quantity?: number
13
+ }
14
+
15
+ /**
16
+ * Fluent builder for creating Stripe subscriptions.
17
+ *
18
+ * @example
19
+ * const sub = await new SubscriptionBuilder('pro', 'price_xxx')
20
+ * .trialDays(14)
21
+ * .coupon('LAUNCH20')
22
+ * .create(user)
23
+ */
24
+ export default class SubscriptionBuilder {
25
+ private _name: string
26
+ private _items: PendingItem[] = []
27
+ private _trialDays?: number
28
+ private _trialUntil?: Date
29
+ private _skipTrial = false
30
+ private _coupon?: string
31
+ private _promotionCode?: string
32
+ private _metadata: Record<string, string> = {}
33
+ private _paymentBehavior: Stripe.SubscriptionCreateParams.PaymentBehavior = 'default_incomplete'
34
+ private _quantity?: number
35
+ private _anchorBillingCycleOn?: number
36
+
37
+ constructor(name: string, ...prices: string[]) {
38
+ this._name = name
39
+ for (const price of prices) {
40
+ this._items.push({ price })
41
+ }
42
+ }
43
+
44
+ /** Set the default quantity for all items (unless overridden per-item). */
45
+ quantity(qty: number): this {
46
+ this._quantity = qty
47
+ return this
48
+ }
49
+
50
+ /** Add a price with an explicit quantity. */
51
+ plan(price: string, quantity?: number): this {
52
+ this._items.push({ price, quantity })
53
+ return this
54
+ }
55
+
56
+ /** Set a trial period in days. */
57
+ trialDays(days: number): this {
58
+ this._trialDays = days
59
+ return this
60
+ }
61
+
62
+ /** Set a specific trial end date. */
63
+ trialUntil(date: Date): this {
64
+ this._trialUntil = date
65
+ return this
66
+ }
67
+
68
+ /** Skip any trial period. */
69
+ skipTrial(): this {
70
+ this._skipTrial = true
71
+ return this
72
+ }
73
+
74
+ /** Apply a coupon to the subscription. */
75
+ coupon(couponId: string): this {
76
+ this._coupon = couponId
77
+ return this
78
+ }
79
+
80
+ /** Apply a promotion code. */
81
+ promotionCode(code: string): this {
82
+ this._promotionCode = code
83
+ return this
84
+ }
85
+
86
+ /** Add custom metadata to the Stripe subscription. */
87
+ metadata(data: Record<string, string>): this {
88
+ this._metadata = { ...this._metadata, ...data }
89
+ return this
90
+ }
91
+
92
+ /** Anchor the billing cycle to a specific timestamp. */
93
+ anchorBillingCycleOn(timestamp: number): this {
94
+ this._anchorBillingCycleOn = timestamp
95
+ return this
96
+ }
97
+
98
+ /** Set the payment behavior (default: 'default_incomplete'). */
99
+ paymentBehavior(behavior: Stripe.SubscriptionCreateParams.PaymentBehavior): this {
100
+ this._paymentBehavior = behavior
101
+ return this
102
+ }
103
+
104
+ /**
105
+ * Create the subscription on Stripe and record it locally.
106
+ * Returns the local SubscriptionData.
107
+ */
108
+ async create(user: unknown): Promise<SubscriptionData> {
109
+ // 1. Ensure Stripe customer exists
110
+ const customer = await Customer.createOrGet(user)
111
+
112
+ // 2. Build Stripe params
113
+ const items: Stripe.SubscriptionCreateParams.Item[] = this._items.map(item => ({
114
+ price: item.price,
115
+ quantity: item.quantity ?? this._quantity,
116
+ }))
117
+
118
+ const params: Stripe.SubscriptionCreateParams = {
119
+ customer: customer.stripeId,
120
+ items,
121
+ payment_behavior: this._paymentBehavior,
122
+ expand: ['latest_invoice.payment_intent', 'pending_setup_intent'],
123
+ metadata: {
124
+ strav_user_id: String(extractUserId(user)),
125
+ strav_name: this._name,
126
+ ...this._metadata,
127
+ },
128
+ }
129
+
130
+ // Trial logic
131
+ if (!this._skipTrial) {
132
+ if (this._trialUntil) {
133
+ params.trial_end = Math.floor(this._trialUntil.getTime() / 1000)
134
+ } else if (this._trialDays) {
135
+ params.trial_end = Math.floor(Date.now() / 1000) + this._trialDays * 86400
136
+ }
137
+ }
138
+
139
+ if (this._coupon) params.coupon = this._coupon
140
+ if (this._promotionCode) params.promotion_code = this._promotionCode
141
+ if (this._anchorBillingCycleOn) {
142
+ params.billing_cycle_anchor = this._anchorBillingCycleOn
143
+ }
144
+
145
+ // 3. Create on Stripe
146
+ let stripeSub: Stripe.Subscription
147
+ try {
148
+ stripeSub = await StripeManager.stripe.subscriptions.create(params)
149
+ } catch (err: any) {
150
+ throw new SubscriptionCreationError(
151
+ `Failed to create Stripe subscription "${this._name}": ${err?.message ?? err}`
152
+ )
153
+ }
154
+
155
+ // 4. Record locally
156
+ const trialEndsAt = stripeSub.trial_end ? new Date(stripeSub.trial_end * 1000) : null
157
+
158
+ const localSub = await Subscription.create({
159
+ user,
160
+ name: this._name,
161
+ stripeId: stripeSub.id,
162
+ stripeStatus: stripeSub.status,
163
+ stripePriceId: this._items[0]?.price ?? null,
164
+ quantity: this._items[0]?.quantity ?? this._quantity ?? null,
165
+ trialEndsAt,
166
+ })
167
+
168
+ // 5. Record subscription items
169
+ for (const item of stripeSub.items.data) {
170
+ await SubscriptionItem.create({
171
+ subscriptionId: localSub.id,
172
+ stripeId: item.id,
173
+ stripeProductId:
174
+ typeof item.price.product === 'string' ? item.price.product : item.price.product.id,
175
+ stripePriceId: item.price.id,
176
+ quantity: item.quantity ?? null,
177
+ })
178
+ }
179
+
180
+ return localSub
181
+ }
182
+ }
@@ -0,0 +1,170 @@
1
+ import StripeManager from './stripe_manager.ts'
2
+ import type { SubscriptionItemData, SubscriptionData } from './types.ts'
3
+
4
+ /**
5
+ * Static helper for managing subscription item records (multi-plan subscriptions).
6
+ *
7
+ * @example
8
+ * const items = await SubscriptionItem.findBySubscription(sub.id)
9
+ * await SubscriptionItem.add(sub, sub.id, 'price_addon', 2)
10
+ */
11
+ export default class SubscriptionItem {
12
+ private static get sql() {
13
+ return StripeManager.db.sql
14
+ }
15
+
16
+ // ---------------------------------------------------------------------------
17
+ // Queries
18
+ // ---------------------------------------------------------------------------
19
+
20
+ /** Find all items belonging to a subscription. */
21
+ static async findBySubscription(subscriptionId: number): Promise<SubscriptionItemData[]> {
22
+ const rows = await SubscriptionItem.sql`
23
+ SELECT * FROM "subscription_item"
24
+ WHERE "subscription_id" = ${subscriptionId}
25
+ ORDER BY "created_at" ASC
26
+ `
27
+ return rows.map((r: any) => SubscriptionItem.hydrate(r))
28
+ }
29
+
30
+ /** Find by Stripe subscription item ID. */
31
+ static async findByStripeId(stripeId: string): Promise<SubscriptionItemData | null> {
32
+ const rows = await SubscriptionItem.sql`
33
+ SELECT * FROM "subscription_item" WHERE "stripe_id" = ${stripeId} LIMIT 1
34
+ `
35
+ return rows.length > 0 ? SubscriptionItem.hydrate(rows[0] as Record<string, unknown>) : null
36
+ }
37
+
38
+ // ---------------------------------------------------------------------------
39
+ // Create / Update
40
+ // ---------------------------------------------------------------------------
41
+
42
+ /** Create a local subscription item record. */
43
+ static async create(data: {
44
+ subscriptionId: number
45
+ stripeId: string
46
+ stripeProductId: string
47
+ stripePriceId: string
48
+ quantity?: number | null
49
+ }): Promise<SubscriptionItemData> {
50
+ const rows = await SubscriptionItem.sql`
51
+ INSERT INTO "subscription_item"
52
+ ("subscription_id", "stripe_id", "stripe_product_id", "stripe_price_id", "quantity")
53
+ VALUES (${data.subscriptionId}, ${data.stripeId}, ${data.stripeProductId},
54
+ ${data.stripePriceId}, ${data.quantity ?? null})
55
+ RETURNING *
56
+ `
57
+ return SubscriptionItem.hydrate(rows[0] as Record<string, unknown>)
58
+ }
59
+
60
+ /** Add a new price/item to an existing Stripe subscription and record it locally. */
61
+ static async add(
62
+ sub: SubscriptionData,
63
+ localSubId: number,
64
+ priceId: string,
65
+ quantity?: number
66
+ ): Promise<SubscriptionItemData> {
67
+ const stripeItem = await StripeManager.stripe.subscriptionItems.create({
68
+ subscription: sub.stripeId,
69
+ price: priceId,
70
+ quantity: quantity ?? 1,
71
+ })
72
+
73
+ return SubscriptionItem.create({
74
+ subscriptionId: localSubId,
75
+ stripeId: stripeItem.id,
76
+ stripeProductId:
77
+ typeof stripeItem.price.product === 'string'
78
+ ? stripeItem.price.product
79
+ : stripeItem.price.product.id,
80
+ stripePriceId: stripeItem.price.id,
81
+ quantity: stripeItem.quantity ?? null,
82
+ })
83
+ }
84
+
85
+ /** Swap an existing item to a different price. */
86
+ static async swap(item: SubscriptionItemData, newPriceId: string): Promise<void> {
87
+ await StripeManager.stripe.subscriptionItems.update(item.stripeId, {
88
+ price: newPriceId,
89
+ proration_behavior: 'create_prorations',
90
+ })
91
+
92
+ await SubscriptionItem.sql`
93
+ UPDATE "subscription_item"
94
+ SET "stripe_price_id" = ${newPriceId}, "updated_at" = NOW()
95
+ WHERE "stripe_id" = ${item.stripeId}
96
+ `
97
+ }
98
+
99
+ /** Update quantity for an item on Stripe and locally. */
100
+ static async updateQuantity(item: SubscriptionItemData, quantity: number): Promise<void> {
101
+ await StripeManager.stripe.subscriptionItems.update(item.stripeId, { quantity })
102
+
103
+ await SubscriptionItem.sql`
104
+ UPDATE "subscription_item"
105
+ SET "quantity" = ${quantity}, "updated_at" = NOW()
106
+ WHERE "stripe_id" = ${item.stripeId}
107
+ `
108
+ }
109
+
110
+ /** Remove an item from the Stripe subscription and delete locally. */
111
+ static async remove(item: SubscriptionItemData): Promise<void> {
112
+ await StripeManager.stripe.subscriptionItems.del(item.stripeId)
113
+ await SubscriptionItem.sql`
114
+ DELETE FROM "subscription_item" WHERE "stripe_id" = ${item.stripeId}
115
+ `
116
+ }
117
+
118
+ /** Report metered usage for a subscription item. */
119
+ static async reportUsage(
120
+ item: SubscriptionItemData,
121
+ quantity: number,
122
+ options?: { timestamp?: number }
123
+ ): Promise<void> {
124
+ await StripeManager.stripe.subscriptionItems.createUsageRecord(item.stripeId, {
125
+ quantity,
126
+ ...(options?.timestamp ? { timestamp: options.timestamp } : {}),
127
+ })
128
+ }
129
+
130
+ /** Sync all items from Stripe into the local table for a subscription. */
131
+ static async syncFromStripe(sub: SubscriptionData, localSubId: number): Promise<void> {
132
+ const stripeSub = await StripeManager.stripe.subscriptions.retrieve(sub.stripeId, {
133
+ expand: ['items.data.price.product'],
134
+ })
135
+
136
+ // Delete existing local items
137
+ await SubscriptionItem.sql`
138
+ DELETE FROM "subscription_item" WHERE "subscription_id" = ${localSubId}
139
+ `
140
+
141
+ // Re-create from Stripe data
142
+ for (const item of stripeSub.items.data) {
143
+ await SubscriptionItem.create({
144
+ subscriptionId: localSubId,
145
+ stripeId: item.id,
146
+ stripeProductId:
147
+ typeof item.price.product === 'string' ? item.price.product : item.price.product.id,
148
+ stripePriceId: item.price.id,
149
+ quantity: item.quantity ?? null,
150
+ })
151
+ }
152
+ }
153
+
154
+ // ---------------------------------------------------------------------------
155
+ // Internal
156
+ // ---------------------------------------------------------------------------
157
+
158
+ private static hydrate(row: Record<string, unknown>): SubscriptionItemData {
159
+ return {
160
+ id: row.id as number,
161
+ subscriptionId: row.subscription_id as number,
162
+ stripeId: row.stripe_id as string,
163
+ stripeProductId: row.stripe_product_id as string,
164
+ stripePriceId: row.stripe_price_id as string,
165
+ quantity: (row.quantity as number) ?? null,
166
+ createdAt: row.created_at as Date,
167
+ updatedAt: row.updated_at as Date,
168
+ }
169
+ }
170
+ }