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
10 changes: 8 additions & 2 deletions docs/PROJECT_STATE.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Ravel Project State

Last updated: 2026-09-11
Last updated: 2026-09-17

## Current status

Expand Down Expand Up @@ -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.
Expand All @@ -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.
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.
59 changes: 59 additions & 0 deletions src/transactions/backdatedOpeningBalance.test.ts
Original file line number Diff line number Diff line change
@@ -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,
});
});
});
138 changes: 138 additions & 0 deletions supabase/migrations/20260917_fix_capture_opening_balance_parity.sql
Original file line number Diff line number Diff line change
@@ -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;