@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/src/helpers.ts
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import type Stripe from 'stripe'
|
|
2
|
+
import CashierManager from './cashier_manager.ts'
|
|
3
|
+
import Customer from './customer.ts'
|
|
4
|
+
import Subscription from './subscription.ts'
|
|
5
|
+
import SubscriptionBuilder from './subscription_builder.ts'
|
|
6
|
+
import CheckoutBuilder from './checkout_builder.ts'
|
|
7
|
+
import Invoice from './invoice.ts'
|
|
8
|
+
import PaymentMethod from './payment_method.ts'
|
|
9
|
+
import Receipt from './receipt.ts'
|
|
10
|
+
import type { CustomerData, SubscriptionData, ReceiptData } from './types.ts'
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Cashier helper object — the primary convenience API.
|
|
14
|
+
*
|
|
15
|
+
* @example
|
|
16
|
+
* import { cashier } from '@stravigor/cashier'
|
|
17
|
+
*
|
|
18
|
+
* // Direct Stripe instance access
|
|
19
|
+
* cashier.stripe.customers.list()
|
|
20
|
+
*
|
|
21
|
+
* // Subscription builder
|
|
22
|
+
* await cashier.newSubscription('pro', 'price_xxx').trialDays(14).create(user)
|
|
23
|
+
*
|
|
24
|
+
* // Checkout builder
|
|
25
|
+
* const session = await cashier.newCheckout().item('price_xxx').mode('payment').create(user)
|
|
26
|
+
*/
|
|
27
|
+
export const cashier = {
|
|
28
|
+
/** Direct access to the configured Stripe SDK instance. */
|
|
29
|
+
get stripe(): Stripe {
|
|
30
|
+
return CashierManager.stripe
|
|
31
|
+
},
|
|
32
|
+
|
|
33
|
+
/** The Stripe publishable key (for frontend use). */
|
|
34
|
+
get key(): string {
|
|
35
|
+
return CashierManager.config.key
|
|
36
|
+
},
|
|
37
|
+
|
|
38
|
+
/** The configured default currency. */
|
|
39
|
+
get currency(): string {
|
|
40
|
+
return CashierManager.config.currency
|
|
41
|
+
},
|
|
42
|
+
|
|
43
|
+
// ----- Customer -----
|
|
44
|
+
|
|
45
|
+
/** Get or create a Stripe customer for a user. */
|
|
46
|
+
createOrGetCustomer(user: unknown, params?: Stripe.CustomerCreateParams): Promise<CustomerData> {
|
|
47
|
+
return Customer.createOrGet(user, params)
|
|
48
|
+
},
|
|
49
|
+
|
|
50
|
+
/** Find the local customer record for a user. */
|
|
51
|
+
findCustomer(user: unknown): Promise<CustomerData | null> {
|
|
52
|
+
return Customer.findByUser(user)
|
|
53
|
+
},
|
|
54
|
+
|
|
55
|
+
// ----- Subscriptions -----
|
|
56
|
+
|
|
57
|
+
/** Start building a new subscription. */
|
|
58
|
+
newSubscription(name: string, ...prices: string[]): SubscriptionBuilder {
|
|
59
|
+
return new SubscriptionBuilder(name, ...prices)
|
|
60
|
+
},
|
|
61
|
+
|
|
62
|
+
/** Find a user's subscription by name. */
|
|
63
|
+
subscription(user: unknown, name: string = 'default'): Promise<SubscriptionData | null> {
|
|
64
|
+
return Subscription.findByName(user, name)
|
|
65
|
+
},
|
|
66
|
+
|
|
67
|
+
/** Check if a user has a valid subscription. */
|
|
68
|
+
async subscribed(user: unknown, name: string = 'default'): Promise<boolean> {
|
|
69
|
+
const sub = await Subscription.findByName(user, name)
|
|
70
|
+
return sub !== null && Subscription.valid(sub)
|
|
71
|
+
},
|
|
72
|
+
|
|
73
|
+
// ----- Checkout -----
|
|
74
|
+
|
|
75
|
+
/** Start building a Stripe Checkout session. */
|
|
76
|
+
newCheckout(): CheckoutBuilder {
|
|
77
|
+
return new CheckoutBuilder()
|
|
78
|
+
},
|
|
79
|
+
|
|
80
|
+
// ----- Invoices -----
|
|
81
|
+
|
|
82
|
+
/** List invoices for a user. */
|
|
83
|
+
invoices(user: unknown, params?: Stripe.InvoiceListParams): Promise<Stripe.Invoice[]> {
|
|
84
|
+
return Invoice.list(user, params)
|
|
85
|
+
},
|
|
86
|
+
|
|
87
|
+
/** Preview the upcoming invoice. */
|
|
88
|
+
upcomingInvoice(user: unknown): Promise<Stripe.UpcomingInvoice | null> {
|
|
89
|
+
return Invoice.upcoming(user)
|
|
90
|
+
},
|
|
91
|
+
|
|
92
|
+
// ----- Payment Methods -----
|
|
93
|
+
|
|
94
|
+
/** List payment methods for a user. */
|
|
95
|
+
paymentMethods(user: unknown): Promise<Stripe.PaymentMethod[]> {
|
|
96
|
+
return PaymentMethod.list(user)
|
|
97
|
+
},
|
|
98
|
+
|
|
99
|
+
/** Set a payment method as default. */
|
|
100
|
+
setDefaultPaymentMethod(user: unknown, paymentMethodId: string): Promise<void> {
|
|
101
|
+
return PaymentMethod.setDefault(user, paymentMethodId)
|
|
102
|
+
},
|
|
103
|
+
|
|
104
|
+
// ----- Receipts -----
|
|
105
|
+
|
|
106
|
+
/** List receipts for a user. */
|
|
107
|
+
receipts(user: unknown): Promise<ReceiptData[]> {
|
|
108
|
+
return Receipt.findByUser(user)
|
|
109
|
+
},
|
|
110
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// Manager
|
|
2
|
+
export { default, default as CashierManager } from './cashier_manager.ts'
|
|
3
|
+
|
|
4
|
+
// Static helpers
|
|
5
|
+
export { default as Customer } from './customer.ts'
|
|
6
|
+
export { default as Subscription } from './subscription.ts'
|
|
7
|
+
export { default as SubscriptionItem } from './subscription_item.ts'
|
|
8
|
+
export { default as Invoice } from './invoice.ts'
|
|
9
|
+
export { default as PaymentMethod } from './payment_method.ts'
|
|
10
|
+
export { default as Receipt } from './receipt.ts'
|
|
11
|
+
|
|
12
|
+
// Builders
|
|
13
|
+
export { default as SubscriptionBuilder } from './subscription_builder.ts'
|
|
14
|
+
export { default as CheckoutBuilder } from './checkout_builder.ts'
|
|
15
|
+
|
|
16
|
+
// Mixin
|
|
17
|
+
export { billable } from './billable.ts'
|
|
18
|
+
export type { BillableInstance } from './billable.ts'
|
|
19
|
+
|
|
20
|
+
// Helper
|
|
21
|
+
export { cashier } from './helpers.ts'
|
|
22
|
+
|
|
23
|
+
// Webhook
|
|
24
|
+
export { stripeWebhook, onWebhookEvent } from './webhook.ts'
|
|
25
|
+
|
|
26
|
+
// Errors
|
|
27
|
+
export {
|
|
28
|
+
CashierError,
|
|
29
|
+
WebhookSignatureError,
|
|
30
|
+
CustomerNotFoundError,
|
|
31
|
+
SubscriptionNotFoundError,
|
|
32
|
+
} from './errors.ts'
|
|
33
|
+
|
|
34
|
+
// Types
|
|
35
|
+
export type {
|
|
36
|
+
CashierConfig,
|
|
37
|
+
CustomerData,
|
|
38
|
+
SubscriptionData,
|
|
39
|
+
SubscriptionItemData,
|
|
40
|
+
ReceiptData,
|
|
41
|
+
SubscriptionStatusValue,
|
|
42
|
+
WebhookEventHandler,
|
|
43
|
+
} from './types.ts'
|
|
44
|
+
export { SubscriptionStatus } from './types.ts'
|
package/src/invoice.ts
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import type Stripe from 'stripe'
|
|
2
|
+
import CashierManager from './cashier_manager.ts'
|
|
3
|
+
import Customer from './customer.ts'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Static helper for Stripe invoice operations.
|
|
7
|
+
*
|
|
8
|
+
* @example
|
|
9
|
+
* const invoices = await Invoice.list(user)
|
|
10
|
+
* const upcoming = await Invoice.upcoming(user)
|
|
11
|
+
* const pdf = await Invoice.pdfUrl('in_xxx')
|
|
12
|
+
*/
|
|
13
|
+
export default class Invoice {
|
|
14
|
+
/** List invoices for a user. */
|
|
15
|
+
static async list(
|
|
16
|
+
user: unknown,
|
|
17
|
+
params?: Stripe.InvoiceListParams
|
|
18
|
+
): Promise<Stripe.Invoice[]> {
|
|
19
|
+
const customer = await Customer.findByUser(user)
|
|
20
|
+
if (!customer) return []
|
|
21
|
+
|
|
22
|
+
const result = await CashierManager.stripe.invoices.list({
|
|
23
|
+
customer: customer.stripeId,
|
|
24
|
+
limit: 24,
|
|
25
|
+
...params,
|
|
26
|
+
})
|
|
27
|
+
return result.data
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Get the upcoming invoice preview (prorations, next charge). */
|
|
31
|
+
static async upcoming(
|
|
32
|
+
user: unknown,
|
|
33
|
+
params?: Stripe.InvoiceRetrieveUpcomingParams
|
|
34
|
+
): Promise<Stripe.UpcomingInvoice | null> {
|
|
35
|
+
const customer = await Customer.findByUser(user)
|
|
36
|
+
if (!customer) return null
|
|
37
|
+
|
|
38
|
+
try {
|
|
39
|
+
return await CashierManager.stripe.invoices.retrieveUpcoming({
|
|
40
|
+
customer: customer.stripeId,
|
|
41
|
+
...params,
|
|
42
|
+
})
|
|
43
|
+
} catch {
|
|
44
|
+
return null
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Retrieve a specific invoice by Stripe ID. */
|
|
49
|
+
static async find(invoiceId: string): Promise<Stripe.Invoice> {
|
|
50
|
+
return CashierManager.stripe.invoices.retrieve(invoiceId)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Get the hosted invoice URL for payment. */
|
|
54
|
+
static async hostedUrl(invoiceId: string): Promise<string | null> {
|
|
55
|
+
const invoice = await CashierManager.stripe.invoices.retrieve(invoiceId)
|
|
56
|
+
return invoice.hosted_invoice_url ?? null
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Get the invoice PDF download URL. */
|
|
60
|
+
static async pdfUrl(invoiceId: string): Promise<string | null> {
|
|
61
|
+
const invoice = await CashierManager.stripe.invoices.retrieve(invoiceId)
|
|
62
|
+
return invoice.invoice_pdf ?? null
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Void an invoice. */
|
|
66
|
+
static async void_(invoiceId: string): Promise<Stripe.Invoice> {
|
|
67
|
+
return CashierManager.stripe.invoices.voidInvoice(invoiceId)
|
|
68
|
+
}
|
|
69
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type Stripe from 'stripe'
|
|
2
|
+
import CashierManager from './cashier_manager.ts'
|
|
3
|
+
import Customer from './customer.ts'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Static helper for managing Stripe payment methods.
|
|
7
|
+
*
|
|
8
|
+
* @example
|
|
9
|
+
* const methods = await PaymentMethod.list(user)
|
|
10
|
+
* await PaymentMethod.setDefault(user, 'pm_xxx')
|
|
11
|
+
*/
|
|
12
|
+
export default class PaymentMethod {
|
|
13
|
+
/** List all payment methods for a user. */
|
|
14
|
+
static async list(
|
|
15
|
+
user: unknown,
|
|
16
|
+
type: Stripe.PaymentMethodListParams.Type = 'card'
|
|
17
|
+
): Promise<Stripe.PaymentMethod[]> {
|
|
18
|
+
return Customer.paymentMethods(user, type)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Retrieve a single payment method from Stripe. */
|
|
22
|
+
static async find(paymentMethodId: string): Promise<Stripe.PaymentMethod> {
|
|
23
|
+
return CashierManager.stripe.paymentMethods.retrieve(paymentMethodId)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Set a payment method as the customer's default. */
|
|
27
|
+
static async setDefault(user: unknown, paymentMethodId: string): Promise<void> {
|
|
28
|
+
const customer = await Customer.createOrGet(user)
|
|
29
|
+
|
|
30
|
+
// Attach if not already attached
|
|
31
|
+
try {
|
|
32
|
+
await CashierManager.stripe.paymentMethods.attach(paymentMethodId, {
|
|
33
|
+
customer: customer.stripeId,
|
|
34
|
+
})
|
|
35
|
+
} catch {
|
|
36
|
+
// Already attached — ignore
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Set as default on Stripe
|
|
40
|
+
await CashierManager.stripe.customers.update(customer.stripeId, {
|
|
41
|
+
invoice_settings: { default_payment_method: paymentMethodId },
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
// Update local record
|
|
45
|
+
const pm = await CashierManager.stripe.paymentMethods.retrieve(paymentMethodId)
|
|
46
|
+
await Customer.updateDefaultPaymentMethod(customer.stripeId, pm)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Detach a payment method from the customer. */
|
|
50
|
+
static async delete(paymentMethodId: string): Promise<void> {
|
|
51
|
+
return Customer.deletePaymentMethod(paymentMethodId)
|
|
52
|
+
}
|
|
53
|
+
}
|
package/src/receipt.ts
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { extractUserId } from '@stravigor/core/helpers'
|
|
2
|
+
import CashierManager from './cashier_manager.ts'
|
|
3
|
+
import type { ReceiptData } from './types.ts'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Static helper for managing one-time payment receipts.
|
|
7
|
+
*
|
|
8
|
+
* @example
|
|
9
|
+
* const receipt = await Receipt.create({ user, stripeId: pi.id, amount: 2500, currency: 'usd' })
|
|
10
|
+
* const receipts = await Receipt.findByUser(user)
|
|
11
|
+
*/
|
|
12
|
+
export default class Receipt {
|
|
13
|
+
private static get sql() {
|
|
14
|
+
return CashierManager.db.sql
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
private static get fk() {
|
|
18
|
+
return CashierManager.userFkColumn
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Record a one-time payment receipt. */
|
|
22
|
+
static async create(data: {
|
|
23
|
+
user: unknown
|
|
24
|
+
stripeId: string
|
|
25
|
+
amount: number
|
|
26
|
+
currency: string
|
|
27
|
+
description?: string | null
|
|
28
|
+
receiptUrl?: string | null
|
|
29
|
+
}): Promise<ReceiptData> {
|
|
30
|
+
const userId = extractUserId(data.user)
|
|
31
|
+
const fk = Receipt.fk
|
|
32
|
+
const rows = await Receipt.sql.unsafe(
|
|
33
|
+
`INSERT INTO "receipt" ("${fk}", "stripe_id", "amount", "currency", "description", "receipt_url")
|
|
34
|
+
VALUES ($1, $2, $3, $4, $5, $6)
|
|
35
|
+
RETURNING *`,
|
|
36
|
+
[
|
|
37
|
+
userId,
|
|
38
|
+
data.stripeId,
|
|
39
|
+
data.amount,
|
|
40
|
+
data.currency,
|
|
41
|
+
data.description ?? null,
|
|
42
|
+
data.receiptUrl ?? null,
|
|
43
|
+
]
|
|
44
|
+
)
|
|
45
|
+
return Receipt.hydrate(rows[0] as Record<string, unknown>)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Find all receipts for a user, newest first. */
|
|
49
|
+
static async findByUser(user: unknown): Promise<ReceiptData[]> {
|
|
50
|
+
const userId = extractUserId(user)
|
|
51
|
+
const fk = Receipt.fk
|
|
52
|
+
const rows = await Receipt.sql.unsafe(
|
|
53
|
+
`SELECT * FROM "receipt" WHERE "${fk}" = $1 ORDER BY "created_at" DESC`,
|
|
54
|
+
[userId]
|
|
55
|
+
)
|
|
56
|
+
return rows.map((r: any) => Receipt.hydrate(r))
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Find a receipt by Stripe payment intent ID. */
|
|
60
|
+
static async findByStripeId(stripeId: string): Promise<ReceiptData | null> {
|
|
61
|
+
const rows = await Receipt.sql`
|
|
62
|
+
SELECT * FROM "receipt" WHERE "stripe_id" = ${stripeId} LIMIT 1
|
|
63
|
+
`
|
|
64
|
+
return rows.length > 0 ? Receipt.hydrate(rows[0] as Record<string, unknown>) : null
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// ---------------------------------------------------------------------------
|
|
68
|
+
// Internal
|
|
69
|
+
// ---------------------------------------------------------------------------
|
|
70
|
+
|
|
71
|
+
private static hydrate(row: Record<string, unknown>): ReceiptData {
|
|
72
|
+
const fk = Receipt.fk
|
|
73
|
+
return {
|
|
74
|
+
id: row.id as number,
|
|
75
|
+
userId: row[fk] as string | number,
|
|
76
|
+
stripeId: row.stripe_id as string,
|
|
77
|
+
amount: row.amount as number,
|
|
78
|
+
currency: row.currency as string,
|
|
79
|
+
description: (row.description as string) ?? null,
|
|
80
|
+
receiptUrl: (row.receipt_url as string) ?? null,
|
|
81
|
+
createdAt: row.created_at as Date,
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
import { extractUserId } from '@stravigor/core/helpers'
|
|
2
|
+
import CashierManager from './cashier_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 CashierManager.db.sql
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
private static get fk() {
|
|
19
|
+
return CashierManager.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 CashierManager.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 CashierManager.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 CashierManager.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 CashierManager.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 CashierManager.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 CashierManager.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 CashierManager.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
|
+
}
|