@riligar/agents-kit 1.12.0 → 1.14.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/.agent/skills/riligar-business-startup/SKILL.md +0 -1
- package/.agent/skills/riligar-design-system/SKILL.md +0 -1
- package/.agent/skills/riligar-dev-architecture/SKILL.md +7 -8
- package/.agent/skills/riligar-dev-auth-elysia/SKILL.md +7 -3
- package/.agent/skills/riligar-dev-auth-react/SKILL.md +5 -3
- package/.agent/skills/riligar-dev-autopilot/SKILL.md +0 -1
- package/.agent/skills/riligar-dev-backend/SKILL.md +0 -1
- package/.agent/skills/riligar-dev-clean-code/SKILL.md +0 -1
- package/.agent/skills/riligar-dev-code-review/SKILL.md +0 -1
- package/.agent/skills/riligar-dev-database/SKILL.md +8 -9
- package/.agent/skills/riligar-dev-frontend/SKILL.md +0 -1
- package/.agent/skills/riligar-dev-landing-page/SKILL.md +0 -1
- package/.agent/skills/riligar-dev-seo/SKILL.md +6 -3
- package/.agent/skills/riligar-dev-stripe/SKILL.md +196 -91
- package/.agent/skills/riligar-dev-stripe/assets/stripe-client.js +422 -0
- package/.agent/skills/riligar-dev-stripe/assets/stripe-server.js +300 -0
- package/.agent/skills/riligar-dev-stripe/references/stripe-database.md +369 -0
- package/.agent/skills/riligar-dev-stripe/references/stripe-elysia.md +342 -0
- package/.agent/skills/riligar-dev-stripe/references/stripe-react.md +478 -0
- package/.agent/skills/riligar-dev-stripe/references/stripe-webhooks.md +376 -0
- package/.agent/skills/riligar-infrastructure/SKILL.md +0 -1
- package/.agent/skills/riligar-marketing-copy/SKILL.md +0 -1
- package/.agent/skills/riligar-marketing-email/SKILL.md +0 -1
- package/.agent/skills/riligar-marketing-seo/SKILL.md +0 -1
- package/.agent/skills/riligar-plan-writing/SKILL.md +0 -1
- package/.agent/skills/riligar-tech-stack/SKILL.md +0 -1
- package/.agent/skills/skill-creator/SKILL.md +0 -2
- package/package.json +1 -1
- /package/.agent/skills/riligar-dev-architecture/{context-discovery.md → references/context-discovery.md} +0 -0
- /package/.agent/skills/riligar-dev-architecture/{examples.md → references/examples.md} +0 -0
- /package/.agent/skills/riligar-dev-architecture/{pattern-selection.md → references/pattern-selection.md} +0 -0
- /package/.agent/skills/riligar-dev-architecture/{patterns-reference.md → references/patterns-reference.md} +0 -0
- /package/.agent/skills/riligar-dev-architecture/{trade-off-analysis.md → references/trade-off-analysis.md} +0 -0
- /package/.agent/skills/riligar-dev-database/{database-selection.md → references/database-selection.md} +0 -0
- /package/.agent/skills/riligar-dev-database/{indexing.md → references/indexing.md} +0 -0
- /package/.agent/skills/riligar-dev-database/{migrations.md → references/migrations.md} +0 -0
- /package/.agent/skills/riligar-dev-database/{optimization.md → references/optimization.md} +0 -0
- /package/.agent/skills/riligar-dev-database/{orm-selection.md → references/orm-selection.md} +0 -0
- /package/.agent/skills/riligar-dev-database/{schema-design.md → references/schema-design.md} +0 -0
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stripe Server Snippets for Elysia
|
|
3
|
+
* Copy-paste ready code for backend Stripe integration
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
// ============================================
|
|
7
|
+
// 1. SETUP & PLUGIN
|
|
8
|
+
// ============================================
|
|
9
|
+
|
|
10
|
+
// plugins/stripe.js
|
|
11
|
+
import { Elysia } from 'elysia'
|
|
12
|
+
import Stripe from 'stripe'
|
|
13
|
+
|
|
14
|
+
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY)
|
|
15
|
+
|
|
16
|
+
export const stripePlugin = new Elysia({ name: 'stripe' })
|
|
17
|
+
.decorate('stripe', stripe)
|
|
18
|
+
|
|
19
|
+
// ============================================
|
|
20
|
+
// 2. BILLING ROUTES
|
|
21
|
+
// ============================================
|
|
22
|
+
|
|
23
|
+
// routes/billing.js
|
|
24
|
+
import { Elysia, t } from 'elysia'
|
|
25
|
+
import { stripePlugin } from '../plugins/stripe'
|
|
26
|
+
import { billingService } from '../services/billing'
|
|
27
|
+
|
|
28
|
+
export const billingRoutes = new Elysia({ prefix: '/billing' })
|
|
29
|
+
.use(stripePlugin)
|
|
30
|
+
|
|
31
|
+
// Create checkout session
|
|
32
|
+
.post('/checkout', async ({ stripe, body, user }) => {
|
|
33
|
+
// Get or create Stripe customer
|
|
34
|
+
let customerId = user.stripeCustomerId
|
|
35
|
+
if (!customerId) {
|
|
36
|
+
const customer = await stripe.customers.create({
|
|
37
|
+
email: user.email,
|
|
38
|
+
name: user.name,
|
|
39
|
+
metadata: { userId: user.id }
|
|
40
|
+
})
|
|
41
|
+
customerId = customer.id
|
|
42
|
+
await billingService.linkStripeCustomer(user.id, customerId)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const session = await stripe.checkout.sessions.create({
|
|
46
|
+
mode: body.mode,
|
|
47
|
+
customer: customerId,
|
|
48
|
+
line_items: [{ price: body.priceId, quantity: 1 }],
|
|
49
|
+
success_url: `${process.env.APP_URL}/success?session_id={CHECKOUT_SESSION_ID}`,
|
|
50
|
+
cancel_url: `${process.env.APP_URL}/pricing`,
|
|
51
|
+
metadata: { userId: user.id }
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
return { url: session.url }
|
|
55
|
+
}, {
|
|
56
|
+
body: t.Object({
|
|
57
|
+
priceId: t.String(),
|
|
58
|
+
mode: t.Union([t.Literal('subscription'), t.Literal('payment')])
|
|
59
|
+
})
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
// Get subscription status
|
|
63
|
+
.get('/subscription', async ({ stripe, user }) => {
|
|
64
|
+
if (!user.stripeSubscriptionId) {
|
|
65
|
+
return { status: 'none', plan: null }
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const subscription = await stripe.subscriptions.retrieve(user.stripeSubscriptionId)
|
|
69
|
+
|
|
70
|
+
return {
|
|
71
|
+
status: subscription.status,
|
|
72
|
+
plan: subscription.items.data[0].price.id,
|
|
73
|
+
currentPeriodEnd: subscription.current_period_end,
|
|
74
|
+
cancelAtPeriodEnd: subscription.cancel_at_period_end
|
|
75
|
+
}
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
// Customer portal
|
|
79
|
+
.post('/portal', async ({ stripe, user, set }) => {
|
|
80
|
+
if (!user.stripeCustomerId) {
|
|
81
|
+
set.status = 400
|
|
82
|
+
return { error: 'No billing account' }
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const portal = await stripe.billingPortal.sessions.create({
|
|
86
|
+
customer: user.stripeCustomerId,
|
|
87
|
+
return_url: `${process.env.APP_URL}/account`
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
return { url: portal.url }
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
// Cancel subscription
|
|
94
|
+
.post('/subscription/cancel', async ({ stripe, user, set }) => {
|
|
95
|
+
if (!user.stripeSubscriptionId) {
|
|
96
|
+
set.status = 400
|
|
97
|
+
return { error: 'No active subscription' }
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const subscription = await stripe.subscriptions.update(
|
|
101
|
+
user.stripeSubscriptionId,
|
|
102
|
+
{ cancel_at_period_end: true }
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
return {
|
|
106
|
+
status: subscription.status,
|
|
107
|
+
cancelAt: subscription.cancel_at
|
|
108
|
+
}
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
// Resume subscription
|
|
112
|
+
.post('/subscription/resume', async ({ stripe, user, set }) => {
|
|
113
|
+
if (!user.stripeSubscriptionId) {
|
|
114
|
+
set.status = 400
|
|
115
|
+
return { error: 'No subscription to resume' }
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const subscription = await stripe.subscriptions.update(
|
|
119
|
+
user.stripeSubscriptionId,
|
|
120
|
+
{ cancel_at_period_end: false }
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
return { status: subscription.status }
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
// List invoices
|
|
127
|
+
.get('/invoices', async ({ stripe, user, query }) => {
|
|
128
|
+
if (!user.stripeCustomerId) {
|
|
129
|
+
return { data: [], hasMore: false }
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const invoices = await stripe.invoices.list({
|
|
133
|
+
customer: user.stripeCustomerId,
|
|
134
|
+
limit: query.limit
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
return {
|
|
138
|
+
data: invoices.data.map(inv => ({
|
|
139
|
+
id: inv.id,
|
|
140
|
+
amount: inv.amount_paid,
|
|
141
|
+
status: inv.status,
|
|
142
|
+
date: inv.created,
|
|
143
|
+
pdfUrl: inv.invoice_pdf
|
|
144
|
+
})),
|
|
145
|
+
hasMore: invoices.has_more
|
|
146
|
+
}
|
|
147
|
+
}, {
|
|
148
|
+
query: t.Object({
|
|
149
|
+
limit: t.Optional(t.Numeric({ default: 10 }))
|
|
150
|
+
})
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
// ============================================
|
|
154
|
+
// 3. WEBHOOK ROUTES
|
|
155
|
+
// ============================================
|
|
156
|
+
|
|
157
|
+
// routes/webhook.js
|
|
158
|
+
import { Elysia } from 'elysia'
|
|
159
|
+
import Stripe from 'stripe'
|
|
160
|
+
import { billingService } from '../services/billing'
|
|
161
|
+
|
|
162
|
+
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY)
|
|
163
|
+
|
|
164
|
+
export const webhookRoutes = new Elysia()
|
|
165
|
+
.post('/webhook/stripe', async ({ request, set }) => {
|
|
166
|
+
const sig = request.headers.get('stripe-signature')
|
|
167
|
+
const body = await request.text()
|
|
168
|
+
|
|
169
|
+
let event
|
|
170
|
+
|
|
171
|
+
try {
|
|
172
|
+
event = stripe.webhooks.constructEvent(
|
|
173
|
+
body,
|
|
174
|
+
sig,
|
|
175
|
+
process.env.STRIPE_WEBHOOK_SECRET
|
|
176
|
+
)
|
|
177
|
+
} catch (err) {
|
|
178
|
+
console.error('Webhook signature failed:', err.message)
|
|
179
|
+
set.status = 400
|
|
180
|
+
return { error: 'Invalid signature' }
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Handle events
|
|
184
|
+
switch (event.type) {
|
|
185
|
+
case 'checkout.session.completed': {
|
|
186
|
+
const session = event.data.object
|
|
187
|
+
const userId = session.metadata.userId
|
|
188
|
+
|
|
189
|
+
await billingService.activateSubscription(userId, {
|
|
190
|
+
subscriptionId: session.subscription,
|
|
191
|
+
plan: 'pro', // Map from price ID
|
|
192
|
+
periodEnd: new Date(session.expires_at * 1000)
|
|
193
|
+
})
|
|
194
|
+
break
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
case 'customer.subscription.updated': {
|
|
198
|
+
const subscription = event.data.object
|
|
199
|
+
// Handle plan changes, status updates
|
|
200
|
+
break
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
case 'customer.subscription.deleted': {
|
|
204
|
+
const subscription = event.data.object
|
|
205
|
+
await billingService.deactivateSubscription(subscription.id)
|
|
206
|
+
break
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
case 'invoice.paid': {
|
|
210
|
+
const invoice = event.data.object
|
|
211
|
+
// Record successful payment
|
|
212
|
+
break
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
case 'invoice.payment_failed': {
|
|
216
|
+
const invoice = event.data.object
|
|
217
|
+
// Handle failed payment - notify user
|
|
218
|
+
break
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
return { received: true }
|
|
223
|
+
})
|
|
224
|
+
|
|
225
|
+
// ============================================
|
|
226
|
+
// 4. BILLING SERVICE
|
|
227
|
+
// ============================================
|
|
228
|
+
|
|
229
|
+
// services/billing.js
|
|
230
|
+
import { db } from '../database'
|
|
231
|
+
import { users } from '../database/schema'
|
|
232
|
+
import { eq } from 'drizzle-orm'
|
|
233
|
+
|
|
234
|
+
export const billingService = {
|
|
235
|
+
async linkStripeCustomer(userId, stripeCustomerId) {
|
|
236
|
+
await db.update(users)
|
|
237
|
+
.set({ stripeCustomerId, updatedAt: new Date() })
|
|
238
|
+
.where(eq(users.id, userId))
|
|
239
|
+
},
|
|
240
|
+
|
|
241
|
+
async activateSubscription(userId, { subscriptionId, plan, periodEnd }) {
|
|
242
|
+
await db.update(users)
|
|
243
|
+
.set({
|
|
244
|
+
stripeSubscriptionId: subscriptionId,
|
|
245
|
+
plan,
|
|
246
|
+
subscriptionStatus: 'active',
|
|
247
|
+
currentPeriodEnd: periodEnd,
|
|
248
|
+
cancelAtPeriodEnd: false,
|
|
249
|
+
updatedAt: new Date()
|
|
250
|
+
})
|
|
251
|
+
.where(eq(users.id, userId))
|
|
252
|
+
},
|
|
253
|
+
|
|
254
|
+
async deactivateSubscription(subscriptionId) {
|
|
255
|
+
await db.update(users)
|
|
256
|
+
.set({
|
|
257
|
+
plan: 'free',
|
|
258
|
+
subscriptionStatus: 'canceled',
|
|
259
|
+
stripeSubscriptionId: null,
|
|
260
|
+
updatedAt: new Date()
|
|
261
|
+
})
|
|
262
|
+
.where(eq(users.stripeSubscriptionId, subscriptionId))
|
|
263
|
+
},
|
|
264
|
+
|
|
265
|
+
async canAccessFeature(userId, requiredPlan) {
|
|
266
|
+
const [user] = await db.select()
|
|
267
|
+
.from(users)
|
|
268
|
+
.where(eq(users.id, userId))
|
|
269
|
+
.limit(1)
|
|
270
|
+
|
|
271
|
+
if (!user) return false
|
|
272
|
+
|
|
273
|
+
const planHierarchy = { free: 0, starter: 1, pro: 2, enterprise: 3 }
|
|
274
|
+
return (planHierarchy[user.plan] || 0) >= (planHierarchy[requiredPlan] || 0)
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// ============================================
|
|
279
|
+
// 5. MIDDLEWARE
|
|
280
|
+
// ============================================
|
|
281
|
+
|
|
282
|
+
// middleware/require-subscription.js
|
|
283
|
+
export const requireSubscription = async ({ user, set }) => {
|
|
284
|
+
if (!user?.stripeSubscriptionId) {
|
|
285
|
+
set.status = 403
|
|
286
|
+
return { error: 'Subscription required' }
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
export const requirePlan = (plan) => async ({ user, set }) => {
|
|
291
|
+
const canAccess = await billingService.canAccessFeature(user.id, plan)
|
|
292
|
+
if (!canAccess) {
|
|
293
|
+
set.status = 403
|
|
294
|
+
return { error: `Plan ${plan} required` }
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// Usage:
|
|
299
|
+
// app.guard({ beforeHandle: requireSubscription }, app => app.get('/premium', handler))
|
|
300
|
+
// app.get('/enterprise-only', handler, { beforeHandle: requirePlan('enterprise') })
|
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
# Stripe Database Patterns (Drizzle)
|
|
2
|
+
|
|
3
|
+
Database schemas and queries for Stripe billing with SQLite and Drizzle ORM.
|
|
4
|
+
|
|
5
|
+
## Schema
|
|
6
|
+
|
|
7
|
+
### Users Table (with Stripe fields)
|
|
8
|
+
|
|
9
|
+
```javascript
|
|
10
|
+
// database/schema.js
|
|
11
|
+
import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core'
|
|
12
|
+
|
|
13
|
+
export const users = sqliteTable('users', {
|
|
14
|
+
id: text('id').primaryKey(),
|
|
15
|
+
email: text('email').notNull().unique(),
|
|
16
|
+
name: text('name'),
|
|
17
|
+
|
|
18
|
+
// Stripe fields
|
|
19
|
+
stripeCustomerId: text('stripe_customer_id').unique(),
|
|
20
|
+
stripeSubscriptionId: text('stripe_subscription_id').unique(),
|
|
21
|
+
plan: text('plan').default('free'), // free, starter, pro, enterprise
|
|
22
|
+
subscriptionStatus: text('subscription_status'), // active, past_due, canceled, trialing
|
|
23
|
+
currentPeriodEnd: integer('current_period_end', { mode: 'timestamp' }),
|
|
24
|
+
cancelAtPeriodEnd: integer('cancel_at_period_end', { mode: 'boolean' }).default(false),
|
|
25
|
+
|
|
26
|
+
createdAt: integer('created_at', { mode: 'timestamp' }).notNull().$defaultFn(() => new Date()),
|
|
27
|
+
updatedAt: integer('updated_at', { mode: 'timestamp' })
|
|
28
|
+
})
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
### Invoices Table (optional)
|
|
32
|
+
|
|
33
|
+
```javascript
|
|
34
|
+
export const invoices = sqliteTable('invoices', {
|
|
35
|
+
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
|
|
36
|
+
userId: text('user_id').references(() => users.id).notNull(),
|
|
37
|
+
stripeInvoiceId: text('stripe_invoice_id').unique().notNull(),
|
|
38
|
+
amount: integer('amount').notNull(), // in cents
|
|
39
|
+
status: text('status').notNull(), // paid, open, void
|
|
40
|
+
url: text('url'), // hosted invoice URL
|
|
41
|
+
createdAt: integer('created_at', { mode: 'timestamp' }).notNull().$defaultFn(() => new Date())
|
|
42
|
+
})
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
### Webhook Events Table (for idempotency)
|
|
46
|
+
|
|
47
|
+
```javascript
|
|
48
|
+
export const webhookEvents = sqliteTable('webhook_events', {
|
|
49
|
+
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
|
|
50
|
+
stripeEventId: text('stripe_event_id').unique().notNull(),
|
|
51
|
+
type: text('type').notNull(),
|
|
52
|
+
processedAt: integer('processed_at', { mode: 'timestamp' }).notNull()
|
|
53
|
+
})
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
### Products Table (for local price cache)
|
|
57
|
+
|
|
58
|
+
```javascript
|
|
59
|
+
export const products = sqliteTable('products', {
|
|
60
|
+
id: text('id').primaryKey(),
|
|
61
|
+
stripePriceId: text('stripe_price_id').unique().notNull(),
|
|
62
|
+
name: text('name').notNull(),
|
|
63
|
+
description: text('description'),
|
|
64
|
+
amount: integer('amount').notNull(), // in cents
|
|
65
|
+
interval: text('interval'), // month, year, null for one-time
|
|
66
|
+
active: integer('active', { mode: 'boolean' }).default(true),
|
|
67
|
+
features: text('features', { mode: 'json' }) // ["feature1", "feature2"]
|
|
68
|
+
})
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## Queries
|
|
72
|
+
|
|
73
|
+
### Get User with Subscription
|
|
74
|
+
|
|
75
|
+
```javascript
|
|
76
|
+
import { db } from '../database'
|
|
77
|
+
import { users } from '../database/schema'
|
|
78
|
+
import { eq } from 'drizzle-orm'
|
|
79
|
+
|
|
80
|
+
export async function getUserWithSubscription(userId) {
|
|
81
|
+
const [user] = await db.select()
|
|
82
|
+
.from(users)
|
|
83
|
+
.where(eq(users.id, userId))
|
|
84
|
+
.limit(1)
|
|
85
|
+
|
|
86
|
+
return user
|
|
87
|
+
}
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
### Check Active Subscription
|
|
91
|
+
|
|
92
|
+
```javascript
|
|
93
|
+
export async function hasActiveSubscription(userId) {
|
|
94
|
+
const [user] = await db.select({
|
|
95
|
+
status: users.subscriptionStatus,
|
|
96
|
+
periodEnd: users.currentPeriodEnd
|
|
97
|
+
})
|
|
98
|
+
.from(users)
|
|
99
|
+
.where(eq(users.id, userId))
|
|
100
|
+
.limit(1)
|
|
101
|
+
|
|
102
|
+
if (!user) return false
|
|
103
|
+
|
|
104
|
+
const activeStatuses = ['active', 'trialing']
|
|
105
|
+
return activeStatuses.includes(user.status)
|
|
106
|
+
}
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
### Get User by Stripe Customer
|
|
110
|
+
|
|
111
|
+
```javascript
|
|
112
|
+
export async function getUserByStripeCustomer(stripeCustomerId) {
|
|
113
|
+
const [user] = await db.select()
|
|
114
|
+
.from(users)
|
|
115
|
+
.where(eq(users.stripeCustomerId, stripeCustomerId))
|
|
116
|
+
.limit(1)
|
|
117
|
+
|
|
118
|
+
return user
|
|
119
|
+
}
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
### Get User by Subscription
|
|
123
|
+
|
|
124
|
+
```javascript
|
|
125
|
+
export async function getUserBySubscription(stripeSubscriptionId) {
|
|
126
|
+
const [user] = await db.select()
|
|
127
|
+
.from(users)
|
|
128
|
+
.where(eq(users.stripeSubscriptionId, stripeSubscriptionId))
|
|
129
|
+
.limit(1)
|
|
130
|
+
|
|
131
|
+
return user
|
|
132
|
+
}
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
### Update Subscription
|
|
136
|
+
|
|
137
|
+
```javascript
|
|
138
|
+
export async function updateUserSubscription(userId, data) {
|
|
139
|
+
await db.update(users)
|
|
140
|
+
.set({
|
|
141
|
+
stripeCustomerId: data.customerId,
|
|
142
|
+
stripeSubscriptionId: data.subscriptionId,
|
|
143
|
+
plan: data.plan,
|
|
144
|
+
subscriptionStatus: data.status,
|
|
145
|
+
currentPeriodEnd: data.periodEnd,
|
|
146
|
+
cancelAtPeriodEnd: data.cancelAtPeriodEnd,
|
|
147
|
+
updatedAt: new Date()
|
|
148
|
+
})
|
|
149
|
+
.where(eq(users.id, userId))
|
|
150
|
+
}
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
### Cancel Subscription (in DB)
|
|
154
|
+
|
|
155
|
+
```javascript
|
|
156
|
+
export async function cancelSubscriptionInDb(subscriptionId) {
|
|
157
|
+
await db.update(users)
|
|
158
|
+
.set({
|
|
159
|
+
plan: 'free',
|
|
160
|
+
subscriptionStatus: 'canceled',
|
|
161
|
+
stripeSubscriptionId: null,
|
|
162
|
+
updatedAt: new Date()
|
|
163
|
+
})
|
|
164
|
+
.where(eq(users.stripeSubscriptionId, subscriptionId))
|
|
165
|
+
}
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
### Get User Invoices
|
|
169
|
+
|
|
170
|
+
```javascript
|
|
171
|
+
import { desc } from 'drizzle-orm'
|
|
172
|
+
|
|
173
|
+
export async function getUserInvoices(userId, limit = 10) {
|
|
174
|
+
return db.select()
|
|
175
|
+
.from(invoices)
|
|
176
|
+
.where(eq(invoices.userId, userId))
|
|
177
|
+
.orderBy(desc(invoices.createdAt))
|
|
178
|
+
.limit(limit)
|
|
179
|
+
}
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
### Check Webhook Processed
|
|
183
|
+
|
|
184
|
+
```javascript
|
|
185
|
+
export async function isWebhookProcessed(eventId) {
|
|
186
|
+
const [event] = await db.select()
|
|
187
|
+
.from(webhookEvents)
|
|
188
|
+
.where(eq(webhookEvents.stripeEventId, eventId))
|
|
189
|
+
.limit(1)
|
|
190
|
+
|
|
191
|
+
return !!event
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export async function markWebhookProcessed(eventId, type) {
|
|
195
|
+
await db.insert(webhookEvents).values({
|
|
196
|
+
stripeEventId: eventId,
|
|
197
|
+
type,
|
|
198
|
+
processedAt: new Date()
|
|
199
|
+
})
|
|
200
|
+
}
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
## Migrations
|
|
204
|
+
|
|
205
|
+
### Initial Migration
|
|
206
|
+
|
|
207
|
+
```javascript
|
|
208
|
+
// database/migrations/0001_add_stripe_fields.js
|
|
209
|
+
import { sql } from 'drizzle-orm'
|
|
210
|
+
|
|
211
|
+
export async function up(db) {
|
|
212
|
+
await db.run(sql`
|
|
213
|
+
ALTER TABLE users ADD COLUMN stripe_customer_id TEXT UNIQUE;
|
|
214
|
+
ALTER TABLE users ADD COLUMN stripe_subscription_id TEXT UNIQUE;
|
|
215
|
+
ALTER TABLE users ADD COLUMN plan TEXT DEFAULT 'free';
|
|
216
|
+
ALTER TABLE users ADD COLUMN subscription_status TEXT;
|
|
217
|
+
ALTER TABLE users ADD COLUMN current_period_end INTEGER;
|
|
218
|
+
ALTER TABLE users ADD COLUMN cancel_at_period_end INTEGER DEFAULT 0;
|
|
219
|
+
`)
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export async function down(db) {
|
|
223
|
+
// SQLite doesn't support DROP COLUMN easily
|
|
224
|
+
// Would need to recreate table
|
|
225
|
+
}
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
### Create Invoices Table
|
|
229
|
+
|
|
230
|
+
```javascript
|
|
231
|
+
// database/migrations/0002_create_invoices.js
|
|
232
|
+
import { sql } from 'drizzle-orm'
|
|
233
|
+
|
|
234
|
+
export async function up(db) {
|
|
235
|
+
await db.run(sql`
|
|
236
|
+
CREATE TABLE invoices (
|
|
237
|
+
id TEXT PRIMARY KEY,
|
|
238
|
+
user_id TEXT NOT NULL REFERENCES users(id),
|
|
239
|
+
stripe_invoice_id TEXT UNIQUE NOT NULL,
|
|
240
|
+
amount INTEGER NOT NULL,
|
|
241
|
+
status TEXT NOT NULL,
|
|
242
|
+
url TEXT,
|
|
243
|
+
created_at INTEGER NOT NULL
|
|
244
|
+
)
|
|
245
|
+
`)
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
export async function down(db) {
|
|
249
|
+
await db.run(sql`DROP TABLE invoices`)
|
|
250
|
+
}
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
## Helper Service
|
|
254
|
+
|
|
255
|
+
```javascript
|
|
256
|
+
// services/billing.js
|
|
257
|
+
import { db } from '../database'
|
|
258
|
+
import { users, invoices } from '../database/schema'
|
|
259
|
+
import { eq, and } from 'drizzle-orm'
|
|
260
|
+
|
|
261
|
+
export const billingService = {
|
|
262
|
+
// Check if user can access feature
|
|
263
|
+
async canAccessFeature(userId, requiredPlan) {
|
|
264
|
+
const user = await this.getUser(userId)
|
|
265
|
+
|
|
266
|
+
if (!user) return false
|
|
267
|
+
|
|
268
|
+
const planHierarchy = { free: 0, starter: 1, pro: 2, enterprise: 3 }
|
|
269
|
+
const userLevel = planHierarchy[user.plan] || 0
|
|
270
|
+
const requiredLevel = planHierarchy[requiredPlan] || 0
|
|
271
|
+
|
|
272
|
+
return userLevel >= requiredLevel
|
|
273
|
+
},
|
|
274
|
+
|
|
275
|
+
// Get user
|
|
276
|
+
async getUser(userId) {
|
|
277
|
+
const [user] = await db.select()
|
|
278
|
+
.from(users)
|
|
279
|
+
.where(eq(users.id, userId))
|
|
280
|
+
.limit(1)
|
|
281
|
+
return user
|
|
282
|
+
},
|
|
283
|
+
|
|
284
|
+
// Link Stripe customer
|
|
285
|
+
async linkStripeCustomer(userId, stripeCustomerId) {
|
|
286
|
+
await db.update(users)
|
|
287
|
+
.set({ stripeCustomerId, updatedAt: new Date() })
|
|
288
|
+
.where(eq(users.id, userId))
|
|
289
|
+
},
|
|
290
|
+
|
|
291
|
+
// Activate subscription
|
|
292
|
+
async activateSubscription(userId, { subscriptionId, plan, periodEnd }) {
|
|
293
|
+
await db.update(users)
|
|
294
|
+
.set({
|
|
295
|
+
stripeSubscriptionId: subscriptionId,
|
|
296
|
+
plan,
|
|
297
|
+
subscriptionStatus: 'active',
|
|
298
|
+
currentPeriodEnd: periodEnd,
|
|
299
|
+
cancelAtPeriodEnd: false,
|
|
300
|
+
updatedAt: new Date()
|
|
301
|
+
})
|
|
302
|
+
.where(eq(users.id, userId))
|
|
303
|
+
},
|
|
304
|
+
|
|
305
|
+
// Deactivate subscription
|
|
306
|
+
async deactivateSubscription(subscriptionId) {
|
|
307
|
+
await db.update(users)
|
|
308
|
+
.set({
|
|
309
|
+
plan: 'free',
|
|
310
|
+
subscriptionStatus: 'canceled',
|
|
311
|
+
stripeSubscriptionId: null,
|
|
312
|
+
updatedAt: new Date()
|
|
313
|
+
})
|
|
314
|
+
.where(eq(users.stripeSubscriptionId, subscriptionId))
|
|
315
|
+
},
|
|
316
|
+
|
|
317
|
+
// Record invoice
|
|
318
|
+
async recordInvoice(userId, stripeInvoice) {
|
|
319
|
+
await db.insert(invoices).values({
|
|
320
|
+
userId,
|
|
321
|
+
stripeInvoiceId: stripeInvoice.id,
|
|
322
|
+
amount: stripeInvoice.amount_paid,
|
|
323
|
+
status: stripeInvoice.status,
|
|
324
|
+
url: stripeInvoice.hosted_invoice_url
|
|
325
|
+
})
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
```
|
|
329
|
+
|
|
330
|
+
## Plan Feature Checks
|
|
331
|
+
|
|
332
|
+
```javascript
|
|
333
|
+
// services/features.js
|
|
334
|
+
const planFeatures = {
|
|
335
|
+
free: {
|
|
336
|
+
projects: 1,
|
|
337
|
+
storage: 100 * 1024 * 1024, // 100MB
|
|
338
|
+
apiCalls: 100,
|
|
339
|
+
support: 'community'
|
|
340
|
+
},
|
|
341
|
+
starter: {
|
|
342
|
+
projects: 5,
|
|
343
|
+
storage: 1 * 1024 * 1024 * 1024, // 1GB
|
|
344
|
+
apiCalls: 1000,
|
|
345
|
+
support: 'email'
|
|
346
|
+
},
|
|
347
|
+
pro: {
|
|
348
|
+
projects: Infinity,
|
|
349
|
+
storage: 10 * 1024 * 1024 * 1024, // 10GB
|
|
350
|
+
apiCalls: 10000,
|
|
351
|
+
support: 'priority'
|
|
352
|
+
},
|
|
353
|
+
enterprise: {
|
|
354
|
+
projects: Infinity,
|
|
355
|
+
storage: Infinity,
|
|
356
|
+
apiCalls: Infinity,
|
|
357
|
+
support: 'dedicated'
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
export function getFeatureLimits(plan) {
|
|
362
|
+
return planFeatures[plan] || planFeatures.free
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
export function canCreateProject(plan, currentCount) {
|
|
366
|
+
const limits = getFeatureLimits(plan)
|
|
367
|
+
return currentCount < limits.projects
|
|
368
|
+
}
|
|
369
|
+
```
|