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
+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