feat: invoice-inbox polish + SIE source voucher traceability (#299)

* fix: consolidate commit_journal_entry to single 4-arg signature

Replaces the phantom-overload drop migration with an idempotent consolidation
that leaves only the 4-arg-with-defaults signature, callable with either 2 or
4 named args. Fixes the "Could not choose the best candidate function"
ambiguity caused when the commit-metadata migration CREATE OR REPLACE'd a
4-arg version alongside the existing 2-arg one.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat: preserve SIE source voucher identity on journal entries

Adds source_voucher_series / source_voucher_number columns to journal_entries
so per-verifikat traceability survives the importer's skip-empty-voucher
logic. The SIE importer populates the original series/number even when
skipped vouchers cause gnubok's target numbering to drift from the source
file's sequence. Required for BFNAR 2013:2 kap 8 behandlingshistorik.

- Migration adds columns + partial index + extends immutability trigger
- importVouchers() records rawSeries/rawNumber per voucher
- JournalEntry type + test fixtures gain the new fields
- Bookkeeping detail page surfaces "Ursprungligt verifikat" when present

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat: polish invoice-inbox workspace for production use

- Bedrock image fit: shrink images > 5 MB via sharp before Bedrock upload
  so HEIC/high-res phone photos don't fail with the 5 MB cap
- Swedish error mapping: toSwedishInboxError translates Bedrock /
  infrastructure errors to Swedish sentences stored in error_message
- History timeline endpoint (GET /items/:id/history) returns the
  processing_history events correlated to the inbox item
- Workspace UI: inline diagnostic timeline inside the convert dialog,
  same-email row grouping ("+N dokument" chip), inferred-VAT affordance
  with "needs review" signalling, Riksbanken exchange-rate prefill for
  foreign-currency invoices so the supplier-invoice create path populates
  *_sek audit columns

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat: extend inbox-smart-match to supplier invoices

Both receipts and supplier invoices expose structurally identical match
anchors (date, amount, currency, counterparty name) so the matcher can
reuse the same narrowing + LLM prompt. Adds getMatchAnchors() as a shared
extractor across ReceiptExtractionResult / InvoiceExtractionResult, and
updates the event handlers to process supplier_invoice items alongside
receipts. LLM prompt re-phrased as "dokument" rather than "kvitto" and
loosened the date-window heuristic since invoice payments can lag behind
the invoice date by weeks.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor: drop unused category selector from TransactionForm

The manual "Lägg till transaktion" dialog predates the current categorization
flow (SwipeCategorizationView, BatchCategorySelector, AI suggestions). The
category dropdown here never drove journal-entry creation — onSubmit fanned
it out to CreateTransactionInput.category, which is optional. Removes the
dropdown, the unused watch() hook, and the categories lookup table.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(migrations): restore drop-phantom file and rebump timestamps

Supabase branch DB failed with PK violation on schema_migrations because
my two migrations collided with timestamps already on main:
  20260421120000 → journal_entries_with_related_rpc (PR #298)
  20260421130000 → drop_legacy_supplier_invoice_user_id_uniqueness (PR #296)

Rebumped to 20260421140000 and 20260421150000 so each migration has a
unique version (Supabase uses only the 14-digit prefix as the PK).

Also restored the 20260420130000_drop_phantom_commit_journal_entry_overload
migration I had deleted — CLAUDE.md rule #5 forbids modifying existing
migrations. My consolidate migration is still compatible: drop_phantom
drops the 4-arg overload (no-op where absent), then consolidate recreates
it with defaults.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(inbox-smart-match): anchor invoices on dueDate with wider window

The original ±7d window around invoiceDate filtered out all real payments
for invoices with standard 30–60 day terms — the matcher would see zero
candidates before the LLM was called, making the supplier-invoice matcher
effectively dead.

New anchor selection:
- Receipts: receipt date ±7 days (unchanged; paid on the spot)
- Invoices with dueDate: dueDate ±14 days (covers early/late payments)
- Invoices without dueDate: invoiceDate -7/+45 days (covers 30-day terms)

MatchAnchors now carries windowDaysBefore/After so the window can vary per
document shape. Added three getMatchAnchors tests asserting window sizes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-04-21 14:33:38 +02:00
committed by GitHub
co-authored by Claude Opus 4.7
parent 129664533c
commit b5df2fb292
17 changed files with 832 additions and 123 deletions
+1 -49
View File
@@ -11,15 +11,13 @@ import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Loader2 } from 'lucide-react'
import type { CreateTransactionInput, TransactionCategory, Currency } from '@/types'
import type { CreateTransactionInput, Currency } from '@/types'
const schema = z.object({
date: z.string().min(1, 'Datum krävs'),
description: z.string().min(1, 'Beskrivning krävs'),
amount: z.number().refine((n) => n !== 0, 'Belopp måste anges'),
currency: z.enum(['SEK', 'EUR', 'USD', 'GBP', 'NOK', 'DKK']),
category: z.string().optional(),
is_business: z.boolean().optional(),
notes: z.string().optional(),
})
@@ -30,21 +28,6 @@ interface TransactionFormProps {
isLoading: boolean
}
const categories: { value: TransactionCategory; label: string; isIncome?: boolean }[] = [
{ value: 'income_services', label: 'Intäkt: Tjänster', isIncome: true },
{ value: 'income_products', label: 'Intäkt: Produkter', isIncome: true },
{ value: 'income_other', label: 'Intäkt: Övrigt', isIncome: true },
{ value: 'expense_equipment', label: 'Kostnad: Utrustning' },
{ value: 'expense_software', label: 'Kostnad: Programvara' },
{ value: 'expense_travel', label: 'Kostnad: Resor' },
{ value: 'expense_office', label: 'Kostnad: Kontor' },
{ value: 'expense_marketing', label: 'Kostnad: Marknadsföring' },
{ value: 'expense_professional_services', label: 'Kostnad: Konsulter' },
{ value: 'expense_education', label: 'Kostnad: Utbildning' },
{ value: 'expense_other', label: 'Kostnad: Övrigt' },
{ value: 'private', label: 'Privat (ej avdragsgillt)' },
]
const currencies: Currency[] = ['SEK', 'EUR', 'USD', 'GBP', 'NOK', 'DKK']
export default function TransactionForm({ onSubmit, isLoading }: TransactionFormProps) {
@@ -52,7 +35,6 @@ export default function TransactionForm({ onSubmit, isLoading }: TransactionForm
register,
handleSubmit,
control,
watch,
setValue,
formState: { errors },
} = useForm<FormData>({
@@ -62,8 +44,6 @@ export default function TransactionForm({ onSubmit, isLoading }: TransactionForm
description: '',
amount: 0,
currency: 'SEK',
category: undefined,
is_business: undefined,
notes: '',
},
})
@@ -73,18 +53,12 @@ export default function TransactionForm({ onSubmit, isLoading }: TransactionForm
setValue('date', format(new Date(), 'yyyy-MM-dd'))
}, [])
const watchCategory = watch('category')
const isPrivate = watchCategory === 'private'
const isIncome = categories.find((c) => c.value === watchCategory)?.isIncome
const onFormSubmit = (data: FormData) => {
onSubmit({
date: data.date,
description: data.description,
amount: data.amount,
currency: data.currency,
category: data.category as TransactionCategory,
is_business: undefined,
notes: data.notes,
})
}
@@ -151,28 +125,6 @@ export default function TransactionForm({ onSubmit, isLoading }: TransactionForm
</p>
</div>
<div className="space-y-2">
<Label>Kategori (valfritt)</Label>
<Controller
name="category"
control={control}
render={({ field }) => (
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger>
<SelectValue placeholder="Välj kategori" />
</SelectTrigger>
<SelectContent>
{categories.map((category) => (
<SelectItem key={category.value} value={category.value}>
{category.label}
</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="notes">Anteckningar</Label>
<Textarea