import { BadRequestException, GoneException, Injectable, Logger, NotFoundException } from "@nestjs/common"; import * as crypto from "crypto"; import { google } from "googleapis"; import { Prisma } from "@prisma/client"; import * as XLSX from "xlsx"; import { PrismaService } from "../prisma/prisma.service"; import { AbuseService } from "../abuse/abuse.service"; import { RequestContext } from "../abuse/abuse.types"; import { ExportObjectStorageService } from "./export-object-storage.service"; type ExportWatermark = { label: string; userId: string; generatedAt: string; traceId: string; text: string; }; @Injectable() export class ExportsService { private readonly logger = new Logger(ExportsService.name); private readonly syncSheetTitle = "LedgerOne Sync"; private readonly downloadTokenTtlMs = 2 * 60 * 1000; constructor( private readonly prisma: PrismaService, private readonly abuseService: AbuseService, private readonly exportObjectStorage: ExportObjectStorageService, ) {} 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 createWatermark(userId: string): ExportWatermark { const generatedAt = new Date().toISOString(); const traceId = crypto.randomBytes(12).toString("hex"); const label = "LedgerOne Export Watermark"; return { label, userId, generatedAt, traceId, text: `${label} | user=${userId} | generated=${generatedAt} | trace=${traceId}`, }; } private applyWatermarkToRows(rows: ReturnType, watermark: ExportWatermark) { const watermarkedRows = rows.map((row) => ({ ...row, watermark: watermark.text })); if (watermarkedRows.length) return watermarkedRows; return [{ id: "", date: "", description: "", amount: "", category: "", notes: "", attribution: "", splitMode: "", splitMinePercent: "", splitYoursPercent: "", splitMineAmount: "", splitYoursAmount: "", hidden: "", source: "", watermark: watermark.text, }]; } private escapePdfText(value: string) { return value.replace(/\\/g, "\\\\").replace(/\(/g, "\\(").replace(/\)/g, "\\)"); } private buildPdf(rows: Array>, watermark: ExportWatermark) { const headers = rows.length ? Object.keys(rows[0]) : ["id", "date", "description", "amount", "category", "notes", "attribution", "splitMode", "splitMinePercent", "splitYoursPercent", "splitMineAmount", "splitYoursAmount", "hidden", "source"]; const lines = [ "LedgerOne Export", `Generated ${new Date().toISOString()}`, `Rows ${rows.length}`, watermark.text, "", headers.join(" | "), ...rows.map((row) => headers.map((header) => row[header] ?? "").join(" | ")), ].map((line) => line.length > 118 ? `${line.slice(0, 115)}...` : line); const pageHeight = 792; const margin = 36; const lineHeight = 11; const fontSize = 8; const linesPerPage = Math.floor((pageHeight - margin * 2) / lineHeight); const pages: string[] = []; for (let index = 0; index < lines.length; index += linesPerPage) { const pageLines = lines.slice(index, index + linesPerPage); const commands = ["BT", `/F1 ${fontSize} Tf`]; pageLines.forEach((line, lineIndex) => { const y = pageHeight - margin - lineIndex * lineHeight; commands.push(`1 0 0 1 ${margin} ${y} Tm (${this.escapePdfText(line)}) Tj`); }); commands.push(`1 0 0 1 ${margin} ${margin - 14} Tm (${this.escapePdfText(watermark.text)}) Tj`); commands.push("ET"); pages.push(commands.join("\n")); } const objects: string[] = []; const pageObjectIds = pages.map((_, index) => 4 + index * 2); objects[0] = "<< /Type /Catalog /Pages 2 0 R >>"; objects[1] = `<< /Type /Pages /Kids [${pageObjectIds.map((id) => `${id} 0 R`).join(" ")}] /Count ${pages.length} >>`; objects[2] = "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>"; pages.forEach((content, index) => { const pageObjectId = 4 + index * 2; const contentObjectId = pageObjectId + 1; objects[pageObjectId - 1] = `<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources << /Font << /F1 3 0 R >> >> /Contents ${contentObjectId} 0 R >>`; objects[contentObjectId - 1] = `<< /Length ${Buffer.byteLength(content, "binary")} >>\nstream\n${content}\nendstream`; }); let pdf = "%PDF-1.4\n"; const offsets = [0]; objects.forEach((object, index) => { offsets.push(Buffer.byteLength(pdf, "binary")); pdf += `${index + 1} 0 obj\n${object}\nendobj\n`; }); const xrefOffset = Buffer.byteLength(pdf, "binary"); pdf += `xref\n0 ${objects.length + 1}\n0000000000 65535 f \n`; for (let index = 1; index < offsets.length; index += 1) { pdf += `${String(offsets[index]).padStart(10, "0")} 00000 n \n`; } pdf += `trailer\n<< /Size ${objects.length + 1} /Root 1 0 R >>\nstartxref\n${xrefOffset}\n%%EOF`; return Buffer.from(pdf, "binary"); } 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, account: { select: { ownershipType: 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 ?? "", attribution: tx.derived?.attribution ?? this.defaultAttributionForAccount(tx.account?.ownershipType), splitMode: tx.derived?.splitMode ?? "none", splitMinePercent: this.splitPercent(tx.derived?.splitMode, tx.derived?.splitMinePercent, "mine"), splitYoursPercent: this.splitPercent(tx.derived?.splitMode, tx.derived?.splitYoursPercent, "yours"), splitMineAmount: this.splitAmount(Number(tx.amount), tx.derived?.splitMode, tx.derived?.splitMinePercent, "mine"), splitYoursAmount: this.splitAmount(Number(tx.amount), tx.derived?.splitMode, tx.derived?.splitYoursPercent, "yours"), hidden: tx.derived?.isHidden ? "true" : "false", source: tx.source, })); } private defaultAttributionForAccount(ownershipType?: string | null) { if (ownershipType === "joint") return "ours"; if (ownershipType === "theirs") return "yours"; return "mine"; } private splitPercent(mode: string | null | undefined, value: unknown, side: "mine" | "yours") { if (!mode || mode === "none") return side === "mine" ? "100.00" : "0.00"; if (mode === "equal") return "50.00"; return Number(value ?? 0).toFixed(2); } private splitAmount(amount: number, mode: string | null | undefined, value: unknown, side: "mine" | "yours") { const percent = Number(this.splitPercent(mode, value, side)); return (amount * (percent / 100)).toFixed(2); } private hashContent(content: string | Buffer) { return crypto.createHash("sha256").update(content).digest("hex"); } private hashToken(token: string) { return crypto.createHash("sha256").update(token).digest("hex"); } private async createExportLog( userId: string, filters: Record, rowCount: number, audit: { format: string; destination?: string; fileName?: string; mimeType?: string; fileContent?: string | Buffer; metadata?: Record; }, context?: RequestContext, ) { await this.prisma.exportLog.create({ data: { userId, format: audit.format, destination: audit.destination ?? "download", filters, rowCount, fileName: audit.fileName, mimeType: audit.mimeType, fileHash: audit.fileContent ? this.hashContent(audit.fileContent) : undefined, ipAddress: context?.ipAddress, userAgent: context?.userAgent, metadata: (audit.metadata ?? {}) as Prisma.InputJsonValue, }, }); } private async buildCsvFile(userId: string, filters: Record = {}) { const transactions = await this.getTransactions(userId, filters); const rows = this.toRows(transactions); const watermark = this.createWatermark(userId); const csv = this.toCsv(this.applyWatermarkToRows(rows, watermark)); const fileName = `ledgerone-export-${new Date().toISOString().slice(0, 10)}.csv`; return { content: csv, fileName, mimeType: "text/csv", rowCount: rows.length, watermark, }; } async exportCsv(userId: string, filters: Record = {}, context?: RequestContext) { const file = await this.buildCsvFile(userId, filters); await this.createExportLog(userId, filters, file.rowCount, { format: "csv", fileName: file.fileName, mimeType: file.mimeType, fileContent: file.content, metadata: { watermark: file.watermark }, }, context); await this.abuseService.recordExportActivity(userId, file.rowCount, filters, context); return { status: "ready", csv: file.content, fileName: file.fileName, rowCount: file.rowCount }; } private async buildJsonFile(userId: string, filters: Record = {}) { const transactions = await this.getTransactions(userId, filters); const rows = this.toRows(transactions); const watermark = this.createWatermark(userId); const payload = { exportedAt: new Date().toISOString(), rowCount: rows.length, filters, watermark, transactions: rows, }; const buffer = Buffer.from(JSON.stringify(payload, null, 2), "utf8"); return { content: buffer, fileName: `ledgerone-export-${new Date().toISOString().slice(0, 10)}.json`, mimeType: "application/json", rowCount: rows.length, watermark, }; } async exportJson(userId: string, filters: Record = {}, context?: RequestContext) { const file = await this.buildJsonFile(userId, filters); await this.createExportLog(userId, filters, file.rowCount, { format: "json", fileName: file.fileName, mimeType: file.mimeType, fileContent: file.content, metadata: { watermark: file.watermark }, }, context); await this.abuseService.recordExportActivity(userId, file.rowCount, { ...filters, format: "json" }, context); return { status: "ready", fileName: file.fileName, mimeType: file.mimeType, base64: file.content.toString("base64"), rowCount: file.rowCount, }; } private async buildXlsxFile(userId: string, filters: Record = {}) { const transactions = await this.getTransactions(userId, filters); const rows = this.toRows(transactions); const watermark = this.createWatermark(userId); const watermarkedRows = this.applyWatermarkToRows(rows, watermark); const headers = this.getHeaders(watermarkedRows); const worksheet = XLSX.utils.json_to_sheet(watermarkedRows, { header: headers }); const watermarkWorksheet = XLSX.utils.json_to_sheet([{ label: watermark.label, userId: watermark.userId, generatedAt: watermark.generatedAt, traceId: watermark.traceId, watermark: watermark.text, }]); const workbook = XLSX.utils.book_new(); XLSX.utils.book_append_sheet(workbook, worksheet, "LedgerOne Export"); XLSX.utils.book_append_sheet(workbook, watermarkWorksheet, "Watermark"); const buffer = XLSX.write(workbook, { type: "buffer", bookType: "xlsx" }) as Buffer; return { content: buffer, fileName: `ledgerone-export-${new Date().toISOString().slice(0, 10)}.xlsx`, mimeType: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", rowCount: rows.length, watermark, }; } async exportXlsx(userId: string, filters: Record = {}, context?: RequestContext) { const file = await this.buildXlsxFile(userId, filters); await this.createExportLog(userId, filters, file.rowCount, { format: "xlsx", fileName: file.fileName, mimeType: file.mimeType, fileContent: file.content, metadata: { watermark: file.watermark }, }, context); await this.abuseService.recordExportActivity(userId, file.rowCount, { ...filters, format: "xlsx" }, context); return { status: "ready", fileName: file.fileName, mimeType: file.mimeType, base64: file.content.toString("base64"), rowCount: file.rowCount, }; } private async buildPdfFile(userId: string, filters: Record = {}) { const transactions = await this.getTransactions(userId, filters); const rows = this.toRows(transactions); const watermark = this.createWatermark(userId); const buffer = this.buildPdf(rows, watermark); return { content: buffer, fileName: `ledgerone-export-${new Date().toISOString().slice(0, 10)}.pdf`, mimeType: "application/pdf", rowCount: rows.length, watermark, }; } async exportPdf(userId: string, filters: Record = {}, context?: RequestContext) { const file = await this.buildPdfFile(userId, filters); await this.createExportLog(userId, filters, file.rowCount, { format: "pdf", fileName: file.fileName, mimeType: file.mimeType, fileContent: file.content, metadata: { watermark: file.watermark }, }, context); await this.abuseService.recordExportActivity(userId, file.rowCount, { ...filters, format: "pdf" }, context); return { status: "ready", fileName: file.fileName, mimeType: file.mimeType, base64: file.content.toString("base64"), rowCount: file.rowCount, }; } async createSignedDownloadUrl( userId: string, format: "csv" | "json" | "xlsx" | "pdf", filters: Record = {}, context?: RequestContext, ) { const token = crypto.randomBytes(32).toString("base64url"); const expiresAt = new Date(Date.now() + this.downloadTokenTtlMs); const file = await this.buildDownloadFile(userId, format, filters); const stored = await this.exportObjectStorage.store({ userId, format, fileName: file.fileName, mimeType: file.mimeType, content: file.content, }); const fileHash = this.hashContent(file.content); await this.prisma.exportDownloadToken.create({ data: { userId, tokenHash: this.hashToken(token), format, filters, storageProvider: stored.provider, storageKey: stored.key, fileName: file.fileName, mimeType: file.mimeType, rowCount: file.rowCount, fileHash, expiresAt, }, }); return { status: "signed", downloadUrl: `/api/exports/download/${token}`, expiresAt: expiresAt.toISOString(), singleUse: true, expiresInSeconds: Math.floor(this.downloadTokenTtlMs / 1000), storageProvider: stored.provider, }; } async consumeSignedDownloadUrl(token: string, context?: RequestContext) { const tokenHash = this.hashToken(token); const record = await this.prisma.exportDownloadToken.findUnique({ where: { tokenHash } }); if (!record) throw new NotFoundException("Download link not found."); if (record.usedAt) throw new GoneException("Download link has already been used."); if (record.expiresAt < new Date()) throw new GoneException("Download link has expired."); const consumed = await this.prisma.exportDownloadToken.updateMany({ where: { id: record.id, usedAt: null, expiresAt: { gt: new Date() } }, data: { usedAt: new Date() }, }); if (consumed.count !== 1) { throw new GoneException("Download link is no longer valid."); } const filters = record.filters as unknown as Record; const format = record.format as "csv" | "json" | "xlsx" | "pdf"; const storedFile = await this.exportObjectStorage.load(record.storageProvider, record.storageKey); const file = { content: storedFile.content, fileName: record.fileName ?? `ledgerone-export-${new Date().toISOString().slice(0, 10)}.${format}`, mimeType: record.mimeType ?? this.mimeTypeForFormat(format), rowCount: record.rowCount ?? 0, }; await this.createExportLog(record.userId, filters, file.rowCount, { format, fileName: file.fileName, mimeType: file.mimeType, fileContent: file.content, metadata: { signedUrl: true, tokenId: record.id, expiresAt: record.expiresAt.toISOString(), storageProvider: record.storageProvider, storageKey: record.storageKey, precomputedFileHash: record.fileHash, }, }, context); await this.abuseService.recordExportActivity(record.userId, file.rowCount, { ...filters, format, signedUrl: "true" }, context); return file; } private async buildDownloadFile(userId: string, format: "csv" | "json" | "xlsx" | "pdf", filters: Record) { if (format === "csv") return this.buildCsvFile(userId, filters); if (format === "json") return this.buildJsonFile(userId, filters); if (format === "xlsx") return this.buildXlsxFile(userId, filters); if (format === "pdf") return this.buildPdfFile(userId, filters); throw new BadRequestException("Unsupported export format."); } private mimeTypeForFormat(format: "csv" | "json" | "xlsx" | "pdf") { if (format === "csv") return "text/csv"; if (format === "json") return "application/json"; if (format === "xlsx") return "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"; if (format === "pdf") return "application/pdf"; return "application/octet-stream"; } private getHeaders(rows: ReturnType) { return rows.length ? Object.keys(rows[0]) : ["id", "date", "description", "amount", "category", "notes", "attribution", "splitMode", "splitMinePercent", "splitYoursPercent", "splitMineAmount", "splitYoursAmount", "hidden", "source"]; } private toSheetValues(rows: ReturnType) { const headers = this.getHeaders(rows); return [ headers, ...rows.map((row) => headers.map((header) => row[header as keyof typeof row] ?? "")), ]; } private async createGoogleSheetsClient(userId: string, failWhenDisconnected: boolean) { const gc = await this.prisma.googleConnection.findUnique({ where: { userId } }); if (!gc || !gc.isConnected) { if (failWhenDisconnected) { throw new BadRequestException( "Google account not connected. Please connect via /api/google/connect.", ); } return null; } 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, }); 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); return { gc, sheets: google.sheets({ version: "v4", auth: oauth2Client }), }; } private async ensureSpreadsheet( userId: string, sheets: ReturnType, spreadsheetId?: string | null, ) { if (spreadsheetId) { await this.markDriveMirror(userId, spreadsheetId, "ready"); return spreadsheetId; } const spreadsheet = await sheets.spreadsheets.create({ requestBody: { properties: { title: "LedgerOne User-Owned Ledger" } }, }); const createdSpreadsheetId = spreadsheet.data.spreadsheetId!; await this.markDriveMirror(userId, createdSpreadsheetId, "ready"); return createdSpreadsheetId; } private async markDriveMirror(userId: string, spreadsheetId: string, status: string, syncedAt?: Date) { await this.prisma.googleConnection.update({ where: { userId }, data: { spreadsheetId, driveMirrorEnabled: true, driveMirrorStatus: status, driveMirrorSpreadsheetUrl: `https://docs.google.com/spreadsheets/d/${spreadsheetId}`, ...(syncedAt ? { driveMirrorLastSyncedAt: syncedAt, lastSyncedAt: syncedAt } : {}), }, }); } private async addSheetIfMissing( sheets: ReturnType, spreadsheetId: string, sheetTitle: string, ) { try { await sheets.spreadsheets.batchUpdate({ spreadsheetId, requestBody: { requests: [{ addSheet: { properties: { title: sheetTitle } } }], }, }); } catch (error: unknown) { const message = error instanceof Error ? error.message : ""; if (!/already exists|duplicate/i.test(message)) { throw error; } } } private async writeSheetValues( sheets: ReturnType, spreadsheetId: string, sheetTitle: string, values: string[][], clearFirst = false, ) { if (clearFirst) { await sheets.spreadsheets.values.clear({ spreadsheetId, range: `'${sheetTitle}'!A:Z`, }); } await sheets.spreadsheets.values.update({ spreadsheetId, range: `'${sheetTitle}'!A1`, valueInputOption: "RAW", requestBody: { values }, }); } async exportSheets(userId: string, filters: Record = {}, context?: RequestContext) { const client = await this.createGoogleSheetsClient(userId, true); const { gc, sheets } = client!; const transactions = await this.getTransactions(userId, filters); const rows = this.toRows(transactions); const sheetTitle = `LedgerOne Export ${new Date().toISOString().slice(0, 10)}`; const spreadsheetId = await this.ensureSpreadsheet(userId, sheets, gc.spreadsheetId); await sheets.spreadsheets.batchUpdate({ spreadsheetId, requestBody: { requests: [{ addSheet: { properties: { title: sheetTitle } } }], }, }); await this.writeSheetValues(sheets, spreadsheetId, sheetTitle, this.toSheetValues(rows)); await this.markDriveMirror(userId, spreadsheetId, "synced", new Date()); await this.createExportLog(userId, filters, rows.length, { format: "google_sheets", destination: "google_sheets", metadata: { spreadsheetId, sheetTitle, url: `https://docs.google.com/spreadsheets/d/${spreadsheetId}` }, }, context); await this.abuseService.recordExportActivity(userId, rows.length, { ...filters, destination: "google_sheets" }, context); 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}`, }; } async syncGoogleSheets(userId: string, reason = "transaction_change") { const client = await this.createGoogleSheetsClient(userId, false); if (!client) { return { status: "skipped", reason: "not_connected" }; } const { gc, sheets } = client; const spreadsheetId = await this.ensureSpreadsheet(userId, sheets, gc.spreadsheetId); const transactions = await this.getTransactions(userId, {}, 5000); const rows = this.toRows(transactions); await this.addSheetIfMissing(sheets, spreadsheetId, this.syncSheetTitle); await this.writeSheetValues( sheets, spreadsheetId, this.syncSheetTitle, this.toSheetValues(rows), true, ); await this.markDriveMirror(userId, spreadsheetId, "synced", new Date()); this.logger.log(`Synced ${rows.length} rows to Google Sheets for user ${userId}`); return { status: "synced", reason, rowCount: rows.length, spreadsheetId, sheetTitle: this.syncSheetTitle, url: `https://docs.google.com/spreadsheets/d/${spreadsheetId}`, }; } }