diff --git a/app/api/v1/companies/[companyId]/supplier-invoices/[id]/approve/route.ts b/app/api/v1/companies/[companyId]/supplier-invoices/[id]/approve/route.ts new file mode 100644 index 00000000..6aa07902 --- /dev/null +++ b/app/api/v1/companies/[companyId]/supplier-invoices/[id]/approve/route.ts @@ -0,0 +1,142 @@ +/** + * POST /api/v1/companies/{companyId}/supplier-invoices/{id}/approve + * + * Transitions a `registered` supplier invoice to `approved`. No journal entry + * is involved in this transition — the registration JE has already been posted + * (under accrual) or is deferred to :mark-paid (under cash). Idempotent + * (mandatory Idempotency-Key). Dry-runnable. + * + * Strict-mode: the optimistic-lock UPDATE filters on status='registered' so + * concurrent calls (or a same-key replay racing the first) yield a clean 409 + * rather than a silent no-op. + */ + +import { z } from 'zod' +import { ok } from '@/lib/api/v1/response' +import { dryRunPreview } from '@/lib/api/v1/dry-run' +import { registerEndpoint } from '@/lib/api/v1/registry' +import { withApiV1 } from '@/lib/api/v1/with-api-v1' +import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors' +import { eventBus } from '@/lib/events' +import type { SupplierInvoice } from '@/types' + +const SI_RESPONSE_COLUMNS = + 'id, supplier_id, arrival_number, supplier_invoice_number, invoice_date, due_date, status, currency, subtotal, vat_amount, total, paid_amount, remaining_amount, is_credit_note, registration_journal_entry_id, payment_journal_entry_id, created_at, updated_at' + +const SupplierInvoiceApproved = z.object({ + id: z.string().uuid(), + status: z.literal('approved'), + arrival_number: z.number().int(), + supplier_invoice_number: z.string(), +}) + +registerEndpoint({ + operation: 'supplier-invoices.approve', + method: 'POST', + path: '/api/v1/companies/:companyId/supplier-invoices/:id/approve', + summary: 'Approve a registered supplier invoice.', + description: + 'Flips a supplier invoice from `registered` to `approved`. No journal entry is posted here — the registration JE was already booked at :create under accrual, or is deferred to :mark-paid under cash. Idempotent. Dry-runnable.', + useWhen: + 'A registered SI has been reviewed and you want to mark it ready for payment. Many AP workflows gate :mark-paid behind an explicit approval step.', + doNotUseFor: + 'Posting a journal entry (already done at :create under accrual). Paying the SI (use :mark-paid). Re-approving an already-approved SI (returns 400 SI_APPROVE_NOT_REGISTERED).', + pitfalls: [ + 'Idempotency-Key is mandatory.', + 'Returns 400 SI_APPROVE_NOT_REGISTERED when current status !== "registered". Use the detail endpoint to inspect status first if unsure.', + ], + example: { + response: { + data: { id: '0e9c…', status: 'approved', arrival_number: 42, supplier_invoice_number: '2026-1234' }, + meta: { request_id: 'req_…', api_version: '2026-05-12' }, + }, + }, + scope: 'suppliers:write', + risk: 'low', + idempotent: true, + reversible: false, + dryRunSupported: true, + response: { success: SupplierInvoiceApproved }, +}) + +export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string }> }>( + 'supplier-invoices.approve', + async (_request, ctx, params) => { + const { id } = await params.params + const idParse = z.string().uuid().safeParse(id) + if (!idParse.success) { + return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, { + requestId: ctx.requestId, + details: { field: 'id', message: 'Supplier-invoice id must be a UUID.' }, + }) + } + const invoiceId = idParse.data + + const { data: existing, error: fetchErr } = await ctx.supabase + .from('supplier_invoices') + .select(SI_RESPONSE_COLUMNS) + .eq('company_id', ctx.companyId!) + .eq('id', invoiceId) + .maybeSingle() + + if (fetchErr) { + return v1ErrorResponse(fetchErr, ctx.log, { requestId: ctx.requestId }) + } + if (!existing) { + return v1ErrorResponseFromCode('SI_NOT_FOUND', ctx.log, { requestId: ctx.requestId }) + } + if ((existing as { status: string }).status !== 'registered') { + return v1ErrorResponseFromCode('SI_APPROVE_NOT_REGISTERED', ctx.log, { + requestId: ctx.requestId, + details: { current_status: (existing as { status: string }).status }, + }) + } + + if (ctx.dryRun) { + return dryRunPreview( + { ...(existing as object), status: 'approved' }, + { requestId: ctx.requestId, log: ctx.log }, + ) + } + + const { data, error } = await ctx.supabase + .from('supplier_invoices') + .update({ status: 'approved' }) + .eq('company_id', ctx.companyId!) + .eq('id', invoiceId) + .eq('status', 'registered') + .select(SI_RESPONSE_COLUMNS) + .maybeSingle() + + if (error) { + ctx.log.error('supplier-invoice approve update failed', error, { + invoiceId, + companyId: ctx.companyId, + }) + return v1ErrorResponseFromCode('SI_APPROVE_UPDATE_FAILED', ctx.log, { requestId: ctx.requestId }) + } + if (!data) { + // Race: status transitioned between pre-flight and update. + return v1ErrorResponseFromCode('SI_APPROVE_NOT_REGISTERED', ctx.log, { + requestId: ctx.requestId, + details: { reason: 'race' }, + }) + } + + try { + await eventBus.emit({ + type: 'supplier_invoice.approved', + payload: { + supplierInvoice: data as unknown as SupplierInvoice, + companyId: ctx.companyId!, + userId: ctx.userId, + }, + }) + } catch (err) { + ctx.log.warn('supplier_invoice.approved emit failed', err as Error) + } + + return ok(data, { requestId: ctx.requestId }) + }, + { requireIdempotencyKey: true }, +) diff --git a/app/api/v1/companies/[companyId]/supplier-invoices/[id]/credit/route.ts b/app/api/v1/companies/[companyId]/supplier-invoices/[id]/credit/route.ts new file mode 100644 index 00000000..9a2972c6 --- /dev/null +++ b/app/api/v1/companies/[companyId]/supplier-invoices/[id]/credit/route.ts @@ -0,0 +1,555 @@ +/** + * POST /api/v1/companies/{companyId}/supplier-invoices/{id}/credit + * + * Issues a credit note (kreditfaktura) for an existing supplier invoice. + * Mirrors the dashboard credit flow: + * + * 1. Allocate a new arrival_number for the credit note. + * 2. Insert a new supplier_invoices row with is_credit_note=true, + * credited_invoice_id=, and reversed amounts copied from + * the original. + * 3. Copy items from the original. + * 4. Under accrual, post the credit-note JE (reverses the registration: + * Debit 2440 / Credit 5xxx + Credit 2641). + * 5. Flip the original's status to `credited`. + * + * Strict-mode v1: any failure rolls back the credit-note row before + * returning the error. Idempotent (mandatory Idempotency-Key). Dry-runnable. + */ + +import { z } from 'zod' +import { ok } from '@/lib/api/v1/response' +import { dryRunPreview } from '@/lib/api/v1/dry-run' +import { registerEndpoint } from '@/lib/api/v1/registry' +import { withApiV1 } from '@/lib/api/v1/with-api-v1' +import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors' +import { checkPeriodLock } from '@/lib/api/v1/check-period-lock' +import { createSupplierCreditNoteEntry } from '@/lib/bookkeeping/supplier-invoice-entries' +import { reverseEntry } from '@/lib/bookkeeping/engine' +import { isBookkeepingError } from '@/lib/bookkeeping/errors' +import { eventBus } from '@/lib/events' +import type { SupabaseClient } from '@supabase/supabase-js' +import type { AccountingMethod, SupplierInvoice, SupplierInvoiceItem } from '@/types' + +const SI_RESPONSE_COLUMNS = + 'id, supplier_id, arrival_number, supplier_invoice_number, invoice_date, due_date, status, currency, subtotal, vat_amount, total, paid_amount, remaining_amount, is_credit_note, credited_invoice_id, registration_journal_entry_id, created_at, updated_at' + +// GDPR Art.25 data minimisation: the original SI's `user_id` (the row's +// historical creator) is never used in the credit flow — the new credit-note +// row uses `ctx.userId` (the actor performing the credit). Don't fetch what +// you don't need. `company_id` is already scoped by the `.eq('company_id')` +// filter, so omit that too — the route can't write to a different one. +// GDPR Art.25 data minimisation: only fields actually read in the credit +// flow are projected. `user_id` and `company_id` were dropped earlier; this +// round drops `notes` (the original SI's free-text notes are never copied +// onto the credit note and never inspected) plus several housekeeping +// fields (`paid_at`, `payment_journal_entry_id`, `transaction_id`, +// `document_id`, `payment_reference`, `paid_amount`, `delivery_date`, +// `received_date`, `is_credit_note`, `reversed_at`, `created_at`, +// `updated_at`) that the credit handler never reads. SEK-conversion fields +// (`subtotal_sek` / `vat_amount_sek` / `total_sek`) ARE read — they're +// copied verbatim onto the credit-note row so the 2440 reversal nets +// correctly. +const SI_FULL_COLUMNS = ` + id, supplier_id, supplier_invoice_number, invoice_date, status, + currency, exchange_rate, + subtotal, subtotal_sek, vat_amount, vat_amount_sek, total, total_sek, + vat_treatment, reverse_charge, remaining_amount, + is_credit_note, credited_invoice_id, arrival_number, + supplier:suppliers(id, name, supplier_type), + items:supplier_invoice_items(id, sort_order, description, quantity, unit, unit_price, line_total, account_number, vat_code, vat_rate, vat_amount) +` + +const SupplierInvoiceCredited = z.object({ + credit_note_id: z.string().uuid(), + original_id: z.string().uuid(), + arrival_number: z.number().int(), + supplier_invoice_number: z.string(), + registration_journal_entry_id: z.string().uuid().nullable(), +}) + +registerEndpoint({ + operation: 'supplier-invoices.credit', + method: 'POST', + path: '/api/v1/companies/:companyId/supplier-invoices/:id/credit', + summary: 'Issue a credit note for a supplier invoice.', + description: + 'Creates a kreditfaktura that reverses the original supplier invoice. Under accrual the reversing JE is posted atomically (Debit 2440 / Credit expense + Credit 2641). The original status flips to `credited`. Strict-mode: any failure rolls back the credit-note row. Idempotent. Dry-runnable.', + useWhen: + 'You need to nullify a registered, approved, partially_paid, or paid supplier invoice — for a returned shipment, an over-invoice, or a vendor dispute resolution. Use dry-run to confirm the totals first.', + doNotUseFor: + 'Editing line items on an unchanged invoice (use PATCH on `registered` SIs). Crediting an already-credited SI (returns 409 SI_CREDIT_ALREADY_CREDITED). Reversing a v1-issued credit (no v1 endpoint today — use the dashboard).', + pitfalls: [ + 'Idempotency-Key is mandatory.', + 'Today\'s date is used as the credit-note invoice_date. It must fall in an open fiscal period — locked period returns 400 SI_CREDIT_PERIOD_LOCKED.', + 'Cash basis (kontantmetoden): no reversing JE is posted — recognition is deferred until a refund transaction is booked. The credit-note row is still created so the AP audit trail stays consistent.', + 'The original SI is flipped to `credited` regardless of how much of it was already paid; reconcile the bank refund via the transactions endpoints.', + ], + example: { + response: { + data: { + credit_note_id: '4d2a…', + original_id: '0e9c…', + arrival_number: 43, + supplier_invoice_number: 'KREDIT-2026-1234', + registration_journal_entry_id: '9c2f…', + }, + meta: { request_id: 'req_…', api_version: '2026-05-12' }, + }, + }, + scope: 'suppliers:write', + risk: 'high', + idempotent: true, + reversible: false, + dryRunSupported: true, + response: { success: SupplierInvoiceCredited }, +}) + +export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string }> }>( + 'supplier-invoices.credit', + async (_request, ctx, params) => { + const { id } = await params.params + const idParse = z.string().uuid().safeParse(id) + if (!idParse.success) { + return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, { + requestId: ctx.requestId, + details: { field: 'id', message: 'Supplier-invoice id must be a UUID.' }, + }) + } + const invoiceId = idParse.data + + // Fetch the original with supplier + items. + const { data: original, error: fetchErr } = await ctx.supabase + .from('supplier_invoices') + .select(SI_FULL_COLUMNS) + .eq('company_id', ctx.companyId!) + .eq('id', invoiceId) + .maybeSingle() + + if (fetchErr) { + return v1ErrorResponse(fetchErr, ctx.log, { requestId: ctx.requestId }) + } + if (!original) { + return v1ErrorResponseFromCode('SI_NOT_FOUND', ctx.log, { requestId: ctx.requestId }) + } + + type SupplierObj = { id: string; name: string; supplier_type: string } + type Original = { + id: string + supplier_id: string + status: string + currency: string + exchange_rate: number | null + subtotal: number + subtotal_sek: number | null + vat_amount: number + vat_amount_sek: number | null + total: number + total_sek: number | null + vat_treatment: string + reverse_charge: boolean + remaining_amount: number + paid_amount: number + is_credit_note: boolean + credited_invoice_id: string | null + supplier_invoice_number: string + arrival_number: number + supplier: SupplierObj | SupplierObj[] | null + items?: Array<{ + sort_order: number + description: string + quantity: number + unit: string + unit_price: number + line_total: number + account_number: string + vat_code: string | null + vat_rate: number + vat_amount: number + }> + } & Record + + const typed = original as unknown as Original + + if (typed.is_credit_note) { + return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, { + requestId: ctx.requestId, + details: { field: 'id', message: 'Cannot credit a credit note. Reverse from the dashboard instead.' }, + }) + } + if (typed.status === 'credited') { + return v1ErrorResponseFromCode('SI_CREDIT_ALREADY_CREDITED', ctx.log, { requestId: ctx.requestId }) + } + + const today = new Date().toISOString().split('T')[0] + + // Pre-flight period-lock on the credit-note invoice_date (today). + const lockVerdict = await checkPeriodLock(ctx.supabase, ctx.companyId!, today) + if (lockVerdict.locked) { + return v1ErrorResponseFromCode('SI_CREDIT_PERIOD_LOCKED', ctx.log, { + requestId: ctx.requestId, + details: { + reason: lockVerdict.reason, + fiscal_period_id: lockVerdict.fiscal_period_id, + }, + }) + } + + const pickSupplier = (s: Original['supplier']): SupplierObj | null => { + if (!s) return null + return Array.isArray(s) ? (s[0] ?? null) : s + } + const supplierRow = pickSupplier(typed.supplier) + + if (ctx.dryRun) { + // The arrival_number isn't allocated in a dry-run (would burn the + // sequence on a non-commit). The preview reports the count. + const previewItems = (typed.items ?? []).map((item) => ({ + sort_order: item.sort_order, + description: item.description, + quantity: item.quantity, + unit: item.unit, + unit_price: item.unit_price, + line_total: item.line_total, + account_number: item.account_number, + vat_code: item.vat_code, + vat_rate: item.vat_rate, + vat_amount: item.vat_amount, + })) + return dryRunPreview( + { + credit_note: { + supplier_id: typed.supplier_id, + supplier_invoice_number: `KREDIT-${typed.supplier_invoice_number}`, + invoice_date: today, + due_date: today, + status: 'registered', + currency: typed.currency, + exchange_rate: typed.exchange_rate, + subtotal: typed.subtotal, + vat_amount: typed.vat_amount, + total: typed.total, + is_credit_note: true, + credited_invoice_id: typed.id, + items: previewItems, + }, + original_will_become: 'credited', + would_create_reversal_journal_entry: true, + }, + { requestId: ctx.requestId, log: ctx.log }, + ) + } + + // Allocate arrival_number for the credit note. + const { data: arrivalNum, error: arrivalErr } = await ctx.supabase + .rpc('get_next_arrival_number', { p_company_id: ctx.companyId! }) + if (arrivalErr || arrivalNum == null) { + ctx.log.error('arrival_number allocation failed (credit)', (arrivalErr as Error) ?? new Error('null')) + return v1ErrorResponseFromCode('SI_CREDIT_FAILED', ctx.log, { + requestId: ctx.requestId, + details: { step: 'arrival_number' }, + }) + } + + // Insert credit-note row. + const { data: creditNote, error: creditErr } = await ctx.supabase + .from('supplier_invoices') + .insert({ + user_id: ctx.userId, + company_id: ctx.companyId!, + supplier_id: typed.supplier_id, + arrival_number: arrivalNum, + supplier_invoice_number: `KREDIT-${typed.supplier_invoice_number}`, + invoice_date: today, + due_date: today, + status: 'registered', + currency: typed.currency, + exchange_rate: typed.exchange_rate, + vat_treatment: typed.vat_treatment, + reverse_charge: typed.reverse_charge, + subtotal: typed.subtotal, + subtotal_sek: typed.subtotal_sek, + vat_amount: typed.vat_amount, + vat_amount_sek: typed.vat_amount_sek, + total: typed.total, + total_sek: typed.total_sek, + remaining_amount: 0, + is_credit_note: true, + credited_invoice_id: typed.id, + }) + .select(SI_RESPONSE_COLUMNS) + .single() + + if (creditErr || !creditNote) { + ctx.log.error('credit-note insert failed', creditErr as Error, { + originalId: typed.id, + companyId: ctx.companyId, + }) + return v1ErrorResponseFromCode('SI_CREDIT_FAILED', ctx.log, { + requestId: ctx.requestId, + details: { step: 'credit_note_insert', pg_code: (creditErr as { code?: string } | null)?.code }, + }) + } + + const creditNoteId = (creditNote as { id: string }).id + + // Copy items. + const creditItems = (typed.items ?? []).map((item) => ({ + supplier_invoice_id: creditNoteId, + sort_order: item.sort_order, + description: item.description, + quantity: item.quantity, + unit: item.unit, + unit_price: item.unit_price, + line_total: item.line_total, + account_number: item.account_number, + vat_code: item.vat_code, + vat_rate: item.vat_rate, + vat_amount: item.vat_amount, + })) + if (creditItems.length > 0) { + const { error: itemsErr } = await ctx.supabase + .from('supplier_invoice_items') + .insert(creditItems) + if (itemsErr) { + // items_insert fires before any engine call — no JE could exist. + await rollbackCreditNote(ctx.supabase, creditNoteId, ctx.companyId!, ctx.log, 'items_insert', false) + return v1ErrorResponseFromCode('SI_CREDIT_FAILED', ctx.log, { + requestId: ctx.requestId, + details: { step: 'credit_items_insert', pg_code: (itemsErr as { code?: string }).code }, + }) + } + } + + // Accrual: post the reversing JE. Cash basis: skip (no original + // registration entry to reverse; refund is recognized when the bank + // transaction is booked). + const { data: settings } = await ctx.supabase + .from('company_settings') + .select('accounting_method') + .eq('company_id', ctx.companyId!) + .maybeSingle() + const accountingMethod = ((settings as { accounting_method?: string } | null)?.accounting_method + ?? 'accrual') as AccountingMethod + + let journalEntryId: string | null = null + if (accountingMethod === 'accrual') { + try { + const entry = await createSupplierCreditNoteEntry( + ctx.supabase, + ctx.companyId!, + ctx.userId, + creditNote as unknown as SupplierInvoice, + creditItems as unknown as SupplierInvoiceItem[], + supplierRow?.supplier_type ?? 'swedish_business', + supplierRow?.name, + ) + if (entry) { + journalEntryId = entry.id + const { error: linkErr } = await ctx.supabase + .from('supplier_invoices') + .update({ registration_journal_entry_id: entry.id }) + .eq('id', creditNoteId) + .eq('company_id', ctx.companyId!) + if (linkErr) { + // Symmetric with the SI register path: storno the posted JE + + // roll back the credit note before returning, so we never leave + // a credit note row with registration_journal_entry_id=null while + // the reversing JE sits live on the books. + ctx.log.error('credit-note JE link update failed — stornoing JE and rolling back row', linkErr, { + creditNoteId, + originalId: typed.id, + journalEntryId: entry.id, + companyId: ctx.companyId, + userId: ctx.userId, + }) + try { + await reverseEntry(ctx.supabase, ctx.companyId!, ctx.userId, entry.id, today) + } catch (revErr) { + ctx.log.error('JE storno failed after credit-note link-update error — manual reconciliation required', revErr as Error, { + creditNoteId, + journalEntryId: entry.id, + userId: ctx.userId, + }) + } + // je_link_failed: the credit-note JE was posted (and we just + // stornoed it above). Soft-mark keeps the trail per BFL 5:5. + await rollbackCreditNote(ctx.supabase, creditNoteId, ctx.companyId!, ctx.log, 'je_link_failed', true) + return v1ErrorResponseFromCode('SI_CREDIT_FAILED', ctx.log, { + requestId: ctx.requestId, + details: { step: 'credit_journal_entry_link' }, + }) + } + } else { + // Engine returned null before posting — no JE exists. + await rollbackCreditNote(ctx.supabase, creditNoteId, ctx.companyId!, ctx.log, 'no_fiscal_period', false) + return v1ErrorResponseFromCode('SI_CREDIT_FAILED', ctx.log, { + requestId: ctx.requestId, + details: { step: 'credit_journal_entry', reason: 'no_fiscal_period' }, + }) + } + } catch (err) { + // Engine threw — conservatively assume the JE may have committed. + await rollbackCreditNote(ctx.supabase, creditNoteId, ctx.companyId!, ctx.log, 'credit_journal_entry', true) + if (isBookkeepingError(err)) { + return v1ErrorResponse(err, ctx.log, { requestId: ctx.requestId }) + } + ctx.log.error('supplier credit-note JE creation failed', err as Error, { + creditNoteId, + originalId: typed.id, + companyId: ctx.companyId, + }) + return v1ErrorResponseFromCode('SI_CREDIT_FAILED', ctx.log, { + requestId: ctx.requestId, + details: { step: 'credit_journal_entry' }, + }) + } + } + + // Step 5: flip the original to `credited`. CAS guard: only transition + // from a non-terminal state (avoids a concurrent credit/credit race + // leaving us with two credit notes against one original). + // + // A kreditfaktura nullifies the AP obligation on the original (BFL 5 kap + // 5 §); refunds of already-paid amounts are recorded separately via the + // transactions endpoints. So both `remaining_amount` and `status` are set + // unconditionally — no arithmetic on remaining_amount - total (the prior + // calc was a logic error: remaining_amount ≤ total, so the result was + // always ≤ 0, forcing status to 'credited' anyway via the clamp). + const { data: originalUpdated, error: originalUpdateErr } = await ctx.supabase + .from('supplier_invoices') + .update({ + status: 'credited', + remaining_amount: 0, + }) + .eq('company_id', ctx.companyId!) + .eq('id', typed.id) + // Don't re-credit an already-credited/reversed original. + .not('status', 'in', '(credited,reversed)') + .select('id, status, remaining_amount') + .maybeSingle() + + if (originalUpdateErr) { + ctx.log.error('original SI status flip to credited failed', originalUpdateErr, { + originalId: typed.id, + creditNoteId, + }) + // Don't roll back the credit note here — the JE exists on the books + // and rolling back leaves a partial state. Surface the error; manual + // reconciliation will flip the original. + return v1ErrorResponseFromCode('SI_CREDIT_FAILED', ctx.log, { + requestId: ctx.requestId, + details: { step: 'original_status_flip', credit_note_id: creditNoteId, journal_entry_id: journalEntryId }, + }) + } + if (!originalUpdated) { + // Race: original was credited/reversed between fetch and update. Roll + // back the new credit note (and its JE) to avoid a double-credit state. + ctx.log.warn('credit race detected; rolling back new credit note', { + originalId: typed.id, + creditNoteId, + journalEntryId, + userId: ctx.userId, + }) + if (journalEntryId) { + try { + await reverseEntry(ctx.supabase, ctx.companyId!, ctx.userId, journalEntryId, today) + } catch (revErr) { + ctx.log.error('orphan credit JE storno failed', revErr as Error, { + creditNoteId, + journalEntryId, + userId: ctx.userId, + }) + } + } + // credit_race: the credit-note JE was posted (and just stornoed above). + await rollbackCreditNote(ctx.supabase, creditNoteId, ctx.companyId!, ctx.log, 'credit_race', true) + return v1ErrorResponseFromCode('SI_CREDIT_ALREADY_CREDITED', ctx.log, { + requestId: ctx.requestId, + details: { reason: 'race' }, + }) + } + + try { + await eventBus.emit({ + type: 'supplier_invoice.credited', + payload: { + supplierInvoice: typed as unknown as SupplierInvoice, + creditNote: creditNote as unknown as SupplierInvoice, + companyId: ctx.companyId!, + userId: ctx.userId, + }, + }) + } catch (err) { + ctx.log.warn('supplier_invoice.credited emit failed', err as Error) + } + + return ok( + { + credit_note_id: creditNoteId, + original_id: typed.id, + arrival_number: (creditNote as { arrival_number: number }).arrival_number, + supplier_invoice_number: (creditNote as { supplier_invoice_number: string }).supplier_invoice_number, + registration_journal_entry_id: journalEntryId, + }, + { requestId: ctx.requestId }, + ) + }, + { requireIdempotencyKey: true }, +) + +async function rollbackCreditNote( + supabase: SupabaseClient, + creditNoteId: string, + companyId: string, + log: import('@/lib/logger').Logger, + reason: string, + journalEntryPosted: boolean, +) { + // BFL 5 kap 5 § applies once a verifikation has been committed to the + // books. Pre-JE failures (items_insert, engine-returned-null) are not + // bokföringsposter and should hard-delete. Post-JE failures soft-mark + // `'reversed'` so the audit trail of the attempt (and the JE+storno pair + // on the verifikation side) stays visible. + if (!journalEntryPosted) { + await supabase.from('supplier_invoice_items').delete().eq('supplier_invoice_id', creditNoteId) + const { error: parentErr } = await supabase + .from('supplier_invoices') + .delete() + .eq('id', creditNoteId) + .eq('company_id', companyId) + if (parentErr) { + log.error('credit-note hard-rollback failed — orphan row', parentErr, { + creditNoteId, + companyId, + rollbackReason: reason, + }) + } else { + log.warn('credit-note hard-rolled back (no JE existed)', { + creditNoteId, + companyId, + rollbackReason: reason, + }) + } + return + } + + const { error: updateErr } = await supabase + .from('supplier_invoices') + .update({ status: 'reversed', reversed_at: new Date().toISOString() }) + .eq('id', creditNoteId) + .eq('company_id', companyId) + if (updateErr) { + log.error('credit-note soft-rollback failed — manual reconciliation required', updateErr, { + creditNoteId, + companyId, + rollbackReason: reason, + }) + } else { + log.warn('credit-note soft-rolled back (status=reversed)', { + creditNoteId, + companyId, + rollbackReason: reason, + }) + } +} diff --git a/app/api/v1/companies/[companyId]/supplier-invoices/[id]/mark-paid/route.ts b/app/api/v1/companies/[companyId]/supplier-invoices/[id]/mark-paid/route.ts new file mode 100644 index 00000000..9c25be4a --- /dev/null +++ b/app/api/v1/companies/[companyId]/supplier-invoices/[id]/mark-paid/route.ts @@ -0,0 +1,485 @@ +/** + * POST /api/v1/companies/{companyId}/supplier-invoices/{id}/mark-paid + * + * Records a payment against a supplier invoice. Books the payment journal + * entry via createSupplierInvoicePaymentEntry (accrual) or + * createSupplierInvoiceCashEntry (cash basis — recognizes the expense here), + * then flips status to `paid` or `partially_paid` with an optimistic-lock + * UPDATE that prevents double-booking under concurrent calls. + * + * Strict-mode v1 (per Phase 3 lessons): if JE creation fails, the route + * ABORTS before any SI state mutation — no payment row is written, status + * is unchanged. The caller can retry cleanly. + * + * Idempotent (mandatory Idempotency-Key). Dry-runnable. + */ + +import { z } from 'zod' +import { ok } from '@/lib/api/v1/response' +import { dryRunPreview } from '@/lib/api/v1/dry-run' +import { registerEndpoint } from '@/lib/api/v1/registry' +import { withApiV1 } from '@/lib/api/v1/with-api-v1' +import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors' +import { checkPeriodLock } from '@/lib/api/v1/check-period-lock' +import { MarkSupplierInvoicePaidSchema } from '@/lib/api/schemas' +import { + createSupplierInvoiceCashEntry, + createSupplierInvoicePaymentEntry, +} from '@/lib/bookkeeping/supplier-invoice-entries' +import { reverseEntry } from '@/lib/bookkeeping/engine' +import { isBookkeepingError } from '@/lib/bookkeeping/errors' +import { eventBus } from '@/lib/events' +import type { SupplierInvoice, SupplierInvoiceItem } from '@/types' + +const SI_PAID_RESPONSE_COLUMNS = + 'id, supplier_id, arrival_number, supplier_invoice_number, status, currency, total, paid_amount, remaining_amount, paid_at, payment_journal_entry_id' + +const PAYABLE_STATUSES = ['registered', 'approved', 'partially_paid', 'overdue'] as const + +const SupplierInvoicePaidResponse = z.object({ + id: z.string().uuid(), + status: z.enum(['paid', 'partially_paid']), + total: z.number(), + paid_amount: z.number(), + remaining_amount: z.number(), + paid_at: z.string().nullable(), + payment_journal_entry_id: z.string().uuid().nullable(), +}) + +registerEndpoint({ + operation: 'supplier-invoices.mark-paid', + method: 'POST', + path: '/api/v1/companies/:companyId/supplier-invoices/:id/mark-paid', + summary: 'Record a payment against a supplier invoice.', + description: + 'Books the payment journal entry (Debit 2440 / Credit 1930 under accrual; or Debit expense + Debit 2641 / Credit 1930 under cash) and flips the SI status to `paid` (full settlement) or `partially_paid`. Strict-mode: a JE failure aborts before any SI mutation. Idempotent. Dry-runnable.', + useWhen: + 'You paid a registered or approved leverantörsfaktura through a channel other than the synced bank flow. For bank-matched payments use POST /transactions/{id}/match-supplier-invoice instead — that path also reconciles the bank line.', + doNotUseFor: + 'Refunding a payment (the public API does not expose unmark-paid; credit the SI instead). Paying a credited or already-paid SI (returns 409 SI_PAID_ALREADY).', + pitfalls: [ + 'Idempotency-Key is mandatory.', + 'payment_date must fall in an open fiscal period — locked period returns 400 PERIOD_LOCKED.', + 'exchange_rate_difference (SEK delta vs the booked rate at registration) is required for foreign-currency SIs to book the FX gain/loss to 3960 / 7960. Omitting it on a non-SEK SI under accrual mis-books FX.', + 'Strict-mode: a JE creation failure ABORTS before the status flip. There is no partial-state recovery banner — retry the call.', + 'Cash basis (kontantmetoden) recognizes the expense + ingående moms HERE, not at :create.', + ], + example: { + request: { payment_date: '2026-05-13' }, + response: { + data: { + id: '0e9c…', + status: 'paid', + total: 1250, + paid_amount: 1250, + remaining_amount: 0, + paid_at: '2026-05-13', + payment_journal_entry_id: '7b3a…', + }, + meta: { request_id: 'req_…', api_version: '2026-05-12' }, + }, + }, + scope: 'suppliers:write', + risk: 'medium', + idempotent: true, + reversible: false, + dryRunSupported: true, + request: { body: MarkSupplierInvoicePaidSchema }, + response: { success: SupplierInvoicePaidResponse }, +}) + +export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string }> }>( + 'supplier-invoices.mark-paid', + async (request, ctx, params) => { + const { id } = await params.params + const idParse = z.string().uuid().safeParse(id) + if (!idParse.success) { + return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, { + requestId: ctx.requestId, + details: { field: 'id', message: 'Supplier-invoice id must be a UUID.' }, + }) + } + const invoiceId = idParse.data + + // Body is optional — empty POST = pay the full remaining_amount today. + let rawBody: unknown = null + try { + const text = await request.text() + if (text.trim()) rawBody = JSON.parse(text) + } catch { + return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, { + requestId: ctx.requestId, + details: { field: 'body', message: 'Body is not valid JSON.' }, + }) + } + + let bodyAmount: number | undefined + let bodyPaymentDate: string | undefined + let exchangeRateDifference: number | undefined + let bodyNotes: string | undefined + if (rawBody) { + const parsed = MarkSupplierInvoicePaidSchema.safeParse(rawBody) + if (!parsed.success) { + return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, { + requestId: ctx.requestId, + details: { + issues: parsed.error.issues.map((i) => ({ + field: i.path.join('.'), + message: i.message, + })), + }, + }) + } + bodyAmount = parsed.data.amount + bodyPaymentDate = parsed.data.payment_date + exchangeRateDifference = parsed.data.exchange_rate_difference + bodyNotes = parsed.data.notes + } + + const today = new Date().toISOString().split('T')[0] + const paymentDate = bodyPaymentDate || today + + // Reject future payment_date at the schema layer. BFL 5 kap 2 § + // requires bokföring to follow real cash movement; a payment booked + // in the future is a scheduling artefact, not an affärshändelse. + // No legitimate v1 workflow needs to backstamp tomorrow; if the user + // wants to schedule, that's a different surface. + if (paymentDate > today) { + return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, { + requestId: ctx.requestId, + details: { + field: 'payment_date', + message: 'payment_date cannot be in the future.', + attempted: paymentDate, + today, + }, + }) + } + + // Fetch SI with supplier + items (needed by the engine for cash-basis). + const { data: invoice, error: fetchErr } = await ctx.supabase + .from('supplier_invoices') + .select(` + id, supplier_id, status, currency, exchange_rate, total, paid_amount, remaining_amount, + supplier_invoice_number, arrival_number, invoice_date, vat_treatment, reverse_charge, + subtotal, subtotal_sek, vat_amount, vat_amount_sek, total_sek, due_date, received_date, + is_credit_note, credited_invoice_id, payment_journal_entry_id, + supplier:suppliers(id, name, supplier_type), + items:supplier_invoice_items(id, sort_order, description, quantity, unit, unit_price, line_total, account_number, vat_code, vat_rate, vat_amount) + `) + .eq('company_id', ctx.companyId!) + .eq('id', invoiceId) + .maybeSingle() + + if (fetchErr) { + return v1ErrorResponse(fetchErr, ctx.log, { requestId: ctx.requestId }) + } + if (!invoice) { + return v1ErrorResponseFromCode('SI_NOT_FOUND', ctx.log, { requestId: ctx.requestId }) + } + + type SupplierObj = { id: string; name: string; supplier_type: string } + type SI = { + id: string + supplier_id: string + status: string + currency: string + total: number + paid_amount: number + remaining_amount: number + supplier_invoice_number: string + arrival_number: number + invoice_date: string + is_credit_note: boolean + supplier: SupplierObj | SupplierObj[] | null + items?: unknown[] + } & Record + + const typed = invoice as unknown as SI + + if (typed.is_credit_note) { + return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, { + requestId: ctx.requestId, + details: { field: 'id', message: 'Credit notes cannot be marked paid.' }, + }) + } + + if (!PAYABLE_STATUSES.includes(typed.status as (typeof PAYABLE_STATUSES)[number])) { + const code = typed.status === 'paid' || typed.status === 'credited' || typed.status === 'reversed' + ? 'SI_PAID_ALREADY' + : 'SI_PAID_NOT_PAYABLE' + return v1ErrorResponseFromCode(code, ctx.log, { + requestId: ctx.requestId, + details: { current_status: typed.status }, + }) + } + + // Application-layer period-lock pre-check. + const lockVerdict = await checkPeriodLock(ctx.supabase, ctx.companyId!, paymentDate) + if (lockVerdict.locked) { + return v1ErrorResponseFromCode('SI_PAID_PERIOD_LOCKED', ctx.log, { + requestId: ctx.requestId, + details: { + reason: lockVerdict.reason, + fiscal_period_id: lockVerdict.fiscal_period_id, + payment_date: paymentDate, + }, + }) + } + + const paymentAmount = bodyAmount != null + ? Math.round(bodyAmount * 100) / 100 + : Math.round(typed.remaining_amount * 100) / 100 + + if (paymentAmount <= 0) { + return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, { + requestId: ctx.requestId, + details: { field: 'amount', message: 'amount must be positive.' }, + }) + } + + // Reject overpayment up front. Without this, the silent `Math.max(0, ...)` + // clamp below would book the full payment_amount in the JE but only + // reduce the SI balance to 0 — the difference would be an unaccounted + // overpayment on the 2440 ledger. If a refund is genuinely due, the + // caller credits the SI (which reverses the obligation) and books the + // refund as a separate bank transaction. Half-öre tolerance allows + // legitimate rounding artefacts from FX-difference adjustments. + if (paymentAmount > typed.remaining_amount + 0.005) { + return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, { + requestId: ctx.requestId, + details: { + field: 'amount', + message: + 'amount exceeds remaining_amount. Issue a credit note via :credit for over-billing, or book the refund through the transactions endpoints.', + attempted: paymentAmount, + remaining_amount: typed.remaining_amount, + }, + }) + } + + const newRemaining = Math.max( + 0, + Math.round((typed.remaining_amount - paymentAmount) * 100) / 100, + ) + // Half-öre epsilon — same convention as v1 invoices.mark-paid. + const newStatus: 'paid' | 'partially_paid' = newRemaining <= 0.005 ? 'paid' : 'partially_paid' + const newPaidAmount = Math.round((typed.paid_amount + paymentAmount) * 100) / 100 + + // Settings fetch hoisted ahead of the dry-run branch so the FX-required + // check below fires in both preview and commit modes (and so dry-run can + // surface the requirement before a caller learns it the hard way). + const { data: settings } = await ctx.supabase + .from('company_settings') + .select('accounting_method') + .eq('company_id', ctx.companyId!) + .maybeSingle() + const accountingMethod = (settings as { accounting_method?: string } | null)?.accounting_method ?? 'accrual' + + // FX-required validation. Under accrual the registration JE used the + // invoice's exchange rate to compute subtotal_sek; the payment JE has to + // book any rate delta to 3960 / 7960 (BAS) or AP will carry a stranded + // 2440 balance after the bank line clears. The pitfall docs warn about + // this — enforce it. + if ( + typed.currency !== 'SEK' && + accountingMethod === 'accrual' && + exchangeRateDifference === undefined + ) { + return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, { + requestId: ctx.requestId, + details: { + issues: [{ + field: 'exchange_rate_difference', + message: + 'exchange_rate_difference (SEK delta vs the registration rate) is required when paying a non-SEK supplier invoice under faktureringsmetoden. Use 0 if there is no rate movement.', + }], + invoice_currency: typed.currency, + }, + }) + } + + if (ctx.dryRun) { + // paid_at: the live UPDATE writes `new Date().toISOString()` (a full UTC + // timestamp). Mirror that shape here so callers validating dry-run vs + // live against the same regex don't see surprises. payment_date stays + // ISO date because it represents the user-supplied calendar date. + return dryRunPreview( + { + ...typed, + status: newStatus, + paid_amount: newPaidAmount, + remaining_amount: newRemaining, + paid_at: newStatus === 'paid' ? new Date().toISOString() : null, + payment_date: paymentDate, + payment_amount: paymentAmount, + would_create_payment_journal_entry: true, + }, + { requestId: ctx.requestId, log: ctx.log }, + ) + } + + const pickSupplier = (s: SI['supplier']): SupplierObj | null => { + if (!s) return null + return Array.isArray(s) ? (s[0] ?? null) : s + } + const supplierRow = pickSupplier(typed.supplier) + + // Strict-mode: book the JE FIRST. Failure aborts before any SI mutation. + let journalEntryId: string | null = null + try { + if (accountingMethod === 'cash') { + const entry = await createSupplierInvoiceCashEntry( + ctx.supabase, + ctx.companyId!, + ctx.userId, + typed as unknown as SupplierInvoice, + (typed.items ?? []) as SupplierInvoiceItem[], + paymentDate, + supplierRow?.supplier_type ?? 'swedish_business', + supplierRow?.name, + ) + journalEntryId = entry?.id ?? null + } else { + const entry = await createSupplierInvoicePaymentEntry( + ctx.supabase, + ctx.companyId!, + ctx.userId, + typed as unknown as SupplierInvoice, + paymentAmount, + paymentDate, + exchangeRateDifference, + supplierRow?.name, + ) + journalEntryId = entry?.id ?? null + } + if (!journalEntryId) { + // Engine returned null (no open fiscal period). Strict-mode abort. + return v1ErrorResponseFromCode('SI_PAID_FAILED', ctx.log, { + requestId: ctx.requestId, + details: { reason: 'no_fiscal_period', payment_date: paymentDate }, + }) + } + } catch (err) { + if (isBookkeepingError(err)) { + return v1ErrorResponse(err, ctx.log, { requestId: ctx.requestId }) + } + ctx.log.error('supplier-invoice mark-paid JE creation failed', err as Error, { + invoiceId, + companyId: ctx.companyId, + }) + return v1ErrorResponseFromCode('SI_PAID_FAILED', ctx.log, { + requestId: ctx.requestId, + details: { reason: err instanceof Error ? err.message : 'unknown' }, + }) + } + + // Step 2: optimistic-lock SI update. The .in() filter guards against + // concurrent calls (or a credit/mark-paid race) flipping the status + // between our pre-flight and write. + const { data: updated, error: updateErr } = await ctx.supabase + .from('supplier_invoices') + .update({ + status: newStatus, + remaining_amount: newRemaining, + paid_amount: newPaidAmount, + paid_at: newStatus === 'paid' ? new Date().toISOString() : null, + payment_journal_entry_id: journalEntryId, + }) + .eq('company_id', ctx.companyId!) + .eq('id', invoiceId) + .in('status', PAYABLE_STATUSES as unknown as string[]) + .select(SI_PAID_RESPONSE_COLUMNS) + .maybeSingle() + + if (updateErr) { + ctx.log.error('supplier-invoice mark-paid update failed — attempting storno of orphaned JE', updateErr, { + invoiceId, + companyId: ctx.companyId, + userId: ctx.userId, + journalEntryId, + }) + // The payment JE is already posted but the SI update failed — without a + // storno, the AP ledger would carry a 2440/1930 entry with no matching + // SI status change (BFL 5 kap 5 § integrity violation). reverseEntry() + // takes the entry id directly (no pre-fetch needed), matching the CAS- + // race branch immediately below. + try { + await reverseEntry(ctx.supabase, ctx.companyId!, ctx.userId, journalEntryId, paymentDate) + } catch (revErr) { + ctx.log.error('orphan JE storno failed after SI update error — manual reconciliation required', revErr as Error, { + invoiceId, + companyId: ctx.companyId, + userId: ctx.userId, + journalEntryId, + }) + } + return v1ErrorResponseFromCode('SI_PAID_FAILED', ctx.log, { + requestId: ctx.requestId, + details: { reason: 'si_update_failed', journal_entry_id: journalEntryId }, + }) + } + if (!updated) { + // CAS race: the SI moved out of a payable state between pre-flight and + // write. The JE we just posted is now orphaned. Storno it. + ctx.log.warn('supplier-invoice mark-paid race — JE was orphaned, attempting storno', { + invoiceId, + companyId: ctx.companyId, + userId: ctx.userId, + journalEntryId, + }) + try { + await reverseEntry(ctx.supabase, ctx.companyId!, ctx.userId, journalEntryId, paymentDate) + } catch (revErr) { + ctx.log.error('orphan JE storno failed — manual reconciliation required', revErr as Error, { + invoiceId, + companyId: ctx.companyId, + userId: ctx.userId, + journalEntryId, + }) + } + return v1ErrorResponseFromCode('SI_PAID_ALREADY', ctx.log, { + requestId: ctx.requestId, + details: { reason: 'race' }, + }) + } + + // Step 3: record the payment row (non-blocking — its only consumer is the + // dashboard's "payment history" tab, and the JE is the source of truth). + const { error: paymentErr } = await ctx.supabase + .from('supplier_invoice_payments') + .insert({ + user_id: ctx.userId, + company_id: ctx.companyId!, + supplier_invoice_id: invoiceId, + payment_date: paymentDate, + amount: paymentAmount, + currency: typed.currency, + exchange_rate_difference: exchangeRateDifference ?? 0, + journal_entry_id: journalEntryId, + notes: bodyNotes ?? null, + }) + if (paymentErr) { + ctx.log.warn('supplier_invoice_payments insert failed (non-blocking)', paymentErr, { + invoiceId, + }) + } + + try { + await eventBus.emit({ + type: 'supplier_invoice.paid', + payload: { + supplierInvoice: typed as unknown as SupplierInvoice, + paymentAmount, + companyId: ctx.companyId!, + userId: ctx.userId, + }, + }) + } catch (err) { + ctx.log.warn('supplier_invoice.paid emit failed', err as Error) + } + + return ok(updated, { requestId: ctx.requestId }) + }, + { requireIdempotencyKey: true }, +) diff --git a/app/api/v1/companies/[companyId]/supplier-invoices/[id]/route.ts b/app/api/v1/companies/[companyId]/supplier-invoices/[id]/route.ts new file mode 100644 index 00000000..77b65c3e --- /dev/null +++ b/app/api/v1/companies/[companyId]/supplier-invoices/[id]/route.ts @@ -0,0 +1,307 @@ +/** + * /api/v1/companies/{companyId}/supplier-invoices/{id} — detail + update. + * + * GET — full record. ?expand=supplier,items,payments embeds related rows. + * PATCH — partial update. Only allowed on `registered` status (mirrors the + * dashboard: an approved/paid SI is effectively immutable from the + * caller's perspective; for those, use the action verbs or :credit). + * Idempotent (mandatory Idempotency-Key). Dry-runnable. + * + * No DELETE — supplier-invoice withdrawal is via :credit (mirrors v1 invoices). + * The credit verb keeps both originals AND credit notes in the audit trail per + * BFL 5 kap 5 § (corrections via reversing entries). + */ + +import { z } from 'zod' +import { ok } from '@/lib/api/v1/response' +import { dryRunPreview } from '@/lib/api/v1/dry-run' +import { parseExpand } from '@/lib/api/v1/expand' +import { registerEndpoint } from '@/lib/api/v1/registry' +import { withApiV1 } from '@/lib/api/v1/with-api-v1' +import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors' +import { UpdateSupplierInvoiceSchema } from '@/lib/api/schemas' + +// V1-only strict variant. The shared `UpdateSupplierInvoiceSchema` is also +// consumed by the dashboard, where unknown keys are silently stripped — fine +// for a UI that controls its own payload. The public API treats unknown keys +// as a contract violation: if a future schema iteration ever adds a +// protected field (status, company_id, user_id), `.strict()` makes the +// mass-assignment vector structurally impossible regardless of whether the +// downstream allowlist iteration catches it. +const V1PatchSupplierInvoiceSchema = UpdateSupplierInvoiceSchema.strict() + +const SI_DETAIL_COLUMNS = + 'id, supplier_id, arrival_number, supplier_invoice_number, invoice_date, due_date, received_date, delivery_date, status, currency, exchange_rate, exchange_rate_date, subtotal, subtotal_sek, vat_amount, vat_amount_sek, total, total_sek, vat_treatment, reverse_charge, payment_reference, paid_at, paid_amount, remaining_amount, is_credit_note, credited_invoice_id, registration_journal_entry_id, payment_journal_entry_id, transaction_id, document_id, notes, reversed_at, created_at, updated_at' + +const SI_ITEM_COLUMNS = + 'id, sort_order, description, quantity, unit, unit_price, line_total, account_number, vat_code, vat_rate, vat_amount' + +const SI_PAYMENT_COLUMNS = + 'id, payment_date, amount, currency, exchange_rate, exchange_rate_difference, journal_entry_id, transaction_id, notes, created_at' + +const SUPPLIER_DETAIL_COLUMNS_EXPAND = + 'id, name, supplier_type, email, org_number, vat_number, default_payment_terms, default_currency, bankgiro, plusgiro, iban, bic, default_expense_account, archived_at' + +const SupplierInvoiceDetail = z.object({ + id: z.string().uuid(), + supplier_id: z.string().uuid(), + arrival_number: z.number().int(), + supplier_invoice_number: z.string(), + invoice_date: z.string(), + due_date: z.string(), + received_date: z.string(), + delivery_date: z.string().nullable(), + status: z.string(), + currency: z.string(), + exchange_rate: z.number().nullable(), + subtotal: z.number(), + vat_amount: z.number(), + total: z.number(), + vat_treatment: z.string(), + reverse_charge: z.boolean(), + paid_amount: z.number(), + remaining_amount: z.number(), + is_credit_note: z.boolean(), + credited_invoice_id: z.string().uuid().nullable(), + registration_journal_entry_id: z.string().uuid().nullable(), + payment_journal_entry_id: z.string().uuid().nullable(), + notes: z.string().nullable(), + created_at: z.string(), + updated_at: z.string(), +}) + +const ALLOWED_EXPAND = ['supplier', 'items', 'payments'] as const + +registerEndpoint({ + operation: 'supplier-invoices.get', + method: 'GET', + path: '/api/v1/companies/:companyId/supplier-invoices/:id', + summary: 'Retrieve a single supplier invoice by id.', + description: + 'Returns the full supplier-invoice record. Pass ?expand=supplier,items,payments to embed the related rows in the same response.', + useWhen: + 'You need the full record before approving, paying, or crediting it — or for audit trail / reconciliation.', + doNotUseFor: + 'Listing supplier invoices (use the list endpoint). Customer-invoice lookups (different resource).', + pitfalls: [ + 'Credit notes return is_credit_note=true and a credited_invoice_id pointing at the original.', + 'registration_journal_entry_id and payment_journal_entry_id let you trace the SI to its bokföring rows; they are null when no JE has been posted (e.g. on a kontantmetoden SI before payment).', + ], + example: { + response: { + data: { + id: '0e9c…', + supplier_id: 'a8f1…', + arrival_number: 42, + supplier_invoice_number: '2026-1234', + status: 'registered', + currency: 'SEK', + subtotal: 1000, + vat_amount: 250, + total: 1250, + remaining_amount: 1250, + is_credit_note: false, + }, + meta: { request_id: 'req_…', api_version: '2026-05-12' }, + }, + }, + scope: 'suppliers:read', + risk: 'low', + idempotent: true, + reversible: false, + dryRunSupported: false, + response: { success: SupplierInvoiceDetail }, +}) + +export const GET = withApiV1<{ params: Promise<{ companyId: string; id: string }> }>( + 'supplier-invoices.get', + async (request, ctx, params) => { + const { id } = await params.params + const idParse = z.string().uuid().safeParse(id) + if (!idParse.success) { + return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, { + requestId: ctx.requestId, + details: { field: 'id', message: 'Supplier-invoice id must be a UUID.' }, + }) + } + const invoiceId = idParse.data + + const url = new URL(request.url) + const expandResult = parseExpand(url, ALLOWED_EXPAND) + if (!expandResult.ok) { + return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, { + requestId: ctx.requestId, + details: { + field: 'expand', + invalidKeys: expandResult.invalidKeys, + allowed: expandResult.allowed, + }, + }) + } + const expand = expandResult.expand + + const parts: string[] = [SI_DETAIL_COLUMNS] + if (expand.has('supplier')) parts.push(`supplier:suppliers(${SUPPLIER_DETAIL_COLUMNS_EXPAND})`) + if (expand.has('items')) parts.push(`items:supplier_invoice_items(${SI_ITEM_COLUMNS})`) + if (expand.has('payments')) parts.push(`payments:supplier_invoice_payments(${SI_PAYMENT_COLUMNS})`) + const selectClause = parts.join(', ') + + const { data, error } = await ctx.supabase + .from('supplier_invoices') + .select(selectClause) + .eq('company_id', ctx.companyId!) + .eq('id', invoiceId) + .maybeSingle() + + if (error) { + return v1ErrorResponse(error, ctx.log, { requestId: ctx.requestId }) + } + if (!data) { + ctx.log.warn('supplier-invoices.get: not found', { invoiceId, companyId: ctx.companyId }) + return v1ErrorResponseFromCode('SI_NOT_FOUND', ctx.log, { requestId: ctx.requestId }) + } + + return ok(data, { requestId: ctx.requestId }) + }, +) + +// ────────────────────────────────────────────────────────────────── +// PATCH — partial update (registered-only) +// ────────────────────────────────────────────────────────────────── + +registerEndpoint({ + operation: 'supplier-invoices.update', + method: 'PATCH', + path: '/api/v1/companies/:companyId/supplier-invoices/:id', + summary: 'Update a registered supplier invoice.', + description: + 'Patches a supplier invoice with the supplied fields. Only allowed on `registered` status — once approved, paid, or credited, the record is effectively immutable from the API\'s perspective. Idempotent (mandatory Idempotency-Key). Dry-runnable.', + useWhen: + 'You need to fix a typo in supplier_invoice_number, adjust dates, or attach a payment reference / notes to a registered SI before approval. Use dry-run to confirm the merged state first.', + doNotUseFor: + 'Editing line items (immutable — credit the SI and register a new one). Changing status (use action verbs). Approved/paid/credited SIs (returns 400 SI_NOT_DRAFT).', + pitfalls: [ + 'Returns 400 SI_NOT_DRAFT when current status !== "registered".', + 'invoice_date / due_date changes do not re-post the registration JE; if the entry date needs to change, credit the SI and re-register.', + ], + example: { + request: { payment_reference: 'OCR-1234567890' }, + response: { + data: { id: '0e9c…', payment_reference: 'OCR-1234567890' }, + meta: { request_id: 'req_…', api_version: '2026-05-12' }, + }, + }, + scope: 'suppliers:write', + risk: 'low', + idempotent: true, + reversible: true, + dryRunSupported: true, + request: { body: V1PatchSupplierInvoiceSchema }, + response: { success: SupplierInvoiceDetail }, +}) + +export const PATCH = withApiV1<{ params: Promise<{ companyId: string; id: string }> }>( + 'supplier-invoices.update', + async (request, ctx, params) => { + const { id } = await params.params + const idParse = z.string().uuid().safeParse(id) + if (!idParse.success) { + return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, { + requestId: ctx.requestId, + details: { field: 'id', message: 'Supplier-invoice id must be a UUID.' }, + }) + } + const invoiceId = idParse.data + + let rawBody: unknown + try { + rawBody = await request.json() + } catch { + return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, { + requestId: ctx.requestId, + details: { field: 'body', message: 'Body is not valid JSON.' }, + }) + } + + const parsed = V1PatchSupplierInvoiceSchema.safeParse(rawBody) + if (!parsed.success) { + return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, { + requestId: ctx.requestId, + details: { + issues: parsed.error.issues.map((i) => ({ + field: i.path.join('.'), + message: i.message, + })), + }, + }) + } + const body = parsed.data + + const updateData: Record = {} + for (const key of [ + 'supplier_invoice_number', + 'invoice_date', + 'due_date', + 'delivery_date', + 'payment_reference', + 'notes', + ] as const) { + if (body[key] !== undefined) updateData[key] = body[key] + } + if (Object.keys(updateData).length === 0) { + return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, { + requestId: ctx.requestId, + details: { field: 'body', message: 'At least one field must be supplied for update.' }, + }) + } + + // Status guard before doing anything else. + const { data: existing, error: fetchErr } = await ctx.supabase + .from('supplier_invoices') + .select(SI_DETAIL_COLUMNS) + .eq('company_id', ctx.companyId!) + .eq('id', invoiceId) + .maybeSingle() + + if (fetchErr) { + return v1ErrorResponse(fetchErr, ctx.log, { requestId: ctx.requestId }) + } + if (!existing) { + return v1ErrorResponseFromCode('SI_NOT_FOUND', ctx.log, { requestId: ctx.requestId }) + } + if ((existing as { status: string }).status !== 'registered') { + return v1ErrorResponseFromCode('SI_NOT_DRAFT', ctx.log, { + requestId: ctx.requestId, + details: { current_status: (existing as { status: string }).status }, + }) + } + + if (ctx.dryRun) { + return dryRunPreview({ ...existing, ...updateData }, { requestId: ctx.requestId, log: ctx.log }) + } + + const { data, error } = await ctx.supabase + .from('supplier_invoices') + .update(updateData) + .eq('company_id', ctx.companyId!) + .eq('id', invoiceId) + // Race guard: another request may have approved / paid between the + // pre-flight status check and this update. + .eq('status', 'registered') + .select(SI_DETAIL_COLUMNS) + .maybeSingle() + + if (error) { + return v1ErrorResponse(error, ctx.log, { requestId: ctx.requestId }) + } + if (!data) { + return v1ErrorResponseFromCode('SI_NOT_DRAFT', ctx.log, { + requestId: ctx.requestId, + details: { reason: 'race' }, + }) + } + + return ok(data, { requestId: ctx.requestId }) + }, + { requireIdempotencyKey: true }, +) diff --git a/app/api/v1/companies/[companyId]/supplier-invoices/__tests__/route.test.ts b/app/api/v1/companies/[companyId]/supplier-invoices/__tests__/route.test.ts new file mode 100644 index 00000000..b2ceafbe --- /dev/null +++ b/app/api/v1/companies/[companyId]/supplier-invoices/__tests__/route.test.ts @@ -0,0 +1,1098 @@ +/** + * Integration tests for the v1 supplier-invoices vertical (Phase 4 PR-1). + * + * Coverage: list, get, create (incl. period-lock + strict-mode), patch, + * approve, mark-paid, credit. Same Proxy-mock pattern as the suppliers + * tests — we test outcomes, not query shape. + */ + +import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' + +beforeAll(() => { + if (process.env.NODE_ENV !== 'test') { + throw new Error( + `supplier-invoices route tests require NODE_ENV=test (got ${process.env.NODE_ENV ?? 'undefined'})`, + ) + } + process.env.NEXT_PUBLIC_SUPABASE_URL ||= 'http://localhost:54321' + process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ||= 'test-anon-key' +}) + +vi.mock('@/lib/auth/api-keys', async () => { + const actual = await vi.importActual('@/lib/auth/api-keys') + return { + ...actual, + validateApiKey: vi.fn(), + createServiceClientNoCookies: vi.fn(), + } +}) + +vi.mock('@supabase/supabase-js', async () => { + const actual = await vi.importActual('@supabase/supabase-js') + return { ...actual, createClient: vi.fn().mockReturnValue({}) } +}) + +// Mock the engine so JE creation succeeds without hitting Postgres. +const mockedReg = vi.fn() +const mockedPayment = vi.fn() +const mockedCash = vi.fn() +const mockedCredit = vi.fn() +vi.mock('@/lib/bookkeeping/supplier-invoice-entries', () => ({ + createSupplierInvoiceRegistrationEntry: (...args: unknown[]) => mockedReg(...args), + createSupplierInvoicePaymentEntry: (...args: unknown[]) => mockedPayment(...args), + createSupplierInvoiceCashEntry: (...args: unknown[]) => mockedCash(...args), + createSupplierCreditNoteEntry: (...args: unknown[]) => mockedCredit(...args), +})) + +// reverseEntry is dynamically imported in the route file for orphan storno — +// stub it so the import resolves quickly without exercising the real engine. +vi.mock('@/lib/bookkeeping/engine', async () => { + const actual = await vi.importActual( + '@/lib/bookkeeping/engine', + ) + return { + ...actual, + reverseEntry: vi.fn().mockResolvedValue({ id: 'storno-1' }), + } +}) + +import { validateApiKey, createServiceClientNoCookies } from '@/lib/auth/api-keys' +import { GET as listSIs, POST as createSI } from '../route' +import { GET as getSI, PATCH as updateSI } from '../[id]/route' +import { POST as approveSI } from '../[id]/approve/route' +import { POST as markPaidSI } from '../[id]/mark-paid/route' +import { POST as creditSI } from '../[id]/credit/route' + +const mockValidate = validateApiKey as ReturnType +const mockServiceClient = createServiceClientNoCookies as ReturnType + +interface TableResp { + data?: unknown + error?: unknown + count?: number | null +} + +function makeFlexibleSupabase(byTable: Record) { + // Per-table queue: TableResp[] consumes one entry per await, then sticks + // on the last entry. Plain TableResp is treated as a constant. + const queues = new Map() + for (const [t, val] of Object.entries(byTable)) { + queues.set(t, Array.isArray(val) ? [...val] : [val]) + } + const buildChain = (table: string): unknown => { + const handler: ProxyHandler = { + get(_target, prop) { + if (prop === 'then') { + return (resolve: (v: unknown) => void) => { + const q = queues.get(table) + const next = q && q.length > 1 ? q.shift()! : (q?.[0] ?? { data: null, error: null }) + resolve(next) + } + } + return (..._args: unknown[]) => buildChain(table) + }, + } + return new Proxy({}, handler) + } + return { + from: vi.fn((table: string) => buildChain(table)), + rpc: vi.fn((name: string) => { + if (name === 'get_next_arrival_number') { + return Promise.resolve({ data: 42, error: null }) + } + return Promise.resolve({ data: null, error: null }) + }), + } +} + +const COMPANY_ID = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' +const SUPPLIER_ID = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb' +const SI_ID = 'cccccccc-cccc-4ccc-8ccc-cccccccccccc' +const USER_ID = 'user-1' + +function makeRequest(url: string, init?: RequestInit): Request { + return new Request(url, { + ...init, + headers: { + Authorization: 'Bearer test-fixture-not-a-real-key', + 'Idempotency-Key': 'b1aaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', + ...(init?.headers ?? {}), + }, + }) +} + +function companyParams(companyId: string) { + return { params: Promise.resolve({ companyId }) } +} + +function detailParams(companyId: string, id: string) { + return { params: Promise.resolve({ companyId, id }) } +} + +const SAMPLE_SUPPLIER = { + id: SUPPLIER_ID, + name: 'Office Depot AB', + supplier_type: 'swedish_business', + archived_at: null, +} + +const SAMPLE_SI = { + id: SI_ID, + supplier_id: SUPPLIER_ID, + arrival_number: 42, + supplier_invoice_number: '2026-1234', + invoice_date: '2026-05-10', + due_date: '2026-06-09', + received_date: '2026-05-10', + delivery_date: null, + status: 'registered', + currency: 'SEK', + exchange_rate: null, + exchange_rate_date: null, + subtotal: 1000, + subtotal_sek: null, + vat_amount: 250, + vat_amount_sek: null, + total: 1250, + total_sek: null, + vat_treatment: 'standard_25', + reverse_charge: false, + payment_reference: null, + paid_at: null, + paid_amount: 0, + remaining_amount: 1250, + is_credit_note: false, + credited_invoice_id: null, + registration_journal_entry_id: null, + payment_journal_entry_id: null, + transaction_id: null, + document_id: null, + notes: null, + reversed_at: null, + created_at: '2026-05-13T15:00:00Z', + updated_at: '2026-05-13T15:00:00Z', +} + +beforeEach(() => { + vi.clearAllMocks() + mockedReg.mockResolvedValue({ id: 'je-reg-1' }) + mockedPayment.mockResolvedValue({ id: 'je-pay-1' }) + mockedCash.mockResolvedValue({ id: 'je-cash-1' }) + mockedCredit.mockResolvedValue({ id: 'je-credit-1' }) + mockValidate.mockResolvedValue({ + userId: USER_ID, + companyId: COMPANY_ID, + apiKeyId: 'ak_1', + apiKeyName: 'CI key', + scopes: ['suppliers:read', 'suppliers:write'], + mode: 'live', + }) +}) + +describe('GET /api/v1/companies/:companyId/supplier-invoices', () => { + it('returns paginated SIs with supplier_name inlined', async () => { + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + supplier_invoices: { + data: [{ ...SAMPLE_SI, supplier: { id: SUPPLIER_ID, name: 'Office Depot AB' } }], + error: null, + }, + }), + ) + const res = await listSIs( + makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/supplier-invoices`), + companyParams(COMPANY_ID), + ) + expect(res.status).toBe(200) + const body = await res.json() + expect(body.data).toHaveLength(1) + expect(body.data[0].supplier_name).toBe('Office Depot AB') + expect(body.data[0].total).toBe(1250) + }) + + it('rejects malformed date_from with 400', async () => { + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + }), + ) + const res = await listSIs( + makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/supplier-invoices?date_from=2026/05/10`), + companyParams(COMPANY_ID), + ) + expect(res.status).toBe(400) + }) +}) + +describe('GET /api/v1/companies/:companyId/supplier-invoices/:id', () => { + it('returns the SI when found', async () => { + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + supplier_invoices: { data: SAMPLE_SI, error: null }, + }), + ) + const res = await getSI( + makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/supplier-invoices/${SI_ID}`), + detailParams(COMPANY_ID, SI_ID), + ) + expect(res.status).toBe(200) + const body = await res.json() + expect(body.data.id).toBe(SI_ID) + }) + + it('returns 404 SI_NOT_FOUND when missing', async () => { + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + supplier_invoices: { data: null, error: null }, + }), + ) + const res = await getSI( + makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/supplier-invoices/${SI_ID}`), + detailParams(COMPANY_ID, SI_ID), + ) + expect(res.status).toBe(404) + const body = await res.json() + expect(body.error.code).toBe('SI_NOT_FOUND') + }) +}) + +describe('POST /api/v1/companies/:companyId/supplier-invoices', () => { + const validBody = { + supplier_id: SUPPLIER_ID, + supplier_invoice_number: '2026-1234', + invoice_date: '2026-05-10', + due_date: '2026-06-09', + items: [ + { description: 'Office supplies', amount: 1000, account_number: '5410', vat_rate: 0.25 }, + ], + } + + it('registers the SI + posts the registration JE under accrual', async () => { + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + suppliers: { data: SAMPLE_SUPPLIER, error: null }, + company_settings: { data: { accounting_method: 'accrual' }, error: null }, + fiscal_periods: { data: { id: 'fp-1', is_closed: false, locked_at: null }, error: null }, + supplier_invoices: { data: SAMPLE_SI, error: null }, + supplier_invoice_items: { data: null, error: null }, + idempotency_keys: { data: null, error: null }, + }), + ) + + const res = await createSI( + makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/supplier-invoices`, { + method: 'POST', + body: JSON.stringify(validBody), + }), + companyParams(COMPANY_ID), + ) + + expect(res.status).toBe(201) + expect(mockedReg).toHaveBeenCalledTimes(1) + const body = await res.json() + expect(body.data.id).toBe(SI_ID) + }) + + it('returns 404 SUPPLIER_NOT_FOUND when supplier does not exist', async () => { + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + suppliers: { data: null, error: null }, + idempotency_keys: { data: null, error: null }, + }), + ) + const res = await createSI( + makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/supplier-invoices`, { + method: 'POST', + body: JSON.stringify(validBody), + }), + companyParams(COMPANY_ID), + ) + expect(res.status).toBe(404) + const body = await res.json() + expect(body.error.code).toBe('SUPPLIER_NOT_FOUND') + }) + + it('returns 400 PERIOD_LOCKED when invoice_date falls in a locked period', async () => { + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + suppliers: { data: SAMPLE_SUPPLIER, error: null }, + company_settings: { data: { bookkeeping_locked_through: '2026-12-31' }, error: null }, + fiscal_periods: { data: { id: 'fp-1', is_closed: false, locked_at: null }, error: null }, + idempotency_keys: { data: null, error: null }, + }), + ) + const res = await createSI( + makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/supplier-invoices`, { + method: 'POST', + body: JSON.stringify(validBody), + }), + companyParams(COMPANY_ID), + ) + expect(res.status).toBe(400) + const body = await res.json() + expect(body.error.code).toBe('PERIOD_LOCKED') + }) + + it('strict-mode: rolls back SI row when registration JE creation throws', async () => { + mockedReg.mockRejectedValueOnce(new Error('engine boom')) + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + suppliers: { data: SAMPLE_SUPPLIER, error: null }, + company_settings: { data: { accounting_method: 'accrual' }, error: null }, + fiscal_periods: { data: { id: 'fp-1', is_closed: false, locked_at: null }, error: null }, + supplier_invoices: { data: SAMPLE_SI, error: null }, + supplier_invoice_items: { data: null, error: null }, + idempotency_keys: { data: null, error: null }, + }), + ) + const res = await createSI( + makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/supplier-invoices`, { + method: 'POST', + body: JSON.stringify(validBody), + }), + companyParams(COMPANY_ID), + ) + expect(res.status).toBe(500) + const body = await res.json() + expect(body.error.code).toBe('SI_CREATE_FAILED') + expect(body.error.details.step).toBe('registration_journal_entry') + }) + + it('returns a dry-run preview when ?dry_run=true', async () => { + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + suppliers: { data: SAMPLE_SUPPLIER, error: null }, + company_settings: { data: { bookkeeping_locked_through: null }, error: null }, + fiscal_periods: { data: { id: 'fp-1', is_closed: false, locked_at: null }, error: null }, + idempotency_keys: { data: null, error: null }, + }), + ) + const res = await createSI( + makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/supplier-invoices?dry_run=true`, { + method: 'POST', + body: JSON.stringify(validBody), + }), + companyParams(COMPANY_ID), + ) + expect(res.status).toBe(200) + expect(res.headers.get('X-Dry-Run')).toBe('true') + expect(mockedReg).not.toHaveBeenCalled() + }) + + it('rejects a non-Swedish VAT rate with 400 VALIDATION_ERROR', async () => { + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + suppliers: { data: SAMPLE_SUPPLIER, error: null }, + company_settings: { data: { bookkeeping_locked_through: null }, error: null }, + fiscal_periods: { data: { id: 'fp-1', is_closed: false, locked_at: null }, error: null }, + idempotency_keys: { data: null, error: null }, + }), + ) + const res = await createSI( + makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/supplier-invoices`, { + method: 'POST', + body: JSON.stringify({ + ...validBody, + items: [{ description: 'X', amount: 1000, account_number: '5410', vat_rate: 0.15 }], + }), + }), + companyParams(COMPANY_ID), + ) + expect(res.status).toBe(400) + const body = await res.json() + expect(body.error.code).toBe('VALIDATION_ERROR') + expect(body.error.details.attempted_rate).toBe(0.15) + expect(body.error.details.allowed_rates).toEqual([0, 0.06, 0.12, 0.25]) + }) + + it('defaults vat_treatment to reverse_charge for eu_business suppliers', async () => { + let insertedRow: Record | null = null + mockServiceClient.mockReturnValue({ + from: (table: string) => { + if (table === 'suppliers') { + return new Proxy({}, { + get(_t, prop) { + if (prop === 'then') { + return (r: (v: unknown) => void) => + r({ data: { ...SAMPLE_SUPPLIER, supplier_type: 'eu_business' }, error: null }) + } + return () => new Proxy({}, this!) + }, + }) + } + if (table === 'supplier_invoices') { + return new Proxy({}, { + get(_t, prop) { + if (prop === 'insert') { + return (row: Record) => { + insertedRow = row + return new Proxy({}, { + get(_t2, prop2) { + if (prop2 === 'then') { + return (r: (v: unknown) => void) => r({ data: SAMPLE_SI, error: null }) + } + return () => new Proxy({}, this!) + }, + }) + } + } + if (prop === 'then') { + return (r: (v: unknown) => void) => r({ data: SAMPLE_SI, error: null }) + } + return () => new Proxy({}, this!) + }, + }) + } + return new Proxy({}, { + get(_t, prop) { + if (prop === 'then') { + const data = table === 'company_members' + ? { company_id: COMPANY_ID, role: 'owner' } + : table === 'fiscal_periods' + ? { id: 'fp-1', is_closed: false, locked_at: null } + : table === 'company_settings' + ? { bookkeeping_locked_through: null, accounting_method: 'accrual' } + : null + return (r: (v: unknown) => void) => r({ data, error: null }) + } + return () => new Proxy({}, this!) + }, + }) + }, + rpc: vi.fn(() => Promise.resolve({ data: 42, error: null })), + }) + + const res = await createSI( + makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/supplier-invoices`, { + method: 'POST', + // No vat_treatment, no reverse_charge — supplier_type should drive + // both. vat_rate: 0 because reverse-charge invoices must carry no + // line-item VAT (buyer self-assesses). + body: JSON.stringify({ + ...validBody, + items: [ + { description: 'Office supplies', amount: 1000, account_number: '5410', vat_rate: 0 }, + ], + }), + }), + companyParams(COMPANY_ID), + ) + + expect(res.status).toBe(201) + expect(insertedRow).not.toBeNull() + expect(insertedRow!.vat_treatment).toBe('reverse_charge') + expect(insertedRow!.reverse_charge).toBe(true) + }) + + it('rejects reverse_charge=true with non-zero item vat_rate (cross-field)', async () => { + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + suppliers: { data: SAMPLE_SUPPLIER, error: null }, + company_settings: { data: { bookkeeping_locked_through: null }, error: null }, + fiscal_periods: { data: { id: 'fp-1', is_closed: false, locked_at: null }, error: null }, + idempotency_keys: { data: null, error: null }, + }), + ) + const res = await createSI( + makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/supplier-invoices`, { + method: 'POST', + body: JSON.stringify({ + ...validBody, + reverse_charge: true, + // item vat_rate still 0.25 — must be 0 under reverse charge + }), + }), + companyParams(COMPANY_ID), + ) + expect(res.status).toBe(400) + const body = await res.json() + expect(body.error.code).toBe('VALIDATION_ERROR') + expect(body.error.details.reverse_charge).toBe(true) + expect(body.error.details.attempted_rate).toBe(0.25) + }) + + it('normalises vat_treatment to "reverse_charge" when reverse_charge resolves true', async () => { + // Caller explicitly passes vat_treatment='standard_25' for an + // eu_business supplier and omits reverse_charge. Supplier-type drives + // reverse_charge=true; vat_treatment must follow. + let insertedRow: Record | null = null + mockServiceClient.mockReturnValue({ + from: (table: string) => { + if (table === 'suppliers') { + return new Proxy({}, { + get(_t, prop) { + if (prop === 'then') { + return (r: (v: unknown) => void) => + r({ data: { ...SAMPLE_SUPPLIER, supplier_type: 'eu_business' }, error: null }) + } + return () => new Proxy({}, this!) + }, + }) + } + if (table === 'supplier_invoices') { + return new Proxy({}, { + get(_t, prop) { + if (prop === 'insert') { + return (row: Record) => { + insertedRow = row + return new Proxy({}, { + get(_t2, prop2) { + if (prop2 === 'then') { + return (r: (v: unknown) => void) => r({ data: SAMPLE_SI, error: null }) + } + return () => new Proxy({}, this!) + }, + }) + } + } + if (prop === 'then') { + return (r: (v: unknown) => void) => r({ data: SAMPLE_SI, error: null }) + } + return () => new Proxy({}, this!) + }, + }) + } + return new Proxy({}, { + get(_t, prop) { + if (prop === 'then') { + const data = table === 'company_members' + ? { company_id: COMPANY_ID, role: 'owner' } + : table === 'fiscal_periods' + ? { id: 'fp-1', is_closed: false, locked_at: null } + : table === 'company_settings' + ? { bookkeeping_locked_through: null, accounting_method: 'accrual' } + : null + return (r: (v: unknown) => void) => r({ data, error: null }) + } + return () => new Proxy({}, this!) + }, + }) + }, + rpc: vi.fn(() => Promise.resolve({ data: 42, error: null })), + }) + + const res = await createSI( + makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/supplier-invoices`, { + method: 'POST', + body: JSON.stringify({ + ...validBody, + vat_treatment: 'standard_25', // explicit but should be overridden + items: [ + { description: 'Office supplies', amount: 1000, account_number: '5410', vat_rate: 0 }, + ], + }), + }), + companyParams(COMPANY_ID), + ) + + expect(res.status).toBe(201) + expect(insertedRow).not.toBeNull() + // Even with explicit vat_treatment='standard_25', the resolved + // reverse_charge=true forces normalisation to 'reverse_charge'. + expect(insertedRow!.vat_treatment).toBe('reverse_charge') + expect(insertedRow!.reverse_charge).toBe(true) + }) +}) + +describe('PATCH /api/v1/companies/:companyId/supplier-invoices/:id', () => { + it('updates a registered SI', async () => { + const updated = { ...SAMPLE_SI, payment_reference: 'OCR-9999' } + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + supplier_invoices: { data: updated, error: null }, + idempotency_keys: { data: null, error: null }, + }), + ) + const res = await updateSI( + makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/supplier-invoices/${SI_ID}`, { + method: 'PATCH', + body: JSON.stringify({ payment_reference: 'OCR-9999' }), + }), + detailParams(COMPANY_ID, SI_ID), + ) + expect(res.status).toBe(200) + const body = await res.json() + expect(body.data.payment_reference).toBe('OCR-9999') + }) + + it('refuses to update an approved SI (400 SI_NOT_DRAFT)', async () => { + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + supplier_invoices: { data: { ...SAMPLE_SI, status: 'approved' }, error: null }, + idempotency_keys: { data: null, error: null }, + }), + ) + const res = await updateSI( + makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/supplier-invoices/${SI_ID}`, { + method: 'PATCH', + body: JSON.stringify({ payment_reference: 'OCR-9999' }), + }), + detailParams(COMPANY_ID, SI_ID), + ) + expect(res.status).toBe(400) + const body = await res.json() + expect(body.error.code).toBe('SI_NOT_DRAFT') + }) + + it('rejects unknown body keys (V4.5 strict schema)', async () => { + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + supplier_invoices: { data: SAMPLE_SI, error: null }, + idempotency_keys: { data: null, error: null }, + }), + ) + const res = await updateSI( + makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/supplier-invoices/${SI_ID}`, { + method: 'PATCH', + // `status` is not in UpdateSupplierInvoiceSchema — must be rejected. + body: JSON.stringify({ status: 'approved' }), + }), + detailParams(COMPANY_ID, SI_ID), + ) + expect(res.status).toBe(400) + const body = await res.json() + expect(body.error.code).toBe('VALIDATION_ERROR') + }) +}) + +describe('POST /api/v1/companies/:companyId/supplier-invoices/:id/approve', () => { + it('flips registered → approved', async () => { + const registered = { ...SAMPLE_SI, status: 'registered' } + const approved = { ...SAMPLE_SI, status: 'approved' } + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + // Queue: 1st = pre-flight (registered), 2nd = post-update (approved). + supplier_invoices: [ + { data: registered, error: null }, + { data: approved, error: null }, + ], + idempotency_keys: { data: null, error: null }, + }), + ) + const res = await approveSI( + makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/supplier-invoices/${SI_ID}/approve`, { + method: 'POST', + }), + detailParams(COMPANY_ID, SI_ID), + ) + expect(res.status).toBe(200) + const body = await res.json() + expect(body.data.status).toBe('approved') + }) + + it('refuses on already-approved SI (400 SI_APPROVE_NOT_REGISTERED)', async () => { + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + supplier_invoices: { data: { ...SAMPLE_SI, status: 'approved' }, error: null }, + idempotency_keys: { data: null, error: null }, + }), + ) + const res = await approveSI( + makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/supplier-invoices/${SI_ID}/approve`, { + method: 'POST', + }), + detailParams(COMPANY_ID, SI_ID), + ) + expect(res.status).toBe(400) + const body = await res.json() + expect(body.error.code).toBe('SI_APPROVE_NOT_REGISTERED') + }) +}) + +describe('POST /api/v1/companies/:companyId/supplier-invoices/:id/mark-paid', () => { + const approvedSI = { + ...SAMPLE_SI, + status: 'approved', + supplier: { id: SUPPLIER_ID, name: 'Office Depot AB', supplier_type: 'swedish_business' }, + items: [], + } + + it('books the payment JE and flips status to paid', async () => { + const updated = { + id: SI_ID, + status: 'paid', + total: 1250, + paid_amount: 1250, + remaining_amount: 0, + paid_at: '2026-05-13T16:00:00Z', + payment_journal_entry_id: 'je-pay-1', + } + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + supplier_invoices: { data: approvedSI, error: null }, + company_settings: { data: { accounting_method: 'accrual' }, error: null }, + fiscal_periods: { data: { id: 'fp-1', is_closed: false, locked_at: null }, error: null }, + supplier_invoice_payments: { data: null, error: null }, + idempotency_keys: { data: null, error: null }, + }), + ) + // The .update() returns a different table-keyed response. To return the + // updated row we re-mock with a queue once the route updates supplier_invoices. + // Simpler: rebind makeFlexibleSupabase to also return `updated` on the + // second call. We rely on the fact that the route's first read uses one + // proxy chain and the update uses another. Returning the same response + // for every supplier_invoices read works for the happy-path test. + mockServiceClient.mockReturnValueOnce( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + supplier_invoices: { data: approvedSI, error: null }, + company_settings: { data: { accounting_method: 'accrual' }, error: null }, + fiscal_periods: { data: { id: 'fp-1', is_closed: false, locked_at: null }, error: null }, + supplier_invoice_payments: { data: null, error: null }, + idempotency_keys: { data: null, error: null }, + }), + ) + + // For the update path, simulate the .update().select().maybeSingle() + // returning the new row. The flexible mock returns the same response per + // table, so set the supplier_invoices response to the updated row — both + // the pre-flight read AND the update read will return it. We only check + // the response shape from the latter, which the route maps directly. + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + supplier_invoices: { data: { ...approvedSI, ...updated }, error: null }, + company_settings: { data: { accounting_method: 'accrual' }, error: null }, + fiscal_periods: { data: { id: 'fp-1', is_closed: false, locked_at: null }, error: null }, + supplier_invoice_payments: { data: null, error: null }, + idempotency_keys: { data: null, error: null }, + }), + ) + + const res = await markPaidSI( + makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/supplier-invoices/${SI_ID}/mark-paid`, { + method: 'POST', + }), + detailParams(COMPANY_ID, SI_ID), + ) + + expect(res.status).toBe(200) + expect(mockedPayment).toHaveBeenCalledTimes(1) + }) + + it('returns 400 SI_PAID_PERIOD_LOCKED when payment_date is in a locked period', async () => { + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + supplier_invoices: { data: approvedSI, error: null }, + company_settings: { data: { bookkeeping_locked_through: '2030-01-01' }, error: null }, + fiscal_periods: { data: { id: 'fp-1', is_closed: false, locked_at: null }, error: null }, + idempotency_keys: { data: null, error: null }, + }), + ) + const res = await markPaidSI( + makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/supplier-invoices/${SI_ID}/mark-paid`, { + method: 'POST', + }), + detailParams(COMPANY_ID, SI_ID), + ) + expect(res.status).toBe(400) + const body = await res.json() + expect(body.error.code).toBe('SI_PAID_PERIOD_LOCKED') + expect(mockedPayment).not.toHaveBeenCalled() + }) + + it('returns 409 SI_PAID_ALREADY when SI is already paid', async () => { + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + supplier_invoices: { data: { ...approvedSI, status: 'paid' }, error: null }, + idempotency_keys: { data: null, error: null }, + }), + ) + const res = await markPaidSI( + makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/supplier-invoices/${SI_ID}/mark-paid`, { + method: 'POST', + }), + detailParams(COMPANY_ID, SI_ID), + ) + expect(res.status).toBe(409) + const body = await res.json() + expect(body.error.code).toBe('SI_PAID_ALREADY') + }) + + it('strict-mode: aborts before SI mutation when JE engine throws', async () => { + mockedPayment.mockRejectedValueOnce(new Error('engine fail')) + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + supplier_invoices: { data: approvedSI, error: null }, + company_settings: { data: { accounting_method: 'accrual' }, error: null }, + fiscal_periods: { data: { id: 'fp-1', is_closed: false, locked_at: null }, error: null }, + idempotency_keys: { data: null, error: null }, + }), + ) + const res = await markPaidSI( + makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/supplier-invoices/${SI_ID}/mark-paid`, { + method: 'POST', + }), + detailParams(COMPANY_ID, SI_ID), + ) + expect(res.status).toBe(500) + const body = await res.json() + expect(body.error.code).toBe('SI_PAID_FAILED') + }) + + it('requires exchange_rate_difference for non-SEK accrual', async () => { + const eurSI = { ...approvedSI, currency: 'EUR' } + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + supplier_invoices: { data: eurSI, error: null }, + company_settings: { data: { accounting_method: 'accrual', bookkeeping_locked_through: null }, error: null }, + fiscal_periods: { data: { id: 'fp-1', is_closed: false, locked_at: null }, error: null }, + idempotency_keys: { data: null, error: null }, + }), + ) + // POST with NO body → no exchange_rate_difference supplied. + const res = await markPaidSI( + makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/supplier-invoices/${SI_ID}/mark-paid`, { + method: 'POST', + }), + detailParams(COMPANY_ID, SI_ID), + ) + expect(res.status).toBe(400) + const body = await res.json() + expect(body.error.code).toBe('VALIDATION_ERROR') + expect(body.error.details.issues[0].field).toBe('exchange_rate_difference') + expect(body.error.details.invoice_currency).toBe('EUR') + // JE engine must NOT have been called. + expect(mockedPayment).not.toHaveBeenCalled() + }) + + it('passes when exchange_rate_difference is supplied (even as 0) for non-SEK accrual', async () => { + const eurSI = { ...approvedSI, currency: 'EUR' } + const paidEurSI = { ...eurSI, status: 'paid', paid_amount: 1250, remaining_amount: 0 } + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + // 1st read: pre-flight (approved). 2nd read: post-update select (paid). + supplier_invoices: [ + { data: eurSI, error: null }, + { data: paidEurSI, error: null }, + ], + company_settings: { data: { accounting_method: 'accrual', bookkeeping_locked_through: null }, error: null }, + fiscal_periods: { data: { id: 'fp-1', is_closed: false, locked_at: null }, error: null }, + supplier_invoice_payments: { data: null, error: null }, + idempotency_keys: { data: null, error: null }, + }), + ) + const res = await markPaidSI( + makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/supplier-invoices/${SI_ID}/mark-paid`, { + method: 'POST', + body: JSON.stringify({ exchange_rate_difference: 0 }), + }), + detailParams(COMPANY_ID, SI_ID), + ) + expect(res.status).toBe(200) + expect(mockedPayment).toHaveBeenCalledTimes(1) + }) + + it('rejects a future payment_date with 400 VALIDATION_ERROR', async () => { + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + // The pre-flight fetch should not even fire — the schema check runs first. + supplier_invoices: { data: approvedSI, error: null }, + idempotency_keys: { data: null, error: null }, + }), + ) + const future = new Date(Date.now() + 7 * 24 * 3600 * 1000).toISOString().split('T')[0] + const res = await markPaidSI( + makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/supplier-invoices/${SI_ID}/mark-paid`, { + method: 'POST', + body: JSON.stringify({ payment_date: future }), + }), + detailParams(COMPANY_ID, SI_ID), + ) + expect(res.status).toBe(400) + const body = await res.json() + expect(body.error.code).toBe('VALIDATION_ERROR') + expect(body.error.details.field).toBe('payment_date') + expect(body.error.details.attempted).toBe(future) + expect(mockedPayment).not.toHaveBeenCalled() + }) + + it('rejects payment amount exceeding remaining_amount', async () => { + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + // approvedSI.remaining_amount === 1250 + supplier_invoices: { data: approvedSI, error: null }, + company_settings: { data: { accounting_method: 'accrual', bookkeeping_locked_through: null }, error: null }, + fiscal_periods: { data: { id: 'fp-1', is_closed: false, locked_at: null }, error: null }, + idempotency_keys: { data: null, error: null }, + }), + ) + const res = await markPaidSI( + makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/supplier-invoices/${SI_ID}/mark-paid`, { + method: 'POST', + // 1500 > 1250 remaining — must be rejected, not silently clamped. + body: JSON.stringify({ amount: 1500 }), + }), + detailParams(COMPANY_ID, SI_ID), + ) + expect(res.status).toBe(400) + const body = await res.json() + expect(body.error.code).toBe('VALIDATION_ERROR') + expect(body.error.details.field).toBe('amount') + expect(body.error.details.attempted).toBe(1500) + expect(body.error.details.remaining_amount).toBe(1250) + expect(mockedPayment).not.toHaveBeenCalled() + }) +}) + +describe('POST /api/v1/companies/:companyId/supplier-invoices/:id/credit', () => { + const registeredSI = { + ...SAMPLE_SI, + supplier: { id: SUPPLIER_ID, name: 'Office Depot AB', supplier_type: 'swedish_business' }, + items: [ + { + sort_order: 0, + description: 'Office supplies', + quantity: 1, + unit: 'st', + unit_price: 1000, + line_total: 1000, + account_number: '5410', + vat_code: null, + vat_rate: 0.25, + vat_amount: 250, + }, + ], + } + + it('issues a credit note + posts the reversing JE', async () => { + const creditNoteRow = { + ...SAMPLE_SI, + id: 'dddddddd-dddd-4ddd-8ddd-dddddddddddd', + arrival_number: 43, + supplier_invoice_number: 'KREDIT-2026-1234', + is_credit_note: true, + credited_invoice_id: SI_ID, + } + let siReadCount = 0 + mockServiceClient.mockReturnValue({ + from: (table: string) => { + return new Proxy({}, { + get(_t, prop) { + if (prop === 'then') { + return (resolve: (v: unknown) => void) => { + if (table === 'company_members') { + resolve({ data: { company_id: COMPANY_ID, role: 'owner' }, error: null }) + } else if (table === 'supplier_invoices') { + const n = siReadCount++ + // 1st: pre-flight fetch of original (with supplier + items) + // 2nd: insert credit-note row + // 3rd: update credit-note with reg JE id (no return needed) + // 4th: flip original's status to credited + if (n === 0) resolve({ data: registeredSI, error: null }) + else if (n === 1) resolve({ data: creditNoteRow, error: null }) + else resolve({ data: { id: SI_ID, status: 'credited' }, error: null }) + } else if (table === 'company_settings') { + resolve({ data: { accounting_method: 'accrual' }, error: null }) + } else if (table === 'fiscal_periods') { + resolve({ data: { id: 'fp-1', is_closed: false, locked_at: null }, error: null }) + } else { + resolve({ data: null, error: null }) + } + } + } + return () => new Proxy({}, this!) + }, + }) + }, + rpc: vi.fn(() => Promise.resolve({ data: 43, error: null })), + }) + + const res = await creditSI( + makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/supplier-invoices/${SI_ID}/credit`, { + method: 'POST', + }), + detailParams(COMPANY_ID, SI_ID), + ) + + expect(res.status).toBe(200) + expect(mockedCredit).toHaveBeenCalledTimes(1) + const body = await res.json() + expect(body.data.credit_note_id).toBe(creditNoteRow.id) + expect(body.data.original_id).toBe(SI_ID) + }) + + it('returns 409 SI_CREDIT_ALREADY_CREDITED when status=credited', async () => { + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + supplier_invoices: { data: { ...registeredSI, status: 'credited' }, error: null }, + idempotency_keys: { data: null, error: null }, + }), + ) + const res = await creditSI( + makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/supplier-invoices/${SI_ID}/credit`, { + method: 'POST', + }), + detailParams(COMPANY_ID, SI_ID), + ) + expect(res.status).toBe(409) + const body = await res.json() + expect(body.error.code).toBe('SI_CREDIT_ALREADY_CREDITED') + }) + + it('returns 400 SI_CREDIT_PERIOD_LOCKED when today falls in a locked period', async () => { + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + supplier_invoices: { data: registeredSI, error: null }, + company_settings: { data: { bookkeeping_locked_through: '2030-01-01' }, error: null }, + fiscal_periods: { data: { id: 'fp-1', is_closed: false, locked_at: null }, error: null }, + idempotency_keys: { data: null, error: null }, + }), + ) + const res = await creditSI( + makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/supplier-invoices/${SI_ID}/credit`, { + method: 'POST', + }), + detailParams(COMPANY_ID, SI_ID), + ) + expect(res.status).toBe(400) + const body = await res.json() + expect(body.error.code).toBe('SI_CREDIT_PERIOD_LOCKED') + }) + + it('dry-run returns preview without arrival_number allocation or JE creation', async () => { + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + supplier_invoices: { data: registeredSI, error: null }, + company_settings: { data: { bookkeeping_locked_through: null }, error: null }, + fiscal_periods: { data: { id: 'fp-1', is_closed: false, locked_at: null }, error: null }, + idempotency_keys: { data: null, error: null }, + }), + ) + const res = await creditSI( + makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/supplier-invoices/${SI_ID}/credit?dry_run=true`, { + method: 'POST', + }), + detailParams(COMPANY_ID, SI_ID), + ) + expect(res.status).toBe(200) + expect(res.headers.get('X-Dry-Run')).toBe('true') + expect(mockedCredit).not.toHaveBeenCalled() + }) +}) diff --git a/app/api/v1/companies/[companyId]/supplier-invoices/route.ts b/app/api/v1/companies/[companyId]/supplier-invoices/route.ts new file mode 100644 index 00000000..97b5c0e9 --- /dev/null +++ b/app/api/v1/companies/[companyId]/supplier-invoices/route.ts @@ -0,0 +1,821 @@ +/** + * /api/v1/companies/{companyId}/supplier-invoices — list + register endpoints. + * + * GET — list with filters (status, supplier_id, currency, invoice_date range). + * Cursor pagination on (invoice_date DESC, id DESC). + * POST — register a new supplier invoice. Idempotent (mandatory Idempotency-Key). + * Dry-runnable. + * + * Lifecycle: a fresh SI is created in `registered` status. Under + * faktureringsmetoden the registration JE (Debit expense + Debit 2641 / Credit + * 2440) is posted in the same call — failure aborts and the SI row is rolled + * back to avoid orphaning a half-baked AP balance. + * + * Under kontantmetoden no JE is posted at registration; recognition is + * deferred to :mark-paid. + * + * `arrival_number` (ankomstnummer) is an internal counter; it does NOT carry + * the BFL/ML 17 kap löpnummer obligation that customer invoices do. The + * supplier-invoice number (`supplier_invoice_number`) is the seller's own + * series and is preserved verbatim. + */ + +import { z } from 'zod' +import type { SupabaseClient } from '@supabase/supabase-js' +import { created, paginated } from '@/lib/api/v1/response' +import { dryRunPreview } from '@/lib/api/v1/dry-run' +import { + decodeDefaultCursor, + encodeDefaultCursor, + parsePaginationParams, +} from '@/lib/api/v1/pagination' +import { registerEndpoint } from '@/lib/api/v1/registry' +import { withApiV1 } from '@/lib/api/v1/with-api-v1' +import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors' +import { checkPeriodLock } from '@/lib/api/v1/check-period-lock' +import { CreateSupplierInvoiceSchema } from '@/lib/api/schemas' +import { createSupplierInvoiceRegistrationEntry } from '@/lib/bookkeeping/supplier-invoice-entries' +import { reverseEntry } from '@/lib/bookkeeping/engine' +import { isBookkeepingError } from '@/lib/bookkeeping/errors' +import { eventBus } from '@/lib/events' +import type { SupplierInvoice, SupplierInvoiceItem } from '@/types' + +const SupplierInvoiceStatus = z.enum([ + 'registered', + 'approved', + 'paid', + 'partially_paid', + 'overdue', + 'disputed', + 'credited', + 'reversed', +]) + +const SupplierInvoiceSummary = z.object({ + id: z.string().uuid(), + supplier_id: z.string().uuid(), + supplier_name: z.string(), + arrival_number: z.number().int(), + supplier_invoice_number: z.string(), + invoice_date: z.string(), + due_date: z.string(), + status: SupplierInvoiceStatus, + currency: z.string(), + subtotal: z.number(), + vat_amount: z.number(), + total: z.number(), + paid_amount: z.number(), + remaining_amount: z.number(), + is_credit_note: z.boolean(), + paid_at: z.string().nullable(), + created_at: z.string(), +}) + +const SupplierInvoicesListResponse = z.object({ + supplier_invoices: z.array(SupplierInvoiceSummary), +}) + +// Explicit projection. +const SI_SUMMARY_COLUMNS = + 'id, supplier_id, arrival_number, supplier_invoice_number, invoice_date, due_date, status, currency, subtotal, vat_amount, total, paid_amount, remaining_amount, is_credit_note, paid_at, created_at' + +const SUPPLIER_NAME_ONLY_COLUMNS = 'id, name' + +registerEndpoint({ + operation: 'supplier-invoices.list', + method: 'GET', + path: '/api/v1/companies/:companyId/supplier-invoices', + summary: 'List supplier invoices for a company.', + description: + 'Returns supplier invoices in most-recent-first order. Filters: status, supplier_id, currency, date_from / date_to (filter by invoice_date).', + useWhen: + 'You need to enumerate registered supplier invoices for an AP dashboard, a payment run, or a leverantörsreskontra reconciliation.', + doNotUseFor: + 'Fetching a single supplier invoice — use GET /supplier-invoices/{id}. Listing customer invoices (different resource).', + pitfalls: [ + 'Credit notes (is_credit_note=true) appear in the same list as the originals; filter by status=credited or check the flag to separate.', + 'remaining_amount is the unpaid portion; a partially_paid SI has remaining_amount > 0.', + 'arrival_number is internal book-keeping, not the seller\'s invoice number — use supplier_invoice_number for matching to received documents.', + ], + example: { + response: { + data: [ + { + id: '0e9c…', + supplier_id: 'a8f1…', + supplier_name: 'Office Depot AB', + arrival_number: 42, + supplier_invoice_number: '2026-1234', + invoice_date: '2026-05-10', + due_date: '2026-06-09', + status: 'registered', + currency: 'SEK', + subtotal: 1000, + vat_amount: 250, + total: 1250, + paid_amount: 0, + remaining_amount: 1250, + is_credit_note: false, + paid_at: null, + created_at: '2026-05-13T15:00:00Z', + }, + ], + meta: { request_id: 'req_…', api_version: '2026-05-12', next_cursor: null }, + }, + }, + scope: 'suppliers:read', + risk: 'low', + idempotent: true, + reversible: false, + dryRunSupported: false, + response: { success: SupplierInvoicesListResponse }, +}) + +export const GET = withApiV1<{ params: Promise<{ companyId: string }> }>( + 'supplier-invoices.list', + async (request, ctx) => { + const url = new URL(request.url) + const { limit, cursor } = parsePaginationParams(url) + const decoded = decodeDefaultCursor(cursor) + + const FiltersSchema = z.object({ + status: SupplierInvoiceStatus.optional(), + supplier_id: z.string().uuid().optional(), + currency: z.string().regex(/^[A-Z]{3}$/, 'currency must be a 3-letter ISO-4217 code').optional(), + date_from: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'date_from must be ISO YYYY-MM-DD').optional(), + date_to: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'date_to must be ISO YYYY-MM-DD').optional(), + }) + const filtersResult = FiltersSchema.safeParse({ + status: url.searchParams.get('status') ?? undefined, + supplier_id: url.searchParams.get('supplier_id') ?? undefined, + currency: url.searchParams.get('currency') ?? undefined, + date_from: url.searchParams.get('date_from') ?? undefined, + date_to: url.searchParams.get('date_to') ?? undefined, + }) + if (!filtersResult.success) { + return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, { + requestId: ctx.requestId, + details: { + issues: filtersResult.error.issues.map((i) => ({ + field: i.path.join('.'), + message: i.message, + })), + }, + }) + } + const filters = filtersResult.data + + let query = ctx.supabase + .from('supplier_invoices') + .select(`${SI_SUMMARY_COLUMNS}, supplier:suppliers(${SUPPLIER_NAME_ONLY_COLUMNS})`) + .eq('company_id', ctx.companyId!) + .order('invoice_date', { ascending: false }) + .order('id', { ascending: false }) + .limit(limit + 1) + + if (filters.status) query = query.eq('status', filters.status) + if (filters.supplier_id) query = query.eq('supplier_id', filters.supplier_id) + if (filters.currency) query = query.eq('currency', filters.currency) + if (filters.date_from) query = query.gte('invoice_date', filters.date_from) + if (filters.date_to) query = query.lte('invoice_date', filters.date_to) + + if (decoded) { + // Keyset on (invoice_date DESC, id DESC). + query = query.or( + `invoice_date.lt.${decoded.ts},and(invoice_date.eq.${decoded.ts},id.lt.${decoded.id})`, + ) + } + + const { data, error } = await query + if (error) { + return v1ErrorResponse(error, ctx.log, { requestId: ctx.requestId }) + } + + type SupplierObj = { id: string; name: string } & Record + type Row = { + id: string + supplier_id: string + arrival_number: number + supplier_invoice_number: string + invoice_date: string + due_date: string + status: string + currency: string + subtotal: number + vat_amount: number + total: number + paid_amount: number + remaining_amount: number + is_credit_note: boolean + paid_at: string | null + created_at: string + supplier: SupplierObj | SupplierObj[] | null + } & Record + + const rows = ((data ?? []) as unknown) as Row[] + const trimmed = rows.slice(0, limit) + const hasMore = rows.length > limit + + const pickSupplier = (s: Row['supplier']): SupplierObj | null => { + if (!s) return null + return Array.isArray(s) ? (s[0] ?? null) : s + } + + const supplier_invoices = trimmed.map((r) => { + const s = pickSupplier(r.supplier) + return { + id: r.id, + supplier_id: r.supplier_id, + supplier_name: s?.name ?? '', + arrival_number: r.arrival_number, + supplier_invoice_number: r.supplier_invoice_number, + invoice_date: r.invoice_date, + due_date: r.due_date, + status: r.status, + currency: r.currency, + subtotal: r.subtotal, + vat_amount: r.vat_amount, + total: r.total, + paid_amount: r.paid_amount, + remaining_amount: r.remaining_amount, + is_credit_note: r.is_credit_note, + paid_at: r.paid_at, + created_at: r.created_at, + } + }) + + const last = trimmed[trimmed.length - 1] + const nextCursor = hasMore && last + ? encodeDefaultCursor({ id: last.id, created_at: last.invoice_date }) + : null + + return paginated(supplier_invoices, { + requestId: ctx.requestId, + nextCursor: nextCursor ?? undefined, + }) + }, +) + +// ────────────────────────────────────────────────────────────────── +// POST — register supplier invoice +// ────────────────────────────────────────────────────────────────── + +const SI_RESPONSE_COLUMNS = + 'id, supplier_id, arrival_number, supplier_invoice_number, invoice_date, due_date, received_date, delivery_date, status, currency, exchange_rate, subtotal, subtotal_sek, vat_amount, vat_amount_sek, total, total_sek, vat_treatment, reverse_charge, payment_reference, paid_amount, remaining_amount, is_credit_note, credited_invoice_id, registration_journal_entry_id, payment_journal_entry_id, notes, created_at, updated_at' + +const SI_ITEMS_RESPONSE_COLUMNS = + 'id, sort_order, description, quantity, unit, unit_price, line_total, account_number, vat_code, vat_rate, vat_amount' + +const SupplierInvoiceCreated = z.object({ + id: z.string().uuid(), + supplier_id: z.string().uuid(), + arrival_number: z.number().int(), + supplier_invoice_number: z.string(), + invoice_date: z.string(), + due_date: z.string(), + status: z.string(), + currency: z.string(), + subtotal: z.number(), + vat_amount: z.number(), + total: z.number(), + remaining_amount: z.number(), + is_credit_note: z.boolean(), + registration_journal_entry_id: z.string().uuid().nullable(), + created_at: z.string(), +}) + +registerEndpoint({ + operation: 'supplier-invoices.create', + method: 'POST', + path: '/api/v1/companies/:companyId/supplier-invoices', + summary: 'Register a new supplier invoice.', + description: + 'Creates a supplier invoice in `registered` status and posts the registration journal entry under faktureringsmetoden (Debit expense + Debit 2641 Ingående moms / Credit 2440 Leverantörsskulder). Under kontantmetoden no JE is posted at this stage. Idempotent (mandatory Idempotency-Key). Dry-runnable.', + useWhen: + 'You\'re registering an incoming leverantörsfaktura. Use dry-run first to validate VAT calculations + period-lock state before committing.', + doNotUseFor: + 'Marking an existing SI as paid (use POST /:id/mark-paid). Issuing a credit note (use POST /:id/credit). Customer invoices (different resource).', + pitfalls: [ + 'Idempotency-Key is mandatory.', + 'invoice_date must fall within an open fiscal period — a date covered by a locked period or the company-wide bookkeeping lock returns 400 PERIOD_LOCKED.', + 'Under faktureringsmetoden the registration JE is posted atomically with the SI row. JE failure aborts the whole call and no SI row is left behind (strict-mode).', + 'supplier_id must reference an existing, non-archived supplier in the same company — 404 SUPPLIER_NOT_FOUND otherwise.', + 'Duplicate (supplier_id, supplier_invoice_number) returns 409 SI_CREATE_DUPLICATE_INVOICE_NUMBER. Use the credit flow on the original instead of re-registering with a tweaked number.', + ], + example: { + request: { + supplier_id: 'a8f1…', + supplier_invoice_number: '2026-1234', + invoice_date: '2026-05-10', + due_date: '2026-06-09', + items: [ + { description: 'Office supplies', amount: 1000, account_number: '5410', vat_rate: 0.25 }, + ], + }, + response: { + data: { + id: '0e9c…', + supplier_id: 'a8f1…', + arrival_number: 42, + supplier_invoice_number: '2026-1234', + status: 'registered', + total: 1250, + registration_journal_entry_id: '7b3a…', + }, + meta: { request_id: 'req_…', api_version: '2026-05-12' }, + }, + }, + scope: 'suppliers:write', + risk: 'medium', + idempotent: true, + reversible: true, + dryRunSupported: true, + request: { body: CreateSupplierInvoiceSchema }, + response: { success: SupplierInvoiceCreated }, +}) + +interface ComputedItem { + sort_order: number + description: string + quantity: number + unit: string + unit_price: number + line_total: number + account_number: string + vat_code: string | null + vat_rate: number + vat_amount: number +} + +// Swedish VAT rates per ML 2 kap 1 § + Skatteverket's 2026 satser. Allow +// 0 (export / undantag / reverse charge), 6 (livsmedel / kultur), 12 (food +// service / hotel), 25 (default). A misstated rate flows straight into the +// registration JE → momsdeklaration Ruta 48 + INK2R, so reject anything +// else at the surface rather than silently book a wrong figure. +const ALLOWED_SV_VAT_RATES = new Set([0, 0.06, 0.12, 0.25]) + +function computeItemsAndTotals(input: z.infer): + | { ok: true; items: ComputedItem[]; subtotal: number; vatAmount: number; total: number } + | { ok: false; field: string; message: string; attempted_rate: number; index: number } { + const items: ComputedItem[] = [] + for (let index = 0; index < input.items.length; index++) { + const item = input.items[index] + const vatRate = item.vat_rate ?? 0.25 + if (!ALLOWED_SV_VAT_RATES.has(vatRate)) { + return { + ok: false, + field: `items[${index}].vat_rate`, + message: 'vat_rate must be one of 0, 0.06, 0.12, or 0.25 (ML 2 kap 1 §).', + attempted_rate: vatRate, + index, + } + } + const lineTotal = item.amount != null + ? Math.round(item.amount * 100) / 100 + : Math.round((item.quantity ?? 1) * (item.unit_price ?? 0) * 100) / 100 + const vatAmount = Math.round(lineTotal * vatRate * 100) / 100 + items.push({ + sort_order: index, + description: item.description, + quantity: item.amount != null ? 1 : (item.quantity ?? 1), + unit: item.amount != null ? 'st' : (item.unit || 'st'), + unit_price: item.amount != null ? lineTotal : (item.unit_price ?? 0), + line_total: lineTotal, + account_number: item.account_number, + vat_code: item.vat_code || null, + vat_rate: vatRate, + vat_amount: vatAmount, + }) + } + const subtotal = items.reduce((sum, i) => sum + i.line_total, 0) + const vatAmount = items.reduce((sum, i) => sum + i.vat_amount, 0) + const total = Math.round((subtotal + vatAmount) * 100) / 100 + return { + ok: true, + items, + subtotal: Math.round(subtotal * 100) / 100, + vatAmount: Math.round(vatAmount * 100) / 100, + total, + } +} + +export const POST = withApiV1<{ params: Promise<{ companyId: string }> }>( + 'supplier-invoices.create', + async (request, ctx) => { + if (!z.string().uuid().safeParse(ctx.companyId).success) { + return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, { + requestId: ctx.requestId, + details: { field: 'companyId', message: 'companyId must be a UUID.' }, + }) + } + + let rawBody: unknown + try { + rawBody = await request.json() + } catch { + return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, { + requestId: ctx.requestId, + details: { field: 'body', message: 'Body is not valid JSON.' }, + }) + } + + const parsed = CreateSupplierInvoiceSchema.safeParse(rawBody) + if (!parsed.success) { + return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, { + requestId: ctx.requestId, + details: { + issues: parsed.error.issues.map((i) => ({ + field: i.path.join('.'), + message: i.message, + })), + }, + }) + } + const body = parsed.data + + // Supplier lookup. Scoped to company; deny soft-archived. + const { data: supplier, error: supplierErr } = await ctx.supabase + .from('suppliers') + .select('id, name, supplier_type, archived_at') + .eq('company_id', ctx.companyId!) + .eq('id', body.supplier_id) + .maybeSingle() + + if (supplierErr) { + return v1ErrorResponse(supplierErr, ctx.log, { requestId: ctx.requestId }) + } + if (!supplier || supplier.archived_at) { + return v1ErrorResponseFromCode('SUPPLIER_NOT_FOUND', ctx.log, { + requestId: ctx.requestId, + }) + } + + // Application-layer period-lock check on invoice_date. The DB trigger + // remains authoritative; this is for ergonomics so agents get a + // structured PERIOD_LOCKED instead of a generic 500. + const lockVerdict = await checkPeriodLock(ctx.supabase, ctx.companyId!, body.invoice_date) + if (lockVerdict.locked) { + return v1ErrorResponseFromCode('PERIOD_LOCKED', ctx.log, { + requestId: ctx.requestId, + details: { + reason: lockVerdict.reason, + fiscal_period_id: lockVerdict.fiscal_period_id, + }, + }) + } + + const totalsResult = computeItemsAndTotals(body) + if (!totalsResult.ok) { + return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, { + requestId: ctx.requestId, + details: { + issues: [{ field: totalsResult.field, message: totalsResult.message }], + attempted_rate: totalsResult.attempted_rate, + allowed_rates: [0, 0.06, 0.12, 0.25], + }, + }) + } + const { items, subtotal, vatAmount, total } = totalsResult + const exchangeRate = body.exchange_rate ?? null + const subtotalSek = exchangeRate ? Math.round(subtotal * exchangeRate * 100) / 100 : null + const vatAmountSek = exchangeRate ? Math.round(vatAmount * exchangeRate * 100) / 100 : null + const totalSek = exchangeRate ? Math.round(total * exchangeRate * 100) / 100 : null + + // Derive a sensible default for vat_treatment + reverse_charge from the + // supplier_type. EU/non-EU suppliers default to reverse-charge unless the + // caller explicitly overrides; Swedish suppliers default to standard 25%. + // The engine looks at `invoice.reverse_charge` (boolean) for the actual + // booking choice — `vat_treatment` is recorded as metadata. Keeping the + // two in sync prevents momsdeklaration Ruta 30 / 48 misclassification on + // EU-supplier rows that omit both fields. + const foreignSupplier = + supplier.supplier_type === 'eu_business' || supplier.supplier_type === 'non_eu_business' + const reverseCharge = body.reverse_charge ?? foreignSupplier + // Force `vat_treatment` to track the resolved `reverse_charge` flag. + // Otherwise a caller could pass `vat_treatment: 'standard_25'` explicitly + // and have it co-exist with `reverse_charge=true` (driven by + // supplier_type), producing inconsistent metadata: the engine books via + // `reverse_charge` (Ruta 30 / 48) but a downstream momsdeklaration + // export reading `vat_treatment` would mis-classify. Normalisation here + // keeps the two fields in lock-step; an explicit override only sticks + // when it agrees with the boolean flag. + const vatTreatment = reverseCharge + ? 'reverse_charge' + : (body.vat_treatment ?? 'standard_25') + + // Cross-field constraint for reverse-charge invoices: the Swedish supplier + // does not charge VAT, the buyer self-assesses (ML 1 kap 2§ p.4b / + // 16 kap 6 § / 16 kap 13 §). All item vat_rates MUST be 0 — otherwise the + // engine will mis-book ingående moms in Ruta 30 / 48 (BAS 2614 / 2645 / + // 2641). Reject up front rather than booking a phantom VAT line. + if (reverseCharge) { + const offending = items.findIndex((it) => it.vat_rate !== 0) + if (offending !== -1) { + return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, { + requestId: ctx.requestId, + details: { + field: `items[${offending}].vat_rate`, + message: + 'reverse_charge invoices must have vat_rate=0 on every line item — the buyer self-assesses VAT.', + attempted_rate: items[offending].vat_rate, + reverse_charge: true, + }, + }) + } + } + + // Dry-run preview — no arrival_number is allocated (would burn a sequence + // number on a non-commit). + if (ctx.dryRun) { + return dryRunPreview( + { + supplier_id: body.supplier_id, + supplier_invoice_number: body.supplier_invoice_number, + invoice_date: body.invoice_date, + due_date: body.due_date, + delivery_date: body.delivery_date ?? null, + status: 'registered', + currency: body.currency ?? 'SEK', + exchange_rate: exchangeRate, + vat_treatment: vatTreatment, + reverse_charge: reverseCharge, + subtotal, + subtotal_sek: subtotalSek, + vat_amount: vatAmount, + vat_amount_sek: vatAmountSek, + total, + total_sek: totalSek, + remaining_amount: total, + is_credit_note: false, + notes: body.notes ?? null, + items, + // Indicate what the live commit would do; the actual JE row is not + // staged in pending_operations because the SI write path is + // orchestrated here, not via the staging substrate. + would_create_registration_journal_entry: true, + }, + { requestId: ctx.requestId, log: ctx.log }, + ) + } + + // Allocate arrival_number (atomic, per-company sequence). + const { data: arrivalNum, error: arrivalErr } = await ctx.supabase + .rpc('get_next_arrival_number', { p_company_id: ctx.companyId! }) + + if (arrivalErr || arrivalNum == null) { + ctx.log.error('arrival_number allocation failed', (arrivalErr as Error) ?? new Error('null arrival_number')) + return v1ErrorResponseFromCode('SI_CREATE_FAILED', ctx.log, { + requestId: ctx.requestId, + details: { step: 'arrival_number' }, + }) + } + + // Insert SI row. + const { data: invoice, error: invoiceErr } = await ctx.supabase + .from('supplier_invoices') + .insert({ + user_id: ctx.userId, + company_id: ctx.companyId!, + supplier_id: body.supplier_id, + arrival_number: arrivalNum, + supplier_invoice_number: body.supplier_invoice_number, + invoice_date: body.invoice_date, + due_date: body.due_date, + delivery_date: body.delivery_date ?? null, + status: 'registered', + currency: body.currency ?? 'SEK', + exchange_rate: exchangeRate, + vat_treatment: vatTreatment, + reverse_charge: reverseCharge, + payment_reference: body.payment_reference ?? null, + subtotal, + subtotal_sek: subtotalSek, + vat_amount: vatAmount, + vat_amount_sek: vatAmountSek, + total, + total_sek: totalSek, + remaining_amount: total, + notes: body.notes ?? null, + }) + .select(SI_RESPONSE_COLUMNS) + .single() + + if (invoiceErr || !invoice) { + const pgErr = invoiceErr as { code?: string; message?: string } | null + const isDuplicateNumber = + pgErr?.code === '23505' && + (pgErr.message || '').includes('idx_supplier_invoices_company_supplier_number') + if (isDuplicateNumber) { + return v1ErrorResponseFromCode('SI_CREATE_DUPLICATE_INVOICE_NUMBER', ctx.log, { + requestId: ctx.requestId, + details: { + supplier_id: body.supplier_id, + supplier_invoice_number: body.supplier_invoice_number, + }, + }) + } + ctx.log.error('supplier invoice insert failed', invoiceErr, { + companyId: ctx.companyId, + pgCode: pgErr?.code, + }) + return v1ErrorResponseFromCode('SI_CREATE_FAILED', ctx.log, { + requestId: ctx.requestId, + details: { pg_code: pgErr?.code }, + }) + } + + const invoiceId = (invoice as { id: string }).id + + // Insert items; rollback the parent on failure. + const itemInserts = items.map((item) => ({ supplier_invoice_id: invoiceId, ...item })) + const { error: itemsErr } = await ctx.supabase + .from('supplier_invoice_items') + .insert(itemInserts) + if (itemsErr) { + // items_insert fires before any engine call — no JE could exist. + await rollbackSupplierInvoice(ctx.supabase, invoiceId, ctx.companyId!, ctx.log, 'items_insert', false) + return v1ErrorResponseFromCode('SI_CREATE_FAILED', ctx.log, { + requestId: ctx.requestId, + details: { step: 'items_insert', pg_code: (itemsErr as { code?: string }).code }, + }) + } + + // Determine accounting method — registration JE is only posted under accrual. + const { data: settings } = await ctx.supabase + .from('company_settings') + .select('accounting_method') + .eq('company_id', ctx.companyId!) + .maybeSingle() + const accountingMethod = (settings as { accounting_method?: string } | null)?.accounting_method ?? 'accrual' + + let registrationJournalEntryId: string | null = null + if (accountingMethod === 'accrual') { + try { + const entry = await createSupplierInvoiceRegistrationEntry( + ctx.supabase, + ctx.companyId!, + ctx.userId, + invoice as unknown as SupplierInvoice, + itemInserts as unknown as SupplierInvoiceItem[], + supplier.supplier_type, + supplier.name, + ) + if (entry) { + registrationJournalEntryId = entry.id + const { error: linkErr } = await ctx.supabase + .from('supplier_invoices') + .update({ registration_journal_entry_id: entry.id }) + .eq('id', invoiceId) + .eq('company_id', ctx.companyId!) + if (linkErr) { + // The JE is posted but the SI denormalised back-reference failed + // to update. Storno the orphan JE first, then roll back the SI row + // to preserve strict-mode atomicity (otherwise a subsequent GET + // would show registration_journal_entry_id=null with a live JE on + // the books). reverseEntry takes the entry id directly. + ctx.log.error('SI register: JE link update failed — stornoing JE and rolling back row', linkErr, { + invoiceId, + companyId: ctx.companyId, + userId: ctx.userId, + journalEntryId: entry.id, + }) + try { + await reverseEntry(ctx.supabase, ctx.companyId!, ctx.userId, entry.id, body.invoice_date) + } catch (revErr) { + ctx.log.error('JE storno failed after SI link-update error — manual reconciliation required', revErr as Error, { + invoiceId, + companyId: ctx.companyId, + userId: ctx.userId, + journalEntryId: entry.id, + }) + } + // je_link_failed: the JE was posted (and we just stornoed it + // above). Soft-mark keeps the audit trail visible per BFL 5:5. + await rollbackSupplierInvoice(ctx.supabase, invoiceId, ctx.companyId!, ctx.log, 'je_link_failed', true) + return v1ErrorResponseFromCode('SI_CREATE_FAILED', ctx.log, { + requestId: ctx.requestId, + details: { step: 'registration_journal_entry_link' }, + }) + } + } else { + // Engine returned null (no open fiscal period). Strict-mode: roll back. + // Engine returned null before posting — no JE exists. + await rollbackSupplierInvoice(ctx.supabase, invoiceId, ctx.companyId!, ctx.log, 'no_fiscal_period', false) + return v1ErrorResponseFromCode('SI_CREATE_FAILED', ctx.log, { + requestId: ctx.requestId, + details: { step: 'registration_journal_entry', reason: 'no_fiscal_period' }, + }) + } + } catch (err) { + // Engine threw — conservatively assume the JE may have committed + // before the throw (createJournalEntry is the atomic write inside + // the engine; a throw after that point would still leave a posted + // JE). Soft-mark to preserve any half-committed audit trail. + await rollbackSupplierInvoice(ctx.supabase, invoiceId, ctx.companyId!, ctx.log, 'registration_journal_entry', true) + if (isBookkeepingError(err)) { + return v1ErrorResponse(err, ctx.log, { requestId: ctx.requestId }) + } + ctx.log.error('supplier-invoice registration JE creation failed', err as Error, { + invoiceId, + companyId: ctx.companyId, + }) + return v1ErrorResponseFromCode('SI_CREATE_FAILED', ctx.log, { + requestId: ctx.requestId, + details: { step: 'registration_journal_entry' }, + }) + } + } + + try { + await eventBus.emit({ + type: 'supplier_invoice.registered', + payload: { + supplierInvoice: invoice as unknown as SupplierInvoice, + companyId: ctx.companyId!, + userId: ctx.userId, + }, + }) + } catch (err) { + ctx.log.warn('supplier_invoice.registered emit failed', err as Error) + } + + // Refetch with the registration_journal_entry_id populated and items. + const { data: complete } = await ctx.supabase + .from('supplier_invoices') + .select(`${SI_RESPONSE_COLUMNS}, items:supplier_invoice_items(${SI_ITEMS_RESPONSE_COLUMNS})`) + .eq('company_id', ctx.companyId!) + .eq('id', invoiceId) + .maybeSingle() + + return created( + complete ?? { ...invoice, items: itemInserts, registration_journal_entry_id: registrationJournalEntryId }, + { requestId: ctx.requestId }, + ) + }, + { requireIdempotencyKey: true }, +) + +async function rollbackSupplierInvoice( + supabase: SupabaseClient, + invoiceId: string, + companyId: string, + log: import('@/lib/logger').Logger, + reason: string, + journalEntryPosted: boolean, +) { + // BFL 5 kap 5 § applies once a verifikation has been committed to the + // books. A failed insert that never produced a JE (items_insert error, + // engine returning null because no fiscal period covers the date) is a + // failed insertion, not a bokföringspost — a row with status='reversed' + // and registration_journal_entry_id=null would be a dangling + // räkenskapsinformation entry harder to audit than a clean removal. + // + // So: soft-mark `reversed` ONLY when a JE existed at the point of + // failure. Pre-JE failures hard-delete (with explicit items wipe in case + // the items insert partially succeeded — which Postgres makes atomic for + // a single INSERT, but the defense is cheap). + if (!journalEntryPosted) { + await supabase.from('supplier_invoice_items').delete().eq('supplier_invoice_id', invoiceId) + const { error: parentErr } = await supabase + .from('supplier_invoices') + .delete() + .eq('id', invoiceId) + .eq('company_id', companyId) + if (parentErr) { + log.error('supplier-invoice hard-rollback failed — orphan row', parentErr, { + invoiceId, + companyId, + rollbackReason: reason, + }) + } else { + log.warn('supplier-invoice hard-rolled back (no JE existed)', { + invoiceId, + companyId, + rollbackReason: reason, + }) + } + return + } + + // Post-JE soft-mark. Status='reversed' is the same flag the dashboard + // uses for "Ångra kreditering"; the row + items remain queryable, the + // already-stornoed verifikation pair stays visible on the JE side. + const { error: updateErr } = await supabase + .from('supplier_invoices') + .update({ status: 'reversed', reversed_at: new Date().toISOString() }) + .eq('id', invoiceId) + .eq('company_id', companyId) + if (updateErr) { + log.error('supplier-invoice soft-rollback failed — manual reconciliation required', updateErr, { + invoiceId, + companyId, + rollbackReason: reason, + }) + } else { + log.warn('supplier-invoice soft-rolled back (status=reversed)', { + invoiceId, + companyId, + rollbackReason: reason, + }) + } +} diff --git a/app/api/v1/companies/[companyId]/suppliers/[id]/route.ts b/app/api/v1/companies/[companyId]/suppliers/[id]/route.ts new file mode 100644 index 00000000..83abfb68 --- /dev/null +++ b/app/api/v1/companies/[companyId]/suppliers/[id]/route.ts @@ -0,0 +1,536 @@ +/** + * /api/v1/companies/{companyId}/suppliers/{id} — supplier detail + writes. + * + * GET — full record. ?expand=supplier_invoices embeds open supplier invoices. + * PATCH — partial update. Idempotent (mandatory Idempotency-Key). Dry-runnable. + * Setting archived_at: null un-archives the supplier. + * DELETE — soft-delete (sets archived_at). Idempotent. Dry-runnable. 204 on + * success. REFUSES to archive when the supplier has any open + * (registered / approved / partially_paid / overdue / disputed) + * supplier invoice — preserves the canonical seller name/address + * that BFL 7 kap requires the leverantörsfaktura to carry. Close + * (credit or mark paid) the open invoices first. + */ + +import { z } from 'zod' +import { noContent, ok } from '@/lib/api/v1/response' +import { dryRunPreview } from '@/lib/api/v1/dry-run' +import { parseExpand } from '@/lib/api/v1/expand' +import { registerEndpoint } from '@/lib/api/v1/registry' +import { withApiV1 } from '@/lib/api/v1/with-api-v1' +import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors' +import { UpdateSupplierSchema } from '@/lib/api/schemas' + +// v1-only extension: allow PATCH to set archived_at back to null to +// un-archive a supplier. Restricted to literal `null` so the caller can't +// fake an archive timestamp. +const V1PatchSupplierSchema = UpdateSupplierSchema.extend({ + archived_at: z.null().optional(), +}) + +const SupplierDetail = z.object({ + id: z.string().uuid(), + name: z.string(), + supplier_type: z.string(), + email: z.string().nullable(), + phone: z.string().nullable(), + address_line1: z.string().nullable(), + address_line2: z.string().nullable(), + postal_code: z.string().nullable(), + city: z.string().nullable(), + country: z.string(), + org_number: z.string().nullable(), + vat_number: z.string().nullable(), + bankgiro: z.string().nullable(), + plusgiro: z.string().nullable(), + bank_account: z.string().nullable(), + iban: z.string().nullable(), + bic: z.string().nullable(), + default_expense_account: z.string().nullable(), + default_payment_terms: z.number(), + default_currency: z.string(), + notes: z.string().nullable(), + archived_at: z.string().nullable(), + created_at: z.string(), + updated_at: z.string(), +}) + +const ALLOWED_EXPAND = ['supplier_invoices'] as const +// `disputed` is included so a held supplier invoice still blocks archive — +// the seller record may still be needed if the dispute resolves into a +// kreditfaktura or partial payment. +const OPEN_SUPPLIER_INVOICE_STATUSES = [ + 'registered', + 'approved', + 'partially_paid', + 'overdue', + 'disputed', +] + +const SUPPLIER_DETAIL_COLUMNS = + 'id, name, supplier_type, email, phone, address_line1, address_line2, postal_code, city, country, org_number, vat_number, bankgiro, plusgiro, bank_account, iban, bic, default_expense_account, default_payment_terms, default_currency, notes, archived_at, created_at, updated_at' + +const OPEN_SUPPLIER_INVOICE_COLUMNS = + 'id, supplier_invoice_number, arrival_number, invoice_date, due_date, status, currency, total, remaining_amount' + +registerEndpoint({ + operation: 'suppliers.get', + method: 'GET', + path: '/api/v1/companies/:companyId/suppliers/:id', + summary: 'Retrieve a single supplier by id.', + description: + 'Returns the full supplier record. Pass ?expand=supplier_invoices to embed any open supplier invoices (registered / approved / partially_paid / overdue / disputed) for the supplier in the same response.', + useWhen: + 'You need the full supplier record — address, payment terms, banking details, default expense account — before booking a supplier invoice or syncing to an external AP system.', + doNotUseFor: + 'Listing suppliers (use the list endpoint). Looking up customer or employee records (different resources).', + pitfalls: [ + 'archived_at is non-null when the supplier has been soft-deleted; the supplier is still queryable by id but excluded from default lists.', + 'Banking fields (bankgiro / plusgiro / iban / bic) are stored as supplied; no Luhn or IBAN check is performed at this layer.', + ], + example: { + response: { + data: { + id: 'a8f1…', + name: 'Office Depot AB', + supplier_type: 'swedish_business', + email: 'invoices@officedepot.example', + org_number: '556677-8899', + bankgiro: '123-4567', + default_expense_account: '5410', + default_payment_terms: 30, + default_currency: 'SEK', + archived_at: null, + created_at: '2026-04-12T08:30:00Z', + updated_at: '2026-04-30T11:22:09Z', + }, + meta: { request_id: 'req_…', api_version: '2026-05-12' }, + }, + }, + scope: 'suppliers:read', + risk: 'low', + idempotent: true, + reversible: false, + dryRunSupported: false, + response: { success: SupplierDetail }, +}) + +export const GET = withApiV1<{ params: Promise<{ companyId: string; id: string }> }>( + 'suppliers.get', + async (request, ctx, params) => { + const { id } = await params.params + + const idParse = z.string().uuid().safeParse(id) + if (!idParse.success) { + return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, { + requestId: ctx.requestId, + details: { field: 'id', message: 'Supplier id must be a UUID.' }, + }) + } + const supplierId = idParse.data + + const url = new URL(request.url) + + const expandResult = parseExpand(url, ALLOWED_EXPAND) + if (!expandResult.ok) { + return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, { + requestId: ctx.requestId, + details: { + field: 'expand', + invalidKeys: expandResult.invalidKeys, + allowed: expandResult.allowed, + }, + }) + } + const expand = expandResult.expand + + const { data: supplier, error } = await ctx.supabase + .from('suppliers') + .select(SUPPLIER_DETAIL_COLUMNS) + .eq('company_id', ctx.companyId!) + .eq('id', supplierId) + .maybeSingle() + + if (error) { + return v1ErrorResponse(error, ctx.log, { requestId: ctx.requestId }) + } + if (!supplier) { + ctx.log.warn('suppliers.get: not found', { supplierId, companyId: ctx.companyId }) + return v1ErrorResponseFromCode('NOT_FOUND', ctx.log, { + requestId: ctx.requestId, + details: { resource: 'supplier' }, + }) + } + + let supplier_invoices: unknown[] | undefined + const partialExpansions: string[] = [] + if (expand.has('supplier_invoices')) { + const { data: invs, error: invErr } = await ctx.supabase + .from('supplier_invoices') + .select(OPEN_SUPPLIER_INVOICE_COLUMNS) + .eq('company_id', ctx.companyId!) + .eq('supplier_id', supplierId) + .in('status', OPEN_SUPPLIER_INVOICE_STATUSES) + .order('due_date', { ascending: true }) + + if (invErr) { + // Soft-degrade: log but still return the supplier. Same Postgres + // class-42 (auth/access) treatment as the customers expand handler. + const errMsg = (invErr as { code?: string; message?: string }).message ?? 'unknown' + const errCode = (invErr as { code?: string }).code ?? 'unknown' + const isPermissionError = typeof errCode === 'string' && errCode.startsWith('42') + if (isPermissionError) { + ctx.log.error('suppliers.get: open-invoices expansion permission denied', new Error(errMsg), { + errCode, + supplierId, + }) + } else { + ctx.log.warn('suppliers.get: open-invoices expansion failed', { errCode, errMsg }) + } + supplier_invoices = [] + partialExpansions.push('supplier_invoices') + } else { + supplier_invoices = invs ?? [] + } + } + + return ok( + { ...supplier, ...(supplier_invoices !== undefined ? { supplier_invoices } : {}) }, + { + requestId: ctx.requestId, + partialExpansions: partialExpansions.length > 0 ? partialExpansions : undefined, + }, + ) + }, +) + +// ────────────────────────────────────────────────────────────────── +// PATCH — partial update +// ────────────────────────────────────────────────────────────────── + +registerEndpoint({ + operation: 'suppliers.update', + method: 'PATCH', + path: '/api/v1/companies/:companyId/suppliers/:id', + summary: 'Partially update a supplier.', + description: + 'Patches the supplier with the supplied fields. All fields optional. Idempotent (mandatory Idempotency-Key). Dry-runnable.', + useWhen: + 'You need to change a supplier\'s contact details, payment terms, banking info, default expense account, or VAT number. Use dry-run first to confirm the merged record before committing.', + doNotUseFor: + 'Archiving a supplier (use DELETE — sets archived_at). Replacing the entire record (no PUT verb is exposed; PATCH is partial).', + pitfalls: [ + 'Idempotency-Key is mandatory; calls without it return 400.', + 'org_number uniqueness is enforced at DB level — 23505 → 409 SUPPLIER_DUPLICATE_ORG_NUMBER.', + 'Changing default_expense_account does not retroactively rebook prior supplier invoices — only future bookings pick up the new default.', + ], + example: { + request: { default_payment_terms: 14, notes: 'New payment terms agreed 2026-05-12.' }, + response: { + data: { + id: '0e9c…', + name: 'Office Depot AB', + default_payment_terms: 14, + notes: 'New payment terms agreed 2026-05-12.', + }, + meta: { request_id: 'req_…', api_version: '2026-05-12' }, + }, + }, + scope: 'suppliers:write', + risk: 'low', + idempotent: true, + reversible: true, + dryRunSupported: true, + request: { body: UpdateSupplierSchema }, + response: { success: SupplierDetail }, +}) + +export const PATCH = withApiV1<{ params: Promise<{ companyId: string; id: string }> }>( + 'suppliers.update', + async (request, ctx, params) => { + const { id } = await params.params + + const idParse = z.string().uuid().safeParse(id) + if (!idParse.success) { + return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, { + requestId: ctx.requestId, + details: { field: 'id', message: 'Supplier id must be a UUID.' }, + }) + } + const supplierId = idParse.data + + let rawBody: unknown + try { + rawBody = await request.json() + } catch { + return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, { + requestId: ctx.requestId, + details: { field: 'body', message: 'Body is not valid JSON.' }, + }) + } + + const parsed = V1PatchSupplierSchema.safeParse(rawBody) + if (!parsed.success) { + return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, { + requestId: ctx.requestId, + details: { + issues: parsed.error.issues.map((i) => ({ + field: i.path.join('.'), + message: i.message, + })), + }, + }) + } + const body = parsed.data + + const updateData: Record = {} + for (const key of [ + 'name', + 'supplier_type', + 'email', + 'phone', + 'address_line1', + 'address_line2', + 'postal_code', + 'city', + 'country', + 'org_number', + 'vat_number', + 'bankgiro', + 'plusgiro', + 'bank_account', + 'iban', + 'bic', + 'default_expense_account', + 'default_payment_terms', + 'default_currency', + 'notes', + 'archived_at', + ] as const) { + if (body[key] !== undefined) updateData[key] = body[key] + } + + if (Object.keys(updateData).length === 0) { + return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, { + requestId: ctx.requestId, + details: { field: 'body', message: 'At least one field must be supplied for update.' }, + }) + } + + // Mirror is_active (legacy boolean) when archived_at changes. Keeps the + // dashboard's "show only active suppliers" filters working without + // backfill — every v1 archive/un-archive flows through here and the + // bulk-create. + if (Object.prototype.hasOwnProperty.call(updateData, 'archived_at')) { + updateData.is_active = updateData.archived_at === null + } + + // Fetch the current row up front. Needed for the BFL 7 kap immutability + // check below — a supplier with `archived_at IS NOT NULL` is part of the + // räkenskapsinformation backing historical verifikationer and must not + // have its name / address / banking fields mutated. The single exception + // is un-archiving (PATCH archived_at: null). + const { data: current, error: currentFetchErr } = await ctx.supabase + .from('suppliers') + .select(SUPPLIER_DETAIL_COLUMNS) + .eq('company_id', ctx.companyId!) + .eq('id', supplierId) + .maybeSingle() + + if (currentFetchErr) { + return v1ErrorResponse(currentFetchErr, ctx.log, { requestId: ctx.requestId }) + } + if (!current) { + ctx.log.warn('suppliers.update: not found', { supplierId, companyId: ctx.companyId }) + return v1ErrorResponseFromCode('NOT_FOUND', ctx.log, { + requestId: ctx.requestId, + details: { resource: 'supplier' }, + }) + } + + const currentArchivedAt = (current as { archived_at: string | null }).archived_at + const isUnArchiving = updateData.archived_at === null + if (currentArchivedAt && !isUnArchiving) { + // BFL 7 kap 1 § protects räkenskapsinformation — the identifying + // fields that historical verifikationer reference through their join + // to this row. Internal notes / payment-config don't qualify, so we + // only refuse the PATCH when an identifying field is in the update. + // The dashboard equivalent allows the same narrow exception. + const PROTECTED_FIELDS = new Set([ + 'name', + 'supplier_type', + 'org_number', + 'vat_number', + 'address_line1', + 'address_line2', + 'postal_code', + 'city', + 'country', + 'bankgiro', + 'plusgiro', + 'bank_account', + 'iban', + 'bic', + ]) + const offendingFields = Object.keys(updateData).filter((k) => PROTECTED_FIELDS.has(k)) + if (offendingFields.length > 0) { + return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, { + requestId: ctx.requestId, + details: { + field: 'archived_at', + message: + 'Supplier is archived; identifying fields (name, address, banking, org/vat number) are räkenskapsinformation under BFL 7 kap 1 § and cannot be edited. Un-archive (PATCH archived_at: null) first if a correction is needed.', + archived_at: currentArchivedAt, + offending_fields: offendingFields, + }, + }) + } + // Non-identifying fields (notes, default_payment_terms, + // default_expense_account, default_currency, email, phone) are not + // räkenskapsinformation — fall through and allow the update. + } + + if (ctx.dryRun) { + return dryRunPreview({ ...current, ...updateData }, { requestId: ctx.requestId, log: ctx.log }) + } + + const { data, error } = await ctx.supabase + .from('suppliers') + .update(updateData) + .eq('company_id', ctx.companyId!) + .eq('id', supplierId) + .select(SUPPLIER_DETAIL_COLUMNS) + .maybeSingle() + + if (error) { + if (error.code === '23505') { + return v1ErrorResponseFromCode('SUPPLIER_DUPLICATE_ORG_NUMBER', ctx.log, { + requestId: ctx.requestId, + details: { field: 'org_number' }, + }) + } + return v1ErrorResponse(error, ctx.log, { requestId: ctx.requestId }) + } + if (!data) { + ctx.log.warn('suppliers.update: not found', { supplierId, companyId: ctx.companyId }) + return v1ErrorResponseFromCode('NOT_FOUND', ctx.log, { + requestId: ctx.requestId, + details: { resource: 'supplier' }, + }) + } + + return ok(data, { requestId: ctx.requestId }) + }, + { requireIdempotencyKey: true }, +) + +// ────────────────────────────────────────────────────────────────── +// DELETE — soft-delete (sets archived_at) +// ────────────────────────────────────────────────────────────────── + +registerEndpoint({ + operation: 'suppliers.delete', + method: 'DELETE', + path: '/api/v1/companies/:companyId/suppliers/:id', + summary: 'Archive a supplier (soft-delete).', + description: + 'Sets archived_at on the supplier; the record is preserved (supplier invoices and audit history remain intact) but excluded from default list responses. To un-archive, PATCH archived_at back to null. Idempotent — archiving an already-archived supplier is a no-op. Dry-runnable.', + useWhen: + 'You want to remove a supplier from active rosters without losing their history. Idempotent: re-archiving is safe.', + doNotUseFor: + 'Permanently deleting a supplier with all history — the public API does not expose hard-delete. GDPR erasure requests go through a dedicated workflow.', + pitfalls: [ + 'Idempotency-Key is mandatory.', + 'A supplier with any open supplier invoice (registered / approved / partially_paid / overdue / disputed) cannot be archived — returns 409 SUPPLIER_HAS_INVOICES. Close the invoices first. This protects BFL 7 kap audit: the supplier record is the canonical source of seller name/address for invoice reissuance.', + '204 No Content is returned on success — there is no response body to parse.', + ], + example: { + response: { data: null, meta: { request_id: 'req_…', api_version: '2026-05-12' } }, + }, + scope: 'suppliers:write', + risk: 'medium', + idempotent: true, + reversible: true, + dryRunSupported: true, + response: { success: z.object({}) }, +}) + +export const DELETE = withApiV1<{ params: Promise<{ companyId: string; id: string }> }>( + 'suppliers.delete', + async (_request, ctx, params) => { + const { id } = await params.params + + const idParse = z.string().uuid().safeParse(id) + if (!idParse.success) { + return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, { + requestId: ctx.requestId, + details: { field: 'id', message: 'Supplier id must be a UUID.' }, + }) + } + const supplierId = idParse.data + + const { count: openInvoiceCount, error: openErr } = await ctx.supabase + .from('supplier_invoices') + .select('id', { count: 'exact', head: true }) + .eq('company_id', ctx.companyId!) + .eq('supplier_id', supplierId) + .in('status', OPEN_SUPPLIER_INVOICE_STATUSES) + + if (openErr) { + return v1ErrorResponse(openErr, ctx.log, { requestId: ctx.requestId }) + } + if ((openInvoiceCount ?? 0) > 0) { + return v1ErrorResponseFromCode('SUPPLIER_HAS_INVOICES', ctx.log, { + requestId: ctx.requestId, + details: { open_invoice_count: openInvoiceCount }, + }) + } + + if (ctx.dryRun) { + const { data: current, error: fetchErr } = await ctx.supabase + .from('suppliers') + .select(SUPPLIER_DETAIL_COLUMNS) + .eq('company_id', ctx.companyId!) + .eq('id', supplierId) + .maybeSingle() + + if (fetchErr) { + return v1ErrorResponse(fetchErr, ctx.log, { requestId: ctx.requestId }) + } + if (!current) { + ctx.log.warn('suppliers.delete dry-run: not found', { supplierId, companyId: ctx.companyId }) + return v1ErrorResponseFromCode('NOT_FOUND', ctx.log, { + requestId: ctx.requestId, + details: { resource: 'supplier' }, + }) + } + + return dryRunPreview( + { ...current, archived_at: new Date().toISOString() }, + { requestId: ctx.requestId, log: ctx.log }, + ) + } + + const { data, error } = await ctx.supabase + .from('suppliers') + .update({ archived_at: new Date().toISOString(), is_active: false }) + .eq('company_id', ctx.companyId!) + .eq('id', supplierId) + .select('id') + .maybeSingle() + + if (error) { + return v1ErrorResponse(error, ctx.log, { requestId: ctx.requestId }) + } + if (!data) { + ctx.log.warn('suppliers.delete: not found', { supplierId, companyId: ctx.companyId }) + return v1ErrorResponseFromCode('NOT_FOUND', ctx.log, { + requestId: ctx.requestId, + details: { resource: 'supplier' }, + }) + } + + return noContent({ requestId: ctx.requestId }) + }, + { requireIdempotencyKey: true }, +) diff --git a/app/api/v1/companies/[companyId]/suppliers/__tests__/route.test.ts b/app/api/v1/companies/[companyId]/suppliers/__tests__/route.test.ts new file mode 100644 index 00000000..b2aa295e --- /dev/null +++ b/app/api/v1/companies/[companyId]/suppliers/__tests__/route.test.ts @@ -0,0 +1,574 @@ +/** + * Integration tests for the v1 suppliers vertical (Phase 4 PR-1). + * + * Mirrors the customers test pattern: a Proxy-backed Supabase mock returns + * whatever the route awaits, keyed by table name. Each suite focuses on + * outcome (status / body shape) rather than query mechanics — the wrapper + * already validates auth, scope, idempotency, and dry-run resolution. + */ + +import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' + +beforeAll(() => { + if (process.env.NODE_ENV !== 'test') { + throw new Error( + `suppliers route tests require NODE_ENV=test (got ${process.env.NODE_ENV ?? 'undefined'})`, + ) + } + process.env.NEXT_PUBLIC_SUPABASE_URL ||= 'http://localhost:54321' + process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ||= 'test-anon-key' +}) + +vi.mock('@/lib/auth/api-keys', async () => { + const actual = await vi.importActual('@/lib/auth/api-keys') + return { + ...actual, + validateApiKey: vi.fn(), + createServiceClientNoCookies: vi.fn(), + } +}) + +vi.mock('@supabase/supabase-js', async () => { + const actual = await vi.importActual('@supabase/supabase-js') + return { ...actual, createClient: vi.fn().mockReturnValue({}) } +}) + +import { validateApiKey, createServiceClientNoCookies } from '@/lib/auth/api-keys' +import { GET as listSuppliers, POST as createSupplier } from '../route' +import { + GET as getSupplier, + PATCH as updateSupplier, + DELETE as deleteSupplier, +} from '../[id]/route' +import { POST as bulkCreateSuppliers } from '../bulk-create/route' + +const mockValidate = validateApiKey as ReturnType +const mockServiceClient = createServiceClientNoCookies as ReturnType + +interface TableResp { + data?: unknown + error?: unknown + count?: number | null +} + +function makeFlexibleSupabase(byTable: Record) { + // Per-table queue: TableResp[] consumes one entry per await, then sticks + // on the last entry. Plain TableResp is treated as a constant. + const queues = new Map() + for (const [t, val] of Object.entries(byTable)) { + queues.set(t, Array.isArray(val) ? [...val] : [val]) + } + const buildChain = (table: string): unknown => { + const handler: ProxyHandler = { + get(_target, prop) { + if (prop === 'then') { + return (resolve: (v: unknown) => void) => { + const q = queues.get(table) + const next = q && q.length > 1 ? q.shift()! : (q?.[0] ?? { data: null, error: null }) + resolve(next) + } + } + return (..._args: unknown[]) => buildChain(table) + }, + } + return new Proxy({}, handler) + } + return { from: vi.fn((table: string) => buildChain(table)) } +} + +const COMPANY_ID = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' +const SUPPLIER_ID = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb' +const USER_ID = 'user-1' + +function makeRequest(url: string, init?: RequestInit): Request { + return new Request(url, { + ...init, + headers: { + Authorization: 'Bearer test-fixture-not-a-real-key', + 'Idempotency-Key': 'b1aaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', + ...(init?.headers ?? {}), + }, + }) +} + +function companyParams(companyId: string) { + return { params: Promise.resolve({ companyId }) } +} + +function detailParams(companyId: string, id: string) { + return { params: Promise.resolve({ companyId, id }) } +} + +beforeEach(() => { + vi.clearAllMocks() + mockValidate.mockResolvedValue({ + userId: USER_ID, + companyId: COMPANY_ID, + apiKeyId: 'ak_1', + apiKeyName: 'CI key', + scopes: ['suppliers:read', 'suppliers:write'], + mode: 'live', + }) +}) + +const SAMPLE_SUPPLIER = { + id: SUPPLIER_ID, + name: 'Office Depot AB', + supplier_type: 'swedish_business', + email: 'invoices@officedepot.test', + phone: null, + address_line1: null, + address_line2: null, + postal_code: null, + city: null, + country: 'SE', + org_number: 'TEST-0000-0001', + vat_number: 'SETEST00000001', + bankgiro: '123-4567', + plusgiro: null, + bank_account: null, + iban: null, + bic: null, + default_expense_account: '5410', + default_payment_terms: 30, + default_currency: 'SEK', + notes: null, + archived_at: null, + created_at: '2026-04-12T08:30:00Z', + updated_at: '2026-04-30T11:22:09Z', +} + +describe('GET /api/v1/companies/:companyId/suppliers', () => { + it('returns paginated suppliers, excluding archived by default', async () => { + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + suppliers: { data: [SAMPLE_SUPPLIER], error: null }, + }), + ) + + const res = await listSuppliers( + makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/suppliers`), + companyParams(COMPANY_ID), + ) + + expect(res.status).toBe(200) + const body = await res.json() + expect(body.data).toHaveLength(1) + expect(body.data[0].name).toBe('Office Depot AB') + expect(body.data[0].default_currency).toBe('SEK') + }) + + it('rejects unknown filter values with 400 VALIDATION_ERROR', async () => { + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + suppliers: { data: [], error: null }, + }), + ) + const res = await listSuppliers( + makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/suppliers?supplier_type=individual`), + companyParams(COMPANY_ID), + ) + expect(res.status).toBe(400) + const body = await res.json() + expect(body.error.code).toBe('VALIDATION_ERROR') + }) +}) + +describe('GET /api/v1/companies/:companyId/suppliers/:id', () => { + it('returns the supplier when found', async () => { + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + suppliers: { data: SAMPLE_SUPPLIER, error: null }, + }), + ) + + const res = await getSupplier( + makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/suppliers/${SUPPLIER_ID}`), + detailParams(COMPANY_ID, SUPPLIER_ID), + ) + + expect(res.status).toBe(200) + const body = await res.json() + expect(body.data.id).toBe(SUPPLIER_ID) + }) + + it('returns 404 NOT_FOUND when missing', async () => { + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + suppliers: { data: null, error: null }, + }), + ) + + const res = await getSupplier( + makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/suppliers/${SUPPLIER_ID}`), + detailParams(COMPANY_ID, SUPPLIER_ID), + ) + + expect(res.status).toBe(404) + const body = await res.json() + expect(body.error.code).toBe('NOT_FOUND') + }) + + it('rejects a non-UUID id with 400 VALIDATION_ERROR', async () => { + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + }), + ) + const res = await getSupplier( + makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/suppliers/not-a-uuid`), + detailParams(COMPANY_ID, 'not-a-uuid'), + ) + expect(res.status).toBe(400) + }) +}) + +describe('POST /api/v1/companies/:companyId/suppliers', () => { + it('creates a supplier (happy path)', async () => { + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + suppliers: { data: SAMPLE_SUPPLIER, error: null }, + idempotency_keys: { data: null, error: null }, + }), + ) + + const res = await createSupplier( + makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/suppliers`, { + method: 'POST', + body: JSON.stringify({ + name: 'Office Depot AB', + supplier_type: 'swedish_business', + org_number: 'TEST-0000-0001', + }), + }), + companyParams(COMPANY_ID), + ) + + expect(res.status).toBe(201) + const body = await res.json() + expect(body.data.name).toBe('Office Depot AB') + }) + + it('returns 409 SUPPLIER_DUPLICATE_ORG_NUMBER on a 23505', async () => { + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + suppliers: { data: null, error: { code: '23505', message: 'duplicate' } }, + idempotency_keys: { data: null, error: null }, + }), + ) + + const res = await createSupplier( + makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/suppliers`, { + method: 'POST', + body: JSON.stringify({ + name: 'Office Depot AB', + supplier_type: 'swedish_business', + org_number: 'TEST-0000-0001', + }), + }), + companyParams(COMPANY_ID), + ) + + expect(res.status).toBe(409) + const body = await res.json() + expect(body.error.code).toBe('SUPPLIER_DUPLICATE_ORG_NUMBER') + // GDPR Art.5(1)(c) defense-in-depth — error never echoes the value back. + expect(JSON.stringify(body.error)).not.toContain('TEST-0000-0001') + }) + + it('returns a dry-run preview without committing when ?dry_run=true', async () => { + const fromSpy = vi.fn() + mockServiceClient.mockReturnValue({ + from: (table: string) => { + fromSpy(table) + return new Proxy({}, { + get(_t, prop) { + if (prop === 'then') { + const data = table === 'company_members' + ? { company_id: COMPANY_ID, role: 'owner' } + : null + return (resolve: (v: unknown) => void) => resolve({ data, error: null }) + } + return () => new Proxy({}, this!) + }, + }) + }, + }) + + const res = await createSupplier( + makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/suppliers?dry_run=true`, { + method: 'POST', + body: JSON.stringify({ + name: 'Office Depot AB', + supplier_type: 'swedish_business', + org_number: 'TEST-0000-0001', + }), + }), + companyParams(COMPANY_ID), + ) + + expect(res.status).toBe(200) + expect(res.headers.get('X-Dry-Run')).toBe('true') + // No supplier insert; only the membership check should have hit the DB. + expect(fromSpy).not.toHaveBeenCalledWith('suppliers') + }) + + it('returns 400 when Idempotency-Key is missing', async () => { + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + }), + ) + + const req = new Request(`https://x.test/api/v1/companies/${COMPANY_ID}/suppliers`, { + method: 'POST', + headers: { Authorization: 'Bearer test' }, + body: JSON.stringify({ name: 'X', supplier_type: 'swedish_business' }), + }) + + const res = await createSupplier(req, companyParams(COMPANY_ID)) + expect(res.status).toBe(400) + }) +}) + +describe('PATCH /api/v1/companies/:companyId/suppliers/:id', () => { + it('updates an existing supplier', async () => { + const updated = { ...SAMPLE_SUPPLIER, default_payment_terms: 14 } + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + suppliers: { data: updated, error: null }, + idempotency_keys: { data: null, error: null }, + }), + ) + + const res = await updateSupplier( + makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/suppliers/${SUPPLIER_ID}`, { + method: 'PATCH', + body: JSON.stringify({ default_payment_terms: 14 }), + }), + detailParams(COMPANY_ID, SUPPLIER_ID), + ) + + expect(res.status).toBe(200) + const body = await res.json() + expect(body.data.default_payment_terms).toBe(14) + }) + + it('returns 400 VALIDATION_ERROR for an empty body', async () => { + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + }), + ) + + const res = await updateSupplier( + makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/suppliers/${SUPPLIER_ID}`, { + method: 'PATCH', + body: JSON.stringify({}), + }), + detailParams(COMPANY_ID, SUPPLIER_ID), + ) + + expect(res.status).toBe(400) + }) + + it('refuses to edit identifying fields on an archived supplier (BFL 7 kap)', async () => { + const archived = { ...SAMPLE_SUPPLIER, archived_at: '2026-01-01T00:00:00Z' } + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + suppliers: { data: archived, error: null }, + idempotency_keys: { data: null, error: null }, + }), + ) + const res = await updateSupplier( + makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/suppliers/${SUPPLIER_ID}`, { + method: 'PATCH', + body: JSON.stringify({ name: 'Renamed AB' }), + }), + detailParams(COMPANY_ID, SUPPLIER_ID), + ) + expect(res.status).toBe(400) + const body = await res.json() + expect(body.error.code).toBe('VALIDATION_ERROR') + expect(body.error.details.field).toBe('archived_at') + expect(body.error.details.archived_at).toBe('2026-01-01T00:00:00Z') + }) + + it('allows un-archive (archived_at: null) on an archived supplier', async () => { + const archived = { ...SAMPLE_SUPPLIER, archived_at: '2026-01-01T00:00:00Z' } + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + // Queue: pre-flight fetch (archived) → final update select (un-archived) + suppliers: [ + { data: archived, error: null }, + { data: { ...archived, archived_at: null }, error: null }, + ], + idempotency_keys: { data: null, error: null }, + } as Record), + ) + const res = await updateSupplier( + makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/suppliers/${SUPPLIER_ID}`, { + method: 'PATCH', + body: JSON.stringify({ archived_at: null }), + }), + detailParams(COMPANY_ID, SUPPLIER_ID), + ) + expect(res.status).toBe(200) + const body = await res.json() + expect(body.data.archived_at).toBeNull() + }) + + it('allows non-identifying field edits (notes) on an archived supplier', async () => { + // BFL 7 kap protects räkenskapsinformation — internal notes are not + // referenced by any verifikation, so they remain editable. + const archived = { ...SAMPLE_SUPPLIER, archived_at: '2026-01-01T00:00:00Z' } + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + suppliers: [ + { data: archived, error: null }, + { data: { ...archived, notes: 'Updated internal note' }, error: null }, + ], + idempotency_keys: { data: null, error: null }, + } as Record), + ) + const res = await updateSupplier( + makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/suppliers/${SUPPLIER_ID}`, { + method: 'PATCH', + body: JSON.stringify({ notes: 'Updated internal note' }), + }), + detailParams(COMPANY_ID, SUPPLIER_ID), + ) + expect(res.status).toBe(200) + const body = await res.json() + expect(body.data.notes).toBe('Updated internal note') + }) +}) + +describe('DELETE /api/v1/companies/:companyId/suppliers/:id', () => { + it('archives a supplier with no open invoices (204)', async () => { + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + supplier_invoices: { data: null, error: null, count: 0 }, + suppliers: { data: { id: SUPPLIER_ID }, error: null }, + idempotency_keys: { data: null, error: null }, + }), + ) + + const res = await deleteSupplier( + makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/suppliers/${SUPPLIER_ID}`, { + method: 'DELETE', + }), + detailParams(COMPANY_ID, SUPPLIER_ID), + ) + + expect(res.status).toBe(204) + }) + + it('refuses to archive when open invoices exist (409 SUPPLIER_HAS_INVOICES)', async () => { + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + supplier_invoices: { data: null, error: null, count: 3 }, + idempotency_keys: { data: null, error: null }, + }), + ) + + const res = await deleteSupplier( + makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/suppliers/${SUPPLIER_ID}`, { + method: 'DELETE', + }), + detailParams(COMPANY_ID, SUPPLIER_ID), + ) + + expect(res.status).toBe(409) + const body = await res.json() + expect(body.error.code).toBe('SUPPLIER_HAS_INVOICES') + expect(body.error.details.open_invoice_count).toBe(3) + }) +}) + +describe('POST /api/v1/companies/:companyId/suppliers/bulk-create', () => { + it('partial-success: returns per-item ok/error rows', async () => { + // First insert succeeds, second hits a 23505. The makeFlexibleSupabase + // proxy returns the same response for every `from('suppliers')` await, so + // for this case we wire a tiny custom client that flips on call count. + let calls = 0 + mockServiceClient.mockReturnValue({ + from: (table: string) => { + if (table !== 'suppliers') { + return new Proxy({}, { + get(_t, prop) { + if (prop === 'then') return (r: (v: unknown) => void) => r({ data: table === 'company_members' ? { company_id: COMPANY_ID, role: 'owner' } : null, error: null }) + return () => new Proxy({}, this!) + }, + }) + } + const responses = [ + { data: { ...SAMPLE_SUPPLIER, id: 'first-id' }, error: null }, + { data: null, error: { code: '23505', message: 'duplicate' } }, + ] + return new Proxy({}, { + get(_t, prop) { + if (prop === 'then') { + const i = calls++ + return (r: (v: unknown) => void) => r(responses[Math.min(i, responses.length - 1)]) + } + return () => new Proxy({}, this!) + }, + }) + }, + }) + + const res = await bulkCreateSuppliers( + makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/suppliers/bulk-create`, { + method: 'POST', + body: JSON.stringify({ + suppliers: [ + { name: 'A', supplier_type: 'swedish_business' }, + { name: 'B', supplier_type: 'swedish_business', org_number: 'TEST-DUP' }, + ], + }), + }), + companyParams(COMPANY_ID), + ) + + expect(res.status).toBe(200) + const body = await res.json() + expect(body.data.summary).toEqual({ total: 2, succeeded: 1, failed: 1 }) + expect(body.data.results[1].error.code).toBe('SUPPLIER_DUPLICATE_ORG_NUMBER') + }) + + it('rejects all_or_nothing: true with 501 NOT_IMPLEMENTED', async () => { + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + }), + ) + + const res = await bulkCreateSuppliers( + makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/suppliers/bulk-create`, { + method: 'POST', + body: JSON.stringify({ + suppliers: [{ name: 'A', supplier_type: 'swedish_business' }], + all_or_nothing: true, + }), + }), + companyParams(COMPANY_ID), + ) + + expect(res.status).toBe(501) + const body = await res.json() + expect(body.error.code).toBe('NOT_IMPLEMENTED') + }) +}) diff --git a/app/api/v1/companies/[companyId]/suppliers/bulk-create/route.ts b/app/api/v1/companies/[companyId]/suppliers/bulk-create/route.ts new file mode 100644 index 00000000..7dde060a --- /dev/null +++ b/app/api/v1/companies/[companyId]/suppliers/bulk-create/route.ts @@ -0,0 +1,312 @@ +/** + * POST /api/v1/companies/{companyId}/suppliers/bulk-create + * + * Bulk-create up to 50 suppliers in one call. Each item is validated and + * inserted independently — per-item failures don't roll back successes. + * Mirrors the shape of /customers/bulk-create exactly so agents only need + * to learn one bulk pattern. + * + * Response: `{ results: [{ ok, request_index, data?, error? }], summary }`. + * Idempotent over the whole batch. Dry-runnable. + * + * Unlike customers, suppliers do not run VIES validation on create — the + * vat_number is stored as supplied without an external check. + */ + +import { z } from 'zod' +import type { SupabaseClient } from '@supabase/supabase-js' +import { ok } from '@/lib/api/v1/response' +import { dryRunPreview } from '@/lib/api/v1/dry-run' +import { registerEndpoint } from '@/lib/api/v1/registry' +import { withApiV1 } from '@/lib/api/v1/with-api-v1' +import { v1ErrorResponseFromCode } from '@/lib/api/v1/errors' +import { CreateSupplierSchema } from '@/lib/api/schemas' +import { eventBus } from '@/lib/events' +import type { Logger } from '@/lib/logger' +import type { Supplier } from '@/types' + +const BulkCreateRequest = z.object({ + suppliers: z.array(CreateSupplierSchema).min(1).max(50), + all_or_nothing: z.boolean().optional().default(false), +}) + +const BulkResultItem = z.object({ + ok: z.boolean(), + request_index: z.number().int().nonnegative(), + data: z.unknown().optional(), + error: z + .object({ + code: z.string(), + message: z.string(), + details: z.unknown().optional(), + }) + .optional(), +}) + +const BulkCreateResponse = z.object({ + results: z.array(BulkResultItem), + summary: z.object({ + total: z.number().int(), + succeeded: z.number().int(), + failed: z.number().int(), + }), +}) + +const SUPPLIER_RESPONSE_COLUMNS = + 'id, name, supplier_type, email, phone, address_line1, address_line2, postal_code, city, country, org_number, vat_number, bankgiro, plusgiro, bank_account, iban, bic, default_expense_account, default_payment_terms, default_currency, notes, archived_at, created_at, updated_at' + +registerEndpoint({ + operation: 'suppliers.bulk-create', + method: 'POST', + path: '/api/v1/companies/:companyId/suppliers/bulk-create', + summary: 'Create up to 50 suppliers in one call (partial-success).', + description: + 'Bulk-create endpoint mirroring /customers/bulk-create. Each supplier is validated and inserted independently — per-item failures do not roll back items that succeeded. Returns a results array plus a summary. Idempotent over the whole batch. Dry-runnable.', + useWhen: + 'You\'re importing a roster of suppliers from another AP system, or seeding a fresh company with its existing vendor list. Use dry-run first to validate the batch.', + doNotUseFor: + 'Updating existing suppliers — PATCH /suppliers/{id} once per supplier. Bulk uploads of > 50 suppliers — split into pages of 50. Transactional all-or-nothing imports — passing all_or_nothing: true returns 501 NOT_IMPLEMENTED.', + pitfalls: [ + 'Idempotency-Key is mandatory and covers the WHOLE batch. A retried bulk-create returns the cached full response — it does not retry only the failed items.', + 'Passing all_or_nothing: true returns 501 NOT_IMPLEMENTED. Today only partial-success batches exist; omit the flag or pass false.', + 'org_number uniqueness is enforced at the DB level — items with duplicates fail individually with SUPPLIER_DUPLICATE_ORG_NUMBER.', + 'No VIES validation runs per item; vat_number is stored as supplied. Validate externally if your workflow requires it.', + ], + example: { + request: { + suppliers: [ + { name: 'Office Depot AB', supplier_type: 'swedish_business', org_number: '556677-8899' }, + { name: 'Cloud Hosting GmbH', supplier_type: 'eu_business', vat_number: 'DE123456789' }, + ], + }, + response: { + data: { + results: [ + { ok: true, request_index: 0, data: { id: '0e9c…', name: 'Office Depot AB' } }, + { ok: true, request_index: 1, data: { id: '4d2a…', name: 'Cloud Hosting GmbH' } }, + ], + summary: { total: 2, succeeded: 2, failed: 0 }, + }, + meta: { request_id: 'req_…', api_version: '2026-05-12' }, + }, + }, + scope: 'suppliers:write', + risk: 'low', + idempotent: true, + reversible: true, + dryRunSupported: true, + request: { body: BulkCreateRequest }, + response: { success: BulkCreateResponse }, +}) + +interface ResultItem { + ok: boolean + request_index: number + data?: unknown + error?: { code: string; message: string; details?: unknown } +} + +async function createOneSupplier( + supabase: SupabaseClient, + companyId: string, + userId: string, + index: number, + input: z.infer, + dryRun: boolean, + log: Logger, +): Promise { + if (dryRun) { + return { + ok: true, + request_index: index, + data: { + preview: { + id: null, + name: input.name, + supplier_type: input.supplier_type, + email: input.email ?? null, + phone: input.phone ?? null, + address_line1: input.address_line1 ?? null, + address_line2: input.address_line2 ?? null, + postal_code: input.postal_code ?? null, + city: input.city ?? null, + country: input.country ?? 'SE', + org_number: input.org_number ?? null, + vat_number: input.vat_number ?? null, + bankgiro: input.bankgiro ?? null, + plusgiro: input.plusgiro ?? null, + bank_account: input.bank_account ?? null, + iban: input.iban ?? null, + bic: input.bic ?? null, + default_expense_account: input.default_expense_account ?? null, + default_payment_terms: input.default_payment_terms ?? 30, + default_currency: input.default_currency ?? 'SEK', + notes: input.notes ?? null, + archived_at: null, + created_at: null, + updated_at: null, + }, + }, + } + } + + const { data, error } = await supabase + .from('suppliers') + .insert({ + user_id: userId, + company_id: companyId, + name: input.name, + supplier_type: input.supplier_type, + email: input.email ?? null, + phone: input.phone ?? null, + address_line1: input.address_line1 ?? null, + address_line2: input.address_line2 ?? null, + postal_code: input.postal_code ?? null, + city: input.city ?? null, + country: input.country ?? 'SE', + org_number: input.org_number ?? null, + vat_number: input.vat_number ?? null, + bankgiro: input.bankgiro ?? null, + plusgiro: input.plusgiro ?? null, + bank_account: input.bank_account ?? null, + iban: input.iban ?? null, + bic: input.bic ?? null, + default_expense_account: input.default_expense_account ?? null, + default_payment_terms: input.default_payment_terms ?? 30, + default_currency: input.default_currency ?? 'SEK', + notes: input.notes ?? null, + }) + .select(SUPPLIER_RESPONSE_COLUMNS) + .single() + + if (error) { + if (error.code === '23505') { + return { + ok: false, + request_index: index, + error: { + code: 'SUPPLIER_DUPLICATE_ORG_NUMBER', + message: 'A supplier with this org_number already exists in this company.', + details: { field: 'org_number' }, + }, + } + } + log.error('bulk-create: supplier insert failed', error, { + request_index: index, + companyId, + pgCode: error.code, + }) + return { + ok: false, + request_index: index, + error: { + code: 'SUPPLIER_CREATE_FAILED', + message: 'Supplier insert failed.', + details: { pg_code: error.code }, + }, + } + } + + try { + await eventBus.emit({ + type: 'supplier.created', + payload: { + supplier: { + ...(data as Record), + user_id: userId, + company_id: companyId, + } as unknown as Supplier, + companyId, + userId, + }, + }) + } catch (err) { + log.warn('bulk-create: supplier.created emit failed', err as Error, { + request_index: index, + }) + } + + return { ok: true, request_index: index, data } +} + +export const POST = withApiV1<{ params: Promise<{ companyId: string }> }>( + 'suppliers.bulk-create', + async (request, ctx) => { + if (!z.string().uuid().safeParse(ctx.companyId).success) { + return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, { + requestId: ctx.requestId, + details: { field: 'companyId', message: 'companyId must be a UUID.' }, + }) + } + + let rawBody: unknown + try { + rawBody = await request.json() + } catch { + return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, { + requestId: ctx.requestId, + details: { field: 'body', message: 'Body is not valid JSON.' }, + }) + } + + const parsed = BulkCreateRequest.safeParse(rawBody) + if (!parsed.success) { + return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, { + requestId: ctx.requestId, + details: { + issues: parsed.error.issues.map((i) => ({ + field: i.path.join('.'), + message: i.message, + })), + }, + }) + } + const body = parsed.data + + if (body.all_or_nothing) { + return v1ErrorResponseFromCode('NOT_IMPLEMENTED', ctx.log, { + requestId: ctx.requestId, + details: { + field: 'all_or_nothing', + message: + 'all_or_nothing: true is not yet implemented. Omit the flag (or pass false) to use partial-success semantics.', + }, + }) + } + + // Sequential processing matches /customers/bulk-create and /invoices/bulk-create. + // The 50-item cap bounds worst-case latency. + const results: ResultItem[] = [] + for (let i = 0; i < body.suppliers.length; i++) { + const item = await createOneSupplier( + ctx.supabase, + ctx.companyId!, + ctx.userId, + i, + body.suppliers[i], + ctx.dryRun, + ctx.log, + ) + results.push(item) + } + + const summary = { + total: results.length, + succeeded: results.filter((r) => r.ok).length, + failed: results.filter((r) => !r.ok).length, + } + + ctx.log.info('suppliers.bulk-create completed', { + companyId: ctx.companyId, + userId: ctx.userId, + ...summary, + dryRun: ctx.dryRun, + }) + + if (ctx.dryRun) { + return dryRunPreview({ results, summary }, { requestId: ctx.requestId, log: ctx.log }) + } + return ok({ results, summary }, { requestId: ctx.requestId }) + }, + { requireIdempotencyKey: true }, +) diff --git a/app/api/v1/companies/[companyId]/suppliers/route.ts b/app/api/v1/companies/[companyId]/suppliers/route.ts new file mode 100644 index 00000000..30f361d5 --- /dev/null +++ b/app/api/v1/companies/[companyId]/suppliers/route.ts @@ -0,0 +1,432 @@ +/** + * /api/v1/companies/{companyId}/suppliers — list + create supplier endpoints. + * + * GET — list with filters (supplier_type, search, include_archived). + * Cursor pagination on (created_at ASC, id ASC). + * POST — create. Idempotent (mandatory Idempotency-Key). Dry-runnable + * (?dry_run=true returns the validated would-be record without + * committing). + * + * VIES validation note: unlike customers, suppliers do not carry a + * `vat_number_validated` flag in the schema today. The vat_number is + * accepted as input but not auto-verified against VIES — a deliberate + * scope decision documented in the endpoint pitfalls. + */ + +import { z } from 'zod' +import { created, paginated } from '@/lib/api/v1/response' +import { dryRunPreview } from '@/lib/api/v1/dry-run' +import { + decodeDefaultCursor, + encodeDefaultCursor, + parsePaginationParams, +} from '@/lib/api/v1/pagination' +import { registerEndpoint } from '@/lib/api/v1/registry' +import { withApiV1 } from '@/lib/api/v1/with-api-v1' +import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors' +import { CreateSupplierSchema } from '@/lib/api/schemas' +import { eventBus } from '@/lib/events' +import type { Supplier } from '@/types' + +const SupplierType = z.enum([ + 'swedish_business', + 'eu_business', + 'non_eu_business', +]) + +const SupplierSummary = z.object({ + id: z.string().uuid(), + name: z.string(), + supplier_type: SupplierType, + email: z.string().nullable(), + org_number: z.string().nullable(), + vat_number: z.string().nullable(), + default_payment_terms: z.number(), + default_currency: z.string(), + archived_at: z.string().nullable(), + created_at: z.string(), +}) + +const SuppliersListResponse = z.object({ + suppliers: z.array(SupplierSummary), +}) + +// Explicit projection — never SELECT *. Schema migrations adding columns +// must update this list before the field becomes visible on the public API. +const SUPPLIER_SUMMARY_COLUMNS = + 'id, name, supplier_type, email, org_number, vat_number, default_payment_terms, default_currency, archived_at, created_at' + +registerEndpoint({ + operation: 'suppliers.list', + method: 'GET', + path: '/api/v1/companies/:companyId/suppliers', + summary: 'List suppliers for a company.', + description: + 'Returns active suppliers in created-first order. Pass ?include_archived=true to include archived rows. Use ?search to match against name or org_number.', + useWhen: + 'You need a supplier roster — for building a UI picker, resolving a supplier_id before registering a supplier invoice, or syncing an external AP system.', + doNotUseFor: + 'Fetching a single supplier you already know the id of — use GET /api/v1/companies/{companyId}/suppliers/{id}. Customers are a separate resource.', + pitfalls: [ + 'Archived suppliers are hidden by default; the dashboard makes the same choice.', + 'org_number identifies legal entities only — suppliers currently have no `individual` type, so the field is Bolagsverket public-record data when present.', + 'vat_number is stored as supplied; unlike customers, suppliers are not auto-validated against VIES on create. Validate externally if the integration requires it.', + ], + example: { + response: { + data: [ + { + id: 'a8f1…', + name: 'Office Depot AB', + supplier_type: 'swedish_business', + email: 'invoices@officedepot.example', + org_number: '556677-8899', + vat_number: 'SE556677889901', + default_payment_terms: 30, + default_currency: 'SEK', + archived_at: null, + created_at: '2026-04-12T08:30:00Z', + }, + ], + meta: { request_id: 'req_…', api_version: '2026-05-12', next_cursor: null }, + }, + }, + scope: 'suppliers:read', + risk: 'low', + idempotent: true, + reversible: false, + dryRunSupported: false, + response: { success: SuppliersListResponse }, +}) + +export const GET = withApiV1<{ params: Promise<{ companyId: string }> }>( + 'suppliers.list', + async (request, ctx) => { + const url = new URL(request.url) + const { limit, cursor } = parsePaginationParams(url) + const decoded = decodeDefaultCursor(cursor) + + const FiltersSchema = z.object({ + supplier_type: SupplierType.optional(), + search: z.string().min(1).max(200).optional(), + include_archived: z.enum(['true', 'false']).optional(), + }) + const filtersResult = FiltersSchema.safeParse({ + supplier_type: url.searchParams.get('supplier_type') ?? undefined, + search: url.searchParams.get('search') ?? undefined, + include_archived: url.searchParams.get('include_archived') ?? undefined, + }) + if (!filtersResult.success) { + return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, { + requestId: ctx.requestId, + details: { + issues: filtersResult.error.issues.map((i) => ({ + field: i.path.join('.'), + message: i.message, + })), + }, + }) + } + const filters = filtersResult.data + const includeArchived = filters.include_archived === 'true' + + let query = ctx.supabase + .from('suppliers') + .select(SUPPLIER_SUMMARY_COLUMNS) + .eq('company_id', ctx.companyId!) + .order('created_at', { ascending: true }) + .order('id', { ascending: true }) + .limit(limit + 1) + + if (!includeArchived) { + query = query.is('archived_at', null) + } + if (filters.supplier_type) { + query = query.eq('supplier_type', filters.supplier_type) + } + if (filters.search) { + // Two layers of escaping (matches the customers list): + // 1. PostgREST `.or()` filter syntax uses commas + parens as + // delimiters; strip them from the user-supplied term. + // 2. SQL LIKE treats `%` and `_` (and `\` as the default escape) as + // wildcards; escape them so '100%' matches the literal string. + const term = filters.search + .replace(/[,()]/g, '') + .replace(/[%_\\]/g, '\\$&') + query = query.or(`name.ilike.%${term}%,org_number.ilike.${term}%`) + } + + if (decoded) { + query = query.or( + `created_at.gt.${decoded.ts},and(created_at.eq.${decoded.ts},id.gt.${decoded.id})`, + ) + } + + const { data, error } = await query + + if (error) { + return v1ErrorResponse(error, ctx.log, { requestId: ctx.requestId }) + } + + type Row = { + id: string + name: string + supplier_type: string + email: string | null + org_number: string | null + vat_number: string | null + default_payment_terms: number + default_currency: string + archived_at: string | null + created_at: string + } & Record + + const rows = ((data ?? []) as unknown) as Row[] + const trimmed = rows.slice(0, limit) + const hasMore = rows.length > limit + + // GDPR Art.5(1)(c) defense-in-depth: SupplierType has no `individual` + // variant today (only swedish_business / eu_business / non_eu_business), + // so the org_number is Bolagsverket public-record data. Were a future + // schema iteration introduce a natural-person supplier type, the list + // endpoint should mask org_number/vat_number the same way the customer + // list does for `individual`. Leaving the hook (an empty INDIVIDUAL_TYPES + // set) makes that change a one-line edit and signals the design intent + // to anyone copying the file. + const INDIVIDUAL_TYPES = new Set([]) + + const suppliers = trimmed.map((r) => { + const isIndividual = INDIVIDUAL_TYPES.has(r.supplier_type) + return { + id: r.id, + name: r.name, + supplier_type: r.supplier_type, + email: r.email, + org_number: isIndividual ? null : r.org_number, + vat_number: isIndividual ? null : r.vat_number, + default_payment_terms: r.default_payment_terms, + default_currency: r.default_currency, + archived_at: r.archived_at, + created_at: r.created_at, + } + }) + + const last = trimmed[trimmed.length - 1] + const nextCursor = hasMore && last + ? encodeDefaultCursor({ id: last.id, created_at: last.created_at }) + : null + + return paginated(suppliers, { + requestId: ctx.requestId, + nextCursor: nextCursor ?? undefined, + }) + }, +) + +// ────────────────────────────────────────────────────────────────── +// POST — create supplier +// ────────────────────────────────────────────────────────────────── + +const SupplierCreated = z.object({ + id: z.string().uuid().nullable(), + name: z.string(), + supplier_type: SupplierType, + email: z.string().nullable(), + phone: z.string().nullable(), + address_line1: z.string().nullable(), + address_line2: z.string().nullable(), + postal_code: z.string().nullable(), + city: z.string().nullable(), + country: z.string(), + org_number: z.string().nullable(), + vat_number: z.string().nullable(), + bankgiro: z.string().nullable(), + plusgiro: z.string().nullable(), + bank_account: z.string().nullable(), + iban: z.string().nullable(), + bic: z.string().nullable(), + default_expense_account: z.string().nullable(), + default_payment_terms: z.number(), + default_currency: z.string(), + notes: z.string().nullable(), + archived_at: z.string().nullable(), + created_at: z.string().nullable(), + updated_at: z.string().nullable(), +}) + +const SUPPLIER_RESPONSE_COLUMNS = + 'id, name, supplier_type, email, phone, address_line1, address_line2, postal_code, city, country, org_number, vat_number, bankgiro, plusgiro, bank_account, iban, bic, default_expense_account, default_payment_terms, default_currency, notes, archived_at, created_at, updated_at' + +registerEndpoint({ + operation: 'suppliers.create', + method: 'POST', + path: '/api/v1/companies/:companyId/suppliers', + summary: 'Create a supplier.', + description: + 'Creates a new supplier for the company. Requires Idempotency-Key (UUID). Supports ?dry_run=true for input validation without committing — the dry-run response shows the would-be record minus id and timestamps.', + useWhen: + 'You need to register a new supplier before booking supplier invoices against them. Use dry-run first to catch validation errors before committing.', + doNotUseFor: + 'Updating an existing supplier (PATCH instead). Creating customers (different resource).', + pitfalls: [ + 'Idempotency-Key is mandatory — calls without it return 400 VALIDATION_ERROR.', + 'org_number uniqueness is enforced at the database level; duplicate inserts return 409 SUPPLIER_DUPLICATE_ORG_NUMBER.', + 'Unlike customers, suppliers carry no `vat_number_validated` flag — vat_number is stored as supplied without VIES verification. Validate externally if your workflow requires it.', + 'default_expense_account is a BAS account number (e.g. "5410"); the value is stored as-is and used as the suggested debit account when supplier invoices are booked.', + ], + example: { + request: { + name: 'Office Depot AB', + supplier_type: 'swedish_business', + email: 'invoices@officedepot.example', + org_number: '556677-8899', + bankgiro: '123-4567', + default_expense_account: '5410', + default_payment_terms: 30, + default_currency: 'SEK', + }, + response: { + data: { + id: '0e9c…', + name: 'Office Depot AB', + supplier_type: 'swedish_business', + email: 'invoices@officedepot.example', + org_number: '556677-8899', + bankgiro: '123-4567', + default_expense_account: '5410', + default_payment_terms: 30, + default_currency: 'SEK', + archived_at: null, + created_at: '2026-05-13T15:00:00Z', + updated_at: '2026-05-13T15:00:00Z', + }, + meta: { request_id: 'req_…', api_version: '2026-05-12' }, + }, + }, + scope: 'suppliers:write', + risk: 'low', + idempotent: true, + reversible: true, + dryRunSupported: true, + request: { body: CreateSupplierSchema }, + response: { success: SupplierCreated }, +}) + +export const POST = withApiV1<{ params: Promise<{ companyId: string }> }>( + 'suppliers.create', + async (request, ctx) => { + let rawBody: unknown + try { + rawBody = await request.json() + } catch { + return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, { + requestId: ctx.requestId, + details: { field: 'body', message: 'Body is not valid JSON.' }, + }) + } + + const parsed = CreateSupplierSchema.safeParse(rawBody) + if (!parsed.success) { + return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, { + requestId: ctx.requestId, + details: { + issues: parsed.error.issues.map((i) => ({ + field: i.path.join('.'), + message: i.message, + })), + }, + }) + } + const body = parsed.data + + if (ctx.dryRun) { + return dryRunPreview( + { + id: null, + name: body.name, + supplier_type: body.supplier_type, + email: body.email ?? null, + phone: body.phone ?? null, + address_line1: body.address_line1 ?? null, + address_line2: body.address_line2 ?? null, + postal_code: body.postal_code ?? null, + city: body.city ?? null, + country: body.country ?? 'SE', + org_number: body.org_number ?? null, + vat_number: body.vat_number ?? null, + bankgiro: body.bankgiro ?? null, + plusgiro: body.plusgiro ?? null, + bank_account: body.bank_account ?? null, + iban: body.iban ?? null, + bic: body.bic ?? null, + default_expense_account: body.default_expense_account ?? null, + default_payment_terms: body.default_payment_terms ?? 30, + default_currency: body.default_currency ?? 'SEK', + notes: body.notes ?? null, + archived_at: null, + created_at: null, + updated_at: null, + }, + { requestId: ctx.requestId, log: ctx.log }, + ) + } + + const { data, error } = await ctx.supabase + .from('suppliers') + .insert({ + user_id: ctx.userId, + company_id: ctx.companyId!, + name: body.name, + supplier_type: body.supplier_type, + email: body.email ?? null, + phone: body.phone ?? null, + address_line1: body.address_line1 ?? null, + address_line2: body.address_line2 ?? null, + postal_code: body.postal_code ?? null, + city: body.city ?? null, + country: body.country ?? 'SE', + org_number: body.org_number ?? null, + vat_number: body.vat_number ?? null, + bankgiro: body.bankgiro ?? null, + plusgiro: body.plusgiro ?? null, + bank_account: body.bank_account ?? null, + iban: body.iban ?? null, + bic: body.bic ?? null, + default_expense_account: body.default_expense_account ?? null, + default_payment_terms: body.default_payment_terms ?? 30, + default_currency: body.default_currency ?? 'SEK', + notes: body.notes ?? null, + }) + .select(SUPPLIER_RESPONSE_COLUMNS) + .single() + + if (error) { + if (error.code === '23505') { + // Symmetric with customers: do NOT echo body.org_number — guards + // against accidentally leaking a natural-person identifier in the + // future if SupplierType ever gains an `individual` variant. + return v1ErrorResponseFromCode('SUPPLIER_DUPLICATE_ORG_NUMBER', ctx.log, { + requestId: ctx.requestId, + details: { field: 'org_number' }, + }) + } + return v1ErrorResponse(error, ctx.log, { requestId: ctx.requestId }) + } + + try { + await eventBus.emit({ + type: 'supplier.created', + payload: { + supplier: { ...(data as Record), user_id: ctx.userId, company_id: ctx.companyId! } as unknown as Supplier, + companyId: ctx.companyId!, + userId: ctx.userId, + }, + }) + } catch (err) { + ctx.log.warn('supplier.created emit failed', err as Error) + } + + return created(data, { requestId: ctx.requestId }) + }, + { requireIdempotencyKey: true }, +) diff --git a/lib/api/v1/load-routes.ts b/lib/api/v1/load-routes.ts index 832c3078..a58ca0a9 100644 --- a/lib/api/v1/load-routes.ts +++ b/lib/api/v1/load-routes.ts @@ -44,4 +44,14 @@ import '@/app/api/v1/companies/[companyId]/transactions/batch-categorize/route' import '@/app/api/v1/companies/[companyId]/reconciliation/bank/run/route' import '@/app/api/v1/companies/[companyId]/reconciliation/bank/status/route' +// Phase 4 PR-1 — AP world: suppliers + supplier-invoices verticals. +import '@/app/api/v1/companies/[companyId]/suppliers/route' +import '@/app/api/v1/companies/[companyId]/suppliers/[id]/route' +import '@/app/api/v1/companies/[companyId]/suppliers/bulk-create/route' +import '@/app/api/v1/companies/[companyId]/supplier-invoices/route' +import '@/app/api/v1/companies/[companyId]/supplier-invoices/[id]/route' +import '@/app/api/v1/companies/[companyId]/supplier-invoices/[id]/approve/route' +import '@/app/api/v1/companies/[companyId]/supplier-invoices/[id]/mark-paid/route' +import '@/app/api/v1/companies/[companyId]/supplier-invoices/[id]/credit/route' + export {} diff --git a/lib/auth/scopes.ts b/lib/auth/scopes.ts index ac0ebfc7..35c5030d 100644 --- a/lib/auth/scopes.ts +++ b/lib/auth/scopes.ts @@ -69,6 +69,24 @@ export const V1_ENDPOINT_SCOPES: Record = { 'GET /api/v1/companies/:companyId/invoices/:id/pdf': 'invoices:read', 'POST /api/v1/companies/:companyId/customers/bulk-create': 'customers:write', + // Phase 4 PR-1 — Suppliers + Supplier-invoices verticals (AP world). + // Suppliers + 'GET /api/v1/companies/:companyId/suppliers': 'suppliers:read', + 'GET /api/v1/companies/:companyId/suppliers/:id': 'suppliers:read', + 'POST /api/v1/companies/:companyId/suppliers': 'suppliers:write', + 'PATCH /api/v1/companies/:companyId/suppliers/:id': 'suppliers:write', + 'DELETE /api/v1/companies/:companyId/suppliers/:id': 'suppliers:write', + 'POST /api/v1/companies/:companyId/suppliers/bulk-create': 'suppliers:write', + // Supplier invoices + 'GET /api/v1/companies/:companyId/supplier-invoices': 'suppliers:read', + 'GET /api/v1/companies/:companyId/supplier-invoices/:id': 'suppliers:read', + 'POST /api/v1/companies/:companyId/supplier-invoices': 'suppliers:write', + 'PATCH /api/v1/companies/:companyId/supplier-invoices/:id': 'suppliers:write', + // Note: no DELETE — supplier-invoice withdrawal is via :credit (mirrors v1 invoices). + 'POST /api/v1/companies/:companyId/supplier-invoices/:id/approve': 'suppliers:write', + 'POST /api/v1/companies/:companyId/supplier-invoices/:id/mark-paid': 'suppliers:write', + 'POST /api/v1/companies/:companyId/supplier-invoices/:id/credit': 'suppliers:write', + // Phase 3 — transactions + reconciliation vertical. // Reads 'GET /api/v1/companies/:companyId/transactions': 'transactions:read', diff --git a/lib/errors/structured-errors.ts b/lib/errors/structured-errors.ts index 16c1108b..9e8be4b5 100644 --- a/lib/errors/structured-errors.ts +++ b/lib/errors/structured-errors.ts @@ -1127,6 +1127,28 @@ const SUPPLIER: Record = { message_sv: 'Leverantören kunde inte tas bort.', message_en: 'Failed to delete supplier.', }, + // v1 archive refusal — leverantörsfakturor pointing at this supplier still + // need its name/address for BFL 7 kap audit. Issue credit notes first. + SUPPLIER_HAS_INVOICES: { + httpStatus: 409, + message_sv: + 'Leverantören kan inte arkiveras eftersom det finns öppna leverantörsfakturor som refererar till den.', + message_en: + 'Supplier cannot be archived while open supplier invoices reference it.', + remediation: { + description: + 'Close (credit / mark paid) every open supplier invoice before archiving the supplier. The dashboard exposes the same blocker.', + }, + }, + // v1 strict-mode: update / delete only allowed on `registered` SIs (the + // SI analogue of `draft`). Mirrors the dashboard internal route. + SI_NOT_DRAFT: { + httpStatus: 400, + message_sv: + 'Leverantörsfakturan är inte längre i status "registrerad" och kan därför inte uppdateras eller tas bort.', + message_en: + 'Supplier invoice is not in `registered` status and cannot be updated or deleted.', + }, } const SUPPLIER_INVOICE_WAVE4: Record = { diff --git a/supabase/migrations/20260513150000_archived_at_for_customers_and_suppliers.sql b/supabase/migrations/20260513150000_archived_at_for_customers_and_suppliers.sql new file mode 100644 index 00000000..e3f89dfc --- /dev/null +++ b/supabase/migrations/20260513150000_archived_at_for_customers_and_suppliers.sql @@ -0,0 +1,40 @@ +-- Migration: archived_at + vat_number_validated_at for customers + archived_at for suppliers +-- +-- Phase 4 PR-1 (AP world) introduces a soft-archive flow for suppliers +-- analogous to the customers vertical from Phase 2. Both v1 routes treat +-- `archived_at IS NULL` as the canonical "active" filter. +-- +-- This migration also retroactively adds the two columns that the Phase 2 +-- v1 customer routes (PR #451 / #452 / #460) already reference but that no +-- prior migration installed in production: +-- - customers.archived_at (soft-archive timestamp) +-- - customers.vat_number_validated_at (last successful VIES check) +-- +-- `suppliers.is_active` (legacy boolean from migration 025) is preserved; +-- the v1 layer treats it as a back-compat companion. Archive sets +-- archived_at = now() AND is_active = false; un-archive flips both back. +-- +-- BFL 7 kap 2 § (7-year retention) and ML 17 kap 24 § (invoice metadata +-- preservation) both want the archived row to remain queryable; this is a +-- soft-delete, never a hard one. Trigger-level retention protection on +-- documents and journal entries is unchanged. + +ALTER TABLE public.customers + ADD COLUMN IF NOT EXISTS archived_at timestamptz, + ADD COLUMN IF NOT EXISTS vat_number_validated_at timestamptz; + +ALTER TABLE public.suppliers + ADD COLUMN IF NOT EXISTS archived_at timestamptz; + +-- Partial indexes on archived_at NULL — the list endpoint's default filter. +-- A partial index is roughly half the size of a full one and covers the +-- common case (active rows only). +CREATE INDEX IF NOT EXISTS idx_customers_company_active + ON public.customers (company_id, created_at) + WHERE archived_at IS NULL; + +CREATE INDEX IF NOT EXISTS idx_suppliers_company_active + ON public.suppliers (company_id, created_at) + WHERE archived_at IS NULL; + +NOTIFY pgrst, 'reload schema';