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>
2026-03-26 11:52:51 +01:00
|
|
|
import { eq, and, desc, like, lt, sql, gte, lte, inArray, ne } from 'drizzle-orm';
|
2026-03-26 12:29:55 +01:00
|
|
|
import type { PgColumn } from 'drizzle-orm/pg-core';
|
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>
2026-03-26 11:52:51 +01:00
|
|
|
|
|
|
|
|
import { db } from '@/lib/db';
|
|
|
|
|
import {
|
|
|
|
|
invoices,
|
|
|
|
|
invoiceLineItems,
|
|
|
|
|
invoiceExpenses,
|
|
|
|
|
expenses,
|
|
|
|
|
} from '@/lib/db/schema/financial';
|
|
|
|
|
import { files } from '@/lib/db/schema/documents';
|
|
|
|
|
import { ports } from '@/lib/db/schema/ports';
|
|
|
|
|
import { systemSettings } from '@/lib/db/schema/system';
|
|
|
|
|
import { buildListQuery } from '@/lib/db/query-builder';
|
|
|
|
|
import { createAuditLog } from '@/lib/audit';
|
|
|
|
|
import { diffEntity } from '@/lib/entity-diff';
|
|
|
|
|
import { withTransaction } from '@/lib/db/utils';
|
|
|
|
|
import { NotFoundError, ConflictError } from '@/lib/errors';
|
|
|
|
|
import { emitToRoom } from '@/lib/socket/server';
|
|
|
|
|
import { logger } from '@/lib/logger';
|
|
|
|
|
import { generatePdf } from '@/lib/pdf/generate';
|
|
|
|
|
import { invoiceTemplate, buildInvoiceInputs } from '@/lib/pdf/templates/invoice-template';
|
|
|
|
|
import { minioClient, buildStoragePath } from '@/lib/minio';
|
|
|
|
|
import { getQueue } from '@/lib/queue';
|
|
|
|
|
import { env } from '@/lib/env';
|
|
|
|
|
import type {
|
|
|
|
|
CreateInvoiceInput,
|
|
|
|
|
UpdateInvoiceInput,
|
|
|
|
|
RecordPaymentInput,
|
|
|
|
|
ListInvoicesInput,
|
|
|
|
|
} from '@/lib/validators/invoices';
|
|
|
|
|
|
|
|
|
|
// AuditMeta type expected by service functions
|
|
|
|
|
export interface ServiceAuditMeta {
|
|
|
|
|
userId: string;
|
|
|
|
|
portId: string;
|
|
|
|
|
ipAddress: string;
|
|
|
|
|
userAgent: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ─── Auto-numbering (BR-041) ───────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
async function generateInvoiceNumber(portId: string, tx: typeof db): Promise<string> {
|
|
|
|
|
const lockKey = `invoice_${portId}`;
|
|
|
|
|
await tx.execute(sql`SELECT pg_advisory_xact_lock(hashtext(${lockKey}))`);
|
|
|
|
|
|
|
|
|
|
const now = new Date();
|
|
|
|
|
const prefix = `INV-${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}`;
|
|
|
|
|
|
|
|
|
|
const [existing] = await tx
|
|
|
|
|
.select({ invoiceNumber: invoices.invoiceNumber })
|
|
|
|
|
.from(invoices)
|
|
|
|
|
.where(
|
|
|
|
|
and(eq(invoices.portId, portId), like(invoices.invoiceNumber, `${prefix}-%`)),
|
|
|
|
|
)
|
|
|
|
|
.orderBy(desc(invoices.invoiceNumber))
|
|
|
|
|
.limit(1);
|
|
|
|
|
|
|
|
|
|
let seq = 1;
|
|
|
|
|
if (existing) {
|
|
|
|
|
const parts = existing.invoiceNumber.split('-');
|
|
|
|
|
seq = parseInt(parts[parts.length - 1] ?? '0', 10) + 1;
|
|
|
|
|
}
|
|
|
|
|
return `${prefix}-${String(seq).padStart(3, '0')}`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ─── List ─────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
export async function listInvoices(portId: string, query: ListInvoicesInput) {
|
|
|
|
|
const filters = [];
|
|
|
|
|
|
|
|
|
|
if (query.status) {
|
|
|
|
|
filters.push(eq(invoices.status, query.status));
|
|
|
|
|
}
|
|
|
|
|
if (query.clientName) {
|
|
|
|
|
filters.push(like(invoices.clientName, `%${query.clientName}%`));
|
|
|
|
|
}
|
|
|
|
|
if (query.dateFrom) {
|
|
|
|
|
filters.push(gte(invoices.dueDate, query.dateFrom));
|
|
|
|
|
}
|
|
|
|
|
if (query.dateTo) {
|
|
|
|
|
filters.push(lte(invoices.dueDate, query.dateTo));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return buildListQuery({
|
|
|
|
|
table: invoices,
|
|
|
|
|
portIdColumn: invoices.portId,
|
|
|
|
|
portId,
|
|
|
|
|
idColumn: invoices.id,
|
|
|
|
|
updatedAtColumn: invoices.updatedAt,
|
|
|
|
|
filters,
|
|
|
|
|
page: query.page,
|
|
|
|
|
pageSize: query.limit,
|
|
|
|
|
searchColumns: [invoices.clientName, invoices.invoiceNumber],
|
|
|
|
|
searchTerm: query.search,
|
|
|
|
|
includeArchived: query.includeArchived,
|
|
|
|
|
archivedAtColumn: invoices.archivedAt,
|
|
|
|
|
sort: query.sort
|
|
|
|
|
? {
|
2026-03-26 12:29:55 +01:00
|
|
|
column: invoices[query.sort as keyof typeof invoices] as unknown as PgColumn,
|
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>
2026-03-26 11:52:51 +01:00
|
|
|
direction: query.order,
|
|
|
|
|
}
|
|
|
|
|
: undefined,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ─── Get by ID ────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
export async function getInvoiceById(id: string, portId: string) {
|
|
|
|
|
const invoice = await db.query.invoices.findFirst({
|
|
|
|
|
where: and(eq(invoices.id, id), eq(invoices.portId, portId)),
|
|
|
|
|
});
|
|
|
|
|
if (!invoice) throw new NotFoundError('Invoice');
|
|
|
|
|
|
|
|
|
|
const lineItems = await db
|
|
|
|
|
.select()
|
|
|
|
|
.from(invoiceLineItems)
|
|
|
|
|
.where(eq(invoiceLineItems.invoiceId, id))
|
|
|
|
|
.orderBy(invoiceLineItems.sortOrder);
|
|
|
|
|
|
|
|
|
|
const linkedExpenses = await db
|
|
|
|
|
.select({ expense: expenses })
|
|
|
|
|
.from(invoiceExpenses)
|
|
|
|
|
.innerJoin(expenses, eq(expenses.id, invoiceExpenses.expenseId))
|
|
|
|
|
.where(eq(invoiceExpenses.invoiceId, id));
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
...invoice,
|
|
|
|
|
lineItems,
|
|
|
|
|
linkedExpenses: linkedExpenses.map((r) => r.expense),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ─── Create (BR-041, BR-042, BR-045) ─────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
export async function createInvoice(
|
|
|
|
|
portId: string,
|
|
|
|
|
data: CreateInvoiceInput,
|
|
|
|
|
meta: ServiceAuditMeta,
|
|
|
|
|
) {
|
|
|
|
|
const invoice = await withTransaction(async (tx) => {
|
|
|
|
|
const invoiceNumber = await generateInvoiceNumber(portId, tx);
|
|
|
|
|
|
|
|
|
|
// Calculate subtotal from line items
|
|
|
|
|
const lineItemsData = data.lineItems ?? [];
|
|
|
|
|
const subtotal = lineItemsData.reduce(
|
|
|
|
|
(sum, li) => sum + li.quantity * li.unitPrice,
|
|
|
|
|
0,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// BR-042: net10 discount — read from systemSettings
|
|
|
|
|
let discountPct = 0;
|
|
|
|
|
if (data.paymentTerms === 'net10') {
|
|
|
|
|
const [setting] = await tx
|
|
|
|
|
.select({ value: systemSettings.value })
|
|
|
|
|
.from(systemSettings)
|
|
|
|
|
.where(
|
|
|
|
|
and(
|
|
|
|
|
eq(systemSettings.key, 'invoice_net10_discount'),
|
|
|
|
|
eq(systemSettings.portId, portId),
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
.limit(1);
|
|
|
|
|
|
|
|
|
|
if (setting) {
|
|
|
|
|
discountPct = Number(setting.value) || 2;
|
|
|
|
|
} else {
|
|
|
|
|
discountPct = 2;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const discountAmount = (subtotal * discountPct) / 100;
|
|
|
|
|
const feeAmount = 0; // No fee by default
|
|
|
|
|
const feePct = 0;
|
|
|
|
|
const total = subtotal - discountAmount + feeAmount;
|
|
|
|
|
|
|
|
|
|
// BR-045: Verify expenses aren't already linked to a non-draft invoice
|
|
|
|
|
const expenseIds = data.expenseIds ?? [];
|
|
|
|
|
if (expenseIds.length > 0) {
|
|
|
|
|
const alreadyLinked = await tx
|
|
|
|
|
.select({ expenseId: invoiceExpenses.expenseId })
|
|
|
|
|
.from(invoiceExpenses)
|
|
|
|
|
.innerJoin(invoices, eq(invoices.id, invoiceExpenses.invoiceId))
|
|
|
|
|
.where(
|
|
|
|
|
and(
|
|
|
|
|
inArray(invoiceExpenses.expenseId, expenseIds),
|
|
|
|
|
sql`${invoices.status} != 'draft'`,
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
.limit(1);
|
|
|
|
|
|
|
|
|
|
if (alreadyLinked.length > 0) {
|
|
|
|
|
throw new ConflictError(
|
|
|
|
|
'One or more expenses are already linked to a non-draft invoice',
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const [newInvoice] = await tx
|
|
|
|
|
.insert(invoices)
|
|
|
|
|
.values({
|
|
|
|
|
portId,
|
|
|
|
|
invoiceNumber,
|
|
|
|
|
clientName: data.clientName,
|
|
|
|
|
billingEmail: data.billingEmail ?? null,
|
|
|
|
|
billingAddress: data.billingAddress ?? null,
|
|
|
|
|
dueDate: data.dueDate,
|
|
|
|
|
paymentTerms: data.paymentTerms ?? 'net30',
|
|
|
|
|
currency: data.currency ?? 'USD',
|
|
|
|
|
subtotal: String(subtotal),
|
|
|
|
|
discountPct: String(discountPct),
|
|
|
|
|
discountAmount: String(discountAmount),
|
|
|
|
|
feePct: String(feePct),
|
|
|
|
|
feeAmount: String(feeAmount),
|
|
|
|
|
total: String(total),
|
|
|
|
|
status: 'draft',
|
|
|
|
|
paymentStatus: 'unpaid',
|
|
|
|
|
notes: data.notes ?? null,
|
|
|
|
|
createdBy: meta.userId,
|
|
|
|
|
})
|
|
|
|
|
.returning();
|
|
|
|
|
|
|
|
|
|
if (!newInvoice) throw new Error('Insert failed');
|
|
|
|
|
|
|
|
|
|
// Insert line items
|
|
|
|
|
if (lineItemsData.length > 0) {
|
|
|
|
|
await tx.insert(invoiceLineItems).values(
|
|
|
|
|
lineItemsData.map((li, idx) => ({
|
|
|
|
|
invoiceId: newInvoice.id,
|
|
|
|
|
description: li.description,
|
|
|
|
|
quantity: String(li.quantity),
|
|
|
|
|
unitPrice: String(li.unitPrice),
|
|
|
|
|
total: String(li.quantity * li.unitPrice),
|
|
|
|
|
sortOrder: idx,
|
|
|
|
|
})),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Link expenses
|
|
|
|
|
if (expenseIds.length > 0) {
|
|
|
|
|
await tx.insert(invoiceExpenses).values(
|
|
|
|
|
expenseIds.map((expenseId) => ({
|
|
|
|
|
invoiceId: newInvoice.id,
|
|
|
|
|
expenseId,
|
|
|
|
|
})),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return newInvoice;
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
void createAuditLog({
|
|
|
|
|
userId: meta.userId,
|
|
|
|
|
portId,
|
|
|
|
|
action: 'create',
|
|
|
|
|
entityType: 'invoice',
|
|
|
|
|
entityId: invoice.id,
|
|
|
|
|
newValue: invoice as unknown as Record<string, unknown>,
|
|
|
|
|
ipAddress: meta.ipAddress,
|
|
|
|
|
userAgent: meta.userAgent,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
emitToRoom(`port:${portId}`, 'invoice:created', {
|
|
|
|
|
invoiceId: invoice.id,
|
|
|
|
|
invoiceNumber: invoice.invoiceNumber,
|
|
|
|
|
total: Number(invoice.total),
|
|
|
|
|
clientName: invoice.clientName,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return invoice;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ─── Update (draft only) ──────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
export async function updateInvoice(
|
|
|
|
|
id: string,
|
|
|
|
|
portId: string,
|
|
|
|
|
data: UpdateInvoiceInput,
|
|
|
|
|
meta: ServiceAuditMeta,
|
|
|
|
|
) {
|
|
|
|
|
const existing = await getInvoiceById(id, portId);
|
|
|
|
|
if (existing.status !== 'draft') {
|
|
|
|
|
throw new ConflictError('Only draft invoices can be updated');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const updated = await withTransaction(async (tx) => {
|
|
|
|
|
const updateData: Record<string, unknown> = { updatedAt: new Date() };
|
|
|
|
|
|
|
|
|
|
if (data.clientName !== undefined) updateData.clientName = data.clientName;
|
|
|
|
|
if (data.billingEmail !== undefined) updateData.billingEmail = data.billingEmail;
|
|
|
|
|
if (data.billingAddress !== undefined) updateData.billingAddress = data.billingAddress;
|
|
|
|
|
if (data.dueDate !== undefined) updateData.dueDate = data.dueDate;
|
|
|
|
|
if (data.paymentTerms !== undefined) updateData.paymentTerms = data.paymentTerms;
|
|
|
|
|
if (data.currency !== undefined) updateData.currency = data.currency;
|
|
|
|
|
if (data.notes !== undefined) updateData.notes = data.notes;
|
|
|
|
|
|
|
|
|
|
// Recalculate totals if line items changed
|
|
|
|
|
if (data.lineItems !== undefined) {
|
|
|
|
|
const lineItemsData = data.lineItems;
|
|
|
|
|
const subtotal = lineItemsData.reduce(
|
|
|
|
|
(sum, li) => sum + li.quantity * li.unitPrice,
|
|
|
|
|
0,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const paymentTerms = data.paymentTerms ?? existing.paymentTerms;
|
|
|
|
|
let discountPct = 0;
|
|
|
|
|
if (paymentTerms === 'net10') {
|
|
|
|
|
const [setting] = await tx
|
|
|
|
|
.select({ value: systemSettings.value })
|
|
|
|
|
.from(systemSettings)
|
|
|
|
|
.where(
|
|
|
|
|
and(
|
|
|
|
|
eq(systemSettings.key, 'invoice_net10_discount'),
|
|
|
|
|
eq(systemSettings.portId, portId),
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
.limit(1);
|
|
|
|
|
discountPct = setting ? Number(setting.value) || 2 : 2;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const discountAmount = (subtotal * discountPct) / 100;
|
|
|
|
|
const feeAmount = Number(existing.feeAmount) || 0;
|
|
|
|
|
const feePct = Number(existing.feePct) || 0;
|
|
|
|
|
const total = subtotal - discountAmount + feeAmount;
|
|
|
|
|
|
|
|
|
|
updateData.subtotal = String(subtotal);
|
|
|
|
|
updateData.discountPct = String(discountPct);
|
|
|
|
|
updateData.discountAmount = String(discountAmount);
|
|
|
|
|
updateData.feePct = String(feePct);
|
|
|
|
|
updateData.feeAmount = String(feeAmount);
|
|
|
|
|
updateData.total = String(total);
|
|
|
|
|
|
|
|
|
|
// Replace line items
|
|
|
|
|
await tx.delete(invoiceLineItems).where(eq(invoiceLineItems.invoiceId, id));
|
|
|
|
|
if (lineItemsData.length > 0) {
|
|
|
|
|
await tx.insert(invoiceLineItems).values(
|
|
|
|
|
lineItemsData.map((li, idx) => ({
|
|
|
|
|
invoiceId: id,
|
|
|
|
|
description: li.description,
|
|
|
|
|
quantity: String(li.quantity),
|
|
|
|
|
unitPrice: String(li.unitPrice),
|
|
|
|
|
total: String(li.quantity * li.unitPrice),
|
|
|
|
|
sortOrder: idx,
|
|
|
|
|
})),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Replace expense links if provided
|
|
|
|
|
if (data.expenseIds !== undefined) {
|
|
|
|
|
// BR-045
|
|
|
|
|
if (data.expenseIds.length > 0) {
|
|
|
|
|
const alreadyLinked = await tx
|
|
|
|
|
.select({ expenseId: invoiceExpenses.expenseId })
|
|
|
|
|
.from(invoiceExpenses)
|
|
|
|
|
.innerJoin(invoices, eq(invoices.id, invoiceExpenses.invoiceId))
|
|
|
|
|
.where(
|
|
|
|
|
and(
|
|
|
|
|
inArray(invoiceExpenses.expenseId, data.expenseIds),
|
|
|
|
|
sql`${invoices.status} != 'draft'`,
|
|
|
|
|
ne(invoices.id, id),
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
.limit(1);
|
|
|
|
|
|
|
|
|
|
if (alreadyLinked.length > 0) {
|
|
|
|
|
throw new ConflictError(
|
|
|
|
|
'One or more expenses are already linked to a non-draft invoice',
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await tx.delete(invoiceExpenses).where(eq(invoiceExpenses.invoiceId, id));
|
|
|
|
|
if (data.expenseIds.length > 0) {
|
|
|
|
|
await tx.insert(invoiceExpenses).values(
|
|
|
|
|
data.expenseIds.map((expenseId) => ({ invoiceId: id, expenseId })),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const [result] = await tx
|
|
|
|
|
.update(invoices)
|
2026-03-26 12:06:18 +01:00
|
|
|
.set(updateData as Record<string, unknown>)
|
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>
2026-03-26 11:52:51 +01:00
|
|
|
.where(and(eq(invoices.id, id), eq(invoices.portId, portId)))
|
|
|
|
|
.returning();
|
|
|
|
|
|
|
|
|
|
if (!result) throw new NotFoundError('Invoice');
|
|
|
|
|
return result;
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const { diff } = diffEntity(
|
|
|
|
|
existing as unknown as Record<string, unknown>,
|
|
|
|
|
updated as unknown as Record<string, unknown>,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
void createAuditLog({
|
|
|
|
|
userId: meta.userId,
|
|
|
|
|
portId,
|
|
|
|
|
action: 'update',
|
|
|
|
|
entityType: 'invoice',
|
|
|
|
|
entityId: id,
|
|
|
|
|
oldValue: existing as unknown as Record<string, unknown>,
|
|
|
|
|
newValue: updated as unknown as Record<string, unknown>,
|
|
|
|
|
metadata: { diff },
|
|
|
|
|
ipAddress: meta.ipAddress,
|
|
|
|
|
userAgent: meta.userAgent,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
emitToRoom(`port:${portId}`, 'invoice:updated', {
|
|
|
|
|
invoiceId: id,
|
|
|
|
|
changedFields: Object.keys(diff),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return updated;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ─── Delete (draft only) ──────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
export async function deleteInvoice(
|
|
|
|
|
id: string,
|
|
|
|
|
portId: string,
|
|
|
|
|
meta: ServiceAuditMeta,
|
|
|
|
|
) {
|
|
|
|
|
const existing = await getInvoiceById(id, portId);
|
|
|
|
|
if (existing.status !== 'draft') {
|
|
|
|
|
throw new ConflictError('Only draft invoices can be deleted');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await withTransaction(async (tx) => {
|
|
|
|
|
await tx.delete(invoiceExpenses).where(eq(invoiceExpenses.invoiceId, id));
|
|
|
|
|
await tx.delete(invoiceLineItems).where(eq(invoiceLineItems.invoiceId, id));
|
|
|
|
|
await tx
|
|
|
|
|
.delete(invoices)
|
|
|
|
|
.where(and(eq(invoices.id, id), eq(invoices.portId, portId)));
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
void createAuditLog({
|
|
|
|
|
userId: meta.userId,
|
|
|
|
|
portId,
|
|
|
|
|
action: 'delete',
|
|
|
|
|
entityType: 'invoice',
|
|
|
|
|
entityId: id,
|
|
|
|
|
oldValue: existing as unknown as Record<string, unknown>,
|
|
|
|
|
ipAddress: meta.ipAddress,
|
|
|
|
|
userAgent: meta.userAgent,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
emitToRoom(`port:${portId}`, 'invoice:updated', {
|
|
|
|
|
invoiceId: id,
|
|
|
|
|
changedFields: ['status'],
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ─── Generate PDF ─────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
export async function generateInvoicePdf(
|
|
|
|
|
id: string,
|
|
|
|
|
portId: string,
|
|
|
|
|
meta: ServiceAuditMeta,
|
|
|
|
|
) {
|
|
|
|
|
const invoice = await getInvoiceById(id, portId);
|
|
|
|
|
|
|
|
|
|
const [port] = await db
|
|
|
|
|
.select({ id: ports.id, name: ports.name, slug: ports.slug })
|
|
|
|
|
.from(ports)
|
|
|
|
|
.where(eq(ports.id, portId))
|
|
|
|
|
.limit(1);
|
|
|
|
|
|
2026-03-26 12:29:55 +01:00
|
|
|
const inputs = buildInvoiceInputs(invoice, invoice.lineItems, port ?? {});
|
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>
2026-03-26 11:52:51 +01:00
|
|
|
|
|
|
|
|
const pdfBytes = await generatePdf(invoiceTemplate, [inputs]);
|
|
|
|
|
|
|
|
|
|
const fileId = crypto.randomUUID();
|
|
|
|
|
const storagePath = buildStoragePath(port?.slug ?? portId, 'invoices', id, fileId, 'pdf');
|
|
|
|
|
|
|
|
|
|
await minioClient.putObject(
|
|
|
|
|
env.MINIO_BUCKET,
|
|
|
|
|
storagePath,
|
|
|
|
|
Buffer.from(pdfBytes),
|
|
|
|
|
pdfBytes.length,
|
|
|
|
|
{ 'Content-Type': 'application/pdf' },
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const [fileRecord] = await db
|
|
|
|
|
.insert(files)
|
|
|
|
|
.values({
|
|
|
|
|
portId,
|
|
|
|
|
filename: `invoice-${invoice.invoiceNumber}.pdf`,
|
|
|
|
|
originalName: `invoice-${invoice.invoiceNumber}.pdf`,
|
|
|
|
|
mimeType: 'application/pdf',
|
|
|
|
|
sizeBytes: String(pdfBytes.length),
|
|
|
|
|
storagePath,
|
|
|
|
|
storageBucket: env.MINIO_BUCKET,
|
|
|
|
|
category: 'invoice',
|
|
|
|
|
uploadedBy: meta.userId,
|
|
|
|
|
})
|
|
|
|
|
.returning();
|
|
|
|
|
|
|
|
|
|
if (!fileRecord) throw new Error('File record insert failed');
|
|
|
|
|
|
|
|
|
|
await db
|
|
|
|
|
.update(invoices)
|
|
|
|
|
.set({ pdfFileId: fileRecord.id, updatedAt: new Date() })
|
|
|
|
|
.where(and(eq(invoices.id, id), eq(invoices.portId, portId)));
|
|
|
|
|
|
|
|
|
|
void createAuditLog({
|
|
|
|
|
userId: meta.userId,
|
|
|
|
|
portId,
|
|
|
|
|
action: 'update',
|
|
|
|
|
entityType: 'invoice',
|
|
|
|
|
entityId: id,
|
|
|
|
|
metadata: { action: 'pdf_generated', fileId: fileRecord.id },
|
|
|
|
|
ipAddress: meta.ipAddress,
|
|
|
|
|
userAgent: meta.userAgent,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return fileRecord;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ─── Send invoice ─────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
export async function sendInvoice(
|
|
|
|
|
id: string,
|
|
|
|
|
portId: string,
|
|
|
|
|
meta: ServiceAuditMeta,
|
|
|
|
|
) {
|
|
|
|
|
const invoice = await getInvoiceById(id, portId);
|
|
|
|
|
|
|
|
|
|
// Generate PDF if not exists
|
|
|
|
|
let pdfFileId = invoice.pdfFileId;
|
|
|
|
|
if (!pdfFileId) {
|
|
|
|
|
const fileRecord = await generateInvoicePdf(id, portId, meta);
|
|
|
|
|
pdfFileId = fileRecord.id;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Queue email job
|
|
|
|
|
await getQueue('email').add('send-invoice', { invoiceId: id, portId });
|
|
|
|
|
|
|
|
|
|
// Update status to 'sent'
|
|
|
|
|
const [updated] = await db
|
|
|
|
|
.update(invoices)
|
|
|
|
|
.set({ status: 'sent', updatedAt: new Date() })
|
|
|
|
|
.where(and(eq(invoices.id, id), eq(invoices.portId, portId)))
|
|
|
|
|
.returning();
|
|
|
|
|
|
|
|
|
|
void createAuditLog({
|
|
|
|
|
userId: meta.userId,
|
|
|
|
|
portId,
|
|
|
|
|
action: 'update',
|
|
|
|
|
entityType: 'invoice',
|
|
|
|
|
entityId: id,
|
|
|
|
|
oldValue: { status: invoice.status },
|
|
|
|
|
newValue: { status: 'sent' },
|
|
|
|
|
metadata: { action: 'invoice_sent' },
|
|
|
|
|
ipAddress: meta.ipAddress,
|
|
|
|
|
userAgent: meta.userAgent,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
emitToRoom(`port:${portId}`, 'invoice:sent', {
|
|
|
|
|
invoiceId: id,
|
|
|
|
|
invoiceNumber: invoice.invoiceNumber,
|
|
|
|
|
recipientEmail: invoice.billingEmail ?? '',
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return updated;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ─── Record payment ───────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
export async function recordPayment(
|
|
|
|
|
id: string,
|
|
|
|
|
portId: string,
|
|
|
|
|
data: RecordPaymentInput,
|
|
|
|
|
meta: ServiceAuditMeta,
|
|
|
|
|
) {
|
|
|
|
|
const existing = await getInvoiceById(id, portId);
|
|
|
|
|
|
|
|
|
|
const [updated] = await db
|
|
|
|
|
.update(invoices)
|
|
|
|
|
.set({
|
|
|
|
|
paymentStatus: 'paid',
|
|
|
|
|
paymentDate: data.paymentDate,
|
|
|
|
|
paymentMethod: data.paymentMethod ?? null,
|
|
|
|
|
paymentReference: data.paymentReference ?? null,
|
|
|
|
|
status: 'paid',
|
|
|
|
|
updatedAt: new Date(),
|
|
|
|
|
})
|
|
|
|
|
.where(and(eq(invoices.id, id), eq(invoices.portId, portId)))
|
|
|
|
|
.returning();
|
|
|
|
|
|
|
|
|
|
if (!updated) throw new NotFoundError('Invoice');
|
|
|
|
|
|
|
|
|
|
void createAuditLog({
|
|
|
|
|
userId: meta.userId,
|
|
|
|
|
portId,
|
|
|
|
|
action: 'update',
|
|
|
|
|
entityType: 'invoice',
|
|
|
|
|
entityId: id,
|
|
|
|
|
oldValue: { status: existing.status, paymentStatus: existing.paymentStatus },
|
|
|
|
|
newValue: { status: 'paid', paymentStatus: 'paid', paymentDate: data.paymentDate },
|
|
|
|
|
metadata: { action: 'payment_recorded' },
|
|
|
|
|
ipAddress: meta.ipAddress,
|
|
|
|
|
userAgent: meta.userAgent,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
emitToRoom(`port:${portId}`, 'invoice:paid', {
|
|
|
|
|
invoiceId: id,
|
|
|
|
|
invoiceNumber: existing.invoiceNumber,
|
|
|
|
|
amount: Number(existing.total),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return updated;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ─── Detect overdue (BR-044) ──────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
export async function detectOverdue(portId: string) {
|
|
|
|
|
const today = new Date().toISOString().split('T')[0]!;
|
|
|
|
|
|
|
|
|
|
const overdueInvoices = await db
|
|
|
|
|
.select({ id: invoices.id, invoiceNumber: invoices.invoiceNumber, dueDate: invoices.dueDate })
|
|
|
|
|
.from(invoices)
|
|
|
|
|
.where(
|
|
|
|
|
and(
|
|
|
|
|
eq(invoices.portId, portId),
|
|
|
|
|
eq(invoices.status, 'sent'),
|
|
|
|
|
lt(invoices.dueDate, today),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
if (overdueInvoices.length === 0) return;
|
|
|
|
|
|
|
|
|
|
for (const inv of overdueInvoices) {
|
|
|
|
|
await db
|
|
|
|
|
.update(invoices)
|
|
|
|
|
.set({ status: 'overdue', updatedAt: new Date() })
|
|
|
|
|
.where(eq(invoices.id, inv.id));
|
|
|
|
|
|
|
|
|
|
const daysPastDue = Math.max(1, Math.ceil(
|
|
|
|
|
(Date.now() - new Date(inv.dueDate).getTime()) / (1000 * 60 * 60 * 24),
|
|
|
|
|
));
|
|
|
|
|
emitToRoom(`port:${portId}`, 'invoice:overdue', {
|
|
|
|
|
invoiceId: inv.id,
|
|
|
|
|
invoiceNumber: inv.invoiceNumber,
|
|
|
|
|
daysPastDue,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await getQueue('notifications').add('invoice-overdue-notify', {
|
|
|
|
|
invoiceId: inv.id,
|
|
|
|
|
portId,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
logger.info(
|
|
|
|
|
{ invoiceId: inv.id, invoiceNumber: inv.invoiceNumber, portId },
|
|
|
|
|
'Invoice marked overdue',
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|