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