Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM, PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source files covering clients, berths, interests/pipeline, documents/EOI, expenses/invoices, email, notifications, dashboard, admin, and client portal. CI/CD via Gitea Actions with Docker builds. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
211
src/lib/services/expense-export.ts
Normal file
211
src/lib/services/expense-export.ts
Normal file
@@ -0,0 +1,211 @@
|
||||
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 any];
|
||||
|
||||
if (!query.includeArchived) {
|
||||
conditions.push(isNull(expenses.archivedAt) as any);
|
||||
}
|
||||
if (query.category) {
|
||||
conditions.push(eq(expenses.category, query.category) as any);
|
||||
}
|
||||
if (query.paymentStatus) {
|
||||
conditions.push(eq(expenses.paymentStatus, query.paymentStatus) as any);
|
||||
}
|
||||
if (query.currency) {
|
||||
conditions.push(eq(expenses.currency, query.currency) as any);
|
||||
}
|
||||
if (query.payer) {
|
||||
conditions.push(eq(expenses.payer, query.payer) as any);
|
||||
}
|
||||
if (query.dateFrom) {
|
||||
conditions.push(gte(expenses.expenseDate, new Date(query.dateFrom)) as any);
|
||||
}
|
||||
if (query.dateTo) {
|
||||
conditions.push(lte(expenses.expenseDate, new Date(query.dateTo)) as any);
|
||||
}
|
||||
if (query.search) {
|
||||
conditions.push(
|
||||
or(
|
||||
ilike(expenses.establishmentName, `%${query.search}%`),
|
||||
ilike(expenses.description, `%${query.search}%`),
|
||||
) as any,
|
||||
);
|
||||
}
|
||||
|
||||
return db
|
||||
.select()
|
||||
.from(expenses)
|
||||
.where(and(...(conditions as any[])));
|
||||
}
|
||||
|
||||
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 any, 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 any, inputs);
|
||||
}
|
||||
Reference in New Issue
Block a user