@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/src/types.ts
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import type Stripe from 'stripe'
|
|
2
|
+
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
// Config
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
|
|
7
|
+
export interface StripeConfig {
|
|
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,171 @@
|
|
|
1
|
+
import type Stripe from 'stripe'
|
|
2
|
+
import type { Context, Handler } from '@stravigor/http'
|
|
3
|
+
import StripeManager from './stripe_manager.ts'
|
|
4
|
+
import Customer from './customer.ts'
|
|
5
|
+
import Subscription from './subscription.ts'
|
|
6
|
+
import SubscriptionItem from './subscription_item.ts'
|
|
7
|
+
import { WebhookSignatureError } from './errors.ts'
|
|
8
|
+
import type { WebhookEventHandler } from './types.ts'
|
|
9
|
+
|
|
10
|
+
/** Registry of custom webhook event handlers. */
|
|
11
|
+
const customHandlers = new Map<string, WebhookEventHandler[]>()
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Register a custom handler for a Stripe webhook event type.
|
|
15
|
+
*
|
|
16
|
+
* @example
|
|
17
|
+
* import { onWebhookEvent } from '@stravigor/stripe/webhook'
|
|
18
|
+
*
|
|
19
|
+
* onWebhookEvent('invoice.payment_failed', async (event) => {
|
|
20
|
+
* const invoice = event.data.object as Stripe.Invoice
|
|
21
|
+
* // Send notification to user...
|
|
22
|
+
* })
|
|
23
|
+
*/
|
|
24
|
+
export function onWebhookEvent(eventType: string, handler: WebhookEventHandler): void {
|
|
25
|
+
const handlers = customHandlers.get(eventType) ?? []
|
|
26
|
+
handlers.push(handler)
|
|
27
|
+
customHandlers.set(eventType, handlers)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Create a route handler for Stripe webhooks.
|
|
32
|
+
*
|
|
33
|
+
* Verifies the Stripe signature, dispatches built-in handlers to keep
|
|
34
|
+
* local DB in sync, then calls any custom handlers registered via
|
|
35
|
+
* `onWebhookEvent()`.
|
|
36
|
+
*
|
|
37
|
+
* @example
|
|
38
|
+
* import { stripeWebhook } from '@stravigor/stripe/webhook'
|
|
39
|
+
* router.post('/stripe/webhook', stripeWebhook())
|
|
40
|
+
*/
|
|
41
|
+
export function stripeWebhook(): Handler {
|
|
42
|
+
return async (ctx: Context): Promise<Response> => {
|
|
43
|
+
const signature = ctx.header('stripe-signature')
|
|
44
|
+
if (!signature) {
|
|
45
|
+
return ctx.json({ error: 'Missing stripe-signature header' }, 400)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const rawBody = await ctx.request.text()
|
|
49
|
+
const webhookSecret = StripeManager.config.webhookSecret
|
|
50
|
+
|
|
51
|
+
let event: Stripe.Event
|
|
52
|
+
try {
|
|
53
|
+
event = await StripeManager.stripe.webhooks.constructEventAsync(
|
|
54
|
+
rawBody,
|
|
55
|
+
signature,
|
|
56
|
+
webhookSecret
|
|
57
|
+
)
|
|
58
|
+
} catch {
|
|
59
|
+
throw new WebhookSignatureError()
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Dispatch built-in handlers
|
|
63
|
+
await handleBuiltinEvent(event)
|
|
64
|
+
|
|
65
|
+
// Dispatch custom handlers
|
|
66
|
+
const handlers = customHandlers.get(event.type) ?? []
|
|
67
|
+
for (const handler of handlers) {
|
|
68
|
+
await handler(event)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return ctx.json({ received: true }, 200)
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ---------------------------------------------------------------------------
|
|
76
|
+
// Built-in event handling: keeps local DB in sync with Stripe
|
|
77
|
+
// ---------------------------------------------------------------------------
|
|
78
|
+
|
|
79
|
+
async function handleBuiltinEvent(event: Stripe.Event): Promise<void> {
|
|
80
|
+
switch (event.type) {
|
|
81
|
+
// ---- Customer Events ----
|
|
82
|
+
|
|
83
|
+
case 'customer.updated': {
|
|
84
|
+
const stripeCustomer = event.data.object as Stripe.Customer
|
|
85
|
+
const defaultPm = stripeCustomer.invoice_settings?.default_payment_method
|
|
86
|
+
if (defaultPm && typeof defaultPm !== 'string') {
|
|
87
|
+
await Customer.updateDefaultPaymentMethod(stripeCustomer.id, defaultPm)
|
|
88
|
+
}
|
|
89
|
+
break
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
case 'customer.deleted': {
|
|
93
|
+
const stripeCustomer = event.data.object as Stripe.Customer
|
|
94
|
+
const customer = await Customer.findByStripeId(stripeCustomer.id)
|
|
95
|
+
if (customer) {
|
|
96
|
+
// Clean up all local subscription records
|
|
97
|
+
const subs = await Subscription.findByUser(customer.userId)
|
|
98
|
+
for (const sub of subs) {
|
|
99
|
+
await Subscription.delete(sub.id)
|
|
100
|
+
}
|
|
101
|
+
await Customer.deleteByStripeId(stripeCustomer.id)
|
|
102
|
+
}
|
|
103
|
+
break
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// ---- Subscription Events ----
|
|
107
|
+
|
|
108
|
+
case 'customer.subscription.created': {
|
|
109
|
+
const stripeSub = event.data.object as Stripe.Subscription
|
|
110
|
+
const customerId =
|
|
111
|
+
typeof stripeSub.customer === 'string' ? stripeSub.customer : stripeSub.customer.id
|
|
112
|
+
const customer = await Customer.findByStripeId(customerId)
|
|
113
|
+
|
|
114
|
+
if (customer) {
|
|
115
|
+
const existing = await Subscription.findByStripeId(stripeSub.id)
|
|
116
|
+
if (!existing) {
|
|
117
|
+
const name = stripeSub.metadata?.strav_name ?? 'default'
|
|
118
|
+
const localSub = await Subscription.create({
|
|
119
|
+
user: customer.userId,
|
|
120
|
+
name,
|
|
121
|
+
stripeId: stripeSub.id,
|
|
122
|
+
stripeStatus: stripeSub.status,
|
|
123
|
+
stripePriceId: stripeSub.items.data[0]?.price.id ?? null,
|
|
124
|
+
quantity: stripeSub.items.data[0]?.quantity ?? null,
|
|
125
|
+
trialEndsAt: stripeSub.trial_end ? new Date(stripeSub.trial_end * 1000) : null,
|
|
126
|
+
})
|
|
127
|
+
await SubscriptionItem.syncFromStripe(localSub, localSub.id)
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
break
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
case 'customer.subscription.updated': {
|
|
134
|
+
const stripeSub = event.data.object as Stripe.Subscription
|
|
135
|
+
|
|
136
|
+
const endsAt = stripeSub.cancel_at
|
|
137
|
+
? new Date(stripeSub.cancel_at * 1000)
|
|
138
|
+
: stripeSub.canceled_at
|
|
139
|
+
? new Date(stripeSub.current_period_end * 1000)
|
|
140
|
+
: null
|
|
141
|
+
|
|
142
|
+
await Subscription.syncStripeStatus(stripeSub.id, stripeSub.status, endsAt)
|
|
143
|
+
|
|
144
|
+
// Sync items and metadata
|
|
145
|
+
const localSub = await Subscription.findByStripeId(stripeSub.id)
|
|
146
|
+
if (localSub) {
|
|
147
|
+
await SubscriptionItem.syncFromStripe(localSub, localSub.id)
|
|
148
|
+
|
|
149
|
+
// Update price_id, quantity, and trial from first item
|
|
150
|
+
const firstItem = stripeSub.items.data[0]
|
|
151
|
+
if (firstItem) {
|
|
152
|
+
await StripeManager.db.sql`
|
|
153
|
+
UPDATE "subscription"
|
|
154
|
+
SET "stripe_price_id" = ${firstItem.price.id},
|
|
155
|
+
"quantity" = ${firstItem.quantity ?? null},
|
|
156
|
+
"trial_ends_at" = ${stripeSub.trial_end ? new Date(stripeSub.trial_end * 1000) : null},
|
|
157
|
+
"updated_at" = NOW()
|
|
158
|
+
WHERE "stripe_id" = ${stripeSub.id}
|
|
159
|
+
`
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
break
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
case 'customer.subscription.deleted': {
|
|
166
|
+
const stripeSub = event.data.object as Stripe.Subscription
|
|
167
|
+
await Subscription.syncStripeStatus(stripeSub.id, 'canceled', new Date())
|
|
168
|
+
break
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { env } from '@stravigor/kernel/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/database/schema'
|
|
2
|
+
|
|
3
|
+
export default defineSchema('customer', {
|
|
4
|
+
archetype: Archetype.Component,
|
|
5
|
+
parents: ['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/database/schema'
|
|
2
|
+
|
|
3
|
+
export default defineSchema('receipt', {
|
|
4
|
+
archetype: Archetype.Component,
|
|
5
|
+
parents: ['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/database/schema'
|
|
2
|
+
|
|
3
|
+
export default defineSchema('subscription', {
|
|
4
|
+
archetype: Archetype.Component,
|
|
5
|
+
parents: ['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/database/schema'
|
|
2
|
+
|
|
3
|
+
export default defineSchema('subscription_item', {
|
|
4
|
+
archetype: Archetype.Component,
|
|
5
|
+
parents: ['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
|
+
})
|