feat(costs): let a trip keep its costs as totals only (#1714)

Travellers who track what a trip costs but never split it saw an
Unfinished warning on every expense and a settle-up they had no use for.
A trip now has a costs mode (appended migration, trips.costs_mode, split
by default); totals leaves payers, shares and settling up out of Costs on
desktop and phone through the same gate as a trip of one, and saves new
expenses without a payer. PUT /api/trips/:id and update_trip take it on
budget_edit. The split chips move into their own component.
This commit is contained in:
Maurice
2026-11-13 09:58:17 +01:00
parent c9c419b4ad
commit a70eea5bf2
62 changed files with 778 additions and 104 deletions
+2
View File
@@ -58,6 +58,7 @@ export class Trips {
created_at?: string | null;
updated_at?: string | null;
reminder_sent_for?: string | null;
costs_mode: string & Opt = 'split';
assistant_attachments_collection = new Collection<AssistantAttachments>(this);
assistant_threads_collection = new Collection<AssistantThreads>(this);
budget_category_order_collection = new Collection<BudgetCategoryOrder>(this);
@@ -124,6 +125,7 @@ export const TripsSchema = defineEntity({
created_at: p.type(DbTimestampType).nullable().defaultRaw(`CURRENT_TIMESTAMP`).index('idx_trips_created_at'),
updated_at: p.type(DbTimestampType).nullable().defaultRaw(`CURRENT_TIMESTAMP`),
reminder_sent_for: p.text().nullable(),
costs_mode: p.text().default('split'),
assistant_attachments_collection: () => p.oneToMany(AssistantAttachments).mappedBy('trip').hidden(),
assistant_threads_collection: () => p.oneToMany(AssistantThreads).mappedBy('trip').hidden(),
budget_category_order_collection: () => p.oneToMany(BudgetCategoryOrder).mappedBy('trip').hidden(),
+1
View File
@@ -17,4 +17,5 @@ export interface TripsTable {
created_at: InsertOptional<string | null>;
updated_at: InsertOptional<string | null>;
reminder_sent_for: InsertOptional<string | null>;
costs_mode: InsertOptional<string>;
}
@@ -0,0 +1,24 @@
import { addColumnIfMissing } from '../migration-utils';
import { Migration } from '@mikro-orm/migrations';
/**
* A trip can keep its costs as totals only (#1714): who paid, the shares and
* settling up are left out of Costs, for travellers who track what a trip costs
* but never split it.
*
* `costs_mode` is 'split' (what every trip did so far) or 'totals'. 'split' as
* the default keeps this from being a breaking change: SQLite fills every
* existing row with it on the ALTER, so every trip looks as it did before.
*/
export class Migration20200101046390_a_trip_can_keep_its_costs_as_totals_only extends Migration {
override name = 'Migration20200101046390_a_trip_can_keep_its_costs_as_totals_only';
override async up(): Promise<void> {
await addColumnIfMissing(
this,
'trips',
'costs_mode',
`costs_mode TEXT NOT NULL DEFAULT 'split' CHECK (costs_mode IN ('split', 'totals'))`,
);
}
}
+10 -12
View File
@@ -36,6 +36,7 @@ export interface TripRawRow {
created_at: string | null;
updated_at: string | null;
reminder_sent_for: string | null;
costs_mode: string;
}
const _tripRawRowKeys: AssertRowKeys<TripRawRow, Trips> = true;
@@ -544,7 +545,7 @@ export class TripsRepository extends TrekRepository<Trips> {
/**
* TP37 (`trips.service.ts::copy`'s INSERT) — `INSERT INTO trips (user_id,
* title, description, start_date, end_date, currency, cover_image,
* is_archived, reminder_days) VALUES (?, ?, ?, ?, ?, ?, ?, 0, ?)`. A
* is_archived, reminder_days, costs_mode) VALUES (?, ?, ?, ?, ?, ?, ?, 0, ?, ?)`. A
* DIFFERENT column set from `insertTrip` (TP18, `create`'s own INSERT):
* this one also writes `cover_image` (verbatim from the source row) and
* `is_archived` as a hard-coded literal `0` — a copy is never archived,
@@ -563,6 +564,7 @@ export class TripsRepository extends TrekRepository<Trips> {
currency: string | null;
cover_image: string | null;
reminder_days: number;
costs_mode: string;
}): Promise<number> {
return await this.insert({
user: input.user_id,
@@ -574,13 +576,14 @@ export class TripsRepository extends TrekRepository<Trips> {
cover_image: input.cover_image,
is_archived: 0,
reminder_days: input.reminder_days,
costs_mode: input.costs_mode,
});
}
/**
* TP25 (`trips.service.ts::updateTrip`) — `UPDATE trips SET title=?,
* description=?, start_date=?, end_date=?, currency=?, is_archived=?,
* cover_image=?, reminder_days=?, updated_at=CURRENT_TIMESTAMP WHERE
* cover_image=?, reminder_days=?, costs_mode=?, updated_at=CURRENT_TIMESTAMP WHERE
* id=?`. The `||`/`!== undefined` pre-image fallbacks are the SERVICE's
* own (R5/§18.6 — this write stays OUTSIDE the days-regeneration
* transaction, unchanged); this takes the final, already-decided values
@@ -597,16 +600,11 @@ export class TripsRepository extends TrekRepository<Trips> {
* here would tempt a caller to paper over that with its own `?? 0`, the
* exact regression the review found.
*/
async updateTripRow(id: number, data: {
title: string;
description: string | null;
start_date: string | null;
end_date: string | null;
currency: string;
is_archived: number | null;
cover_image: string | null;
reminder_days: number;
}): Promise<void> {
async updateTripRow(
id: number,
data: Pick<TripRawRow, 'title' | 'description' | 'start_date' | 'end_date' | 'is_archived' | 'cover_image' | 'costs_mode'>
& { currency: string; reminder_days: number },
): Promise<void> {
const platform = this.getEntityManager().getPlatform();
await this.nativeUpdate({ id }, { ...data, updated_at: currentTimestamp(platform) });
}
+8 -3
View File
@@ -40,9 +40,11 @@ const TRIP_EDIT_FIELDS = ['title', 'description', 'start_date', 'end_date', 'cur
/**
* The permissions a trip update needs, in the order PUT /api/trips/:id checks
* them, each with the refusal that route answers. Archiving and the cover have
* their own action; any other field present is an edit. update_trip runs the
* same list, so an MCP caller cannot archive a trip or swap its cover on the
* strength of trip_edit alone.
* their own action, and so does the costs mode (#1714): it decides what Costs
* shows, so whoever may edit the costs sets it, as against the trip's own
* details. Any other field present is an edit. update_trip runs the same list,
* so an MCP caller cannot archive a trip or swap its cover on the strength of
* trip_edit alone.
*/
export function tripUpdatePermissions(body: object): { action: PermissionKey; refusal: string }[] {
const sent = (field: string) => (body as Record<string, unknown>)[field] !== undefined;
@@ -53,6 +55,9 @@ export function tripUpdatePermissions(body: object): { action: PermissionKey; re
if (sent('cover_image')) {
needed.push({ action: 'trip_cover_upload', refusal: 'No permission to change cover image' });
}
if (sent('costs_mode')) {
needed.push({ action: 'budget_edit', refusal: 'No permission to change how this trip keeps its costs' });
}
if (TRIP_EDIT_FIELDS.some(sent)) needed.push({ action: 'trip_edit', refusal: 'No permission to edit this trip' });
return needed;
}
+18 -3
View File
@@ -29,7 +29,7 @@ import { TripReadModelService } from '../trip-read-model/trip-read-model.service
import { TripWritesService, type TripListWriter } from './trip-writes.service';
import { tripUpdatePermissions } from './trips.helpers';
import { TripsService, NotFoundError, ValidationError } from './trips.service';
import { idSchema, MAX_TRIP_DAYS } from '@trek/shared';
import { idSchema, MAX_TRIP_DAYS, tripUpdateRequestSchema, type TripCostsMode } from '@trek/shared';
import { z } from 'zod';
@@ -251,6 +251,9 @@ export class TripsMcp {
.describe(
'When changing dates: keep_bookings (default) keeps dated reservations/accommodations on their dates while day plans move; shift_all moves the whole itinerary, bookings included',
),
costs_mode: tripUpdateRequestSchema.shape.costs_mode.describe(
'How the trip keeps its costs: split (who paid and who shares each expense, with settling up) or totals (what the trip costs, nothing split). Who paid and the shares stay stored either way. Needs the budget_edit permission rather than trip_edit',
),
},
annotations: TOOL_ANNOTATIONS_WRITE,
access: { group: 'trips', mode: 'write' },
@@ -269,6 +272,7 @@ export class TripsMcp {
day_count,
reminder_days,
date_shift_mode,
costs_mode,
}: {
tripId: number;
title?: string;
@@ -282,6 +286,7 @@ export class TripsMcp {
day_count?: number;
reminder_days?: number;
date_shift_mode?: 'keep_bookings' | 'shift_all';
costs_mode?: TripCostsMode;
},
ctx: McpContext,
) {
@@ -293,8 +298,18 @@ export class TripsMcp {
const dates: { start_date?: string | null; end_date?: string | null } = clear_dates
? { start_date: null, end_date: null }
: { start_date, end_date };
const fields = { title, description, ...dates, currency, is_archived, cover_image, day_count, reminder_days };
// The rights PUT /api/trips/:id asks for, field by field: archiving and the cover have their own.
const fields = {
title,
description,
...dates,
currency,
is_archived,
cover_image,
day_count,
reminder_days,
costs_mode,
};
// The rights PUT /api/trips/:id asks for, field by field: archiving, the cover and the costs mode have their own.
const granted = await Promise.all(
tripUpdatePermissions(fields).map(({ action }) => this.guards.hasTripPermission(action, tripId, ctx.userId)),
);
+9 -15
View File
@@ -34,6 +34,7 @@ import {
type DayGridRemoval,
type TrekWsPayload,
type TrekWsTripEventName,
type TripUpdateRequest,
} from '@trek/shared';
import { RealtimeService } from '../realtime/realtime.service';
import { PermissionsService } from '../permissions/permissions.service';
@@ -62,20 +63,9 @@ interface CreateTripData {
day_count?: number;
}
// Nullable where the wire contract (tripUpdateRequestSchema) is nullable — the
// legacy route accepted arbitrary JSON, so null always reached these fields.
interface UpdateTripData {
title?: string;
description?: string | null;
start_date?: string | null;
end_date?: string | null;
currency?: string;
is_archived?: boolean | number;
cover_image?: string | null;
reminder_days?: number;
day_count?: number;
date_shift_mode?: 'keep_bookings' | 'shift_all';
}
// The wire contract of PUT /api/trips/:id (tripUpdateRequestSchema), which
// update_trip hands on as well; nullable where it is, as null always arrived.
type UpdateTripData = TripUpdateRequest;
export interface UpdateTripResult {
updatedTrip: any;
@@ -475,7 +465,7 @@ export class TripsService {
const trip = await this.tripsRepo.findRaw(tripId) as (Trip & { reminder_days?: number }) | null; // TP24
if (!trip) throw new NotFoundError('Trip not found');
const { title, description, currency, is_archived, cover_image, reminder_days } = data;
const { title, description, currency, is_archived, cover_image, reminder_days, costs_mode } = data;
const { newStart, newEnd, dayCount, regenerate } = this.resolveRange(trip, data);
const newTitle = title || trip.title;
@@ -483,6 +473,7 @@ export class TripsService {
const newCurrency = currency || trip.currency;
const newArchived = is_archived !== undefined ? (is_archived ? 1 : 0) : trip.is_archived;
const newCover = cover_image !== undefined ? cover_image : trip.cover_image;
const newCostsMode = costs_mode ?? trip.costs_mode;
const oldReminder = (trip as any).reminder_days ?? 3;
const newReminder = reminder_days !== undefined
? (Number(reminder_days) >= 0 && Number(reminder_days) <= 30 ? Number(reminder_days) : oldReminder)
@@ -510,6 +501,7 @@ export class TripsService {
is_archived: newArchived,
cover_image: newCover ?? null,
reminder_days: newReminder,
costs_mode: newCostsMode,
});
if (trip.start_date && trip.end_date && newStart && newStart !== trip.start_date)
@@ -545,6 +537,7 @@ export class TripsService {
if (newEnd !== trip.end_date) changes.end_date = newEnd;
if (newReminder !== oldReminder) changes.reminder_days = newReminder === 0 ? 'none' : `${newReminder} days`;
if (is_archived !== undefined && newArchived !== trip.is_archived) changes.archived = !!newArchived;
if (newCostsMode !== trip.costs_mode) changes.costs_mode = newCostsMode;
const isAdminEdit = userRole === 'admin' && trip.user_id !== userId;
let ownerEmail: string | undefined;
@@ -659,6 +652,7 @@ export class TripsService {
currency: src.currency,
cover_image: src.cover_image,
reminder_days: src.reminder_days ?? 3,
costs_mode: src.costs_mode,
});
const oldDays = await this.daysRepo.listByTrip(Number(sourceTripId)); // TP38