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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * chore: retrigger CI after preview env fix Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
aec81cb7ad
commit
53452e183d
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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<string, string> = { 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,
|
||||
})
|
||||
}
|
||||
@@ -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<string, Promise<ExchangeRate | null>>()
|
||||
function getRate(currency: Currency, date: string): Promise<ExchangeRate | null> {
|
||||
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<string, { n: number; storedSek: number; correctSek: number; missing: number }>()
|
||||
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)
|
||||
})
|
||||
Reference in New Issue
Block a user