From 53452e183deef9d5d8ce5ec097e5e0ee00637425 Mon Sep 17 00:00:00 2001 From: Jakob Wennberg <149234542+jakobwennberg@users.noreply.github.com> Date: Fri, 10 Jul 2026 14:07:43 +0200 Subject: [PATCH] feat(ops): personnummer backfill route (temporary) + FX repair script (#981) * feat(ops): temporary cron-gated route to backfill plaintext personnummer in prod PERSONNUMMER_ENCRYPTION_KEY is a sensitive Vercel env var and cannot be read outside the runtime, so scripts/backfill-encrypt-personnummer.ts cannot run locally with the production key. This route performs the same guarded, idempotent backfill inside the production runtime instead. CRON_SECRET-gated, dry-run by default, counts-only response. To be deleted after the backfill is verified (issue #979). Co-Authored-By: Claude Fable 5 * chore(ops): commit the FX fallback-rate repair script for the audit trail One-off repair for transactions booked with pre-#892 hardcoded fallback rates; unbooked rows only, rate-guarded and idempotent. Already executed against prod 2026-07-10 (issue #979). Co-Authored-By: Claude Fable 5 * chore: retrigger CI after preview env fix Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Claude Fable 5 --- .../__tests__/route.test.ts | 82 +++++++++ .../backfill-personnummer/route.ts | 75 ++++++++ scripts/repair-fx-fallback-rates.ts | 172 ++++++++++++++++++ 3 files changed, 329 insertions(+) create mode 100644 app/api/maintenance/backfill-personnummer/__tests__/route.test.ts create mode 100644 app/api/maintenance/backfill-personnummer/route.ts create mode 100644 scripts/repair-fx-fallback-rates.ts diff --git a/app/api/maintenance/backfill-personnummer/__tests__/route.test.ts b/app/api/maintenance/backfill-personnummer/__tests__/route.test.ts new file mode 100644 index 00000000..ebbd85fb --- /dev/null +++ b/app/api/maintenance/backfill-personnummer/__tests__/route.test.ts @@ -0,0 +1,82 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { NextResponse } from 'next/server' + +const mockVerifyCronSecret = vi.fn() +vi.mock('@/lib/auth/cron', () => ({ + verifyCronSecret: (req: Request) => mockVerifyCronSecret(req), +})) + +const mockUpdateEq2 = vi.fn() +const mockUpdateEq1 = vi.fn(() => ({ eq: mockUpdateEq2 })) +const mockUpdate = vi.fn(() => ({ eq: mockUpdateEq1 })) +const mockSelect = vi.fn() +vi.mock('@supabase/supabase-js', () => ({ + createClient: () => ({ + from: () => ({ select: mockSelect, update: mockUpdate }), + }), +})) + +import { POST } from '../route' + +const ENCRYPTED = + 'a1b2c3d4e5f60718293a4b5c' + 'deadbeefdeadbeefdeadbeef' + 'cafebabecafebabecafebabecafebabe' + +function makeRequest(confirm: boolean) { + const url = `http://localhost/api/maintenance/backfill-personnummer${confirm ? '?confirm=true' : ''}` + return new Request(url, { method: 'POST' }) +} + +describe('POST /api/maintenance/backfill-personnummer', () => { + beforeEach(() => { + vi.clearAllMocks() + mockVerifyCronSecret.mockReturnValue(null) + mockUpdateEq2.mockResolvedValue({ error: null }) + mockSelect.mockResolvedValue({ + data: [ + { id: 'emp-1', personnummer: '199001011234', personnummer_last4: null }, + { id: 'emp-2', personnummer: ENCRYPTED, personnummer_last4: '5678' }, + ], + error: null, + }) + }) + + it('returns 401 when the cron secret is missing or wrong', async () => { + mockVerifyCronSecret.mockReturnValue( + NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), + ) + const res = await POST(makeRequest(true)) + expect(res.status).toBe(401) + expect(mockSelect).not.toHaveBeenCalled() + }) + + it('dry run counts plaintext rows without writing', async () => { + const res = await POST(makeRequest(false)) + const body = await res.json() + expect(res.status).toBe(200) + expect(body).toEqual({ mode: 'dry_run', scanned: 2, plaintext: 1, updated: 0, failed: 0 }) + expect(mockUpdate).not.toHaveBeenCalled() + }) + + it('confirm encrypts only plaintext rows, guarded on the old value', async () => { + const res = await POST(makeRequest(true)) + const body = await res.json() + expect(res.status).toBe(200) + expect(body).toEqual({ mode: 'write', scanned: 2, plaintext: 1, updated: 1, failed: 0 }) + + expect(mockUpdate).toHaveBeenCalledTimes(1) + const patch = mockUpdate.mock.calls[0][0] as { personnummer: string; personnummer_last4: string } + // aes-256-gcm output: 12-byte iv + ciphertext + 16-byte tag, hex-encoded + expect(patch.personnummer).toMatch(/^[0-9a-f]+$/) + expect(patch.personnummer).not.toMatch(/^\d{12}$/) + expect(patch.personnummer.length).toBeGreaterThan(60) + expect(patch.personnummer_last4).toBe('1234') + expect(mockUpdateEq1).toHaveBeenCalledWith('id', 'emp-1') + expect(mockUpdateEq2).toHaveBeenCalledWith('personnummer', '199001011234') + }) + + it('returns 500 when the employees read fails', async () => { + mockSelect.mockResolvedValue({ data: null, error: { message: 'boom' } }) + const res = await POST(makeRequest(false)) + expect(res.status).toBe(500) + }) +}) diff --git a/app/api/maintenance/backfill-personnummer/route.ts b/app/api/maintenance/backfill-personnummer/route.ts new file mode 100644 index 00000000..45e4a8cb --- /dev/null +++ b/app/api/maintenance/backfill-personnummer/route.ts @@ -0,0 +1,75 @@ +import { NextResponse } from 'next/server' +import { createClient } from '@supabase/supabase-js' +import { verifyCronSecret } from '@/lib/auth/cron' +import { encryptPersonnummer, extractLast4 } from '@/lib/salary/personnummer' + +export const dynamic = 'force-dynamic' + +const PLAINTEXT = /^\d{12}$/ + +/** + * TEMPORARY maintenance endpoint: re-encrypt employees.personnummer rows that + * were stored as plaintext by the v1 REST create route before the #911 fix. + * + * This exists because PERSONNUMMER_ENCRYPTION_KEY is a sensitive Vercel env + * var (unreadable outside the runtime), so the committed local backfill + * script (scripts/backfill-encrypt-personnummer.ts) cannot obtain the + * production key. This route runs the same guarded, idempotent backfill + * inside the production runtime, where the key already lives. + * + * Gated by CRON_SECRET. Dry-run by default; writes only with ?confirm=true. + * The response carries counts only, never personnummer values. + * + * DELETE this route once the backfill is verified (tracked in issue #979). + */ +export async function POST(request: Request) { + const unauthorized = verifyCronSecret(request) + if (unauthorized) return unauthorized + + const confirm = new URL(request.url).searchParams.get('confirm') === 'true' + + const supabase = createClient( + process.env.NEXT_PUBLIC_SUPABASE_URL!, + process.env.SUPABASE_SERVICE_ROLE_KEY!, + { auth: { persistSession: false } }, + ) + + const { data, error } = await supabase + .from('employees') + .select('id, personnummer, personnummer_last4') + if (error) { + return NextResponse.json({ error: 'Failed to read employees' }, { status: 500 }) + } + + const rows = data ?? [] + const plaintextRows = rows.filter((r) => PLAINTEXT.test(String(r.personnummer ?? ''))) + + let updated = 0 + let failed = 0 + if (confirm) { + for (const row of plaintextRows) { + const plaintext = String(row.personnummer) + const patch: Record = { personnummer: encryptPersonnummer(plaintext) } + const last4 = extractLast4(plaintext) + if (row.personnummer_last4 !== last4) patch.personnummer_last4 = last4 + + // Guard on the still-plaintext value: idempotent and safe against a + // concurrent write; can never double-encrypt. + const { error: updateError } = await supabase + .from('employees') + .update(patch) + .eq('id', row.id) + .eq('personnummer', plaintext) + if (updateError) failed++ + else updated++ + } + } + + return NextResponse.json({ + mode: confirm ? 'write' : 'dry_run', + scanned: rows.length, + plaintext: plaintextRows.length, + updated, + failed, + }) +} diff --git a/scripts/repair-fx-fallback-rates.ts b/scripts/repair-fx-fallback-rates.ts new file mode 100644 index 00000000..d653e91e --- /dev/null +++ b/scripts/repair-fx-fallback-rates.ts @@ -0,0 +1,172 @@ +/** + * One-off repair: transactions booked with the pre-2026-07-05 hardcoded + * fallback exchange rates (EUR 11.5, USD 10.5, GBP 13.5) or with NULL rates. + * + * WHY: before PR #892, a Riksbanken 429 during bank sync silently booked + * hardcoded rates into transactions.exchange_rate/amount_sek. PR #892 fixed + * the code path (null-not-fallback); this repairs the rows left behind. + * + * WHAT IT DOES: + * - UNBOOKED rows (journal_entry_id IS NULL): re-fetches the correct + * per-date rate from Riksbanken (via the exchange_rates cache) and + * overwrites exchange_rate / exchange_rate_date / amount_sek. + * - BOOKED rows (posted to journal entries): NEVER touched. Reported as a + * per-company materiality summary so corrections can be decided per + * company (storno/correctEntry, never edit posted entries). + * + * Idempotent: updates are guarded on the exact old rate value (or NULL) plus + * journal_entry_id IS NULL, so re-runs and concurrent bookings are safe. + * Once a row is repaired it no longer matches the poisoned pattern. + * + * Usage: + * npx tsx scripts/repair-fx-fallback-rates.ts # dry run (read-only) + * npx tsx scripts/repair-fx-fallback-rates.ts --execute # performs the writes + * + * Reads NEXT_PUBLIC_SUPABASE_URL + SUPABASE_SERVICE_ROLE_KEY from .env.local. + * Treat .env.local as pointing at PRODUCTION. + */ +import { createClient, type SupabaseClient } from '@supabase/supabase-js' +import { config as dotenv } from 'dotenv' +import { resolve } from 'node:path' +import { fetchExchangeRate } from '@/lib/currency/riksbanken' +import type { Currency, ExchangeRate } from '@/types' + +dotenv({ path: resolve(process.cwd(), '.env.local') }) + +const SUPABASE_URL = process.env.NEXT_PUBLIC_SUPABASE_URL +const SERVICE_KEY = process.env.SUPABASE_SERVICE_ROLE_KEY +if (!SUPABASE_URL || !SERVICE_KEY) { + console.error('Missing NEXT_PUBLIC_SUPABASE_URL or SUPABASE_SERVICE_ROLE_KEY in .env.local') + process.exit(1) +} + +const EXECUTE = process.argv.includes('--execute') + +/** The exact sentinel rates the old getFallbackRate() booked. */ +const POISONED: Array<{ currency: Currency; rate: number }> = [ + { currency: 'EUR', rate: 11.5 }, + { currency: 'USD', rate: 10.5 }, + { currency: 'GBP', rate: 13.5 }, +] + +interface TxRow { + id: string + company_id: string + currency: Currency + amount: number + amount_sek: number | null + exchange_rate: number | null + exchange_rate_date: string | null + date: string + journal_entry_id: string | null +} + +const sb: SupabaseClient = createClient(SUPABASE_URL, SERVICE_KEY, { + auth: { persistSession: false }, +}) + +/** In-process memo so repeated (currency, date) pairs cost one lookup. */ +const rateMemo = new Map>() +function getRate(currency: Currency, date: string): Promise { + const key = `${currency}|${date}` + let p = rateMemo.get(key) + if (!p) { + p = fetchExchangeRate(currency, new Date(date), sb) + rateMemo.set(key, p) + } + return p +} + +const roundMoney = (x: number) => Math.round(x * 100) / 100 + +async function main() { + const host = new URL(SUPABASE_URL!).host + console.log(`Target: ${host} mode: ${EXECUTE ? 'WRITE (--execute)' : 'DRY RUN (read-only)'}`) + + const orFilter = POISONED.map( + (p) => `and(currency.eq.${p.currency},exchange_rate.eq.${p.rate})`, + ).join(',') + + const { data: poisonedRows, error: e1 } = await sb + .from('transactions') + .select('id, company_id, currency, amount, amount_sek, exchange_rate, exchange_rate_date, date, journal_entry_id') + .or(orFilter) + if (e1) throw new Error(`select poisoned: ${e1.message}`) + + const { data: nullRows, error: e2 } = await sb + .from('transactions') + .select('id, company_id, currency, amount, amount_sek, exchange_rate, exchange_rate_date, date, journal_entry_id') + .not('currency', 'is', null) + .neq('currency', 'SEK') + .is('exchange_rate', null) + if (e2) throw new Error(`select null-rate: ${e2.message}`) + + const all: TxRow[] = [...(poisonedRows ?? []), ...(nullRows ?? [])] as TxRow[] + const unbooked = all.filter((r) => r.journal_entry_id === null) + const booked = all.filter((r) => r.journal_entry_id !== null) + + console.log( + `Found ${all.length} rows: ${unbooked.length} unbooked (repairable), ${booked.length} booked (report only).`, + ) + + // ---- Repair (or plan) unbooked rows, sequentially to be gentle on Riksbanken. + let repaired = 0 + let skipped = 0 + for (const r of unbooked) { + const rate = await getRate(r.currency, r.date) + if (!rate) { + console.log(` SKIP ${r.id} ${r.currency} ${r.date}: no rate available`) + skipped++ + continue + } + const newSek = roundMoney(r.amount * rate.rate) + const oldRate = r.exchange_rate === null ? 'NULL' : r.exchange_rate + console.log( + ` ${EXECUTE ? 'FIX ' : 'PLAN'} ${r.id} ${r.currency} ${r.date}: rate ${oldRate} -> ${rate.rate} (obs ${rate.date}), amount_sek ${r.amount_sek} -> ${newSek}`, + ) + if (!EXECUTE) continue + + let q = sb + .from('transactions') + .update({ exchange_rate: rate.rate, exchange_rate_date: rate.date, amount_sek: newSek }) + .eq('id', r.id) + .is('journal_entry_id', null) + q = r.exchange_rate === null ? q.is('exchange_rate', null) : q.eq('exchange_rate', r.exchange_rate) + const { error: upErr } = await q + if (upErr) throw new Error(`update ${r.id}: ${upErr.message}`) + repaired++ + } + + // ---- Materiality report for booked rows (never modified). + if (booked.length > 0) { + console.log('\nBOOKED rows (NOT touched; correct via storno/correctEntry per company if material):') + const byCompany = new Map() + for (const r of booked) { + const agg = byCompany.get(r.company_id) ?? { n: 0, storedSek: 0, correctSek: 0, missing: 0 } + agg.n++ + const rate = await getRate(r.currency, r.date) + if (rate) { + agg.storedSek += r.amount_sek ?? 0 + agg.correctSek += roundMoney(r.amount * rate.rate) + } else { + agg.missing++ + } + byCompany.set(r.company_id, agg) + } + for (const [companyId, a] of byCompany) { + const diff = roundMoney(a.storedSek - a.correctSek) + console.log( + ` company ${companyId}: ${a.n} booked row(s), stored SEK ${roundMoney(a.storedSek)}, correct SEK ${roundMoney(a.correctSek)}, overstatement ${diff}${a.missing ? `, ${a.missing} row(s) without fetchable rate` : ''}`, + ) + } + } + + console.log( + `\n${EXECUTE ? `Repaired ${repaired} unbooked row(s), skipped ${skipped}.` : `DRY RUN: would repair ${unbooked.length} unbooked row(s). Re-run with --execute.`}`, + ) +} + +main().catch((e) => { + console.error(e) + process.exit(1) +})