430 lines
15 KiB
JavaScript
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");
|