elsabro 2.0.1 → 2.2.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/commands/elsabro/add-phase.md +17 -0
- package/commands/elsabro/add-todo.md +111 -53
- package/commands/elsabro/audit-milestone.md +19 -0
- package/commands/elsabro/check-todos.md +210 -31
- package/commands/elsabro/complete-milestone.md +20 -1
- package/commands/elsabro/debug.md +19 -0
- package/commands/elsabro/discuss-phase.md +18 -1
- package/commands/elsabro/execute.md +496 -52
- package/commands/elsabro/insert-phase.md +18 -1
- package/commands/elsabro/list-phase-assumptions.md +17 -0
- package/commands/elsabro/new-milestone.md +19 -0
- package/commands/elsabro/new.md +19 -0
- package/commands/elsabro/pause-work.md +75 -0
- package/commands/elsabro/plan-milestone-gaps.md +20 -1
- package/commands/elsabro/plan.md +264 -36
- package/commands/elsabro/progress.md +203 -79
- package/commands/elsabro/quick.md +19 -0
- package/commands/elsabro/remove-phase.md +17 -0
- package/commands/elsabro/research-phase.md +18 -1
- package/commands/elsabro/resume-work.md +130 -2
- package/commands/elsabro/start.md +365 -98
- package/commands/elsabro/verify-work.md +271 -12
- package/package.json +1 -1
- package/references/SYSTEM_INDEX.md +241 -0
- package/references/command-flow.md +352 -0
- package/references/enforcement-rules.md +331 -0
- package/references/error-handling-instructions.md +26 -12
- package/references/state-sync.md +381 -0
- package/references/task-dispatcher.md +388 -0
- package/references/tasks-integration.md +380 -0
- package/skills/api-microservice.md +765 -0
- package/skills/api-setup.md +76 -3
- package/skills/auth-setup.md +46 -6
- package/skills/chrome-extension.md +584 -0
- package/skills/cicd-setup.md +1206 -0
- package/skills/cli-tool.md +884 -0
- package/skills/database-setup.md +41 -5
- package/skills/desktop-app.md +1351 -0
- package/skills/expo-app.md +35 -2
- package/skills/full-stack-app.md +543 -0
- package/skills/mobile-app.md +813 -0
- package/skills/nextjs-app.md +33 -2
- package/skills/payments-setup.md +76 -1
- package/skills/saas-starter.md +639 -0
- package/skills/sentry-setup.md +41 -7
- package/skills/testing-setup.md +1218 -0
|
@@ -0,0 +1,639 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: saas-starter
|
|
3
|
+
description: Template SaaS completo con pagos Stripe, subscripciones, dashboard de usuario y panel de admin. Basado en full-stack-app con features adicionales de monetizacion.
|
|
4
|
+
tags: [saas, stripe, subscriptions, payments, dashboard, admin, nextjs, prisma]
|
|
5
|
+
difficulty: advanced
|
|
6
|
+
estimated_time: 60min
|
|
7
|
+
extends: full-stack-app
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
# Skill: SaaS Starter
|
|
11
|
+
|
|
12
|
+
<when_to_use>
|
|
13
|
+
Usar cuando el usuario menciona:
|
|
14
|
+
- "crear SaaS"
|
|
15
|
+
- "app con pagos"
|
|
16
|
+
- "subscripciones"
|
|
17
|
+
- "Stripe"
|
|
18
|
+
- "monetizar app"
|
|
19
|
+
- "dashboard con billing"
|
|
20
|
+
</when_to_use>
|
|
21
|
+
|
|
22
|
+
<pre_requisites>
|
|
23
|
+
## Pre-requisitos
|
|
24
|
+
|
|
25
|
+
- Todo lo de full-stack-app
|
|
26
|
+
- Cuenta de Stripe (test mode)
|
|
27
|
+
- Stripe CLI instalado
|
|
28
|
+
</pre_requisites>
|
|
29
|
+
|
|
30
|
+
<additional_structure>
|
|
31
|
+
## Estructura Adicional (sobre full-stack-app)
|
|
32
|
+
|
|
33
|
+
```
|
|
34
|
+
my-saas/
|
|
35
|
+
├── src/
|
|
36
|
+
│ ├── app/
|
|
37
|
+
│ │ ├── (dashboard)/
|
|
38
|
+
│ │ │ ├── billing/page.tsx # Gestion de subscripcion
|
|
39
|
+
│ │ │ └── settings/page.tsx # Configuracion de cuenta
|
|
40
|
+
│ │ ├── (admin)/
|
|
41
|
+
│ │ │ ├── layout.tsx # Layout admin
|
|
42
|
+
│ │ │ ├── page.tsx # Dashboard admin
|
|
43
|
+
│ │ │ └── users/page.tsx # Gestion de usuarios
|
|
44
|
+
│ │ ├── api/
|
|
45
|
+
│ │ │ ├── stripe/
|
|
46
|
+
│ │ │ │ └── webhook/route.ts # Webhooks de Stripe
|
|
47
|
+
│ │ │ └── admin/
|
|
48
|
+
│ │ │ └── users/route.ts
|
|
49
|
+
│ │ └── pricing/page.tsx # Pagina de precios
|
|
50
|
+
│ ├── lib/
|
|
51
|
+
│ │ └── stripe.ts # Cliente Stripe
|
|
52
|
+
│ └── actions/
|
|
53
|
+
│ └── billing.ts # Server Actions para billing
|
|
54
|
+
├── prisma/
|
|
55
|
+
│ └── schema.prisma # + Subscription, Price models
|
|
56
|
+
└── stripe/
|
|
57
|
+
└── fixtures/ # Productos y precios de prueba
|
|
58
|
+
```
|
|
59
|
+
</additional_structure>
|
|
60
|
+
|
|
61
|
+
<setup_steps>
|
|
62
|
+
## Pasos de Setup
|
|
63
|
+
|
|
64
|
+
### Paso 1: Completar full-stack-app primero
|
|
65
|
+
|
|
66
|
+
Ejecutar todos los pasos de `/elsabro:skill full-stack-app` primero.
|
|
67
|
+
|
|
68
|
+
### Paso 2: Instalar dependencias de Stripe
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
npm install stripe @stripe/stripe-js
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
### Paso 3: Configurar variables de Stripe
|
|
75
|
+
|
|
76
|
+
Agregar a `.env.local`:
|
|
77
|
+
|
|
78
|
+
```env
|
|
79
|
+
STRIPE_SECRET_KEY=sk_test_...
|
|
80
|
+
STRIPE_PUBLISHABLE_KEY=pk_test_...
|
|
81
|
+
STRIPE_WEBHOOK_SECRET=whsec_...
|
|
82
|
+
STRIPE_PRICE_ID_BASIC=price_...
|
|
83
|
+
STRIPE_PRICE_ID_PRO=price_...
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
### Paso 4: Actualizar schema de Prisma
|
|
87
|
+
|
|
88
|
+
Agregar a `prisma/schema.prisma`:
|
|
89
|
+
|
|
90
|
+
```prisma
|
|
91
|
+
model User {
|
|
92
|
+
id String @id @default(cuid())
|
|
93
|
+
name String?
|
|
94
|
+
email String @unique
|
|
95
|
+
emailVerified DateTime?
|
|
96
|
+
password String?
|
|
97
|
+
image String?
|
|
98
|
+
accounts Account[]
|
|
99
|
+
sessions Session[]
|
|
100
|
+
|
|
101
|
+
// Stripe fields
|
|
102
|
+
stripeCustomerId String? @unique
|
|
103
|
+
subscription Subscription?
|
|
104
|
+
|
|
105
|
+
// Admin
|
|
106
|
+
role Role @default(USER)
|
|
107
|
+
|
|
108
|
+
createdAt DateTime @default(now())
|
|
109
|
+
updatedAt DateTime @updatedAt
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
enum Role {
|
|
113
|
+
USER
|
|
114
|
+
ADMIN
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
model Subscription {
|
|
118
|
+
id String @id @default(cuid())
|
|
119
|
+
userId String @unique
|
|
120
|
+
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
121
|
+
|
|
122
|
+
stripeSubscriptionId String @unique
|
|
123
|
+
stripePriceId String
|
|
124
|
+
stripeCurrentPeriodEnd DateTime
|
|
125
|
+
status SubscriptionStatus
|
|
126
|
+
|
|
127
|
+
createdAt DateTime @default(now())
|
|
128
|
+
updatedAt DateTime @updatedAt
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
enum SubscriptionStatus {
|
|
132
|
+
ACTIVE
|
|
133
|
+
CANCELED
|
|
134
|
+
PAST_DUE
|
|
135
|
+
UNPAID
|
|
136
|
+
TRIALING
|
|
137
|
+
}
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
### Paso 5: Crear cliente de Stripe
|
|
141
|
+
|
|
142
|
+
Crear `src/lib/stripe.ts`:
|
|
143
|
+
|
|
144
|
+
```typescript
|
|
145
|
+
import Stripe from "stripe";
|
|
146
|
+
|
|
147
|
+
export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
|
|
148
|
+
apiVersion: "2024-12-18.acacia",
|
|
149
|
+
typescript: true,
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
export const PLANS = {
|
|
153
|
+
basic: {
|
|
154
|
+
name: "Basic",
|
|
155
|
+
priceId: process.env.STRIPE_PRICE_ID_BASIC!,
|
|
156
|
+
price: 9,
|
|
157
|
+
features: ["5 proyectos", "1GB almacenamiento", "Soporte email"],
|
|
158
|
+
},
|
|
159
|
+
pro: {
|
|
160
|
+
name: "Pro",
|
|
161
|
+
priceId: process.env.STRIPE_PRICE_ID_PRO!,
|
|
162
|
+
price: 29,
|
|
163
|
+
features: ["Proyectos ilimitados", "10GB almacenamiento", "Soporte prioritario", "API access"],
|
|
164
|
+
},
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
export async function createCheckoutSession(
|
|
168
|
+
userId: string,
|
|
169
|
+
priceId: string,
|
|
170
|
+
email: string
|
|
171
|
+
) {
|
|
172
|
+
const session = await stripe.checkout.sessions.create({
|
|
173
|
+
mode: "subscription",
|
|
174
|
+
payment_method_types: ["card"],
|
|
175
|
+
line_items: [{ price: priceId, quantity: 1 }],
|
|
176
|
+
success_url: `${process.env.NEXTAUTH_URL}/dashboard?success=true`,
|
|
177
|
+
cancel_url: `${process.env.NEXTAUTH_URL}/pricing?canceled=true`,
|
|
178
|
+
customer_email: email,
|
|
179
|
+
metadata: { userId },
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
return session;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export async function createBillingPortalSession(customerId: string) {
|
|
186
|
+
const session = await stripe.billingPortal.sessions.create({
|
|
187
|
+
customer: customerId,
|
|
188
|
+
return_url: `${process.env.NEXTAUTH_URL}/dashboard/billing`,
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
return session;
|
|
192
|
+
}
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
### Paso 6: Crear Server Actions para billing
|
|
196
|
+
|
|
197
|
+
Crear `src/actions/billing.ts`:
|
|
198
|
+
|
|
199
|
+
```typescript
|
|
200
|
+
"use server";
|
|
201
|
+
|
|
202
|
+
import { auth } from "@/lib/auth";
|
|
203
|
+
import { db } from "@/lib/db";
|
|
204
|
+
import { createCheckoutSession, createBillingPortalSession } from "@/lib/stripe";
|
|
205
|
+
import { redirect } from "next/navigation";
|
|
206
|
+
|
|
207
|
+
export async function subscribe(priceId: string) {
|
|
208
|
+
const session = await auth();
|
|
209
|
+
|
|
210
|
+
if (!session?.user?.id) {
|
|
211
|
+
redirect("/login");
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const user = await db.user.findUnique({
|
|
215
|
+
where: { id: session.user.id },
|
|
216
|
+
include: { subscription: true },
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
if (!user) {
|
|
220
|
+
throw new Error("Usuario no encontrado");
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Si ya tiene subscripcion activa, redirigir al portal
|
|
224
|
+
if (user.subscription?.status === "ACTIVE") {
|
|
225
|
+
const portalSession = await createBillingPortalSession(user.stripeCustomerId!);
|
|
226
|
+
redirect(portalSession.url);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// Crear checkout session
|
|
230
|
+
const checkoutSession = await createCheckoutSession(
|
|
231
|
+
user.id,
|
|
232
|
+
priceId,
|
|
233
|
+
user.email
|
|
234
|
+
);
|
|
235
|
+
|
|
236
|
+
redirect(checkoutSession.url!);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
export async function manageBilling() {
|
|
240
|
+
const session = await auth();
|
|
241
|
+
|
|
242
|
+
if (!session?.user?.id) {
|
|
243
|
+
redirect("/login");
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const user = await db.user.findUnique({
|
|
247
|
+
where: { id: session.user.id },
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
if (!user?.stripeCustomerId) {
|
|
251
|
+
redirect("/pricing");
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const portalSession = await createBillingPortalSession(user.stripeCustomerId);
|
|
255
|
+
redirect(portalSession.url);
|
|
256
|
+
}
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
### Paso 7: Crear webhook de Stripe
|
|
260
|
+
|
|
261
|
+
Crear `src/app/api/stripe/webhook/route.ts`:
|
|
262
|
+
|
|
263
|
+
```typescript
|
|
264
|
+
import { headers } from "next/headers";
|
|
265
|
+
import { NextResponse } from "next/server";
|
|
266
|
+
import { stripe } from "@/lib/stripe";
|
|
267
|
+
import { db } from "@/lib/db";
|
|
268
|
+
import Stripe from "stripe";
|
|
269
|
+
|
|
270
|
+
export async function POST(request: Request) {
|
|
271
|
+
const body = await request.text();
|
|
272
|
+
const signature = headers().get("stripe-signature")!;
|
|
273
|
+
|
|
274
|
+
let event: Stripe.Event;
|
|
275
|
+
|
|
276
|
+
try {
|
|
277
|
+
event = stripe.webhooks.constructEvent(
|
|
278
|
+
body,
|
|
279
|
+
signature,
|
|
280
|
+
process.env.STRIPE_WEBHOOK_SECRET!
|
|
281
|
+
);
|
|
282
|
+
} catch (error) {
|
|
283
|
+
console.error("Webhook signature verification failed");
|
|
284
|
+
return NextResponse.json({ error: "Invalid signature" }, { status: 400 });
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
switch (event.type) {
|
|
288
|
+
case "checkout.session.completed": {
|
|
289
|
+
const session = event.data.object as Stripe.Checkout.Session;
|
|
290
|
+
|
|
291
|
+
await db.user.update({
|
|
292
|
+
where: { id: session.metadata!.userId },
|
|
293
|
+
data: { stripeCustomerId: session.customer as string },
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
const subscription = await stripe.subscriptions.retrieve(
|
|
297
|
+
session.subscription as string
|
|
298
|
+
);
|
|
299
|
+
|
|
300
|
+
await db.subscription.create({
|
|
301
|
+
data: {
|
|
302
|
+
userId: session.metadata!.userId,
|
|
303
|
+
stripeSubscriptionId: subscription.id,
|
|
304
|
+
stripePriceId: subscription.items.data[0].price.id,
|
|
305
|
+
stripeCurrentPeriodEnd: new Date(subscription.current_period_end * 1000),
|
|
306
|
+
status: "ACTIVE",
|
|
307
|
+
},
|
|
308
|
+
});
|
|
309
|
+
break;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
case "customer.subscription.updated": {
|
|
313
|
+
const subscription = event.data.object as Stripe.Subscription;
|
|
314
|
+
|
|
315
|
+
await db.subscription.update({
|
|
316
|
+
where: { stripeSubscriptionId: subscription.id },
|
|
317
|
+
data: {
|
|
318
|
+
stripePriceId: subscription.items.data[0].price.id,
|
|
319
|
+
stripeCurrentPeriodEnd: new Date(subscription.current_period_end * 1000),
|
|
320
|
+
status: subscription.status.toUpperCase() as any,
|
|
321
|
+
},
|
|
322
|
+
});
|
|
323
|
+
break;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
case "customer.subscription.deleted": {
|
|
327
|
+
const subscription = event.data.object as Stripe.Subscription;
|
|
328
|
+
|
|
329
|
+
await db.subscription.update({
|
|
330
|
+
where: { stripeSubscriptionId: subscription.id },
|
|
331
|
+
data: { status: "CANCELED" },
|
|
332
|
+
});
|
|
333
|
+
break;
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
return NextResponse.json({ received: true });
|
|
338
|
+
}
|
|
339
|
+
```
|
|
340
|
+
|
|
341
|
+
### Paso 8: Crear pagina de precios
|
|
342
|
+
|
|
343
|
+
Crear `src/app/pricing/page.tsx`:
|
|
344
|
+
|
|
345
|
+
```typescript
|
|
346
|
+
import { auth } from "@/lib/auth";
|
|
347
|
+
import { PLANS } from "@/lib/stripe";
|
|
348
|
+
import { subscribe } from "@/actions/billing";
|
|
349
|
+
import { Button } from "@/components/ui/button";
|
|
350
|
+
|
|
351
|
+
export default async function PricingPage() {
|
|
352
|
+
const session = await auth();
|
|
353
|
+
|
|
354
|
+
return (
|
|
355
|
+
<div className="min-h-screen bg-gray-50 py-16">
|
|
356
|
+
<div className="max-w-5xl mx-auto px-4">
|
|
357
|
+
<div className="text-center mb-12">
|
|
358
|
+
<h1 className="text-4xl font-bold mb-4">Planes y Precios</h1>
|
|
359
|
+
<p className="text-gray-600 text-lg">
|
|
360
|
+
Elige el plan que mejor se adapte a tus necesidades
|
|
361
|
+
</p>
|
|
362
|
+
</div>
|
|
363
|
+
|
|
364
|
+
<div className="grid md:grid-cols-2 gap-8">
|
|
365
|
+
{Object.entries(PLANS).map(([key, plan]) => (
|
|
366
|
+
<div
|
|
367
|
+
key={key}
|
|
368
|
+
className="bg-white rounded-2xl shadow-lg p-8 border-2 border-transparent hover:border-blue-500 transition-colors"
|
|
369
|
+
>
|
|
370
|
+
<h2 className="text-2xl font-bold mb-2">{plan.name}</h2>
|
|
371
|
+
<div className="mb-6">
|
|
372
|
+
<span className="text-4xl font-bold">${plan.price}</span>
|
|
373
|
+
<span className="text-gray-600">/mes</span>
|
|
374
|
+
</div>
|
|
375
|
+
|
|
376
|
+
<ul className="space-y-3 mb-8">
|
|
377
|
+
{plan.features.map((feature) => (
|
|
378
|
+
<li key={feature} className="flex items-center gap-2">
|
|
379
|
+
<svg className="w-5 h-5 text-green-500" fill="currentColor" viewBox="0 0 20 20">
|
|
380
|
+
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd"/>
|
|
381
|
+
</svg>
|
|
382
|
+
{feature}
|
|
383
|
+
</li>
|
|
384
|
+
))}
|
|
385
|
+
</ul>
|
|
386
|
+
|
|
387
|
+
<form action={subscribe.bind(null, plan.priceId)}>
|
|
388
|
+
<Button className="w-full" type="submit">
|
|
389
|
+
{session ? "Subscribirse" : "Empezar"}
|
|
390
|
+
</Button>
|
|
391
|
+
</form>
|
|
392
|
+
</div>
|
|
393
|
+
))}
|
|
394
|
+
</div>
|
|
395
|
+
</div>
|
|
396
|
+
</div>
|
|
397
|
+
);
|
|
398
|
+
}
|
|
399
|
+
```
|
|
400
|
+
|
|
401
|
+
### Paso 9: Crear pagina de billing
|
|
402
|
+
|
|
403
|
+
Crear `src/app/(dashboard)/billing/page.tsx`:
|
|
404
|
+
|
|
405
|
+
```typescript
|
|
406
|
+
import { auth } from "@/lib/auth";
|
|
407
|
+
import { db } from "@/lib/db";
|
|
408
|
+
import { redirect } from "next/navigation";
|
|
409
|
+
import { manageBilling } from "@/actions/billing";
|
|
410
|
+
import { Button } from "@/components/ui/button";
|
|
411
|
+
import { PLANS } from "@/lib/stripe";
|
|
412
|
+
|
|
413
|
+
export default async function BillingPage() {
|
|
414
|
+
const session = await auth();
|
|
415
|
+
|
|
416
|
+
if (!session?.user?.id) {
|
|
417
|
+
redirect("/login");
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
const user = await db.user.findUnique({
|
|
421
|
+
where: { id: session.user.id },
|
|
422
|
+
include: { subscription: true },
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
const currentPlan = user?.subscription
|
|
426
|
+
? Object.values(PLANS).find(p => p.priceId === user.subscription?.stripePriceId)
|
|
427
|
+
: null;
|
|
428
|
+
|
|
429
|
+
return (
|
|
430
|
+
<div className="max-w-2xl mx-auto py-8 px-4">
|
|
431
|
+
<h1 className="text-2xl font-bold mb-8">Facturacion</h1>
|
|
432
|
+
|
|
433
|
+
<div className="bg-white rounded-xl shadow p-6">
|
|
434
|
+
{user?.subscription ? (
|
|
435
|
+
<>
|
|
436
|
+
<div className="mb-6">
|
|
437
|
+
<h2 className="text-lg font-semibold mb-2">Plan Actual</h2>
|
|
438
|
+
<p className="text-3xl font-bold text-blue-600">
|
|
439
|
+
{currentPlan?.name || "Plan"}
|
|
440
|
+
</p>
|
|
441
|
+
<p className="text-gray-600">
|
|
442
|
+
${currentPlan?.price}/mes
|
|
443
|
+
</p>
|
|
444
|
+
</div>
|
|
445
|
+
|
|
446
|
+
<div className="mb-6">
|
|
447
|
+
<h3 className="font-medium mb-2">Estado</h3>
|
|
448
|
+
<span className={`px-3 py-1 rounded-full text-sm ${
|
|
449
|
+
user.subscription.status === "ACTIVE"
|
|
450
|
+
? "bg-green-100 text-green-800"
|
|
451
|
+
: "bg-yellow-100 text-yellow-800"
|
|
452
|
+
}`}>
|
|
453
|
+
{user.subscription.status}
|
|
454
|
+
</span>
|
|
455
|
+
</div>
|
|
456
|
+
|
|
457
|
+
<div className="mb-6">
|
|
458
|
+
<h3 className="font-medium mb-2">Proximo cobro</h3>
|
|
459
|
+
<p className="text-gray-600">
|
|
460
|
+
{user.subscription.stripeCurrentPeriodEnd.toLocaleDateString()}
|
|
461
|
+
</p>
|
|
462
|
+
</div>
|
|
463
|
+
|
|
464
|
+
<form action={manageBilling}>
|
|
465
|
+
<Button type="submit" variant="outline">
|
|
466
|
+
Gestionar Subscripcion
|
|
467
|
+
</Button>
|
|
468
|
+
</form>
|
|
469
|
+
</>
|
|
470
|
+
) : (
|
|
471
|
+
<div className="text-center py-8">
|
|
472
|
+
<p className="text-gray-600 mb-4">
|
|
473
|
+
No tienes una subscripcion activa
|
|
474
|
+
</p>
|
|
475
|
+
<a href="/pricing">
|
|
476
|
+
<Button>Ver Planes</Button>
|
|
477
|
+
</a>
|
|
478
|
+
</div>
|
|
479
|
+
)}
|
|
480
|
+
</div>
|
|
481
|
+
</div>
|
|
482
|
+
);
|
|
483
|
+
}
|
|
484
|
+
```
|
|
485
|
+
|
|
486
|
+
### Paso 10: Crear panel de admin
|
|
487
|
+
|
|
488
|
+
Crear `src/app/(admin)/layout.tsx`:
|
|
489
|
+
|
|
490
|
+
```typescript
|
|
491
|
+
import { auth } from "@/lib/auth";
|
|
492
|
+
import { db } from "@/lib/db";
|
|
493
|
+
import { redirect } from "next/navigation";
|
|
494
|
+
|
|
495
|
+
export default async function AdminLayout({
|
|
496
|
+
children,
|
|
497
|
+
}: {
|
|
498
|
+
children: React.ReactNode;
|
|
499
|
+
}) {
|
|
500
|
+
const session = await auth();
|
|
501
|
+
|
|
502
|
+
if (!session?.user?.id) {
|
|
503
|
+
redirect("/login");
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
const user = await db.user.findUnique({
|
|
507
|
+
where: { id: session.user.id },
|
|
508
|
+
});
|
|
509
|
+
|
|
510
|
+
if (user?.role !== "ADMIN") {
|
|
511
|
+
redirect("/dashboard");
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
return (
|
|
515
|
+
<div className="min-h-screen bg-gray-100">
|
|
516
|
+
<nav className="bg-gray-900 text-white">
|
|
517
|
+
<div className="max-w-7xl mx-auto px-4 py-3 flex gap-6">
|
|
518
|
+
<a href="/admin" className="font-medium hover:text-gray-300">
|
|
519
|
+
Dashboard
|
|
520
|
+
</a>
|
|
521
|
+
<a href="/admin/users" className="font-medium hover:text-gray-300">
|
|
522
|
+
Usuarios
|
|
523
|
+
</a>
|
|
524
|
+
<a href="/dashboard" className="ml-auto text-gray-400 hover:text-white">
|
|
525
|
+
Volver a la app
|
|
526
|
+
</a>
|
|
527
|
+
</div>
|
|
528
|
+
</nav>
|
|
529
|
+
<main className="max-w-7xl mx-auto px-4 py-8">{children}</main>
|
|
530
|
+
</div>
|
|
531
|
+
);
|
|
532
|
+
}
|
|
533
|
+
```
|
|
534
|
+
|
|
535
|
+
Crear `src/app/(admin)/page.tsx`:
|
|
536
|
+
|
|
537
|
+
```typescript
|
|
538
|
+
import { db } from "@/lib/db";
|
|
539
|
+
|
|
540
|
+
export default async function AdminDashboard() {
|
|
541
|
+
const [totalUsers, activeSubscriptions, revenue] = await Promise.all([
|
|
542
|
+
db.user.count(),
|
|
543
|
+
db.subscription.count({ where: { status: "ACTIVE" } }),
|
|
544
|
+
db.subscription.findMany({
|
|
545
|
+
where: { status: "ACTIVE" },
|
|
546
|
+
select: { stripePriceId: true },
|
|
547
|
+
}),
|
|
548
|
+
]);
|
|
549
|
+
|
|
550
|
+
return (
|
|
551
|
+
<div>
|
|
552
|
+
<h1 className="text-2xl font-bold mb-8">Admin Dashboard</h1>
|
|
553
|
+
|
|
554
|
+
<div className="grid md:grid-cols-3 gap-6">
|
|
555
|
+
<div className="bg-white rounded-xl shadow p-6">
|
|
556
|
+
<h3 className="text-gray-600 text-sm">Total Usuarios</h3>
|
|
557
|
+
<p className="text-3xl font-bold">{totalUsers}</p>
|
|
558
|
+
</div>
|
|
559
|
+
|
|
560
|
+
<div className="bg-white rounded-xl shadow p-6">
|
|
561
|
+
<h3 className="text-gray-600 text-sm">Subscripciones Activas</h3>
|
|
562
|
+
<p className="text-3xl font-bold">{activeSubscriptions}</p>
|
|
563
|
+
</div>
|
|
564
|
+
|
|
565
|
+
<div className="bg-white rounded-xl shadow p-6">
|
|
566
|
+
<h3 className="text-gray-600 text-sm">MRR Estimado</h3>
|
|
567
|
+
<p className="text-3xl font-bold">
|
|
568
|
+
${activeSubscriptions * 19}
|
|
569
|
+
</p>
|
|
570
|
+
</div>
|
|
571
|
+
</div>
|
|
572
|
+
</div>
|
|
573
|
+
);
|
|
574
|
+
}
|
|
575
|
+
```
|
|
576
|
+
|
|
577
|
+
### Paso 11: Ejecutar migraciones
|
|
578
|
+
|
|
579
|
+
```bash
|
|
580
|
+
npx prisma migrate dev --name add-stripe-fields
|
|
581
|
+
npx prisma generate
|
|
582
|
+
```
|
|
583
|
+
|
|
584
|
+
### Paso 12: Configurar Stripe webhook local
|
|
585
|
+
|
|
586
|
+
```bash
|
|
587
|
+
stripe listen --forward-to localhost:3000/api/stripe/webhook
|
|
588
|
+
```
|
|
589
|
+
</setup_steps>
|
|
590
|
+
|
|
591
|
+
<verification>
|
|
592
|
+
## Verificacion
|
|
593
|
+
|
|
594
|
+
### 1. Probar checkout flow
|
|
595
|
+
1. Ir a /pricing
|
|
596
|
+
2. Click en "Subscribirse"
|
|
597
|
+
3. Usar tarjeta de test: 4242 4242 4242 4242
|
|
598
|
+
4. Verificar redireccion a dashboard
|
|
599
|
+
|
|
600
|
+
### 2. Verificar webhook
|
|
601
|
+
- Revisar logs de `stripe listen`
|
|
602
|
+
- Verificar que subscription se creo en DB
|
|
603
|
+
|
|
604
|
+
### 3. Probar billing portal
|
|
605
|
+
- Ir a /dashboard/billing
|
|
606
|
+
- Click "Gestionar Subscripcion"
|
|
607
|
+
- Verificar acceso al portal de Stripe
|
|
608
|
+
|
|
609
|
+
### 4. Probar admin
|
|
610
|
+
- Cambiar role a ADMIN en DB
|
|
611
|
+
- Ir a /admin
|
|
612
|
+
- Verificar metricas
|
|
613
|
+
</verification>
|
|
614
|
+
|
|
615
|
+
<stripe_test_cards>
|
|
616
|
+
## Tarjetas de Test
|
|
617
|
+
|
|
618
|
+
| Escenario | Numero |
|
|
619
|
+
|-----------|--------|
|
|
620
|
+
| Exito | 4242 4242 4242 4242 |
|
|
621
|
+
| Requiere auth | 4000 0025 0000 3155 |
|
|
622
|
+
| Declinada | 4000 0000 0000 0002 |
|
|
623
|
+
| Fondos insuficientes | 4000 0000 0000 9995 |
|
|
624
|
+
</stripe_test_cards>
|
|
625
|
+
|
|
626
|
+
<common_issues>
|
|
627
|
+
## Problemas Comunes
|
|
628
|
+
|
|
629
|
+
### "Webhook signature verification failed"
|
|
630
|
+
- Verificar STRIPE_WEBHOOK_SECRET
|
|
631
|
+
- Usar el secret de `stripe listen`
|
|
632
|
+
|
|
633
|
+
### "Customer not found"
|
|
634
|
+
- El checkout debe completarse primero
|
|
635
|
+
- Verificar que el webhook creo el customer
|
|
636
|
+
|
|
637
|
+
### "Cannot access admin"
|
|
638
|
+
- Verificar que el user tiene role=ADMIN en DB
|
|
639
|
+
</common_issues>
|