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:
@@ -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 "Ångra kreditering".
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Invoice details */}
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Card>
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
|
||||
@@ -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 } }
|
||||
|
||||
+12
@@ -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
@@ -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 =
|
||||
|
||||
Reference in New Issue
Block a user