File size: 7,220 Bytes
98fbc6d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
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<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];

/**
 * 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(),
});

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<typeof budgetSettlementSchema>;

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
>;