Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 19 additions & 2 deletions e2e/conversion-balances.spec.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
import { expect, test } from '@playwright/test';

test('exchange moves money between currency balances in the real browser workflow', async ({ page }) => {
test('backdated exchange updates balances and deletion reverses it in the real browser workflow', async ({ page }) => {
test.skip(test.info().project.name !== 'mobile-390', 'One mobile viewport is enough for ledger movement evidence.');

const today = new Date().toLocaleDateString('en-CA');
const todayDate = new Date();
const today = todayDate.toLocaleDateString('en-CA');
const previousDate = new Date(todayDate);
previousDate.setDate(previousDate.getDate() - 1);
const yesterday = previousDate.toLocaleDateString('en-CA');

await page.route('**/api/exchange-rates/currencies', async (route) => {
await route.fulfill({
Expand Down Expand Up @@ -73,6 +77,9 @@ test('exchange moves money between currency balances in the real browser workflo
await destination.getByLabel('Method').selectOption('cash');
await source.getByLabel('Amount to move').fill('10');

await page.getByText('Date and note', { exact: true }).click();
await page.getByLabel('Date').fill(yesterday);

await expect(destination.getByLabel('Destination amount')).toHaveValue('400.00');
await page.getByRole('button', { name: 'Exchange USD to TRY' }).click();
await expect(page.getByText('Exchange recorded.', { exact: true })).toBeVisible();
Expand All @@ -84,4 +91,14 @@ test('exchange moves money between currency balances in the real browser workflo
const tryBalance = page.locator('[data-balance-currency="TRY"]');
await expect(usd.locator('[data-balance-method="card"]')).toContainText('90');
await expect(tryBalance.locator('[data-balance-method="cash"]')).toContainText(/1[,.]?400/);

await page.goto('/app/conversions');
await expect(page.getByText(yesterday, { exact: false })).toBeVisible();
await page.getByRole('button', { name: 'Delete', exact: true }).click();
await page.getByRole('button', { name: 'Delete move', exact: true }).click();
await expect(page.getByText('Exchange deleted.', { exact: true })).toBeVisible();

await page.goto('/app/balances');
await expect(usd.locator('[data-balance-method="card"]')).toContainText('100');
await expect(tryBalance.locator('[data-balance-method="cash"]')).toContainText(/1[,.]?000/);
});
66 changes: 65 additions & 1 deletion src/balances/ledgerService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,7 @@ describe('rebuildDerivedBalances', () => {
expect((await database.balances.get(getBalanceId('GBP', 'cash')))?.amount).toBe(380);
});

it('does not replay historical records that fall before an absolute checkpoint', async () => {
it('replays backdated records created after an opening checkpoint', async () => {
await database.balanceCheckpoints.put(checkpoint());
await database.transactions.put(
transaction({
Expand All @@ -138,9 +138,73 @@ describe('rebuildDerivedBalances', () => {

await rebuildDerivedBalances(database);

expect((await database.balances.get(getBalanceId('TRY', 'cash')))?.amount).toBe(80);
});

it('does not replay records that were already present when an opening checkpoint was created', async () => {
await database.balanceCheckpoints.put(checkpoint());
await database.transactions.put(
transaction({
date: '2026-04-30',
occurredAt: undefined,
createdAt: '2026-04-30T12:00:00.000Z',
updatedAt: '2026-04-30T12:00:00.000Z',
})
);

await rebuildDerivedBalances(database);

expect((await database.balances.get(getBalanceId('TRY', 'cash')))?.amount).toBe(100);
});

it('does not replay backdated records created after an absolute reconciliation', async () => {
await database.balanceCheckpoints.put(
checkpoint({
id: 'reconciliation-2026-05-TRY-cash',
kind: 'reconciliation',
})
);
await database.transactions.put(
transaction({
date: '2026-04-30',
occurredAt: undefined,
createdAt: '2026-05-02T12:00:00.000Z',
updatedAt: '2026-05-02T12:00:00.000Z',
})
);

await rebuildDerivedBalances(database);

expect((await database.balances.get(getBalanceId('TRY', 'cash')))?.amount).toBe(100);
});

it('replays a backdated conversion created after opening checkpoints on both balance buckets', async () => {
await database.balanceCheckpoints.bulkPut([
checkpoint(),
checkpoint({
id: 'opening-USD-card',
balanceId: getBalanceId('USD', 'card'),
currency: 'USD',
method: 'card',
observedAmount: 5,
deltaAmount: 5,
}),
]);
await database.conversions.put(
conversion({
date: '2026-04-30',
occurredAt: undefined,
createdAt: '2026-05-02T12:00:00.000Z',
updatedAt: '2026-05-02T12:00:00.000Z',
})
);

await rebuildDerivedBalances(database);

expect((await database.balances.get(getBalanceId('TRY', 'cash')))?.amount).toBe(90);
expect((await database.balances.get(getBalanceId('USD', 'card')))?.amount).toBe(6);
});

it('treats a later reconciliation as the new absolute base', async () => {
await database.balanceCheckpoints.bulkPut([
checkpoint(),
Expand Down
21 changes: 17 additions & 4 deletions src/balances/ledgerService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,13 @@ type LedgerActivity = {
/**
* Rebuilds the local balance cache from authoritative ledger records.
*
* For each balance bucket, the latest absolute checkpoint is the base. Only
* transactions/conversions that happened after that checkpoint are applied.
* For each balance bucket, the latest checkpoint is the base. Opening
* checkpoints are baseline snapshots of the ledger state that existed when
* they were created, so records created later are replayed even when their
* business date is backdated before the opening. Reconciliation checkpoints
* remain absolute observations: only activity that happened after them is
* applied.
*
* Balance buckets are derived from the ledger itself rather than a static
* currency catalog, so newly activated currencies remain first-class across
* restore, sync, and ordinary local mutations.
Expand Down Expand Up @@ -155,12 +160,20 @@ function isActivityAfterCheckpoint(
): boolean {
if (!checkpoint) return true;

if (checkpoint.kind === 'opening') {
// An opening amount is a baseline snapshot of the ledger state that existed
// when it was created. Records already present at that point are baked into
// observedAmount. Records created later were not, so they must affect the
// derived balance even when their business date is backdated.
return activity.createdAt > checkpoint.effectiveAt;
}

if (activity.occurredAt) {
return activity.occurredAt > checkpoint.effectiveAt;
}

// If the activity already existed when the absolute observation was made,
// its effect was already reflected in the balance being reconciled/migrated.
// If the activity already existed when the absolute reconciliation was made,
// its effect was already reflected in the observed balance.
if (activity.createdAt <= checkpoint.effectiveAt) return false;

if (activity.date > checkpoint.date) return true;
Expand Down
86 changes: 86 additions & 0 deletions src/conversions/backdatedOpeningBalance.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { RavelDatabase, ensureDatabaseSeeded } from '@/database';
import { getBalanceId } from '@/defaultData';
import type { Currency, Method } from '@/types';
import { createConversion, deleteConversion, updateConversion } from './conversionService';

let database: RavelDatabase;

beforeEach(async () => {
database = new RavelDatabase(`RavelBackdatedConversionTest-${crypto.randomUUID()}`);
await ensureDatabaseSeeded(database);
});

afterEach(async () => {
await database.delete();
});

async function putOpeningBalance(
currency: Currency,
method: Method,
amount: number,
effectiveAt = '2026-05-18T09:00:00.000Z'
) {
const balanceId = getBalanceId(currency, method);
await database.balanceCheckpoints.put({
id: `opening-${balanceId}`,
balanceId,
currency,
method,
kind: 'opening',
observedAmount: amount,
deltaAmount: amount,
date: '2026-05-18',
effectiveAt,
month: '2026-05',
createdAt: effectiveAt,
updatedAt: effectiveAt,
});
}

describe('backdated conversion lifecycle across an opening checkpoint', () => {
it('applies create and update to current balances and reverses them on delete', async () => {
await putOpeningBalance('USD', 'card', 100);
await putOpeningBalance('TRY', 'cash', 1000);

const created = await createConversion(
{
fromCurrency: 'USD',
toCurrency: 'TRY',
fromMethod: 'card',
toMethod: 'cash',
fromAmount: 10,
toAmount: 400,
date: '2026-05-17',
},
database,
new Date('2026-05-20T12:00:00.000Z')
);

expect((await database.balances.get(getBalanceId('USD', 'card')))?.amount).toBe(90);
expect((await database.balances.get(getBalanceId('TRY', 'cash')))?.amount).toBe(1400);

await updateConversion(
created.id,
{
fromCurrency: 'USD',
toCurrency: 'TRY',
fromMethod: 'card',
toMethod: 'cash',
fromAmount: 20,
toAmount: 800,
date: '2026-05-17',
},
database,
new Date('2026-05-20T13:00:00.000Z')
);

expect((await database.balances.get(getBalanceId('USD', 'card')))?.amount).toBe(80);
expect((await database.balances.get(getBalanceId('TRY', 'cash')))?.amount).toBe(1800);

await deleteConversion(created.id, database);

expect((await database.balances.get(getBalanceId('USD', 'card')))?.amount).toBe(100);
expect((await database.balances.get(getBalanceId('TRY', 'cash')))?.amount).toBe(1000);
});
});