create-solostack 1.0.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.
@@ -0,0 +1,541 @@
1
+ import path from 'path';
2
+ import { writeFile, ensureDir } from '../utils/files.js';
3
+
4
+ /**
5
+ * Generates Stripe payment integration
6
+ * @param {string} projectPath - Path where the project is located
7
+ * @param {string} paymentProvider - Payment provider type
8
+ */
9
+ export async function generatePayments(projectPath, paymentProvider) {
10
+ // Create Stripe lib file
11
+ const stripeLib = `import Stripe from 'stripe';
12
+
13
+ if (!process.env.STRIPE_SECRET_KEY) {
14
+ throw new Error('STRIPE_SECRET_KEY is not set');
15
+ }
16
+
17
+ export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY, {
18
+ apiVersion: '2024-12-18.acacia',
19
+ typescript: true,
20
+ });
21
+
22
+ export const PLANS = {
23
+ FREE: {
24
+ name: 'Free',
25
+ price: 0,
26
+ priceId: null,
27
+ features: [
28
+ 'Basic features',
29
+ 'Community support',
30
+ 'Limited usage',
31
+ ],
32
+ },
33
+ PRO: {
34
+ name: 'Pro',
35
+ price: 9,
36
+ priceId: process.env.STRIPE_PRO_PRICE_ID,
37
+ features: [
38
+ 'All free features',
39
+ 'Priority support',
40
+ 'Unlimited usage',
41
+ 'Advanced analytics',
42
+ ],
43
+ },
44
+ ENTERPRISE: {
45
+ name: 'Enterprise',
46
+ price: 29,
47
+ priceId: process.env.STRIPE_ENTERPRISE_PRICE_ID,
48
+ features: [
49
+ 'All pro features',
50
+ 'Dedicated support',
51
+ 'Custom integrations',
52
+ 'SLA guarantee',
53
+ ],
54
+ },
55
+ };
56
+ `;
57
+
58
+ await writeFile(path.join(projectPath, 'src/lib/stripe.ts'), stripeLib);
59
+
60
+ // Create webhook handler
61
+ await ensureDir(path.join(projectPath, 'src/app/api/webhooks/stripe'));
62
+
63
+ const webhookHandler = `import { headers } from 'next/headers';
64
+ import { NextRequest, NextResponse } from 'next/server';
65
+ import Stripe from 'stripe';
66
+ import { stripe } from '@/lib/stripe';
67
+ import { db } from '@/lib/db';
68
+
69
+ export async function POST(req: NextRequest) {
70
+ const body = await req.text();
71
+ const signature = (await headers()).get('stripe-signature');
72
+
73
+ if (!signature) {
74
+ return NextResponse.json(
75
+ { error: 'No signature' },
76
+ { status: 400 }
77
+ );
78
+ }
79
+
80
+ let event: Stripe.Event;
81
+
82
+ try {
83
+ event = stripe.webhooks.constructEvent(
84
+ body,
85
+ signature,
86
+ process.env.STRIPE_WEBHOOK_SECRET!
87
+ );
88
+ } catch (error) {
89
+ console.error('Webhook signature verification failed:', error);
90
+ return NextResponse.json(
91
+ { error: 'Invalid signature' },
92
+ { status: 400 }
93
+ );
94
+ }
95
+
96
+ try {
97
+ // Check if we've already processed this event (idempotency)
98
+ const existingEvent = await db.stripeEvent.findUnique({
99
+ where: { eventId: event.id },
100
+ });
101
+
102
+ if (existingEvent?.processed) {
103
+ console.log(\`Event \${event.id} already processed, skipping\`);
104
+ return NextResponse.json({ received: true, skipped: true });
105
+ }
106
+
107
+ // Create or update event record
108
+ await db.stripeEvent.upsert({
109
+ where: { eventId: event.id },
110
+ create: {
111
+ eventId: event.id,
112
+ type: event.type,
113
+ processed: false,
114
+ },
115
+ update: {
116
+ type: event.type,
117
+ },
118
+ });
119
+
120
+ try {
121
+ switch (event.type) {
122
+ case 'checkout.session.completed': {
123
+ const session = event.data.object as Stripe.Checkout.Session;
124
+
125
+ if (session.mode === 'subscription') {
126
+ const subscriptionId = session.subscription as string;
127
+ const userId = session.metadata?.userId;
128
+
129
+ if (!userId) {
130
+ throw new Error('No userId in metadata');
131
+ }
132
+
133
+ const subscription = await stripe.subscriptions.retrieve(subscriptionId);
134
+
135
+ await db.subscription.create({
136
+ data: {
137
+ userId,
138
+ stripeSubscriptionId: subscription.id,
139
+ stripePriceId: subscription.items.data[0].price.id,
140
+ status: subscription.status.toUpperCase() as any,
141
+ currentPeriodStart: new Date(subscription.current_period_start * 1000),
142
+ currentPeriodEnd: new Date(subscription.current_period_end * 1000),
143
+ },
144
+ });
145
+
146
+ await db.user.update({
147
+ where: { id: userId },
148
+ data: { stripeCustomerId: subscription.customer as string },
149
+ });
150
+ }
151
+ break;
152
+ }
153
+
154
+ case 'customer.subscription.updated': {
155
+ const subscription = event.data.object as Stripe.Subscription;
156
+
157
+ await db.subscription.update({
158
+ where: { stripeSubscriptionId: subscription.id },
159
+ data: {
160
+ status: subscription.status.toUpperCase() as any,
161
+ currentPeriodStart: new Date(subscription.current_period_start * 1000),
162
+ currentPeriodEnd: new Date(subscription.current_period_end * 1000),
163
+ cancelAtPeriodEnd: subscription.cancel_at_period_end,
164
+ },
165
+ });
166
+ break;
167
+ }
168
+
169
+ case 'customer.subscription.deleted': {
170
+ const subscription = event.data.object as Stripe.Subscription;
171
+
172
+ await db.subscription.update({
173
+ where: { stripeSubscriptionId: subscription.id },
174
+ data: {
175
+ status: 'CANCELED',
176
+ },
177
+ });
178
+ break;
179
+ }
180
+
181
+ case 'payment_intent.succeeded': {
182
+ const paymentIntent = event.data.object as Stripe.PaymentIntent;
183
+ const userId = paymentIntent.metadata?.userId;
184
+
185
+ if (userId) {
186
+ await db.payment.create({
187
+ data: {
188
+ userId,
189
+ stripePaymentId: paymentIntent.id,
190
+ amount: paymentIntent.amount,
191
+ currency: paymentIntent.currency,
192
+ status: paymentIntent.status,
193
+ },
194
+ });
195
+ }
196
+ break;
197
+ }
198
+
199
+ case 'payment_intent.payment_failed': {
200
+ const paymentIntent = event.data.object as Stripe.PaymentIntent;
201
+ console.error('Payment failed:', paymentIntent.id);
202
+ // TODO: Send email notification
203
+ break;
204
+ }
205
+ }
206
+
207
+ // Mark event as processed
208
+ await db.stripeEvent.update({
209
+ where: { eventId: event.id },
210
+ data: { processed: true },
211
+ });
212
+
213
+ return NextResponse.json({ received: true, processed: true });
214
+ } catch (error) {
215
+ console.error('Webhook handler error:', error);
216
+
217
+ // Don't mark as processed on error, allow retry
218
+ try {
219
+ await db.stripeEvent.update({
220
+ where: { eventId: event.id },
221
+ data: { processed: false },
222
+ });
223
+ } catch (dbError) {
224
+ console.error('Failed to update event status:', dbError);
225
+ }
226
+
227
+ return NextResponse.json(
228
+ { error: 'Webhook handler failed', message: error instanceof Error ? error.message : 'Unknown error' },
229
+ { status: 500 }
230
+ );
231
+ }
232
+ }
233
+ `;
234
+
235
+ await writeFile(
236
+ path.join(projectPath, 'src/app/api/webhooks/stripe/route.ts'),
237
+ webhookHandler
238
+ );
239
+
240
+ // Create checkout API route
241
+ await ensureDir(path.join(projectPath, 'src/app/api/stripe/checkout'));
242
+
243
+ const checkoutApi = `import { NextRequest, NextResponse } from 'next/server';
244
+ import { auth } from '@/lib/auth';
245
+ import { stripe } from '@/lib/stripe';
246
+
247
+ export async function POST(req: NextRequest) {
248
+ try {
249
+ const session = await auth();
250
+
251
+ if (!session?.user) {
252
+ return NextResponse.json(
253
+ { error: 'Unauthorized' },
254
+ { status: 401 }
255
+ );
256
+ }
257
+
258
+ const { priceId } = await req.json();
259
+
260
+ if (!priceId) {
261
+ return NextResponse.json(
262
+ { error: 'Price ID is required' },
263
+ { status: 400 }
264
+ );
265
+ }
266
+
267
+ const checkoutSession = await stripe.checkout.sessions.create({
268
+ mode: 'subscription',
269
+ payment_method_types: ['card'],
270
+ line_items: [
271
+ {
272
+ price: priceId,
273
+ quantity: 1,
274
+ },
275
+ ],
276
+ success_url: \`\${process.env.NEXTAUTH_URL}/dashboard/billing?success=true\`,
277
+ cancel_url: \`\${process.env.NEXTAUTH_URL}/dashboard/billing?canceled=true\`,
278
+ metadata: {
279
+ userId: session.user.id,
280
+ },
281
+ });
282
+
283
+ return NextResponse.json({ url: checkoutSession.url });
284
+ } catch (error) {
285
+ console.error('Checkout error:', error);
286
+ return NextResponse.json(
287
+ { error: 'Failed to create checkout session' },
288
+ { status: 500 }
289
+ );
290
+ }
291
+ }
292
+ `;
293
+
294
+ await writeFile(
295
+ path.join(projectPath, 'src/app/api/stripe/checkout/route.ts'),
296
+ checkoutApi
297
+ );
298
+
299
+ // Create customer portal API route
300
+ await ensureDir(path.join(projectPath, 'src/app/api/stripe/portal'));
301
+
302
+ const portalApi = `import { NextRequest, NextResponse } from 'next/server';
303
+ import { auth } from '@/lib/auth';
304
+ import { stripe } from '@/lib/stripe';
305
+ import { db } from '@/lib/db';
306
+
307
+ export async function POST(req: NextRequest) {
308
+ try {
309
+ const session = await auth();
310
+
311
+ if (!session?.user) {
312
+ return NextResponse.json(
313
+ { error: 'Unauthorized' },
314
+ { status: 401 }
315
+ );
316
+ }
317
+
318
+ const user = await db.user.findUnique({
319
+ where: { id: session.user.id },
320
+ });
321
+
322
+ if (!user?.stripeCustomerId) {
323
+ return NextResponse.json(
324
+ { error: 'No subscription found' },
325
+ { status: 400 }
326
+ );
327
+ }
328
+
329
+ const portalSession = await stripe.billingPortal.sessions.create({
330
+ customer: user.stripeCustomerId,
331
+ return_url: \`\${process.env.NEXTAUTH_URL}/dashboard/billing\`,
332
+ });
333
+
334
+ return NextResponse.json({ url: portalSession.url });
335
+ } catch (error) {
336
+ console.error('Portal error:', error);
337
+ return NextResponse.json(
338
+ { error: 'Failed to create portal session' },
339
+ { status: 500 }
340
+ );
341
+ }
342
+ }
343
+ `;
344
+
345
+ await writeFile(
346
+ path.join(projectPath, 'src/app/api/stripe/portal/route.ts'),
347
+ portalApi
348
+ );
349
+
350
+ // Create billing page
351
+ await ensureDir(path.join(projectPath, 'src/app/dashboard/billing'));
352
+
353
+ const billingPage = `import { auth } from '@/lib/auth';
354
+ import { db } from '@/lib/db';
355
+ import { redirect } from 'next/navigation';
356
+ import { PLANS } from '@/lib/stripe';
357
+ import { SubscriptionCard } from '@/components/subscription-card';
358
+
359
+ export default async function BillingPage() {
360
+ const session = await auth();
361
+
362
+ if (!session?.user) {
363
+ redirect('/login');
364
+ }
365
+
366
+ const user = await db.user.findUnique({
367
+ where: { id: session.user.id },
368
+ include: { subscription: true },
369
+ });
370
+
371
+ return (
372
+ <div className="container mx-auto px-4 py-8">
373
+ <h1 className="text-3xl font-bold mb-8">Billing & Subscription</h1>
374
+
375
+ {user?.subscription ? (
376
+ <div className="mb-8 rounded-lg border p-6">
377
+ <h2 className="text-xl font-semibold mb-4">Current Subscription</h2>
378
+ <div className="grid gap-4">
379
+ <div>
380
+ <p className="text-sm text-muted-foreground">Status</p>
381
+ <p className="font-medium capitalize">{user.subscription.status.toLowerCase()}</p>
382
+ </div>
383
+ <div>
384
+ <p className="text-sm text-muted-foreground">Current Period</p>
385
+ <p className="font-medium">
386
+ {new Date(user.subscription.currentPeriodStart).toLocaleDateString()} -{' '}
387
+ {new Date(user.subscription.currentPeriodEnd).toLocaleDateString()}
388
+ </p>
389
+ </div>
390
+ {user.subscription.cancelAtPeriodEnd && (
391
+ <div className="rounded-md bg-yellow-50 p-4">
392
+ <p className="text-sm text-yellow-800">
393
+ Your subscription will be canceled at the end of the current billing period.
394
+ </p>
395
+ </div>
396
+ )}
397
+ </div>
398
+ <form action="/api/stripe/portal" method="POST" className="mt-4">
399
+ <button
400
+ type="submit"
401
+ className="rounded-md bg-indigo-600 px-4 py-2 text-sm font-semibold text-white hover:bg-indigo-500"
402
+ >
403
+ Manage Subscription
404
+ </button>
405
+ </form>
406
+ </div>
407
+ ) : (
408
+ <div className="mb-8">
409
+ <p className="text-muted-foreground mb-4">
410
+ You don't have an active subscription. Choose a plan below to get started.
411
+ </p>
412
+ </div>
413
+ )}
414
+
415
+ <div className="grid gap-6 md:grid-cols-3">
416
+ {Object.entries(PLANS).map(([key, plan]) => (
417
+ <SubscriptionCard
418
+ key={key}
419
+ name={plan.name}
420
+ price={plan.price}
421
+ priceId={plan.priceId}
422
+ features={plan.features}
423
+ currentPlan={user?.subscription?.stripePriceId === plan.priceId}
424
+ />
425
+ ))}
426
+ </div>
427
+ </div>
428
+ );
429
+ }
430
+ `;
431
+
432
+ await writeFile(
433
+ path.join(projectPath, 'src/app/dashboard/billing/page.tsx'),
434
+ billingPage
435
+ );
436
+
437
+ // Create SubscriptionCard component
438
+ const subscriptionCard = `'use client';
439
+
440
+ import { useRouter } from 'next/navigation';
441
+ import { useState } from 'react';
442
+
443
+ interface SubscriptionCardProps {
444
+ name: string;
445
+ price: number;
446
+ priceId: string | null;
447
+ features: string[];
448
+ currentPlan: boolean;
449
+ }
450
+
451
+ export function SubscriptionCard({
452
+ name,
453
+ price,
454
+ priceId,
455
+ features,
456
+ currentPlan,
457
+ }: SubscriptionCardProps) {
458
+ const router = useRouter();
459
+ const [loading, setLoading] = useState(false);
460
+
461
+ const handleSubscribe = async () => {
462
+ if (!priceId) return;
463
+
464
+ setLoading(true);
465
+ try {
466
+ const res = await fetch('/api/stripe/checkout', {
467
+ method: 'POST',
468
+ headers: { 'Content-Type': 'application/json' },
469
+ body: JSON.stringify({ priceId }),
470
+ });
471
+
472
+ const { url } = await res.json();
473
+ if (url) {
474
+ window.location.href = url;
475
+ }
476
+ } catch (error) {
477
+ console.error('Checkout error:', error);
478
+ setLoading(false);
479
+ }
480
+ };
481
+
482
+ return (
483
+ <div className="rounded-lg border p-6 flex flex-col">
484
+ <h3 className="text-2xl font-bold mb-2">{name}</h3>
485
+ <div className="mb-4">
486
+ <span className="text-4xl font-bold">\${price}</span>
487
+ <span className="text-muted-foreground">/month</span>
488
+ </div>
489
+ <ul className="space-y-2 mb-6 flex-grow">
490
+ {features.map((feature, i) => (
491
+ <li key={i} className="flex items-start">
492
+ <svg
493
+ className="h-5 w-5 text-green-500 mr-2 flex-shrink-0"
494
+ fill="none"
495
+ stroke="currentColor"
496
+ viewBox="0 0 24 24"
497
+ >
498
+ <path
499
+ strokeLinecap="round"
500
+ strokeLinejoin="round"
501
+ strokeWidth={2}
502
+ d="M5 13l4 4L19 7"
503
+ />
504
+ </svg>
505
+ <span className="text-sm">{feature}</span>
506
+ </li>
507
+ ))}
508
+ </ul>
509
+ {currentPlan ? (
510
+ <button
511
+ disabled
512
+ className="w-full rounded-md bg-gray-300 px-4 py-2 text-sm font-semibold text-gray-500 cursor-not-allowed"
513
+ >
514
+ Current Plan
515
+ </button>
516
+ ) : priceId ? (
517
+ <button
518
+ onClick={handleSubscribe}
519
+ disabled={loading}
520
+ className="w-full rounded-md bg-indigo-600 px-4 py-2 text-sm font-semibold text-white hover:bg-indigo-500 disabled:opacity-50"
521
+ >
522
+ {loading ? 'Loading...' : 'Subscribe'}
523
+ </button>
524
+ ) : (
525
+ <button
526
+ disabled
527
+ className="w-full rounded-md bg-gray-200 px-4 py-2 text-sm font-semibold text-gray-700"
528
+ >
529
+ Free Plan
530
+ </button>
531
+ )}
532
+ </div>
533
+ );
534
+ }
535
+ `;
536
+
537
+ await writeFile(
538
+ path.join(projectPath, 'src/components/subscription-card.tsx'),
539
+ subscriptionCard
540
+ );
541
+ }