import { z } from 'zod'; import { baseListQuerySchema } from '@/lib/api/route-helpers'; import { EXPENSE_CATEGORIES, PAYMENT_METHODS } from '@/lib/constants'; /** * Inner-shape ZodObject — kept exported (without .refine wrapping) so * `updateExpenseSchema` can still call `.partial()`. The `.refine()` rule * for "receipt or acknowledgement" is applied via `createExpenseSchema`. */ export const createExpenseShape = z.object({ establishmentName: z.string().max(200).optional(), amount: z.coerce.number().positive(), currency: z.string().length(3).default('USD'), paymentMethod: z.enum(PAYMENT_METHODS).optional(), category: z.enum(EXPENSE_CATEGORIES).optional(), payer: z.string().max(200).optional(), expenseDate: z.coerce.date(), description: z.string().max(2000).optional(), receiptFileIds: z.array(z.string()).optional(), /** * Set to `true` when the rep deliberately creates an expense without a * receipt. The UI shows a non-blocking warning that surfaces both at * creation time and again in the PDF export. Without this flag, the * server rejects an expense submitted with no `receiptFileIds` so reps * can't accidentally lose-receipt by mistake. */ noReceiptAcknowledged: z.boolean().optional().default(false), paymentStatus: z.enum(['unpaid', 'paid', 'partial']).default('unpaid'), paymentDate: z.string().optional(), paymentReference: z.string().optional(), paymentNotes: z.string().optional(), /** * Free-text trip / event label (e.g. "Palm Beach 2026"). Empty / null * means "no trip". The form's autocomplete suggests prior values per * port to keep spellings consistent so group-by works downstream. */ tripLabel: z.string().max(120).optional(), }); export const createExpenseSchema = createExpenseShape.refine( (v) => (v.receiptFileIds && v.receiptFileIds.length > 0) || v.noReceiptAcknowledged === true, { message: 'Receipt required. Tick "I have no receipt for this expense" if you understand it may not be reimbursed.', path: ['receiptFileIds'], }, ); // Update accepts partial fields and skips the create-time receipt-or-ack // rule (the row already exists and may legitimately be edited without // touching receipts). export const updateExpenseSchema = createExpenseShape.partial(); export const listExpensesSchema = baseListQuerySchema.extend({ category: z.string().optional(), paymentStatus: z.string().optional(), dateFrom: z.string().optional(), dateTo: z.string().optional(), currency: z.string().optional(), payer: z.string().optional(), tripLabel: z.string().optional(), }); /** * Body for `POST /api/v1/expenses/export/pdf`. Mirrors the legacy * `PDFOptions` shape from the Nuxt client-portal so reps can re-use the * same mental model. `expenseIds` selects an explicit subset; when * absent, the listExpenses-style filter is used to gather rows. * * Limits are deliberate: * - max 1000 expenseIds so a runaway selection can't queue an OOM-able * receipt-fetch loop (see expense-pdf.service.ts). * - documentName is sanitized at the service layer for the filename; * the validator only enforces a sane upper bound. */ export const exportExpensePdfSchema = z.object({ expenseIds: z.array(z.string()).max(1000).optional(), filter: z .object({ dateFrom: z.string().optional().nullable(), dateTo: z.string().optional().nullable(), category: z.string().optional().nullable(), paymentStatus: z.string().optional().nullable(), payer: z.string().optional().nullable(), tripLabel: z.string().optional().nullable(), includeArchived: z.boolean().optional(), }) .optional(), options: z.object({ documentName: z.string().min(1).max(200), subheader: z.string().max(300).optional(), groupBy: z.enum(['none', 'payer', 'category', 'date', 'trip']).default('none'), includeReceipts: z.boolean().default(false), includeReceiptContents: z.boolean().default(false), includeSummary: z.boolean().default(true), includeDetails: z.boolean().default(true), includeProcessingFee: z.boolean().default(false), targetCurrency: z.enum(['USD', 'EUR']).default('EUR'), pageFormat: z.enum(['A4', 'Letter', 'Legal']).default('A4'), }), }); export type CreateExpenseInput = z.infer; export type UpdateExpenseInput = z.infer; export type ListExpensesInput = z.infer; export type ExportExpensePdfInput = z.infer;