diff --git a/DECISIONS.md b/DECISIONS.md index 33c58eb7..c6d97d55 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1496,6 +1496,9 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and [2026-09-02] parties phase 0, golden set stays out of git: the labelling sample is prod voucher text with person names (salary, expense claims) and the repo is public, so the draw SQL is versioned but the rows and labels live in gitignored dev_docs/parties/golden/. [2026-09-02] parties substrate: customers and suppliers keep their tables and gain party_id; parties dedupe on normalised org number only, never on name at insert time. A name merge is a recorded human decision because the July measurement showed a majority of generic keys still map to one real vendor, so an automatic name merge would fuse unrelated suppliers. [2026-09-02] MCP eager-auth flag (`auth=required`) on the claude.ai connector links instead of reverting lazy auth: claude.ai's two-step Add-custom-connector dialog probes the URL without credentials and pre-fills Authentication "None" when the lazy handshake answers 200, which blocks the sign-in later; per Anthropic's docs a 401 is the only answer it reads as OAuth. The flag lives in the URL, so the links we control (Settings, onboarding checklist, both docs pages, website) get OAuth detected while the bare URL keeps lazy auth for Claude Code, the plugin, Cursor and ChatGPT, and existing connector records stay untouched. Rejected: keying eager auth off `client=claude-connector` (documented as telemetry-only) and sniffing the probe's user agent (fragile, undocumented). +[2026-09-02] Kundorder (sales orders) ship as their own tables, not as invoices.document_type = 'order': orders have a delivery axis invoices lack, and an order row inside invoices would leak into every AR/VAT/reminder surface that filters on status instead of document_type. Invoiced quantity per order line is DERIVED from invoice_items.sales_order_item_id (sum over non-cancelled, non-credited invoices) and enforced by a BEFORE trigger, never stored: a counter cannot drift and a credited invoice frees the quantity by itself. Header status is four-state (draft, confirmed, completed, cancelled) with completion maintained by DB triggers; delivery and invoicing progress are derived per line. Offert is out of scope because no quote entity exists (only six dead QUOTE_* error codes); proforma -> order reuses the proforma -> invoice convert precedent. Nav key `sales_orders` is repointed to kundorder and the webshop row renamed `webshop_orders`. +[2026-09-02] Kundorder hardening after the skeptic pass (PR #2166): (a) an invoice created from an order refuses to invoice when the customer's type or VAT-number validation differs from the snapshot the order lines were priced under (SALES_ORDER_CUSTOMER_VAT_CHANGED); re-saving the order re-validates, chosen over silently re-deriving rates because a rate change is a pricing decision the user must see. (b) replaceInvoiceItems refuses a line set that drops an existing sales_order_item_id link (INVOICE_UPDATE_DROPS_ORDER_LINK) instead of guessing which new line maps to which order line; this closes the MCP update_invoice / v1 PATCH path that would otherwise free the quantity for double invoicing. (c) Derived quantities are rounded to 6 decimals (roundQty) and compared with an epsilon because Postgres numeric is exact and JS doubles are not. (d) Invoice delivery_date comes from per-line last_delivery_date over the covered lines, only when the covered quantity was delivered; an advance invoice gets none. (e) Kept ON DELETE RESTRICT on invoices.sales_order_id and invoice_items.sales_order_item_id despite the company-cascade ordering hazard (a company hard delete would hit the RESTRICT before invoices cascade): no shipped route hard-deletes companies, transactions.document_id already carries the same RESTRICT-under-cascade shape, and SET NULL would let an order deletion silently erase invoice provenance. (f) Kept sales_orders.user_id NOT NULL ON DELETE CASCADE, matching invoices; the prod user-deletion recipe repoints authorship first. (g) Proforma -> order refuses proformas with ROT/RUT, periodisering or negative-quantity lines rather than dropping those fields. +[2026-09-02] Kundorder MCP tools: only gnubok_list_sales_orders sits in the default tools/list catalog; gnubok_get_sales_order and the four staged writes (create, transition, register delivery, create invoice from order) are catalogVisibility 'search'. The payload-size ceiling (60K tokens, payload-size.bench.test.ts) had ~900 tokens of headroom and the six tools need ~4200 (~2500 trimmed); the test forbids bumping the ceiling and demoting unrelated reads is out of the feature's scope. Same footing as the mileage and skattekonto write families; promoting the writes later means demoting other reads first. [2026-09-02] Agent-triggerable bank sync shipped (v1 POST /bank-connections/{id}/sync + MCP gnubok_sync_bank), lifting the 2026-09-01 deferral: Emil chose to close every open F2 item in one PR. The cost worry is bounded structurally instead of by policy: the window is never caller-controlled (gap-aware 7 to 90 days, same helper as the cron), a connection synced within 15 minutes answers BANK_SYNC_COOLDOWN with next_allowed_at (MCP returns it in-band as synced=false so agents read on instead of retrying), and failures are throttled per process by attempt time. The web Synka-nu route is left untouched rather than refactored onto the shared runner: it carries UI-only behaviour (caller-chosen days_back up to 365, SIE sweep stamping) and a regression there would hit every user for a code-sharing win. [2026-09-02] Bank-sync cooldown is a durable lease column (bank_connections.sync_lease_until, migration 20260902150000) claimed with one conditional UPDATE, not a process-local attempt map: the security scan on PR #2165 showed the map is bypassed by a second serverless instance or a cold start, so two agent calls could each bill Enable Banking. A column add was chosen over reusing extension_data because PostgREST cannot express an atomic conditional upsert there; the nightly cron deliberately ignores the lease. [2026-09-02] Grok links carry auth=required like the claude.ai link (#2159), decided from a live test: on the lazy URL Grok's connector dialog listed all 150+ tools and never opened the sign-in, so it reads the 200 probe as an authless server exactly as claude.ai does. The flag lives in one helper (mcpServerUrl / sideDoorServerUrl in lib/onboarding/checklist.ts) so the settings row, the onboarding side door and the deep link cannot drift; ChatGPT stays lazy because its developer mode honours the 401 on the first protected call. diff --git a/app/(dashboard)/invoices/[id]/page.tsx b/app/(dashboard)/invoices/[id]/page.tsx index 82d54b53..0db0fb31 100644 --- a/app/(dashboard)/invoices/[id]/page.tsx +++ b/app/(dashboard)/invoices/[id]/page.tsx @@ -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')} )} + {isProforma && invoice.status !== 'cancelled' && ( + + )} {isUnnumberedDraft && ( + )} + {canDeliver && ( + + )} + {canInvoice && ( + + )} + {canConfirm && ( + + )} + {canReopen && ( + + )} + + + + + + + {order.customer ? ( + + {order.customer.name} + + ) : ( + + )} + + + {formatDate(order.order_date)} + + + {order.requested_delivery_date ? ( + {formatDate(order.requested_delivery_date)} + ) : ( + + )} + + {order.last_delivery_date && ( + + {formatDate(order.last_delivery_date)} + + )} + + + {tList(DELIVERY_LABEL_KEY[order.delivery_progress ?? 'none'])} + + + + + {tList(INVOICING_LABEL_KEY[order.invoicing_progress ?? 'none'])} + + + {order.currency} + {order.your_reference && {order.your_reference}} + {order.our_reference && {order.our_reference}} + {order.source_invoice_id && ( + + + {t('source_proforma')} + + + )} + {order.notes && ( + + {order.notes} + + )} + + + +
+ + + + + + + + + + + + + + + + {items.map((item) => + isTextLine(item) ? ( + + + + ) : ( + + + + + + + + + + + + ), + )} + +
{t('th_description')}{t('th_quantity')}{t('th_delivered')}{t('th_invoiced')}{t('th_remaining')}{t('th_unit_price')}{t('th_discount')}{t('th_vat')}{t('th_amount')}
+ {item.description || ' '} +
{item.description} + {formatQty(item.quantity)} {item.unit} + + {formatQty(item.delivered_qty)} + + {formatQty(item.invoiced_qty ?? 0)} + + {formatQty(item.remaining_qty ?? Math.max(0, item.quantity - (item.invoiced_qty ?? 0)))} + + {formatCurrency(item.unit_price, order.currency)} + + {item.discount_percent > 0 ? `${formatQty(item.discount_percent)} %` : ''} + + {formatQty(item.vat_rate)} % + + {formatCurrency(item.line_total, order.currency)} +
+
+ +
+
+ {t('subtotal')} + {formatCurrency(order.subtotal, order.currency)} +
+
+ {t('vat')} + {formatCurrency(order.vat_amount, order.currency)} +
+
+ {t('total')} + {formatCurrency(order.total, order.currency)} +
+
+
+ + {invoices.length > 0 && ( + + + + + + + + + + + {invoices.map((invoice) => { + const variant = INVOICE_STATUS_BADGE[invoice.status] + const label = t(INVOICE_STATUS_LABEL_KEY[invoice.status] ?? 'invoice_status_draft') + return ( + + + + + + ) + })} + +
{t('th_invoice_number')}{t('th_invoice_status')}{t('th_invoice_total')}
+ + {invoice.invoice_number ?? t('invoice_draft_label')} + + + {variant ? ( + {label} + ) : ( + {label} + )} + + {formatCurrency(invoice.total, invoice.currency)} +
+
+ )} + + {(canCancel || canDelete) && canWrite && ( +
+ {canCancel && ( + + )} + {canDelete && ( + + )} +
+ )} + + {pending && pendingTransition && ( + !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)} + /> + )} + + + { + setOrder(next) + void fetchInvoices() + }} + /> + + + ) +} diff --git a/app/(dashboard)/sales-orders/new/page.tsx b/app/(dashboard)/sales-orders/new/page.tsx new file mode 100644 index 00000000..dedf6239 --- /dev/null +++ b/app/(dashboard)/sales-orders/new/page.tsx @@ -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 ( +
+
+ + + {t('back')} + +

{t('title_create')}

+
+ +
+ ) +} diff --git a/app/(dashboard)/sales-orders/page.tsx b/app/(dashboard)/sales-orders/page.tsx new file mode 100644 index 00000000..58669f63 --- /dev/null +++ b/app/(dashboard)/sales-orders/page.tsx @@ -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([]) + 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 ( +
+
+

{t('title')}

+ +
+ +
+ + { + setSearchTerm(e.target.value) + setVisibleCount(INITIAL_VISIBLE_ROWS) + }} + /> +
+ + {isLoading ? ( +
+ {[1, 2, 3, 4, 5].map((i) => ( + + ))} +
+ ) : filtered.length === 0 ? ( + searchTerm ? ( + {t('no_search_results_description', { term: searchTerm })}} + /> + ) : statusFilter !== ALL ? ( + + ) : ( + + ) + ) : ( + <> +
+ + + + + + + + + + + + + + {visible.map((order) => { + const badgeVariant = STATUS_BADGE_VARIANT[order.status] + return ( + router.push(`/sales-orders/${order.id}`)} + > + + + + + + + + + ) + })} + +
{t('col_number')}{t('col_customer')}{t('col_date')}{t('col_status')}{t('col_delivery')}{t('col_invoicing')}{t('col_total')}
+ {order.order_number || '-'} + + e.stopPropagation()} + > + {order.customer?.name || '-'} + + + {formatDate(order.order_date)} + + {badgeVariant ? ( + + {t(STATUS_LABEL_KEY[order.status])} + + ) : ( + {t(STATUS_LABEL_KEY[order.status])} + )} + + {t(DELIVERY_LABEL_KEY[order.delivery_progress ?? 'none'])} + + {t(INVOICING_LABEL_KEY[order.invoicing_progress ?? 'none'])} + + {formatCurrency(order.total, order.currency)} +
+
+ +

