feat(api): v1 invoice :mark-sent action verb (Phase 2 PR-B-2b-1) (#454)

* feat(api): v1 invoice :mark-sent action verb (Phase 2 PR-B-2b-1)

First invoice action verb. Transitions a DRAFT invoice to 'sent' status —
intended for invoices delivered outside gnubok (Peppol, postal, custom
SMTP). The full :send pipeline (PDF + email) builds on top of this in
PR-B-2b-3.

URL convention: plain /verb subpath (e.g. /invoices/:id/mark-sent), not
the AIP-style :verb suffix the plan originally proposed. Next.js routes
don't support `:` in folder names, and the Stripe/QuickBooks idiom is
plain subpaths anyway. The agent-facing docs can still describe the
action however we want.

What happens on commit:
1. F-series invoice_number allocated atomically via the
   generate_invoice_number RPC (per the PR-B-2a design — drafts have
   invoice_number=null until this transition, preserving the unbroken
   löpnummer series required by ML 17 kap 24§ p.2).
2. Status flips draft → sent.
3. For accrual + real invoices, posts the invoice journal entry via
   createInvoiceJournalEntry (Debit AR 1510 / Credit revenue 3xxx /
   Credit output VAT 26xx). Cash basis skips this; booking happens at
   payment time.
4. Writes journal_entry_id back onto the invoice row.
5. Emits invoice.sent.

Race-condition guard: the status update matches .eq('status', 'draft'),
so a concurrent transition between pre-flight and update returns 409
INVOICE_UPDATE_NOT_DRAFT.

Dry-run: returns a preview of the post-send invoice state including a
would_create_journal_entry flag and the resolved accounting_method.
invoice_number can't be predicted exactly (atomic sequence allocation)
so the preview shows a marker rather than a fake number.

PDF archival is deliberately NOT in this PR. The internal route does
it, but PDF rendering + document upload is a meaningful surface area
that belongs with :send (PR-B-2b-3) where email + PDF land together.

Test infrastructure: the makeFlexibleSupabase mock now supports
per-table result QUEUES (array form returns results in order across
multiple calls; single value returns same result every time). Required
to mock the pre-flight read (status=draft) and post-update read
(status=sent) on the same `invoices` table inside one request.

9 new tests covering happy path, idempotency, scope, draft-only guard,
delivery-note rejection, 404, UUID validation, dry-run preview shape,
and cash-method skip. 3174/3174 vitest pass; build clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(api): address PR #454 review (Greptile + swarm + Swedish compliance)

Real bugs / contract violations:

- Greptile P1 (guard order): the delivery_note guard ran AFTER the
  status check, so a sent delivery note returned 409 instead of the
  documented 400. Reordered: document-shape guards (delivery_note +
  credit_note + missing moms_ruta) now run before the status check.
- Greptile P1 (journal_entry_id write-back): Supabase returns
  { data, error } and never rejects on DB errors, so a write-back
  failure produced no log and left the invoice with a real journal
  entry but no pointer. Now destructured + escalated to error log AND
  surfaced as a warning in the response.
- Swedish: credit notes (credited_invoice_id !== null) were not
  rejected — they would have been posted via createInvoiceJournalEntry
  with the wrong sign (Debit AR / Credit revenue instead of the
  inverse). Now explicitly rejected; credit-note path goes through
  POST /:id/credit (PR-B-2b-4).
- Swedish: moms_ruta now validated in the pre-flight. A null value
  would silently default to 25% domestic in the journal-entry
  generator — wrong for reverse-charge / EU-service / zero-rated
  invoices. Real ML 17 kap 24§ concern.

Partial-state visibility (Swedish + Swarm V2.3 + A.8.15 + PI1.3):

The response now carries an optional `warnings: [{ code, message }]`
field when the status flip succeeded but a follow-up step failed
(journal entry creation, event emission, or journal_entry_id write-
back). Three warning codes:
  - JOURNAL_ENTRY_NOT_POSTED — verifikation missing; BFL 5 kap
    reconciliation required
  - JOURNAL_ENTRY_ID_WRITEBACK_FAILED — entry exists but invoice row
    has no pointer
  - EVENT_EMIT_FAILED — webhook subscribers may miss this transition
All three escalate to error-level logs. The architectural fix
(transactional Postgres RPC that bundles allocation + status flip
+ journal entry) is tracked as cross-surface compliance work; the
warnings field is the agent-facing signal until that lands.

The F-series race window (number allocated before status flip; a
concurrent transition can leave a consumed-but-orphaned number, ML
17 kap 24§ p.2 gap) is now explicitly documented in the route
docstring rather than hidden in implementation. Same residual issue
exists in the internal route; fix needs the transactional RPC.

Pushing back (consistent with prior triage):
- V8.2.1 explicit ownership check (wrapper handles — false positive)
- Cross-tenant IDOR test (duplicates wrapper test coverage)
- Pseudonymise IDs in logs (operational value > theoretical risk)
- Structured audit event sink (current ctx.log.info IS structured)
- Test fixture A.8.33 (NODE_ENV guard in place; Acme AB is canonical
  synthetic placeholder)
- Projection column narrowing (fields ARE used in the flow)
- company_settings hard-fail on miss (accrual default is normal)

3 new tests covering credit-note rejection, missing moms_ruta, and
the journal-entry-failed warnings path. Plus the delivery-note test
now asserts the guard ordering works for sent delivery notes too.
3177/3177 vitest pass; build clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-05-12 23:19:21 +02:00
committed by GitHub
co-authored by Claude Opus 4.7
parent e96cbe05d0
commit e4186523f9
4 changed files with 812 additions and 0 deletions
@@ -0,0 +1,416 @@
/**
* Integration tests for POST /api/v1/companies/:companyId/invoices/:id/mark-sent.
*/
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
beforeAll(() => {
if (process.env.NODE_ENV !== 'test') {
throw new Error(
`mark-sent route tests require NODE_ENV=test (got ${process.env.NODE_ENV ?? 'undefined'})`,
)
}
process.env.NEXT_PUBLIC_SUPABASE_URL ||= 'http://localhost:54321'
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ||= 'test-anon-key'
})
vi.mock('@/lib/auth/api-keys', async () => {
const actual = await vi.importActual<typeof import('@/lib/auth/api-keys')>('@/lib/auth/api-keys')
return {
...actual,
validateApiKey: vi.fn(),
createServiceClientNoCookies: vi.fn(),
}
})
vi.mock('@supabase/supabase-js', async () => {
const actual = await vi.importActual<typeof import('@supabase/supabase-js')>('@supabase/supabase-js')
return { ...actual, createClient: vi.fn().mockReturnValue({}) }
})
// Stub the F-series allocator — the route's flow is what we're testing.
vi.mock('@/lib/invoices/ensure-invoice-number', () => ({
ensureInvoiceNumber: vi.fn().mockResolvedValue(undefined),
}))
// Stub the journal-entry creator. Returns a fake entry so the route's
// "post entry, write back journal_entry_id" path is exercised.
vi.mock('@/lib/bookkeeping/invoice-entries', () => ({
createInvoiceJournalEntry: vi.fn().mockResolvedValue({
id: 'jjjjjjjj-jjjj-4jjj-8jjj-jjjjjjjjjjjj',
status: 'posted',
}),
}))
import { validateApiKey, createServiceClientNoCookies } from '@/lib/auth/api-keys'
import {
createInvoiceJournalEntry as mockedCreateEntry,
} from '@/lib/bookkeeping/invoice-entries'
import { POST as markSent } from '../route'
const mockValidate = validateApiKey as ReturnType<typeof vi.fn>
const mockServiceClient = createServiceClientNoCookies as ReturnType<typeof vi.fn>
const mockCreateJournalEntry = mockedCreateEntry as ReturnType<typeof vi.fn>
type MockResult = { data?: unknown; error?: unknown }
function makeFlexibleSupabase(byTable: Record<string, MockResult | MockResult[]>) {
// Per-table queue: arrays return results in order across multiple calls
// to .from('table'); single values return the same result every time.
const queues = new Map<string, MockResult[]>()
for (const [t, val] of Object.entries(byTable)) {
queues.set(t, Array.isArray(val) ? [...val] : [val])
}
const buildChain = (table: string): unknown => {
const handler: ProxyHandler<object> = {
get(_target, prop) {
if (prop === 'then') {
return (resolve: (v: unknown) => void) => {
const q = queues.get(table)
const next = q && q.length > 1 ? q.shift()! : (q?.[0] ?? { data: null, error: null })
resolve(next)
}
}
return (..._args: unknown[]) => buildChain(table)
},
}
return new Proxy({}, handler)
}
return { from: vi.fn((table: string) => buildChain(table)) }
}
const COMPANY_ID = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'
const INVOICE_ID = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb'
const USER_ID = 'user-1'
function makeMarkSentRequest(url: string, extraHeaders: Record<string, string> = {}): Request {
return new Request(url, {
method: 'POST',
headers: {
Authorization: 'Bearer test-fixture-not-a-real-key',
'Idempotency-Key': 'idem1234-7777-4abc-8def-1234567890ab',
...extraHeaders,
},
})
}
function detailParams(companyId: string, id: string) {
return { params: Promise.resolve({ companyId, id }) }
}
const DRAFT_INVOICE = {
id: INVOICE_ID,
invoice_number: null,
customer_id: 'cccccccc-cccc-4ccc-8ccc-cccccccccccc',
invoice_date: '2026-05-12',
due_date: '2026-06-11',
status: 'draft',
document_type: 'invoice',
currency: 'SEK',
subtotal: 10000,
vat_amount: 2500,
total: 12500,
vat_treatment: 'standard_25',
moms_ruta: '05',
credited_invoice_id: null,
customer: { id: 'cccccccc-cccc-4ccc-8ccc-cccccccccccc', name: 'Acme AB', country: 'Sweden' },
items: [{ id: 'iiiiiiii-iiii-4iii-8iii-iiiiiiiiiiii', sort_order: 0, description: 'x', quantity: 1, unit: 'st', unit_price: 10000, line_total: 10000, vat_rate: 25, vat_amount: 2500 }],
}
const SENT_INVOICE = { ...DRAFT_INVOICE, status: 'sent', invoice_number: '2026-0042' }
beforeEach(() => {
vi.clearAllMocks()
mockValidate.mockResolvedValue({
userId: USER_ID,
companyId: COMPANY_ID,
apiKeyId: 'ak_1',
apiKeyName: 'CI key',
scopes: ['invoices:write'],
mode: 'live',
})
})
describe('POST /api/v1/companies/:companyId/invoices/:id/mark-sent', () => {
it('transitions a draft invoice to sent and writes the journal entry id back', async () => {
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
// First read: pre-flight (status=draft); second: post-update (status=sent).
invoices: [
{ data: DRAFT_INVOICE, error: null },
{ data: SENT_INVOICE, error: null },
],
company_settings: { data: { accounting_method: 'accrual', entity_type: 'enskild_firma' }, error: null },
}),
)
const res = await markSent(
makeMarkSentRequest(
`https://x.test/api/v1/companies/${COMPANY_ID}/invoices/${INVOICE_ID}/mark-sent`,
),
detailParams(COMPANY_ID, INVOICE_ID),
)
expect(res.status).toBe(200)
const body = await res.json()
expect(body.data.status).toBe('sent')
expect(body.data.invoice_number).toBe('2026-0042')
expect(body.data.journal_entry_id).toBe('jjjjjjjj-jjjj-4jjj-8jjj-jjjjjjjjjjjj')
expect(mockCreateJournalEntry).toHaveBeenCalledTimes(1)
})
it('returns 409 INVOICE_UPDATE_NOT_DRAFT when the invoice is already sent', async () => {
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
invoices: { data: { ...DRAFT_INVOICE, status: 'sent' }, error: null },
}),
)
const res = await markSent(
makeMarkSentRequest(
`https://x.test/api/v1/companies/${COMPANY_ID}/invoices/${INVOICE_ID}/mark-sent`,
),
detailParams(COMPANY_ID, INVOICE_ID),
)
expect(res.status).toBe(409)
const body = await res.json()
expect(body.error.code).toBe('INVOICE_UPDATE_NOT_DRAFT')
expect(body.error.details.current_status).toBe('sent')
})
it('rejects delivery notes with VALIDATION_ERROR (regardless of status)', async () => {
// Critical: the delivery-note guard must run BEFORE the status check
// so a sent delivery note still returns 400 (per the documented
// contract) rather than 409 INVOICE_UPDATE_NOT_DRAFT.
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
invoices: { data: { ...DRAFT_INVOICE, document_type: 'delivery_note', status: 'sent' }, error: null },
}),
)
const res = await markSent(
makeMarkSentRequest(
`https://x.test/api/v1/companies/${COMPANY_ID}/invoices/${INVOICE_ID}/mark-sent`,
),
detailParams(COMPANY_ID, INVOICE_ID),
)
expect(res.status).toBe(400)
const body = await res.json()
expect(body.error.code).toBe('VALIDATION_ERROR')
expect(body.error.details.field).toBe('document_type')
})
it('rejects credit notes (credited_invoice_id set) with VALIDATION_ERROR', async () => {
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
invoices: {
data: { ...DRAFT_INVOICE, credited_invoice_id: 'oldoldol-dold-4old-8old-oldoldoldoldold'.slice(0, 36) },
error: null,
},
}),
)
const res = await markSent(
makeMarkSentRequest(
`https://x.test/api/v1/companies/${COMPANY_ID}/invoices/${INVOICE_ID}/mark-sent`,
),
detailParams(COMPANY_ID, INVOICE_ID),
)
expect(res.status).toBe(400)
const body = await res.json()
expect(body.error.code).toBe('VALIDATION_ERROR')
expect(body.error.details.field).toBe('credited_invoice_id')
})
it('rejects invoices with missing moms_ruta', async () => {
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
invoices: { data: { ...DRAFT_INVOICE, moms_ruta: null }, error: null },
}),
)
const res = await markSent(
makeMarkSentRequest(
`https://x.test/api/v1/companies/${COMPANY_ID}/invoices/${INVOICE_ID}/mark-sent`,
),
detailParams(COMPANY_ID, INVOICE_ID),
)
expect(res.status).toBe(400)
const body = await res.json()
expect(body.error.code).toBe('VALIDATION_ERROR')
expect(body.error.details.field).toBe('moms_ruta')
})
it('surfaces a warning in the response when journal entry creation fails', async () => {
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
invoices: [
{ data: DRAFT_INVOICE, error: null },
{ data: SENT_INVOICE, error: null },
],
company_settings: { data: { accounting_method: 'accrual', entity_type: 'enskild_firma' }, error: null },
}),
)
// Force the journal-entry generator to throw.
mockCreateJournalEntry.mockRejectedValueOnce(new Error('Period closed'))
const res = await markSent(
makeMarkSentRequest(
`https://x.test/api/v1/companies/${COMPANY_ID}/invoices/${INVOICE_ID}/mark-sent`,
),
detailParams(COMPANY_ID, INVOICE_ID),
)
expect(res.status).toBe(200)
const body = await res.json()
// Status STILL flips to sent.
expect(body.data.status).toBe('sent')
expect(body.data.journal_entry_id).toBeNull()
// But the caller is warned.
expect(body.data.warnings).toBeDefined()
expect(body.data.warnings[0].code).toBe('JOURNAL_ENTRY_NOT_POSTED')
})
it('returns 404 when the invoice does not belong to the company', async () => {
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
invoices: { data: null, error: null },
}),
)
const res = await markSent(
makeMarkSentRequest(
`https://x.test/api/v1/companies/${COMPANY_ID}/invoices/${INVOICE_ID}/mark-sent`,
),
detailParams(COMPANY_ID, INVOICE_ID),
)
expect(res.status).toBe(404)
const body = await res.json()
expect(body.error.code).toBe('NOT_FOUND')
})
it('returns 400 VALIDATION_ERROR when :id is not a UUID', async () => {
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
}),
)
const res = await markSent(
makeMarkSentRequest(
`https://x.test/api/v1/companies/${COMPANY_ID}/invoices/not-a-uuid/mark-sent`,
),
detailParams(COMPANY_ID, 'not-a-uuid'),
)
expect(res.status).toBe(400)
const body = await res.json()
expect(body.error.code).toBe('VALIDATION_ERROR')
expect(body.error.details.field).toBe('id')
})
it('dry-run returns 200 + X-Dry-Run; no journal entry is created', async () => {
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
invoices: { data: DRAFT_INVOICE, error: null },
company_settings: { data: { accounting_method: 'accrual', entity_type: 'enskild_firma' }, error: null },
}),
)
const res = await markSent(
makeMarkSentRequest(
`https://x.test/api/v1/companies/${COMPANY_ID}/invoices/${INVOICE_ID}/mark-sent?dry_run=true`,
),
detailParams(COMPANY_ID, INVOICE_ID),
)
expect(res.status).toBe(200)
expect(res.headers.get('X-Dry-Run')).toBe('true')
const body = await res.json()
expect(body.data.dry_run).toBe(true)
expect(body.data.preview.status).toBe('sent')
expect(body.data.preview.would_create_journal_entry).toBe(true)
expect(body.data.preview.accounting_method).toBe('accrual')
// No mutation calls.
expect(mockCreateJournalEntry).not.toHaveBeenCalled()
})
it('does NOT create a journal entry when accounting_method=cash', async () => {
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
invoices: [
{ data: DRAFT_INVOICE, error: null },
{ data: SENT_INVOICE, error: null },
],
company_settings: { data: { accounting_method: 'cash', entity_type: 'enskild_firma' }, error: null },
}),
)
const res = await markSent(
makeMarkSentRequest(
`https://x.test/api/v1/companies/${COMPANY_ID}/invoices/${INVOICE_ID}/mark-sent`,
),
detailParams(COMPANY_ID, INVOICE_ID),
)
expect(res.status).toBe(200)
const body = await res.json()
expect(body.data.status).toBe('sent')
expect(body.data.journal_entry_id).toBeNull()
expect(mockCreateJournalEntry).not.toHaveBeenCalled()
})
it('rejects keys without invoices:write scope', async () => {
mockValidate.mockResolvedValue({
userId: USER_ID,
companyId: COMPANY_ID,
scopes: ['invoices:read'],
mode: 'live',
})
mockServiceClient.mockReturnValue(makeFlexibleSupabase({}))
const res = await markSent(
makeMarkSentRequest(
`https://x.test/api/v1/companies/${COMPANY_ID}/invoices/${INVOICE_ID}/mark-sent`,
),
detailParams(COMPANY_ID, INVOICE_ID),
)
expect(res.status).toBe(403)
const body = await res.json()
expect(body.error.code).toBe('INSUFFICIENT_SCOPE')
})
it('rejects requests without Idempotency-Key', async () => {
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
}),
)
const req = new Request(
`https://x.test/api/v1/companies/${COMPANY_ID}/invoices/${INVOICE_ID}/mark-sent`,
{
method: 'POST',
headers: { Authorization: 'Bearer test-fixture-not-a-real-key' },
},
)
const res = await markSent(req, detailParams(COMPANY_ID, INVOICE_ID))
expect(res.status).toBe(400)
const body = await res.json()
expect(body.error.code).toBe('VALIDATION_ERROR')
})
})
@@ -0,0 +1,391 @@
/**
* POST /api/v1/companies/{companyId}/invoices/{id}/mark-sent
*
* Transitions a DRAFT invoice to `sent` status. Use this for invoices
* delivered outside the system (Peppol, postal, custom email). The full
* :send pipeline (PDF + email) will land in PR-B-2b-3.
*
* What happens on commit:
* 1. F-series invoice_number is allocated atomically via the
* generate_invoice_number Postgres RPC (ML 17 kap 24§ p.2 — only
* issued invoices consume numbers; this is where the F-series
* number gets assigned, NOT at draft-create per PR-B-2a's design).
* 2. Invoice status flips to 'sent'.
* 3. If accounting_method='accrual' AND document_type='invoice', a
* journal entry is posted via createInvoiceJournalEntry (Debit AR
* 1510, Credit revenue 3xxx, Credit output VAT 2611/2621/2631).
* Under kontantmetoden ('cash') no journal entry is created here —
* booking happens at payment time.
* 4. invoice.sent event is emitted.
*
* Idempotent (mandatory Idempotency-Key). Dry-run shows the would-be
* post-send state without allocating a number, posting a journal entry,
* or emitting events.
*
* Known residual race window: the F-series number is allocated via the
* generate_invoice_number RPC BEFORE the status-flip UPDATE. If a
* concurrent transition wins the race-guard check (status='draft' filter),
* the F-series number is consumed but no invoice carries it — a gap in
* the löpnummer series (ML 17 kap 24§ p.2). The internal /api/invoices
* mark-sent route has the same semantic. The architecturally correct
* fix is a Postgres RPC that allocates + flips status atomically;
* tracked as cross-surface compliance work. The race window is narrow
* (sub-millisecond between the two statements in normal load).
*/
import { z } from 'zod'
import { ok } from '@/lib/api/v1/response'
import { dryRunPreview } from '@/lib/api/v1/dry-run'
import { registerEndpoint } from '@/lib/api/v1/registry'
import { withApiV1 } from '@/lib/api/v1/with-api-v1'
import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors'
import { createInvoiceJournalEntry } from '@/lib/bookkeeping/invoice-entries'
import { ensureInvoiceNumber } from '@/lib/invoices/ensure-invoice-number'
import { eventBus } from '@/lib/events'
import type { EntityType, Invoice } from '@/types'
// Explicit projection — drops user_id, company_id (internal scoping).
const INVOICE_MARK_SENT_RESPONSE_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, vat_treatment, vat_rate, moms_ruta, your_reference, our_reference, notes, reverse_charge_text, credited_invoice_id, document_type, converted_from_id, paid_at, paid_amount, remaining_amount, created_at, updated_at'
const InvoiceMarkSentResponse = z.object({
id: z.string().uuid(),
invoice_number: z.string(),
status: z.literal('sent'),
total: z.number(),
journal_entry_id: z.string().uuid().nullable(),
// Present only when the status flip succeeded but a follow-up step
// (journal entry creation, event emission) failed and the response
// therefore reflects partial state. Agents that need transactional
// guarantees can detect this without parsing the body.
warnings: z
.array(z.object({ code: z.string(), message: z.string() }))
.optional(),
})
registerEndpoint({
operation: 'invoices.mark-sent',
method: 'POST',
path: '/api/v1/companies/:companyId/invoices/:id/mark-sent',
summary: 'Transition a draft invoice to sent (without emailing).',
description:
'Marks a draft invoice as sent — for invoices delivered outside gnubok (Peppol, postal, manual email). Allocates the F-series invoice_number atomically (ML 17 kap 24§ p.2). On accounting_method=accrual, also posts the invoice journal entry (Debit AR 1510 / Credit revenue + output VAT). Emits invoice.sent. Idempotent and dry-runnable. The companion :send action (PR-B-2b-3) adds PDF rendering and email delivery on top of this same flow.',
useWhen:
'You delivered the invoice through a channel other than gnubok\'s email (Peppol, postal, your own SMTP) and need to record it as sent so the F-series number is allocated and the journal entry is posted.',
doNotUseFor:
'Sending the invoice via gnubok email — use :send (PR-B-2b-3) for that. Marking an already-sent invoice as paid — use :mark-paid (PR-B-2b-2).',
pitfalls: [
'Only invoices in `status=draft` can be marked sent. Other states return 409 INVOICE_UPDATE_NOT_DRAFT (re-used; the action is structurally an update).',
'Allocation is atomic. If a concurrent transition beats the agent\'s request to the same draft, the runner-up gets 409 INVOICE_UPDATE_NOT_DRAFT and no number is consumed.',
'Delivery notes (document_type=delivery_note) don\'t transition to sent — they were never drafts in the f-series sense. This endpoint will reject them with 400 VALIDATION_ERROR.',
'Idempotency-Key is mandatory. A retried mark-sent with the same key replays the cached response.',
],
example: {
response: {
data: {
id: '0e9c…',
invoice_number: '2026-0042',
status: 'sent',
total: 12500,
journal_entry_id: '7b3a…',
},
meta: { request_id: 'req_…', api_version: '2026-05-12' },
},
},
scope: 'invoices:write',
risk: 'medium',
idempotent: true,
reversible: false,
dryRunSupported: true,
response: { success: InvoiceMarkSentResponse },
})
export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string }> }>(
'invoices.mark-sent',
async (_request, ctx, params) => {
const { id } = await params.params
const idParse = z.string().uuid().safeParse(id)
if (!idParse.success) {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: { field: 'id', message: 'Invoice id must be a UUID.' },
})
}
const invoiceId = idParse.data
if (!z.string().uuid().safeParse(ctx.companyId).success) {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: { field: 'companyId', message: 'companyId must be a UUID.' },
})
}
// Pre-flight: fetch the invoice (with items + customer for the journal-
// entry generator) and verify it's a draft.
const { data: invoice, error: fetchErr } = await ctx.supabase
.from('invoices')
.select(
`${INVOICE_MARK_SENT_RESPONSE_COLUMNS}, customer:customers(id, name, customer_type, country), items:invoice_items(id, sort_order, description, quantity, unit, unit_price, line_total, vat_rate, vat_amount)`,
)
.eq('company_id', ctx.companyId!)
.eq('id', invoiceId)
.maybeSingle()
if (fetchErr) {
return v1ErrorResponse(fetchErr, ctx.log, { requestId: ctx.requestId })
}
if (!invoice) {
ctx.log.warn('invoices.mark-sent: not found', { invoiceId, companyId: ctx.companyId })
return v1ErrorResponseFromCode('NOT_FOUND', ctx.log, {
requestId: ctx.requestId,
details: { resource: 'invoice' },
})
}
const typed = invoice as unknown as Invoice & { customer?: { name?: string } }
// Type/document-shape guards run BEFORE the status check so the
// returned error matches the documented contract (400 VALIDATION_ERROR
// for delivery notes / credit notes regardless of their current
// status; 409 INVOICE_UPDATE_NOT_DRAFT only for genuine invoices).
if (typed.document_type === 'delivery_note') {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: {
field: 'document_type',
message: 'Delivery notes are not transitioned via mark-sent; they have no F-series lifecycle.',
},
})
}
// Credit notes (document_type='invoice' but credited_invoice_id set)
// need the credit-note journal entry generator (reverses sign of the
// original invoice). The PR-B-2b-4 :credit endpoint handles them.
// Reject here so we don't post the wrong-direction journal entry.
if (typed.credited_invoice_id) {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: {
field: 'credited_invoice_id',
message: 'Credit notes are issued via POST /invoices/:id/credit (PR-B-2b-4); they cannot be mark-sent like regular invoices.',
},
})
}
if (typed.status !== 'draft') {
return v1ErrorResponseFromCode('INVOICE_UPDATE_NOT_DRAFT', ctx.log, {
requestId: ctx.requestId,
details: { current_status: typed.status },
})
}
// Defense in depth: moms_ruta drives which output-VAT account the
// journal-entry generator posts to (2611 / 2614 / etc.). A null value
// would silently default — wrong for reverse-charge / EU-service /
// zero-rated invoices. moms_ruta is populated by the POST handler
// from getVatRules(); a null here means the row was created via a
// path that bypassed v1 (legacy import, manual SQL).
if (!typed.moms_ruta) {
ctx.log.warn('invoices.mark-sent: missing moms_ruta', {
invoiceId,
companyId: ctx.companyId,
})
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: {
field: 'moms_ruta',
message: 'Invoice has no moms_ruta set. The customer\'s VAT rule must be applied (re-create the draft via POST /invoices).',
},
})
}
// Fetch company settings (accounting method + entity type drive the
// journal-entry decision). Best-effort — without settings we default
// to enskild_firma / accrual which matches the dashboard default.
const { data: settings } = await ctx.supabase
.from('company_settings')
.select('accounting_method, entity_type')
.eq('company_id', ctx.companyId!)
.maybeSingle()
const accountingMethod = (settings as { accounting_method?: string } | null)?.accounting_method ?? 'accrual'
const entityType = ((settings as { entity_type?: string } | null)?.entity_type ?? 'enskild_firma') as EntityType
const isRealInvoice = !typed.document_type || typed.document_type === 'invoice'
const wouldCreateJournalEntry = isRealInvoice && accountingMethod === 'accrual'
if (ctx.dryRun) {
// Preview the post-send state. invoice_number can't be predicted
// exactly (atomic sequence allocation); show a marker so the agent
// knows commit will assign one.
return dryRunPreview(
{
...typed,
status: 'sent' as const,
invoice_number: typed.invoice_number ?? '(allocated atomically on commit)',
would_create_journal_entry: wouldCreateJournalEntry,
accounting_method: accountingMethod,
},
{ requestId: ctx.requestId, log: ctx.log },
)
}
// Commit path. Step 1: F-series invoice_number allocation. The RPC is
// atomic; the helper writes the number back onto the invoice row.
try {
await ensureInvoiceNumber(ctx.supabase, ctx.companyId!, typed)
} catch (err) {
ctx.log.error('mark-sent: ensureInvoiceNumber failed', err as Error, {
invoiceId,
companyId: ctx.companyId,
})
return v1ErrorResponseFromCode('INVOICE_SEND_NUMBER_ASSIGN_FAILED', ctx.log, {
requestId: ctx.requestId,
})
}
// Step 2: flip status to 'sent'. Guard with status='draft' so a
// concurrent transition becomes a 409 rather than a silent re-flip.
const { data: updated, error: statusErr } = await ctx.supabase
.from('invoices')
.update({ status: 'sent', updated_at: new Date().toISOString() })
.eq('company_id', ctx.companyId!)
.eq('id', invoiceId)
.eq('status', 'draft')
.select(INVOICE_MARK_SENT_RESPONSE_COLUMNS)
.maybeSingle()
if (statusErr) {
ctx.log.error('mark-sent: status update failed', statusErr as Error, {
invoiceId,
companyId: ctx.companyId,
pgCode: (statusErr as { code?: string }).code,
})
return v1ErrorResponseFromCode('INVOICE_SEND_PROVIDER_FAILED', ctx.log, {
requestId: ctx.requestId,
})
}
if (!updated) {
// Race: invoice transitioned out of draft between our pre-flight and
// the update. The F-series number has been consumed (atomic RPC), so
// we have a draft-cancelled with an allocated number — log so the
// operator can investigate.
ctx.log.warn(
'mark-sent: status race — invoice transitioned out of draft between pre-flight and update',
{ invoiceId, companyId: ctx.companyId },
)
return v1ErrorResponseFromCode('INVOICE_UPDATE_NOT_DRAFT', ctx.log, {
requestId: ctx.requestId,
details: { reason: 'Invoice transitioned out of draft during mark-sent.' },
})
}
// Collect partial-state signals to surface on the response. BFL 5 kap
// requires every affärshändelse to have a verifikation; if the
// journal-entry creation fails after the status flip, the response
// must surface this so the agent (or the dashboard, or a monitoring
// sink) can reconcile rather than silently treating the invoice as
// fully posted.
const warnings: { code: string; message: string }[] = []
// Step 3: journal entry for accrual + real invoices. Failure escalates
// to error-level log AND surfaces in the response as a warning.
let journalEntryId: string | null = null
if (wouldCreateJournalEntry) {
try {
// Pass the just-updated invoice (carries the new invoice_number).
const refreshedInvoice = { ...typed, ...(updated as object), customer: typed.customer } as Invoice & { customer?: { name?: string } }
const entry = await createInvoiceJournalEntry(
ctx.supabase,
ctx.companyId!,
ctx.userId,
refreshedInvoice as Invoice,
entityType,
refreshedInvoice.customer?.name,
)
if (entry) {
journalEntryId = entry.id
// Supabase returns { data, error } — never rejects on DB error.
// A failed write-back leaves the invoice with a real journal
// entry in the ledger but no pointer on the row, which is
// unreconcilable without operator visibility.
const { error: writeBackErr } = await ctx.supabase
.from('invoices')
.update({ journal_entry_id: entry.id })
.eq('id', invoiceId)
.eq('company_id', ctx.companyId!)
if (writeBackErr) {
ctx.log.error('mark-sent: journal_entry_id write-back failed', writeBackErr as Error, {
invoiceId,
companyId: ctx.companyId,
journalEntryId: entry.id,
})
warnings.push({
code: 'JOURNAL_ENTRY_ID_WRITEBACK_FAILED',
message: 'Journal entry was posted but the invoice row could not be updated with its id. Re-fetch the invoice and reconcile manually.',
})
}
} else {
// null result = no fiscal period or other engine-side guard.
ctx.log.error('mark-sent: journal entry not created (engine returned null)', new Error('null entry'), {
invoiceId,
companyId: ctx.companyId,
})
warnings.push({
code: 'JOURNAL_ENTRY_NOT_POSTED',
message: 'Invoice was marked sent but no journal entry was posted. Check fiscal period, then issue a credit note and reissue if the missing verifikation is required (BFL 5 kap).',
})
}
} catch (err) {
ctx.log.error('mark-sent: journal entry creation failed', err as Error, {
invoiceId,
companyId: ctx.companyId,
})
warnings.push({
code: 'JOURNAL_ENTRY_NOT_POSTED',
message: 'Invoice was marked sent but the journal entry posting failed. Check fiscal period and engine logs; the verifikation must be created for BFL 5 kap compliance.',
})
}
}
// Step 4: emit invoice.sent. Best-effort; escalate to error if it
// fails (downstream webhook delivery and audit trails depend on this).
try {
await eventBus.emit({
type: 'invoice.sent',
payload: {
invoice: { ...(typed as object), ...(updated as object) } as Invoice,
companyId: ctx.companyId!,
userId: ctx.userId,
},
})
} catch (err) {
ctx.log.error('invoice.sent emit failed', err as Error, {
invoiceId,
companyId: ctx.companyId,
})
warnings.push({
code: 'EVENT_EMIT_FAILED',
message: 'invoice.sent event did not reach the bus; downstream subscribers (webhooks) may miss this transition.',
})
}
ctx.log.info('invoices.mark-sent success', {
invoiceId,
companyId: ctx.companyId,
userId: ctx.userId,
invoiceNumber: (updated as { invoice_number?: string }).invoice_number,
journalEntryId,
hadWarnings: warnings.length > 0,
})
return ok(
{
...(updated as object),
journal_entry_id: journalEntryId,
...(warnings.length > 0 ? { warnings } : {}),
},
{ requestId: ctx.requestId },
)
},
{ requireIdempotencyKey: true },
)
+2
View File
@@ -20,5 +20,7 @@ import '@/app/api/v1/companies/[companyId]/invoices/route'
import '@/app/api/v1/companies/[companyId]/invoices/[id]/route'
import '@/app/api/v1/companies/[companyId]/customers/route'
import '@/app/api/v1/companies/[companyId]/customers/[id]/route'
// Phase 2 PR-B-2b — invoice action verbs.
import '@/app/api/v1/companies/[companyId]/invoices/[id]/mark-sent/route'
export {}
+3
View File
@@ -58,6 +58,9 @@ export const V1_ENDPOINT_SCOPES: Record<string, ApiKeyScope> = {
'GET /api/v1/companies/:companyId/invoices/:id': 'invoices:read',
'POST /api/v1/companies/:companyId/invoices': 'invoices:write',
'PATCH /api/v1/companies/:companyId/invoices/:id': 'invoices:write',
// Phase 2 PR-B-2b — action verbs. URL uses /verb subpath (not Google-AIP-style :verb)
// because Next.js routes don't support `:` in folder names.
'POST /api/v1/companies/:companyId/invoices/:id/mark-sent': 'invoices:write',
// Webhooks (Phase 6 — placeholder so the catalogue is complete)
'GET /api/v1/companies/:companyId/webhooks': 'webhooks:manage',