@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.
- package/README.md +72 -0
- package/package.json +29 -0
- package/src/billable.ts +287 -0
- package/src/checkout_builder.ts +130 -0
- package/src/customer.ts +156 -0
- package/src/errors.ts +31 -0
- package/src/helpers.ts +110 -0
- package/src/index.ts +49 -0
- package/src/invoice.ts +66 -0
- package/src/payment_method.ts +59 -0
- package/src/receipt.ts +84 -0
- package/src/stripe_manager.ts +75 -0
- package/src/stripe_provider.ts +16 -0
- package/src/subscription.ts +276 -0
- package/src/subscription_builder.ts +182 -0
- package/src/subscription_item.ts +170 -0
- package/src/types.ts +97 -0
- package/src/webhook.ts +171 -0
- package/stubs/config/stripe.ts +27 -0
- package/stubs/schemas/customer.ts +12 -0
- package/stubs/schemas/receipt.ts +13 -0
- package/stubs/schemas/subscription.ts +15 -0
- package/stubs/schemas/subscription_item.ts +12 -0
- package/tsconfig.json +5 -0
|
@@ -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
|
+
}
|