diff --git a/e2e/conversion-balances.spec.ts b/e2e/conversion-balances.spec.ts index b663e98..96ba2b7 100644 --- a/e2e/conversion-balances.spec.ts +++ b/e2e/conversion-balances.spec.ts @@ -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({ @@ -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(); @@ -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/); }); diff --git a/src/balances/ledgerService.test.ts b/src/balances/ledgerService.test.ts index 2dd50f0..46e2559 100644 --- a/src/balances/ledgerService.test.ts +++ b/src/balances/ledgerService.test.ts @@ -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({ @@ -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(), diff --git a/src/balances/ledgerService.ts b/src/balances/ledgerService.ts index 815293b..445c9c9 100644 --- a/src/balances/ledgerService.ts +++ b/src/balances/ledgerService.ts @@ -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. @@ -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; diff --git a/src/conversions/backdatedOpeningBalance.test.ts b/src/conversions/backdatedOpeningBalance.test.ts new file mode 100644 index 0000000..ccb99c4 --- /dev/null +++ b/src/conversions/backdatedOpeningBalance.test.ts @@ -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); + }); +});