diff --git a/.claude/skills/supportmail-to-ticket/SKILL.md b/.claude/skills/supportmail-to-ticket/SKILL.md index ff17fcdd..cdd034b4 100644 --- a/.claude/skills/supportmail-to-ticket/SKILL.md +++ b/.claude/skills/supportmail-to-ticket/SKILL.md @@ -43,12 +43,11 @@ For each returned thread, call `get_thread` with `messageFormat: FULL_CONTENT` t - `threadId` - `subject` -- Sender of the first message (the original customer — note that `invoiceservice@arcim.io` is a relay; the actual customer address is in the body as `Från: ` for Swedish or directly visible in the body text) - Date of the first message - Full body text of the first message (this is the customer's actual complaint) - Any reply messages (if the team has already responded, mention that but still propose a ticket unless the reply clearly resolves it) -**Customer email extraction**: The Gnubok support relay wraps the original mail. The body typically starts with `Från: ` (Swedish) — parse this line for the real customer email. Fall back to the thread's first-message sender if parsing fails. +**Do not extract, store, or display the customer's email address anywhere.** Tickets are fully anonymized — the sender address must never appear in chat output, issue bodies, or issue comments. Use the Gmail thread ID alone as the correlation identifier. ### Phase 2 — Codebase analysis (local only) @@ -102,19 +101,31 @@ Next steps: - (2–4 steps total, specific not vague) Customer email (anonymized): - + -Customer: Gmail thread ID: ``` **Anonymization rules — applied to the email body before it goes into the ticket**: +The goal: strip anything that identifies the **specific customer or their employer**. Keep everything else — competitor tools, banks, authorities, domain terms — because that's operational context the developer needs. + - **Replace personal first and last names with `x`**. Example: *"Hey this is amazing. My name is Emil and I do bla bla"* → *"Hey this is amazing. My name is x and I do bla bla"*. Handles greetings (*"Hej Anna,"* → *"Hej x,"*) and signatures (*"/Lars Andersson"* → *"/x"*). -- **Keep company names, product names, domain terms, error messages, account numbers, SIE references, dates, and amounts.** These are operational details developers need. Only personal names get redacted. -- **Keep the customer's email address in the `Customer:` metadata line** (outside the anonymized body). The team needs it to reply; developers generally don't read metadata to learn names. -- **Keep the Gmail thread ID** as a plain identifier (no URL). It's used for duplicate detection across runs. -- If unsure whether something is a personal name, redact it — false positives are harmless, leaked names aren't. +- **Replace the customer's employer / own company name with `x`**, wherever it appears — body text, signatures, "jag jobbar på …", "vi på …", org numbers attributed to the sender, `@company.com` email domains. Also redact names of their direct clients or other companies they identify themselves through. Example: *"Jag jobbar på Capnos och behöver ta bort Capnos"* → *"Jag jobbar på x och behöver ta bort x"*. +- **Do NOT redact** (these are not identifying — they're context): + - **gnubok itself** + - **Swedish authorities / standard bodies**: Skatteverket, Bolagsverket, Försäkringskassan, Bankgirot, BFN + - **Accounting / ERP providers and competitors**: Fortnox, Visma, Bokio, SpeedLedger, BL/Björn Lundén, Briox, etc. + - **Banks by name**: Swedbank, SEB, Handelsbanken, Nordea, etc. (unless clearly the customer's *own* company — rare) + - **File formats / protocols / standards**: SIE, K2, K3, BAS, PSD2, Peppol, BFNAR + - **Generic domain terms**: moms, verifikat, räkenskapsår, etc. +- **Replace every email address with `x@x`**, including the sender's address, any `Från: ` or `From: ` header line inside the body, CC/BCC lines, and addresses mentioned in the body text. Do this before any other processing of the body. +- **Replace phone numbers with `x`** (Swedish and international formats). +- **Replace any internal identifier that ties the ticket to a specific account**: user UUIDs (e.g. `User ID: d36ff376-...`), session IDs, customer IDs. Replace the value with `x` but keep the label so the field structure is still readable. +- **Never include Gmail URLs** (`https://mail.google.com/mail/u/.../#inbox/`) anywhere in the issue body. The Gmail thread ID alone, as a plain identifier, is the only correlation allowed — it shows up once at the bottom as `**Gmail thread ID:** \`\``. No `Original support thread: ` line. +- **Never output the customer's email address anywhere** — not in the chat preview, not in the GitHub issue body, not in issue comments, not in the summary. +- **Keep** product names we build around, domain terms, error messages, account numbers, SIE references, dates, and amounts. +- If unsure whether a capitalized word identifies the *customer or their employer* specifically, redact it. If it's clearly a third-party tool, bank, or authority, keep it. False positives on names/employers are harmless; leaked identifiers aren't. **Priority label convention**: use `priority:high`, `priority:medium`, `priority:low`. If the repo already has `P0`/`P1`/`P2` labels (check in phase 4), prefer those instead. @@ -180,10 +191,9 @@ cat > /tmp/issue-body-.md <<'EOF' ## Customer email (anonymized) -> +> --- -**Customer:** **Gmail thread ID:** `` EOF @@ -202,7 +212,7 @@ The command prints the new issue's URL on success — capture it for the summary ```bash gh issue comment \ --repo erp-mafia/gnubok \ - --body "Another customer report of this issue. Customer: \`\`. Gmail thread ID: \`\`." + --body "Another customer report of this issue. Gmail thread ID: \`\`." ``` **Label notes**: diff --git a/app/(dashboard)/supplier-invoices/[id]/page.tsx b/app/(dashboard)/supplier-invoices/[id]/page.tsx index 79409e8a..438f117b 100644 --- a/app/(dashboard)/supplier-invoices/[id]/page.tsx +++ b/app/(dashboard)/supplier-invoices/[id]/page.tsx @@ -9,7 +9,7 @@ import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog' import { useToast } from '@/components/ui/use-toast' -import { ArrowLeft, CheckCircle, CreditCard, FileText, Trash2, Lock } from 'lucide-react' +import { ArrowLeft, CheckCircle, CreditCard, FileText, Trash2, Lock, Undo2, Info } from 'lucide-react' import { useCanWrite } from '@/lib/hooks/use-can-write' import Link from 'next/link' import { AccountNumber } from '@/components/ui/account-number' @@ -28,6 +28,7 @@ const statusColors: Record = { overdue: 'bg-destructive/10 text-destructive', disputed: 'bg-purple-100 text-purple-800', credited: 'bg-gray-100 text-gray-800', + reversed: 'bg-gray-100 text-gray-500', } const statusLabels: Record = { @@ -38,6 +39,7 @@ const statusLabels: Record = { overdue: 'Förfallen', disputed: 'Tvist', credited: 'Krediterad', + reversed: 'Makulerad', } export default function SupplierInvoiceDetailPage() { @@ -141,6 +143,34 @@ export default function SupplierInvoiceDetailPage() { } } + async function handleUncredit() { + const ok = await confirmAction({ + title: 'Ångra kreditering', + description: + 'Kreditfakturan tas bort och dess verifikation makuleras (storno). Originalfakturan återställs så att fakturanumret blir ledigt igen.', + confirmLabel: 'Ångra kreditering', + variant: 'warning', + }) + if (!ok) return + setIsProcessing(true) + const res = await fetch(`/api/supplier-invoices/${params.id}/uncredit`, { method: 'POST' }) + const result = await res.json() + if (!res.ok) { + toast({ + title: 'Kunde inte ångra kreditering', + description: result.error || 'Försök igen', + variant: 'destructive', + }) + } else { + toast({ + title: 'Kreditering ångrad', + description: 'Originalfakturan är återställd och numret är ledigt.', + }) + fetchInvoice() + } + setIsProcessing(false) + } + if (isLoading) { return (
@@ -189,7 +219,7 @@ export default function SupplierInvoiceDetailPage() { {/* Actions */}
- {invoice.status === 'registered' && ( + {invoice.status === 'registered' && !invoice.is_credit_note && ( <> + )}
+ {/* Credit note banner — explain why this row has no edit/delete affordances and where to undo */} + {invoice.is_credit_note && ( +
+ +
+

Detta är en kreditfaktura

+

+ Den är kopplad till{' '} + {(invoice as SupplierInvoice & { credited_original?: { id: string; supplier_invoice_number: string; arrival_number: number } }).credited_original ? ( + + faktura {(invoice as SupplierInvoice & { credited_original: { id: string; supplier_invoice_number: string; arrival_number: number } }).credited_original.supplier_invoice_number} + + ) : ( + originalfakturan + )} + . För att ta bort kreditfakturan och frigöra fakturanumret, gå till originalet och välj "Ångra kreditering". +

+
+
+ )} + {/* Invoice details */}
diff --git a/app/(dashboard)/supplier-invoices/new/page.tsx b/app/(dashboard)/supplier-invoices/new/page.tsx index 503f164b..e1f2c3bc 100644 --- a/app/(dashboard)/supplier-invoices/new/page.tsx +++ b/app/(dashboard)/supplier-invoices/new/page.tsx @@ -1,6 +1,6 @@ 'use client' -import { useState, useEffect } from 'react' +import { useState, useEffect, useRef } from 'react' import { useRouter } from 'next/navigation' import { useForm, Controller, useFieldArray } from 'react-hook-form' import { Button } from '@/components/ui/button' @@ -10,8 +10,9 @@ import { Label } from '@/components/ui/label' import { Textarea } from '@/components/ui/textarea' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' import { Checkbox } from '@/components/ui/checkbox' +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog' import { useToast } from '@/components/ui/use-toast' -import { ArrowLeft, Plus, Trash2, Lock } from 'lucide-react' +import { ArrowLeft, Plus, Trash2, Lock, AlertCircle } from 'lucide-react' import { useCanWrite } from '@/lib/hooks/use-can-write' import { ConfirmationDialog } from '@/components/ui/confirmation-dialog' import { SupplierInvoiceReviewContent } from '@/components/suppliers/SupplierInvoiceReviewContent' @@ -70,6 +71,12 @@ export default function NewSupplierInvoicePage() { const [isSubmitting, setIsSubmitting] = useState(false) const [showReview, setShowReview] = useState(false) const [pendingData, setPendingData] = useState(null) + const [conflict, setConflict] = useState<{ + message: string + existing: { id: string; supplier_invoice_number: string; status: string; credit_note_id: string | null } | null + } | null>(null) + const [isResolvingConflict, setIsResolvingConflict] = useState(false) + const invoiceNumberInputRef = useRef(null) const { register, control, handleSubmit, watch, setValue, formState: { isDirty } } = useForm({ defaultValues: { @@ -180,9 +187,8 @@ export default function NewSupplierInvoicePage() { setShowReview(true) } - async function handleConfirm() { - if (!pendingData) return - setIsSubmitting(true) + async function submitInvoice(): Promise<{ ok: boolean; status: number; result: { data?: { id: string; arrival_number: number }; error?: string; message?: string; existing?: { id: string; supplier_invoice_number: string; status: string; credit_note_id: string | null } } }> { + if (!pendingData) return { ok: false, status: 0, result: {} } const vatTreatment = inferVatTreatment(pendingData.items, pendingData.reverse_charge) @@ -213,18 +219,90 @@ export default function NewSupplierInvoicePage() { }) const result = await res.json() + return { ok: res.ok, status: res.status, result } + } - if (!res.ok) { - toast({ title: 'Kunde inte registrera faktura', description: getErrorMessage(result, { context: 'supplier_invoice', statusCode: res.status }), variant: 'destructive' }) - } else { + async function handleConfirm() { + if (!pendingData) return + setIsSubmitting(true) + + const { ok, status, result } = await submitInvoice() + + if (ok && result.data) { toast({ title: 'Faktura registrerad', description: `Ankomstnummer: ${result.data.arrival_number}` }) setShowReview(false) router.push(`/supplier-invoices/${result.data.id}`) + } else if (status === 409 && result.error === 'duplicate_supplier_invoice_number') { + // Surface the explanatory modal instead of a toast — the user has real choices to make. + setShowReview(false) + setConflict({ + message: result.message || 'Det finns redan en faktura med detta nummer från denna leverantör.', + existing: result.existing ?? null, + }) + } else { + toast({ + title: 'Kunde inte registrera faktura', + description: getErrorMessage(result, { context: 'supplier_invoice', statusCode: status }), + variant: 'destructive', + }) } setIsSubmitting(false) } + async function handleUncreditAndRetry() { + if (!conflict?.existing) return + const existingId = conflict.existing.id + const existingNumber = conflict.existing.supplier_invoice_number + setIsResolvingConflict(true) + + const uncreditRes = await fetch( + `/api/supplier-invoices/${existingId}/uncredit`, + { method: 'POST' } + ) + const uncreditResult = await uncreditRes.json() + if (!uncreditRes.ok) { + toast({ + title: 'Kunde inte ångra kreditering', + description: getErrorMessage(uncreditResult, { context: 'supplier_invoice', statusCode: uncreditRes.status }), + variant: 'destructive', + }) + setIsResolvingConflict(false) + return + } + + // The duplicate number is now free — drop the dialog regardless of what + // happens next, otherwise the user is left staring at a stale "number in + // use" prompt that no longer matches reality. + setConflict(null) + + const { ok, status, result } = await submitInvoice() + setIsResolvingConflict(false) + + if (ok && result.data) { + toast({ + title: 'Kreditering ångrad och faktura registrerad', + description: `Ankomstnummer: ${result.data.arrival_number}`, + }) + router.push(`/supplier-invoices/${result.data.id}`) + return + } + + // Uncredit succeeded but the resubmit hit a different validation error. + // Tell the user exactly what happened so they don't assume the uncredit + // also failed, and leave them on the form to fix and retry. + toast({ + title: 'Kreditering ångrad — men nya fakturan kunde inte registreras', + description: `Faktura ${existingNumber} är återställd och numret är ledigt. ${getErrorMessage(result, { context: 'supplier_invoice', statusCode: status })}`, + variant: 'destructive', + }) + } + + function handlePickNewNumber() { + setConflict(null) + setTimeout(() => invoiceNumberInputRef.current?.focus(), 0) + } + return (
@@ -268,10 +346,19 @@ export default function NewSupplierInvoicePage() {
- + {(() => { + const { ref: rhfRef, ...rest } = register('supplier_invoice_number') + return ( + { + rhfRef(el) + invoiceNumberInputRef.current = el + }} + /> + ) + })()}
@@ -638,6 +725,37 @@ export default function NewSupplierInvoicePage() { ) })()} + + !open && setConflict(null)}> + + + + + Fakturanummer används redan + + {conflict?.message} + +
+ {conflict?.existing && ( + + )} + {conflict?.existing?.status === 'credited' && ( + + )} + +
+
+
) } diff --git a/app/(dashboard)/supplier-invoices/page.tsx b/app/(dashboard)/supplier-invoices/page.tsx index 47332ee8..af7d8ffa 100644 --- a/app/(dashboard)/supplier-invoices/page.tsx +++ b/app/(dashboard)/supplier-invoices/page.tsx @@ -22,6 +22,7 @@ const statusVariants: Record = { @@ -32,6 +33,7 @@ const statusLabels: Record = { overdue: 'Förfallen', disputed: 'Tvist', credited: 'Krediterad', + reversed: 'Makulerad', } export default function SupplierInvoicesPage() { diff --git a/app/api/supplier-invoices/[id]/route.ts b/app/api/supplier-invoices/[id]/route.ts index 8994a071..341a60c0 100644 --- a/app/api/supplier-invoices/[id]/route.ts +++ b/app/api/supplier-invoices/[id]/route.ts @@ -22,7 +22,9 @@ export async function GET( const { data: invoice, error } = await supabase .from('supplier_invoices') - .select('*, supplier:suppliers(*), items:supplier_invoice_items(*), payments:supplier_invoice_payments(*)') + .select( + '*, supplier:suppliers(*), items:supplier_invoice_items(*), payments:supplier_invoice_payments(*), credited_original:supplier_invoices!credited_invoice_id(id, supplier_invoice_number, arrival_number)' + ) .eq('id', id) .eq('company_id', companyId) .single() @@ -111,7 +113,7 @@ export async function DELETE( // Only allow deleting registered invoices without journal entries const { data: existing } = await supabase .from('supplier_invoices') - .select('status, registration_journal_entry_id') + .select('status, registration_journal_entry_id, is_credit_note') .eq('id', id) .eq('company_id', companyId) .single() @@ -120,6 +122,20 @@ export async function DELETE( return NextResponse.json({ error: 'Not found' }, { status: 404 }) } + // Block direct deletion of credit notes — deleting just the row would orphan the + // posted reversal JE and silently break momsdeklaration. The user must instead + // run "Ångra kreditering" on the original, which storno-reverses the JE and + // restores the original's status atomically. + if (existing.is_credit_note) { + return NextResponse.json( + { + error: + 'Kreditfakturor kan inte tas bort direkt. Gå till originalfakturan och välj "Ångra kreditering" för att frigöra numret och återställa bokföringen.', + }, + { status: 400 } + ) + } + if (existing.status !== 'registered') { return NextResponse.json( { error: 'Kan bara ta bort registrerade fakturor' }, diff --git a/app/api/supplier-invoices/[id]/uncredit/__tests__/route.test.ts b/app/api/supplier-invoices/[id]/uncredit/__tests__/route.test.ts new file mode 100644 index 00000000..70d3f611 --- /dev/null +++ b/app/api/supplier-invoices/[id]/uncredit/__tests__/route.test.ts @@ -0,0 +1,338 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { + createMockRequest, + parseJsonResponse, + createMockRouteParams, + createQueuedMockSupabase, + makeSupplierInvoice, +} from '@/tests/helpers' + +const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase() +vi.mock('@/lib/supabase/server', () => ({ + createClient: () => Promise.resolve(mockSupabase), +})) + +vi.mock('@/lib/init', () => ({ + ensureInitialized: vi.fn(), +})) + +vi.mock('@/lib/company/context', () => ({ + requireCompanyId: vi.fn().mockResolvedValue('company-1'), + getActiveCompanyId: vi.fn().mockResolvedValue('company-1'), +})) + +vi.mock('@/lib/auth/require-write', () => ({ + requireWritePermission: vi.fn().mockResolvedValue({ ok: true }), +})) + +const mockReverseEntry = vi.fn() +vi.mock('@/lib/bookkeeping/engine', () => ({ + reverseEntry: (...args: unknown[]) => mockReverseEntry(...args), +})) + +import { eventBus } from '@/lib/events' + +import { POST } from '../route' + +describe('POST /api/supplier-invoices/[id]/uncredit', () => { + const mockUser = { id: 'user-1', email: 'test@test.se' } + + beforeEach(() => { + vi.clearAllMocks() + reset() + eventBus.clear() + mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } }) + }) + + it('returns 401 when not authenticated', async () => { + mockSupabase.auth.getUser.mockResolvedValue({ data: { user: null } }) + + const request = createMockRequest('/api/supplier-invoices/inv-1/uncredit', { method: 'POST' }) + const response = await POST(request, createMockRouteParams({ id: 'inv-1' })) + const { status, body } = await parseJsonResponse(response) + + expect(status).toBe(401) + expect(body).toEqual({ error: 'Unauthorized' }) + }) + + it('returns 404 when original invoice not found', async () => { + enqueue({ data: null, error: { message: 'not found' } }) + + const request = createMockRequest('/api/supplier-invoices/inv-1/uncredit', { method: 'POST' }) + const response = await POST(request, createMockRouteParams({ id: 'inv-1' })) + const { status, body } = await parseJsonResponse<{ error: string }>(response) + + expect(status).toBe(404) + expect(body.error).toBe('Not found') + }) + + it('idempotently returns 200 when invoice is not credited', async () => { + const original = makeSupplierInvoice({ id: 'inv-1', status: 'approved', payments: [] }) + enqueue({ data: original, error: null }) + + const request = createMockRequest('/api/supplier-invoices/inv-1/uncredit', { method: 'POST' }) + const response = await POST(request, createMockRouteParams({ id: 'inv-1' })) + const { status, body } = await parseJsonResponse<{ data: { status: string } }>(response) + + expect(status).toBe(200) + expect(body.data.status).toBe('approved') + expect(mockReverseEntry).not.toHaveBeenCalled() + }) + + it('reverses credit JE, soft-deletes credit row, and restores original to approved when no payments', async () => { + const original = makeSupplierInvoice({ + id: 'inv-1', + status: 'credited', + total: 10000, + remaining_amount: 0, + due_date: '2099-12-31', + registration_journal_entry_id: 'je-reg-1', + payments: [], + }) + + // Fetch original + enqueue({ data: original, error: null }) + // Find active (non-reversed) credit row + enqueue({ + data: { id: 'credit-1', registration_journal_entry_id: 'je-credit' }, + error: null, + }) + mockReverseEntry.mockResolvedValue({ id: 'je-reversal' }) + // Mark credit row reversed (soft-delete) + enqueue({ data: null, error: null }) + // Update original + enqueue({ + data: { ...original, status: 'approved', remaining_amount: 10000 }, + error: null, + }) + + const request = createMockRequest('/api/supplier-invoices/inv-1/uncredit', { method: 'POST' }) + const response = await POST(request, createMockRouteParams({ id: 'inv-1' })) + const { status, body } = await parseJsonResponse<{ + data: { status: string; remaining_amount: number } + reversal_entry_id: string + }>(response) + + expect(status).toBe(200) + expect(body.data.status).toBe('approved') + expect(body.data.remaining_amount).toBe(10000) + expect(body.reversal_entry_id).toBe('je-reversal') + expect(mockReverseEntry).toHaveBeenCalledWith( + mockSupabase, + 'company-1', + 'user-1', + 'je-credit' + ) + }) + + it('restores to paid when full payments exist', async () => { + const original = makeSupplierInvoice({ + id: 'inv-1', + status: 'credited', + total: 10000, + remaining_amount: 0, + payments: [ + { + id: 'p-1', + supplier_invoice_id: 'inv-1', + payment_date: '2024-06-15', + amount: 10000, + currency: 'SEK', + exchange_rate: null, + exchange_rate_difference: 0, + journal_entry_id: 'je-pay', + transaction_id: null, + notes: null, + created_at: '2024-06-15T00:00:00Z', + }, + ], + }) + + enqueue({ data: original, error: null }) + enqueue({ + data: { id: 'credit-1', registration_journal_entry_id: 'je-credit' }, + error: null, + }) + mockReverseEntry.mockResolvedValue({ id: 'je-reversal' }) + enqueue({ data: null, error: null }) + enqueue({ + data: { ...original, status: 'paid', remaining_amount: 0 }, + error: null, + }) + + const request = createMockRequest('/api/supplier-invoices/inv-1/uncredit', { method: 'POST' }) + const response = await POST(request, createMockRouteParams({ id: 'inv-1' })) + const { status, body } = await parseJsonResponse<{ data: { status: string } }>(response) + + expect(status).toBe(200) + expect(body.data.status).toBe('paid') + }) + + it('handles cash method (credit row without registration_journal_entry_id) — skips reverseEntry and restores to registered', async () => { + // Pure cash-method: neither the original nor the credit row have a + // registration JE. The original must not be restored to 'approved' — + // that would assert a verifikation that never existed (sambandskravet, + // BFL 4 kap 2§). 'registered' is the correct state. + const original = makeSupplierInvoice({ + id: 'inv-1', + status: 'credited', + total: 5000, + remaining_amount: 0, + due_date: '2099-12-31', + registration_journal_entry_id: null, + payments: [], + }) + + enqueue({ data: original, error: null }) + enqueue({ + data: { id: 'credit-1', registration_journal_entry_id: null }, + error: null, + }) + enqueue({ data: null, error: null }) + enqueue({ + data: { ...original, status: 'registered', remaining_amount: 5000 }, + error: null, + }) + + const request = createMockRequest('/api/supplier-invoices/inv-1/uncredit', { method: 'POST' }) + const response = await POST(request, createMockRouteParams({ id: 'inv-1' })) + const { status, body } = await parseJsonResponse<{ + data: { status: string } + reversal_entry_id: string | null + }>(response) + + expect(status).toBe(200) + expect(body.data.status).toBe('registered') + expect(mockReverseEntry).not.toHaveBeenCalled() + expect(body.reversal_entry_id).toBeNull() + }) + + it('continues cleanup when JE was already manually reversed', async () => { + const original = makeSupplierInvoice({ + id: 'inv-1', + status: 'credited', + total: 5000, + remaining_amount: 0, + registration_journal_entry_id: 'je-reg-1', + payments: [], + }) + + enqueue({ data: original, error: null }) + enqueue({ + data: { id: 'credit-1', registration_journal_entry_id: 'je-credit' }, + error: null, + }) + mockReverseEntry.mockRejectedValue(new Error('Can only reverse posted entries')) + enqueue({ data: null, error: null }) + enqueue({ + data: { ...original, status: 'approved', remaining_amount: 5000 }, + error: null, + }) + + const request = createMockRequest('/api/supplier-invoices/inv-1/uncredit', { method: 'POST' }) + const response = await POST(request, createMockRouteParams({ id: 'inv-1' })) + const { status } = await parseJsonResponse(response) + + expect(status).toBe(200) + }) + + it('returns 400 with Swedish message when reverseEntry hits a locked period', async () => { + const original = makeSupplierInvoice({ + id: 'inv-1', + status: 'credited', + total: 5000, + remaining_amount: 0, + payments: [], + }) + + enqueue({ data: original, error: null }) + enqueue({ + data: { id: 'credit-1', registration_journal_entry_id: 'je-credit' }, + error: null, + }) + mockReverseEntry.mockRejectedValue( + new Error('Cannot create entry in locked/closed fiscal period') + ) + + const request = createMockRequest('/api/supplier-invoices/inv-1/uncredit', { method: 'POST' }) + const response = await POST(request, createMockRouteParams({ id: 'inv-1' })) + const { status, body } = await parseJsonResponse<{ error: string }>(response) + + expect(status).toBe(400) + expect(body.error).toMatch(/låst|stängd/i) + }) + + it('restores original even when no active credit row is found (credit already reversed)', async () => { + const original = makeSupplierInvoice({ + id: 'inv-1', + status: 'credited', + total: 5000, + remaining_amount: 0, + due_date: '2099-12-31', + registration_journal_entry_id: 'je-reg-1', + payments: [], + }) + + enqueue({ data: original, error: null }) + // Find credit row filters out status='reversed' — nothing comes back + enqueue({ data: null, error: null }) + enqueue({ + data: { ...original, status: 'approved', remaining_amount: 5000 }, + error: null, + }) + + const request = createMockRequest('/api/supplier-invoices/inv-1/uncredit', { method: 'POST' }) + const response = await POST(request, createMockRouteParams({ id: 'inv-1' })) + const { status, body } = await parseJsonResponse<{ + data: { status: string } + reversal_entry_id: string | null + }>(response) + + expect(status).toBe(200) + expect(body.data.status).toBe('approved') + expect(body.reversal_entry_id).toBeNull() + expect(mockReverseEntry).not.toHaveBeenCalled() + }) + + it('emits supplier_invoice.uncredited event', async () => { + const original = makeSupplierInvoice({ + id: 'inv-1', + status: 'credited', + total: 5000, + remaining_amount: 0, + due_date: '2099-12-31', + registration_journal_entry_id: 'je-reg-1', + payments: [], + }) + + enqueue({ data: original, error: null }) + enqueue({ + data: { id: 'credit-1', registration_journal_entry_id: 'je-credit' }, + error: null, + }) + mockReverseEntry.mockResolvedValue({ id: 'je-reversal' }) + enqueue({ data: null, error: null }) + enqueue({ + data: { ...original, status: 'approved', remaining_amount: 5000 }, + error: null, + }) + + const emitSpy = vi.spyOn(eventBus, 'emit') + + const request = createMockRequest('/api/supplier-invoices/inv-1/uncredit', { method: 'POST' }) + const response = await POST(request, createMockRouteParams({ id: 'inv-1' })) + const { status } = await parseJsonResponse(response) + + expect(status).toBe(200) + expect(emitSpy).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'supplier_invoice.uncredited', + payload: expect.objectContaining({ + reversedCreditNoteId: 'credit-1', + reversalEntryId: 'je-reversal', + userId: 'user-1', + }), + }) + ) + }) +}) diff --git a/app/api/supplier-invoices/[id]/uncredit/route.ts b/app/api/supplier-invoices/[id]/uncredit/route.ts new file mode 100644 index 00000000..38832005 --- /dev/null +++ b/app/api/supplier-invoices/[id]/uncredit/route.ts @@ -0,0 +1,175 @@ +import { createClient } from '@/lib/supabase/server' +import { NextResponse } from 'next/server' +import { eventBus } from '@/lib/events' +import { ensureInitialized } from '@/lib/init' +import { reverseEntry } from '@/lib/bookkeeping/engine' +import { AccountsNotInChartError, accountsNotInChartResponse } from '@/lib/bookkeeping/errors' +import { getErrorMessage } from '@/lib/errors/get-error-message' +import { requireCompanyId } from '@/lib/company/context' +import { requireWritePermission } from '@/lib/auth/require-write' +import type { SupplierInvoice, SupplierInvoicePayment } from '@/types' + +ensureInitialized() + +export async function POST( + _request: Request, + { params }: { params: Promise<{ id: string }> } +) { + const supabase = await createClient() + const { id } = await params + + const { data: { user } } = await supabase.auth.getUser() + + if (!user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const writeCheck = await requireWritePermission(supabase, user.id) + if (!writeCheck.ok) return writeCheck.response + + const companyId = await requireCompanyId(supabase, user.id) + + const { data: original, error: fetchError } = await supabase + .from('supplier_invoices') + .select('*, payments:supplier_invoice_payments(*)') + .eq('id', id) + .eq('company_id', companyId) + .single() + + if (fetchError || !original) { + return NextResponse.json({ error: 'Not found' }, { status: 404 }) + } + + // Idempotent no-op: an already-uncredited or never-credited invoice just returns the row. + // Friendlier than a 409 — the client retry path can blindly call this without checking first. + if (original.status !== 'credited') { + return NextResponse.json({ data: original }) + } + + // Filter out already-reversed credits — re-crediting the same original after a prior + // uncredit creates a second credit row, so we may find multiple historical matches. + const { data: creditNote } = await supabase + .from('supplier_invoices') + .select('id, registration_journal_entry_id') + .eq('company_id', companyId) + .eq('credited_invoice_id', id) + .eq('is_credit_note', true) + .neq('status', 'reversed') + .maybeSingle() + + let reversalEntryId: string | null = null + + if (creditNote?.registration_journal_entry_id) { + try { + const reversal = await reverseEntry( + supabase, + companyId, + user.id, + creditNote.registration_journal_entry_id + ) + reversalEntryId = reversal.id + } catch (err) { + const msg = err instanceof Error ? err.message : String(err) + // Already reversed (manually or by another concurrent uncredit) — fine, continue. + if ( + /Can only reverse posted entries/i.test(msg) || + /already reversed by a concurrent operation/i.test(msg) + ) { + // proceed to row cleanup + } else if (err instanceof AccountsNotInChartError) { + return accountsNotInChartResponse(err) + } else { + // Period lock and similar trigger errors — surface a clear Swedish message + // so the user knows WHY the action failed (per project's error-UX guidelines). + return NextResponse.json( + { error: getErrorMessage(err, { context: 'supplier_invoice' }) }, + { status: 400 } + ) + } + } + } + + if (creditNote) { + // Soft-delete: mark the credit row 'reversed' and stamp reversed_at. BFL 7 kap + // requires räkenskapsinformation to be preserved for 7 years; BFL 5 kap 7§ wants + // an unbroken ankomstnummer series; sambandskravet requires the posted JE to + // remain traceable back to its business-layer row. A hard-delete would break all + // three. The row and its items are kept; the partial unique index excludes + // status='reversed' so re-crediting is still possible. + const { error: reverseMarkError } = await supabase + .from('supplier_invoices') + .update({ status: 'reversed', reversed_at: new Date().toISOString() }) + .eq('id', creditNote.id) + .eq('company_id', companyId) + + if (reverseMarkError) { + return NextResponse.json( + { error: getErrorMessage(reverseMarkError, { context: 'supplier_invoice' }) }, + { status: 500 } + ) + } + } + + // Recompute original status from payments. The credit had reduced remaining_amount + // to 0 and bumped status to 'credited' — undo both based on what's actually paid. + const payments = (original.payments as SupplierInvoicePayment[]) || [] + const paidSum = payments.reduce((sum, p) => sum + (p.amount || 0), 0) + const total = original.total || 0 + const remaining = Math.round((total - paidSum) * 100) / 100 + + let newStatus: SupplierInvoice['status'] + if (paidSum >= total && total > 0) { + newStatus = 'paid' + } else if (paidSum > 0) { + newStatus = 'partially_paid' + } else if (original.due_date && new Date(original.due_date) < new Date()) { + newStatus = 'overdue' + } else if (original.registration_journal_entry_id) { + // Posted verifikation exists -> safe to restore to 'approved'. + newStatus = 'approved' + } else { + // No registration JE on the original (cash method, or an inconsistent row + // that somehow reached 'credited' without a booking). Restoring to + // 'approved' would yield an approved invoice with no verifikation, which + // violates sambandskravet (BFL 4 kap 2§). Fall back to 'registered'. + newStatus = 'registered' + } + + const { data: restored, error: updateError } = await supabase + .from('supplier_invoices') + .update({ + status: newStatus, + remaining_amount: remaining, + }) + .eq('id', id) + .eq('company_id', companyId) + .select() + .single() + + if (updateError || !restored) { + return NextResponse.json( + { error: getErrorMessage(updateError, { context: 'supplier_invoice' }) }, + { status: 500 } + ) + } + + try { + await eventBus.emit({ + type: 'supplier_invoice.uncredited', + payload: { + supplierInvoice: restored as SupplierInvoice, + reversedCreditNoteId: creditNote?.id ?? '', + reversalEntryId, + userId: user.id, + companyId, + }, + }) + } catch { + // Non-blocking + } + + return NextResponse.json({ + data: restored, + reversal_entry_id: reversalEntryId, + }) +} diff --git a/app/api/supplier-invoices/__tests__/route.test.ts b/app/api/supplier-invoices/__tests__/route.test.ts index dadfa482..f1690d1f 100644 --- a/app/api/supplier-invoices/__tests__/route.test.ts +++ b/app/api/supplier-invoices/__tests__/route.test.ts @@ -301,4 +301,163 @@ describe('POST /api/supplier-invoices', () => { expect(status).toBe(500) expect(body.error).toBe('Items insert failed') }) + + it('returns 409 with credit chain on duplicate supplier_invoice_number for credited original', async () => { + const supplier = makeSupplier({ id: VALID_UUID }) + + // Fetch supplier + enqueue({ data: supplier, error: null }) + // RPC get_next_arrival_number + enqueue({ data: 8 }) + // Insert invoice → unique-index violation + enqueue({ + data: null, + error: { + code: '23505', + message: + 'duplicate key value violates unique constraint "idx_supplier_invoices_company_supplier_number"', + }, + }) + // Lookup existing row + enqueue({ + data: { + id: 'existing-1', + supplier_invoice_number: 'LF-DUP', + status: 'credited', + }, + error: null, + }) + // Lookup credit note for the credited original + enqueue({ data: { id: 'credit-1' }, error: null }) + + const request = createMockRequest('/api/supplier-invoices', { + method: 'POST', + body: { + supplier_id: VALID_UUID, + supplier_invoice_number: 'LF-DUP', + invoice_date: '2024-06-01', + due_date: '2024-07-01', + items: [{ description: 'Test', quantity: 1, unit_price: 1000, account_number: '4010' }], + }, + }) + const response = await POST(request) + const { status, body } = await parseJsonResponse<{ + error: string + message: string + existing: { id: string; supplier_invoice_number: string; status: string; credit_note_id: string } + }>(response) + + expect(status).toBe(409) + expect(body.error).toBe('duplicate_supplier_invoice_number') + expect(body.message).toMatch(/krediterad/i) + expect(body.existing).toEqual({ + id: 'existing-1', + supplier_invoice_number: 'LF-DUP', + status: 'credited', + credit_note_id: 'credit-1', + }) + }) + + it('returns 409 without credit_note_id when existing invoice is not credited', async () => { + const supplier = makeSupplier({ id: VALID_UUID }) + + enqueue({ data: supplier, error: null }) + enqueue({ data: 9 }) + enqueue({ + data: null, + error: { + code: '23505', + message: + 'duplicate key value violates unique constraint "idx_supplier_invoices_company_supplier_number"', + }, + }) + enqueue({ + data: { + id: 'existing-2', + supplier_invoice_number: 'LF-DUP-2', + status: 'approved', + }, + error: null, + }) + + const request = createMockRequest('/api/supplier-invoices', { + method: 'POST', + body: { + supplier_id: VALID_UUID, + supplier_invoice_number: 'LF-DUP-2', + invoice_date: '2024-06-01', + due_date: '2024-07-01', + items: [{ description: 'Test', quantity: 1, unit_price: 1000, account_number: '4010' }], + }, + }) + const response = await POST(request) + const { status, body } = await parseJsonResponse<{ + error: string + message: string + existing: { id: string; status: string; credit_note_id: string | null } + }>(response) + + expect(status).toBe(409) + expect(body.error).toBe('duplicate_supplier_invoice_number') + expect(body.existing.status).toBe('approved') + expect(body.existing.credit_note_id).toBeNull() + }) + + it('returns generic 409 when existing row lookup races to nothing', async () => { + const supplier = makeSupplier({ id: VALID_UUID }) + + enqueue({ data: supplier, error: null }) + enqueue({ data: 10 }) + enqueue({ + data: null, + error: { + code: '23505', + message: + 'duplicate key value violates unique constraint "idx_supplier_invoices_company_supplier_number"', + }, + }) + // Lookup returns null — the row was deleted between the failing insert and our fetch + enqueue({ data: null, error: null }) + + const request = createMockRequest('/api/supplier-invoices', { + method: 'POST', + body: { + supplier_id: VALID_UUID, + supplier_invoice_number: 'LF-RACE', + invoice_date: '2024-06-01', + due_date: '2024-07-01', + items: [{ description: 'Test', quantity: 1, unit_price: 1000, account_number: '4010' }], + }, + }) + const response = await POST(request) + const { status, body } = await parseJsonResponse<{ error: string; message: string; existing?: unknown }>(response) + + expect(status).toBe(409) + expect(body.error).toBe('duplicate_supplier_invoice_number') + expect(body.existing).toBeUndefined() + }) + + it('falls through to 500 for non-23505 insert errors', async () => { + const supplier = makeSupplier({ id: VALID_UUID }) + + enqueue({ data: supplier, error: null }) + enqueue({ data: 11 }) + enqueue({ data: null, error: { code: '23502', message: 'NOT NULL violation' } }) + + const request = createMockRequest('/api/supplier-invoices', { + method: 'POST', + body: { + supplier_id: VALID_UUID, + supplier_invoice_number: 'LF-OTHER', + invoice_date: '2024-06-01', + due_date: '2024-07-01', + items: [{ description: 'Test', quantity: 1, unit_price: 1000, account_number: '4010' }], + }, + }) + const response = await POST(request) + const { status, body } = await parseJsonResponse<{ error: string }>(response) + + expect(status).toBe(500) + expect(body.error).toBe('NOT NULL violation') + }) }) diff --git a/app/api/supplier-invoices/route.ts b/app/api/supplier-invoices/route.ts index c4e1605a..dd9689ea 100644 --- a/app/api/supplier-invoices/route.ts +++ b/app/api/supplier-invoices/route.ts @@ -147,6 +147,76 @@ export async function POST(request: Request) { .single() if (invoiceError || !invoice) { + // Translate the unique-index violation on (company_id, supplier_id, supplier_invoice_number) + // into a structured 409 so the UI can offer to undo the credit chain rather than + // leaving the user stuck on a generic 500. Other DB errors keep the existing 500 path. + const pgErr = invoiceError as { code?: string; message?: string } | null + const isDuplicateNumber = + pgErr?.code === '23505' && + (pgErr.message || '').includes('idx_supplier_invoices_company_supplier_number') + + if (isDuplicateNumber) { + const { data: existing } = await supabase + .from('supplier_invoices') + .select('id, supplier_invoice_number, status') + .eq('company_id', companyId) + .eq('supplier_id', body.supplier_id) + .eq('supplier_invoice_number', body.supplier_invoice_number) + .maybeSingle() + + if (!existing) { + // Race: row vanished between the failing insert and our lookup. Stay defensive. + return NextResponse.json( + { + error: 'duplicate_supplier_invoice_number', + message: `Det finns redan en faktura med nummer ${body.supplier_invoice_number} från denna leverantör.`, + }, + { status: 409 } + ) + } + + let creditNoteId: string | null = null + if (existing.status === 'credited') { + const { data: creditNote } = await supabase + .from('supplier_invoices') + .select('id') + .eq('company_id', companyId) + .eq('credited_invoice_id', existing.id) + .eq('is_credit_note', true) + .maybeSingle() + creditNoteId = creditNote?.id ?? null + } + + const statusLabels: Record = { + registered: 'registrerad', + approved: 'godkänd', + paid: 'betald', + partially_paid: 'delbetald', + overdue: 'förfallen', + disputed: 'tvist', + credited: 'krediterad', + } + const statusLabel = statusLabels[existing.status] || existing.status + const message = + existing.status === 'credited' + ? `Det finns redan en faktura med nummer ${existing.supplier_invoice_number} från denna leverantör (krediterad). Du kan ångra krediteringen för att frigöra numret, eller använda ett annat nummer.` + : `Det finns redan en faktura med nummer ${existing.supplier_invoice_number} från denna leverantör (status: ${statusLabel}). Använd ett annat nummer.` + + return NextResponse.json( + { + error: 'duplicate_supplier_invoice_number', + message, + existing: { + id: existing.id, + supplier_invoice_number: existing.supplier_invoice_number, + status: existing.status, + credit_note_id: creditNoteId, + }, + }, + { status: 409 } + ) + } + return NextResponse.json({ error: invoiceError?.message || 'Failed to create invoice' }, { status: 500 }) } diff --git a/lib/events/types.ts b/lib/events/types.ts index 967601d8..1dd978b5 100644 --- a/lib/events/types.ts +++ b/lib/events/types.ts @@ -67,6 +67,7 @@ export type CoreEvent = | { type: 'supplier_invoice.approved'; payload: { supplierInvoice: SupplierInvoice; userId: string; companyId: string } } | { type: 'supplier_invoice.paid'; payload: { supplierInvoice: SupplierInvoice; paymentAmount: number; userId: string; companyId: string } } | { type: 'supplier_invoice.credited'; payload: { supplierInvoice: SupplierInvoice; creditNote: SupplierInvoice; userId: string; companyId: string } } + | { type: 'supplier_invoice.uncredited'; payload: { supplierInvoice: SupplierInvoice; reversedCreditNoteId: string; reversalEntryId: string | null; userId: string; companyId: string } } // Payment Matching | { type: 'invoice.match_confirmed'; payload: { invoice: Invoice; transaction: Transaction; userId: string; companyId: string } } | { type: 'supplier_invoice.match_confirmed'; payload: { supplierInvoice: SupplierInvoice; transaction: Transaction; userId: string; companyId: string } } diff --git a/supabase/migrations/20260423120000_supplier_invoice_number_partial_unique_excludes_credited.sql b/supabase/migrations/20260423120000_supplier_invoice_number_partial_unique_excludes_credited.sql new file mode 100644 index 00000000..96f022d2 --- /dev/null +++ b/supabase/migrations/20260423120000_supplier_invoice_number_partial_unique_excludes_credited.sql @@ -0,0 +1,12 @@ +-- SUPERSEDED — no-op stub retained for migration-history alignment. +-- +-- This migration was originally introduced to widen +-- idx_supplier_invoices_company_supplier_number to exclude status='credited'. +-- The next migration (20260423121000_supplier_invoice_reversed_status.sql) +-- immediately drops and recreates the same index with the broader +-- NOT IN ('credited', 'reversed') predicate, which makes the change here +-- redundant. +-- +-- The file is kept (empty) so that Supabase environments that already +-- applied this version do not fail their local-vs-remote migration-history +-- diff. The real index work lives in 20260423121000. diff --git a/supabase/migrations/20260423121000_supplier_invoice_reversed_status.sql b/supabase/migrations/20260423121000_supplier_invoice_reversed_status.sql new file mode 100644 index 00000000..74f924f2 --- /dev/null +++ b/supabase/migrations/20260423121000_supplier_invoice_reversed_status.sql @@ -0,0 +1,55 @@ +-- BFL compliance: replace hard-delete of uncredited credit notes with a soft-delete +-- status so that the row, its items, and its back-reference from the posted JE all +-- survive. BFL 7 kap requires räkenskapsinformation to be preserved in an unalterable +-- form for 7 years; BFL 5 kap 7§ implies ankomstnummer should be an unbroken series; +-- sambandskravet (BFL 4 kap 2§) requires verifikationer to remain traceable back to +-- their underlag. Hard-deleting the supplier_invoices row would break all three. +-- +-- This migration mirrors the pattern used for journal_entries in +-- 20260319000001_add_cancelled_journal_status.sql. + +-- 1. Expand status CHECK to include 'reversed'. +ALTER TABLE public.supplier_invoices + DROP CONSTRAINT IF EXISTS supplier_invoices_status_check; +ALTER TABLE public.supplier_invoices + ADD CONSTRAINT supplier_invoices_status_check + CHECK (status IN ( + 'registered', + 'approved', + 'paid', + 'partially_paid', + 'overdue', + 'disputed', + 'credited', + 'reversed' + )); + +-- 2. Add reversed_at timestamp for audit — when a credit note was storno-reversed. +ALTER TABLE public.supplier_invoices + ADD COLUMN IF NOT EXISTS reversed_at TIMESTAMPTZ NULL; + +COMMENT ON COLUMN public.supplier_invoices.reversed_at IS + 'Timestamp when a credit note was reversed via "Ångra kreditering". Pairs with status=''reversed''. Row itself is retained for BFL 7 kap compliance.'; + +-- 3. Widen the partial unique index to exclude both 'credited' and 'reversed'. +-- +-- Baseline (migration 20260330130000): UNIQUE on (company_id, supplier_id, +-- supplier_invoice_number) WHERE supplier_invoice_number IS NOT NULL. A credited +-- original kept its row under the original number, blocking any re-entry of a +-- corrected invoice under the same number (Postgres 23505 -> HTTP 500 with no +-- recovery path). That breaks a common Swedish accounting pattern: a supplier +-- re-issues a corrected invoice under the same löpnummer. +-- +-- With this migration: 'credited' originals and 'reversed' (uncredited) credit +-- notes both drop out of the uniqueness check. The credit note itself carries +-- a "KREDIT-" prefixed number so it never collides with the original either way. +-- The credited original and the reversed credit row both remain in the table +-- for BFL 7 kap audit purposes; only the number slot is freed. +DROP INDEX IF EXISTS public.idx_supplier_invoices_company_supplier_number; + +CREATE UNIQUE INDEX idx_supplier_invoices_company_supplier_number + ON public.supplier_invoices (company_id, supplier_id, supplier_invoice_number) + WHERE supplier_invoice_number IS NOT NULL + AND status NOT IN ('credited', 'reversed'); + +NOTIFY pgrst, 'reload schema'; diff --git a/types/index.ts b/types/index.ts index de283227..323eb208 100644 --- a/types/index.ts +++ b/types/index.ts @@ -91,7 +91,9 @@ export type InvoiceDocumentType = 'invoice' | 'proforma' | 'delivery_note' export type SupplierType = 'swedish_business' | 'eu_business' | 'non_eu_business' // Supplier invoice status -export type SupplierInvoiceStatus = 'registered' | 'approved' | 'paid' | 'partially_paid' | 'overdue' | 'disputed' | 'credited' +// 'reversed' marks a credit note whose journal entry was storno-reversed via +// "Ångra kreditering". The row is preserved (BFL 7 kap) rather than hard-deleted. +export type SupplierInvoiceStatus = 'registered' | 'approved' | 'paid' | 'partially_paid' | 'overdue' | 'disputed' | 'credited' | 'reversed' // VAT treatment export type VatTreatment =