feat(sales-orders): kundorder with partial delivery and partial invoicing (#2166)

* feat(sales-orders): kundorder with partial delivery and partial invoicing

Adds sales orders (kundorder) as their own non-ledger document between
agreement and invoice, for companies that deliver or invoice in parts.

Schema (20260902130000): sales_orders + sales_order_items with RLS via
user_company_ids(), OR-<n> numbering RPC (membership-gated, no anon
execute), company_settings.sales_orders_enabled UI gate, and back-links
invoices.sales_order_id / invoice_items.sales_order_item_id. The invoiced
quantity per order line is DERIVED from the linked invoice lines on
non-cancelled, non-credited invoices and enforced by a BEFORE trigger, so
no counter can drift and a credited invoice frees its quantity. Header
status is draft / confirmed / completed / cancelled; completion is kept
by DB triggers from the same derived quantity. Delivery and invoicing
progress are derived per line, never stored as status.

Service + API: lib/sales-orders (create/update with id-preserving line
replace, transitions with compare-and-set, cumulative delivery
registration, invoice-from-order through buildInvoiceWriteData so
booking stays in the engine, proforma -> order conversion), routes under
/api/sales-orders and /api/invoices/[id]/convert-to-order, structured
SALES_ORDER_* error codes, archive classification of the new tables.
The invoice editor round-trips sales_order_item_id so a draft edit
cannot drop the link; GET /api/invoices gains ?sales_order_id=.

UI: /sales-orders list, create/edit form reusing the invoice line
conventions, detail with deliver and create-invoice dialogs and linked
invoices; nav row behind the settings toggle; the webshop row is
relabelled webshop_orders; "Skapa order" on proformas.

MCP (20260902141000/141001): list/get reads plus four staged writes
(create, transition, register delivery, create invoice from order) whose
executors call the lib services; op types added to the pending
operations CHECK.

Tests: route tests for every route (401/400/404/happy), service unit
tests, executor and tool tests, and tests/pg/sales-orders.pg.test.ts
(16 cases, green on staging) covering RLS, numbering guards, the
over-invoice trigger incl. release on cancel/credit and cross-company
refusal, the quantity floor, and completion maintenance.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RQW7mXvbAPgjUHq7dSEamr

* fix(sales-orders): harden kundorder after skeptic and security review

Resolves every finding from the PR #2166 review pass in one batch.

Order link integrity: replaceInvoiceItems now refuses a line set that
drops an existing sales_order_item_id (INVOICE_UPDATE_DROPS_ORDER_LINK),
closing the MCP update_invoice header-only edit and the v1 PATCH path
that severed the link and freed the quantity for double invoicing. The
update_invoice re-fetch, gnubok_get_invoice and the v1 item projection
now carry sales_order_item_id so well-behaved clients round-trip it.

Quantity math: derived remaining/invoiced quantities are rounded to six
decimals and compared with an epsilon (roundQty, qtyGreater) so a float
remainder such as 0.5999999999999996 can neither refuse the final partial
invoice nor land as an invoice quantity; duplicate explicit picks are
summed before validation.

Leveransdatum: per-line last_delivery_date (migration 20260902160000);
an invoice takes the latest date over the lines it covers and only when
the covered quantity was delivered, never the header date and never for
an advance invoice (ML 17 kap 24 p.7, FX anchor per ML 8 kap 21-23).

VAT drift: the order stores the customer type and VAT-validation flag its
lines were priced under; invoicing refuses with
SALES_ORDER_CUSTOMER_VAT_CHANGED when they differ, and re-saving the
order re-validates the lines. Customer and currency are frozen once
invoices exist.

Tenant and role gates: composite FK (sales_order_id, company_id) ties a
line to its parent's company (Superagent P2); aa_enforce_company_writer_role
on both tables so a viewer cannot write through the browser client.

Proforma -> order refuses proformas with ROT/RUT, periodisering or
negative-quantity lines instead of dropping those fields. RESTRICT FK
errors on delete map to SALES_ORDER_LINE_LOCKED / SALES_ORDER_HAS_INVOICES.

Also: schema-guard literal payloads in lib/sales-orders (ceiling +2 with
reason), regenerated skills/accounted-api (sales_order_item_id on invoice
items), pg tests for the composite FK, the viewer gate and the new
columns, unit tests for every changed path.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XzFmmH82hCJNmZbPqycDiW

* fix(sales-orders): resolve CodeRabbit round on PR #2166

Quick wins from the review, all in one pass:

- replaceInvoiceItems fails closed when the invoice_items snapshot cannot
  be read (it is both the restore source and the input to the kundorder
  link guard); the guard branch is explicit in both PATCH routes.
- Cumulative delivery registration carries an optimistic predicate on the
  quantity it read, so two concurrent registrations cannot regress each
  other; DELETE of an order keeps its allowed status in the predicate and
  answers a conflict when zero rows match.
- Business dates (order date, delivery date, invoice date) default to the
  Europe/Stockholm calendar day (todayIsoStockholm), never UTC: the
  delivery date is also the Riksbanken rate anchor.
- The invoice-from-order executor treats an event emit failure as
  non-blocking: the draft already exists.
- sales_order_items are archived through their parent with the order
  currency denormalised, like invoice_items.
- Proforma "Skapa order" tolerates a 2xx without a parsable body; the
  settings toggle refreshes the server-rendered nav.
- List route doc states that q matches the order number (customer names
  are matched client-side).

Declined (out of scope for this PR): moving header + line writes and the
delivery loop into transactional RPCs (same PostgREST pattern as the
invoice PATCH path, tracked as a follow-up), the MCP approval handler's
error message shape (pre-existing code outside this change), and the
docstring-coverage warning (no repo convention).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XzFmmH82hCJNmZbPqycDiW

* fix(sales-orders): move hardening migration off a colliding version; archive contract; ceiling

- 20260902160000_sales_orders_hardening.sql collided with main's
  20260902160000_parties_substrate.sql after the third sync; renamed to
  20260902180000 and made idempotent (DROP ... IF EXISTS before each
  ADD CONSTRAINT) so a preview branch that applied it under the old
  version replays it cleanly. Staging's schema_migrations row renamed.
- sales_order_items goes back to a direct archive dump: the coverage
  contract (tests/pg/full-archive-coverage.pg.test.ts) requires it for a
  table with its own company_id; the currency lives on the parent order
  one file over, joined by sales_order_id.
- Scanner ceiling re-baselined after merging main (parties phase 1): 397.
- v1 PATCH test queues a real empty invoice_items snapshot now that
  replaceInvoiceItems fails closed on an unreadable one.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XzFmmH82hCJNmZbPqycDiW

* fix(sales-orders): drop the composite FK before its unique index on replay

The idempotent guard in 20260902180000_sales_orders_hardening.sql dropped
the unique (id, company_id) before the FK that depends on its index, so
the preview branch replay (which had applied the file under its former
version) failed with SQLSTATE 2BP01. Order swapped; replay verified on
staging.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XzFmmH82hCJNmZbPqycDiW

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-09-02 18:14:49 +02:00
committed by GitHub
co-authored by Claude Fable 5.1
parent c40e63e3f7
commit c0818bb2d2
80 changed files with 11247 additions and 32 deletions
+76 -1
View File
@@ -57,6 +57,7 @@ import {
Pencil,
Copy,
MoreHorizontal,
ClipboardList,
} from 'lucide-react'
import { useCanWrite } from '@/lib/hooks/use-can-write'
import { useCompany, useCapability } from '@/contexts/CompanyContext'
@@ -82,7 +83,7 @@ import {
} from '@/components/ui/dialog'
import type { Invoice, InvoiceItem, InvoiceStatus, InvoiceReminder, InvoiceDocumentType } from '@/types'
import type { InvoiceWithRelations } from '@/components/invoices/types'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
import { getErrorMessage as getUserErrorMessage, type ErrorLocale } from '@/lib/errors/get-error-message'
import { useBranding } from '@/lib/branding/brand-context'
import { DetailPageSkeleton } from '@/components/common/DetailPageSkeleton'
@@ -211,6 +212,7 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
const [showSendDialog, setShowSendDialog] = useState(false)
const [sendDialogMode, setSendDialogMode] = useState<'email' | 'manual'>('email')
const [isConverting, setIsConverting] = useState(false)
const [isCreatingOrder, setIsCreatingOrder] = useState(false)
const [isLoading, setIsLoading] = useState(true)
const [isUpdating, setIsUpdating] = useState(false)
const [isDownloading, setIsDownloading] = useState(false)
@@ -663,6 +665,55 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
setIsConverting(false)
}
// Proforma -> draft kundorder (sibling of convertToInvoice). The proforma is
// cancelled by the service; the user lands on the new order.
async function convertToOrder() {
if (!invoice) return
setIsCreatingOrder(true)
try {
const response = await fetch(`/api/invoices/${invoice.id}/convert-to-order`, {
method: 'POST',
})
const data = await response.json().catch(() => null)
if (!response.ok) {
toast({
title: t('create_order_failed_title'),
description: getUserErrorMessage(data, {
locale: locale as ErrorLocale,
statusCode: response.status,
}),
variant: 'destructive',
})
setIsCreatingOrder(false)
return
}
toast({
title: t('order_created_toast_title'),
description: data?.data?.order_number
? t('order_created_toast_description', { number: data.data.order_number })
: undefined,
})
const salesOrderId: string | undefined = data?.sales_order_id ?? data?.data?.id
if (salesOrderId) {
router.push(`/sales-orders/${salesOrderId}`)
} else {
// 2xx without a parsable body: the order exists, so refresh instead
// of turning the success into a failure toast.
router.refresh()
setIsCreatingOrder(false)
}
} catch (error) {
// Network failure (offline, aborted): the structured envelope never
// arrived, so map the raw error the same way the convert action does.
toast({
title: t('create_order_failed_title'),
description: getUserErrorMessage(error, { locale: locale as ErrorLocale }),
variant: 'destructive',
})
setIsCreatingOrder(false)
}
}
/**
* Fetch and save one specific document, then say truthfully which one it was.
*
@@ -1472,6 +1523,23 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
{t('convert_to_invoice')}
</Button>
)}
{isProforma && invoice.status !== 'cancelled' && (
<Button
variant="outline"
onClick={convertToOrder}
disabled={isCreatingOrder || !canWrite}
title={!canWrite ? t('viewer_disabled_tooltip') : undefined}
>
{isCreatingOrder ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : !canWrite ? (
<Lock className="mr-2 h-4 w-4" />
) : (
<ClipboardList className="mr-2 h-4 w-4" />
)}
{t('create_order')}
</Button>
)}
{isUnnumberedDraft && (
<Button
onClick={openFinalizeDialog}
@@ -1884,6 +1952,13 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
</Link>
</DefRow>
)}
{invoice.sales_order_id && (
<DefRow label={t('def_sales_order')}>
<Link href={`/sales-orders/${invoice.sales_order_id}`} className="hover:underline">
{t('open_sales_order')}
</Link>
</DefRow>
)}
</DetailSection>
</div>
+3
View File
@@ -400,6 +400,8 @@ export default async function DashboardLayout({
// mechanism as paysSalaries: UI gate only, never load-bearing for
// correctness (dimensions plan §2).
const dimensionsEnabled = settings?.dimensions_enabled ?? false
// Kundorder visibility: same UI-only gate as dimensionsEnabled.
const salesOrdersEnabled = settings?.sales_orders_enabled ?? false
// Körjournal visibility: the settings toggle is the normal way in, existing
// trips force the row on so already-created data stays reachable.
const hasMileage = (settings?.mileage_enabled ?? false) || hasMileageTrips
@@ -586,6 +588,7 @@ export default async function DashboardLayout({
entityType={entityType}
paysSalaries={paysSalaries}
dimensionsEnabled={dimensionsEnabled}
salesOrdersEnabled={salesOrdersEnabled}
hasWebshop={hasWebshop}
hasMileage={hasMileage}
isSandbox={isSandbox}
@@ -0,0 +1,77 @@
'use client'
import { use, useEffect, useState } from 'react'
import Link from 'next/link'
import { useRouter } from 'next/navigation'
import { useTranslations } from 'next-intl'
import { ArrowLeft } from 'lucide-react'
import { useToast } from '@/components/ui/use-toast'
import { DetailPageSkeleton } from '@/components/common/DetailPageSkeleton'
import SalesOrderForm from '@/components/sales-orders/SalesOrderForm'
import type { SalesOrder } from '@/types'
/**
* Edit an existing kundorder (draft or confirmed). Loads the order with its
* lines and hands it to the same form used for creation; line ids ride along
* so delivered/invoiced history survives the replace.
*/
export default function EditSalesOrderPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = use(params)
const t = useTranslations('sales_order_form')
const router = useRouter()
const { toast } = useToast()
const [order, setOrder] = useState<SalesOrder | null>(null)
const [isLoading, setIsLoading] = useState(true)
useEffect(() => {
let cancelled = false
fetch(`/api/sales-orders/${id}`)
.then(async (res) => {
if (!res.ok) throw new Error('load failed')
const { data } = await res.json()
return data as SalesOrder
})
.then((data) => {
if (cancelled) return
if (data.status !== 'draft' && data.status !== 'confirmed') {
toast({ title: t('not_editable'), variant: 'destructive' })
router.replace(`/sales-orders/${id}`)
return
}
setOrder(data)
})
.catch(() => {
if (cancelled) return
toast({ title: t('load_failed_title'), variant: 'destructive' })
router.replace('/sales-orders')
})
.finally(() => {
if (!cancelled) setIsLoading(false)
})
return () => {
cancelled = true
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [id])
if (isLoading) return <DetailPageSkeleton />
if (!order) return null
return (
<div className="space-y-8">
<div>
<Link
href={`/sales-orders/${id}`}
className="mb-6 flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground"
>
<ArrowLeft className="h-4 w-4" />
{t('back_to_order')}
</Link>
<h1 data-ph-mask="" className="font-display text-2xl leading-8 tracking-tight">
{t('title_edit', { number: order.order_number ?? '' })}
</h1>
</div>
<SalesOrderForm mode="edit" initial={order} />
</div>
)
}
+499
View File
@@ -0,0 +1,499 @@
'use client'
import { use, useCallback, useEffect, useState } from 'react'
import Link from 'next/link'
import { useRouter } from 'next/navigation'
import { useLocale, useTranslations } from 'next-intl'
import { ArrowLeft, Check, ClipboardList, Lock, Pencil, ReceiptText, Truck } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { DetailSection, DefRow, DefEmpty } from '@/components/ui/detail-section'
import { TH_CLASS, TD_CLASS } from '@/components/ui/dry-table'
import { ConfirmDialog } from '@/components/ui/confirm-dialog'
import { DestructiveConfirmDialog, useDestructiveConfirm } from '@/components/ui/destructive-confirm-dialog'
import { DetailPageSkeleton } from '@/components/common/DetailPageSkeleton'
import { useToast } from '@/components/ui/use-toast'
import { useCanWrite } from '@/lib/hooks/use-can-write'
import { getErrorMessage, type ErrorLocale } from '@/lib/errors/get-error-message'
import { cn, formatCurrency, formatDate } from '@/lib/utils'
import RegisterDeliveryDialog from '@/components/sales-orders/RegisterDeliveryDialog'
import CreateInvoiceDialog from '@/components/sales-orders/CreateInvoiceDialog'
import {
DELIVERY_LABEL_KEY,
INVOICING_LABEL_KEY,
STATUS_BADGE_VARIANT,
STATUS_LABEL_KEY,
formatQty,
} from '@/components/sales-orders/labels'
import type { Invoice, InvoiceStatus, SalesOrder, SalesOrderItem } from '@/types'
type Transition = 'confirm' | 'cancel' | 'reopen'
const TRANSITION_COPY: Record<
Transition,
{ title: string; description: string; label: string; toast: string; destructive: boolean }
> = {
confirm: {
title: 'confirm_dialog_title',
description: 'confirm_dialog_description',
label: 'confirm_dialog_label',
toast: 'confirmed_toast',
destructive: false,
},
cancel: {
title: 'cancel_dialog_title',
description: 'cancel_dialog_description',
label: 'cancel_dialog_label',
toast: 'cancelled_toast',
destructive: true,
},
reopen: {
title: 'reopen_dialog_title',
description: 'reopen_dialog_description',
label: 'reopen_dialog_label',
toast: 'reopened_toast',
destructive: false,
},
}
function isTextLine(item: SalesOrderItem): boolean {
return item.line_type === 'text'
}
const INVOICE_STATUS_LABEL_KEY: Record<InvoiceStatus, string> = {
draft: 'invoice_status_draft',
sent: 'invoice_status_sent',
paid: 'invoice_status_paid',
partially_paid: 'invoice_status_partially_paid',
overdue: 'invoice_status_overdue',
cancelled: 'invoice_status_cancelled',
credited: 'invoice_status_credited',
}
// Chips mark exceptions: a draft, an overdue or a cancelled invoice deviates
// from the paid-in-time path; the rest render as muted text.
const INVOICE_STATUS_BADGE: Partial<Record<InvoiceStatus, 'outline' | 'warning' | 'destructive'>> = {
draft: 'outline',
overdue: 'warning',
cancelled: 'destructive',
}
export default function SalesOrderDetailPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = use(params)
const t = useTranslations('sales_order_detail')
const tList = useTranslations('sales_orders')
const tCommon = useTranslations('common')
const errorLocale = useLocale() as ErrorLocale
const router = useRouter()
const { toast } = useToast()
const { canWrite } = useCanWrite()
const { dialogProps: deleteDialogProps, confirm: confirmDelete } = useDestructiveConfirm()
const [order, setOrder] = useState<SalesOrder | null>(null)
const [isLoading, setIsLoading] = useState(true)
const [pendingTransition, setPendingTransition] = useState<Transition | null>(null)
const [isDeliveryOpen, setIsDeliveryOpen] = useState(false)
const [isInvoiceOpen, setIsInvoiceOpen] = useState(false)
// Invoices created from this order (GET /api/invoices?sales_order_id=).
const [invoices, setInvoices] = useState<Invoice[]>([])
const fetchOrder = useCallback(async () => {
try {
const res = await fetch(`/api/sales-orders/${id}`)
if (!res.ok) throw new Error('load failed')
const { data } = await res.json()
setOrder(data as SalesOrder)
} catch {
toast({ title: t('load_failed_title'), variant: 'destructive' })
router.push('/sales-orders')
} finally {
setIsLoading(false)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [id])
const fetchInvoices = useCallback(async () => {
try {
const res = await fetch(`/api/invoices?sales_order_id=${encodeURIComponent(id)}&limit=200`)
const json = await res.json().catch(() => null)
if (!res.ok) throw Object.assign(new Error('load failed'), { body: json, status: res.status })
setInvoices((json?.data ?? []) as Invoice[])
} catch (err) {
const e = err as { body?: unknown; status?: number }
toast({
title: t('invoices_load_failed_title'),
description: getErrorMessage(e.body ?? err, { locale: errorLocale, statusCode: e.status }),
variant: 'destructive',
})
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [id])
useEffect(() => {
void fetchOrder()
void fetchInvoices()
}, [fetchOrder, fetchInvoices])
async function runTransition(action: Transition) {
if (!order) return
const res = await fetch(`/api/sales-orders/${order.id}/transition`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action }),
})
const json = await res.json().catch(() => null)
if (!res.ok) {
toast({
title: t('transition_failed_title'),
description: getErrorMessage(json, { locale: errorLocale, statusCode: res.status }),
variant: 'destructive',
})
return
}
setOrder(json.data as SalesOrder)
toast({ title: t(TRANSITION_COPY[action].toast) })
setPendingTransition(null)
}
async function handleDelete() {
if (!order) return
const number = order.order_number ?? ''
await confirmDelete(
{
title: t('delete_confirm_title', { number }),
description: t('delete_confirm_description'),
confirmLabel: t('delete_confirm_label'),
variant: 'destructive',
},
async () => {
const res = await fetch(`/api/sales-orders/${order.id}`, { method: 'DELETE' })
const json = await res.json().catch(() => null)
if (!res.ok) {
toast({
title: t('delete_failed_title'),
description: getErrorMessage(json, { locale: errorLocale, statusCode: res.status }),
variant: 'destructive',
})
throw new Error('delete failed')
}
toast({ title: t('deleted_toast') })
router.push('/sales-orders')
},
)
}
if (isLoading) return <DetailPageSkeleton />
if (!order) return null
const status = order.status
const number = order.order_number ?? ''
const badgeVariant = STATUS_BADGE_VARIANT[status]
const canEdit = status === 'draft' || status === 'confirmed'
const canConfirm = status === 'draft'
const canDeliver = status === 'confirmed' || status === 'completed'
const canInvoice = status === 'confirmed'
const canCancel = status === 'draft' || status === 'confirmed'
const canReopen = status === 'cancelled'
const canDelete = status === 'draft' || status === 'cancelled'
const items = [...(order.items ?? [])].sort((a, b) => a.sort_order - b.sort_order)
const lockTitle = !canWrite ? t('viewer_disabled_tooltip') : undefined
const pending = pendingTransition ? TRANSITION_COPY[pendingTransition] : null
return (
<div className="space-y-8 stagger-enter">
<div>
<Link
href="/sales-orders"
className="mb-6 flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground"
>
<ArrowLeft className="h-4 w-4" />
{t('back')}
</Link>
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-3">
<h1 data-ph-mask="" className="font-display text-2xl leading-8 tracking-tight">
{number ? t('title', { number }) : t('title_unnumbered')}
</h1>
{badgeVariant ? (
<Badge variant={badgeVariant}>{tList(STATUS_LABEL_KEY[status])}</Badge>
) : (
<span className="text-sm text-muted-foreground">{tList(STATUS_LABEL_KEY[status])}</span>
)}
</div>
<p className="mt-1 text-sm text-muted-foreground" data-ph-mask="">
{[order.customer?.name, formatDate(order.order_date)].filter(Boolean).join(' · ')}
</p>
</div>
<div className="flex shrink-0 flex-wrap items-center justify-end gap-2">
{canEdit && (
<Button variant="outline" asChild={canWrite} disabled={!canWrite} title={lockTitle}>
{canWrite ? (
<Link href={`/sales-orders/${order.id}/edit`}>
<Pencil className="mr-2 h-4 w-4" />
{t('action_edit')}
</Link>
) : (
<span>
<Lock className="mr-2 h-4 w-4" />
{t('action_edit')}
</span>
)}
</Button>
)}
{canDeliver && (
<Button
variant={canInvoice ? 'outline' : 'default'}
onClick={() => setIsDeliveryOpen(true)}
disabled={!canWrite}
title={lockTitle}
>
{canWrite ? <Truck className="mr-2 h-4 w-4" /> : <Lock className="mr-2 h-4 w-4" />}
{t('action_register_delivery')}
</Button>
)}
{canInvoice && (
<Button onClick={() => setIsInvoiceOpen(true)} disabled={!canWrite} title={lockTitle}>
{canWrite ? <ReceiptText className="mr-2 h-4 w-4" /> : <Lock className="mr-2 h-4 w-4" />}
{t('action_create_invoice')}
</Button>
)}
{canConfirm && (
<Button onClick={() => setPendingTransition('confirm')} disabled={!canWrite} title={lockTitle}>
{canWrite ? <Check className="mr-2 h-4 w-4" /> : <Lock className="mr-2 h-4 w-4" />}
{t('action_confirm')}
</Button>
)}
{canReopen && (
<Button onClick={() => setPendingTransition('reopen')} disabled={!canWrite} title={lockTitle}>
{canWrite ? <ClipboardList className="mr-2 h-4 w-4" /> : <Lock className="mr-2 h-4 w-4" />}
{t('action_reopen')}
</Button>
)}
</div>
</div>
</div>
<DetailSection kicker={t('section_details')}>
<DefRow label={t('def_customer')}>
{order.customer ? (
<Link href={`/customers/${order.customer.id}`} className="hover:underline">
{order.customer.name}
</Link>
) : (
<DefEmpty />
)}
</DefRow>
<DefRow label={t('def_order_date')}>
<span className="tabular-nums">{formatDate(order.order_date)}</span>
</DefRow>
<DefRow label={t('def_requested_delivery_date')}>
{order.requested_delivery_date ? (
<span className="tabular-nums">{formatDate(order.requested_delivery_date)}</span>
) : (
<DefEmpty />
)}
</DefRow>
{order.last_delivery_date && (
<DefRow label={t('def_last_delivery_date')}>
<span className="tabular-nums">{formatDate(order.last_delivery_date)}</span>
</DefRow>
)}
<DefRow label={t('def_delivery')}>
<span className="text-muted-foreground">
{tList(DELIVERY_LABEL_KEY[order.delivery_progress ?? 'none'])}
</span>
</DefRow>
<DefRow label={t('def_invoicing')}>
<span className="text-muted-foreground">
{tList(INVOICING_LABEL_KEY[order.invoicing_progress ?? 'none'])}
</span>
</DefRow>
<DefRow label={t('def_currency')}>{order.currency}</DefRow>
{order.your_reference && <DefRow label={t('def_your_reference')}>{order.your_reference}</DefRow>}
{order.our_reference && <DefRow label={t('def_our_reference')}>{order.our_reference}</DefRow>}
{order.source_invoice_id && (
<DefRow label={t('def_source_invoice')}>
<Link href={`/invoices/${order.source_invoice_id}`} className="hover:underline">
{t('source_proforma')}
</Link>
</DefRow>
)}
{order.notes && (
<DefRow label={t('def_notes')}>
<span className="whitespace-pre-wrap">{order.notes}</span>
</DefRow>
)}
</DetailSection>
<DetailSection kicker={t('section_lines')}>
<div className="overflow-x-auto">
<table className="w-full border-collapse text-[13px]">
<thead>
<tr>
<th className={cn(TH_CLASS, 'pl-0')}>{t('th_description')}</th>
<th className={cn(TH_CLASS, 'text-right')}>{t('th_quantity')}</th>
<th className={cn(TH_CLASS, 'text-right')}>{t('th_delivered')}</th>
<th className={cn(TH_CLASS, 'text-right')}>{t('th_invoiced')}</th>
<th className={cn(TH_CLASS, 'text-right')}>{t('th_remaining')}</th>
<th className={cn(TH_CLASS, 'hidden text-right md:table-cell')}>{t('th_unit_price')}</th>
<th className={cn(TH_CLASS, 'hidden text-right md:table-cell')}>{t('th_discount')}</th>
<th className={cn(TH_CLASS, 'hidden text-right md:table-cell')}>{t('th_vat')}</th>
<th className={cn(TH_CLASS, 'pr-0 text-right')}>{t('th_amount')}</th>
</tr>
</thead>
<tbody>
{items.map((item) =>
isTextLine(item) ? (
<tr key={item.id}>
<td colSpan={9} className={cn(TD_CLASS, 'pl-0 pr-0 text-muted-foreground')}>
{item.description || ' '}
</td>
</tr>
) : (
<tr key={item.id}>
<td className={cn(TD_CLASS, 'pl-0')}>{item.description}</td>
<td className={cn(TD_CLASS, 'whitespace-nowrap text-right tabular-nums')}>
{formatQty(item.quantity)} {item.unit}
</td>
<td className={cn(TD_CLASS, 'whitespace-nowrap text-right tabular-nums text-muted-foreground')}>
{formatQty(item.delivered_qty)}
</td>
<td className={cn(TD_CLASS, 'whitespace-nowrap text-right tabular-nums text-muted-foreground')}>
{formatQty(item.invoiced_qty ?? 0)}
</td>
<td className={cn(TD_CLASS, 'whitespace-nowrap text-right tabular-nums')}>
{formatQty(item.remaining_qty ?? Math.max(0, item.quantity - (item.invoiced_qty ?? 0)))}
</td>
<td className={cn(TD_CLASS, 'hidden whitespace-nowrap text-right tabular-nums md:table-cell')}>
{formatCurrency(item.unit_price, order.currency)}
</td>
<td className={cn(TD_CLASS, 'hidden whitespace-nowrap text-right tabular-nums text-muted-foreground md:table-cell')}>
{item.discount_percent > 0 ? `${formatQty(item.discount_percent)} %` : ''}
</td>
<td className={cn(TD_CLASS, 'hidden whitespace-nowrap text-right tabular-nums text-muted-foreground md:table-cell')}>
{formatQty(item.vat_rate)} %
</td>
<td className={cn(TD_CLASS, 'whitespace-nowrap pr-0 text-right tabular-nums')}>
{formatCurrency(item.line_total, order.currency)}
</td>
</tr>
),
)}
</tbody>
</table>
</div>
<div className="mt-6 ml-auto w-full max-w-xs space-y-1 text-sm">
<div className="flex justify-between">
<span className="text-muted-foreground">{t('subtotal')}</span>
<span className="tabular-nums">{formatCurrency(order.subtotal, order.currency)}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">{t('vat')}</span>
<span className="tabular-nums">{formatCurrency(order.vat_amount, order.currency)}</span>
</div>
<div className="flex justify-between border-t border-border pt-2">
<span>{t('total')}</span>
<span className="font-display text-xl tabular-nums">{formatCurrency(order.total, order.currency)}</span>
</div>
</div>
</DetailSection>
{invoices.length > 0 && (
<DetailSection kicker={t('section_invoices')}>
<table className="w-full border-collapse text-[13px]">
<thead>
<tr>
<th className={cn(TH_CLASS, 'pl-0')}>{t('th_invoice_number')}</th>
<th className={TH_CLASS}>{t('th_invoice_status')}</th>
<th className={cn(TH_CLASS, 'pr-0 text-right')}>{t('th_invoice_total')}</th>
</tr>
</thead>
<tbody>
{invoices.map((invoice) => {
const variant = INVOICE_STATUS_BADGE[invoice.status]
const label = t(INVOICE_STATUS_LABEL_KEY[invoice.status] ?? 'invoice_status_draft')
return (
<tr key={invoice.id} className="transition-colors duration-150 hover:bg-secondary/35">
<td className={cn(TD_CLASS, 'pl-0 tabular-nums')}>
<Link href={`/invoices/${invoice.id}`} className="hover:underline">
{invoice.invoice_number ?? t('invoice_draft_label')}
</Link>
</td>
<td className={cn(TD_CLASS, 'whitespace-nowrap')}>
{variant ? (
<Badge variant={variant} className="font-normal">{label}</Badge>
) : (
<span className="text-muted-foreground">{label}</span>
)}
</td>
<td className={cn(TD_CLASS, 'whitespace-nowrap pr-0 text-right tabular-nums')}>
{formatCurrency(invoice.total, invoice.currency)}
</td>
</tr>
)
})}
</tbody>
</table>
</DetailSection>
)}
{(canCancel || canDelete) && canWrite && (
<div className="flex flex-wrap items-center justify-end gap-1">
{canCancel && (
<Button
variant="ghost"
size="sm"
onClick={() => setPendingTransition('cancel')}
className="min-h-10 text-muted-foreground hover:text-destructive"
>
{t('action_cancel')}
</Button>
)}
{canDelete && (
<Button
variant="ghost"
size="sm"
onClick={handleDelete}
className="min-h-10 text-muted-foreground hover:text-destructive"
>
{t('action_delete')}
</Button>
)}
</div>
)}
{pending && pendingTransition && (
<ConfirmDialog
open
onOpenChange={(open) => !open && setPendingTransition(null)}
title={t(pending.title, { number })}
description={t(pending.description)}
confirmLabel={t(pending.label)}
cancelLabel={tCommon('cancel')}
destructive={pending.destructive}
onConfirm={() => runTransition(pendingTransition)}
/>
)}
<RegisterDeliveryDialog
open={isDeliveryOpen}
onOpenChange={setIsDeliveryOpen}
order={order}
onRegistered={setOrder}
/>
<CreateInvoiceDialog
open={isInvoiceOpen}
onOpenChange={setIsInvoiceOpen}
order={order}
onCreated={(next) => {
setOrder(next)
void fetchInvoices()
}}
/>
<DestructiveConfirmDialog {...deleteDialogProps} />
</div>
)
}
+25
View File
@@ -0,0 +1,25 @@
'use client'
import Link from 'next/link'
import { useTranslations } from 'next-intl'
import { ArrowLeft } from 'lucide-react'
import SalesOrderForm from '@/components/sales-orders/SalesOrderForm'
export default function NewSalesOrderPage() {
const t = useTranslations('sales_order_form')
return (
<div className="space-y-8">
<div>
<Link
href="/sales-orders"
className="mb-6 flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground"
>
<ArrowLeft className="h-4 w-4" />
{t('back')}
</Link>
<h1 className="font-display text-2xl leading-8 tracking-tight">{t('title_create')}</h1>
</div>
<SalesOrderForm mode="create" />
</div>
)
}
+283
View File
@@ -0,0 +1,283 @@
'use client'
import { Suspense, useCallback, useEffect, useMemo, useState } from 'react'
import Link from 'next/link'
import { usePathname, useRouter, useSearchParams } from 'next/navigation'
import { useLocale, useTranslations } from 'next-intl'
import { ClipboardList, Lock, Plus } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Skeleton } from '@/components/ui/skeleton'
import { EmptyState } from '@/components/ui/empty-state'
import { ToolbarSearch } from '@/components/ui/toolbar-search'
import { ContextPicker } from '@/components/common/ContextPicker'
import { TH_CLASS, TD_CLASS } from '@/components/ui/dry-table'
import { useToast } from '@/components/ui/use-toast'
import { useCanWrite } from '@/lib/hooks/use-can-write'
import { getErrorMessage, type ErrorLocale } from '@/lib/errors/get-error-message'
import { cn, formatCurrency, formatDate } from '@/lib/utils'
import {
DELIVERY_LABEL_KEY,
INVOICING_LABEL_KEY,
STATUS_BADGE_VARIANT,
STATUS_LABEL_KEY,
} from '@/components/sales-orders/labels'
import type { SalesOrder, SalesOrderStatus } from '@/types'
const ALL = 'all'
const STATUSES: SalesOrderStatus[] = ['draft', 'confirmed', 'completed', 'cancelled']
const INITIAL_VISIBLE_ROWS = 100
function isStatus(value: string | null): value is SalesOrderStatus {
return !!value && (STATUSES as string[]).includes(value)
}
function SalesOrdersPageInner() {
const t = useTranslations('sales_orders')
const tCommon = useTranslations('common')
const errorLocale = useLocale() as ErrorLocale
const { canWrite } = useCanWrite()
const { toast } = useToast()
const router = useRouter()
const pathname = usePathname()
const searchParams = useSearchParams()
const statusParam = searchParams.get('status')
const statusFilter: SalesOrderStatus | typeof ALL = isStatus(statusParam) ? statusParam : ALL
const [orders, setOrders] = useState<SalesOrder[]>([])
const [isLoading, setIsLoading] = useState(true)
const [searchTerm, setSearchTerm] = useState('')
const [visibleCount, setVisibleCount] = useState(INITIAL_VISIBLE_ROWS)
const updateStatus = useCallback(
(next: string) => {
setVisibleCount(INITIAL_VISIBLE_ROWS)
const params = new URLSearchParams(searchParams.toString())
if (next === ALL) params.delete('status')
else params.set('status', next)
const query = params.toString()
router.replace(query ? `${pathname}?${query}` : pathname, { scroll: false })
},
[searchParams, router, pathname],
)
useEffect(() => {
let cancelled = false
setIsLoading(true)
const query = statusFilter === ALL ? '' : `?status=${statusFilter}`
fetch(`/api/sales-orders${query}`)
.then(async (res) => {
const json = await res.json().catch(() => null)
if (!res.ok) throw Object.assign(new Error('load failed'), { body: json, status: res.status })
return (json?.data ?? []) as SalesOrder[]
})
.then((data) => {
if (!cancelled) setOrders(data)
})
.catch((err) => {
if (cancelled) return
toast({
title: t('load_failed_title'),
description: getErrorMessage(err.body ?? err, { locale: errorLocale, statusCode: err.status }),
variant: 'destructive',
})
})
.finally(() => {
if (!cancelled) setIsLoading(false)
})
return () => {
cancelled = true
}
// toast/t are stable enough; the fetch keys on the status filter only.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [statusFilter])
const filtered = useMemo(() => {
const term = searchTerm.trim().toLowerCase()
if (!term) return orders
return orders.filter(
(o) =>
(o.order_number ?? '').toLowerCase().includes(term) ||
(o.customer?.name ?? '').toLowerCase().includes(term),
)
}, [orders, searchTerm])
const visible = filtered.slice(0, visibleCount)
const statusItems = [
{ id: ALL, label: t('status_all') },
...STATUSES.map((s) => ({ id: s, label: t(STATUS_LABEL_KEY[s]) })),
]
return (
<div className="space-y-8">
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<h1 className="font-display text-2xl leading-8 tracking-tight">{t('title')}</h1>
<Button asChild={canWrite} disabled={!canWrite} title={!canWrite ? t('viewer_disabled_tooltip') : undefined}>
{canWrite ? (
<Link href="/sales-orders/new">
<Plus className="mr-2 h-4 w-4" />
{t('new_order')}
</Link>
) : (
<span>
<Lock className="mr-2 h-4 w-4" />
{t('new_order')}
</span>
)}
</Button>
</div>
<div className="flex flex-wrap items-center gap-2">
<ContextPicker
value={statusFilter}
onChange={updateStatus}
ariaLabel={t('status_picker_aria')}
triggerLabel={statusFilter === ALL ? t('status_all') : t(STATUS_LABEL_KEY[statusFilter])}
items={statusItems}
/>
<ToolbarSearch
containerClassName="min-w-[190px]"
placeholder={t('search_placeholder')}
value={searchTerm}
onChange={(e) => {
setSearchTerm(e.target.value)
setVisibleCount(INITIAL_VISIBLE_ROWS)
}}
/>
</div>
{isLoading ? (
<div className="space-y-3">
{[1, 2, 3, 4, 5].map((i) => (
<Skeleton key={i} className="h-10 w-full" />
))}
</div>
) : filtered.length === 0 ? (
searchTerm ? (
<EmptyState
icon={ClipboardList}
title={t('no_search_results_title')}
description={<span data-ph-mask="">{t('no_search_results_description', { term: searchTerm })}</span>}
/>
) : statusFilter !== ALL ? (
<EmptyState
icon={ClipboardList}
title={t('no_search_results_title')}
description={t('no_status_results_description')}
/>
) : (
<EmptyState
icon={ClipboardList}
title={t('empty_title')}
description={t('empty_description')}
actionLabel={canWrite ? t('empty_action') : undefined}
actionHref={canWrite ? '/sales-orders/new' : undefined}
/>
)
) : (
<>
<div className="overflow-x-auto">
<table className="w-full border-collapse text-[13px]">
<thead>
<tr>
<th className={TH_CLASS}>{t('col_number')}</th>
<th className={cn(TH_CLASS, 'w-full')}>{t('col_customer')}</th>
<th className={cn(TH_CLASS, 'hidden md:table-cell')}>{t('col_date')}</th>
<th className={TH_CLASS}>{t('col_status')}</th>
<th className={cn(TH_CLASS, 'hidden lg:table-cell')}>{t('col_delivery')}</th>
<th className={cn(TH_CLASS, 'hidden lg:table-cell')}>{t('col_invoicing')}</th>
<th className={cn(TH_CLASS, 'text-right')}>{t('col_total')}</th>
</tr>
</thead>
<tbody className="stagger-enter">
{visible.map((order) => {
const badgeVariant = STATUS_BADGE_VARIANT[order.status]
return (
<tr
key={order.id}
className="group cursor-pointer transition-colors duration-150 hover:bg-secondary/35"
onClick={() => router.push(`/sales-orders/${order.id}`)}
>
<td className={cn(TD_CLASS, 'whitespace-nowrap tabular-nums text-muted-foreground')}>
{order.order_number || '-'}
</td>
<td className={cn(TD_CLASS, 'max-w-0 w-full')}>
<Link
href={`/sales-orders/${order.id}`}
className="block truncate hover:underline"
onClick={(e) => e.stopPropagation()}
>
{order.customer?.name || '-'}
</Link>
</td>
<td className={cn(TD_CLASS, 'hidden whitespace-nowrap tabular-nums text-muted-foreground md:table-cell')}>
{formatDate(order.order_date)}
</td>
<td className={cn(TD_CLASS, 'whitespace-nowrap')}>
{badgeVariant ? (
<Badge variant={badgeVariant} className="font-normal">
{t(STATUS_LABEL_KEY[order.status])}
</Badge>
) : (
<span className="text-muted-foreground">{t(STATUS_LABEL_KEY[order.status])}</span>
)}
</td>
<td className={cn(TD_CLASS, 'hidden whitespace-nowrap text-muted-foreground lg:table-cell')}>
{t(DELIVERY_LABEL_KEY[order.delivery_progress ?? 'none'])}
</td>
<td className={cn(TD_CLASS, 'hidden whitespace-nowrap text-muted-foreground lg:table-cell')}>
{t(INVOICING_LABEL_KEY[order.invoicing_progress ?? 'none'])}
</td>
<td className={cn(TD_CLASS, 'whitespace-nowrap text-right tabular-nums')}>
{formatCurrency(order.total, order.currency)}
</td>
</tr>
)
})}
</tbody>
</table>
</div>
<p className="px-1 text-xs text-muted-foreground tabular-nums">
{t('count_footer', { count: filtered.length })}
</p>
{visibleCount < filtered.length && (
<div className="flex justify-center">
<Button
type="button"
variant="outline"
onClick={() => setVisibleCount((count) => count + INITIAL_VISIBLE_ROWS)}
>
{tCommon('load_more')}
</Button>
</div>
)}
</>
)}
</div>
)
}
export default function SalesOrdersPage() {
return (
<Suspense
fallback={
<div className="space-y-8">
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<Skeleton className="h-9 w-40" />
<Skeleton className="h-10 w-32" />
</div>
<div className="space-y-3">
{[1, 2, 3, 4].map((i) => (
<Skeleton key={i} className="h-10 w-full" />
))}
</div>
</div>
}
>
<SalesOrdersPageInner />
</Suspense>
)
}
@@ -175,6 +175,29 @@ describe('PATCH /api/invoices/[id]', () => {
expect(emitSpy).not.toHaveBeenCalled()
})
it('returns 409 INVOICE_UPDATE_DROPS_ORDER_LINK when the new lines drop a kundorder link', async () => {
enqueue({
data: { id: 'inv-1', status: 'draft', invoice_number: null, journal_entry_id: null, is_self_billed: false },
error: null,
}) // existing
enqueue({ data: makeCustomer({ id: 'customer-1', customer_type: 'swedish_business' }), error: null }) // customer
enqueue({ data: { vat_registered: true }, error: null }) // settings
enqueue({ data: [{ id: 'inv-1' }], error: null }) // header update matched
enqueue({
// snapshot: the stored line is linked to an order line; VALID_BODY carries no sales_order_item_id
data: [{ id: 'item-old-1', invoice_id: 'inv-1', sales_order_item_id: 'd1000000-0000-4000-8000-000000000001' }],
error: null,
})
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(await patch('inv-1'))
expect(status).toBe(409)
expect(body.error.code).toBe('INVOICE_UPDATE_DROPS_ORDER_LINK')
// The guard fires before the delete: from() was called for existing,
// customer, settings, update and snapshot only, never for delete/insert.
expect(mockSupabase.from).toHaveBeenCalledTimes(5)
})
it('returns 409 when the draft is sent/finalized concurrently (0-row update)', async () => {
enqueue({
data: { id: 'inv-1', status: 'draft', invoice_number: null, journal_entry_id: null, is_self_billed: false },
@@ -0,0 +1,330 @@
/**
* POST /api/invoices/[id]/convert-to-order: proforma -> draft kundorder.
*
* Queue order: invoices select (proforma + items), sales_orders head count
* (already converted?), then createSalesOrder (customers select,
* sales_orders insert, sales_order_items insert, generate number rpc,
* sales_orders select, invoiced rpc), then the invoices compare-and-set
* update that marks the proforma cancelled.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { NextResponse } from 'next/server'
import {
createQueuedMockSupabase,
createMockRequest,
createMockRouteParams,
parseJsonResponse,
makeInvoice,
} from '@/tests/helpers'
import { IDS, makeOrderCustomer, makeSalesOrder } from '@/lib/sales-orders/__tests__/fixtures'
import type { SalesOrder } from '@/types'
const { supabase, enqueue, reset, findCall, findCalls } = createQueuedMockSupabase()
const requireAuthMock = vi.fn()
vi.mock('@/lib/auth/require-auth', () => ({
requireAuth: (...args: unknown[]) => requireAuthMock(...args),
}))
vi.mock('@/lib/company/context', () => ({
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
}))
const requireWriteMock = vi.fn()
vi.mock('@/lib/auth/require-write', () => ({
requireWritePermission: (...args: unknown[]) => requireWriteMock(...args),
}))
vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() }))
import { POST } from '../route'
const params = createMockRouteParams({ id: IDS.invoice })
function post() {
return POST(createMockRequest(`/api/invoices/${IDS.invoice}/convert-to-order`, { method: 'POST' }), params)
}
function makeProforma(overrides: Record<string, unknown> = {}) {
return {
...makeInvoice({
id: IDS.invoice,
customer_id: IDS.customer,
document_type: 'proforma',
status: 'sent',
invoice_number: 'P-2026001',
your_reference: 'Anna',
notes: 'Enligt offert',
}),
items: [
{
id: 'e2000000-0000-4000-8000-000000000002',
sort_order: 1,
line_type: 'text',
description: 'Tack för förtroendet',
quantity: 0,
unit: null,
unit_price: 0,
vat_rate: 0,
},
{
id: 'e2000000-0000-4000-8000-000000000001',
sort_order: 0,
line_type: 'product',
description: 'Konsulttimme',
quantity: 10,
unit: 'h',
unit_price: 100,
discount_percent: null,
vat_rate: 25,
article_id: null,
revenue_account: '3011',
dimensions: { project: 'P1' },
},
] as Record<string, unknown>[],
...overrides,
}
}
describe('POST /api/invoices/[id]/convert-to-order', () => {
beforeEach(() => {
vi.clearAllMocks()
reset()
requireAuthMock.mockResolvedValue({ user: { id: IDS.user }, supabase })
requireWriteMock.mockResolvedValue({ ok: true })
})
it('returns 401 when not authenticated', async () => {
requireAuthMock.mockResolvedValue({
user: null,
supabase,
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
})
const { status } = await parseJsonResponse(await post())
expect(status).toBe(401)
expect(supabase.from).not.toHaveBeenCalled()
})
it('returns 403 for a viewer', async () => {
requireWriteMock.mockResolvedValue({
ok: false,
response: NextResponse.json({ error: 'Forbidden' }, { status: 403 }),
})
const { status } = await parseJsonResponse(await post())
expect(status).toBe(403)
})
it('returns 404 when the invoice is missing', async () => {
enqueue({ data: null })
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(await post())
expect(status).toBe(404)
expect(body.error.code).toBe('INVOICE_NOT_FOUND')
})
it('returns 400 SALES_ORDER_SOURCE_NOT_PROFORMA for a real invoice', async () => {
enqueue({ data: makeProforma({ document_type: 'invoice' }) })
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(await post())
expect(status).toBe(400)
expect(body.error.code).toBe('SALES_ORDER_SOURCE_NOT_PROFORMA')
expect(findCall('sales_orders', 'insert')).toBeUndefined()
})
it('returns 409 SALES_ORDER_SOURCE_ALREADY_CONVERTED for a cancelled proforma', async () => {
enqueue({ data: makeProforma({ status: 'cancelled' }) })
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(await post())
expect(status).toBe(409)
expect(body.error.code).toBe('SALES_ORDER_SOURCE_ALREADY_CONVERTED')
})
it('returns 409 SALES_ORDER_SOURCE_ALREADY_CONVERTED when an order already points at the proforma', async () => {
enqueue({ data: makeProforma() })
enqueue({ data: null, count: 1 })
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(await post())
expect(status).toBe(409)
expect(body.error.code).toBe('SALES_ORDER_SOURCE_ALREADY_CONVERTED')
expect(findCalls('sales_orders', 'eq')).toContainEqual(['source_invoice_id', IDS.invoice])
})
it('returns 409 SALES_ORDER_CUSTOMER_MISSING for a proforma without customer', async () => {
enqueue({ data: makeProforma({ customer_id: null }) })
enqueue({ data: null, count: 0 })
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(await post())
expect(status).toBe(409)
expect(body.error.code).toBe('SALES_ORDER_CUSTOMER_MISSING')
})
it('returns 400 SALES_ORDER_SOURCE_UNSUPPORTED_LINES for a ROT line (order lines carry no skattereduktion)', async () => {
const proforma = makeProforma()
proforma.items[1] = {
...proforma.items[1],
deduction_type: 'rot',
labor_hours: 8,
work_type: 'bygg',
}
enqueue({ data: proforma })
enqueue({ data: null, count: 0 })
const { status, body } = await parseJsonResponse<{ error: { code: string; details: { lines: Record<string, unknown>[] } } }>(
await post(),
)
expect(status).toBe(400)
expect(body.error.code).toBe('SALES_ORDER_SOURCE_UNSUPPORTED_LINES')
expect(body.error.details.lines).toEqual([
{
invoice_item_id: 'e2000000-0000-4000-8000-000000000001',
deduction_type: 'rot',
accrual: false,
quantity: 10,
},
])
// Refused after the already-converted count, before the customer load and any insert.
expect(findCalls('sales_orders', 'eq')).toContainEqual(['source_invoice_id', IDS.invoice])
expect(findCall('customers', 'select')).toBeUndefined()
expect(findCall('sales_orders', 'insert')).toBeUndefined()
expect(findCall('invoices', 'update')).toBeUndefined()
})
it('returns 400 SALES_ORDER_SOURCE_UNSUPPORTED_LINES for a negative-quantity line', async () => {
const proforma = makeProforma()
proforma.items.push({
...proforma.items[1],
id: 'e2000000-0000-4000-8000-000000000003',
sort_order: 2,
description: 'Rabatt',
quantity: -1,
unit_price: 200,
})
enqueue({ data: proforma })
enqueue({ data: null, count: 0 })
const { status, body } = await parseJsonResponse<{ error: { code: string; details: { lines: Record<string, unknown>[] } } }>(
await post(),
)
expect(status).toBe(400)
expect(body.error.code).toBe('SALES_ORDER_SOURCE_UNSUPPORTED_LINES')
expect(body.error.details.lines).toEqual([
{
invoice_item_id: 'e2000000-0000-4000-8000-000000000003',
deduction_type: null,
accrual: false,
quantity: -1,
},
])
expect(findCall('sales_orders', 'insert')).toBeUndefined()
})
it('returns 400 SALES_ORDER_SOURCE_UNSUPPORTED_LINES for a periodised line', async () => {
const proforma = makeProforma()
proforma.items[1] = {
...proforma.items[1],
accrual_period_start: '2026-09-01',
accrual_period_end: '2027-08-31',
accrual_balance_account: '2990',
}
enqueue({ data: proforma })
enqueue({ data: null, count: 0 })
const { status, body } = await parseJsonResponse<{ error: { code: string; details: { lines: Record<string, unknown>[] } } }>(
await post(),
)
expect(status).toBe(400)
expect(body.error.code).toBe('SALES_ORDER_SOURCE_UNSUPPORTED_LINES')
expect(body.error.details.lines[0]).toMatchObject({ deduction_type: null, accrual: true })
expect(findCall('sales_orders', 'insert')).toBeUndefined()
})
it('does not treat a text row as unsupported (text rows have no deduction or quantity)', async () => {
// Same happy path as below, with the text row carrying a stray negative
// quantity: text rows are copied as quantity 0 and never gate the convert.
const proforma = makeProforma()
proforma.items[0] = { ...proforma.items[0], quantity: -1 }
enqueue({ data: proforma })
enqueue({ data: null, count: 0 })
enqueue({ data: makeOrderCustomer() })
enqueue({ data: { id: IDS.order } })
enqueue({ data: null })
enqueue({ data: 'OR-1' })
enqueue({ data: makeSalesOrder({ source_invoice_id: IDS.invoice, order_number: 'OR-1' }) })
enqueue({ data: [] })
enqueue({ data: [{ id: IDS.invoice }] })
const { status } = await parseJsonResponse(await post())
expect(status).toBe(201)
const lines = findCall('sales_order_items', 'insert')![0] as Record<string, unknown>[]
expect(lines[1]).toMatchObject({ line_type: 'text', quantity: 0 })
})
it('creates a draft order from the proforma, cancels the proforma and answers 201 with sales_order_id', async () => {
enqueue({ data: makeProforma() })
enqueue({ data: null, count: 0 })
enqueue({ data: makeOrderCustomer() })
enqueue({ data: { id: IDS.order } }) // sales_orders insert
enqueue({ data: null }) // sales_order_items insert
enqueue({ data: 'OR-1' }) // generate_sales_order_number
enqueue({ data: makeSalesOrder({ source_invoice_id: IDS.invoice, order_number: 'OR-1' }) })
enqueue({ data: [] })
enqueue({ data: [{ id: IDS.invoice }] }) // proforma CAS update
const { status, body } = await parseJsonResponse<{ data: SalesOrder; sales_order_id: string }>(await post())
expect(status).toBe(201)
expect(body.sales_order_id).toBe(IDS.order)
expect(body.data.id).toBe(IDS.order)
expect(body.data.source_invoice_id).toBe(IDS.invoice)
expect(body.data.status).toBe('draft')
expect(findCall('sales_orders', 'insert')![0]).toMatchObject({
customer_id: IDS.customer,
source_invoice_id: IDS.invoice,
currency: 'SEK',
your_reference: 'Anna',
notes: 'Enligt offert',
subtotal: 1000,
vat_amount: 250,
total: 1250,
})
// Lines copied in proforma sort order: product first, text row second.
const lines = findCall('sales_order_items', 'insert')![0] as Record<string, unknown>[]
expect(lines).toHaveLength(2)
expect(lines[0]).toMatchObject({
sort_order: 0,
line_type: 'product',
description: 'Konsulttimme',
quantity: 10,
unit: 'h',
revenue_account: '3011',
dimensions: { project: 'P1' },
line_total: 1000,
})
expect(lines[1]).toMatchObject({ sort_order: 1, line_type: 'text', quantity: 0, line_total: 0 })
expect(findCall('invoices', 'update')![0]).toEqual({ status: 'cancelled' })
expect(findCall('invoices', 'neq')).toEqual(['status', 'cancelled'])
expect(findCall('sales_orders', 'delete')).toBeUndefined()
})
it('removes the fresh order and answers 409 when the proforma was converted concurrently', async () => {
enqueue({ data: makeProforma() })
enqueue({ data: null, count: 0 })
enqueue({ data: makeOrderCustomer() })
enqueue({ data: { id: IDS.order } })
enqueue({ data: null })
enqueue({ data: 'OR-1' })
enqueue({ data: makeSalesOrder({ source_invoice_id: IDS.invoice }) })
enqueue({ data: [] })
enqueue({ data: [] }) // CAS update matched nothing
enqueue({ data: null }) // order delete
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(await post())
expect(status).toBe(409)
expect(body.error.code).toBe('SALES_ORDER_SOURCE_ALREADY_CONVERTED')
expect(findCall('sales_orders', 'delete')).toBeDefined()
expect(findCalls('sales_orders', 'eq')).toContainEqual(['id', IDS.order])
})
})
@@ -0,0 +1,23 @@
import { NextResponse } from 'next/server'
import { ensureInitialized } from '@/lib/init'
import { withRouteContext } from '@/lib/api/with-route-context'
import { convertProformaToSalesOrder } from '@/lib/sales-orders/convert-proforma'
import { serviceFailureResponse } from '@/lib/sales-orders/respond'
ensureInitialized()
/**
* POST /api/invoices/[id]/convert-to-order: proforma -> draft kundorder.
* Sibling of /convert (proforma -> invoice): copies the lines into a new
* order and marks the proforma cancelled.
*/
export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
'invoice.convert_to_order',
async (_request, { supabase, user, companyId, log, requestId }, { params }) => {
const { id } = await params
const result = await convertProformaToSalesOrder(supabase, { companyId, userId: user.id, invoiceId: id })
if (!result.ok) return serviceFailureResponse(result, log, requestId)
return NextResponse.json({ data: result.order, sales_order_id: result.order.id }, { status: 201 })
},
{ requireWrite: true },
)
+3
View File
@@ -189,6 +189,9 @@ export const PATCH = withRouteContext<{ params: Promise<{ id: string }> }>(
// nothing else.
const replaced = await replaceInvoiceItems(supabase, id, build.items)
if (!replaced.ok) {
if (replaced.stage === 'guard') {
return errorResponseFromCode(replaced.code, ctxLog, { requestId })
}
ctxLog.error(`invoice items ${replaced.stage} failed on update`, replaced.error, { invoiceId: id })
return errorResponseFromCode('INVOICE_CREATE_ITEMS_FAILED', ctxLog, {
requestId,
+5
View File
@@ -35,6 +35,11 @@ export const GET = withRouteContext(
if (status) {
query = query.eq('status', status)
}
// Kundorder detail: the invoices created from one order.
const salesOrderId = searchParams.get('sales_order_id')
if (salesOrderId && /^[0-9a-f-]{36}$/i.test(salesOrderId)) {
query = query.eq('sales_order_id', salesOrderId)
}
const { data, error, count } = await query
@@ -0,0 +1,51 @@
import { NextResponse } from 'next/server'
import { eventBus } from '@/lib/events'
import { ensureInitialized } from '@/lib/init'
import { withRouteContext } from '@/lib/api/with-route-context'
import { validateBody } from '@/lib/api/validate'
import { CreateInvoiceFromSalesOrderSchema } from '@/lib/api/schemas'
import { createInvoiceFromSalesOrder } from '@/lib/sales-orders/create-invoice-from-order'
import { serviceFailureResponse } from '@/lib/sales-orders/respond'
ensureInitialized()
/**
* POST /api/sales-orders/[id]/create-invoice: create an unnumbered DRAFT
* kundfaktura for the picked (or remaining / delivered) order lines. The
* user reviews and sends it through the normal invoice flow, which books
* it. Partial invoicing is the point: call again for the rest.
*/
export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
'sales_order.create_invoice',
async (request, { supabase, user, companyId, log, requestId }, { params }) => {
const { id } = await params
const validation = await validateBody(request, CreateInvoiceFromSalesOrderSchema, {
log,
operation: 'sales_order.create_invoice',
})
if (!validation.success) return validation.response
const result = await createInvoiceFromSalesOrder(supabase, {
companyId,
userId: user.id,
orderId: id,
input: validation.data,
})
if (!result.ok) return serviceFailureResponse(result, log, requestId)
try {
await eventBus.emit({
type: 'invoice.created',
payload: { invoice: result.invoice, userId: user.id, companyId },
})
} catch {
// Non-critical
}
return NextResponse.json(
{ data: { invoice: result.invoice, order: result.order }, invoice_id: result.invoice.id },
{ status: 201 },
)
},
{ requireWrite: true },
)
@@ -0,0 +1,24 @@
import { NextResponse } from 'next/server'
import { ensureInitialized } from '@/lib/init'
import { withRouteContext } from '@/lib/api/with-route-context'
import { validateBody } from '@/lib/api/validate'
import { RegisterSalesOrderDeliverySchema } from '@/lib/api/schemas'
import { registerSalesOrderDelivery } from '@/lib/sales-orders/register-delivery'
import { serviceFailureResponse } from '@/lib/sales-orders/respond'
ensureInitialized()
/** POST /api/sales-orders/[id]/deliver: register cumulative delivered quantities. */
export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
'sales_order.deliver',
async (request, { supabase, companyId, log, requestId }, { params }) => {
const { id } = await params
const validation = await validateBody(request, RegisterSalesOrderDeliverySchema, { log, operation: 'sales_order.deliver' })
if (!validation.success) return validation.response
const result = await registerSalesOrderDelivery(supabase, { companyId, orderId: id, input: validation.data })
if (!result.ok) return serviceFailureResponse(result, log, requestId)
return NextResponse.json({ data: result.order })
},
{ requireWrite: true },
)
+89
View File
@@ -0,0 +1,89 @@
import { NextResponse } from 'next/server'
import { ensureInitialized } from '@/lib/init'
import { withRouteContext } from '@/lib/api/with-route-context'
import { validateBody } from '@/lib/api/validate'
import { UpdateSalesOrderSchema } from '@/lib/api/schemas'
import { loadSalesOrder } from '@/lib/sales-orders/load'
import { hasOpenInvoices, updateSalesOrder } from '@/lib/sales-orders/write'
import { serviceFailureResponse } from '@/lib/sales-orders/respond'
import { codeFromPgError } from '@/lib/sales-orders/result'
import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error'
ensureInitialized()
type Ctx = { params: Promise<{ id: string }> }
/** GET /api/sales-orders/[id]: one order with lines + derived quantities. */
export const GET = withRouteContext<Ctx>(
'sales_order.get',
async (_request, { supabase, companyId, log, requestId }, { params }) => {
const { id } = await params
const result = await loadSalesOrder(supabase, companyId, id)
if (!result.ok) return serviceFailureResponse(result, log, requestId)
return NextResponse.json({ data: result.order })
},
)
/** PATCH /api/sales-orders/[id]: edit header and/or replace lines (draft or confirmed). */
export const PATCH = withRouteContext<Ctx>(
'sales_order.update',
async (request, { supabase, companyId, log, requestId }, { params }) => {
const { id } = await params
const validation = await validateBody(request, UpdateSalesOrderSchema, { log, operation: 'sales_order.update' })
if (!validation.success) return validation.response
const result = await updateSalesOrder(supabase, { companyId, orderId: id, input: validation.data })
if (!result.ok) return serviceFailureResponse(result, log, requestId)
return NextResponse.json({ data: result.order })
},
{ requireWrite: true },
)
/**
* DELETE /api/sales-orders/[id]: hard delete a draft or cancelled order
* that no invoice was created from. Orders never book and carry no
* sequence obligation, so nothing is lost; the RESTRICT FKs make a linked
* order undeletable at the DB level regardless.
*/
export const DELETE = withRouteContext<Ctx>(
'sales_order.delete',
async (_request, { supabase, companyId, log, requestId }, { params }) => {
const { id } = await params
const current = await loadSalesOrder(supabase, companyId, id)
if (!current.ok) return serviceFailureResponse(current, log, requestId)
if (current.order.status !== 'draft' && current.order.status !== 'cancelled') {
return errorResponseFromCode('SALES_ORDER_INVALID_STATE', log, {
requestId,
details: { status: current.order.status, action: 'delete' },
})
}
const open = await hasOpenInvoices(supabase, companyId, id)
if (!open.ok) return errorResponse(open.dbError, log, { requestId })
if (open.open) return errorResponseFromCode('SALES_ORDER_HAS_INVOICES', log, { requestId })
// Status in the predicate: a concurrent confirm between the read above
// and this delete must not remove a confirmed order.
const { data: deleted, error } = await supabase
.from('sales_orders')
.delete()
.eq('id', id)
.eq('company_id', companyId)
.in('status', ['draft', 'cancelled'])
.select('id')
if (!error && (!deleted || deleted.length === 0)) {
return errorResponseFromCode('SALES_ORDER_INVALID_STATE', log, {
requestId,
details: { action: 'delete', reason: 'status changed concurrently' },
})
}
if (error) {
// A makulerad (cancelled) invoice still references the order: the
// RESTRICT FK is the authority, surfaced as the structured code.
const code = codeFromPgError(error)
if (code) return errorResponseFromCode(code, log, { requestId })
return errorResponse(error, log, { requestId })
}
return NextResponse.json({ data: { id, deleted: true } })
},
{ requireWrite: true },
)
@@ -0,0 +1,24 @@
import { NextResponse } from 'next/server'
import { ensureInitialized } from '@/lib/init'
import { withRouteContext } from '@/lib/api/with-route-context'
import { validateBody } from '@/lib/api/validate'
import { SalesOrderTransitionSchema } from '@/lib/api/schemas'
import { transitionSalesOrder } from '@/lib/sales-orders/transitions'
import { serviceFailureResponse } from '@/lib/sales-orders/respond'
ensureInitialized()
/** POST /api/sales-orders/[id]/transition: confirm | cancel | reopen. */
export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
'sales_order.transition',
async (request, { supabase, companyId, log, requestId }, { params }) => {
const { id } = await params
const validation = await validateBody(request, SalesOrderTransitionSchema, { log, operation: 'sales_order.transition' })
if (!validation.success) return validation.response
const result = await transitionSalesOrder(supabase, { companyId, orderId: id, action: validation.data.action })
if (!result.ok) return serviceFailureResponse(result, log, requestId)
return NextResponse.json({ data: result.order })
},
{ requireWrite: true },
)
@@ -0,0 +1,208 @@
/**
* POST /api/sales-orders/[id]/create-invoice: unnumbered draft kundfaktura
* from an order, with the invoice builder mocked.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { NextResponse } from 'next/server'
import {
createQueuedMockSupabase,
createMockRequest,
createMockRouteParams,
parseJsonResponse,
} from '@/tests/helpers'
import { eventBus } from '@/lib/events'
import { IDS, invoicedRow, makeOrderCustomer, makeSalesOrder, makeSalesOrderItem } from '@/lib/sales-orders/__tests__/fixtures'
import type { Invoice, SalesOrder } from '@/types'
const { supabase, enqueue, reset, findCall } = createQueuedMockSupabase()
const requireAuthMock = vi.fn()
vi.mock('@/lib/auth/require-auth', () => ({
requireAuth: (...args: unknown[]) => requireAuthMock(...args),
}))
vi.mock('@/lib/company/context', () => ({
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
}))
const requireWriteMock = vi.fn()
vi.mock('@/lib/auth/require-write', () => ({
requireWritePermission: (...args: unknown[]) => requireWriteMock(...args),
}))
vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() }))
const mockBuildInvoiceWriteData = vi.fn()
vi.mock('@/lib/invoices/build-invoice-write', () => ({
buildInvoiceWriteData: (...args: unknown[]) => mockBuildInvoiceWriteData(...args),
}))
import { POST } from '../[id]/create-invoice/route'
const params = createMockRouteParams({ id: IDS.order })
function post(body: unknown = {}) {
return POST(createMockRequest(`/api/sales-orders/${IDS.order}/create-invoice`, { method: 'POST', body }), params)
}
const okBuild = {
ok: true,
invoiceFields: {
customer_id: IDS.customer,
invoice_date: '2026-09-02',
due_date: '2026-10-02',
currency: 'SEK',
subtotal: 1000,
vat_amount: 250,
total: 1250,
},
items: [
{
sort_order: 0,
line_type: 'product',
description: 'Konsulttimme',
quantity: 10,
unit: 'h',
unit_price: 100,
line_total: 1000,
vat_rate: 25,
vat_amount: 250,
sales_order_item_id: IDS.item1,
},
],
}
const confirmed = () =>
makeSalesOrder({
status: 'confirmed',
confirmed_at: '2026-09-01T10:00:00Z',
items: [makeSalesOrderItem({ id: IDS.item1, quantity: 10 })],
})
describe('POST /api/sales-orders/[id]/create-invoice', () => {
beforeEach(() => {
vi.clearAllMocks()
reset()
eventBus.clear()
requireAuthMock.mockResolvedValue({ user: { id: IDS.user }, supabase })
requireWriteMock.mockResolvedValue({ ok: true })
mockBuildInvoiceWriteData.mockResolvedValue(okBuild)
})
it('returns 401 when not authenticated', async () => {
requireAuthMock.mockResolvedValue({
user: null,
supabase,
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
})
const { status } = await parseJsonResponse(await post())
expect(status).toBe(401)
expect(mockBuildInvoiceWriteData).not.toHaveBeenCalled()
})
it('returns 403 for a viewer', async () => {
requireWriteMock.mockResolvedValue({
ok: false,
response: NextResponse.json({ error: 'Forbidden' }, { status: 403 }),
})
const { status } = await parseJsonResponse(await post())
expect(status).toBe(403)
})
it('returns 400 for a picked line with quantity 0', async () => {
const { status } = await parseJsonResponse(
await post({ lines: [{ sales_order_item_id: IDS.item1, quantity: 0 }] }),
)
expect(status).toBe(400)
expect(supabase.from).not.toHaveBeenCalled()
})
it('returns 400 for an unknown mode', async () => {
const { status } = await parseJsonResponse(await post({ mode: 'everything' }))
expect(status).toBe(400)
})
it('returns 404 when the order is missing', async () => {
enqueue({ data: null })
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(await post())
expect(status).toBe(404)
expect(body.error.code).toBe('SALES_ORDER_NOT_FOUND')
})
it('returns 409 SALES_ORDER_INVALID_STATE for a draft order', async () => {
enqueue({ data: makeSalesOrder({ status: 'draft' }) })
enqueue({ data: [] })
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(await post())
expect(status).toBe(409)
expect(body.error.code).toBe('SALES_ORDER_INVALID_STATE')
})
it('returns 409 SALES_ORDER_OVER_INVOICED for a pick above the remaining quantity', async () => {
enqueue({ data: confirmed() })
enqueue({ data: [invoicedRow(IDS.item1, 7)] })
const { status, body } = await parseJsonResponse<{ error: { code: string; details: Record<string, unknown> } }>(
await post({ lines: [{ sales_order_item_id: IDS.item1, quantity: 4 }] }),
)
expect(status).toBe(409)
expect(body.error.code).toBe('SALES_ORDER_OVER_INVOICED')
expect(body.error.details).toMatchObject({ remaining_qty: 3, requested_qty: 4 })
expect(mockBuildInvoiceWriteData).not.toHaveBeenCalled()
})
it('returns 409 SALES_ORDER_NOTHING_TO_INVOICE when everything is invoiced', async () => {
enqueue({ data: confirmed() })
enqueue({ data: [invoicedRow(IDS.item1, 10)] })
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(await post())
expect(status).toBe(409)
expect(body.error.code).toBe('SALES_ORDER_NOTHING_TO_INVOICE')
})
it('creates the draft, links it to the order and answers 201 with invoice_id', async () => {
const emitted: string[] = []
eventBus.on('invoice.created', async () => {
emitted.push('invoice.created')
})
enqueue({ data: confirmed() })
enqueue({ data: [] })
enqueue({ data: makeOrderCustomer() })
enqueue({ data: { id: IDS.invoice, status: 'draft', invoice_number: null, sales_order_id: IDS.order } })
enqueue({ data: null }) // invoice_items insert
enqueue({ data: confirmed() }) // reload
enqueue({ data: [invoicedRow(IDS.item1, 10)] })
const { status, body } = await parseJsonResponse<{
data: { invoice: Invoice; order: SalesOrder }
invoice_id: string
}>(await post({ invoice_date: '2026-09-02' }))
expect(status).toBe(201)
expect(body.invoice_id).toBe(IDS.invoice)
expect(body.data.invoice.id).toBe(IDS.invoice)
expect(body.data.order.invoicing_progress).toBe('full')
expect(mockBuildInvoiceWriteData).toHaveBeenCalledWith(expect.objectContaining({ documentType: 'invoice' }))
const invoiceInsert = findCall('invoices', 'insert')![0] as Record<string, unknown>
expect(invoiceInsert).toMatchObject({ sales_order_id: IDS.order, invoice_number: null, status: 'draft' })
const itemRows = findCall('invoice_items', 'insert')![0] as Record<string, unknown>[]
expect(itemRows[0]).toMatchObject({ invoice_id: IDS.invoice, sales_order_item_id: IDS.item1 })
expect(emitted).toEqual(['invoice.created'])
})
it('rolls the draft back and answers 409 when the over-invoice trigger fires', async () => {
enqueue({ data: confirmed() })
enqueue({ data: [] })
enqueue({ data: makeOrderCustomer() })
enqueue({ data: { id: IDS.invoice, status: 'draft' } })
enqueue({ data: null, error: { message: 'SALES_ORDER_OVER_INVOICED: exceeds ordered', code: 'P0001' } })
enqueue({ data: null })
enqueue({ data: null })
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(await post())
expect(status).toBe(409)
expect(body.error.code).toBe('SALES_ORDER_OVER_INVOICED')
expect(findCall('invoices', 'delete')).toBeDefined()
})
})
@@ -0,0 +1,159 @@
/**
* POST /api/sales-orders/[id]/deliver (cumulative delivered quantities).
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { NextResponse } from 'next/server'
import {
createQueuedMockSupabase,
createMockRequest,
createMockRouteParams,
parseJsonResponse,
} from '@/tests/helpers'
import { IDS, makeSalesOrder, makeSalesOrderItem } from '@/lib/sales-orders/__tests__/fixtures'
import type { SalesOrder } from '@/types'
const { supabase, enqueue, reset, findCall } = createQueuedMockSupabase()
const requireAuthMock = vi.fn()
vi.mock('@/lib/auth/require-auth', () => ({
requireAuth: (...args: unknown[]) => requireAuthMock(...args),
}))
vi.mock('@/lib/company/context', () => ({
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
}))
const requireWriteMock = vi.fn()
vi.mock('@/lib/auth/require-write', () => ({
requireWritePermission: (...args: unknown[]) => requireWriteMock(...args),
}))
vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() }))
import { POST } from '../[id]/deliver/route'
const params = createMockRouteParams({ id: IDS.order })
function post(body: unknown) {
return POST(createMockRequest(`/api/sales-orders/${IDS.order}/deliver`, { method: 'POST', body }), params)
}
const confirmed = (delivered = 0) =>
makeSalesOrder({
status: 'confirmed',
confirmed_at: '2026-09-01T10:00:00Z',
items: [makeSalesOrderItem({ id: IDS.item1, quantity: 10, delivered_qty: delivered })],
})
describe('POST /api/sales-orders/[id]/deliver', () => {
beforeEach(() => {
vi.clearAllMocks()
reset()
requireAuthMock.mockResolvedValue({ user: { id: IDS.user }, supabase })
requireWriteMock.mockResolvedValue({ ok: true })
})
it('returns 401 when not authenticated', async () => {
requireAuthMock.mockResolvedValue({
user: null,
supabase,
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
})
const { status } = await parseJsonResponse(
await post({ lines: [{ sales_order_item_id: IDS.item1, delivered_qty: 1 }] }),
)
expect(status).toBe(401)
expect(supabase.from).not.toHaveBeenCalled()
})
it('returns 400 for an empty lines array', async () => {
const { status } = await parseJsonResponse(await post({ lines: [] }))
expect(status).toBe(400)
expect(supabase.from).not.toHaveBeenCalled()
})
it('returns 400 for a negative delivered quantity', async () => {
const { status } = await parseJsonResponse(
await post({ lines: [{ sales_order_item_id: IDS.item1, delivered_qty: -1 }] }),
)
expect(status).toBe(400)
})
it('returns 400 for a malformed delivery_date', async () => {
const { status } = await parseJsonResponse(
await post({ delivery_date: '02/09/2026', lines: [{ sales_order_item_id: IDS.item1, delivered_qty: 1 }] }),
)
expect(status).toBe(400)
})
it('returns 404 when the order is missing', async () => {
enqueue({ data: null })
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(
await post({ lines: [{ sales_order_item_id: IDS.item1, delivered_qty: 1 }] }),
)
expect(status).toBe(404)
expect(body.error.code).toBe('SALES_ORDER_NOT_FOUND')
})
it('returns 409 SALES_ORDER_INVALID_STATE for a draft order', async () => {
enqueue({ data: makeSalesOrder({ status: 'draft' }) })
enqueue({ data: [] })
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(
await post({ lines: [{ sales_order_item_id: IDS.item1, delivered_qty: 1 }] }),
)
expect(status).toBe(409)
expect(body.error.code).toBe('SALES_ORDER_INVALID_STATE')
})
it('returns 400 SALES_ORDER_OVER_DELIVERED when delivering more than ordered', async () => {
enqueue({ data: confirmed() })
enqueue({ data: [] })
const { status, body } = await parseJsonResponse<{ error: { code: string; details: Record<string, unknown> } }>(
await post({ lines: [{ sales_order_item_id: IDS.item1, delivered_qty: 11 }] }),
)
expect(status).toBe(400)
expect(body.error.code).toBe('SALES_ORDER_OVER_DELIVERED')
expect(body.error.details).toMatchObject({ quantity: 10, delivered_qty: 11 })
expect(findCall('sales_order_items', 'update')).toBeUndefined()
})
it('returns 409 SALES_ORDER_INVALID_STATE when the line moved concurrently (update matched zero rows)', async () => {
enqueue({ data: confirmed(0) })
enqueue({ data: [] })
enqueue({ data: [] }) // line update: optimistic predicate on delivered_qty did not match
const { status, body } = await parseJsonResponse<{ error: { code: string; details: Record<string, unknown> } }>(
await post({ delivery_date: '2026-09-02', lines: [{ sales_order_item_id: IDS.item1, delivered_qty: 6 }] }),
)
expect(status).toBe(409)
expect(body.error.code).toBe('SALES_ORDER_INVALID_STATE')
expect(body.error.details).toMatchObject({ action: 'deliver', sales_order_item_id: IDS.item1 })
expect(findCall('sales_orders', 'update')).toBeUndefined()
})
it('registers the delivery and answers with the reloaded order', async () => {
enqueue({ data: confirmed(0) })
enqueue({ data: [] })
enqueue({ data: [{ id: IDS.item1 }] }) // line update (optimistic write matched the row)
enqueue({ data: null }) // last_delivery_date update
enqueue({ data: { ...confirmed(6), last_delivery_date: '2026-09-02' } })
enqueue({ data: [] })
const { status, body } = await parseJsonResponse<{ data: SalesOrder }>(
await post({ delivery_date: '2026-09-02', lines: [{ sales_order_item_id: IDS.item1, delivered_qty: 6 }] }),
)
expect(status).toBe(200)
expect(body.data.last_delivery_date).toBe('2026-09-02')
expect(body.data.delivery_progress).toBe('partial')
expect(body.data.items?.[0].delivered_qty).toBe(6)
// The increased line gets the delivery date as its own last_delivery_date.
expect(findCall('sales_order_items', 'update')![0]).toEqual({
delivered_qty: 6,
last_delivery_date: '2026-09-02',
})
expect(findCall('sales_orders', 'update')![0]).toEqual({ last_delivery_date: '2026-09-02' })
})
})
@@ -0,0 +1,306 @@
/**
* GET/PATCH/DELETE /api/sales-orders/[id].
*
* Queue order for loadSalesOrder: sales_orders select, then the
* sales_order_invoiced_quantities RPC.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { NextResponse } from 'next/server'
import {
createQueuedMockSupabase,
createMockRequest,
createMockRouteParams,
parseJsonResponse,
} from '@/tests/helpers'
import { IDS, invoicedRow, makeOrderCustomer, makeSalesOrder, makeSalesOrderItem } from '@/lib/sales-orders/__tests__/fixtures'
import type { SalesOrder } from '@/types'
const { supabase, enqueue, reset, findCall, findCalls } = createQueuedMockSupabase()
const requireAuthMock = vi.fn()
vi.mock('@/lib/auth/require-auth', () => ({
requireAuth: (...args: unknown[]) => requireAuthMock(...args),
}))
vi.mock('@/lib/company/context', () => ({
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
}))
const requireWriteMock = vi.fn()
vi.mock('@/lib/auth/require-write', () => ({
requireWritePermission: (...args: unknown[]) => requireWriteMock(...args),
}))
vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() }))
import { GET, PATCH, DELETE } from '../[id]/route'
const params = createMockRouteParams({ id: IDS.order })
const url = `/api/sales-orders/${IDS.order}`
function unauthenticated() {
requireAuthMock.mockResolvedValue({
user: null,
supabase,
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
})
}
beforeEach(() => {
vi.clearAllMocks()
reset()
requireAuthMock.mockResolvedValue({ user: { id: IDS.user }, supabase })
requireWriteMock.mockResolvedValue({ ok: true })
})
describe('GET /api/sales-orders/[id]', () => {
it('returns 401 when not authenticated', async () => {
unauthenticated()
const { status } = await parseJsonResponse(await GET(createMockRequest(url), params))
expect(status).toBe(401)
})
it('returns 404 when the order is missing', async () => {
enqueue({ data: null })
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(
await GET(createMockRequest(url), params),
)
expect(status).toBe(404)
expect(body.error.code).toBe('SALES_ORDER_NOT_FOUND')
expect(supabase.rpc).not.toHaveBeenCalled()
})
it('returns the order with sorted lines, derived quantities and a masked customer', async () => {
enqueue({
data: makeSalesOrder({
customer: makeOrderCustomer({ customer_type: 'individual', personal_number: '199001011234' }),
items: [
makeSalesOrderItem({ id: IDS.item2, sort_order: 1, quantity: 5 }),
makeSalesOrderItem({ id: IDS.item1, sort_order: 0, quantity: 10, delivered_qty: 3 }),
],
}),
})
enqueue({ data: [invoicedRow(IDS.item1, '2')] })
const { status, body } = await parseJsonResponse<{ data: SalesOrder }>(await GET(createMockRequest(url), params))
expect(status).toBe(200)
expect(body.data.id).toBe(IDS.order)
expect(body.data.items?.map((i) => i.id)).toEqual([IDS.item1, IDS.item2])
expect(body.data.items?.[0]).toMatchObject({ invoiced_qty: 2, remaining_qty: 8 })
expect(body.data.items?.[1]).toMatchObject({ invoiced_qty: 0, remaining_qty: 5 })
expect(body.data.delivery_progress).toBe('partial')
expect(body.data.invoicing_progress).toBe('partial')
// The embedded customer never carries the raw personnummer out.
expect(body.data.customer?.personal_number).not.toBe('199001011234')
expect(findCalls('sales_orders', 'eq')).toContainEqual(['id', IDS.order])
expect(findCalls('sales_orders', 'eq')).toContainEqual(['company_id', IDS.company])
})
})
describe('PATCH /api/sales-orders/[id]', () => {
function patch(body: unknown) {
return PATCH(createMockRequest(url, { method: 'PATCH', body }), params)
}
it('returns 401 when not authenticated', async () => {
unauthenticated()
const { status } = await parseJsonResponse(await patch({ notes: 'x' }))
expect(status).toBe(401)
})
it('returns 400 for an empty items array', async () => {
const { status } = await parseJsonResponse(await patch({ items: [] }))
expect(status).toBe(400)
expect(supabase.from).not.toHaveBeenCalled()
})
it('returns 400 for an unsupported currency', async () => {
const { status } = await parseJsonResponse(await patch({ currency: 'CHF' }))
expect(status).toBe(400)
})
it('returns 404 when the order is missing', async () => {
enqueue({ data: null })
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(await patch({ notes: 'x' }))
expect(status).toBe(404)
expect(body.error.code).toBe('SALES_ORDER_NOT_FOUND')
})
it('returns 409 SALES_ORDER_NOT_EDITABLE for a completed order', async () => {
enqueue({ data: makeSalesOrder({ status: 'completed' }) })
enqueue({ data: [] })
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(await patch({ notes: 'x' }))
expect(status).toBe(409)
expect(body.error.code).toBe('SALES_ORDER_NOT_EDITABLE')
})
it('returns 409 SALES_ORDER_LINE_LOCKED when an invoiced line is dropped', async () => {
enqueue({ data: makeSalesOrder({ status: 'confirmed', items: [makeSalesOrderItem({ id: IDS.item1 })] }) })
enqueue({ data: [invoicedRow(IDS.item1, 2)] })
enqueue({ data: makeOrderCustomer() })
const { status, body } = await parseJsonResponse<{ error: { code: string; details: Record<string, unknown> } }>(
await patch({ items: [{ description: 'Ny rad', quantity: 1, unit: 'st', unit_price: 10, vat_rate: 25 }] }),
)
expect(status).toBe(409)
expect(body.error.code).toBe('SALES_ORDER_LINE_LOCKED')
expect(body.error.details).toMatchObject({ sales_order_item_id: IDS.item1 })
})
it('updates the header and answers with the reloaded order', async () => {
enqueue({ data: makeSalesOrder({ status: 'draft' }) })
enqueue({ data: [] })
enqueue({ data: makeOrderCustomer() })
enqueue({ data: null }) // header update
enqueue({ data: makeSalesOrder({ status: 'draft', notes: 'Ring innan leverans' }) })
enqueue({ data: [] })
const { status, body } = await parseJsonResponse<{ data: SalesOrder }>(
await patch({ notes: 'Ring innan leverans' }),
)
expect(status).toBe(200)
expect(body.data.notes).toBe('Ring innan leverans')
expect(body.data.delivery_progress).toBe('none')
expect(findCall('sales_orders', 'update')![0]).toMatchObject({ notes: 'Ring innan leverans' })
})
})
describe('DELETE /api/sales-orders/[id]', () => {
function del() {
return DELETE(createMockRequest(url, { method: 'DELETE' }), params)
}
it('returns 401 when not authenticated', async () => {
unauthenticated()
const { status } = await parseJsonResponse(await del())
expect(status).toBe(401)
})
it('returns 404 when the order is missing', async () => {
enqueue({ data: null })
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(await del())
expect(status).toBe(404)
expect(body.error.code).toBe('SALES_ORDER_NOT_FOUND')
expect(findCall('sales_orders', 'delete')).toBeUndefined()
})
it('returns 409 for a confirmed order', async () => {
enqueue({ data: makeSalesOrder({ status: 'confirmed' }) })
enqueue({ data: [] })
const { status, body } = await parseJsonResponse<{ error: { code: string; details: Record<string, unknown> } }>(
await del(),
)
expect(status).toBe(409)
expect(body.error.code).toBe('SALES_ORDER_INVALID_STATE')
expect(body.error.details).toMatchObject({ status: 'confirmed', action: 'delete' })
expect(findCall('sales_orders', 'delete')).toBeUndefined()
})
it('returns 409 SALES_ORDER_HAS_INVOICES when an invoice is linked', async () => {
enqueue({ data: makeSalesOrder({ status: 'cancelled' }) })
enqueue({ data: [] })
enqueue({ data: [] }) // hasOpenInvoices rpc
enqueue({ data: null, count: 1 }) // header-linked invoice
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(await del())
expect(status).toBe(409)
expect(body.error.code).toBe('SALES_ORDER_HAS_INVOICES')
expect(findCall('sales_orders', 'delete')).toBeUndefined()
})
it('maps the RESTRICT FK on delete onto 409 SALES_ORDER_HAS_INVOICES (makulerad invoice still linked)', async () => {
// A cancelled invoice carries 0 invoiced quantity and is excluded from
// the header count, so the pre-checks let the delete through and the FK
// is the authority.
enqueue({ data: makeSalesOrder({ status: 'cancelled' }) })
enqueue({ data: [] })
enqueue({ data: [] }) // hasOpenInvoices rpc
enqueue({ data: null, count: 0 }) // no non-cancelled invoice
enqueue({
data: null,
error: {
code: '23503',
message:
'update or delete on table "sales_orders" violates foreign key constraint "invoices_sales_order_id_fkey" on table "invoices"',
},
})
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(await del())
expect(status).toBe(409)
expect(body.error.code).toBe('SALES_ORDER_HAS_INVOICES')
expect(findCall('sales_orders', 'delete')).toBeDefined()
})
it('maps the line-level RESTRICT FK on delete onto 409 SALES_ORDER_LINE_LOCKED', async () => {
enqueue({ data: makeSalesOrder({ status: 'draft' }) })
enqueue({ data: [] })
enqueue({ data: [] })
enqueue({ data: null, count: 0 })
enqueue({
data: null,
error: {
code: '23503',
message:
'update or delete on table "sales_order_items" violates foreign key constraint "invoice_items_sales_order_item_id_fkey" on table "invoice_items"',
},
})
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(await del())
expect(status).toBe(409)
expect(body.error.code).toBe('SALES_ORDER_LINE_LOCKED')
})
it('hard-deletes a draft with no invoices', async () => {
enqueue({ data: makeSalesOrder({ status: 'draft' }) })
enqueue({ data: [] })
enqueue({ data: [] })
enqueue({ data: null, count: 0 })
enqueue({ data: [{ id: IDS.order }] }) // delete (status-guarded, one row matched)
const { status, body } = await parseJsonResponse<{ data: { id: string; deleted: boolean } }>(await del())
expect(status).toBe(200)
expect(body.data).toEqual({ id: IDS.order, deleted: true })
expect(findCall('sales_orders', 'delete')).toBeDefined()
const eqs = findCalls('sales_orders', 'eq')
expect(eqs).toContainEqual(['id', IDS.order])
expect(eqs).toContainEqual(['company_id', IDS.company])
// Status in the delete predicate: a concurrent confirm must not be deleted.
expect(findCall('sales_orders', 'in')).toEqual(['status', ['draft', 'cancelled']])
// The first sales_orders select is loadSalesOrder's projection; the delete chain's is ['id'].
expect(findCalls('sales_orders', 'select')).toContainEqual(['id'])
})
it('returns 409 SALES_ORDER_INVALID_STATE when the delete matches zero rows (confirmed concurrently)', async () => {
enqueue({ data: makeSalesOrder({ status: 'draft' }) })
enqueue({ data: [] })
enqueue({ data: [] }) // hasOpenInvoices rpc
enqueue({ data: null, count: 0 }) // no linked invoice
enqueue({ data: [] }) // delete: the status predicate no longer matches
const { status, body } = await parseJsonResponse<{ error: { code: string; details: Record<string, unknown> } }>(
await del(),
)
expect(status).toBe(409)
expect(body.error.code).toBe('SALES_ORDER_INVALID_STATE')
expect(body.error.details).toMatchObject({ action: 'delete', reason: 'status changed concurrently' })
expect(findCall('sales_orders', 'delete')).toBeDefined()
})
it('treats a null delete result as the same conflict', async () => {
enqueue({ data: makeSalesOrder({ status: 'cancelled' }) })
enqueue({ data: [] })
enqueue({ data: [] })
enqueue({ data: null, count: 0 })
enqueue({ data: null }) // delete
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(await del())
expect(status).toBe(409)
expect(body.error.code).toBe('SALES_ORDER_INVALID_STATE')
})
})
@@ -0,0 +1,199 @@
/**
* GET/POST /api/sales-orders (kundorder list + create), through the real
* withRouteContext wrapper with its auth/company/write dependencies mocked.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { NextResponse } from 'next/server'
import { createQueuedMockSupabase, createMockRequest, parseJsonResponse } from '@/tests/helpers'
import { IDS, makeOrderCustomer, makeSalesOrder, makeSalesOrderItem, invoicedRow } from '@/lib/sales-orders/__tests__/fixtures'
import type { SalesOrder } from '@/types'
const { supabase, enqueue, reset, findCall, findCalls } = createQueuedMockSupabase()
const requireAuthMock = vi.fn()
vi.mock('@/lib/auth/require-auth', () => ({
requireAuth: (...args: unknown[]) => requireAuthMock(...args),
}))
vi.mock('@/lib/company/context', () => ({
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
}))
const requireWriteMock = vi.fn()
vi.mock('@/lib/auth/require-write', () => ({
requireWritePermission: (...args: unknown[]) => requireWriteMock(...args),
}))
vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() }))
import { GET, POST } from '../route'
const noParams = { params: Promise.resolve({}) }
const validLine = { description: 'Konsulttimme', quantity: 10, unit: 'h', unit_price: 100, vat_rate: 25 }
function unauthenticated() {
requireAuthMock.mockResolvedValue({
user: null,
supabase,
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
})
}
describe('GET /api/sales-orders', () => {
beforeEach(() => {
vi.clearAllMocks()
reset()
requireAuthMock.mockResolvedValue({ user: { id: IDS.user }, supabase })
requireWriteMock.mockResolvedValue({ ok: true })
})
it('returns 401 when not authenticated', async () => {
unauthenticated()
const { status } = await parseJsonResponse(await GET(createMockRequest('/api/sales-orders'), noParams))
expect(status).toBe(401)
expect(supabase.from).not.toHaveBeenCalled()
})
it('returns 400 for an unknown status filter', async () => {
const request = createMockRequest('/api/sales-orders', { searchParams: { status: 'shipped' } })
const { status } = await parseJsonResponse(await GET(request, noParams))
expect(status).toBe(400)
expect(supabase.from).not.toHaveBeenCalled()
})
it('lists orders decorated with invoiced quantities and progress', async () => {
enqueue({
data: [
makeSalesOrder({ items: [makeSalesOrderItem({ id: IDS.item1, quantity: 10, delivered_qty: 10 })] }),
makeSalesOrder({ id: 'a1000000-0000-4000-8000-000000000002', status: 'confirmed', items: [] }),
],
})
enqueue({ data: [invoicedRow(IDS.item1, '4')] })
const request = createMockRequest('/api/sales-orders', { searchParams: { status: 'draft', q: 'OR-1' } })
const { status, body } = await parseJsonResponse<{ data: SalesOrder[] }>(await GET(request, noParams))
expect(status).toBe(200)
expect(body.data).toHaveLength(2)
expect(body.data[0].items?.[0]).toMatchObject({ invoiced_qty: 4, remaining_qty: 6 })
expect(body.data[0].delivery_progress).toBe('full')
expect(body.data[0].invoicing_progress).toBe('partial')
expect(body.data[1].delivery_progress).toBe('none')
expect(findCalls('sales_orders', 'eq')).toContainEqual(['company_id', IDS.company])
expect(findCalls('sales_orders', 'eq')).toContainEqual(['status', 'draft'])
expect(findCall('sales_orders', 'ilike')).toEqual(['order_number', '%OR-1%'])
expect(supabase.rpc).toHaveBeenCalledWith('sales_order_invoiced_quantities', { p_order_ids: [IDS.order, 'a1000000-0000-4000-8000-000000000002'] })
})
it('returns an empty list without calling the RPC when there are no orders', async () => {
enqueue({ data: [] })
const { status, body } = await parseJsonResponse<{ data: unknown[] }>(
await GET(createMockRequest('/api/sales-orders'), noParams),
)
expect(status).toBe(200)
expect(body.data).toEqual([])
expect(supabase.rpc).not.toHaveBeenCalled()
})
})
describe('POST /api/sales-orders', () => {
beforeEach(() => {
vi.clearAllMocks()
reset()
requireAuthMock.mockResolvedValue({ user: { id: IDS.user }, supabase })
requireWriteMock.mockResolvedValue({ ok: true })
})
function post(body: unknown) {
return POST(createMockRequest('/api/sales-orders', { method: 'POST', body }), noParams)
}
it('returns 401 when not authenticated', async () => {
unauthenticated()
const { status } = await parseJsonResponse(await post({ customer_id: IDS.customer, items: [validLine] }))
expect(status).toBe(401)
})
it('returns 403 for a viewer', async () => {
requireWriteMock.mockResolvedValue({
ok: false,
response: NextResponse.json({ error: 'Forbidden' }, { status: 403 }),
})
const { status } = await parseJsonResponse(await post({ customer_id: IDS.customer, items: [validLine] }))
expect(status).toBe(403)
expect(supabase.from).not.toHaveBeenCalled()
})
it('returns 400 when items are missing', async () => {
const { status } = await parseJsonResponse(await post({ customer_id: IDS.customer }))
expect(status).toBe(400)
expect(supabase.from).not.toHaveBeenCalled()
})
it('returns 400 when customer_id is not a uuid', async () => {
const { status } = await parseJsonResponse(await post({ customer_id: 'kund-1', items: [validLine] }))
expect(status).toBe(400)
})
it('returns 400 for a product line with an empty description', async () => {
const { status } = await parseJsonResponse(
await post({ customer_id: IDS.customer, items: [{ ...validLine, description: ' ' }] }),
)
expect(status).toBe(400)
})
it('returns 404 when the customer does not exist in the company', async () => {
enqueue({ data: null })
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(
await post({ customer_id: IDS.customer, items: [validLine] }),
)
expect(status).toBe(404)
expect(body.error.code).toBe('CUSTOMER_NOT_FOUND')
})
it('returns 400 INVOICE_CREATE_VAT_RULE_VIOLATION for a rate the customer type forbids', async () => {
enqueue({ data: makeOrderCustomer({ customer_type: 'non_eu_business' }) })
const { status, body } = await parseJsonResponse<{ error: { code: string; details: Record<string, unknown> } }>(
await post({ customer_id: IDS.customer, items: [{ ...validLine, vat_rate: 20 }] }),
)
expect(status).toBe(400)
expect(body.error.code).toBe('INVOICE_CREATE_VAT_RULE_VIOLATION')
expect(findCall('sales_orders', 'insert')).toBeUndefined()
})
it('creates a draft order and answers 201 with the loaded order', async () => {
enqueue({ data: makeOrderCustomer() }) // customer lookup
enqueue({ data: { id: IDS.order } }) // header insert
enqueue({ data: null }) // lines insert
enqueue({ data: 'OR-1' }) // generate_sales_order_number
enqueue({ data: makeSalesOrder({ order_number: 'OR-1' }) }) // reload
enqueue({ data: [] }) // invoiced quantities
const { status, body } = await parseJsonResponse<{ data: SalesOrder }>(
await post({
customer_id: IDS.customer,
order_date: '2026-09-01',
items: [validLine, { line_type: 'text', description: 'Leverans v.36', quantity: 0, unit: '', unit_price: 0 }],
}),
)
expect(status).toBe(201)
expect(body.data.id).toBe(IDS.order)
expect(body.data.order_number).toBe('OR-1')
expect(body.data.status).toBe('draft')
expect(body.data.delivery_progress).toBe('none')
expect(body.data.invoicing_progress).toBe('none')
expect(findCall('sales_orders', 'insert')![0]).toMatchObject({
company_id: IDS.company,
user_id: IDS.user,
customer_id: IDS.customer,
status: 'draft',
subtotal: 1000,
vat_amount: 250,
total: 1250,
})
const lines = findCall('sales_order_items', 'insert')![0] as unknown[]
expect(lines).toHaveLength(2)
})
})
@@ -0,0 +1,126 @@
/**
* POST /api/sales-orders/[id]/transition (confirm | cancel | reopen).
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { NextResponse } from 'next/server'
import {
createQueuedMockSupabase,
createMockRequest,
createMockRouteParams,
parseJsonResponse,
} from '@/tests/helpers'
import { IDS, invoicedRow, makeSalesOrder } from '@/lib/sales-orders/__tests__/fixtures'
import type { SalesOrder } from '@/types'
const { supabase, enqueue, reset, findCall } = createQueuedMockSupabase()
const requireAuthMock = vi.fn()
vi.mock('@/lib/auth/require-auth', () => ({
requireAuth: (...args: unknown[]) => requireAuthMock(...args),
}))
vi.mock('@/lib/company/context', () => ({
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
}))
const requireWriteMock = vi.fn()
vi.mock('@/lib/auth/require-write', () => ({
requireWritePermission: (...args: unknown[]) => requireWriteMock(...args),
}))
vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() }))
import { POST } from '../[id]/transition/route'
const params = createMockRouteParams({ id: IDS.order })
function post(body: unknown) {
return POST(createMockRequest(`/api/sales-orders/${IDS.order}/transition`, { method: 'POST', body }), params)
}
describe('POST /api/sales-orders/[id]/transition', () => {
beforeEach(() => {
vi.clearAllMocks()
reset()
requireAuthMock.mockResolvedValue({ user: { id: IDS.user }, supabase })
requireWriteMock.mockResolvedValue({ ok: true })
})
it('returns 401 when not authenticated', async () => {
requireAuthMock.mockResolvedValue({
user: null,
supabase,
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
})
const { status } = await parseJsonResponse(await post({ action: 'confirm' }))
expect(status).toBe(401)
expect(supabase.from).not.toHaveBeenCalled()
})
it('returns 403 for a viewer', async () => {
requireWriteMock.mockResolvedValue({
ok: false,
response: NextResponse.json({ error: 'Forbidden' }, { status: 403 }),
})
const { status } = await parseJsonResponse(await post({ action: 'confirm' }))
expect(status).toBe(403)
})
it('returns 400 for an unknown action', async () => {
const { status } = await parseJsonResponse(await post({ action: 'ship' }))
expect(status).toBe(400)
expect(supabase.from).not.toHaveBeenCalled()
})
it('returns 400 for a missing body', async () => {
const { status } = await parseJsonResponse(await post({}))
expect(status).toBe(400)
})
it('returns 404 when the order is missing', async () => {
enqueue({ data: null })
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(await post({ action: 'confirm' }))
expect(status).toBe(404)
expect(body.error.code).toBe('SALES_ORDER_NOT_FOUND')
})
it('returns 409 SALES_ORDER_INVALID_STATE for confirm on a confirmed order', async () => {
enqueue({ data: makeSalesOrder({ status: 'confirmed' }) })
enqueue({ data: [] })
const { status, body } = await parseJsonResponse<{ error: { code: string; details: Record<string, unknown> } }>(
await post({ action: 'confirm' }),
)
expect(status).toBe(409)
expect(body.error.code).toBe('SALES_ORDER_INVALID_STATE')
expect(body.error.details).toMatchObject({ status: 'confirmed', action: 'confirm' })
})
it('returns 409 SALES_ORDER_HAS_INVOICES for cancel with a linked invoice', async () => {
enqueue({ data: makeSalesOrder({ status: 'confirmed' }) })
enqueue({ data: [invoicedRow(IDS.item1, 1)] })
enqueue({ data: [invoicedRow(IDS.item1, 1)] })
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(await post({ action: 'cancel' }))
expect(status).toBe(409)
expect(body.error.code).toBe('SALES_ORDER_HAS_INVOICES')
expect(findCall('sales_orders', 'update')).toBeUndefined()
})
it('confirms a draft and answers with the reloaded order', async () => {
enqueue({ data: makeSalesOrder({ status: 'draft' }) })
enqueue({ data: [] })
enqueue({ data: [{ id: IDS.order }] }) // CAS update
enqueue({ data: null }) // refresh_sales_order_completion
enqueue({ data: makeSalesOrder({ status: 'confirmed', confirmed_at: '2026-09-02T10:00:00Z' }) })
enqueue({ data: [] })
const { status, body } = await parseJsonResponse<{ data: SalesOrder }>(await post({ action: 'confirm' }))
expect(status).toBe(200)
expect(body.data.status).toBe('confirmed')
expect(body.data.confirmed_at).toBe('2026-09-02T10:00:00Z')
expect(body.data.delivery_progress).toBe('none')
expect(findCall('sales_orders', 'update')![0]).toMatchObject({ status: 'confirmed' })
expect(supabase.rpc).toHaveBeenCalledWith('refresh_sales_order_completion', { p_order_id: IDS.order })
})
})
+62
View File
@@ -0,0 +1,62 @@
import { NextResponse } from 'next/server'
import { ensureInitialized } from '@/lib/init'
import { withRouteContext } from '@/lib/api/with-route-context'
import { validateBody, validateQuery } from '@/lib/api/validate'
import { CreateSalesOrderSchema, SalesOrderListQuerySchema } from '@/lib/api/schemas'
import { fetchAllRows } from '@/lib/supabase/fetch-all'
import { maskEmbeddedCustomer } from '@/lib/customers/protect-personal-number'
import { decorate, fetchInvoicedQuantities } from '@/lib/sales-orders/load'
import { createSalesOrder } from '@/lib/sales-orders/write'
import { serviceFailureResponse } from '@/lib/sales-orders/respond'
import { errorResponse } from '@/lib/errors/get-structured-error'
import type { SalesOrder } from '@/types'
ensureInitialized()
/**
* GET /api/sales-orders: the company's kundorder with customer, lines and
* the derived delivery/invoicing progress. Filters: status, customer_id, q
* (order number; the list page matches customer names client-side over the
* embedded customer).
*/
export const GET = withRouteContext('sales_order.list', async (request, { supabase, companyId, log, requestId }) => {
const query = validateQuery(request, SalesOrderListQuerySchema)
if (!query.success) return query.response
const { status, customer_id, q } = query.data
let orders: SalesOrder[]
try {
orders = await fetchAllRows<SalesOrder>(({ from, to }) => {
let qb = supabase
.from('sales_orders')
.select('*, customer:customers(id, name, customer_number, customer_type), items:sales_order_items(*)')
.eq('company_id', companyId)
if (status) qb = qb.eq('status', status)
if (customer_id) qb = qb.eq('customer_id', customer_id)
if (q) qb = qb.ilike('order_number', `%${q}%`)
return qb.order('order_date', { ascending: false }).order('created_at', { ascending: false }).range(from, to)
})
} catch (err) {
return errorResponse(err, log, { requestId })
}
const invoiced = await fetchInvoicedQuantities(supabase, orders.map((o) => o.id))
if (!invoiced.ok) return errorResponse(invoiced.dbError, log, { requestId })
const data = orders.map((o) => decorate(maskEmbeddedCustomer(o), invoiced.byItem))
return NextResponse.json({ data })
})
/** POST /api/sales-orders: create a draft order with lines. */
export const POST = withRouteContext(
'sales_order.create',
async (request, { supabase, user, companyId, log, requestId }) => {
const validation = await validateBody(request, CreateSalesOrderSchema, { log, operation: 'sales_order.create' })
if (!validation.success) return validation.response
const result = await createSalesOrder(supabase, { companyId, userId: user.id, input: validation.data })
if (!result.ok) return serviceFailureResponse(result, log, requestId)
return NextResponse.json({ data: result.order }, { status: 201 })
},
{ requireWrite: true },
)
@@ -252,7 +252,10 @@ describe('PATCH /api/v1/companies/:companyId/invoices/:id', () => {
error: null,
},
company_settings: { data: { vat_registered: true }, error: null },
invoice_items: { data: null, error: null },
// replaceInvoiceItems snapshots the current rows before deleting and
// refuses (fails closed) when the snapshot is unreadable, so the
// mock must answer with a real (empty) row set.
invoice_items: { data: [], error: null },
},
captures,
),
@@ -464,6 +464,9 @@ export const PATCH = withApiV1<{ params: Promise<{ companyId: string; id: string
// cookie route and the update_invoice commit executor).
const replaced = await replaceInvoiceItems(ctx.supabase, invoiceId, build.items)
if (!replaced.ok) {
if (replaced.stage === 'guard') {
return v1ErrorResponseFromCode(replaced.code, ctx.log, { requestId: ctx.requestId })
}
ctx.log.error(`invoice items ${replaced.stage} failed on v1 update`, replaced.error, {
invoiceId,
companyId: ctx.companyId,