Skill/fix (#355)

* fix: ensure customer email addresses are anonymized and not displayed in tickets

* feat: add uncredit functionality for supplier invoices

- Implemented the ability to uncredit supplier invoices, restoring the original invoice status and freeing up the invoice number.
- Added confirmation dialog for uncrediting actions.
- Updated the supplier invoice detail page to show an "Undo Credit" button for credited invoices.
- Enhanced the new supplier invoice page to handle conflicts when a duplicate invoice number is detected, allowing users to uncredit the existing invoice.
- Created API endpoint for uncrediting invoices, including handling of journal entries and invoice status updates.
- Added tests for the uncredit functionality to ensure proper behavior and error handling.

* feat: implement soft-delete for credited invoices and add reversed status

* fix: update uncredit logic to handle registration journal entries and improve user feedback

* fix: retain no-op migration stub for history alignment with future index changes
This commit is contained in:
Mattsson
2026-04-23 11:57:12 +02:00
committed by GitHub
parent 02f94ef631
commit e137a9f452
13 changed files with 1051 additions and 28 deletions
+21 -11
View File
@@ -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: <email>` 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: <customer@domain>` (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):
<The full body of the customer's first message, with all personal names replaced by `x`. See the anonymization rules below.>
<The full body of the customer's first message, with all personal names AND email addresses replaced by `x`. See the anonymization rules below.>
Customer: <email address>
Gmail thread ID: <threadId>
```
**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: <email>` or `From: <email>` 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/<id>`) 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:** \`<id>\``. No `Original support thread: <URL>` 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-<N>.md <<'EOF'
## Customer email (anonymized)
> <Full body of the customer's first message, wrapped as a blockquote, with all personal names replaced by `x`.>
> <Full body of the customer's first message, wrapped as a blockquote, with all personal names AND email addresses replaced by `x` / `x@x` respectively.>
---
**Customer:** <email>
**Gmail thread ID:** `<threadId>`
EOF
@@ -202,7 +212,7 @@ The command prints the new issue's URL on success — capture it for the summary
```bash
gh issue comment <issue-number> \
--repo erp-mafia/gnubok \
--body "Another customer report of this issue. Customer: \`<email>\`. Gmail thread ID: \`<threadId>\`."
--body "Another customer report of this issue. Gmail thread ID: \`<threadId>\`."
```
**Label notes**:
@@ -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<string, string> = {
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<string, string> = {
@@ -38,6 +39,7 @@ const statusLabels: Record<string, string> = {
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 (
<div className="space-y-6">
@@ -189,7 +219,7 @@ export default function SupplierInvoiceDetailPage() {
{/* Actions */}
<div className="flex flex-wrap gap-2">
{invoice.status === 'registered' && (
{invoice.status === 'registered' && !invoice.is_credit_note && (
<>
<Button
onClick={handleApprove}
@@ -233,9 +263,44 @@ export default function SupplierInvoiceDetailPage() {
)}
</>
)}
{invoice.status === 'credited' && !invoice.is_credit_note && (
<Button
variant="outline"
onClick={handleUncredit}
disabled={isProcessing || !canWrite}
title={!canWrite ? 'Du har endast läsbehörighet i detta företag' : undefined}
>
{canWrite ? <Undo2 className="mr-2 h-4 w-4" /> : <Lock className="mr-2 h-4 w-4" />}
Ångra kreditering
</Button>
)}
</div>
</div>
{/* Credit note banner — explain why this row has no edit/delete affordances and where to undo */}
{invoice.is_credit_note && (
<div className="rounded-lg border bg-muted/40 p-4 flex gap-3 text-sm">
<Info className="h-5 w-5 shrink-0 text-muted-foreground mt-0.5" />
<div className="space-y-1">
<p className="font-medium">Detta är en kreditfaktura</p>
<p className="text-muted-foreground">
Den är kopplad till{' '}
{(invoice as SupplierInvoice & { credited_original?: { id: string; supplier_invoice_number: string; arrival_number: number } }).credited_original ? (
<Link
href={`/supplier-invoices/${(invoice as SupplierInvoice & { credited_original: { id: string; supplier_invoice_number: string; arrival_number: number } }).credited_original.id}`}
className="text-primary hover:underline font-medium"
>
faktura {(invoice as SupplierInvoice & { credited_original: { id: string; supplier_invoice_number: string; arrival_number: number } }).credited_original.supplier_invoice_number}
</Link>
) : (
<span>originalfakturan</span>
)}
. För att ta bort kreditfakturan och frigöra fakturanumret, gå till originalet och välj &quot;Ångra kreditering&quot;.
</p>
</div>
</div>
)}
{/* Invoice details */}
<div className="grid gap-4 md:grid-cols-2">
<Card>
+130 -12
View File
@@ -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<FormData | null>(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<HTMLInputElement | null>(null)
const { register, control, handleSubmit, watch, setValue, formState: { isDirty } } = useForm<FormData>({
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 (
<div className="space-y-6 max-w-4xl">
<div className="flex items-center gap-4">
@@ -268,10 +346,19 @@ export default function NewSupplierInvoicePage() {
</div>
<div className="space-y-2">
<Label>Leverantörens fakturanummer *</Label>
<Input
placeholder="Fakturanr från leverantören"
{...register('supplier_invoice_number')}
/>
{(() => {
const { ref: rhfRef, ...rest } = register('supplier_invoice_number')
return (
<Input
placeholder="Fakturanr från leverantören"
{...rest}
ref={(el) => {
rhfRef(el)
invoiceNumberInputRef.current = el
}}
/>
)
})()}
</div>
</div>
<div className="space-y-2">
@@ -638,6 +725,37 @@ export default function NewSupplierInvoicePage() {
</ConfirmationDialog>
)
})()}
<Dialog open={!!conflict} onOpenChange={(open) => !open && setConflict(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<AlertCircle className="h-5 w-5 text-destructive" />
Fakturanummer används redan
</DialogTitle>
<DialogDescription>{conflict?.message}</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-2">
{conflict?.existing && (
<Button
variant="outline"
onClick={() => router.push(`/supplier-invoices/${conflict.existing!.id}`)}
disabled={isResolvingConflict}
>
Visa befintlig faktura
</Button>
)}
{conflict?.existing?.status === 'credited' && (
<Button onClick={handleUncreditAndRetry} disabled={isResolvingConflict}>
{isResolvingConflict ? 'Bearbetar...' : 'Ångra kreditering & återförsök'}
</Button>
)}
<Button variant="ghost" onClick={handlePickNewNumber} disabled={isResolvingConflict}>
Använd ett annat nummer
</Button>
</div>
</DialogContent>
</Dialog>
</div>
)
}
@@ -22,6 +22,7 @@ const statusVariants: Record<string, 'default' | 'secondary' | 'success' | 'warn
overdue: 'destructive',
disputed: 'warning',
credited: 'secondary',
reversed: 'secondary',
}
const statusLabels: Record<string, string> = {
@@ -32,6 +33,7 @@ const statusLabels: Record<string, string> = {
overdue: 'Förfallen',
disputed: 'Tvist',
credited: 'Krediterad',
reversed: 'Makulerad',
}
export default function SupplierInvoicesPage() {
+18 -2
View File
@@ -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' },
@@ -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',
}),
})
)
})
})
@@ -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,
})
}
@@ -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')
})
})
+70
View File
@@ -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<string, string> = {
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 })
}
+1
View File
@@ -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 } }
@@ -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.
@@ -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';
+3 -1
View File
@@ -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 =