mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-11-13 15:51:00 +00:00
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:
@@ -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(),
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
+24
@@ -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'))`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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) });
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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)),
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user