@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.
@@ -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/stripe`
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 Config de Preços
180
+ ### Step 9: Criar Configs de Planos e Preços
180
181
 
181
- Gere o arquivo de configuração com os Price IDs:
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
- - [ ] Schema do database atualizado
215
- - [ ] Webhook endpoint configurado no Stripe Dashboard
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:3000/webhook/stripe
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 ky.get('/api/billing/subscription').json()
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 ky.post('/api/billing/checkout', {
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
- ky.get('/api/billing/subscription').json()
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 ky.post('/api/billing/portal').json()
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 ky.post('/api/billing/subscription/cancel').json()
249
+ await api.post('api/billing/subscription/cancel').json()
205
250
  // Refresh subscription data
206
- const data = await ky.get('/api/billing/subscription').json()
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
- ky.get('/api/billing/invoices').json()
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
- // 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)
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
- 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
- })
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
- return { url: session.url }
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, user }) => {
64
- if (!user.stripeSubscriptionId) {
65
- return { status: 'none', plan: null }
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
- const subscription = await stripe.subscriptions.retrieve(user.stripeSubscriptionId)
100
+ if (!localUser?.stripeSubscriptionId) {
101
+ return baseResponse
102
+ }
69
103
 
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
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, user, set }) => {
80
- if (!user.stripeCustomerId) {
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
- const portal = await stripe.billingPortal.sessions.create({
86
- customer: user.stripeCustomerId,
87
- return_url: `${process.env.APP_URL}/account`
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
- return { url: portal.url }
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, user, set }) => {
95
- if (!user.stripeSubscriptionId) {
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
- user.stripeSubscriptionId,
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, user, set }) => {
113
- if (!user.stripeSubscriptionId) {
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
- user.stripeSubscriptionId,
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, user, query }) => {
128
- if (!user.stripeCustomerId) {
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: user.stripeCustomerId,
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 webhookRoutes = new Elysia()
165
- .post('/webhook/stripe', async ({ request, set }) => {
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.constructEvent(
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
- // Handle events
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: 'pro', // Map from price ID
192
- periodEnd: new Date(session.expires_at * 1000)
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
- // Handle plan changes, status updates
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
- // Record successful payment
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
- // Handle failed payment - notify user
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 '../database'
231
- import { users } from '../database/schema'
232
- import { eq } from 'drizzle-orm'
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, updatedAt: new Date() })
379
+ .set({ stripeCustomerId })
238
380
  .where(eq(users.id, userId))
239
381
  },
240
382
 
241
383
  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))
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.update(users)
401
+ await db
402
+ .update(users)
256
403
  .set({
257
404
  plan: 'free',
258
405
  subscriptionStatus: 'canceled',
259
406
  stripeSubscriptionId: null,
260
- updatedAt: new Date()
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
- const session = await stripe.checkout.sessions.create({
30
- mode: body.mode,
31
- customer_email: user.email,
32
- line_items: [{ price: body.priceId, quantity: 1 }],
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
- return { url: session.url }
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, user }) => {
48
- const portal = await stripe.billingPortal.sessions.create({
49
- customer: user.stripeCustomerId,
50
- return_url: `${process.env.APP_URL}/account`
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
- return { url: portal.url }
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, user }) => {
58
- if (!user.stripeSubscriptionId) {
59
- return { status: 'none', plan: null }
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
- const subscription = await stripe.subscriptions.retrieve(user.stripeSubscriptionId)
117
+ if (!localUser?.stripeSubscriptionId) {
118
+ return baseResponse
119
+ }
63
120
 
64
- return {
65
- status: subscription.status,
66
- plan: subscription.items.data[0].price.id,
67
- currentPeriodEnd: subscription.current_period_end,
68
- cancelAtPeriodEnd: subscription.cancel_at_period_end
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
- ### Create or Get Customer
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
- const getOrCreateCustomer = async (stripe, user, db) => {
79
- // Check if user already has a Stripe customer
80
- if (user.stripeCustomerId) {
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
- // Create new customer
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
- // Store customer ID
92
- await db.update(users)
93
- .set({ stripeCustomerId: customer.id })
94
- .where(eq(users.id, user.id))
156
+ if (!user) {
157
+ if (!email) {
158
+ console.warn('Cannot create user record: No email provided', { userId })
159
+ return null
160
+ }
95
161
 
96
- return customer.id
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
- ### Checkout with Existing Customer
176
+ ### getOrCreateStripeCustomer (Billing Service)
177
+
178
+ Cria ou retorna o Stripe Customer ID:
101
179
 
102
180
  ```javascript
103
- .post('/checkout', async ({ stripe, body, user, db }) => {
104
- const customerId = await getOrCreateCustomer(stripe, user, db)
105
-
106
- const session = await stripe.checkout.sessions.create({
107
- mode: 'subscription',
108
- customer: customerId, // Use existing customer
109
- line_items: [{ price: body.priceId, quantity: 1 }],
110
- success_url: `${process.env.APP_URL}/success`,
111
- cancel_url: `${process.env.APP_URL}/pricing`,
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
- return { url: session.url }
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/stripe', async ({ request, set }) => {
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.constructEvent(
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
- // Update user with Stripe IDs
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: 'pro', // or extract from session
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
- // Optional: send welcome email
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].price.id
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 planMap = {
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: planMap[priceId] || 'free',
197
+ plan: PLAN_MAP[priceId] || 'free',
149
198
  subscriptionStatus: status,
150
- currentPeriodEnd: new Date(current_period_end * 1000),
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:3000/webhook/stripe
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/stripe', async ({ request, set }) => {
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.constructEvent(
391
+ event = await stripe.webhooks.constructEventAsync(
343
392
  body,
344
393
  sig,
345
394
  process.env.STRIPE_WEBHOOK_SECRET
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@riligar/agents-kit",
3
- "version": "1.17.0",
3
+ "version": "1.18.0",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },