From cfdbfb49c0720c8e937d09425da08364b196b9d7 Mon Sep 17 00:00:00 2001 From: Maurice Date: Mon, 12 Oct 2026 18:14:30 +0200 Subject: [PATCH] fix(costs): expenses and payments saved offline wait in the queue and sync once the connection is back Every Costs write went straight to the API, so offline an expense failed with "Unknown error" and was lost. Expenses (add, edit, delete, split, paid tick, reorder) now go through budgetRepo and the mutation queue like places and packing items: a temporary row offline, held behind an older queued write online. The replay reads the expense from `item` only, so the split answer ({ members, item }) keeps the local payers and the paid answer ({ member }) never lands in the expense table. An expense linked to a place or booking created offline is sent with the real id once that one has synced. Payments are queued without a local row and a hint says they show once sent; the ledger reads the balances again when the queue is through, and offline it shows the last balances it read (new Dexie table, v10). Receipts need an upload, so an offline save with new ones is stopped with a hint. Refs #2601 --- client/scripts/offline-baseline.json | 2 - .../components/Budget/useCostsLedger.test.ts | 37 ++- .../src/components/Budget/useCostsLedger.ts | 43 ++- .../components/Budget/useExpenseForm.test.ts | 25 ++ .../src/components/Budget/useExpenseForm.ts | 7 + .../Budget/useSettlementForm.test.ts | 30 +- .../components/Budget/useSettlementForm.ts | 10 +- client/src/db/offlineDb.ts | 17 + .../mobile/screens/trip/tabs/MCostsTab.tsx | 2 +- client/src/repo/budgetRepo.offline.test.ts | 237 ++++++++++++++ client/src/repo/budgetRepo.ts | 300 +++++++++++++++++- client/src/store/slices/budgetSlice.ts | 21 +- client/src/sync/mutationQueue.ts | 59 +++- .../tests/unit/db/offlineDb.upgrade.test.ts | 10 + client/tests/unit/slices/budgetSlice.test.ts | 28 ++ .../unit/sync/mutationQueue.budget.test.ts | 132 ++++++++ shared/src/i18n/ar/budget.ts | 3 + shared/src/i18n/az/budget.ts | 3 + shared/src/i18n/br/budget.ts | 3 + shared/src/i18n/ca/budget.ts | 3 + shared/src/i18n/cs/budget.ts | 3 + shared/src/i18n/de/budget.ts | 3 + shared/src/i18n/en/budget.ts | 3 + shared/src/i18n/es/budget.ts | 3 + shared/src/i18n/et/budget.ts | 3 + shared/src/i18n/fr/budget.ts | 3 + shared/src/i18n/gr/budget.ts | 3 + shared/src/i18n/hu/budget.ts | 3 + shared/src/i18n/id/budget.ts | 3 + shared/src/i18n/it/budget.ts | 3 + shared/src/i18n/ja/budget.ts | 3 + shared/src/i18n/ko/budget.ts | 3 + shared/src/i18n/nl/budget.ts | 3 + shared/src/i18n/pl/budget.ts | 3 + shared/src/i18n/ru/budget.ts | 3 + shared/src/i18n/sk/budget.ts | 3 + shared/src/i18n/sv/budget.ts | 3 + shared/src/i18n/th/budget.ts | 3 + shared/src/i18n/tr/budget.ts | 3 + shared/src/i18n/uk/budget.ts | 3 + shared/src/i18n/vi/budget.ts | 3 + shared/src/i18n/zh-TW/budget.ts | 3 + shared/src/i18n/zh/budget.ts | 3 + 43 files changed, 1006 insertions(+), 35 deletions(-) create mode 100644 client/src/repo/budgetRepo.offline.test.ts create mode 100644 client/tests/unit/sync/mutationQueue.budget.test.ts diff --git a/client/scripts/offline-baseline.json b/client/scripts/offline-baseline.json index 1b27ea839..08f94545a 100644 --- a/client/scripts/offline-baseline.json +++ b/client/scripts/offline-baseline.json @@ -23,8 +23,6 @@ "src/components/BackgroundTasks/BackgroundTasksWidget.tsx", "src/components/Budget/ReceiptPreviewModal.tsx", "src/components/Budget/receiptUploads.ts", - "src/components/Budget/useCostsLedger.ts", - "src/components/Budget/useSettlementForm.ts", "src/components/Collab/CollabChatLinkPreview.tsx", "src/components/Collab/CollabLinks.tsx", "src/components/Collab/CollabNotes.tsx", diff --git a/client/src/components/Budget/useCostsLedger.test.ts b/client/src/components/Budget/useCostsLedger.test.ts index 880d114b2..3d558cdc3 100644 --- a/client/src/components/Budget/useCostsLedger.test.ts +++ b/client/src/components/Budget/useCostsLedger.test.ts @@ -4,8 +4,10 @@ import { beforeEach, describe, expect, it, vi, type Mock } from 'vitest'; import { buildBudgetItem, buildTrip } from '../../../tests/helpers/factories'; import { resetAllStores, seedStore } from '../../../tests/helpers/store'; import { budgetApi } from '../../api/client'; +import { clearAll, offlineDb } from '../../db/offlineDb'; import { useAuthStore } from '../../store/authStore'; import { useSettingsStore } from '../../store/settingsStore'; +import { setForcedOffline } from '../../sync/networkMode'; import type { BudgetItem, Trip } from '../../types'; import { downloadBlob } from '../../utils/fileDownload'; import { localToday } from '../Planner/today'; @@ -13,7 +15,7 @@ import type { CostsSettlementResponse } from './costsModel'; import { useCostsLedger } from './useCostsLedger'; import { useFreezeMissingRates } from './useFreezeMissingRates'; -// FE-BUDGET-LEDGER-001 to FE-BUDGET-LEDGER-012 +// FE-BUDGET-LEDGER-001 to FE-BUDGET-LEDGER-013 const fx = vi.hoisted(() => ({ // One stable converter, the way useExchangeRates hands out a memoised one: USD is half a euro. @@ -74,6 +76,7 @@ const paidByBob = () => let loadBudgetItems: Mock<(tripId: number | string) => Promise>; let deleteBudgetItem: Mock<(tripId: number | string, id: number) => Promise>; let toastError: Mock<(message: string) => void>; +let toastInfo: Mock<(message: string) => void>; interface Options { trip?: Trip; @@ -94,7 +97,7 @@ function setup(options: Options = {}) { actions: { loadBudgetItems, deleteBudgetItem }, canEdit: true, t: (key: string) => key, - toast: { error: toastError }, + toast: { error: toastError, info: toastInfo }, settlementOnTripChange: options.settlementOnTripChange, skipUnfinishedShares: options.skipUnfinishedShares, csvCurrencyAsStored: options.csvCurrencyAsStored, @@ -115,6 +118,7 @@ beforeEach(() => { loadBudgetItems = vi.fn<(tripId: number | string) => Promise>(async () => undefined); deleteBudgetItem = vi.fn<(tripId: number | string, id: number) => Promise>(async () => undefined); toastError = vi.fn<(message: string) => void>(); + toastInfo = vi.fn<(message: string) => void>(); settlementMock().mockResolvedValue(SETTLEMENT); createMock().mockResolvedValue({}); deleteMock().mockResolvedValue({}); @@ -317,3 +321,32 @@ describe('useCostsLedger: writes', () => { expect(await readCsv(true)).toBe('usd'); }); }); + +describe('useCostsLedger: offline (#2601)', () => { + it('FE-BUDGET-LEDGER-013: an offline settle waits in the queue with a hint, and the balances are read again once it is sent', async () => { + await clearAll(); + const { result } = setup(); + await waitFor(() => expect(result.current.settlement).toEqual(SETTLEMENT)); + act(() => setForcedOffline(true)); + try { + await act(async () => { + await result.current.settleFlow(1, 2, 15); + }); + expect(createMock()).not.toHaveBeenCalled(); + expect(toastError).not.toHaveBeenCalled(); + expect(toastInfo).toHaveBeenCalledWith('costs.paymentQueued'); + expect(await offlineDb.mutationQueue.count()).toBe(1); + // Offline the last balances read stand in, so the ledger keeps showing them. + expect(result.current.settlementError).toBe(false); + expect(result.current.settlement).toEqual(SETTLEMENT); + } finally { + act(() => setForcedOffline(false)); + } + // Back online with the payment still queued: no read until the queue is through. + const before = settlementMock().mock.calls.length; + await act(async () => { + await offlineDb.mutationQueue.clear(); + }); + await waitFor(() => expect(settlementMock().mock.calls.length).toBeGreaterThan(before)); + }); +}); diff --git a/client/src/components/Budget/useCostsLedger.ts b/client/src/components/Budget/useCostsLedger.ts index cb4cc8d4a..6b4e48152 100644 --- a/client/src/components/Budget/useCostsLedger.ts +++ b/client/src/components/Budget/useCostsLedger.ts @@ -1,8 +1,10 @@ +import { liveQuery } from 'dexie'; import { useCallback, useEffect, useEffectEvent, useMemo, useState } from 'react'; -import { budgetApi } from '../../api/client'; import { useExchangeRates, withFallbackFx } from '../../hooks/useExchangeRates'; +import { useNetworkMode } from '../../hooks/useNetworkMode'; import { useTranslation } from '../../i18n'; +import { budgetRepo, type SettlementWriteResult } from '../../repo/budgetRepo'; import { useAuthStore } from '../../store/authStore'; import { useSettingsStore } from '../../store/settingsStore'; import type { TripStoreState } from '../../store/tripStore'; @@ -27,6 +29,7 @@ import { useFreezeMissingRates } from './useFreezeMissingRates'; type Translate = (key: string, params?: Record) => string; interface Toaster { error: (message: string) => void; + info: (message: string) => void; } /** @@ -89,7 +92,7 @@ export function useCostsLedger({ // The browser's own figure for the display currency goes along, for the server to // answer in it when it cannot fetch a quote itself. const loadSettlement = useCallback(() => { - budgetApi + budgetRepo .settlement(tripId, base, base !== tripCurrency ? displayPerTrip : null) .then((s) => { setSettlement(s); @@ -110,9 +113,30 @@ export function useCostsLedger({ useEffect(() => { onTripChange(); }, [tripId, loadBudgetItems]); + // Offline the balances are the last ones the server worked out, and an expense or + // payment saved meanwhile waits in the queue. Read again once the connection is + // back and the queue holds no Costs write any more, so they count. + const { offline } = useNetworkMode(); + const [queuedWrites, setQueuedWrites] = useState(0); + useEffect(() => { + const subscription = liveQuery(() => budgetRepo.pendingWrites(tripId)).subscribe({ + next: setQueuedWrites, + // A queue that cannot be read holds nothing to wait for. + error: () => setQueuedWrites(0), + }); + return () => subscription.unsubscribe(); + }, [tripId]); + const synced = !offline && queuedWrites === 0; useEffect(() => { loadSettlement(); - }, [budgetItems.length, loadSettlement]); + }, [budgetItems.length, loadSettlement, synced]); + + // A payment saved offline has no row until the server records it, so the user + // is told it is on its way rather than seeing nothing happen. + const afterSettlementWrite = (result: SettlementWriteResult) => { + if (result.queued) toast.info(t('costs.paymentQueued')); + loadSettlement(); + }; // Rows the server could not count get a rate frozen from the browser's, and the // settlement is read again once they count. @@ -179,8 +203,7 @@ export function useCostsLedger({ // as "undo" rather than delete. const undoSettlement = async (id: number) => { try { - await budgetApi.deleteSettlement(tripId, id); - loadSettlement(); + afterSettlementWrite(await budgetRepo.deleteSettlement(tripId, id)); } catch { toast.error(t('common.unknownError')); } @@ -190,14 +213,14 @@ export function useCostsLedger({ // it, so it is not left to the server's UTC clock (#2604). const settleFlow = async (fromId: number, toId: number, amount: number) => { try { - await budgetApi.createSettlement( + const result = await budgetRepo.createSettlement( tripId, withFallbackFx( { from_user_id: fromId, to_user_id: toId, amount, currency: base, settled_at: localToday() }, tripCurrency ) ); - loadSettlement(); + afterSettlementWrite(result); } catch { toast.error(t('common.unknownError')); } @@ -205,9 +228,10 @@ export function useCostsLedger({ const settleAll = async () => { if (!flows.length) return; + let queued = false; try { for (const f of flows) { - await budgetApi.createSettlement( + const result = await budgetRepo.createSettlement( tripId, withFallbackFx( { @@ -220,13 +244,14 @@ export function useCostsLedger({ tripCurrency ) ); + queued ||= result.queued; } } catch { toast.error(t('common.unknownError')); } finally { // Refresh even when one transfer failed: the ones created before it are real, // and leaving them in the flow list invites a second, doubled settle-up. - loadSettlement(); + afterSettlementWrite({ queued }); } }; diff --git a/client/src/components/Budget/useExpenseForm.test.ts b/client/src/components/Budget/useExpenseForm.test.ts index 6de21e9bb..2ab992f93 100644 --- a/client/src/components/Budget/useExpenseForm.test.ts +++ b/client/src/components/Budget/useExpenseForm.test.ts @@ -600,3 +600,28 @@ describe('useExpenseForm: saving', () => { expect(result.current.total).toBe('1.555'); }); }); + +describe('useExpenseForm: offline', () => { + const setOnline = (v: boolean) => + Object.defineProperty(navigator, 'onLine', { value: v, writable: true, configurable: true }); + afterEach(() => setOnline(true)); + + it('FE-BUDGET-EXPFORM-024: offline, new receipts stop the save with a hint, an expense without them saves (#2601)', async () => { + setOnline(false); + const withPhoto = setup(); + fill(withPhoto.result, 'Lunch', '12'); + act(() => withPhoto.result.current.handleReceiptFileSelect([new File(['x'], 'bill.jpg')])); + await save(withPhoto.result); + expect(saveWithReceipts).not.toHaveBeenCalled(); + expect(addBudgetItem).not.toHaveBeenCalled(); + expect(withPhoto.onSaved).not.toHaveBeenCalled(); + expect(errorToasts()).toEqual(['costs.receiptsNeedConnection']); + expect(withPhoto.result.current.saving).toBe(false); + + const plain = setup(); + fill(plain.result, 'Lunch', '12'); + await save(plain.result); + expect(addBudgetItem).toHaveBeenCalledWith(7, expect.objectContaining({ name: 'Lunch', total_price: 12 })); + expect(plain.onSaved).toHaveBeenCalled(); + }); +}); diff --git a/client/src/components/Budget/useExpenseForm.ts b/client/src/components/Budget/useExpenseForm.ts index 282d2f14e..8d160ac03 100644 --- a/client/src/components/Budget/useExpenseForm.ts +++ b/client/src/components/Budget/useExpenseForm.ts @@ -2,6 +2,7 @@ import { useEffect, useEffectEvent, useMemo, useState } from 'react'; import { useTranslation } from '../../i18n'; import { useTripStore } from '../../store/tripStore'; +import { isEffectivelyOffline } from '../../sync/networkMode'; import type { BudgetItem, BudgetItemReceipt } from '../../types'; import { amountToInputString } from '../../utils/formatters'; import { localToday } from '../Planner/today'; @@ -349,6 +350,12 @@ export function useExpenseForm({ const save = async () => { if (!valid || (oneSaveAtATime && saving)) return; + // A receipt is a file upload, which cannot wait in the offline queue. The + // expense itself can, so the user is asked to save it without the new ones. + if (pendingReceiptFiles.length > 0 && isEffectivelyOffline()) { + toast.error(t('costs.receiptsNeedConnection')); + return; + } setSaving(true); // A picked payer always goes out, even when nobody shares the expense: the // server re-derives total_price from the payer sum (CostsPanel.helpers), so diff --git a/client/src/components/Budget/useSettlementForm.test.ts b/client/src/components/Budget/useSettlementForm.test.ts index bd2f237ce..91da0d75f 100644 --- a/client/src/components/Budget/useSettlementForm.test.ts +++ b/client/src/components/Budget/useSettlementForm.test.ts @@ -2,12 +2,13 @@ import { act, renderHook } from '@testing-library/react'; import { beforeEach, describe, expect, it, vi, type Mock } from 'vitest'; import { budgetApi } from '../../api/client'; +import { clearAll, offlineDb } from '../../db/offlineDb'; import { clearExchangeRateCache } from '../../hooks/useExchangeRates'; import { localToday } from '../Planner/today'; import type { CostsSettlement } from './costsModel'; import { useSettlementForm } from './useSettlementForm'; -// FE-BUDGET-SETTLEFORM-001 to FE-BUDGET-SETTLEFORM-010 +// FE-BUDGET-SETTLEFORM-001 to FE-BUDGET-SETTLEFORM-011 vi.mock('../../api/client', async (importOriginal) => ({ ...(await importOriginal()), @@ -28,6 +29,7 @@ const RECORDED: CostsSettlement = { let onSaved: Mock<() => void>; let toastError: Mock<(message: string) => void>; +let toastInfo: Mock<(message: string) => void>; interface Props { editing: CostsSettlement | null; @@ -46,7 +48,7 @@ function setup(initial: Props, oneSaveAtATime?: boolean) { editing, open, t: (key: string) => key, - toast: { error: toastError }, + toast: { error: toastError, info: toastInfo }, onSaved, oneSaveAtATime, }), @@ -62,6 +64,7 @@ beforeEach(() => { clearExchangeRateCache(); onSaved = vi.fn<() => void>(); toastError = vi.fn<(message: string) => void>(); + toastInfo = vi.fn<(message: string) => void>(); createMock().mockResolvedValue({}); updateMock().mockResolvedValue({}); }); @@ -229,3 +232,26 @@ describe('useSettlementForm: saving', () => { expect(result.current.amount).toBe('1000.'); }); }); + +describe('useSettlementForm: offline (#2601)', () => { + it('FE-BUDGET-SETTLEFORM-011: offline a payment is queued with a hint instead of failing', async () => { + await clearAll(); + Object.defineProperty(navigator, 'onLine', { value: false, writable: true, configurable: true }); + try { + const { result } = setup({ editing: null }); + act(() => result.current.setAmount('8')); + await act(async () => { + await result.current.save(); + }); + expect(createMock()).not.toHaveBeenCalled(); + expect(toastError).not.toHaveBeenCalled(); + expect(toastInfo).toHaveBeenCalledWith('costs.paymentQueued'); + expect(onSaved).toHaveBeenCalledTimes(1); + const [queued] = await offlineDb.mutationQueue.toArray(); + expect(queued).toMatchObject({ method: 'POST', url: '/trips/7/budget/settlements' }); + expect(queued.body).toMatchObject({ from_user_id: 1, to_user_id: 2, amount: 8 }); + } finally { + Object.defineProperty(navigator, 'onLine', { value: true, writable: true, configurable: true }); + } + }); +}); diff --git a/client/src/components/Budget/useSettlementForm.ts b/client/src/components/Budget/useSettlementForm.ts index 867997099..53545ba31 100644 --- a/client/src/components/Budget/useSettlementForm.ts +++ b/client/src/components/Budget/useSettlementForm.ts @@ -1,7 +1,7 @@ import { useEffect, useState } from 'react'; -import { budgetApi } from '../../api/client'; import { withFallbackFx } from '../../hooks/useExchangeRates'; +import { budgetRepo } from '../../repo/budgetRepo'; import { amountToInputString } from '../../utils/formatters'; import { localToday } from '../Planner/today'; import { amountInCurrency, clampAmountDecimals, settlementDate } from './CostsPanel.helpers'; @@ -10,6 +10,7 @@ import type { CostsSettlement } from './costsModel'; type Translate = (key: string, params?: Record) => string; interface Toaster { error: (message: string) => void; + info: (message: string) => void; } /** @@ -86,8 +87,11 @@ export function useSettlementForm({ tripCurrency ); try { - if (editing) await budgetApi.updateSettlement(tripId, editing.id, data); - else await budgetApi.createSettlement(tripId, data); + const result = editing + ? await budgetRepo.updateSettlement(tripId, editing.id, data) + : await budgetRepo.createSettlement(tripId, data); + // Offline the payment waits in the queue and shows once it is sent. + if (result.queued) toast.info(t('costs.paymentQueued')); onSaved(); } catch { toast.error(t('common.unknownError')); diff --git a/client/src/db/offlineDb.ts b/client/src/db/offlineDb.ts index c65a69a4d..e7ed0ed4a 100644 --- a/client/src/db/offlineDb.ts +++ b/client/src/db/offlineDb.ts @@ -17,6 +17,17 @@ export interface CachedTour extends TourListItem { waypoints?: TourWaypoint[]; } +/** + * The last settlement the server worked out for a trip, in the display currency + * it was asked for, so the Costs balances still read offline. The server nets + * them, so offline they stay as they were when last read. + */ +export interface CachedBudgetSettlement { + tripId: number; + base: string; + settlement: unknown; +} + // ── Queue + sync types ──────────────────────────────────────────────────────── // 'conflict' is terminal-until-resolved: the server rejected the replay because @@ -222,6 +233,7 @@ export class TrekOfflineDb extends Dexie { importFiles!: Table; areaPlaces!: Table; tours!: Table; + budgetSettlements!: Table; constructor(name: string = ANON_DB_NAME) { super(name); @@ -293,6 +305,9 @@ export class TrekOfflineDb extends Dexie { // v9: the Tours facet, keyed like the place it belongs to, so a Tour still // reads as one offline and can be edited there. this.version(9).stores({ tours: 'place_id, trip_id' }); + + // v10: the Costs settlement, one per trip, so the balances read offline. + this.version(10).stores({ budgetSettlements: 'tripId' }); } } @@ -574,9 +589,11 @@ export async function clearTripData(tripId: number): Promise { offlineDb.areaPlaces, offlineDb.roadtripPreferences, offlineDb.tours, + offlineDb.budgetSettlements, ], async () => { await offlineDb.roadtripPreferences.delete(tripId); + await offlineDb.budgetSettlements.delete(tripId); await offlineDb.days.where('trip_id').equals(tripId).delete(); await offlineDb.places.where('trip_id').equals(tripId).delete(); await offlineDb.packingItems.where('trip_id').equals(tripId).delete(); diff --git a/client/src/mobile/screens/trip/tabs/MCostsTab.tsx b/client/src/mobile/screens/trip/tabs/MCostsTab.tsx index 45e440d81..753c2d6ed 100644 --- a/client/src/mobile/screens/trip/tabs/MCostsTab.tsx +++ b/client/src/mobile/screens/trip/tabs/MCostsTab.tsx @@ -839,7 +839,7 @@ function AddPaymentSheet({ open, editing, onClose, tripId, base, tripCurrency, p tripCurrency: string people: TripMember[] me: number - toast: { error: (message: string) => void } + toast: { error: (message: string) => void; info: (message: string) => void } t: TFn onSaved: () => void }) { diff --git a/client/src/repo/budgetRepo.offline.test.ts b/client/src/repo/budgetRepo.offline.test.ts new file mode 100644 index 000000000..1f873b8d1 --- /dev/null +++ b/client/src/repo/budgetRepo.offline.test.ts @@ -0,0 +1,237 @@ +// FE-REPO-BUDGET-OFF-001 to FE-REPO-BUDGET-OFF-010 +// Offline write paths (#2601) — budgetRepo.test.ts covers the online reads. +import 'fake-indexeddb/auto'; +import { http, HttpResponse } from 'msw'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { buildBudgetItem } from '../../tests/helpers/factories'; +import { server } from '../../tests/helpers/msw/server'; +import type { QueuedMutation } from '../db/offlineDb'; +import { clearAll, offlineDb } from '../db/offlineDb'; +import { mutationQueue } from '../sync/mutationQueue'; +import { budgetRepo } from './budgetRepo'; + +function setOnline(v: boolean): void { + Object.defineProperty(navigator, 'onLine', { value: v, writable: true, configurable: true }); +} + +async function queue(): Promise { + return (await offlineDb.mutationQueue.toArray()).sort((a, b) => a.createdAt - b.createdAt); +} + +beforeEach(async () => { + await clearAll(); + mutationQueue._resetFlushing(); + setOnline(false); + await offlineDb.tripMembers.bulkPut([ + { tripId: 4, id: 1, username: 'ana' }, + { tripId: 4, id: 2, username: 'ben' }, + ]); +}); + +afterEach(() => { + vi.restoreAllMocks(); + setOnline(true); +}); + +describe('budgetRepo expenses — offline', () => { + it('FE-REPO-BUDGET-OFF-001: a new expense gets a temp row with the total, payers and split the server would derive', async () => { + const { item } = await budgetRepo.create(4, { + name: 'Dinner', + category: 'food', + currency: 'EUR', + total_price: 30, + payers: [ + { user_id: 1, amount: 20.1 }, + { user_id: 2, amount: 9.9 }, + ], + members: [ + { user_id: 1, amount: null }, + { user_id: 2, amount: null }, + ], + member_ids: [1, 2], + receipt_file_ids: [], + fallback_fx: { base: 'EUR', rates: { USD: 1.1 } }, + }); + expect(item.id).toBeLessThan(0); + expect(item).toMatchObject({ trip_id: 4, name: 'Dinner', category: 'food', total_price: 30, persons: 2 }); + expect(item.payers?.map((p) => [p.user_id, p.amount, p.username])).toEqual([ + [1, 20.1, 'ana'], + [2, 9.9, 'ben'], + ]); + expect(item.members?.map((m) => [m.user_id, m.paid, m.username])).toEqual([ + [1, 0, 'ana'], + [2, 0, 'ben'], + ]); + // Request-only fields stay out of the row. + expect(item).not.toHaveProperty('fallback_fx'); + expect(item).not.toHaveProperty('member_ids'); + + expect(await offlineDb.budgetItems.get(item.id)).toMatchObject({ name: 'Dinner' }); + const [mutation] = await queue(); + expect(mutation).toMatchObject({ + method: 'POST', + url: '/trips/4/budget', + resource: 'budgetItems', + tempId: item.id, + }); + expect(mutation.body).toMatchObject({ name: 'Dinner', fallback_fx: { base: 'EUR' } }); + }); + + it('FE-REPO-BUDGET-OFF-002: an edit and a delete of an unsynced expense wait for its id', async () => { + const { item } = await budgetRepo.create(4, { name: 'Taxi', total_price: 12 }); + const { item: edited } = await budgetRepo.update(4, item.id, { name: 'Cab', total_price: 15 }); + expect(edited).toMatchObject({ id: item.id, name: 'Cab', total_price: 15 }); + await budgetRepo.delete(4, item.id); + + expect(await offlineDb.budgetItems.get(item.id)).toBeUndefined(); + const [, put, del] = await queue(); + expect(put).toMatchObject({ method: 'PUT', url: '/trips/4/budget/{id}', tempEntityId: item.id, entityId: item.id }); + expect(del).toMatchObject({ method: 'DELETE', url: '/trips/4/budget/{id}', tempEntityId: item.id }); + }); + + it('FE-REPO-BUDGET-OFF-003: an edit keeps the paid ticks and drops the receipts taken off', async () => { + await offlineDb.budgetItems.put( + buildBudgetItem({ + id: 70, + trip_id: 4, + name: 'Hotel', + total_price: 200, + members: [ + { user_id: 1, paid: 1, username: 'ana' }, + { user_id: 2, paid: 0, username: 'ben' }, + ], + receipts: [ + { id: 5, filename: 'a', original_name: 'a.pdf', url: '/a' }, + { id: 6, filename: 'b', original_name: 'b.pdf', url: '/b' }, + ], + }) + ); + const { item } = await budgetRepo.update(4, 70, { + members: [ + { user_id: 1, amount: 150 }, + { user_id: 2, amount: 50 }, + ], + receipt_file_ids: [6], + }); + expect(item.members?.map((m) => [m.user_id, m.paid, m.amount])).toEqual([ + [1, 1, 150], + [2, 0, 50], + ]); + expect(item.receipts?.map((r) => r.id)).toEqual([6]); + expect(item.total_price).toBe(200); + const [mutation] = await queue(); + expect(mutation).toMatchObject({ method: 'PUT', url: '/trips/4/budget/70', entityId: 70 }); + }); + + it('FE-REPO-BUDGET-OFF-004: the split and the paid tick are written here and queued', async () => { + await offlineDb.budgetItems.put( + buildBudgetItem({ id: 71, trip_id: 4, members: [{ user_id: 1, paid: 0, username: 'ana' }] }) + ); + const { members } = await budgetRepo.setMembers(4, 71, [1, 2]); + expect(members.map((m) => m.user_id)).toEqual([1, 2]); + await budgetRepo.togglePaid(4, 71, 2, true); + + const cached = await offlineDb.budgetItems.get(71); + expect(cached?.members?.map((m) => [m.user_id, m.paid])).toEqual([ + [1, 0], + [2, 1], + ]); + expect(cached?.persons).toBe(2); + const [set, paid] = await queue(); + expect(set).toMatchObject({ url: '/trips/4/budget/71/members', body: { user_ids: [1, 2] }, entityId: 71 }); + expect(paid).toMatchObject({ url: '/trips/4/budget/71/members/2/paid', body: { paid: true }, entityId: 71 }); + }); + + it('FE-REPO-BUDGET-OFF-005: a reorder is queued without the expenses that have no server id yet', async () => { + await offlineDb.budgetItems.bulkPut([ + buildBudgetItem({ id: 72, trip_id: 4 }), + buildBudgetItem({ id: 73, trip_id: 4 }), + ]); + await budgetRepo.reorderItems(4, [73, -5, 72]); + expect((await offlineDb.budgetItems.get(73))?.sort_order).toBe(0); + expect((await offlineDb.budgetItems.get(72))?.sort_order).toBe(2); + await budgetRepo.reorderCategories(4, ['food', 'other']); + const [items, categories] = await queue(); + expect(items).toMatchObject({ url: '/trips/4/budget/reorder/items', body: { orderedIds: [73, 72] } }); + expect(items.resource).toBeUndefined(); + expect(categories).toMatchObject({ + url: '/trips/4/budget/reorder/categories', + body: { orderedCategories: ['food', 'other'] }, + }); + }); + + it('FE-REPO-BUDGET-OFF-006: online, an edit of an expense with a queued write joins the queue behind it', async () => { + await offlineDb.budgetItems.put(buildBudgetItem({ id: 74, trip_id: 4, name: 'Old' })); + await budgetRepo.update(4, 74, { name: 'Offline' }); + setOnline(true); + let sent = 0; + server.use( + http.put('/api/trips/4/budget/74', () => { + sent += 1; + return HttpResponse.error(); + }) + ); + const sendSoon = vi.spyOn(mutationQueue, 'sendSoon').mockImplementation(() => {}); + + await budgetRepo.update(4, 74, { name: 'Online' }); + + expect(sent).toBe(0); + expect(sendSoon).toHaveBeenCalled(); + expect((await queue()).map((m) => (m.body as { name: string }).name)).toEqual(['Offline', 'Online']); + }); +}); + +describe('budgetRepo settlements — offline', () => { + it('FE-REPO-BUDGET-OFF-007: a payment is queued without a local row', async () => { + const created = await budgetRepo.createSettlement(4, { + from_user_id: 1, + to_user_id: 2, + amount: 10, + currency: 'EUR', + }); + const edited = await budgetRepo.updateSettlement(4, 30, { from_user_id: 1, to_user_id: 2, amount: 12 }); + const undone = await budgetRepo.deleteSettlement(4, 31); + expect([created, edited, undone]).toEqual([{ queued: true }, { queued: true }, { queued: true }]); + + const [post, put, del] = await queue(); + expect(post).toMatchObject({ method: 'POST', url: '/trips/4/budget/settlements', resource: 'budgetSettlements' }); + expect(post.tempId).toBeUndefined(); + expect(put).toMatchObject({ method: 'PUT', url: '/trips/4/budget/settlements/30', entityId: 30 }); + expect(del).toMatchObject({ method: 'DELETE', url: '/trips/4/budget/settlements/31', entityId: 31 }); + expect(await budgetRepo.pendingWrites(4)).toBe(3); + expect(await budgetRepo.pendingWrites(5)).toBe(0); + }); + + it('FE-REPO-BUDGET-OFF-008: online a payment goes straight out', async () => { + setOnline(true); + server.use(http.post('/api/trips/4/budget/settlements', () => HttpResponse.json({ settlement: { id: 40 } }))); + expect(await budgetRepo.createSettlement(4, { from_user_id: 1, to_user_id: 2, amount: 10 })).toEqual({ + queued: false, + }); + expect(await queue()).toEqual([]); + }); + + it('FE-REPO-BUDGET-OFF-009: the settlement read online is what answers offline, for the same display currency', async () => { + setOnline(true); + const answer = { + balances: [{ user_id: 1, username: 'ana', balance: 5 }], + flows: [], + settlements: [], + finalBudgets: [], + }; + server.use(http.get('/api/trips/4/budget/settlement', () => HttpResponse.json(answer))); + expect(await budgetRepo.settlement(4, 'EUR')).toEqual(answer); + + setOnline(false); + expect(await budgetRepo.settlement(4, 'EUR')).toEqual(answer); + await expect(budgetRepo.settlement(4, 'USD')).rejects.toThrow(); + await expect(budgetRepo.settlement(5, 'EUR')).rejects.toThrow(); + }); + + it('FE-REPO-BUDGET-OFF-010: a trip taken off the device takes its settlement with it', async () => { + const { clearTripData } = await import('../db/offlineDb'); + await offlineDb.budgetSettlements.put({ tripId: 4, base: 'EUR', settlement: {} }); + await clearTripData(4); + expect(await offlineDb.budgetSettlements.get(4)).toBeUndefined(); + }); +}); diff --git a/client/src/repo/budgetRepo.ts b/client/src/repo/budgetRepo.ts index a8cbf06ef..7d8d87d6f 100644 --- a/client/src/repo/budgetRepo.ts +++ b/client/src/repo/budgetRepo.ts @@ -1,8 +1,119 @@ +import { sumMinor } from '@trek/shared' +import type { + BudgetCreateItemRequest, + BudgetCreateSettlementRequest, + BudgetFreezeRatesRequest, + BudgetFreezeRatesResponse, + BudgetUpdateItemRequest, + BudgetUpdateSettlementRequest, +} from '@trek/shared' import { budgetApi } from '../api/client' import { offlineDb, replaceTripRows } from '../db/offlineDb' +import { mutationQueue, generateUUID, nextTempId } from '../sync/mutationQueue' +import { isEffectivelyOffline } from '../sync/networkMode' import { onlineThenCache } from './withOfflineFallback' -import type { BudgetItem } from '../types' +import type { BudgetItemPayer } from '@trek/shared' +import type { BudgetItem, BudgetItemMember } from '../types' +type SettlementResponse = Awaited> + +/** Whether a settle-up write went out or waits in the queue for the connection. */ +export interface SettlementWriteResult { + queued: boolean +} + +type ItemBody = BudgetCreateItemRequest | BudgetUpdateItemRequest +type People = Map + +/** The names the cached trip members carry, for the split rows an offline write draws. */ +async function peopleOf(tripId: number): Promise { + const rows = await offlineDb.tripMembers.where('tripId').equals(tripId).toArray() + return new Map(rows.map(m => [m.id, { username: m.username, avatar_url: m.avatar_url ?? null }])) +} + +function payersFrom(itemId: number, payers: { user_id: number; amount: number }[], people: People): BudgetItemPayer[] { + return payers + .filter(p => p.amount !== 0) + .map(p => ({ user_id: p.user_id, amount: p.amount, budget_item_id: itemId, username: people.get(p.user_id)?.username ?? '', avatar_url: people.get(p.user_id)?.avatar_url ?? null })) +} + +/** + * The split as the server will store it: the members with their amounts when the + * body lists them, the ids otherwise. A paid tick already on someone stays, as it + * does on the server. + */ +function membersFrom(itemId: number, body: ItemBody, before: BudgetItemMember[], people: People): BudgetItemMember[] | undefined { + const listed = body.members ?? body.member_ids?.map(user_id => ({ user_id, amount: null })) + if (!listed) return undefined + const paid = new Map(before.map(m => [m.user_id, m.paid])) + return listed.map(m => ({ + user_id: m.user_id, + amount: m.amount ?? null, + paid: paid.get(m.user_id) ?? 0, + budget_item_id: itemId, + username: people.get(m.user_id)?.username ?? before.find(b => b.user_id === m.user_id)?.username ?? '', + avatar_url: people.get(m.user_id)?.avatar_url ?? null, + })) +} + +/** The total the server derives: the payers' sum when there are payers, the entered amount otherwise. */ +function totalFrom(body: ItemBody, payers: BudgetItemPayer[] | undefined, fallback: number): number { + if (payers && payers.length > 0) return sumMinor(payers.map(p => p.amount)) / 100 + return body.total_price ?? fallback +} + +/** + * The expense an offline write leaves on screen until the queue replays it and + * the server's own row replaces it. Request-only fields stay out of the row. + */ +function localItem(itemId: number, tripId: number, body: ItemBody, before: BudgetItem | undefined, people: People): BudgetItem { + const { fallback_fx: _fx, member_ids: _ids, members: _members, payers: _payers, receipt_file_ids: receiptIds, ...fields } = body + const payers = body.payers ? payersFrom(itemId, body.payers, people) : undefined + const members = membersFrom(itemId, body, before?.members ?? [], people) + return { + ...(before ?? { sort_order: Number.MAX_SAFE_INTEGER, persons: null, receipts: [] }), + ...fields, + id: itemId, + trip_id: tripId, + name: fields.name || before?.name || '', + category: fields.category || before?.category || 'other', + total_price: totalFrom(body, payers, before?.total_price ?? 0), + ...(payers ? { payers } : {}), + ...(members ? { members, persons: members.length || null } : {}), + ...(receiptIds ? { receipts: (before?.receipts ?? []).filter(r => receiptIds.includes(r.id)) } : {}), + } as BudgetItem +} + +function itemUrl(tripId: number | string, id: number, suffix = ''): string { + return `/trips/${tripId}/budget/${id < 0 ? '{id}' : id}${suffix}` +} + +/** Queue a write to one expense, held behind any older write to it. */ +async function enqueueItemWrite(tripId: number | string, id: number, method: 'PUT' | 'DELETE', suffix: string, body: unknown): Promise { + await mutationQueue.enqueue({ + id: generateUUID(), + tripId: Number(tripId), + method, + url: itemUrl(tripId, id, suffix), + body, + resource: 'budgetItems', + entityId: id, + ...(id < 0 ? { tempEntityId: id } : {}), + }) + mutationQueue.sendSoon() +} + +/** + * Expenses and settle-up payments through the offline core. Reads fall back to + * the Dexie copy; a write made offline lands there and in the mutation queue, + * and replays once the connection is back. Budget writes carry no version token, + * so an online write to an expense that still has an older one queued joins the + * queue behind it (mustQueue). + * + * A payment gets no local row: its effect on the balances is worked out by the + * server, so the ledger shows it once the queue has sent it. Receipts are file + * uploads and stay online-only. + */ export const budgetRepo = { async list(tripId: number | string): Promise<{ items: BudgetItem[] }> { return onlineThenCache( @@ -17,4 +128,191 @@ export const budgetRepo = { }), ) }, + + async create(tripId: number | string, data: BudgetCreateItemRequest): Promise<{ item: BudgetItem }> { + if (!isEffectivelyOffline()) { + const result: { item: BudgetItem } = await budgetApi.create(tripId, data) + await offlineDb.budgetItems.put(result.item) + return result + } + const tid = Number(tripId) + const tempId = nextTempId() + const item = localItem(tempId, tid, data, undefined, await peopleOf(tid)) + await offlineDb.transaction('rw', offlineDb.budgetItems, offlineDb.mutationQueue, async () => { + await offlineDb.budgetItems.put(item) + await mutationQueue.enqueue({ + id: generateUUID(), + tripId: tid, + method: 'POST', + url: `/trips/${tripId}/budget`, + body: data, + resource: 'budgetItems', + tempId, + }) + }) + return { item } + }, + + async update(tripId: number | string, id: number, data: BudgetUpdateItemRequest): Promise<{ item: BudgetItem }> { + if (!(await mutationQueue.mustQueue('budgetItems', id))) { + const result: { item: BudgetItem } = await budgetApi.update(tripId, id, data) + await offlineDb.budgetItems.put(result.item) + return result + } + const tid = Number(tripId) + const item = localItem(id, tid, data, await offlineDb.budgetItems.get(id), await peopleOf(tid)) + await offlineDb.budgetItems.put(item) + await enqueueItemWrite(tripId, id, 'PUT', '', data) + return { item } + }, + + async delete(tripId: number | string, id: number): Promise { + if (!(await mutationQueue.mustQueue('budgetItems', id))) { + await budgetApi.delete(tripId, id) + await offlineDb.budgetItems.delete(id) + return + } + await offlineDb.budgetItems.delete(id) + await enqueueItemWrite(tripId, id, 'DELETE', '', undefined) + }, + + async setMembers(tripId: number | string, id: number, userIds: number[]): Promise<{ members: BudgetItemMember[]; item: BudgetItem }> { + if (!(await mutationQueue.mustQueue('budgetItems', id))) { + const result: { members: BudgetItemMember[]; item: BudgetItem } = await budgetApi.setMembers(tripId, id, userIds) + const cached = await offlineDb.budgetItems.get(id) + if (cached) await offlineDb.budgetItems.put({ ...cached, ...result.item, members: result.members }) + return result + } + const tid = Number(tripId) + const item = localItem(id, tid, { member_ids: userIds }, await offlineDb.budgetItems.get(id), await peopleOf(tid)) + await offlineDb.budgetItems.put(item) + await enqueueItemWrite(tripId, id, 'PUT', '/members', { user_ids: userIds }) + return { members: item.members ?? [], item } + }, + + async togglePaid(tripId: number | string, id: number, userId: number, paid: boolean): Promise { + const queued = await mutationQueue.mustQueue('budgetItems', id) + if (!queued) await budgetApi.togglePaid(tripId, id, userId, paid) + // The server keeps the tick as 0/1, and so does the copy here. + await offlineDb.budgetItems.where('id').equals(id).modify(item => { + item.members = (item.members ?? []).map(m => (m.user_id === userId ? { ...m, paid: paid ? 1 : 0 } : m)) + }) + if (queued) await enqueueItemWrite(tripId, id, 'PUT', `/members/${userId}/paid`, { paid }) + }, + + /** + * The new order of the expenses. Offline it waits in the queue; an expense + * created offline has no server id to name yet, so it keeps the place the + * server gives a new one, at the end. + */ + async reorderItems(tripId: number | string, orderedIds: number[]): Promise { + if (!isEffectivelyOffline()) { + await budgetApi.reorderItems(tripId, orderedIds) + return + } + await offlineDb.transaction('rw', offlineDb.budgetItems, offlineDb.mutationQueue, async () => { + for (const [index, id] of orderedIds.entries()) { + await offlineDb.budgetItems.update(id, { sort_order: index }) + } + await mutationQueue.enqueue({ + id: generateUUID(), + tripId: Number(tripId), + method: 'PUT', + url: `/trips/${tripId}/budget/reorder/items`, + body: { orderedIds: orderedIds.filter(id => id > 0) }, + }) + }) + }, + + async reorderCategories(tripId: number | string, orderedCategories: string[]): Promise { + if (!isEffectivelyOffline()) { + await budgetApi.reorderCategories(tripId, orderedCategories) + return + } + await mutationQueue.enqueue({ + id: generateUUID(), + tripId: Number(tripId), + method: 'PUT', + url: `/trips/${tripId}/budget/reorder/categories`, + body: { orderedCategories }, + }) + }, + + /** Online only: the rate it freezes is today's, which a later replay would not be. */ + async freezeRates(tripId: number | string, data: BudgetFreezeRatesRequest): Promise { + return budgetApi.freezeRates(tripId, data) + }, + + /** + * The balances, flows and payments the server nets for the trip. Offline the + * last ones read for the same display currency stand in. + */ + async settlement(tripId: number | string, base: string, baseRate?: number | null): Promise { + const tid = Number(tripId) + return onlineThenCache( + async () => { + const result = await budgetApi.settlement(tripId, base, baseRate) + await offlineDb.budgetSettlements.put({ tripId: tid, base, settlement: result }) + return result + }, + async () => { + const cached = await offlineDb.budgetSettlements.get(tid) + if (!cached || cached.base !== base) throw new Error('Settlement not available offline') + return cached.settlement as SettlementResponse + }, + ) + }, + + async pendingWrites(tripId: number | string): Promise { + const rows = await mutationQueue.pending(Number(tripId)) + return rows.filter(m => m.resource === 'budgetItems' || m.resource === 'budgetSettlements' || m.url.includes('/budget/')).length + }, + + async createSettlement(tripId: number | string, data: BudgetCreateSettlementRequest): Promise { + if (!isEffectivelyOffline()) { + await budgetApi.createSettlement(tripId, data) + return { queued: false } + } + await mutationQueue.enqueue({ + id: generateUUID(), + tripId: Number(tripId), + method: 'POST', + url: `/trips/${tripId}/budget/settlements`, + body: data, + resource: 'budgetSettlements', + }) + return { queued: true } + }, + + async updateSettlement(tripId: number | string, id: number, data: BudgetUpdateSettlementRequest): Promise { + return settlementWrite(tripId, id, 'PUT', data, () => budgetApi.updateSettlement(tripId, id, data)) + }, + + async deleteSettlement(tripId: number | string, id: number): Promise { + return settlementWrite(tripId, id, 'DELETE', undefined, () => budgetApi.deleteSettlement(tripId, id)) + }, +} + +async function settlementWrite( + tripId: number | string, + id: number, + method: 'PUT' | 'DELETE', + body: unknown, + send: () => Promise, +): Promise { + if (!(await mutationQueue.mustQueue('budgetSettlements', id))) { + await send() + return { queued: false } + } + await mutationQueue.enqueue({ + id: generateUUID(), + tripId: Number(tripId), + method, + url: `/trips/${tripId}/budget/settlements/${id}`, + body, + resource: 'budgetSettlements', + entityId: id, + }) + mutationQueue.sendSoon() + return { queued: true } } diff --git a/client/src/store/slices/budgetSlice.ts b/client/src/store/slices/budgetSlice.ts index 054483a4d..0cb5b687b 100644 --- a/client/src/store/slices/budgetSlice.ts +++ b/client/src/store/slices/budgetSlice.ts @@ -1,4 +1,3 @@ -import { budgetApi } from '../../api/client' import { budgetRepo } from '../../repo/budgetRepo' import type { StoreApi } from 'zustand' import type { TripStoreState } from '../tripStore' @@ -47,7 +46,7 @@ export const createBudgetSlice = (set: SetState, get: GetState): BudgetSlice => try { // A foreign currency goes out with the rate the browser holds for it, so the server // can freeze one even when its own fetch fails. - const result = await budgetApi.create(tripId, withFallbackFx(data, openTripCurrency(get, tripId))) + const result = await budgetRepo.create(tripId, withFallbackFx(data, openTripCurrency(get, tripId))) set(state => ({ budgetItems: [...state.budgetItems, result.item] })) // The booking mirrors its expenses' total (#2084), so a new one on it changes its card. if (result.item.reservation_id) get().loadReservations(tripId) @@ -59,7 +58,7 @@ export const createBudgetSlice = (set: SetState, get: GetState): BudgetSlice => updateBudgetItem: async (tripId, id, data) => { try { - const result = await budgetApi.update(tripId, id, withFallbackFx(data, openTripCurrency(get, tripId))) + const result = await budgetRepo.update(tripId, id, withFallbackFx(data, openTripCurrency(get, tripId))) set(state => ({ budgetItems: state.budgetItems.map(item => item.id === id ? result.item : item) })) @@ -78,7 +77,7 @@ export const createBudgetSlice = (set: SetState, get: GetState): BudgetSlice => const prev = get().budgetItems set(state => ({ budgetItems: state.budgetItems.filter(item => item.id !== id) })) try { - await budgetApi.delete(tripId, id) + await budgetRepo.delete(tripId, id) } catch (err: unknown) { set({ budgetItems: prev }) throw new Error(getApiErrorMessage(err, 'Error deleting budget item')) @@ -86,7 +85,7 @@ export const createBudgetSlice = (set: SetState, get: GetState): BudgetSlice => }, setBudgetItemMembers: async (tripId, itemId, userIds) => { - const result = await budgetApi.setMembers(tripId, itemId, userIds); + const result = await budgetRepo.setMembers(tripId, itemId, userIds); set(state => ({ budgetItems: state.budgetItems.map(item => item.id === itemId ? { ...item, members: result.members, persons: result.item.persons } : item @@ -96,7 +95,7 @@ export const createBudgetSlice = (set: SetState, get: GetState): BudgetSlice => }, toggleBudgetMemberPaid: async (tripId, itemId, userId, paid) => { - await budgetApi.togglePaid(tripId, itemId, userId, paid); + await budgetRepo.togglePaid(tripId, itemId, userId, paid); set(state => ({ budgetItems: state.budgetItems.map(item => item.id === itemId @@ -124,13 +123,13 @@ export const createBudgetSlice = (set: SetState, get: GetState): BudgetSlice => return { budgetItems: [...reordered, ...remaining] } }) try { - await budgetApi.reorderItems(tripId, orderedIds) + await budgetRepo.reorderItems(tripId, orderedIds) } catch (err: unknown) { // Reload on failure to restore the server's ordering, and tell the user // their reorder didn't stick (the caller fires this without awaiting, so // a failing reload must not escape as an unhandled rejection). try { - const data = await budgetApi.list(tripId) + const data = await budgetRepo.list(tripId) set({ budgetItems: data.items }) } catch { /* offline too — the next successful load restores the order */ } notify(getApiErrorMessage(err, 'Error reordering budget items'), 'error') @@ -141,13 +140,13 @@ export const createBudgetSlice = (set: SetState, get: GetState): BudgetSlice => // Optimistic: reorder items by new category order (Map preserves insertion order for numeric keys) set(state => ({ budgetItems: orderBudgetByCategories(state.budgetItems, orderedCategories) })) try { - await budgetApi.reorderCategories(tripId, orderedCategories) + await budgetRepo.reorderCategories(tripId, orderedCategories) } catch (err: unknown) { // Reload on failure to restore the server's ordering, and tell the user // their reorder didn't stick (the caller fires this without awaiting, so // a failing reload must not escape as an unhandled rejection). try { - const data = await budgetApi.list(tripId) + const data = await budgetRepo.list(tripId) set({ budgetItems: data.items }) } catch { /* offline too — the next successful load restores the order */ } notify(getApiErrorMessage(err, 'Error reordering budget items'), 'error') @@ -157,7 +156,7 @@ export const createBudgetSlice = (set: SetState, get: GetState): BudgetSlice => // Freezes a rate onto the rows the settlement could not count (see useFreezeMissingRates). // Not optimistic: nothing changes locally until the server says which rows it froze. freezeMissingRates: async (tripId, fallback) => { - const result = await budgetApi.freezeRates(tripId, fallback ? { fallback_fx: fallback } : {}) + const result = await budgetRepo.freezeRates(tripId, fallback ? { fallback_fx: fallback } : {}) if (result.items.length > 0) { const healed = new Map(result.items.map(item => [item.id, item])) set(state => ({ budgetItems: state.budgetItems.map(item => healed.get(item.id) ?? item) })) diff --git a/client/src/sync/mutationQueue.ts b/client/src/sync/mutationQueue.ts index c7e1eba41..ed9e658fa 100644 --- a/client/src/sync/mutationQueue.ts +++ b/client/src/sync/mutationQueue.ts @@ -15,6 +15,7 @@ import type { QueuedMutation } from '../db/offlineDb' import type { Table } from 'dexie' import { roadtripPreferencesResponseSchema, assignmentSchema, tourCreateResponseSchema, type TourCreateResponse } from '@trek/shared' import { cacheAssignment } from '../db/cacheAssignment' +import type { BudgetItem, BudgetItemMember } from '../types' // Map Dexie table names used in `resource` field → actual Dexie tables. function getTable(resource: string): Table | undefined { @@ -197,17 +198,65 @@ async function dropTempRows(mutation: QueuedMutation): Promise { * this flush and durably for the next. */ async function remapTempId(mutation: QueuedMutation, realId: number, idMap: Map): Promise { - if (mutation.tempId === undefined || mutation.tempId === realId) return - idMap.set(mutation.tempId, realId) + const tempId = mutation.tempId + if (tempId === undefined || tempId === realId) return + idMap.set(tempId, realId) await offlineDb.mutationQueue .where('tripId') .equals(mutation.tripId) - .filter(m => m.tempEntityId === mutation.tempId) + .filter(m => m.tempEntityId === tempId) .modify(m => { m.url = m.url.replace('{id}', String(realId)) m.entityId = realId m.tempEntityId = undefined }) + const learned = new Map([[tempId, realId]]) + await offlineDb.mutationQueue + .where('tripId') + .equals(mutation.tripId) + .filter(m => remapBodyRefs(m.body, learned) !== m.body) + .modify(m => { m.body = remapBodyRefs(m.body, learned) }) +} + +// Body fields that can name an entity created offline earlier in the queue: an +// expense saved from a place or a booking that has not synced yet. +const TEMP_REF_FIELDS = ['place_id', 'reservation_id'] as const + +/** + * The body with every link to an offline-created entity pointed at the real id + * the queue learned for it. The same body when nothing changed. + */ +function remapBodyRefs(body: unknown, idMap: Map): unknown { + if (!body || typeof body !== 'object' || Array.isArray(body)) return body + const fields = body as Record + let remapped: Record | null = null + for (const field of TEMP_REF_FIELDS) { + const ref = fields[field] + const realId = typeof ref === 'number' && ref < 0 ? idMap.get(ref) : undefined + if (realId === undefined) continue + remapped ??= { ...fields } + remapped[field] = realId + } + return remapped ?? body +} + +/** + * A replayed expense write. Only `item` names the expense: the members route + * answers `{ members, item }` with the item's bare row and the paid tick answers + * `{ member }` with no item at all, so neither fits the first-value rule of the + * generic branch. The bare row keeps the payers and receipts the local copy + * holds and takes the members the answer lists. + */ +async function reconcileBudgetItem(mutation: QueuedMutation, data: unknown, idMap: Map): Promise { + const answer = (data && typeof data === 'object' ? data : {}) as { item?: BudgetItem; members?: BudgetItemMember[] } + const item = answer.item + if (!item || typeof item.id !== 'number') return + if (mutation.tempId !== undefined && mutation.tempId !== item.id) { + await offlineDb.budgetItems.delete(mutation.tempId) + await remapTempId(mutation, item.id, idMap) + } + const local = await offlineDb.budgetItems.get(item.id) + await offlineDb.budgetItems.put({ ...local, ...item, ...(answer.members ? { members: answer.members } : {}) }) } /** @@ -432,7 +481,7 @@ export const mutationQueue = { const response = await apiClient.request({ method: mutation.method, url: reqUrl, - data: mutation.body, + data: remapBodyRefs(mutation.body, idMap), headers, }) @@ -446,6 +495,8 @@ export const mutationQueue = { } if (mutation.resource === 'tours') { await reconcileTour(mutation, tourCreateResponseSchema.parse(response.data), idMap) + } else if (mutation.resource === 'budgetItems' && mutation.method !== 'DELETE') { + await reconcileBudgetItem(mutation, response.data, idMap) } else if (mutation.method !== 'DELETE' && mutation.resource) { const table = getTable(mutation.resource) if (table && response.data && typeof response.data === 'object') { diff --git a/client/tests/unit/db/offlineDb.upgrade.test.ts b/client/tests/unit/db/offlineDb.upgrade.test.ts index cf2069838..c62b41e89 100644 --- a/client/tests/unit/db/offlineDb.upgrade.test.ts +++ b/client/tests/unit/db/offlineDb.upgrade.test.ts @@ -52,6 +52,7 @@ const HISTORY: Record = { 7: { areaPlaces: null }, 8: { areaPlaces: '[gers+tripId], gers, tripId, searchName' }, 9: { tours: 'place_id, trip_id' }, + 10: { budgetSettlements: 'tripId' }, }; const HISTORY_VERSIONS = Object.keys(HISTORY) @@ -128,6 +129,7 @@ function rowsAt(version: number): Record> { areaPlaces: { gers: 'g-1', tripId: 1, name: 'Café A', searchName: 'cafe a', address: '', cachedAt: 7 }, roadtripPreferences: { tripId: 1, preferences: { avoid_tolls: true } }, tours: { place_id: 21, trip_id: 1, name: 'Old town loop', distance: 4200 }, + budgetSettlements: { tripId: 1, base: 'EUR', settlement: { balances: [], flows: [] } }, }; const tables = schemaAt(version); return Object.fromEntries(Object.entries(all).filter(([table]) => table in tables)); @@ -281,6 +283,14 @@ const UPGRADE_CASES: Record = { expect(await db.tours.where('trip_id').equals(3).count()).toBe(1); }, }, + 10: { + intent: 'adds the cached Costs settlement and keeps the tours', + check: async (db) => { + expect(await db.tours.get(21)).toMatchObject({ name: 'Old town loop' }); + await db.table('budgetSettlements').put({ tripId: 3, base: 'USD', settlement: { flows: [] } }); + expect(await db.budgetSettlements.get(3)).toMatchObject({ base: 'USD' }); + }, + }, }; describe('offlineDb upgrades', () => { diff --git a/client/tests/unit/slices/budgetSlice.test.ts b/client/tests/unit/slices/budgetSlice.test.ts index 2b1a7d2cb..a11a0b392 100644 --- a/client/tests/unit/slices/budgetSlice.test.ts +++ b/client/tests/unit/slices/budgetSlice.test.ts @@ -174,3 +174,31 @@ describe('budgetSlice', () => { }); }); }); + +describe('budgetSlice offline (#2601)', () => { + const setOnline = (v: boolean) => + Object.defineProperty(navigator, 'onLine', { value: v, writable: true, configurable: true }); + + it('FE-BUDGET-009: offline an expense is added, edited, ticked and deleted without an error', async () => { + const { clearAll, offlineDb } = await import('../../../src/db/offlineDb'); + await clearAll(); + seedStore(useTripStore, { budgetItems: [] }); + setOnline(false); + try { + const added = await useTripStore.getState().addBudgetItem(1, { name: 'Offline lunch', total_price: 12, member_ids: [5] }); + expect(added.id).toBeLessThan(0); + expect(useTripStore.getState().budgetItems.map(i => i.name)).toEqual(['Offline lunch']); + + await useTripStore.getState().updateBudgetItem(1, added.id, { name: 'Offline brunch' }); + await useTripStore.getState().toggleBudgetMemberPaid(1, added.id, 5, true); + expect(useTripStore.getState().budgetItems[0]).toMatchObject({ name: 'Offline brunch' }); + expect(useTripStore.getState().budgetItems[0].members?.[0].paid).toBe(1); + + await useTripStore.getState().deleteBudgetItem(1, added.id); + expect(useTripStore.getState().budgetItems).toEqual([]); + expect((await offlineDb.mutationQueue.orderBy('createdAt').toArray()).map(m => m.method)).toEqual(['POST', 'PUT', 'PUT', 'DELETE']); + } finally { + setOnline(true); + } + }); +}); diff --git a/client/tests/unit/sync/mutationQueue.budget.test.ts b/client/tests/unit/sync/mutationQueue.budget.test.ts new file mode 100644 index 000000000..274ff67e3 --- /dev/null +++ b/client/tests/unit/sync/mutationQueue.budget.test.ts @@ -0,0 +1,132 @@ +/** + * mutationQueue replay of offline Costs writes (#2601): a created expense moves + * to its real id, the split and paid-tick answers only ever touch the expense + * they name, and an expense saved from a place created offline reaches the + * server linked to the place's real id. + */ +import 'fake-indexeddb/auto'; +import { http, HttpResponse } from 'msw'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { clearAll, offlineDb } from '../../../src/db/offlineDb'; +import { budgetRepo } from '../../../src/repo/budgetRepo'; +import { placeRepo } from '../../../src/repo/placeRepo'; +import { setAuthed } from '../../../src/sync/authGate'; +import { mutationQueue } from '../../../src/sync/mutationQueue'; +import { buildBudgetItem } from '../../helpers/factories'; +import { server } from '../../helpers/msw/server'; + +function setOnline(v: boolean): void { + Object.defineProperty(navigator, 'onLine', { value: v, writable: true, configurable: true }); +} + +beforeEach(async () => { + await clearAll(); + mutationQueue._resetFlushing(); + setAuthed(true); + setOnline(false); +}); + +afterEach(() => { + vi.restoreAllMocks(); + setAuthed(false); + setOnline(true); +}); + +describe('mutationQueue.flush — Costs', () => { + it('FE-SYNC-BUDGET-001: a replayed expense moves to the real id and the queued edit follows it', async () => { + const { item } = await budgetRepo.create(3, { name: 'Lunch', total_price: 10 }); + await budgetRepo.update(3, item.id, { name: 'Brunch' }); + const puts: string[] = []; + server.use( + http.post('/api/trips/3/budget', () => + HttpResponse.json({ item: buildBudgetItem({ id: 801, trip_id: 3, name: 'Lunch' }) }) + ), + http.put('/api/trips/3/budget/:id', ({ params }) => { + puts.push(String(params.id)); + return HttpResponse.json({ item: buildBudgetItem({ id: 801, trip_id: 3, name: 'Brunch' }) }); + }) + ); + setOnline(true); + + await mutationQueue.flush(); + + expect(puts).toEqual(['801']); + expect(await offlineDb.mutationQueue.count()).toBe(0); + expect(await offlineDb.budgetItems.get(item.id)).toBeUndefined(); + expect(await offlineDb.budgetItems.get(801)).toMatchObject({ name: 'Brunch' }); + }); + + it('FE-SYNC-BUDGET-002: the split answer keeps the payers here and the paid answer writes no foreign row', async () => { + const payers = [{ user_id: 1, amount: 40, username: 'ana' }]; + await offlineDb.budgetItems.put(buildBudgetItem({ id: 90, trip_id: 3, total_price: 40, payers, members: [] })); + await budgetRepo.setMembers(3, 90, [1, 2]); + await budgetRepo.togglePaid(3, 90, 2, true); + const members = [ + { user_id: 1, paid: 0, username: 'ana', budget_item_id: 90 }, + { user_id: 2, paid: 0, username: 'ben', budget_item_id: 90 }, + ]; + // The members route answers the bare row, with no payers or members on it. + const bare = { id: 90, trip_id: 3, name: 'Hotel', category: 'other', total_price: 40, persons: 2 }; + server.use( + http.put('/api/trips/3/budget/90/members', () => HttpResponse.json({ members, item: bare })), + // A member row carrying an id must never land in the expense table. + http.put('/api/trips/3/budget/90/members/2/paid', () => + HttpResponse.json({ member: { id: 2, user_id: 2, paid: 1, username: 'ben' } }) + ) + ); + setOnline(true); + + await mutationQueue.flush(); + + expect(await offlineDb.mutationQueue.count()).toBe(0); + const cached = await offlineDb.budgetItems.get(90); + expect(cached?.payers).toEqual(payers); + expect(cached?.members?.map((m) => m.user_id)).toEqual([1, 2]); + expect(cached?.persons).toBe(2); + expect(await offlineDb.budgetItems.get(2)).toBeUndefined(); + expect(await offlineDb.budgetItems.count()).toBe(1); + }); + + it('FE-SYNC-BUDGET-003: an expense saved from an offline place reaches the server linked to its real id', async () => { + const place = await placeRepo.create(3, { name: 'Cafe', lat: 1, lng: 2 }); + const placeTempId = place.place.id; + await budgetRepo.create(3, { name: 'Coffee', total_price: 4, place_id: placeTempId }); + const queued = await offlineDb.mutationQueue.toArray(); + expect(queued.find((m) => m.url === '/trips/3/budget')?.body).toMatchObject({ place_id: placeTempId }); + let linkedTo: unknown; + server.use( + http.post('/api/trips/3/places', () => + HttpResponse.json({ place: { id: 650, trip_id: 3, name: 'Cafe', lat: 1, lng: 2 } }) + ), + http.post('/api/trips/3/budget', async ({ request }) => { + linkedTo = ((await request.json()) as { place_id?: number }).place_id; + return HttpResponse.json({ item: buildBudgetItem({ id: 802, trip_id: 3, place_id: 650 }) }); + }) + ); + setOnline(true); + + await mutationQueue.flush(); + + expect(linkedTo).toBe(650); + expect(await offlineDb.mutationQueue.count()).toBe(0); + }); + + it('FE-SYNC-BUDGET-004: a payment replays once and leaves no row in the expense table', async () => { + await budgetRepo.createSettlement(3, { from_user_id: 1, to_user_id: 2, amount: 10 }); + const keys: (string | null)[] = []; + server.use( + http.post('/api/trips/3/budget/settlements', ({ request }) => { + keys.push(request.headers.get('X-Idempotency-Key')); + return HttpResponse.json({ settlement: { id: 55, trip_id: 3, from_user_id: 1, to_user_id: 2, amount: 10 } }); + }) + ); + setOnline(true); + + await mutationQueue.flush(); + + expect(keys).toHaveLength(1); + expect(keys[0]).toBeTruthy(); + expect(await offlineDb.budgetItems.count()).toBe(0); + expect(await budgetRepo.pendingWrites(3)).toBe(0); + }); +}); diff --git a/shared/src/i18n/ar/budget.ts b/shared/src/i18n/ar/budget.ts index 41d42781a..aa6aefe93 100644 --- a/shared/src/i18n/ar/budget.ts +++ b/shared/src/i18n/ar/budget.ts @@ -245,5 +245,8 @@ const budget: TranslationStrings = { 'costs.receiptLeftBehind.two': 'فشل الحفظ، وما زال هناك إيصالان مرفوعان. احذفهما من تبويب الملفات.', 'costs.receiptLeftBehind.few': 'فشل الحفظ، وما زال هناك {count} إيصالات مرفوعة. احذفها من تبويب الملفات.', 'costs.receiptLeftBehind.many': 'فشل الحفظ، وما زال هناك {count} إيصالًا مرفوعًا. احذفها من تبويب الملفات.', + 'costs.receiptsNeedConnection': + 'يحتاج رفع الإيصالات إلى اتصال. أزل الإيصالات الجديدة لحفظ المصروف دون اتصال، أو احفظ عندما تعود متصلاً.', + 'costs.paymentQueued': 'أنت غير متصل. تم حفظ الدفعة وستظهر عندما يعود TREK للاتصال.', }; export default budget; diff --git a/shared/src/i18n/az/budget.ts b/shared/src/i18n/az/budget.ts index 35258525e..4cd81cedc 100644 --- a/shared/src/i18n/az/budget.ts +++ b/shared/src/i18n/az/budget.ts @@ -229,6 +229,9 @@ const budget: TranslationStrings = { 'Yadda saxlamaq mümkün olmadı və yüklənmiş {count} qəbz hələ də qalır. Onları Fayllar bölməsindən silin.', 'costs.receiptLeftBehind.one': 'Yadda saxlamaq mümkün olmadı və yüklənmiş {count} qəbz hələ də qalır. Onu Fayllar bölməsindən silin.', + 'costs.receiptsNeedConnection': + 'Qəbzlərin yüklənməsi üçün bağlantı lazımdır. Xərci oflayn saxlamaq üçün yeni qəbzləri silin və ya yenidən onlayn olduqda saxlayın.', + 'costs.paymentQueued': 'Siz oflaynsınız. Ödəniş saxlanıldı və TREK yenidən onlayn olduqda görünəcək.', }; export default budget; diff --git a/shared/src/i18n/br/budget.ts b/shared/src/i18n/br/budget.ts index 373b0213c..a4148d85e 100644 --- a/shared/src/i18n/br/budget.ts +++ b/shared/src/i18n/br/budget.ts @@ -207,5 +207,8 @@ const budget: TranslationStrings = { 'Falha ao salvar, e {count} comprovantes enviados ainda estão lá. Remova-os na aba Arquivos.', 'costs.receiptLeftBehind.one': 'Falha ao salvar, e {count} comprovante enviado ainda está lá. Remova-o na aba Arquivos.', + 'costs.receiptsNeedConnection': + 'Os recibos precisam de conexão para serem enviados. Remova os novos para salvar a despesa offline, ou salve quando estiver online de novo.', + 'costs.paymentQueued': 'Você está offline. O pagamento foi salvo e aparece quando o TREK voltar a ficar online.', }; export default budget; diff --git a/shared/src/i18n/ca/budget.ts b/shared/src/i18n/ca/budget.ts index db0fecd01..7000ea7ea 100644 --- a/shared/src/i18n/ca/budget.ts +++ b/shared/src/i18n/ca/budget.ts @@ -207,6 +207,9 @@ const budget: TranslationStrings = { "No s'ha pogut desar i encara hi ha {count} rebuts pujats. Elimina'ls a la pestanya Fitxers.", 'costs.receiptLeftBehind.one': "No s'ha pogut desar i encara hi ha {count} rebut pujat. Elimina'l a la pestanya Fitxers.", + 'costs.receiptsNeedConnection': + 'Els rebuts necessiten connexió per pujar-se. Treu els nous per desar la despesa sense connexió, o desa quan tornis a estar en línia.', + 'costs.paymentQueued': "No tens connexió. El pagament s'ha desat i apareixerà quan TREK torni a estar en línia.", }; export default budget; diff --git a/shared/src/i18n/cs/budget.ts b/shared/src/i18n/cs/budget.ts index faa390741..e0cef225e 100644 --- a/shared/src/i18n/cs/budget.ts +++ b/shared/src/i18n/cs/budget.ts @@ -217,5 +217,8 @@ const budget: TranslationStrings = { 'Uložení selhalo, {count} nahraná účtenka tam stále je. Odstraňte ji v záložce Soubory.', 'costs.receiptLeftBehind.few': 'Uložení selhalo, {count} nahrané účtenky tam stále jsou. Odstraňte je v záložce Soubory.', + 'costs.receiptsNeedConnection': + 'Účtenky potřebují k nahrání připojení. Odeberte nové, abyste výdaj uložili offline, nebo uložte, až budete znovu online.', + 'costs.paymentQueued': 'Jste offline. Platba je uložená a zobrazí se, jakmile bude TREK znovu online.', }; export default budget; diff --git a/shared/src/i18n/de/budget.ts b/shared/src/i18n/de/budget.ts index 9e2b35992..8f5e61747 100644 --- a/shared/src/i18n/de/budget.ts +++ b/shared/src/i18n/de/budget.ts @@ -207,5 +207,8 @@ const budget: TranslationStrings = { 'Speichern fehlgeschlagen, {count} hochgeladene Belege liegen noch da. Im Dateien-Tab entfernen.', 'costs.receiptLeftBehind.one': 'Speichern fehlgeschlagen, {count} hochgeladener Beleg liegt noch da. Im Dateien-Tab entfernen.', + 'costs.receiptsNeedConnection': + 'Belege brauchen zum Hochladen eine Verbindung. Entferne die neuen, um die Ausgabe offline zu speichern, oder speichere, sobald du wieder online bist.', + 'costs.paymentQueued': 'Du bist offline. Die Zahlung ist gespeichert und erscheint, sobald TREK wieder online ist.', }; export default budget; diff --git a/shared/src/i18n/en/budget.ts b/shared/src/i18n/en/budget.ts index 106b3b49a..8ac28187c 100644 --- a/shared/src/i18n/en/budget.ts +++ b/shared/src/i18n/en/budget.ts @@ -207,6 +207,9 @@ const budget: TranslationStrings = { 'Save failed, and {count} uploaded receipts are still there. Remove them in the Files tab.', 'costs.receiptLeftBehind.one': 'Save failed, and {count} uploaded receipt is still there. Remove it in the Files tab.', + 'costs.receiptsNeedConnection': + 'Receipts need a connection to upload. Remove the new ones to save the expense offline, or save once you are back online.', + 'costs.paymentQueued': 'You are offline. The payment is saved and shows up once TREK is back online.', }; export default budget; diff --git a/shared/src/i18n/es/budget.ts b/shared/src/i18n/es/budget.ts index 3a05527d7..bbfa8b557 100644 --- a/shared/src/i18n/es/budget.ts +++ b/shared/src/i18n/es/budget.ts @@ -205,5 +205,8 @@ const budget: TranslationStrings = { 'costs.viewReceipt': 'Ver recibo', 'costs.receiptLeftBehind': 'No se pudo guardar y quedan {count} recibos subidos. Elimínalos en la pestaña Archivos.', 'costs.receiptLeftBehind.one': 'No se pudo guardar y queda {count} recibo subido. Elimínalo en la pestaña Archivos.', + 'costs.receiptsNeedConnection': + 'Los recibos necesitan conexión para subirse. Quita los nuevos para guardar el gasto sin conexión, o guarda cuando vuelvas a estar en línea.', + 'costs.paymentQueued': 'Estás sin conexión. El pago está guardado y aparecerá cuando TREK vuelva a estar en línea.', }; export default budget; diff --git a/shared/src/i18n/et/budget.ts b/shared/src/i18n/et/budget.ts index 584bc8c23..e86d68c35 100644 --- a/shared/src/i18n/et/budget.ts +++ b/shared/src/i18n/et/budget.ts @@ -207,6 +207,9 @@ const budget: TranslationStrings = { 'Salvestamine ebaõnnestus ja {count} üleslaaditud kviitungit on endiselt alles. Eemalda need vahekaardil Failid.', 'costs.receiptLeftBehind.one': 'Salvestamine ebaõnnestus ja {count} üleslaaditud kviitung on endiselt alles. Eemalda see vahekaardil Failid.', + 'costs.receiptsNeedConnection': + 'Tšekkide üleslaadimiseks on vaja ühendust. Eemalda uued, et kulu võrguühenduseta salvestada, või salvesta, kui oled taas võrgus.', + 'costs.paymentQueued': 'Oled võrguühenduseta. Makse on salvestatud ja ilmub, kui TREK on taas võrgus.', }; export default budget; diff --git a/shared/src/i18n/fr/budget.ts b/shared/src/i18n/fr/budget.ts index 0b525b586..7935f2cb4 100644 --- a/shared/src/i18n/fr/budget.ts +++ b/shared/src/i18n/fr/budget.ts @@ -208,5 +208,8 @@ const budget: TranslationStrings = { "Échec de l'enregistrement : {count} reçus importés sont toujours là. Supprimez-les dans l'onglet Fichiers.", 'costs.receiptLeftBehind.one': "Échec de l'enregistrement : {count} reçu importé est toujours là. Supprimez-le dans l'onglet Fichiers.", + 'costs.receiptsNeedConnection': + "Les justificatifs ont besoin d'une connexion pour être envoyés. Retirez les nouveaux pour enregistrer la dépense hors ligne, ou enregistrez une fois de retour en ligne.", + 'costs.paymentQueued': 'Vous êtes hors ligne. Le paiement est enregistré et apparaîtra dès que TREK sera de nouveau en ligne.', }; export default budget; diff --git a/shared/src/i18n/gr/budget.ts b/shared/src/i18n/gr/budget.ts index 0ea44a0dc..b64c189c6 100644 --- a/shared/src/i18n/gr/budget.ts +++ b/shared/src/i18n/gr/budget.ts @@ -209,5 +209,8 @@ const budget: TranslationStrings = { 'Η αποθήκευση απέτυχε και {count} ανεβασμένες αποδείξεις παραμένουν. Διαγράψτε τες στην καρτέλα Αρχεία.', 'costs.receiptLeftBehind.one': 'Η αποθήκευση απέτυχε και {count} ανεβασμένη απόδειξη παραμένει. Διαγράψτε τη στην καρτέλα Αρχεία.', + 'costs.receiptsNeedConnection': + 'Οι αποδείξεις χρειάζονται σύνδεση για να ανέβουν. Αφαίρεσε τις νέες για να αποθηκεύσεις το έξοδο εκτός σύνδεσης ή αποθήκευσε όταν είσαι ξανά online.', + 'costs.paymentQueued': 'Είσαι εκτός σύνδεσης. Η πληρωμή αποθηκεύτηκε και θα εμφανιστεί μόλις το TREK συνδεθεί ξανά.', }; export default budget; diff --git a/shared/src/i18n/hu/budget.ts b/shared/src/i18n/hu/budget.ts index bb823b7ff..f942bac4f 100644 --- a/shared/src/i18n/hu/budget.ts +++ b/shared/src/i18n/hu/budget.ts @@ -208,5 +208,8 @@ const budget: TranslationStrings = { 'costs.receiptLeftBehind': 'A mentés nem sikerült, és {count} feltöltött nyugta még ott van. Töröld őket a Fájlok fülön.', 'costs.receiptLeftBehind.one': 'A mentés nem sikerült, és a feltöltött nyugta még ott van. Töröld a Fájlok fülön.', + 'costs.receiptsNeedConnection': + 'A nyugták feltöltéséhez kapcsolat kell. Távolítsd el az újakat, hogy offline mentsd a kiadást, vagy ments, amikor újra online vagy.', + 'costs.paymentQueued': 'Offline vagy. A fizetés el van mentve, és megjelenik, amint a TREK újra online lesz.', }; export default budget; diff --git a/shared/src/i18n/id/budget.ts b/shared/src/i18n/id/budget.ts index 6ada99ed4..979e29c18 100644 --- a/shared/src/i18n/id/budget.ts +++ b/shared/src/i18n/id/budget.ts @@ -195,5 +195,8 @@ const budget: TranslationStrings = { 'costs.deleteReceipt': 'Hapus kuitansi', 'costs.viewReceipt': 'Lihat kuitansi', 'costs.receiptLeftBehind': 'Gagal menyimpan, tetapi {count} kuitansi yang diunggah masih ada. Hapus di tab File.', + 'costs.receiptsNeedConnection': + 'Struk perlu koneksi untuk diunggah. Hapus struk baru untuk menyimpan pengeluaran secara offline, atau simpan saat kamu kembali online.', + 'costs.paymentQueued': 'Kamu sedang offline. Pembayaran sudah disimpan dan akan muncul begitu TREK kembali online.', }; export default budget; diff --git a/shared/src/i18n/it/budget.ts b/shared/src/i18n/it/budget.ts index 6bb93e738..937386311 100644 --- a/shared/src/i18n/it/budget.ts +++ b/shared/src/i18n/it/budget.ts @@ -207,5 +207,8 @@ const budget: TranslationStrings = { 'Salvataggio non riuscito e {count} ricevute caricate sono ancora lì. Rimuovile nella scheda File.', 'costs.receiptLeftBehind.one': 'Salvataggio non riuscito e {count} ricevuta caricata è ancora lì. Rimuovila nella scheda File.', + 'costs.receiptsNeedConnection': + 'Le ricevute hanno bisogno di una connessione per essere caricate. Rimuovi quelle nuove per salvare la spesa offline, oppure salva quando sei di nuovo online.', + 'costs.paymentQueued': 'Sei offline. Il pagamento è salvato e comparirà quando TREK tornerà online.', }; export default budget; diff --git a/shared/src/i18n/ja/budget.ts b/shared/src/i18n/ja/budget.ts index 45dca4f75..73661f3d5 100644 --- a/shared/src/i18n/ja/budget.ts +++ b/shared/src/i18n/ja/budget.ts @@ -196,5 +196,8 @@ const budget: TranslationStrings = { 'costs.viewReceipt': 'レシートを表示', 'costs.receiptLeftBehind': '保存に失敗し、アップロード済みのレシートが{count}件残っています。「ファイル」タブで削除してください。', + 'costs.receiptsNeedConnection': + 'レシートのアップロードには接続が必要です。オフラインで経費を保存するには新しいレシートを外すか、オンラインに戻ってから保存してください。', + 'costs.paymentQueued': 'オフラインです。支払いは保存され、TREK がオンラインに戻ると表示されます。', }; export default budget; diff --git a/shared/src/i18n/ko/budget.ts b/shared/src/i18n/ko/budget.ts index 58e378e0b..7a87ea284 100644 --- a/shared/src/i18n/ko/budget.ts +++ b/shared/src/i18n/ko/budget.ts @@ -195,5 +195,8 @@ const budget: TranslationStrings = { 'costs.deleteReceipt': '영수증 삭제', 'costs.viewReceipt': '영수증 보기', 'costs.receiptLeftBehind': '저장에 실패했고 업로드된 영수증 {count}개가 남아 있습니다. 파일 탭에서 삭제하세요.', + 'costs.receiptsNeedConnection': + '영수증을 업로드하려면 연결이 필요합니다. 오프라인으로 지출을 저장하려면 새 영수증을 제거하거나, 다시 온라인이 되면 저장하세요.', + 'costs.paymentQueued': '오프라인 상태입니다. 결제가 저장되었으며 TREK가 다시 온라인이 되면 표시됩니다.', }; export default budget; diff --git a/shared/src/i18n/nl/budget.ts b/shared/src/i18n/nl/budget.ts index 3cd6cf1f0..35595ff1a 100644 --- a/shared/src/i18n/nl/budget.ts +++ b/shared/src/i18n/nl/budget.ts @@ -208,5 +208,8 @@ const budget: TranslationStrings = { 'Opslaan mislukt en er staan nog {count} geüploade bonnen. Verwijder ze op het tabblad Bestanden.', 'costs.receiptLeftBehind.one': 'Opslaan mislukt en er staat nog {count} geüploade bon. Verwijder hem op het tabblad Bestanden.', + 'costs.receiptsNeedConnection': + 'Bonnetjes hebben een verbinding nodig om te uploaden. Verwijder de nieuwe om de uitgave offline op te slaan, of sla op zodra je weer online bent.', + 'costs.paymentQueued': 'Je bent offline. De betaling is opgeslagen en verschijnt zodra TREK weer online is.', }; export default budget; diff --git a/shared/src/i18n/pl/budget.ts b/shared/src/i18n/pl/budget.ts index 36cb0ccfc..62e038c1e 100644 --- a/shared/src/i18n/pl/budget.ts +++ b/shared/src/i18n/pl/budget.ts @@ -228,5 +228,8 @@ const budget: TranslationStrings = { 'Zapis nie powiódł się, a {count} przesłane paragony wciąż tam są. Usuń je w zakładce Pliki.', 'costs.receiptLeftBehind.many': 'Zapis nie powiódł się, a {count} przesłanych paragonów wciąż tam jest. Usuń je w zakładce Pliki.', + 'costs.receiptsNeedConnection': + 'Paragony wymagają połączenia do przesłania. Usuń nowe, aby zapisać wydatek offline, albo zapisz po powrocie online.', + 'costs.paymentQueued': 'Jesteś offline. Płatność została zapisana i pojawi się, gdy TREK znów będzie online.', }; export default budget; diff --git a/shared/src/i18n/ru/budget.ts b/shared/src/i18n/ru/budget.ts index f9c3cfcf4..519f68f86 100644 --- a/shared/src/i18n/ru/budget.ts +++ b/shared/src/i18n/ru/budget.ts @@ -228,5 +228,8 @@ const budget: TranslationStrings = { 'Не удалось сохранить, {count} загруженных чека остались. Удалите их на вкладке «Файлы».', 'costs.receiptLeftBehind.many': 'Не удалось сохранить, {count} загруженных чеков остались. Удалите их на вкладке «Файлы».', + 'costs.receiptsNeedConnection': + 'Для загрузки чеков нужно подключение. Удалите новые, чтобы сохранить расход офлайн, или сохраните, когда снова будете онлайн.', + 'costs.paymentQueued': 'Вы офлайн. Платёж сохранён и появится, когда TREK снова будет онлайн.', }; export default budget; diff --git a/shared/src/i18n/sk/budget.ts b/shared/src/i18n/sk/budget.ts index 1aa675d01..7e231a295 100644 --- a/shared/src/i18n/sk/budget.ts +++ b/shared/src/i18n/sk/budget.ts @@ -218,6 +218,9 @@ const budget: TranslationStrings = { 'Uloženie zlyhalo a {count} nahraná účtenka tam stále je. Odstráňte ju v záložke Súbory.', 'costs.receiptLeftBehind.few': 'Uloženie zlyhalo a {count} nahrané účtenky tam stále sú. Odstráňte ich v záložke Súbory.', + 'costs.receiptsNeedConnection': + 'Účtenky potrebujú na nahratie pripojenie. Odstráňte nové, aby ste výdavok uložili offline, alebo uložte, keď budete znova online.', + 'costs.paymentQueued': 'Ste offline. Platba je uložená a zobrazí sa, keď bude TREK znova online.', }; export default budget; diff --git a/shared/src/i18n/sv/budget.ts b/shared/src/i18n/sv/budget.ts index 565fa4d0f..8d100bd59 100644 --- a/shared/src/i18n/sv/budget.ts +++ b/shared/src/i18n/sv/budget.ts @@ -207,5 +207,8 @@ const budget: TranslationStrings = { 'Sparandet misslyckades och {count} uppladdade kvitton finns kvar. Ta bort dem under Filer.', 'costs.receiptLeftBehind.one': 'Sparandet misslyckades och {count} uppladdat kvitto finns kvar. Ta bort det under Filer.', + 'costs.receiptsNeedConnection': + 'Kvitton behöver en anslutning för att laddas upp. Ta bort de nya för att spara utgiften offline, eller spara när du är online igen.', + 'costs.paymentQueued': 'Du är offline. Betalningen är sparad och visas när TREK är online igen.', }; export default budget; diff --git a/shared/src/i18n/th/budget.ts b/shared/src/i18n/th/budget.ts index a1e9a9992..ac2b6c148 100644 --- a/shared/src/i18n/th/budget.ts +++ b/shared/src/i18n/th/budget.ts @@ -195,6 +195,9 @@ const budget: TranslationStrings = { 'costs.deleteReceipt': 'ลบใบเสร็จ', 'costs.viewReceipt': 'ดูใบเสร็จ', 'costs.receiptLeftBehind': 'บันทึกไม่สำเร็จ และยังมีใบเสร็จที่อัปโหลด {count} รายการอยู่ ลบได้ในแท็บไฟล์', + 'costs.receiptsNeedConnection': + 'การอัปโหลดใบเสร็จต้องใช้การเชื่อมต่อ ลบใบเสร็จใหม่ออกเพื่อบันทึกค่าใช้จ่ายแบบออฟไลน์ หรือบันทึกเมื่อกลับมาออนไลน์', + 'costs.paymentQueued': 'คุณออฟไลน์อยู่ การชำระเงินถูกบันทึกแล้วและจะแสดงเมื่อ TREK กลับมาออนไลน์', }; export default budget; diff --git a/shared/src/i18n/tr/budget.ts b/shared/src/i18n/tr/budget.ts index 2369c0d26..1d5c5a17d 100644 --- a/shared/src/i18n/tr/budget.ts +++ b/shared/src/i18n/tr/budget.ts @@ -208,5 +208,8 @@ const budget: TranslationStrings = { 'Kaydetme başarısız oldu ve yüklenen {count} fiş hâlâ duruyor. Dosyalar sekmesinden silin.', 'costs.receiptLeftBehind.one': 'Kaydetme başarısız oldu ve yüklenen {count} fiş hâlâ duruyor. Dosyalar sekmesinden silin.', + 'costs.receiptsNeedConnection': + 'Fişlerin yüklenmesi için bağlantı gerekir. Harcamayı çevrimdışı kaydetmek için yenilerini kaldırın ya da tekrar çevrimiçi olduğunuzda kaydedin.', + 'costs.paymentQueued': 'Çevrimdışısınız. Ödeme kaydedildi ve TREK yeniden çevrimiçi olduğunda görünecek.', }; export default budget; diff --git a/shared/src/i18n/uk/budget.ts b/shared/src/i18n/uk/budget.ts index 2c091f4d8..809fadeca 100644 --- a/shared/src/i18n/uk/budget.ts +++ b/shared/src/i18n/uk/budget.ts @@ -229,5 +229,8 @@ const budget: TranslationStrings = { 'Не вдалося зберегти, а {count} завантажені чеки залишилися. Видаліть їх на вкладці «Файли».', 'costs.receiptLeftBehind.many': 'Не вдалося зберегти, а {count} завантажених чеків залишилося. Видаліть їх на вкладці «Файли».', + 'costs.receiptsNeedConnection': + "Для завантаження чеків потрібне з'єднання. Видаліть нові, щоб зберегти витрату офлайн, або збережіть, коли знову будете онлайн.", + 'costs.paymentQueued': "Ви офлайн. Платіж збережено, і він з'явиться, коли TREK знову буде онлайн.", }; export default budget; diff --git a/shared/src/i18n/vi/budget.ts b/shared/src/i18n/vi/budget.ts index f9473700d..515054a30 100644 --- a/shared/src/i18n/vi/budget.ts +++ b/shared/src/i18n/vi/budget.ts @@ -195,6 +195,9 @@ const budget: TranslationStrings = { 'costs.deleteReceipt': 'Xóa hóa đơn', 'costs.viewReceipt': 'Xem hóa đơn', 'costs.receiptLeftBehind': 'Không lưu được, và {count} hóa đơn đã tải lên vẫn còn đó. Hãy vào tab Tập tin để xóa.', + 'costs.receiptsNeedConnection': + 'Cần có kết nối để tải hóa đơn lên. Gỡ các hóa đơn mới để lưu khoản chi khi ngoại tuyến, hoặc lưu khi bạn trực tuyến trở lại.', + 'costs.paymentQueued': 'Bạn đang ngoại tuyến. Khoản thanh toán đã được lưu và sẽ hiện ra khi TREK trực tuyến trở lại.', }; export default budget; diff --git a/shared/src/i18n/zh-TW/budget.ts b/shared/src/i18n/zh-TW/budget.ts index 58484d09c..1248a0c1d 100644 --- a/shared/src/i18n/zh-TW/budget.ts +++ b/shared/src/i18n/zh-TW/budget.ts @@ -194,5 +194,8 @@ const budget: TranslationStrings = { 'costs.deleteReceipt': '刪除收據', 'costs.viewReceipt': '查看收據', 'costs.receiptLeftBehind': '儲存失敗,已上傳的 {count} 張收據仍留在系統中。請到「檔案」分頁刪除。', + 'costs.receiptsNeedConnection': + '上傳收據需要網路連線。移除新收據即可離線儲存這筆支出,或在恢復連線後再儲存。', + 'costs.paymentQueued': '你目前處於離線狀態。付款已儲存,TREK 恢復連線後就會顯示。', }; export default budget; diff --git a/shared/src/i18n/zh/budget.ts b/shared/src/i18n/zh/budget.ts index afa1b93cc..5dcea46b9 100644 --- a/shared/src/i18n/zh/budget.ts +++ b/shared/src/i18n/zh/budget.ts @@ -194,5 +194,8 @@ const budget: TranslationStrings = { 'costs.deleteReceipt': '删除收据', 'costs.viewReceipt': '查看收据', 'costs.receiptLeftBehind': '保存失败,但已上传的 {count} 张收据仍然保留。请在“文件”标签页中删除。', + 'costs.receiptsNeedConnection': + '上传收据需要网络连接。移除新收据即可离线保存这笔支出,或在恢复联网后再保存。', + 'costs.paymentQueued': '你当前处于离线状态。付款已保存,TREK 恢复联网后就会显示。', }; export default budget;