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
+38 -3
View File
@@ -30,7 +30,7 @@ import {
orgNumberHoldsPersonalNumber,
personalNumberDigits,
} from '@/lib/customers/personal-number-shape'
import type { AuditAction, Currency } from '@/types'
import type { AuditAction, Currency, InvoiceDocumentType } from '@/types'
import type { BankFileFormatId } from '@/lib/import/bank-file/types'
// ============================================================
@@ -222,9 +222,10 @@ export const SupplierTypeSchema = z.enum([
])
export const InvoiceDocumentTypeSchema = z.enum([
'invoice', 'proforma', 'delivery_note',
'invoice', 'proforma', 'delivery_note', 'quote',
])
export const VatTreatmentSchema = z.enum([
'standard_25', 'reduced_12', 'reduced_6', 'reverse_charge', 'export', 'exempt',
])
@@ -488,6 +489,18 @@ export const CreateInvoiceItemSchema = z
const optionalIsoDate = isoDate.or(z.literal('')).transform(v => v || undefined).optional()
export const QuoteStatusSchema = z.enum(['open', 'accepted', 'declined'])
// POST /api/invoices/[id]/quote-status. Any transition between the three
// decisions is allowed until the quote has been converted to an invoice.
export const SetQuoteStatusSchema = z.object({
status: QuoteStatusSchema,
// Optional new expiry. This is how an expired (open, past valid_until)
// quote is reopened: sent quotes are not draft-editable, so the date
// travels with the decision. The DB trigger mirrors it into due_date.
valid_until: optionalIsoDate,
})
/**
* ROT/RUT claim completeness (HUSFL: art av arbete + antal arbetstimmar) at
* the invoice level, where document_type is known: only real invoices book a
@@ -523,6 +536,9 @@ const CreateInvoiceBaseSchema = z.object({
delivery_date: optionalIsoDate,
currency: CurrencySchema,
document_type: InvoiceDocumentTypeSchema.optional(),
// Quotes only: the date the offer expires ("Giltig till"). Required when
// document_type is 'quote' (refineQuoteFields); ignored otherwise.
valid_until: optionalIsoDate,
your_reference: z.string().optional(),
our_reference: z.string().optional(),
// Fakturamärkning: buyer-required marking (kostnadsställe/projekt/PO),
@@ -614,7 +630,25 @@ const CreateInvoiceBaseSchema = z.object({
items: z.array(CreateInvoiceItemSchema).min(1, 'At least one item is required'),
})
export const CreateInvoiceSchema = CreateInvoiceBaseSchema.superRefine(refineRotRutLineCompleteness)
// A quote must say how long it is valid: valid_until is the one header
// field the document type adds, and the PDF, the list and the derived
// "expired" state all key off it.
function refineQuoteFields(
data: { document_type?: InvoiceDocumentType; valid_until?: string },
ctx: z.RefinementCtx,
) {
if (data.document_type === 'quote' && !data.valid_until) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['valid_until'],
message: 'Giltig till (valid_until) krävs för en offert.',
})
}
}
export const CreateInvoiceSchema = CreateInvoiceBaseSchema
.superRefine(refineRotRutLineCompleteness)
.superRefine(refineQuoteFields)
// Update (edit) an existing DRAFT invoice in place. Same shape as create minus
// `save_as_draft`: editing never (re)creates a draft or allocates a number, it
@@ -623,6 +657,7 @@ export const CreateInvoiceSchema = CreateInvoiceBaseSchema.superRefine(refineRot
export const UpdateInvoiceSchema = CreateInvoiceBaseSchema
.omit({ save_as_draft: true })
.superRefine(refineRotRutLineCompleteness)
.superRefine(refineQuoteFields)
export const CreateCreditNoteSchema = z.object({
credited_invoice_id: uuid,
@@ -1,6 +1,6 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`v1 spec snapshot > matches the recorded endpoint count > endpoint-count 1`] = `145`;
exports[`v1 spec snapshot > matches the recorded endpoint count > endpoint-count 1`] = `146`;
exports[`v1 spec snapshot > matches the recorded endpoint key set > endpoint-keys 1`] = `
[
@@ -106,6 +106,7 @@ exports[`v1 spec snapshot > matches the recorded endpoint key set > endpoint-key
"POST /api/v1/companies/:companyId/invoices/:id/credit",
"POST /api/v1/companies/:companyId/invoices/:id/mark-paid",
"POST /api/v1/companies/:companyId/invoices/:id/mark-sent",
"POST /api/v1/companies/:companyId/invoices/:id/quote-status",
"POST /api/v1/companies/:companyId/invoices/:id/send",
"POST /api/v1/companies/:companyId/invoices/bulk-create",
"POST /api/v1/companies/:companyId/journal-entries",
+1 -1
View File
@@ -12,7 +12,7 @@
*/
export const INVOICE_FULL_COLUMNS =
'id, invoice_number, customer_id, invoice_date, due_date, delivery_date, status, currency, exchange_rate, exchange_rate_date, subtotal, subtotal_sek, vat_amount, vat_amount_sek, total, total_sek, ore_rounding, vat_treatment, vat_rate, moms_ruta, your_reference, our_reference, invoice_marking, notes, payment_link_url, stripe_payment_link_id, payment_link_auto, reverse_charge_text, credited_invoice_id, document_type, converted_from_id, paid_at, paid_amount, remaining_amount, default_dimensions, deduction_total, deduction_personnummer_last4, created_at, updated_at'
'id, invoice_number, customer_id, invoice_date, due_date, delivery_date, status, currency, exchange_rate, exchange_rate_date, subtotal, subtotal_sek, vat_amount, vat_amount_sek, total, total_sek, ore_rounding, vat_treatment, vat_rate, moms_ruta, your_reference, our_reference, invoice_marking, notes, payment_link_url, stripe_payment_link_id, payment_link_auto, reverse_charge_text, credited_invoice_id, document_type, converted_from_id, valid_until, quote_status, quote_decided_at, paid_at, paid_amount, remaining_amount, default_dimensions, deduction_total, deduction_personnummer_last4, created_at, updated_at'
/**
* Projection for the v1 PDF download route. Narrower than INVOICE_FULL_COLUMNS
+1
View File
@@ -52,6 +52,7 @@ import '@/app/api/v1/companies/[companyId]/invoices/[id]/mark-sent/route'
import '@/app/api/v1/companies/[companyId]/invoices/[id]/mark-paid/route'
import '@/app/api/v1/companies/[companyId]/invoices/[id]/credit/route'
import '@/app/api/v1/companies/[companyId]/invoices/[id]/send/route'
import '@/app/api/v1/companies/[companyId]/invoices/[id]/quote-status/route'
import '@/app/api/v1/companies/[companyId]/invoices/bulk-create/route'
// Phase 2 PR-B-3: invoice PDF + customer bulk-create.
import '@/app/api/v1/companies/[companyId]/invoices/[id]/pdf/route'
+1
View File
@@ -375,6 +375,7 @@ export const TOOL_SCOPE_MAP: Record<string, ApiKeyScope> = {
gnubok_link_supplier_invoice_to_voucher: 'suppliers:write',
// Invoice conversion + crediting
gnubok_convert_invoice: 'invoices:write',
gnubok_set_quote_status: 'invoices:write',
gnubok_credit_invoice: 'invoices:write',
// Phase 4: arbitrary-line bookkeeping primitives (high-risk, always staged)
gnubok_create_voucher: 'bookkeeping:write',
+1
View File
@@ -75,6 +75,7 @@ export const V1_ENDPOINT_SCOPES: Record<string, ApiKeyScope> = {
'POST /api/v1/companies/:companyId/invoices/:id/mark-paid': 'invoices:write',
'POST /api/v1/companies/:companyId/invoices/:id/credit': 'invoices:write',
'POST /api/v1/companies/:companyId/invoices/:id/send': 'invoices:write',
'POST /api/v1/companies/:companyId/invoices/:id/quote-status': 'invoices:write',
'POST /api/v1/companies/:companyId/invoices/bulk-create': 'invoices:write',
// Phase 2 PR-B-3: invoice PDF + customer bulk-create.
'GET /api/v1/companies/:companyId/invoices/:id/pdf': 'invoices:read',
@@ -66,6 +66,20 @@ describe('detectPeriodisering', () => {
expect(result[0].suggested_deferred_account).toBeNull()
})
it('only reads fakturor: proformas, delivery notes and quotes are never booked, so nothing to periodise', async () => {
mock.enqueue({
data: { id: 'period-1', period_start: '2025-01-01', period_end: '2025-12-31' },
error: null,
})
mock.enqueue({ data: [], error: null }) // accrual_schedules
mock.enqueue({ data: [], error: null }) // invoices
mock.enqueue({ data: [], error: null }) // supplier_invoices
await detectPeriodisering(mock.supabase as never, 'company-1', 'period-1')
expect(mock.findCalls('invoices', 'eq')).toContainEqual(['document_type', 'invoice'])
})
it('detects a customer invoice with a service window in its notes', async () => {
mock.enqueue({
data: { id: 'period-1', period_start: '2025-01-01', period_end: '2025-12-31' },
+3 -1
View File
@@ -276,11 +276,13 @@ export async function detectPeriodisering(
// Customer invoices: only "real" ones (sent/paid). Drafts and overdue
// get skipped: drafts haven't moved through the engine, overdue is just a
// status label that overlaps with sent here.
// status label that overlaps with sent here. Proformas, delivery notes and
// quotes are never booked, so there is no revenue to periodise.
const { data: invoiceRows } = await supabase
.from('invoices')
.select('id, invoice_number, invoice_date, subtotal, currency, subtotal_sek, notes, customers(name), invoice_items(description)')
.eq('company_id', companyId)
.eq('document_type', 'invoice')
.gte('invoice_date', periodStart)
.lte('invoice_date', periodEnd)
.in('status', ['sent', 'partially_paid', 'paid', 'overdue'])
@@ -261,6 +261,30 @@ describe('currency-revaluation', () => {
expect(result).toHaveLength(0)
})
it('never revalues a quote or proforma: they sit on no 1510 balance', async () => {
const invoice = makeInvoice({ id: 'inv-1', status: 'sent', currency: 'EUR', exchange_rate: 11.5 })
const quote = makeInvoice({
id: 'quote-1',
status: 'sent',
currency: 'EUR',
exchange_rate: 11.5,
document_type: 'quote',
})
const proforma = makeInvoice({
id: 'proforma-1',
status: 'sent',
currency: 'EUR',
exchange_rate: 11.5,
document_type: 'proforma',
})
const supabase = createMockSupabase({ invoices: [invoice, quote, proforma] })
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const result = await getOpenForeignCurrencyReceivables(supabase as any, 'company-1')
expect(result.map((i) => i.id)).toEqual(['inv-1'])
})
it('includes partially_paid invoices: the unpaid remainder is still an open FX item', async () => {
// payment-sync.ts moves a customer invoice to 'partially_paid' on a
// partial settlement. Omitting the status made these receivables
+3
View File
@@ -179,6 +179,9 @@ export async function getOpenForeignCurrencyReceivables(
.from('invoices')
.select('*')
.eq('company_id', companyId)
// Proformas, delivery notes and quotes are never receivables: they
// sit on no 1510 balance, so there is nothing to revalue.
.eq('document_type', 'invoice')
.neq('currency', 'SEK')
.in(
'status',
@@ -179,6 +179,7 @@ function fxInvoice(overrides: Row = {}): Row {
return {
id: `inv-${Math.random().toString(36).slice(2, 10)}`,
company_id: 'company-1',
document_type: 'invoice',
status: 'sent',
currency: 'EUR',
exchange_rate: 11.2,
@@ -914,6 +915,21 @@ describe('validateYearEndReadiness: open FX items at balansdagen (ÅRL 4 kap. 13
expect(result.warnings.filter((w: string) => w.includes('valuta'))).toHaveLength(0)
})
it('ignores sent EUR quotes and proformas: they are not receivables and sit on no 1510 balance', async () => {
const supabase = makeFilteringClient(
fxBaseTables({
invoices: [
fxInvoice({ id: 'quote-1', document_type: 'quote' }),
fxInvoice({ id: 'proforma-1', document_type: 'proforma', exchange_rate: null }),
],
})
)
const result = await validateYearEndReadiness(supabase as never, 'company-1', 'user-1', 'fp-1')
expect(result.warnings.filter((w: string) => w.includes('valuta'))).toHaveLength(0)
})
it('counts open FX payables as well as receivables', async () => {
const supabase = makeFilteringClient(
fxBaseTables({
+3
View File
@@ -1039,6 +1039,9 @@ async function countOpenFxItemsAtBalansdagen(
.from('invoices')
.select(columns)
.eq('company_id', companyId)
// Proformas, delivery notes and quotes are never receivables (kept in
// lockstep with getOpenForeignCurrencyReceivables).
.eq('document_type', 'invoice')
.neq('currency', 'SEK')
// 'partially_paid' is a live status for customer invoices (payment-sync
// sets it on partial settlements); the unpaid remainder was open on
@@ -359,6 +359,119 @@ describe('invoice email templates', () => {
expect(generateInvoiceEmailSubject({ invoice: deliveryNote, customer: svCustomer, company: fullOverrides }))
.toBe('F\u00f6ljesedel 1045 fr\u00e5n Acme AB')
})
it('ignores overrides on quotes', () => {
const quote = makeInvoice({
invoice_number: 'OF-001',
document_type: 'quote',
valid_until: '2026-10-02',
quote_status: 'open',
})
const html = generateInvoiceEmailHtml({ invoice: quote, customer: svCustomer, company: fullOverrides })
expect(html).toContain('Bifogat hittar du v\u00e5r offert')
expect(html).not.toContain('H\u00e4r kommer m\u00e5nadens faktura.')
expect(generateInvoiceEmailSubject({ invoice: quote, customer: svCustomer, company: fullOverrides }))
.toBe('Offert OF-001 fr\u00e5n Acme AB')
})
})
// A quote (offert) is not a payment request: the mail states the expiry
// instead of a due date and carries no payment details or pay-online CTA.
describe('quote (offert)', () => {
const quote = makeInvoice({
invoice_number: 'OF-001',
invoice_date: '2026-09-02',
due_date: '2026-10-02',
valid_until: '2026-10-02',
quote_status: 'open',
document_type: 'quote',
currency: 'SEK',
total: 12500,
payment_link_url: 'https://buy.stripe.com/test_quote',
})
const svCustomer = makeCustomer({ name: 'Erik Andersson', email: 'erik@example.se', language: 'sv' })
const enCustomer = makeCustomer({ name: 'Jane Doe', email: 'jane@example.com', language: 'en' })
it('uses the Swedish quote subject', () => {
expect(generateInvoiceEmailSubject({ invoice: quote, customer: svCustomer, company }))
.toBe('Offert OF-001 fr\u00e5n Acme AB')
})
it('uses the English quote subject', () => {
expect(generateInvoiceEmailSubject({ invoice: quote, customer: enCustomer, company }))
.toBe('Quote OF-001 from Acme AB')
})
it('sv HTML: attached quote, Giltig till, no payment section and no pay-online button', () => {
const html = generateInvoiceEmailHtml({ invoice: quote, customer: svCustomer, company })
expect(html).toContain('Offert fr\u00e5n Acme AB')
expect(html).toContain('Offertnummer:')
expect(html).toContain('Offertdatum:')
expect(html).toContain('Bifogat hittar du v\u00e5r offert. Offerten \u00e4r giltig till 2026-10-02.')
expect(html).toContain('Giltig till:')
expect(html).toContain('2026-10-02')
expect(html).not.toContain('F\u00f6rfallodatum:')
expect(html).not.toContain('Betalningsinformation')
expect(html).not.toContain('Betala online')
expect(html).not.toContain('buy.stripe.com')
expect(html).toContain('Har du fr\u00e5gor om offerten?')
expect(html).not.toContain('Har du fr\u00e5gor om fakturan?')
})
it('en HTML: attached quote, Valid until, no payment section and no pay-online button', () => {
const html = generateInvoiceEmailHtml({ invoice: quote, customer: enCustomer, company })
expect(html).toContain('Quote from Acme AB')
expect(html).toContain('Quote number:')
expect(html).toContain('Attached you will find our quote. The quote is valid until 2026-10-02.')
expect(html).toContain('Valid until:')
expect(html).not.toContain('Due date:')
expect(html).not.toContain('Payment information')
expect(html).not.toContain('Pay online')
expect(html).toContain('Questions about the quote?')
})
it('plain text mirrors the HTML in both languages', () => {
const sv = generateInvoiceEmailText({ invoice: quote, customer: svCustomer, company })
expect(sv).toContain('Offert fr\u00e5n Acme AB')
expect(sv).toContain('Offerten \u00e4r giltig till 2026-10-02.')
expect(sv).toContain('Giltig till: 2026-10-02')
expect(sv).not.toContain('F\u00f6rfallodatum:')
expect(sv).not.toContain('Betalningsinformation')
expect(sv).not.toContain('Betala online')
expect(sv).not.toContain('buy.stripe.com')
const en = generateInvoiceEmailText({ invoice: quote, customer: enCustomer, company })
expect(en).toContain('Quote from Acme AB')
expect(en).toContain('The quote is valid until 2026-10-02.')
expect(en).toContain('Valid until: 2026-10-02')
expect(en).not.toContain('Due date:')
expect(en).not.toContain('Payment information')
expect(en).not.toContain('Pay online')
})
it('falls back to due_date when valid_until is missing on an older row', () => {
const legacy = makeInvoice({ ...quote, valid_until: null })
const text = generateInvoiceEmailText({ invoice: legacy, customer: svCustomer, company })
expect(text).toContain('Giltig till: 2026-10-02')
})
// The grand total sits right above the "not a payment request" notice,
// so it must not be labelled as an amount due.
it('labels the total Summa / Total, never Att betala / Total due', () => {
const svHtml = generateInvoiceEmailHtml({ invoice: quote, customer: svCustomer, company })
const svText = generateInvoiceEmailText({ invoice: quote, customer: svCustomer, company })
expect(svHtml).toContain('Summa:')
expect(svHtml).not.toContain('Att betala')
expect(svText).toContain('Summa: 12 500,00 SEK')
expect(svText).not.toContain('Att betala')
const enHtml = generateInvoiceEmailHtml({ invoice: quote, customer: enCustomer, company })
const enText = generateInvoiceEmailText({ invoice: quote, customer: enCustomer, company })
expect(enHtml).toContain('Total:')
expect(enHtml).not.toContain('Total due')
expect(enText).toContain('Total: 12,500.00 SEK')
expect(enText).not.toContain('Total due')
})
})
describe('payment link (payment_link_url)', () => {
+48 -13
View File
@@ -15,16 +15,22 @@ const LABELS = {
docInvoice: 'Faktura',
docCreditNote: 'Kreditfaktura',
docProforma: 'Proformafaktura',
docQuote: 'Offert',
docDeliveryNote: 'Följesedel',
htmlLang: 'sv',
documentFrom: (doc: string, sender: string) => `${doc} från ${sender}`,
documentNumber: (doc: string) => `${doc}nummer:`,
documentDate: (doc: string) => `${doc}datum:`,
dueDate: 'Förfallodatum:',
validUntil: 'Giltig till:',
greeting: (firstName: string) => `Hej${firstName ? ` ${firstName}` : ''},`,
bodyCreditNote: 'Bifogat hittar du en kreditfaktura som korrigerar en tidigare faktura.',
bodyInvoice: 'Tack för ditt förtroende! Bifogat hittar du din faktura.',
bodyQuote: (validUntil: string) => `Tack för ditt intresse! Bifogat hittar du vår offert. Offerten är giltig till ${validUntil}.`,
questionsQuote: 'Har du frågor om offerten? Svara direkt på detta mejl så hjälper vi dig.',
toPay: 'Att betala:',
// A quote is not a payment request, so its grand total is a neutral sum.
totalQuote: 'Summa:',
payOnline: 'Betala online',
paymentHeading: 'Betalningsinformation',
bank: 'Bank:',
@@ -51,16 +57,21 @@ const LABELS = {
docInvoice: 'Invoice',
docCreditNote: 'Credit note',
docProforma: 'Proforma invoice',
docQuote: 'Quote',
docDeliveryNote: 'Delivery note',
htmlLang: 'en',
documentFrom: (doc: string, sender: string) => `${doc} from ${sender}`,
documentNumber: (doc: string) => `${doc} number:`,
documentDate: (doc: string) => `${doc} date:`,
dueDate: 'Due date:',
validUntil: 'Valid until:',
greeting: (firstName: string) => `Hi${firstName ? ` ${firstName}` : ''},`,
bodyCreditNote: 'Attached you will find a credit note that corrects an earlier invoice.',
bodyInvoice: 'Thank you for your business. Attached you will find your invoice.',
bodyQuote: (validUntil: string) => `Thank you for your interest. Attached you will find our quote. The quote is valid until ${validUntil}.`,
questionsQuote: 'Questions about the quote? Reply directly to this email and we will help you.',
toPay: 'Total due:',
totalQuote: 'Total:',
payOnline: 'Pay online',
paymentHeading: 'Payment information',
bank: 'Bank:',
@@ -122,7 +133,7 @@ function resolveLang(customer: Customer): EmailLang {
return customer.language === 'en' ? 'en' : 'sv'
}
// Custom texts apply ONLY to standard invoices. Credit notes, proforma and
// Custom texts apply ONLY to standard invoices. Credit notes, proforma, quotes and
// delivery notes always use the stock texts: a custom "Tack för ditt
// förtroende..." body or "Faktura..." subject would be wrong on those.
function isStandardInvoice(invoice: Invoice): boolean {
@@ -135,10 +146,18 @@ function getDocumentLabel(invoice: Invoice, lang: EmailLang): string {
if (invoice.credited_invoice_id) return L.docCreditNote
const docType = (invoice as Invoice & { document_type?: InvoiceDocumentType }).document_type || 'invoice'
if (docType === 'proforma') return L.docProforma
if (docType === 'quote') return L.docQuote
if (docType === 'delivery_note') return L.docDeliveryNote
return L.docInvoice
}
// A quote's expiry: valid_until is authoritative, due_date only mirrors it
// (the column is NOT NULL), so fall back to it for rows written before
// valid_until existed.
function quoteValidUntil(invoice: Invoice): string {
return formatDate(invoice.valid_until || invoice.due_date)
}
// Currency for the customer-facing total: explicit ISO code so a non-Swedish
// recipient reads "1 234,56 SEK" instead of the Swedish symbol "kr". Use the
// English locale for digit grouping when the email is in English so the comma
@@ -221,9 +240,17 @@ export function generateInvoiceEmailHtml(data: InvoiceEmailData): string {
const docType = (invoice as Invoice & { document_type?: InvoiceDocumentType }).document_type || 'invoice'
const isDeliveryNote = docType === 'delivery_note'
const isProforma = docType === 'proforma'
const hidePayment = isCreditNote || isDeliveryNote || isProforma
// A quote (offert) is never a payment request: no payment details, no
// pay-online button; its expiry replaces the due date.
const isQuote = docType === 'quote'
const hidePayment = isCreditNote || isDeliveryNote || isProforma || isQuote
const firstName = customer.name ? customer.name.split(' ')[0] : ''
const custom = resolveCustomTexts(data, lang)
const stockBody = isCreditNote
? L.bodyCreditNote
: isQuote
? L.bodyQuote(quoteValidUntil(invoice))
: L.bodyInvoice
// Primary color drives the heading accent and the highlighted total. The
// accent is sanitized to a strict hex pattern: anything else falls back
@@ -258,7 +285,7 @@ export function generateInvoiceEmailHtml(data: InvoiceEmailData): string {
${custom.greeting !== undefined ? userTextToHtml(custom.greeting) : L.greeting(firstName)}
</p>
<p style="margin: 0;">
${custom.body !== undefined ? userTextToHtml(custom.body) : (isCreditNote ? L.bodyCreditNote : L.bodyInvoice)}
${custom.body !== undefined ? userTextToHtml(custom.body) : stockBody}
</p>
</div>
@@ -274,16 +301,16 @@ export function generateInvoiceEmailHtml(data: InvoiceEmailData): string {
<td style="padding: 8px 0; text-align: right;">${formatDate(invoice.invoice_date)}</td>
</tr>
<tr>
<td style="padding: 8px 0; color: #666; font-size: 14px;">${L.dueDate}</td>
<td style="padding: 8px 0; text-align: right; font-weight: 500; color: ${isCreditNote ? '#333' : '#e11d48'};">
${formatDate(invoice.due_date)}
<td style="padding: 8px 0; color: #666; font-size: 14px;">${isQuote ? L.validUntil : L.dueDate}</td>
<td style="padding: 8px 0; text-align: right; font-weight: 500; color: ${isCreditNote || isQuote ? '#333' : '#e11d48'};">
${isQuote ? quoteValidUntil(invoice) : formatDate(invoice.due_date)}
</td>
</tr>
<tr>
<td colspan="2" style="padding: 15px 0 8px 0; border-top: 1px solid #e5e7eb;"></td>
</tr>
<tr>
<td style="padding: 8px 0; font-size: 18px; font-weight: 600;">${L.toPay}</td>
<td style="padding: 8px 0; font-size: 18px; font-weight: 600;">${isQuote ? L.totalQuote : L.toPay}</td>
<td style="padding: 8px 0; text-align: right; font-size: 18px; font-weight: 600; color: ${isCreditNote ? '#059669' : primaryColor};">
${formatCurrencyForCustomer(getAmountToPay(invoice, company).toPay, invoice.currency, lang)}
</td>
@@ -344,7 +371,7 @@ export function generateInvoiceEmailHtml(data: InvoiceEmailData): string {
<!-- Footer -->
<div style="margin-top: 30px; padding-top: 20px; border-top: 1px solid #e5e7eb;">
<p style="margin: 0 0 10px 0; color: #666; font-size: 14px;">
${L.questions}
${isQuote ? L.questionsQuote : L.questions}
</p>
<p style="margin: 0; color: #666; font-size: 14px;">
${custom.signoff !== undefined ? userTextToHtml(custom.signoff) : L.sincerely}<br>
@@ -378,23 +405,31 @@ export function generateInvoiceEmailText(data: InvoiceEmailData): string {
const docType = (invoice as Invoice & { document_type?: InvoiceDocumentType }).document_type || 'invoice'
const isDeliveryNote = docType === 'delivery_note'
const isProforma = docType === 'proforma'
const hidePayment = isCreditNote || isDeliveryNote || isProforma
const isQuote = docType === 'quote'
const hidePayment = isCreditNote || isDeliveryNote || isProforma || isQuote
const firstName = customer.name ? customer.name.split(' ')[0] : ''
const custom = resolveCustomTexts(data, lang)
const stockBody = isCreditNote
? L.bodyCreditNote
: isQuote
? L.bodyQuote(quoteValidUntil(invoice))
: L.bodyInvoice
let text = `${L.documentFrom(documentType, getCompanyDisplayName(company))}\n`
text += `${L.documentNumber(documentType)} ${invoice.invoice_number}\n\n`
text += `${custom.greeting ?? L.greeting(firstName)}\n\n`
text += `${custom.body ?? (isCreditNote ? L.bodyCreditNote : L.bodyInvoice)}\n\n`
text += `${custom.body ?? stockBody}\n\n`
text += `${L.documentSummary(documentType)}\n`
text += `---\n`
text += `${L.documentNumber(documentType)} ${invoice.invoice_number}\n`
text += `${L.documentDate(documentType)} ${formatDate(invoice.invoice_date)}\n`
text += `${L.dueDate} ${formatDate(invoice.due_date)}\n`
text += `${L.toPay} ${formatCurrencyForCustomer(getAmountToPay(invoice, company).toPay, invoice.currency, lang)}\n`
text += isQuote
? `${L.validUntil} ${quoteValidUntil(invoice)}\n`
: `${L.dueDate} ${formatDate(invoice.due_date)}\n`
text += `${isQuote ? L.totalQuote : L.toPay} ${formatCurrencyForCustomer(getAmountToPay(invoice, company).toPay, invoice.currency, lang)}\n`
text += `---\n\n`
if (!hidePayment) {
@@ -409,7 +444,7 @@ export function generateInvoiceEmailText(data: InvoiceEmailData): string {
text += `${L.message} ${invoice.invoice_number}\n\n`
}
text += `${L.questions}\n\n`
text += `${isQuote ? L.questionsQuote : L.questions}\n\n`
text += `${custom.signoff ?? L.sincerely}\n`
text += `${getCompanyDisplayName(company)}\n`
+53
View File
@@ -1209,6 +1209,59 @@ const INVOICE: Record<string, StructuredErrorEntry> = {
message_sv: 'Fakturan kan inte markeras som betald i nuvarande status.',
message_en: 'Invoice is not in a payable status.',
},
INVOICE_QUOTE_NOT_PAYABLE: {
httpStatus: 400,
message_sv: 'En offert kan inte betalas. Skapa en faktura från offerten först.',
message_en: 'A quote cannot be paid. Convert it to an invoice first.',
remediation: {
description: 'Convert the accepted quote with POST /api/invoices/{id}/convert, then register the payment on the invoice.',
},
},
INVOICE_NOT_A_QUOTE: {
httpStatus: 400,
message_sv: 'Dokumentet är inte en offert.',
message_en: 'This document is not a quote.',
},
INVOICE_QUOTE_NOT_DECIDABLE: {
httpStatus: 400,
message_sv: 'Makulerade offerter kan inte accepteras eller avböjas.',
message_en: 'A cancelled quote cannot be accepted or declined.',
},
INVOICE_QUOTE_ALREADY_INVOICED: {
httpStatus: 409,
message_sv: 'Offerten är redan fakturerad och kan inte ändras.',
message_en: 'This quote has already been invoiced and can no longer change.',
},
INVOICE_CONVERT_NOT_CONVERTIBLE: {
httpStatus: 400,
message_sv: 'Endast proformafakturor och offerter kan omvandlas till faktura.',
message_en: 'Only proforma invoices and quotes can be converted to an invoice.',
},
INVOICE_CONVERT_SOURCE_CANCELLED: {
httpStatus: 409,
message_sv: 'Dokumentet är makulerat och kan inte omvandlas.',
message_en: 'This document is cancelled and cannot be converted.',
},
INVOICE_CONVERT_SOURCE_CHANGED: {
httpStatus: 409,
message_sv: 'Dokumentet ändrades samtidigt (makulerat, omvandlat eller beslutat på annat sätt). Ladda om och försök igen.',
message_en: 'The document changed concurrently (cancelled, converted or decided elsewhere). Reload and try again.',
},
INVOICE_QUOTE_CHANGED_CONCURRENTLY: {
httpStatus: 409,
message_sv: 'Offerten ändrades samtidigt (fakturerad, makulerad eller beslutad på annat sätt). Ladda om och försök igen.',
message_en: 'The quote changed concurrently (invoiced, cancelled or decided elsewhere). Reload and try again.',
},
INVOICE_CONVERT_QUOTE_DECLINED: {
httpStatus: 409,
message_sv: 'Offerten är avböjd. Markera den som accepterad innan du skapar en faktura.',
message_en: 'The quote was declined. Mark it accepted before creating an invoice.',
},
INVOICE_UPDATE_DOCUMENT_TYPE_LOCKED: {
httpStatus: 400,
message_sv: 'Dokumenttypen kan inte ändras på en offert eller följesedel: numret hör till serien. Skapa ett nytt dokument i stället.',
message_en: 'The document type of a quote or delivery note cannot change: its number belongs to that series. Create a new document instead.',
},
INVOICE_PAYMENT_CONFIRMATION_NOT_PAID: {
httpStatus: 409,
message_sv:
@@ -526,4 +526,74 @@ describe('buildInvoiceWriteData kundkort fallback customer-type gate', () => {
if (result.ok) return
expect('code' in result && result.code).toBe('INVOICE_CREATE_ROT_RUT_VALIDATION')
})
it('writes valid_until + quote_status open for a quote, mirrors it into due_date and keeps nothing owed', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { vat_registered: true }, error: null })
const customer = makeCustomer({ customer_type: 'swedish_business' })
const result = await call(
enqueue,
supabase as unknown as SupabaseClient,
customer,
{
...baseHeader,
valid_until: '2026-08-01',
items: [{ description: 'Offererat arbete', quantity: 2, unit: 'tim', unit_price: 1000, vat_rate: 25 }],
},
'quote',
)
expect(result.ok).toBe(true)
if (!result.ok) return
expect(result.invoiceFields.document_type).toBe('quote')
expect(result.invoiceFields.valid_until).toBe('2026-08-01')
expect(result.invoiceFields.due_date).toBe('2026-08-01')
// The decision column is never a builder output (a draft edit must not
// overwrite an accept/decline); the DB trigger opens a new quote.
expect(result.invoiceFields).not.toHaveProperty('quote_status')
expect(result.invoiceFields.total).toBe(2500)
expect(result.invoiceFields.remaining_amount).toBe(0)
expect(result.invoiceFields.deduction_total).toBe(0)
})
it('leaves the quote columns NULL on every other document type', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { vat_registered: true }, error: null })
const customer = makeCustomer({ customer_type: 'swedish_business' })
const result = await call(enqueue, supabase as unknown as SupabaseClient, customer, {
...baseHeader,
valid_until: '2026-08-01',
items: [{ description: 'Konsult', quantity: 1, unit: 'tim', unit_price: 1000, vat_rate: 25 }],
})
expect(result.ok).toBe(true)
if (!result.ok) return
expect(result.invoiceFields.valid_until).toBeNull()
expect(result.invoiceFields).not.toHaveProperty('quote_status')
expect(result.invoiceFields.due_date).toBe(baseHeader.due_date)
})
it('drops sales_order_item_id on quote lines so an offer never consumes kundorder quantity', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { vat_registered: true }, error: null })
const customer = makeCustomer({ customer_type: 'swedish_business' })
const result = await call(
enqueue,
supabase as unknown as SupabaseClient,
customer,
{
...baseHeader,
valid_until: '2026-08-01',
items: [{ description: 'Orderrad', quantity: 1, unit: 'st', unit_price: 1000, vat_rate: 25, sales_order_item_id: '33333333-3333-4333-8333-333333333333' }],
},
'quote',
)
expect(result.ok).toBe(true)
if (!result.ok) return
expect(result.items[0]).toMatchObject({ sales_order_item_id: null })
})
})
@@ -0,0 +1,348 @@
import { randomUUID } from 'node:crypto'
import { describe, expect, it } from 'vitest'
import { getPool } from '@/tests/pg/setup'
import { seedCompany } from '@/tests/pg/fixtures'
// Migration 20260902220000_invoice_quotes: the OF-series RPC, the widened
// document_type CHECK and the quote column pairing constraint.
async function ensureCompanySettings(params: {
userId: string
companyId: string
nextQuoteNumber?: number
}): Promise<void> {
await getPool().query(
`INSERT INTO public.company_settings
(user_id, company_id, invoice_prefix, next_invoice_number, next_quote_number)
VALUES ($1, $2, 'F', 1, $3)
ON CONFLICT (company_id) DO UPDATE
SET next_quote_number = EXCLUDED.next_quote_number`,
[params.userId, params.companyId, params.nextQuoteNumber ?? 1],
)
}
async function insertCustomer(userId: string, companyId: string): Promise<string> {
const customerId = randomUUID()
await getPool().query(
`INSERT INTO public.customers (id, user_id, company_id, name)
VALUES ($1, $2, $3, 'Test Customer')`,
[customerId, userId, companyId],
)
return customerId
}
async function insertInvoiceRow(params: {
userId: string
companyId: string
customerId: string
documentType: string
invoiceNumber: string | null
quoteStatus: string | null
validUntil?: string | null
}): Promise<string> {
const id = randomUUID()
await getPool().query(
`INSERT INTO public.invoices
(id, user_id, company_id, customer_id, invoice_number, document_type,
invoice_date, due_date, valid_until, quote_status, currency,
subtotal, vat_amount, total, vat_treatment, vat_rate, moms_ruta, status)
VALUES ($1, $2, $3, $4, $5, $6,
'2026-06-01', '2026-07-01', $7, $8, 'SEK',
1000, 250, 1250, 'standard_25', 25, '10', 'draft')`,
[
id,
params.userId,
params.companyId,
params.customerId,
params.invoiceNumber,
params.documentType,
params.validUntil ?? null,
params.quoteStatus,
],
)
return id
}
async function readCounter(companyId: string): Promise<{ quote: number; invoice: number }> {
const { rows } = await getPool().query<{ next_quote_number: number; next_invoice_number: number }>(
'SELECT next_quote_number, next_invoice_number FROM public.company_settings WHERE company_id = $1',
[companyId],
)
return { quote: rows[0]!.next_quote_number, invoice: rows[0]!.next_invoice_number }
}
describe('generate_quote_number RPC', () => {
it('allocates OF-nnn from its own counter and leaves the F-series untouched', async () => {
const { userId, companyId } = await seedCompany()
await ensureCompanySettings({ userId, companyId, nextQuoteNumber: 1 })
const first = await getPool().query<{ n: string }>(
'SELECT public.generate_quote_number($1) AS n',
[companyId],
)
const second = await getPool().query<{ n: string }>(
'SELECT public.generate_quote_number($1) AS n',
[companyId],
)
expect(first.rows[0]!.n).toBe('OF-001')
expect(second.rows[0]!.n).toBe('OF-002')
const counters = await readCounter(companyId)
expect(counters.quote).toBe(3)
expect(counters.invoice).toBe(1)
})
it('grows past three digits without truncation', async () => {
const { userId, companyId } = await seedCompany()
await ensureCompanySettings({ userId, companyId, nextQuoteNumber: 1234 })
const { rows } = await getPool().query<{ n: string }>(
'SELECT public.generate_quote_number($1) AS n',
[companyId],
)
expect(rows[0]!.n).toBe('OF-1234')
})
it('raises when the company has no settings row', async () => {
await expect(
getPool().query('SELECT public.generate_quote_number($1)', [randomUUID()]),
).rejects.toThrow(/Company settings not found/)
})
it('is not executable by anon', async () => {
const { rows } = await getPool().query<{ ok: boolean }>(
`SELECT has_function_privilege('anon', 'public.generate_quote_number(uuid)', 'EXECUTE') AS ok`,
)
expect(rows[0]!.ok).toBe(false)
})
})
describe('invoices quote columns', () => {
it('accepts document_type quote with an open quote_status', async () => {
const { userId, companyId } = await seedCompany()
const customerId = await insertCustomer(userId, companyId)
const id = await insertInvoiceRow({
userId,
companyId,
customerId,
documentType: 'quote',
invoiceNumber: 'OF-001',
quoteStatus: 'open',
validUntil: '2026-07-01',
})
const { rows } = await getPool().query<{ document_type: string; quote_status: string; valid_until: string }>(
'SELECT document_type, quote_status, valid_until::text FROM public.invoices WHERE id = $1',
[id],
)
expect(rows[0]).toEqual({ document_type: 'quote', quote_status: 'open', valid_until: '2026-07-01' })
})
it('rejects a non-quote that carries a quote_status', async () => {
const { userId, companyId } = await seedCompany()
const customerId = await insertCustomer(userId, companyId)
// A quote inserted WITHOUT quote_status is opened by the
// invoices_quote_defaults trigger (tested below), so only the reverse
// pairing can still violate invoices_quote_columns_check.
await expect(
insertInvoiceRow({
userId,
companyId,
customerId,
documentType: 'invoice',
invoiceNumber: null,
quoteStatus: 'open',
}),
).rejects.toThrow(/invoices_quote_columns_check/)
})
it('rejects an unknown quote_status and an unknown document_type', async () => {
const { userId, companyId } = await seedCompany()
const customerId = await insertCustomer(userId, companyId)
await expect(
insertInvoiceRow({
userId,
companyId,
customerId,
documentType: 'quote',
invoiceNumber: 'OF-003',
quoteStatus: 'expired',
}),
).rejects.toThrow(/invoices_quote_status_check/)
await expect(
insertInvoiceRow({
userId,
companyId,
customerId,
documentType: 'estimate',
invoiceNumber: null,
quoteStatus: null,
}),
).rejects.toThrow(/invoices_document_type_check/)
})
describe('invoices_quote_defaults trigger and one-live-conversion index (20260902221000)', () => {
it('opens a new quote and mirrors due_date into valid_until when a writer omits them', async () => {
const { userId, companyId } = await seedCompany()
const customerId = await insertCustomer(userId, companyId)
const id = await insertInvoiceRow({
userId,
companyId,
customerId,
documentType: 'quote',
invoiceNumber: 'OF-010',
quoteStatus: null,
validUntil: null,
})
const { rows } = await getPool().query<{ quote_status: string; valid_until: string; due_date: string }>(
'SELECT quote_status, valid_until::text, due_date::text FROM public.invoices WHERE id = $1',
[id],
)
expect(rows[0]).toEqual({ quote_status: 'open', valid_until: '2026-07-01', due_date: '2026-07-01' })
})
it('keeps the two dates equal on update, valid_until winning, and never rewrites a decision', async () => {
const { userId, companyId } = await seedCompany()
const customerId = await insertCustomer(userId, companyId)
const id = await insertInvoiceRow({
userId,
companyId,
customerId,
documentType: 'quote',
invoiceNumber: 'OF-011',
quoteStatus: 'accepted',
validUntil: '2026-07-01',
})
await getPool().query('UPDATE public.invoices SET due_date = $2 WHERE id = $1', [id, '2026-08-15'])
let res = await getPool().query<{ quote_status: string; valid_until: string; due_date: string }>(
'SELECT quote_status, valid_until::text, due_date::text FROM public.invoices WHERE id = $1',
[id],
)
expect(res.rows[0]).toEqual({ quote_status: 'accepted', valid_until: '2026-08-15', due_date: '2026-08-15' })
await getPool().query('UPDATE public.invoices SET valid_until = $2 WHERE id = $1', [id, '2026-09-30'])
res = await getPool().query<{ quote_status: string; valid_until: string; due_date: string }>(
'SELECT quote_status, valid_until::text, due_date::text FROM public.invoices WHERE id = $1',
[id],
)
expect(res.rows[0]).toEqual({ quote_status: 'accepted', valid_until: '2026-09-30', due_date: '2026-09-30' })
})
it('leaves non-quote rows untouched', async () => {
const { userId, companyId } = await seedCompany()
const customerId = await insertCustomer(userId, companyId)
const id = await insertInvoiceRow({
userId,
companyId,
customerId,
documentType: 'invoice',
invoiceNumber: null,
quoteStatus: null,
})
const { rows } = await getPool().query<{ quote_status: string | null; valid_until: string | null }>(
'SELECT quote_status, valid_until::text FROM public.invoices WHERE id = $1',
[id],
)
expect(rows[0]).toEqual({ quote_status: null, valid_until: null })
})
it('allows one live invoice per converted source, and another once the first is cancelled', async () => {
const { userId, companyId } = await seedCompany()
const customerId = await insertCustomer(userId, companyId)
const quoteId = await insertInvoiceRow({
userId,
companyId,
customerId,
documentType: 'quote',
invoiceNumber: 'OF-012',
quoteStatus: 'accepted',
validUntil: '2026-07-01',
})
const insertConverted = (id: string, number: string) =>
getPool().query(
`INSERT INTO public.invoices
(id, user_id, company_id, customer_id, invoice_number, document_type, converted_from_id,
invoice_date, due_date, currency, subtotal, vat_amount, total, vat_treatment, vat_rate, moms_ruta, status)
VALUES ($1, $2, $3, $4, $5, 'invoice', $6,
'2026-06-01', '2026-07-01', 'SEK', 1000, 250, 1250, 'standard_25', 25, '10', 'draft')`,
[id, userId, companyId, customerId, number, quoteId],
)
const first = randomUUID()
await insertConverted(first, 'F-901')
await expect(insertConverted(randomUUID(), 'F-902')).rejects.toThrow(/idx_invoices_one_live_conversion/)
await getPool().query(`UPDATE public.invoices SET status = 'cancelled' WHERE id = $1`, [first])
await expect(insertConverted(randomUUID(), 'F-903')).resolves.toBeDefined()
})
})
})
describe('quote decision guard and viewer gate (20260902222000)', () => {
it('refuses to move an accepted quote away from accepted while a live converted invoice exists', async () => {
const { userId, companyId } = await seedCompany()
const customerId = await insertCustomer(userId, companyId)
const quoteId = await insertInvoiceRow({
userId,
companyId,
customerId,
documentType: 'quote',
invoiceNumber: 'OF-020',
quoteStatus: 'accepted',
validUntil: '2026-07-01',
})
const invoiceId = randomUUID()
await getPool().query(
`INSERT INTO public.invoices
(id, user_id, company_id, customer_id, invoice_number, document_type, converted_from_id,
invoice_date, due_date, currency, subtotal, vat_amount, total, vat_treatment, vat_rate, moms_ruta, status)
VALUES ($1, $2, $3, $4, 'F-920', 'invoice', $5,
'2026-06-01', '2026-07-01', 'SEK', 1000, 250, 1250, 'standard_25', 25, '10', 'draft')`,
[invoiceId, userId, companyId, customerId, quoteId],
)
await expect(
getPool().query(`UPDATE public.invoices SET quote_status = 'declined' WHERE id = $1`, [quoteId]),
).rejects.toThrow(/INVOICE_QUOTE_ALREADY_INVOICED/)
// Cancelling the invoice frees the decision again.
await getPool().query(`UPDATE public.invoices SET status = 'cancelled' WHERE id = $1`, [invoiceId])
await expect(
getPool().query(`UPDATE public.invoices SET quote_status = 'declined' WHERE id = $1`, [quoteId]),
).resolves.toBeDefined()
})
it('leaves an accepted quote without a converted invoice free to change', async () => {
const { userId, companyId } = await seedCompany()
const customerId = await insertCustomer(userId, companyId)
const quoteId = await insertInvoiceRow({
userId,
companyId,
customerId,
documentType: 'quote',
invoiceNumber: 'OF-021',
quoteStatus: 'accepted',
validUntil: '2026-07-01',
})
await expect(
getPool().query(`UPDATE public.invoices SET quote_status = 'open' WHERE id = $1`, [quoteId]),
).resolves.toBeDefined()
})
it('generate_quote_number requires a non-viewer membership under a user JWT', async () => {
const { rows } = await getPool().query<{ src: string }>(
`SELECT pg_get_functiondef('public.generate_quote_number(uuid)'::regprocedure) AS src`,
)
expect(rows[0]!.src).toContain("cm.role <> 'viewer'")
})
})
@@ -8,6 +8,31 @@ function sort(invoices: Invoice[], sortBy: InvoiceListSort, oreRounding = true)
}
describe('sortInvoiceList', () => {
it('sorts quotes by valid_until in the date column even though they stay drafts', () => {
const invoices = [
makeInvoice({ id: 'draft', status: 'draft', due_date: '2024-05-01' }),
makeInvoice({
id: 'quote-late',
document_type: 'quote',
status: 'draft',
due_date: '2024-08-01',
valid_until: '2024-08-01',
}),
makeInvoice({
id: 'quote-early',
document_type: 'quote',
status: 'draft',
due_date: '2024-06-01',
valid_until: '2024-06-01',
}),
]
const sorted = sortInvoiceList(invoices, { column: 'due', direction: 'asc' }, true)
// Quotes carry a date; the plain draft has none and sorts last.
expect(sorted.map((i) => i.id)).toEqual(['quote-early', 'quote-late', 'draft'])
})
it('sorts displayed invoice numbers naturally and keeps missing numbers last', () => {
const invoices = [
makeInvoice({ id: '10', invoice_number: 'F-10' }),
@@ -170,6 +170,16 @@ describe('findMatchingInvoices', () => {
expect(result).toEqual([])
})
it('never proposes a quote or proforma: the candidate query is scoped to fakturor', async () => {
const queued = createQueuedMockSupabase()
queued.enqueue({ data: [], error: null })
const tx = makeTransaction({ amount: 12500 })
await findMatchingInvoices(queued.supabase as never, 'company-1', tx)
expect(queued.findCalls('invoices', 'eq')).toContainEqual(['document_type', 'invoice'])
})
it('matches by OCR reference with confidence 0.99', async () => {
const tx = makeTransaction({ amount: 12500, reference: 'F-2024001' })
mockResult({
@@ -39,4 +39,10 @@ describe('isEditableInvoiceDraft', () => {
}),
).toBe(false)
})
it('allows an open quote draft but blocks one that was accepted or declined', () => {
expect(isEditableInvoiceDraft({ status: 'draft', quote_status: 'open' })).toBe(true)
expect(isEditableInvoiceDraft({ status: 'draft', quote_status: 'accepted' })).toBe(false)
expect(isEditableInvoiceDraft({ status: 'draft', quote_status: 'declined' })).toBe(false)
})
})
@@ -195,6 +195,7 @@ describe('invoice payment accounts', () => {
expect(invoiceRequiresPaymentAccount(makeInvoice({ credited_invoice_id: 'invoice-original' }))).toBe(false)
expect(invoiceRequiresPaymentAccount(makeInvoice({ document_type: 'delivery_note' }))).toBe(false)
expect(invoiceRequiresPaymentAccount(makeInvoice({ document_type: 'proforma' }))).toBe(false)
expect(invoiceRequiresPaymentAccount(makeInvoice({ document_type: 'quote' }))).toBe(false)
})
it('accepts non-payable documents without an account', () => {
@@ -211,6 +212,10 @@ describe('invoice payment accounts', () => {
emptySettings,
makeInvoice({ document_type: 'proforma' }),
)).toBe(true)
expect(hasRequiredInvoicePaymentAccount(
emptySettings,
makeInvoice({ document_type: 'quote' }),
)).toBe(true)
expect(hasRequiredInvoicePaymentAccount(emptySettings, makeInvoice())).toBe(false)
})
})
@@ -27,6 +27,16 @@ describe('invoicePdfFilename', () => {
.toContain('Följesedel nr 42')
})
it('labels a quote (offert) with its OF- number', () => {
expect(invoicePdfFilename({
companyName: 'Oppy',
customerName: 'Kund AB',
invoiceNumber: 'OF-001',
invoiceDate: '2026-09-02',
documentType: 'quote',
})).toBe('Oppy x Kund AB Offert nr OF-001 20260902.pdf')
})
it('keeps drafts identifiable without inventing an invoice number', () => {
expect(invoicePdfFilename({
companyName: 'Oppy',
@@ -0,0 +1,161 @@
/**
* Offert (quote) PDF. A quote is never a payment request: it is titled
* OFFERT / QUOTE, states an issue date and an expiry ("Giltig till") instead
* of a due date, carries a notice that it is neither an invoice nor a
* betalningsanmodan, and renders no payment box (no bank account, bankgiro,
* OCR, Swish, QR or payment link). VAT lines render as on a proforma.
*/
import { describe, expect, it } from 'vitest'
import type { ReactElement, ReactNode } from 'react'
import { InvoicePDF, type InvoicePdfInvoice } from '@/lib/invoices/pdf-template'
import { makeCompanySettings, makeCustomer, makeInvoice } from '@/tests/helpers'
import type { InvoiceItem } from '@/types'
/** Every string leaf in the element tree, in document order. */
function textLeaves(node: ReactNode, out: string[] = []): string[] {
if (node === null || node === undefined || typeof node === 'boolean') return out
if (typeof node === 'string' || typeof node === 'number') {
out.push(String(node))
return out
}
if (Array.isArray(node)) {
for (const child of node) textLeaves(child, out)
return out
}
const element = node as ReactElement<{ children?: ReactNode }>
if (element.props) textLeaves(element.props.children, out)
return out
}
const items: InvoiceItem[] = [
{
id: 'item-1',
invoice_id: 'quote-1',
sort_order: 0,
line_type: 'product',
description: 'Konsulttimmar',
quantity: 10,
unit: 'tim',
unit_price: 1000,
line_total: 10000,
vat_rate: 25,
vat_amount: 2500,
created_at: '2026-09-02T00:00:00Z',
},
]
const company = makeCompanySettings({
bank_name: 'SEB',
clearing_number: '5000',
account_number: '1234567',
bankgiro: '123-4567',
invoice_show_bankgiro: true,
invoice_show_ocr: true,
})
function renderText(invoice: InvoicePdfInvoice, language: 'sv' | 'en' = 'sv'): string {
const tree = InvoicePDF({
invoice,
customer: makeCustomer({ language }),
items,
company,
paymentLinkQrDataUrl: null,
swishQrDataUrl: null,
})
return textLeaves(tree).join('\n')
}
const quote = (overrides: Partial<InvoicePdfInvoice> = {}): InvoicePdfInvoice =>
makeInvoice({
id: 'quote-1',
status: 'sent',
document_type: 'quote',
invoice_number: 'OF-001',
invoice_date: '2026-09-02',
due_date: '2026-10-02',
valid_until: '2026-10-02',
quote_status: 'open',
total: 12500,
payment_link_url: 'https://buy.stripe.com/test_quote',
...overrides,
})
describe('quote PDF (sv)', () => {
it('is titled OFFERT with Offertdatum and Giltig till instead of invoice dates', () => {
const text = renderText(quote())
expect(text).toContain('OFFERT')
expect(text).not.toContain('PROFORMAFAKTURA')
expect(text).toContain('Offertdatum:')
expect(text).toContain('Giltig till:')
expect(text).toContain('2026-10-02')
expect(text).not.toContain('Fakturadatum:')
expect(text).not.toContain('Förfallodatum:')
})
it('carries the quote notice and no payment information', () => {
const text = renderText(quote())
expect(text).toContain('Detta är en offert och utgör ingen faktura eller betalningsanmodan.')
expect(text).not.toContain('Betalningsinformation')
expect(text).not.toContain('Bankgiro:')
expect(text).not.toContain('OCR')
expect(text).not.toContain('Swish')
expect(text).not.toContain('buy.stripe.com')
expect(text).not.toContain('Betala online')
})
it('still renders the VAT lines like a proforma', () => {
const text = renderText(quote())
expect(text).toContain('Moms 25%:')
expect(text).toContain('2\u00a0500,00 SEK')
expect(text).toContain('12\u00a0500,00 SEK')
})
it('labels the grand total Summa, never Att betala', () => {
const text = renderText(quote())
expect(text).toContain('Summa:')
expect(text).not.toContain('Att betala')
expect(text).not.toContain('Att kreditera')
})
it('never carries a paid state even if the row says paid', () => {
const text = renderText(quote({ status: 'paid', paid_amount: 12500, paid_at: '2026-09-10T00:00:00Z' }))
expect(text).not.toContain('BETALD')
expect(text).not.toContain('Betalt:')
})
it('falls back to due_date when valid_until is missing on an older row', () => {
const text = renderText(quote({ valid_until: null, due_date: '2026-10-15' }))
expect(text).toContain('Giltig till:')
expect(text).toContain('2026-10-15')
})
})
describe('quote PDF (en)', () => {
it('is titled QUOTE with Quote date and Valid until, the notice, and no payment box', () => {
const text = renderText(quote(), 'en')
expect(text).toContain('QUOTE')
expect(text).not.toContain('PROFORMA INVOICE')
expect(text).toContain('Quote date:')
expect(text).toContain('Valid until:')
expect(text).not.toContain('Invoice date:')
expect(text).not.toContain('Due date:')
expect(text).toContain('This is a quote and is not an invoice or a request for payment.')
expect(text).not.toContain('Payment information')
expect(text).not.toContain('Pay online')
})
it('labels the grand total Total, never Total due', () => {
const text = renderText(quote(), 'en')
expect(text).toContain('Total:')
expect(text).not.toContain('Total due')
expect(text).not.toContain('To credit')
})
})
@@ -0,0 +1,25 @@
import { describe, it, expect } from 'vitest'
import { effectiveQuoteStatus, formatQuoteNumber, isQuoteExpired } from '@/lib/invoices/quote-status'
describe('quote status helpers', () => {
it('derives expired only for an open quote past valid_until', () => {
expect(isQuoteExpired({ quote_status: 'open', valid_until: '2026-05-31' }, '2026-06-01')).toBe(true)
expect(isQuoteExpired({ quote_status: 'open', valid_until: '2026-06-01' }, '2026-06-01')).toBe(false)
expect(isQuoteExpired({ quote_status: 'accepted', valid_until: '2026-05-31' }, '2026-06-01')).toBe(false)
expect(isQuoteExpired({ quote_status: 'declined', valid_until: '2026-05-31' }, '2026-06-01')).toBe(false)
expect(isQuoteExpired({ quote_status: 'open', valid_until: null }, '2026-06-01')).toBe(false)
})
it('reports the stored decision, expired, or null for non-quotes', () => {
expect(effectiveQuoteStatus({ quote_status: 'open', valid_until: '2026-05-31' }, '2026-06-01')).toBe('expired')
expect(effectiveQuoteStatus({ quote_status: 'open', valid_until: '2026-06-30' }, '2026-06-01')).toBe('open')
expect(effectiveQuoteStatus({ quote_status: 'accepted', valid_until: '2026-05-31' }, '2026-06-01')).toBe('accepted')
expect(effectiveQuoteStatus({ quote_status: null, valid_until: null })).toBeNull()
})
it('formats quote numbers like generate_quote_number', () => {
expect(formatQuoteNumber(1)).toBe('OF-001')
expect(formatQuoteNumber(42)).toBe('OF-042')
expect(formatQuoteNumber(1234)).toBe('OF-1234')
})
})
@@ -184,6 +184,15 @@ describe('processOverdueReminders: credit-note filter', () => {
expect(eqTerminal).toBeUndefined()
})
it('only considers fakturor: proformas, delivery notes and quotes never get a påminnelse', async () => {
await processOverdueReminders()
const docTypeFilter = chainCalls.find(
(c) => c.method === 'eq' && c.args[0] === 'document_type',
)
expect(docTypeFilter?.args[1]).toBe('invoice')
})
it('includes overdue in the allowlist so level-2 and level-3 reminders re-fire after the first reminder flips status', async () => {
await processOverdueReminders()
const inStatus = chainCalls.find(
@@ -25,7 +25,7 @@ describe('hasRequiredSellerVatNumber', () => {
).toBe(true)
})
it('exempts credit notes, proformas, and delivery notes', () => {
it('exempts credit notes, proformas, quotes, and delivery notes', () => {
const broken = { vat_registered: true, vat_number: null }
expect(
hasRequiredSellerVatNumber(broken, { credited_invoice_id: 'inv-0', document_type: 'invoice' }),
@@ -33,6 +33,9 @@ describe('hasRequiredSellerVatNumber', () => {
expect(
hasRequiredSellerVatNumber(broken, { credited_invoice_id: null, document_type: 'proforma' }),
).toBe(true)
expect(
hasRequiredSellerVatNumber(broken, { credited_invoice_id: null, document_type: 'quote' }),
).toBe(true)
expect(
hasRequiredSellerVatNumber(broken, { credited_invoice_id: null, document_type: 'delivery_note' }),
).toBe(true)
+20 -3
View File
@@ -74,6 +74,8 @@ export interface InvoiceWriteInput {
invoice_date: string
due_date: string
delivery_date?: string | null
/** Quotes only: expiry date. Mirrored into due_date (NOT NULL) for quotes. */
valid_until?: string | null
currency: Currency
your_reference?: string
our_reference?: string
@@ -104,6 +106,7 @@ export type InvoiceWriteFields = {
invoice_date: string
due_date: string
delivery_date: string | null
valid_until: string | null
currency: Currency
exchange_rate: number | null
exchange_rate_date: string | null
@@ -505,11 +508,22 @@ export async function buildInvoiceWriteData(params: {
totalSek = Math.round(total * 100) / 100
}
// A quote has no due date, only an expiry. due_date is NOT NULL on the
// table and every date-ordered reader sorts on it, so it mirrors
// valid_until; valid_until stays the authoritative column.
const validUntil = documentType === 'quote' ? (input.valid_until ?? input.due_date) : null
const invoiceFields: InvoiceWriteFields = {
customer_id: input.customer_id,
invoice_date: input.invoice_date,
due_date: input.due_date,
due_date: validUntil ?? input.due_date,
delivery_date: input.delivery_date ?? null,
valid_until: validUntil,
// quote_status is deliberately NOT a builder output: this object is
// spread into both inserts and draft updates, and a recorded accept or
// decline must never be overwritten by an edit. New quotes start open via
// the invoices_quote_defaults trigger (20260902221000); decisions are
// written only by the quote-status routes and the converter.
currency: input.currency,
exchange_rate: exchangeRate,
exchange_rate_date: exchangeRateDate,
@@ -522,7 +536,7 @@ export async function buildInvoiceWriteData(params: {
// remaining_amount = total - deduction for real invoices so open-invoice
// queries treat them as fully unpaid for the CUSTOMER's share: the
// Skatteverket portion is on 1513 and clears when the agency pays out.
// Proformas / delivery notes have no payment obligation → keep 0.
// Proformas / delivery notes / quotes have no payment obligation → keep 0.
remaining_amount: documentType === 'invoice' ? total - deductionTotal : 0,
vat_treatment: notVatRegistered ? 'exempt' : headerRules.treatment,
vat_rate: documentType === 'delivery_note' ? 0 : (isMixedRate ? null : (uniqueRates.values().next().value ?? vatRules.rate)),
@@ -615,7 +629,10 @@ export async function buildInvoiceWriteData(params: {
// VAT-treatment-derived account in generatePerRateLines().
article_id: item.article_id ?? null,
revenue_account: item.revenue_account ?? null,
sales_order_item_id: item.sales_order_item_id ?? null,
// Only a faktura consumes kundorder quantity: a quote or proforma line
// linked to an order item would mark the order invoiced without any
// invoice existing (sales_order_invoiced_quantities counts by status).
sales_order_item_id: documentType === 'invoice' ? (item.sales_order_item_id ?? null) : null,
deduction_type: deductionType,
deduction_amount: deductionAmount,
labor_hours: documentType === 'invoice' ? (item.labor_hours ?? null) : null,
+349
View File
@@ -0,0 +1,349 @@
import type { SupabaseClient } from '@supabase/supabase-js'
import { eventBus } from '@/lib/events'
import { ensureInvoiceNumber } from '@/lib/invoices/ensure-invoice-number'
import { convertToSEK, fetchExchangeRate } from '@/lib/currency/riksbanken'
import type { Invoice } from '@/types'
/**
* Convert a proforma or a quote (offert) into a real invoice.
*
* ONE implementation for every caller (the dashboard route, the MCP staged
* commit, v1): the proforma conversion used to exist twice and had drifted
* (one copy dropped per-line VAT and revenue accounts, the other dropped
* discounts and fakturamarkning). Both now flow through here.
*
* Source handling differs by document type:
* - proforma: the source is marked cancelled (it was a pre-invoice for the
* same sale) and its due_date is carried over.
* - quote: the source stays, flips to quote_status = 'accepted', and the
* invoice links back via converted_from_id. A quote has no due date, so
* the invoice gets invoice_date + the customer's payment terms (falling
* back to company_settings.invoice_default_days, then 30). A second
* conversion is refused while an active converted invoice exists; a
* declined quote must be re-accepted first.
*
* Ordering: ensureInvoiceNumber() is the LAST side effect. The F-series
* counter only advances after items are inserted and the source is updated,
* so a partial failure in any earlier step rolls back the orphan row without
* leaking a number (ML 17 kap 24 paragraph: gap-free lopnummer).
*/
export type ConvertToInvoiceFailureCode =
| 'INVOICE_NOT_FOUND'
| 'INVOICE_CONVERT_NOT_CONVERTIBLE'
| 'INVOICE_CONVERT_SOURCE_CANCELLED'
| 'INVOICE_CONVERT_SOURCE_CHANGED'
| 'INVOICE_CONVERT_QUOTE_DECLINED'
| 'INVOICE_QUOTE_ALREADY_INVOICED'
export type ConvertToInvoiceResult =
| { ok: true; invoice: Invoice }
| { ok: false; code: ConvertToInvoiceFailureCode }
| { ok: false; code: 'INVOICE_CONVERT_FAILED'; cause: Error | { message: string } }
interface SourceItem {
sort_order: number
line_type?: 'product' | 'text' | null
description: string
quantity: number
unit: string
unit_price: number
discount_percent?: number | null
line_total: number
vat_rate?: number | null
vat_amount?: number | null
revenue_account?: string | null
article_id?: string | null
sales_order_item_id?: string | null
dimensions?: Record<string, string> | null
}
type SourceRow = Invoice & {
items?: SourceItem[] | null
customer?: { default_payment_terms?: number | null } | null
}
const DEFAULT_PAYMENT_TERMS_DAYS = 30
function isoDate(d: Date): string {
return d.toISOString().split('T')[0]
}
function addDays(iso: string, days: number): string {
const d = new Date(`${iso}T00:00:00Z`)
d.setUTCDate(d.getUTCDate() + days)
return isoDate(d)
}
export async function convertToInvoice(params: {
supabase: SupabaseClient
userId: string
companyId: string
sourceId: string
}): Promise<ConvertToInvoiceResult> {
const { supabase, userId, companyId, sourceId } = params
const { data: sourceRaw, error: sourceError } = await supabase
.from('invoices')
.select('*, items:invoice_items(*), customer:customers(default_payment_terms)')
.eq('id', sourceId)
.eq('company_id', companyId)
.single()
if (sourceError || !sourceRaw) {
return { ok: false, code: 'INVOICE_NOT_FOUND' }
}
const source = sourceRaw as SourceRow
const isQuote = source.document_type === 'quote'
if (source.document_type !== 'proforma' && !isQuote) {
return { ok: false, code: 'INVOICE_CONVERT_NOT_CONVERTIBLE' }
}
if (source.status === 'cancelled') {
return { ok: false, code: 'INVOICE_CONVERT_SOURCE_CANCELLED' }
}
const today = isoDate(new Date())
let dueDate: string = source.due_date
if (isQuote) {
if (source.quote_status === 'declined') {
return { ok: false, code: 'INVOICE_CONVERT_QUOTE_DECLINED' }
}
// One invoice per quote while that invoice lives. A cancelled converted
// invoice frees the quote for another attempt.
const { data: existing, error: existingError } = await supabase
.from('invoices')
.select('id')
.eq('company_id', companyId)
.eq('converted_from_id', sourceId)
.neq('status', 'cancelled')
.limit(1)
.maybeSingle()
if (existingError) {
return { ok: false, code: 'INVOICE_CONVERT_FAILED', cause: existingError }
}
if (existing) {
return { ok: false, code: 'INVOICE_QUOTE_ALREADY_INVOICED' }
}
// 0 days is a real term (due on receipt); only a missing value falls
// through to the company default and then to 30.
let termsDays: number | null = source.customer?.default_payment_terms ?? null
if (termsDays == null) {
const { data: settings, error: settingsError } = await supabase
.from('company_settings')
.select('invoice_default_days')
.eq('company_id', companyId)
.maybeSingle()
// A failed read must not silently become "30 days": that would date
// the receivable wrong. Only a successful empty result falls back.
if (settingsError) {
return { ok: false, code: 'INVOICE_CONVERT_FAILED', cause: settingsError }
}
termsDays = (settings as { invoice_default_days?: number | null } | null)?.invoice_default_days ?? null
}
dueDate = addDays(today, termsDays ?? DEFAULT_PAYMENT_TERMS_DAYS)
}
// The invoice is a new taxable event dated today (ML 8 kap 21-23 §): a
// quote can sit for weeks, so the SEK twins must use today's rate, not
// the one stamped when the offer was written. Fail closed if no rate can
// be had (the create paths refuse a NULL rate for the same reason).
let exchangeRate = source.exchange_rate
let exchangeRateDate = source.exchange_rate_date
let subtotalSek = source.subtotal_sek
let vatAmountSek = source.vat_amount_sek
let totalSek = source.total_sek
if (source.currency !== 'SEK') {
const rate = await fetchExchangeRate(source.currency, new Date(`${today}T00:00:00Z`), supabase)
if (!rate) {
return {
ok: false,
code: 'INVOICE_CONVERT_FAILED',
cause: { message: `Exchange rate for ${source.currency} unavailable; try again later` },
}
}
exchangeRate = rate.rate
exchangeRateDate = rate.date
subtotalSek = convertToSEK(source.subtotal, rate.rate)
vatAmountSek = convertToSEK(source.vat_amount, rate.rate)
totalSek = convertToSEK(source.total, rate.rate)
}
const { data: invoice, error: invoiceError } = await supabase
.from('invoices')
.insert({
user_id: userId,
company_id: companyId,
customer_id: source.customer_id,
invoice_number: null,
invoice_date: today,
due_date: dueDate,
currency: source.currency,
exchange_rate: exchangeRate,
exchange_rate_date: exchangeRateDate,
subtotal: source.subtotal,
subtotal_sek: subtotalSek,
vat_amount: source.vat_amount,
vat_amount_sek: vatAmountSek,
total: source.total,
total_sek: totalSek,
// The converted invoice is a fresh unpaid receivable: proformas and
// quotes carry no ROT/RUT deduction, so the customer owes the full
// total. Omitting this left the NOT NULL DEFAULT 0, which every payment
// surface reads as "nothing open".
remaining_amount: source.total,
paid_amount: 0,
vat_treatment: source.vat_treatment,
vat_rate: source.vat_rate,
moms_ruta: source.moms_ruta,
reverse_charge_text: source.reverse_charge_text,
your_reference: source.your_reference,
our_reference: source.our_reference,
// Buyer routing survives conversion (Peppol BT-10 may rely on it alone).
invoice_marking: source.invoice_marking ?? null,
notes: source.notes,
document_type: 'invoice',
converted_from_id: sourceId,
// Quote-only columns must not travel: the CHECK pairs quote_status
// with document_type = 'quote'.
valid_until: null,
quote_status: null,
quote_decided_at: null,
// Dimensions PR7: the converted invoice books with the source's bag.
default_dimensions: source.default_dimensions ?? {},
})
.select()
.single()
if (invoiceError) {
// idx_invoices_one_live_conversion: a concurrent conversion won the race.
if ((invoiceError as { code?: string }).code === '23505') {
return { ok: false, code: isQuote ? 'INVOICE_QUOTE_ALREADY_INVOICED' : 'INVOICE_CONVERT_SOURCE_CHANGED' }
}
return { ok: false, code: 'INVOICE_CONVERT_FAILED', cause: invoiceError }
}
const items = (source.items ?? []).map((item) => ({
invoice_id: invoice.id,
sort_order: item.sort_order,
line_type: item.line_type ?? 'product',
description: item.description,
quantity: item.quantity,
unit: item.unit,
unit_price: item.unit_price,
// The stored line_total is net of this; dropping it would make the
// converted invoice fail the Peppol line check and lose the rebate on
// the next builder pass.
discount_percent: item.discount_percent ?? 0,
line_total: item.line_total,
// Per-line VAT and any article / revenue-account override travel too, so
// the invoice books exactly as the source showed (mixed rates and
// per-article accounts both rely on these).
vat_rate: item.vat_rate ?? 0,
vat_amount: item.vat_amount ?? 0,
revenue_account: item.revenue_account ?? null,
article_id: item.article_id ?? null,
// A kundorder link on a proforma line moves to the invoice, so the order
// keeps counting the quantity as invoiced once the proforma is cancelled.
sales_order_item_id: item.sales_order_item_id ?? null,
dimensions: item.dimensions ?? {},
}))
if (items.length > 0) {
const { error: itemsError } = await supabase.from('invoice_items').insert(items)
if (itemsError) {
await supabase.from('invoices').delete().eq('id', invoice.id)
return { ok: false, code: 'INVOICE_CONVERT_FAILED', cause: itemsError }
}
}
// Update the source. If this fails, the new (still unnumbered) invoice is
// an orphan: delete it so the user can retry without ending up with two
// active invoices for the same source. invoice_items cascade.
const previousSource = {
status: source.status,
quote_status: source.quote_status ?? null,
quote_decided_at: source.quote_decided_at ?? null,
}
// Literal payloads on purpose: the phantom-column schema guard
// (tests/schema/no-phantom-columns.test.ts) can only check object literals.
// Compare-and-set on the state read above: a concurrent cancel, a
// concurrent proforma-to-order conversion (which cancels the proforma) or
// a concurrent decision on the quote turns this into a 0-row update, and
// the orphan invoice is removed instead of a second document for the
// same sale surviving.
const { data: sourceRows, error: sourceUpdateError } = isQuote
? await supabase
.from('invoices')
.update({
quote_status: 'accepted',
quote_decided_at: source.quote_decided_at ?? new Date().toISOString(),
})
.eq('id', sourceId)
.eq('quote_status', source.quote_status)
.neq('status', 'cancelled')
.select('id')
: await supabase
.from('invoices')
.update({ status: 'cancelled' })
.eq('id', sourceId)
.neq('status', 'cancelled')
.select('id')
if (sourceUpdateError) {
await supabase.from('invoices').delete().eq('id', invoice.id)
return { ok: false, code: 'INVOICE_CONVERT_FAILED', cause: sourceUpdateError }
}
if (!sourceRows || sourceRows.length === 0) {
await supabase.from('invoices').delete().eq('id', invoice.id)
return { ok: false, code: 'INVOICE_CONVERT_SOURCE_CHANGED' }
}
// Allocate the F-series number last. If allocation fails, restore the
// source and delete the orphan invoice. The F-counter is unaffected because
// generate_invoice_number only commits on success.
try {
await ensureInvoiceNumber(supabase, companyId, invoice as Invoice)
} catch (err) {
if (isQuote) {
await supabase
.from('invoices')
.update({
quote_status: previousSource.quote_status,
quote_decided_at: previousSource.quote_decided_at,
})
.eq('id', sourceId)
} else {
await supabase
.from('invoices')
.update({ status: previousSource.status })
.eq('id', sourceId)
}
await supabase.from('invoices').delete().eq('id', invoice.id)
return {
ok: false,
code: 'INVOICE_CONVERT_FAILED',
cause: err instanceof Error ? err : { message: 'Failed to assign invoice number' },
}
}
const { data: completeInvoice } = await supabase
.from('invoices')
.select('*, customer:customers(*), items:invoice_items(*)')
.eq('id', invoice.id)
.single()
const result = (completeInvoice ?? invoice) as Invoice
if (completeInvoice) {
await eventBus.emit({
type: 'invoice.created',
payload: { invoice: result, companyId, userId },
})
}
return { ok: true, invoice: result }
}
+3
View File
@@ -33,6 +33,9 @@ function displayedCustomer(invoice: Invoice): string | null {
}
function displayedDueDate(invoice: Invoice): string | null {
// The list shows a quote's "Giltig till" in the date column (a quote stays
// a draft, so the draft rule below would blank it).
if (invoice.document_type === 'quote') return invoice.valid_until || null
if (invoice.credited_invoice_id || invoice.status === 'draft') return null
return invoice.due_date || null
}
+4 -1
View File
@@ -275,7 +275,9 @@ export async function findInvoiceMatchCandidates(
return emptyResult()
}
// Query unpaid invoices (sent or overdue) with customer info
// Query unpaid invoices (sent or overdue) with customer info. Proformas,
// delivery notes and quotes are never receivables, so a sent quote is never
// proposed as a payment candidate.
let invoices: Array<Invoice & { customer?: { name?: string | null } | null }>
try {
invoices = await fetchAllRows(({ from, to }) =>
@@ -283,6 +285,7 @@ export async function findInvoiceMatchCandidates(
.from('invoices')
.select('*, customer:customers(*), credit_notes:invoices!credited_invoice_id(id, status, creation_complete)')
.eq('company_id', companyId)
.eq('document_type', 'invoice')
.is('credited_invoice_id', null)
.in('status', ['sent', 'overdue', 'partially_paid'])
.order('id', { ascending: true })
+5 -2
View File
@@ -6,7 +6,8 @@
* invoice must be corrected with a credit note instead. A self-billed invoice we
* received is the counterparty's document: never editable here. Credit-note
* drafts mirror an issued invoice and must not be changed into a different
* correction after creation.
* correction after creation. A quote that has been accepted or declined is a
* recorded decision: flip it back to open before editing the offer itself.
*
* This is the single source of truth for that predicate. The PATCH route
* (app/api/invoices/[id]/route.ts) enforces it server-side; the detail and edit
@@ -18,11 +19,13 @@ export function isEditableInvoiceDraft(invoice: {
journal_entry_id?: string | null
is_self_billed?: boolean | null
credited_invoice_id?: string | null
quote_status?: string | null
}): boolean {
return (
invoice.status === 'draft' &&
!invoice.journal_entry_id &&
!invoice.is_self_billed &&
!invoice.credited_invoice_id
!invoice.credited_invoice_id &&
(!invoice.quote_status || invoice.quote_status === 'open')
)
}
+2
View File
@@ -128,6 +128,8 @@ export function invoiceRequiresPaymentAccount(
return !invoice.credited_invoice_id
&& invoice.document_type !== 'delivery_note'
&& invoice.document_type !== 'proforma'
// A quote (offert) is never a payment request: nothing to pay to.
&& invoice.document_type !== 'quote'
}
export function hasRequiredInvoicePaymentAccount(
+1
View File
@@ -30,6 +30,7 @@ function safeFilenamePart(value: string | null | undefined, fallback: string, ma
function documentLabel(documentType: InvoiceDocumentType, isCreditNote: boolean): string {
if (isCreditNote) return 'Kreditfaktura'
if (documentType === 'proforma') return 'Proformafaktura'
if (documentType === 'quote') return 'Offert'
if (documentType === 'delivery_note') return 'Följesedel'
return 'Faktura'
}
+51 -14
View File
@@ -32,6 +32,7 @@ const LABELS = {
titleInvoice: 'FAKTURA',
titleCreditNote: 'KREDITFAKTURA',
titleProforma: 'PROFORMAFAKTURA',
titleQuote: 'OFFERT',
titleDeliveryNote: 'FÖLJESEDEL',
titlePreview: 'FÖRHANDSGRANSKNING',
// Status banners
@@ -39,6 +40,8 @@ const LABELS = {
cancelledWithNumber: (n: string) => `Faktura ${n} har makulerats. Numret behålls i serien för att hålla nummerföljden obruten enligt ML 17 kap 24§, men dokumentet är inte ett giltigt fakturaunderlag.`,
cancelledNoNumber: 'Detta utkast har makulerats och är inte ett giltigt fakturaunderlag.',
draftTitle: 'UTKAST: inte en giltig faktura',
draftTitleQuote: 'UTKAST',
draftTextQuote: 'Detta är ett utkast av offerten.',
draftWithNumber: 'Detta är ett utkast. Markera fakturan som skickad eller skicka via systemet för att göra den giltig som fakturaunderlag.',
draftNoNumber: 'Denna faktura saknar löpnummer och kan inte användas som fakturaunderlag enligt ML 17 kap 24§. Skicka fakturan via systemet för att tilldela ett nummer.',
paidTitle: 'BETALD',
@@ -48,11 +51,16 @@ const LABELS = {
creditNoteRef: (n: string) => `Denna kreditfaktura avser och krediterar faktura nr ${n}`,
// Sections
invoiceInfoHeading: 'Fakturainformation',
quoteInfoHeading: 'Offertinformation',
billedToHeading: 'Faktureras till',
itemsHeading: 'Specifikation',
// Invoice details
invoiceDate: 'Fakturadatum:',
dueDate: 'Förfallodatum:',
// Quote (offert) date labels: a quote has an issue date and an expiry,
// never a due date.
quoteDate: 'Offertdatum:',
validUntil: 'Giltig till:',
deliveryDate: 'Leveransdatum:',
yourReference: 'Er referens:',
ourReference: 'Vår referens:',
@@ -84,11 +92,14 @@ const LABELS = {
deductionNotice: 'Köparen ansöker om utbetalning hos Skatteverket via fakturamodellen. Säljaren begär utbetalning för den del köparen inte betalat.',
toCredit: 'Att kreditera:',
toPay: 'Att betala:',
// A quote is not a payment request, so its grand total is a neutral sum.
totalQuote: 'Summa:',
paidRow: 'Betalt:',
vatInSek: (rate: number | string) => `Moms i SEK (kurs ${rate}):`,
totalInSek: 'Totalt i SEK:',
// Proforma / exempt
// Proforma / quote / exempt
proformaNotice: 'Detta är en proformafaktura och utgör ingen betalningsanmodan.',
quoteNotice: 'Detta är en offert och utgör ingen faktura eller betalningsanmodan.',
exemptNotice: 'Undantag från skatteplikt, ML 3 kap.',
notVatRegisteredNotice: 'Företaget är inte momsregistrerat. Mervärdesskatt redovisas ej.',
// Payment
@@ -119,12 +130,15 @@ const LABELS = {
titleInvoice: 'INVOICE',
titleCreditNote: 'CREDIT NOTE',
titleProforma: 'PROFORMA INVOICE',
titleQuote: 'QUOTE',
titleDeliveryNote: 'DELIVERY NOTE',
titlePreview: 'PREVIEW',
cancelledTitle: 'VOID: not a valid invoice',
cancelledWithNumber: (n: string) => `Invoice ${n} has been voided. The number is retained in the sequence to keep the numbering unbroken (ML 17 kap 24§, Swedish VAT Act), but this document is not a valid invoice.`,
cancelledNoNumber: 'This draft has been voided and is not a valid invoice.',
draftTitle: 'DRAFT: not a valid invoice',
draftTitleQuote: 'DRAFT',
draftTextQuote: 'This is a draft of the quote.',
draftWithNumber: 'This is a draft. Mark the invoice as sent, or send it via the system, to make it a valid invoice.',
draftNoNumber: 'This invoice has no serial number and cannot be used as a valid invoice under ML 17 kap 24§ (Swedish VAT Act). Send the invoice via the system to assign a number.',
paidTitle: 'PAID',
@@ -132,10 +146,13 @@ const LABELS = {
paidBannerNoDate: (amount: string) => `Paid · ${amount}`,
creditNoteRef: (n: string) => `This credit note credits invoice no. ${n}`,
invoiceInfoHeading: 'Invoice information',
quoteInfoHeading: 'Quote information',
billedToHeading: 'Billed to',
itemsHeading: 'Items',
invoiceDate: 'Invoice date:',
dueDate: 'Due date:',
quoteDate: 'Quote date:',
validUntil: 'Valid until:',
deliveryDate: 'Delivery date:',
yourReference: 'Your reference:',
ourReference: 'Our reference:',
@@ -164,10 +181,12 @@ const LABELS = {
deductionNotice: 'The customer claims the deduction via fakturamodellen at Skatteverket. The seller requests payment from the agency for the portion not paid by the customer.',
toCredit: 'To credit:',
toPay: 'Total due:',
totalQuote: 'Total:',
paidRow: 'Paid:',
vatInSek: (rate: number | string) => `VAT in SEK (rate ${rate}):`,
totalInSek: 'Total in SEK:',
proformaNotice: 'This is a proforma invoice and is not a request for payment.',
quoteNotice: 'This is a quote and is not an invoice or a request for payment.',
exemptNotice: 'Exempt from VAT (ML 3 kap., Swedish VAT Act).',
notVatRegisteredNotice: 'The seller is not VAT-registered. No VAT is charged on this invoice.',
paymentHeading: 'Payment information',
@@ -719,6 +738,7 @@ function getDocumentTitle(invoice: Invoice, lang: PdfLang): string {
if (invoice.credited_invoice_id) return L.titleCreditNote
const docType = (invoice as Invoice & { document_type?: InvoiceDocumentType }).document_type || 'invoice'
if (docType === 'proforma') return L.titleProforma
if (docType === 'quote') return L.titleQuote
if (docType === 'delivery_note') return L.titleDeliveryNote
return L.titleInvoice
}
@@ -797,6 +817,10 @@ export function InvoicePDF({ invoice, customer, items, company, originalInvoiceN
const docType = (invoice as Invoice & { document_type?: InvoiceDocumentType }).document_type || 'invoice'
const isDeliveryNote = docType === 'delivery_note'
const isProforma = docType === 'proforma'
// A quote (offert) is never a payment request: no payment box, no OCR,
// no Swish/QR, no payment link (pdf-render-helpers gates the QR builders
// on docType === 'invoice'). Its expiry replaces the due date.
const isQuote = docType === 'quote'
// Shared with the invoice email (lib/email/invoice-templates.ts) so the
// mail and the PDF always state the same "Att betala". Computed once here
@@ -843,11 +867,13 @@ export function InvoicePDF({ invoice, customer, items, company, originalInvoiceN
</View>
) : isPreview ? null : (invoice.status === 'draft' || !invoice.invoice_number) ? (
<View style={styles.draftBanner}>
<Text style={styles.draftBannerTitle}>{L.draftTitle}</Text>
<Text style={styles.draftBannerTitle}>{isQuote ? L.draftTitleQuote : L.draftTitle}</Text>
<Text style={styles.draftBannerText}>
{invoice.invoice_number
? L.draftWithNumber
: L.draftNoNumber}
{isQuote
? L.draftTextQuote
: invoice.invoice_number
? L.draftWithNumber
: L.draftNoNumber}
</Text>
</View>
) : paidState?.kind === 'paid' && (
@@ -915,14 +941,16 @@ export function InvoicePDF({ invoice, customer, items, company, originalInvoiceN
<View style={styles.twoColumn}>
{/* Invoice details */}
<View style={styles.column}>
<Text style={styles.sectionTitle}>{L.invoiceInfoHeading}</Text>
<Text style={styles.sectionTitle}>{isQuote ? L.quoteInfoHeading : L.invoiceInfoHeading}</Text>
<View style={styles.row}>
<Text style={styles.label}>{L.invoiceDate}</Text>
<Text style={styles.label}>{isQuote ? L.quoteDate : L.invoiceDate}</Text>
<Text style={styles.value}>{formatDate(invoice.invoice_date)}</Text>
</View>
<View style={styles.row}>
<Text style={styles.label}>{L.dueDate}</Text>
<Text style={styles.value}>{formatDate(invoice.due_date)}</Text>
<Text style={styles.label}>{isQuote ? L.validUntil : L.dueDate}</Text>
<Text style={styles.value}>
{formatDate(isQuote ? (invoice.valid_until || invoice.due_date) : invoice.due_date)}
</Text>
</View>
{invoice.delivery_date && invoice.delivery_date !== invoice.invoice_date && (
<View style={styles.row}>
@@ -1141,7 +1169,7 @@ export function InvoicePDF({ invoice, customer, items, company, originalInvoiceN
</>
) : (
<View style={styles.grandTotal}>
<Text style={styles.grandTotalLabel}>{isCreditNote ? L.toCredit : L.toPay}</Text>
<Text style={styles.grandTotalLabel}>{isCreditNote ? L.toCredit : isQuote ? L.totalQuote : L.toPay}</Text>
<Text style={styles.grandTotalValue}>{formatPdfCurrency(grandTotal, invoice.currency, lang)}</Text>
</View>
)}
@@ -1232,8 +1260,17 @@ export function InvoicePDF({ invoice, customer, items, company, originalInvoiceN
</View>
)}
{/* Payment information - not shown for credit notes, proformas, or delivery notes */}
{!isCreditNote && !isProforma && !isDeliveryNote && (
{/* Quote notice */}
{isQuote && (
<View style={[styles.reverseChargeBox, { backgroundColor: '#e8f4fd', borderColor: '#90cdf4' }]}>
<Text style={[styles.reverseChargeText, { color: '#2b6cb0' }]}>
{L.quoteNotice}
</Text>
</View>
)}
{/* Payment information - not shown for credit notes, proformas, quotes, or delivery notes */}
{!isCreditNote && !isProforma && !isQuote && !isDeliveryNote && (
<View style={styles.paymentSection}>
<Text style={styles.paymentTitle}>{L.paymentHeading}</Text>
{invoice.payment_link_url && (
@@ -1375,8 +1412,8 @@ export function InvoicePDF({ invoice, customer, items, company, originalInvoiceN
</View>
)}
{/* Late fee & credit terms */}
{(company.invoice_late_fee_text || company.invoice_credit_terms_text) && (
{/* Late fee & credit terms: payment terms, so never on a quote */}
{!isQuote && (company.invoice_late_fee_text || company.invoice_credit_terms_text) && (
<View style={{ marginTop: 10, marginBottom: 10 }}>
{company.invoice_late_fee_text && (
<Text style={{ fontSize: 8, color: '#666', marginBottom: 2 }}>{company.invoice_late_fee_text}</Text>
+30
View File
@@ -0,0 +1,30 @@
import type { QuoteStatus } from '@/types'
/** What the UI and API report for a quote: the stored decision, or expired. */
export type EffectiveQuoteStatus = QuoteStatus | 'expired'
/**
* "Expired" is derived, never stored: an open quote whose valid_until has
* passed. Extending valid_until therefore un-expires it with no cron and no
* status write. Accepted and declined quotes never expire.
*/
export function isQuoteExpired(
quote: { quote_status?: string | null; valid_until?: string | null },
today: string = new Date().toISOString().split('T')[0],
): boolean {
return quote.quote_status === 'open' && !!quote.valid_until && quote.valid_until < today
}
export function effectiveQuoteStatus(
quote: { quote_status?: string | null; valid_until?: string | null },
today?: string,
): EffectiveQuoteStatus | null {
if (!quote.quote_status) return null
if (isQuoteExpired(quote, today)) return 'expired'
return quote.quote_status as QuoteStatus
}
/** Quote numbers are allocated at insert; this mirrors generate_quote_number's format for previews. */
export function formatQuoteNumber(n: number): string {
return `OF-${String(n).padStart(3, '0')}`
}
+4
View File
@@ -198,6 +198,10 @@ export async function processOverdueReminders(): Promise<ProcessRemindersResult>
credit_notes:invoices!credited_invoice_id(id, status, creation_complete)
`)
.in('status', ['sent', 'overdue'])
// Only fakturor are payment requests. A sent proforma or quote past its
// date is not overdue and must never receive a betalningspåminnelse or
// be flipped to 'overdue' below.
.eq('document_type', 'invoice')
.is('credited_invoice_id', null)
.lte('due_date', cutoffDate.toISOString().split('T')[0])
.order('due_date', { ascending: true })
+3 -1
View File
@@ -6,7 +6,7 @@ import type { CompanySettings, Invoice } from '@/types'
* defective invoice and a defective input-VAT underlag for the buyer, so
* issuance is gated the same way the payment account is.
*
* Proformas and delivery notes are not tax documents. Credit notes are
* Proformas, quotes (offert) and delivery notes are not tax documents. Credit notes are
* exempted deliberately: an ändringsfaktura has its own mandatory-content
* list (ML 17 kap. 22-23 §§: unambiguous reference to the original, the
* change, own number and date, negative amounts, VAT per original rate)
@@ -20,6 +20,8 @@ export function invoiceRequiresSellerVatNumber(
return !invoice.credited_invoice_id
&& invoice.document_type !== 'delivery_note'
&& invoice.document_type !== 'proforma'
// A quote is not a faktura under ML 17 kap.: no mandatory-content list applies.
&& invoice.document_type !== 'quote'
}
export function hasRequiredSellerVatNumber(
@@ -420,3 +420,94 @@ describe('commitPendingOperation: create_invoice: dimensions propagation (PR7)',
expect(itemRows[0]).toMatchObject({ dimensions: {} })
})
})
describe('commitPendingOperation: create_invoice as a quote (offert)', () => {
/** Quote queue: CAS claim → customers → company_settings → invoices insert →
* invoice_items insert → dispatcher update. No complete-invoice select:
* quotes never emit invoice.created. */
const quoteQueue = [
{ data: { id: 'op-1' } },
{ data: customer },
{ data: { vat_registered: true } },
{ data: { id: 'inv-q', invoice_number: 'OF-001' } },
{ data: null },
{ data: null },
]
it('allocates OF-nnn via generate_quote_number and never touches the F-series', async () => {
const base = createCapturingSupabase(quoteQueue)
const rpc = vi.fn().mockResolvedValue({ data: 'OF-001', error: null })
const supabase = { ...base.supabase, rpc }
const emitted: string[] = []
eventBus.on('invoice.created', async () => {
emitted.push('invoice.created')
})
const op = makePendingOp({
params: {
customer_id: 'cust-1',
document_type: 'quote',
valid_until: '2026-12-31',
items: [{ description: 'Konsulttimmar', quantity: 1, unit: 'tim', unit_price: 1000, vat_rate: 25 }],
invoice_date: '2026-09-02',
due_date: '2026-12-31',
},
})
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
expect(result.status).toBe('committed')
expect(rpc).toHaveBeenCalledTimes(1)
expect(rpc).toHaveBeenCalledWith('generate_quote_number', { p_company_id: 'company-1' })
expect(rpc).not.toHaveBeenCalledWith('generate_invoice_number', expect.anything())
expect(base.inserts['invoices'][0]).toMatchObject({
invoice_number: 'OF-001',
document_type: 'quote',
quote_status: 'open',
valid_until: '2026-12-31',
due_date: '2026-12-31',
total: 1250,
})
expect(emitted).toEqual([])
})
it('refuses a quote without valid_until', async () => {
const base = createCapturingSupabase([{ data: { id: 'op-1' } }])
const rpc = vi.fn()
const supabase = { ...base.supabase, rpc }
const op = makePendingOp({
params: {
customer_id: 'cust-1',
document_type: 'quote',
items: [{ description: 'Konsulttimmar', quantity: 1, unit: 'tim', unit_price: 1000, vat_rate: 25 }],
},
})
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
expect(result.status).not.toBe('committed')
expect(rpc).not.toHaveBeenCalled()
expect(base.inserts['invoices']).toBeUndefined()
})
it('fails closed when the OF-series allocation errors, inserting nothing', async () => {
const base = createCapturingSupabase([{ data: { id: 'op-1' } }, { data: customer }, { data: { vat_registered: true } }])
const rpc = vi.fn().mockResolvedValue({ data: null, error: { message: 'boom' } })
const supabase = { ...base.supabase, rpc }
const op = makePendingOp({
params: {
customer_id: 'cust-1',
document_type: 'quote',
valid_until: '2026-12-31',
items: [{ description: 'Konsulttimmar', quantity: 1, unit: 'tim', unit_price: 1000, vat_rate: 25 }],
},
})
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
expect(result.status).not.toBe('committed')
expect(base.inserts['invoices']).toBeUndefined()
})
})
@@ -163,4 +163,34 @@ describe('commitPendingOperation: mark_invoice_paid state + invoice.paid', () =>
// remaining balance (no custom amount param), so newStatus is always 'paid'.
// The partial case is pinned on the surfaces that can produce it, e.g.
// lib/invoices/__tests__/settle-invoice-payment.test.ts.
it('rejects a quote (offert) before creating a payment journal entry', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
enqueue({
data: {
id: 'q-1',
invoice_number: 'OF-001',
status: 'sent',
total: 2500,
remaining_amount: 0,
paid_amount: null,
credited_invoice_id: null,
document_type: 'quote',
quote_status: 'accepted',
journal_entry_id: null,
customer: { name: 'Test AB' },
},
error: null,
}) // invoice fetch
enqueue({ data: null, error: null }) // dispatcher pending_operations update
const op = makePendingOp({ params: { invoice_id: 'q-1', payment_date: '2026-06-30' } })
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
expect(result.status).toBe('rejected')
expect(result.http_status).toBe(409)
expect(mockCreatePaymentEntry).not.toHaveBeenCalled()
expect(mockCreateCashEntry).not.toHaveBeenCalled()
})
})
+110 -97
View File
@@ -60,6 +60,7 @@ import {
} from '@/lib/supplier-invoices/lifecycle'
import { coerceDimensionsBag } from '@/lib/bookkeeping/dimension-resolver'
import { ACCOUNT_NUMBER_RE } from '@/lib/invariants/account-number'
import { ISO_DATE_RE } from '@/lib/invariants/iso-date'
import { isSlpPensionAccount } from '@/lib/bookkeeping/slp-lines'
import { cancelOrphanedPaymentEntry } from '@/lib/bookkeeping/cancel-orphaned-entry'
import { runWithActor } from '@/lib/bookkeeping/actor-context-node'
@@ -139,6 +140,7 @@ import {
resolveInvoiceEmailRecipients,
} from '@/lib/invoices/email-recipients'
import { ensureInvoiceNumber } from '@/lib/invoices/ensure-invoice-number'
import { convertToInvoice } from '@/lib/invoices/convert-to-invoice'
import { invoicePdfFilename } from '@/lib/invoices/pdf-filename'
import {
recordManualInvoiceDelivery,
@@ -1867,6 +1869,14 @@ async function commitCreateInvoice(
params: Record<string, unknown>
): Promise<ExecutorResult> {
const customerId = params.customer_id as string
// Offert (quote): own OF-series allocated at insert, never an F-number,
// never books, never emits invoice.created. Only 'quote' is honoured here;
// anything else stays an ordinary invoice (staged params are caller JSON).
const isQuote = params.document_type === 'quote'
const validUntil = typeof params.valid_until === 'string' ? params.valid_until : null
if (isQuote && (!validUntil || !ISO_DATE_RE.test(validUntil))) {
return { error: 'Giltig till (valid_until) krävs för en offert.', status: 400 }
}
const items = params.items as Array<{
description: string; quantity: number; unit: string; unit_price: number; vat_rate?: number
discount_percent?: number | null
@@ -2046,15 +2056,41 @@ async function commitCreateInvoice(
}
})()
// Quotes are numbered at insert from their own OF-series (see
// generate_quote_number); ensureInvoiceNumber must never run on one.
let quoteNumber: string | null = null
if (isQuote) {
const { data: allocated, error: quoteNumberError } = await supabase.rpc('generate_quote_number', {
p_company_id: companyId,
})
if (quoteNumberError || !allocated) {
return {
error:
getErrorEntry('INVOICE_CREATE_NUMBER_ASSIGN_FAILED')?.message_sv ??
'Offertnumret kunde inte tilldelas.',
errorCode: 'INVOICE_CREATE_NUMBER_ASSIGN_FAILED',
status: 500,
}
}
quoteNumber = allocated as string
}
const { data: invoice, error: invoiceError } = await supabase
.from('invoices')
.insert({
user_id: userId,
company_id: companyId,
customer_id: customerId,
invoice_number: null,
invoice_number: quoteNumber,
invoice_date: invoiceDate,
due_date: (params.due_date as string) || null,
// A quote has no payment due date: due_date mirrors valid_until
// (build-invoice-write parity).
due_date: isQuote ? validUntil : (params.due_date as string) || null,
// Explicit keys, not a conditional spread: the phantom-column guard
// only reads literal payloads. NULLs on non-quotes satisfy the pairing CHECK.
document_type: isQuote ? 'quote' : 'invoice',
valid_until: isQuote ? validUntil : null,
quote_status: isQuote ? 'open' : null,
currency,
exchange_rate: exchangeRate,
exchange_rate_date: exchangeRateDate,
@@ -2064,10 +2100,12 @@ async function commitCreateInvoice(
vat_amount_sek: vatAmountSek,
total,
total_sek: totalSek,
// Fresh unpaid receivable: remaining_amount is what every payment
// A quote is an offer, not a claim: nothing is owed on it (parity with
// build-invoice-write). Otherwise a fresh unpaid receivable:
// remaining_amount is what every payment
// surface reads as the open balance; leaving the NOT NULL DEFAULT 0
// made every agent-created invoice look settled.
remaining_amount: total,
remaining_amount: isQuote ? 0 : total,
paid_amount: 0,
vat_treatment: notVatRegistered ? 'exempt' : vatRules.treatment,
vat_rate: isMixedRate ? null : (uniqueRates.values().next().value ?? vatRules.rate),
@@ -2138,20 +2176,24 @@ async function commitCreateInvoice(
return { error: itemsError.message, status: 500 }
}
const { data: completeInvoice } = await supabase
.from('invoices')
.select('*, customer:customers(*), items:invoice_items(*)')
.eq('id', invoice.id)
.single()
// invoice.created is for real invoices only: a quote is an offer, not a
// claim, and has no downstream consumer obligation (v1 route parity).
if (!isQuote) {
const { data: completeInvoice } = await supabase
.from('invoices')
.select('*, customer:customers(*), items:invoice_items(*)')
.eq('id', invoice.id)
.single()
if (completeInvoice) {
await eventBus.emit({
type: 'invoice.created',
payload: { invoice: completeInvoice as Invoice, userId, companyId },
})
if (completeInvoice) {
await eventBus.emit({
type: 'invoice.created',
payload: { invoice: completeInvoice as Invoice, userId, companyId },
})
}
}
return { data: { invoice_id: invoice.id, invoice_number: invoice.invoice_number } }
return { data: { invoice_id: invoice.id, invoice_number: invoice.invoice_number ?? quoteNumber } }
}
/**
@@ -2197,7 +2239,7 @@ async function commitUpdateInvoice(
const { data: existing, error: fetchError } = await supabase
.from('invoices')
.select(
'id, status, invoice_number, journal_entry_id, is_self_billed, credited_invoice_id, customer_id, document_type, invoice_date, due_date, delivery_date, currency, your_reference, our_reference, invoice_marking, notes, payment_link_url, payment_link_auto, ore_rounding, default_dimensions, deduction_personnummer_encrypted, deduction_personnummer_last4',
'id, status, invoice_number, journal_entry_id, is_self_billed, credited_invoice_id, quote_status, customer_id, document_type, invoice_date, due_date, delivery_date, currency, your_reference, our_reference, invoice_marking, notes, payment_link_url, payment_link_auto, ore_rounding, default_dimensions, deduction_personnummer_encrypted, deduction_personnummer_last4',
)
.eq('id', invoiceId)
.eq('company_id', companyId)
@@ -2389,6 +2431,14 @@ async function commitMarkInvoicePaid(
if (invoice.credited_invoice_id) {
return { error: 'Kreditfakturor kan inte markeras som betalda.', status: 409 }
}
// Parity with the dashboard mark-paid route: a quote is an offer, not a
// claim. Proformas keep working (a prepayment record with no verifikat).
if (invoice.document_type === 'quote') {
const entry = getErrorEntry('INVOICE_QUOTE_NOT_PAYABLE')
// 409 like the credit-note guard above: the dispatcher records it as
// rejected (a state refusal), not failed (an execution error).
return { error: entry?.message_sv ?? 'Only invoices can be paid.', errorCode: 'INVOICE_QUOTE_NOT_PAYABLE', status: 409 }
}
if (invoice.status !== 'sent' && invoice.status !== 'overdue') {
return { error: 'Invoice can only be marked as paid when status is "sent" or "overdue"', status: 409 }
}
@@ -3049,6 +3099,18 @@ async function commitMatchTransactionInvoice(
if (invoice.credited_invoice_id) {
return { error: 'Kreditfakturor kan inte registreras som betalda.', status: 409 }
}
// Parity with the dashboard match route (MATCH_INVOICE_NOT_INVOICE_TYPE):
// proformas, delivery notes and quotes carry no receivable to settle.
if (invoice.document_type && invoice.document_type !== 'invoice') {
const entry = getErrorEntry('MATCH_INVOICE_NOT_INVOICE_TYPE')
return {
error: entry?.message_sv ?? 'Only invoices can be matched to a transaction.',
errorCode: 'MATCH_INVOICE_NOT_INVOICE_TYPE',
// 409 like the credit-note guard above: the dispatcher records it as
// rejected (a state refusal), not failed (an execution error).
status: 409,
}
}
if (!['sent', 'overdue', 'partially_paid'].includes(invoice.status)) {
return { error: 'Invoice is not in a matchable state', status: 409 }
}
@@ -3407,6 +3469,24 @@ async function commitLinkInvoiceVoucher(
return { error: 'invoice_id and journal_entry_id are required', status: 400 }
}
// Only a faktura carries a receivable to settle (parity with the dashboard
// link route and the match executors); the RPC validates status only.
const { data: docRow } = await supabase
.from('invoices')
.select('document_type')
.eq('id', invoiceId)
.eq('company_id', companyId)
.maybeSingle()
const docType = (docRow as { document_type?: string | null } | null)?.document_type
if (docType && docType !== 'invoice') {
const entry = getErrorEntry('MATCH_INVOICE_NOT_INVOICE_TYPE')
return {
error: entry?.message_sv ?? 'Only invoices can be linked to a payment voucher.',
errorCode: 'MATCH_INVOICE_NOT_INVOICE_TYPE',
status: 409,
}
}
const outcome = await linkInvoiceToVoucher(supabase, userId, companyId, {
invoiceId,
journalEntryId,
@@ -5012,91 +5092,24 @@ async function commitConvertInvoice(
const id = params.invoice_id as string
if (!id) return { error: 'invoice_id is required', status: 400 }
const { data: proforma, error: proformaError } = await supabase
.from('invoices').select('*, items:invoice_items(*)').eq('id', id).eq('company_id', companyId).single()
// Shared with POST /api/invoices/[id]/convert: proforma or quote to
// invoice, F-number allocated last, source cancelled (proforma) or
// accepted (quote).
const result = await convertToInvoice({ supabase, userId, companyId, sourceId: id })
if (proformaError || !proforma) return { error: 'Proformafakturan hittades inte', status: 404 }
if (proforma.document_type !== 'proforma') {
return { error: 'Endast proformafakturor kan konverteras', status: 400 }
}
if (proforma.status === 'cancelled') {
return { error: 'Denna proformafaktura har redan makuleras', status: 409 }
}
const { data: invoice, error: invoiceError } = await supabase
.from('invoices')
.insert({
user_id: userId,
company_id: companyId,
customer_id: proforma.customer_id,
invoice_number: null,
invoice_date: new Date().toISOString().split('T')[0],
due_date: proforma.due_date,
currency: proforma.currency,
exchange_rate: proforma.exchange_rate,
exchange_rate_date: proforma.exchange_rate_date,
subtotal: proforma.subtotal,
subtotal_sek: proforma.subtotal_sek,
vat_amount: proforma.vat_amount,
vat_amount_sek: proforma.vat_amount_sek,
total: proforma.total,
remaining_amount: proforma.total,
paid_amount: 0,
total_sek: proforma.total_sek,
vat_treatment: proforma.vat_treatment,
vat_rate: proforma.vat_rate,
moms_ruta: proforma.moms_ruta,
reverse_charge_text: proforma.reverse_charge_text,
your_reference: proforma.your_reference,
our_reference: proforma.our_reference,
notes: proforma.notes,
document_type: 'invoice',
converted_from_id: id,
// Dimensions PR7: the converted invoice books with the proforma's bag.
default_dimensions: proforma.default_dimensions ?? {},
})
.select()
.single()
if (invoiceError) return { error: invoiceError.message, status: 500 }
try {
await ensureInvoiceNumber(supabase, companyId, invoice as Invoice)
} catch (err) {
await supabase.from('invoices').delete().eq('id', invoice.id)
return { error: err instanceof Error ? err.message : 'Failed to assign invoice number', status: 500 }
}
const items = (proforma.items ?? []).map((item: Record<string, unknown>) => ({
invoice_id: invoice.id,
sort_order: item.sort_order,
line_type: item.line_type ?? 'product',
description: item.description,
quantity: item.quantity,
unit: item.unit,
unit_price: item.unit_price,
line_total: item.line_total,
// Preserve per-line VAT and any article/revenue-account override from the
// proforma so the converted invoice books exactly as the proforma showed
// (mixed rates + per-article accounts both rely on these per-line fields).
vat_rate: item.vat_rate ?? 0,
vat_amount: item.vat_amount ?? 0,
revenue_account: item.revenue_account ?? null,
article_id: item.article_id ?? null,
dimensions: item.dimensions ?? {},
}))
if (items.length > 0) {
const { error: itemsError } = await supabase.from('invoice_items').insert(items)
if (itemsError) {
await supabase.from('invoices').delete().eq('id', invoice.id)
return { error: itemsError.message, status: 500 }
if (!result.ok) {
if (result.code === 'INVOICE_CONVERT_FAILED') {
return { error: result.cause.message, errorCode: result.code, status: 500 }
}
const entry = getErrorEntry(result.code)
return {
error: entry?.message_sv ?? result.code,
errorCode: result.code,
status: entry?.httpStatus ?? 400,
}
}
await supabase.from('invoices').update({ status: 'cancelled' }).eq('id', id)
return { data: { invoice_id: invoice.id, invoice_number: invoice.invoice_number } }
return { data: { invoice_id: result.invoice.id, invoice_number: result.invoice.invoice_number } }
}
async function commitImportSie(
+14 -1
View File
@@ -6,11 +6,15 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'
let resultIdx: number
let results: Array<{ data?: unknown; error?: unknown }>
let calls: Array<{ method: string; args: unknown[] }>
function makeBuilder() {
const b: Record<string, unknown> = {}
for (const m of ['select', 'eq', 'in', 'lte', 'order', 'range']) {
b[m] = vi.fn().mockReturnValue(b)
b[m] = vi.fn().mockImplementation((...args: unknown[]) => {
calls.push({ method: m, args })
return b
})
}
b.single = vi.fn().mockImplementation(async () => results[resultIdx++] ?? { data: null, error: null })
b.then = (resolve: (v: unknown) => void) => resolve(results[resultIdx++] ?? { data: null, error: null })
@@ -32,10 +36,19 @@ beforeEach(() => {
vi.clearAllMocks()
resultIdx = 0
results = []
calls = []
supabase = makeClient()
})
describe('generateARLedger', () => {
it('only reads fakturor: proformas, delivery notes and quotes are not receivables', async () => {
results = [{ data: [], error: null }]
await generateARLedger(supabase, 'company-1')
expect(calls).toContainEqual({ method: 'eq', args: ['document_type', 'invoice'] })
})
it('returns empty report when no invoices found', async () => {
results = [
{ data: [], error: null },
@@ -41,6 +41,14 @@ beforeEach(() => {
})
describe('generateARReconciliation', () => {
it('only reads fakturor: proformas, delivery notes and quotes are not receivables', async () => {
results = [{ data: [], error: null }, { data: [], error: null }]
await generateARReconciliation(supabase, 'company-1', 'period-1')
expect(calls).toContainEqual({ method: 'eq', args: ['document_type', 'invoice'] })
})
it('returns reconciled when AR ledger matches account 1510', async () => {
results = [
// 0: invoices
+2
View File
@@ -83,6 +83,8 @@ export async function generateARLedger(
.from('invoices')
.select('*, customer:customers(id, name)')
.eq('company_id', companyId)
// Proformas, delivery notes and quotes are never receivables.
.eq('document_type', 'invoice')
query = isHistorical
? query.in('status', ['sent', 'overdue', 'credited', 'paid']).lte('invoice_date', asOfDate!)
: query.in('status', ['sent', 'overdue', 'credited'])
+2
View File
@@ -55,6 +55,8 @@ export async function generateARReconciliation(
.from('invoices')
.select('id, total, paid_amount, currency, exchange_rate')
.eq('company_id', companyId)
// Proformas, delivery notes and quotes are never receivables.
.eq('document_type', 'invoice')
.in('status', ['sent', 'overdue'])
.order('id', { ascending: true })
.range(from, to)