ledgerone_backend/write-stripe-sheets.mjs
2026-03-14 08:51:16 -04:00

430 lines
15 KiB
JavaScript

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<string, { accounts: number; exports: number }> = {
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<string> {
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<Request>,
@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<string, number> = { 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<boolean> {
const required = this.reflector.getAllAndOverride<string>(REQUIRED_PLAN_KEY, [
context.getHandler(),
context.getClass(),
]);
if (!required) return true;
const request = context.switchToHttp().getRequest<Request & { user?: { sub: string } }>();
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<Record<string, string>>) {
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<string, string>,
limit = 1000,
) {
const where: Record<string, unknown> = { 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<ReturnType<typeof this.getTransactions>>) {
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<string, string> = {}) {
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<string, string> = {}) {
// 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");