Files
TREK/shared/src/budget/budget.schema.ts
T
Maurice b8728078c4 feat(reservations): add a parking booking type (#1444) (#1642)
* feat(reservations): add a parking booking type (#1444)

* feat(reservations): parking type option, icon and color across the UI

* test(reservations): cover the parking booking type

* docs(reservations): document the parking booking type
2026-07-21 16:02:53 +02:00

243 lines
9.2 KiB
TypeScript

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(),
amount: z.number().nullable().optional(),
});
export type BudgetItemMember = z.infer<typeof budgetItemMemberSchema>;
/**
* 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];
/**
* Maps a reservation `type` (flight, train, hotel, …) to one of the fixed Costs
* categories, so an expense created from a booking lands in the right bucket
* instead of a free-text/localized label. Unknown types fall back to `other`.
*/
const RESERVATION_TYPE_TO_COST_CATEGORY: Record<string, CostCategory> = {
flight: 'flights',
plane: 'flights',
train: 'transport',
bus: 'transport',
car: 'transport',
'car-rental': 'transport',
ferry: 'transport',
boat: 'transport',
taxi: 'transport',
transfer: 'transport',
transport: 'transport',
hotel: 'accommodation',
accommodation: 'accommodation',
lodging: 'accommodation',
parking: 'transport',
restaurant: 'food',
activity: 'activities',
};
export function typeToCostCategory(type: string | null | undefined): CostCategory {
if (!type) return 'other';
return RESERVATION_TYPE_TO_COST_CATEGORY[type.trim().toLowerCase()] || 'other';
}
/**
* 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<typeof budgetItemPayerSchema>;
/**
* 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<typeof budgetItemSchema>;
const payerInputSchema = z.object({
user_id: z.number(),
amount: z.number(),
});
const memberInputSchema = z.object({
user_id: z.number(),
amount: z.number().nullable().optional(),
});
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(),
members: z.array(memberInputSchema).optional(),
persons: z.number().nullable().optional(),
days: z.number().nullable().optional(),
note: z.string().nullable().optional(),
expense_date: z.string().nullable().optional(),
// Link this expense to a reservation (e.g. created from a booking's
// "add expense" flow). The server stores it on budget_items.reservation_id.
reservation_id: z.number().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(),
members: z.array(memberInputSchema).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, entered in the payer's display `currency`. `exchange_rate` is the
* live rate frozen at settle time (units of that currency per 1 trip currency), so
* a settled position stays balanced when live rates drift (#1445). Legacy rows
* have currency = null / exchange_rate = 1 and convert with live rates. Creating
* one marks a suggested flow as paid; deleting it (undo) brings the flow back.
*/
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(),
currency: z.string().nullable().optional(),
exchange_rate: z.number().optional(),
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<typeof budgetSettlementSchema>;
export const budgetCreateSettlementRequestSchema = z.object({
from_user_id: z.number(),
to_user_id: z.number(),
amount: z.number(),
// The display currency the amount was entered in; the server freezes its FX rate.
currency: z.string().nullable().optional(),
});
export type BudgetCreateSettlementRequest = z.infer<typeof budgetCreateSettlementRequestSchema>;
/** Edit a persisted settle-up transfer (same fields as create; full replace). */
export const budgetUpdateSettlementRequestSchema = z.object({
from_user_id: z.number(),
to_user_id: z.number(),
amount: z.number(),
currency: z.string().nullable().optional(),
});
export type BudgetUpdateSettlementRequest = z.infer<typeof budgetUpdateSettlementRequestSchema>;
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>;