mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-24 07:53:09 +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'));
|
||||
|
||||
@@ -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<ImportSourceFile, [string, string]>;
|
||||
areaPlaces!: Table<CachedAreaPlace, [string, number]>;
|
||||
tours!: Table<CachedTour, number>;
|
||||
budgetSettlements!: Table<CachedBudgetSettlement, number>;
|
||||
|
||||
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<void> {
|
||||
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();
|
||||
|
||||
@@ -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
|
||||
}) {
|
||||
|
||||
@@ -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<QueuedMutation[]> {
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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<ReturnType<typeof budgetApi.settlement>>
|
||||
|
||||
/** 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<number, { username: string; avatar_url?: string | null }>
|
||||
|
||||
/** The names the cached trip members carry, for the split rows an offline write draws. */
|
||||
async function peopleOf(tripId: number): Promise<People> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<BudgetFreezeRatesResponse> {
|
||||
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<SettlementResponse> {
|
||||
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<number> {
|
||||
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<SettlementWriteResult> {
|
||||
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<SettlementWriteResult> {
|
||||
return settlementWrite(tripId, id, 'PUT', data, () => budgetApi.updateSettlement(tripId, id, data))
|
||||
},
|
||||
|
||||
async deleteSettlement(tripId: number | string, id: number): Promise<SettlementWriteResult> {
|
||||
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<unknown>,
|
||||
): Promise<SettlementWriteResult> {
|
||||
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 }
|
||||
}
|
||||
|
||||
@@ -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) }))
|
||||
|
||||
@@ -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<void> {
|
||||
* this flush and durably for the next.
|
||||
*/
|
||||
async function remapTempId(mutation: QueuedMutation, realId: number, idMap: Map<number, number>): Promise<void> {
|
||||
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<number, number>): unknown {
|
||||
if (!body || typeof body !== 'object' || Array.isArray(body)) return body
|
||||
const fields = body as Record<string, unknown>
|
||||
let remapped: Record<string, unknown> | 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<number, number>): Promise<void> {
|
||||
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') {
|
||||
|
||||
Reference in New Issue
Block a user