@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
package/README.md
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# @stravigor/stripe
|
|
2
|
+
|
|
3
|
+
Stripe billing for the [Strav](https://www.npmjs.com/package/@stravigor/core) framework. Subscriptions, one-time charges, checkout sessions, invoices, payment methods, and webhooks.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
bun add @stravigor/stripe
|
|
9
|
+
bun strav install stripe
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
Requires `@stravigor/core` as a peer dependency.
|
|
13
|
+
|
|
14
|
+
## Setup
|
|
15
|
+
|
|
16
|
+
```ts
|
|
17
|
+
import { StripeProvider } from '@stravigor/stripe'
|
|
18
|
+
|
|
19
|
+
app.use(new StripeProvider())
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## Usage
|
|
23
|
+
|
|
24
|
+
```ts
|
|
25
|
+
import { stripe } from '@stravigor/stripe'
|
|
26
|
+
|
|
27
|
+
// Create a customer
|
|
28
|
+
const customer = await stripe.createOrGetCustomer(user)
|
|
29
|
+
|
|
30
|
+
// Start a subscription
|
|
31
|
+
const subscription = await stripe
|
|
32
|
+
.newSubscription('default', 'price_xxx')
|
|
33
|
+
.create(user)
|
|
34
|
+
|
|
35
|
+
// Checkout session
|
|
36
|
+
const checkout = await stripe
|
|
37
|
+
.newCheckout()
|
|
38
|
+
.item('price_xxx')
|
|
39
|
+
.successUrl('/success')
|
|
40
|
+
.cancelUrl('/cancel')
|
|
41
|
+
.create(user)
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Billable Mixin
|
|
45
|
+
|
|
46
|
+
```ts
|
|
47
|
+
import { billable } from '@stravigor/stripe'
|
|
48
|
+
|
|
49
|
+
class User extends billable(BaseModel) {
|
|
50
|
+
// adds subscription, invoice, and payment helpers
|
|
51
|
+
}
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## Webhooks
|
|
55
|
+
|
|
56
|
+
```ts
|
|
57
|
+
import { stripeWebhook, onWebhookEvent } from '@stravigor/stripe'
|
|
58
|
+
|
|
59
|
+
onWebhookEvent('customer.subscription.updated', async (event) => {
|
|
60
|
+
// handle subscription changes
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
router.post('/stripe/webhook', stripeWebhook())
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## Documentation
|
|
67
|
+
|
|
68
|
+
See the full [Stripe billing guide](../../guides/stripe.md).
|
|
69
|
+
|
|
70
|
+
## License
|
|
71
|
+
|
|
72
|
+
MIT
|
package/package.json
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@strav/stripe",
|
|
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": [
|
|
12
|
+
"src/",
|
|
13
|
+
"stubs/",
|
|
14
|
+
"package.json",
|
|
15
|
+
"tsconfig.json"
|
|
16
|
+
],
|
|
17
|
+
"peerDependencies": {
|
|
18
|
+
"@strav/kernel": "0.1.0",
|
|
19
|
+
"@strav/database": "0.1.0",
|
|
20
|
+
"@strav/http": "0.1.0"
|
|
21
|
+
},
|
|
22
|
+
"dependencies": {
|
|
23
|
+
"stripe": "^17.4.0"
|
|
24
|
+
},
|
|
25
|
+
"scripts": {
|
|
26
|
+
"test": "bun test tests/",
|
|
27
|
+
"typecheck": "tsc --noEmit"
|
|
28
|
+
}
|
|
29
|
+
}
|
package/src/billable.ts
ADDED
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
import type Stripe from 'stripe'
|
|
2
|
+
import type { BaseModel } from '@stravigor/database'
|
|
3
|
+
import type { NormalizeConstructor } from '@stravigor/kernel'
|
|
4
|
+
import { extractUserId } from '@stravigor/database'
|
|
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 StripeManager from './stripe_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/database'
|
|
53
|
+
* import { billable } from '@stravigor/stripe'
|
|
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/kernel'
|
|
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(params?: Stripe.CustomerCreateParams): Promise<CustomerData> {
|
|
74
|
+
return Customer.createOrGet(this, params)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Get the local customer record. */
|
|
78
|
+
async customer(): Promise<CustomerData | null> {
|
|
79
|
+
return Customer.findByUser(this)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Get the Stripe customer ID. */
|
|
83
|
+
async stripeId(): Promise<string | null> {
|
|
84
|
+
const customer = await Customer.findByUser(this)
|
|
85
|
+
return customer?.stripeId ?? null
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Check if the user has a Stripe customer record. */
|
|
89
|
+
async hasStripeId(): Promise<boolean> {
|
|
90
|
+
return (await Customer.findByUser(this)) !== null
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// ----- Subscriptions -----
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Start building a new subscription.
|
|
97
|
+
*
|
|
98
|
+
* @example
|
|
99
|
+
* await user.newSubscription('pro', 'price_xxx').trialDays(14).create()
|
|
100
|
+
* await user.newSubscription('enterprise', 'price_a', 'price_b').create()
|
|
101
|
+
*/
|
|
102
|
+
newSubscription(name: string, ...prices: string[]): BoundSubscriptionBuilder {
|
|
103
|
+
return new BoundSubscriptionBuilder(this, name, ...prices)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Create a subscription immediately (shorthand).
|
|
108
|
+
*
|
|
109
|
+
* @example
|
|
110
|
+
* await user.subscribe('pro', 'price_xxx')
|
|
111
|
+
*/
|
|
112
|
+
async subscribe(name: string, priceId: string): Promise<SubscriptionData> {
|
|
113
|
+
return new BoundSubscriptionBuilder(this, name, priceId).create()
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Get a specific subscription by name. */
|
|
117
|
+
async subscription(name: string = 'default'): Promise<SubscriptionData | null> {
|
|
118
|
+
return Subscription.findByName(this, name)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Get all subscriptions. */
|
|
122
|
+
async subscriptions(): Promise<SubscriptionData[]> {
|
|
123
|
+
return Subscription.findByUser(this)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Check if the user has a valid subscription with the given name. */
|
|
127
|
+
async subscribed(name: string = 'default'): Promise<boolean> {
|
|
128
|
+
const sub = await Subscription.findByName(this, name)
|
|
129
|
+
return sub !== null && Subscription.valid(sub)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Check if the user is on a trial for the given subscription. */
|
|
133
|
+
async onTrial(name: string = 'default'): Promise<boolean> {
|
|
134
|
+
const sub = await Subscription.findByName(this, name)
|
|
135
|
+
if (sub) return Subscription.onTrial(sub)
|
|
136
|
+
|
|
137
|
+
// Also check customer-level trial (generic trial)
|
|
138
|
+
const customer = await Customer.findByUser(this)
|
|
139
|
+
return (
|
|
140
|
+
customer?.trialEndsAt !== null &&
|
|
141
|
+
customer?.trialEndsAt !== undefined &&
|
|
142
|
+
customer.trialEndsAt.getTime() > Date.now()
|
|
143
|
+
)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Check if the user has an active subscription to a specific price ID. */
|
|
147
|
+
async subscribedToPrice(priceId: string): Promise<boolean> {
|
|
148
|
+
const subs = await Subscription.findByUser(this)
|
|
149
|
+
return subs.some(sub => Subscription.valid(sub) && sub.stripePriceId === priceId)
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Check if the subscription is on a grace period. */
|
|
153
|
+
async onGracePeriod(name: string = 'default'): Promise<boolean> {
|
|
154
|
+
const sub = await Subscription.findByName(this, name)
|
|
155
|
+
return sub !== null && Subscription.onGracePeriod(sub)
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// ----- One-time Charges -----
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Create a one-time charge.
|
|
162
|
+
*
|
|
163
|
+
* @example
|
|
164
|
+
* await user.charge(2500, 'pm_xxx', { description: 'Pro-rated upgrade' })
|
|
165
|
+
*/
|
|
166
|
+
async charge(
|
|
167
|
+
amount: number,
|
|
168
|
+
paymentMethodId: string,
|
|
169
|
+
options?: {
|
|
170
|
+
currency?: string
|
|
171
|
+
description?: string
|
|
172
|
+
metadata?: Record<string, string>
|
|
173
|
+
}
|
|
174
|
+
): Promise<Stripe.PaymentIntent> {
|
|
175
|
+
const customer = await Customer.createOrGet(this)
|
|
176
|
+
return StripeManager.stripe.paymentIntents.create({
|
|
177
|
+
amount,
|
|
178
|
+
currency: options?.currency ?? StripeManager.config.currency,
|
|
179
|
+
customer: customer.stripeId,
|
|
180
|
+
payment_method: paymentMethodId,
|
|
181
|
+
confirm: true,
|
|
182
|
+
automatic_payment_methods: {
|
|
183
|
+
enabled: true,
|
|
184
|
+
allow_redirects: 'never',
|
|
185
|
+
},
|
|
186
|
+
description: options?.description,
|
|
187
|
+
metadata: {
|
|
188
|
+
strav_user_id: String(extractUserId(this)),
|
|
189
|
+
...options?.metadata,
|
|
190
|
+
},
|
|
191
|
+
})
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** Refund a payment intent (fully or partially). */
|
|
195
|
+
async refund(paymentIntentId: string, amount?: number): Promise<Stripe.Refund> {
|
|
196
|
+
return StripeManager.stripe.refunds.create({
|
|
197
|
+
payment_intent: paymentIntentId,
|
|
198
|
+
...(amount ? { amount } : {}),
|
|
199
|
+
})
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// ----- Payment Methods -----
|
|
203
|
+
|
|
204
|
+
/** List all payment methods for this user. */
|
|
205
|
+
async paymentMethods(
|
|
206
|
+
type?: Stripe.PaymentMethodListParams.Type
|
|
207
|
+
): Promise<Stripe.PaymentMethod[]> {
|
|
208
|
+
return PaymentMethod.list(this, type)
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** Get the default payment method. */
|
|
212
|
+
async defaultPaymentMethod(): Promise<Stripe.PaymentMethod | null> {
|
|
213
|
+
const customer = await Customer.findByUser(this)
|
|
214
|
+
if (!customer) return null
|
|
215
|
+
const methods = await PaymentMethod.list(this)
|
|
216
|
+
return methods[0] ?? null
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** Set a payment method as default. */
|
|
220
|
+
async setDefaultPaymentMethod(paymentMethodId: string): Promise<void> {
|
|
221
|
+
return PaymentMethod.setDefault(this, paymentMethodId)
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** Create a Stripe SetupIntent for collecting payment info without charging. */
|
|
225
|
+
async createSetupIntent(params?: Stripe.SetupIntentCreateParams): Promise<Stripe.SetupIntent> {
|
|
226
|
+
return Customer.createSetupIntent(this, params)
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// ----- Checkout -----
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Create a Stripe Checkout session.
|
|
233
|
+
*
|
|
234
|
+
* @example
|
|
235
|
+
* const session = await user.checkout([{ price: 'price_xxx', quantity: 1 }])
|
|
236
|
+
*/
|
|
237
|
+
async checkout(
|
|
238
|
+
items: Array<{ price: string; quantity?: number }>
|
|
239
|
+
): Promise<Stripe.Checkout.Session> {
|
|
240
|
+
const builder = new CheckoutBuilder(items)
|
|
241
|
+
return builder.create(this)
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Start building a checkout session fluently.
|
|
246
|
+
*
|
|
247
|
+
* @example
|
|
248
|
+
* const session = await user.newCheckout()
|
|
249
|
+
* .item('price_xxx', 2)
|
|
250
|
+
* .mode('subscription')
|
|
251
|
+
* .subscriptionName('pro')
|
|
252
|
+
* .create()
|
|
253
|
+
*/
|
|
254
|
+
newCheckout(): BoundCheckoutBuilder {
|
|
255
|
+
return new BoundCheckoutBuilder(this)
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// ----- Invoices -----
|
|
259
|
+
|
|
260
|
+
/** List Stripe invoices for this user. */
|
|
261
|
+
async invoices(params?: Stripe.InvoiceListParams): Promise<Stripe.Invoice[]> {
|
|
262
|
+
return Invoice.list(this, params)
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** Preview the upcoming invoice. */
|
|
266
|
+
async upcomingInvoice(
|
|
267
|
+
params?: Stripe.InvoiceRetrieveUpcomingParams
|
|
268
|
+
): Promise<Stripe.UpcomingInvoice | null> {
|
|
269
|
+
return Invoice.upcoming(this, params)
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// ----- Billing Portal -----
|
|
273
|
+
|
|
274
|
+
/** Create a Stripe Customer Portal session URL. */
|
|
275
|
+
async billingPortalUrl(returnUrl?: string): Promise<string> {
|
|
276
|
+
const customer = await Customer.createOrGet(this)
|
|
277
|
+
const session = await StripeManager.stripe.billingPortal.sessions.create({
|
|
278
|
+
customer: customer.stripeId,
|
|
279
|
+
return_url: returnUrl ?? StripeManager.config.urls.success,
|
|
280
|
+
})
|
|
281
|
+
return session.url
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/** The instance type of any billable model. */
|
|
287
|
+
export type BillableInstance = InstanceType<ReturnType<typeof billable>>
|
|
@@ -0,0 +1,130 @@
|
|
|
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
|
+
|
|
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 = StripeManager.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 StripeManager.stripe.checkout.sessions.create(params)
|
|
129
|
+
}
|
|
130
|
+
}
|
package/src/customer.ts
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import type Stripe from 'stripe'
|
|
2
|
+
import { extractUserId } from '@stravigor/database'
|
|
3
|
+
import StripeManager from './stripe_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 StripeManager.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 StripeManager.db.sql
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
private static get fk() {
|
|
21
|
+
return StripeManager.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(`SELECT * FROM "customer" WHERE "${fk}" = $1 LIMIT 1`, [
|
|
29
|
+
userId,
|
|
30
|
+
])
|
|
31
|
+
return rows.length > 0 ? Customer.hydrate(rows[0] as Record<string, unknown>) : null
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Find a customer record by Stripe customer ID. */
|
|
35
|
+
static async findByStripeId(stripeId: string): Promise<CustomerData | null> {
|
|
36
|
+
const rows = await Customer.sql`
|
|
37
|
+
SELECT * FROM "customer" WHERE "stripe_id" = ${stripeId} LIMIT 1
|
|
38
|
+
`
|
|
39
|
+
return rows.length > 0 ? Customer.hydrate(rows[0] as Record<string, unknown>) : null
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Get or create the Stripe customer + local record for a user.
|
|
44
|
+
* Optionally pass params forwarded to `stripe.customers.create()`.
|
|
45
|
+
*/
|
|
46
|
+
static async createOrGet(
|
|
47
|
+
user: unknown,
|
|
48
|
+
params?: Stripe.CustomerCreateParams
|
|
49
|
+
): Promise<CustomerData> {
|
|
50
|
+
const existing = await Customer.findByUser(user)
|
|
51
|
+
if (existing) return existing
|
|
52
|
+
|
|
53
|
+
const userId = extractUserId(user)
|
|
54
|
+
const stripeCustomer = await StripeManager.stripe.customers.create({
|
|
55
|
+
metadata: { strav_user_id: String(userId) },
|
|
56
|
+
...params,
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
const fk = Customer.fk
|
|
60
|
+
const rows = await Customer.sql.unsafe(
|
|
61
|
+
`INSERT INTO "customer" ("${fk}", "stripe_id")
|
|
62
|
+
VALUES ($1, $2)
|
|
63
|
+
RETURNING *`,
|
|
64
|
+
[userId, stripeCustomer.id]
|
|
65
|
+
)
|
|
66
|
+
return Customer.hydrate(rows[0] as Record<string, unknown>)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Update the default payment method on the local record. */
|
|
70
|
+
static async updateDefaultPaymentMethod(
|
|
71
|
+
stripeCustomerId: string,
|
|
72
|
+
paymentMethod: Stripe.PaymentMethod
|
|
73
|
+
): Promise<void> {
|
|
74
|
+
await Customer.sql`
|
|
75
|
+
UPDATE "customer"
|
|
76
|
+
SET "pm_type" = ${paymentMethod.type},
|
|
77
|
+
"pm_last_four" = ${paymentMethod.card?.last4 ?? null},
|
|
78
|
+
"updated_at" = NOW()
|
|
79
|
+
WHERE "stripe_id" = ${stripeCustomerId}
|
|
80
|
+
`
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Create a Stripe SetupIntent for the customer. */
|
|
84
|
+
static async createSetupIntent(
|
|
85
|
+
user: unknown,
|
|
86
|
+
params?: Stripe.SetupIntentCreateParams
|
|
87
|
+
): Promise<Stripe.SetupIntent> {
|
|
88
|
+
const customer = await Customer.createOrGet(user)
|
|
89
|
+
return StripeManager.stripe.setupIntents.create({
|
|
90
|
+
customer: customer.stripeId,
|
|
91
|
+
...params,
|
|
92
|
+
})
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** List all payment methods for a user's Stripe customer. */
|
|
96
|
+
static async paymentMethods(
|
|
97
|
+
user: unknown,
|
|
98
|
+
type: Stripe.PaymentMethodListParams.Type = 'card'
|
|
99
|
+
): Promise<Stripe.PaymentMethod[]> {
|
|
100
|
+
const customer = await Customer.findByUser(user)
|
|
101
|
+
if (!customer) return []
|
|
102
|
+
const result = await StripeManager.stripe.paymentMethods.list({
|
|
103
|
+
customer: customer.stripeId,
|
|
104
|
+
type,
|
|
105
|
+
})
|
|
106
|
+
return result.data
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Detach a payment method from Stripe. */
|
|
110
|
+
static async deletePaymentMethod(paymentMethodId: string): Promise<void> {
|
|
111
|
+
await StripeManager.stripe.paymentMethods.detach(paymentMethodId)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Sync trial_ends_at on the local record. */
|
|
115
|
+
static async updateTrialEndsAt(
|
|
116
|
+
stripeCustomerId: string,
|
|
117
|
+
trialEndsAt: Date | null
|
|
118
|
+
): Promise<void> {
|
|
119
|
+
await Customer.sql`
|
|
120
|
+
UPDATE "customer"
|
|
121
|
+
SET "trial_ends_at" = ${trialEndsAt},
|
|
122
|
+
"updated_at" = NOW()
|
|
123
|
+
WHERE "stripe_id" = ${stripeCustomerId}
|
|
124
|
+
`
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Delete the local customer record. Does NOT delete from Stripe. */
|
|
128
|
+
static async deleteByUser(user: unknown): Promise<void> {
|
|
129
|
+
const userId = extractUserId(user)
|
|
130
|
+
const fk = Customer.fk
|
|
131
|
+
await Customer.sql.unsafe(`DELETE FROM "customer" WHERE "${fk}" = $1`, [userId])
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Delete the local customer record by Stripe ID. */
|
|
135
|
+
static async deleteByStripeId(stripeId: string): Promise<void> {
|
|
136
|
+
await Customer.sql`DELETE FROM "customer" WHERE "stripe_id" = ${stripeId}`
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// ---------------------------------------------------------------------------
|
|
140
|
+
// Internal
|
|
141
|
+
// ---------------------------------------------------------------------------
|
|
142
|
+
|
|
143
|
+
private static hydrate(row: Record<string, unknown>): CustomerData {
|
|
144
|
+
const fk = Customer.fk
|
|
145
|
+
return {
|
|
146
|
+
id: row.id as number,
|
|
147
|
+
userId: row[fk] as string | number,
|
|
148
|
+
stripeId: row.stripe_id as string,
|
|
149
|
+
pmType: (row.pm_type as string) ?? null,
|
|
150
|
+
pmLastFour: (row.pm_last_four as string) ?? null,
|
|
151
|
+
trialEndsAt: (row.trial_ends_at as Date) ?? null,
|
|
152
|
+
createdAt: row.created_at as Date,
|
|
153
|
+
updatedAt: row.updated_at as Date,
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
package/src/errors.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { StravError } from '@stravigor/kernel'
|
|
2
|
+
|
|
3
|
+
/** Base error class for all Stripe billing errors. */
|
|
4
|
+
export class StripeError extends StravError {}
|
|
5
|
+
|
|
6
|
+
/** Thrown when webhook signature verification fails. */
|
|
7
|
+
export class WebhookSignatureError extends StripeError {
|
|
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 StripeError {
|
|
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 StripeError {
|
|
22
|
+
constructor(name: string) {
|
|
23
|
+
super(`No subscription named "${name}" found for this user.`)
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Thrown when a payment method operation fails on Stripe. */
|
|
28
|
+
export class PaymentMethodError extends StripeError {}
|
|
29
|
+
|
|
30
|
+
/** Thrown when a subscription creation fails on Stripe. */
|
|
31
|
+
export class SubscriptionCreationError extends StripeError {}
|