Files
pn-new-crm/src/lib/services/expense-export.ts
Matt 4c20bcffcd
Some checks failed
Build & Push Docker Images / lint (push) Failing after 1m10s
Build & Push Docker Images / build-and-push (push) Has been skipped
Build & Push Docker Images / deploy (push) Has been skipped
Fix all ESLint errors: remove unused imports, replace any types
- Remove ~60 unused imports and variables across 88 files
- Replace ~80 `any` type annotations with proper types (unknown,
  Record<string, unknown>, or specific types)
- Prefix unused callback args with underscore
- Fix unescaped JSX entities
- Lint now passes cleanly (0 errors, 2 intentional img warnings)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 12:06:18 +01:00

212 lines
5.8 KiB
TypeScript

import { eq, and, gte, lte, isNull, or, ilike } from 'drizzle-orm';
import { db } from '@/lib/db';
import { expenses } from '@/lib/db/schema/financial';
import { generatePdf } from '@/lib/pdf/generate';
import { getRate } from '@/lib/services/currency';
import { logger } from '@/lib/logger';
import type { ListExpensesInput } from '@/lib/validators/expenses';
async function fetchAllExpenses(portId: string, query: ListExpensesInput) {
const conditions: ReturnType<typeof eq>[] = [eq(expenses.portId, portId) as ReturnType<typeof eq>];
if (!query.includeArchived) {
conditions.push(isNull(expenses.archivedAt) as unknown as ReturnType<typeof eq>);
}
if (query.category) {
conditions.push(eq(expenses.category, query.category) as ReturnType<typeof eq>);
}
if (query.paymentStatus) {
conditions.push(eq(expenses.paymentStatus, query.paymentStatus) as ReturnType<typeof eq>);
}
if (query.currency) {
conditions.push(eq(expenses.currency, query.currency) as ReturnType<typeof eq>);
}
if (query.payer) {
conditions.push(eq(expenses.payer, query.payer) as ReturnType<typeof eq>);
}
if (query.dateFrom) {
conditions.push(gte(expenses.expenseDate, new Date(query.dateFrom)) as unknown as ReturnType<typeof eq>);
}
if (query.dateTo) {
conditions.push(lte(expenses.expenseDate, new Date(query.dateTo)) as unknown as ReturnType<typeof eq>);
}
if (query.search) {
conditions.push(
or(
ilike(expenses.establishmentName, `%${query.search}%`),
ilike(expenses.description, `%${query.search}%`),
) as unknown as ReturnType<typeof eq>,
);
}
return db
.select()
.from(expenses)
.where(and(...conditions));
}
export async function exportCsv(portId: string, query: ListExpensesInput): Promise<string> {
const rows = await fetchAllExpenses(portId, query);
const headers = [
'Date',
'Establishment',
'Category',
'Amount',
'Currency',
'Amount USD',
'Payment Status',
'Payment Method',
'Description',
];
const csvRows = rows.map((r) => {
const date = r.expenseDate ? new Date(r.expenseDate).toISOString().split('T')[0] : '';
return [
date,
r.establishmentName ?? '',
r.category ?? '',
r.amount,
r.currency,
r.amountUsd ?? 'N/A',
r.paymentStatus ?? '',
r.paymentMethod ?? '',
(r.description ?? '').replace(/"/g, '""'),
]
.map((v) => `"${v}"`)
.join(',');
});
return [headers.join(','), ...csvRows].join('\n');
}
export async function exportPdf(portId: string, query: ListExpensesInput): Promise<Uint8Array> {
const rows = await fetchAllExpenses(portId, query);
const template = {
basePdf: { width: 210, height: 297, padding: [10, 10, 10, 10] },
schemas: [
[
{
name: 'title',
type: 'text',
position: { x: 10, y: 10 },
width: 190,
height: 10,
fontSize: 14,
fontColor: '#000000',
},
{
name: 'content',
type: 'text',
position: { x: 10, y: 25 },
width: 190,
height: 260,
fontSize: 8,
fontColor: '#000000',
},
],
],
};
const lines = rows.map((r) => {
const date = r.expenseDate ? new Date(r.expenseDate).toISOString().split('T')[0] : '';
return `${date} | ${r.establishmentName ?? '-'} | ${r.category ?? '-'} | ${r.amount} ${r.currency} | ${r.paymentStatus ?? '-'}`;
});
const inputs = [
{
title: 'Expense Report',
content: lines.join('\n'),
},
];
return generatePdf(template as unknown as Parameters<typeof generatePdf>[0], inputs);
}
export async function exportParentCompany(
portId: string,
query: ListExpensesInput,
): Promise<Uint8Array> {
// BR-043: Convert all amounts to EUR, add 5% management fee
const rows = await fetchAllExpenses(portId, query);
const eurRate = await getRate('USD', 'EUR');
if (!eurRate) {
logger.warn('EUR rate unavailable for parent company export, using 1:1 fallback');
}
const rate = eurRate ?? 1;
const convertedRows = rows.map((r) => {
const amountUsd = r.amountUsd ? Number(r.amountUsd) : Number(r.amount);
const amountEur = Number((amountUsd * rate).toFixed(2));
return {
date: r.expenseDate ? new Date(r.expenseDate).toISOString().split('T')[0] : '',
establishment: r.establishmentName ?? '-',
category: r.category ?? '-',
amountEur,
};
});
const subtotal = convertedRows.reduce((sum, r) => sum + r.amountEur, 0);
const fee = Number((subtotal * 0.05).toFixed(2));
const total = Number((subtotal + fee).toFixed(2));
const template = {
basePdf: { width: 210, height: 297, padding: [10, 10, 10, 10] },
schemas: [
[
{
name: 'title',
type: 'text',
position: { x: 10, y: 10 },
width: 190,
height: 10,
fontSize: 14,
fontColor: '#000000',
},
{
name: 'content',
type: 'text',
position: { x: 10, y: 25 },
width: 190,
height: 230,
fontSize: 8,
fontColor: '#000000',
},
{
name: 'summary',
type: 'text',
position: { x: 10, y: 260 },
width: 190,
height: 30,
fontSize: 10,
fontColor: '#000000',
},
],
],
};
const lines = convertedRows.map(
(r) => `${r.date} | ${r.establishment} | ${r.category} | EUR ${r.amountEur.toFixed(2)}`,
);
const summary = [
`Subtotal: EUR ${subtotal.toFixed(2)}`,
`Management Fee (5%): EUR ${fee.toFixed(2)}`,
`Total: EUR ${total.toFixed(2)}`,
].join('\n');
const inputs = [
{
title: 'Parent Company Expense Report (EUR)',
content: lines.join('\n'),
summary,
},
];
return generatePdf(template as unknown as Parameters<typeof generatePdf>[0], inputs);
}