diff --git a/client/src/components/Budget/CostsPanel.test.tsx b/client/src/components/Budget/CostsPanel.test.tsx index 6dd9ec25c..037d62a37 100644 --- a/client/src/components/Budget/CostsPanel.test.tsx +++ b/client/src/components/Budget/CostsPanel.test.tsx @@ -1393,6 +1393,26 @@ describe('CostsPanel — expense modal', () => { expect(posted).toMatchObject({ name: 'Hotel Astoria', category: 'accommodation', total_price: 240, reservation_id: 12 }) expect(onSaved).toHaveBeenCalled() }) + + it('FE-W5COSTS-038b: a prefill from a place links the expense to that place (#1298)', async () => { + const user = userEvent.setup() + let posted: Record | null = null + server.use(http.post('/api/trips/1/budget', async ({ request }) => { + posted = await request.json() as Record + return HttpResponse.json({ item: dinner() }) + })) + render( + {}} onSaved={vi.fn()} /> + ) + + await user.click(screen.getByRole('button', { name: 'Add expense' })) + + await waitFor(() => expect(posted).toBeTruthy()); + expect(posted).toMatchObject({ name: 'Louvre', category: 'activities', total_price: 34, place_id: 7 }) + expect(posted).not.toHaveProperty('reservation_id') + }) }) describe('CostsPanel — remaining paths', () => { diff --git a/client/src/components/Budget/CostsPanel.tsx b/client/src/components/Budget/CostsPanel.tsx index 4c10528cd..2a210162a 100644 --- a/client/src/components/Budget/CostsPanel.tsx +++ b/client/src/components/Budget/CostsPanel.tsx @@ -1000,6 +1000,8 @@ export interface ExpensePrefill { category?: string amount?: number reservationId?: number + /** Set when the expense is being created from a place (#1298). */ + placeId?: number } export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClose, onSaved }: { @@ -1252,6 +1254,7 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo note: note.trim() || null, ticket_json: splitMode === 'ticket' ? writeTicketItems(ticketItems) : null, ...(!editing && prefill?.reservationId ? { reservation_id: prefill.reservationId } : {}), + ...(!editing && prefill?.placeId ? { place_id: prefill.placeId } : {}), } try { if (editing) await updateBudgetItem(tripId, editing.id, data) diff --git a/client/src/components/Planner/BookingCostsSection.tsx b/client/src/components/Planner/BookingCostsSection.tsx index 89851c52f..b8a491985 100644 --- a/client/src/components/Planner/BookingCostsSection.tsx +++ b/client/src/components/Planner/BookingCostsSection.tsx @@ -7,13 +7,21 @@ import { catMeta } from '../Budget/costsCategories' import type { BudgetItem } from '../../types' /** - * The Costs block inside a booking modal. Replaces the old inline price + budget - * category fields: when no expense is linked yet it offers a "create expense" - * button (the modal saves the booking first, then opens the full Costs editor); - * once linked it shows the expense with edit / remove actions. + * The Costs block inside a booking modal — and, since #1298, inside the place + * form, which links its expense the same way. Replaces the old inline price + + * budget category fields: when no expense is linked yet it offers a "create + * expense" button (the modal saves its own record first, then opens the full + * Costs editor); once linked it shows the expense with edit / remove actions. + * + * Exactly one of reservationId / placeId is set — they are the two sides of the + * same link, and the block behaves identically on both. */ -export function BookingCostsSection({ reservationId, pendingExpense, onCreate, onEdit, onRemove }: { +export function BookingCostsSection({ reservationId, placeId = null, hintKey = 'reservations.createExpenseHint', pendingExpense, onCreate, onEdit, onRemove }: { reservationId: number | null + /** Set instead of reservationId when the block sits in the place form (#1298). */ + placeId?: number | null + /** What gets saved before the editor opens — "the booking" or "the place". */ + hintKey?: string /** A cost parsed from an import that will be linked on save — previewed before the booking exists. */ pendingExpense?: { total_price: number; currency?: string | null; category: string } | null onCreate: () => void @@ -25,7 +33,11 @@ export function BookingCostsSection({ reservationId, pendingExpense, onCreate, o const trip = useTripStore(s => s.trip) const displayCurrency = useSettingsStore(s => s.settings.default_currency) const base = (displayCurrency || trip?.currency || 'EUR').toUpperCase() - const linked = reservationId ? budgetItems.find(i => i.reservation_id === reservationId) : null + const linked = reservationId + ? budgetItems.find(i => i.reservation_id === reservationId) + : placeId + ? budgetItems.find(i => i.place_id === placeId) + : null const labelCls = 'block text-[11px] font-semibold uppercase tracking-[0.08em] text-content-faint mb-[6px]' @@ -40,7 +52,7 @@ export function BookingCostsSection({ reservationId, pendingExpense, onCreate, o
{t(meta.labelKey)}
-
{t('reservations.createExpenseHint')}
+
{t(hintKey)}
{formatMoney(pendingExpense.total_price, pendingExpense.currency || base, locale)} @@ -76,7 +88,7 @@ export function BookingCostsSection({ reservationId, pendingExpense, onCreate, o style={{ width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8, padding: '11px 13px', borderRadius: 10, fontSize: 'calc(13.5px * var(--fs-scale-body, 1))', fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit' }}> {t('reservations.createExpense')} -
{t('reservations.createExpenseHint')}
+
{t(hintKey)}
) } diff --git a/client/src/components/Planner/BookingCostsSection.types.ts b/client/src/components/Planner/BookingCostsSection.types.ts index a4805e4a8..5e100cdb6 100644 --- a/client/src/components/Planner/BookingCostsSection.types.ts +++ b/client/src/components/Planner/BookingCostsSection.types.ts @@ -1,11 +1,12 @@ import type { BudgetItem } from '../../types' /** - * A request from a booking modal to open the Costs expense editor — either to - * edit the already-linked expense, or to create a new one prefilled from the - * booking (the modal saves the booking first so `reservationId` is known). + * A request from a booking modal — or from the place form (#1298) — to open the + * Costs expense editor: either to edit the already-linked expense, or to create + * a new one prefilled from the record. The modal saves itself first, so the + * `reservationId` / `placeId` it names already exists. */ export interface BookingExpenseRequest { editItem?: BudgetItem - prefill?: { reservationId?: number; name?: string; category?: string; amount?: number } + prefill?: { reservationId?: number; placeId?: number; name?: string; category?: string; amount?: number } } diff --git a/client/src/components/Planner/PlaceFormModal.test.tsx b/client/src/components/Planner/PlaceFormModal.test.tsx index 04e225f39..063614aca 100644 --- a/client/src/components/Planner/PlaceFormModal.test.tsx +++ b/client/src/components/Planner/PlaceFormModal.test.tsx @@ -1,4 +1,4 @@ -// FE-COMP-PLACEFORM-001 to FE-COMP-PLACEFORM-036, FE-PLANNER-PLACEFORM-016 to FE-PLANNER-PLACEFORM-062 +// FE-COMP-PLACEFORM-001 to FE-COMP-PLACEFORM-036, FE-PLANNER-PLACEFORM-016 to FE-PLANNER-PLACEFORM-067, plus FE-PLANNER-PLACEFORM-068 to -072 import { render, screen, waitFor, fireEvent, within } from '../../../tests/helpers/render'; import userEvent from '@testing-library/user-event'; import { http, HttpResponse } from 'msw'; @@ -1467,4 +1467,94 @@ describe('PlaceFormModal remaining branches', () => { expect(websiteInput().value).toBe('https://stored.example'); }); }); + + // ── Linked expense (#1298) — the same block bookings have ────────────────── + + describe('Costs section', () => { + const withBudget = () => seedStore(useAddonStore, { + addons: [{ id: 'budget', name: 'Budget', type: 'budget', icon: '', enabled: true }], + loaded: true, + }); + + it('FE-PLANNER-PLACEFORM-068: the block only appears while the Budget addon is on', () => { + const { unmount } = render(); + expect(screen.queryByRole('button', { name: /Create expense/i })).not.toBeInTheDocument(); + unmount(); + + withBudget(); + render(); + expect(screen.getByRole('button', { name: /Create expense/i })).toBeInTheDocument(); + }); + + it('FE-PLANNER-PLACEFORM-069: creating an expense saves the place first, then opens the editor', async () => { + withBudget(); + const onSave = vi.fn().mockResolvedValue({ id: 42 }); + const onOpenExpense = vi.fn(); + render(); + + fireEvent.change(screen.getByPlaceholderText(/e\.g\. Eiffel Tower/i), { target: { value: 'Louvre' } }); + fireEvent.click(screen.getByRole('button', { name: /Create expense/i })); + + await waitFor(() => expect(onOpenExpense).toHaveBeenCalled()); + expect(onSave).toHaveBeenCalled(); + expect(onSave.mock.invocationCallOrder[0]).toBeLessThan(onOpenExpense.mock.invocationCallOrder[0]); + expect(onOpenExpense).toHaveBeenCalledWith({ + prefill: { placeId: 42, name: 'Louvre', category: 'activities' }, + }); + }); + + it('FE-PLANNER-PLACEFORM-070: a save that yields no id opens no editor', async () => { + withBudget(); + const onSave = vi.fn().mockResolvedValue(undefined); + const onOpenExpense = vi.fn(); + render(); + + fireEvent.change(screen.getByPlaceholderText(/e\.g\. Eiffel Tower/i), { target: { value: 'Louvre' } }); + fireEvent.click(screen.getByRole('button', { name: /Create expense/i })); + + await waitFor(() => expect(onSave).toHaveBeenCalled()); + expect(onOpenExpense).not.toHaveBeenCalled(); + }); + + it('FE-PLANNER-PLACEFORM-070b: a nameless click drops the intent instead of arming the next save', async () => { + withBudget(); + const onSave = vi.fn().mockResolvedValue({ id: 42 }); + const onOpenExpense = vi.fn(); + render(); + + // No name yet — the save is refused and the intent must not survive it. + fireEvent.click(screen.getByRole('button', { name: /Create expense/i })); + await waitFor(() => expect(onSave).not.toHaveBeenCalled()); + + fireEvent.change(screen.getByPlaceholderText(/e\.g\. Eiffel Tower/i), { target: { value: 'Louvre' } }); + fireEvent.click(screen.getByRole('button', { name: /^Add$/i })); + + await waitFor(() => expect(onSave).toHaveBeenCalled()); + expect(onOpenExpense).not.toHaveBeenCalled(); + }); + + it('FE-PLANNER-PLACEFORM-071: an already-linked expense is shown with its amount instead of the button', () => { + withBudget(); + seedStore(useTripStore, { + trip: buildTrip({ id: 1 }), + budgetItems: [{ id: 8, trip_id: 1, name: 'Louvre tickets', total_price: 34, category: 'activities', place_id: 7 }], + }); + render(); + + expect(screen.getByText('Louvre tickets')).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /Create expense/i })).not.toBeInTheDocument(); + }); + + it('FE-PLANNER-PLACEFORM-072: an expense linked to another place is not claimed', () => { + withBudget(); + seedStore(useTripStore, { + trip: buildTrip({ id: 1 }), + budgetItems: [{ id: 8, trip_id: 1, name: 'Orsay tickets', total_price: 16, category: 'activities', place_id: 99 }], + }); + render(); + + expect(screen.queryByText('Orsay tickets')).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: /Create expense/i })).toBeInTheDocument(); + }); + }); }); diff --git a/client/src/components/Planner/PlaceFormModal.tsx b/client/src/components/Planner/PlaceFormModal.tsx index 1927f0c84..1648f8aaa 100644 --- a/client/src/components/Planner/PlaceFormModal.tsx +++ b/client/src/components/Planner/PlaceFormModal.tsx @@ -16,7 +16,9 @@ import { useTranslation } from '../../i18n' import CustomTimePicker from '../shared/CustomTimePicker' import { DEFAULT_FORM, isGoogleMapsUrl, mergeResult, type PlaceFormData, type ResultField } from './PlaceFormModal.helpers' import { getApiErrorMessage } from '../../utils/apiError' -import type { Place, Category, Assignment } from '../../types' +import { BookingCostsSection } from './BookingCostsSection' +import type { BookingExpenseRequest } from './BookingCostsSection.types' +import type { Place, Category, Assignment, BudgetItem } from '../../types' import { NumericInput } from '../shared/NumericInput' // The submit payload mirrors the form, but lat/lng are parsed to numbers and @@ -31,7 +33,7 @@ export interface PlaceSubmitData extends Omit void - onSave: (data: PlaceSubmitData, files?: File[]) => Promise | void + onSave: (data: PlaceSubmitData, files?: File[]) => Promise<{ id: number } | void> | void place: Place | null prefillCoords?: { lat: number; lng: number; name?: string; address?: string; website?: string; phone?: string; osm_id?: string } | null tripId: number @@ -43,6 +45,9 @@ interface PlaceFormModalProps { * picker column when the Collections addon is enabled. Sourced from the trip * planner's matchMedia('(max-width:767px)'). */ isMobile?: boolean + /** Opens the Costs editor for this place's linked expense (#1298) — the same + * seam the booking and transport modals use. */ + onOpenExpense?: (req: BookingExpenseRequest) => void } @@ -78,6 +83,7 @@ function usePlaceFormModal(props: PlaceFormModalProps) { const { isOpen, onClose, onSave, place, prefillCoords, tripId, categories, onCategoryCreated, assignmentId, dayAssignments = [], isMobile = false, + onOpenExpense, } = props const [form, setForm] = useState(DEFAULT_FORM) const [mapsSearch, setMapsSearch] = useState('') @@ -108,6 +114,11 @@ function usePlaceFormModal(props: PlaceFormModalProps) { const tripObj = useTripStore((s) => s.trip) const canUploadFiles = can('file_upload', tripObj) const collectionsEnabled = useAddonStore((s) => s.isEnabled('collections')) + const isBudgetEnabled = useAddonStore((s) => s.isEnabled('budget')) + const deleteBudgetItem = useTripStore((s) => s.deleteBudgetItem) + // Set right before submit when the user clicked create/edit expense — the + // place has to exist before an expense can point at it (see ReservationModal). + const expenseIntentRef = useRef<{ editItem?: BudgetItem; create?: boolean } | null>(null) useEffect(() => { if (place) { @@ -430,9 +441,12 @@ function usePlaceFormModal(props: PlaceFormModalProps) { const hasTimeError = place && form.place_time && form.end_time && form.place_time.length >= 5 && form.end_time.length >= 5 && form.end_time <= form.place_time - const handleSubmit = async (e) => { - e.preventDefault() + const handleSubmit = async (e?: { preventDefault?: () => void }) => { + e?.preventDefault?.() if (!form.name.trim()) { + // Nothing was saved, so an expense intent from a previous click is stale — + // otherwise the next plain Save would open a Costs editor out of nowhere. + expenseIntentRef.current = null toast.error(t('places.nameRequired')) return } @@ -449,13 +463,23 @@ function usePlaceFormModal(props: PlaceFormModalProps) { } setIsSaving(true) try { - await onSave({ + const saved = await onSave({ ...form, lat: form.lat ? parseFloat(form.lat) : null, lng: form.lng ? parseFloat(form.lng) : null, category_id: form.category_id || null, _pendingFiles: pendingFiles.length > 0 ? pendingFiles : undefined, }) + // Open the Costs editor for the saved place when the user asked to + // create/edit its linked expense — gated on an id, so a create that the + // server refused never opens an editor pointing at nothing (#1298). + const intent = expenseIntentRef.current + expenseIntentRef.current = null + const savedId = (saved && 'id' in saved ? saved.id : null) ?? place?.id ?? null + if (intent && onOpenExpense && savedId) { + if (intent.editItem) onOpenExpense({ editItem: intent.editItem }) + else onOpenExpense({ prefill: { placeId: savedId, name: form.name.trim(), category: 'activities' } }) + } onClose() } catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('places.saveError')) @@ -464,6 +488,12 @@ function usePlaceFormModal(props: PlaceFormModalProps) { } } + const handleCreateExpense = () => { expenseIntentRef.current = { create: true }; void handleSubmit() } + const handleEditExpense = (item: BudgetItem) => { expenseIntentRef.current = { editItem: item }; void handleSubmit() } + const handleRemoveExpense = async (item: BudgetItem) => { + try { await deleteBudgetItem(Number(tripId), item.id) } catch { toast.error(t('common.unknownError')) } + } + return { isOpen, onClose, @@ -527,6 +557,10 @@ function usePlaceFormModal(props: PlaceFormModalProps) { handleSubmit, duplicateWarning, detailsSelection, + isBudgetEnabled, + handleCreateExpense, + handleEditExpense, + handleRemoveExpense, } } @@ -593,6 +627,10 @@ export default function PlaceFormModal(props: PlaceFormModalProps) { handleSubmit, duplicateWarning, detailsSelection, + isBudgetEnabled, + handleCreateExpense, + handleEditExpense, + handleRemoveExpense, } = S // Desktop + Collections addon → the saved-place picker on the right. Mobile // always keeps the original single-column form untouched. @@ -917,6 +955,19 @@ export default function PlaceFormModal(props: PlaceFormModalProps) { )} + {/* Costs — create / view the expense linked to this place (#1298). + Same block, same flow as a booking: save first, then the editor. */} + {isBudgetEnabled && ( + + )} + {twoColumn && ( diff --git a/client/src/mobile/screens/trip/sheets/MCostSheet.tsx b/client/src/mobile/screens/trip/sheets/MCostSheet.tsx index aa4acbbe8..fc57e938d 100644 --- a/client/src/mobile/screens/trip/sheets/MCostSheet.tsx +++ b/client/src/mobile/screens/trip/sheets/MCostSheet.tsx @@ -258,6 +258,7 @@ export default function MCostSheet({ tripId, base, people, me, editing, prefill, note: note.trim() || null, ticket_json: splitMode === 'ticket' ? writeTicketItems(ticketItems) : null, ...(!editing && prefill?.reservationId ? { reservation_id: prefill.reservationId } : {}), + ...(!editing && prefill?.placeId ? { place_id: prefill.placeId } : {}), } try { if (editing) await updateBudgetItem(tripId, editing.id, data) diff --git a/client/src/mobile/screens/trip/sheets/MPlaceEditSheet.tsx b/client/src/mobile/screens/trip/sheets/MPlaceEditSheet.tsx index fab3821aa..3cb27e686 100644 --- a/client/src/mobile/screens/trip/sheets/MPlaceEditSheet.tsx +++ b/client/src/mobile/screens/trip/sheets/MPlaceEditSheet.tsx @@ -1,5 +1,5 @@ import { useEffect, useMemo, useRef, useState, type ClipboardEvent } from 'react' -import { MapPin } from 'lucide-react' +import { MapPin, Plus } from 'lucide-react' import MSheet from '../../../components/MSheet' import { DEFAULT_FORM, @@ -8,6 +8,8 @@ import { type ResultField, } from '../../../../components/Planner/PlaceFormModal.helpers' import { Eyebrow, FIELD_AREA_CLS, FIELD_CLS, FormSheetFooter, FormSheetHeader } from './PlSheetChrome' +import { useAddonStore } from '../../../../store/addonStore' +import type { BookingExpenseRequest } from '../../../../components/Planner/BookingCostsSection.types' import PlPlaceSearch, { type PlSearchPick } from './PlPlaceSearch' import PlCategoryPicker from './PlCategoryPicker' import PlTimeFields from './PlTimeFields' @@ -17,6 +19,8 @@ import type { TripPlanner } from '../MTripShell' export interface MPlaceEditSheetProps { planner: TripPlanner + /** Opens the Costs editor for this place's linked expense (#1298). */ + onOpenExpense: (req: BookingExpenseRequest) => void } // #1152: same duplicate heuristic as the desktop form — shared Google Place ID, @@ -51,7 +55,7 @@ function findDuplicateName( * planner.handleSavePlace, which owns the assignment-time split, pending-file * upload and undo. */ -export default function MPlaceEditSheet({ planner }: MPlaceEditSheetProps) { +export default function MPlaceEditSheet({ planner, onOpenExpense }: MPlaceEditSheetProps) { const { t, toast, places, assignments, canUploadFiles, showPlaceForm, setShowPlaceForm, @@ -68,6 +72,10 @@ export default function MPlaceEditSheet({ planner }: MPlaceEditSheetProps) { const [isSaving, setIsSaving] = useState(false) const [resolvingPick, setResolvingPick] = useState(false) const [duplicateWarning, setDuplicateWarning] = useState(null) + const isBudgetEnabled = useAddonStore(s => s.isEnabled('budget')) + // Set right before submit: the place has to exist before an expense can point + // at it, exactly like MReservationSheet does it. + const expenseIntentRef = useRef(false) const [deleteArmed, setDeleteArmed] = useState(false) // Open-time snapshot: closing clears the planner flags immediately, but the // sheet still shows through its exit animation — render off the snapshot so @@ -221,15 +229,21 @@ export default function MPlaceEditSheet({ planner }: MPlaceEditSheetProps) { return } } + const withExpense = expenseIntentRef.current + expenseIntentRef.current = false setIsSaving(true) try { - await handleSavePlace({ + const saved = await handleSavePlace({ ...form, lat: form.lat ? parseFloat(form.lat) : null, lng: form.lng ? parseFloat(form.lng) : null, category_id: form.category_id || null, _pendingFiles: pendingFiles.length > 0 ? pendingFiles : undefined, }) + const savedId = saved?.id ?? sheetPlace?.id ?? null + if (withExpense && savedId) { + onOpenExpense({ prefill: { placeId: savedId, name: form.name.trim(), category: 'activities' } }) + } handleClose() } catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('places.saveError')) @@ -360,6 +374,23 @@ export default function MPlaceEditSheet({ planner }: MPlaceEditSheetProps) { onRemove={idx => setPendingFiles(prev => prev.filter((_, i) => i !== idx))} /> )} + + {/* COSTS — same block, same flow as the booking sheet (#1298) */} + {isBudgetEnabled && ( + <> + {t('reservations.costsLabel')} + +
{t('places.createExpenseHint')}
+ + )} {/* ── Planner-flag editors (also serve ?create= and the import review) ── */} - + diff --git a/client/src/pages/TripPlannerPage.tsx b/client/src/pages/TripPlannerPage.tsx index 200b6ef67..814213cce 100644 --- a/client/src/pages/TripPlannerPage.tsx +++ b/client/src/pages/TripPlannerPage.tsx @@ -789,7 +789,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null { )} - { setShowPlaceForm(false); setEditingPlace(null); setEditingAssignmentId(null); setPrefillCoords(null) }} onSave={handleSavePlace} place={editingPlace} prefillCoords={prefillCoords} assignmentId={editingAssignmentId} dayAssignments={editingPlace ? Object.values(assignments).flat() : []} tripId={tripId} categories={categories} onCategoryCreated={cat => tripActions.addCategory?.(cat)} isMobile={isMobile} /> + { setShowPlaceForm(false); setEditingPlace(null); setEditingAssignmentId(null); setPrefillCoords(null) }} onSave={handleSavePlace} place={editingPlace} prefillCoords={prefillCoords} assignmentId={editingAssignmentId} dayAssignments={editingPlace ? Object.values(assignments).flat() : []} tripId={tripId} categories={categories} onCategoryCreated={cat => tripActions.addCategory?.(cat)} isMobile={isMobile} onOpenExpense={openBookingExpense} /> setShowTripForm(false)} diff --git a/client/src/pages/tripPlanner/useTripPlanner.ts b/client/src/pages/tripPlanner/useTripPlanner.ts index b0d3f13bf..4e920677d 100644 --- a/client/src/pages/tripPlanner/useTripPlanner.ts +++ b/client/src/pages/tripPlanner/useTripPlanner.ts @@ -545,6 +545,7 @@ export function useTripPlanner() { } } toast.success(t('trip.toast.placeUpdated')) + return { id: editingPlace.id } } else { const place = await tripActions.addPlace(tripId, data) if (pendingFiles?.length > 0 && place?.id) { @@ -562,6 +563,9 @@ export function useTripPlanner() { await tripActions.deletePlace(tripId, capturedId) }) } + // Handed back so the form can link an expense to a place that did not + // exist a moment ago (#1298), the same way the booking modals work. + return place?.id ? { id: place.id } : undefined } }, [editingPlace, editingAssignmentId, tripId, toast, pushUndo]) diff --git a/client/tests/unit/mobile/trip/MCostSheet.test.tsx b/client/tests/unit/mobile/trip/MCostSheet.test.tsx index c9f4d1a33..3689e1b13 100644 --- a/client/tests/unit/mobile/trip/MCostSheet.test.tsx +++ b/client/tests/unit/mobile/trip/MCostSheet.test.tsx @@ -33,7 +33,7 @@ interface SheetOverrides { me?: number base?: string editing?: BudgetItem | null - prefill?: { name?: string; category?: string; amount?: number; reservationId?: number } + prefill?: { name?: string; category?: string; amount?: number; reservationId?: number; placeId?: number } } function renderSheet(overrides: SheetOverrides = {}) { @@ -175,6 +175,19 @@ describe('MCostSheet', () => { })) }) + it('FE-MOB-COSTSH-003b: a place prefill carries the place link into the payload (#1298)', async () => { + renderSheet({ prefill: { name: 'Louvre', category: 'activities', placeId: 12 } }) + expect(nameField()).toHaveValue('Louvre') + + fireEvent.change(totalField(), { target: { value: '34' } }) + fireEvent.click(submit()) + await waitFor(() => expect(addBudgetItem).toHaveBeenCalledTimes(1)) + expect(addBudgetItem).toHaveBeenCalledWith(1, expect.objectContaining({ + name: 'Louvre', category: 'activities', total_price: 34, place_id: 12, + })) + expect(addBudgetItem.mock.calls[0][1]).not.toHaveProperty('reservation_id') + }) + it('FE-MOB-COSTSH-004: dropping a participant re-splits and shrinks the member list', async () => { renderSheet() fillBasics('Taxi', '30') diff --git a/client/tests/unit/mobile/trip/MPlaceEditSheet.test.tsx b/client/tests/unit/mobile/trip/MPlaceEditSheet.test.tsx index b942b02e7..7965daea5 100644 --- a/client/tests/unit/mobile/trip/MPlaceEditSheet.test.tsx +++ b/client/tests/unit/mobile/trip/MPlaceEditSheet.test.tsx @@ -4,10 +4,11 @@ import MPlaceEditSheet from '../../../../src/mobile/screens/trip/sheets/MPlaceEd import type { TripPlanner } from '../../../../src/mobile/screens/trip/MTripShell' import type { Assignment, Category, Place } from '../../../../src/types' import { buildPlanner } from '../../../helpers/mobileTrip' +import { useAddonStore } from '../../../../src/store/addonStore' import { server } from '../../../helpers/msw/server' import { fireEvent, render, screen, waitFor } from '../../../helpers/render' -// FE-MOB-PLEDIT-001 to FE-MOB-PLEDIT-024 +// FE-MOB-PLEDIT-001 to FE-MOB-PLEDIT-024, plus FE-MOB-PLEDIT-033 to -035 // planner.t echoes the key, so every label/placeholder is asserted as its key. const CATEGORIES = [ @@ -27,8 +28,9 @@ const EDITED = { function setup(overrides: Partial = {}) { const planner = buildPlanner({ showPlaceForm: true, categories: CATEGORIES, ...overrides }) - const view = render() - return { ...view, planner } + const onOpenExpense = vi.fn() + const view = render() + return { ...view, planner, onOpenExpense } } const nameField = () => screen.getByPlaceholderText('places.formNamePlaceholder') @@ -247,7 +249,7 @@ describe('MPlaceEditSheet', () => { it('FE-MOB-PLEDIT-020: shows the saving label and blocks the button while the save runs', async () => { let release: () => void = () => {} - const handleSavePlace = vi.fn(() => new Promise(resolve => { release = resolve })) + const handleSavePlace = vi.fn(() => new Promise<{ id: number }>(resolve => { release = () => resolve({ id: 1 }) })) setup({ handleSavePlace }) fireEvent.change(nameField(), { target: { value: 'Nakamise' } }) fireEvent.click(submit()) @@ -390,4 +392,50 @@ describe('MPlaceEditSheet', () => { it('FE-MOB-PLEDIT-032: sends no bias for a trip without any located place', async () => { expect(await capturedBias([])).toBeUndefined() }) + + // ── Linked expense (#1298) — the same block the booking sheet has ────────── + + describe('Costs', () => { + const withBudget = () => useAddonStore.setState({ + addons: [{ id: 'budget', name: 'Budget', type: 'budget', icon: '', enabled: true }] as never, + loaded: true, + }) + + it('FE-MOB-PLEDIT-033: the button only appears while the Budget addon is on', () => { + const { unmount } = setup() + expect(screen.queryByRole('button', { name: 'reservations.createExpense' })).not.toBeInTheDocument() + unmount() + + withBudget() + setup() + expect(screen.getByRole('button', { name: 'reservations.createExpense' })).toBeInTheDocument() + }) + + it('FE-MOB-PLEDIT-034: creating an expense saves the place first, then opens the editor', async () => { + withBudget() + const handleSavePlace = vi.fn(async () => ({ id: 42 })) + const { onOpenExpense } = setup({ handleSavePlace }) + + fireEvent.change(nameField(), { target: { value: 'Louvre' } }) + fireEvent.click(screen.getByRole('button', { name: 'reservations.createExpense' })) + + await waitFor(() => expect(onOpenExpense).toHaveBeenCalled()) + expect(handleSavePlace.mock.invocationCallOrder[0]).toBeLessThan(onOpenExpense.mock.invocationCallOrder[0]) + expect(onOpenExpense).toHaveBeenCalledWith({ + prefill: { placeId: 42, name: 'Louvre', category: 'activities' }, + }) + }) + + it('FE-MOB-PLEDIT-035: a save that yields no id opens no editor', async () => { + withBudget() + const handleSavePlace = vi.fn(async () => undefined) + const { onOpenExpense } = setup({ handleSavePlace }) + + fireEvent.change(nameField(), { target: { value: 'Louvre' } }) + fireEvent.click(screen.getByRole('button', { name: 'reservations.createExpense' })) + + await waitFor(() => expect(handleSavePlace).toHaveBeenCalled()) + expect(onOpenExpense).not.toHaveBeenCalled() + }) + }) }) diff --git a/server/src/db/migrations.ts b/server/src/db/migrations.ts index a79cf90cf..dfa0b72c9 100644 --- a/server/src/db/migrations.ts +++ b/server/src/db/migrations.ts @@ -3879,6 +3879,18 @@ function runMigrations(db: Database.Database): void { if (!err.message?.includes('duplicate column name')) throw err; } }, + // #1298 — an expense can hang off a place, exactly as it already hangs off a + // reservation. Same nullable FK, same ON DELETE SET NULL: the delete paths + // take the linked expense with the place themselves, so the constraint is a + // backstop for anything that removes a place without going through them. + // Appended LAST: the array is index-addressed against schema_version, so a + // slot inserted above this line never runs on an existing database. + () => { + const cols = db.prepare("SELECT name FROM pragma_table_info('budget_items')").all() as Array<{ name: string }>; + if (!cols.some(c => c.name === 'place_id')) { + db.exec('ALTER TABLE budget_items ADD COLUMN place_id INTEGER REFERENCES places(id) ON DELETE SET NULL DEFAULT NULL'); + } + }, ]; if (currentVersion < migrations.length) { diff --git a/server/src/nest/budget/budget.service.ts b/server/src/nest/budget/budget.service.ts index 6eb67db8c..d3d6eba31 100644 --- a/server/src/nest/budget/budget.service.ts +++ b/server/src/nest/budget/budget.service.ts @@ -301,6 +301,7 @@ export class BudgetService { persons?: number | null; days?: number | null; note?: string | null; expense_date?: string | null; ticket_json?: string | null; reservation_id?: number | null; + place_id?: number | null; }, ) { return this.db.transaction(() => { @@ -330,7 +331,7 @@ export class BudgetService { const { note, ticket } = splitLegacyTicketNote(data.note, data.ticket_json); const result = this.db.run( - 'INSERT INTO budget_items (trip_id, category, name, total_price, currency, exchange_rate, persons, days, note, ticket_json, sort_order, expense_date, reservation_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', + 'INSERT INTO budget_items (trip_id, category, name, total_price, currency, exchange_rate, persons, days, note, ticket_json, sort_order, expense_date, reservation_id, place_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', tripId, cat, data.name, @@ -344,6 +345,7 @@ export class BudgetService { sortOrder, data.expense_date || null, data.reservation_id != null ? data.reservation_id : null, + data.place_id != null ? data.place_id : null, ); const itemId = result.lastInsertRowid as number; diff --git a/server/src/nest/places/places.controller.ts b/server/src/nest/places/places.controller.ts index 23ccf7f8e..9d13543db 100644 --- a/server/src/nest/places/places.controller.ts +++ b/server/src/nest/places/places.controller.ts @@ -287,11 +287,17 @@ export class PlacesController { // journey entries even though removeMany below refuses it. Still ahead of // the DELETE — journey_entries.source_place_id is ON DELETE SET NULL, so // afterwards there is nothing left to detach. - for (const id of this.places.scopedIds(tripId, ids)) this.places.onDeleted(id); + const scoped = this.places.scopedIds(tripId, ids); + for (const id of scoped) this.places.onDeleted(id); + // Read the linked expenses before the delete — afterwards the link is gone (#1298). + const expenseIds = this.places.linkedExpenseIds(tripId, scoped); const deleted = this.places.removeMany(tripId, ids); for (const id of deleted) { this.places.broadcast(tripId, 'place:deleted', { placeId: id }, socketId); } + for (const itemId of expenseIds) { + this.places.broadcast(tripId, 'budget:deleted', { itemId }, socketId); + } return { deleted, count: deleted.length }; } @@ -448,10 +454,14 @@ export class PlacesController { throw new HttpException({ error: 'Place not found' }, 404); } this.places.onDeleted(Number(id)); + const expenseIds = this.places.linkedExpenseIds(tripId, [id]); if (!this.places.remove(tripId, id)) { throw new HttpException({ error: 'Place not found' }, 404); } this.places.broadcast(tripId, 'place:deleted', { placeId: Number(id) }, socketId); + for (const itemId of expenseIds) { + this.places.broadcast(tripId, 'budget:deleted', { itemId }, socketId); + } return { success: true }; } } diff --git a/server/src/nest/places/places.mcp.ts b/server/src/nest/places/places.mcp.ts index 79a3a943b..39c88127f 100644 --- a/server/src/nest/places/places.mcp.ts +++ b/server/src/nest/places/places.mcp.ts @@ -232,9 +232,12 @@ export class PlacesMcp { return { content: [{ type: 'text' as const, text: 'Place not found.' }], isError: true }; } try { this.journey.onPlaceDeleted(placeId); } catch { /* non-fatal */ } // sync journeys before the row is gone + // The link is gone once the place is, so read it first (#1298). + const expenseIds = this.places.linkedExpenseIds(tripId, [placeId]); const deleted = this.places.remove(String(tripId), String(placeId)); if (!deleted) return { content: [{ type: 'text' as const, text: 'Place not found.' }], isError: true }; this.guards.safeBroadcast(tripId, 'place:deleted', { placeId }); + for (const itemId of expenseIds) this.guards.safeBroadcast(tripId, 'budget:deleted', { itemId }); return ok({ success: true }); } @@ -337,11 +340,15 @@ export class PlacesMcp { // Trip-scoped, and ahead of the DELETE: journey_entries.source_place_id is // ON DELETE SET NULL, so a hook that ran afterwards found nothing left to // detach and left the entries as orphans. - for (const id of this.places.scopedIds(String(tripId), placeIds)) { + const scoped = this.places.scopedIds(String(tripId), placeIds); + for (const id of scoped) { try { this.journey.onPlaceDeleted(id); } catch { /* non-fatal */ } } + // The link is gone once the places are, so read it first (#1298). + const expenseIds = this.places.linkedExpenseIds(tripId, scoped); const deleted = this.places.removeMany(String(tripId), placeIds); for (const id of deleted) this.guards.safeBroadcast(tripId, 'place:deleted', { placeId: id }); + for (const itemId of expenseIds) this.guards.safeBroadcast(tripId, 'budget:deleted', { itemId }); return ok({ deleted, count: deleted.length }); } diff --git a/server/src/nest/places/places.rpc.ts b/server/src/nest/places/places.rpc.ts index b5474973c..3f61da812 100644 --- a/server/src/nest/places/places.rpc.ts +++ b/server/src/nest/places/places.rpc.ts @@ -80,10 +80,13 @@ export class PlacesRpc { // journey_entries.source_place_id is ON DELETE SET NULL, so afterwards the hook // finds nothing left to detach and the entries linger as orphans. this.mirrorJourneys(() => this.journey.onPlaceDeleted(placeId)); + // The link is gone once the place is, so read it first (#1298). + const expenseIds = this.places.linkedExpenseIds(tripId, [placeId]); if (!this.places.remove(String(tripId), String(placeId))) { throw new ForbiddenResource(`no place ${placeId} on trip ${tripId}`); } this.realtime.broadcast(tripId, 'place:deleted', { placeId }); + for (const itemId of expenseIds) this.realtime.broadcast(tripId, 'budget:deleted', { itemId }); return { deleted: true }; } diff --git a/server/src/nest/places/places.service.ts b/server/src/nest/places/places.service.ts index 26cdf9b8f..ceef2576d 100644 --- a/server/src/nest/places/places.service.ts +++ b/server/src/nest/places/places.service.ts @@ -343,12 +343,32 @@ export class PlacesService { // Delete place // ------------------------------------------------------------------------- + /** + * The expenses hanging off these places (#1298), so a caller can broadcast the + * budget:deleted events for the rows remove()/removeMany() are about to take + * with them. Read it BEFORE deleting — afterwards the link is gone. + */ + linkedExpenseIds(tripId: string | number, placeIds: Array): number[] { + if (placeIds.length === 0) return []; + const rows = this.dbs.all<{ id: number }>( + `SELECT id FROM budget_items WHERE trip_id = ? AND place_id IN (${placeIds.map(() => '?').join(',')})`, + tripId, ...placeIds, + ); + return rows.map(r => r.id); + } + remove(tripId: string, placeId: string): boolean { const place = this.dbs.get<{ google_place_id: string | null; image_url: string | null }>( 'SELECT google_place_id, image_url FROM places WHERE id = ? AND trip_id = ?', placeId, tripId, ); if (!place) return false; - this.dbs.run('DELETE FROM places WHERE id = ?', placeId); + // The linked expense goes with the place, the same way a booking takes its + // expense with it (#1298). One transaction, so a place can never survive + // half-detached from its money. + this.dbs.transaction(() => { + this.dbs.run('DELETE FROM budget_items WHERE trip_id = ? AND place_id = ?', tripId, placeId); + this.dbs.run('DELETE FROM places WHERE id = ?', placeId); + }); reclaimPhotoCache(this.photoCache, place.google_place_id, place.image_url); reclaimPlaceImage(place.image_url); return true; @@ -358,12 +378,14 @@ export class PlacesService { if (ids.length === 0) return []; const selectStmt = this.dbs.prepare('SELECT google_place_id, image_url FROM places WHERE id = ? AND trip_id = ?'); const deleteStmt = this.dbs.prepare('DELETE FROM places WHERE id = ?'); + const deleteExpenseStmt = this.dbs.prepare('DELETE FROM budget_items WHERE trip_id = ? AND place_id = ?'); const deleted: number[] = []; const reclaimable: { google_place_id: string | null; image_url: string | null }[] = []; this.dbs.transaction(() => { for (const id of ids) { const row = selectStmt.get(id, tripId) as { google_place_id: string | null; image_url: string | null } | undefined; if (!row) continue; + deleteExpenseStmt.run(tripId, id); deleteStmt.run(id); deleted.push(id); reclaimable.push(row); diff --git a/server/src/types.ts b/server/src/types.ts index 1f0f601ce..4187e4622 100644 --- a/server/src/types.ts +++ b/server/src/types.ts @@ -139,6 +139,9 @@ export interface BudgetItem { /** Itemized receipt for a per-item split, as JSON. Its own column since #1658. */ ticket_json?: string | null; reservation_id?: number | null; + /** Set when the expense was created from a place (#1298) — the other side of + * the same link reservation_id is for a booking. */ + place_id?: number | null; paid_by_user_id?: number | null; expense_date?: string | null; sort_order: number; diff --git a/server/tests/e2e/places.e2e.test.ts b/server/tests/e2e/places.e2e.test.ts index a85bb26ea..abaed821b 100644 --- a/server/tests/e2e/places.e2e.test.ts +++ b/server/tests/e2e/places.e2e.test.ts @@ -47,6 +47,9 @@ const { db } = vi.hoisted(() => { tmp.exec(`CREATE TABLE collection_places (id INTEGER PRIMARY KEY AUTOINCREMENT, image_url TEXT);`); // reclaimPhotoCache's removeIfUnreferenced sweeps the Google photo cache. tmp.exec(`CREATE TABLE google_place_photo_meta (place_id TEXT PRIMARY KEY, attribution TEXT, error_at DATETIME);`); + // Deleting a place takes its linked expense with it (#1298). + tmp.exec(`CREATE TABLE budget_items (id INTEGER PRIMARY KEY AUTOINCREMENT, trip_id INTEGER NOT NULL, + name TEXT, total_price REAL DEFAULT 0, place_id INTEGER, reservation_id INTEGER);`); return { db: tmp }; }); @@ -260,6 +263,17 @@ describe('Places e2e (real auth guard + temp SQLite)', () => { expect(foreign.body).toEqual({ error: 'Place not found' }); }); + it('DELETE :id takes the expense linked to the place with it (#1298)', async () => { + db.prepare("INSERT INTO places (id, trip_id, name) VALUES (12, 5, 'Louvre')").run(); + db.prepare("INSERT INTO budget_items (id, trip_id, name, total_price, place_id) VALUES (44, 5, 'Tickets', 34, 12)").run(); + db.prepare("INSERT INTO budget_items (id, trip_id, name, total_price) VALUES (45, 5, 'Coffee', 3)").run(); + + const res = await request(server).delete('/api/trips/5/places/12').set('Cookie', sessionCookie(1)); + + expect(res.status).toBe(200); + expect(db.prepare('SELECT id FROM budget_items ORDER BY id').all()).toEqual([{ id: 45 }]); + }); + it('404 trip when not accessible', async () => { canAccessTrip.mockReturnValue(undefined); const res = await request(server).get('/api/trips/5/places').set('Cookie', sessionCookie(1)); diff --git a/server/tests/integration/leg-mode-incoming.test.ts b/server/tests/integration/leg-mode-incoming.test.ts index 1e2adcad7..4fb2739f6 100644 --- a/server/tests/integration/leg-mode-incoming.test.ts +++ b/server/tests/integration/leg-mode-incoming.test.ts @@ -34,23 +34,32 @@ describe('incoming_leg_transport_mode migration', () => { }); // The case above starts from an empty database and runs every migration, so it - // passes wherever this one sits in the array. Existing installs replay only the - // slots above their schema_version, which is why the migration has to be the - // trailing entry: from anywhere but the end it is silently skipped on upgrade. - it('still lands on a database that already holds every earlier migration', () => { + // passes wherever a migration sits in the array. + it('the trailing migration lands on a database that is one version behind', () => { + // What actually has to hold: existing installs replay only the slots above + // their schema_version, so a migration inserted mid-array is silently + // skipped on every one of them. Undoing what the LAST migration did and + // rewinding one version is the only way to prove it still runs. + // + // >>> Appending a migration? Re-point the DROP below at the column yours + // >>> adds. That is the whole maintenance cost of this guard. + const TRAILING_TABLE = 'budget_items'; + const TRAILING_COLUMN = 'place_id'; + const upgraded = new Database(':memory:'); upgraded.exec('PRAGMA foreign_keys = ON'); createTables(upgraded); runMigrations(upgraded); const { version } = upgraded.prepare('SELECT version FROM schema_version').get() as { version: number }; - upgraded.exec('ALTER TABLE day_assignments DROP COLUMN incoming_leg_transport_mode'); + upgraded.exec(`ALTER TABLE ${TRAILING_TABLE} DROP COLUMN ${TRAILING_COLUMN}`); upgraded.prepare('UPDATE schema_version SET version = ?').run(version - 1); runMigrations(upgraded); - const cols = upgraded.prepare(`PRAGMA table_info(day_assignments)`).all() as { name: string }[]; - expect(cols.map(c => c.name)).toContain('incoming_leg_transport_mode'); + const cols = upgraded.prepare(`PRAGMA table_info(${TRAILING_TABLE})`).all() as { name: string }[]; + expect(cols.map(c => c.name)).toContain(TRAILING_COLUMN); + expect(upgraded.prepare('SELECT version FROM schema_version').get()).toEqual({ version }); upgraded.close(); }); }); diff --git a/server/tests/unit/mcp/tools-places.test.ts b/server/tests/unit/mcp/tools-places.test.ts index d8b5b4aa2..e34071447 100644 --- a/server/tests/unit/mcp/tools-places.test.ts +++ b/server/tests/unit/mcp/tools-places.test.ts @@ -291,6 +291,21 @@ describe('Tool: delete_place', () => { }); }); + it('takes the linked expense with it and announces that too (#1298)', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const place = createPlace(testDb, trip.id); + const itemId = Number(testDb + .prepare("INSERT INTO budget_items (trip_id, name, total_price, place_id) VALUES (?, 'Tickets', 34, ?)") + .run(trip.id, place.id).lastInsertRowid); + + await withHarness(user.id, async (h) => { + await h.client.callTool({ name: 'delete_place', arguments: { tripId: trip.id, placeId: place.id } }); + expect(testDb.prepare('SELECT id FROM budget_items WHERE id = ?').get(itemId)).toBeUndefined(); + expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'budget:deleted', expect.objectContaining({ itemId })); + }); + }); + it('broadcasts place:deleted event', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); diff --git a/server/tests/unit/nest/budget.service.db.test.ts b/server/tests/unit/nest/budget.service.db.test.ts index 2a6ad8445..6c363276b 100644 --- a/server/tests/unit/nest/budget.service.db.test.ts +++ b/server/tests/unit/nest/budget.service.db.test.ts @@ -452,6 +452,31 @@ describe('composite service paths (ex budget.bridge delegation)', () => { const row = testDb.prepare('SELECT reservation_id FROM budget_items WHERE id = ?').get(item.id) as { reservation_id: number | null }; expect(row.reservation_id).toBe(reservationId); }); + + it('BUDGET-SVC-DB-018b: an expense can be created against a place (#1298)', () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const placeId = Number(testDb + .prepare("INSERT INTO places (trip_id, name) VALUES (?, 'Louvre')") + .run(trip.id).lastInsertRowid); + + const item = budget.createBudgetItem(trip.id, { name: 'Louvre tickets', total_price: 34, place_id: placeId }); + + expect(item.place_id).toBe(placeId); + expect(item.reservation_id).toBeNull(); + const row = testDb.prepare('SELECT place_id FROM budget_items WHERE id = ?').get(item.id) as { place_id: number | null }; + expect(row.place_id).toBe(placeId); + }); + + it('BUDGET-SVC-DB-018c: an expense without a link stores neither id', () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + + const item = budget.createBudgetItem(trip.id, { name: 'Coffee', total_price: 3 }); + + expect(item.place_id).toBeNull(); + expect(item.reservation_id).toBeNull(); + }); }); describe('post-fold quirk fixes', () => { diff --git a/server/tests/unit/nest/places.controller.test.ts b/server/tests/unit/nest/places.controller.test.ts index 973f798c4..12f4af731 100644 --- a/server/tests/unit/nest/places.controller.test.ts +++ b/server/tests/unit/nest/places.controller.test.ts @@ -15,6 +15,7 @@ function svc(o: Partial = {}): PlacesService { // Trip-scoping reads the delete paths run before firing the journey hook // (#1745); default to "everything belongs to the trip". get: vi.fn().mockReturnValue({ id: 9 }), scopedIds: vi.fn((_t: string, ids: number[]) => ids), + linkedExpenseIds: vi.fn().mockReturnValue([]), ...o, } as unknown as PlacesService; } @@ -208,6 +209,19 @@ describe('PlacesController (parity with the legacy /api/trips/:tripId/places rou expect(onDeleted).toHaveBeenCalledWith(1); expect(onDeleted.mock.invocationCallOrder[0]).toBeLessThan(removeMany.mock.invocationCallOrder[0]); }); + + // #1298: the link is gone once the place is, so the ids have to be read first. + it('announces the expenses the deleted places took with them', () => { + const removeMany = vi.fn().mockReturnValue([1, 2]); + const linkedExpenseIds = vi.fn().mockReturnValue([77]); + const broadcast = vi.fn(); + const s = svc({ removeMany, linkedExpenseIds, broadcast } as Partial); + new PlacesController(s, new RuntimeEnvService()).bulkDelete(user, '5', { ids: [1, 2] }, 'sock'); + + expect(linkedExpenseIds).toHaveBeenCalledWith('5', [1, 2]); + expect(linkedExpenseIds.mock.invocationCallOrder[0]).toBeLessThan(removeMany.mock.invocationCallOrder[0]); + expect(broadcast).toHaveBeenCalledWith('5', 'budget:deleted', { itemId: 77 }, 'sock'); + }); }); describe('POST /bulk-update', () => { diff --git a/server/tests/unit/nest/places.service.test.ts b/server/tests/unit/nest/places.service.test.ts index 77bde3de3..e98decef5 100644 --- a/server/tests/unit/nest/places.service.test.ts +++ b/server/tests/unit/nest/places.service.test.ts @@ -393,6 +393,51 @@ describe('remove', () => { expect(remaining[0].id).toBe(p1.id); }); + it('PLACE-SVC-019c — the linked expense goes with the place (#1298)', () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const place = createPlace(testDb, trip.id, { name: 'Louvre' }) as any; + const other = createPlace(testDb, trip.id, { name: 'Orsay', lat: 48.86, lng: 2.3266 }) as any; + const linked = Number(testDb.prepare("INSERT INTO budget_items (trip_id, name, total_price, place_id) VALUES (?, 'Tickets', 34, ?)").run(trip.id, place.id).lastInsertRowid); + const untouched = Number(testDb.prepare("INSERT INTO budget_items (trip_id, name, total_price, place_id) VALUES (?, 'Other tickets', 12, ?)").run(trip.id, other.id).lastInsertRowid); + const standalone = Number(testDb.prepare("INSERT INTO budget_items (trip_id, name, total_price) VALUES (?, 'Coffee', 3)").run(trip.id).lastInsertRowid); + + // Read the link before the delete — that is what the controller broadcasts. + expect(svc.linkedExpenseIds(trip.id, [place.id])).toEqual([linked]); + expect(svc.remove(String(trip.id), String(place.id))).toBe(true); + + const rows = testDb.prepare('SELECT id FROM budget_items ORDER BY id').all() as { id: number }[]; + expect(rows.map(r => r.id)).toEqual([untouched, standalone]); + }); + + it('PLACE-SVC-019d — removeMany takes the expense of every deleted place with it', () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const a = createPlace(testDb, trip.id, { name: 'A' }) as any; + const b = createPlace(testDb, trip.id, { name: 'B', lat: 48.86, lng: 2.3266 }) as any; + const keep = createPlace(testDb, trip.id, { name: 'C', lat: 48.87, lng: 2.34 }) as any; + for (const p of [a, b, keep]) { + testDb.prepare("INSERT INTO budget_items (trip_id, name, total_price, place_id) VALUES (?, 'x', 1, ?)").run(trip.id, p.id); + } + + expect(svc.linkedExpenseIds(trip.id, [a.id, b.id])).toHaveLength(2); + svc.removeMany(String(trip.id), [a.id, b.id]); + + const rows = testDb.prepare('SELECT place_id FROM budget_items').all() as { place_id: number }[]; + expect(rows.map(r => r.place_id)).toEqual([keep.id]); + }); + + it('PLACE-SVC-019e — linkedExpenseIds ignores places of another trip and an empty list', () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const other = createTrip(testDb, user.id); + const place = createPlace(testDb, other.id, { name: 'Elsewhere' }) as any; + testDb.prepare("INSERT INTO budget_items (trip_id, name, total_price, place_id) VALUES (?, 'x', 1, ?)").run(other.id, place.id); + + expect(svc.linkedExpenseIds(trip.id, [place.id])).toEqual([]); + expect(svc.linkedExpenseIds(trip.id, [])).toEqual([]); + }); + it('PLACE-SVC-019b — reclaims the photo cache for the deleted place', () => { removeIfUnreferencedSpy.mockClear(); const { user } = createUser(testDb); diff --git a/server/tests/unit/places/places.rpc.test.ts b/server/tests/unit/places/places.rpc.test.ts index c807d8232..95ec337c5 100644 --- a/server/tests/unit/places/places.rpc.test.ts +++ b/server/tests/unit/places/places.rpc.test.ts @@ -37,6 +37,7 @@ function build(opts: { canEdit?: boolean; journeyThrows?: boolean } = {}) { update: vi.fn((_t: string, id: string) => (id === '7' ? { id: 7, name: 'updated' } : null)), get: vi.fn((_t: string, id: string) => (id === '7' ? { id: 7 } : undefined)), remove: vi.fn((_t: string, id: string) => id === '7'), + linkedExpenseIds: vi.fn(() => []), }; const journey = { onPlaceCreated: vi.fn(() => { if (opts.journeyThrows) throw new Error('journey down'); }), diff --git a/server/tests/unit/plugins/rpc-fallbacks.test.ts b/server/tests/unit/plugins/rpc-fallbacks.test.ts index d5682dcb8..19bc9ebfa 100644 --- a/server/tests/unit/plugins/rpc-fallbacks.test.ts +++ b/server/tests/unit/plugins/rpc-fallbacks.test.ts @@ -107,7 +107,7 @@ describe('optional fields fall back rather than reaching the service as undefine describe('a service that says no becomes a refusal, not a crash', () => { it('FALLBACK-006 a place that will not delete is refused', async () => { const { guards } = guardsFor(); - const places = { get: vi.fn(() => ({ id: 7 })), remove: vi.fn(() => false), create: vi.fn(), update: vi.fn() } as never; + const places = { get: vi.fn(() => ({ id: 7 })), remove: vi.fn(() => false), create: vi.fn(), update: vi.fn(), linkedExpenseIds: vi.fn(() => []) } as never; const journey = { onPlaceDeleted: vi.fn(), onPlaceCreated: vi.fn(), onPlaceUpdated: vi.fn() } as never; const host = new PluginRpcHost('p', ALL, makeDeps(), createTestPluginRegistry([new PlacesRpc(places, journey, realtime(), guards)])); expect(err(await host.dispatch(req('places.delete', { tripId: 1, placeId: 7 }), 42)).message).toBe('no place 7 on trip 1'); diff --git a/shared/src/budget/budget.schema.ts b/shared/src/budget/budget.schema.ts index e6b4178b8..e2355a3d7 100644 --- a/shared/src/budget/budget.schema.ts +++ b/shared/src/budget/budget.schema.ts @@ -116,6 +116,9 @@ export const budgetItemSchema = z.object({ /** Itemized receipt behind a per-item split, as JSON. Its own column since #1658. */ ticket_json: z.string().nullable().optional(), reservation_id: z.number().nullable().optional(), + /** Set when the expense was created from a place (#1298) — the same link + * reservation_id is for a booking, on the other side of the planner. */ + place_id: z.number().nullable().optional(), paid_by_user_id: z.number().nullable().optional(), expense_date: z.string().nullable().optional(), sort_order: z.number().optional(), @@ -155,6 +158,9 @@ export const budgetCreateItemRequestSchema = z.object({ // 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(), + // The same for a place: the place form's "add expense" flow saves the place + // first, then creates the expense against it (#1298). + place_id: z.number().optional(), }); export type BudgetCreateItemRequest = z.infer; diff --git a/shared/src/i18n/ar/places.ts b/shared/src/i18n/ar/places.ts index 197457b30..18756faf0 100644 --- a/shared/src/i18n/ar/places.ts +++ b/shared/src/i18n/ar/places.ts @@ -115,6 +115,7 @@ const places: TranslationStrings = { 'places.categoryCreateError': 'فشل إنشاء الفئة', 'places.nameRequired': 'يرجى إدخال اسم', 'places.saveError': 'فشل الحفظ', + 'places.createExpenseHint': 'يحفظ المكان ثم يفتح محرر التكاليف.', 'places.duplicateExists': "'{name}' موجود بالفعل في هذه الرحلة.", 'places.addAnyway': 'الإضافة على أي حال', 'places.enrichOnImport': 'إثراء الأماكن عبر Google', diff --git a/shared/src/i18n/br/places.ts b/shared/src/i18n/br/places.ts index c92fd76aa..0db968490 100644 --- a/shared/src/i18n/br/places.ts +++ b/shared/src/i18n/br/places.ts @@ -115,6 +115,7 @@ const places: TranslationStrings = { 'places.categoryCreateError': 'Falha ao criar categoria', 'places.nameRequired': 'Digite um nome', 'places.saveError': 'Falha ao salvar', + 'places.createExpenseHint': 'Salva o local e depois abre o editor de despesas.', 'places.duplicateExists': "'{name}' já está nesta viagem.", 'places.addAnyway': 'Adicionar mesmo assim', 'places.enrichOnImport': 'Enriquecer lugares via Google', diff --git a/shared/src/i18n/ca/places.ts b/shared/src/i18n/ca/places.ts index 56d3280f1..fa38f3d7e 100644 --- a/shared/src/i18n/ca/places.ts +++ b/shared/src/i18n/ca/places.ts @@ -113,6 +113,7 @@ const places: TranslationStrings = { 'places.categoryCreateError': "No s'ha pogut crear la categoria", 'places.nameRequired': 'Introdueix un nom', 'places.saveError': "No s'ha pogut desar", + 'places.createExpenseHint': 'Desa el lloc i després obre l’editor de despeses.', 'places.changeCategory': 'Canviar categoria', 'places.categoryChanged': '{count} llocs actualitzats', diff --git a/shared/src/i18n/cs/places.ts b/shared/src/i18n/cs/places.ts index e8073085d..c971bc1e4 100644 --- a/shared/src/i18n/cs/places.ts +++ b/shared/src/i18n/cs/places.ts @@ -115,6 +115,7 @@ const places: TranslationStrings = { 'places.categoryCreateError': 'Nepodařilo se vytvořit kategorii', 'places.nameRequired': 'Prosím zadejte název', 'places.saveError': 'Uložení se nezdařilo', + 'places.createExpenseHint': 'Uloží místo a poté otevře editor nákladů.', 'places.duplicateExists': "'{name}' už v tomto výletu existuje.", 'places.addAnyway': 'Přesto přidat', 'places.enrichOnImport': 'Obohatit místa přes Google', diff --git a/shared/src/i18n/de/places.ts b/shared/src/i18n/de/places.ts index 46f655dd8..868b06b5b 100644 --- a/shared/src/i18n/de/places.ts +++ b/shared/src/i18n/de/places.ts @@ -115,6 +115,7 @@ const places: TranslationStrings = { 'places.categoryCreateError': 'Fehler beim Erstellen der Kategorie', 'places.nameRequired': 'Bitte einen Namen eingeben', 'places.saveError': 'Fehler beim Speichern', + 'places.createExpenseHint': 'Speichert den Ort und öffnet dann den Kosten-Editor.', 'places.duplicateExists': "'{name}' ist bereits in dieser Reise.", 'places.uploadImage': 'Bild hochladen', 'places.changeImage': 'Bild ändern', diff --git a/shared/src/i18n/en/places.ts b/shared/src/i18n/en/places.ts index 7795ee8a5..f37966208 100644 --- a/shared/src/i18n/en/places.ts +++ b/shared/src/i18n/en/places.ts @@ -115,6 +115,7 @@ const places: TranslationStrings = { 'places.categoryCreateError': 'Failed to create category', 'places.nameRequired': 'Please enter a name', 'places.saveError': 'Failed to save', + 'places.createExpenseHint': 'Saves the place, then opens the Costs editor.', 'places.duplicateExists': "'{name}' is already in this trip.", 'places.addAnyway': 'Add anyway', 'places.enrichOnImport': 'Enrich places via Google', diff --git a/shared/src/i18n/es/places.ts b/shared/src/i18n/es/places.ts index b405f2905..8c23d0b60 100644 --- a/shared/src/i18n/es/places.ts +++ b/shared/src/i18n/es/places.ts @@ -115,6 +115,7 @@ const places: TranslationStrings = { 'places.categoryCreateError': 'No se pudo crear la categoría', 'places.nameRequired': 'Introduce un nombre', 'places.saveError': 'No se pudo guardar', + 'places.createExpenseHint': 'Guarda el lugar y luego abre el editor de gastos.', 'places.duplicateExists': "'{name}' ya está en este viaje.", 'places.addAnyway': 'Añadir de todos modos', 'places.enrichOnImport': 'Enriquecer lugares con Google', diff --git a/shared/src/i18n/fr/places.ts b/shared/src/i18n/fr/places.ts index 1b1c71851..b98849abc 100644 --- a/shared/src/i18n/fr/places.ts +++ b/shared/src/i18n/fr/places.ts @@ -115,6 +115,7 @@ const places: TranslationStrings = { 'places.categoryCreateError': 'Impossible de créer la catégorie', 'places.nameRequired': 'Veuillez saisir un nom', 'places.saveError': "Échec de l'enregistrement", + 'places.createExpenseHint': 'Enregistre le lieu, puis ouvre l’éditeur de dépenses.', 'places.duplicateExists': "'{name}' est déjà dans ce voyage.", 'places.addAnyway': 'Ajouter quand même', 'places.enrichOnImport': 'Enrichir les lieux via Google', diff --git a/shared/src/i18n/gr/places.ts b/shared/src/i18n/gr/places.ts index d721161c2..8ac900e71 100644 --- a/shared/src/i18n/gr/places.ts +++ b/shared/src/i18n/gr/places.ts @@ -115,6 +115,7 @@ const places: TranslationStrings = { 'places.categoryCreateError': 'Αποτυχία δημιουργίας κατηγορίας', 'places.nameRequired': 'Παρακαλώ εισαγάγετε ένα όνομα', 'places.saveError': 'Αποτυχία αποθήκευσης', + 'places.createExpenseHint': 'Αποθηκεύει την τοποθεσία και ανοίγει τον επεξεργαστή εξόδων.', 'places.duplicateExists': "Το '{name}' υπάρχει ήδη σε αυτό το ταξίδι.", 'places.addAnyway': 'Προσθήκη ούτως ή άλλως', 'places.enrichOnImport': 'Εμπλουτισμός τόπων μέσω Google', diff --git a/shared/src/i18n/hu/places.ts b/shared/src/i18n/hu/places.ts index 3cb41240c..d90046f60 100644 --- a/shared/src/i18n/hu/places.ts +++ b/shared/src/i18n/hu/places.ts @@ -115,6 +115,7 @@ const places: TranslationStrings = { 'places.categoryCreateError': 'Nem sikerült létrehozni a kategóriát', 'places.nameRequired': 'Kérjük, adj meg egy nevet', 'places.saveError': 'Nem sikerült menteni', + 'places.createExpenseHint': 'Menti a helyet, majd megnyitja a költségszerkesztőt.', 'places.duplicateExists': "A(z) '{name}' már szerepel ebben az utazásban.", 'places.addAnyway': 'Hozzáadás mindenképp', 'places.enrichOnImport': 'Helyek gazdagítása a Google-lel', diff --git a/shared/src/i18n/id/places.ts b/shared/src/i18n/id/places.ts index ae57bb480..a3b3a9eb3 100644 --- a/shared/src/i18n/id/places.ts +++ b/shared/src/i18n/id/places.ts @@ -114,6 +114,7 @@ const places: TranslationStrings = { 'places.categoryCreateError': 'Gagal membuat kategori', 'places.nameRequired': 'Harap masukkan nama', 'places.saveError': 'Gagal menyimpan', + 'places.createExpenseHint': 'Menyimpan tempat, lalu membuka editor biaya.', 'places.duplicateExists': "'{name}' sudah ada di perjalanan ini.", 'places.addAnyway': 'Tetap tambahkan', 'places.enrichOnImport': 'Perkaya tempat via Google', diff --git a/shared/src/i18n/it/places.ts b/shared/src/i18n/it/places.ts index 812bb1272..7556a1b4b 100644 --- a/shared/src/i18n/it/places.ts +++ b/shared/src/i18n/it/places.ts @@ -115,6 +115,7 @@ const places: TranslationStrings = { 'places.categoryCreateError': 'Impossibile creare la categoria', 'places.nameRequired': 'Inserisci un nome', 'places.saveError': 'Impossibile salvare', + 'places.createExpenseHint': 'Salva il luogo e poi apre l’editor dei costi.', 'places.duplicateExists': "'{name}' è già in questo viaggio.", 'places.addAnyway': 'Aggiungi comunque', 'places.enrichOnImport': 'Arricchisci i luoghi con Google', diff --git a/shared/src/i18n/ja/places.ts b/shared/src/i18n/ja/places.ts index c358d1d1c..f09f7ee79 100644 --- a/shared/src/i18n/ja/places.ts +++ b/shared/src/i18n/ja/places.ts @@ -115,6 +115,7 @@ const places: TranslationStrings = { 'places.categoryCreateError': 'カテゴリの作成に失敗しました', 'places.nameRequired': '名前を入力してください', 'places.saveError': '保存に失敗しました', + 'places.createExpenseHint': '場所を保存してから費用エディタを開きます。', 'places.duplicateExists': '「{name}」はすでにこの旅程に含まれています。', 'places.addAnyway': 'それでも追加', 'places.enrichOnImport': 'Googleで場所を補完', diff --git a/shared/src/i18n/ko/places.ts b/shared/src/i18n/ko/places.ts index 03139024f..22b4385fe 100644 --- a/shared/src/i18n/ko/places.ts +++ b/shared/src/i18n/ko/places.ts @@ -114,6 +114,7 @@ const places: TranslationStrings = { 'places.categoryCreateError': '카테고리 생성 실패', 'places.nameRequired': '이름을 입력하세요', 'places.saveError': '저장 실패', + 'places.createExpenseHint': '장소를 저장한 뒤 비용 편집기를 엽니다.', 'places.duplicateExists': "'{name}'은(는) 이미 이 여행에 있습니다.", 'places.addAnyway': '그래도 추가', 'places.enrichOnImport': 'Google로 장소 정보 보강', diff --git a/shared/src/i18n/nl/places.ts b/shared/src/i18n/nl/places.ts index 6c0c6f008..f2f6f5c1a 100644 --- a/shared/src/i18n/nl/places.ts +++ b/shared/src/i18n/nl/places.ts @@ -115,6 +115,7 @@ const places: TranslationStrings = { 'places.categoryCreateError': 'Categorie aanmaken mislukt', 'places.nameRequired': 'Voer een naam in', 'places.saveError': 'Opslaan mislukt', + 'places.createExpenseHint': 'Slaat de plaats op en opent daarna de kosten-editor.', 'places.duplicateExists': "'{name}' staat al in deze reis.", 'places.addAnyway': 'Toch toevoegen', 'places.enrichOnImport': 'Plaatsen verrijken via Google', diff --git a/shared/src/i18n/pl/places.ts b/shared/src/i18n/pl/places.ts index 224be55ab..7a93196a0 100644 --- a/shared/src/i18n/pl/places.ts +++ b/shared/src/i18n/pl/places.ts @@ -106,6 +106,7 @@ const places: TranslationStrings = { 'places.categoryCreateError': 'Nie udało się utworzyć kategorii', 'places.nameRequired': 'Proszę podać nazwę', 'places.saveError': 'Nie udało się zapisać', + 'places.createExpenseHint': 'Zapisuje miejsce i otwiera edytor kosztów.', 'places.duplicateExists': "'{name}' jest już w tej podróży.", 'places.addAnyway': 'Dodaj mimo to', 'places.importNaverList': 'Lista Naver', diff --git a/shared/src/i18n/ru/places.ts b/shared/src/i18n/ru/places.ts index d154d1eb2..83740a7df 100644 --- a/shared/src/i18n/ru/places.ts +++ b/shared/src/i18n/ru/places.ts @@ -115,6 +115,7 @@ const places: TranslationStrings = { 'places.categoryCreateError': 'Не удалось создать категорию', 'places.nameRequired': 'Введите название', 'places.saveError': 'Ошибка сохранения', + 'places.createExpenseHint': 'Сохраняет место и открывает редактор расходов.', 'places.duplicateExists': "'{name}' уже есть в этой поездке.", 'places.addAnyway': 'Всё равно добавить', 'places.enrichOnImport': 'Обогатить места через Google', diff --git a/shared/src/i18n/sv/places.ts b/shared/src/i18n/sv/places.ts index 4e29e62c8..068a95815 100644 --- a/shared/src/i18n/sv/places.ts +++ b/shared/src/i18n/sv/places.ts @@ -115,6 +115,7 @@ const places: TranslationStrings = { 'places.categoryCreateError': 'Det gick inte att skapa kategorin', 'places.nameRequired': 'Ange ett namn', 'places.saveError': 'Det gick inte att spara', + 'places.createExpenseHint': 'Sparar platsen och öppnar sedan kostnadsredigeraren.', 'places.duplicateExists': "'{name}' ingår redan i resan.", 'places.addAnyway': 'Lägg till ändå', 'places.enrichOnImport': 'Berika platser via Google', diff --git a/shared/src/i18n/tr/places.ts b/shared/src/i18n/tr/places.ts index b6dc3cfe6..bb4e97a27 100644 --- a/shared/src/i18n/tr/places.ts +++ b/shared/src/i18n/tr/places.ts @@ -117,6 +117,7 @@ const places: TranslationStrings = { 'places.categoryCreateError': 'Kategori oluşturulamadı', 'places.nameRequired': 'Lütfen bir ad girin', 'places.saveError': 'Kaydedilemedi', + 'places.createExpenseHint': 'Yeri kaydeder, sonra masraf düzenleyicisini açar.', 'places.duplicateExists': "'{name}' zaten bu gezide var.", 'places.addAnyway': 'Yine de ekle', 'places.enrichOnImport': 'Yerleri Google ile zenginleştir', diff --git a/shared/src/i18n/uk/places.ts b/shared/src/i18n/uk/places.ts index efde4db2e..47af6cfe6 100644 --- a/shared/src/i18n/uk/places.ts +++ b/shared/src/i18n/uk/places.ts @@ -115,6 +115,7 @@ const places: TranslationStrings = { 'places.categoryCreateError': 'Не вдалося створити категорію', 'places.nameRequired': 'Введіть назву', 'places.saveError': 'Помилка збереження', + 'places.createExpenseHint': 'Зберігає місце й відкриває редактор витрат.', 'places.duplicateExists': "'{name}' вже є в цій подорожі.", 'places.addAnyway': 'Все одно додати', 'places.enrichOnImport': 'Збагатити місця через Google', diff --git a/shared/src/i18n/vi/places.ts b/shared/src/i18n/vi/places.ts index 6cbee89bf..aadcc18ec 100644 --- a/shared/src/i18n/vi/places.ts +++ b/shared/src/i18n/vi/places.ts @@ -115,6 +115,7 @@ const places: TranslationStrings = { 'places.categoryCreateError': 'Không tạo được danh mục', 'places.nameRequired': 'Vui lòng nhập tên', 'places.saveError': 'Không lưu được', + 'places.createExpenseHint': 'Lưu địa điểm rồi mở trình sửa chi phí.', 'places.duplicateExists': "'{name}' đã có trong chuyến đi này.", 'places.addAnyway': 'Vẫn thêm', 'places.enrichOnImport': 'Làm phong phú các địa điểm thông qua Google', diff --git a/shared/src/i18n/zh-TW/places.ts b/shared/src/i18n/zh-TW/places.ts index b05b4dfcd..a319d8081 100644 --- a/shared/src/i18n/zh-TW/places.ts +++ b/shared/src/i18n/zh-TW/places.ts @@ -113,6 +113,7 @@ const places: TranslationStrings = { 'places.categoryCreateError': '建立分類失敗', 'places.nameRequired': '請輸入名稱', 'places.saveError': '儲存失敗', + 'places.createExpenseHint': '先儲存地點,然後開啟費用編輯器。', 'places.duplicateExists': "'{name}' 已在此行程中。", 'places.addAnyway': '仍要新增', 'places.enrichOnImport': '透過 Google 豐富地點資訊', diff --git a/shared/src/i18n/zh/places.ts b/shared/src/i18n/zh/places.ts index acc141c99..02a7b6eb1 100644 --- a/shared/src/i18n/zh/places.ts +++ b/shared/src/i18n/zh/places.ts @@ -113,6 +113,7 @@ const places: TranslationStrings = { 'places.categoryCreateError': '创建分类失败', 'places.nameRequired': '请输入名称', 'places.saveError': '保存失败', + 'places.createExpenseHint': '先保存地点,然后打开费用编辑器。', 'places.duplicateExists': "'{name}' 已在此行程中。", 'places.addAnyway': '仍然添加', 'places.enrichOnImport': '通过 Google 丰富地点信息', diff --git a/wiki/Budget-Tracking.md b/wiki/Budget-Tracking.md index e70f4b89f..d4c522bae 100644 --- a/wiki/Budget-Tracking.md +++ b/wiki/Budget-Tracking.md @@ -59,6 +59,12 @@ Click any editable cell to edit it inline. Drag the grip handle to reorder items Add a new item using the inline **add row** at the bottom of each category table. +### Expenses linked to a booking or a place + +An expense can hang off a **booking** (reservation or transport) or off a **place** — both offer a **Create expense** button in their form, which saves the record first and then opens the expense editor for it. A linked expense is an ordinary expense: it takes a payer, a split, a date and a currency like any other, and it shows up in the settlement. + +Deleting the booking or the place deletes its linked expense with it. Removing the expense from the record's Costs block deletes only the expense and leaves the record standing. + ## Splitting costs The **Persons** column behaves differently depending on the trip: diff --git a/wiki/Places-and-Search.md b/wiki/Places-and-Search.md index 87016c791..77cfc76ec 100644 --- a/wiki/Places-and-Search.md +++ b/wiki/Places-and-Search.md @@ -79,6 +79,14 @@ Type or paste a `lat, lng` pair (e.g. `48.8566, 2.3522`) into the **Latitude** f Two inline warnings are shown when editing times: one if the end time is set to a value before or equal to the start time, and one if the times overlap with another place already assigned to the same day. +## Costs for a place + +With the [Costs/Budget addon](Budget-Tracking) enabled, the place form carries the same **Costs** block that bookings and transports have. **Create expense** saves the place and then opens the Costs editor for a new expense linked to it — the museum ticket, the guided tour, the entry fee. Once linked, the block shows that expense with edit and remove actions. + +The expense belongs to the **place**, not to a day. Putting the same place on several days does not multiply it: you bought the ticket once. If you really pay each time, add a second expense from the Costs tab. + +Deleting the place deletes its linked expense too, the same way deleting a booking does. + ## Custom place image By default a place's thumbnail is fetched automatically (from Google/OpenStreetMap when the place was imported or matched, otherwise a category icon). To use your own photo instead, open the place's detail panel and click its round thumbnail — pick an image and it becomes that place's thumbnail everywhere (list, map marker, itinerary, PDF export and shared trips). A small remove button on the thumbnail clears the custom image and restores the automatic default. Accepted formats are JPG, PNG, GIF and WebP (HEIC is converted automatically), up to 20 MB. diff --git a/wiki/Reservations-and-Bookings.md b/wiki/Reservations-and-Bookings.md index e8b09981c..e12801c8f 100644 --- a/wiki/Reservations-and-Bookings.md +++ b/wiki/Reservations-and-Bookings.md @@ -73,7 +73,7 @@ Click **Add** (or the + button) in the Reservations panel. Fill in the form: 9. **Hotel-specific fields** — shown only for Hotel type, immediately after status: hotel place, check-in day, check-out day, check-in time (window start and end), and check-out time. See [Accommodations](Accommodations) 10. **Notes** 11. **Files** — attach from your device (PDF, Word documents, text files, images) or link an existing trip file. Files added before saving are uploaded automatically after the reservation is created -12. **Price and budget category** — shown only when the Budget addon is enabled. Entering a price greater than zero automatically creates a linked budget entry. See [Budget-Tracking](Budget-Tracking) +12. **Costs** — shown only when the Budget addon is enabled. Instead of a price field, the form carries a **Create expense** button: it saves the booking and then opens the Costs editor for a new expense linked to it, so the expense gets a payer, a split and a date like any other. Once linked, the block shows that expense with edit and remove actions. See [Budget-Tracking](Budget-Tracking)