@riligar/agents-kit 1.17.0 → 1.18.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-infra-stripe/SKILL.md +53 -10
- package/.agent/skills/riligar-infra-stripe/assets/stripe-client.js +69 -10
- package/.agent/skills/riligar-infra-stripe/assets/stripe-server.js +258 -79
- package/.agent/skills/riligar-infra-stripe/references/stripe-elysia.md +136 -55
- package/.agent/skills/riligar-infra-stripe/references/stripe-webhooks.md +67 -18
- package/package.json +1 -1
|
@@ -151,7 +151,7 @@ Instrua o usuário:
|
|
|
151
151
|
>
|
|
152
152
|
> 1. Acesse https://dashboard.stripe.com/webhooks
|
|
153
153
|
> 2. Clique em "Add endpoint"
|
|
154
|
-
> 3. URL: `https://seu-dominio.com/webhook
|
|
154
|
+
> 3. URL: `https://seu-dominio.com/api/webhook`
|
|
155
155
|
> 4. Selecione os eventos:
|
|
156
156
|
> - `checkout.session.completed`
|
|
157
157
|
> - `customer.subscription.updated`
|
|
@@ -171,14 +171,15 @@ Gere todos os arquivos necessários usando os templates de [assets/](assets/):
|
|
|
171
171
|
| `routes/billing.js` | stripe-server.js (seção 2) |
|
|
172
172
|
| `routes/webhook.js` | stripe-server.js (seção 3) |
|
|
173
173
|
| `services/billing.js` | stripe-server.js (seção 4) |
|
|
174
|
-
| `config/stripe-prices.js` | Price IDs coletados |
|
|
174
|
+
| `config/stripe-prices.js` | Price IDs coletados (Step 9) |
|
|
175
|
+
| `config/plans.js` | PLAN_MAP + PLAN_LIMITS (Step 9) |
|
|
175
176
|
| `pages/Pricing.jsx` | stripe-client.js (seção 3) |
|
|
176
177
|
| `components/BillingSettings.jsx` | stripe-client.js (seção 4) |
|
|
177
178
|
| `hooks/useSubscription.js` | stripe-client.js (seção 2) |
|
|
178
179
|
|
|
179
|
-
### Step 9: Criar
|
|
180
|
+
### Step 9: Criar Configs de Planos e Preços
|
|
180
181
|
|
|
181
|
-
|
|
182
|
+
**A) Arquivo de preços (config/stripe-prices.js):**
|
|
182
183
|
|
|
183
184
|
```javascript
|
|
184
185
|
// config/stripe-prices.js
|
|
@@ -194,6 +195,12 @@ export const STRIPE_PRICES = {
|
|
|
194
195
|
name: 'Pro',
|
|
195
196
|
price: 99,
|
|
196
197
|
features: ['Projetos ilimitados', 'Suporte prioritário', '10GB storage']
|
|
198
|
+
},
|
|
199
|
+
enterprise: {
|
|
200
|
+
priceId: 'price_COLETADO_ENTERPRISE',
|
|
201
|
+
name: 'Enterprise',
|
|
202
|
+
price: 299,
|
|
203
|
+
features: ['Tudo do Pro', 'Storage ilimitado', 'SLA garantido']
|
|
197
204
|
}
|
|
198
205
|
}
|
|
199
206
|
|
|
@@ -201,6 +208,40 @@ export const getPrice = (plan) => STRIPE_PRICES[plan]
|
|
|
201
208
|
export const getPriceId = (plan) => STRIPE_PRICES[plan]?.priceId
|
|
202
209
|
```
|
|
203
210
|
|
|
211
|
+
**B) Arquivo de mapeamento e limites (config/plans.js):**
|
|
212
|
+
|
|
213
|
+
```javascript
|
|
214
|
+
// config/plans.js
|
|
215
|
+
|
|
216
|
+
// Mapeia Price IDs do Stripe para nomes de planos internos
|
|
217
|
+
export const PLAN_MAP = {
|
|
218
|
+
'price_COLETADO_STARTER': 'starter',
|
|
219
|
+
'price_COLETADO_PRO': 'pro',
|
|
220
|
+
'price_COLETADO_ENTERPRISE': 'enterprise',
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Define limites de features por plano
|
|
224
|
+
export const PLAN_LIMITS = {
|
|
225
|
+
free: {
|
|
226
|
+
maxFlows: 1,
|
|
227
|
+
maxContacts: 100,
|
|
228
|
+
// Adicione outros limites conforme necessário
|
|
229
|
+
},
|
|
230
|
+
starter: {
|
|
231
|
+
maxFlows: 3,
|
|
232
|
+
maxContacts: 500,
|
|
233
|
+
},
|
|
234
|
+
pro: {
|
|
235
|
+
maxFlows: 15,
|
|
236
|
+
maxContacts: 5000,
|
|
237
|
+
},
|
|
238
|
+
enterprise: {
|
|
239
|
+
maxFlows: 55,
|
|
240
|
+
maxContacts: Infinity,
|
|
241
|
+
},
|
|
242
|
+
}
|
|
243
|
+
```
|
|
244
|
+
|
|
204
245
|
---
|
|
205
246
|
|
|
206
247
|
## Checklist Final
|
|
@@ -211,11 +252,13 @@ Ao completar o setup, confirme:
|
|
|
211
252
|
- [ ] Chaves no `.env.development` e `.env.production` (backend e frontend)
|
|
212
253
|
- [ ] Produtos criados no Stripe
|
|
213
254
|
- [ ] Price IDs configurados em `config/stripe-prices.js`
|
|
214
|
-
- [ ]
|
|
215
|
-
- [ ]
|
|
255
|
+
- [ ] `PLAN_MAP` e `PLAN_LIMITS` configurados em `config/plans.js`
|
|
256
|
+
- [ ] Schema do database atualizado (campos Stripe na tabela users)
|
|
257
|
+
- [ ] Webhook endpoint configurado no Stripe Dashboard (`/api/webhook`)
|
|
216
258
|
- [ ] Webhook secret nos arquivos de ambiente
|
|
217
|
-
- [ ] Rotas de billing funcionando
|
|
218
|
-
- [ ] Página de pricing criada
|
|
259
|
+
- [ ] Rotas de billing funcionando (`/api/billing/*`)
|
|
260
|
+
- [ ] Página de pricing criada (com token de auth no ky)
|
|
261
|
+
- [ ] `useSubscription` hook com header Authorization
|
|
219
262
|
- [ ] Testado com cartão 4242 4242 4242 4242
|
|
220
263
|
|
|
221
264
|
---
|
|
@@ -229,8 +272,8 @@ brew install stripe/stripe-cli/stripe
|
|
|
229
272
|
# Login
|
|
230
273
|
stripe login
|
|
231
274
|
|
|
232
|
-
# Forward webhooks
|
|
233
|
-
stripe listen --forward-to localhost:
|
|
275
|
+
# Forward webhooks (ajuste a porta conforme seu backend)
|
|
276
|
+
stripe listen --forward-to localhost:3333/api/webhook
|
|
234
277
|
|
|
235
278
|
# Testar checkout
|
|
236
279
|
stripe trigger checkout.session.completed
|
|
@@ -32,6 +32,20 @@ export const elementsAppearance = {
|
|
|
32
32
|
import { useState, useEffect } from 'react'
|
|
33
33
|
import ky from 'ky'
|
|
34
34
|
|
|
35
|
+
const api = ky.create({
|
|
36
|
+
prefixUrl: import.meta.env.VITE_API_URL || 'http://localhost:3333',
|
|
37
|
+
hooks: {
|
|
38
|
+
beforeRequest: [
|
|
39
|
+
request => {
|
|
40
|
+
const token = localStorage.getItem('auth:token')
|
|
41
|
+
if (token) {
|
|
42
|
+
request.headers.set('Authorization', `Bearer ${token}`)
|
|
43
|
+
}
|
|
44
|
+
},
|
|
45
|
+
],
|
|
46
|
+
},
|
|
47
|
+
})
|
|
48
|
+
|
|
35
49
|
export function useSubscription() {
|
|
36
50
|
const [subscription, setSubscription] = useState(null)
|
|
37
51
|
const [loading, setLoading] = useState(true)
|
|
@@ -40,7 +54,7 @@ export function useSubscription() {
|
|
|
40
54
|
const fetchSubscription = async () => {
|
|
41
55
|
setLoading(true)
|
|
42
56
|
try {
|
|
43
|
-
const data = await
|
|
57
|
+
const data = await api.get('api/billing/subscription').json()
|
|
44
58
|
setSubscription(data)
|
|
45
59
|
setError(null)
|
|
46
60
|
} catch (err) {
|
|
@@ -77,35 +91,52 @@ import { IconCheck } from '@tabler/icons-react'
|
|
|
77
91
|
import { useState } from 'react'
|
|
78
92
|
import ky from 'ky'
|
|
79
93
|
|
|
94
|
+
// Importar do arquivo de configuração centralizado
|
|
95
|
+
// import { STRIPE_PRICES } from '../config/stripe-prices'
|
|
96
|
+
|
|
80
97
|
const plans = [
|
|
81
98
|
{
|
|
82
99
|
name: 'Starter',
|
|
83
|
-
priceId: 'price_starter_monthly',
|
|
100
|
+
priceId: 'price_starter_monthly', // Substituir pelo valor de STRIPE_PRICES.starter.priceId
|
|
84
101
|
price: 29,
|
|
85
102
|
features: ['5 projetos', 'Suporte por email', '1GB de storage']
|
|
86
103
|
},
|
|
87
104
|
{
|
|
88
105
|
name: 'Pro',
|
|
89
|
-
priceId: 'price_pro_monthly',
|
|
106
|
+
priceId: 'price_pro_monthly', // Substituir pelo valor de STRIPE_PRICES.pro.priceId
|
|
90
107
|
price: 99,
|
|
91
108
|
popular: true,
|
|
92
109
|
features: ['Projetos ilimitados', 'Suporte prioritário', '10GB de storage', 'API access']
|
|
93
110
|
},
|
|
94
111
|
{
|
|
95
112
|
name: 'Enterprise',
|
|
96
|
-
priceId: 'price_enterprise_monthly',
|
|
113
|
+
priceId: 'price_enterprise_monthly', // Substituir pelo valor de STRIPE_PRICES.enterprise.priceId
|
|
97
114
|
price: 299,
|
|
98
115
|
features: ['Tudo do Pro', 'Suporte dedicado', 'Storage ilimitado', 'SLA garantido']
|
|
99
116
|
}
|
|
100
117
|
]
|
|
101
118
|
|
|
119
|
+
const api = ky.create({
|
|
120
|
+
prefixUrl: import.meta.env.VITE_API_URL || 'http://localhost:3333',
|
|
121
|
+
hooks: {
|
|
122
|
+
beforeRequest: [
|
|
123
|
+
request => {
|
|
124
|
+
const token = localStorage.getItem('auth:token')
|
|
125
|
+
if (token) {
|
|
126
|
+
request.headers.set('Authorization', `Bearer ${token}`)
|
|
127
|
+
}
|
|
128
|
+
},
|
|
129
|
+
],
|
|
130
|
+
},
|
|
131
|
+
})
|
|
132
|
+
|
|
102
133
|
export default function Pricing() {
|
|
103
134
|
const [loading, setLoading] = useState(null)
|
|
104
135
|
|
|
105
136
|
const handleCheckout = async (priceId) => {
|
|
106
137
|
setLoading(priceId)
|
|
107
138
|
try {
|
|
108
|
-
const { url } = await
|
|
139
|
+
const { url } = await api.post('api/billing/checkout', {
|
|
109
140
|
json: { priceId, mode: 'subscription' }
|
|
110
141
|
}).json()
|
|
111
142
|
window.location.href = url
|
|
@@ -178,12 +209,26 @@ import { Button, Card, Group, Stack, Text, Title, Badge, Divider } from '@mantin
|
|
|
178
209
|
import { useState, useEffect } from 'react'
|
|
179
210
|
import ky from 'ky'
|
|
180
211
|
|
|
212
|
+
const api = ky.create({
|
|
213
|
+
prefixUrl: import.meta.env.VITE_API_URL || 'http://localhost:3333',
|
|
214
|
+
hooks: {
|
|
215
|
+
beforeRequest: [
|
|
216
|
+
request => {
|
|
217
|
+
const token = localStorage.getItem('auth:token')
|
|
218
|
+
if (token) {
|
|
219
|
+
request.headers.set('Authorization', `Bearer ${token}`)
|
|
220
|
+
}
|
|
221
|
+
},
|
|
222
|
+
],
|
|
223
|
+
},
|
|
224
|
+
})
|
|
225
|
+
|
|
181
226
|
export default function BillingSettings() {
|
|
182
227
|
const [subscription, setSubscription] = useState(null)
|
|
183
228
|
const [loading, setLoading] = useState(false)
|
|
184
229
|
|
|
185
230
|
useEffect(() => {
|
|
186
|
-
|
|
231
|
+
api.get('api/billing/subscription').json()
|
|
187
232
|
.then(setSubscription)
|
|
188
233
|
.catch(console.error)
|
|
189
234
|
}, [])
|
|
@@ -191,7 +236,7 @@ export default function BillingSettings() {
|
|
|
191
236
|
const openPortal = async () => {
|
|
192
237
|
setLoading(true)
|
|
193
238
|
try {
|
|
194
|
-
const { url } = await
|
|
239
|
+
const { url } = await api.post('api/billing/portal').json()
|
|
195
240
|
window.location.href = url
|
|
196
241
|
} finally {
|
|
197
242
|
setLoading(false)
|
|
@@ -201,9 +246,9 @@ export default function BillingSettings() {
|
|
|
201
246
|
const cancelSubscription = async () => {
|
|
202
247
|
setLoading(true)
|
|
203
248
|
try {
|
|
204
|
-
await
|
|
249
|
+
await api.post('api/billing/subscription/cancel').json()
|
|
205
250
|
// Refresh subscription data
|
|
206
|
-
const data = await
|
|
251
|
+
const data = await api.get('api/billing/subscription').json()
|
|
207
252
|
setSubscription(data)
|
|
208
253
|
} finally {
|
|
209
254
|
setLoading(false)
|
|
@@ -356,12 +401,26 @@ import { IconDownload } from '@tabler/icons-react'
|
|
|
356
401
|
import { useState, useEffect } from 'react'
|
|
357
402
|
import ky from 'ky'
|
|
358
403
|
|
|
404
|
+
const api = ky.create({
|
|
405
|
+
prefixUrl: import.meta.env.VITE_API_URL || 'http://localhost:3333',
|
|
406
|
+
hooks: {
|
|
407
|
+
beforeRequest: [
|
|
408
|
+
request => {
|
|
409
|
+
const token = localStorage.getItem('auth:token')
|
|
410
|
+
if (token) {
|
|
411
|
+
request.headers.set('Authorization', `Bearer ${token}`)
|
|
412
|
+
}
|
|
413
|
+
},
|
|
414
|
+
],
|
|
415
|
+
},
|
|
416
|
+
})
|
|
417
|
+
|
|
359
418
|
export default function InvoiceList() {
|
|
360
419
|
const [invoices, setInvoices] = useState([])
|
|
361
420
|
const [loading, setLoading] = useState(true)
|
|
362
421
|
|
|
363
422
|
useEffect(() => {
|
|
364
|
-
|
|
423
|
+
api.get('api/billing/invoices').json()
|
|
365
424
|
.then(({ data }) => setInvoices(data))
|
|
366
425
|
.catch(console.error)
|
|
367
426
|
.finally(() => setLoading(false))
|
|
@@ -22,36 +22,62 @@ export const stripePlugin = new Elysia({ name: 'stripe' })
|
|
|
22
22
|
|
|
23
23
|
// routes/billing.js
|
|
24
24
|
import { Elysia, t } from 'elysia'
|
|
25
|
-
import { stripePlugin } from '../plugins/stripe'
|
|
26
|
-
import { billingService } from '../services/billing'
|
|
25
|
+
import { stripePlugin } from '../plugins/stripe.js'
|
|
26
|
+
import { billingService } from '../services/billing.js'
|
|
27
27
|
|
|
28
|
-
export const billingRoutes = new Elysia({ prefix: '/billing' })
|
|
28
|
+
export const billingRoutes = new Elysia({ prefix: '/api/billing' })
|
|
29
29
|
.use(stripePlugin)
|
|
30
|
+
// Resolve localUser a partir do token de auth.
|
|
31
|
+
// Auto-cria usuário local e Stripe Customer se ainda não existirem.
|
|
32
|
+
.derive(async ({ stripe, user }) => {
|
|
33
|
+
if (!user) {
|
|
34
|
+
return { localUser: null }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const email = user.email || user.emailAddress || user.primaryEmail
|
|
38
|
+
let localUser = await billingService.getOrCreateUser(user.id, email)
|
|
39
|
+
|
|
40
|
+
if (!localUser) {
|
|
41
|
+
return { localUser: null }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (!localUser.stripeCustomerId && email) {
|
|
45
|
+
try {
|
|
46
|
+
const stripeCustomerId = await billingService.getOrCreateStripeCustomer(stripe, user.id, email)
|
|
47
|
+
localUser.stripeCustomerId = stripeCustomerId
|
|
48
|
+
} catch (err) {
|
|
49
|
+
console.error('Failed to auto-create Stripe Customer:', err.message)
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return { localUser }
|
|
54
|
+
})
|
|
30
55
|
|
|
31
56
|
// Create checkout session
|
|
32
|
-
.post('/checkout', async ({ stripe, body, user }) => {
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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)
|
|
57
|
+
.post('/checkout', async ({ stripe, body, user, localUser, set }) => {
|
|
58
|
+
if (!user) {
|
|
59
|
+
set.status = 401
|
|
60
|
+
return { error: 'Unauthorized' }
|
|
43
61
|
}
|
|
44
62
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
63
|
+
try {
|
|
64
|
+
const stripeCustomerId = await billingService.getOrCreateStripeCustomer(stripe, user.id, user.email)
|
|
65
|
+
|
|
66
|
+
const session = await stripe.checkout.sessions.create({
|
|
67
|
+
mode: body.mode,
|
|
68
|
+
customer: stripeCustomerId,
|
|
69
|
+
line_items: [{ price: body.priceId, quantity: 1 }],
|
|
70
|
+
success_url: `${process.env.APP_URL || 'http://localhost:5173'}/billing/success?session_id={CHECKOUT_SESSION_ID}`,
|
|
71
|
+
cancel_url: `${process.env.APP_URL || 'http://localhost:5173'}/billing`,
|
|
72
|
+
metadata: { userId: user.id },
|
|
73
|
+
})
|
|
53
74
|
|
|
54
|
-
|
|
75
|
+
return { url: session.url }
|
|
76
|
+
} catch (error) {
|
|
77
|
+
console.error('Checkout session creation failed:', error)
|
|
78
|
+
set.status = 500
|
|
79
|
+
return { error: 'Internal Server Error', message: error.message }
|
|
80
|
+
}
|
|
55
81
|
}, {
|
|
56
82
|
body: t.Object({
|
|
57
83
|
priceId: t.String(),
|
|
@@ -60,45 +86,72 @@ export const billingRoutes = new Elysia({ prefix: '/billing' })
|
|
|
60
86
|
})
|
|
61
87
|
|
|
62
88
|
// Get subscription status
|
|
63
|
-
.get('/subscription', async ({ stripe,
|
|
64
|
-
|
|
65
|
-
|
|
89
|
+
.get('/subscription', async ({ stripe, localUser }) => {
|
|
90
|
+
// Retorna dados do DB como base, enriquece com Stripe se houver assinatura
|
|
91
|
+
const baseResponse = {
|
|
92
|
+
status: localUser?.subscriptionStatus || 'none',
|
|
93
|
+
plan: localUser?.plan || 'free',
|
|
94
|
+
hasBillingAccount: !!localUser?.stripeCustomerId,
|
|
95
|
+
currentPeriodEnd: localUser?.currentPeriodEnd
|
|
96
|
+
? Math.floor(new Date(localUser.currentPeriodEnd).getTime() / 1000)
|
|
97
|
+
: null,
|
|
66
98
|
}
|
|
67
99
|
|
|
68
|
-
|
|
100
|
+
if (!localUser?.stripeSubscriptionId) {
|
|
101
|
+
return baseResponse
|
|
102
|
+
}
|
|
69
103
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
104
|
+
try {
|
|
105
|
+
const subscription = await stripe.subscriptions.retrieve(localUser.stripeSubscriptionId)
|
|
106
|
+
|
|
107
|
+
// current_period_end pode estar nos items em versões mais recentes da API
|
|
108
|
+
let currentPeriodEnd = subscription.current_period_end
|
|
109
|
+
if (!currentPeriodEnd && subscription.items?.data?.length > 0) {
|
|
110
|
+
currentPeriodEnd = subscription.items.data[0].current_period_end
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return {
|
|
114
|
+
...baseResponse,
|
|
115
|
+
status: subscription.status,
|
|
116
|
+
currentPeriodEnd,
|
|
117
|
+
cancelAtPeriodEnd: subscription.cancel_at_period_end,
|
|
118
|
+
}
|
|
119
|
+
} catch (error) {
|
|
120
|
+
console.error('Failed to fetch subscription:', error.message)
|
|
121
|
+
return { ...baseResponse, status: 'error', message: error.message }
|
|
75
122
|
}
|
|
76
123
|
})
|
|
77
124
|
|
|
78
125
|
// Customer portal
|
|
79
|
-
.post('/portal', async ({ stripe,
|
|
80
|
-
if (!
|
|
126
|
+
.post('/portal', async ({ stripe, localUser, set }) => {
|
|
127
|
+
if (!localUser?.stripeCustomerId) {
|
|
81
128
|
set.status = 400
|
|
82
129
|
return { error: 'No billing account' }
|
|
83
130
|
}
|
|
84
131
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
132
|
+
try {
|
|
133
|
+
const portal = await stripe.billingPortal.sessions.create({
|
|
134
|
+
customer: localUser.stripeCustomerId,
|
|
135
|
+
return_url: `${process.env.APP_URL || 'http://localhost:5173'}/billing`,
|
|
136
|
+
})
|
|
89
137
|
|
|
90
|
-
|
|
138
|
+
return { url: portal.url }
|
|
139
|
+
} catch (error) {
|
|
140
|
+
console.error('Failed to create portal session:', error)
|
|
141
|
+
set.status = 500
|
|
142
|
+
return { error: 'Internal Server Error' }
|
|
143
|
+
}
|
|
91
144
|
})
|
|
92
145
|
|
|
93
146
|
// Cancel subscription
|
|
94
|
-
.post('/subscription/cancel', async ({ stripe,
|
|
95
|
-
if (!
|
|
147
|
+
.post('/subscription/cancel', async ({ stripe, localUser, set }) => {
|
|
148
|
+
if (!localUser?.stripeSubscriptionId) {
|
|
96
149
|
set.status = 400
|
|
97
150
|
return { error: 'No active subscription' }
|
|
98
151
|
}
|
|
99
152
|
|
|
100
153
|
const subscription = await stripe.subscriptions.update(
|
|
101
|
-
|
|
154
|
+
localUser.stripeSubscriptionId,
|
|
102
155
|
{ cancel_at_period_end: true }
|
|
103
156
|
)
|
|
104
157
|
|
|
@@ -109,14 +162,14 @@ export const billingRoutes = new Elysia({ prefix: '/billing' })
|
|
|
109
162
|
})
|
|
110
163
|
|
|
111
164
|
// Resume subscription
|
|
112
|
-
.post('/subscription/resume', async ({ stripe,
|
|
113
|
-
if (!
|
|
165
|
+
.post('/subscription/resume', async ({ stripe, localUser, set }) => {
|
|
166
|
+
if (!localUser?.stripeSubscriptionId) {
|
|
114
167
|
set.status = 400
|
|
115
168
|
return { error: 'No subscription to resume' }
|
|
116
169
|
}
|
|
117
170
|
|
|
118
171
|
const subscription = await stripe.subscriptions.update(
|
|
119
|
-
|
|
172
|
+
localUser.stripeSubscriptionId,
|
|
120
173
|
{ cancel_at_period_end: false }
|
|
121
174
|
)
|
|
122
175
|
|
|
@@ -124,13 +177,13 @@ export const billingRoutes = new Elysia({ prefix: '/billing' })
|
|
|
124
177
|
})
|
|
125
178
|
|
|
126
179
|
// List invoices
|
|
127
|
-
.get('/invoices', async ({ stripe,
|
|
128
|
-
if (!
|
|
180
|
+
.get('/invoices', async ({ stripe, localUser, query }) => {
|
|
181
|
+
if (!localUser?.stripeCustomerId) {
|
|
129
182
|
return { data: [], hasMore: false }
|
|
130
183
|
}
|
|
131
184
|
|
|
132
185
|
const invoices = await stripe.invoices.list({
|
|
133
|
-
customer:
|
|
186
|
+
customer: localUser.stripeCustomerId,
|
|
134
187
|
limit: query.limit
|
|
135
188
|
})
|
|
136
189
|
|
|
@@ -157,46 +210,87 @@ export const billingRoutes = new Elysia({ prefix: '/billing' })
|
|
|
157
210
|
// routes/webhook.js
|
|
158
211
|
import { Elysia } from 'elysia'
|
|
159
212
|
import Stripe from 'stripe'
|
|
160
|
-
import { billingService } from '../services/billing'
|
|
213
|
+
import { billingService } from '../services/billing.js'
|
|
214
|
+
import { PLAN_MAP } from '../config/plans.js'
|
|
161
215
|
|
|
162
216
|
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY)
|
|
163
217
|
|
|
164
|
-
export const
|
|
165
|
-
.post('/webhook
|
|
218
|
+
export const stripeWebhookRoutes = new Elysia()
|
|
219
|
+
.post('/api/webhook', async ({ request, set }) => {
|
|
166
220
|
const sig = request.headers.get('stripe-signature')
|
|
167
221
|
const body = await request.text()
|
|
168
222
|
|
|
169
223
|
let event
|
|
170
224
|
|
|
171
225
|
try {
|
|
172
|
-
event = stripe.webhooks.
|
|
173
|
-
body,
|
|
174
|
-
sig,
|
|
175
|
-
process.env.STRIPE_WEBHOOK_SECRET
|
|
176
|
-
)
|
|
226
|
+
event = await stripe.webhooks.constructEventAsync(body, sig, process.env.STRIPE_WEBHOOK_SECRET)
|
|
177
227
|
} catch (err) {
|
|
178
228
|
console.error('Webhook signature failed:', err.message)
|
|
179
229
|
set.status = 400
|
|
180
230
|
return { error: 'Invalid signature' }
|
|
181
231
|
}
|
|
182
232
|
|
|
183
|
-
|
|
233
|
+
console.log(`Stripe Webhook received: ${event.type}`)
|
|
234
|
+
|
|
184
235
|
switch (event.type) {
|
|
185
236
|
case 'checkout.session.completed': {
|
|
186
237
|
const session = event.data.object
|
|
187
238
|
const userId = session.metadata.userId
|
|
239
|
+
const email = session.customer_details?.email
|
|
240
|
+
|
|
241
|
+
// Garante que o usuário existe localmente antes de ativar
|
|
242
|
+
if (userId && email) {
|
|
243
|
+
await billingService.getOrCreateUser(userId, email)
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
let periodEnd = null
|
|
247
|
+
let plan = 'starter' // Fallback padrão
|
|
248
|
+
|
|
249
|
+
// "Trust but Verify": busca a assinatura completa no Stripe
|
|
250
|
+
// para garantir dados corretos (plano real e período)
|
|
251
|
+
if (session.subscription) {
|
|
252
|
+
try {
|
|
253
|
+
const sub = await stripe.subscriptions.retrieve(session.subscription)
|
|
254
|
+
|
|
255
|
+
// current_period_end pode estar nos items em versões mais recentes da API
|
|
256
|
+
let retrievedPeriodEnd = sub.current_period_end
|
|
257
|
+
let priceId = null
|
|
258
|
+
|
|
259
|
+
if (sub.items?.data?.length > 0) {
|
|
260
|
+
const mainItem = sub.items.data[0]
|
|
261
|
+
if (!retrievedPeriodEnd) {
|
|
262
|
+
retrievedPeriodEnd = mainItem.current_period_end
|
|
263
|
+
}
|
|
264
|
+
priceId = mainItem.price?.id
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
if (priceId && PLAN_MAP[priceId]) {
|
|
268
|
+
plan = PLAN_MAP[priceId]
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
periodEnd = retrievedPeriodEnd ? new Date(retrievedPeriodEnd * 1000) : null
|
|
272
|
+
} catch (err) {
|
|
273
|
+
console.error('Failed to fetch subscription details:', err.message)
|
|
274
|
+
}
|
|
275
|
+
}
|
|
188
276
|
|
|
189
277
|
await billingService.activateSubscription(userId, {
|
|
190
278
|
subscriptionId: session.subscription,
|
|
191
|
-
plan
|
|
192
|
-
periodEnd
|
|
279
|
+
plan,
|
|
280
|
+
periodEnd,
|
|
193
281
|
})
|
|
194
282
|
break
|
|
195
283
|
}
|
|
196
284
|
|
|
197
285
|
case 'customer.subscription.updated': {
|
|
198
286
|
const subscription = event.data.object
|
|
199
|
-
|
|
287
|
+
|
|
288
|
+
await billingService.updateSubscription(subscription.id, {
|
|
289
|
+
status: subscription.status,
|
|
290
|
+
periodEnd: subscription.current_period_end
|
|
291
|
+
? new Date(subscription.current_period_end * 1000)
|
|
292
|
+
: null,
|
|
293
|
+
})
|
|
200
294
|
break
|
|
201
295
|
}
|
|
202
296
|
|
|
@@ -208,13 +302,13 @@ export const webhookRoutes = new Elysia()
|
|
|
208
302
|
|
|
209
303
|
case 'invoice.paid': {
|
|
210
304
|
const invoice = event.data.object
|
|
211
|
-
//
|
|
305
|
+
// TODO: Registrar fatura no banco local se necessário
|
|
212
306
|
break
|
|
213
307
|
}
|
|
214
308
|
|
|
215
309
|
case 'invoice.payment_failed': {
|
|
216
310
|
const invoice = event.data.object
|
|
217
|
-
//
|
|
311
|
+
// TODO: Notificar usuário sobre falha no pagamento
|
|
218
312
|
break
|
|
219
313
|
}
|
|
220
314
|
}
|
|
@@ -227,37 +321,100 @@ export const webhookRoutes = new Elysia()
|
|
|
227
321
|
// ============================================
|
|
228
322
|
|
|
229
323
|
// services/billing.js
|
|
230
|
-
import { db } from '../
|
|
231
|
-
import {
|
|
232
|
-
import {
|
|
324
|
+
import { db, users, flows } from '../db/index.js'
|
|
325
|
+
import { eq, sql } from 'drizzle-orm'
|
|
326
|
+
import { PLAN_LIMITS } from '../config/plans.js'
|
|
233
327
|
|
|
234
328
|
export const billingService = {
|
|
329
|
+
async getOrCreateUser(userId, email) {
|
|
330
|
+
if (!userId) throw new Error('User ID is required')
|
|
331
|
+
|
|
332
|
+
let [user] = await db.select().from(users).where(eq(users.id, userId)).limit(1)
|
|
333
|
+
|
|
334
|
+
if (!user) {
|
|
335
|
+
if (!email) {
|
|
336
|
+
console.warn('[BillingService] Cannot create user record: No email provided', { userId })
|
|
337
|
+
return null
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
console.log('[BillingService] Creating local user record:', { userId, email })
|
|
341
|
+
try {
|
|
342
|
+
;[user] = await db
|
|
343
|
+
.insert(users)
|
|
344
|
+
.values({ id: userId, email })
|
|
345
|
+
.returning()
|
|
346
|
+
} catch (err) {
|
|
347
|
+
console.error('[BillingService] Failed to insert user record:', err.message)
|
|
348
|
+
throw err
|
|
349
|
+
}
|
|
350
|
+
} else if (email && user.email !== email) {
|
|
351
|
+
console.log('[BillingService] Updating email for user:', { userId, from: user.email, to: email })
|
|
352
|
+
await db.update(users).set({ email }).where(eq(users.id, userId))
|
|
353
|
+
user.email = email
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
return user
|
|
357
|
+
},
|
|
358
|
+
|
|
359
|
+
async getOrCreateStripeCustomer(stripe, userId, email) {
|
|
360
|
+
const user = await this.getOrCreateUser(userId, email)
|
|
361
|
+
if (!user) throw new Error('Local user record not found or could not be created')
|
|
362
|
+
|
|
363
|
+
if (user.stripeCustomerId) return user.stripeCustomerId
|
|
364
|
+
|
|
365
|
+
if (!user.email) throw new Error('User email is required for Stripe registration')
|
|
366
|
+
|
|
367
|
+
console.log('[BillingService] Creating Stripe customer for:', user.email)
|
|
368
|
+
const customer = await stripe.customers.create({
|
|
369
|
+
email: user.email,
|
|
370
|
+
metadata: { userId: user.id },
|
|
371
|
+
})
|
|
372
|
+
|
|
373
|
+
await db.update(users).set({ stripeCustomerId: customer.id }).where(eq(users.id, userId))
|
|
374
|
+
return customer.id
|
|
375
|
+
},
|
|
376
|
+
|
|
235
377
|
async linkStripeCustomer(userId, stripeCustomerId) {
|
|
236
378
|
await db.update(users)
|
|
237
|
-
.set({ stripeCustomerId
|
|
379
|
+
.set({ stripeCustomerId })
|
|
238
380
|
.where(eq(users.id, userId))
|
|
239
381
|
},
|
|
240
382
|
|
|
241
383
|
async activateSubscription(userId, { subscriptionId, plan, periodEnd }) {
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
384
|
+
try {
|
|
385
|
+
await db
|
|
386
|
+
.update(users)
|
|
387
|
+
.set({
|
|
388
|
+
stripeSubscriptionId: subscriptionId,
|
|
389
|
+
plan,
|
|
390
|
+
subscriptionStatus: 'active',
|
|
391
|
+
currentPeriodEnd: periodEnd,
|
|
392
|
+
})
|
|
393
|
+
.where(eq(users.id, userId))
|
|
394
|
+
} catch (error) {
|
|
395
|
+
console.error('[BillingService] Failed to update user:', error)
|
|
396
|
+
throw error
|
|
397
|
+
}
|
|
252
398
|
},
|
|
253
399
|
|
|
254
400
|
async deactivateSubscription(subscriptionId) {
|
|
255
|
-
await db
|
|
401
|
+
await db
|
|
402
|
+
.update(users)
|
|
256
403
|
.set({
|
|
257
404
|
plan: 'free',
|
|
258
405
|
subscriptionStatus: 'canceled',
|
|
259
406
|
stripeSubscriptionId: null,
|
|
260
|
-
|
|
407
|
+
})
|
|
408
|
+
.where(eq(users.stripeSubscriptionId, subscriptionId))
|
|
409
|
+
},
|
|
410
|
+
|
|
411
|
+
async updateSubscription(subscriptionId, { periodEnd, status, plan }) {
|
|
412
|
+
await db
|
|
413
|
+
.update(users)
|
|
414
|
+
.set({
|
|
415
|
+
currentPeriodEnd: periodEnd,
|
|
416
|
+
subscriptionStatus: status,
|
|
417
|
+
...(plan ? { plan } : {}),
|
|
261
418
|
})
|
|
262
419
|
.where(eq(users.stripeSubscriptionId, subscriptionId))
|
|
263
420
|
},
|
|
@@ -272,7 +429,29 @@ export const billingService = {
|
|
|
272
429
|
|
|
273
430
|
const planHierarchy = { free: 0, starter: 1, pro: 2, enterprise: 3 }
|
|
274
431
|
return (planHierarchy[user.plan] || 0) >= (planHierarchy[requiredPlan] || 0)
|
|
275
|
-
}
|
|
432
|
+
},
|
|
433
|
+
|
|
434
|
+
async checkFlowLimit(userId) {
|
|
435
|
+
const [user] = await db.select().from(users).where(eq(users.id, userId)).limit(1)
|
|
436
|
+
if (!user) throw new Error('User not found')
|
|
437
|
+
|
|
438
|
+
const userPlan = user.plan || 'free'
|
|
439
|
+
const limit = PLAN_LIMITS[userPlan]?.maxFlows || 1
|
|
440
|
+
|
|
441
|
+
const [result] = await db
|
|
442
|
+
.select({ count: sql`count(*)` })
|
|
443
|
+
.from(flows)
|
|
444
|
+
.where(eq(flows.userId, userId))
|
|
445
|
+
|
|
446
|
+
const currentCount = result?.count || 0
|
|
447
|
+
|
|
448
|
+
return {
|
|
449
|
+
allowed: currentCount < limit,
|
|
450
|
+
current: currentCount,
|
|
451
|
+
limit: limit,
|
|
452
|
+
plan: userPlan,
|
|
453
|
+
}
|
|
454
|
+
},
|
|
276
455
|
}
|
|
277
456
|
|
|
278
457
|
// ============================================
|
|
@@ -20,22 +20,61 @@ export const stripePlugin = new Elysia({ name: 'stripe' })
|
|
|
20
20
|
```javascript
|
|
21
21
|
// routes/billing.js
|
|
22
22
|
import { Elysia, t } from 'elysia'
|
|
23
|
+
import { billingService } from '../services/billing.js'
|
|
23
24
|
|
|
24
|
-
export const billingRoutes = new Elysia({ prefix: '/billing' })
|
|
25
|
+
export const billingRoutes = new Elysia({ prefix: '/api/billing' })
|
|
25
26
|
.use(stripePlugin)
|
|
26
27
|
|
|
28
|
+
// Resolve localUser a partir do token de auth
|
|
29
|
+
.derive(async ({ stripe, user }) => {
|
|
30
|
+
if (!user) {
|
|
31
|
+
return { localUser: null }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const email = user.email || user.emailAddress || user.primaryEmail
|
|
35
|
+
let localUser = await billingService.getOrCreateUser(user.id, email)
|
|
36
|
+
|
|
37
|
+
if (!localUser) {
|
|
38
|
+
return { localUser: null }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (!localUser.stripeCustomerId && email) {
|
|
42
|
+
try {
|
|
43
|
+
const stripeCustomerId = await billingService.getOrCreateStripeCustomer(stripe, user.id, email)
|
|
44
|
+
localUser.stripeCustomerId = stripeCustomerId
|
|
45
|
+
} catch (err) {
|
|
46
|
+
console.error('Failed to auto-create Stripe Customer:', err.message)
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return { localUser }
|
|
51
|
+
})
|
|
52
|
+
|
|
27
53
|
// Create checkout session
|
|
28
|
-
.post('/checkout', async ({ stripe, body, user, set }) => {
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
success_url: `${process.env.APP_URL}/success?session_id={CHECKOUT_SESSION_ID}`,
|
|
34
|
-
cancel_url: `${process.env.APP_URL}/pricing`,
|
|
35
|
-
metadata: { userId: user.id }
|
|
36
|
-
})
|
|
54
|
+
.post('/checkout', async ({ stripe, body, user, localUser, set }) => {
|
|
55
|
+
if (!user) {
|
|
56
|
+
set.status = 401
|
|
57
|
+
return { error: 'Unauthorized' }
|
|
58
|
+
}
|
|
37
59
|
|
|
38
|
-
|
|
60
|
+
try {
|
|
61
|
+
const stripeCustomerId = await billingService.getOrCreateStripeCustomer(stripe, user.id, user.email)
|
|
62
|
+
|
|
63
|
+
const session = await stripe.checkout.sessions.create({
|
|
64
|
+
mode: body.mode,
|
|
65
|
+
customer: stripeCustomerId,
|
|
66
|
+
line_items: [{ price: body.priceId, quantity: 1 }],
|
|
67
|
+
success_url: `${process.env.APP_URL || 'http://localhost:5173'}/billing/success?session_id={CHECKOUT_SESSION_ID}`,
|
|
68
|
+
cancel_url: `${process.env.APP_URL || 'http://localhost:5173'}/billing`,
|
|
69
|
+
metadata: { userId: user.id }
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
return { url: session.url }
|
|
73
|
+
} catch (error) {
|
|
74
|
+
console.error('Checkout session creation failed:', error)
|
|
75
|
+
set.status = 500
|
|
76
|
+
return { error: 'Internal Server Error', message: error.message }
|
|
77
|
+
}
|
|
39
78
|
}, {
|
|
40
79
|
body: t.Object({
|
|
41
80
|
priceId: t.String(),
|
|
@@ -44,75 +83,117 @@ export const billingRoutes = new Elysia({ prefix: '/billing' })
|
|
|
44
83
|
})
|
|
45
84
|
|
|
46
85
|
// Customer portal
|
|
47
|
-
.post('/portal', async ({ stripe,
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
}
|
|
86
|
+
.post('/portal', async ({ stripe, localUser, set }) => {
|
|
87
|
+
if (!localUser?.stripeCustomerId) {
|
|
88
|
+
set.status = 400
|
|
89
|
+
return { error: 'No billing account' }
|
|
90
|
+
}
|
|
52
91
|
|
|
53
|
-
|
|
92
|
+
try {
|
|
93
|
+
const portal = await stripe.billingPortal.sessions.create({
|
|
94
|
+
customer: localUser.stripeCustomerId,
|
|
95
|
+
return_url: `${process.env.APP_URL || 'http://localhost:5173'}/billing`
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
return { url: portal.url }
|
|
99
|
+
} catch (error) {
|
|
100
|
+
console.error('Failed to create portal session:', error)
|
|
101
|
+
set.status = 500
|
|
102
|
+
return { error: 'Internal Server Error' }
|
|
103
|
+
}
|
|
54
104
|
})
|
|
55
105
|
|
|
56
106
|
// Get subscription status
|
|
57
|
-
.get('/subscription', async ({ stripe,
|
|
58
|
-
|
|
59
|
-
|
|
107
|
+
.get('/subscription', async ({ stripe, localUser }) => {
|
|
108
|
+
const baseResponse = {
|
|
109
|
+
status: localUser?.subscriptionStatus || 'none',
|
|
110
|
+
plan: localUser?.plan || 'free',
|
|
111
|
+
hasBillingAccount: !!localUser?.stripeCustomerId,
|
|
112
|
+
currentPeriodEnd: localUser?.currentPeriodEnd
|
|
113
|
+
? Math.floor(new Date(localUser.currentPeriodEnd).getTime() / 1000)
|
|
114
|
+
: null,
|
|
60
115
|
}
|
|
61
116
|
|
|
62
|
-
|
|
117
|
+
if (!localUser?.stripeSubscriptionId) {
|
|
118
|
+
return baseResponse
|
|
119
|
+
}
|
|
63
120
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
121
|
+
try {
|
|
122
|
+
const subscription = await stripe.subscriptions.retrieve(localUser.stripeSubscriptionId)
|
|
123
|
+
|
|
124
|
+
// current_period_end pode estar nos items em versões mais recentes da API
|
|
125
|
+
let currentPeriodEnd = subscription.current_period_end
|
|
126
|
+
if (!currentPeriodEnd && subscription.items?.data?.length > 0) {
|
|
127
|
+
currentPeriodEnd = subscription.items.data[0].current_period_end
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return {
|
|
131
|
+
...baseResponse,
|
|
132
|
+
status: subscription.status,
|
|
133
|
+
currentPeriodEnd,
|
|
134
|
+
cancelAtPeriodEnd: subscription.cancel_at_period_end,
|
|
135
|
+
}
|
|
136
|
+
} catch (error) {
|
|
137
|
+
console.error('Failed to fetch subscription:', error.message)
|
|
138
|
+
return { ...baseResponse, status: 'error', message: error.message }
|
|
69
139
|
}
|
|
70
140
|
})
|
|
71
141
|
```
|
|
72
142
|
|
|
73
143
|
## Customer Management
|
|
74
144
|
|
|
75
|
-
###
|
|
145
|
+
### getOrCreateUser (Billing Service)
|
|
146
|
+
|
|
147
|
+
Garante que o usuário existe localmente antes de qualquer operação de billing:
|
|
76
148
|
|
|
77
149
|
```javascript
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
if (
|
|
81
|
-
return user.stripeCustomerId
|
|
82
|
-
}
|
|
150
|
+
// services/billing.js
|
|
151
|
+
async getOrCreateUser(userId, email) {
|
|
152
|
+
if (!userId) throw new Error('User ID is required')
|
|
83
153
|
|
|
84
|
-
|
|
85
|
-
const customer = await stripe.customers.create({
|
|
86
|
-
email: user.email,
|
|
87
|
-
name: user.name,
|
|
88
|
-
metadata: { userId: user.id }
|
|
89
|
-
})
|
|
154
|
+
let [user] = await db.select().from(users).where(eq(users.id, userId)).limit(1)
|
|
90
155
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
156
|
+
if (!user) {
|
|
157
|
+
if (!email) {
|
|
158
|
+
console.warn('Cannot create user record: No email provided', { userId })
|
|
159
|
+
return null
|
|
160
|
+
}
|
|
95
161
|
|
|
96
|
-
|
|
162
|
+
console.log('Creating local user record:', { userId, email })
|
|
163
|
+
;[user] = await db
|
|
164
|
+
.insert(users)
|
|
165
|
+
.values({ id: userId, email })
|
|
166
|
+
.returning()
|
|
167
|
+
} else if (email && user.email !== email) {
|
|
168
|
+
await db.update(users).set({ email }).where(eq(users.id, userId))
|
|
169
|
+
user.email = email
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return user
|
|
97
173
|
}
|
|
98
174
|
```
|
|
99
175
|
|
|
100
|
-
###
|
|
176
|
+
### getOrCreateStripeCustomer (Billing Service)
|
|
177
|
+
|
|
178
|
+
Cria ou retorna o Stripe Customer ID:
|
|
101
179
|
|
|
102
180
|
```javascript
|
|
103
|
-
|
|
104
|
-
const
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
181
|
+
async getOrCreateStripeCustomer(stripe, userId, email) {
|
|
182
|
+
const user = await this.getOrCreateUser(userId, email)
|
|
183
|
+
if (!user) throw new Error('Local user record not found or could not be created')
|
|
184
|
+
|
|
185
|
+
if (user.stripeCustomerId) return user.stripeCustomerId
|
|
186
|
+
|
|
187
|
+
if (!user.email) throw new Error('User email is required for Stripe registration')
|
|
188
|
+
|
|
189
|
+
const customer = await stripe.customers.create({
|
|
190
|
+
email: user.email,
|
|
191
|
+
metadata: { userId: user.id },
|
|
112
192
|
})
|
|
113
193
|
|
|
114
|
-
|
|
115
|
-
|
|
194
|
+
await db.update(users).set({ stripeCustomerId: customer.id }).where(eq(users.id, userId))
|
|
195
|
+
return customer.id
|
|
196
|
+
}
|
|
116
197
|
```
|
|
117
198
|
|
|
118
199
|
## Subscription Operations
|
|
@@ -12,14 +12,14 @@ import Stripe from 'stripe'
|
|
|
12
12
|
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY)
|
|
13
13
|
|
|
14
14
|
export const webhookRoutes = new Elysia()
|
|
15
|
-
.post('/webhook
|
|
15
|
+
.post('/api/webhook', async ({ request, set }) => {
|
|
16
16
|
const sig = request.headers.get('stripe-signature')
|
|
17
17
|
const body = await request.text()
|
|
18
18
|
|
|
19
19
|
let event
|
|
20
20
|
|
|
21
21
|
try {
|
|
22
|
-
event = stripe.webhooks.
|
|
22
|
+
event = await stripe.webhooks.constructEventAsync(
|
|
23
23
|
body,
|
|
24
24
|
sig,
|
|
25
25
|
process.env.STRIPE_WEBHOOK_SECRET
|
|
@@ -98,23 +98,66 @@ export async function handleWebhookEvent(event) {
|
|
|
98
98
|
|
|
99
99
|
```javascript
|
|
100
100
|
async function handleCheckoutComplete(session) {
|
|
101
|
-
const { customer, subscription, metadata } = session
|
|
101
|
+
const { customer, subscription, metadata, customer_details } = session
|
|
102
102
|
const userId = metadata.userId
|
|
103
|
+
const email = customer_details?.email
|
|
103
104
|
|
|
104
|
-
//
|
|
105
|
+
// CRÍTICO: Garante que o usuário existe localmente antes de ativar
|
|
106
|
+
if (userId && email) {
|
|
107
|
+
await billingService.getOrCreateUser(userId, email)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
let plan = 'starter' // Fallback padrão
|
|
111
|
+
let periodEnd = null
|
|
112
|
+
|
|
113
|
+
// "Trust but Verify": Busca a assinatura completa no Stripe
|
|
114
|
+
// para obter dados reais (plano e período corretos)
|
|
115
|
+
if (subscription) {
|
|
116
|
+
try {
|
|
117
|
+
const sub = await stripe.subscriptions.retrieve(subscription)
|
|
118
|
+
|
|
119
|
+
// current_period_end pode estar nos items em versões mais recentes da API
|
|
120
|
+
let retrievedPeriodEnd = sub.current_period_end
|
|
121
|
+
let priceId = null
|
|
122
|
+
|
|
123
|
+
if (sub.items?.data?.length > 0) {
|
|
124
|
+
const mainItem = sub.items.data[0]
|
|
125
|
+
if (!retrievedPeriodEnd) {
|
|
126
|
+
retrievedPeriodEnd = mainItem.current_period_end
|
|
127
|
+
}
|
|
128
|
+
priceId = mainItem.price?.id
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Mapeia price ID para plano (importar PLAN_MAP de config/plans.js)
|
|
132
|
+
const PLAN_MAP = {
|
|
133
|
+
'price_starter_monthly': 'starter',
|
|
134
|
+
'price_pro_monthly': 'pro',
|
|
135
|
+
'price_enterprise_monthly': 'enterprise'
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (priceId && PLAN_MAP[priceId]) {
|
|
139
|
+
plan = PLAN_MAP[priceId]
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
periodEnd = retrievedPeriodEnd ? new Date(retrievedPeriodEnd * 1000) : null
|
|
143
|
+
} catch (err) {
|
|
144
|
+
console.error('Failed to fetch subscription details:', err.message)
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Update user with validated data
|
|
105
149
|
await db.update(users)
|
|
106
150
|
.set({
|
|
107
151
|
stripeCustomerId: customer,
|
|
108
152
|
stripeSubscriptionId: subscription,
|
|
109
|
-
plan
|
|
153
|
+
plan,
|
|
154
|
+
subscriptionStatus: 'active',
|
|
155
|
+
currentPeriodEnd: periodEnd,
|
|
110
156
|
updatedAt: new Date()
|
|
111
157
|
})
|
|
112
158
|
.where(eq(users.id, userId))
|
|
113
159
|
|
|
114
|
-
|
|
115
|
-
// await sendWelcomeEmail(userId)
|
|
116
|
-
|
|
117
|
-
console.log(`Checkout completed for user ${userId}`)
|
|
160
|
+
console.log(`Checkout completed for user ${userId}, plan: ${plan}`)
|
|
118
161
|
}
|
|
119
162
|
```
|
|
120
163
|
|
|
@@ -123,7 +166,7 @@ async function handleCheckoutComplete(session) {
|
|
|
123
166
|
```javascript
|
|
124
167
|
async function handleSubscriptionChange(subscription) {
|
|
125
168
|
const { id, status, items, current_period_end, cancel_at_period_end } = subscription
|
|
126
|
-
const priceId = items.data[0]
|
|
169
|
+
const priceId = items.data[0]?.price?.id
|
|
127
170
|
|
|
128
171
|
// Find user by subscription ID
|
|
129
172
|
const [user] = await db.select()
|
|
@@ -136,18 +179,24 @@ async function handleSubscriptionChange(subscription) {
|
|
|
136
179
|
return
|
|
137
180
|
}
|
|
138
181
|
|
|
139
|
-
// Map price ID to plan name
|
|
140
|
-
const
|
|
182
|
+
// Map price ID to plan name (importar PLAN_MAP de config/plans.js)
|
|
183
|
+
const PLAN_MAP = {
|
|
141
184
|
'price_starter_monthly': 'starter',
|
|
142
185
|
'price_pro_monthly': 'pro',
|
|
143
186
|
'price_enterprise_monthly': 'enterprise'
|
|
144
187
|
}
|
|
145
188
|
|
|
189
|
+
// current_period_end pode estar nos items em versões mais recentes da API
|
|
190
|
+
let periodEnd = current_period_end
|
|
191
|
+
if (!periodEnd && items?.data?.length > 0) {
|
|
192
|
+
periodEnd = items.data[0].current_period_end
|
|
193
|
+
}
|
|
194
|
+
|
|
146
195
|
await db.update(users)
|
|
147
196
|
.set({
|
|
148
|
-
plan:
|
|
197
|
+
plan: PLAN_MAP[priceId] || 'free',
|
|
149
198
|
subscriptionStatus: status,
|
|
150
|
-
currentPeriodEnd: new Date(
|
|
199
|
+
currentPeriodEnd: periodEnd ? new Date(periodEnd * 1000) : null,
|
|
151
200
|
cancelAtPeriodEnd: cancel_at_period_end,
|
|
152
201
|
updatedAt: new Date()
|
|
153
202
|
})
|
|
@@ -282,8 +331,8 @@ brew install stripe/stripe-cli/stripe
|
|
|
282
331
|
# Login to Stripe
|
|
283
332
|
stripe login
|
|
284
333
|
|
|
285
|
-
# Forward webhooks to local server
|
|
286
|
-
stripe listen --forward-to localhost:
|
|
334
|
+
# Forward webhooks to local server (ajuste a porta conforme necessário)
|
|
335
|
+
stripe listen --forward-to localhost:3333/api/webhook
|
|
287
336
|
|
|
288
337
|
# Copy the webhook signing secret (whsec_...) to .env.development
|
|
289
338
|
|
|
@@ -331,7 +380,7 @@ stripe trigger invoice.payment_failed
|
|
|
331
380
|
|
|
332
381
|
```javascript
|
|
333
382
|
export const webhookRoutes = new Elysia()
|
|
334
|
-
.post('/webhook
|
|
383
|
+
.post('/api/webhook', async ({ request, set }) => {
|
|
335
384
|
const sig = request.headers.get('stripe-signature')
|
|
336
385
|
const body = await request.text()
|
|
337
386
|
|
|
@@ -339,7 +388,7 @@ export const webhookRoutes = new Elysia()
|
|
|
339
388
|
|
|
340
389
|
// Verify signature
|
|
341
390
|
try {
|
|
342
|
-
event = stripe.webhooks.
|
|
391
|
+
event = await stripe.webhooks.constructEventAsync(
|
|
343
392
|
body,
|
|
344
393
|
sig,
|
|
345
394
|
process.env.STRIPE_WEBHOOK_SECRET
|