@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 +22 -0
- package/src/billable.ts +291 -0
- package/src/cashier_manager.ts +80 -0
- package/src/checkout_builder.ts +130 -0
- package/src/customer.ts +157 -0
- package/src/errors.ts +25 -0
- package/src/helpers.ts +110 -0
- package/src/index.ts +44 -0
- package/src/invoice.ts +69 -0
- package/src/payment_method.ts +53 -0
- package/src/receipt.ts +84 -0
- package/src/subscription.ts +276 -0
- package/src/subscription_builder.ts +176 -0
- package/src/subscription_item.ts +172 -0
- package/src/types.ts +97 -0
- package/src/webhook.ts +168 -0
- package/stubs/config/cashier.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 +4 -0
|
@@ -0,0 +1,176 @@
|
|
|
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
|
+
import Subscription from './subscription.ts'
|
|
6
|
+
import SubscriptionItem from './subscription_item.ts'
|
|
7
|
+
import type { SubscriptionData } from './types.ts'
|
|
8
|
+
|
|
9
|
+
interface PendingItem {
|
|
10
|
+
price: string
|
|
11
|
+
quantity?: number
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Fluent builder for creating Stripe subscriptions.
|
|
16
|
+
*
|
|
17
|
+
* @example
|
|
18
|
+
* const sub = await new SubscriptionBuilder('pro', 'price_xxx')
|
|
19
|
+
* .trialDays(14)
|
|
20
|
+
* .coupon('LAUNCH20')
|
|
21
|
+
* .create(user)
|
|
22
|
+
*/
|
|
23
|
+
export default class SubscriptionBuilder {
|
|
24
|
+
private _name: string
|
|
25
|
+
private _items: PendingItem[] = []
|
|
26
|
+
private _trialDays?: number
|
|
27
|
+
private _trialUntil?: Date
|
|
28
|
+
private _skipTrial = false
|
|
29
|
+
private _coupon?: string
|
|
30
|
+
private _promotionCode?: string
|
|
31
|
+
private _metadata: Record<string, string> = {}
|
|
32
|
+
private _paymentBehavior: Stripe.SubscriptionCreateParams.PaymentBehavior = 'default_incomplete'
|
|
33
|
+
private _quantity?: number
|
|
34
|
+
private _anchorBillingCycleOn?: number
|
|
35
|
+
|
|
36
|
+
constructor(name: string, ...prices: string[]) {
|
|
37
|
+
this._name = name
|
|
38
|
+
for (const price of prices) {
|
|
39
|
+
this._items.push({ price })
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Set the default quantity for all items (unless overridden per-item). */
|
|
44
|
+
quantity(qty: number): this {
|
|
45
|
+
this._quantity = qty
|
|
46
|
+
return this
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Add a price with an explicit quantity. */
|
|
50
|
+
plan(price: string, quantity?: number): this {
|
|
51
|
+
this._items.push({ price, quantity })
|
|
52
|
+
return this
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Set a trial period in days. */
|
|
56
|
+
trialDays(days: number): this {
|
|
57
|
+
this._trialDays = days
|
|
58
|
+
return this
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Set a specific trial end date. */
|
|
62
|
+
trialUntil(date: Date): this {
|
|
63
|
+
this._trialUntil = date
|
|
64
|
+
return this
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Skip any trial period. */
|
|
68
|
+
skipTrial(): this {
|
|
69
|
+
this._skipTrial = true
|
|
70
|
+
return this
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Apply a coupon to the subscription. */
|
|
74
|
+
coupon(couponId: string): this {
|
|
75
|
+
this._coupon = couponId
|
|
76
|
+
return this
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Apply a promotion code. */
|
|
80
|
+
promotionCode(code: string): this {
|
|
81
|
+
this._promotionCode = code
|
|
82
|
+
return this
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Add custom metadata to the Stripe subscription. */
|
|
86
|
+
metadata(data: Record<string, string>): this {
|
|
87
|
+
this._metadata = { ...this._metadata, ...data }
|
|
88
|
+
return this
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Anchor the billing cycle to a specific timestamp. */
|
|
92
|
+
anchorBillingCycleOn(timestamp: number): this {
|
|
93
|
+
this._anchorBillingCycleOn = timestamp
|
|
94
|
+
return this
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Set the payment behavior (default: 'default_incomplete'). */
|
|
98
|
+
paymentBehavior(behavior: Stripe.SubscriptionCreateParams.PaymentBehavior): this {
|
|
99
|
+
this._paymentBehavior = behavior
|
|
100
|
+
return this
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Create the subscription on Stripe and record it locally.
|
|
105
|
+
* Returns the local SubscriptionData.
|
|
106
|
+
*/
|
|
107
|
+
async create(user: unknown): Promise<SubscriptionData> {
|
|
108
|
+
// 1. Ensure Stripe customer exists
|
|
109
|
+
const customer = await Customer.createOrGet(user)
|
|
110
|
+
|
|
111
|
+
// 2. Build Stripe params
|
|
112
|
+
const items: Stripe.SubscriptionCreateParams.Item[] = this._items.map((item) => ({
|
|
113
|
+
price: item.price,
|
|
114
|
+
quantity: item.quantity ?? this._quantity,
|
|
115
|
+
}))
|
|
116
|
+
|
|
117
|
+
const params: Stripe.SubscriptionCreateParams = {
|
|
118
|
+
customer: customer.stripeId,
|
|
119
|
+
items,
|
|
120
|
+
payment_behavior: this._paymentBehavior,
|
|
121
|
+
expand: ['latest_invoice.payment_intent', 'pending_setup_intent'],
|
|
122
|
+
metadata: {
|
|
123
|
+
strav_user_id: String(extractUserId(user)),
|
|
124
|
+
strav_name: this._name,
|
|
125
|
+
...this._metadata,
|
|
126
|
+
},
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Trial logic
|
|
130
|
+
if (!this._skipTrial) {
|
|
131
|
+
if (this._trialUntil) {
|
|
132
|
+
params.trial_end = Math.floor(this._trialUntil.getTime() / 1000)
|
|
133
|
+
} else if (this._trialDays) {
|
|
134
|
+
params.trial_end = Math.floor(Date.now() / 1000) + this._trialDays * 86400
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (this._coupon) params.coupon = this._coupon
|
|
139
|
+
if (this._promotionCode) params.promotion_code = this._promotionCode
|
|
140
|
+
if (this._anchorBillingCycleOn) {
|
|
141
|
+
params.billing_cycle_anchor = this._anchorBillingCycleOn
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// 3. Create on Stripe
|
|
145
|
+
const stripeSub = await CashierManager.stripe.subscriptions.create(params)
|
|
146
|
+
|
|
147
|
+
// 4. Record locally
|
|
148
|
+
const trialEndsAt = stripeSub.trial_end ? new Date(stripeSub.trial_end * 1000) : null
|
|
149
|
+
|
|
150
|
+
const localSub = await Subscription.create({
|
|
151
|
+
user,
|
|
152
|
+
name: this._name,
|
|
153
|
+
stripeId: stripeSub.id,
|
|
154
|
+
stripeStatus: stripeSub.status,
|
|
155
|
+
stripePriceId: this._items[0]?.price ?? null,
|
|
156
|
+
quantity: this._items[0]?.quantity ?? this._quantity ?? null,
|
|
157
|
+
trialEndsAt,
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
// 5. Record subscription items
|
|
161
|
+
for (const item of stripeSub.items.data) {
|
|
162
|
+
await SubscriptionItem.create({
|
|
163
|
+
subscriptionId: localSub.id,
|
|
164
|
+
stripeId: item.id,
|
|
165
|
+
stripeProductId:
|
|
166
|
+
typeof item.price.product === 'string'
|
|
167
|
+
? item.price.product
|
|
168
|
+
: item.price.product.id,
|
|
169
|
+
stripePriceId: item.price.id,
|
|
170
|
+
quantity: item.quantity ?? null,
|
|
171
|
+
})
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return localSub
|
|
175
|
+
}
|
|
176
|
+
}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import CashierManager from './cashier_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 CashierManager.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 CashierManager.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 CashierManager.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 CashierManager.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 CashierManager.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 CashierManager.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 CashierManager.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'
|
|
148
|
+
? item.price.product
|
|
149
|
+
: item.price.product.id,
|
|
150
|
+
stripePriceId: item.price.id,
|
|
151
|
+
quantity: item.quantity ?? null,
|
|
152
|
+
})
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// ---------------------------------------------------------------------------
|
|
157
|
+
// Internal
|
|
158
|
+
// ---------------------------------------------------------------------------
|
|
159
|
+
|
|
160
|
+
private static hydrate(row: Record<string, unknown>): SubscriptionItemData {
|
|
161
|
+
return {
|
|
162
|
+
id: row.id as number,
|
|
163
|
+
subscriptionId: row.subscription_id as number,
|
|
164
|
+
stripeId: row.stripe_id as string,
|
|
165
|
+
stripeProductId: row.stripe_product_id as string,
|
|
166
|
+
stripePriceId: row.stripe_price_id as string,
|
|
167
|
+
quantity: (row.quantity as number) ?? null,
|
|
168
|
+
createdAt: row.created_at as Date,
|
|
169
|
+
updatedAt: row.updated_at as Date,
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import type Stripe from 'stripe'
|
|
2
|
+
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
// Config
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
|
|
7
|
+
export interface CashierConfig {
|
|
8
|
+
/** Stripe secret key. */
|
|
9
|
+
secret: string
|
|
10
|
+
/** Stripe publishable key (passed to frontend). */
|
|
11
|
+
key: string
|
|
12
|
+
/** Stripe webhook signing secret. */
|
|
13
|
+
webhookSecret: string
|
|
14
|
+
/** Default currency code (lowercase). */
|
|
15
|
+
currency: string
|
|
16
|
+
/** The user model's primary key property name (e.g. 'id', 'uid'). */
|
|
17
|
+
userKey: string
|
|
18
|
+
/** URL prefix for Checkout success/cancel. */
|
|
19
|
+
urls: {
|
|
20
|
+
success: string
|
|
21
|
+
cancel: string
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
// Data Records
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
|
|
29
|
+
export interface CustomerData {
|
|
30
|
+
id: number
|
|
31
|
+
userId: string | number
|
|
32
|
+
stripeId: string
|
|
33
|
+
pmType: string | null
|
|
34
|
+
pmLastFour: string | null
|
|
35
|
+
trialEndsAt: Date | null
|
|
36
|
+
createdAt: Date
|
|
37
|
+
updatedAt: Date
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface SubscriptionData {
|
|
41
|
+
id: number
|
|
42
|
+
userId: string | number
|
|
43
|
+
name: string
|
|
44
|
+
stripeId: string
|
|
45
|
+
stripeStatus: string
|
|
46
|
+
stripePriceId: string | null
|
|
47
|
+
quantity: number | null
|
|
48
|
+
trialEndsAt: Date | null
|
|
49
|
+
endsAt: Date | null
|
|
50
|
+
createdAt: Date
|
|
51
|
+
updatedAt: Date
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface SubscriptionItemData {
|
|
55
|
+
id: number
|
|
56
|
+
subscriptionId: number
|
|
57
|
+
stripeId: string
|
|
58
|
+
stripeProductId: string
|
|
59
|
+
stripePriceId: string
|
|
60
|
+
quantity: number | null
|
|
61
|
+
createdAt: Date
|
|
62
|
+
updatedAt: Date
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface ReceiptData {
|
|
66
|
+
id: number
|
|
67
|
+
userId: string | number
|
|
68
|
+
stripeId: string
|
|
69
|
+
amount: number
|
|
70
|
+
currency: string
|
|
71
|
+
description: string | null
|
|
72
|
+
receiptUrl: string | null
|
|
73
|
+
createdAt: Date
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// ---------------------------------------------------------------------------
|
|
77
|
+
// Subscription Status
|
|
78
|
+
// ---------------------------------------------------------------------------
|
|
79
|
+
|
|
80
|
+
export const SubscriptionStatus = {
|
|
81
|
+
Active: 'active',
|
|
82
|
+
Canceled: 'canceled',
|
|
83
|
+
Incomplete: 'incomplete',
|
|
84
|
+
IncompleteExpired: 'incomplete_expired',
|
|
85
|
+
PastDue: 'past_due',
|
|
86
|
+
Paused: 'paused',
|
|
87
|
+
Trialing: 'trialing',
|
|
88
|
+
Unpaid: 'unpaid',
|
|
89
|
+
} as const
|
|
90
|
+
|
|
91
|
+
export type SubscriptionStatusValue = (typeof SubscriptionStatus)[keyof typeof SubscriptionStatus]
|
|
92
|
+
|
|
93
|
+
// ---------------------------------------------------------------------------
|
|
94
|
+
// Webhook
|
|
95
|
+
// ---------------------------------------------------------------------------
|
|
96
|
+
|
|
97
|
+
export type WebhookEventHandler = (event: Stripe.Event) => void | Promise<void>
|
package/src/webhook.ts
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import type Stripe from 'stripe'
|
|
2
|
+
import type Context from '@stravigor/core/http/context'
|
|
3
|
+
import type { Handler } from '@stravigor/core/http'
|
|
4
|
+
import CashierManager from './cashier_manager.ts'
|
|
5
|
+
import Customer from './customer.ts'
|
|
6
|
+
import Subscription from './subscription.ts'
|
|
7
|
+
import SubscriptionItem from './subscription_item.ts'
|
|
8
|
+
import { WebhookSignatureError } from './errors.ts'
|
|
9
|
+
import type { WebhookEventHandler } from './types.ts'
|
|
10
|
+
|
|
11
|
+
/** Registry of custom webhook event handlers. */
|
|
12
|
+
const customHandlers = new Map<string, WebhookEventHandler[]>()
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Register a custom handler for a Stripe webhook event type.
|
|
16
|
+
*
|
|
17
|
+
* @example
|
|
18
|
+
* import { onWebhookEvent } from '@stravigor/cashier/webhook'
|
|
19
|
+
*
|
|
20
|
+
* onWebhookEvent('invoice.payment_failed', async (event) => {
|
|
21
|
+
* const invoice = event.data.object as Stripe.Invoice
|
|
22
|
+
* // Send notification to user...
|
|
23
|
+
* })
|
|
24
|
+
*/
|
|
25
|
+
export function onWebhookEvent(eventType: string, handler: WebhookEventHandler): void {
|
|
26
|
+
const handlers = customHandlers.get(eventType) ?? []
|
|
27
|
+
handlers.push(handler)
|
|
28
|
+
customHandlers.set(eventType, handlers)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Create a route handler for Stripe webhooks.
|
|
33
|
+
*
|
|
34
|
+
* Verifies the Stripe signature, dispatches built-in handlers to keep
|
|
35
|
+
* local DB in sync, then calls any custom handlers registered via
|
|
36
|
+
* `onWebhookEvent()`.
|
|
37
|
+
*
|
|
38
|
+
* @example
|
|
39
|
+
* import { stripeWebhook } from '@stravigor/cashier/webhook'
|
|
40
|
+
* router.post('/stripe/webhook', stripeWebhook())
|
|
41
|
+
*/
|
|
42
|
+
export function stripeWebhook(): Handler {
|
|
43
|
+
return async (ctx: Context): Promise<Response> => {
|
|
44
|
+
const signature = ctx.header('stripe-signature')
|
|
45
|
+
if (!signature) {
|
|
46
|
+
return ctx.json({ error: 'Missing stripe-signature header' }, 400)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const rawBody = await ctx.request.text()
|
|
50
|
+
const webhookSecret = CashierManager.config.webhookSecret
|
|
51
|
+
|
|
52
|
+
let event: Stripe.Event
|
|
53
|
+
try {
|
|
54
|
+
event = CashierManager.stripe.webhooks.constructEvent(rawBody, signature, webhookSecret)
|
|
55
|
+
} catch {
|
|
56
|
+
throw new WebhookSignatureError()
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Dispatch built-in handlers
|
|
60
|
+
await handleBuiltinEvent(event)
|
|
61
|
+
|
|
62
|
+
// Dispatch custom handlers
|
|
63
|
+
const handlers = customHandlers.get(event.type) ?? []
|
|
64
|
+
for (const handler of handlers) {
|
|
65
|
+
await handler(event)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return ctx.json({ received: true }, 200)
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ---------------------------------------------------------------------------
|
|
73
|
+
// Built-in event handling: keeps local DB in sync with Stripe
|
|
74
|
+
// ---------------------------------------------------------------------------
|
|
75
|
+
|
|
76
|
+
async function handleBuiltinEvent(event: Stripe.Event): Promise<void> {
|
|
77
|
+
switch (event.type) {
|
|
78
|
+
// ---- Customer Events ----
|
|
79
|
+
|
|
80
|
+
case 'customer.updated': {
|
|
81
|
+
const stripeCustomer = event.data.object as Stripe.Customer
|
|
82
|
+
const defaultPm = stripeCustomer.invoice_settings?.default_payment_method
|
|
83
|
+
if (defaultPm && typeof defaultPm !== 'string') {
|
|
84
|
+
await Customer.updateDefaultPaymentMethod(stripeCustomer.id, defaultPm)
|
|
85
|
+
}
|
|
86
|
+
break
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
case 'customer.deleted': {
|
|
90
|
+
const stripeCustomer = event.data.object as Stripe.Customer
|
|
91
|
+
const customer = await Customer.findByStripeId(stripeCustomer.id)
|
|
92
|
+
if (customer) {
|
|
93
|
+
// Clean up all local subscription records
|
|
94
|
+
const subs = await Subscription.findByUser(customer.userId)
|
|
95
|
+
for (const sub of subs) {
|
|
96
|
+
await Subscription.delete(sub.id)
|
|
97
|
+
}
|
|
98
|
+
await Customer.deleteByStripeId(stripeCustomer.id)
|
|
99
|
+
}
|
|
100
|
+
break
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// ---- Subscription Events ----
|
|
104
|
+
|
|
105
|
+
case 'customer.subscription.created': {
|
|
106
|
+
const stripeSub = event.data.object as Stripe.Subscription
|
|
107
|
+
const customerId =
|
|
108
|
+
typeof stripeSub.customer === 'string' ? stripeSub.customer : stripeSub.customer.id
|
|
109
|
+
const customer = await Customer.findByStripeId(customerId)
|
|
110
|
+
|
|
111
|
+
if (customer) {
|
|
112
|
+
const existing = await Subscription.findByStripeId(stripeSub.id)
|
|
113
|
+
if (!existing) {
|
|
114
|
+
const name = stripeSub.metadata?.strav_name ?? 'default'
|
|
115
|
+
const localSub = await Subscription.create({
|
|
116
|
+
user: customer.userId,
|
|
117
|
+
name,
|
|
118
|
+
stripeId: stripeSub.id,
|
|
119
|
+
stripeStatus: stripeSub.status,
|
|
120
|
+
stripePriceId: stripeSub.items.data[0]?.price.id ?? null,
|
|
121
|
+
quantity: stripeSub.items.data[0]?.quantity ?? null,
|
|
122
|
+
trialEndsAt: stripeSub.trial_end ? new Date(stripeSub.trial_end * 1000) : null,
|
|
123
|
+
})
|
|
124
|
+
await SubscriptionItem.syncFromStripe(localSub, localSub.id)
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
break
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
case 'customer.subscription.updated': {
|
|
131
|
+
const stripeSub = event.data.object as Stripe.Subscription
|
|
132
|
+
|
|
133
|
+
const endsAt = stripeSub.cancel_at
|
|
134
|
+
? new Date(stripeSub.cancel_at * 1000)
|
|
135
|
+
: stripeSub.canceled_at
|
|
136
|
+
? new Date(stripeSub.current_period_end * 1000)
|
|
137
|
+
: null
|
|
138
|
+
|
|
139
|
+
await Subscription.syncStripeStatus(stripeSub.id, stripeSub.status, endsAt)
|
|
140
|
+
|
|
141
|
+
// Sync items and metadata
|
|
142
|
+
const localSub = await Subscription.findByStripeId(stripeSub.id)
|
|
143
|
+
if (localSub) {
|
|
144
|
+
await SubscriptionItem.syncFromStripe(localSub, localSub.id)
|
|
145
|
+
|
|
146
|
+
// Update price_id, quantity, and trial from first item
|
|
147
|
+
const firstItem = stripeSub.items.data[0]
|
|
148
|
+
if (firstItem) {
|
|
149
|
+
await CashierManager.db.sql`
|
|
150
|
+
UPDATE "subscription"
|
|
151
|
+
SET "stripe_price_id" = ${firstItem.price.id},
|
|
152
|
+
"quantity" = ${firstItem.quantity ?? null},
|
|
153
|
+
"trial_ends_at" = ${stripeSub.trial_end ? new Date(stripeSub.trial_end * 1000) : null},
|
|
154
|
+
"updated_at" = NOW()
|
|
155
|
+
WHERE "stripe_id" = ${stripeSub.id}
|
|
156
|
+
`
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
break
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
case 'customer.subscription.deleted': {
|
|
163
|
+
const stripeSub = event.data.object as Stripe.Subscription
|
|
164
|
+
await Subscription.syncStripeStatus(stripeSub.id, 'canceled', new Date())
|
|
165
|
+
break
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { env } from '@stravigor/core/helpers'
|
|
2
|
+
|
|
3
|
+
export default {
|
|
4
|
+
/** Stripe secret key. */
|
|
5
|
+
secret: env('STRIPE_SECRET', ''),
|
|
6
|
+
|
|
7
|
+
/** Stripe publishable key. */
|
|
8
|
+
key: env('STRIPE_KEY', ''),
|
|
9
|
+
|
|
10
|
+
/** Stripe webhook signing secret. */
|
|
11
|
+
webhookSecret: env('STRIPE_WEBHOOK_SECRET', ''),
|
|
12
|
+
|
|
13
|
+
/** Default currency for charges. */
|
|
14
|
+
currency: 'usd',
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The user model's primary key property (determines FK column name).
|
|
18
|
+
* 'id' → user_id, 'uid' → user_uid, etc.
|
|
19
|
+
*/
|
|
20
|
+
userKey: 'id',
|
|
21
|
+
|
|
22
|
+
/** URLs for Stripe Checkout success/cancel redirects. */
|
|
23
|
+
urls: {
|
|
24
|
+
success: env('APP_URL', 'http://localhost:3000') + '/billing/success',
|
|
25
|
+
cancel: env('APP_URL', 'http://localhost:3000') + '/billing/cancel',
|
|
26
|
+
},
|
|
27
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { defineSchema, t, Archetype } from '@stravigor/core/schema'
|
|
2
|
+
|
|
3
|
+
export default defineSchema('customer', {
|
|
4
|
+
archetype: Archetype.Component,
|
|
5
|
+
parent: 'user',
|
|
6
|
+
fields: {
|
|
7
|
+
stripeId: t.varchar(255).required().unique().index(),
|
|
8
|
+
pmType: t.varchar(50).nullable(),
|
|
9
|
+
pmLastFour: t.varchar(4).nullable(),
|
|
10
|
+
trialEndsAt: t.timestamptz().nullable(),
|
|
11
|
+
},
|
|
12
|
+
})
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { defineSchema, t, Archetype } from '@stravigor/core/schema'
|
|
2
|
+
|
|
3
|
+
export default defineSchema('receipt', {
|
|
4
|
+
archetype: Archetype.Event,
|
|
5
|
+
parent: 'user',
|
|
6
|
+
fields: {
|
|
7
|
+
stripeId: t.varchar(255).required().unique().index(),
|
|
8
|
+
amount: t.integer().required(),
|
|
9
|
+
currency: t.varchar(3).required(),
|
|
10
|
+
description: t.text().nullable(),
|
|
11
|
+
receiptUrl: t.text().nullable(),
|
|
12
|
+
},
|
|
13
|
+
})
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { defineSchema, t, Archetype } from '@stravigor/core/schema'
|
|
2
|
+
|
|
3
|
+
export default defineSchema('subscription', {
|
|
4
|
+
archetype: Archetype.Component,
|
|
5
|
+
parent: 'user',
|
|
6
|
+
fields: {
|
|
7
|
+
name: t.varchar(255).required().index(),
|
|
8
|
+
stripeId: t.varchar(255).required().unique().index(),
|
|
9
|
+
stripeStatus: t.varchar(50).required(),
|
|
10
|
+
stripePriceId: t.varchar(255).nullable(),
|
|
11
|
+
quantity: t.integer().nullable(),
|
|
12
|
+
trialEndsAt: t.timestamptz().nullable(),
|
|
13
|
+
endsAt: t.timestamptz().nullable(),
|
|
14
|
+
},
|
|
15
|
+
})
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { defineSchema, t, Archetype } from '@stravigor/core/schema'
|
|
2
|
+
|
|
3
|
+
export default defineSchema('subscription_item', {
|
|
4
|
+
archetype: Archetype.Component,
|
|
5
|
+
parent: 'subscription',
|
|
6
|
+
fields: {
|
|
7
|
+
stripeId: t.varchar(255).required().unique().index(),
|
|
8
|
+
stripeProductId: t.varchar(255).required().index(),
|
|
9
|
+
stripePriceId: t.varchar(255).required().index(),
|
|
10
|
+
quantity: t.integer().nullable(),
|
|
11
|
+
},
|
|
12
|
+
})
|