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
@@ -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])