import { z } from 'zod'; /** * Budget API contract — single source of truth for the /api/trips/:tripId/budget * endpoints (expense items, per-member splits, paid toggles, settlement). * * Trip-scoped: every endpoint verifies trip access (404 "Trip not found") and * mutations check the 'budget_edit' permission (403 "No permission"). The legacy * route (server/src/routes/budget.ts) wraps services/budgetService.ts; rows are * DB-shaped and kept open. Mutations broadcast over WebSocket with the forwarded * X-Socket-Id. Updating a linked item's total_price also syncs the price into the * linked reservation's metadata (and broadcasts reservation:updated). */ /** * Budget item member as embedded on a budget item * (server/src/services/budgetService.ts -> loadItemMembers). `paid` is the raw * SQLite INTEGER (0/1); `avatar_url` is the resolved avatar (avatarUrl()). */ export const budgetItemMemberSchema = z.object({ user_id: z.number(), paid: z.number(), username: z.string(), avatar_url: z.string().nullable().optional(), avatar: z.string().nullable().optional(), budget_item_id: z.number().optional(), }); export type BudgetItemMember = z.infer; /** * The fixed "Costs" expense categories. Unlike the old budget, users cannot * create their own categories — every expense maps to one of these keys. The * label/icon/colour per key live in the client; the server only stores the key. * Pre-rework rows used free-text categories; those are shown as `other`. */ export const COST_CATEGORIES = [ 'accommodation', 'food', 'groceries', 'transport', 'flights', 'activities', 'sightseeing', 'shopping', 'fees', 'health', 'tips', 'other', ] as const; export type CostCategory = (typeof COST_CATEGORIES)[number]; /** * One payer of an expense — a row of budget_item_payers. `amount` is in the * expense's own currency (budget_items.currency). Several payers can split who * actually paid one bill. Username/avatar are joined for display. */ export const budgetItemPayerSchema = z.object({ user_id: z.number(), amount: z.number(), username: z.string().optional(), avatar_url: z.string().nullable().optional(), avatar: z.string().nullable().optional(), budget_item_id: z.number().optional(), }); export type BudgetItemPayer = z.infer; /** * Budget item entity as returned by the budget list/create/update endpoints * (server/src/services/budgetService.ts). Columns of the `budget_items` table * plus the embedded `members` (equal-split participants) and `payers` arrays. * total_price is the sum of payer amounts in `currency`; `exchange_rate` converts * that to the trip base currency (NULL currency + rate 1 = base currency). */ export const budgetItemSchema = z.object({ id: z.number(), trip_id: z.number(), category: z.string(), name: z.string(), total_price: z.number(), currency: z.string().nullable().optional(), exchange_rate: z.number().optional(), persons: z.number().nullable().optional(), days: z.number().nullable().optional(), note: z.string().nullable().optional(), reservation_id: z.number().nullable().optional(), paid_by_user_id: z.number().nullable().optional(), expense_date: z.string().nullable().optional(), sort_order: z.number().optional(), created_at: z.string().optional(), members: z.array(budgetItemMemberSchema).optional(), payers: z.array(budgetItemPayerSchema).optional(), }); export type BudgetItem = z.infer; const payerInputSchema = z.object({ user_id: z.number(), amount: z.number(), }); export const budgetCreateItemRequestSchema = z.object({ name: z.string().min(1), category: z.string().optional(), total_price: z.number().optional(), currency: z.string().nullable().optional(), exchange_rate: z.number().optional(), // Multi-payer: who paid how much (in the expense currency). When omitted, the // server falls back to total_price with no explicit payer. payers: z.array(payerInputSchema).optional(), // Equal-split participants. When omitted, the item has no split (planning-only). member_ids: z.array(z.number()).optional(), persons: z.number().nullable().optional(), days: z.number().nullable().optional(), note: z.string().nullable().optional(), expense_date: z.string().nullable().optional(), }); export type BudgetCreateItemRequest = z.infer< typeof budgetCreateItemRequestSchema >; /** Update accepts the same fields plus total_price changes; all optional. */ export const budgetUpdateItemRequestSchema = z.object({ name: z.string().optional(), category: z.string().optional(), total_price: z.number().optional(), currency: z.string().nullable().optional(), exchange_rate: z.number().optional(), payers: z.array(payerInputSchema).optional(), member_ids: z.array(z.number()).optional(), persons: z.number().nullable().optional(), days: z.number().nullable().optional(), note: z.string().nullable().optional(), expense_date: z.string().nullable().optional(), }); export type BudgetUpdateItemRequest = z.infer< typeof budgetUpdateItemRequestSchema >; /** Replace the explicit payers of an expense (amounts in expense currency). */ export const budgetUpdatePayersRequestSchema = z.object({ payers: z.array(payerInputSchema), }); export type BudgetUpdatePayersRequest = z.infer< typeof budgetUpdatePayersRequestSchema >; /** * A persisted settle-up transfer (budget_settlements row): "from paid to" a * given amount in the trip base currency. Creating one marks a suggested flow as * paid; deleting it (undo) brings the flow back. Names joined for display. */ export const budgetSettlementSchema = z.object({ id: z.number(), trip_id: z.number(), from_user_id: z.number(), to_user_id: z.number(), amount: z.number(), created_at: z.string().optional(), created_by_user_id: z.number().nullable().optional(), from_username: z.string().optional(), from_avatar_url: z.string().nullable().optional(), to_username: z.string().optional(), to_avatar_url: z.string().nullable().optional(), }); export type BudgetSettlement = z.infer; export const budgetCreateSettlementRequestSchema = z.object({ from_user_id: z.number(), to_user_id: z.number(), amount: z.number(), }); export type BudgetCreateSettlementRequest = z.infer< typeof budgetCreateSettlementRequestSchema >; export const budgetUpdateMembersRequestSchema = z.object({ user_ids: z.array(z.number()), }); export type BudgetUpdateMembersRequest = z.infer< typeof budgetUpdateMembersRequestSchema >; export const budgetToggleMemberPaidRequestSchema = z.object({ paid: z.boolean(), }); export type BudgetToggleMemberPaidRequest = z.infer< typeof budgetToggleMemberPaidRequestSchema >; export const budgetReorderItemsRequestSchema = z.object({ orderedIds: z.array(z.number()), }); export type BudgetReorderItemsRequest = z.infer< typeof budgetReorderItemsRequestSchema >; export const budgetReorderCategoriesRequestSchema = z.object({ orderedCategories: z.array(z.string()), }); export type BudgetReorderCategoriesRequest = z.infer< typeof budgetReorderCategoriesRequestSchema >;