diff --git a/DECISIONS.md b/DECISIONS.md index 38d39000..84aec8af 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -372,3 +372,6 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and [2026-07-25] Article EUR-price support bug: root cause was the edit dialog omitting currency from initialData (form defaulted SEK and PATCHed it back) plus kr-hardcoded formatCurrency calls; export gets a Valuta column + suffix-free decimalColumn instead of extending CURRENCY_FORMAT, importer Valuta detection deferred as follow-up to keep the diff scoped. [2026-07-25] Reinstated article deactivation as an explicit PATCH active-toggle button on the detail page (support: odinaero.se) instead of reverting DELETE to soft-delete: 8a9a930f intentionally made DELETE hard-delete for unused articles, but that left invoice-referenced articles (ARTICLE_IN_USE) with no retire path; the old deactivate i18n keys were still in messages/ and are reused. [2026-07-25] Momsdeklaration period selection fused into one chip (year+quarter/month, reverse-chron, 5 years) instead of separate year and period pickers; cadence stays behind the Period chip. Standalone report pages now render their own PageHeader so Exportera sits on the title row per convention 9. +[2026-07-25] Currency-honesty batch (#1177-#1187): aggregates over mixed-currency rows group per currency instead of converting (supplier stats, bank-file totals): read paths must not depend on live FX fetches, and a per-currency line is honest where a converted single number would hide the mix. Where a stored SEK conversion exists but is NULL (invoices.total_sek after a failed rate fetch), aggregates skip-and-flag (unconvertedCount + one visible note) rather than fall back to the raw foreign amount. +[2026-07-25] Editing a draft ROT/RUT invoice keeps the stored encrypted personnummer when the field is left empty and deduction lines remain (#1186): the plaintext is not client-rehydratable by design, so empty-means-keep is the only edit semantics that neither blocks the edit nor wipes the ciphertext; typed value replaces, removing all deduction lines clears. +[2026-07-25] Article delete was broken globally by a phantom invoice_items.company_id filter (42703 -> ARTICLE_DELETE_FAILED) that mocked route tests cannot catch; fixed in #1188 with a source-pin test. Lesson: supabase-mock tests validate flow, never schema: any new filtered column needs a schema-level check or pg-real coverage. diff --git a/app/api/articles/[id]/route.ts b/app/api/articles/[id]/route.ts index 5a645ad5..0755752a 100644 --- a/app/api/articles/[id]/route.ts +++ b/app/api/articles/[id]/route.ts @@ -140,11 +140,14 @@ export const DELETE = withRouteContext( }) } + // invoice_items has NO company_id column: filtering on it made PostgREST + // 42703 here, which the error branch below turned into ARTICLE_DELETE_FAILED + // for EVERY delete (support: odinaero.se). Tenancy is already enforced by + // the article lookup above: article_id is a UUID owned by this company. const { count: usageCount, error: usageError } = await supabase .from('invoice_items') .select('id', { count: 'exact', head: true }) .eq('article_id', id) - .eq('company_id', companyId) if (usageError) { opLog.error('article usage check failed', usageError) diff --git a/app/api/articles/__tests__/id.test.ts b/app/api/articles/__tests__/id.test.ts index 101b8f6a..dcda4ab3 100644 --- a/app/api/articles/__tests__/id.test.ts +++ b/app/api/articles/__tests__/id.test.ts @@ -165,3 +165,20 @@ describe('GET/PATCH/DELETE /api/articles/[id]', () => { }) }) }) + +describe('DELETE usage-check query shape', () => { + it('must not filter invoice_items by company_id (column does not exist)', async () => { + // Regression pin for the odinaero support case: invoice_items has no + // company_id column, so filtering on it made PostgREST answer 42703 and + // the route mapped that to ARTICLE_DELETE_FAILED for EVERY delete. The + // queued supabase mock swallows chained filters, so no behavioral mock + // test can catch a phantom column: pin the source instead. Tenancy is + // enforced by the preceding articles lookup. + const fs = await import('node:fs') + const path = await import('node:path') + const src = fs.readFileSync(path.resolve(__dirname, '../[id]/route.ts'), 'utf8') + const usageBlock = src.slice(src.indexOf("from('invoice_items')")) + const firstQuery = usageBlock.slice(0, usageBlock.indexOf('usageError')) + expect(firstQuery).not.toContain("eq('company_id'") + }) +})