ledgerone_backend/src/exports/exports.service.ts

711 lines
25 KiB
TypeScript

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<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 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<typeof this.toRows>, 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<Record<string, string>>, 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<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,
account: {
select: {
ownershipType: 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 ?? "",
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<string, string>,
rowCount: number,
audit: {
format: string;
destination?: string;
fileName?: string;
mimeType?: string;
fileContent?: string | Buffer;
metadata?: Record<string, unknown>;
},
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<string, string> = {}) {
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<string, string> = {}, 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<string, string> = {}) {
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<string, string> = {}, 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<string, string> = {}) {
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<string, string> = {}, 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<string, string> = {}) {
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<string, string> = {}, 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<string, string> = {},
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<string, string>;
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<string, string>) {
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<typeof this.toRows>) {
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<typeof this.toRows>) {
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<typeof google.sheets>,
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<typeof google.sheets>,
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<typeof google.sheets>,
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<string, string> = {}, 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}`,
};
}
}