+ {t('count_footer', { count: filtered.length })} +

+ + {visibleCount < filtered.length && ( +
+ +
+ )} + + )} +
+ ) +} + +export default function SalesOrdersPage() { + return ( + +
+ + +
+
+ {[1, 2, 3, 4].map((i) => ( + + ))} +
+ + } + > + +
+ ) +} diff --git a/app/api/invoices/[id]/__tests__/patch.test.ts b/app/api/invoices/[id]/__tests__/patch.test.ts index 04f127e4..6bc4bada 100644 --- a/app/api/invoices/[id]/__tests__/patch.test.ts +++ b/app/api/invoices/[id]/__tests__/patch.test.ts @@ -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 }, diff --git a/app/api/invoices/[id]/convert-to-order/__tests__/route.test.ts b/app/api/invoices/[id]/convert-to-order/__tests__/route.test.ts new file mode 100644 index 00000000..cd94af8c --- /dev/null +++ b/app/api/invoices/[id]/convert-to-order/__tests__/route.test.ts @@ -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 = {}) { + 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[], + ...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[] } } }>( + 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[] } } }>( + 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[] } } }>( + 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[] + 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[] + 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]) + }) +}) diff --git a/app/api/invoices/[id]/convert-to-order/route.ts b/app/api/invoices/[id]/convert-to-order/route.ts new file mode 100644 index 00000000..4ac672fa --- /dev/null +++ b/app/api/invoices/[id]/convert-to-order/route.ts @@ -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 }, +) diff --git a/app/api/invoices/[id]/route.ts b/app/api/invoices/[id]/route.ts index c5cea38b..038a85a5 100644 --- a/app/api/invoices/[id]/route.ts +++ b/app/api/invoices/[id]/route.ts @@ -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, diff --git a/app/api/invoices/route.ts b/app/api/invoices/route.ts index 8e7984b2..75aef5e1 100644 --- a/app/api/invoices/route.ts +++ b/app/api/invoices/route.ts @@ -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 diff --git a/app/api/sales-orders/[id]/create-invoice/route.ts b/app/api/sales-orders/[id]/create-invoice/route.ts new file mode 100644 index 00000000..45564e2e --- /dev/null +++ b/app/api/sales-orders/[id]/create-invoice/route.ts @@ -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 }, +) diff --git a/app/api/sales-orders/[id]/deliver/route.ts b/app/api/sales-orders/[id]/deliver/route.ts new file mode 100644 index 00000000..a709673a --- /dev/null +++ b/app/api/sales-orders/[id]/deliver/route.ts @@ -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 }, +) diff --git a/app/api/sales-orders/[id]/route.ts b/app/api/sales-orders/[id]/route.ts new file mode 100644 index 00000000..248cdff3 --- /dev/null +++ b/app/api/sales-orders/[id]/route.ts @@ -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( + '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( + '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( + '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 }, +) diff --git a/app/api/sales-orders/[id]/transition/route.ts b/app/api/sales-orders/[id]/transition/route.ts new file mode 100644 index 00000000..5d668642 --- /dev/null +++ b/app/api/sales-orders/[id]/transition/route.ts @@ -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 }, +) diff --git a/app/api/sales-orders/__tests__/create-invoice.test.ts b/app/api/sales-orders/__tests__/create-invoice.test.ts new file mode 100644 index 00000000..400e8508 --- /dev/null +++ b/app/api/sales-orders/__tests__/create-invoice.test.ts @@ -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 } }>( + 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 + expect(invoiceInsert).toMatchObject({ sales_order_id: IDS.order, invoice_number: null, status: 'draft' }) + const itemRows = findCall('invoice_items', 'insert')![0] as Record[] + 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() + }) +}) diff --git a/app/api/sales-orders/__tests__/deliver.test.ts b/app/api/sales-orders/__tests__/deliver.test.ts new file mode 100644 index 00000000..232b6dee --- /dev/null +++ b/app/api/sales-orders/__tests__/deliver.test.ts @@ -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 } }>( + 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 } }>( + 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' }) + }) +}) diff --git a/app/api/sales-orders/__tests__/id-route.test.ts b/app/api/sales-orders/__tests__/id-route.test.ts new file mode 100644 index 00000000..d9b613a4 --- /dev/null +++ b/app/api/sales-orders/__tests__/id-route.test.ts @@ -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 } }>( + 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 } }>( + 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 } }>( + 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') + }) +}) diff --git a/app/api/sales-orders/__tests__/route.test.ts b/app/api/sales-orders/__tests__/route.test.ts new file mode 100644 index 00000000..e2f17b6d --- /dev/null +++ b/app/api/sales-orders/__tests__/route.test.ts @@ -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 } }>( + 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) + }) +}) diff --git a/app/api/sales-orders/__tests__/transition.test.ts b/app/api/sales-orders/__tests__/transition.test.ts new file mode 100644 index 00000000..9a6d5fce --- /dev/null +++ b/app/api/sales-orders/__tests__/transition.test.ts @@ -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 } }>( + 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 }) + }) +}) diff --git a/app/api/sales-orders/route.ts b/app/api/sales-orders/route.ts new file mode 100644 index 00000000..c7c4897a --- /dev/null +++ b/app/api/sales-orders/route.ts @@ -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(({ 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 }, +) diff --git a/app/api/v1/companies/[companyId]/invoices/[id]/__tests__/route.test.ts b/app/api/v1/companies/[companyId]/invoices/[id]/__tests__/route.test.ts index 04272fa4..7a9cbbee 100644 --- a/app/api/v1/companies/[companyId]/invoices/[id]/__tests__/route.test.ts +++ b/app/api/v1/companies/[companyId]/invoices/[id]/__tests__/route.test.ts @@ -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, ), diff --git a/app/api/v1/companies/[companyId]/invoices/[id]/route.ts b/app/api/v1/companies/[companyId]/invoices/[id]/route.ts index 9480384d..42f6c6d8 100644 --- a/app/api/v1/companies/[companyId]/invoices/[id]/route.ts +++ b/app/api/v1/companies/[companyId]/invoices/[id]/route.ts @@ -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, diff --git a/components/dashboard/DashboardNav.tsx b/components/dashboard/DashboardNav.tsx index f1b465a3..b9ccbbc9 100644 --- a/components/dashboard/DashboardNav.tsx +++ b/components/dashboard/DashboardNav.tsx @@ -50,6 +50,7 @@ import { FolderArchive, ShoppingCart, Car, + ClipboardList, } from 'lucide-react' import { getBranding } from '@/lib/branding/service' import { BrandHomeLink } from '@/components/branding/BrandHomeLink' @@ -88,6 +89,10 @@ interface DashboardNavProps { // switched on. Drives visibility of the Kostnadsställen & projekt row: // same mechanism as paysSalaries: fetched by the dashboard layout. dimensionsEnabled?: boolean + // Whether kundorder (company_settings.sales_orders_enabled) is switched on. + // Drives visibility of the Kundorder row: same mechanism as + // dimensionsEnabled, fetched by the dashboard layout. + salesOrdersEnabled?: boolean // Whether the company has a webshop hooked up (active WooCommerce/Shopify // connection, or existing webshop_orders rows). Drives visibility of the // Order row: same mechanism as paysSalaries, fetched by the layout. @@ -117,6 +122,7 @@ type NavLabelKey = | 'invoice_inbox' | 'invoices' | 'sales_orders' + | 'webshop_orders' | 'customers' | 'articles' | 'supplier_invoices' @@ -183,6 +189,10 @@ interface NavItem { // company_settings.dimensions_enabled (UI-visibility gate only; the pages // and APIs work regardless, dimensions plan §2). requiresDimensions?: boolean + // Kundorder surfaces: visible only when the company has opted in via + // company_settings.sales_orders_enabled (UI-visibility gate only; the + // pages and APIs work regardless). + requiresSalesOrders?: boolean // Webshop surfaces: visible only when the company has an active // WooCommerce/Shopify connection or already-imported order rows. // UI-visibility gate only; the page and APIs work regardless. @@ -225,12 +235,14 @@ const navItems: NavItem[] = [ { href: '/transactions', labelKey: 'transactions', icon: ArrowLeftRight, group: 'arbeta' }, { href: '/reconciliation', labelKey: 'reconciliation', icon: Scale, group: 'arbeta' }, { href: '/pending', labelKey: 'review', icon: ClipboardCheck, group: 'arbeta' }, + // Kundorder: opt-in via the bookkeeping settings toggle (UI gate only). + { href: '/sales-orders', labelKey: 'sales_orders', icon: ClipboardList, group: 'arbeta', requiresSalesOrders: true }, { href: '/invoices', labelKey: 'invoices', icon: ReceiptText, group: 'arbeta' }, // Webshop orders: visible only for companies that actually have a webshop // hooked up (active WooCommerce/Shopify connection or existing order rows). // Deliberately NOT capability-gated: a company whose entitlement lapsed // must still reach its already-imported orders (accounting underlag). - { href: '/orders', labelKey: 'sales_orders', icon: ShoppingCart, group: 'arbeta', requiresWebshop: true, betaBadge: true }, + { href: '/orders', labelKey: 'webshop_orders', icon: ShoppingCart, group: 'arbeta', requiresWebshop: true, betaBadge: true }, { href: '/supplier-invoices', labelKey: 'supplier_invoices', icon: Wallet, group: 'arbeta' }, { href: '/salary', labelKey: 'salary', icon: HandCoins, group: 'arbeta', employerOnly: true }, // Körjournal: hidden by default (most companies have no car); shows when @@ -329,7 +341,7 @@ const groupLabelKey: Record, string> = { skatt: 'group_tax', } -export default function DashboardNav({ companyName: _companyName, entityType, paysSalaries = false, dimensionsEnabled = false, hasWebshop = false, hasMileage = false, isSandbox = false, extensionNavItems = [], userName = null, userEmail = null, initialUiState }: DashboardNavProps) { +export default function DashboardNav({ companyName: _companyName, entityType, paysSalaries = false, dimensionsEnabled = false, salesOrdersEnabled = false, hasWebshop = false, hasMileage = false, isSandbox = false, extensionNavItems = [], userName = null, userEmail = null, initialUiState }: DashboardNavProps) { const pathname = usePathname() const router = useRouter() const supabase = useRealtimeSupabase() @@ -572,6 +584,7 @@ export default function DashboardNav({ companyName: _companyName, entityType, pa // Dimension surfaces are hidden until the company opts in via the // bookkeeping settings toggle (company_settings.dimensions_enabled). if (item.requiresDimensions && !dimensionsEnabled) return false + if (item.requiresSalesOrders && !salesOrdersEnabled) return false // Webshop surfaces are hidden until a store is connected (or order rows // already exist from a since-disconnected store). if (item.requiresWebshop && !hasWebshop) return false diff --git a/components/invoices/InvoiceEditor.tsx b/components/invoices/InvoiceEditor.tsx index a93197c2..e0f07a85 100644 --- a/components/invoices/InvoiceEditor.tsx +++ b/components/invoices/InvoiceEditor.tsx @@ -270,6 +270,10 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat vat_rate: z.number().min(0).max(25), // Article linkage (artikelregister). Optional: free-text lines omit them. article_id: z.string().nullable().optional(), + // Kundorder line link: an invoice created from a sales order carries it + // per item; the draft editor replaces items wholesale on save, so the + // field must round-trip or the order line becomes re-invoiceable. + sales_order_item_id: z.string().nullable().optional(), revenue_account: z .string() .regex(INVOICE_POSTING_ACCOUNT_REGEX, t('posting_account_invalid')) @@ -559,6 +563,7 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat discount_percent: hasLineDiscount(item.discount_percent) ? item.discount_percent : null, vat_rate: item.vat_rate ?? 25, article_id: item.article_id ?? null, + sales_order_item_id: item.sales_order_item_id ?? null, revenue_account: item.revenue_account ?? null, deduction_type: item.deduction_type ?? null, labor_hours: item.labor_hours ?? null, @@ -836,6 +841,7 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat discount_percent: null, vat_rate: vatRegistered ? vatRatePlan.defaultRate : 0, article_id: null, + sales_order_item_id: null, revenue_account: null, deduction_type: null, labor_hours: null, @@ -891,6 +897,7 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat discount_percent: null, vat_rate: 0, article_id: null, + sales_order_item_id: null, revenue_account: null, deduction_type: null, labor_hours: null, diff --git a/components/sales-orders/CreateInvoiceDialog.tsx b/components/sales-orders/CreateInvoiceDialog.tsx new file mode 100644 index 00000000..858e1bb9 --- /dev/null +++ b/components/sales-orders/CreateInvoiceDialog.tsx @@ -0,0 +1,241 @@ +'use client' + +import { useEffect, useState } from 'react' +import { useRouter } from 'next/navigation' +import { useLocale, useTranslations } from 'next-intl' +import { Loader2 } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { TH_CLASS, TD_CLASS } from '@/components/ui/dry-table' +import { ToastAction } from '@/components/ui/toast' +import { useToast } from '@/components/ui/use-toast' +import { getErrorMessage, type ErrorLocale } from '@/lib/errors/get-error-message' +import { cn } from '@/lib/utils' +import { formatQty } from '@/components/sales-orders/labels' +import type { SalesOrder, SalesOrderItem } from '@/types' + +interface CreateInvoiceDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + order: SalesOrder + onCreated: (order: SalesOrder, invoiceId: string) => void +} + +function productLines(order: SalesOrder): SalesOrderItem[] { + return [...(order.items ?? [])] + .filter((i) => (i.line_type ?? 'product') === 'product') + .sort((a, b) => a.sort_order - b.sort_order) +} + +function remainingOf(item: SalesOrderItem): number { + return Math.max(0, item.remaining_qty ?? item.quantity - (item.invoiced_qty ?? 0)) +} + +function deliveredNotInvoicedOf(item: SalesOrderItem): number { + return Math.max(0, Math.min(remainingOf(item), (item.delivered_qty ?? 0) - (item.invoiced_qty ?? 0))) +} + +function parseQty(value: string | undefined): number { + const n = parseFloat((value ?? '').replace(',', '.')) + return Number.isFinite(n) && n > 0 ? n : 0 +} + +/** + * Creates a DRAFT kundfaktura from the order for the picked quantities. + * Prefilled with what remains; the two shortcuts reseed the picks (all + * remaining / delivered but not yet invoiced). The explicit line picks are + * what is sent, so what the user sees is exactly what gets invoiced. + */ +export default function CreateInvoiceDialog({ + open, + onOpenChange, + order, + onCreated, +}: CreateInvoiceDialogProps) { + const t = useTranslations('sales_order_detail') + const tCommon = useTranslations('common') + const errorLocale = useLocale() as ErrorLocale + const router = useRouter() + const { toast } = useToast() + const [quantities, setQuantities] = useState>({}) + const [invoiceDate, setInvoiceDate] = useState('') + const [dueDate, setDueDate] = useState('') + const [isSubmitting, setIsSubmitting] = useState(false) + + const lines = productLines(order) + + function seed(mode: 'remaining' | 'delivered') { + const next: Record = {} + for (const item of productLines(order)) { + const qty = mode === 'remaining' ? remainingOf(item) : deliveredNotInvoicedOf(item) + next[item.id] = qty > 0 ? String(qty) : '' + } + setQuantities(next) + } + + useEffect(() => { + if (!open) return + setInvoiceDate('') + setDueDate('') + const next: Record = {} + for (const item of productLines(order)) { + const qty = remainingOf(item) + next[item.id] = qty > 0 ? String(qty) : '' + } + setQuantities(next) + }, [open, order]) + + const picked = lines + .map((item) => ({ sales_order_item_id: item.id, quantity: parseQty(quantities[item.id]) })) + .filter((l) => l.quantity > 0) + + async function handleSubmit() { + if (picked.length === 0) { + toast({ title: t('invoice_nothing_selected'), variant: 'destructive' }) + return + } + setIsSubmitting(true) + try { + const response = await fetch(`/api/sales-orders/${order.id}/create-invoice`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + lines: picked, + ...(invoiceDate ? { invoice_date: invoiceDate } : {}), + ...(dueDate ? { due_date: dueDate } : {}), + }), + }) + const json = await response.json().catch(() => null) + if (!response.ok) { + toast({ + title: t('invoice_failed_title'), + description: getErrorMessage(json, { locale: errorLocale, statusCode: response.status }), + variant: 'destructive', + }) + return + } + const invoiceId: string = json.invoice_id + toast({ + title: t('invoice_created_title'), + description: t('invoice_created_description'), + action: ( + router.push(`/invoices/${invoiceId}`)}> + {t('open_invoice')} + + ), + }) + onCreated(json.data.order as SalesOrder, invoiceId) + onOpenChange(false) + } catch (err) { + toast({ + title: t('invoice_failed_title'), + description: getErrorMessage(err, { locale: errorLocale }), + variant: 'destructive', + }) + } finally { + setIsSubmitting(false) + } + } + + return ( + !isSubmitting && onOpenChange(v)}> + + + {t('invoice_dialog_title')} + {t('invoice_dialog_description')} + + +
+
+ + +
+ +
+ + + + + + + + + + {lines.map((item) => ( + + + + + + ))} + +
{t('th_description')}{t('th_remaining_qty')}{t('th_invoice_qty')}
+ {item.description} + + {formatQty(remainingOf(item))} {item.unit} + + + setQuantities((prev) => ({ ...prev, [item.id]: e.target.value })) + } + aria-label={`${t('th_invoice_qty')}: ${item.description}`} + className="ml-auto h-8 w-24 text-right tabular-nums" + /> +
+
+ +
+
+ + setInvoiceDate(e.target.value)} + className="tabular-nums" + /> +
+
+ + setDueDate(e.target.value)} + className="tabular-nums" + /> +
+
+
+ + + + + +
+
+ ) +} diff --git a/components/sales-orders/RegisterDeliveryDialog.tsx b/components/sales-orders/RegisterDeliveryDialog.tsx new file mode 100644 index 00000000..89d2f7e6 --- /dev/null +++ b/components/sales-orders/RegisterDeliveryDialog.tsx @@ -0,0 +1,186 @@ +'use client' + +import { useEffect, useState } from 'react' +import { useLocale, useTranslations } from 'next-intl' +import { Loader2 } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { TH_CLASS, TD_CLASS } from '@/components/ui/dry-table' +import { useToast } from '@/components/ui/use-toast' +import { getErrorMessage, type ErrorLocale } from '@/lib/errors/get-error-message' +import { cn } from '@/lib/utils' +import { formatQty, todayIso } from '@/components/sales-orders/labels' +import type { SalesOrder, SalesOrderItem } from '@/types' + +interface RegisterDeliveryDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + order: SalesOrder + onRegistered: (order: SalesOrder) => void +} + +function productLines(order: SalesOrder): SalesOrderItem[] { + return [...(order.items ?? [])] + .filter((i) => (i.line_type ?? 'product') === 'product') + .sort((a, b) => a.sort_order - b.sort_order) +} + +/** + * Registers CUMULATIVE delivered quantities per line (what the user sees is + * what is stored; a retry is idempotent). Prefilled with the current + * delivered_qty so the dialog doubles as the correction surface. + */ +export default function RegisterDeliveryDialog({ + open, + onOpenChange, + order, + onRegistered, +}: RegisterDeliveryDialogProps) { + const t = useTranslations('sales_order_detail') + const tCommon = useTranslations('common') + const errorLocale = useLocale() as ErrorLocale + const { toast } = useToast() + const [deliveryDate, setDeliveryDate] = useState(todayIso()) + const [quantities, setQuantities] = useState>({}) + const [isSubmitting, setIsSubmitting] = useState(false) + + // Re-seed from the order every time the dialog opens: the order may have + // changed since the last registration. + useEffect(() => { + if (!open) return + setDeliveryDate(todayIso()) + const seed: Record = {} + for (const item of productLines(order)) seed[item.id] = String(item.delivered_qty ?? 0) + setQuantities(seed) + }, [open, order]) + + const lines = productLines(order) + + function deliverAll() { + const next: Record = {} + for (const item of lines) next[item.id] = String(item.quantity) + setQuantities(next) + } + + async function handleSubmit() { + setIsSubmitting(true) + try { + const response = await fetch(`/api/sales-orders/${order.id}/deliver`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + delivery_date: deliveryDate || undefined, + lines: lines.map((item) => ({ + sales_order_item_id: item.id, + delivered_qty: Math.max(0, parseFloat((quantities[item.id] ?? '0').replace(',', '.')) || 0), + })), + }), + }) + const json = await response.json().catch(() => null) + if (!response.ok) { + toast({ + title: t('delivery_failed_title'), + description: getErrorMessage(json, { locale: errorLocale, statusCode: response.status }), + variant: 'destructive', + }) + return + } + toast({ title: t('delivery_success') }) + onRegistered(json.data as SalesOrder) + onOpenChange(false) + } catch (err) { + toast({ + title: t('delivery_failed_title'), + description: getErrorMessage(err, { locale: errorLocale }), + variant: 'destructive', + }) + } finally { + setIsSubmitting(false) + } + } + + return ( + !isSubmitting && onOpenChange(v)}> + + + {t('delivery_dialog_title')} + {t('delivery_dialog_description')} + + +
+
+ + setDeliveryDate(e.target.value)} + className="tabular-nums" + /> +
+ +
+ + + + + + + + + + {lines.map((item) => ( + + + + + + ))} + +
{t('th_description')}{t('th_ordered')}{t('th_delivered_qty')}
+ {item.description} + + {formatQty(item.quantity)} {item.unit} + + + setQuantities((prev) => ({ ...prev, [item.id]: e.target.value })) + } + aria-label={`${t('th_delivered_qty')}: ${item.description}`} + className="ml-auto h-8 w-24 text-right tabular-nums" + /> +
+
+ + +
+ + + + + +
+
+ ) +} diff --git a/components/sales-orders/SalesOrderForm.tsx b/components/sales-orders/SalesOrderForm.tsx new file mode 100644 index 00000000..2970eb88 --- /dev/null +++ b/components/sales-orders/SalesOrderForm.tsx @@ -0,0 +1,615 @@ +'use client' + +import { useMemo, useState } from 'react' +import { useRouter } from 'next/navigation' +import { useLocale, useTranslations } from 'next-intl' +import { Loader2, Plus, Trash2 } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { Textarea } from '@/components/ui/textarea' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' +import { DetailSection } from '@/components/ui/detail-section' +import { TH_CLASS, TD_CLASS } from '@/components/ui/dry-table' +import { useToast } from '@/components/ui/use-toast' +import ArticleCombobox from '@/components/invoices/ArticleCombobox' +import { resolveLineVatRates, FALLBACK_VAT_RATE } from '@/components/invoices/line-vat-rates' +import { useArticles, useCustomers } from '@/lib/reference-data/hooks' +import { sortArticles } from '@/lib/articles/sort' +import { computeLineNet } from '@/lib/invoices/line-amounts' +import { roundOre } from '@/lib/money' +import { getErrorMessage, type ErrorLocale } from '@/lib/errors/get-error-message' +import { cn, formatCurrency } from '@/lib/utils' +import { formatQty, todayIso } from '@/components/sales-orders/labels' +import type { Article, SalesOrder, SalesOrderItem, SalesOrderItemInput } from '@/types' + +// The invoice editor's currency set (CurrencySchema in lib/api/schemas.ts). +const CURRENCIES = ['SEK', 'EUR', 'USD', 'GBP', 'NOK', 'DKK'] as const + +// Dense-row cell controls: same vocabulary as the invoice editor's line grid +// (rounded-sm leaves inside the table surface, hairline-free until focus). +const CELL_INPUT_CLASS = + 'h-8 rounded-sm border border-transparent bg-transparent px-2 py-1 text-[13px] transition-colors duration-150 hover:bg-secondary/40 focus-visible:bg-background focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring placeholder:text-muted-foreground/60' +const CELL_SELECT_TRIGGER_CLASS = + 'h-8 w-auto gap-1 rounded-sm border-transparent bg-transparent px-2 py-1 text-[13px] shadow-none hover:bg-secondary/40 tabular-nums' + +interface LineState { + key: string + id?: string + line_type: 'product' | 'text' + description: string + // Numeric fields are held as strings so the inputs stay controlled while + // the user types ("1." or "" are valid intermediate states). + quantity: string + unit: string + unit_price: string + discount_percent: string + vat_rate: number + article_id: string | null + revenue_account: string | null + // Edit mode: what has already been invoiced on this line (read-only hint). + invoiced_qty?: number +} + +interface SalesOrderFormProps { + mode: 'create' | 'edit' + initial?: SalesOrder +} + +let keyCounter = 0 +function nextKey(): string { + keyCounter += 1 + return `line-${keyCounter}` +} + +function parseNumber(value: string): number { + const n = parseFloat(value.replace(',', '.')) + return Number.isFinite(n) ? n : 0 +} + +function lineFromItem(item: SalesOrderItem): LineState { + return { + key: nextKey(), + id: item.id, + line_type: item.line_type ?? 'product', + description: item.description, + quantity: String(item.quantity), + unit: item.unit, + unit_price: String(item.unit_price), + discount_percent: item.discount_percent > 0 ? String(item.discount_percent) : '', + vat_rate: item.vat_rate, + article_id: item.article_id, + revenue_account: item.revenue_account, + invoiced_qty: item.invoiced_qty, + } +} + +function blankProductLine(vatRate: number): LineState { + return { + key: nextKey(), + line_type: 'product', + description: '', + quantity: '1', + unit: 'st', + unit_price: '', + discount_percent: '', + vat_rate: vatRate, + article_id: null, + revenue_account: null, + } +} + +function blankTextLine(): LineState { + return { + key: nextKey(), + line_type: 'text', + description: '', + quantity: '0', + unit: '', + unit_price: '0', + discount_percent: '', + vat_rate: 0, + article_id: null, + revenue_account: null, + } +} + +/** + * Create/edit form for a kundorder. A lighter sibling of the invoice editor: + * same customer picker, same article prefill and per-line VAT plan, same + * line math (computeLineNet), minus everything invoice-only (ROT/RUT, + * periodisering, payment links). POST on create, PATCH on edit; existing + * line ids ride along on edit so delivered/invoiced history survives. + */ +export default function SalesOrderForm({ mode, initial }: SalesOrderFormProps) { + const t = useTranslations('sales_order_form') + const tCommon = useTranslations('common') + const errorLocale = useLocale() as ErrorLocale + const router = useRouter() + const { toast } = useToast() + + const { customers, isLoading: customersLoading } = useCustomers() + const { articles: articleRows } = useArticles() + const articles = useMemo( + () => sortArticles(articleRows.filter((a) => a.active !== false)), + [articleRows], + ) + + const [customerId, setCustomerId] = useState(initial?.customer_id ?? '') + const [orderDate, setOrderDate] = useState(initial?.order_date ?? todayIso()) + const [requestedDeliveryDate, setRequestedDeliveryDate] = useState( + initial?.requested_delivery_date ?? '', + ) + const [currency, setCurrency] = useState(initial?.currency ?? 'SEK') + const [yourReference, setYourReference] = useState(initial?.your_reference ?? '') + const [ourReference, setOurReference] = useState(initial?.our_reference ?? '') + const [notes, setNotes] = useState(initial?.notes ?? '') + const [lines, setLines] = useState(() => { + const items = [...(initial?.items ?? [])].sort((a, b) => a.sort_order - b.sort_order) + return items.length > 0 ? items.map(lineFromItem) : [blankProductLine(FALLBACK_VAT_RATE)] + }) + const [errors, setErrors] = useState<{ customer?: string; lines?: string }>({}) + const [isSubmitting, setIsSubmitting] = useState(false) + + const selectedCustomer = useMemo( + () => customers.find((c) => c.id === customerId) ?? null, + [customers, customerId], + ) + // The lawful VAT set for the picked customer (0% first for a foreign + // business); before a customer is picked, the domestic set. + const vatPlan = useMemo(() => resolveLineVatRates(selectedCustomer), [selectedCustomer]) + const vatOptions = vatPlan.options.length > 0 + ? vatPlan.options + : resolveLineVatRates({ customer_type: 'swedish_business', vat_number_validated: false }).options + + function updateLine(key: string, patch: Partial) { + setLines((prev) => prev.map((l) => (l.key === key ? { ...l, ...patch } : l))) + } + + function removeLine(key: string) { + setLines((prev) => (prev.length > 1 ? prev.filter((l) => l.key !== key) : prev)) + } + + function applyArticle(key: string, value: string) { + if (value === 'none') { + updateLine(key, { article_id: null, revenue_account: null }) + return + } + const article = articles.find((a) => a.id === value) as Article | undefined + if (!article) return + const rateAllowed = vatOptions.some((o) => o.rate === article.vat_rate) + updateLine(key, { + article_id: article.id, + description: article.name, + unit: article.unit, + unit_price: String(article.price_excl_vat), + vat_rate: rateAllowed ? article.vat_rate : vatPlan.defaultRate, + revenue_account: article.revenue_account, + }) + } + + function handleCustomerChange(nextId: string) { + const previousDefault = vatPlan.defaultRate + setCustomerId(nextId) + const nextCustomer = customers.find((c) => c.id === nextId) ?? null + const nextDefault = resolveLineVatRates(nextCustomer).defaultRate + // Only lines still on the previous default follow the customer switch; + // an explicitly chosen rate is left alone (same rule as the invoice editor). + if (nextDefault !== previousDefault) { + setLines((prev) => + prev.map((l) => + l.line_type === 'product' && l.vat_rate === previousDefault + ? { ...l, vat_rate: nextDefault } + : l, + ), + ) + } + } + + // Client-side totals, same öre-exact formulas as the server. + const totals = useMemo(() => { + let subtotal = 0 + let vat = 0 + for (const l of lines) { + if (l.line_type === 'text') continue + const net = computeLineNet( + parseNumber(l.quantity), + parseNumber(l.unit_price), + l.discount_percent ? parseNumber(l.discount_percent) : null, + ) + subtotal = roundOre(subtotal + net) + vat = roundOre(vat + roundOre((net * l.vat_rate) / 100)) + } + return { subtotal, vat, total: roundOre(subtotal + vat) } + }, [lines]) + + function lineNet(l: LineState): number { + if (l.line_type === 'text') return 0 + return computeLineNet( + parseNumber(l.quantity), + parseNumber(l.unit_price), + l.discount_percent ? parseNumber(l.discount_percent) : null, + ) + } + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault() + const nextErrors: { customer?: string; lines?: string } = {} + if (!customerId) nextErrors.customer = t('validation_customer_required') + const productLines = lines.filter((l) => l.line_type === 'product') + if (productLines.length === 0 || productLines.some((l) => !l.description.trim())) { + nextErrors.lines = t('validation_lines_required') + } + setErrors(nextErrors) + if (nextErrors.customer || nextErrors.lines) return + + const items: SalesOrderItemInput[] = lines.map((l) => ({ + ...(l.id ? { id: l.id } : {}), + line_type: l.line_type, + description: l.description, + quantity: l.line_type === 'text' ? 0 : parseNumber(l.quantity), + unit: l.line_type === 'text' ? '' : l.unit, + unit_price: l.line_type === 'text' ? 0 : parseNumber(l.unit_price), + discount_percent: + l.line_type === 'text' || !l.discount_percent ? null : parseNumber(l.discount_percent), + vat_rate: l.line_type === 'text' ? 0 : l.vat_rate, + article_id: l.article_id, + revenue_account: l.revenue_account, + })) + + const body = { + customer_id: customerId, + order_date: orderDate, + requested_delivery_date: requestedDeliveryDate || null, + currency, + your_reference: yourReference.trim() || null, + our_reference: ourReference.trim() || null, + notes: notes.trim() || null, + items, + } + + setIsSubmitting(true) + try { + const url = mode === 'edit' && initial ? `/api/sales-orders/${initial.id}` : '/api/sales-orders' + const response = await fetch(url, { + method: mode === 'edit' ? 'PATCH' : 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) + const json = await response.json().catch(() => null) + if (!response.ok) { + toast({ + title: mode === 'edit' ? t('update_failed_title') : t('create_failed_title'), + description: getErrorMessage(json, { locale: errorLocale, statusCode: response.status }), + variant: 'destructive', + }) + return + } + const order = json?.data as SalesOrder + toast({ + title: + mode === 'edit' + ? t('updated_title') + : t('created_title', { number: order.order_number ?? '' }), + }) + router.push(`/sales-orders/${order.id}`) + } catch (err) { + toast({ + title: mode === 'edit' ? t('update_failed_title') : t('create_failed_title'), + description: getErrorMessage(err, { locale: errorLocale }), + variant: 'destructive', + }) + } finally { + setIsSubmitting(false) + } + } + + return ( +
+ +
+ + {errors.customer &&

{errors.customer}

} +
+
+ + +
+
+ + setOrderDate(e.target.value)} + className="tabular-nums" + required + /> +
+
+ + setRequestedDeliveryDate(e.target.value)} + className="tabular-nums" + /> +
+
+ + +
+
+ + setYourReference(e.target.value)} + maxLength={200} + /> +
+
+ + setOurReference(e.target.value)} + maxLength={200} + /> +
+
+ +