Drift detection a manuální rebuild Redis kvótových čítačů z PG usage_events — dva admin endpointy umožňují zobrazit aktuální odchylku mezi persistentní usage_events tabulkou (single source of truth pro náklady) a Redis rolling windows, a po incidentu atomicky přepočítat Redis stav pro konkrétního uživatele.
- Navazuje na UC-08001 Anthropic Call Gateway (gateway emituje usage events) a UC-08002 Mara Fatigue & Quota Enforcement (Redis rolling windows, window semantics).
- MR1 (2026-05) — single source of truth =
usage_events: před MR1 se reconciliation počítal zapi_usage_ledger, který trpěl ztrátou eventů kvůlipendingUsagemap race vAnthropicGatewayService(orchestrator + subagent v jednom conversationId, druhý event přepsal první).usage_eventsje append-only, 1 NDJSON usage event = 1 row, takže suma je správná i pro paralelní orchestrator/subagent eventy.api_usage_ledgerzůstává jako diagnostic source proinFlightCalls/errors_24h/executor_breakdown_today(UC-08004) a v MR2 bude přejmenován nacall_log. - Motivace: existuje 6 failure modes způsobujících drift — BE crash mezi PG INSERT a Redis INCRBY, swallowed
log.warnpři PG fail, Redis restart/flush, TTL expiraceglobal:monthly_used_cents(po MR1: dotaženo, admin má manuální rebuild button přesPOST /api/admin/reconciliation/rebuild-global), race dvou paralelních turnů, ztracené eventy v ledger pendingUsage map race. Žádná automatická detekce dnes neexistuje. - Window semantics (Anthropic-style): 5h okno začíná prvním eventem v daném okně, nikoli od
now() - 5h. Při rebuild se bereMIN(occurred_at)v posledních 5h jakowindow_start. Totéž platí pro weekly okno (7 dní). Pokud v daném okně neexistuje žádný event → okno neexistuje, Redis klíče se smažou. - In-flight requesty:
usage_eventsz definice neobsahuje crashed/in-flight rows (failed Anthropic call neemmituje usage event), takže filtracerequest_completed_at IS NOT NULLz pre-MR1 ledgeru tu odpadá. - Žádná Liquibase migrace pro nové tabulky — oba endpointy čtou
usage_eventsa Redis; MR1 jen přidává sloupeccost_centsdousage_events. - Žádný cron job — reconciliation je výhradně na vyžádání adminem.
- Rebuild má dvě varianty: per-user a global. Per-user rebuild aktualizuje 5h+weekly Redis okna. Global rebuild aktualizuje
global:monthly_used_cents. Oba jsou samostatné endpointy. - Rebuild je idempotentní — opakované volání konverguje do stejného stavu (PG SUM je deterministický).
- Admin role se ověřuje vzorem silent probe: non-admin dostane
401 AUTHENTICATION_FAILED, nikoli403— endpoint nedává najevo svou existenci. - Přístupné výhradně přes
/api/admin/...prefix (konzistentní s UC-08004 Admin Capacity Console).
Flow 1 — Admin čte reconciliation report
sequenceDiagram
actor Admin
Admin->>+FE: otevře /admin/reconciliation
FE->>+BE: GET /api/admin/reconciliation/report <br> Authorization: Bearer {accessToken}
BE->>BE: silent probe — ověř roli ADMIN
alt uživatel není ADMIN nebo není autentizován
BE-->>FE: 401 Unauthorized <br> ErrorResponse
end
BE->>+ReconciliationReportUseCase: execute()
ReconciliationReportUseCase->>DB: SELECT user_id, SUM(cost_cents) FROM usage_events <br> WHERE occurred_at >= now() - 7d <br> GROUP BY user_id
ReconciliationReportUseCase->>Redis: KEYS user:*:5h_used_cents <br> KEYS user:*:weekly_used_cents
ReconciliationReportUseCase->>Redis: GET global:monthly_used_cents
ReconciliationReportUseCase->>DB: SELECT SUM(cost_cents) FROM usage_events <br> WHERE occurred_at >= billing_month_start
ReconciliationReportUseCase->>ReconciliationReportUseCase: vypočítej delta pro každý user a global <br> has_drift = (delta_5h != 0) || (delta_weekly != 0)
ReconciliationReportUseCase-->>-BE: ReconciliationReport
BE->>-FE: 200 OK <br> ReconciliationReportResponse
FE->>-Admin: zobraz tabulku uživatelů s delta hodnotami <br> zvýrazni řádky s has_drift=true
GET /api/admin/reconciliation/report — bez request body, bez query params
200 OK ReconciliationReportResponse:
{
"generated_at": "2026-05-09T14:23:45Z",
"global": {
"pg_monthly_cents": 12450,
"redis_monthly_cents": 12380,
"delta_monthly_cents": 70
},
"users": [
{
"user_id": 1234,
"email": "test@talkide.app",
"pg_5h_cents": 320,
"redis_5h_cents": 320,
"delta_5h_cents": 0,
"pg_weekly_cents": 1450,
"redis_weekly_cents": 1380,
"delta_weekly_cents": 70,
"has_drift": true
},
{
"user_id": 5678,
"email": "alice@example.com",
"pg_5h_cents": 150,
"redis_5h_cents": 150,
"delta_5h_cents": 0,
"pg_weekly_cents": 800,
"redis_weekly_cents": 800,
"delta_weekly_cents": 0,
"has_drift": false
}
]
}
401 Unauthorized ErrorResponse:
{
"code": "AUTHENTICATION_FAILED",
"message": "Authentication required"
}
Flow 2 — Admin provede rebuild Redis pro konkrétního uživatele
sequenceDiagram
actor Admin
Admin->>+FE: klikne "Rebuild" u uživatele s drift <br> (confirm modal)
FE->>FE: UI guard — zobraz confirm modal <br> "Rebuild přepíše Redis state pro {email}. Potvrdit?"
Admin->>+FE: potvrdí rebuild
FE->>+BE: POST /api/admin/reconciliation/rebuild/{userId} <br> Authorization: Bearer {accessToken}
BE->>BE: silent probe — ověř roli ADMIN
alt uživatel není ADMIN nebo není autentizován
BE-->>FE: 401 Unauthorized <br> ErrorResponse
end
BE->>+ReconciliationRebuildUseCase: execute(userId)
ReconciliationRebuildUseCase->>DB: SELECT id FROM users WHERE id = ?
alt user neexistuje
ReconciliationRebuildUseCase-->>BE: throws UserNotFoundException
BE-->>FE: 404 Not Found <br> ErrorResponse
end
Note over ReconciliationRebuildUseCase: --- 5h window rebuild ---
ReconciliationRebuildUseCase->>DB: SELECT MIN(occurred_at) FROM usage_events <br> WHERE user_id = ? AND occurred_at > now() - 5h
alt žádný záznam v posledních 5h
ReconciliationRebuildUseCase->>Redis: DEL user:{id}:5h_window_start_ts <br> DEL user:{id}:5h_used_cents
else záznam nalezen → window_start_5h = MIN(occurred_at)
ReconciliationRebuildUseCase->>DB: SELECT SUM(cost_cents) FROM usage_events <br> WHERE user_id = ? AND occurred_at >= window_start_5h
ReconciliationRebuildUseCase->>Redis: Lua atomic write: <br> SET user:{id}:5h_window_start_ts = window_start_5h (TTL = 5h - elapsed) <br> SET user:{id}:5h_used_cents = sum (TTL = 5h - elapsed)
end
Note over ReconciliationRebuildUseCase: --- weekly window rebuild ---
ReconciliationRebuildUseCase->>DB: SELECT MIN(occurred_at) FROM usage_events <br> WHERE user_id = ? AND occurred_at > now() - 7d
alt žádný záznam v posledních 7d
ReconciliationRebuildUseCase->>Redis: DEL user:{id}:weekly_window_start_ts <br> DEL user:{id}:weekly_used_cents
else záznam nalezen → window_start_weekly = MIN(occurred_at)
ReconciliationRebuildUseCase->>DB: SELECT SUM(cost_cents) FROM usage_events <br> WHERE user_id = ? AND occurred_at >= window_start_weekly
ReconciliationRebuildUseCase->>Redis: Lua atomic write: <br> SET user:{id}:weekly_window_start_ts = window_start_weekly (TTL = 7d - elapsed) <br> SET user:{id}:weekly_used_cents = sum (TTL = 7d - elapsed)
end
ReconciliationRebuildUseCase->>DB: SELECT SUM(cost_cents) FROM usage_events <br> WHERE user_id = ? AND occurred_at >= billing_month_start
ReconciliationRebuildUseCase-->>-BE: SingleUserReconciliationReport
BE->>-FE: 200 OK <br> SingleUserReconciliationResponse
FE->>-Admin: zobraz updated stav uživatele (delta by měla být 0)
POST /api/admin/reconciliation/rebuild/{userId} — bez request body
200 OK SingleUserReconciliationResponse (stejný shape jako jeden users[] prvek z GET report):
{
"generated_at": "2026-05-09T14:31:12Z",
"global": {
"pg_monthly_cents": 12450,
"redis_monthly_cents": 12450,
"delta_monthly_cents": 0
},
"users": [
{
"user_id": 1234,
"email": "test@talkide.app",
"pg_5h_cents": 320,
"redis_5h_cents": 320,
"delta_5h_cents": 0,
"pg_weekly_cents": 1450,
"redis_weekly_cents": 1450,
"delta_weekly_cents": 0,
"has_drift": false
}
]
}
401 Unauthorized ErrorResponse:
{
"code": "AUTHENTICATION_FAILED",
"message": "Authentication required"
}
404 Not Found ErrorResponse:
{
"code": "NOT_FOUND_USER",
"message": "User not found"
}
Flow 3 — Admin provede rebuild globálního monthly Redis counteru
sequenceDiagram
actor Admin
Admin->>+FE: klikne "Rebuild Global" v GlobalDriftCard
FE->>+BE: POST /api/admin/reconciliation/rebuild-global <br> Authorization: Bearer {accessToken}
BE->>BE: silent probe — ověř roli ADMIN
alt uživatel není ADMIN nebo není autentizován
BE-->>FE: 401 Unauthorized <br> ErrorResponse
end
BE->>+RebuildGlobalQuotaUseCase: invoke()
RebuildGlobalQuotaUseCase->>DB: SELECT SUM(cost_cents) FROM usage_events <br> WHERE occurred_at >= billing_month_start (UTC)
RebuildGlobalQuotaUseCase->>RebuildGlobalQuotaUseCase: secondsUntilEndOfMonth = seconds until <br> 1st day of next UTC month
RebuildGlobalQuotaUseCase->>Redis: Lua atomic write: <br> SET global:monthly_used_cents = pgMonthlyCents (TTL = secondsUntilEndOfMonth)
RebuildGlobalQuotaUseCase->>+GetReconciliationReportUseCase: invoke()
GetReconciliationReportUseCase-->>-RebuildGlobalQuotaUseCase: ReconciliationReport
RebuildGlobalQuotaUseCase-->>-BE: ReconciliationReport (delta_monthly_cents = 0)
BE->>-FE: 200 OK <br> ReconciliationReportResponse
FE->>-Admin: GlobalDriftCard aktualizuje delta na 0 (zelená)
POST /api/admin/reconciliation/rebuild-global — bez request body
200 OK ReconciliationReportResponse (stejný shape jako GET /report):
{
"generated_at": "2026-05-09T14:35:00Z",
"global": {
"pg_monthly_cents": 12450,
"redis_monthly_cents": 12450,
"delta_monthly_cents": 0
},
"users": [ ... ]
}
401 Unauthorized ErrorResponse:
{
"code": "AUTHENTICATION_FAILED",
"message": "Authentication required"
}
Redis Keys Reference
Tento UC pracuje s Redis klíči definovanými v UC-08002:
| Key | Typ | TTL | Popis |
|---|---|---|---|
user:{id}:5h_window_start_ts | String (epoch ms) | 5 hodin | Timestamp startu aktuálního 5h okna |
user:{id}:5h_used_cents | Integer | 5 hodin | Spotřeba v aktuálním 5h okně (centy) |
user:{id}:weekly_window_start_ts | String (epoch ms) | 7 dní | Timestamp startu aktuálního weekly okna |
user:{id}:weekly_used_cents | Integer | 7 dní | Spotřeba v aktuálním weekly okně (centy) |
global:monthly_used_cents | Integer | do konce billing měsíce | Globální měsíční spotřeba — rebuilds přes POST /api/admin/reconciliation/rebuild-global |
Rebuild atomicky přepíše window_start_ts i used_cents pro dané okno v jediném Lua scriptu — race-condition free i při souběžně probíhajícím Mara turnu.
Frontend
Validations
| Prvek | Pravidlo | Poznámka |
|---|---|---|
| Rebuild tlačítko | Zobrazit pouze pokud has_drift = true | Disabled tlačítko pro uživatele bez driftu, aby se předešlo zbytečným API voláním |
| Confirm modal před rebuild | Povinný confirm krok | Zobrazit email uživatele a aktuální delta hodnoty; bez potvrzení rebuild nespustit |
| Rebuild tlačítko (stav) | Disabled po dobu inflight requestu | Zabránit dvojkliku — tlačítko disabled po kliknutí dokud POST /rebuild/{userId} nevrátí odpověď |
UX Notes
- Report se načítá při otevření
/admin/reconciliationstránky a lze ho ručně obnovit tlačítkem “Refresh”. - Řádky s
has_drift = truejsou zvýrazněny (např. žlutý nebo červený background), řádky bez driftu jsou neutrální. - Po úspěšném rebuild UI inline aktualizuje řádek daného uživatele s hodnotami z
200 OKresponse — nezobrazuje se celé přenačtení tabulky. - Pole
generated_atje zobrazeno nad tabulkou jako timestamp posledního načtení reportu.
UX Guidelines
User Flow
Admin otevře /admin/reconciliation — stránka se okamžitě načte a zavolá GET /api/admin/reconciliation/report; během načítání se zobrazí skeleton ve GlobalDriftCard i v DriftTable. Po načtení jsou uživatelé s has_drift=true vypsáni nahoře (default sort) a filtr “Show only users with drift” je zapnutý. Admin vidí barevné DriftBadge buňky ve sloupcích delta a rozhodne, zda je drift podezřelý — pokud ano, klikne “Rebuild” u konkrétního uživatele. Systém zobrazí RebuildConfirmModal s emailem uživatele a aktuálními delta hodnotami; default focus je na tlačítku Cancel. Admin potvrdí tlačítkem Rebuild (červené) — button se přepne na spinner, zavolá se POST /api/admin/reconciliation/rebuild/{userId}, po úspěchu se řádek uživatele aktualizuje in-place a delta buňky přeskočí na 0. Admin může kdykoli kliknout RefreshButton pro nové načtení celého reportu.
Layout
Screen type: Dashboard (read-only + manuální akce Rebuild)
Route: /admin/reconciliation
Responsive: Desktop-first, single-column na mobilech (admin operační nástroj)
Container: max-w-6xl mx-auto px-6 py-8
Admin layout: sdílený sidebar/header již přítomný — žádný vlastní sidebar na této obrazovce
┌─ /admin/reconciliation ────────────────────────────────────────────────┐
│ │
│ Reconciliation Log [Refresh] Last refreshed: 14:23 │
│ (3 min ago) │
│ │
│ ┌─ GlobalDriftCard ────────────────────────────────────────────────┐ │
│ │ Monthly PG Monthly Redis Delta (monthly) │ │
│ │ $124.50 $123.80 $0.70 ← žlutý badge │ │
│ └──────────────────────────────────────────────────────────────────┘ │
│ │
│ [x] Show only users with drift │
│ │
│ ┌─ DriftTable ─────────────────────────────────────────────────────┐ │
│ │ Email PG 5h Redis 5h Δ 5h PG wk Redis wk Δ wk │ │
│ │─────────────────────────────────────────────────────────────────│ │
│ │ test@talkide.app $3.20 $3.20 [OK] $14.50 $13.80 [+$0.70]│ [Rebuild] │
│ │ alice@example.com $1.50 $1.50 [OK] $8.00 $8.00 [OK] │ (disabled) │
│ └──────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────┘
┌─ RebuildConfirmModal ─────────────────────────────────────────────┐
│ │
│ Rebuild Redis quota for test@talkide.app? │
│ │
│ This will overwrite Redis quota counters for this user by │
│ recalculating from PG ledger. Active Mara turn for this user │
│ may have conflicting writes. │
│ │
│ Current drift: │
│ 5h delta: $0.00 │
│ Weekly delta: +$0.70 │
│ │
│ [Cancel ← default focus] [Rebuild (red)] │
│ │
└────────────────────────────────────────────────────────────────────┘
Components
| Komponenta | Soubor | Odpovědnost |
|---|---|---|
ReconciliationDashboardScreen | src/screens/admin/ReconciliationDashboardScreen.vue | Parent route /admin/reconciliation. Orchestruje fetchReport() při mountu, předává data do child komponent, spravuje viditelnost RebuildConfirmModal. |
GlobalDriftCard | src/screens/admin/components/GlobalDriftCard.vue | Zobrazuje 3 čísla: PG monthly, Redis monthly, delta. Přijímá global objekt z response. Barva delta závisí na color/state matrix (viz níže). Skeleton prop při načítání. |
DriftTable | src/screens/admin/components/DriftTable.vue | <table> s header cells <th scope="col">. Sloupce: Email, PG 5h, Redis 5h, Δ 5h, PG weekly, Redis weekly, Δ weekly, Action. Default sort: has_drift=true nahoře. Filter prop showOnlyDrift. Skeleton rows při fetchReport. |
DriftBadge | src/screens/admin/components/DriftBadge.vue | Buňka delta hodnoty. Text “OK” nebo “Drift” + číselná hodnota (např. ”+$0.70”). Barva dle color/state matrix. Tooltip s vysvětlením driftu (např. “Redis underreports $0.70 vs ledger”). aria-label s plným popisem. |
RebuildConfirmModal | src/screens/admin/components/RebuildConfirmModal.vue | Confirm dialog před POST /rebuild/{userId}. Zobrazuje email uživatele a aktuální delta hodnoty. Focus trap, Escape zavře (= Cancel), default focus na Cancel button. role="dialog" + aria-modal="true" + aria-labelledby. Loading spinner na Rebuild button při submitu. |
RefreshButton | src/screens/admin/components/RefreshButton.vue | Manuální refresh reportu (žádný auto-poll). Zobrazuje timestamp “Last refreshed: HH:mm:ss (X min ago)”. Spinner při fetchReport. |
useReconciliationStore | src/screens/admin/stores/reconciliation.ts | Pinia store (feature-based scope). State: report, loading, error, lastRefreshedAt. Actions: fetchReport(), rebuildUser(userId). Po rebuildUser success → fetchReport() automaticky. |
Color / State Matrix
GlobalDriftCard — delta barva
| Podmínka | Barva | CSS var | Zdůvodnění |
|---|---|---|---|
delta_monthly_cents == 0 | Zelená | var(--green) | Plná shoda — vše OK |
0 < |delta| < 50 (< $0.50) | Žlutá | var(--amber) | Malá odchylka — pozorovat |
|delta| >= 50 (>= $0.50) | Červená | var(--rose) | Významný drift — vyžaduje akci |
DriftBadge — delta cell
| Podmínka | Barva | Text | Tooltip |
|---|---|---|---|
delta_cents == 0 | Zelená var(--green) | ”OK · $0.00" | "Redis and PG ledger are in sync” |
0 < |delta| < 5 % sumy nebo |delta| < 50 | Žlutá var(--amber) | ”Drift · +$X.XX" | "Redis underreports $X.XX vs ledger (minor)“ |
|delta| >= 5 % sumy a |delta| >= 50 | Červená var(--rose) | ”Drift · +$X.XX" | "Redis underreports $X.XX vs ledger — rebuild recommended” |
Pozn.: barva není jediný signál — vždy přítomen text “OK” nebo “Drift” i číselná hodnota.
DriftTable — řádek highlight
| Stav | Pozadí řádku |
|---|---|
has_drift = false | Neutrální (default table row) |
has_drift = true | var(--amber-soft) — jemný amber tint; přechod na červený tint pokud |delta| >= 50 |
| Právě probíhá rebuild (loading) | Skeleton overlay na řádku, ostatní řádky zachovány |
Rebuild button — stav
| Stav | Vizuál |
|---|---|
has_drift = true, idle | Aktivní červené destructive outline tlačítko |
has_drift = false | Disabled, opacity-50, cursor-not-allowed, aria-disabled="true" |
| Rebuild in-flight | Spinner + “Rebuilding…” text, disabled |
| Po úspěchu | Tlačítko přejde do disabled stavu (delta = 0, has_drift = false) |
Validation Feedback
| Událost | Mechanismus | Zobrazení |
|---|---|---|
Network error při fetchReport | Red banner nad tabulkou | ”Failed to load reconciliation report. [Retry]” · role="alert" · aria-live="assertive" |
| 401 Unauthorized | Axios interceptor → redirect na login | Žádný toast — interní guard |
| 404 User not found po rebuild | Toast + auto-refresh | ”User not found, refreshing report.” · aria-live="polite" · 5 s |
| 500 / network error při rebuild | Toast error | ”Rebuild failed. Please try again.” · aria-live="assertive" · 8 s |
| Úspěšný rebuild | In-place update řádku + toast | ”Redis quota rebuilt for user@example.com.” · aria-live="polite" · 5 s |
| Prázdná tabulka (žádná data) | Empty state v DriftTable | ”No users tracked yet — no ledger entries and no active Redis quota keys.” |
Toast vs Banner pravidlo:
- Banner (persistentní, nad tabulkou): chyby načítání reportu — blokují celou funkci obrazovky.
- Toast (dočasný): akce rebuild — úspěch, 404, rebuild failure. Neblokují pohled na tabulku.
Accessibility
Klávesové chování:
Tabnaviguje po řádcích tabulky; Rebuild button je focusable standardněEnter/Spacena Rebuild button → otevřeRebuildConfirmModalEscapevRebuildConfirmModal→ zavře modal (= Cancel)Tab/Shift+Tabv modalu cykluje pouze mezi Cancel a Rebuild tlačítky (focus trap)- Default focus po otevření modalu → Cancel button
- Po zavření modalu (Cancel nebo Escape) → focus vrácen na Rebuild button daného řádku
- Po úspěšném rebuildu (modal se zavře) → focus vrácen na Rebuild button (nyní disabled) nebo na první aktivní prvek v řádku
ARIA:
DriftTable:<table>,<th scope="col">na všech header cellsDriftBadge:aria-labels plným popisem (např.aria-label="Weekly delta: Redis underreports $0.70 vs PG ledger")RebuildConfirmModal:role="dialog",aria-modal="true",aria-labelledbyodkazující na nadpis modalu- Rebuild button (disabled):
aria-disabled="true"+disabledatribut - Error banner:
role="alert",aria-live="assertive" - Success / info toast:
aria-live="polite" - Error toast (rebuild failure):
aria-live="assertive" RefreshButton:aria-label="Refresh reconciliation report",aria-busy="true"při načítáníGlobalDriftCard:role="region",aria-label="Global monthly drift summary"
Barevný kontrast: Všechny rose/amber/green akcenty na var(--bg-2) pozadí splňují WCAG AA (min 4.5:1) v dark i light tématu. Barva není jediný signál — textové popisky vždy přítomny.
Loading States
- Initial load (
fetchReport):GlobalDriftCardzobrazí skeleton (3 pulse boxy).DriftTablezobrazí 3–5 skeleton řádků (pulse animated).RefreshButtondisabled + spinner. - Manual refresh: identický skeleton jako initial load — celá tabulka se překreslí.
- Rebuild in-flight: pouze Rebuild button daného řádku → spinner + “Rebuilding…” text + disabled. Ostatní řádky a tabulka zůstanou interaktivní.
- Skeleton styl:
bg-[var(--bg-3)] animate-pulse rounded(konzistentní s UC-08004 MetricCard skeleton).
i18n Klíče (namespace reconciliation.*)
reconciliation.title
reconciliation.lastRefreshed
reconciliation.lastRefreshedAgo
reconciliation.global.title
reconciliation.global.pgMonthly
reconciliation.global.redisMonthly
reconciliation.global.deltaMonthly
reconciliation.filter.showOnlyDrift
reconciliation.table.headerEmail
reconciliation.table.headerPg5h
reconciliation.table.headerRedis5h
reconciliation.table.headerDelta5h
reconciliation.table.headerPgWeekly
reconciliation.table.headerRedisWeekly
reconciliation.table.headerDeltaWeekly
reconciliation.table.headerAction
reconciliation.table.emptyState
reconciliation.badge.ok
reconciliation.badge.drift
reconciliation.badge.tooltipSync
reconciliation.badge.tooltipMinor
reconciliation.badge.tooltipMajor
reconciliation.rebuild.button
reconciliation.rebuild.buttonLoading
reconciliation.rebuild.modal.title
reconciliation.rebuild.modal.description
reconciliation.rebuild.modal.currentDrift
reconciliation.rebuild.modal.delta5h
reconciliation.rebuild.modal.deltaWeekly
reconciliation.rebuild.modal.cancel
reconciliation.rebuild.modal.confirm
reconciliation.rebuild.modal.warningActiveConversation
reconciliation.toast.rebuildSuccess
reconciliation.toast.rebuildFailure
reconciliation.toast.userNotFound
reconciliation.error.loadFailed
reconciliation.error.retry
Backend
Validations
| Pole / Podmínka | Constraints | Poznámka |
|---|---|---|
| Admin role (oba endpointy) | user.role == ADMIN | Silent probe — non-admin dostane 401 AUTHENTICATION_FAILED, ne 403 |
userId (path param, POST rebuild) | not_null, validní Long (BIGINT), existuje v users tabulce | 404 NOT_FOUND_USER pokud user neexistuje. users.id je bigint, nikoli UUID — konzistentní s UC-08001/02/03/04. |
window_start_5h výpočet | MIN(occurred_at WHERE > now()-5h) | Anthropic-style: okno začíná prvním requestem, ne od now()-5h |
window_start_weekly výpočet | MIN(occurred_at WHERE > now()-7d) | Stejná logika pro weekly okno |
| Redis TTL při rebuild | TTL = window_duration - (now - window_start) | TTL musí být kladné; pokud by vyšlo ≤ 0, okno již expiruje → smaž klíče |
global:monthly_used_cents TTL při global rebuild | TTL = secondsUntilEndOfMonth() | Seconds to 1st day of next UTC month; pokud = 0 (extrémní případ na měsíční hranici) → DEL klíče |
| Rebuild idempotence | Opakované volání → stejný Redis stav | PG SUM je deterministický; Lua SET přepíše předchozí hodnotu |
MR1 invariant: usage_events neobsahuje crashed/in-flight rows | Žádná query-level filtrace není potřeba | Strukturální invariant: crashed/in-flight call nikdy neemittuje NDJSON usage event → žádný řádek nevznikne. usage_events.occurred_at je NOT NULL sloupec. Filtrace occurred_at IS NULL by byla sémanticky nesmyslná. Invariant je vynucen schématem, ne runtime filtrem. |
Implementation Notes
ReconciliationReportUseCaseaReconciliationRebuildUseCasejsou Spring@Service/ UseCase beany. Controller jeAdminReconciliationControllerna/api/admin/reconciliation.- GET report inkluduje všechny uživatele s ledger záznamy v posledních 7 dnech, UNION s uživateli s aktivními Redis klíči. Uživatelé bez jakékoli aktivity se nezobrazují.
pg_5h_centspro report =SUM(cost_cents WHERE user_id=? AND occurred_at >= user:{id}:5h_window_start_ts z Redis). Pokud Redis klíč neexistuje →redis_5h_cents = 0,pg_5h_centsse počítá za posledních 5h odnow()jako best-effort.OffsetDateTimepro všechny timestamp sloupce (TIMESTAMPTZ v PG), nikoliLocalDateTime.- Rebuild Lua script: atomicky nastaví oba klíče (window_start_ts + used_cents) pro dané okno s identickým TTL — obě hodnoty jsou vždy konzistentní nebo obě chybí.
Test Cases
| GIVEN | WHEN | THEN |
|---|---|---|
| Admin je přihlášen; 2 uživatelé mají ledger záznamy; Redis a PG jsou v sync | GET /api/admin/reconciliation/report je zavolán | 200 OK; oba uživatelé v users[]; has_drift = false pro oba; delta_*_cents = 0 |
Admin je přihlášen; user A má redis_weekly_cents = 1380, PG weekly sum = 1450 | GET /api/admin/reconciliation/report je zavolán | 200 OK; user A má delta_weekly_cents = 70, has_drift = true |
Admin je přihlášen; Redis a PG jsou v sync; delta_5h = 0, delta_weekly = 0 | GET /api/admin/reconciliation/report je zavolán | has_drift = false pro daného uživatele |
| Žádné ledger záznamy v posledních 7d; žádné Redis kvóta klíče | GET /api/admin/reconciliation/report je zavolán | 200 OK; users[] je prázdné pole; global delta = 0 |
| Non-admin uživatel (ROLE_USER) | GET /api/admin/reconciliation/report je zavolán | 401 AUTHENTICATION_FAILED; endpoint nedává najevo svou existenci |
| Neautentizovaný request (žádný Bearer token) | GET /api/admin/reconciliation/report je zavolán | 401 AUTHENTICATION_FAILED |
| Admin je přihlášen; user existuje; Redis weekly counter odchýlen od PG o 70 centů | POST /api/admin/reconciliation/rebuild/{userId} je zavolán | 200 OK; Redis weekly_used_cents přepsán na PG SUM; delta_weekly_cents = 0 v response; has_drift = false |
| Admin je přihlášen; user nemá žádné záznamy v posledních 5h (5h window neaktivní) | POST /api/admin/reconciliation/rebuild/{userId} je zavolán | Redis klíče user:{id}:5h_window_start_ts a user:{id}:5h_used_cents smazány; pg_5h_cents = 0, redis_5h_cents = 0 |
| Admin je přihlášen; user nemá žádné záznamy v posledních 7d (weekly window neaktivní) | POST /api/admin/reconciliation/rebuild/{userId} je zavolán | Redis klíče user:{id}:weekly_window_start_ts a user:{id}:weekly_used_cents smazány |
Admin je přihlášen; userId neexistuje v tabulce users | POST /api/admin/reconciliation/rebuild/{userId} je zavolán | 404 NOT_FOUND_USER; Redis ani PG se nemění |
| Non-admin uživatel (ROLE_USER) | POST /api/admin/reconciliation/rebuild/{userId} je zavolán | 401 AUTHENTICATION_FAILED |
| Admin je přihlášen; rebuild byl úspěšně dokončen | POST /api/admin/reconciliation/rebuild/{userId} je zavolán podruhé (identická data) | 200 OK; Redis state identický jako po prvním rebuildu; žádná chyba — idempotentní |
Admin je přihlášen; global:monthly_used_cents je v Redis s hodnotou X | POST /api/admin/reconciliation/rebuild/{userId} je zavolán | global:monthly_used_cents v Redis zůstane beze změny; hodnota X je zachována |
Admin je přihlášen; MR1 invariant: usage_events neobsahuje crashed/in-flight rows z definice | POST /api/admin/reconciliation/rebuild/{userId} je zavolán | Strukturální invariant místo runtime filtru: crashed call nikdy neemittuje NDJSON usage event → žádný usage_events řádek nevznikne. occurred_at je NOT NULL — query-level filtrace IS NULL není potřeba ani možná. |
| Admin je přihlášen; user má ledger záznamy pouze od 2h zpět (okno začíná za 2h) | POST /api/admin/reconciliation/rebuild/{userId} je zavolán | window_start_5h = MIN(occurred_at) z 2h zpět; TTL Redis klíčů = 3h (zbývající čas); pg_5h_cents = SUM od toho timestampu |
Admin je přihlášen; global:monthly_used_cents v Redis underreportuje o 200 centů; PG SUM usage_events = 1500 | POST /api/admin/reconciliation/rebuild-global je zavolán | 200 OK; Redis global:monthly_used_cents přepsán na 1500; delta_monthly_cents = 0 v response |
Admin je přihlášen; žádné usage_events v aktuálním billing měsíci; Redis global key má stale hodnotu | POST /api/admin/reconciliation/rebuild-global je zavolán | 200 OK; Redis global:monthly_used_cents nastaven na 0 (nebo smazán na měsíční hranici); delta_monthly_cents = 0 |
| Non-admin uživatel (ROLE_USER) | POST /api/admin/reconciliation/rebuild-global je zavolán | 401 AUTHENTICATION_FAILED; endpoint nedává najevo svou existenci |
Known Test Limitations (alpha)
-
TC-06 E2E (Rebuild fixes drift) je v
e2e/tests/.../UC-08005_reconciliation-log.spec.tsoznačený jakotest.skip(). Důvod: E2E helpersetRedisWeeklyCounternastaví Redis-only stav pro admin usera, ale BE report endpoint v drop-first DB neenumeruje usera bez PG ledger entries (nebo helper nepokrývá všechny 4 Redis klíče:5h_window_start_ts,5h_used_cents,weekly_window_start_ts,weekly_used_cents). BE JUnitTC-16: Rebuild Redis-only uservReconciliationTestCases.ktpokrývá tuto logiku přímou Redis manipulací a prochází. UI flow lze ověřit v alpha manuálně — admin user nejdřív udělá pár requestů přes UI (UC-08001 Mara conversation), vznikne PG ledger entry, drift se zobrazí, rebuild funguje. -
TC-API-04 E2E (MR1 invariant — no in-flight rows) je
test.skip()— MR1 invariant je vynucen schématem (occurred_at NOT NULL,usage_eventsje append-only z NDJSON eventu), E2E framework nemá potřebu ověřovat strukturální DB constraint. Backend JUnitTC-14: usage_events MR1 invariantaTC-17: in-flight record nesmí být v pg_monthly_centsvReconciliationTestCases.ktto pokrývají.
FEEDBACK
Při psaní UC-08005 mi chybělo jasné dokumentování přesného tvaru userId napříč UC-08 sérií — UC-08001 až UC-08004 používají v DB BIGINT user_id, zatímco zadání pro UC-08005 popisuje user_id jako UUID v API responsu. Bylo by užitečné mít v model/README.md explicitně zdokumentováno, zda users.id je BIGINT nebo UUID, aby reference UCs nebyly v rozporu. Dále by pomohlo, kdyby UC-08002 obsahoval explicitní příklad KEYS user:*:5h_used_cents scan patternů nebo preferovaný přístup (SCAN vs KEYS) — pro report endpoint je to relevantní implementační detail, který ovlivňuje výkon v produkci.
FEEDBACK — UI/UX Designer
Pro definici UX Guidelines mi chyběl odkaz na projektový design token soubor (CSS custom properties var(--green), var(--amber), var(--rose) apod.) — musel jsem je odvodit z UC-08004, ne ze zdrojového CSS. Bylo by užitečné mít v design SKILL nebo v projektové spec odkaz na src/assets/tokens.css (nebo ekvivalent), aby designerský agent mohl ověřit skutečné hodnoty tokenů místo opisování z jiného UC. Dále: zadání popisovalo threshold pro DriftBadge červené barvy jako |delta| >= 50¢ (absolutní) i >= 5 % (relativní), ale chybělo, zda jsou obě podmínky splněny zároveň (AND) nebo stačí jedna (OR) — pro implementaci DriftBadge je to klíčové; bylo by dobré, aby UX zadání explicitně uvádělo booleovský operátor.
Thanks for the feedback.