feat(invoices): offert (quote) document type with own OF-series, decisions, conversion, MCP and v1 (#2163)

* fix(invoices): reminders, AR ledger, AR reconciliation and deadlines only read fakturor

The overdue-reminder run, the kundreskontra, the 1510 reconciliation and the
deadlines page selected invoices by status alone. A sent proforma past its
due date was chased with a betalningspaminnelse and flipped to 'overdue',
and it appeared as a receivable. All four now filter document_type =
'invoice', which is also the precondition for adding quotes (offert): a
quote carries a date but never a receivable.

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

* feat(invoices): offert (quote) document type with its own OF-series, decisions and conversion

Adds document_type 'quote' with valid_until, quote_status (open / accepted /
declined; expired is derived from valid_until, never stored) and
quote_decided_at. Quotes are numbered OF-nnn at insert from
company_settings.next_quote_number via generate_quote_number(), the same
pattern as delivery notes, so a declined quote never leaves a hole in the
F-series the way a proforma does. The column next_quote_number already
existed on prod and staging without a migration; the migration adopts it.

Engine: build-invoice-write writes the quote columns and keeps
remaining_amount at 0; the draft editor refuses accepted or declined
quotes; PATCH refuses changing a quote's or delivery note's document type
since the number belongs to the series; mark-paid refuses quotes.

New POST /api/invoices/[id]/quote-status records the decision and locks
once an invoice exists. Conversion is extracted into
lib/invoices/convert-to-invoice.ts (one implementation for the route and
the MCP staged commit, which had drifted): a converted quote stays and
flips to accepted, the invoice links back via converted_from_id and gets
its due date from the customer's payment terms; a declined or already
invoiced quote is refused. next-number previews the OF-series for quotes.

Migration applied to the staging branch and registered as 20260902140000;
the pg test runs in CI (pg-real).

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

* feat(invoices): quote PDF, email and filename surfaces

The customer-facing surfaces get a quote sibling for every proforma branch:
PDF title OFFERT / QUOTE with Offertdatum and Giltig till instead of the
due date, a notice that the document is not an invoice or a payment
request, and no payment box, OCR, bankgiro, Swish, QR or payment link.
The email says the quote is attached and valid until the expiry, drops
the payment details and pay-online button, and asks about the quote
rather than the invoice. Filenames read "Offert nr OF-001". Seller VAT
number and payment accounts are skipped for quotes as for proformas:
a quote is not a faktura under ML 17 kap.

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

* feat(invoices): offert in the editor, list and detail pages

Editor: "Offert" document type with a required "Giltig till" field
(default today + 30 days) in place of the due date; the wire body mirrors
it into due_date so the shared schema is satisfied. Payment link, ROT/RUT,
periodisering and the bank box are already gated on real invoices. The
type cannot be switched on an existing quote (its OF-number belongs to
the series).

List: an Offerter tab beside Proforma, "Ny offert" in the split button,
and a status column that shows the decision or the derived expiry:
Utgången and Avböjd are exception chips, Öppen and Accepterad muted text.

Detail: Acceptera and Skapa faktura in the header, Avböj in the overflow
menu; an expired quote asks before accepting or invoicing (bypassable);
once an invoice exists the page links to it as Fakturerad and hides the
decision actions. Strings in both sv and en.

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

* feat(mcp,v1): expose offert on the MCP tools and the v1 REST surface

MCP: create_invoice takes document_type quote with a required valid_until
and allocates the OF-number at insert; the convert tool keeps its id and
accepts quotes with the registry refusal codes; new set_quote_status;
list_invoices and get_invoice expose valid_until and the effective quote
status, including a derived expired filter. The tools/list payload stays
under its ceiling without a ledger change. The MCP staged convert now
uses the shared converter.

v1: POST /invoices/{id}/quote-status (registered in the endpoint registry,
scope map and route loader), valid_until and quote_status in the list,
create and detail shapes, and a quote_status list filter. Skill atoms
mention offert. Decision log lines for the own number series, derived
expiry, accepted-not-cancelled conversion and the header action layout.

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

* test(invoices): pass route params and period id in the new quote tests

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

* refactor(invoices): literal update payloads in the converter so the phantom-column guard can read them

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

* fix(invoices): close the quote review findings in one pass

Skeptics (correctness, compliance, regression) and CodeRabbit on #2163:

- quote_status is no longer a write-builder output, so a v1 PATCH or MCP
  update_invoice can never reset a recorded accept/decline; new quotes are
  opened by the invoices_quote_defaults trigger (20260902141000), which
  also keeps due_date and valid_until equal. v1 PATCH and the MCP update
  executor now use the shared editable-draft predicate.
- One live invoice per converted source, enforced by a partial unique
  index; the converter maps 23505 to INVOICE_QUOTE_ALREADY_INVOICED and
  both quote-status routes compare-and-set on the decision they read.
- MCP-created quotes carry remaining_amount 0; mark-paid, transaction
  match and voucher link refuse non-invoices on the MCP staging tools,
  the executors and the dashboard link route.
- Conversion of a foreign-currency source refetches the rate for the
  conversion day (ML 8 kap 21-23 paragraphs) and fails closed without one;
  0-day payment terms mean due on receipt.
- bulk-create refuses quotes per item; list_invoices rejects a
  quote_status filter combined with another document_type; an omitted
  document_type on PATCH means unchanged.
- attention, push notifications, open-AR count, FX revaluation, year-end
  and accrual auto-detect and bank-match suggestions only read fakturor.
- Quote PDF and email print Summa / Total instead of Att betala.
- Regenerated skills/accounted-api for the new v1 endpoint.

Declined with reasons in DECISIONS.md: NOT VALID + VALIDATE and CONCURRENTLY
on the migrations (repo precedent, 13.8k rows, transactional apply);
re-validating VAT treatment at conversion (the converted invoice is a
draft the user reviews; follow-up).

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

* fix(invoices): second review round: migration versions, order links, batch allocation, races

- Migrations renamed to 20260902220000 / 20260902221000: #2166 shipped its
  own 20260902141000 to prod while this PR was in review and prod's head
  moved past both files; below-head versions are skipped by branching,
  which would have left the quote trigger off prod. Staging rows renamed.
- Quote lines never carry sales_order_item_id (an offer must not count as
  invoiced kundorder quantity); the converter carries a proforma line's
  order link onto the invoice.
- Converter compare-and-sets the source (proforma cancel, quote accept):
  a concurrent cancel, proforma-to-order conversion or decision removes
  the orphan invoice with INVOICE_CONVERT_SOURCE_CHANGED instead of a
  second document for the same sale.
- MCP set_quote_status gets the same compare-and-set as the HTTP routes;
  0-row updates report INVOICE_QUOTE_CHANGED_CONCURRENTLY everywhere.
  quote-status (dashboard, v1, MCP) accepts valid_until so an expired
  sent quote can be reopened, as the docs promised.
- MCP mark-paid refuses only quotes, parity with the dashboard route
  (a sent proforma marked paid is a supported prepayment record).
- Batch allocation (dashboard route and MCP tool) refuses non-invoices
  before the RPC, which gates on status alone.
- Customer AR drill-down, v1 customer open invoices and archive guard,
  and the calendar feed read fakturor only.
- Draft quote PDF says "UTKAST" instead of "not a valid invoice"; the
  editor locks the document type on existing quotes and delivery notes.

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

* chore(invoices): use roundOre in the quote MCP summaries and FX test after main tightened the guard baseline

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

* fix(invoices): third review round: atomic decision lock, viewer gate, lookup errors, quote payment terms

- 20260902222000: BEFORE UPDATE trigger locks an accepted quote while a
  live converted invoice exists (the compare-and-set in the three decision
  writers could still be beaten by a conversion landing in between); the
  routes and the MCP tool map the raise to 409 INVOICE_QUOTE_ALREADY_INVOICED.
  generate_quote_number now also requires a non-viewer membership so a
  viewer's session token cannot burn OF-numbers through PostgREST.
- Converter checks quote eligibility before the Riksbanken call and treats
  a failed company_settings read as a failure instead of a 30-day default.
- Re-sending the same decision keeps quote_decided_at (idempotent).
- gnubok_find_voucher_candidates_for_invoice refuses non-invoices like its
  write sibling; the dashboard link route surfaces a failed lookup.
- Late-fee and credit-term texts never print on a quote.
Applied and registered on staging; pg tests added.

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

* fix(invoices): review nits: fail-closed batch lookup, dry-run expiry, quote heading, quote-date CHECK

- match-batch surfaces a failed document lookup instead of allocating.
- v1 quote-status dry-run preview carries the new valid_until.
- Quote PDF heading reads Offertinformation / Quote information.
- 20260902222000 also pins the date invariants the trigger maintains as a
  CHECK: a quote always has valid_until = due_date, nothing else has one.
  Applied on staging.

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

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-09-02 22:40:25 +02:00
committed by GitHub
co-authored by Claude Fable 5.1
parent 3f9a21c64d
commit 1c82baf553
102 changed files with 5044 additions and 418 deletions
+9
View File
@@ -1496,6 +1496,13 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. 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] Offert detail actions: Acceptera + Skapa faktura in the header, Avboj in the overflow menu: convention 9 (one obvious next step, alternatives behind the caret); declining is the rarer branch.
[2026-09-02] Quotes (offert) get their own number series (company_settings.next_quote_number, generate_quote_number, OF-nnn allocated at insert) instead of an OF- prefix on generate_invoice_number: proformas share the F-counter today, so PF-042 leaves F-042 unused, and quotes are far more numerous, so every declined quote would have punched a hole in the faktura series. Delivery notes already use the own-counter pattern. The column next_quote_number pre-existed on prod and staging with no migration file; 20260902220000 adopts it (backfill NULLs, DEFAULT 1, NOT NULL).
[2026-09-02] Quote expiry is derived, not stored: quote_status stays open/accepted/declined and the UI/API compute expired as open AND valid_until < today (lib/invoices/quote-status.ts). Rejected the nightly cron from the original plan: it needed a route, a vercel.json entry, a Docker cron gap and un-expire logic when a user extends valid_until; the derived form needs none of that and cannot race the clock.
[2026-09-02] Converting a quote leaves the quote in place as accepted (converted_from_id on the invoice points back), unlike proforma conversion which cancels the source: a proforma is a pre-invoice for the same sale, a quote is the customer's decision record and the list should keep showing it as Fakturerad. Conversion is refused while an active invoice exists for the quote and for declined quotes; a cancelled converted invoice frees the quote. The conversion itself was extracted into lib/invoices/convert-to-invoice.ts because the route and the MCP staged commit carried two drifted copies (one lost per-line VAT/revenue accounts, the other lost discounts and fakturamarkning).
[2026-09-02] gnubok_create_invoice exposes document_type as invoice | quote only (no proforma / delivery_note on the MCP path): the staged executor never numbered proformas or delivery notes and adding them means adding their number-series branches; the quote branch is the one this feature needs, and the enum can widen later without breaking callers.
[2026-09-02] v1 and MCP read surfaces report quote_status as the EFFECTIVE status (open | accepted | declined | expired) with valid_until beside it, and the quote_status=expired filter is the derived predicate (open AND valid_until < today): a caller filtering on expired must get rows that say expired, and the stored column is still recoverable from valid_until. The write surfaces (POST quote-status, gnubok_set_quote_status) return both the stored decision and effective_quote_status so a client can tell the two apart.
[2026-09-02] Quote migrations keep validated CHECKs and plain CREATE INDEX (CodeRabbit asked for NOT VALID + VALIDATE and CONCURRENTLY): invoices is 13 781 rows on prod so the validating scan is milliseconds, and Supabase branching applies each migration inside a transaction where CONCURRENTLY is refused (the same call as 20260901140000_targeted_fk_indexes.sql and 20260831111519_api_key_unattended_commit_limit.sql). The one-live-conversion rule moved into a partial unique index (20260902221000) because the TS existence check alone could not stop two concurrent conversions.
[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.
@@ -1505,6 +1512,8 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
[2026-09-02] parties children/roles reference parties(id, company_id) with composite FKs, not parties(id): a party UUID from another tenant is rejected by construction instead of relying on each writer to check; ON DELETE SET NULL (party_id) on customers/suppliers because a plain SET NULL would null company_id too (Superagent P2 on #2162)
[2026-09-02] Party suggestions attach only by explicit party_id, org number or an exact ledger key already in alias_keys; same-core text is reported as similar_to for a person to decide and identities are withheld when a key mixes org numbers: the selection eval measured 9% false merges on trade names shared by distinct legal entities (Fortnox AB / Fortnox Finans), so text never merges
[2026-09-02] Edited migration 20260902160000 after merge: its backfill failed on prod (ensure_party: name is required; 3 nameless rows) so it was never applied there, the Supabase main branch sat in MIGRATIONS_FAILED and every later migration was blocked behind it. An unapplied file is not a shipped schema; a follow-up migration could not run before it
[2026-09-02] Quote migrations renamed 20260902140000/141000 -> 20260902220000/221000 before merge: #2166 shipped its own 20260902141000 (pending_operations_add_sales_orders) to prod while this PR was in review, and prod's head had moved to 20260902210000; a version below the head is skipped by Supabase branching, which would have left the invoices_quote_defaults trigger off prod and made every dashboard quote create fail the pairing CHECK. Staging rows were renamed to match.
[2026-09-02] MCP gnubok_mark_invoice_as_paid refuses only quotes (parity with the dashboard route), not proformas: marking a sent proforma paid is a supported prepayment record (status flip, no verifikat) on the dashboard and v1, and the round-two regression skeptic showed the broader guard broke that. Transaction match and voucher link refuse every non-invoice on all surfaces, as the dashboard match route always did.
[2026-09-02] Accounted Connect direction (founder decision after a fork was resold): the ledger stays AGPL with no licence change and no ee/ split; provider integration logic moves behind the connector (hosted app/api/connect/* today, a separate Connect service later) one upstream at a time on the existing connector keys, ledger and entitlement sync. Rejected: FSL/BSL relicensing (58 public forks keep the AGPL version; DCO-only contributions cannot be relicensed without consent) and closed first-party extensions alone (the code still ships to every self-hoster, and the provider extensions do not honour the Extension API boundary).
[2026-09-02] Peppol connector proxy (#2177) is operation-shaped (lookup/submit/status/evidence/recipient/inbound), NOT a path passthrough like the bank proxy: Qvalia URLs embed Arcim's partner and account numbers, the account is shared by every hosted company and every instance so reads must be scoped to what the caller owns, and Qvalia's inbound "read" endpoint marks documents read for the whole account. Ownership is bound to (key, company_ref); participants a key may publish are recorded on the key at issuance (connector_keys.peppol_participants) because the hosted side cannot otherwise know which organisations an instance legitimately hosts. Inbound is served from the hosted archive, never by calling Qvalia on the instance's behalf.
[2026-09-02] The connector wire contract is an MIT package (packages/connect-contract, @accounted/connect-contract) consumed in-repo from source through a tsconfig/vitest alias (#2179), and check:guards ratchets the set of files naming a provider API host (#2178): the open repo keeps the contract and the manual file paths, either side of the connection can be implemented outside it, and the grandfathered provider-host set may only shrink. Declined a NOT VALID + later VALIDATE pair for the ledger service CHECK: connector_connections has zero prod rows until keys are issued.
+3
View File
@@ -106,6 +106,9 @@ export default function DeadlinesPage() {
// received as still owed, and the action link lands on the
// invoices tab that lists exactly sent + overdue.
.in('status', ['sent', 'overdue'])
// Only fakturor are owed: proformas, delivery notes and quotes
// carry a date but no receivable.
.eq('document_type', 'invoice')
.lt('due_date', today)
.order('id', { ascending: true })
.range(from, to),
+200 -14
View File
@@ -28,6 +28,8 @@ import { isEditableInvoiceDraft } from '@/lib/invoices/is-editable-draft'
import { creditNoteNeedsJournalEntry } from '@/lib/invoices/issue-credit-note'
import { getCreditNoteSendMode } from '@/lib/invoices/credit-note-send-mode'
import { canCopyInvoice } from '@/lib/invoices/copy-invoice'
import { effectiveQuoteStatus, isQuoteExpired } from '@/lib/invoices/quote-status'
import type { QuoteStatus } from '@/types'
import {
invoiceDocumentCaveat,
invoiceRerenderUrl,
@@ -208,6 +210,12 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
const [creditNote, setCreditNote] = useState<Invoice | null>(null)
const [originalInvoice, setOriginalInvoice] = useState<Invoice | null>(null)
const [convertedFromInvoice, setConvertedFromInvoice] = useState<Invoice | null>(null)
// Offert: the invoice created from this quote (converted_from_id points
// back here), and the accept/decline round trip.
const [quoteInvoice, setQuoteInvoice] = useState<Invoice | null>(null)
const [isDeciding, setIsDeciding] = useState(false)
const [showExpiredAcceptDialog, setShowExpiredAcceptDialog] = useState(false)
const [showExpiredConvertDialog, setShowExpiredConvertDialog] = useState(false)
const [showPaymentDialog, setShowPaymentDialog] = useState(false)
const [showSendDialog, setShowSendDialog] = useState(false)
const [sendDialogMode, setSendDialogMode] = useState<'email' | 'manual'>('email')
@@ -496,11 +504,20 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
data.converted_from_id
? supabase
.from('invoices')
.select('id, invoice_number')
.select('id, invoice_number, document_type')
.eq('id', data.converted_from_id)
.single()
: Promise.resolve(null),
]).then(([personnummerMasked, creditNoteRes, originalRes, convertedRes]) => {
data.document_type === 'quote'
? supabase
.from('invoices')
.select('id, invoice_number, status')
.eq('converted_from_id', id)
.neq('status', 'cancelled')
.order('created_at', { ascending: false })
.limit(1)
: Promise.resolve(null),
]).then(([personnummerMasked, creditNoteRes, originalRes, convertedRes, invoicedRes]) => {
// Deferred writes need the same guard: they land after first paint
// and would otherwise attach the previous invoice's related documents
// to the one the pager has since navigated to.
@@ -513,6 +530,7 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
if (convertedRes?.data) {
setConvertedFromInvoice(convertedRes.data as Invoice)
}
setQuoteInvoice((invoicedRes?.data?.[0] as Invoice | undefined) ?? null)
})
}
@@ -589,11 +607,11 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
throw new Error(data.error || t('mark_sent_failed_fallback'))
}
} else if (status === 'cancelled') {
// Only drafts and proformas can be cancelled directly: sent/overdue/paid
// Only drafts, proformas and quotes can be cancelled directly: sent/overdue/paid
// invoices have committed journal entries and require a credit note instead
if (invoice.status !== 'draft') {
const docType = ((invoice as Invoice & { document_type?: InvoiceDocumentType }).document_type || 'invoice') as InvoiceDocumentType
if (docType !== 'proforma') {
if (docType !== 'proforma' && docType !== 'quote') {
throw new Error(t('cancel_posted_error'))
}
}
@@ -645,7 +663,10 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
const data = await response.json()
if (!response.ok) {
throw new Error(data.error || t('convert_failed_fallback'))
throw new Error(
getUserErrorMessage(data, { context: 'invoice', statusCode: response.status }) ||
t('convert_failed_fallback'),
)
}
toast({
@@ -714,6 +735,62 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
}
}
/**
* Offert decision (open / accepted / declined). "Expired" is derived from
* valid_until and never stored, so accepting an expired quote is a plain
* accept: the confirm dialog only makes the lapse explicit first.
*/
async function setQuoteDecision(status: QuoteStatus) {
if (!invoice) return
setIsDeciding(true)
try {
const response = await fetch(`/api/invoices/${invoice.id}/quote-status`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status }),
})
const data = await response.json().catch(() => null)
if (!response.ok) {
throw new Error(
getUserErrorMessage(data, { context: 'invoice', statusCode: response.status }) ||
t('quote_decision_failed_fallback'),
)
}
toast({
title: t('quote_decision_toast_title'),
description: t(`quote_decision_toast_${status}`),
})
await fetchInvoice()
} catch (error) {
toast({
title: t('quote_decision_failed_title'),
description: error instanceof Error ? getUserErrorMessage(error) : t('fallback_try_again'),
variant: 'destructive',
})
}
setIsDeciding(false)
}
function acceptQuote() {
if (!invoice) return
if (isQuoteExpired(invoice)) {
setShowExpiredAcceptDialog(true)
return
}
void setQuoteDecision('accepted')
}
/** "Skapa faktura" on an expired open quote confirms the lapse first; an
* accepted quote past valid_until is not expired and converts directly. */
function startQuoteConvert() {
if (!invoice) return
if (isQuoteExpired(invoice)) {
setShowExpiredConvertDialog(true)
return
}
void convertToInvoice()
}
/**
* Fetch and save one specific document, then say truthfully which one it was.
*
@@ -1234,8 +1311,14 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
const customerHasEmail = !!customer.email
const docType = ((invoice as Invoice & { document_type?: InvoiceDocumentType }).document_type || 'invoice') as InvoiceDocumentType
const isProforma = docType === 'proforma'
const isQuote = docType === 'quote'
const isDeliveryNote = docType === 'delivery_note'
const isRealInvoice = docType === 'invoice'
// Offert: the effective status (expired is derived, never stored) and what
// can still happen to it. Once an invoice exists the decision is final.
const quoteStatus = isQuote ? effectiveQuoteStatus(invoice) : null
const canDecideQuote = isQuote && invoice.status !== 'cancelled' && !quoteInvoice
const canConvertQuote = canDecideQuote && quoteStatus !== 'declined'
// #1693: only a fully paid faktura has a betalningsbekräftelse to offer.
const canSendPaymentConfirmation = isPaymentConfirmationEligible(invoice)
const isCreditNote = !!invoice.credited_invoice_id
@@ -1327,17 +1410,33 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
: t('title_credit_draft')
: isProforma
? t('title_proforma', { number: invoiceDisplayNumber(invoice as Invoice) })
: isDeliveryNote
: isQuote
? t('title_quote', { number: invoiceDisplayNumber(invoice as Invoice) })
: isDeliveryNote
? t('title_delivery_note', { number: invoiceDisplayNumber(invoice as Invoice) })
: invoice.invoice_number
? t('title_invoice', { number: titleNumber })
: t('title_draft')
// Offert: open and accepted are the normal states (muted text); declined
// and the derived expiry deviate and get a chip. Same map as the list page.
const quoteStatusDescriptor: { label: string; exception: boolean; variant?: 'secondary' | 'warning' } | null =
quoteStatus === 'expired'
? { label: tInvoices('quote_status_expired'), exception: true, variant: 'warning' }
: quoteStatus === 'declined'
? { label: tInvoices('quote_status_declined'), exception: true, variant: 'secondary' }
: quoteStatus === 'accepted'
? { label: tInvoices('quote_status_accepted'), exception: false }
: quoteStatus === 'open'
? { label: tInvoices('quote_status_open'), exception: false }
: null
// One status element, same rule as the list page (chips mark exceptions):
// sent and paid render as muted text, everything that deviates gets a chip.
const status: { label: string; exception: boolean; variant?: 'secondary' | 'outline' | 'warning' } =
invoice.status === 'cancelled'
? { label: statusLabel('cancelled'), exception: true, variant: 'secondary' }
: quoteStatusDescriptor
? quoteStatusDescriptor
: invoice.status === 'credited'
? { label: statusLabel('credited'), exception: true, variant: 'secondary' }
: invoice.status === 'draft'
@@ -1373,6 +1472,7 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
// obvious next step in the header, the alternatives behind a caret).
const showManualSendAlternative =
!isProforma &&
!isQuote &&
!isDeliveryNote &&
isUnsentNumberedInvoice &&
preferredSendMode === 'email'
@@ -1400,7 +1500,7 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
invoice.status !== 'cancelled' &&
invoice.status !== 'credited' &&
(!invoice.credited_invoice_id || invoice.status === 'draft') &&
(isProforma || invoice.status === 'draft')
(isProforma || isQuote || invoice.status === 'draft')
const hasMenu =
!isSelfBilled ||
(isCopyable && canWrite) ||
@@ -1523,6 +1623,37 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
{t('convert_to_invoice')}
</Button>
)}
{canDecideQuote && quoteStatus !== 'accepted' && (
<Button
variant="outline"
onClick={acceptQuote}
disabled={isDeciding || isConverting || !canWrite}
title={!canWrite ? t('viewer_disabled_tooltip') : undefined}
>
{isDeciding ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<CheckCircle className="mr-2 h-4 w-4" />
)}
{t('quote_accept')}
</Button>
)}
{canConvertQuote && (
<Button
onClick={startQuoteConvert}
disabled={isConverting || isDeciding || !canWrite}
title={!canWrite ? t('viewer_disabled_tooltip') : undefined}
>
{isConverting ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : !canWrite ? (
<Lock className="mr-2 h-4 w-4" />
) : (
<FileText className="mr-2 h-4 w-4" />
)}
{t('quote_create_invoice')}
</Button>
)}
{isProforma && invoice.status !== 'cancelled' && (
<Button
variant="outline"
@@ -1558,7 +1689,7 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
title={!canWrite ? t('viewer_disabled_tooltip') : undefined}
>
{canWrite ? <Mail className="mr-2 h-4 w-4" /> : <Lock className="mr-2 h-4 w-4" />}
{t(booksOnIssue ? 'send_via_email_and_book' : 'send_via_email')}
{t(booksOnIssue && !isQuote ? 'send_via_email_and_book' : 'send_via_email')}
</Button>
) : (
<Button
@@ -1567,7 +1698,7 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
title={!canWrite ? t('viewer_disabled_tooltip') : undefined}
>
{canWrite ? <Send className="mr-2 h-4 w-4" /> : <Lock className="mr-2 h-4 w-4" />}
{t(booksOnIssue ? 'mark_sent_and_book' : 'mark_as_sent')}
{t(booksOnIssue && !isQuote ? 'mark_sent_and_book' : 'mark_as_sent')}
</Button>
)
)}
@@ -1709,10 +1840,22 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
)}
</>
)}
{canDecideQuote && quoteStatus !== 'declined' && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem
onSelect={() => void setQuoteDecision('declined')}
disabled={isDeciding || isConverting || !canWrite}
>
<XCircle className="h-4 w-4" />
{t('quote_decline')}
</DropdownMenuItem>
</>
)}
{showDestructive && (
<>
<DropdownMenuSeparator />
{isProforma ? (
{isProforma || isQuote ? (
<DropdownMenuItem
onSelect={() => void updateStatus('cancelled')}
disabled={isUpdating || !canWrite}
@@ -1803,9 +1946,15 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
<DefRow label={t('invoice_date_label')}>
<span className="tabular-nums">{formatDate(invoice.invoice_date)}</span>
</DefRow>
<DefRow label={t('due_date_label')}>
<span className="tabular-nums">{formatDate(invoice.due_date)}</span>
</DefRow>
{isQuote ? (
<DefRow label={t('valid_until_label')}>
<span className="tabular-nums">{formatDate(invoice.valid_until ?? invoice.due_date)}</span>
</DefRow>
) : (
<DefRow label={t('due_date_label')}>
<span className="tabular-nums">{formatDate(invoice.due_date)}</span>
</DefRow>
)}
<DefRow label={t('currency_label')}>{invoice.currency}</DefRow>
<DefRow label={t('vat_treatment_label')}>{getVatTreatmentLabel(invoice.vat_treatment)}</DefRow>
{invoice.our_reference && (
@@ -1948,7 +2097,16 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
{convertedFromInvoice && (
<DefRow label={t('def_converted_from')}>
<Link href={`/invoices/${convertedFromInvoice.id}`} className="hover:underline">
{t('title_proforma', { number: convertedFromInvoice.invoice_number ?? '' })}
{t(convertedFromInvoice.document_type === 'quote' ? 'title_quote' : 'title_proforma', {
number: convertedFromInvoice.invoice_number ?? '',
})}
</Link>
</DefRow>
)}
{isQuote && quoteInvoice && (
<DefRow label={t('def_invoiced')}>
<Link href={`/invoices/${quoteInvoice.id}`} className="hover:underline">
{t('title_invoice', { number: quoteInvoice.invoice_number ?? '' })}
</Link>
</DefRow>
)}
@@ -2443,6 +2601,34 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
{/* Confirm-before-posting (convention 10): booking writes an immutable
verifikat, so the outcome is described before the POST, not narrated
in a toast afterwards. */}
<ConfirmDialog
open={showExpiredConvertDialog}
onOpenChange={setShowExpiredConvertDialog}
title={t('quote_expired_convert_title')}
description={t('quote_expired_convert_description', {
date: formatDate(invoice.valid_until ?? invoice.due_date),
})}
confirmLabel={t('quote_create_invoice')}
onConfirm={async () => {
setShowExpiredConvertDialog(false)
await convertToInvoice()
}}
/>
<ConfirmDialog
open={showExpiredAcceptDialog}
onOpenChange={setShowExpiredAcceptDialog}
title={t('quote_expired_accept_title')}
description={t('quote_expired_accept_description', {
date: formatDate(invoice.valid_until ?? invoice.due_date),
})}
confirmLabel={t('quote_accept')}
onConfirm={async () => {
setShowExpiredAcceptDialog(false)
await setQuoteDecision('accepted')
}}
/>
<ConfirmDialog
open={showConfirmationSendDialog}
onOpenChange={setShowConfirmationSendDialog}
+54 -9
View File
@@ -30,6 +30,7 @@ import { formatCurrency, formatDate } from '@/lib/utils'
import { cn } from '@/lib/utils'
import { invoiceDisplayNumber } from '@/lib/invoices/display'
import { getDisplayTotal } from '@/lib/invoices/rounding'
import { effectiveQuoteStatus } from '@/lib/invoices/quote-status'
import {
sortInvoiceList,
type InvoiceListSort,
@@ -45,6 +46,7 @@ import {
Repeat,
FileInput,
FileDown,
FileText,
} from 'lucide-react'
import { StartCard } from '@/components/dashboard/StartCard'
import { useCompany } from '@/contexts/CompanyContext'
@@ -87,7 +89,7 @@ const CREATE_MODES = ['faktura', 'aterkommande', 'sjalvfaktura'] as const
// Main views (concept seg) and the low-frequency views behind "Fler …".
const SEG_TABS = ['all', 'unpaid', 'overdue', 'draft'] as const
const MORE_TABS = ['paid', 'proforma', 'delivery_note', 'credit', 'cancelled'] as const
const MORE_TABS = ['paid', 'proforma', 'quote', 'delivery_note', 'credit', 'cancelled'] as const
const ALL_TABS = [...SEG_TABS, ...MORE_TABS]
type ListTab = (typeof SEG_TABS)[number] | (typeof MORE_TABS)[number]
@@ -114,6 +116,7 @@ function matchesListTab(invoice: Invoice, tab: ListTab): boolean {
(tab === 'paid' && invoice.status === 'paid') ||
(tab === 'credit' && isCreditNote) ||
(tab === 'proforma' && docType === 'proforma' && invoice.status !== 'cancelled') ||
(tab === 'quote' && docType === 'quote' && invoice.status !== 'cancelled') ||
(tab === 'delivery_note' &&
docType === 'delivery_note' &&
invoice.status !== 'cancelled') ||
@@ -128,6 +131,7 @@ const TAB_LABEL_KEYS: Record<ListTab, string> = {
draft: 'tab_draft',
paid: 'tab_paid',
proforma: 'tab_proforma',
quote: 'tab_quote',
delivery_note: 'tab_delivery_note',
credit: 'tab_credit',
cancelled: 'tab_cancelled',
@@ -242,6 +246,8 @@ export default function InvoicesPage() {
const copyFromId = searchParams.get('copy')
const showNewInvoice = searchParams.has('new') || copyFromId !== null
const openSelfBilled = searchParams.has('self')
// ?quote=1 preselects the offert document type ("Ny offert" split entry).
const openQuote = searchParams.has('quote')
const showRotRutPayout = searchParams.has('rot-rut')
// Open/close handlers rewrite only their own keys: a hardcoded '/invoices'
// would destroy the ?status= view write-back (and any other params) every
@@ -257,6 +263,7 @@ export default function InvoicesPage() {
invoicesUrl((p) => {
p.delete('new')
p.delete('self')
p.delete('quote')
p.delete('copy')
}),
{ scroll: false },
@@ -271,6 +278,14 @@ export default function InvoicesPage() {
}),
{ scroll: false },
)
const openNewQuote = () =>
router.push(
invoicesUrl((p) => {
p.set('new', '1')
p.set('quote', '1')
}),
{ scroll: false },
)
const closeRotRutPayout = () =>
router.replace(invoicesUrl((p) => p.delete('rot-rut')), { scroll: false })
const openRotRutPayout = () =>
@@ -503,6 +518,15 @@ export default function InvoicesPage() {
disabledTitle: t('viewer_disabled_tooltip'),
onSelect: () => openNewInvoice(),
},
{
key: 'offert',
label: t('create_quote'),
icon: FileText,
description: t('create_quote_desc'),
disabled: !canWrite,
disabledTitle: t('viewer_disabled_tooltip'),
onSelect: () => openNewQuote(),
},
{
key: 'aterkommande',
label: t('create_recurring'),
@@ -535,6 +559,20 @@ export default function InvoicesPage() {
if (invoice.status === 'credited') {
return { label: t('status_credited'), exception: true, variant: 'secondary' }
}
// Offert: the decision (or derived expiry) is the status. Open and
// accepted are the normal states; declined and expired deviate.
const quoteStatus =
(invoice as Invoice & { document_type?: string }).document_type === 'quote'
? effectiveQuoteStatus(invoice)
: null
if (quoteStatus === 'expired') {
return { label: t('quote_status_expired'), exception: true, variant: 'warning' }
}
if (quoteStatus === 'declined') {
return { label: t('quote_status_declined'), exception: true, variant: 'secondary' }
}
if (quoteStatus === 'accepted') return { label: t('quote_status_accepted') }
if (quoteStatus === 'open') return { label: t('quote_status_open') }
if (invoice.status === 'draft') {
const docType = (invoice as Invoice & { document_type?: string }).document_type || 'invoice'
const isUnsent =
@@ -772,11 +810,13 @@ export default function InvoicesPage() {
activeTab === 'all'
? docType === 'proforma'
? t('badge_proforma')
: docType === 'delivery_note'
? t('badge_delivery_note')
: invoice.is_self_billed
? t('badge_self_billed')
: null
: docType === 'quote'
? t('badge_quote')
: docType === 'delivery_note'
? t('badge_delivery_note')
: invoice.is_self_billed
? t('badge_self_billed')
: null
: null
return (
<tr
@@ -832,9 +872,13 @@ export default function InvoicesPage() {
</span>
</td>
<td className={cn(TD_CLASS, 'hidden whitespace-nowrap text-right tabular-nums text-muted-foreground sm:table-cell')}>
{invoice.due_date && !isCreditNote && invoice.status !== 'draft'
? formatDate(invoice.due_date)
: ''}
{docType === 'quote'
? invoice.valid_until
? formatDate(invoice.valid_until)
: ''
: invoice.due_date && !isCreditNote && invoice.status !== 'draft'
? formatDate(invoice.due_date)
: ''}
</td>
<td
className={cn(
@@ -885,6 +929,7 @@ export default function InvoicesPage() {
open
copyFromId={copyFromId}
selfBilled={openSelfBilled}
documentType={openQuote ? 'quote' : undefined}
onOpenChange={(open) => {
if (!open) closeNewInvoice()
}}
+3
View File
@@ -129,6 +129,9 @@ export async function GET(
.select('*, customer:customers(*)')
.eq('company_id', feed.company_id)
.in('status', ['sent', 'paid', 'partially_paid', 'overdue'])
// A quote's due_date only mirrors its expiry; it is not a
// payment date. Proformas and delivery notes are not owed.
.eq('document_type', 'invoice')
.gte('due_date', startStr)
.lte('due_date', endStr)
.order('due_date')
@@ -8,7 +8,16 @@ import {
} from '@/tests/helpers'
import { eventBus } from '@/lib/events'
const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase()
const mockFetchExchangeRate = vi.fn()
vi.mock('@/lib/currency/riksbanken', async () => {
const actual = await vi.importActual<typeof import('@/lib/currency/riksbanken')>('@/lib/currency/riksbanken')
return {
...actual,
fetchExchangeRate: (...args: unknown[]) => mockFetchExchangeRate(...args),
}
})
const { supabase: mockSupabase, enqueue, reset, findCall, findCalls } = createQueuedMockSupabase()
const requireAuthMock = vi.fn()
vi.mock('@/lib/auth/require-auth', () => ({
@@ -176,8 +185,8 @@ describe('POST /api/invoices/[id]/convert', () => {
})
// 3. insert items
enqueue({ data: null, error: null })
// 4. cancel proforma, succeeds
enqueue({ data: null, error: null })
// 4. cancel proforma, succeeds (compare-and-set returns the row)
enqueue({ data: [{ id: 'pf-1' }], error: null })
// 5. ensureInvoiceNumber → rpc THROWS
enqueue({ data: null, error: { message: 'number allocation failed' } })
// 6. un-cancel proforma (restore previous status)
@@ -204,8 +213,8 @@ describe('POST /api/invoices/[id]/convert', () => {
})
// 3. insert items
enqueue({ data: null, error: null })
// 4. cancel proforma
enqueue({ data: null, error: null })
// 4. cancel proforma (compare-and-set returns the row)
enqueue({ data: [{ id: 'pf-1' }], error: null })
// 5. ensureInvoiceNumber → rpc returns the assigned F-number
enqueue({ data: 'F-2026005', error: null })
// 6. fetch complete invoice
@@ -230,4 +239,194 @@ describe('POST /api/invoices/[id]/convert', () => {
})
)
})
describe('quotes (offert)', () => {
const baseQuote = {
...baseProforma,
id: 'q-1',
document_type: 'quote',
status: 'sent',
invoice_number: 'OF-003',
valid_until: '2026-06-30',
quote_status: 'open',
quote_decided_at: null,
customer: { default_payment_terms: 20 },
}
it('refuses a declined quote', async () => {
enqueue({ data: { ...baseQuote, quote_status: 'declined' }, error: null })
const response = await POST(
createMockRequest('/api/invoices/q-1/convert', { method: 'POST' }),
createMockRouteParams({ id: 'q-1' })
)
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
expect(status).toBe(409)
expect(body.error.code).toBe('INVOICE_CONVERT_QUOTE_DECLINED')
expect(mockSupabase.rpc).not.toHaveBeenCalled()
})
it('refuses a second conversion while the first invoice lives', async () => {
// 1. fetch quote
enqueue({ data: baseQuote, error: null })
// 2. existing active invoice with converted_from_id = quote
enqueue({ data: { id: 'inv-9' }, error: null })
const response = await POST(
createMockRequest('/api/invoices/q-1/convert', { method: 'POST' }),
createMockRouteParams({ id: 'q-1' })
)
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
expect(status).toBe(409)
expect(body.error.code).toBe('INVOICE_QUOTE_ALREADY_INVOICED')
expect(mockSupabase.rpc).not.toHaveBeenCalled()
})
it('creates the invoice with a due date from the customer terms, marks the quote accepted and keeps it', async () => {
// 1. fetch quote
enqueue({ data: baseQuote, error: null })
// 2. no existing conversion
enqueue({ data: null, error: null })
// 3. insert invoice
enqueue({ data: { id: 'inv-1', invoice_number: null, document_type: 'invoice' }, error: null })
// 4. insert items
enqueue({ data: null, error: null })
// 5. quote -> accepted (compare-and-set returns the row)
enqueue({ data: [{ id: 'q-1' }], error: null })
// 6. generate_invoice_number RPC
enqueue({ data: 'F-042', error: null })
// 7. refetch complete invoice
enqueue({ data: { id: 'inv-1', invoice_number: 'F-042', document_type: 'invoice', items: [] }, error: null })
const response = await POST(
createMockRequest('/api/invoices/q-1/convert', { method: 'POST' }),
createMockRouteParams({ id: 'q-1' })
)
const { status, body } = await parseJsonResponse<{ data: { invoice_number: string } }>(response)
expect(status).toBe(200)
expect(body.data.invoice_number).toBe('F-042')
const inserted = findCall('invoices', 'insert')?.[0] as Record<string, unknown>
expect(inserted.document_type).toBe('invoice')
expect(inserted.converted_from_id).toBe('q-1')
expect(inserted.quote_status).toBeNull()
expect(inserted.valid_until).toBeNull()
expect(inserted.remaining_amount).toBe(12500)
// invoice_date is today; due_date is today + the customer's 20 days, never the quote's valid_until.
const today = new Date().toISOString().split('T')[0]
const expectedDue = new Date(`${today}T00:00:00Z`)
expectedDue.setUTCDate(expectedDue.getUTCDate() + 20)
expect(inserted.invoice_date).toBe(today)
expect(inserted.due_date).toBe(expectedDue.toISOString().split('T')[0])
const updates = findCalls('invoices', 'update').map((args) => args[0] as Record<string, unknown>)
expect(updates.some((u) => u.quote_status === 'accepted')).toBe(true)
expect(updates.some((u) => u.status === 'cancelled')).toBe(false)
})
})
describe('foreign-currency sources', () => {
const eurQuote = {
...baseProforma,
id: 'q-eur',
document_type: 'quote',
status: 'sent',
invoice_number: 'OF-004',
valid_until: '2026-08-31',
quote_status: 'open',
quote_decided_at: null,
currency: 'EUR',
exchange_rate: 11.1,
exchange_rate_date: '2026-06-01',
subtotal_sek: 111000,
vat_amount_sek: 27750,
total_sek: 138750,
customer: { default_payment_terms: 30 },
}
it('books the converted invoice at the rate of the conversion day, not the quote day', async () => {
mockFetchExchangeRate.mockResolvedValue({ rate: 11.45, date: '2026-07-28' })
enqueue({ data: eurQuote, error: null }) // fetch quote
enqueue({ data: null, error: null }) // no existing conversion
enqueue({ data: { id: 'inv-1', invoice_number: null, document_type: 'invoice' }, error: null })
enqueue({ data: null, error: null }) // items
enqueue({ data: [{ id: 'q-eur' }], error: null }) // quote -> accepted
enqueue({ data: 'F-050', error: null }) // number
enqueue({ data: { id: 'inv-1', invoice_number: 'F-050', document_type: 'invoice', items: [] }, error: null })
const response = await POST(
createMockRequest('/api/invoices/q-eur/convert', { method: 'POST' }),
createMockRouteParams({ id: 'q-eur' })
)
const { status } = await parseJsonResponse(response)
expect(status).toBe(200)
expect(mockFetchExchangeRate).toHaveBeenCalledWith('EUR', expect.any(Date), expect.anything())
const inserted = findCall('invoices', 'insert')?.[0] as Record<string, unknown>
expect(inserted.exchange_rate).toBe(11.45)
expect(inserted.exchange_rate_date).toBe('2026-07-28')
expect(inserted.subtotal_sek).toBeCloseTo(114500, 2)
expect(inserted.vat_amount_sek).toBeCloseTo(28625, 2)
expect(inserted.total_sek).toBeCloseTo(143125, 2)
})
it('fails closed before inserting anything when no rate can be fetched', async () => {
mockFetchExchangeRate.mockResolvedValue(null)
enqueue({ data: eurQuote, error: null })
enqueue({ data: null, error: null })
const response = await POST(
createMockRequest('/api/invoices/q-eur/convert', { method: 'POST' }),
createMockRouteParams({ id: 'q-eur' })
)
const { status } = await parseJsonResponse(response)
expect(status).toBe(500)
expect(findCall('invoices', 'insert')).toBeUndefined()
expect(mockSupabase.rpc).not.toHaveBeenCalled()
})
})
it('maps the one-live-conversion unique index violation to INVOICE_QUOTE_ALREADY_INVOICED', async () => {
enqueue({ data: { ...baseProforma, id: 'q-1', document_type: 'quote', status: 'sent', quote_status: 'open', customer: { default_payment_terms: 30 } }, error: null })
enqueue({ data: null, error: null }) // existence check passed (race)
enqueue({ data: null, error: { code: '23505', message: 'duplicate key value violates unique constraint "idx_invoices_one_live_conversion"' } })
const response = await POST(
createMockRequest('/api/invoices/q-1/convert', { method: 'POST' }),
createMockRouteParams({ id: 'q-1' })
)
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
expect(status).toBe(409)
expect(body.error.code).toBe('INVOICE_QUOTE_ALREADY_INVOICED')
expect(mockSupabase.rpc).not.toHaveBeenCalled()
})
it('removes the orphan invoice and refuses when the proforma was cancelled concurrently (0-row compare-and-set)', async () => {
// 1. fetch proforma
enqueue({ data: baseProforma, error: null })
// 2. insert real invoice
enqueue({ data: { id: 'inv-1', invoice_number: null, document_type: 'invoice' }, error: null })
// 3. insert items
enqueue({ data: null, error: null })
// 4. cancel proforma: a concurrent order conversion already cancelled it, 0 rows
enqueue({ data: [], error: null })
// 5. delete orphan invoice
enqueue({ data: null, error: null })
const response = await POST(
createMockRequest('/api/invoices/pf-1/convert', { method: 'POST' }),
createMockRouteParams({ id: 'pf-1' })
)
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
expect(status).toBe(409)
expect(body.error.code).toBe('INVOICE_CONVERT_SOURCE_CHANGED')
expect(mockSupabase.rpc).not.toHaveBeenCalled()
expect(findCalls('invoices', 'delete').length).toBe(1)
})
})
+21 -150
View File
@@ -1,9 +1,8 @@
import { NextResponse } from 'next/server'
import { eventBus } from '@/lib/events'
import { ensureInitialized } from '@/lib/init'
import { withRouteContext } from '@/lib/api/with-route-context'
import { ensureInvoiceNumber } from '@/lib/invoices/ensure-invoice-number'
import type { Invoice } from '@/types'
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
import { convertToInvoice } from '@/lib/invoices/convert-to-invoice'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
ensureInitialized()
@@ -11,161 +10,33 @@ ensureInitialized()
/**
* POST /api/invoices/[id]/convert
*
* Converts a proforma invoice to a real invoice.
* Copies all data, generates a real invoice number, and marks the proforma as cancelled.
*
* Ordering note: ensureInvoiceNumber() is the LAST side effect. The F-series
* counter only advances after items are inserted and the proforma is marked
* cancelled, so a partial failure in any earlier step rolls back the orphan
* row without leaking a number.
* Converts a proforma invoice or an accepted quote (offert) to a real
* invoice. The shared implementation (lib/invoices/convert-to-invoice.ts)
* copies the data, generates an F-series number LAST, marks a proforma
* cancelled and a quote accepted; the new invoice links back through
* converted_from_id.
*/
export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
'invoice.convert',
async (request, { supabase, user, companyId }, { params }) => {
const { id } = await params
async (_request, { supabase, user, companyId, log, requestId }, { params }) => {
const { id } = await params
const { data: proforma, error: proformaError } = await supabase
.from('invoices')
.select('*, items:invoice_items(*)')
.eq('id', id)
.eq('company_id', companyId)
.single()
if (proformaError || !proforma) {
return NextResponse.json({ error: 'Proformafakturan hittades inte' }, { status: 404 })
}
if (proforma.document_type !== 'proforma') {
return NextResponse.json(
{ error: 'Endast proformafakturor kan konverteras' },
{ status: 400 }
)
}
if (proforma.status === 'cancelled') {
return NextResponse.json(
{ error: 'Denna proformafaktura har redan makuleras' },
{ status: 400 }
)
}
const { data: invoice, error: invoiceError } = await supabase
.from('invoices')
.insert({
user_id: user.id,
company_id: companyId,
customer_id: proforma.customer_id,
invoice_number: null,
invoice_date: new Date().toISOString().split('T')[0],
due_date: proforma.due_date,
currency: proforma.currency,
exchange_rate: proforma.exchange_rate,
exchange_rate_date: proforma.exchange_rate_date,
subtotal: proforma.subtotal,
subtotal_sek: proforma.subtotal_sek,
vat_amount: proforma.vat_amount,
vat_amount_sek: proforma.vat_amount_sek,
total: proforma.total,
total_sek: proforma.total_sek,
// The converted invoice is a fresh unpaid receivable: proformas carry no
// ROT/RUT deduction, so the customer owes the full total. Omitting this
// left the NOT NULL DEFAULT 0, which every payment surface reads as
// "nothing open" (dialog overpayment rejection, bank match sees 0).
remaining_amount: proforma.total,
paid_amount: 0,
vat_treatment: proforma.vat_treatment,
vat_rate: proforma.vat_rate,
moms_ruta: proforma.moms_ruta,
reverse_charge_text: proforma.reverse_charge_text,
your_reference: proforma.your_reference,
our_reference: proforma.our_reference,
// Buyer routing survives conversion (Peppol BT-10 may rely on it alone).
invoice_marking: proforma.invoice_marking ?? null,
notes: proforma.notes,
document_type: 'invoice',
converted_from_id: id,
// Dimensions PR7: the converted invoice books with the proforma's bag.
default_dimensions: proforma.default_dimensions ?? {},
const result = await convertToInvoice({
supabase,
userId: user.id,
companyId,
sourceId: id,
})
.select()
.single()
if (invoiceError) {
return NextResponse.json({ error: getUserErrorMessage(invoiceError) }, { status: 500 })
}
const items = (proforma.items || []).map((item: { sort_order: number; line_type?: 'product' | 'text'; description: string; quantity: number; unit: string; unit_price: number; discount_percent?: number | null; line_total: number; dimensions?: Record<string, string> }) => ({
invoice_id: invoice.id,
sort_order: item.sort_order,
line_type: item.line_type ?? 'product',
description: item.description,
quantity: item.quantity,
unit: item.unit,
unit_price: item.unit_price,
// The stored line_total is net of this; dropping it would make the
// converted invoice fail the Peppol line check and lose the rebate on
// the next builder pass.
discount_percent: item.discount_percent ?? 0,
line_total: item.line_total,
dimensions: item.dimensions ?? {},
}))
if (items.length > 0) {
const { error: itemsError } = await supabase
.from('invoice_items')
.insert(items)
if (itemsError) {
await supabase.from('invoices').delete().eq('id', invoice.id)
return NextResponse.json({ error: getUserErrorMessage(itemsError) }, { status: 500 })
if (!result.ok) {
if (result.code === 'INVOICE_CONVERT_FAILED') {
log.error('invoice conversion failed', result.cause as Error, { sourceId: id })
return NextResponse.json({ error: getUserErrorMessage(result.cause) }, { status: 500 })
}
return errorResponseFromCode(result.code, log, { requestId })
}
}
// Cancel the proforma. If this fails, the new (still unnumbered) invoice
// is an orphan: delete it so the user can retry without ending up with
// two active invoices for the same proforma. invoice_items cascade.
const previousProformaStatus = proforma.status
const { error: cancelError } = await supabase
.from('invoices')
.update({ status: 'cancelled' })
.eq('id', id)
if (cancelError) {
await supabase.from('invoices').delete().eq('id', invoice.id)
return NextResponse.json({ error: getUserErrorMessage(cancelError) }, { status: 500 })
}
// Allocate the F-series number last. If allocation fails, restore the
// proforma's previous status and delete the orphan invoice. The F-counter
// is unaffected because generate_invoice_number only commits on success.
try {
await ensureInvoiceNumber(supabase, companyId, invoice as Invoice)
} catch (err) {
await supabase
.from('invoices')
.update({ status: previousProformaStatus })
.eq('id', id)
await supabase.from('invoices').delete().eq('id', invoice.id)
return NextResponse.json(
{ error: err instanceof Error ? getUserErrorMessage(err) : 'Failed to assign invoice number' },
{ status: 500 }
)
}
const { data: completeInvoice } = await supabase
.from('invoices')
.select('*, customer:customers(*), items:invoice_items(*)')
.eq('id', invoice.id)
.single()
if (completeInvoice) {
await eventBus.emit({
type: 'invoice.created',
payload: { invoice: completeInvoice as Invoice, companyId, userId: user.id },
})
}
return NextResponse.json({ data: completeInvoice })
return NextResponse.json({ data: result.invoice })
},
{ requireWrite: true },
)
@@ -0,0 +1,95 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import {
createMockRequest,
parseJsonResponse,
createMockRouteParams,
createQueuedMockSupabase,
} from '@/tests/helpers'
const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase()
vi.mock('@/lib/supabase/server', () => ({
createClient: () => Promise.resolve(mockSupabase),
}))
vi.mock('@/lib/init', () => ({
ensureInitialized: vi.fn(),
}))
vi.mock('@/lib/company/context', () => ({
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
}))
vi.mock('@/lib/auth/require-write', () => ({
requireWritePermission: vi.fn().mockResolvedValue({ ok: true }),
}))
const mockLink = vi.fn()
vi.mock('@/lib/invoices/voucher-matching', () => ({
linkInvoiceToVoucher: (...args: unknown[]) => mockLink(...args),
}))
import { POST } from '../route'
const JE = '11111111-1111-4111-8111-111111111111'
function post(body: unknown) {
return POST(
createMockRequest('/api/invoices/inv-1/link-to-voucher', { method: 'POST', body }),
createMockRouteParams({ id: 'inv-1' }),
)
}
describe('POST /api/invoices/[id]/link-to-voucher', () => {
beforeEach(() => {
vi.clearAllMocks()
reset()
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: { id: 'user-1' } } })
})
it('returns 401 when not authenticated', async () => {
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: null } })
const { status } = await parseJsonResponse(await post({ journal_entry_id: JE }))
expect(status).toBe(401)
expect(mockLink).not.toHaveBeenCalled()
})
it('returns 400 without a journal_entry_id', async () => {
const { status } = await parseJsonResponse(await post({}))
expect(status).toBe(400)
expect(mockLink).not.toHaveBeenCalled()
})
it('refuses a quote before the RPC: an offert carries no receivable', async () => {
enqueue({ data: { document_type: 'quote' }, error: null })
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(
await post({ journal_entry_id: JE }),
)
expect(status).toBe(400)
expect(body.error.code).toBe('MATCH_INVOICE_NOT_INVOICE_TYPE')
expect(mockLink).not.toHaveBeenCalled()
})
it('links a real invoice through the shared helper', async () => {
enqueue({ data: { document_type: 'invoice' }, error: null })
mockLink.mockResolvedValue({
ok: true,
result: { invoiceId: 'inv-1', journalEntryId: JE, paymentAmount: 1250, paymentDate: '2026-06-10', newStatus: 'paid' },
})
const { status } = await parseJsonResponse(await post({ journal_entry_id: JE }))
expect(status).toBe(200)
expect(mockLink).toHaveBeenCalledWith(
mockSupabase,
'user-1',
'company-1',
expect.objectContaining({ invoiceId: 'inv-1', journalEntryId: JE }),
)
})
})
+22 -1
View File
@@ -2,7 +2,7 @@ import { NextResponse } from 'next/server'
import { withRouteContext } from '@/lib/api/with-route-context'
import { validateBody } from '@/lib/api/validate'
import { LinkInvoiceToVoucherSchema } from '@/lib/api/schemas'
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error'
import { linkInvoiceToVoucher } from '@/lib/invoices/voucher-matching'
import { ensureInitialized } from '@/lib/init'
@@ -32,6 +32,27 @@ export const POST = withRouteContext(
if (!validation.success) return validation.response
const { journal_entry_id, notes } = validation.data
// Only a faktura carries a receivable to settle: proformas, delivery notes
// and quotes are refused before the RPC (which validates status, not
// document type). A missing row falls through to the RPC's own 404.
const { data: docRow, error: docError } = await supabase
.from('invoices')
.select('document_type')
.eq('id', id)
.eq('company_id', companyId)
.maybeSingle()
if (docError) {
opLog.error('link-to-voucher: document lookup failed', docError)
return errorResponse(docError, opLog, { requestId })
}
const docType = (docRow as { document_type?: string | null } | null)?.document_type
if (docType && docType !== 'invoice') {
return errorResponseFromCode('MATCH_INVOICE_NOT_INVOICE_TYPE', opLog, {
requestId,
details: { documentType: docType },
})
}
const outcome = await linkInvoiceToVoucher(supabase, user.id, companyId, {
invoiceId: id,
journalEntryId: journal_entry_id,
@@ -1112,4 +1112,17 @@ describe('POST /api/invoices/[id]/mark-paid', () => {
expect(body.paid_amount).toBe(1000)
expect(body.remaining_amount).toBe(0)
})
it('refuses to mark a quote paid: an offert is not a claim', async () => {
const quote = makeInvoice({ status: 'sent', document_type: 'quote', quote_status: 'accepted' })
enqueue({ data: quote, error: null })
const request = createMockRequest('/api/invoices/q-1/mark-paid', { method: 'POST' })
const response = await POST(request, createMockRouteParams({ id: 'q-1' }))
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
expect(status).toBe(400)
expect(body.error.code).toBe('INVOICE_QUOTE_NOT_PAYABLE')
expect(mockCreateInvoicePaymentJournalEntry).not.toHaveBeenCalled()
})
})
+6
View File
@@ -65,6 +65,12 @@ export const POST = withRouteContext(
// remaining (öresavrundning) or an ordinary open partial is completed
// here. settleInvoicePayment's plan math and CAS guard already handle
// the state; only this route-level gate excluded it.
// A quote is an offer, not a claim: nothing is owed until it has been
// converted to a faktura.
if (invoice.document_type === 'quote') {
return errorResponseFromCode('INVOICE_QUOTE_NOT_PAYABLE', opLog, { requestId })
}
if (
invoice.status !== 'sent' &&
invoice.status !== 'overdue' &&
@@ -0,0 +1,142 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import {
createMockRequest,
parseJsonResponse,
createMockRouteParams,
createQueuedMockSupabase,
} from '@/tests/helpers'
import { eventBus } from '@/lib/events'
const { supabase: mockSupabase, enqueue, reset, findCall } = createQueuedMockSupabase()
vi.mock('@/lib/supabase/server', () => ({
createClient: () => Promise.resolve(mockSupabase),
}))
vi.mock('@/lib/init', () => ({
ensureInitialized: vi.fn(),
}))
vi.mock('@/lib/company/context', () => ({
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
}))
vi.mock('@/lib/auth/require-write', () => ({
requireWritePermission: vi.fn().mockResolvedValue({ ok: true }),
}))
import { POST } from '../route'
const quoteRow = {
id: 'q-1',
document_type: 'quote',
status: 'sent',
quote_status: 'open',
quote_decided_at: null,
}
function post(body: unknown) {
return POST(
createMockRequest('/api/invoices/q-1/quote-status', { method: 'POST', body }),
createMockRouteParams({ id: 'q-1' }),
)
}
describe('POST /api/invoices/[id]/quote-status', () => {
const mockUser = { id: 'user-1', email: 'test@test.se' }
beforeEach(() => {
vi.clearAllMocks()
reset()
eventBus.clear()
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } })
})
it('returns 401 when not authenticated', async () => {
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: null } })
const { status } = await parseJsonResponse(await post({ status: 'accepted' }))
expect(status).toBe(401)
})
it('returns 400 on an unknown status', async () => {
const { status } = await parseJsonResponse(await post({ status: 'expired' }))
expect(status).toBe(400)
})
it('returns 404 when the quote does not exist in the company', async () => {
enqueue({ data: null, error: { message: 'not found' } })
const { status } = await parseJsonResponse(await post({ status: 'accepted' }))
expect(status).toBe(404)
})
it('returns 400 when the document is not a quote', async () => {
enqueue({ data: { ...quoteRow, document_type: 'invoice', quote_status: null }, error: null })
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(
await post({ status: 'accepted' }),
)
expect(status).toBe(400)
expect(body.error.code).toBe('INVOICE_NOT_A_QUOTE')
})
it('returns 400 when the quote is cancelled', async () => {
enqueue({ data: { ...quoteRow, status: 'cancelled' }, error: null })
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(
await post({ status: 'declined' }),
)
expect(status).toBe(400)
expect(body.error.code).toBe('INVOICE_QUOTE_NOT_DECIDABLE')
})
it('returns 409 once an invoice exists for the quote', async () => {
enqueue({ data: { ...quoteRow, quote_status: 'accepted' }, error: null })
enqueue({ data: { id: 'inv-1', invoice_number: 'F-042' }, error: null })
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(
await post({ status: 'declined' }),
)
expect(status).toBe(409)
expect(body.error.code).toBe('INVOICE_QUOTE_ALREADY_INVOICED')
})
it('records an acceptance with a decided_at timestamp', async () => {
enqueue({ data: quoteRow, error: null })
enqueue({ data: null, error: null })
enqueue({
data: { ...quoteRow, quote_status: 'accepted', quote_decided_at: '2026-06-01T10:00:00.000Z' },
error: null,
})
const { status, body } = await parseJsonResponse<{ data: { quote_status: string } }>(
await post({ status: 'accepted' }),
)
expect(status).toBe(200)
expect(body.data.quote_status).toBe('accepted')
const update = findCall('invoices', 'update')?.[0] as Record<string, unknown>
expect(update.quote_status).toBe('accepted')
expect(typeof update.quote_decided_at).toBe('string')
})
it('reopening clears decided_at', async () => {
enqueue({ data: { ...quoteRow, quote_status: 'declined' }, error: null })
enqueue({ data: null, error: null })
enqueue({ data: { ...quoteRow, quote_status: 'open' }, error: null })
const { status } = await parseJsonResponse(await post({ status: 'open' }))
expect(status).toBe(200)
const update = findCall('invoices', 'update')?.[0] as Record<string, unknown>
expect(update.quote_status).toBe('open')
expect(update.quote_decided_at).toBeNull()
})
})
+112
View File
@@ -0,0 +1,112 @@
import { NextResponse } from 'next/server'
import { withRouteContext } from '@/lib/api/with-route-context'
import { validateBody } from '@/lib/api/validate'
import { SetQuoteStatusSchema } from '@/lib/api/schemas'
import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error'
/**
* POST /api/invoices/[id]/quote-status
*
* Records the customer's decision on a quote (offert): open, accepted or
* declined. Any transition between the three is allowed until the quote has
* been converted to an invoice, after which the decision is locked. "expired"
* is never written: it is derived from valid_until (lib/invoices/quote-status).
*
* Accepting a quote past valid_until is permitted here; the UI asks first.
* A cancelled quote cannot be decided.
*/
export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
'invoice.quote_status',
async (request, { supabase, companyId, log, requestId }, { params }) => {
const { id } = await params
const validation = await validateBody(request, SetQuoteStatusSchema, {
log,
operation: 'invoice.quote_status',
})
if (!validation.success) return validation.response
const nextStatus = validation.data.status
const nextValidUntil = validation.data.valid_until
const { data: quote, error: fetchError } = await supabase
.from('invoices')
.select('id, document_type, status, quote_status, quote_decided_at')
.eq('id', id)
.eq('company_id', companyId)
.single()
if (fetchError || !quote) {
return errorResponseFromCode('INVOICE_NOT_FOUND', log, { requestId })
}
if (quote.document_type !== 'quote') {
return errorResponseFromCode('INVOICE_NOT_A_QUOTE', log, {
requestId,
details: { documentType: quote.document_type },
})
}
if (quote.status === 'cancelled') {
return errorResponseFromCode('INVOICE_QUOTE_NOT_DECIDABLE', log, { requestId })
}
const { data: converted, error: convertedError } = await supabase
.from('invoices')
.select('id, invoice_number')
.eq('company_id', companyId)
.eq('converted_from_id', id)
.neq('status', 'cancelled')
.limit(1)
.maybeSingle()
if (convertedError) {
log.error('quote conversion lookup failed', convertedError, { quoteId: id })
return errorResponse(convertedError, log, { requestId })
}
if (converted) {
return errorResponseFromCode('INVOICE_QUOTE_ALREADY_INVOICED', log, {
requestId,
details: { invoiceId: converted.id, invoiceNumber: converted.invoice_number },
})
}
// Compare-and-set on what was just read: a conversion (which writes
// 'accepted') or a cancel that lands between the checks above and this
// write turns it into a 0-row update instead of overwriting the newer
// state. maybeSingle: 0 rows is the race, not a DB error.
const { data: updated, error: updateError } = await supabase
.from('invoices')
.update({
quote_status: nextStatus,
// Re-sending the same decision keeps its original timestamp.
quote_decided_at:
nextStatus === 'open'
? null
: nextStatus === quote.quote_status
? (quote.quote_decided_at ?? new Date().toISOString())
: new Date().toISOString(),
// undefined is dropped by supabase-js: only a supplied date moves.
valid_until: nextValidUntil,
updated_at: new Date().toISOString(),
})
.eq('id', id)
.eq('company_id', companyId)
.eq('quote_status', quote.quote_status)
.neq('status', 'cancelled')
.select('id, invoice_number, document_type, status, quote_status, quote_decided_at, valid_until')
.maybeSingle()
if (updateError) {
// trg_invoices_quote_decision_guard: a conversion landed between the
// read above and this write, so the decision is locked in accepted.
if (updateError.message?.includes('INVOICE_QUOTE_ALREADY_INVOICED')) {
return errorResponseFromCode('INVOICE_QUOTE_ALREADY_INVOICED', log, { requestId })
}
log.error('quote status update failed', updateError, { quoteId: id })
return errorResponse(updateError, log, { requestId })
}
if (!updated) {
return errorResponseFromCode('INVOICE_QUOTE_CHANGED_CONCURRENTLY', log, { requestId })
}
return NextResponse.json({ data: updated })
},
{ requireWrite: true },
)
+18 -2
View File
@@ -87,13 +87,12 @@ export const PATCH = withRouteContext<{ params: Promise<{ id: string }> }>(
})
if (!validation.success) return validation.response
const input = validation.data
const documentType: InvoiceDocumentType = input.document_type || 'invoice'
// Fetch the target. Only drafts (not sent, no committed verifikat, not a
// received self-billing document) may be edited.
const { data: existing, error: fetchError } = await supabase
.from('invoices')
.select('id, status, invoice_number, journal_entry_id, is_self_billed, credited_invoice_id, deduction_personnummer_encrypted, deduction_personnummer_last4')
.select('id, status, invoice_number, journal_entry_id, is_self_billed, credited_invoice_id, document_type, quote_status, deduction_personnummer_encrypted, deduction_personnummer_last4')
.eq('id', id)
.eq('company_id', companyId!)
.single()
@@ -110,6 +109,23 @@ export const PATCH = withRouteContext<{ params: Promise<{ id: string }> }>(
return errorResponseFromCode('INVOICE_UPDATE_NOT_DRAFT', ctxLog, { requestId })
}
// An omitted document_type means "unchanged", never "invoice": a client
// that only edits lines on a delivery-note or quote draft must not trip
// the series lock below.
const existingType: InvoiceDocumentType = existing.document_type ?? 'invoice'
const documentType: InvoiceDocumentType = input.document_type ?? existingType
// Quotes and delivery notes are numbered at insert from their own series,
// so their type is fixed: turning OF-007 into a faktura would carry a
// quote number into the F-series (and vice versa).
const seriesLocked = (t: InvoiceDocumentType) => t === 'quote' || t === 'delivery_note'
if (existingType !== documentType && (seriesLocked(existingType) || seriesLocked(documentType))) {
return errorResponseFromCode('INVOICE_UPDATE_DOCUMENT_TYPE_LOCKED', ctxLog, {
requestId,
details: { from: existingType, to: documentType },
})
}
// Resolve the (possibly changed) customer.
const { data: customer, error: customerError } = await supabase
.from('customers')
+87
View File
@@ -772,4 +772,91 @@ describe('POST /api/invoices (create credit note)', () => {
expect(status).toBe(500)
expect((body.error as unknown as { code: string }).code).toBe('INVOICE_CREATE_ITEMS_FAILED')
})
it('numbers a quote from the OF-series at insert and never touches the F-series or the event bus', async () => {
const customer = makeCustomer({ id: VALID_UUID })
const createdQuote = makeInvoice({
id: 'q-1',
invoice_number: 'OF-001',
document_type: 'quote',
valid_until: '2024-07-15',
quote_status: 'open',
})
mockGetVatRules.mockReturnValue({
treatment: 'standard_25',
rate: 25,
momsRuta: '10',
reverseChargeText: null,
})
mockCalculateVat.mockReturnValue(2500)
mockGetAvailableVatRates.mockReturnValue([
{ rate: 25, label: '25%', treatment: 'standard_25' },
{ rate: 12, label: '12%', treatment: 'reduced_12' },
{ rate: 6, label: '6%', treatment: 'reduced_6' },
{ rate: 0, label: '0% (momsfri)', treatment: 'exempt' },
])
// Fetch customer
enqueue({ data: customer, error: null })
// company_settings.vat_registered gate
enqueue({ data: { vat_registered: true }, error: null })
// generate_quote_number RPC (numbered BEFORE insert, like delivery notes)
enqueue({ data: 'OF-001', error: null })
// Insert quote
enqueue({ data: createdQuote, error: null })
// Insert items
enqueue({ data: null, error: null })
// Fetch complete quote (no generate_invoice_number call in between)
enqueue({ data: { ...createdQuote, customer, items: [] }, error: null })
const emitSpy = vi.spyOn(eventBus, 'emit')
const request = createMockRequest('/api/invoices', {
method: 'POST',
body: {
customer_id: VALID_UUID,
invoice_date: '2024-06-15',
due_date: '2024-07-15',
valid_until: '2024-07-15',
document_type: 'quote',
currency: 'SEK',
items: [{ description: 'Consulting', quantity: 10, unit: 'tim', unit_price: 1000 }],
},
})
const response = await POST(request, { params: Promise.resolve({}) })
const { status, body } = await parseJsonResponse<{ data: { invoice_number: string | null } }>(response)
expect(status).toBe(200)
expect(body.data.invoice_number).toBe('OF-001')
expect(mockSupabase.rpc).toHaveBeenCalledWith('generate_quote_number', { p_company_id: 'company-1' })
expect(mockSupabase.rpc).not.toHaveBeenCalledWith('generate_invoice_number', expect.anything())
const inserted = findCall('invoices', 'insert')?.[0] as Record<string, unknown>
expect(inserted.invoice_number).toBe('OF-001')
expect(inserted.document_type).toBe('quote')
expect(inserted.valid_until).toBe('2024-07-15')
// The decision column is set by the DB trigger, never by a writer.
expect(inserted).not.toHaveProperty('quote_status')
expect(inserted.remaining_amount).toBe(0)
expect(emitSpy).not.toHaveBeenCalledWith(expect.objectContaining({ type: 'invoice.created' }))
})
it('rejects a quote without valid_until', async () => {
const request = createMockRequest('/api/invoices', {
method: 'POST',
body: {
customer_id: VALID_UUID,
invoice_date: '2024-06-15',
due_date: '2024-07-15',
document_type: 'quote',
currency: 'SEK',
items: [{ description: 'Consulting', quantity: 1, unit: 'st', unit_price: 100 }],
},
})
const response = await POST(request, { params: Promise.resolve({}) })
const { status, body } = await parseJsonResponse<{ errors: Array<{ field: string }> }>(response)
expect(status).toBe(400)
expect(body.errors.map((e) => e.field)).toContain('valid_until')
})
})
@@ -0,0 +1,76 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { createMockRequest, parseJsonResponse, createQueuedMockSupabase } from '@/tests/helpers'
const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase()
vi.mock('@/lib/supabase/server', () => ({
createClient: () => Promise.resolve(mockSupabase),
}))
vi.mock('@/lib/company/context', () => ({
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
}))
import { GET } from '../route'
function get(query: string) {
return GET(createMockRequest(`/api/invoices/next-number${query}`, { method: 'GET' }), { params: Promise.resolve({}) })
}
describe('GET /api/invoices/next-number', () => {
beforeEach(() => {
vi.clearAllMocks()
reset()
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: { id: 'user-1' } } })
})
it('returns 401 when not authenticated', async () => {
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: null } })
const { status } = await parseJsonResponse(await get('?document_type=quote'))
expect(status).toBe(401)
})
it('returns 400 on an unknown document type', async () => {
const { status } = await parseJsonResponse(await get('?document_type=receipt'))
expect(status).toBe(400)
})
it('previews the F-series through the peek RPC for invoices', async () => {
enqueue({ data: 'F-012', error: null })
const { status, body } = await parseJsonResponse<{ data: { preview: string | null } }>(await get(''))
expect(status).toBe(200)
expect(body.data.preview).toBe('F-012')
expect(mockSupabase.rpc).toHaveBeenCalledWith('peek_next_invoice_number', {
p_company_id: 'company-1',
p_document_type: 'invoice',
})
})
it('previews the OF-series from company_settings for quotes without touching the F-series', async () => {
enqueue({ data: { next_quote_number: 7 }, error: null })
const { status, body } = await parseJsonResponse<{ data: { preview: string | null } }>(
await get('?document_type=quote'),
)
expect(status).toBe(200)
expect(body.data.preview).toBe('OF-007')
expect(mockSupabase.rpc).not.toHaveBeenCalled()
})
it('returns a null preview for quotes when the company has no settings row yet', async () => {
enqueue({ data: null, error: null })
const { status, body } = await parseJsonResponse<{ data: { preview: string | null } }>(
await get('?document_type=quote'),
)
expect(status).toBe(200)
expect(body.data.preview).toBeNull()
})
})
+17
View File
@@ -1,6 +1,7 @@
import { NextResponse } from 'next/server'
import { withRouteContext } from '@/lib/api/with-route-context'
import { errorResponse } from '@/lib/errors/get-structured-error'
import { formatQuoteNumber } from '@/lib/invoices/quote-status'
export const GET = withRouteContext(
'invoice.peek_next_number',
@@ -23,6 +24,22 @@ export const GET = withRouteContext(
return NextResponse.json({ data: { preview: null } })
}
// Quotes are numbered at insert from company_settings.next_quote_number;
// preview it directly (same format as generate_quote_number).
if (documentType === 'quote') {
const { data: settings, error: settingsError } = await supabase
.from('company_settings')
.select('next_quote_number')
.eq('company_id', companyId)
.maybeSingle()
if (settingsError) {
log.error('next_quote_number read failed', settingsError)
return errorResponse(settingsError, log, { requestId })
}
const next = (settings as { next_quote_number?: number | null } | null)?.next_quote_number
return NextResponse.json({ data: { preview: next ? formatQuoteNumber(next) : null } })
}
const { data, error } = await supabase.rpc('peek_next_invoice_number', {
p_company_id: companyId,
p_document_type: documentType,
@@ -149,6 +149,49 @@ describe('POST /api/invoices/preview-pdf', () => {
expect(mockSupabase.from).not.toHaveBeenCalledWith('customers')
})
it('passes valid_until through for a quote and names the file Offert', async () => {
enqueue({ data: { ...company, bankgiro: null }, error: null })
enqueue({ data: customer, error: null })
const response = await POST(
createMockRequest('/api/invoices/preview-pdf', {
method: 'POST',
body: {
...validBody,
invoice_number: 'OF-001',
document_type: 'quote',
due_date: '2026-08-20',
valid_until: '2026-08-20',
},
}),
createMockRouteParams({}),
)
// No payment account is needed for a quote: it is never a payment request.
expect(response.status).toBe(200)
expect(contentDispositionFilename(response.headers.get('Content-Disposition')))
.toBe('Oppy Sverige x Kund ÅÄÖ AB Offert nr OF-001 20260721.pdf')
const { invoice } = lastRenderProps()
expect(invoice.document_type).toBe('quote')
expect(invoice.valid_until).toBe('2026-08-20')
})
it('leaves valid_until null on non-quote documents', async () => {
enqueue({ data: company, error: null })
enqueue({ data: customer, error: null })
const response = await POST(
createMockRequest('/api/invoices/preview-pdf', {
method: 'POST',
body: { ...validBody, valid_until: '2026-08-20' },
}),
createMockRouteParams({}),
)
expect(response.status).toBe(200)
expect(lastRenderProps().invoice.valid_until).toBeNull()
})
// ROT/RUT (issue #1686): the preview must state the same avdrag row, info
// box and "Att betala" as the invoice the write path creates. The PDF
// template reads invoice.deduction_total / deduction_personnummer_masked
+6 -1
View File
@@ -81,7 +81,7 @@ export const POST = withRouteContext('invoice.preview_pdf', async (request, {
}) => {
const body = await request.json()
const {
customer_id, invoice_date, due_date, delivery_date, currency, items, your_reference, our_reference,
customer_id, invoice_date, due_date, delivery_date, valid_until, currency, items, your_reference, our_reference,
invoice_marking, notes,
document_type, invoice_number, payment_link_url,
deduction_personnummer, deduction_housing_designation, deduction_apartment_number, deduction_brf_org_number,
@@ -304,6 +304,11 @@ export const POST = withRouteContext('invoice.preview_pdf', async (request, {
invoice_date: invoice_date || new Date().toISOString().split('T')[0],
due_date: due_date || new Date().toISOString().split('T')[0],
delivery_date: delivery_date || null,
// Quotes (offert): the expiry the PDF prints as "Giltig till". The write
// path mirrors it into due_date, so fall back to that for the preview.
valid_until: docType === 'quote'
? ((typeof valid_until === 'string' && valid_until.trim()) || due_date || null)
: null,
status: 'draft',
currency: requestedCurrency,
exchange_rate: null,
+12 -2
View File
@@ -135,14 +135,24 @@ export const POST = withRouteContext(
return errorResponseFromCode(build.code, log, { requestId, details: build.details })
}
// Delivery notes are always numbered at insert (ignores save_as_draft);
// invoices/proformas get their F-number below or at finalize.
// Delivery notes and quotes are always numbered at insert from their own
// series (ignores save_as_draft): neither is a faktura, so no F-number is
// at stake. Invoices/proformas get their F-number below or at finalize.
let invoiceNumber: string | null = null
if (documentType === 'delivery_note') {
const { data: dnNumber } = await supabase.rpc('generate_delivery_note_number', {
p_company_id: companyId,
})
invoiceNumber = dnNumber
} else if (documentType === 'quote') {
const { data: quoteNumber, error: quoteNumberError } = await supabase.rpc('generate_quote_number', {
p_company_id: companyId,
})
if (quoteNumberError || !quoteNumber) {
log.error('quote number allocation failed', quoteNumberError ?? new Error('no number'))
return errorResponseFromCode('INVOICE_CREATE_NUMBER_ASSIGN_FAILED', log, { requestId })
}
invoiceNumber = quoteNumber
}
const { data: invoice, error: invoiceError } = await supabase
@@ -52,6 +52,9 @@ export const GET = withRouteContext<{ params: Promise<{ customerId: string }> }>
`)
.eq('company_id', companyId)
.eq('customer_id', customerId)
// Proformas, delivery notes and quotes are never receivables (parity
// with generateARLedger).
.eq('document_type', 'invoice')
.in('status', ['sent', 'overdue', 'credited'])
.order('invoice_date', { ascending: true })
.limit(PAGE_LIMIT)
+1
View File
@@ -167,6 +167,7 @@ export async function POST(request: Request) {
invoice_prefix: 'F',
next_invoice_number: 5,
next_delivery_note_number: 1,
next_quote_number: 1,
invoice_default_days: 30,
// Sender bank details: the pain.001 debtor for the betalfil demo.
// Example IBAN from the Swedish IBAN documentation range; BIC derives
@@ -78,6 +78,8 @@ describe('POST /api/transactions/[id]/match-batch', () => {
it('returns 200 with the RPC result on the happy path', async () => {
// RPC returns success envelope
// document_type pre-check for the customer allocations (before the RPC)
enqueue({ data: [{ id: INV_UUID, document_type: 'invoice' }], error: null })
enqueue({
data: {
ok: true,
@@ -199,6 +201,8 @@ describe('POST /api/transactions/[id]/match-batch', () => {
})
it('maps an RPC structured failure to errorResponseFromCode', async () => {
// document_type pre-check for the customer allocations (before the RPC)
enqueue({ data: [{ id: INV_UUID, document_type: 'invoice' }], error: null })
enqueue({
data: {
ok: false,
@@ -221,6 +225,8 @@ describe('POST /api/transactions/[id]/match-batch', () => {
})
it('maps a raw RPC error to BATCH_RPC_FAILED', async () => {
// document_type pre-check for the customer allocations (before the RPC)
enqueue({ data: [{ id: INV_UUID, document_type: 'invoice' }], error: null })
enqueue({ data: null, error: { message: 'connection dropped' } })
const request = createMockRequest(`/api/transactions/${TX_UUID}/match-batch`, {
@@ -234,4 +240,20 @@ describe('POST /api/transactions/[id]/match-batch', () => {
expect(status).toBe(500)
expect(body.error.code).toBe('BATCH_RPC_FAILED')
})
it('refuses a quote in the allocation list before the RPC runs', async () => {
enqueue({ data: [{ id: INV_UUID, document_type: 'quote' }], error: null })
const request = createMockRequest(`/api/transactions/${TX_UUID}/match-batch`, {
method: 'POST',
body: {
allocations: [{ kind: 'customer_invoice', invoice_id: INV_UUID, amount: 1000 }],
},
})
const response = await POST(request, createMockRouteParams({ id: TX_UUID }))
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
expect(status).toBe(400)
expect(body.error.code).toBe('MATCH_INVOICE_NOT_INVOICE_TYPE')
expect(mockSupabase.rpc).not.toHaveBeenCalled()
})
})
+30 -1
View File
@@ -2,7 +2,7 @@ import { NextResponse } from 'next/server'
import { withRouteContext } from '@/lib/api/with-route-context'
import { validateBody } from '@/lib/api/validate'
import { MatchBatchSchema } from '@/lib/api/schemas'
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error'
import { eventBus } from '@/lib/events/bus'
import { clearSettledBatchAllocationSuggestions } from '@/lib/invoices/clear-settled-batch-allocations'
import { ensureInitialized } from '@/lib/init'
@@ -73,6 +73,35 @@ export const POST = withRouteContext(
// PR #607 round 3: p_user_id removed: RPC resolves caller from
// auth.uid() directly. Keeps the attack surface off the API boundary.
// Only fakturor carry a receivable. The RPC gates on status alone, so a
// sent proforma or quote in the allocation list is refused here, as the
// single-invoice match route does.
const customerInvoiceIds = Array.from(
new Set(
validation.data.allocations.flatMap((a) =>
a.kind === 'customer_invoice' && a.invoice_id ? [a.invoice_id] : [],
),
),
)
if (customerInvoiceIds.length > 0) {
const { data: docRows, error: docError } = await supabase
.from('invoices')
.select('id, document_type')
.in('id', customerInvoiceIds)
.eq('company_id', companyId)
if (docError) {
txLog.error('match-batch: document lookup failed', docError)
return errorResponse(docError, txLog, { requestId })
}
const offender = (docRows ?? []).find((r) => r.document_type && r.document_type !== 'invoice')
if (offender) {
return errorResponseFromCode('MATCH_INVOICE_NOT_INVOICE_TYPE', txLog, {
requestId,
details: { invoiceId: offender.id, documentType: offender.document_type },
})
}
}
const { data, error } = await supabase.rpc('match_batch_allocate', {
p_tx_id: transactionId,
p_allocations: validation.data.allocations,
@@ -183,6 +183,8 @@ export const GET = withApiV1<{ params: Promise<{ companyId: string; id: string }
.select(OPEN_INVOICE_COLUMNS)
.eq('company_id', ctx.companyId!)
.eq('customer_id', customerId)
// Proformas, delivery notes and quotes are never open receivables.
.eq('document_type', 'invoice')
.in('status', OPEN_INVOICE_STATUSES)
.order('invoice_date', { ascending: false })
@@ -580,4 +580,32 @@ describe('DELETE /api/v1/companies/:companyId/invoices/:id', () => {
expect(body.data.preview).toEqual({ cancelled: true, invoice_number: '2026-0042' })
expect(captures.filter((c) => c.table === 'invoices')).toEqual([])
})
it('returns 409 INVOICE_UPDATE_NOT_DRAFT for an accepted quote: a recorded decision is not editable', async () => {
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
invoices: {
data: {
...DRAFT_INVOICE,
invoice_number: 'OF-003',
document_type: 'quote',
valid_until: '2026-07-31',
quote_status: 'accepted',
},
error: null,
},
}),
)
const res = await patchInvoice(
makePatchRequest({ items: NEW_ITEMS }),
detailParams(COMPANY_ID, INVOICE_ID),
)
expect(res.status).toBe(409)
const body = await res.json()
expect(body.error.code).toBe('INVOICE_UPDATE_NOT_DRAFT')
expect(body.error.details.quote_status).toBe('accepted')
})
})
@@ -196,6 +196,12 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
const typed = invoice as unknown as Invoice & { customer?: { name?: string } }
// Document-shape guards before status check (consistent with mark-sent).
// A quote is an offer, not a claim: convert it to a faktura first.
if (typed.document_type === 'quote') {
return v1ErrorResponseFromCode('INVOICE_QUOTE_NOT_PAYABLE', ctx.log, {
requestId: ctx.requestId,
})
}
if (typed.document_type === 'delivery_note') {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
@@ -0,0 +1,319 @@
/**
* Integration tests for POST /api/v1/companies/:companyId/invoices/:id/quote-status.
*/
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
beforeAll(() => {
if (process.env.NODE_ENV !== 'test') {
throw new Error(
`quote-status route tests require NODE_ENV=test (got ${process.env.NODE_ENV ?? 'undefined'})`,
)
}
process.env.NEXT_PUBLIC_SUPABASE_URL ||= 'http://localhost:54321'
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ||= 'test-anon-key'
})
vi.mock('@/lib/auth/api-keys', async () => {
const actual = await vi.importActual<typeof import('@/lib/auth/api-keys')>('@/lib/auth/api-keys')
return {
...actual,
validateApiKey: vi.fn(),
createServiceClientNoCookies: vi.fn(),
}
})
vi.mock('@supabase/supabase-js', async () => {
const actual = await vi.importActual<typeof import('@supabase/supabase-js')>('@supabase/supabase-js')
return { ...actual, createClient: vi.fn().mockReturnValue({}) }
})
import { validateApiKey, createServiceClientNoCookies } from '@/lib/auth/api-keys'
import { POST as setQuoteStatus } from '../route'
const mockValidate = validateApiKey as ReturnType<typeof vi.fn>
const mockServiceClient = createServiceClientNoCookies as ReturnType<typeof vi.fn>
type MockResult = { data?: unknown; error?: unknown }
type RecordedCall = { table: string; method: string; args: unknown[] }
function makeFlexibleSupabase(
byTable: Record<string, MockResult | MockResult[]>,
calls?: RecordedCall[],
) {
// Per-table queue: arrays return results in order across multiple calls
// to .from('table'); single values return the same result every time.
const queues = new Map<string, MockResult[]>()
for (const [t, val] of Object.entries(byTable)) {
queues.set(t, Array.isArray(val) ? [...val] : [val])
}
const buildChain = (table: string): unknown => {
const handler: ProxyHandler<object> = {
get(_target, prop) {
if (prop === 'then') {
return (resolve: (v: unknown) => void) => {
const q = queues.get(table)
const next = q && q.length > 1 ? q.shift()! : (q?.[0] ?? { data: null, error: null })
resolve(next)
}
}
return (...args: unknown[]) => {
calls?.push({ table, method: String(prop), args })
return buildChain(table)
}
},
}
return new Proxy({}, handler)
}
return { from: vi.fn((table: string) => buildChain(table)) }
}
const COMPANY_ID = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'
const QUOTE_ID = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb'
const USER_ID = 'user-1'
function makeRequest(
body: unknown,
opts: { auth?: boolean; idempotencyKey?: boolean; dryRun?: boolean; id?: string } = {},
): Request {
const headers: Record<string, string> = { 'Content-Type': 'application/json' }
if (opts.auth !== false) headers.Authorization = 'Bearer test-fixture-not-a-real-key'
if (opts.idempotencyKey !== false) headers['Idempotency-Key'] = 'idem1234-7777-4abc-8def-1234567890ab'
const id = opts.id ?? QUOTE_ID
const url = `https://x.test/api/v1/companies/${COMPANY_ID}/invoices/${id}/quote-status${opts.dryRun ? '?dry_run=true' : ''}`
return new Request(url, { method: 'POST', headers, body: JSON.stringify(body) })
}
function detailParams(companyId: string, id: string) {
return { params: Promise.resolve({ companyId, id }) }
}
const MEMBER = { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }
const OPEN_QUOTE = {
id: QUOTE_ID,
invoice_number: 'OF-007',
document_type: 'quote',
status: 'sent',
quote_status: 'open',
quote_decided_at: null,
valid_until: '2099-12-31',
}
beforeEach(() => {
vi.clearAllMocks()
mockValidate.mockResolvedValue({
userId: USER_ID,
companyId: COMPANY_ID,
apiKeyId: 'ak_1',
apiKeyName: 'CI key',
scopes: ['invoices:write'],
mode: 'live',
})
})
describe('POST /api/v1/companies/:companyId/invoices/:id/quote-status', () => {
it('returns 401 without a bearer token', async () => {
mockServiceClient.mockReturnValue(makeFlexibleSupabase({}))
const res = await setQuoteStatus(
makeRequest({ status: 'accepted' }, { auth: false }),
detailParams(COMPANY_ID, QUOTE_ID),
)
expect(res.status).toBe(401)
const body = await res.json()
expect(body.error.code).toBe('UNAUTHORIZED')
})
it('returns 400 VALIDATION_ERROR for an unknown status', async () => {
mockServiceClient.mockReturnValue(makeFlexibleSupabase({ company_members: MEMBER }))
const res = await setQuoteStatus(
makeRequest({ status: 'expired' }),
detailParams(COMPANY_ID, QUOTE_ID),
)
expect(res.status).toBe(400)
const body = await res.json()
expect(body.error.code).toBe('VALIDATION_ERROR')
})
it('returns 400 VALIDATION_ERROR for a non-UUID id', async () => {
mockServiceClient.mockReturnValue(makeFlexibleSupabase({ company_members: MEMBER }))
const res = await setQuoteStatus(
makeRequest({ status: 'accepted' }, { id: 'not-a-uuid' }),
detailParams(COMPANY_ID, 'not-a-uuid'),
)
expect(res.status).toBe(400)
const body = await res.json()
expect(body.error.code).toBe('VALIDATION_ERROR')
expect(body.error.details.field).toBe('id')
})
it('returns 404 NOT_FOUND when the quote does not exist in the company', async () => {
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: MEMBER,
invoices: { data: null, error: null },
}),
)
const res = await setQuoteStatus(
makeRequest({ status: 'accepted' }),
detailParams(COMPANY_ID, QUOTE_ID),
)
expect(res.status).toBe(404)
const body = await res.json()
expect(body.error.code).toBe('NOT_FOUND')
})
it('returns 400 INVOICE_NOT_A_QUOTE for a regular invoice', async () => {
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: MEMBER,
invoices: { data: { ...OPEN_QUOTE, document_type: 'invoice', quote_status: null }, error: null },
}),
)
const res = await setQuoteStatus(
makeRequest({ status: 'accepted' }),
detailParams(COMPANY_ID, QUOTE_ID),
)
expect(res.status).toBe(400)
const body = await res.json()
expect(body.error.code).toBe('INVOICE_NOT_A_QUOTE')
expect(body.error.details.document_type).toBe('invoice')
})
it('returns 400 INVOICE_QUOTE_NOT_DECIDABLE for a cancelled quote', async () => {
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: MEMBER,
invoices: { data: { ...OPEN_QUOTE, status: 'cancelled' }, error: null },
}),
)
const res = await setQuoteStatus(
makeRequest({ status: 'declined' }),
detailParams(COMPANY_ID, QUOTE_ID),
)
expect(res.status).toBe(400)
const body = await res.json()
expect(body.error.code).toBe('INVOICE_QUOTE_NOT_DECIDABLE')
})
it('returns 409 INVOICE_QUOTE_ALREADY_INVOICED once an active invoice was converted from it', async () => {
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: MEMBER,
invoices: [
{ data: { ...OPEN_QUOTE, quote_status: 'accepted' }, error: null },
// converted_from_id lookup: an active invoice exists.
{ data: { id: 'cccccccc-cccc-4ccc-8ccc-cccccccccccc', invoice_number: '2026-0042' }, error: null },
],
}),
)
const res = await setQuoteStatus(
makeRequest({ status: 'declined' }),
detailParams(COMPANY_ID, QUOTE_ID),
)
expect(res.status).toBe(409)
const body = await res.json()
expect(body.error.code).toBe('INVOICE_QUOTE_ALREADY_INVOICED')
expect(body.error.details.invoice_number).toBe('2026-0042')
})
it('records the decision and returns the effective status', async () => {
const calls: RecordedCall[] = []
const decidedAt = '2026-09-02T09:14:33.000Z'
mockServiceClient.mockReturnValue(
makeFlexibleSupabase(
{
company_members: MEMBER,
invoices: [
{ data: OPEN_QUOTE, error: null },
// converted_from_id lookup: nothing converted yet.
{ data: null, error: null },
{ data: { ...OPEN_QUOTE, quote_status: 'accepted', quote_decided_at: decidedAt }, error: null },
],
},
calls,
),
)
const res = await setQuoteStatus(
makeRequest({ status: 'accepted' }),
detailParams(COMPANY_ID, QUOTE_ID),
)
expect(res.status).toBe(200)
const body = await res.json()
expect(body.data.document_type).toBe('quote')
expect(body.data.quote_status).toBe('accepted')
expect(body.data.effective_quote_status).toBe('accepted')
expect(body.data.quote_decided_at).toBe(decidedAt)
expect(body.data.invoice_number).toBe('OF-007')
const update = calls.find((c) => c.table === 'invoices' && c.method === 'update')
expect(update).toBeDefined()
expect(update!.args[0]).toMatchObject({ quote_status: 'accepted' })
expect((update!.args[0] as { quote_decided_at: unknown }).quote_decided_at).toEqual(expect.any(String))
})
it('reports expired for an open quote past valid_until (derived, never stored)', async () => {
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: MEMBER,
invoices: [
{ data: { ...OPEN_QUOTE, valid_until: '2020-01-01' }, error: null },
{ data: null, error: null },
{ data: { ...OPEN_QUOTE, valid_until: '2020-01-01' }, error: null },
],
}),
)
const res = await setQuoteStatus(
makeRequest({ status: 'open' }),
detailParams(COMPANY_ID, QUOTE_ID),
)
expect(res.status).toBe(200)
const body = await res.json()
expect(body.data.quote_status).toBe('open')
expect(body.data.effective_quote_status).toBe('expired')
})
it('dry-run previews the decision without writing', async () => {
const calls: RecordedCall[] = []
mockServiceClient.mockReturnValue(
makeFlexibleSupabase(
{
company_members: MEMBER,
invoices: [
{ data: OPEN_QUOTE, error: null },
{ data: null, error: null },
],
},
calls,
),
)
const res = await setQuoteStatus(
makeRequest({ status: 'declined' }, { dryRun: true }),
detailParams(COMPANY_ID, QUOTE_ID),
)
expect(res.status).toBe(200)
const body = await res.json()
expect(body.data.dry_run).toBe(true)
expect(body.data.preview.quote_status).toBe('declined')
expect(body.data.preview.effective_quote_status).toBe('declined')
expect(calls.some((c) => c.table === 'invoices' && c.method === 'update')).toBe(false)
})
})
@@ -0,0 +1,256 @@
/**
* POST /api/v1/companies/{companyId}/invoices/{id}/quote-status
*
* Records the customer's decision on a quote (offert): open, accepted or
* declined. Mirrors the dashboard route (app/api/invoices/[id]/quote-status):
* any transition between the three decisions is allowed until the quote has
* been converted to an invoice, after which the decision is locked.
* "expired" is never written: it is derived from valid_until
* (lib/invoices/quote-status) and reported as effective_quote_status.
*
* Accepting a quote past valid_until is permitted; a cancelled quote cannot
* be decided. No journal entry, no number allocation, no event: a quote is
* an offer, not a claim (it books nothing until converted).
*
* Idempotent (mandatory Idempotency-Key) and dry-runnable.
*/
import { z } from 'zod'
import { ok } from '@/lib/api/v1/response'
import { dryRunPreview } from '@/lib/api/v1/dry-run'
import { registerEndpoint, dataEnvelope } from '@/lib/api/v1/registry'
import { withApiV1 } from '@/lib/api/v1/with-api-v1'
import { v1ErrorResponse, v1ErrorResponseFromCode, v1ValidationError } from '@/lib/api/v1/errors'
import { readV1JsonBody } from '@/lib/api/v1/body'
import { SetQuoteStatusSchema } from '@/lib/api/schemas'
import { effectiveQuoteStatus } from '@/lib/invoices/quote-status'
const QuoteStatusResponse = z.object({
id: z.string().uuid(),
invoice_number: z.string().nullable(),
document_type: z.literal('quote'),
status: z.string(),
quote_status: z.enum(['open', 'accepted', 'declined']),
// The reported state: the stored decision, or 'expired' for an open quote
// whose valid_until has passed.
effective_quote_status: z.enum(['open', 'accepted', 'declined', 'expired']),
quote_decided_at: z.string().nullable(),
valid_until: z.string().nullable(),
})
registerEndpoint({
operation: 'invoices.quote-status',
method: 'POST',
path: '/api/v1/companies/:companyId/invoices/:id/quote-status',
summary: 'Record the customer decision on a quote (offert).',
description:
'Sets quote_status on a quote (document_type=quote) to open, accepted or declined. Any transition between the three is allowed until the quote has been converted to an invoice; after that the decision is locked (409 INVOICE_QUOTE_ALREADY_INVOICED). "expired" is never written: it is derived from valid_until and reported as effective_quote_status. Accepting a quote past valid_until is allowed (pass valid_until here to extend an expired quote so it reads as open again). No journal entry, number allocation or event is involved. Idempotent and dry-runnable.',
useWhen:
'The customer answered a quote and you want Accounted to reflect it (accepted / declined), or you want to reopen a decision that was recorded by mistake.',
doNotUseFor:
'Creating the invoice from an accepted quote (convert it in the dashboard; the conversion marks the quote accepted itself). Regular invoices, proformas or delivery notes: they return 400 INVOICE_NOT_A_QUOTE.',
pitfalls: [
'Only document_type=quote rows are decidable; anything else returns 400 INVOICE_NOT_A_QUOTE.',
'A cancelled quote returns 400 INVOICE_QUOTE_NOT_DECIDABLE.',
'Once an active invoice exists with converted_from_id = this quote, the decision is locked: 409 INVOICE_QUOTE_ALREADY_INVOICED. Cancelling that invoice frees the quote again.',
'Setting status=open clears quote_decided_at; accepted/declined stamp it with the request time.',
'Idempotency-Key is mandatory. A retried call with the same key replays the cached response.',
],
example: {
request: { status: 'accepted' },
response: {
data: {
id: '0e9c…',
invoice_number: 'OF-007',
document_type: 'quote',
status: 'sent',
quote_status: 'accepted',
effective_quote_status: 'accepted',
quote_decided_at: '2026-09-02T09:14:33Z',
valid_until: '2026-09-30',
},
meta: { request_id: 'req_…', api_version: '2026-05-12' },
},
},
scope: 'invoices:write',
risk: 'low',
idempotent: true,
reversible: true,
dryRunSupported: true,
request: { body: SetQuoteStatusSchema },
response: { success: dataEnvelope(QuoteStatusResponse) },
})
type QuoteRow = {
id: string
invoice_number: string | null
document_type: string
status: string
quote_status: string | null
quote_decided_at: string | null
valid_until: string | null
}
function toResponse(row: QuoteRow) {
return {
...row,
effective_quote_status: effectiveQuoteStatus(row) ?? row.quote_status,
}
}
export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string }> }>(
'invoices.quote-status',
async (request, ctx, params) => {
const { id } = await params.params
const idParse = z.string().uuid().safeParse(id)
if (!idParse.success) {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: { field: 'id', message: 'Invoice id must be a UUID.' },
})
}
const quoteId = idParse.data
if (!z.string().uuid().safeParse(ctx.companyId).success) {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: { field: 'companyId', message: 'companyId must be a UUID.' },
})
}
const raw = await readV1JsonBody(request, ctx)
if (!raw.ok) return raw.response
const parsed = SetQuoteStatusSchema.safeParse(raw.body)
if (!parsed.success) {
return v1ValidationError(ctx, parsed.error)
}
const nextStatus = parsed.data.status
const { data: quote, error: fetchError } = await ctx.supabase
.from('invoices')
.select('id, invoice_number, document_type, status, quote_status, quote_decided_at, valid_until')
.eq('company_id', ctx.companyId!)
.eq('id', quoteId)
.maybeSingle()
if (fetchError) {
return v1ErrorResponse(fetchError, ctx.log, { requestId: ctx.requestId })
}
if (!quote) {
ctx.log.warn('invoices.quote-status: not found', { quoteId, companyId: ctx.companyId })
return v1ErrorResponseFromCode('NOT_FOUND', ctx.log, {
requestId: ctx.requestId,
details: { resource: 'invoice' },
})
}
const typed = quote as unknown as QuoteRow
if (typed.document_type !== 'quote') {
return v1ErrorResponseFromCode('INVOICE_NOT_A_QUOTE', ctx.log, {
requestId: ctx.requestId,
details: { document_type: typed.document_type },
})
}
if (typed.status === 'cancelled') {
return v1ErrorResponseFromCode('INVOICE_QUOTE_NOT_DECIDABLE', ctx.log, {
requestId: ctx.requestId,
})
}
// One active invoice per quote locks the decision; a cancelled converted
// invoice frees the quote again (same predicate as the converter).
const { data: converted, error: convertedError } = await ctx.supabase
.from('invoices')
.select('id, invoice_number')
.eq('company_id', ctx.companyId!)
.eq('converted_from_id', quoteId)
.neq('status', 'cancelled')
.limit(1)
.maybeSingle()
if (convertedError) {
return v1ErrorResponse(convertedError, ctx.log, { requestId: ctx.requestId })
}
if (converted) {
const conv = converted as { id: string; invoice_number: string | null }
return v1ErrorResponseFromCode('INVOICE_QUOTE_ALREADY_INVOICED', ctx.log, {
requestId: ctx.requestId,
details: { invoice_id: conv.id, invoice_number: conv.invoice_number },
})
}
// Re-sending the same decision keeps its original timestamp (idempotent).
const decidedAt =
nextStatus === 'open'
? null
: nextStatus === typed.quote_status
? (typed.quote_decided_at ?? new Date().toISOString())
: new Date().toISOString()
if (ctx.dryRun) {
return dryRunPreview(
toResponse({
...typed,
quote_status: nextStatus,
quote_decided_at: decidedAt,
valid_until: parsed.data.valid_until ?? typed.valid_until,
}),
{ requestId: ctx.requestId, log: ctx.log },
)
}
const { data: updated, error: updateError } = await ctx.supabase
.from('invoices')
.update({
quote_status: nextStatus,
quote_decided_at: decidedAt,
// undefined is dropped by supabase-js: only a supplied date moves.
valid_until: parsed.data.valid_until,
updated_at: new Date().toISOString(),
})
.eq('company_id', ctx.companyId!)
.eq('id', quoteId)
// Compare-and-set: a conversion or cancel that lands after the checks
// above makes this a 0-row update instead of overwriting newer state.
.eq('quote_status', typed.quote_status)
.neq('status', 'cancelled')
.select('id, invoice_number, document_type, status, quote_status, quote_decided_at, valid_until')
.maybeSingle()
if (updateError) {
// trg_invoices_quote_decision_guard: a conversion landed between the
// read above and this write, so the decision is locked in accepted.
if ((updateError as { message?: string }).message?.includes('INVOICE_QUOTE_ALREADY_INVOICED')) {
return v1ErrorResponseFromCode('INVOICE_QUOTE_ALREADY_INVOICED', ctx.log, {
requestId: ctx.requestId,
})
}
ctx.log.error('invoices.quote-status: update failed', updateError as Error, {
quoteId,
companyId: ctx.companyId,
})
return v1ErrorResponse(updateError, ctx.log, { requestId: ctx.requestId })
}
if (!updated) {
ctx.log.warn('invoices.quote-status: quote changed between read and update', {
quoteId,
companyId: ctx.companyId,
})
return v1ErrorResponseFromCode('INVOICE_QUOTE_CHANGED_CONCURRENTLY', ctx.log, {
requestId: ctx.requestId,
})
}
ctx.log.info('invoices.quote-status success', {
quoteId,
companyId: ctx.companyId,
userId: ctx.userId,
quoteStatus: nextStatus,
})
return ok(toResponse(updated as unknown as QuoteRow), { requestId: ctx.requestId })
},
{ requireIdempotencyKey: true },
)
@@ -34,6 +34,8 @@ import { INVOICE_FULL_COLUMNS, INVOICE_ITEM_FULL_COLUMNS } from '@/lib/api/v1/in
import { DimensionsBagSchema } from '@/lib/bookkeeping/dimension-resolver'
import { CreateInvoiceItemSchema } from '@/lib/api/schemas'
import { buildInvoiceWriteData } from '@/lib/invoices/build-invoice-write'
import { isEditableInvoiceDraft } from '@/lib/invoices/is-editable-draft'
import { effectiveQuoteStatus } from '@/lib/invoices/quote-status'
import { deleteDraftInvoice } from '@/lib/invoices/delete-draft-invoice'
import { replaceInvoiceItems } from '@/lib/invoices/replace-invoice-items'
import type { Currency, Customer, InvoiceDocumentType } from '@/types'
@@ -72,6 +74,12 @@ const InvoiceDetail = z.object({
due_date: z.string(),
status: z.string(),
document_type: z.string(),
// Quotes only (null otherwise). quote_status is the EFFECTIVE decision:
// open | accepted | declined | expired, where expired is derived from
// valid_until and never stored.
valid_until: z.string().nullable().optional(),
quote_status: z.string().nullable().optional(),
quote_decided_at: z.string().nullable().optional(),
currency: z.string(),
total: z.number(),
remaining_amount: z.number(),
@@ -193,7 +201,13 @@ export const GET = withApiV1<{ params: Promise<{ companyId: string; id: string }
})
}
return ok(data, { requestId: ctx.requestId })
// Quotes: report the effective decision (expired is derived from
// valid_until, never stored). Null for every other document type.
const row = data as unknown as Record<string, unknown> & {
quote_status?: string | null
valid_until?: string | null
}
return ok({ ...row, quote_status: effectiveQuoteStatus(row) }, { requestId: ctx.requestId })
},
)
@@ -304,10 +318,17 @@ export const PATCH = withApiV1<{ params: Promise<{ companyId: string; id: string
details: { resource: 'invoice' },
})
}
if ((current as { status: string }).status !== 'draft') {
// Shared predicate with the dashboard PATCH: draft, no verifikat, not a
// received self-billing document, not a credit-note draft, and for a
// quote not accepted or declined (a recorded decision must be reopened
// before the offer itself is edited).
if (!isEditableInvoiceDraft(current as Parameters<typeof isEditableInvoiceDraft>[0])) {
return v1ErrorResponseFromCode('INVOICE_UPDATE_NOT_DRAFT', ctx.log, {
requestId: ctx.requestId,
details: { current_status: (current as { status: string }).status },
details: {
current_status: (current as { status: string }).status,
quote_status: (current as { quote_status?: string | null }).quote_status ?? null,
},
})
}
@@ -181,6 +181,60 @@ describe('POST /api/v1/companies/:companyId/invoices/bulk-create', () => {
expect(body.data.summary.succeeded).toBe(0)
})
// A quote needs its own OF-number, valid_until and quote_status, none of
// which the bulk path assigns, so the item is refused before any DB access
// and the sibling invoice in the same batch still succeeds.
it('refuses a quote item per item without inserting, other items unaffected', async () => {
const supabase = makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
customers: { data: VALID_CUSTOMER, error: null },
invoices: { data: { id: 'inv-1', invoice_number: null, status: 'draft', total: 1250 }, error: null },
invoice_items: { data: null, error: null },
})
mockServiceClient.mockReturnValue(supabase)
const res = await bulkCreate(
makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/invoices/bulk-create`, {
invoices: [
{
customer_id: CUSTOMER_ID,
document_type: 'quote',
invoice_date: '2026-09-02',
due_date: '2026-10-02',
valid_until: '2026-10-02',
currency: 'SEK',
items: [SAMPLE_ITEM('Offert')],
},
{
customer_id: CUSTOMER_ID,
invoice_date: '2026-09-02',
due_date: '2026-10-02',
currency: 'SEK',
items: [SAMPLE_ITEM('B')],
},
],
}),
companyParams(COMPANY_ID),
)
expect(res.status).toBe(200)
const body = await res.json()
expect(body.data.results[0].ok).toBe(false)
expect(body.data.results[0].request_index).toBe(0)
expect(body.data.results[0].error.code).toBe('VALIDATION_ERROR')
expect(body.data.results[0].error.details).toEqual({
field: 'document_type',
message: 'Quotes are not supported by bulk-create; use POST /invoices with document_type quote.',
})
expect(body.data.results[1].ok).toBe(true)
expect(body.data.summary).toEqual({ total: 2, succeeded: 1, failed: 1 })
// Only the invoice item touched the customers and invoices tables.
const tables = supabase.from.mock.calls.map((c) => c[0])
expect(tables.filter((t) => t === 'customers')).toHaveLength(1)
expect(tables.filter((t) => t === 'invoices')).toHaveLength(1)
})
// A validated EU business: the picker default is 0% (huvudregeln, ML 6 kap.
// 34 §), but the ML 6 kap. supplies taxed where they are performed carry
// Swedish VAT to that same customer, so the gate reads getPermittedVatRates.
@@ -30,6 +30,11 @@
* - 50 invoices per request. Larger imports should be split.
* - No transactional guarantee between items today; the all_or_nothing
* flag is reserved for a future RPC implementation.
* - Quotes (document_type 'quote') are not supported. A quote is a single
* document with its own OF-number series, valid_until and quote_status,
* none of which this path assigns; a quote item is refused per item as
* VALIDATION_ERROR without inserting anything. Use POST /invoices with
* document_type quote instead.
*/
import { z } from 'zod'
@@ -91,6 +96,7 @@ registerEndpoint({
'Passing all_or_nothing: true returns 501 NOT_IMPLEMENTED. Today only partial-success batches exist; omit the flag (or pass false).',
'Each per-item invoice still goes through the same VAT-rule validation as POST /invoices. A mismatched per-item vat_rate produces a per-item failure, not a whole-batch failure.',
'Currency conversion is best-effort PER ITEM. A failed Riksbanken fetch leaves that item\'s SEK columns null but does NOT fail the item.',
'Quotes (document_type: quote) are refused per item as VALIDATION_ERROR: a quote carries its own OF-number, valid_until and quote_status. Create quotes one at a time with POST /invoices.',
],
example: {
request: {
@@ -159,6 +165,25 @@ async function createOneInvoice(
): Promise<ResultItem> {
const documentType: InvoiceDocumentType = input.document_type || 'invoice'
// CreateInvoiceSchema accepts document_type 'quote', but this path never
// assigns the OF-number, valid_until or quote_status the DB CHECK requires
// for a quote (pg 23514). Refuse before touching the database so the item
// fails cleanly and the rest of the batch is unaffected.
if (documentType === 'quote') {
return {
ok: false,
request_index: index,
error: {
code: 'VALIDATION_ERROR',
message: 'Quotes are not supported by bulk-create.',
details: {
field: 'document_type',
message: 'Quotes are not supported by bulk-create; use POST /invoices with document_type quote.',
},
},
}
}
// Customer fetch (scoped to company). We use the DB-returned `customer.id`
// (not `input.customer_id`) downstream as defense in depth: the .eq()
// pair already enforces company scoping, but echoing the trusted value
@@ -30,6 +30,7 @@ import { readV1JsonBody } from '@/lib/api/v1/body'
import { CreateInvoiceSchema } from '@/lib/api/schemas'
import { INVOICE_FULL_COLUMNS, INVOICE_ITEM_FULL_COLUMNS } from '@/lib/api/v1/invoice-columns'
import { buildInvoiceWriteData } from '@/lib/invoices/build-invoice-write'
import { effectiveQuoteStatus } from '@/lib/invoices/quote-status'
import {
resolveSelfBilledSaleDraft,
createSelfBilledSaleInvoice,
@@ -89,7 +90,7 @@ const InvoiceStatus = z.enum([
'credited',
])
const InvoiceDocumentType = z.enum(['invoice', 'proforma', 'delivery_note'])
const InvoiceDocumentType = z.enum(['invoice', 'proforma', 'delivery_note', 'quote'])
const InvoiceSummary = z.object({
id: z.string().uuid(),
@@ -100,6 +101,10 @@ const InvoiceSummary = z.object({
due_date: z.string(),
status: InvoiceStatus,
document_type: InvoiceDocumentType,
// Quotes only (null otherwise): expiry date and the effective decision.
// "expired" is derived from valid_until, never stored.
valid_until: z.string().nullable(),
quote_status: z.enum(['open', 'accepted', 'declined', 'expired']).nullable(),
currency: z.string(),
subtotal: z.number(),
vat_amount: z.number(),
@@ -117,7 +122,7 @@ const ALLOWED_EXPAND = ['customer', 'items'] as const
// fields not in the summary schema. Schema migrations adding columns must
// update this list before the field becomes visible on the public API.
const INVOICE_SUMMARY_COLUMNS =
'id, invoice_number, customer_id, invoice_date, due_date, status, document_type, currency, subtotal, vat_amount, total, remaining_amount, paid_at, created_at'
'id, invoice_number, customer_id, invoice_date, due_date, status, document_type, valid_until, quote_status, currency, subtotal, vat_amount, total, remaining_amount, paid_at, created_at'
// Customer projections: three tiers for different contexts:
// - NAME_ONLY: default for the invoice list (inline customer_name only)
@@ -153,6 +158,7 @@ registerEndpoint({
'Credit notes appear with status=credited and a credited_invoice_id field on the detail endpoint.',
'Ordering is by created_at (registration time), not invoice_date. Backdated invoices therefore appear where they were created, not where their date falls: filter on ?date_from / ?date_to when you care about the business date.',
'Cursor pagination: pass ?cursor=<next_cursor> from the previous response. A stale or tampered cursor is ignored and the first page is returned again.',
'Quotes (document_type=quote, offert) carry valid_until and quote_status (open | accepted | declined | expired). "expired" is derived: an open quote past valid_until; filter with ?quote_status=expired. Quotes never book and are never payable: convert an accepted quote to an invoice in the dashboard first.',
],
example: {
response: {
@@ -214,6 +220,9 @@ export const GET = withApiV1<{ params: Promise<{ companyId: string }> }>(
status: InvoiceStatus.optional(),
customer_id: z.string().uuid().optional(),
document_type: InvoiceDocumentType.optional(),
// Quotes only. "expired" is derived (open AND valid_until < today),
// so it is a predicate here rather than a stored value.
quote_status: z.enum(['open', 'accepted', 'declined', 'expired']).optional(),
currency: z.string().regex(/^[A-Z]{3}$/, 'currency must be a 3-letter ISO-4217 code').optional(),
// invoice_date range. The sort key is created_at (see the ordering
// rationale below), so these filters are how a caller narrows by the
@@ -225,6 +234,7 @@ export const GET = withApiV1<{ params: Promise<{ companyId: string }> }>(
status: url.searchParams.get('status') ?? undefined,
customer_id: url.searchParams.get('customer_id') ?? undefined,
document_type: url.searchParams.get('document_type') ?? undefined,
quote_status: url.searchParams.get('quote_status') ?? undefined,
currency: url.searchParams.get('currency') ?? undefined,
date_from: url.searchParams.get('date_from') ?? undefined,
date_to: url.searchParams.get('date_to') ?? undefined,
@@ -263,6 +273,14 @@ export const GET = withApiV1<{ params: Promise<{ companyId: string }> }>(
if (filters.status) query = query.eq('status', filters.status)
if (filters.customer_id) query = query.eq('customer_id', filters.customer_id)
if (filters.document_type) query = query.eq('document_type', filters.document_type)
if (filters.quote_status) {
query = query.eq('document_type', 'quote')
if (filters.quote_status === 'expired') {
query = query.eq('quote_status', 'open').lt('valid_until', new Date().toISOString().split('T')[0])
} else {
query = query.eq('quote_status', filters.quote_status)
}
}
if (filters.currency) query = query.eq('currency', filters.currency)
if (filters.date_from) query = query.gte('invoice_date', filters.date_from)
if (filters.date_to) query = query.lte('invoice_date', filters.date_to)
@@ -292,6 +310,8 @@ export const GET = withApiV1<{ params: Promise<{ companyId: string }> }>(
due_date: string
status: string
document_type: string
valid_until: string | null
quote_status: string | null
currency: string
subtotal: number
vat_amount: number
@@ -323,6 +343,8 @@ export const GET = withApiV1<{ params: Promise<{ companyId: string }> }>(
due_date: r.due_date,
status: r.status,
document_type: r.document_type,
valid_until: r.valid_until ?? null,
quote_status: effectiveQuoteStatus(r),
currency: r.currency,
subtotal: r.subtotal,
vat_amount: r.vat_amount,
@@ -369,6 +391,10 @@ const InvoiceCreated = z.object({
due_date: z.string(),
status: z.string(),
document_type: z.string(),
// Quotes only (null otherwise): a fresh quote is numbered OF-nnn at
// create and starts as quote_status 'open'.
valid_until: z.string().nullable().optional(),
quote_status: z.string().nullable().optional(),
currency: z.string(),
subtotal: z.number(),
vat_amount: z.number(),
@@ -394,6 +420,7 @@ registerEndpoint({
'Non-SEK currencies require an active Riksbanken exchange-rate fetch. Failure is non-fatal: the invoice is created with null SEK fields and the agent can recompute later.',
'invoice_number is null on creation. The number is allocated atomically when the invoice transitions out of draft. Counting on a specific number at create time is a bug.',
'document_type=\'delivery_note\' produces no VAT and a different number sequence (D-series). Most use cases want the default document_type=\'invoice\'.',
'document_type=\'quote\' (offert) requires valid_until (YYYY-MM-DD, the expiry; due_date mirrors it). A quote is numbered OF-nnn from its own series at create, starts as quote_status=\'open\', never posts a journal entry, never emits invoice.created and cannot be sent-and-booked or paid: record the customer decision with POST /invoices/{id}/quote-status and convert an accepted quote to an invoice in the dashboard.',
'is_self_billed=true registers a self-billing invoice your CUSTOMER issued on your behalf (a sale for you). It is booked immediately (not a draft, no F-number), so external_invoice_number and received_date are required and it is NOT dry-run-free of side effects on the live call. Do NOT set it for a normal invoice you issue yourself.',
'Project/cost-center tagging: pass default_dimensions ({"6":"P001"} = project, {"1":"KS01"} = kostnadsställe) for the whole invoice and/or items[].dimensions per line (per-line wins per key). Tags are stored on the draft and applied to the journal entry lines when the invoice is sent. When the company has the dimension registry enabled, unknown or archived codes are rejected at :send with 400 DIMENSION_VALIDATION_FAILED — list valid codes via GET /dimensions.',
'ROT/RUT: set items[].deduction_type ("rot"|"rut") on labor lines plus labor_hours and work_type (Skatteverket arbetstypskod). The invoice must carry deduction_personnummer AND housing info: deduction_housing_designation (fastighetsbeteckning) for småhus, or deduction_apartment_number + deduction_brf_org_number for bostadsrätt. deduction_amount is computed server-side and cannot be set by the caller; the response exposes deduction_total and remaining_amount = total - deduction_total (Skatteverket pays the rest via 1513). Validation failures return 400 INVOICE_CREATE_ROT_RUT_VALIDATION.',
@@ -628,6 +655,19 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string }> }>(
p_company_id: ctx.companyId!,
})
invoiceNumber = dnNumber as string | null
} else if (documentType === 'quote') {
// Quotes are numbered at insert from their own OF-series (never the
// F-series): see generate_quote_number.
const { data: quoteNumber, error: quoteNumberError } = await ctx.supabase.rpc('generate_quote_number', {
p_company_id: ctx.companyId!,
})
if (quoteNumberError || !quoteNumber) {
ctx.log.error('quote number allocation failed', quoteNumberError ?? new Error('no number'))
return v1ErrorResponseFromCode('INVOICE_CREATE_NUMBER_ASSIGN_FAILED', ctx.log, {
requestId: ctx.requestId,
})
}
invoiceNumber = quoteNumber as string
}
const { data: invoice, error: invoiceErr } = await ctx.supabase
+121 -36
View File
@@ -114,6 +114,9 @@ export type InvoiceEditorProps = (
/** Open with the självfaktura tab preselected (the "Självfaktura" entry in
* the invoice list's split button). Create mode only. */
initialSelfBilled?: boolean
/** Open with this document type preselected (the "Ny offert" entry in the
* invoice list's split button). Create mode only. */
initialDocumentType?: InvoiceDocumentType
}
// Subset of Article fields the line picker needs to pre-fill a row.
@@ -188,6 +191,10 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
// 2026-08-17). Self-billing is never available when editing a draft.
const mode: 'invoice' | 'self_billed' =
props.initialSelfBilled && !isEditMode ? 'self_billed' : 'invoice'
const createDocumentType: InvoiceDocumentType =
!isEditMode && !isCopyMode && !props.initialSelfBilled && props.initialDocumentType
? props.initialDocumentType
: 'invoice'
// Company-wide opt-in from the invoice settings page: the whole payment
// link section (manual field + Stripe auto toggle) stays hidden until the
// company enables it. The send routes enforce the same setting server-side
@@ -350,9 +357,13 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
customer_id: z.string().min(1, t('validation_customer_required')),
invoice_date: z.string().min(1, t('validation_invoice_date_required')),
due_date: z.string().min(1, t('validation_due_date_required')),
// Quotes only: the expiry date ("Giltig till"). due_date mirrors it on
// the wire (the column is NOT NULL); required for quotes via the
// superRefine below so the error lands under the visible field.
valid_until: z.string().optional(),
delivery_date: z.string().optional(),
currency: z.enum(['SEK', 'EUR', 'USD', 'GBP', 'NOK', 'DKK']),
document_type: z.enum(['invoice', 'proforma', 'delivery_note']),
document_type: z.enum(['invoice', 'proforma', 'delivery_note', 'quote']),
your_reference: z.string().optional(),
our_reference: z.string().optional(),
invoice_marking: z.string().optional(),
@@ -389,6 +400,14 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
deduction_personnummer: z.string().optional(),
deduction_housing_designation: z.string().optional(),
items: z.array(itemSchema).min(1, t('validation_min_one_row')),
}).superRefine((data, ctx) => {
if (data.document_type === 'quote' && !data.valid_until) {
ctx.addIssue({
code: 'custom',
path: ['valid_until'],
message: t('validation_valid_until_required'),
})
}
})
}, [t, ta])
@@ -540,6 +559,8 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
customer_id: initial.customer_id,
invoice_date: initial.invoice_date,
due_date: initial.due_date,
valid_until:
initial.document_type === 'quote' ? initial.valid_until ?? initial.due_date : '',
delivery_date: initial.delivery_date ?? '',
currency: initial.currency,
document_type: (initial.document_type ?? 'invoice') as InvoiceDocumentType,
@@ -582,6 +603,7 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
customer_id: copyInitial.customer_id,
invoice_date: '',
due_date: '',
valid_until: '',
delivery_date: '',
currency: copyInitial.currency,
document_type: 'invoice' as InvoiceDocumentType,
@@ -602,8 +624,9 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
customer_id: '',
invoice_date: '',
due_date: '',
valid_until: '',
currency: 'SEK',
document_type: 'invoice' as InvoiceDocumentType,
document_type: createDocumentType,
payment_link_url: '',
payment_link_auto: true,
external_invoice_number: '',
@@ -625,6 +648,7 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
setValue('invoice_date', format(new Date(), 'yyyy-MM-dd'))
setValue('received_date', format(new Date(), 'yyyy-MM-dd'))
setValue('due_date', format(addDays(new Date(), 30), 'yyyy-MM-dd'))
setValue('valid_until', format(addDays(new Date(), 30), 'yyyy-MM-dd'))
}, [])
const { fields, append, remove, move } = useFieldArray({
@@ -652,6 +676,7 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
// stay live while the settings panel is collapsed.
const watchInvoiceDate = watch('invoice_date')
const watchDueDate = watch('due_date')
const watchValidUntil = watch('valid_until')
const watchReceivedDate = watch('received_date')
const watchDeliveryDate = watch('delivery_date')
const watchYourReference = watch('your_reference')
@@ -1176,6 +1201,8 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
const isSelfBilled = mode === 'self_billed'
// ROT/RUT is an own-issued, B2C concept: never shown for a received self-bill.
const isInvoiceDoc = watchDocumentType === 'invoice' && !isSelfBilled
// Offert: no due date (an expiry instead), no payment box, never books.
const isQuoteDoc = watchDocumentType === 'quote' && !isSelfBilled
rotRutCompletenessAppliesRef.current = isInvoiceDoc
// ROT/RUT yearly-ceiling context: what this customer has already been
@@ -1423,6 +1450,15 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
}
}
// A quote's "Giltig till" is its own field; the shared schema still wants a
// due_date, so the wire body mirrors valid_until into it. Other document
// types never send valid_until (undefined disappears in JSON).
function withQuoteValidity(data: FormData): FormData {
if (data.document_type !== 'quote') return { ...data, valid_until: undefined }
const validUntil = data.valid_until || data.due_date
return { ...data, due_date: validUntil, valid_until: validUntil }
}
async function onSubmit(data: FormData) {
if (isEditMode) {
// Editing a draft: no review dialog, straight to PATCH.
@@ -1494,6 +1530,7 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
function getDocLabel(type: InvoiceDocumentType): string {
if (type === 'proforma') return t('doc_label_proforma')
if (type === 'delivery_note') return t('doc_label_delivery_note')
if (type === 'quote') return t('doc_label_quote')
return t('doc_label_invoice')
}
@@ -1516,7 +1553,7 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
// Shared body builder (lib/invoices/editor-payload.ts): dimension pruning,
// ROT/RUT privacy strip, self-billing-carrier removal and the always-sent
// ore_rounding / default_dimensions all live there, pinned by parity tests.
const sanitizedPayload = buildInvoiceWritePayload(pendingData, {
const sanitizedPayload = buildInvoiceWritePayload(withQuoteValidity(pendingData), {
oreRounding,
defaultDims,
})
@@ -1572,7 +1609,7 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
// failures keep their toasts: this replaces the client-side validation
// toast only.
function focusSettingsField(
name: 'invoice_date' | 'due_date' | 'received_date' | 'payment_link_url',
name: 'invoice_date' | 'due_date' | 'valid_until' | 'received_date' | 'payment_link_url',
) {
// In self-billed mode fakturadatum and mottagningsdatum render uncollapsed
// next to the external number: focus directly, no panel to expand.
@@ -1598,10 +1635,13 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
customerTriggerRef.current?.focus()
break
case 'invoice_date':
case 'due_date':
case 'received_date':
focusSettingsField(step.kind)
break
case 'due_date':
// A quote's date row is "Giltig till": the step reuses the due kind.
focusSettingsField(isQuoteDoc ? 'valid_until' : 'due_date')
break
case 'rows_empty':
entryInputRef.current?.focus()
break
@@ -1656,6 +1696,7 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
}
if (errs.customer_id) customerTriggerRef.current?.focus()
else if (errs.invoice_date) focusSettingsField('invoice_date')
else if (errs.valid_until) focusSettingsField('valid_until')
else if (errs.due_date) focusSettingsField('due_date')
}
@@ -1666,7 +1707,7 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
async function saveDraftData(data: FormData) {
setIsSavingDraft(true)
const payload = buildInvoiceWritePayload(data, {
const payload = buildInvoiceWritePayload(withQuoteValidity(data), {
saveAsDraft: true,
oreRounding,
defaultDims,
@@ -1709,7 +1750,7 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
if (!initial) return
setIsSubmitting(true)
const payload = buildInvoiceWritePayload(data, {
const payload = buildInvoiceWritePayload(withQuoteValidity(data), {
oreRounding,
defaultDims,
})
@@ -1791,7 +1832,8 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
body: JSON.stringify({
customer_id: data.customer_id,
invoice_date: data.invoice_date,
due_date: data.due_date,
due_date: withQuoteValidity(data).due_date,
valid_until: withQuoteValidity(data).valid_until,
currency: data.currency,
document_type: data.document_type,
items: data.items,
@@ -1856,7 +1898,9 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
? t('title_proforma')
: watchDocumentType === 'delivery_note'
? t('title_delivery_note')
: t('title_invoice')
: watchDocumentType === 'quote'
? t('title_quote')
: t('title_invoice')
// In bare (dialog) mode the dialog owns the accessible title (sr-only
// DialogTitle) and the page already has its own h1, so the visible heading
// steps down to h2: it still tracks document type and number preview live.
@@ -1879,7 +1923,7 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
isSelfBilled,
customerSelected: Boolean(watchCustomerId),
invoiceDate: watchInvoiceDate || '',
dueDate: watchDueDate || '',
dueDate: (isQuoteDoc ? watchValidUntil : watchDueDate) || '',
receivedDate: watchReceivedDate || '',
externalInvoiceNumber: watchExternalNumber || '',
items: watchItems ?? [],
@@ -1898,7 +1942,7 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
const nextStepLabels: Record<Exclude<NextStep['kind'], 'ready' | 'row_incomplete'>, string> = {
customer: t('next_step_customer'),
invoice_date: t('next_step_invoice_date'),
due_date: t('next_step_due_date'),
due_date: isQuoteDoc ? t('next_step_valid_until') : t('next_step_due_date'),
rows_empty: t('next_step_add_row'),
payment_link: t('next_step_payment_link'),
personnummer: t('next_step_personnummer'),
@@ -1923,6 +1967,7 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
currency: watchCurrency,
invoiceDate: watchInvoiceDate || '',
dueDate: watchDueDate || '',
validUntil: watchValidUntil || '',
receivedDate: watchReceivedDate || '',
deliveryDate: watchDeliveryDate || '',
yourReference: watchYourReference || '',
@@ -1934,7 +1979,11 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
const chipTexts = forvalChips.map((chip) => {
switch (chip.kind) {
case 'doc_type':
return chip.documentType === 'proforma' ? t('doctype_proforma') : t('doctype_delivery_note')
return chip.documentType === 'proforma'
? t('doctype_proforma')
: chip.documentType === 'quote'
? t('doctype_quote')
: t('doctype_delivery_note')
case 'currency':
return t('chip_currency', { currency: chip.currency })
case 'invoice_date':
@@ -1943,6 +1992,8 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
return t('chip_due_days', { days: chip.days, date: chip.date })
case 'due_date':
return t('chip_due_date', { date: chip.date })
case 'valid_until':
return t('chip_valid_until', { date: chip.date })
case 'received':
return t('chip_received', { date: chip.date })
case 'delivery':
@@ -2924,13 +2975,21 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
name="document_type"
control={control}
render={({ field }) => (
<Select value={field.value} onValueChange={field.onChange}>
<Select
value={field.value}
onValueChange={field.onChange}
// Quotes and delivery notes are numbered from their
// own series at insert, so an existing one cannot
// change type (the API refuses it too).
disabled={isEditMode && (field.value === 'quote' || field.value === 'delivery_note')}
>
<SelectTrigger className="h-8 w-44 text-[13px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="invoice">{t('doctype_invoice')}</SelectItem>
<SelectItem value="proforma">{t('doctype_proforma')}</SelectItem>
<SelectItem value="quote">{t('doctype_quote')}</SelectItem>
<SelectItem value="delivery_note">{t('doctype_delivery_note')}</SelectItem>
</SelectContent>
</Select>
@@ -2984,22 +3043,41 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
</div>
)}
<div className={SETTINGS_ROW_CLASS}>
<Label className="text-[13px] font-normal">
{t('due_date_label')}<RequiredMark />
</Label>
<div>
<Input
type="date"
{...register('due_date')}
aria-required="true"
className="h-8 w-40 text-[13px] tabular-nums"
/>
{errors.due_date && (
<p className="mt-1 text-xs text-destructive">{errors.due_date.message}</p>
)}
{isQuoteDoc ? (
<div className={SETTINGS_ROW_CLASS}>
<Label className="text-[13px] font-normal">
{t('valid_until_label')}<RequiredMark />
</Label>
<div>
<Input
type="date"
{...register('valid_until')}
aria-required="true"
className="h-8 w-40 text-[13px] tabular-nums"
/>
{errors.valid_until && (
<p className="mt-1 text-xs text-destructive">{errors.valid_until.message}</p>
)}
</div>
</div>
</div>
) : (
<div className={SETTINGS_ROW_CLASS}>
<Label className="text-[13px] font-normal">
{t('due_date_label')}<RequiredMark />
</Label>
<div>
<Input
type="date"
{...register('due_date')}
aria-required="true"
className="h-8 w-40 text-[13px] tabular-nums"
/>
{errors.due_date && (
<p className="mt-1 text-xs text-destructive">{errors.due_date.message}</p>
)}
</div>
</div>
)}
{watchDocumentType === 'invoice' && !isSelfBilled && (
<div className={SETTINGS_ROW_CLASS}>
@@ -3307,21 +3385,27 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
isSubmitting={isSubmitting}
title={watchDocumentType === 'proforma'
? t('review_dialog_title_proforma')
: watchDocumentType === 'delivery_note'
? t('review_dialog_title_delivery_note')
: t('review_dialog_title_invoice')}
: watchDocumentType === 'quote'
? t('review_dialog_title_quote')
: watchDocumentType === 'delivery_note'
? t('review_dialog_title_delivery_note')
: t('review_dialog_title_invoice')}
warningText={watchDocumentType === 'invoice'
? accountingMethod === 'cash'
? t('review_warning_invoice_cash')
: t('review_warning_invoice_accrual')
: watchDocumentType === 'proforma'
? t('review_warning_proforma')
: t('review_warning_delivery_note')}
: watchDocumentType === 'quote'
? t('review_warning_quote')
: t('review_warning_delivery_note')}
confirmLabel={watchDocumentType === 'proforma'
? t('confirm_create_proforma')
: watchDocumentType === 'delivery_note'
? t('confirm_create_delivery_note')
: t('confirm_create_invoice')}
: watchDocumentType === 'quote'
? t('confirm_create_quote')
: watchDocumentType === 'delivery_note'
? t('confirm_create_delivery_note')
: t('confirm_create_invoice')}
extraActions={
<Button
variant="outline"
@@ -3340,7 +3424,8 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
<InvoiceReviewContent
customer={selectedCustomer}
invoiceDate={pendingData?.invoice_date || ''}
dueDate={pendingData?.due_date || ''}
dueDate={(isQuoteDoc ? pendingData?.valid_until : pendingData?.due_date) || ''}
dueDateLabelKey={isQuoteDoc ? 'valid_until' : 'due_date'}
currency={(pendingData?.currency || 'SEK') as Currency}
items={(pendingData?.items || []).map((item) => ({
...item,
+4 -1
View File
@@ -54,6 +54,8 @@ interface InvoiceReviewContentProps {
/** Payment-link förval on this invoice: Stripe auto-link on send, a manually
* pasted link, or none. Renders in the Förval summary line when set. */
paymentLink?: 'auto' | 'manual' | null
/** Quotes show their expiry ("Giltig till") where an invoice shows its due date. */
dueDateLabelKey?: 'due_date' | 'valid_until'
}
export function InvoiceReviewContent({
@@ -68,6 +70,7 @@ export function InvoiceReviewContent({
yourReference,
ourReference,
invoiceMarking,
dueDateLabelKey = 'due_date',
notes,
numberPreview,
oreRounding,
@@ -122,7 +125,7 @@ export function InvoiceReviewContent({
<p className="font-medium">{invoiceDate ? formatDate(invoiceDate) : ''}</p>
</div>
<div>
<span className="text-muted-foreground">{t('due_date')}</span>
<span className="text-muted-foreground">{t(dueDateLabelKey)}</span>
<p className="font-medium">{dueDate ? formatDate(dueDate) : ''}</p>
</div>
</div>
+24 -6
View File
@@ -22,6 +22,7 @@ import {
// "Ny faktura" opens instead of the dialog chunk followed by a second,
// dependent editor chunk (and then the editor's own data fetches).
import InvoiceEditor from '@/components/invoices/InvoiceEditor'
import type { InvoiceDocumentType } from '@/types'
interface Props {
open: boolean
@@ -29,6 +30,8 @@ interface Props {
copyFromId?: string | null
/** Preselect the självfaktura tab (split-button entry on /invoices). */
selfBilled?: boolean
/** Preselect a document type, e.g. 'quote' ("Ny offert" split-button entry). */
documentType?: InvoiceDocumentType
}
/**
@@ -38,11 +41,17 @@ interface Props {
* (unmounting the host list page and this dialog with it).
*
* The accessible title is visually hidden: the bare editor renders its own
* live heading, which tracks document type (faktura/proforma/följesedel) and
* shows the invoice-number preview: a static DialogTitle would duplicate or
* contradict it.
* live heading, which tracks document type (faktura/proforma/offert/följesedel)
* and shows the invoice-number preview: a static DialogTitle would duplicate
* or contradict it.
*/
export default function NewInvoiceDialog({ open, onOpenChange, copyFromId = null, selfBilled = false }: Props) {
export default function NewInvoiceDialog({
open,
onOpenChange,
copyFromId = null,
selfBilled = false,
documentType,
}: Props) {
const t = useTranslations('invoice_editor')
const { company } = useCompany()
const supabase = useMemo(() => createClient(), [])
@@ -133,7 +142,11 @@ export default function NewInvoiceDialog({ open, onOpenChange, copyFromId = null
onInteractOutside={(e) => e.preventDefault()}
>
<DialogTitle className="sr-only">
{copyFromId ? t('title_copy') : t('title_invoice')}
{copyFromId
? t('title_copy')
: documentType === 'quote' && !selfBilled
? t('title_quote')
: t('title_invoice')}
</DialogTitle>
{copyFromId ? (
copyLoadFailed ? (
@@ -154,7 +167,12 @@ export default function NewInvoiceDialog({ open, onOpenChange, copyFromId = null
</div>
)
) : (
<InvoiceEditor mode="create" bare initialSelfBilled={selfBilled} />
<InvoiceEditor
mode="create"
bare
initialSelfBilled={selfBilled}
initialDocumentType={documentType}
/>
)}
</DialogContent>
</Dialog>
@@ -205,6 +205,22 @@ describe('deriveForvalChips', () => {
})
})
it('replaces the due chips with a valid-until chip for a quote', () => {
const chips = deriveForvalChips(
chipsInput({ documentType: 'quote', validUntil: '2026-09-16' }),
)
expect(chips[0]).toEqual({ kind: 'doc_type', documentType: 'quote' })
expect(chips).toContainEqual({ kind: 'valid_until', date: '2026-09-16' })
expect(chips.find((c) => c.kind === 'due_days')).toBeUndefined()
expect(chips.find((c) => c.kind === 'due_date')).toBeUndefined()
})
it('skips the valid-until chip while a quote has no expiry yet', () => {
const chips = deriveForvalChips(chipsInput({ documentType: 'quote', validUntil: '' }))
expect(chips.find((c) => c.kind === 'valid_until')).toBeUndefined()
expect(chips.find((c) => c.kind === 'due_days')).toBeUndefined()
})
it('falls back to a plain due date when the invoice date is missing or after', () => {
expect(deriveForvalChips(chipsInput({ invoiceDate: '' }))).toContainEqual({
kind: 'due_date',
+10 -3
View File
@@ -113,11 +113,12 @@ export function deriveNextStep(input: NextStepInput): NextStep {
}
export type ForvalChip =
| { kind: 'doc_type'; documentType: 'proforma' | 'delivery_note' }
| { kind: 'doc_type'; documentType: 'proforma' | 'delivery_note' | 'quote' }
| { kind: 'currency'; currency: string }
| { kind: 'invoice_date'; date: string }
| { kind: 'due_days'; days: number; date: string }
| { kind: 'due_date'; date: string }
| { kind: 'valid_until'; date: string }
| { kind: 'received'; date: string }
| { kind: 'delivery'; date: string }
| { kind: 'your_reference'; reference: string }
@@ -128,10 +129,12 @@ export type ForvalChip =
export interface ForvalChipsInput {
isSelfBilled: boolean
documentType: 'invoice' | 'proforma' | 'delivery_note'
documentType: 'invoice' | 'proforma' | 'delivery_note' | 'quote'
currency: string
invoiceDate: string
dueDate: string
/** Quotes only: the expiry date ("Giltig till"). Replaces the due chips. */
validUntil?: string
receivedDate: string
deliveryDate: string
yourReference: string
@@ -162,7 +165,11 @@ export function deriveForvalChips(input: ForvalChipsInput): ForvalChip[] {
if (input.invoiceDate) {
chips.push({ kind: 'invoice_date', date: input.invoiceDate })
}
if (input.dueDate) {
if (input.documentType === 'quote' && !input.isSelfBilled) {
// A quote has no due date, only an expiry: the due chips would describe
// a payment term the document does not carry.
if (input.validUntil) chips.push({ kind: 'valid_until', date: input.validUntil })
} else if (input.dueDate) {
const days = dueDays(input.invoiceDate, input.dueDate)
if (days !== null && days >= 0) chips.push({ kind: 'due_days', days, date: input.dueDate })
else chips.push({ kind: 'due_date', date: input.dueDate })
@@ -74,6 +74,15 @@ describe('Accounted://attention', () => {
expect(result.categories).toEqual([])
})
it('only lists overdue fakturor: proformas, delivery notes and quotes are never receivables', async () => {
const { supabase, enqueue, findCalls } = createQueuedMockSupabase()
enqueueEmpty(enqueue)
await attentionResource.read(ctx(supabase))
expect(findCalls('invoices', 'eq')).toContainEqual(['document_type', 'invoice'])
})
it('classifies recently-unbooked transactions as warning', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
const today = new Date().toISOString().slice(0, 10)
@@ -185,6 +185,16 @@ describe('Accounted://company/current query shape', () => {
expect(argsOf(entries, 'eq')).toContainEqual(['source_type', 'bank_transaction'])
expect(argsOf(entries, 'eq')).not.toContainEqual(['source_type', 'transaction'])
})
it('counts only fakturor as open AR: proformas, delivery notes and quotes are never receivables', async () => {
const { supabase, calls } = createRecordingSupabase(emptyResults())
await companyCurrentResource.read(ctx(supabase))
const [invoices] = findCall(calls, 'invoices')
expect(invoices).toBeDefined()
expect(argsOf(invoices, 'eq')).toContainEqual(['document_type', 'invoice'])
})
})
describe('Accounted://company/current recency signals', () => {
@@ -391,6 +391,16 @@ describe('tools/list payload size guard', () => {
// Only READ tools may be demoted: gnubok_call_tool refuses writes, so a
// search-only WRITE is uncallable on Claude.ai. That is why the three
// bumps above happened instead of demotions.
//
// * 2026-09-02, offert (#2163): gnubok_set_quote_status (a WRITE, so it
// must stay in the default catalog) plus document_type / valid_until /
// quote_status on create, list and get. Measured 60 306 after merging
// the sales-order tools from #2166, then 60 093 after shortening every
// quote description and dropping set_quote_status's outputSchema to a
// bare object. The remaining ~100 tokens came from demoting
// gnubok_get_arsredovisning_filing_status to search-only: iXBRL
// filing is off until the Bolagsverket avtal exists, so no client
// polls it. Ceiling unchanged.
expect(approxTokens).toBeLessThan(60_000)
})
@@ -0,0 +1,365 @@
/**
* Offert (quote) surface on the MCP tools.
*
* A quote is document_type 'quote': numbered from its own OF-series at
* approval (generate_quote_number, never generate_invoice_number), never
* books, carries valid_until, and is decided with gnubok_set_quote_status.
* gnubok_convert_invoice creates the faktura from an open or accepted quote.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { createQueuedMockSupabase } from '@/tests/helpers'
import { tools } from '../server'
const createInvoice = tools.find((t) => t.name === 'gnubok_create_invoice')!
const setQuoteStatus = tools.find((t) => t.name === 'gnubok_set_quote_status')!
const convertInvoice = tools.find((t) => t.name === 'gnubok_convert_invoice')!
const listInvoices = tools.find((t) => t.name === 'gnubok_list_invoices')!
const CUSTOMER = {
id: 'cust-1',
name: 'Testbrand AB',
customer_type: 'swedish_business',
vat_number_validated: false,
default_payment_terms: 30,
}
const ITEMS = [{ description: 'Konsultation', quantity: 2, unit: 'tim', unit_price: 1000, vat_rate: 25 }]
beforeEach(() => {
vi.clearAllMocks()
})
describe('gnubok_create_invoice: quotes', () => {
it('declares document_type quote and valid_until on the input schema', () => {
const props = (createInvoice.inputSchema as { properties: Record<string, { enum?: string[] }> }).properties
expect(props.document_type.enum).toEqual(['invoice', 'quote'])
expect(props.valid_until).toBeDefined()
})
it('requires valid_until for a quote', async () => {
const { supabase } = createQueuedMockSupabase()
await expect(
createInvoice.execute(
{ customer_id: 'cust-1', document_type: 'quote', items: ITEMS },
'company-1',
'user-1',
supabase as never,
),
).rejects.toMatchObject({ code: 'VALIDATION_ERROR' })
})
it('refuses valid_until on a plain invoice', async () => {
const { supabase } = createQueuedMockSupabase()
await expect(
createInvoice.execute(
{ customer_id: 'cust-1', valid_until: '2026-12-31', items: ITEMS },
'company-1',
'user-1',
supabase as never,
),
).rejects.toMatchObject({ code: 'VALIDATION_ERROR' })
})
it('stages a quote with due_date mirroring valid_until and no F-series preview', async () => {
const { supabase, enqueue, findCall } = createQueuedMockSupabase()
enqueue({ data: CUSTOMER, error: null }) // customers fetch
enqueue({ data: null, error: null }) // resolvePeriodStatusForDate layer 1
enqueue({ data: null, error: null }) // resolvePeriodStatusForDate layer 2
enqueue({ data: { id: 'op-quote' }, error: null }) // pending_operations insert
const result = (await createInvoice.execute(
{
customer_id: 'cust-1',
document_type: 'quote',
valid_until: '2026-12-31',
invoice_date: '2026-09-02',
items: ITEMS,
},
'company-1',
'user-1',
supabase as never,
)) as { staged: boolean; message: string; preview: Record<string, unknown> }
expect(result.staged).toBe(true)
expect(result.preview.document_type).toBe('quote')
expect(result.preview.valid_until).toBe('2026-12-31')
expect(result.preview.due_date).toBe('2026-12-31')
expect(result.preview.total).toBe(2500)
expect(String(result.preview.invoice_number)).not.toContain('F-PREVIEW')
expect(result.preview.invoice_number).toBe('Offert OF-preview')
// The staged params carry what the executor needs to allocate OF-nnn.
const insertArgs = findCall('pending_operations', 'insert')
expect(insertArgs).toBeDefined()
const row = insertArgs![0] as { operation_type: string; title: string; params: Record<string, unknown> }
expect(row.operation_type).toBe('create_invoice')
expect(row.title).toContain('Ny offert')
expect(row.params).toMatchObject({ document_type: 'quote', valid_until: '2026-12-31', due_date: '2026-12-31' })
})
})
describe('gnubok_set_quote_status', () => {
it('is a direct invoices:write tool with a strict schema', () => {
expect(setQuoteStatus).toBeDefined()
expect(setQuoteStatus.inputSchema.additionalProperties).toBe(false)
expect(setQuoteStatus.annotations?.readOnlyHint).toBe(false)
})
it('records the decision and returns the effective status', async () => {
const { supabase, enqueue, findCall } = createQueuedMockSupabase()
enqueue({ data: { id: 'q-1', document_type: 'quote', status: 'sent', quote_status: 'open', quote_decided_at: null }, error: null })
enqueue({ data: null, error: null }) // converted_from_id lookup: nothing yet
enqueue({
data: {
id: 'q-1',
invoice_number: 'OF-003',
document_type: 'quote',
status: 'sent',
quote_status: 'accepted',
quote_decided_at: '2026-09-02T10:00:00.000Z',
valid_until: '2099-12-31',
},
error: null,
})
const result = (await setQuoteStatus.execute(
{ invoice_id: 'q-1', status: 'accepted' },
'company-1',
'user-1',
supabase as never,
)) as Record<string, unknown>
expect(result).toMatchObject({
invoice_id: 'q-1',
invoice_number: 'OF-003',
document_type: 'quote',
quote_status: 'accepted',
effective_quote_status: 'accepted',
valid_until: '2099-12-31',
})
expect(findCall('invoices', 'update')?.[0]).toMatchObject({ quote_status: 'accepted' })
})
it('reports expired for an open quote past valid_until', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: 'q-1', document_type: 'quote', status: 'sent', quote_status: 'declined', quote_decided_at: 'x' }, error: null })
enqueue({ data: null, error: null })
enqueue({
data: { id: 'q-1', invoice_number: 'OF-003', document_type: 'quote', status: 'sent', quote_status: 'open', quote_decided_at: null, valid_until: '2020-01-01' },
error: null,
})
const result = (await setQuoteStatus.execute(
{ invoice_id: 'q-1', status: 'open' },
'company-1',
'user-1',
supabase as never,
)) as Record<string, unknown>
expect(result.quote_status).toBe('open')
expect(result.effective_quote_status).toBe('expired')
})
it('refuses a document that is not a quote with INVOICE_NOT_A_QUOTE', async () => {
const { supabase, enqueue, findCall } = createQueuedMockSupabase()
enqueue({ data: { id: 'i-1', document_type: 'invoice', status: 'sent', quote_status: null, quote_decided_at: null }, error: null })
await expect(
setQuoteStatus.execute({ invoice_id: 'i-1', status: 'accepted' }, 'company-1', 'user-1', supabase as never),
).rejects.toMatchObject({ code: 'INVOICE_NOT_A_QUOTE' })
expect(findCall('invoices', 'update')).toBeUndefined()
})
it('locks the decision once the quote has been invoiced', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: 'q-1', document_type: 'quote', status: 'sent', quote_status: 'accepted', quote_decided_at: 'x' }, error: null })
enqueue({ data: { id: 'inv-9' }, error: null }) // active converted invoice exists
await expect(
setQuoteStatus.execute({ invoice_id: 'q-1', status: 'declined' }, 'company-1', 'user-1', supabase as never),
).rejects.toMatchObject({ code: 'INVOICE_QUOTE_ALREADY_INVOICED' })
})
it('rejects an unknown status before touching the database', async () => {
const { supabase } = createQueuedMockSupabase()
await expect(
setQuoteStatus.execute({ invoice_id: 'q-1', status: 'expired' }, 'company-1', 'user-1', supabase as never),
).rejects.toMatchObject({ code: 'VALIDATION_ERROR' })
expect(supabase.from).not.toHaveBeenCalled()
})
})
describe('gnubok_convert_invoice: quotes', () => {
it('fails on a declined quote with INVOICE_CONVERT_QUOTE_DECLINED', async () => {
const { supabase, enqueue, findCall } = createQueuedMockSupabase()
enqueue({
data: { id: 'q-1', invoice_number: 'OF-003', document_type: 'quote', status: 'sent', quote_status: 'declined', total: 2500, currency: 'SEK', customer: { name: 'Testbrand AB' } },
error: null,
})
await expect(
convertInvoice.execute({ invoice_id: 'q-1' }, 'company-1', 'user-1', supabase as never),
).rejects.toMatchObject({ code: 'INVOICE_CONVERT_QUOTE_DECLINED' })
expect(findCall('pending_operations', 'insert')).toBeUndefined()
})
it('fails on an already-invoiced quote with INVOICE_QUOTE_ALREADY_INVOICED', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({
data: { id: 'q-1', invoice_number: 'OF-003', document_type: 'quote', status: 'sent', quote_status: 'accepted', total: 2500, currency: 'SEK', customer: { name: 'Testbrand AB' } },
error: null,
})
enqueue({ data: { id: 'inv-9' }, error: null })
await expect(
convertInvoice.execute({ invoice_id: 'q-1' }, 'company-1', 'user-1', supabase as never),
).rejects.toMatchObject({ code: 'INVOICE_QUOTE_ALREADY_INVOICED' })
})
it('still refuses a regular invoice with INVOICE_CONVERT_NOT_CONVERTIBLE', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({
data: { id: 'i-1', invoice_number: '2026-0001', document_type: 'invoice', status: 'sent', quote_status: null, total: 2500, currency: 'SEK', customer: { name: 'Testbrand AB' } },
error: null,
})
await expect(
convertInvoice.execute({ invoice_id: 'i-1' }, 'company-1', 'user-1', supabase as never),
).rejects.toMatchObject({ code: 'INVOICE_CONVERT_NOT_CONVERTIBLE' })
})
it('stages the conversion of an open quote with an offert summary', async () => {
const { supabase, enqueue, findCall } = createQueuedMockSupabase()
enqueue({
data: { id: 'q-1', invoice_number: 'OF-003', document_type: 'quote', status: 'sent', quote_status: 'open', total: 2500, currency: 'SEK', customer: { name: 'Testbrand AB' } },
error: null,
})
enqueue({ data: null, error: null }) // converted_from_id lookup
enqueue({ data: { id: 'op-convert' }, error: null }) // pending_operations insert
const result = (await convertInvoice.execute(
{ invoice_id: 'q-1' },
'company-1',
'user-1',
supabase as never,
)) as { staged: boolean; message: string; preview: Record<string, unknown> }
expect(result.staged).toBe(true)
const row = findCall('pending_operations', 'insert')![0] as { title: string }
expect(row.title).toContain('Konvertera offert')
expect(row.title).toContain('OF-003')
expect(result.preview.source_document_type).toBe('quote')
expect(String(result.preview.will)).toContain('accepted')
})
})
describe('gnubok_list_invoices: quote filters', () => {
it('filters expired quotes as open past valid_until and exposes the derived status', async () => {
const { supabase, enqueue, findCalls } = createQueuedMockSupabase()
enqueue({
data: [
{ id: 'q-1', invoice_number: 'OF-001', status: 'sent', document_type: 'quote', quote_status: 'open', valid_until: '2020-01-01', total: 100, currency: 'SEK', customers: { name: 'Testbrand AB' } },
],
error: null,
count: 1,
})
const result = (await listInvoices.execute(
{ quote_status: 'expired' },
'company-1',
'user-1',
supabase as never,
)) as { invoices: Array<Record<string, unknown>> }
expect(result.invoices[0]).toMatchObject({ document_type: 'quote', valid_until: '2020-01-01', quote_status: 'expired' })
const eqCalls = findCalls('invoices', 'eq')
expect(eqCalls).toContainEqual(['document_type', 'quote'])
expect(eqCalls).toContainEqual(['quote_status', 'open'])
expect(findCalls('invoices', 'lt')[0]?.[0]).toBe('valid_until')
})
it('returns quote_status null for a regular invoice', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({
data: [{ id: 'i-1', invoice_number: '2026-0001', status: 'sent', document_type: 'invoice', quote_status: null, valid_until: null, total: 100, currency: 'SEK', customers: null }],
error: null,
count: 1,
})
const result = (await listInvoices.execute({}, 'company-1', 'user-1', supabase as never)) as {
invoices: Array<Record<string, unknown>>
}
expect(result.invoices[0].quote_status).toBeNull()
expect(result.invoices[0].valid_until).toBeNull()
})
describe('quotes are never claims on the MCP payment tools', () => {
const markPaid = tools.find((t) => t.name === 'gnubok_mark_invoice_as_paid')!
const matchTx = tools.find((t) => t.name === 'gnubok_match_transaction_to_invoice')!
it('gnubok_mark_invoice_as_paid refuses a sent quote with INVOICE_QUOTE_NOT_PAYABLE', async () => {
const { supabase, enqueue, findCall } = createQueuedMockSupabase()
enqueue({
data: { id: 'q-1', invoice_number: 'OF-001', document_type: 'quote', status: 'sent', quote_status: 'accepted', total: 2500, currency: 'SEK', customer: { name: 'Testbrand AB' } },
error: null,
})
await expect(
markPaid.execute({ invoice_id: 'q-1' }, 'company-1', 'user-1', supabase as never),
).rejects.toMatchObject({ code: 'INVOICE_QUOTE_NOT_PAYABLE' })
expect(findCall('pending_operations', 'insert')).toBeUndefined()
})
it('gnubok_match_transaction_to_invoice refuses a sent quote with MATCH_INVOICE_NOT_INVOICE_TYPE', async () => {
const { supabase, enqueue, findCall } = createQueuedMockSupabase()
enqueue({
data: { id: 'tx-1', description: 'Inbetalning', merchant_name: null, amount: 2500, currency: 'SEK', date: '2026-06-10', invoice_id: null },
error: null,
})
enqueue({
data: { id: 'q-1', invoice_number: 'OF-001', document_type: 'quote', status: 'sent', quote_status: 'accepted', total: 2500, remaining_amount: 0, currency: 'SEK', customer: { name: 'Testbrand AB' } },
error: null,
})
await expect(
matchTx.execute({ transaction_id: 'tx-1', invoice_id: 'q-1' }, 'company-1', 'user-1', supabase as never),
).rejects.toMatchObject({ code: 'MATCH_INVOICE_NOT_INVOICE_TYPE' })
expect(findCall('pending_operations', 'insert')).toBeUndefined()
})
})
describe('gnubok_set_quote_status: concurrency and expiry', () => {
it('reports INVOICE_QUOTE_CHANGED_CONCURRENTLY when the compare-and-set update hits 0 rows', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: 'q-1', document_type: 'quote', status: 'sent', quote_status: 'open', quote_decided_at: null, valid_until: '2026-12-31' }, error: null })
enqueue({ data: null, error: null }) // no converted invoice yet
enqueue({ data: null, error: null }) // update matched 0 rows: converted in between
await expect(
setQuoteStatus.execute({ invoice_id: 'q-1', status: 'declined' }, 'company-1', 'user-1', supabase as never),
).rejects.toMatchObject({ code: 'INVOICE_QUOTE_CHANGED_CONCURRENTLY' })
})
it('writes a new valid_until with the decision and rejects a malformed one', async () => {
const { supabase, enqueue, findCall } = createQueuedMockSupabase()
enqueue({ data: { id: 'q-1', document_type: 'quote', status: 'sent', quote_status: 'open', quote_decided_at: null, valid_until: '2026-01-31' }, error: null })
enqueue({ data: null, error: null })
enqueue({ data: { id: 'q-1', invoice_number: 'OF-001', document_type: 'quote', status: 'sent', quote_status: 'open', quote_decided_at: null, valid_until: '2026-12-31' }, error: null })
await setQuoteStatus.execute({ invoice_id: 'q-1', status: 'open', valid_until: '2026-12-31' }, 'company-1', 'user-1', supabase as never)
const update = findCall('invoices', 'update')?.[0] as Record<string, unknown>
expect(update.valid_until).toBe('2026-12-31')
const second = createQueuedMockSupabase()
second.enqueue({ data: { id: 'q-1', document_type: 'quote', status: 'sent', quote_status: 'open', quote_decided_at: null, valid_until: '2026-01-31' }, error: null })
second.enqueue({ data: null, error: null })
await expect(
setQuoteStatus.execute({ invoice_id: 'q-1', status: 'open', valid_until: 'next week' }, 'company-1', 'user-1', second.supabase as never),
).rejects.toMatchObject({ code: 'VALIDATION_ERROR' })
})
})
})
@@ -86,6 +86,9 @@ export const attentionResource: McpResource = {
.from('invoices')
.select('id, invoice_number, customer_id, due_date, total, currency, status')
.eq('company_id', companyId)
// Proformas, delivery notes and quotes are never receivables: a sent
// quote past its valid_until is expired, not overdue.
.eq('document_type', 'invoice')
.in('status', ['sent', 'overdue'])
.lt('due_date', today)
.order('due_date', { ascending: true })
@@ -73,11 +73,13 @@ export const companyCurrentResource: McpResource = {
.select('id', { count: 'exact', head: true })
.eq('company_id', companyId),
// Open AR: anything not paid/credited/cancelled.
// Open AR: anything not paid/credited/cancelled. Proformas, delivery
// notes and quotes are never receivables, so only fakturor count.
supabase
.from('invoices')
.select('id', { count: 'exact', head: true })
.eq('company_id', companyId)
.eq('document_type', 'invoice')
.in('status', ['draft', 'sent', 'overdue']),
// Open AP: anything still pending payment.
+278 -27
View File
@@ -159,6 +159,7 @@ import {
import { computeInitialRunDate } from '@/lib/invoices/recurring-schedule-service'
import { UpdateInvoiceParamsSchema } from '@/lib/pending-operations/schemas/update-invoice'
import { isEditableInvoiceDraft } from '@/lib/invoices/is-editable-draft'
import { effectiveQuoteStatus } from '@/lib/invoices/quote-status'
import {
ensureCompanyDimensions,
fetchDimensionRegistry,
@@ -536,6 +537,15 @@ const ANNOTATIONS_WRITE_OPEN_WORLD = {
openWorldHint: true,
} as const satisfies McpToolAnnotations
/**
* A domain failure carrying a structured-errors registry code, so the dispatch
* envelope (toToolError → getStructuredError) resolves the registry entry
* instead of UNKNOWN_ERROR. The Swedish registry message is the thrown text.
*/
function registryError(code: string): Error {
return Object.assign(new Error(getErrorEntry(code)?.message_sv ?? code), { code })
}
interface McpTool {
name: string
// Top-level Tool.title per MCP spec 2025-06-18 (human-facing label for
@@ -6437,9 +6447,9 @@ export const tools: McpTool[] = [
{
name: 'gnubok_list_invoices',
keywords: ['faktura', 'kundfaktura', 'fakturor', 'obetalda', 'förfallna', 'påminnelse'],
keywords: ['faktura', 'kundfaktura', 'fakturor', 'obetalda', 'förfallna', 'påminnelse', 'offert', 'offerter'],
title: 'List Customer Invoices',
description: 'List invoices for the active company, newest first. Optional status filter.',
description: 'List invoices and quotes (offerter) for the active company, newest first. Optional status, document_type and quote_status filters.',
inputSchema: {
type: 'object',
additionalProperties: false,
@@ -6449,6 +6459,15 @@ export const tools: McpTool[] = [
enum: ['draft', 'sent', 'paid', 'overdue', 'cancelled', 'credited'],
description: 'Filter by invoice status',
},
document_type: {
type: 'string',
enum: ['invoice', 'proforma', 'delivery_note', 'quote'],
},
quote_status: {
type: 'string',
enum: ['open', 'accepted', 'declined', 'expired'],
description: 'Quotes only',
},
limit: { type: 'number', description: 'Max results (default 50, max 100)' },
offset: { type: 'integer', minimum: 0, description: 'Number of results to skip for pagination (default 0)' },
},
@@ -6459,15 +6478,35 @@ export const tools: McpTool[] = [
const limit = Math.min(Math.max(1, Number(args.limit) || 50), 100)
const offset = Math.max(0, Math.floor(Number(args.offset) || 0))
const status = args.status as string | undefined
const documentType = args.document_type as string | undefined
const quoteStatus = args.quote_status as string | undefined
let query = supabase
.from('invoices')
.select('id, invoice_number, status, customer_id, total, currency, invoice_date, due_date, document_type, default_dimensions, customers(name)', { count: 'exact' })
.select('id, invoice_number, status, customer_id, total, currency, invoice_date, due_date, document_type, valid_until, quote_status, default_dimensions, customers(name)', { count: 'exact' })
.eq('company_id', companyId)
if (status) {
query = query.eq('status', status)
}
if (documentType) {
query = query.eq('document_type', documentType)
}
if (quoteStatus) {
// quote_status only exists on quotes: combining it with another
// document_type can never match, so say so instead of returning [].
if (documentType && documentType !== 'quote') {
throw registryError('VALIDATION_ERROR')
}
// "expired" is derived (lib/invoices/quote-status): an open quote whose
// valid_until has passed. Never stored, so it is a predicate here.
query = query.eq('document_type', 'quote')
if (quoteStatus === 'expired') {
query = query.eq('quote_status', 'open').lt('valid_until', new Date().toISOString().split('T')[0])
} else {
query = query.eq('quote_status', quoteStatus)
}
}
const { data, error, count } = await query
.order('invoice_date', { ascending: false })
@@ -6487,6 +6526,9 @@ export const tools: McpTool[] = [
invoice_date: inv.invoice_date,
due_date: inv.due_date,
document_type: inv.document_type,
valid_until: inv.valid_until ?? null,
// Effective status: open/accepted/declined/expired for quotes, null otherwise.
quote_status: effectiveQuoteStatus(inv as { quote_status?: string | null; valid_until?: string | null }),
default_dimensions: inv.default_dimensions ?? {},
}))
@@ -6525,7 +6567,9 @@ export const tools: McpTool[] = [
invoice_id: { type: 'string' },
invoice_number: { type: ['string', 'null'], description: 'null until sent' },
status: { type: 'string' },
document_type: { type: 'string', description: 'invoice, proforma or delivery_note' },
document_type: { type: 'string' },
valid_until: { type: ['string', 'null'] },
quote_status: { type: ['string', 'null'], description: 'Quotes only; expired is derived' },
customer_id: { type: 'string' },
customer_name: { type: ['string', 'null'] },
invoice_date: { type: 'string' },
@@ -6594,7 +6638,7 @@ export const tools: McpTool[] = [
const { data: invoice, error } = await supabase
.from('invoices')
.select(
'id, invoice_number, status, document_type, customer_id, invoice_date, due_date, delivery_date, currency, subtotal, vat_amount, total, paid_amount, remaining_amount, your_reference, our_reference, invoice_marking, notes, default_dimensions, journal_entry_id, is_self_billed, credited_invoice_id, customer:customers(name), items:invoice_items(id, sort_order, line_type, description, quantity, unit, unit_price, discount_percent, line_total, vat_rate, vat_amount, article_id, revenue_account, sales_order_item_id, deduction_type, labor_hours, work_type, housing_designation, apartment_number, brf_org_number, accrual_period_start, accrual_period_end, accrual_balance_account, dimensions)',
'id, invoice_number, status, document_type, valid_until, quote_status, customer_id, invoice_date, due_date, delivery_date, currency, subtotal, vat_amount, total, paid_amount, remaining_amount, your_reference, our_reference, invoice_marking, notes, default_dimensions, journal_entry_id, is_self_billed, credited_invoice_id, customer:customers(name), items:invoice_items(id, sort_order, line_type, description, quantity, unit, unit_price, discount_percent, line_total, vat_rate, vat_amount, article_id, revenue_account, sales_order_item_id, deduction_type, labor_hours, work_type, housing_designation, apartment_number, brf_org_number, accrual_period_start, accrual_period_end, accrual_balance_account, dimensions)',
)
.eq('id', invoiceId)
.eq('company_id', companyId)
@@ -6665,6 +6709,8 @@ export const tools: McpTool[] = [
invoice_number: invoice.invoice_number ?? null,
status: invoice.status,
document_type: invoice.document_type ?? 'invoice',
valid_until: invoice.valid_until ?? null,
quote_status: effectiveQuoteStatus(invoice),
customer_id: invoice.customer_id,
customer_name: (invoice.customer as { name?: string } | null)?.name ?? null,
invoice_date: invoice.invoice_date,
@@ -6692,13 +6738,19 @@ export const tools: McpTool[] = [
name: 'gnubok_create_invoice',
keywords: ['faktura', 'kundfaktura', 'fakturera', 'ny faktura'],
title: 'Create Customer Invoice',
description: 'Stage a new invoice. Validates inputs, calculates VAT preview. Items accept dims bags. Approval creates a draft; the invoice number is assigned on send or mark-as-sent.',
description: 'Stage a new invoice or quote (offert). Validates inputs, calculates VAT preview. Items accept dims bags. Approval creates a draft (F-number assigned on send) or an open quote numbered OF-nnn at once; quotes require valid_until and never book.',
outputSchema: STAGED_OPERATION_SCHEMA,
inputSchema: {
type: 'object',
additionalProperties: false,
properties: {
customer_id: { type: 'string', description: 'Customer UUID' },
document_type: {
type: 'string',
enum: ['invoice', 'quote'],
description: 'quote = offert (own OF-series, needs valid_until). Default invoice.',
},
valid_until: { type: 'string', description: 'YYYY-MM-DD, quotes only' },
items: {
type: 'array',
items: {
@@ -6757,6 +6809,27 @@ export const tools: McpTool[] = [
const currency = ((args.currency as string) || 'SEK') as Currency
const invoiceDate = (args.invoice_date as string) || today
// Offert: own OF-series allocated at approval, never books, and the
// expiry date is the one header field the type adds (CreateInvoiceSchema
// parity: valid_until is required exactly when document_type is quote).
const documentType = (args.document_type as string | undefined) ?? 'invoice'
if (documentType !== 'invoice' && documentType !== 'quote') {
throw codedError('VALIDATION_ERROR', 'document_type must be "invoice" or "quote".')
}
const isQuote = documentType === 'quote'
const validUntil = args.valid_until as string | undefined
if (isQuote) {
if (
typeof validUntil !== 'string' ||
!ISO_DATE_RE.test(validUntil) ||
Number.isNaN(new Date(`${validUntil}T00:00:00Z`).getTime())
) {
throw codedError('VALIDATION_ERROR', 'valid_until (YYYY-MM-DD) is required for a quote (offert).')
}
} else if (validUntil !== undefined) {
throw codedError('VALIDATION_ERROR', 'valid_until applies to quotes only: set document_type "quote".')
}
// Fetch customer (full row for VAT rules) BEFORE the article prefill:
// an article's stored rate may only be adopted against this customer's
// default rate set (see resolveInvoiceLineFromArticle).
@@ -6871,9 +6944,12 @@ export const tools: McpTool[] = [
}
const total = subtotal + vatAmount
// Due date from payment terms if not provided
// Due date from payment terms if not provided. A quote has no payment
// due date: due_date mirrors valid_until (build-invoice-write parity).
let dueDate = args.due_date as string | undefined
if (!dueDate) {
if (isQuote) {
dueDate = validUntil
} else if (!dueDate) {
const d = new Date(invoiceDate)
d.setDate(d.getDate() + (customer.default_payment_terms || 30))
dueDate = d.toISOString().split('T')[0]
@@ -6881,9 +6957,11 @@ export const tools: McpTool[] = [
// Stage for user approval instead of creating directly
return stagePendingOperation(supabase, companyId, userId, 'create_invoice',
`Ny faktura: ${customer.name} ${Math.round(total * 100) / 100} ${currency}`,
`${isQuote ? 'Ny offert' : 'Ny faktura'}: ${customer.name} ${roundOre(total)} ${currency}`,
{
customer_id: customerId,
document_type: documentType,
...(isQuote ? { valid_until: validUntil } : {}),
items: stagedItems,
...(resolvedDefaultDimensions && Object.keys(resolvedDefaultDimensions).length > 0
? { default_dimensions: resolvedDefaultDimensions }
@@ -6912,15 +6990,30 @@ export const tools: McpTool[] = [
vat_treatment: vatRules.treatment,
invoice_date: invoiceDate,
due_date: dueDate,
document_type: documentType,
...(isQuote
? {
valid_until: validUntil,
// The OF-number is allocated by generate_quote_number at
// approval; the preview must not show an F-series marker.
invoice_number: 'Offert OF-preview',
will: 'allocate OF-series number at approval; never books',
}
: {}),
// Echoed for every non-exact dimension resolution (resolve-don't-
// select) so the agent can verify what a name attached to.
...(dimensionResolutions.length > 0 ? { dimension_resolutions: dimensionResolutions } : {}),
},
actor,
{
description: 'Once approved, the invoice is created as a draft. Send it with gnubok_send_invoice or use gnubok_mark_invoice_as_sent if delivered outside the system.',
tool: 'gnubok_send_invoice',
}
isQuote
? {
description: 'Once approved, the quote exists as an open offert with its OF-number. Record the customer decision with gnubok_set_quote_status; gnubok_convert_invoice creates the faktura from it.',
tool: 'gnubok_convert_invoice',
}
: {
description: 'Once approved, the invoice is created as a draft. Send it with gnubok_send_invoice or use gnubok_mark_invoice_as_sent if delivered outside the system.',
tool: 'gnubok_send_invoice',
}
)
},
},
@@ -7932,6 +8025,12 @@ export const tools: McpTool[] = [
if (!invoiceId) throw new Error('invoice_id is required')
const invoice = await fetchInvoiceWithCustomer(supabase, companyId, invoiceId)
// A quote is an offer, not a claim (parity with the dashboard mark-paid
// route, which likewise refuses only quotes: marking a sent proforma paid
// is a supported prepayment record with no verifikat).
if (invoice.document_type === 'quote') {
throw registryError('INVOICE_QUOTE_NOT_PAYABLE')
}
if (invoice.status !== 'sent' && invoice.status !== 'overdue') {
throw new Error('Invoice can only be marked as paid when status is "sent" or "overdue"')
}
@@ -10450,6 +10549,11 @@ export const tools: McpTool[] = [
.single()
if (invError || !invoice) throw new Error('Invoice not found')
// Parity with the dashboard match route: proformas, delivery notes and
// quotes carry no receivable to settle.
if (invoice.document_type && invoice.document_type !== 'invoice') {
throw registryError('MATCH_INVOICE_NOT_INVOICE_TYPE')
}
if (invoice.status !== 'sent' && invoice.status !== 'overdue' && invoice.status !== 'partially_paid') {
throw new Error('Invoice is not in a matchable state (must be sent, overdue, or partially_paid)')
}
@@ -10594,7 +10698,7 @@ export const tools: McpTool[] = [
const uniqueIds = Array.from(new Set(invoiceIds))
const { data: found } = await supabase
.from('invoices')
.select('id')
.select('id, document_type')
.in('id', uniqueIds)
.eq('company_id', companyId)
const foundRows = found ?? []
@@ -10603,6 +10707,12 @@ export const tools: McpTool[] = [
if (missing.length > 0 || foundRows.length !== uniqueIds.length) {
throw new Error(`Invoices not found for this company: ${missing.join(', ') || '(count mismatch)'}`)
}
// Only fakturor carry a receivable: the RPC gates on status alone, so
// a sent proforma or quote must be refused here (parity with the
// single-invoice match tool).
if (foundRows.some((r) => r.document_type && r.document_type !== 'invoice')) {
throw registryError('MATCH_INVOICE_NOT_INVOICE_TYPE')
}
}
if (supplierInvoiceIds.length > 0) {
const uniqueIds = Array.from(new Set(supplierInvoiceIds))
@@ -11295,12 +11405,17 @@ export const tools: McpTool[] = [
const { data: invoice, error } = await supabase
.from('invoices')
.select(
'id, invoice_number, status, currency, total, paid_amount, remaining_amount, due_date, paid_at, exchange_rate, customer_id, customer:customers(id, name)'
'id, invoice_number, status, document_type, currency, total, paid_amount, remaining_amount, due_date, paid_at, exchange_rate, customer_id, customer:customers(id, name)'
)
.eq('id', invoiceId)
.eq('company_id', companyId)
.single()
if (error || !invoice) throw new Error('Invoice not found')
// Same gate as gnubok_link_invoice_to_voucher: proformas, delivery
// notes and quotes carry no receivable, so there is nothing to match.
if (invoice.document_type && invoice.document_type !== 'invoice') {
throw registryError('MATCH_INVOICE_NOT_INVOICE_TYPE')
}
if (!['sent', 'overdue', 'partially_paid'].includes(invoice.status)) {
return {
@@ -11352,12 +11467,17 @@ export const tools: McpTool[] = [
const { data: invoice, error: invErr } = await supabase
.from('invoices')
.select(
'id, invoice_number, status, currency, total, paid_amount, remaining_amount, due_date, paid_at, exchange_rate, customer_id, customer:customers(id, name)'
'id, invoice_number, status, document_type, currency, total, paid_amount, remaining_amount, due_date, paid_at, exchange_rate, customer_id, customer:customers(id, name)'
)
.eq('id', invoiceId)
.eq('company_id', companyId)
.single()
if (invErr || !invoice) throw new Error('Invoice not found')
// Parity with the dashboard match route: proformas, delivery notes and
// quotes carry no receivable to link a payment voucher to.
if (invoice.document_type && invoice.document_type !== 'invoice') {
throw registryError('MATCH_INVOICE_NOT_INVOICE_TYPE')
}
if (!['sent', 'overdue', 'partially_paid'].includes(invoice.status)) {
throw new Error('Invoice is not in a matchable state (must be sent, overdue, or partially_paid)')
}
@@ -17530,13 +17650,13 @@ export const tools: McpTool[] = [
{
name: 'gnubok_convert_invoice',
keywords: ['proforma', 'kundfaktura', 'omvandla'],
title: 'Convert Proforma to Invoice',
description: 'Stage conversion of a proforma invoice to a real invoice. Allocates F-series number, copies items, marks proforma cancelled.',
keywords: ['proforma', 'offert', 'quote', 'kundfaktura', 'omvandla'],
title: 'Convert Proforma or Quote to Invoice',
description: 'Stage conversion of a proforma or quote (offert) to a real invoice (F-number, items copied). Proforma is cancelled; the quote stays as accepted.',
inputSchema: {
type: 'object',
additionalProperties: false,
properties: { invoice_id: { type: 'string' } },
properties: { invoice_id: { type: 'string', description: 'Proforma or quote UUID' } },
required: ['invoice_id'],
},
outputSchema: STAGED_OPERATION_SCHEMA,
@@ -17547,21 +17667,44 @@ export const tools: McpTool[] = [
const { data: inv } = await supabase
.from('invoices')
.select('id, document_type, status, total, currency, customer:customers(name)')
.select('id, invoice_number, document_type, status, quote_status, total, currency, customer:customers(name)')
.eq('id', id).eq('company_id', companyId).single()
if (!inv) throw new Error('Invoice not found')
if (inv.document_type !== 'proforma') throw new Error('Endast proformafakturor kan konverteras')
if (inv.status === 'cancelled') throw new Error('Denna proformafaktura har redan makulerats')
if (!inv) throw registryError('INVOICE_NOT_FOUND')
const isQuote = inv.document_type === 'quote'
// Same pre-checks as convertToInvoice (the commit path), so the agent
// gets the registry code at staging time instead of a failed approval.
if (inv.document_type !== 'proforma' && !isQuote) throw registryError('INVOICE_CONVERT_NOT_CONVERTIBLE')
if (inv.status === 'cancelled') throw registryError('INVOICE_CONVERT_SOURCE_CANCELLED')
if (isQuote) {
if (inv.quote_status === 'declined') throw registryError('INVOICE_CONVERT_QUOTE_DECLINED')
const { data: converted, error: convertedError } = await supabase
.from('invoices')
.select('id')
.eq('company_id', companyId)
.eq('converted_from_id', id)
.neq('status', 'cancelled')
.limit(1)
.maybeSingle()
if (convertedError) throw dbError(convertedError)
if (converted) throw registryError('INVOICE_QUOTE_ALREADY_INVOICED')
}
const customerName = (inv.customer as { name?: string } | null)?.name ?? 'okänd kund'
const amount = `${roundOre(Number(inv.total))} ${inv.currency}`
return stagePendingOperation(supabase, companyId, userId, 'convert_invoice',
`Konvertera proforma → faktura: ${customerName} ${Math.round(Number(inv.total) * 100) / 100} ${inv.currency}`,
isQuote
? `Konvertera offert → faktura: ${inv.invoice_number ?? ''} ${customerName} ${amount}`.replace(/\s+/g, ' ')
: `Konvertera proforma → faktura: ${customerName} ${amount}`,
{ invoice_id: id },
{
customer_name: (inv.customer as { name?: string } | null)?.name,
source_document_type: inv.document_type,
source_invoice_number: inv.invoice_number ?? null,
total: inv.total,
currency: inv.currency,
will: 'allocate F-series number, copy items, cancel proforma',
will: isQuote
? 'allocate F-series number, copy items, mark the quote accepted (the quote stays)'
: 'allocate F-series number, copy items, cancel proforma',
},
actor,
{
@@ -17572,6 +17715,109 @@ export const tools: McpTool[] = [
},
},
{
name: 'gnubok_set_quote_status',
keywords: ['offert', 'quote', 'accepterad', 'avböjd', 'godkänn offert'],
title: 'Set Quote Status',
description: 'Record the customer decision on a quote (offert): open, accepted or declined. Locked once invoiced; expired is derived from valid_until.',
inputSchema: {
type: 'object',
additionalProperties: false,
properties: {
invoice_id: { type: 'string', description: 'Quote UUID' },
status: { type: 'string', enum: ['open', 'accepted', 'declined'] },
valid_until: { type: 'string', description: 'YYYY-MM-DD; new expiry (reopens an expired quote)' },
},
required: ['invoice_id', 'status'],
},
// Kept shallow on purpose: tools/list has a hard token budget
// (payload-size.bench.test.ts) and the row shape is documented on
// gnubok_get_invoice.
outputSchema: { type: 'object' },
annotations: ANNOTATIONS_IDEMPOTENT_WRITE,
async execute(args, companyId, userId, supabase) {
const id = args.invoice_id as string
if (!id) throw codedError('VALIDATION_ERROR', 'invoice_id is required')
const nextStatus = args.status as string
if (nextStatus !== 'open' && nextStatus !== 'accepted' && nextStatus !== 'declined') {
throw codedError('VALIDATION_ERROR', 'status must be open, accepted or declined')
}
// Mirrors POST /api/invoices/[id]/quote-status: any transition between
// the three decisions until the quote has been converted; cancelled
// quotes are not decidable; accepting past valid_until is allowed.
const { data: quote, error: fetchError } = await supabase
.from('invoices')
.select('id, document_type, status, quote_status, quote_decided_at')
.eq('id', id)
.eq('company_id', companyId)
.maybeSingle()
if (fetchError) throw dbError(fetchError)
if (!quote) throw registryError('INVOICE_NOT_FOUND')
if (quote.document_type !== 'quote') throw registryError('INVOICE_NOT_A_QUOTE')
if (quote.status === 'cancelled') throw registryError('INVOICE_QUOTE_NOT_DECIDABLE')
const { data: converted, error: convertedError } = await supabase
.from('invoices')
.select('id')
.eq('company_id', companyId)
.eq('converted_from_id', id)
.neq('status', 'cancelled')
.limit(1)
.maybeSingle()
if (convertedError) throw dbError(convertedError)
if (converted) throw registryError('INVOICE_QUOTE_ALREADY_INVOICED')
const nextValidUntil = typeof args.valid_until === 'string' ? args.valid_until : undefined
if (nextValidUntil !== undefined && !ISO_DATE_RE.test(nextValidUntil)) {
throw codedError('VALIDATION_ERROR', 'valid_until must be YYYY-MM-DD')
}
// Compare-and-set on the state read above (same as the HTTP routes): a
// conversion or cancel that lands in between makes this a 0-row update
// instead of overwriting newer state.
const { data: updated, error: updateError } = await supabase
.from('invoices')
.update({
quote_status: nextStatus,
// Re-sending the same decision keeps its original timestamp
// (idempotentHint on this tool is honest).
quote_decided_at:
nextStatus === 'open'
? null
: nextStatus === quote.quote_status
? (quote.quote_decided_at ?? new Date().toISOString())
: new Date().toISOString(),
valid_until: nextValidUntil,
updated_at: new Date().toISOString(),
})
.eq('id', id)
.eq('company_id', companyId)
.eq('quote_status', quote.quote_status)
.neq('status', 'cancelled')
.select('id, invoice_number, document_type, status, quote_status, quote_decided_at, valid_until')
.maybeSingle()
if (updateError) {
// trg_invoices_quote_decision_guard: a conversion landed in between.
if (updateError.message?.includes('INVOICE_QUOTE_ALREADY_INVOICED')) {
throw registryError('INVOICE_QUOTE_ALREADY_INVOICED')
}
throw dbError(updateError)
}
if (!updated) throw registryError('INVOICE_QUOTE_CHANGED_CONCURRENTLY')
return {
invoice_id: updated.id,
invoice_number: updated.invoice_number ?? null,
document_type: updated.document_type,
status: updated.status,
quote_status: updated.quote_status,
effective_quote_status: effectiveQuoteStatus(updated) ?? updated.quote_status,
quote_decided_at: updated.quote_decided_at ?? null,
valid_until: updated.valid_until ?? null,
}
},
},
{
name: 'gnubok_unlock_period',
keywords: ['lås upp period', 'öppna period'],
@@ -17791,7 +18037,7 @@ export const tools: McpTool[] = [
// previewed, or returned.
const { data: invoice, error } = await supabase
.from('invoices')
.select('id, invoice_number, status, document_type, journal_entry_id, is_self_billed, credited_invoice_id, total, currency, customer_id, deduction_personnummer_encrypted, customer:customers(name)')
.select('id, invoice_number, status, document_type, quote_status, journal_entry_id, is_self_billed, credited_invoice_id, total, currency, customer_id, deduction_personnummer_encrypted, customer:customers(name)')
.eq('id', invoiceId)
.eq('company_id', companyId)
.maybeSingle()
@@ -19645,6 +19891,11 @@ export const tools: McpTool[] = [
},
outputSchema: { type: 'object', additionalProperties: true },
annotations: ANNOTATIONS_READ_ONLY,
// Search-only (2026-09-02): iXBRL filing is switched off until the
// Bolagsverket avtal and certificate exist, so nothing polls this status
// yet; demoted to make room in tools/list for gnubok_set_quote_status
// (see payload-size.bench.test.ts). Reachable via gnubok_call_tool.
catalogVisibility: 'search',
async execute(args, companyId, _userId, supabase, _actor) {
const fiscalPeriodId = args.fiscal_period_id as string
if (!fiscalPeriodId) throw new Error('fiscal_period_id is required')
@@ -130,17 +130,22 @@ Accounted sends Peppol BIS Billing 3 e-invoices from the invoice page in the das
- **Sent before approval**: not possible: \`gnubok_send_invoice\` stages too. The user must approve.
- **Edit instead of credit**: blocked by DB triggers. Use \`gnubok_credit_invoice\`.
## Offert (quotes)
An offert is \`document_type: 'quote'\` on \`gnubok_create_invoice\`: it is numbered in its own OF-series (OF-001, OF-002, ...) at approval, never touches the F-series and never books anything; \`valid_until\` is required and the quote reads as expired once that date has passed (derived, nothing is stored). Record the customer's decision with \`gnubok_set_quote_status\` (open, accepted, declined); \`gnubok_convert_invoice\` creates the faktura from an open or accepted quote and leaves the quote in place as accepted, so a declined quote must be re-accepted first and a quote can be invoiced only once.
## Tools
- \`gnubok_list_customers\` / \`gnubok_create_customer\`: customer setup
- \`gnubok_create_invoice\`: stage new invoice
- \`gnubok_create_invoice\`: stage new invoice, or a quote with \`document_type: 'quote'\` + \`valid_until\`
- \`gnubok_set_quote_status\`: record the customer decision on a quote (direct write)
- \`gnubok_send_invoice\`: email PDF
- \`gnubok_mark_invoice_as_sent\`: manual delivery (also the recovery when a dashboard Peppol send could not mark the invoice as sent)
- \`gnubok_mark_invoice_as_paid\`: manual payment
- \`gnubok_match_transaction_to_invoice\`: link bank payment
- \`gnubok_credit_invoice\`: kreditfaktura (legal undo)
- \`gnubok_convert_invoice\`: proforma → real invoice
- \`gnubok_list_invoices\`: find existing invoices
- \`gnubok_convert_invoice\`: proforma or quote → real invoice
- \`gnubok_list_invoices\`: find existing invoices and quotes (\`document_type\`, \`quote_status\` incl. \`expired\`)
- \`gnubok_get_invoice\`: one invoice with its lines (article_id, revenue_account, vat_rate); read it before editing
- \`gnubok_update_invoice\`: edit a draft; \`items\` is a FULL REPLACE, so pass every line back (with \`article_id\`) from \`gnubok_get_invoice\`
`
@@ -46,6 +46,7 @@ The invoice must be in one of: \`sent\`, \`paid\`, or \`overdue\`. You cannot cr
- A draft (just edit/delete the draft before sending)
- An already-credited invoice (it has status \`credited\`)
- A proforma (use \`gnubok_convert_invoice\` first if needed, but proformas don't post to VAT/AR: usually you just cancel them)
- A quote (offert): it never posts either. Decline it with \`gnubok_set_quote_status\`, or convert it with \`gnubok_convert_invoice\` and credit the resulting faktura
### Step 2: Stage the kreditfaktura
@@ -83,6 +83,9 @@ const PGRST116 = {
message: 'JSON object requested, multiple (or no) rows returned',
}
/** Every eq/in filter the stub saw, so tests can assert on query scope. */
let recordedFilters: Array<{ table: string; method: string; args: unknown[] }> = []
function makeSupabaseStub(config: StubConfig): SupabaseClient {
const rowsResult = (
rows: Record<string, unknown>[] | 'unreadable' | undefined,
@@ -134,8 +137,14 @@ function makeSupabaseStub(config: StubConfig): SupabaseClient {
isWrite = true
return chain
},
eq: () => chain,
in: () => chain,
eq: (...args: unknown[]) => {
recordedFilters.push({ table, method: 'eq', args })
return chain
},
in: (...args: unknown[]) => {
recordedFilters.push({ table, method: 'in', args })
return chain
},
not: () => chain,
order: () => chain,
range: () => chain,
@@ -170,6 +179,7 @@ beforeAll(async () => {
beforeEach(() => {
vi.clearAllMocks()
recordedFilters = []
})
const oneDeadline = () => [
@@ -270,6 +280,18 @@ describe('invoice gate polarity', () => {
expect(result.sent).toBe(0)
expect(result.skipped).toBe(1)
})
it('only notifies on fakturor: proformas, delivery notes and quotes are never due', async () => {
const supabase = makeSupabaseStub({ settings: [settingsRow()], invoices: [] })
await sendInvoiceNotifications(supabase)
expect(recordedFilters).toContainEqual({
table: 'invoices',
method: 'eq',
args: ['document_type', 'invoice'],
})
})
})
describe('missing underlag gate polarity', () => {
@@ -137,6 +137,9 @@ export async function sendInvoiceNotifications(
const { data: invoices } = await supabase
.from('invoices')
.select('id, user_id, invoice_number, total, currency, due_date, customer:customers(name)')
// Proformas, delivery notes and quotes are never receivables: nothing is
// due on them, so they get no förfallo push.
.eq('document_type', 'invoice')
.in('status', ['sent', 'overdue'])
.in('due_date', [in3DaysStr, todayStr, daysAgo3Str, daysAgo7Str])
+38 -3
View File
@@ -30,7 +30,7 @@ import {
orgNumberHoldsPersonalNumber,
personalNumberDigits,
} from '@/lib/customers/personal-number-shape'
import type { AuditAction, Currency } from '@/types'
import type { AuditAction, Currency, InvoiceDocumentType } from '@/types'
import type { BankFileFormatId } from '@/lib/import/bank-file/types'
// ============================================================
@@ -222,9 +222,10 @@ export const SupplierTypeSchema = z.enum([
])
export const InvoiceDocumentTypeSchema = z.enum([
'invoice', 'proforma', 'delivery_note',
'invoice', 'proforma', 'delivery_note', 'quote',
])
export const VatTreatmentSchema = z.enum([
'standard_25', 'reduced_12', 'reduced_6', 'reverse_charge', 'export', 'exempt',
])
@@ -488,6 +489,18 @@ export const CreateInvoiceItemSchema = z
const optionalIsoDate = isoDate.or(z.literal('')).transform(v => v || undefined).optional()
export const QuoteStatusSchema = z.enum(['open', 'accepted', 'declined'])
// POST /api/invoices/[id]/quote-status. Any transition between the three
// decisions is allowed until the quote has been converted to an invoice.
export const SetQuoteStatusSchema = z.object({
status: QuoteStatusSchema,
// Optional new expiry. This is how an expired (open, past valid_until)
// quote is reopened: sent quotes are not draft-editable, so the date
// travels with the decision. The DB trigger mirrors it into due_date.
valid_until: optionalIsoDate,
})
/**
* ROT/RUT claim completeness (HUSFL: art av arbete + antal arbetstimmar) at
* the invoice level, where document_type is known: only real invoices book a
@@ -523,6 +536,9 @@ const CreateInvoiceBaseSchema = z.object({
delivery_date: optionalIsoDate,
currency: CurrencySchema,
document_type: InvoiceDocumentTypeSchema.optional(),
// Quotes only: the date the offer expires ("Giltig till"). Required when
// document_type is 'quote' (refineQuoteFields); ignored otherwise.
valid_until: optionalIsoDate,
your_reference: z.string().optional(),
our_reference: z.string().optional(),
// Fakturamärkning: buyer-required marking (kostnadsställe/projekt/PO),
@@ -614,7 +630,25 @@ const CreateInvoiceBaseSchema = z.object({
items: z.array(CreateInvoiceItemSchema).min(1, 'At least one item is required'),
})
export const CreateInvoiceSchema = CreateInvoiceBaseSchema.superRefine(refineRotRutLineCompleteness)
// A quote must say how long it is valid: valid_until is the one header
// field the document type adds, and the PDF, the list and the derived
// "expired" state all key off it.
function refineQuoteFields(
data: { document_type?: InvoiceDocumentType; valid_until?: string },
ctx: z.RefinementCtx,
) {
if (data.document_type === 'quote' && !data.valid_until) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['valid_until'],
message: 'Giltig till (valid_until) krävs för en offert.',
})
}
}
export const CreateInvoiceSchema = CreateInvoiceBaseSchema
.superRefine(refineRotRutLineCompleteness)
.superRefine(refineQuoteFields)
// Update (edit) an existing DRAFT invoice in place. Same shape as create minus
// `save_as_draft`: editing never (re)creates a draft or allocates a number, it
@@ -623,6 +657,7 @@ export const CreateInvoiceSchema = CreateInvoiceBaseSchema.superRefine(refineRot
export const UpdateInvoiceSchema = CreateInvoiceBaseSchema
.omit({ save_as_draft: true })
.superRefine(refineRotRutLineCompleteness)
.superRefine(refineQuoteFields)
export const CreateCreditNoteSchema = z.object({
credited_invoice_id: uuid,
@@ -1,6 +1,6 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`v1 spec snapshot > matches the recorded endpoint count > endpoint-count 1`] = `145`;
exports[`v1 spec snapshot > matches the recorded endpoint count > endpoint-count 1`] = `146`;
exports[`v1 spec snapshot > matches the recorded endpoint key set > endpoint-keys 1`] = `
[
@@ -106,6 +106,7 @@ exports[`v1 spec snapshot > matches the recorded endpoint key set > endpoint-key
"POST /api/v1/companies/:companyId/invoices/:id/credit",
"POST /api/v1/companies/:companyId/invoices/:id/mark-paid",
"POST /api/v1/companies/:companyId/invoices/:id/mark-sent",
"POST /api/v1/companies/:companyId/invoices/:id/quote-status",
"POST /api/v1/companies/:companyId/invoices/:id/send",
"POST /api/v1/companies/:companyId/invoices/bulk-create",
"POST /api/v1/companies/:companyId/journal-entries",
+1 -1
View File
@@ -12,7 +12,7 @@
*/
export const INVOICE_FULL_COLUMNS =
'id, invoice_number, customer_id, invoice_date, due_date, delivery_date, status, currency, exchange_rate, exchange_rate_date, subtotal, subtotal_sek, vat_amount, vat_amount_sek, total, total_sek, ore_rounding, vat_treatment, vat_rate, moms_ruta, your_reference, our_reference, invoice_marking, notes, payment_link_url, stripe_payment_link_id, payment_link_auto, reverse_charge_text, credited_invoice_id, document_type, converted_from_id, paid_at, paid_amount, remaining_amount, default_dimensions, deduction_total, deduction_personnummer_last4, created_at, updated_at'
'id, invoice_number, customer_id, invoice_date, due_date, delivery_date, status, currency, exchange_rate, exchange_rate_date, subtotal, subtotal_sek, vat_amount, vat_amount_sek, total, total_sek, ore_rounding, vat_treatment, vat_rate, moms_ruta, your_reference, our_reference, invoice_marking, notes, payment_link_url, stripe_payment_link_id, payment_link_auto, reverse_charge_text, credited_invoice_id, document_type, converted_from_id, valid_until, quote_status, quote_decided_at, paid_at, paid_amount, remaining_amount, default_dimensions, deduction_total, deduction_personnummer_last4, created_at, updated_at'
/**
* Projection for the v1 PDF download route. Narrower than INVOICE_FULL_COLUMNS
+1
View File
@@ -52,6 +52,7 @@ import '@/app/api/v1/companies/[companyId]/invoices/[id]/mark-sent/route'
import '@/app/api/v1/companies/[companyId]/invoices/[id]/mark-paid/route'
import '@/app/api/v1/companies/[companyId]/invoices/[id]/credit/route'
import '@/app/api/v1/companies/[companyId]/invoices/[id]/send/route'
import '@/app/api/v1/companies/[companyId]/invoices/[id]/quote-status/route'
import '@/app/api/v1/companies/[companyId]/invoices/bulk-create/route'
// Phase 2 PR-B-3: invoice PDF + customer bulk-create.
import '@/app/api/v1/companies/[companyId]/invoices/[id]/pdf/route'
+1
View File
@@ -375,6 +375,7 @@ export const TOOL_SCOPE_MAP: Record<string, ApiKeyScope> = {
gnubok_link_supplier_invoice_to_voucher: 'suppliers:write',
// Invoice conversion + crediting
gnubok_convert_invoice: 'invoices:write',
gnubok_set_quote_status: 'invoices:write',
gnubok_credit_invoice: 'invoices:write',
// Phase 4: arbitrary-line bookkeeping primitives (high-risk, always staged)
gnubok_create_voucher: 'bookkeeping:write',
+1
View File
@@ -75,6 +75,7 @@ export const V1_ENDPOINT_SCOPES: Record<string, ApiKeyScope> = {
'POST /api/v1/companies/:companyId/invoices/:id/mark-paid': 'invoices:write',
'POST /api/v1/companies/:companyId/invoices/:id/credit': 'invoices:write',
'POST /api/v1/companies/:companyId/invoices/:id/send': 'invoices:write',
'POST /api/v1/companies/:companyId/invoices/:id/quote-status': 'invoices:write',
'POST /api/v1/companies/:companyId/invoices/bulk-create': 'invoices:write',
// Phase 2 PR-B-3: invoice PDF + customer bulk-create.
'GET /api/v1/companies/:companyId/invoices/:id/pdf': 'invoices:read',
@@ -66,6 +66,20 @@ describe('detectPeriodisering', () => {
expect(result[0].suggested_deferred_account).toBeNull()
})
it('only reads fakturor: proformas, delivery notes and quotes are never booked, so nothing to periodise', async () => {
mock.enqueue({
data: { id: 'period-1', period_start: '2025-01-01', period_end: '2025-12-31' },
error: null,
})
mock.enqueue({ data: [], error: null }) // accrual_schedules
mock.enqueue({ data: [], error: null }) // invoices
mock.enqueue({ data: [], error: null }) // supplier_invoices
await detectPeriodisering(mock.supabase as never, 'company-1', 'period-1')
expect(mock.findCalls('invoices', 'eq')).toContainEqual(['document_type', 'invoice'])
})
it('detects a customer invoice with a service window in its notes', async () => {
mock.enqueue({
data: { id: 'period-1', period_start: '2025-01-01', period_end: '2025-12-31' },
+3 -1
View File
@@ -276,11 +276,13 @@ export async function detectPeriodisering(
// Customer invoices: only "real" ones (sent/paid). Drafts and overdue
// get skipped: drafts haven't moved through the engine, overdue is just a
// status label that overlaps with sent here.
// status label that overlaps with sent here. Proformas, delivery notes and
// quotes are never booked, so there is no revenue to periodise.
const { data: invoiceRows } = await supabase
.from('invoices')
.select('id, invoice_number, invoice_date, subtotal, currency, subtotal_sek, notes, customers(name), invoice_items(description)')
.eq('company_id', companyId)
.eq('document_type', 'invoice')
.gte('invoice_date', periodStart)
.lte('invoice_date', periodEnd)
.in('status', ['sent', 'partially_paid', 'paid', 'overdue'])
@@ -261,6 +261,30 @@ describe('currency-revaluation', () => {
expect(result).toHaveLength(0)
})
it('never revalues a quote or proforma: they sit on no 1510 balance', async () => {
const invoice = makeInvoice({ id: 'inv-1', status: 'sent', currency: 'EUR', exchange_rate: 11.5 })
const quote = makeInvoice({
id: 'quote-1',
status: 'sent',
currency: 'EUR',
exchange_rate: 11.5,
document_type: 'quote',
})
const proforma = makeInvoice({
id: 'proforma-1',
status: 'sent',
currency: 'EUR',
exchange_rate: 11.5,
document_type: 'proforma',
})
const supabase = createMockSupabase({ invoices: [invoice, quote, proforma] })
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const result = await getOpenForeignCurrencyReceivables(supabase as any, 'company-1')
expect(result.map((i) => i.id)).toEqual(['inv-1'])
})
it('includes partially_paid invoices: the unpaid remainder is still an open FX item', async () => {
// payment-sync.ts moves a customer invoice to 'partially_paid' on a
// partial settlement. Omitting the status made these receivables
+3
View File
@@ -179,6 +179,9 @@ export async function getOpenForeignCurrencyReceivables(
.from('invoices')
.select('*')
.eq('company_id', companyId)
// Proformas, delivery notes and quotes are never receivables: they
// sit on no 1510 balance, so there is nothing to revalue.
.eq('document_type', 'invoice')
.neq('currency', 'SEK')
.in(
'status',
@@ -179,6 +179,7 @@ function fxInvoice(overrides: Row = {}): Row {
return {
id: `inv-${Math.random().toString(36).slice(2, 10)}`,
company_id: 'company-1',
document_type: 'invoice',
status: 'sent',
currency: 'EUR',
exchange_rate: 11.2,
@@ -914,6 +915,21 @@ describe('validateYearEndReadiness: open FX items at balansdagen (ÅRL 4 kap. 13
expect(result.warnings.filter((w: string) => w.includes('valuta'))).toHaveLength(0)
})
it('ignores sent EUR quotes and proformas: they are not receivables and sit on no 1510 balance', async () => {
const supabase = makeFilteringClient(
fxBaseTables({
invoices: [
fxInvoice({ id: 'quote-1', document_type: 'quote' }),
fxInvoice({ id: 'proforma-1', document_type: 'proforma', exchange_rate: null }),
],
})
)
const result = await validateYearEndReadiness(supabase as never, 'company-1', 'user-1', 'fp-1')
expect(result.warnings.filter((w: string) => w.includes('valuta'))).toHaveLength(0)
})
it('counts open FX payables as well as receivables', async () => {
const supabase = makeFilteringClient(
fxBaseTables({
+3
View File
@@ -1039,6 +1039,9 @@ async function countOpenFxItemsAtBalansdagen(
.from('invoices')
.select(columns)
.eq('company_id', companyId)
// Proformas, delivery notes and quotes are never receivables (kept in
// lockstep with getOpenForeignCurrencyReceivables).
.eq('document_type', 'invoice')
.neq('currency', 'SEK')
// 'partially_paid' is a live status for customer invoices (payment-sync
// sets it on partial settlements); the unpaid remainder was open on
@@ -359,6 +359,119 @@ describe('invoice email templates', () => {
expect(generateInvoiceEmailSubject({ invoice: deliveryNote, customer: svCustomer, company: fullOverrides }))
.toBe('F\u00f6ljesedel 1045 fr\u00e5n Acme AB')
})
it('ignores overrides on quotes', () => {
const quote = makeInvoice({
invoice_number: 'OF-001',
document_type: 'quote',
valid_until: '2026-10-02',
quote_status: 'open',
})
const html = generateInvoiceEmailHtml({ invoice: quote, customer: svCustomer, company: fullOverrides })
expect(html).toContain('Bifogat hittar du v\u00e5r offert')
expect(html).not.toContain('H\u00e4r kommer m\u00e5nadens faktura.')
expect(generateInvoiceEmailSubject({ invoice: quote, customer: svCustomer, company: fullOverrides }))
.toBe('Offert OF-001 fr\u00e5n Acme AB')
})
})
// A quote (offert) is not a payment request: the mail states the expiry
// instead of a due date and carries no payment details or pay-online CTA.
describe('quote (offert)', () => {
const quote = makeInvoice({
invoice_number: 'OF-001',
invoice_date: '2026-09-02',
due_date: '2026-10-02',
valid_until: '2026-10-02',
quote_status: 'open',
document_type: 'quote',
currency: 'SEK',
total: 12500,
payment_link_url: 'https://buy.stripe.com/test_quote',
})
const svCustomer = makeCustomer({ name: 'Erik Andersson', email: 'erik@example.se', language: 'sv' })
const enCustomer = makeCustomer({ name: 'Jane Doe', email: 'jane@example.com', language: 'en' })
it('uses the Swedish quote subject', () => {
expect(generateInvoiceEmailSubject({ invoice: quote, customer: svCustomer, company }))
.toBe('Offert OF-001 fr\u00e5n Acme AB')
})
it('uses the English quote subject', () => {
expect(generateInvoiceEmailSubject({ invoice: quote, customer: enCustomer, company }))
.toBe('Quote OF-001 from Acme AB')
})
it('sv HTML: attached quote, Giltig till, no payment section and no pay-online button', () => {
const html = generateInvoiceEmailHtml({ invoice: quote, customer: svCustomer, company })
expect(html).toContain('Offert fr\u00e5n Acme AB')
expect(html).toContain('Offertnummer:')
expect(html).toContain('Offertdatum:')
expect(html).toContain('Bifogat hittar du v\u00e5r offert. Offerten \u00e4r giltig till 2026-10-02.')
expect(html).toContain('Giltig till:')
expect(html).toContain('2026-10-02')
expect(html).not.toContain('F\u00f6rfallodatum:')
expect(html).not.toContain('Betalningsinformation')
expect(html).not.toContain('Betala online')
expect(html).not.toContain('buy.stripe.com')
expect(html).toContain('Har du fr\u00e5gor om offerten?')
expect(html).not.toContain('Har du fr\u00e5gor om fakturan?')
})
it('en HTML: attached quote, Valid until, no payment section and no pay-online button', () => {
const html = generateInvoiceEmailHtml({ invoice: quote, customer: enCustomer, company })
expect(html).toContain('Quote from Acme AB')
expect(html).toContain('Quote number:')
expect(html).toContain('Attached you will find our quote. The quote is valid until 2026-10-02.')
expect(html).toContain('Valid until:')
expect(html).not.toContain('Due date:')
expect(html).not.toContain('Payment information')
expect(html).not.toContain('Pay online')
expect(html).toContain('Questions about the quote?')
})
it('plain text mirrors the HTML in both languages', () => {
const sv = generateInvoiceEmailText({ invoice: quote, customer: svCustomer, company })
expect(sv).toContain('Offert fr\u00e5n Acme AB')
expect(sv).toContain('Offerten \u00e4r giltig till 2026-10-02.')
expect(sv).toContain('Giltig till: 2026-10-02')
expect(sv).not.toContain('F\u00f6rfallodatum:')
expect(sv).not.toContain('Betalningsinformation')
expect(sv).not.toContain('Betala online')
expect(sv).not.toContain('buy.stripe.com')
const en = generateInvoiceEmailText({ invoice: quote, customer: enCustomer, company })
expect(en).toContain('Quote from Acme AB')
expect(en).toContain('The quote is valid until 2026-10-02.')
expect(en).toContain('Valid until: 2026-10-02')
expect(en).not.toContain('Due date:')
expect(en).not.toContain('Payment information')
expect(en).not.toContain('Pay online')
})
it('falls back to due_date when valid_until is missing on an older row', () => {
const legacy = makeInvoice({ ...quote, valid_until: null })
const text = generateInvoiceEmailText({ invoice: legacy, customer: svCustomer, company })
expect(text).toContain('Giltig till: 2026-10-02')
})
// The grand total sits right above the "not a payment request" notice,
// so it must not be labelled as an amount due.
it('labels the total Summa / Total, never Att betala / Total due', () => {
const svHtml = generateInvoiceEmailHtml({ invoice: quote, customer: svCustomer, company })
const svText = generateInvoiceEmailText({ invoice: quote, customer: svCustomer, company })
expect(svHtml).toContain('Summa:')
expect(svHtml).not.toContain('Att betala')
expect(svText).toContain('Summa: 12 500,00 SEK')
expect(svText).not.toContain('Att betala')
const enHtml = generateInvoiceEmailHtml({ invoice: quote, customer: enCustomer, company })
const enText = generateInvoiceEmailText({ invoice: quote, customer: enCustomer, company })
expect(enHtml).toContain('Total:')
expect(enHtml).not.toContain('Total due')
expect(enText).toContain('Total: 12,500.00 SEK')
expect(enText).not.toContain('Total due')
})
})
describe('payment link (payment_link_url)', () => {
+48 -13
View File
@@ -15,16 +15,22 @@ const LABELS = {
docInvoice: 'Faktura',
docCreditNote: 'Kreditfaktura',
docProforma: 'Proformafaktura',
docQuote: 'Offert',
docDeliveryNote: 'Följesedel',
htmlLang: 'sv',
documentFrom: (doc: string, sender: string) => `${doc} från ${sender}`,
documentNumber: (doc: string) => `${doc}nummer:`,
documentDate: (doc: string) => `${doc}datum:`,
dueDate: 'Förfallodatum:',
validUntil: 'Giltig till:',
greeting: (firstName: string) => `Hej${firstName ? ` ${firstName}` : ''},`,
bodyCreditNote: 'Bifogat hittar du en kreditfaktura som korrigerar en tidigare faktura.',
bodyInvoice: 'Tack för ditt förtroende! Bifogat hittar du din faktura.',
bodyQuote: (validUntil: string) => `Tack för ditt intresse! Bifogat hittar du vår offert. Offerten är giltig till ${validUntil}.`,
questionsQuote: 'Har du frågor om offerten? Svara direkt på detta mejl så hjälper vi dig.',
toPay: 'Att betala:',
// A quote is not a payment request, so its grand total is a neutral sum.
totalQuote: 'Summa:',
payOnline: 'Betala online',
paymentHeading: 'Betalningsinformation',
bank: 'Bank:',
@@ -51,16 +57,21 @@ const LABELS = {
docInvoice: 'Invoice',
docCreditNote: 'Credit note',
docProforma: 'Proforma invoice',
docQuote: 'Quote',
docDeliveryNote: 'Delivery note',
htmlLang: 'en',
documentFrom: (doc: string, sender: string) => `${doc} from ${sender}`,
documentNumber: (doc: string) => `${doc} number:`,
documentDate: (doc: string) => `${doc} date:`,
dueDate: 'Due date:',
validUntil: 'Valid until:',
greeting: (firstName: string) => `Hi${firstName ? ` ${firstName}` : ''},`,
bodyCreditNote: 'Attached you will find a credit note that corrects an earlier invoice.',
bodyInvoice: 'Thank you for your business. Attached you will find your invoice.',
bodyQuote: (validUntil: string) => `Thank you for your interest. Attached you will find our quote. The quote is valid until ${validUntil}.`,
questionsQuote: 'Questions about the quote? Reply directly to this email and we will help you.',
toPay: 'Total due:',
totalQuote: 'Total:',
payOnline: 'Pay online',
paymentHeading: 'Payment information',
bank: 'Bank:',
@@ -122,7 +133,7 @@ function resolveLang(customer: Customer): EmailLang {
return customer.language === 'en' ? 'en' : 'sv'
}
// Custom texts apply ONLY to standard invoices. Credit notes, proforma and
// Custom texts apply ONLY to standard invoices. Credit notes, proforma, quotes and
// delivery notes always use the stock texts: a custom "Tack för ditt
// förtroende..." body or "Faktura..." subject would be wrong on those.
function isStandardInvoice(invoice: Invoice): boolean {
@@ -135,10 +146,18 @@ function getDocumentLabel(invoice: Invoice, lang: EmailLang): string {
if (invoice.credited_invoice_id) return L.docCreditNote
const docType = (invoice as Invoice & { document_type?: InvoiceDocumentType }).document_type || 'invoice'
if (docType === 'proforma') return L.docProforma
if (docType === 'quote') return L.docQuote
if (docType === 'delivery_note') return L.docDeliveryNote
return L.docInvoice
}
// A quote's expiry: valid_until is authoritative, due_date only mirrors it
// (the column is NOT NULL), so fall back to it for rows written before
// valid_until existed.
function quoteValidUntil(invoice: Invoice): string {
return formatDate(invoice.valid_until || invoice.due_date)
}
// Currency for the customer-facing total: explicit ISO code so a non-Swedish
// recipient reads "1 234,56 SEK" instead of the Swedish symbol "kr". Use the
// English locale for digit grouping when the email is in English so the comma
@@ -221,9 +240,17 @@ export function generateInvoiceEmailHtml(data: InvoiceEmailData): string {
const docType = (invoice as Invoice & { document_type?: InvoiceDocumentType }).document_type || 'invoice'
const isDeliveryNote = docType === 'delivery_note'
const isProforma = docType === 'proforma'
const hidePayment = isCreditNote || isDeliveryNote || isProforma
// A quote (offert) is never a payment request: no payment details, no
// pay-online button; its expiry replaces the due date.
const isQuote = docType === 'quote'
const hidePayment = isCreditNote || isDeliveryNote || isProforma || isQuote
const firstName = customer.name ? customer.name.split(' ')[0] : ''
const custom = resolveCustomTexts(data, lang)
const stockBody = isCreditNote
? L.bodyCreditNote
: isQuote
? L.bodyQuote(quoteValidUntil(invoice))
: L.bodyInvoice
// Primary color drives the heading accent and the highlighted total. The
// accent is sanitized to a strict hex pattern: anything else falls back
@@ -258,7 +285,7 @@ export function generateInvoiceEmailHtml(data: InvoiceEmailData): string {
${custom.greeting !== undefined ? userTextToHtml(custom.greeting) : L.greeting(firstName)}
</p>
<p style="margin: 0;">
${custom.body !== undefined ? userTextToHtml(custom.body) : (isCreditNote ? L.bodyCreditNote : L.bodyInvoice)}
${custom.body !== undefined ? userTextToHtml(custom.body) : stockBody}
</p>
</div>
@@ -274,16 +301,16 @@ export function generateInvoiceEmailHtml(data: InvoiceEmailData): string {
<td style="padding: 8px 0; text-align: right;">${formatDate(invoice.invoice_date)}</td>
</tr>
<tr>
<td style="padding: 8px 0; color: #666; font-size: 14px;">${L.dueDate}</td>
<td style="padding: 8px 0; text-align: right; font-weight: 500; color: ${isCreditNote ? '#333' : '#e11d48'};">
${formatDate(invoice.due_date)}
<td style="padding: 8px 0; color: #666; font-size: 14px;">${isQuote ? L.validUntil : L.dueDate}</td>
<td style="padding: 8px 0; text-align: right; font-weight: 500; color: ${isCreditNote || isQuote ? '#333' : '#e11d48'};">
${isQuote ? quoteValidUntil(invoice) : formatDate(invoice.due_date)}
</td>
</tr>
<tr>
<td colspan="2" style="padding: 15px 0 8px 0; border-top: 1px solid #e5e7eb;"></td>
</tr>
<tr>
<td style="padding: 8px 0; font-size: 18px; font-weight: 600;">${L.toPay}</td>
<td style="padding: 8px 0; font-size: 18px; font-weight: 600;">${isQuote ? L.totalQuote : L.toPay}</td>
<td style="padding: 8px 0; text-align: right; font-size: 18px; font-weight: 600; color: ${isCreditNote ? '#059669' : primaryColor};">
${formatCurrencyForCustomer(getAmountToPay(invoice, company).toPay, invoice.currency, lang)}
</td>
@@ -344,7 +371,7 @@ export function generateInvoiceEmailHtml(data: InvoiceEmailData): string {
<!-- Footer -->
<div style="margin-top: 30px; padding-top: 20px; border-top: 1px solid #e5e7eb;">
<p style="margin: 0 0 10px 0; color: #666; font-size: 14px;">
${L.questions}
${isQuote ? L.questionsQuote : L.questions}
</p>
<p style="margin: 0; color: #666; font-size: 14px;">
${custom.signoff !== undefined ? userTextToHtml(custom.signoff) : L.sincerely}<br>
@@ -378,23 +405,31 @@ export function generateInvoiceEmailText(data: InvoiceEmailData): string {
const docType = (invoice as Invoice & { document_type?: InvoiceDocumentType }).document_type || 'invoice'
const isDeliveryNote = docType === 'delivery_note'
const isProforma = docType === 'proforma'
const hidePayment = isCreditNote || isDeliveryNote || isProforma
const isQuote = docType === 'quote'
const hidePayment = isCreditNote || isDeliveryNote || isProforma || isQuote
const firstName = customer.name ? customer.name.split(' ')[0] : ''
const custom = resolveCustomTexts(data, lang)
const stockBody = isCreditNote
? L.bodyCreditNote
: isQuote
? L.bodyQuote(quoteValidUntil(invoice))
: L.bodyInvoice
let text = `${L.documentFrom(documentType, getCompanyDisplayName(company))}\n`
text += `${L.documentNumber(documentType)} ${invoice.invoice_number}\n\n`
text += `${custom.greeting ?? L.greeting(firstName)}\n\n`
text += `${custom.body ?? (isCreditNote ? L.bodyCreditNote : L.bodyInvoice)}\n\n`
text += `${custom.body ?? stockBody}\n\n`
text += `${L.documentSummary(documentType)}\n`
text += `---\n`
text += `${L.documentNumber(documentType)} ${invoice.invoice_number}\n`
text += `${L.documentDate(documentType)} ${formatDate(invoice.invoice_date)}\n`
text += `${L.dueDate} ${formatDate(invoice.due_date)}\n`
text += `${L.toPay} ${formatCurrencyForCustomer(getAmountToPay(invoice, company).toPay, invoice.currency, lang)}\n`
text += isQuote
? `${L.validUntil} ${quoteValidUntil(invoice)}\n`
: `${L.dueDate} ${formatDate(invoice.due_date)}\n`
text += `${isQuote ? L.totalQuote : L.toPay} ${formatCurrencyForCustomer(getAmountToPay(invoice, company).toPay, invoice.currency, lang)}\n`
text += `---\n\n`
if (!hidePayment) {
@@ -409,7 +444,7 @@ export function generateInvoiceEmailText(data: InvoiceEmailData): string {
text += `${L.message} ${invoice.invoice_number}\n\n`
}
text += `${L.questions}\n\n`
text += `${isQuote ? L.questionsQuote : L.questions}\n\n`
text += `${custom.signoff ?? L.sincerely}\n`
text += `${getCompanyDisplayName(company)}\n`
+53
View File
@@ -1209,6 +1209,59 @@ const INVOICE: Record<string, StructuredErrorEntry> = {
message_sv: 'Fakturan kan inte markeras som betald i nuvarande status.',
message_en: 'Invoice is not in a payable status.',
},
INVOICE_QUOTE_NOT_PAYABLE: {
httpStatus: 400,
message_sv: 'En offert kan inte betalas. Skapa en faktura från offerten först.',
message_en: 'A quote cannot be paid. Convert it to an invoice first.',
remediation: {
description: 'Convert the accepted quote with POST /api/invoices/{id}/convert, then register the payment on the invoice.',
},
},
INVOICE_NOT_A_QUOTE: {
httpStatus: 400,
message_sv: 'Dokumentet är inte en offert.',
message_en: 'This document is not a quote.',
},
INVOICE_QUOTE_NOT_DECIDABLE: {
httpStatus: 400,
message_sv: 'Makulerade offerter kan inte accepteras eller avböjas.',
message_en: 'A cancelled quote cannot be accepted or declined.',
},
INVOICE_QUOTE_ALREADY_INVOICED: {
httpStatus: 409,
message_sv: 'Offerten är redan fakturerad och kan inte ändras.',
message_en: 'This quote has already been invoiced and can no longer change.',
},
INVOICE_CONVERT_NOT_CONVERTIBLE: {
httpStatus: 400,
message_sv: 'Endast proformafakturor och offerter kan omvandlas till faktura.',
message_en: 'Only proforma invoices and quotes can be converted to an invoice.',
},
INVOICE_CONVERT_SOURCE_CANCELLED: {
httpStatus: 409,
message_sv: 'Dokumentet är makulerat och kan inte omvandlas.',
message_en: 'This document is cancelled and cannot be converted.',
},
INVOICE_CONVERT_SOURCE_CHANGED: {
httpStatus: 409,
message_sv: 'Dokumentet ändrades samtidigt (makulerat, omvandlat eller beslutat på annat sätt). Ladda om och försök igen.',
message_en: 'The document changed concurrently (cancelled, converted or decided elsewhere). Reload and try again.',
},
INVOICE_QUOTE_CHANGED_CONCURRENTLY: {
httpStatus: 409,
message_sv: 'Offerten ändrades samtidigt (fakturerad, makulerad eller beslutad på annat sätt). Ladda om och försök igen.',
message_en: 'The quote changed concurrently (invoiced, cancelled or decided elsewhere). Reload and try again.',
},
INVOICE_CONVERT_QUOTE_DECLINED: {
httpStatus: 409,
message_sv: 'Offerten är avböjd. Markera den som accepterad innan du skapar en faktura.',
message_en: 'The quote was declined. Mark it accepted before creating an invoice.',
},
INVOICE_UPDATE_DOCUMENT_TYPE_LOCKED: {
httpStatus: 400,
message_sv: 'Dokumenttypen kan inte ändras på en offert eller följesedel: numret hör till serien. Skapa ett nytt dokument i stället.',
message_en: 'The document type of a quote or delivery note cannot change: its number belongs to that series. Create a new document instead.',
},
INVOICE_PAYMENT_CONFIRMATION_NOT_PAID: {
httpStatus: 409,
message_sv:
@@ -526,4 +526,74 @@ describe('buildInvoiceWriteData kundkort fallback customer-type gate', () => {
if (result.ok) return
expect('code' in result && result.code).toBe('INVOICE_CREATE_ROT_RUT_VALIDATION')
})
it('writes valid_until + quote_status open for a quote, mirrors it into due_date and keeps nothing owed', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { vat_registered: true }, error: null })
const customer = makeCustomer({ customer_type: 'swedish_business' })
const result = await call(
enqueue,
supabase as unknown as SupabaseClient,
customer,
{
...baseHeader,
valid_until: '2026-08-01',
items: [{ description: 'Offererat arbete', quantity: 2, unit: 'tim', unit_price: 1000, vat_rate: 25 }],
},
'quote',
)
expect(result.ok).toBe(true)
if (!result.ok) return
expect(result.invoiceFields.document_type).toBe('quote')
expect(result.invoiceFields.valid_until).toBe('2026-08-01')
expect(result.invoiceFields.due_date).toBe('2026-08-01')
// The decision column is never a builder output (a draft edit must not
// overwrite an accept/decline); the DB trigger opens a new quote.
expect(result.invoiceFields).not.toHaveProperty('quote_status')
expect(result.invoiceFields.total).toBe(2500)
expect(result.invoiceFields.remaining_amount).toBe(0)
expect(result.invoiceFields.deduction_total).toBe(0)
})
it('leaves the quote columns NULL on every other document type', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { vat_registered: true }, error: null })
const customer = makeCustomer({ customer_type: 'swedish_business' })
const result = await call(enqueue, supabase as unknown as SupabaseClient, customer, {
...baseHeader,
valid_until: '2026-08-01',
items: [{ description: 'Konsult', quantity: 1, unit: 'tim', unit_price: 1000, vat_rate: 25 }],
})
expect(result.ok).toBe(true)
if (!result.ok) return
expect(result.invoiceFields.valid_until).toBeNull()
expect(result.invoiceFields).not.toHaveProperty('quote_status')
expect(result.invoiceFields.due_date).toBe(baseHeader.due_date)
})
it('drops sales_order_item_id on quote lines so an offer never consumes kundorder quantity', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { vat_registered: true }, error: null })
const customer = makeCustomer({ customer_type: 'swedish_business' })
const result = await call(
enqueue,
supabase as unknown as SupabaseClient,
customer,
{
...baseHeader,
valid_until: '2026-08-01',
items: [{ description: 'Orderrad', quantity: 1, unit: 'st', unit_price: 1000, vat_rate: 25, sales_order_item_id: '33333333-3333-4333-8333-333333333333' }],
},
'quote',
)
expect(result.ok).toBe(true)
if (!result.ok) return
expect(result.items[0]).toMatchObject({ sales_order_item_id: null })
})
})
@@ -0,0 +1,348 @@
import { randomUUID } from 'node:crypto'
import { describe, expect, it } from 'vitest'
import { getPool } from '@/tests/pg/setup'
import { seedCompany } from '@/tests/pg/fixtures'
// Migration 20260902220000_invoice_quotes: the OF-series RPC, the widened
// document_type CHECK and the quote column pairing constraint.
async function ensureCompanySettings(params: {
userId: string
companyId: string
nextQuoteNumber?: number
}): Promise<void> {
await getPool().query(
`INSERT INTO public.company_settings
(user_id, company_id, invoice_prefix, next_invoice_number, next_quote_number)
VALUES ($1, $2, 'F', 1, $3)
ON CONFLICT (company_id) DO UPDATE
SET next_quote_number = EXCLUDED.next_quote_number`,
[params.userId, params.companyId, params.nextQuoteNumber ?? 1],
)
}
async function insertCustomer(userId: string, companyId: string): Promise<string> {
const customerId = randomUUID()
await getPool().query(
`INSERT INTO public.customers (id, user_id, company_id, name)
VALUES ($1, $2, $3, 'Test Customer')`,
[customerId, userId, companyId],
)
return customerId
}
async function insertInvoiceRow(params: {
userId: string
companyId: string
customerId: string
documentType: string
invoiceNumber: string | null
quoteStatus: string | null
validUntil?: string | null
}): Promise<string> {
const id = randomUUID()
await getPool().query(
`INSERT INTO public.invoices
(id, user_id, company_id, customer_id, invoice_number, document_type,
invoice_date, due_date, valid_until, quote_status, currency,
subtotal, vat_amount, total, vat_treatment, vat_rate, moms_ruta, status)
VALUES ($1, $2, $3, $4, $5, $6,
'2026-06-01', '2026-07-01', $7, $8, 'SEK',
1000, 250, 1250, 'standard_25', 25, '10', 'draft')`,
[
id,
params.userId,
params.companyId,
params.customerId,
params.invoiceNumber,
params.documentType,
params.validUntil ?? null,
params.quoteStatus,
],
)
return id
}
async function readCounter(companyId: string): Promise<{ quote: number; invoice: number }> {
const { rows } = await getPool().query<{ next_quote_number: number; next_invoice_number: number }>(
'SELECT next_quote_number, next_invoice_number FROM public.company_settings WHERE company_id = $1',
[companyId],
)
return { quote: rows[0]!.next_quote_number, invoice: rows[0]!.next_invoice_number }
}
describe('generate_quote_number RPC', () => {
it('allocates OF-nnn from its own counter and leaves the F-series untouched', async () => {
const { userId, companyId } = await seedCompany()
await ensureCompanySettings({ userId, companyId, nextQuoteNumber: 1 })
const first = await getPool().query<{ n: string }>(
'SELECT public.generate_quote_number($1) AS n',
[companyId],
)
const second = await getPool().query<{ n: string }>(
'SELECT public.generate_quote_number($1) AS n',
[companyId],
)
expect(first.rows[0]!.n).toBe('OF-001')
expect(second.rows[0]!.n).toBe('OF-002')
const counters = await readCounter(companyId)
expect(counters.quote).toBe(3)
expect(counters.invoice).toBe(1)
})
it('grows past three digits without truncation', async () => {
const { userId, companyId } = await seedCompany()
await ensureCompanySettings({ userId, companyId, nextQuoteNumber: 1234 })
const { rows } = await getPool().query<{ n: string }>(
'SELECT public.generate_quote_number($1) AS n',
[companyId],
)
expect(rows[0]!.n).toBe('OF-1234')
})
it('raises when the company has no settings row', async () => {
await expect(
getPool().query('SELECT public.generate_quote_number($1)', [randomUUID()]),
).rejects.toThrow(/Company settings not found/)
})
it('is not executable by anon', async () => {
const { rows } = await getPool().query<{ ok: boolean }>(
`SELECT has_function_privilege('anon', 'public.generate_quote_number(uuid)', 'EXECUTE') AS ok`,
)
expect(rows[0]!.ok).toBe(false)
})
})
describe('invoices quote columns', () => {
it('accepts document_type quote with an open quote_status', async () => {
const { userId, companyId } = await seedCompany()
const customerId = await insertCustomer(userId, companyId)
const id = await insertInvoiceRow({
userId,
companyId,
customerId,
documentType: 'quote',
invoiceNumber: 'OF-001',
quoteStatus: 'open',
validUntil: '2026-07-01',
})
const { rows } = await getPool().query<{ document_type: string; quote_status: string; valid_until: string }>(
'SELECT document_type, quote_status, valid_until::text FROM public.invoices WHERE id = $1',
[id],
)
expect(rows[0]).toEqual({ document_type: 'quote', quote_status: 'open', valid_until: '2026-07-01' })
})
it('rejects a non-quote that carries a quote_status', async () => {
const { userId, companyId } = await seedCompany()
const customerId = await insertCustomer(userId, companyId)
// A quote inserted WITHOUT quote_status is opened by the
// invoices_quote_defaults trigger (tested below), so only the reverse
// pairing can still violate invoices_quote_columns_check.
await expect(
insertInvoiceRow({
userId,
companyId,
customerId,
documentType: 'invoice',
invoiceNumber: null,
quoteStatus: 'open',
}),
).rejects.toThrow(/invoices_quote_columns_check/)
})
it('rejects an unknown quote_status and an unknown document_type', async () => {
const { userId, companyId } = await seedCompany()
const customerId = await insertCustomer(userId, companyId)
await expect(
insertInvoiceRow({
userId,
companyId,
customerId,
documentType: 'quote',
invoiceNumber: 'OF-003',
quoteStatus: 'expired',
}),
).rejects.toThrow(/invoices_quote_status_check/)
await expect(
insertInvoiceRow({
userId,
companyId,
customerId,
documentType: 'estimate',
invoiceNumber: null,
quoteStatus: null,
}),
).rejects.toThrow(/invoices_document_type_check/)
})
describe('invoices_quote_defaults trigger and one-live-conversion index (20260902221000)', () => {
it('opens a new quote and mirrors due_date into valid_until when a writer omits them', async () => {
const { userId, companyId } = await seedCompany()
const customerId = await insertCustomer(userId, companyId)
const id = await insertInvoiceRow({
userId,
companyId,
customerId,
documentType: 'quote',
invoiceNumber: 'OF-010',
quoteStatus: null,
validUntil: null,
})
const { rows } = await getPool().query<{ quote_status: string; valid_until: string; due_date: string }>(
'SELECT quote_status, valid_until::text, due_date::text FROM public.invoices WHERE id = $1',
[id],
)
expect(rows[0]).toEqual({ quote_status: 'open', valid_until: '2026-07-01', due_date: '2026-07-01' })
})
it('keeps the two dates equal on update, valid_until winning, and never rewrites a decision', async () => {
const { userId, companyId } = await seedCompany()
const customerId = await insertCustomer(userId, companyId)
const id = await insertInvoiceRow({
userId,
companyId,
customerId,
documentType: 'quote',
invoiceNumber: 'OF-011',
quoteStatus: 'accepted',
validUntil: '2026-07-01',
})
await getPool().query('UPDATE public.invoices SET due_date = $2 WHERE id = $1', [id, '2026-08-15'])
let res = await getPool().query<{ quote_status: string; valid_until: string; due_date: string }>(
'SELECT quote_status, valid_until::text, due_date::text FROM public.invoices WHERE id = $1',
[id],
)
expect(res.rows[0]).toEqual({ quote_status: 'accepted', valid_until: '2026-08-15', due_date: '2026-08-15' })
await getPool().query('UPDATE public.invoices SET valid_until = $2 WHERE id = $1', [id, '2026-09-30'])
res = await getPool().query<{ quote_status: string; valid_until: string; due_date: string }>(
'SELECT quote_status, valid_until::text, due_date::text FROM public.invoices WHERE id = $1',
[id],
)
expect(res.rows[0]).toEqual({ quote_status: 'accepted', valid_until: '2026-09-30', due_date: '2026-09-30' })
})
it('leaves non-quote rows untouched', async () => {
const { userId, companyId } = await seedCompany()
const customerId = await insertCustomer(userId, companyId)
const id = await insertInvoiceRow({
userId,
companyId,
customerId,
documentType: 'invoice',
invoiceNumber: null,
quoteStatus: null,
})
const { rows } = await getPool().query<{ quote_status: string | null; valid_until: string | null }>(
'SELECT quote_status, valid_until::text FROM public.invoices WHERE id = $1',
[id],
)
expect(rows[0]).toEqual({ quote_status: null, valid_until: null })
})
it('allows one live invoice per converted source, and another once the first is cancelled', async () => {
const { userId, companyId } = await seedCompany()
const customerId = await insertCustomer(userId, companyId)
const quoteId = await insertInvoiceRow({
userId,
companyId,
customerId,
documentType: 'quote',
invoiceNumber: 'OF-012',
quoteStatus: 'accepted',
validUntil: '2026-07-01',
})
const insertConverted = (id: string, number: string) =>
getPool().query(
`INSERT INTO public.invoices
(id, user_id, company_id, customer_id, invoice_number, document_type, converted_from_id,
invoice_date, due_date, currency, subtotal, vat_amount, total, vat_treatment, vat_rate, moms_ruta, status)
VALUES ($1, $2, $3, $4, $5, 'invoice', $6,
'2026-06-01', '2026-07-01', 'SEK', 1000, 250, 1250, 'standard_25', 25, '10', 'draft')`,
[id, userId, companyId, customerId, number, quoteId],
)
const first = randomUUID()
await insertConverted(first, 'F-901')
await expect(insertConverted(randomUUID(), 'F-902')).rejects.toThrow(/idx_invoices_one_live_conversion/)
await getPool().query(`UPDATE public.invoices SET status = 'cancelled' WHERE id = $1`, [first])
await expect(insertConverted(randomUUID(), 'F-903')).resolves.toBeDefined()
})
})
})
describe('quote decision guard and viewer gate (20260902222000)', () => {
it('refuses to move an accepted quote away from accepted while a live converted invoice exists', async () => {
const { userId, companyId } = await seedCompany()
const customerId = await insertCustomer(userId, companyId)
const quoteId = await insertInvoiceRow({
userId,
companyId,
customerId,
documentType: 'quote',
invoiceNumber: 'OF-020',
quoteStatus: 'accepted',
validUntil: '2026-07-01',
})
const invoiceId = randomUUID()
await getPool().query(
`INSERT INTO public.invoices
(id, user_id, company_id, customer_id, invoice_number, document_type, converted_from_id,
invoice_date, due_date, currency, subtotal, vat_amount, total, vat_treatment, vat_rate, moms_ruta, status)
VALUES ($1, $2, $3, $4, 'F-920', 'invoice', $5,
'2026-06-01', '2026-07-01', 'SEK', 1000, 250, 1250, 'standard_25', 25, '10', 'draft')`,
[invoiceId, userId, companyId, customerId, quoteId],
)
await expect(
getPool().query(`UPDATE public.invoices SET quote_status = 'declined' WHERE id = $1`, [quoteId]),
).rejects.toThrow(/INVOICE_QUOTE_ALREADY_INVOICED/)
// Cancelling the invoice frees the decision again.
await getPool().query(`UPDATE public.invoices SET status = 'cancelled' WHERE id = $1`, [invoiceId])
await expect(
getPool().query(`UPDATE public.invoices SET quote_status = 'declined' WHERE id = $1`, [quoteId]),
).resolves.toBeDefined()
})
it('leaves an accepted quote without a converted invoice free to change', async () => {
const { userId, companyId } = await seedCompany()
const customerId = await insertCustomer(userId, companyId)
const quoteId = await insertInvoiceRow({
userId,
companyId,
customerId,
documentType: 'quote',
invoiceNumber: 'OF-021',
quoteStatus: 'accepted',
validUntil: '2026-07-01',
})
await expect(
getPool().query(`UPDATE public.invoices SET quote_status = 'open' WHERE id = $1`, [quoteId]),
).resolves.toBeDefined()
})
it('generate_quote_number requires a non-viewer membership under a user JWT', async () => {
const { rows } = await getPool().query<{ src: string }>(
`SELECT pg_get_functiondef('public.generate_quote_number(uuid)'::regprocedure) AS src`,
)
expect(rows[0]!.src).toContain("cm.role <> 'viewer'")
})
})
@@ -8,6 +8,31 @@ function sort(invoices: Invoice[], sortBy: InvoiceListSort, oreRounding = true)
}
describe('sortInvoiceList', () => {
it('sorts quotes by valid_until in the date column even though they stay drafts', () => {
const invoices = [
makeInvoice({ id: 'draft', status: 'draft', due_date: '2024-05-01' }),
makeInvoice({
id: 'quote-late',
document_type: 'quote',
status: 'draft',
due_date: '2024-08-01',
valid_until: '2024-08-01',
}),
makeInvoice({
id: 'quote-early',
document_type: 'quote',
status: 'draft',
due_date: '2024-06-01',
valid_until: '2024-06-01',
}),
]
const sorted = sortInvoiceList(invoices, { column: 'due', direction: 'asc' }, true)
// Quotes carry a date; the plain draft has none and sorts last.
expect(sorted.map((i) => i.id)).toEqual(['quote-early', 'quote-late', 'draft'])
})
it('sorts displayed invoice numbers naturally and keeps missing numbers last', () => {
const invoices = [
makeInvoice({ id: '10', invoice_number: 'F-10' }),
@@ -170,6 +170,16 @@ describe('findMatchingInvoices', () => {
expect(result).toEqual([])
})
it('never proposes a quote or proforma: the candidate query is scoped to fakturor', async () => {
const queued = createQueuedMockSupabase()
queued.enqueue({ data: [], error: null })
const tx = makeTransaction({ amount: 12500 })
await findMatchingInvoices(queued.supabase as never, 'company-1', tx)
expect(queued.findCalls('invoices', 'eq')).toContainEqual(['document_type', 'invoice'])
})
it('matches by OCR reference with confidence 0.99', async () => {
const tx = makeTransaction({ amount: 12500, reference: 'F-2024001' })
mockResult({
@@ -39,4 +39,10 @@ describe('isEditableInvoiceDraft', () => {
}),
).toBe(false)
})
it('allows an open quote draft but blocks one that was accepted or declined', () => {
expect(isEditableInvoiceDraft({ status: 'draft', quote_status: 'open' })).toBe(true)
expect(isEditableInvoiceDraft({ status: 'draft', quote_status: 'accepted' })).toBe(false)
expect(isEditableInvoiceDraft({ status: 'draft', quote_status: 'declined' })).toBe(false)
})
})
@@ -195,6 +195,7 @@ describe('invoice payment accounts', () => {
expect(invoiceRequiresPaymentAccount(makeInvoice({ credited_invoice_id: 'invoice-original' }))).toBe(false)
expect(invoiceRequiresPaymentAccount(makeInvoice({ document_type: 'delivery_note' }))).toBe(false)
expect(invoiceRequiresPaymentAccount(makeInvoice({ document_type: 'proforma' }))).toBe(false)
expect(invoiceRequiresPaymentAccount(makeInvoice({ document_type: 'quote' }))).toBe(false)
})
it('accepts non-payable documents without an account', () => {
@@ -211,6 +212,10 @@ describe('invoice payment accounts', () => {
emptySettings,
makeInvoice({ document_type: 'proforma' }),
)).toBe(true)
expect(hasRequiredInvoicePaymentAccount(
emptySettings,
makeInvoice({ document_type: 'quote' }),
)).toBe(true)
expect(hasRequiredInvoicePaymentAccount(emptySettings, makeInvoice())).toBe(false)
})
})
@@ -27,6 +27,16 @@ describe('invoicePdfFilename', () => {
.toContain('Följesedel nr 42')
})
it('labels a quote (offert) with its OF- number', () => {
expect(invoicePdfFilename({
companyName: 'Oppy',
customerName: 'Kund AB',
invoiceNumber: 'OF-001',
invoiceDate: '2026-09-02',
documentType: 'quote',
})).toBe('Oppy x Kund AB Offert nr OF-001 20260902.pdf')
})
it('keeps drafts identifiable without inventing an invoice number', () => {
expect(invoicePdfFilename({
companyName: 'Oppy',
@@ -0,0 +1,161 @@
/**
* Offert (quote) PDF. A quote is never a payment request: it is titled
* OFFERT / QUOTE, states an issue date and an expiry ("Giltig till") instead
* of a due date, carries a notice that it is neither an invoice nor a
* betalningsanmodan, and renders no payment box (no bank account, bankgiro,
* OCR, Swish, QR or payment link). VAT lines render as on a proforma.
*/
import { describe, expect, it } from 'vitest'
import type { ReactElement, ReactNode } from 'react'
import { InvoicePDF, type InvoicePdfInvoice } from '@/lib/invoices/pdf-template'
import { makeCompanySettings, makeCustomer, makeInvoice } from '@/tests/helpers'
import type { InvoiceItem } from '@/types'
/** Every string leaf in the element tree, in document order. */
function textLeaves(node: ReactNode, out: string[] = []): string[] {
if (node === null || node === undefined || typeof node === 'boolean') return out
if (typeof node === 'string' || typeof node === 'number') {
out.push(String(node))
return out
}
if (Array.isArray(node)) {
for (const child of node) textLeaves(child, out)
return out
}
const element = node as ReactElement<{ children?: ReactNode }>
if (element.props) textLeaves(element.props.children, out)
return out
}
const items: InvoiceItem[] = [
{
id: 'item-1',
invoice_id: 'quote-1',
sort_order: 0,
line_type: 'product',
description: 'Konsulttimmar',
quantity: 10,
unit: 'tim',
unit_price: 1000,
line_total: 10000,
vat_rate: 25,
vat_amount: 2500,
created_at: '2026-09-02T00:00:00Z',
},
]
const company = makeCompanySettings({
bank_name: 'SEB',
clearing_number: '5000',
account_number: '1234567',
bankgiro: '123-4567',
invoice_show_bankgiro: true,
invoice_show_ocr: true,
})
function renderText(invoice: InvoicePdfInvoice, language: 'sv' | 'en' = 'sv'): string {
const tree = InvoicePDF({
invoice,
customer: makeCustomer({ language }),
items,
company,
paymentLinkQrDataUrl: null,
swishQrDataUrl: null,
})
return textLeaves(tree).join('\n')
}
const quote = (overrides: Partial<InvoicePdfInvoice> = {}): InvoicePdfInvoice =>
makeInvoice({
id: 'quote-1',
status: 'sent',
document_type: 'quote',
invoice_number: 'OF-001',
invoice_date: '2026-09-02',
due_date: '2026-10-02',
valid_until: '2026-10-02',
quote_status: 'open',
total: 12500,
payment_link_url: 'https://buy.stripe.com/test_quote',
...overrides,
})
describe('quote PDF (sv)', () => {
it('is titled OFFERT with Offertdatum and Giltig till instead of invoice dates', () => {
const text = renderText(quote())
expect(text).toContain('OFFERT')
expect(text).not.toContain('PROFORMAFAKTURA')
expect(text).toContain('Offertdatum:')
expect(text).toContain('Giltig till:')
expect(text).toContain('2026-10-02')
expect(text).not.toContain('Fakturadatum:')
expect(text).not.toContain('Förfallodatum:')
})
it('carries the quote notice and no payment information', () => {
const text = renderText(quote())
expect(text).toContain('Detta är en offert och utgör ingen faktura eller betalningsanmodan.')
expect(text).not.toContain('Betalningsinformation')
expect(text).not.toContain('Bankgiro:')
expect(text).not.toContain('OCR')
expect(text).not.toContain('Swish')
expect(text).not.toContain('buy.stripe.com')
expect(text).not.toContain('Betala online')
})
it('still renders the VAT lines like a proforma', () => {
const text = renderText(quote())
expect(text).toContain('Moms 25%:')
expect(text).toContain('2\u00a0500,00 SEK')
expect(text).toContain('12\u00a0500,00 SEK')
})
it('labels the grand total Summa, never Att betala', () => {
const text = renderText(quote())
expect(text).toContain('Summa:')
expect(text).not.toContain('Att betala')
expect(text).not.toContain('Att kreditera')
})
it('never carries a paid state even if the row says paid', () => {
const text = renderText(quote({ status: 'paid', paid_amount: 12500, paid_at: '2026-09-10T00:00:00Z' }))
expect(text).not.toContain('BETALD')
expect(text).not.toContain('Betalt:')
})
it('falls back to due_date when valid_until is missing on an older row', () => {
const text = renderText(quote({ valid_until: null, due_date: '2026-10-15' }))
expect(text).toContain('Giltig till:')
expect(text).toContain('2026-10-15')
})
})
describe('quote PDF (en)', () => {
it('is titled QUOTE with Quote date and Valid until, the notice, and no payment box', () => {
const text = renderText(quote(), 'en')
expect(text).toContain('QUOTE')
expect(text).not.toContain('PROFORMA INVOICE')
expect(text).toContain('Quote date:')
expect(text).toContain('Valid until:')
expect(text).not.toContain('Invoice date:')
expect(text).not.toContain('Due date:')
expect(text).toContain('This is a quote and is not an invoice or a request for payment.')
expect(text).not.toContain('Payment information')
expect(text).not.toContain('Pay online')
})
it('labels the grand total Total, never Total due', () => {
const text = renderText(quote(), 'en')
expect(text).toContain('Total:')
expect(text).not.toContain('Total due')
expect(text).not.toContain('To credit')
})
})
@@ -0,0 +1,25 @@
import { describe, it, expect } from 'vitest'
import { effectiveQuoteStatus, formatQuoteNumber, isQuoteExpired } from '@/lib/invoices/quote-status'
describe('quote status helpers', () => {
it('derives expired only for an open quote past valid_until', () => {
expect(isQuoteExpired({ quote_status: 'open', valid_until: '2026-05-31' }, '2026-06-01')).toBe(true)
expect(isQuoteExpired({ quote_status: 'open', valid_until: '2026-06-01' }, '2026-06-01')).toBe(false)
expect(isQuoteExpired({ quote_status: 'accepted', valid_until: '2026-05-31' }, '2026-06-01')).toBe(false)
expect(isQuoteExpired({ quote_status: 'declined', valid_until: '2026-05-31' }, '2026-06-01')).toBe(false)
expect(isQuoteExpired({ quote_status: 'open', valid_until: null }, '2026-06-01')).toBe(false)
})
it('reports the stored decision, expired, or null for non-quotes', () => {
expect(effectiveQuoteStatus({ quote_status: 'open', valid_until: '2026-05-31' }, '2026-06-01')).toBe('expired')
expect(effectiveQuoteStatus({ quote_status: 'open', valid_until: '2026-06-30' }, '2026-06-01')).toBe('open')
expect(effectiveQuoteStatus({ quote_status: 'accepted', valid_until: '2026-05-31' }, '2026-06-01')).toBe('accepted')
expect(effectiveQuoteStatus({ quote_status: null, valid_until: null })).toBeNull()
})
it('formats quote numbers like generate_quote_number', () => {
expect(formatQuoteNumber(1)).toBe('OF-001')
expect(formatQuoteNumber(42)).toBe('OF-042')
expect(formatQuoteNumber(1234)).toBe('OF-1234')
})
})
@@ -184,6 +184,15 @@ describe('processOverdueReminders: credit-note filter', () => {
expect(eqTerminal).toBeUndefined()
})
it('only considers fakturor: proformas, delivery notes and quotes never get a påminnelse', async () => {
await processOverdueReminders()
const docTypeFilter = chainCalls.find(
(c) => c.method === 'eq' && c.args[0] === 'document_type',
)
expect(docTypeFilter?.args[1]).toBe('invoice')
})
it('includes overdue in the allowlist so level-2 and level-3 reminders re-fire after the first reminder flips status', async () => {
await processOverdueReminders()
const inStatus = chainCalls.find(
@@ -25,7 +25,7 @@ describe('hasRequiredSellerVatNumber', () => {
).toBe(true)
})
it('exempts credit notes, proformas, and delivery notes', () => {
it('exempts credit notes, proformas, quotes, and delivery notes', () => {
const broken = { vat_registered: true, vat_number: null }
expect(
hasRequiredSellerVatNumber(broken, { credited_invoice_id: 'inv-0', document_type: 'invoice' }),
@@ -33,6 +33,9 @@ describe('hasRequiredSellerVatNumber', () => {
expect(
hasRequiredSellerVatNumber(broken, { credited_invoice_id: null, document_type: 'proforma' }),
).toBe(true)
expect(
hasRequiredSellerVatNumber(broken, { credited_invoice_id: null, document_type: 'quote' }),
).toBe(true)
expect(
hasRequiredSellerVatNumber(broken, { credited_invoice_id: null, document_type: 'delivery_note' }),
).toBe(true)
+20 -3
View File
@@ -74,6 +74,8 @@ export interface InvoiceWriteInput {
invoice_date: string
due_date: string
delivery_date?: string | null
/** Quotes only: expiry date. Mirrored into due_date (NOT NULL) for quotes. */
valid_until?: string | null
currency: Currency
your_reference?: string
our_reference?: string
@@ -104,6 +106,7 @@ export type InvoiceWriteFields = {
invoice_date: string
due_date: string
delivery_date: string | null
valid_until: string | null
currency: Currency
exchange_rate: number | null
exchange_rate_date: string | null
@@ -505,11 +508,22 @@ export async function buildInvoiceWriteData(params: {
totalSek = Math.round(total * 100) / 100
}
// A quote has no due date, only an expiry. due_date is NOT NULL on the
// table and every date-ordered reader sorts on it, so it mirrors
// valid_until; valid_until stays the authoritative column.
const validUntil = documentType === 'quote' ? (input.valid_until ?? input.due_date) : null
const invoiceFields: InvoiceWriteFields = {
customer_id: input.customer_id,
invoice_date: input.invoice_date,
due_date: input.due_date,
due_date: validUntil ?? input.due_date,
delivery_date: input.delivery_date ?? null,
valid_until: validUntil,
// quote_status is deliberately NOT a builder output: this object is
// spread into both inserts and draft updates, and a recorded accept or
// decline must never be overwritten by an edit. New quotes start open via
// the invoices_quote_defaults trigger (20260902221000); decisions are
// written only by the quote-status routes and the converter.
currency: input.currency,
exchange_rate: exchangeRate,
exchange_rate_date: exchangeRateDate,
@@ -522,7 +536,7 @@ export async function buildInvoiceWriteData(params: {
// remaining_amount = total - deduction for real invoices so open-invoice
// queries treat them as fully unpaid for the CUSTOMER's share: the
// Skatteverket portion is on 1513 and clears when the agency pays out.
// Proformas / delivery notes have no payment obligation → keep 0.
// Proformas / delivery notes / quotes have no payment obligation → keep 0.
remaining_amount: documentType === 'invoice' ? total - deductionTotal : 0,
vat_treatment: notVatRegistered ? 'exempt' : headerRules.treatment,
vat_rate: documentType === 'delivery_note' ? 0 : (isMixedRate ? null : (uniqueRates.values().next().value ?? vatRules.rate)),
@@ -615,7 +629,10 @@ export async function buildInvoiceWriteData(params: {
// VAT-treatment-derived account in generatePerRateLines().
article_id: item.article_id ?? null,
revenue_account: item.revenue_account ?? null,
sales_order_item_id: item.sales_order_item_id ?? null,
// Only a faktura consumes kundorder quantity: a quote or proforma line
// linked to an order item would mark the order invoiced without any
// invoice existing (sales_order_invoiced_quantities counts by status).
sales_order_item_id: documentType === 'invoice' ? (item.sales_order_item_id ?? null) : null,
deduction_type: deductionType,
deduction_amount: deductionAmount,
labor_hours: documentType === 'invoice' ? (item.labor_hours ?? null) : null,
+349
View File
@@ -0,0 +1,349 @@
import type { SupabaseClient } from '@supabase/supabase-js'
import { eventBus } from '@/lib/events'
import { ensureInvoiceNumber } from '@/lib/invoices/ensure-invoice-number'
import { convertToSEK, fetchExchangeRate } from '@/lib/currency/riksbanken'
import type { Invoice } from '@/types'
/**
* Convert a proforma or a quote (offert) into a real invoice.
*
* ONE implementation for every caller (the dashboard route, the MCP staged
* commit, v1): the proforma conversion used to exist twice and had drifted
* (one copy dropped per-line VAT and revenue accounts, the other dropped
* discounts and fakturamarkning). Both now flow through here.
*
* Source handling differs by document type:
* - proforma: the source is marked cancelled (it was a pre-invoice for the
* same sale) and its due_date is carried over.
* - quote: the source stays, flips to quote_status = 'accepted', and the
* invoice links back via converted_from_id. A quote has no due date, so
* the invoice gets invoice_date + the customer's payment terms (falling
* back to company_settings.invoice_default_days, then 30). A second
* conversion is refused while an active converted invoice exists; a
* declined quote must be re-accepted first.
*
* Ordering: ensureInvoiceNumber() is the LAST side effect. The F-series
* counter only advances after items are inserted and the source is updated,
* so a partial failure in any earlier step rolls back the orphan row without
* leaking a number (ML 17 kap 24 paragraph: gap-free lopnummer).
*/
export type ConvertToInvoiceFailureCode =
| 'INVOICE_NOT_FOUND'
| 'INVOICE_CONVERT_NOT_CONVERTIBLE'
| 'INVOICE_CONVERT_SOURCE_CANCELLED'
| 'INVOICE_CONVERT_SOURCE_CHANGED'
| 'INVOICE_CONVERT_QUOTE_DECLINED'
| 'INVOICE_QUOTE_ALREADY_INVOICED'
export type ConvertToInvoiceResult =
| { ok: true; invoice: Invoice }
| { ok: false; code: ConvertToInvoiceFailureCode }
| { ok: false; code: 'INVOICE_CONVERT_FAILED'; cause: Error | { message: string } }
interface SourceItem {
sort_order: number
line_type?: 'product' | 'text' | null
description: string
quantity: number
unit: string
unit_price: number
discount_percent?: number | null
line_total: number
vat_rate?: number | null
vat_amount?: number | null
revenue_account?: string | null
article_id?: string | null
sales_order_item_id?: string | null
dimensions?: Record<string, string> | null
}
type SourceRow = Invoice & {
items?: SourceItem[] | null
customer?: { default_payment_terms?: number | null } | null
}
const DEFAULT_PAYMENT_TERMS_DAYS = 30
function isoDate(d: Date): string {
return d.toISOString().split('T')[0]
}
function addDays(iso: string, days: number): string {
const d = new Date(`${iso}T00:00:00Z`)
d.setUTCDate(d.getUTCDate() + days)
return isoDate(d)
}
export async function convertToInvoice(params: {
supabase: SupabaseClient
userId: string
companyId: string
sourceId: string
}): Promise<ConvertToInvoiceResult> {
const { supabase, userId, companyId, sourceId } = params
const { data: sourceRaw, error: sourceError } = await supabase
.from('invoices')
.select('*, items:invoice_items(*), customer:customers(default_payment_terms)')
.eq('id', sourceId)
.eq('company_id', companyId)
.single()
if (sourceError || !sourceRaw) {
return { ok: false, code: 'INVOICE_NOT_FOUND' }
}
const source = sourceRaw as SourceRow
const isQuote = source.document_type === 'quote'
if (source.document_type !== 'proforma' && !isQuote) {
return { ok: false, code: 'INVOICE_CONVERT_NOT_CONVERTIBLE' }
}
if (source.status === 'cancelled') {
return { ok: false, code: 'INVOICE_CONVERT_SOURCE_CANCELLED' }
}
const today = isoDate(new Date())
let dueDate: string = source.due_date
if (isQuote) {
if (source.quote_status === 'declined') {
return { ok: false, code: 'INVOICE_CONVERT_QUOTE_DECLINED' }
}
// One invoice per quote while that invoice lives. A cancelled converted
// invoice frees the quote for another attempt.
const { data: existing, error: existingError } = await supabase
.from('invoices')
.select('id')
.eq('company_id', companyId)
.eq('converted_from_id', sourceId)
.neq('status', 'cancelled')
.limit(1)
.maybeSingle()
if (existingError) {
return { ok: false, code: 'INVOICE_CONVERT_FAILED', cause: existingError }
}
if (existing) {
return { ok: false, code: 'INVOICE_QUOTE_ALREADY_INVOICED' }
}
// 0 days is a real term (due on receipt); only a missing value falls
// through to the company default and then to 30.
let termsDays: number | null = source.customer?.default_payment_terms ?? null
if (termsDays == null) {
const { data: settings, error: settingsError } = await supabase
.from('company_settings')
.select('invoice_default_days')
.eq('company_id', companyId)
.maybeSingle()
// A failed read must not silently become "30 days": that would date
// the receivable wrong. Only a successful empty result falls back.
if (settingsError) {
return { ok: false, code: 'INVOICE_CONVERT_FAILED', cause: settingsError }
}
termsDays = (settings as { invoice_default_days?: number | null } | null)?.invoice_default_days ?? null
}
dueDate = addDays(today, termsDays ?? DEFAULT_PAYMENT_TERMS_DAYS)
}
// The invoice is a new taxable event dated today (ML 8 kap 21-23 §): a
// quote can sit for weeks, so the SEK twins must use today's rate, not
// the one stamped when the offer was written. Fail closed if no rate can
// be had (the create paths refuse a NULL rate for the same reason).
let exchangeRate = source.exchange_rate
let exchangeRateDate = source.exchange_rate_date
let subtotalSek = source.subtotal_sek
let vatAmountSek = source.vat_amount_sek
let totalSek = source.total_sek
if (source.currency !== 'SEK') {
const rate = await fetchExchangeRate(source.currency, new Date(`${today}T00:00:00Z`), supabase)
if (!rate) {
return {
ok: false,
code: 'INVOICE_CONVERT_FAILED',
cause: { message: `Exchange rate for ${source.currency} unavailable; try again later` },
}
}
exchangeRate = rate.rate
exchangeRateDate = rate.date
subtotalSek = convertToSEK(source.subtotal, rate.rate)
vatAmountSek = convertToSEK(source.vat_amount, rate.rate)
totalSek = convertToSEK(source.total, rate.rate)
}
const { data: invoice, error: invoiceError } = await supabase
.from('invoices')
.insert({
user_id: userId,
company_id: companyId,
customer_id: source.customer_id,
invoice_number: null,
invoice_date: today,
due_date: dueDate,
currency: source.currency,
exchange_rate: exchangeRate,
exchange_rate_date: exchangeRateDate,
subtotal: source.subtotal,
subtotal_sek: subtotalSek,
vat_amount: source.vat_amount,
vat_amount_sek: vatAmountSek,
total: source.total,
total_sek: totalSek,
// The converted invoice is a fresh unpaid receivable: proformas and
// quotes carry no ROT/RUT deduction, so the customer owes the full
// total. Omitting this left the NOT NULL DEFAULT 0, which every payment
// surface reads as "nothing open".
remaining_amount: source.total,
paid_amount: 0,
vat_treatment: source.vat_treatment,
vat_rate: source.vat_rate,
moms_ruta: source.moms_ruta,
reverse_charge_text: source.reverse_charge_text,
your_reference: source.your_reference,
our_reference: source.our_reference,
// Buyer routing survives conversion (Peppol BT-10 may rely on it alone).
invoice_marking: source.invoice_marking ?? null,
notes: source.notes,
document_type: 'invoice',
converted_from_id: sourceId,
// Quote-only columns must not travel: the CHECK pairs quote_status
// with document_type = 'quote'.
valid_until: null,
quote_status: null,
quote_decided_at: null,
// Dimensions PR7: the converted invoice books with the source's bag.
default_dimensions: source.default_dimensions ?? {},
})
.select()
.single()
if (invoiceError) {
// idx_invoices_one_live_conversion: a concurrent conversion won the race.
if ((invoiceError as { code?: string }).code === '23505') {
return { ok: false, code: isQuote ? 'INVOICE_QUOTE_ALREADY_INVOICED' : 'INVOICE_CONVERT_SOURCE_CHANGED' }
}
return { ok: false, code: 'INVOICE_CONVERT_FAILED', cause: invoiceError }
}
const items = (source.items ?? []).map((item) => ({
invoice_id: invoice.id,
sort_order: item.sort_order,
line_type: item.line_type ?? 'product',
description: item.description,
quantity: item.quantity,
unit: item.unit,
unit_price: item.unit_price,
// The stored line_total is net of this; dropping it would make the
// converted invoice fail the Peppol line check and lose the rebate on
// the next builder pass.
discount_percent: item.discount_percent ?? 0,
line_total: item.line_total,
// Per-line VAT and any article / revenue-account override travel too, so
// the invoice books exactly as the source showed (mixed rates and
// per-article accounts both rely on these).
vat_rate: item.vat_rate ?? 0,
vat_amount: item.vat_amount ?? 0,
revenue_account: item.revenue_account ?? null,
article_id: item.article_id ?? null,
// A kundorder link on a proforma line moves to the invoice, so the order
// keeps counting the quantity as invoiced once the proforma is cancelled.
sales_order_item_id: item.sales_order_item_id ?? null,
dimensions: item.dimensions ?? {},
}))
if (items.length > 0) {
const { error: itemsError } = await supabase.from('invoice_items').insert(items)
if (itemsError) {
await supabase.from('invoices').delete().eq('id', invoice.id)
return { ok: false, code: 'INVOICE_CONVERT_FAILED', cause: itemsError }
}
}
// Update the source. If this fails, the new (still unnumbered) invoice is
// an orphan: delete it so the user can retry without ending up with two
// active invoices for the same source. invoice_items cascade.
const previousSource = {
status: source.status,
quote_status: source.quote_status ?? null,
quote_decided_at: source.quote_decided_at ?? null,
}
// Literal payloads on purpose: the phantom-column schema guard
// (tests/schema/no-phantom-columns.test.ts) can only check object literals.
// Compare-and-set on the state read above: a concurrent cancel, a
// concurrent proforma-to-order conversion (which cancels the proforma) or
// a concurrent decision on the quote turns this into a 0-row update, and
// the orphan invoice is removed instead of a second document for the
// same sale surviving.
const { data: sourceRows, error: sourceUpdateError } = isQuote
? await supabase
.from('invoices')
.update({
quote_status: 'accepted',
quote_decided_at: source.quote_decided_at ?? new Date().toISOString(),
})
.eq('id', sourceId)
.eq('quote_status', source.quote_status)
.neq('status', 'cancelled')
.select('id')
: await supabase
.from('invoices')
.update({ status: 'cancelled' })
.eq('id', sourceId)
.neq('status', 'cancelled')
.select('id')
if (sourceUpdateError) {
await supabase.from('invoices').delete().eq('id', invoice.id)
return { ok: false, code: 'INVOICE_CONVERT_FAILED', cause: sourceUpdateError }
}
if (!sourceRows || sourceRows.length === 0) {
await supabase.from('invoices').delete().eq('id', invoice.id)
return { ok: false, code: 'INVOICE_CONVERT_SOURCE_CHANGED' }
}
// Allocate the F-series number last. If allocation fails, restore the
// source and delete the orphan invoice. The F-counter is unaffected because
// generate_invoice_number only commits on success.
try {
await ensureInvoiceNumber(supabase, companyId, invoice as Invoice)
} catch (err) {
if (isQuote) {
await supabase
.from('invoices')
.update({
quote_status: previousSource.quote_status,
quote_decided_at: previousSource.quote_decided_at,
})
.eq('id', sourceId)
} else {
await supabase
.from('invoices')
.update({ status: previousSource.status })
.eq('id', sourceId)
}
await supabase.from('invoices').delete().eq('id', invoice.id)
return {
ok: false,
code: 'INVOICE_CONVERT_FAILED',
cause: err instanceof Error ? err : { message: 'Failed to assign invoice number' },
}
}
const { data: completeInvoice } = await supabase
.from('invoices')
.select('*, customer:customers(*), items:invoice_items(*)')
.eq('id', invoice.id)
.single()
const result = (completeInvoice ?? invoice) as Invoice
if (completeInvoice) {
await eventBus.emit({
type: 'invoice.created',
payload: { invoice: result, companyId, userId },
})
}
return { ok: true, invoice: result }
}
+3
View File
@@ -33,6 +33,9 @@ function displayedCustomer(invoice: Invoice): string | null {
}
function displayedDueDate(invoice: Invoice): string | null {
// The list shows a quote's "Giltig till" in the date column (a quote stays
// a draft, so the draft rule below would blank it).
if (invoice.document_type === 'quote') return invoice.valid_until || null
if (invoice.credited_invoice_id || invoice.status === 'draft') return null
return invoice.due_date || null
}
+4 -1
View File
@@ -275,7 +275,9 @@ export async function findInvoiceMatchCandidates(
return emptyResult()
}
// Query unpaid invoices (sent or overdue) with customer info
// Query unpaid invoices (sent or overdue) with customer info. Proformas,
// delivery notes and quotes are never receivables, so a sent quote is never
// proposed as a payment candidate.
let invoices: Array<Invoice & { customer?: { name?: string | null } | null }>
try {
invoices = await fetchAllRows(({ from, to }) =>
@@ -283,6 +285,7 @@ export async function findInvoiceMatchCandidates(
.from('invoices')
.select('*, customer:customers(*), credit_notes:invoices!credited_invoice_id(id, status, creation_complete)')
.eq('company_id', companyId)
.eq('document_type', 'invoice')
.is('credited_invoice_id', null)
.in('status', ['sent', 'overdue', 'partially_paid'])
.order('id', { ascending: true })
+5 -2
View File
@@ -6,7 +6,8 @@
* invoice must be corrected with a credit note instead. A self-billed invoice we
* received is the counterparty's document: never editable here. Credit-note
* drafts mirror an issued invoice and must not be changed into a different
* correction after creation.
* correction after creation. A quote that has been accepted or declined is a
* recorded decision: flip it back to open before editing the offer itself.
*
* This is the single source of truth for that predicate. The PATCH route
* (app/api/invoices/[id]/route.ts) enforces it server-side; the detail and edit
@@ -18,11 +19,13 @@ export function isEditableInvoiceDraft(invoice: {
journal_entry_id?: string | null
is_self_billed?: boolean | null
credited_invoice_id?: string | null
quote_status?: string | null
}): boolean {
return (
invoice.status === 'draft' &&
!invoice.journal_entry_id &&
!invoice.is_self_billed &&
!invoice.credited_invoice_id
!invoice.credited_invoice_id &&
(!invoice.quote_status || invoice.quote_status === 'open')
)
}
+2
View File
@@ -128,6 +128,8 @@ export function invoiceRequiresPaymentAccount(
return !invoice.credited_invoice_id
&& invoice.document_type !== 'delivery_note'
&& invoice.document_type !== 'proforma'
// A quote (offert) is never a payment request: nothing to pay to.
&& invoice.document_type !== 'quote'
}
export function hasRequiredInvoicePaymentAccount(
+1
View File
@@ -30,6 +30,7 @@ function safeFilenamePart(value: string | null | undefined, fallback: string, ma
function documentLabel(documentType: InvoiceDocumentType, isCreditNote: boolean): string {
if (isCreditNote) return 'Kreditfaktura'
if (documentType === 'proforma') return 'Proformafaktura'
if (documentType === 'quote') return 'Offert'
if (documentType === 'delivery_note') return 'Följesedel'
return 'Faktura'
}
+51 -14
View File
@@ -32,6 +32,7 @@ const LABELS = {
titleInvoice: 'FAKTURA',
titleCreditNote: 'KREDITFAKTURA',
titleProforma: 'PROFORMAFAKTURA',
titleQuote: 'OFFERT',
titleDeliveryNote: 'FÖLJESEDEL',
titlePreview: 'FÖRHANDSGRANSKNING',
// Status banners
@@ -39,6 +40,8 @@ const LABELS = {
cancelledWithNumber: (n: string) => `Faktura ${n} har makulerats. Numret behålls i serien för att hålla nummerföljden obruten enligt ML 17 kap 24§, men dokumentet är inte ett giltigt fakturaunderlag.`,
cancelledNoNumber: 'Detta utkast har makulerats och är inte ett giltigt fakturaunderlag.',
draftTitle: 'UTKAST: inte en giltig faktura',
draftTitleQuote: 'UTKAST',
draftTextQuote: 'Detta är ett utkast av offerten.',
draftWithNumber: 'Detta är ett utkast. Markera fakturan som skickad eller skicka via systemet för att göra den giltig som fakturaunderlag.',
draftNoNumber: 'Denna faktura saknar löpnummer och kan inte användas som fakturaunderlag enligt ML 17 kap 24§. Skicka fakturan via systemet för att tilldela ett nummer.',
paidTitle: 'BETALD',
@@ -48,11 +51,16 @@ const LABELS = {
creditNoteRef: (n: string) => `Denna kreditfaktura avser och krediterar faktura nr ${n}`,
// Sections
invoiceInfoHeading: 'Fakturainformation',
quoteInfoHeading: 'Offertinformation',
billedToHeading: 'Faktureras till',
itemsHeading: 'Specifikation',
// Invoice details
invoiceDate: 'Fakturadatum:',
dueDate: 'Förfallodatum:',
// Quote (offert) date labels: a quote has an issue date and an expiry,
// never a due date.
quoteDate: 'Offertdatum:',
validUntil: 'Giltig till:',
deliveryDate: 'Leveransdatum:',
yourReference: 'Er referens:',
ourReference: 'Vår referens:',
@@ -84,11 +92,14 @@ const LABELS = {
deductionNotice: 'Köparen ansöker om utbetalning hos Skatteverket via fakturamodellen. Säljaren begär utbetalning för den del köparen inte betalat.',
toCredit: 'Att kreditera:',
toPay: 'Att betala:',
// A quote is not a payment request, so its grand total is a neutral sum.
totalQuote: 'Summa:',
paidRow: 'Betalt:',
vatInSek: (rate: number | string) => `Moms i SEK (kurs ${rate}):`,
totalInSek: 'Totalt i SEK:',
// Proforma / exempt
// Proforma / quote / exempt
proformaNotice: 'Detta är en proformafaktura och utgör ingen betalningsanmodan.',
quoteNotice: 'Detta är en offert och utgör ingen faktura eller betalningsanmodan.',
exemptNotice: 'Undantag från skatteplikt, ML 3 kap.',
notVatRegisteredNotice: 'Företaget är inte momsregistrerat. Mervärdesskatt redovisas ej.',
// Payment
@@ -119,12 +130,15 @@ const LABELS = {
titleInvoice: 'INVOICE',
titleCreditNote: 'CREDIT NOTE',
titleProforma: 'PROFORMA INVOICE',
titleQuote: 'QUOTE',
titleDeliveryNote: 'DELIVERY NOTE',
titlePreview: 'PREVIEW',
cancelledTitle: 'VOID: not a valid invoice',
cancelledWithNumber: (n: string) => `Invoice ${n} has been voided. The number is retained in the sequence to keep the numbering unbroken (ML 17 kap 24§, Swedish VAT Act), but this document is not a valid invoice.`,
cancelledNoNumber: 'This draft has been voided and is not a valid invoice.',
draftTitle: 'DRAFT: not a valid invoice',
draftTitleQuote: 'DRAFT',
draftTextQuote: 'This is a draft of the quote.',
draftWithNumber: 'This is a draft. Mark the invoice as sent, or send it via the system, to make it a valid invoice.',
draftNoNumber: 'This invoice has no serial number and cannot be used as a valid invoice under ML 17 kap 24§ (Swedish VAT Act). Send the invoice via the system to assign a number.',
paidTitle: 'PAID',
@@ -132,10 +146,13 @@ const LABELS = {
paidBannerNoDate: (amount: string) => `Paid · ${amount}`,
creditNoteRef: (n: string) => `This credit note credits invoice no. ${n}`,
invoiceInfoHeading: 'Invoice information',
quoteInfoHeading: 'Quote information',
billedToHeading: 'Billed to',
itemsHeading: 'Items',
invoiceDate: 'Invoice date:',
dueDate: 'Due date:',
quoteDate: 'Quote date:',
validUntil: 'Valid until:',
deliveryDate: 'Delivery date:',
yourReference: 'Your reference:',
ourReference: 'Our reference:',
@@ -164,10 +181,12 @@ const LABELS = {
deductionNotice: 'The customer claims the deduction via fakturamodellen at Skatteverket. The seller requests payment from the agency for the portion not paid by the customer.',
toCredit: 'To credit:',
toPay: 'Total due:',
totalQuote: 'Total:',
paidRow: 'Paid:',
vatInSek: (rate: number | string) => `VAT in SEK (rate ${rate}):`,
totalInSek: 'Total in SEK:',
proformaNotice: 'This is a proforma invoice and is not a request for payment.',
quoteNotice: 'This is a quote and is not an invoice or a request for payment.',
exemptNotice: 'Exempt from VAT (ML 3 kap., Swedish VAT Act).',
notVatRegisteredNotice: 'The seller is not VAT-registered. No VAT is charged on this invoice.',
paymentHeading: 'Payment information',
@@ -719,6 +738,7 @@ function getDocumentTitle(invoice: Invoice, lang: PdfLang): string {
if (invoice.credited_invoice_id) return L.titleCreditNote
const docType = (invoice as Invoice & { document_type?: InvoiceDocumentType }).document_type || 'invoice'
if (docType === 'proforma') return L.titleProforma
if (docType === 'quote') return L.titleQuote
if (docType === 'delivery_note') return L.titleDeliveryNote
return L.titleInvoice
}
@@ -797,6 +817,10 @@ export function InvoicePDF({ invoice, customer, items, company, originalInvoiceN
const docType = (invoice as Invoice & { document_type?: InvoiceDocumentType }).document_type || 'invoice'
const isDeliveryNote = docType === 'delivery_note'
const isProforma = docType === 'proforma'
// A quote (offert) is never a payment request: no payment box, no OCR,
// no Swish/QR, no payment link (pdf-render-helpers gates the QR builders
// on docType === 'invoice'). Its expiry replaces the due date.
const isQuote = docType === 'quote'
// Shared with the invoice email (lib/email/invoice-templates.ts) so the
// mail and the PDF always state the same "Att betala". Computed once here
@@ -843,11 +867,13 @@ export function InvoicePDF({ invoice, customer, items, company, originalInvoiceN
</View>
) : isPreview ? null : (invoice.status === 'draft' || !invoice.invoice_number) ? (
<View style={styles.draftBanner}>
<Text style={styles.draftBannerTitle}>{L.draftTitle}</Text>
<Text style={styles.draftBannerTitle}>{isQuote ? L.draftTitleQuote : L.draftTitle}</Text>
<Text style={styles.draftBannerText}>
{invoice.invoice_number
? L.draftWithNumber
: L.draftNoNumber}
{isQuote
? L.draftTextQuote
: invoice.invoice_number
? L.draftWithNumber
: L.draftNoNumber}
</Text>
</View>
) : paidState?.kind === 'paid' && (
@@ -915,14 +941,16 @@ export function InvoicePDF({ invoice, customer, items, company, originalInvoiceN
<View style={styles.twoColumn}>
{/* Invoice details */}
<View style={styles.column}>
<Text style={styles.sectionTitle}>{L.invoiceInfoHeading}</Text>
<Text style={styles.sectionTitle}>{isQuote ? L.quoteInfoHeading : L.invoiceInfoHeading}</Text>
<View style={styles.row}>
<Text style={styles.label}>{L.invoiceDate}</Text>
<Text style={styles.label}>{isQuote ? L.quoteDate : L.invoiceDate}</Text>
<Text style={styles.value}>{formatDate(invoice.invoice_date)}</Text>
</View>
<View style={styles.row}>
<Text style={styles.label}>{L.dueDate}</Text>
<Text style={styles.value}>{formatDate(invoice.due_date)}</Text>
<Text style={styles.label}>{isQuote ? L.validUntil : L.dueDate}</Text>
<Text style={styles.value}>
{formatDate(isQuote ? (invoice.valid_until || invoice.due_date) : invoice.due_date)}
</Text>
</View>
{invoice.delivery_date && invoice.delivery_date !== invoice.invoice_date && (
<View style={styles.row}>
@@ -1141,7 +1169,7 @@ export function InvoicePDF({ invoice, customer, items, company, originalInvoiceN
</>
) : (
<View style={styles.grandTotal}>
<Text style={styles.grandTotalLabel}>{isCreditNote ? L.toCredit : L.toPay}</Text>
<Text style={styles.grandTotalLabel}>{isCreditNote ? L.toCredit : isQuote ? L.totalQuote : L.toPay}</Text>
<Text style={styles.grandTotalValue}>{formatPdfCurrency(grandTotal, invoice.currency, lang)}</Text>
</View>
)}
@@ -1232,8 +1260,17 @@ export function InvoicePDF({ invoice, customer, items, company, originalInvoiceN
</View>
)}
{/* Payment information - not shown for credit notes, proformas, or delivery notes */}
{!isCreditNote && !isProforma && !isDeliveryNote && (
{/* Quote notice */}
{isQuote && (
<View style={[styles.reverseChargeBox, { backgroundColor: '#e8f4fd', borderColor: '#90cdf4' }]}>
<Text style={[styles.reverseChargeText, { color: '#2b6cb0' }]}>
{L.quoteNotice}
</Text>
</View>
)}
{/* Payment information - not shown for credit notes, proformas, quotes, or delivery notes */}
{!isCreditNote && !isProforma && !isQuote && !isDeliveryNote && (
<View style={styles.paymentSection}>
<Text style={styles.paymentTitle}>{L.paymentHeading}</Text>
{invoice.payment_link_url && (
@@ -1375,8 +1412,8 @@ export function InvoicePDF({ invoice, customer, items, company, originalInvoiceN
</View>
)}
{/* Late fee & credit terms */}
{(company.invoice_late_fee_text || company.invoice_credit_terms_text) && (
{/* Late fee & credit terms: payment terms, so never on a quote */}
{!isQuote && (company.invoice_late_fee_text || company.invoice_credit_terms_text) && (
<View style={{ marginTop: 10, marginBottom: 10 }}>
{company.invoice_late_fee_text && (
<Text style={{ fontSize: 8, color: '#666', marginBottom: 2 }}>{company.invoice_late_fee_text}</Text>
+30
View File
@@ -0,0 +1,30 @@
import type { QuoteStatus } from '@/types'
/** What the UI and API report for a quote: the stored decision, or expired. */
export type EffectiveQuoteStatus = QuoteStatus | 'expired'
/**
* "Expired" is derived, never stored: an open quote whose valid_until has
* passed. Extending valid_until therefore un-expires it with no cron and no
* status write. Accepted and declined quotes never expire.
*/
export function isQuoteExpired(
quote: { quote_status?: string | null; valid_until?: string | null },
today: string = new Date().toISOString().split('T')[0],
): boolean {
return quote.quote_status === 'open' && !!quote.valid_until && quote.valid_until < today
}
export function effectiveQuoteStatus(
quote: { quote_status?: string | null; valid_until?: string | null },
today?: string,
): EffectiveQuoteStatus | null {
if (!quote.quote_status) return null
if (isQuoteExpired(quote, today)) return 'expired'
return quote.quote_status as QuoteStatus
}
/** Quote numbers are allocated at insert; this mirrors generate_quote_number's format for previews. */
export function formatQuoteNumber(n: number): string {
return `OF-${String(n).padStart(3, '0')}`
}
+4
View File
@@ -198,6 +198,10 @@ export async function processOverdueReminders(): Promise<ProcessRemindersResult>
credit_notes:invoices!credited_invoice_id(id, status, creation_complete)
`)
.in('status', ['sent', 'overdue'])
// Only fakturor are payment requests. A sent proforma or quote past its
// date is not overdue and must never receive a betalningspåminnelse or
// be flipped to 'overdue' below.
.eq('document_type', 'invoice')
.is('credited_invoice_id', null)
.lte('due_date', cutoffDate.toISOString().split('T')[0])
.order('due_date', { ascending: true })
+3 -1
View File
@@ -6,7 +6,7 @@ import type { CompanySettings, Invoice } from '@/types'
* defective invoice and a defective input-VAT underlag for the buyer, so
* issuance is gated the same way the payment account is.
*
* Proformas and delivery notes are not tax documents. Credit notes are
* Proformas, quotes (offert) and delivery notes are not tax documents. Credit notes are
* exempted deliberately: an ändringsfaktura has its own mandatory-content
* list (ML 17 kap. 22-23 §§: unambiguous reference to the original, the
* change, own number and date, negative amounts, VAT per original rate)
@@ -20,6 +20,8 @@ export function invoiceRequiresSellerVatNumber(
return !invoice.credited_invoice_id
&& invoice.document_type !== 'delivery_note'
&& invoice.document_type !== 'proforma'
// A quote is not a faktura under ML 17 kap.: no mandatory-content list applies.
&& invoice.document_type !== 'quote'
}
export function hasRequiredSellerVatNumber(
@@ -420,3 +420,94 @@ describe('commitPendingOperation: create_invoice: dimensions propagation (PR7)',
expect(itemRows[0]).toMatchObject({ dimensions: {} })
})
})
describe('commitPendingOperation: create_invoice as a quote (offert)', () => {
/** Quote queue: CAS claim → customers → company_settings → invoices insert →
* invoice_items insert → dispatcher update. No complete-invoice select:
* quotes never emit invoice.created. */
const quoteQueue = [
{ data: { id: 'op-1' } },
{ data: customer },
{ data: { vat_registered: true } },
{ data: { id: 'inv-q', invoice_number: 'OF-001' } },
{ data: null },
{ data: null },
]
it('allocates OF-nnn via generate_quote_number and never touches the F-series', async () => {
const base = createCapturingSupabase(quoteQueue)
const rpc = vi.fn().mockResolvedValue({ data: 'OF-001', error: null })
const supabase = { ...base.supabase, rpc }
const emitted: string[] = []
eventBus.on('invoice.created', async () => {
emitted.push('invoice.created')
})
const op = makePendingOp({
params: {
customer_id: 'cust-1',
document_type: 'quote',
valid_until: '2026-12-31',
items: [{ description: 'Konsulttimmar', quantity: 1, unit: 'tim', unit_price: 1000, vat_rate: 25 }],
invoice_date: '2026-09-02',
due_date: '2026-12-31',
},
})
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
expect(result.status).toBe('committed')
expect(rpc).toHaveBeenCalledTimes(1)
expect(rpc).toHaveBeenCalledWith('generate_quote_number', { p_company_id: 'company-1' })
expect(rpc).not.toHaveBeenCalledWith('generate_invoice_number', expect.anything())
expect(base.inserts['invoices'][0]).toMatchObject({
invoice_number: 'OF-001',
document_type: 'quote',
quote_status: 'open',
valid_until: '2026-12-31',
due_date: '2026-12-31',
total: 1250,
})
expect(emitted).toEqual([])
})
it('refuses a quote without valid_until', async () => {
const base = createCapturingSupabase([{ data: { id: 'op-1' } }])
const rpc = vi.fn()
const supabase = { ...base.supabase, rpc }
const op = makePendingOp({
params: {
customer_id: 'cust-1',
document_type: 'quote',
items: [{ description: 'Konsulttimmar', quantity: 1, unit: 'tim', unit_price: 1000, vat_rate: 25 }],
},
})
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
expect(result.status).not.toBe('committed')
expect(rpc).not.toHaveBeenCalled()
expect(base.inserts['invoices']).toBeUndefined()
})
it('fails closed when the OF-series allocation errors, inserting nothing', async () => {
const base = createCapturingSupabase([{ data: { id: 'op-1' } }, { data: customer }, { data: { vat_registered: true } }])
const rpc = vi.fn().mockResolvedValue({ data: null, error: { message: 'boom' } })
const supabase = { ...base.supabase, rpc }
const op = makePendingOp({
params: {
customer_id: 'cust-1',
document_type: 'quote',
valid_until: '2026-12-31',
items: [{ description: 'Konsulttimmar', quantity: 1, unit: 'tim', unit_price: 1000, vat_rate: 25 }],
},
})
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
expect(result.status).not.toBe('committed')
expect(base.inserts['invoices']).toBeUndefined()
})
})
@@ -163,4 +163,34 @@ describe('commitPendingOperation: mark_invoice_paid state + invoice.paid', () =>
// remaining balance (no custom amount param), so newStatus is always 'paid'.
// The partial case is pinned on the surfaces that can produce it, e.g.
// lib/invoices/__tests__/settle-invoice-payment.test.ts.
it('rejects a quote (offert) before creating a payment journal entry', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
enqueue({
data: {
id: 'q-1',
invoice_number: 'OF-001',
status: 'sent',
total: 2500,
remaining_amount: 0,
paid_amount: null,
credited_invoice_id: null,
document_type: 'quote',
quote_status: 'accepted',
journal_entry_id: null,
customer: { name: 'Test AB' },
},
error: null,
}) // invoice fetch
enqueue({ data: null, error: null }) // dispatcher pending_operations update
const op = makePendingOp({ params: { invoice_id: 'q-1', payment_date: '2026-06-30' } })
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
expect(result.status).toBe('rejected')
expect(result.http_status).toBe(409)
expect(mockCreatePaymentEntry).not.toHaveBeenCalled()
expect(mockCreateCashEntry).not.toHaveBeenCalled()
})
})
+110 -97
View File
@@ -60,6 +60,7 @@ import {
} from '@/lib/supplier-invoices/lifecycle'
import { coerceDimensionsBag } from '@/lib/bookkeeping/dimension-resolver'
import { ACCOUNT_NUMBER_RE } from '@/lib/invariants/account-number'
import { ISO_DATE_RE } from '@/lib/invariants/iso-date'
import { isSlpPensionAccount } from '@/lib/bookkeeping/slp-lines'
import { cancelOrphanedPaymentEntry } from '@/lib/bookkeeping/cancel-orphaned-entry'
import { runWithActor } from '@/lib/bookkeeping/actor-context-node'
@@ -139,6 +140,7 @@ import {
resolveInvoiceEmailRecipients,
} from '@/lib/invoices/email-recipients'
import { ensureInvoiceNumber } from '@/lib/invoices/ensure-invoice-number'
import { convertToInvoice } from '@/lib/invoices/convert-to-invoice'
import { invoicePdfFilename } from '@/lib/invoices/pdf-filename'
import {
recordManualInvoiceDelivery,
@@ -1867,6 +1869,14 @@ async function commitCreateInvoice(
params: Record<string, unknown>
): Promise<ExecutorResult> {
const customerId = params.customer_id as string
// Offert (quote): own OF-series allocated at insert, never an F-number,
// never books, never emits invoice.created. Only 'quote' is honoured here;
// anything else stays an ordinary invoice (staged params are caller JSON).
const isQuote = params.document_type === 'quote'
const validUntil = typeof params.valid_until === 'string' ? params.valid_until : null
if (isQuote && (!validUntil || !ISO_DATE_RE.test(validUntil))) {
return { error: 'Giltig till (valid_until) krävs för en offert.', status: 400 }
}
const items = params.items as Array<{
description: string; quantity: number; unit: string; unit_price: number; vat_rate?: number
discount_percent?: number | null
@@ -2046,15 +2056,41 @@ async function commitCreateInvoice(
}
})()
// Quotes are numbered at insert from their own OF-series (see
// generate_quote_number); ensureInvoiceNumber must never run on one.
let quoteNumber: string | null = null
if (isQuote) {
const { data: allocated, error: quoteNumberError } = await supabase.rpc('generate_quote_number', {
p_company_id: companyId,
})
if (quoteNumberError || !allocated) {
return {
error:
getErrorEntry('INVOICE_CREATE_NUMBER_ASSIGN_FAILED')?.message_sv ??
'Offertnumret kunde inte tilldelas.',
errorCode: 'INVOICE_CREATE_NUMBER_ASSIGN_FAILED',
status: 500,
}
}
quoteNumber = allocated as string
}
const { data: invoice, error: invoiceError } = await supabase
.from('invoices')
.insert({
user_id: userId,
company_id: companyId,
customer_id: customerId,
invoice_number: null,
invoice_number: quoteNumber,
invoice_date: invoiceDate,
due_date: (params.due_date as string) || null,
// A quote has no payment due date: due_date mirrors valid_until
// (build-invoice-write parity).
due_date: isQuote ? validUntil : (params.due_date as string) || null,
// Explicit keys, not a conditional spread: the phantom-column guard
// only reads literal payloads. NULLs on non-quotes satisfy the pairing CHECK.
document_type: isQuote ? 'quote' : 'invoice',
valid_until: isQuote ? validUntil : null,
quote_status: isQuote ? 'open' : null,
currency,
exchange_rate: exchangeRate,
exchange_rate_date: exchangeRateDate,
@@ -2064,10 +2100,12 @@ async function commitCreateInvoice(
vat_amount_sek: vatAmountSek,
total,
total_sek: totalSek,
// Fresh unpaid receivable: remaining_amount is what every payment
// A quote is an offer, not a claim: nothing is owed on it (parity with
// build-invoice-write). Otherwise a fresh unpaid receivable:
// remaining_amount is what every payment
// surface reads as the open balance; leaving the NOT NULL DEFAULT 0
// made every agent-created invoice look settled.
remaining_amount: total,
remaining_amount: isQuote ? 0 : total,
paid_amount: 0,
vat_treatment: notVatRegistered ? 'exempt' : vatRules.treatment,
vat_rate: isMixedRate ? null : (uniqueRates.values().next().value ?? vatRules.rate),
@@ -2138,20 +2176,24 @@ async function commitCreateInvoice(
return { error: itemsError.message, status: 500 }
}
const { data: completeInvoice } = await supabase
.from('invoices')
.select('*, customer:customers(*), items:invoice_items(*)')
.eq('id', invoice.id)
.single()
// invoice.created is for real invoices only: a quote is an offer, not a
// claim, and has no downstream consumer obligation (v1 route parity).
if (!isQuote) {
const { data: completeInvoice } = await supabase
.from('invoices')
.select('*, customer:customers(*), items:invoice_items(*)')
.eq('id', invoice.id)
.single()
if (completeInvoice) {
await eventBus.emit({
type: 'invoice.created',
payload: { invoice: completeInvoice as Invoice, userId, companyId },
})
if (completeInvoice) {
await eventBus.emit({
type: 'invoice.created',
payload: { invoice: completeInvoice as Invoice, userId, companyId },
})
}
}
return { data: { invoice_id: invoice.id, invoice_number: invoice.invoice_number } }
return { data: { invoice_id: invoice.id, invoice_number: invoice.invoice_number ?? quoteNumber } }
}
/**
@@ -2197,7 +2239,7 @@ async function commitUpdateInvoice(
const { data: existing, error: fetchError } = await supabase
.from('invoices')
.select(
'id, status, invoice_number, journal_entry_id, is_self_billed, credited_invoice_id, customer_id, document_type, invoice_date, due_date, delivery_date, currency, your_reference, our_reference, invoice_marking, notes, payment_link_url, payment_link_auto, ore_rounding, default_dimensions, deduction_personnummer_encrypted, deduction_personnummer_last4',
'id, status, invoice_number, journal_entry_id, is_self_billed, credited_invoice_id, quote_status, customer_id, document_type, invoice_date, due_date, delivery_date, currency, your_reference, our_reference, invoice_marking, notes, payment_link_url, payment_link_auto, ore_rounding, default_dimensions, deduction_personnummer_encrypted, deduction_personnummer_last4',
)
.eq('id', invoiceId)
.eq('company_id', companyId)
@@ -2389,6 +2431,14 @@ async function commitMarkInvoicePaid(
if (invoice.credited_invoice_id) {
return { error: 'Kreditfakturor kan inte markeras som betalda.', status: 409 }
}
// Parity with the dashboard mark-paid route: a quote is an offer, not a
// claim. Proformas keep working (a prepayment record with no verifikat).
if (invoice.document_type === 'quote') {
const entry = getErrorEntry('INVOICE_QUOTE_NOT_PAYABLE')
// 409 like the credit-note guard above: the dispatcher records it as
// rejected (a state refusal), not failed (an execution error).
return { error: entry?.message_sv ?? 'Only invoices can be paid.', errorCode: 'INVOICE_QUOTE_NOT_PAYABLE', status: 409 }
}
if (invoice.status !== 'sent' && invoice.status !== 'overdue') {
return { error: 'Invoice can only be marked as paid when status is "sent" or "overdue"', status: 409 }
}
@@ -3049,6 +3099,18 @@ async function commitMatchTransactionInvoice(
if (invoice.credited_invoice_id) {
return { error: 'Kreditfakturor kan inte registreras som betalda.', status: 409 }
}
// Parity with the dashboard match route (MATCH_INVOICE_NOT_INVOICE_TYPE):
// proformas, delivery notes and quotes carry no receivable to settle.
if (invoice.document_type && invoice.document_type !== 'invoice') {
const entry = getErrorEntry('MATCH_INVOICE_NOT_INVOICE_TYPE')
return {
error: entry?.message_sv ?? 'Only invoices can be matched to a transaction.',
errorCode: 'MATCH_INVOICE_NOT_INVOICE_TYPE',
// 409 like the credit-note guard above: the dispatcher records it as
// rejected (a state refusal), not failed (an execution error).
status: 409,
}
}
if (!['sent', 'overdue', 'partially_paid'].includes(invoice.status)) {
return { error: 'Invoice is not in a matchable state', status: 409 }
}
@@ -3407,6 +3469,24 @@ async function commitLinkInvoiceVoucher(
return { error: 'invoice_id and journal_entry_id are required', status: 400 }
}
// Only a faktura carries a receivable to settle (parity with the dashboard
// link route and the match executors); the RPC validates status only.
const { data: docRow } = await supabase
.from('invoices')
.select('document_type')
.eq('id', invoiceId)
.eq('company_id', companyId)
.maybeSingle()
const docType = (docRow as { document_type?: string | null } | null)?.document_type
if (docType && docType !== 'invoice') {
const entry = getErrorEntry('MATCH_INVOICE_NOT_INVOICE_TYPE')
return {
error: entry?.message_sv ?? 'Only invoices can be linked to a payment voucher.',
errorCode: 'MATCH_INVOICE_NOT_INVOICE_TYPE',
status: 409,
}
}
const outcome = await linkInvoiceToVoucher(supabase, userId, companyId, {
invoiceId,
journalEntryId,
@@ -5012,91 +5092,24 @@ async function commitConvertInvoice(
const id = params.invoice_id as string
if (!id) return { error: 'invoice_id is required', status: 400 }
const { data: proforma, error: proformaError } = await supabase
.from('invoices').select('*, items:invoice_items(*)').eq('id', id).eq('company_id', companyId).single()
// Shared with POST /api/invoices/[id]/convert: proforma or quote to
// invoice, F-number allocated last, source cancelled (proforma) or
// accepted (quote).
const result = await convertToInvoice({ supabase, userId, companyId, sourceId: id })
if (proformaError || !proforma) return { error: 'Proformafakturan hittades inte', status: 404 }
if (proforma.document_type !== 'proforma') {
return { error: 'Endast proformafakturor kan konverteras', status: 400 }
}
if (proforma.status === 'cancelled') {
return { error: 'Denna proformafaktura har redan makuleras', status: 409 }
}
const { data: invoice, error: invoiceError } = await supabase
.from('invoices')
.insert({
user_id: userId,
company_id: companyId,
customer_id: proforma.customer_id,
invoice_number: null,
invoice_date: new Date().toISOString().split('T')[0],
due_date: proforma.due_date,
currency: proforma.currency,
exchange_rate: proforma.exchange_rate,
exchange_rate_date: proforma.exchange_rate_date,
subtotal: proforma.subtotal,
subtotal_sek: proforma.subtotal_sek,
vat_amount: proforma.vat_amount,
vat_amount_sek: proforma.vat_amount_sek,
total: proforma.total,
remaining_amount: proforma.total,
paid_amount: 0,
total_sek: proforma.total_sek,
vat_treatment: proforma.vat_treatment,
vat_rate: proforma.vat_rate,
moms_ruta: proforma.moms_ruta,
reverse_charge_text: proforma.reverse_charge_text,
your_reference: proforma.your_reference,
our_reference: proforma.our_reference,
notes: proforma.notes,
document_type: 'invoice',
converted_from_id: id,
// Dimensions PR7: the converted invoice books with the proforma's bag.
default_dimensions: proforma.default_dimensions ?? {},
})
.select()
.single()
if (invoiceError) return { error: invoiceError.message, status: 500 }
try {
await ensureInvoiceNumber(supabase, companyId, invoice as Invoice)
} catch (err) {
await supabase.from('invoices').delete().eq('id', invoice.id)
return { error: err instanceof Error ? err.message : 'Failed to assign invoice number', status: 500 }
}
const items = (proforma.items ?? []).map((item: Record<string, unknown>) => ({
invoice_id: invoice.id,
sort_order: item.sort_order,
line_type: item.line_type ?? 'product',
description: item.description,
quantity: item.quantity,
unit: item.unit,
unit_price: item.unit_price,
line_total: item.line_total,
// Preserve per-line VAT and any article/revenue-account override from the
// proforma so the converted invoice books exactly as the proforma showed
// (mixed rates + per-article accounts both rely on these per-line fields).
vat_rate: item.vat_rate ?? 0,
vat_amount: item.vat_amount ?? 0,
revenue_account: item.revenue_account ?? null,
article_id: item.article_id ?? null,
dimensions: item.dimensions ?? {},
}))
if (items.length > 0) {
const { error: itemsError } = await supabase.from('invoice_items').insert(items)
if (itemsError) {
await supabase.from('invoices').delete().eq('id', invoice.id)
return { error: itemsError.message, status: 500 }
if (!result.ok) {
if (result.code === 'INVOICE_CONVERT_FAILED') {
return { error: result.cause.message, errorCode: result.code, status: 500 }
}
const entry = getErrorEntry(result.code)
return {
error: entry?.message_sv ?? result.code,
errorCode: result.code,
status: entry?.httpStatus ?? 400,
}
}
await supabase.from('invoices').update({ status: 'cancelled' }).eq('id', id)
return { data: { invoice_id: invoice.id, invoice_number: invoice.invoice_number } }
return { data: { invoice_id: result.invoice.id, invoice_number: result.invoice.invoice_number } }
}
async function commitImportSie(
+14 -1
View File
@@ -6,11 +6,15 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'
let resultIdx: number
let results: Array<{ data?: unknown; error?: unknown }>
let calls: Array<{ method: string; args: unknown[] }>
function makeBuilder() {
const b: Record<string, unknown> = {}
for (const m of ['select', 'eq', 'in', 'lte', 'order', 'range']) {
b[m] = vi.fn().mockReturnValue(b)
b[m] = vi.fn().mockImplementation((...args: unknown[]) => {
calls.push({ method: m, args })
return b
})
}
b.single = vi.fn().mockImplementation(async () => results[resultIdx++] ?? { data: null, error: null })
b.then = (resolve: (v: unknown) => void) => resolve(results[resultIdx++] ?? { data: null, error: null })
@@ -32,10 +36,19 @@ beforeEach(() => {
vi.clearAllMocks()
resultIdx = 0
results = []
calls = []
supabase = makeClient()
})
describe('generateARLedger', () => {
it('only reads fakturor: proformas, delivery notes and quotes are not receivables', async () => {
results = [{ data: [], error: null }]
await generateARLedger(supabase, 'company-1')
expect(calls).toContainEqual({ method: 'eq', args: ['document_type', 'invoice'] })
})
it('returns empty report when no invoices found', async () => {
results = [
{ data: [], error: null },
@@ -41,6 +41,14 @@ beforeEach(() => {
})
describe('generateARReconciliation', () => {
it('only reads fakturor: proformas, delivery notes and quotes are not receivables', async () => {
results = [{ data: [], error: null }, { data: [], error: null }]
await generateARReconciliation(supabase, 'company-1', 'period-1')
expect(calls).toContainEqual({ method: 'eq', args: ['document_type', 'invoice'] })
})
it('returns reconciled when AR ledger matches account 1510', async () => {
results = [
// 0: invoices
+2
View File
@@ -83,6 +83,8 @@ export async function generateARLedger(
.from('invoices')
.select('*, customer:customers(id, name)')
.eq('company_id', companyId)
// Proformas, delivery notes and quotes are never receivables.
.eq('document_type', 'invoice')
query = isHistorical
? query.in('status', ['sent', 'overdue', 'credited', 'paid']).lte('invoice_date', asOfDate!)
: query.in('status', ['sent', 'overdue', 'credited'])
+2
View File
@@ -55,6 +55,8 @@ export async function generateARReconciliation(
.from('invoices')
.select('id, total, paid_amount, currency, exchange_rate')
.eq('company_id', companyId)
// Proformas, delivery notes and quotes are never receivables.
.eq('document_type', 'invoice')
.in('status', ['sent', 'overdue'])
.order('id', { ascending: true })
.range(from, to)
+36 -1
View File
@@ -3584,6 +3584,7 @@
"chip_invoice_date": "Invoice date {date}",
"chip_due_days": "Due in {days} days ({date})",
"chip_due_date": "Due {date}",
"chip_valid_until": "Valid until {date}",
"chip_received": "Received {date}",
"chip_delivery": "Delivery date {date}",
"chip_your_reference": "Your reference: {reference}",
@@ -3596,6 +3597,7 @@
"next_step_customer": "choose a customer",
"next_step_invoice_date": "set the invoice date",
"next_step_due_date": "set the due date",
"next_step_valid_until": "set the valid-until date",
"next_step_add_row": "add an invoice line",
"next_step_row_incomplete": "complete line {index}",
"next_step_payment_link": "check the payment link",
@@ -3630,6 +3632,7 @@
"save_article_failed": "Could not save the article",
"title_invoice": "New invoice",
"title_proforma": "New proforma invoice",
"title_quote": "New quote",
"title_delivery_note": "New delivery note",
"title_copy": "Copy invoice",
"copy_notice": "The content was copied from {number}. A new draft is created with a new date and invoice number. Check the customer, references, and ROT/RUT details before continuing.",
@@ -3668,10 +3671,12 @@
"document_type_label": "Document type",
"doctype_invoice": "Invoice",
"doctype_proforma": "Proforma invoice",
"doctype_quote": "Quote",
"doctype_delivery_note": "Delivery note",
"currency_label": "Currency",
"invoice_date_label": "Invoice date",
"due_date_label": "Due date",
"valid_until_label": "Valid until",
"delivery_date_label": "Delivery date",
"your_reference_label": "Your reference",
"your_reference_placeholder": "Customer contact person",
@@ -3705,13 +3710,16 @@
"viewer_disabled_tooltip": "You only have read-only access to this company",
"review_dialog_title_invoice": "Review invoice",
"review_dialog_title_proforma": "Review proforma invoice",
"review_dialog_title_quote": "Review quote",
"review_dialog_title_delivery_note": "Review delivery note",
"review_warning_invoice_accrual": "An invoice is created and assigned an invoice number. When it is sent or marked as sent, a journal entry (verifikat) is posted; it cannot be edited directly but can be corrected via a credit note.",
"review_warning_invoice_cash": "An invoice is created and assigned an invoice number. The journal entry (verifikat) is only posted when the invoice is marked as paid (cash method).",
"review_warning_proforma": "A proforma invoice is created. No journal entry is posted. The proforma can later be converted to a real invoice.",
"review_warning_quote": "A quote is created and given a quote number. No journal entry is posted. Once the customer accepts, you can create an invoice from the quote.",
"review_warning_delivery_note": "A delivery note is created without prices. No journal entry is posted.",
"confirm_create_invoice": "Confirm & create",
"confirm_create_proforma": "Create proforma invoice",
"confirm_create_quote": "Create quote",
"confirm_create_delivery_note": "Create delivery note",
"preview_pdf": "Preview PDF",
"preview_pdf_generating": "Generating...",
@@ -3721,6 +3729,7 @@
"doc_created_description": "{docLabel} {number} has been created",
"doc_label_invoice": "Invoice",
"doc_label_proforma": "Proforma invoice",
"doc_label_quote": "Quote",
"doc_label_delivery_note": "Delivery note",
"send_now_dialog_title": "Send the invoice now?",
"send_now_dialog_description": "The invoice was created. Do you want to send it to {email} now?",
@@ -3736,6 +3745,7 @@
"validation_customer_required": "Select a customer",
"validation_invoice_date_required": "Invoice date required",
"validation_due_date_required": "Due date required",
"validation_valid_until_required": "Valid until is required",
"validation_min_one_row": "At least one row required",
"article_search_empty": "No article matches your search",
"deduction_menu_label": "Tax reduction",
@@ -3779,6 +3789,7 @@
"customer_type_non_eu_business": "Outside EU",
"invoice_date": "Invoice date",
"due_date": "Due date",
"valid_until": "Valid until",
"th_description": "Description",
"th_quantity": "Quantity",
"th_unit": "Unit",
@@ -3836,6 +3847,7 @@
"title_credit_note": "Credit note {number}",
"title_credit_draft": "Credit note draft",
"title_proforma": "Proforma {number}",
"title_quote": "Quote {number}",
"title_delivery_note": "Delivery note {number}",
"title_self_billed": "Self-billing invoice {number}",
"title_draft": "Invoice draft",
@@ -3855,6 +3867,7 @@
"order_created_toast_title": "Sales order created",
"order_created_toast_description": "Sales order {number} was created from the proforma",
"def_converted_from": "Converted from",
"def_invoiced": "Invoiced",
"payment_section": "Payment",
"payment_confirmation_label": "Payment confirmation",
"payment_confirmation_download_short": "Download",
@@ -3908,6 +3921,19 @@
"delivery_message_id_label": "Message ID",
"delivery_error_label": "Error",
"convert_to_invoice": "Convert to invoice",
"quote_accept": "Accept",
"quote_decline": "Decline",
"quote_create_invoice": "Create invoice",
"quote_expired_accept_title": "The quote has expired. Accept anyway?",
"quote_expired_accept_description": "Valid until {date} has passed. The quote is marked as accepted and can then be invoiced.",
"quote_expired_convert_title": "The quote has expired. Create an invoice anyway?",
"quote_expired_convert_description": "Valid until {date} has passed. An invoice is created from the quote, which is marked as accepted.",
"quote_decision_toast_title": "Quote updated",
"quote_decision_toast_open": "The quote is open again.",
"quote_decision_toast_accepted": "The quote is marked as accepted.",
"quote_decision_toast_declined": "The quote is marked as declined.",
"quote_decision_failed_title": "Could not update the quote",
"quote_decision_failed_fallback": "The decision could not be saved",
"send_via_email": "Send via email",
"send_via_email_and_book": "Send via email and post",
"mark_sent_manually": "Sent manually",
@@ -3957,6 +3983,7 @@
"invoice_number_label": "Invoice number",
"invoice_date_label": "Invoice date",
"due_date_label": "Due date",
"valid_until_label": "Valid until",
"currency_label": "Currency",
"vat_treatment_label": "VAT treatment",
"your_reference_label": "Your reference",
@@ -4041,7 +4068,7 @@
"cancel_posted_error": "Posted invoices cannot be cancelled. Create a credit note instead.",
"mark_sent_failed_fallback": "Could not mark as sent",
"convert_failed_title": "Conversion failed",
"convert_failed_fallback": "Could not convert the proforma invoice",
"convert_failed_fallback": "Could not create the invoice from the document",
"converted_toast_title": "Converted to invoice",
"converted_toast_description": "Invoice {number} has been created",
"pdf_generate_failed": "Could not generate PDF",
@@ -6369,6 +6396,7 @@
"tab_paid": "Paid",
"tab_draft": "Draft",
"tab_proforma": "Proforma",
"tab_quote": "Quotes",
"tab_delivery_note": "Delivery note",
"tab_credit": "Credit",
"tab_cancelled": "Cancelled",
@@ -6378,6 +6406,11 @@
"no_category_description": "Try switching tabs to see more invoices.",
"badge_credit": "Credit",
"badge_proforma": "Proforma",
"badge_quote": "Quote",
"quote_status_open": "Open",
"quote_status_accepted": "Accepted",
"quote_status_declined": "Declined",
"quote_status_expired": "Expired",
"badge_delivery_note": "Delivery note",
"badge_self_billed": "Self-billing",
"status_draft": "Draft",
@@ -6404,6 +6437,8 @@
"create_recurring_desc": "Created automatically, e.g. every month",
"create_self": "Self-billing invoice",
"create_self_desc": "Register a self-billing invoice from your customer",
"create_quote": "New quote",
"create_quote_desc": "Create a quote that can become an invoice",
"bulkbar_selected": "{count, plural, one {invoice selected} other {invoices selected}}",
"bulk_select_all": "Select all ({count})",
"bulk_clear": "Clear",
+36 -1
View File
@@ -3584,6 +3584,7 @@
"chip_invoice_date": "Fakturadatum {date}",
"chip_due_days": "Förfaller {days} dagar ({date})",
"chip_due_date": "Förfaller {date}",
"chip_valid_until": "Giltig till {date}",
"chip_received": "Mottagen {date}",
"chip_delivery": "Leveransdatum {date}",
"chip_your_reference": "Er referens: {reference}",
@@ -3596,6 +3597,7 @@
"next_step_customer": "välj kund",
"next_step_invoice_date": "ange fakturadatum",
"next_step_due_date": "ange förfallodatum",
"next_step_valid_until": "ange giltig till",
"next_step_add_row": "lägg till en fakturarad",
"next_step_row_incomplete": "komplettera rad {index}",
"next_step_payment_link": "kontrollera betalningslänken",
@@ -3630,6 +3632,7 @@
"save_article_failed": "Kunde inte spara artikeln",
"title_invoice": "Ny faktura",
"title_proforma": "Ny proformafaktura",
"title_quote": "Ny offert",
"title_delivery_note": "Ny följesedel",
"title_copy": "Kopiera faktura",
"copy_notice": "Innehållet har kopierats från {number}. Ett nytt utkast skapas med nytt datum och nytt fakturanummer. Kontrollera kund, referenser och ROT/RUT-uppgifter innan du fortsätter.",
@@ -3668,10 +3671,12 @@
"document_type_label": "Dokumenttyp",
"doctype_invoice": "Faktura",
"doctype_proforma": "Proformafaktura",
"doctype_quote": "Offert",
"doctype_delivery_note": "Följesedel",
"currency_label": "Valuta",
"invoice_date_label": "Fakturadatum",
"due_date_label": "Förfallodatum",
"valid_until_label": "Giltig till",
"delivery_date_label": "Leveransdatum",
"your_reference_label": "Er referens",
"your_reference_placeholder": "Kontaktperson hos kund",
@@ -3705,13 +3710,16 @@
"viewer_disabled_tooltip": "Du har endast läsbehörighet i detta företag",
"review_dialog_title_invoice": "Granska faktura",
"review_dialog_title_proforma": "Granska proformafaktura",
"review_dialog_title_quote": "Granska offert",
"review_dialog_title_delivery_note": "Granska följesedel",
"review_warning_invoice_accrual": "En faktura skapas och tilldelas ett fakturanummer. När den skickas eller markeras som skickad bokförs en verifikation, som inte kan redigeras direkt men kan korrigeras via en kreditnota.",
"review_warning_invoice_cash": "En faktura skapas och tilldelas ett fakturanummer. Verifikationen bokförs först när fakturan markeras som betald (kontantmetoden).",
"review_warning_proforma": "En proformafaktura skapas. Ingen verifikation bokförs. Proforman kan senare konverteras till en riktig faktura.",
"review_warning_quote": "En offert skapas och får ett offertnummer. Ingen verifikation bokförs. När kunden accepterar kan du skapa en faktura från offerten.",
"review_warning_delivery_note": "En följesedel skapas utan priser. Ingen verifikation bokförs.",
"confirm_create_invoice": "Bekräfta & skapa",
"confirm_create_proforma": "Skapa proformafaktura",
"confirm_create_quote": "Skapa offert",
"confirm_create_delivery_note": "Skapa följesedel",
"preview_pdf": "Förhandsgranska PDF",
"preview_pdf_generating": "Genererar...",
@@ -3721,6 +3729,7 @@
"doc_created_description": "{docLabel} {number} har skapats",
"doc_label_invoice": "Faktura",
"doc_label_proforma": "Proformafaktura",
"doc_label_quote": "Offert",
"doc_label_delivery_note": "Följesedel",
"send_now_dialog_title": "Skicka fakturan nu?",
"send_now_dialog_description": "Fakturan skapades. Vill du skicka den till {email} direkt?",
@@ -3736,6 +3745,7 @@
"validation_customer_required": "Välj en kund",
"validation_invoice_date_required": "Fakturadatum krävs",
"validation_due_date_required": "Förfallodatum krävs",
"validation_valid_until_required": "Giltig till krävs",
"validation_min_one_row": "Minst en rad krävs",
"article_search_empty": "Ingen artikel matchar sökningen",
"deduction_menu_label": "Skattereduktion",
@@ -3779,6 +3789,7 @@
"customer_type_non_eu_business": "Utanför EU",
"invoice_date": "Fakturadatum",
"due_date": "Förfallodatum",
"valid_until": "Giltig till",
"th_description": "Beskrivning",
"th_quantity": "Antal",
"th_unit": "Enhet",
@@ -3836,6 +3847,7 @@
"title_credit_note": "Kreditfaktura {number}",
"title_credit_draft": "Kreditutkast",
"title_proforma": "Proforma {number}",
"title_quote": "Offert {number}",
"title_delivery_note": "Följesedel {number}",
"title_self_billed": "Självfaktura {number}",
"title_draft": "Fakturautkast",
@@ -3855,6 +3867,7 @@
"order_created_toast_title": "Kundorder skapad",
"order_created_toast_description": "Kundorder {number} har skapats från proforman",
"def_converted_from": "Konverterad från",
"def_invoiced": "Fakturerad",
"payment_section": "Betalning",
"payment_confirmation_label": "Betalningsbekräftelse",
"payment_confirmation_download_short": "Ladda ner",
@@ -3908,6 +3921,19 @@
"delivery_message_id_label": "Meddelande-id",
"delivery_error_label": "Fel",
"convert_to_invoice": "Konvertera till faktura",
"quote_accept": "Acceptera",
"quote_decline": "Avböj",
"quote_create_invoice": "Skapa faktura",
"quote_expired_accept_title": "Offerten har gått ut. Acceptera ändå?",
"quote_expired_accept_description": "Giltig till {date} har passerat. Offerten markeras som accepterad och kan sedan faktureras.",
"quote_expired_convert_title": "Offerten har gått ut. Skapa faktura ändå?",
"quote_expired_convert_description": "Giltig till {date} har passerat. En faktura skapas från offerten, som markeras som accepterad.",
"quote_decision_toast_title": "Offerten uppdaterad",
"quote_decision_toast_open": "Offerten är öppen igen.",
"quote_decision_toast_accepted": "Offerten är markerad som accepterad.",
"quote_decision_toast_declined": "Offerten är markerad som avböjd.",
"quote_decision_failed_title": "Kunde inte uppdatera offerten",
"quote_decision_failed_fallback": "Beslutet kunde inte sparas",
"send_via_email": "Skicka via e-post",
"send_via_email_and_book": "Skicka via e-post och bokför",
"mark_sent_manually": "Skickad manuellt",
@@ -3957,6 +3983,7 @@
"invoice_number_label": "Fakturanummer",
"invoice_date_label": "Fakturadatum",
"due_date_label": "Förfallodatum",
"valid_until_label": "Giltig till",
"currency_label": "Valuta",
"vat_treatment_label": "Momsbehandling",
"your_reference_label": "Er referens",
@@ -4041,7 +4068,7 @@
"cancel_posted_error": "Bokförda fakturor kan inte makuleras. Skapa en kreditfaktura istället.",
"mark_sent_failed_fallback": "Kunde inte markera som skickad",
"convert_failed_title": "Konvertering misslyckades",
"convert_failed_fallback": "Kunde inte konvertera proformafakturan",
"convert_failed_fallback": "Kunde inte skapa fakturan från dokumentet",
"converted_toast_title": "Konverterad till faktura",
"converted_toast_description": "Faktura {number} har skapats",
"pdf_generate_failed": "Kunde inte generera PDF",
@@ -6369,6 +6396,7 @@
"tab_paid": "Betalda",
"tab_draft": "Utkast",
"tab_proforma": "Proforma",
"tab_quote": "Offerter",
"tab_delivery_note": "Följesedel",
"tab_credit": "Kredit",
"tab_cancelled": "Makulerade",
@@ -6378,6 +6406,11 @@
"no_category_description": "Prova att byta flik för att se fler fakturor.",
"badge_credit": "Kredit",
"badge_proforma": "Proforma",
"badge_quote": "Offert",
"quote_status_open": "Öppen",
"quote_status_accepted": "Accepterad",
"quote_status_declined": "Avböjd",
"quote_status_expired": "Utgången",
"badge_delivery_note": "Följesedel",
"badge_self_billed": "Självfaktura",
"status_draft": "Utkast",
@@ -6404,6 +6437,8 @@
"create_recurring_desc": "Skapas automatiskt, till exempel varje månad",
"create_self": "Självfaktura",
"create_self_desc": "Registrera en självfaktura från din kund",
"create_quote": "Ny offert",
"create_quote_desc": "Skapa en offert som kan bli en faktura",
"bulkbar_selected": "{count, plural, one {faktura vald} other {fakturor valda}}",
"bulk_select_all": "Markera alla ({count})",
"bulk_clear": "Rensa",
+4 -3
View File
@@ -8,7 +8,7 @@ description: >-
transactions and reconciliation, payroll (lön), VAT/moms and financial
reports, SIE import/export, documents, webhooks. Covers auth with
gnubok_sk_ API keys, conventions (dry-run, idempotency, cursor
pagination, scopes), and all 145 endpoints.
pagination, scopes), and all 146 endpoints.
---
<!-- GENERATED FILE, do not edit. Source: lib/api/v1 registry + scripts/api-skill/overlays. Regenerate with `npm run apiskill:generate`. -->
@@ -142,7 +142,7 @@ call can undo it, e.g. invoice credit).
## Endpoint index
API version `2026-05-12`, 145 operations. Paths are shown without
API version `2026-05-12`, 146 operations. Paths are shown without
their `/api/v1` prefix (full base URL: `https://app.gnubok.se/api/v1`).
### Core (5)
@@ -192,7 +192,7 @@ POST /companies/{companyId}/fiscal-periods/{id}/year-end : Execute year-end clos
GET /companies/{companyId}/skatteverket/vat-declarations : Read a filed momsdeklaration (submitted and/or decided) from Skatteverket [scope:compliance:read risk:low idempotent]
```
### Invoices (AR) (11)
### Invoices (AR) (12)
Full detail: [references/invoices.md](references/invoices.md)
@@ -206,6 +206,7 @@ POST /companies/{companyId}/invoices/{id}/credit : Issue a credit note (kreditfa
POST /companies/{companyId}/invoices/{id}/mark-paid : Record a payment against an invoice [scope:invoices:write risk:medium idempotent dry-run]
POST /companies/{companyId}/invoices/{id}/mark-sent : Transition a draft invoice to sent (without emailing) [scope:invoices:write risk:medium idempotent dry-run]
GET /companies/{companyId}/invoices/{id}/pdf : Download the rendered invoice PDF [scope:invoices:read risk:low idempotent]
POST /companies/{companyId}/invoices/{id}/quote-status : Record the customer decision on a quote (offert) [scope:invoices:write risk:low idempotent dry-run reversible]
POST /companies/{companyId}/invoices/{id}/send : Send a draft invoice to the customer by email [scope:invoices:write risk:high idempotent dry-run]
POST /companies/{companyId}/invoices/bulk-create : Create up to 50 draft invoices in one call (partial-success) [scope:invoices:write risk:medium idempotent dry-run reversible]
```
+94 -3
View File
@@ -23,6 +23,7 @@ Cursor-paginated invoice list ordered by created_at DESC, id ASC (newest-registe
- Credit notes appear with status=credited and a credited_invoice_id field on the detail endpoint.
- Ordering is by created_at (registration time), not invoice_date. Backdated invoices therefore appear where they were created, not where their date falls: filter on ?date_from / ?date_to when you care about the business date.
- Cursor pagination: pass ?cursor=<next_cursor> from the previous response. A stale or tampered cursor is ignored and the first page is returned again.
- Quotes (document_type=quote, offert) carry valid_until and quote_status (open | accepted | declined | expired). "expired" is derived: an open quote past valid_until; filter with ?quote_status=expired. Quotes never book and are never payable: convert an accepted quote to an invoice in the dashboard first.
| Parameter | In | Type | Required | Notes |
|---|---|---|---|---|
@@ -31,7 +32,7 @@ Cursor-paginated invoice list ordered by created_at DESC, id ASC (newest-registe
Response `200`:
```ts
{
data: { id: string, invoice_number: string, customer_id: string, customer_name: string, invoice_date: string, due_date: string, status: "draft" | "sent" | "paid" | "partially_paid" | "overdue" | "cancelled" | "credited", document_type: "invoice" | "proforma" | "delivery_note", currency: string, subtotal: number, vat_amount: number, total: number, remaining_amount: number, paid_at: string, created_at: string }[],
data: { id: string, invoice_number: string, customer_id: string, customer_name: string, invoice_date: string, due_date: string, status: "draft" | "sent" | "paid" | "partially_paid" | "overdue" | "cancelled" | "credited", document_type: "invoice" | "proforma" | "delivery_note" | "quote", valid_until: string, quote_status: "open" | "accepted" | "declined" | "expired", currency: string, subtotal: number, vat_amount: number, total: number, remaining_amount: number, paid_at: string, created_at: string }[],
meta: {
request_id: string,
api_version: string,
@@ -90,6 +91,7 @@ Creates an invoice in draft status. The F-series invoice_number is allocated ato
- Non-SEK currencies require an active Riksbanken exchange-rate fetch. Failure is non-fatal: the invoice is created with null SEK fields and the agent can recompute later.
- invoice_number is null on creation. The number is allocated atomically when the invoice transitions out of draft. Counting on a specific number at create time is a bug.
- document_type='delivery_note' produces no VAT and a different number sequence (D-series). Most use cases want the default document_type='invoice'.
- document_type='quote' (offert) requires valid_until (YYYY-MM-DD, the expiry; due_date mirrors it). A quote is numbered OF-nnn from its own series at create, starts as quote_status='open', never posts a journal entry, never emits invoice.created and cannot be sent-and-booked or paid: record the customer decision with POST /invoices/{id}/quote-status and convert an accepted quote to an invoice in the dashboard.
- is_self_billed=true registers a self-billing invoice your CUSTOMER issued on your behalf (a sale for you). It is booked immediately (not a draft, no F-number), so external_invoice_number and received_date are required and it is NOT dry-run-free of side effects on the live call. Do NOT set it for a normal invoice you issue yourself.
- Project/cost-center tagging: pass default_dimensions ({"6":"P001"} = project, {"1":"KS01"} = kostnadsställe) for the whole invoice and/or items[].dimensions per line (per-line wins per key). Tags are stored on the draft and applied to the journal entry lines when the invoice is sent. When the company has the dimension registry enabled, unknown or archived codes are rejected at :send with 400 DIMENSION_VALIDATION_FAILED — list valid codes via GET /dimensions.
- ROT/RUT: set items[].deduction_type ("rot"|"rut") on labor lines plus labor_hours and work_type (Skatteverket arbetstypskod). The invoice must carry deduction_personnummer AND housing info: deduction_housing_designation (fastighetsbeteckning) for småhus, or deduction_apartment_number + deduction_brf_org_number for bostadsrätt. deduction_amount is computed server-side and cannot be set by the caller; the response exposes deduction_total and remaining_amount = total - deduction_total (Skatteverket pays the rest via 1513). Validation failures return 400 INVOICE_CREATE_ROT_RUT_VALIDATION.
@@ -107,7 +109,8 @@ Request body:
due_date: string,
delivery_date?: string | "",
currency: "SEK" | "EUR" | "USD" | "GBP" | "NOK" | "DKK",
document_type?: "invoice" | "proforma" | "delivery_note",
document_type?: "invoice" | "proforma" | "delivery_note" | "quote",
valid_until?: string | "",
your_reference?: string,
our_reference?: string,
invoice_marking?: string,
@@ -158,6 +161,8 @@ Response `200`:
due_date: string,
status: string,
document_type: string,
valid_until?: string,
quote_status?: string,
currency: string,
subtotal: number,
vat_amount: number,
@@ -230,6 +235,9 @@ Response `200`:
due_date: string,
status: string,
document_type: string,
valid_until?: string,
quote_status?: string,
quote_decided_at?: string,
currency: string,
total: number,
remaining_amount: number,
@@ -329,6 +337,9 @@ Response `200`:
due_date: string,
status: string,
document_type: string,
valid_until?: string,
quote_status?: string,
quote_decided_at?: string,
currency: string,
total: number,
remaining_amount: number,
@@ -661,6 +672,85 @@ Response `200` (`application/pdf`).
---
### `POST /api/v1/companies/{companyId}/invoices/{id}/quote-status`
**Record the customer decision on a quote (offert).**
`scope:invoices:write · risk:low · idempotent · dry-run · reversible`
Sets quote_status on a quote (document_type=quote) to open, accepted or declined. Any transition between the three is allowed until the quote has been converted to an invoice; after that the decision is locked (409 INVOICE_QUOTE_ALREADY_INVOICED). "expired" is never written: it is derived from valid_until and reported as effective_quote_status. Accepting a quote past valid_until is allowed (pass valid_until here to extend an expired quote so it reads as open again). No journal entry, number allocation or event is involved. Idempotent and dry-runnable.
**Use when:** The customer answered a quote and you want Accounted to reflect it (accepted / declined), or you want to reopen a decision that was recorded by mistake.
**Do not use for:** Creating the invoice from an accepted quote (convert it in the dashboard; the conversion marks the quote accepted itself). Regular invoices, proformas or delivery notes: they return 400 INVOICE_NOT_A_QUOTE.
**Pitfalls:**
- Only document_type=quote rows are decidable; anything else returns 400 INVOICE_NOT_A_QUOTE.
- A cancelled quote returns 400 INVOICE_QUOTE_NOT_DECIDABLE.
- Once an active invoice exists with converted_from_id = this quote, the decision is locked: 409 INVOICE_QUOTE_ALREADY_INVOICED. Cancelling that invoice frees the quote again.
- Setting status=open clears quote_decided_at; accepted/declined stamp it with the request time.
- Idempotency-Key is mandatory. A retried call with the same key replays the cached response.
| Parameter | In | Type | Required | Notes |
|---|---|---|---|---|
| `companyId` | path | `string` | yes | |
| `id` | path | `string` | yes | |
Request body:
```ts
{ status: "open" | "accepted" | "declined", valid_until?: string | "" }
```
Example request:
```json
{
"status": "accepted"
}
```
Response `200`:
```ts
{
data: {
id: string,
invoice_number: string,
document_type: "quote",
status: string,
quote_status: "open" | "accepted" | "declined",
effective_quote_status: "open" | "accepted" | "declined" | "expired",
quote_decided_at: string,
valid_until: string
},
meta: {
request_id: string,
api_version: string,
next_cursor?: string,
audit?: { voucher_number?: string, voucher_url?: string, audit_trail_url?: string, immutable_at?: string },
partial_expansions?: string[]
}
}
```
Example response `200`:
```json
{
"data": {
"id": "0e9c…",
"invoice_number": "OF-007",
"document_type": "quote",
"status": "sent",
"quote_status": "accepted",
"effective_quote_status": "accepted",
"quote_decided_at": "2026-09-02T09:14:33Z",
"valid_until": "2026-09-30"
},
"meta": {
"request_id": "req_…",
"api_version": "2026-05-12"
}
}
```
---
### `POST /api/v1/companies/{companyId}/invoices/{id}/send`
**Send a draft invoice to the customer by email.**
@@ -769,6 +859,7 @@ Bulk-creation endpoint. Each invoice in the request array is validated and inser
- Passing all_or_nothing: true returns 501 NOT_IMPLEMENTED. Today only partial-success batches exist; omit the flag (or pass false).
- Each per-item invoice still goes through the same VAT-rule validation as POST /invoices. A mismatched per-item vat_rate produces a per-item failure, not a whole-batch failure.
- Currency conversion is best-effort PER ITEM. A failed Riksbanken fetch leaves that item's SEK columns null but does NOT fail the item.
- Quotes (document_type: quote) are refused per item as VALIDATION_ERROR: a quote carries its own OF-number, valid_until and quote_status. Create quotes one at a time with POST /invoices.
| Parameter | In | Type | Required | Notes |
|---|---|---|---|---|
@@ -777,7 +868,7 @@ Bulk-creation endpoint. Each invoice in the request array is validated and inser
Request body:
```ts
{
invoices: { customer_id: string, invoice_date: string, due_date: string, delivery_date?: string | "", currency: "SEK" | "EUR" | "USD" | "GBP" | "NOK" | "DKK", document_type?: "invoice" | "proforma" | "delivery_note", your_reference?: string, our_reference?: string, invoice_marking?: string, notes?: string, payment_link_url?: string | "", payment_link_auto?: boolean, deduction_personnummer?: string, deduction_housing_designation?: string, deduction_apartment_number?: string, deduction_brf_org_number?: string | "", save_as_draft?: boolean, ore_rounding?: boolean, default_dimensions?: Record<string, string>, is_self_billed?: boolean, external_invoice_number?: string | "", self_billing_agreement_ref?: string, received_date?: string | "", items: { line_type?: "product" | "text", description: string, quantity: number, unit: string, unit_price: number, discount_percent?: number, vat_rate?: number, article_id?: string, revenue_account?: string, sales_order_item_id?: string, deduction_type?: "rot" | "rut", labor_hours?: number, work_type?: string, housing_designation?: string, apartment_number?: string, brf_org_number?: string | "", accrual_period_start?: string, accrual_period_end?: string, accrual_balance_account?: string, dimensions?: Record<string, string> }[] }[],
invoices: { customer_id: string, invoice_date: string, due_date: string, delivery_date?: string | "", currency: "SEK" | "EUR" | "USD" | "GBP" | "NOK" | "DKK", document_type?: "invoice" | "proforma" | "delivery_note" | "quote", valid_until?: string | "", your_reference?: string, our_reference?: string, invoice_marking?: string, notes?: string, payment_link_url?: string | "", payment_link_auto?: boolean, deduction_personnummer?: string, deduction_housing_designation?: string, deduction_apartment_number?: string, deduction_brf_org_number?: string | "", save_as_draft?: boolean, ore_rounding?: boolean, default_dimensions?: Record<string, string>, is_self_billed?: boolean, external_invoice_number?: string | "", self_billing_agreement_ref?: string, received_date?: string | "", items: { line_type?: "product" | "text", description: string, quantity: number, unit: string, unit_price: number, discount_percent?: number, vat_rate?: number, article_id?: string, revenue_account?: string, sales_order_item_id?: string, deduction_type?: "rot" | "rut", labor_hours?: number, work_type?: string, housing_designation?: string, apartment_number?: string, brf_org_number?: string | "", accrual_period_start?: string, accrual_period_end?: string, accrual_balance_account?: string, dimensions?: Record<string, string> }[] }[],
all_or_nothing?: boolean
}
```
@@ -0,0 +1,124 @@
-- Offert (quote) as a fourth invoice document type.
--
-- A quote is a customer-facing price proposal. It never books a journal
-- entry, is never a payment request and consumes nothing from the F-series
-- (ML 17 kap 24 § only governs fakturor). It therefore gets:
--
-- * its own number series, company_settings.next_quote_number, allocated
-- at insert by generate_quote_number() exactly like delivery notes.
-- Proformas share the F-series counter (PF-042 leaves F-042 unused);
-- quotes are far more numerous, so that pattern is deliberately NOT
-- copied here.
-- * valid_until: the date the offer expires. Stored next to due_date
-- (NOT NULL on invoices) which mirrors it for quotes so date-ordered
-- readers keep working; valid_until is the authoritative column.
-- * quote_status: open / accepted / declined. "expired" is DERIVED
-- (quote_status = 'open' AND valid_until < today) so no cron is needed
-- and extending valid_until un-expires the quote for free.
-- * quote_decided_at: when accepted/declined was set.
--
-- The lifecycle column `status` keeps meaning draft / sent / cancelled for
-- quotes; the decision lives in quote_status only. Converting an accepted
-- quote creates a fresh invoice with converted_from_id = quote.id; the quote
-- itself is NOT cancelled (unlike proforma conversion).
ALTER TABLE public.invoices
DROP CONSTRAINT IF EXISTS invoices_document_type_check;
ALTER TABLE public.invoices
ADD CONSTRAINT invoices_document_type_check
CHECK (document_type IN ('invoice', 'proforma', 'delivery_note', 'quote'));
ALTER TABLE public.invoices
ADD COLUMN IF NOT EXISTS valid_until date,
ADD COLUMN IF NOT EXISTS quote_status text,
ADD COLUMN IF NOT EXISTS quote_decided_at timestamptz;
ALTER TABLE public.invoices
DROP CONSTRAINT IF EXISTS invoices_quote_status_check;
ALTER TABLE public.invoices
ADD CONSTRAINT invoices_quote_status_check
CHECK (quote_status IS NULL OR quote_status IN ('open', 'accepted', 'declined'));
-- Exactly quotes carry a quote_status (and vice versa): a quote can never
-- lose its decision column, and no other document type can grow one.
ALTER TABLE public.invoices
DROP CONSTRAINT IF EXISTS invoices_quote_columns_check;
ALTER TABLE public.invoices
ADD CONSTRAINT invoices_quote_columns_check
CHECK ((document_type = 'quote') = (quote_status IS NOT NULL));
CREATE INDEX IF NOT EXISTS idx_invoices_quote_open
ON public.invoices (company_id, valid_until)
WHERE document_type = 'quote' AND quote_status = 'open';
-- next_quote_number already exists on prod and staging as a nullable
-- integer DEFAULT 1 with no migration file behind it (an orphan from an
-- earlier hand-applied change). Adopt it here: add where missing, then
-- backfill and tighten so the RPC below can never read NULL.
ALTER TABLE public.company_settings
ADD COLUMN IF NOT EXISTS next_quote_number integer DEFAULT 1;
UPDATE public.company_settings
SET next_quote_number = 1
WHERE next_quote_number IS NULL;
ALTER TABLE public.company_settings
ALTER COLUMN next_quote_number SET DEFAULT 1,
ALTER COLUMN next_quote_number SET NOT NULL;
-- Same authorization gate as generate_delivery_note_number
-- (20260901100000_revoke_anon_execute_on_definer_writes.sql): a member of
-- the company, or a trusted service_role / direct connection with no JWT.
CREATE OR REPLACE FUNCTION public.generate_quote_number(p_company_id uuid)
RETURNS text
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = ''
AS $function$
DECLARE
v_number integer;
v_trusted boolean;
BEGIN
v_trusted := auth.uid() IS NULL
AND (
COALESCE(auth.role(), '') = 'service_role'
OR (auth.role() IS NULL AND session_user <> 'authenticator')
);
IF NOT v_trusted AND NOT EXISTS (
SELECT 1 FROM public.company_members
WHERE user_id = auth.uid() AND company_id = p_company_id
) THEN
RAISE EXCEPTION 'unauthorized: caller is not a member of company %', p_company_id
USING ERRCODE = '42501';
END IF;
UPDATE public.company_settings
SET next_quote_number = next_quote_number + 1,
updated_at = now()
WHERE company_id = p_company_id
RETURNING next_quote_number - 1
INTO v_number;
IF v_number IS NULL THEN
RAISE EXCEPTION 'Company settings not found for company %', p_company_id;
END IF;
RETURN 'OF-' || LPAD(v_number::text, GREATEST(3, length(v_number::text)), '0');
END;
$function$;
REVOKE EXECUTE ON FUNCTION public.generate_quote_number(uuid) FROM PUBLIC, anon;
GRANT EXECUTE ON FUNCTION public.generate_quote_number(uuid)
TO authenticated, service_role;
COMMENT ON FUNCTION public.generate_quote_number(uuid) IS
'Allocates the next offert (quote) number OF-nnn for p_company_id from company_settings.next_quote_number. Requires the caller to be a member of the company; only a service_role or direct database connection with no auth.uid() is trusted without one. Raises 42501 otherwise. Not callable by anon.';
COMMENT ON COLUMN public.invoices.valid_until IS
'Quotes only: the date the offer expires. Expiry is derived (quote_status = open AND valid_until < today); nothing writes an expired state.';
COMMENT ON COLUMN public.invoices.quote_status IS
'Quotes only: open, accepted or declined. NULL for every other document type (enforced by invoices_quote_columns_check).';
NOTIFY pgrst, 'reload schema';
@@ -0,0 +1,60 @@
-- Quote guards that belong in the database, not in every writer.
--
-- 1. quote_status defaults to 'open' for a new quote. invoices_quote_columns_check
-- (20260902220000) pairs quote_status with document_type = 'quote', so every
-- creator (dashboard, v1, bulk-create, MCP) had to remember the column, and
-- every UPDATE that spread the shared write-builder's fields re-wrote 'open'
-- over a recorded accept/decline. The decision now lives only where it is
-- made: creators leave the column NULL and the trigger opens the quote;
-- updaters never carry it.
-- valid_until likewise falls back to due_date on insert, and both dates are
-- kept equal on quotes (the NOT NULL due_date mirrors the authoritative
-- valid_until so date-ordered readers keep working).
--
-- 2. One live invoice per source: a partial unique index on converted_from_id
-- over non-cancelled rows. convertToInvoice() checks before inserting, but
-- two concurrent conversions could both pass the check and mint two
-- F-numbers for one quote (or proforma). A cancelled converted invoice
-- still frees the source for another attempt. Prod had zero duplicates
-- when this was written (read-only check 2026-09-02).
CREATE OR REPLACE FUNCTION public.invoices_quote_defaults()
RETURNS trigger
LANGUAGE plpgsql
SET search_path = ''
AS $function$
BEGIN
IF NEW.document_type = 'quote' THEN
IF NEW.quote_status IS NULL THEN
NEW.quote_status := 'open';
END IF;
IF NEW.valid_until IS NULL THEN
NEW.valid_until := NEW.due_date;
END IF;
-- Keep the mirror honest whichever side a writer touched.
IF TG_OP = 'UPDATE' AND NEW.valid_until IS DISTINCT FROM OLD.valid_until THEN
NEW.due_date := NEW.valid_until;
ELSIF TG_OP = 'UPDATE' AND NEW.due_date IS DISTINCT FROM OLD.due_date THEN
NEW.valid_until := NEW.due_date;
ELSE
NEW.due_date := NEW.valid_until;
END IF;
END IF;
RETURN NEW;
END;
$function$;
DROP TRIGGER IF EXISTS trg_invoices_quote_defaults ON public.invoices;
CREATE TRIGGER trg_invoices_quote_defaults
BEFORE INSERT OR UPDATE ON public.invoices
FOR EACH ROW
EXECUTE FUNCTION public.invoices_quote_defaults();
CREATE UNIQUE INDEX IF NOT EXISTS idx_invoices_one_live_conversion
ON public.invoices (converted_from_id)
WHERE converted_from_id IS NOT NULL AND status <> 'cancelled';
COMMENT ON FUNCTION public.invoices_quote_defaults() IS
'Quotes: quote_status defaults to open, valid_until defaults to due_date, and the two dates stay equal (valid_until wins when both change). No-op for every other document type.';
NOTIFY pgrst, 'reload schema';
@@ -0,0 +1,105 @@
-- Two quote guards that only the database can hold atomically.
--
-- 1. A quote that has a live converted invoice is locked in 'accepted'. The
-- three decision writers (dashboard route, v1 route, MCP tool) check for
-- a converted invoice and compare-and-set on the decision they read, but
-- a conversion that lands between their read and their write leaves the
-- quote 'accepted' with a live F-invoice, and a writer that read
-- 'accepted' before the conversion can still move it to open or declined.
-- A BEFORE UPDATE trigger closes that window: any attempt to leave
-- 'accepted' while an active invoice points back via converted_from_id
-- raises with the registry code in the message, which the routes map to
-- 409 INVOICE_QUOTE_ALREADY_INVOICED.
--
-- 2. generate_quote_number() gated on membership only, like its siblings.
-- Every caller is a write route, but a viewer holding only the session
-- token could call the SECURITY DEFINER RPC through PostgREST and burn
-- OF-numbers. The gate now also requires a non-viewer role (same test as
-- 20260902093000_security_role_gates_membership_and_posting_integrity).
CREATE OR REPLACE FUNCTION public.invoices_quote_decision_guard()
RETURNS trigger
LANGUAGE plpgsql
SET search_path = ''
AS $function$
BEGIN
IF OLD.document_type = 'quote'
AND OLD.quote_status = 'accepted'
AND NEW.quote_status IS DISTINCT FROM 'accepted'
AND EXISTS (
SELECT 1 FROM public.invoices i
WHERE i.converted_from_id = OLD.id
AND i.status <> 'cancelled'
)
THEN
RAISE EXCEPTION 'INVOICE_QUOTE_ALREADY_INVOICED: quote % has a live converted invoice', OLD.id
USING ERRCODE = 'P0001';
END IF;
RETURN NEW;
END;
$function$;
DROP TRIGGER IF EXISTS trg_invoices_quote_decision_guard ON public.invoices;
CREATE TRIGGER trg_invoices_quote_decision_guard
BEFORE UPDATE OF quote_status ON public.invoices
FOR EACH ROW
EXECUTE FUNCTION public.invoices_quote_decision_guard();
CREATE OR REPLACE FUNCTION public.generate_quote_number(p_company_id uuid)
RETURNS text
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = ''
AS $function$
DECLARE
v_number integer;
v_trusted boolean;
BEGIN
v_trusted := auth.uid() IS NULL
AND (
COALESCE(auth.role(), '') = 'service_role'
OR (auth.role() IS NULL AND session_user <> 'authenticator')
);
-- Member AND allowed to write: a viewer must not consume the series.
IF NOT v_trusted AND NOT EXISTS (
SELECT 1 FROM public.company_members cm
WHERE cm.user_id = auth.uid()
AND cm.company_id = p_company_id
AND cm.role <> 'viewer'
) THEN
RAISE EXCEPTION 'unauthorized: caller may not allocate quote numbers for company %', p_company_id
USING ERRCODE = '42501';
END IF;
UPDATE public.company_settings
SET next_quote_number = next_quote_number + 1,
updated_at = now()
WHERE company_id = p_company_id
RETURNING next_quote_number - 1
INTO v_number;
IF v_number IS NULL THEN
RAISE EXCEPTION 'Company settings not found for company %', p_company_id;
END IF;
RETURN 'OF-' || LPAD(v_number::text, GREATEST(3, length(v_number::text)), '0');
END;
$function$;
COMMENT ON FUNCTION public.generate_quote_number(uuid) IS
'Allocates the next offert (quote) number OF-nnn for p_company_id. Requires a non-viewer membership of the company; only a service_role or direct database connection with no auth.uid() is trusted without one. Raises 42501 otherwise. Not callable by anon.';
-- 3. Date invariants the trigger maintains, pinned as CHECKs so no direct
-- write can leave a quote without an expiry or with a diverged mirror,
-- and no other document type can carry an expiry.
ALTER TABLE public.invoices
DROP CONSTRAINT IF EXISTS invoices_quote_dates_check;
ALTER TABLE public.invoices
ADD CONSTRAINT invoices_quote_dates_check
CHECK (
(document_type = 'quote' AND valid_until IS NOT NULL AND due_date = valid_until)
OR (document_type <> 'quote' AND valid_until IS NULL)
);
NOTIFY pgrst, 'reload schema';

Some files were not shown because too many files have changed in this diff Show More