mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-22 23:16:46 +00:00
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
This commit is contained in:
@@ -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<void>>;
|
||||
let deleteBudgetItem: Mock<(tripId: number | string, id: number) => Promise<void>>;
|
||||
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<void>>(async () => undefined);
|
||||
deleteBudgetItem = vi.fn<(tripId: number | string, id: number) => Promise<void>>(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));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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, string | number>) => 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 });
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<typeof import('../../api/client')>()),
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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, string | number>) => 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'));
|
||||
|
||||
Reference in New Issue
Block a user