import { writeFileSync, mkdirSync } from "fs"; mkdirSync("src/stripe", { recursive: true }); // ─── stripe.service.ts ─────────────────────────────────────────────────────── writeFileSync("src/stripe/stripe.service.ts", `import { BadRequestException, Injectable, Logger } from "@nestjs/common"; import Stripe from "stripe"; import { PrismaService } from "../prisma/prisma.service"; export const PLAN_LIMITS: Record = { free: { accounts: 2, exports: 5 }, pro: { accounts: 10, exports: 100 }, elite: { accounts: -1, exports: -1 }, // -1 = unlimited }; @Injectable() export class StripeService { private readonly stripe: Stripe; private readonly logger = new Logger(StripeService.name); constructor(private readonly prisma: PrismaService) { const key = process.env.STRIPE_SECRET_KEY; if (!key) throw new Error("STRIPE_SECRET_KEY is required."); this.stripe = new Stripe(key, { apiVersion: "2025-01-27.acacia" }); } async getOrCreateCustomer(userId: string, email: string): Promise { const sub = await this.prisma.subscription.findUnique({ where: { userId } }); if (sub?.stripeCustomerId) return sub.stripeCustomerId; const customer = await this.stripe.customers.create({ email, metadata: { userId } }); await this.prisma.subscription.upsert({ where: { userId }, update: { stripeCustomerId: customer.id }, create: { userId, plan: "free", stripeCustomerId: customer.id }, }); return customer.id; } async createCheckoutSession(userId: string, email: string, priceId: string) { const customerId = await this.getOrCreateCustomer(userId, email); const session = await this.stripe.checkout.sessions.create({ customer: customerId, payment_method_types: ["card"], mode: "subscription", line_items: [{ price: priceId, quantity: 1 }], success_url: \`\${process.env.APP_URL}/settings/billing?success=true\`, cancel_url: \`\${process.env.APP_URL}/settings/billing?cancelled=true\`, metadata: { userId }, }); return { url: session.url }; } async createPortalSession(userId: string) { const sub = await this.prisma.subscription.findUnique({ where: { userId } }); if (!sub?.stripeCustomerId) { throw new BadRequestException("No Stripe customer found. Please upgrade first."); } const session = await this.stripe.billingPortal.sessions.create({ customer: sub.stripeCustomerId, return_url: \`\${process.env.APP_URL}/settings/billing\`, }); return { url: session.url }; } async getSubscription(userId: string) { const sub = await this.prisma.subscription.findUnique({ where: { userId } }); return sub ?? { userId, plan: "free" }; } async handleWebhook(rawBody: Buffer, signature: string) { const secret = process.env.STRIPE_WEBHOOK_SECRET; if (!secret) throw new Error("STRIPE_WEBHOOK_SECRET is required."); let event: Stripe.Event; try { event = this.stripe.webhooks.constructEvent(rawBody, signature, secret); } catch (err) { this.logger.warn(\`Webhook signature verification failed: \${err}\`); throw new BadRequestException("Invalid webhook signature."); } switch (event.type) { case "customer.subscription.created": case "customer.subscription.updated": { const subscription = event.data.object as Stripe.Subscription; await this.syncSubscription(subscription); break; } case "customer.subscription.deleted": { const subscription = event.data.object as Stripe.Subscription; const customerId = subscription.customer as string; const sub = await this.prisma.subscription.findFirst({ where: { stripeCustomerId: customerId }, }); if (sub) { await this.prisma.subscription.update({ where: { userId: sub.userId }, data: { plan: "free", stripeSubId: null, currentPeriodEnd: null, cancelAtPeriodEnd: false }, }); } break; } default: this.logger.debug(\`Unhandled Stripe event: \${event.type}\`); } return { received: true }; } private async syncSubscription(subscription: Stripe.Subscription) { const customerId = subscription.customer as string; const sub = await this.prisma.subscription.findFirst({ where: { stripeCustomerId: customerId }, }); if (!sub) return; const priceId = subscription.items.data[0]?.price.id; let plan = "free"; if (priceId === process.env.STRIPE_PRICE_PRO) plan = "pro"; else if (priceId === process.env.STRIPE_PRICE_ELITE) plan = "elite"; await this.prisma.subscription.update({ where: { userId: sub.userId }, data: { plan, stripeSubId: subscription.id, currentPeriodEnd: new Date(subscription.current_period_end * 1000), cancelAtPeriodEnd: subscription.cancel_at_period_end, }, }); } } `); // ─── stripe.controller.ts ──────────────────────────────────────────────────── writeFileSync("src/stripe/stripe.controller.ts", `import { Body, Controller, Get, Headers, Post, RawBodyRequest, Req, } from "@nestjs/common"; import { Request } from "express"; import { ok } from "../common/response"; import { StripeService } from "./stripe.service"; import { CurrentUser } from "../common/decorators/current-user.decorator"; import { Public } from "../common/decorators/public.decorator"; import { PrismaService } from "../prisma/prisma.service"; @Controller("billing") export class StripeController { constructor( private readonly stripeService: StripeService, private readonly prisma: PrismaService, ) {} @Get("subscription") async getSubscription(@CurrentUser() userId: string) { const data = await this.stripeService.getSubscription(userId); return ok(data); } @Post("checkout") async checkout(@CurrentUser() userId: string, @Body("priceId") priceId: string) { const user = await this.prisma.user.findUnique({ where: { id: userId }, select: { email: true }, }); if (!user) return ok({ error: "User not found" }); const data = await this.stripeService.createCheckoutSession(userId, user.email, priceId); return ok(data); } @Post("portal") async portal(@CurrentUser() userId: string) { const data = await this.stripeService.createPortalSession(userId); return ok(data); } @Public() @Post("webhook") async webhook( @Req() req: RawBodyRequest, @Headers("stripe-signature") signature: string, ) { const data = await this.stripeService.handleWebhook(req.rawBody!, signature); return data; } } `); // ─── subscription.guard.ts ─────────────────────────────────────────────────── writeFileSync("src/stripe/subscription.guard.ts", `import { CanActivate, ExecutionContext, ForbiddenException, Injectable, SetMetadata, } from "@nestjs/common"; import { Reflector } from "@nestjs/core"; import { Request } from "express"; import { PrismaService } from "../prisma/prisma.service"; export const REQUIRED_PLAN_KEY = "requiredPlan"; export const RequiredPlan = (plan: "pro" | "elite") => SetMetadata(REQUIRED_PLAN_KEY, plan); const PLAN_RANK: Record = { free: 0, pro: 1, elite: 2 }; @Injectable() export class SubscriptionGuard implements CanActivate { constructor( private readonly reflector: Reflector, private readonly prisma: PrismaService, ) {} async canActivate(context: ExecutionContext): Promise { const required = this.reflector.getAllAndOverride(REQUIRED_PLAN_KEY, [ context.getHandler(), context.getClass(), ]); if (!required) return true; const request = context.switchToHttp().getRequest(); const userId = request.user?.sub; if (!userId) throw new ForbiddenException("Authentication required."); const sub = await this.prisma.subscription.findUnique({ where: { userId } }); const currentPlan = sub?.plan ?? "free"; const currentRank = PLAN_RANK[currentPlan] ?? 0; const requiredRank = PLAN_RANK[required] ?? 0; if (currentRank < requiredRank) { throw new ForbiddenException(\`This feature requires a \${required} plan.\`); } return true; } } `); // ─── stripe.module.ts ──────────────────────────────────────────────────────── writeFileSync("src/stripe/stripe.module.ts", `import { Module } from "@nestjs/common"; import { StripeController } from "./stripe.controller"; import { StripeService } from "./stripe.service"; import { SubscriptionGuard } from "./subscription.guard"; @Module({ controllers: [StripeController], providers: [StripeService, SubscriptionGuard], exports: [StripeService, SubscriptionGuard], }) export class StripeModule {} `); // ─── exports.service.ts (with real Google Sheets) ──────────────────────────── writeFileSync("src/exports/exports.service.ts", `import { BadRequestException, Injectable, Logger } from "@nestjs/common"; import { google } from "googleapis"; import { PrismaService } from "../prisma/prisma.service"; @Injectable() export class ExportsService { private readonly logger = new Logger(ExportsService.name); constructor(private readonly prisma: PrismaService) {} private toCsv(rows: Array>) { if (!rows.length) return ""; const headers = Object.keys(rows[0]); const escape = (value: string) => \`"\${value.replace(/"/g, '""')}"\`; const lines = [headers.join(",")]; for (const row of rows) { lines.push(headers.map((key) => escape(row[key] ?? "")).join(",")); } return lines.join("\\n"); } private async getTransactions( userId: string, filters: Record, limit = 1000, ) { const where: Record = { account: { userId } }; if (filters.start_date || filters.end_date) { where.date = { gte: filters.start_date ? new Date(filters.start_date) : undefined, lte: filters.end_date ? new Date(filters.end_date) : undefined, }; } if (filters.min_amount || filters.max_amount) { where.amount = { gte: filters.min_amount ? Number(filters.min_amount) : undefined, lte: filters.max_amount ? Number(filters.max_amount) : undefined, }; } if (filters.category) { where.derived = { is: { userCategory: { contains: filters.category, mode: "insensitive" } }, }; } if (filters.source) { where.source = { contains: filters.source, mode: "insensitive" }; } if (filters.include_hidden !== "true") { where.OR = [{ derived: null }, { derived: { isHidden: false } }]; } return this.prisma.transactionRaw.findMany({ where, include: { derived: true }, orderBy: { date: "desc" }, take: limit, }); } private toRows(transactions: Awaited>) { return transactions.map((tx) => ({ id: tx.id, date: tx.date.toISOString().slice(0, 10), description: tx.description, amount: Number(tx.amount).toFixed(2), category: tx.derived?.userCategory ?? "", notes: tx.derived?.userNotes ?? "", hidden: tx.derived?.isHidden ? "true" : "false", source: tx.source, })); } async exportCsv(userId: string, filters: Record = {}) { const transactions = await this.getTransactions(userId, filters); const rows = this.toRows(transactions); const csv = this.toCsv(rows); await this.prisma.exportLog.create({ data: { userId, filters, rowCount: rows.length }, }); return { status: "ready", csv, rowCount: rows.length }; } async exportSheets(userId: string, filters: Record = {}) { // Get the user's Google connection const gc = await this.prisma.googleConnection.findUnique({ where: { userId } }); if (!gc || !gc.isConnected) { throw new BadRequestException( "Google account not connected. Please connect via /api/google/connect.", ); } const oauth2Client = new google.auth.OAuth2( process.env.GOOGLE_CLIENT_ID, process.env.GOOGLE_CLIENT_SECRET, ); oauth2Client.setCredentials({ access_token: gc.accessToken, refresh_token: gc.refreshToken, }); // Refresh the access token if needed const { credentials } = await oauth2Client.refreshAccessToken(); await this.prisma.googleConnection.update({ where: { userId }, data: { accessToken: credentials.access_token ?? gc.accessToken, lastSyncedAt: new Date(), }, }); oauth2Client.setCredentials(credentials); const sheets = google.sheets({ version: "v4", auth: oauth2Client }); const transactions = await this.getTransactions(userId, filters); const rows = this.toRows(transactions); const sheetTitle = \`LedgerOne Export \${new Date().toISOString().slice(0, 10)}\`; let spreadsheetId = gc.spreadsheetId; if (!spreadsheetId) { // Create a new spreadsheet const spreadsheet = await sheets.spreadsheets.create({ requestBody: { properties: { title: "LedgerOne" } }, }); spreadsheetId = spreadsheet.data.spreadsheetId!; await this.prisma.googleConnection.update({ where: { userId }, data: { spreadsheetId }, }); } // Add a new sheet tab await sheets.spreadsheets.batchUpdate({ spreadsheetId, requestBody: { requests: [{ addSheet: { properties: { title: sheetTitle } } }], }, }); // Build values: header + data rows const headers = rows.length ? Object.keys(rows[0]) : ["id", "date", "description", "amount", "category", "notes", "hidden", "source"]; const values = [ headers, ...rows.map((row) => headers.map((h) => row[h as keyof typeof row] ?? "")), ]; await sheets.spreadsheets.values.update({ spreadsheetId, range: \`'\${sheetTitle}'!A1\`, valueInputOption: "RAW", requestBody: { values }, }); await this.prisma.exportLog.create({ data: { userId, filters: { ...filters, destination: "google_sheets" }, rowCount: rows.length }, }); this.logger.log(\`Exported \${rows.length} rows to Google Sheets for user \${userId}\`); return { status: "exported", rowCount: rows.length, spreadsheetId, sheetTitle, url: \`https://docs.google.com/spreadsheets/d/\${spreadsheetId}\`, }; } } `); console.log("stripe + sheets files written");