diff --git a/docs/PROJECT_STATE.md b/docs/PROJECT_STATE.md index 0a2939c..0a651d6 100644 --- a/docs/PROJECT_STATE.md +++ b/docs/PROJECT_STATE.md @@ -1,6 +1,6 @@ # Ravel Project State -Last updated: 2026-09-11 +Last updated: 2026-09-17 ## Current status @@ -28,6 +28,12 @@ Optional Supabase Auth and sync can bind a device ledger to an account. Canonica Groq Smart Categories are optional and non-blocking. Telegram and capture-token entry are optional server-mediated integrations. Historical exchange-rate and report valuation remain explicit rather than silently inventing rates. +## Post-release correctness fix in this branch + +The Quick Capture server balance calculation is being restored to the same opening-checkpoint semantics as the canonical local ledger. Opening checkpoints are baseline snapshots: activity created after the checkpoint must be replayed even when its business date is backdated before or onto the opening date. Ambiguous before/after ordering remains limited to reconciliation checkpoints, where an exact same-day historical ordering can genuinely be unknown. + +The fix is intentionally limited to the existing deployed `taptrack_calculated_balance` compatibility RPC plus regression coverage for backdated transactions around opening checkpoints. Existing finance records are not rewritten. + ## Rebrand compatibility boundary Historical identifiers that existing data or deployed infrastructure already depends on remain intentionally unchanged. This includes `TapTrackDB`, the `taptrack-backup` V2 wire-format identifier, deployed Supabase RPC names, legacy environment/storage keys, old service-worker cache prefixes, and previously issued `taptrack_capture_` credentials. @@ -46,4 +52,4 @@ New user-facing copy, generated filenames, package identity, PWA metadata, curre ## Current work boundary -Core V1 engineering is frozen unless a correctness, security, or operational defect is found. Current work should focus on presentation, real product screenshots using synthetic data, portfolio/case-study material, LifeOS integration surfaces, and narrowly scoped release-hygiene fixes. \ No newline at end of file +Core V1 engineering is frozen unless a correctness, security, or operational defect is found. Current work should focus on presentation, real product screenshots using synthetic data, portfolio/case-study material, LifeOS integration surfaces, and narrowly scoped release-hygiene fixes. diff --git a/src/transactions/backdatedOpeningBalance.test.ts b/src/transactions/backdatedOpeningBalance.test.ts new file mode 100644 index 0000000..8fcc497 --- /dev/null +++ b/src/transactions/backdatedOpeningBalance.test.ts @@ -0,0 +1,59 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { RavelDatabase, ensureDatabaseSeeded } from '@/database'; +import { getBalanceId } from '@/defaultData'; +import { createTransaction } from './createTransaction'; + +let database: RavelDatabase; + +beforeEach(async () => { + database = new RavelDatabase(`RavelBackdatedOpeningTransaction-${crypto.randomUUID()}`); + await ensureDatabaseSeeded(database); + + const balanceId = getBalanceId('TRY', 'card'); + const effectiveAt = '2026-01-10T12:00:00.000Z'; + await database.balanceCheckpoints.put({ + id: `opening-${balanceId}`, + balanceId, + currency: 'TRY', + method: 'card', + kind: 'opening', + observedAmount: 500, + deltaAmount: 500, + date: '2026-01-10', + effectiveAt, + month: '2026-01', + createdAt: effectiveAt, + updatedAt: effectiveAt, + }); +}); + +afterEach(async () => { + await database.delete(); +}); + +describe('backdated transactions across an opening checkpoint', () => { + it.each([ + ['before the opening date', '2026-01-09'], + ['on the opening date', '2026-01-10'], + ])('replays activity created later even when dated %s', async (_label, date) => { + const now = new Date('2026-02-01T10:00:00.000Z'); + const transaction = await createTransaction( + { + type: 'expense', + amount: 125, + currency: 'TRY', + title: 'Historical expense', + categoryId: 'cat-food', + method: 'card', + date, + }, + database, + now + ); + + expect(transaction.occurredAt).toBeUndefined(); + await expect(database.balances.get(getBalanceId('TRY', 'card'))).resolves.toMatchObject({ + amount: 375, + }); + }); +}); diff --git a/supabase/migrations/20260917_fix_capture_opening_balance_parity.sql b/supabase/migrations/20260917_fix_capture_opening_balance_parity.sql new file mode 100644 index 0000000..4b97833 --- /dev/null +++ b/supabase/migrations/20260917_fix_capture_opening_balance_parity.sql @@ -0,0 +1,138 @@ +-- Restore parity between server-side capture balance checks and the canonical +-- local ledger semantics for opening checkpoints. +-- +-- Opening checkpoints are snapshots of the ledger state that existed when the +-- checkpoint was created. Any activity created later must be replayed even if +-- its business date is backdated before or onto the opening date. +-- Reconciliation checkpoints remain absolute observations and still require +-- explicit before/after ordering for ambiguous same-day historical activity. + +create or replace function public.taptrack_calculated_balance( + target_user_id uuid, + target_currency text, + target_method text +) +returns numeric +language plpgsql +security definer +set search_path = pg_catalog +as $$ +declare + checkpoint_amount numeric := 0; + checkpoint_date text; + checkpoint_effective_at timestamptz; + checkpoint_kind text; + has_checkpoint boolean := false; + ambiguous_count bigint := 0; + transaction_delta numeric := 0; + conversion_delta numeric := 0; +begin + if target_currency !~ '^[A-Z]{3}$' then + raise exception 'Unsupported TapTrack currency: %', target_currency; + end if; + if target_method not in ('cash', 'card') then + raise exception 'Unsupported TapTrack method: %', target_method; + end if; + + select c.observed_amount, c.date, c.effective_at, c.kind + into checkpoint_amount, checkpoint_date, checkpoint_effective_at, checkpoint_kind + from public.balance_checkpoints c + where c.user_id = target_user_id + and c.currency = target_currency + and c.method = target_method + and c.deleted_at is null + order by c.effective_at desc, c.id desc + limit 1; + + has_checkpoint := found; + if not has_checkpoint then + checkpoint_amount := 0; + elsif checkpoint_kind not in ('opening', 'reconciliation') then + raise exception 'Unsupported TapTrack checkpoint kind: %', checkpoint_kind; + end if; + + if has_checkpoint and checkpoint_kind = 'reconciliation' then + select + (select count(*) + from public.transactions t + where t.user_id = target_user_id + and t.currency = target_currency + and t.method = target_method + and t.deleted_at is null + and t.occurred_at is null + and t.created_at > checkpoint_effective_at + and t.date = checkpoint_date) + + + (select count(*) + from public.conversions c + where c.user_id = target_user_id + and c.deleted_at is null + and c.occurred_at is null + and c.created_at > checkpoint_effective_at + and c.date = checkpoint_date + and ((c.from_currency = target_currency and c.from_method = target_method) + or (c.to_currency = target_currency and c.to_method = target_method))) + into ambiguous_count; + + if ambiguous_count > 0 then + raise exception 'TapTrack ledger contains unresolved same-day ordering for % %', target_currency, target_method; + end if; + end if; + + select coalesce(sum(case when t.type = 'income' then t.amount else -t.amount end), 0) + into transaction_delta + from public.transactions t + where t.user_id = target_user_id + and t.currency = target_currency + and t.method = target_method + and t.deleted_at is null + and ( + not has_checkpoint + or (checkpoint_kind = 'opening' and t.created_at > checkpoint_effective_at) + or ( + checkpoint_kind = 'reconciliation' + and ( + (t.occurred_at is not null and t.occurred_at > checkpoint_effective_at) + or ( + t.occurred_at is null + and t.created_at > checkpoint_effective_at + and t.date > checkpoint_date + ) + ) + ) + ); + + select coalesce(sum( + (case when c.to_currency = target_currency and c.to_method = target_method then c.to_amount else 0 end) + - (case when c.from_currency = target_currency and c.from_method = target_method then c.from_amount else 0 end) + ), 0) + into conversion_delta + from public.conversions c + where c.user_id = target_user_id + and c.deleted_at is null + and ((c.from_currency = target_currency and c.from_method = target_method) + or (c.to_currency = target_currency and c.to_method = target_method)) + and ( + not has_checkpoint + or (checkpoint_kind = 'opening' and c.created_at > checkpoint_effective_at) + or ( + checkpoint_kind = 'reconciliation' + and ( + (c.occurred_at is not null and c.occurred_at > checkpoint_effective_at) + or ( + c.occurred_at is null + and c.created_at > checkpoint_effective_at + and c.date > checkpoint_date + ) + ) + ) + ); + + return checkpoint_amount + transaction_delta + conversion_delta; +end; +$$; + +revoke all on function public.taptrack_calculated_balance(uuid, text, text) + from public, anon, authenticated; +grant execute on function public.taptrack_calculated_balance(uuid, text, text) + to service_role;