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;