feat(api): v1 invoice :send + bulk-create (Phase 2 PR-B-2b-3 + PR-B-2c) (#458)
* feat(api): v1 invoice :send + bulk-create action verbs (Phase 2 PR-B-2b-3 + PR-B-2c)
Combined chunk: ship the full :send pipeline and partial-success bulk
creation in one PR, plus include the dangling /reset-password middleware
fix that completes PR-455's password-recovery flow.
POST /api/v1/companies/:companyId/invoices/:id/send
Full send pipeline mirroring the internal route, hardened for the public
API surface: email-configured check, draft-only guard, cancelled /
delivery-note / credit-note / missing-moms_ruta rejections, customer
email check, company-settings fetch, F-series invoice-number allocation
(atomic at :send per ML 17 kap 24§ p.2, not at draft create), preflight
PDF render before number consumption, final PDF render, email send,
point-of-no-return status flip, BFL 5 kap journal entry, document
archival, invoice.sent event emit. Post-send failures (journal entry,
archive) surface via a `warnings` array rather than failing the response
— the invoice IS sent at that point. Dry-run validates the pipeline +
preflight PDF without allocating a number or hitting the provider.
Error codes: INVOICE_SEND_EMAIL_NOT_CONFIGURED (503),
INVOICE_SEND_NO_CUSTOMER_EMAIL / _CANCELLED /
_COMPANY_SETTINGS_MISSING (400), INVOICE_UPDATE_NOT_DRAFT (409),
INVOICE_SEND_PDF_RENDER_FAILED / _NUMBER_ASSIGN_FAILED (500),
INVOICE_SEND_PROVIDER_FAILED (502).
POST /api/v1/companies/:companyId/invoices/bulk-create
Batch create up to 50 invoices in a single call, sequential processing,
partial success: `{ results: [{ ok, request_index, data?, error? }],
summary: { total, succeeded, failed } }`. Per-item rollback on items
insert failure (delete the parent invoice row). Emits invoice.created
per success. Dry-run wraps results in a preview without inserting.
`all_or_nothing` is accepted but reserved for a future PR.
lib/supabase/middleware.ts
Add /reset-password bypass before the authenticated-user redirect so
password-recovery sessions don't bounce to '/'. This should have landed
in PR-455 — the `git add 'app/(auth)'` filter missed the middleware
file at lib/. Without this the recovery email link silently fails for
the recipient.
Tests: 14 new integration tests across both routes (happy path,
provider failure, scope rejection, draft-only guard, dry-run shape,
bulk partial-success, max-50 enforcement, validation error). Full suite
green (3207 passing, 1 unrelated pre-existing pg-real failure).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(api): address PR #458 review — :send + bulk-create hardening
Greptile P1: silent zero-row update after email delivery.
PostgREST returns { error: null } on 0-row UPDATEs. The post-email
status flip used `.eq('status', 'draft')` as an optimistic lock but
never inspected the row count, so a concurrent state change (race,
double-send from another session) would leave the DB row in 'draft'
while the response claimed 'sent' and the email was already gone.
Fix: `.select('id')` after the update and check `flipRows.length`;
on 0-row miss, push STATUS_UPDATE_FAILED warning AND change the
response status to 'draft' so the caller can reconcile.
Greptile P1: re-read error swallowed; invoice_number could vanish
from the response.
After ensureInvoiceNumber, the re-read query destructured only `data`,
silently dropping `error`. A transient connection failure would leave
`numbered` null and `finalInvoiceNumber` undefined; JSON serialization
would then omit the field, violating the documented response schema.
Fix: capture `reReadErr`, log a warning, fall back to typed.invoice_number
(which was just written by the RPC and is authoritative in-memory).
Apply the same fallback at the top-level `ok()` call.
Greptile P2 + Compliance Swarm V2.3 + Swedish-compliance kreditfaktura:
reject credit notes from :send.
The :credit endpoint creates credit notes atomically in 'sent' state
with their own number — there is no v1 path that produces a draft
credit note, so reaching :send with credited_invoice_id set is misuse
or manual DB editing. Allowing it would assign an F-series number to
a kreditfaktura (ML 17 kap 22–23§ require a distinct kreditfaktura
series and a back-reference that this route would not enforce). Fix:
reject with VALIDATION_ERROR pointing at /credit. Removes a stretch
of dead code (originalInvoiceNumber lookup, kreditfaktura filename
branch) that can never execute now.
Greptile P2: all_or_nothing: true silently treated as false.
A caller asking for atomic semantics must not get partial-success
behaviour with no runtime signal. Fix: reject with new
NOT_IMPLEMENTED error (501) plus a details.field pointer. Schema
still accepts the flag for forward compatibility once a DB-side RPC
ships. New error code added to lib/errors/structured-errors.ts.
Tests: 3 new integration cases (credit-note rejection, status-flip
no-op warning, all_or_nothing 501). Suite green: 3218 passing.
Build clean, lint clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(api): PR #458 follow-up — defense-in-depth + comment precision
OWASP V8.2.1 (bulk-create): use DB-returned customer.id at insert time
instead of input.customer_id. The .eq() pair already enforces company
scoping at fetch, but echoing the trusted value from the query makes
the guarantee explicit at the call site and immune to refactoring
drift. Same change in the dry-run preview shape.
Swedish-compliance wording: the credit-note rejection comment now
spells out BOTH ML 17 kap 22–23§ requirements — distinct kreditfaktura
series AND explicit back-reference to the original invoice's
löpnummer — so any future v1 path that does support credit-note send
starts from a complete spec.
Company-settings select: kept select('*') with an explanatory comment
rather than enumerating columns. The InvoicePDF template consumes the
full CompanySettings shape; a partial allow-list risks silently breaking
rendering, and the table has no sensitive columns today (API tokens,
billing data live in scoped tables). Documents the trade-off so the
next reviewer doesn't re-litigate.
Deliberately not changed:
- Math.round → Math.trunc on VAT öre: CLAUDE.md mandates Math.round
project-wide; unilateral deviation here would diverge from the
bookkeeping engine and POST /invoices.
- 207 Multi-Status on partial post-email failures: gnubok's convention
is warnings[] in the 200 envelope; a per-route status divergence
would break the response contract clients rely on.
- Wrapper membership double-check (OWASP V8.2.1 send route): the
withApiV1 wrapper sets ctx.companyId from the URL after the
membership check — recurring false positive in this swarm.
Tests + build + lint clean. 17/17 in the touched suites.
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:
co-authored by
Claude Opus 4.7
parent
7738f286af
commit
37ccda5cad
@@ -0,0 +1,389 @@
|
||||
/**
|
||||
* Integration tests for POST /api/v1/companies/:companyId/invoices/:id/send.
|
||||
*
|
||||
* Mocks the email service, PDF renderer, F-series allocator, journal-entry
|
||||
* helper, and document uploader so the route's orchestration is what's
|
||||
* under test.
|
||||
*/
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
beforeAll(() => {
|
||||
if (process.env.NODE_ENV !== 'test') {
|
||||
throw new Error(
|
||||
`send 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({}) }
|
||||
})
|
||||
|
||||
vi.mock('@/lib/invoices/ensure-invoice-number', () => ({
|
||||
ensureInvoiceNumber: vi.fn().mockResolvedValue(undefined),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/bookkeeping/invoice-entries', () => ({
|
||||
createInvoiceJournalEntry: vi.fn().mockResolvedValue({
|
||||
id: 'jjjjjjjj-jjjj-4jjj-8jjj-jjjjjjjjjjjj',
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/core/documents/document-service', () => ({
|
||||
uploadDocument: vi.fn().mockResolvedValue({}),
|
||||
}))
|
||||
|
||||
vi.mock('@react-pdf/renderer', () => ({
|
||||
renderToBuffer: vi.fn().mockResolvedValue(Buffer.from('pdf-content')),
|
||||
}))
|
||||
|
||||
// Email service mock — configurable per test
|
||||
const mockSendEmail = vi.fn()
|
||||
const mockIsConfigured = vi.fn().mockReturnValue(true)
|
||||
vi.mock('@/lib/email/service', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/lib/email/service')>()
|
||||
return {
|
||||
...actual,
|
||||
getEmailService: () => ({
|
||||
isConfigured: mockIsConfigured,
|
||||
sendEmail: mockSendEmail,
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/lib/email/invoice-templates', () => ({
|
||||
generateInvoiceEmailHtml: vi.fn().mockReturnValue('<html>...</html>'),
|
||||
generateInvoiceEmailText: vi.fn().mockReturnValue('plain text'),
|
||||
generateInvoiceEmailSubject: vi.fn().mockReturnValue('Faktura'),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/invoices/pdf-template', () => ({
|
||||
InvoicePDF: vi.fn().mockReturnValue({}),
|
||||
}))
|
||||
|
||||
import { validateApiKey, createServiceClientNoCookies } from '@/lib/auth/api-keys'
|
||||
import { POST as sendInvoice } from '../route'
|
||||
|
||||
const mockValidate = validateApiKey as ReturnType<typeof vi.fn>
|
||||
const mockServiceClient = createServiceClientNoCookies as ReturnType<typeof vi.fn>
|
||||
|
||||
type MockResult = { data?: unknown; error?: unknown }
|
||||
function makeFlexibleSupabase(byTable: Record<string, MockResult | MockResult[]>) {
|
||||
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 makeRequest(url: string): Request {
|
||||
return new Request(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: 'Bearer test-fixture-not-a-real-key',
|
||||
'Idempotency-Key': 'idem1234-3030-4abc-8def-1234567890ab',
|
||||
},
|
||||
})
|
||||
}
|
||||
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',
|
||||
email: 'billing@acme.test',
|
||||
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 COMPANY_SETTINGS = {
|
||||
company_id: COMPANY_ID,
|
||||
company_name: 'Test AB',
|
||||
email: 'support@test-ab.example',
|
||||
accounting_method: 'accrual',
|
||||
entity_type: 'enskild_firma',
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockValidate.mockResolvedValue({
|
||||
userId: USER_ID,
|
||||
companyId: COMPANY_ID,
|
||||
apiKeyId: 'ak_1',
|
||||
apiKeyName: 'CI key',
|
||||
scopes: ['invoices:write'],
|
||||
mode: 'live',
|
||||
})
|
||||
mockIsConfigured.mockReturnValue(true)
|
||||
mockSendEmail.mockResolvedValue({ success: true, messageId: 're_abc123' })
|
||||
})
|
||||
|
||||
describe('POST /api/v1/companies/:companyId/invoices/:id/send', () => {
|
||||
it('sends a draft invoice end-to-end and returns 200 with messageId', async () => {
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
invoices: [
|
||||
{ data: DRAFT_INVOICE, error: null }, // pre-flight fetch
|
||||
{ data: { invoice_number: '2026-0042' }, error: null }, // re-read after allocation
|
||||
],
|
||||
company_settings: { data: COMPANY_SETTINGS, error: null },
|
||||
}),
|
||||
)
|
||||
|
||||
const res = await sendInvoice(
|
||||
makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/invoices/${INVOICE_ID}/send`),
|
||||
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.message_id).toBe('re_abc123')
|
||||
expect(body.data.sent_to).toBe('billing@acme.test')
|
||||
expect(body.data.journal_entry_id).toBe('jjjjjjjj-jjjj-4jjj-8jjj-jjjjjjjjjjjj')
|
||||
expect(mockSendEmail).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('returns 503 when email service is not configured', async () => {
|
||||
mockIsConfigured.mockReturnValue(false)
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
}),
|
||||
)
|
||||
|
||||
const res = await sendInvoice(
|
||||
makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/invoices/${INVOICE_ID}/send`),
|
||||
detailParams(COMPANY_ID, INVOICE_ID),
|
||||
)
|
||||
|
||||
expect(res.status).toBe(503)
|
||||
const body = await res.json()
|
||||
expect(body.error.code).toBe('INVOICE_SEND_EMAIL_NOT_CONFIGURED')
|
||||
})
|
||||
|
||||
it('returns 400 INVOICE_SEND_NO_CUSTOMER_EMAIL when customer lacks email', async () => {
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
invoices: {
|
||||
data: { ...DRAFT_INVOICE, customer: { ...DRAFT_INVOICE.customer, email: null } },
|
||||
error: null,
|
||||
},
|
||||
company_settings: { data: COMPANY_SETTINGS, error: null },
|
||||
}),
|
||||
)
|
||||
|
||||
const res = await sendInvoice(
|
||||
makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/invoices/${INVOICE_ID}/send`),
|
||||
detailParams(COMPANY_ID, INVOICE_ID),
|
||||
)
|
||||
|
||||
expect(res.status).toBe(400)
|
||||
const body = await res.json()
|
||||
expect(body.error.code).toBe('INVOICE_SEND_NO_CUSTOMER_EMAIL')
|
||||
})
|
||||
|
||||
it('rejects cancelled invoices with INVOICE_SEND_CANCELLED', async () => {
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
invoices: { data: { ...DRAFT_INVOICE, status: 'cancelled' }, error: null },
|
||||
}),
|
||||
)
|
||||
|
||||
const res = await sendInvoice(
|
||||
makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/invoices/${INVOICE_ID}/send`),
|
||||
detailParams(COMPANY_ID, INVOICE_ID),
|
||||
)
|
||||
|
||||
expect(res.status).toBe(400)
|
||||
const body = await res.json()
|
||||
expect(body.error.code).toBe('INVOICE_SEND_CANCELLED')
|
||||
})
|
||||
|
||||
it('rejects already-sent invoices with INVOICE_UPDATE_NOT_DRAFT', 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 sendInvoice(
|
||||
makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/invoices/${INVOICE_ID}/send`),
|
||||
detailParams(COMPANY_ID, INVOICE_ID),
|
||||
)
|
||||
|
||||
expect(res.status).toBe(409)
|
||||
const body = await res.json()
|
||||
expect(body.error.code).toBe('INVOICE_UPDATE_NOT_DRAFT')
|
||||
})
|
||||
|
||||
it('returns 502 INVOICE_SEND_PROVIDER_FAILED when email send fails', async () => {
|
||||
mockSendEmail.mockResolvedValue({ success: false, error: 'rate_limited' })
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
invoices: [
|
||||
{ data: DRAFT_INVOICE, error: null },
|
||||
{ data: { invoice_number: '2026-0042' }, error: null },
|
||||
],
|
||||
company_settings: { data: COMPANY_SETTINGS, error: null },
|
||||
}),
|
||||
)
|
||||
|
||||
const res = await sendInvoice(
|
||||
makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/invoices/${INVOICE_ID}/send`),
|
||||
detailParams(COMPANY_ID, INVOICE_ID),
|
||||
)
|
||||
|
||||
expect(res.status).toBe(502)
|
||||
const body = await res.json()
|
||||
expect(body.error.code).toBe('INVOICE_SEND_PROVIDER_FAILED')
|
||||
})
|
||||
|
||||
it('dry-run validates the pipeline without sending email or allocating a number', async () => {
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
invoices: { data: DRAFT_INVOICE, error: null },
|
||||
company_settings: { data: COMPANY_SETTINGS, error: null },
|
||||
}),
|
||||
)
|
||||
|
||||
const res = await sendInvoice(
|
||||
makeRequest(
|
||||
`https://x.test/api/v1/companies/${COMPANY_ID}/invoices/${INVOICE_ID}/send?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_send_to).toBe('billing@acme.test')
|
||||
expect(body.data.preview.would_cc).toBe('support@test-ab.example')
|
||||
expect(body.data.preview.preflight_pdf_render).toBe('ok')
|
||||
expect(mockSendEmail).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
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: 'dddddddd-dddd-4ddd-8ddd-dddddddddddd' },
|
||||
error: null,
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
const res = await sendInvoice(
|
||||
makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/invoices/${INVOICE_ID}/send`),
|
||||
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('flags status flip 0-row no-op as a warning instead of lying in the response', async () => {
|
||||
// Status flip returns no rows (concurrent state change). Email is gone;
|
||||
// response status must say 'draft' and carry STATUS_UPDATE_FAILED.
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
invoices: [
|
||||
{ data: DRAFT_INVOICE, error: null },
|
||||
{ data: { invoice_number: '2026-0042' }, error: null },
|
||||
{ data: [], error: null }, // status flip: 0 rows matched
|
||||
],
|
||||
company_settings: { data: COMPANY_SETTINGS, error: null },
|
||||
}),
|
||||
)
|
||||
|
||||
const res = await sendInvoice(
|
||||
makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/invoices/${INVOICE_ID}/send`),
|
||||
detailParams(COMPANY_ID, INVOICE_ID),
|
||||
)
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
const body = await res.json()
|
||||
expect(body.data.status).toBe('draft')
|
||||
expect(body.data.warnings).toEqual(
|
||||
expect.arrayContaining([expect.objectContaining({ code: 'STATUS_UPDATE_FAILED' })]),
|
||||
)
|
||||
})
|
||||
|
||||
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 sendInvoice(
|
||||
makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/invoices/${INVOICE_ID}/send`),
|
||||
detailParams(COMPANY_ID, INVOICE_ID),
|
||||
)
|
||||
|
||||
expect(res.status).toBe(403)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,570 @@
|
||||
/**
|
||||
* POST /api/v1/companies/{companyId}/invoices/{id}/send
|
||||
*
|
||||
* Full send pipeline. Renders the invoice PDF, emails it to the customer
|
||||
* (with a copy to the company), allocates the F-series number, posts the
|
||||
* journal entry under accrual basis, archives the PDF as underlag, and
|
||||
* emits invoice.sent. This is :mark-sent + PDF + email + archival.
|
||||
*
|
||||
* Failure ordering (matches the dashboard's internal /api/invoices/[id]/send
|
||||
* exactly so the two surfaces stay reconcilable):
|
||||
*
|
||||
* 1. Email service NOT configured → 503 INVOICE_SEND_EMAIL_NOT_CONFIGURED.
|
||||
* Hard fail before any state changes.
|
||||
* 2. Customer has no email → 400 INVOICE_SEND_NO_CUSTOMER_EMAIL.
|
||||
* 3. Company settings missing → 404 INVOICE_SEND_COMPANY_SETTINGS_MISSING.
|
||||
* 4. Cancelled invoices are rejected — sending one would silently
|
||||
* re-activate it (the status flip below has no race guard tightening
|
||||
* `cancelled`). Returns 400 INVOICE_SEND_CANCELLED.
|
||||
* 5. Preflight PDF render (with a placeholder F-PREVIEW number) validates
|
||||
* the rendering pipeline BEFORE consuming an F-series number. Fail →
|
||||
* 500 INVOICE_SEND_PDF_RENDER_FAILED, no number burned.
|
||||
* 6. ensureInvoiceNumber allocates the F-series number atomically.
|
||||
* Fail → 500 INVOICE_SEND_NUMBER_ASSIGN_FAILED.
|
||||
* 7. Final PDF render with the real number.
|
||||
* 8. Email send via Resend (the email extension). Fail → 502
|
||||
* INVOICE_SEND_PROVIDER_FAILED. The number IS consumed at this point;
|
||||
* same orphan-window as :mark-sent (architecturally tracked).
|
||||
* 9. POINT OF NO RETURN. Steps below are best-effort; failures surface
|
||||
* as `warnings` on the response. Status flip → 'sent', journal entry
|
||||
* (accrual + real invoice), PDF archival via uploadDocument,
|
||||
* invoice.sent event emission.
|
||||
*
|
||||
* Idempotent (mandatory Idempotency-Key). Dry-runnable — dry-run goes
|
||||
* through steps 1–5 (validation + preflight PDF) without allocating a
|
||||
* number, sending email, or mutating state.
|
||||
*/
|
||||
|
||||
import { z } from 'zod'
|
||||
import { renderToBuffer } from '@react-pdf/renderer'
|
||||
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 { InvoicePDF } from '@/lib/invoices/pdf-template'
|
||||
import { getEmailService } from '@/lib/email/service'
|
||||
import {
|
||||
generateInvoiceEmailHtml,
|
||||
generateInvoiceEmailSubject,
|
||||
generateInvoiceEmailText,
|
||||
} from '@/lib/email/invoice-templates'
|
||||
import { createInvoiceJournalEntry } from '@/lib/bookkeeping/invoice-entries'
|
||||
import { uploadDocument } from '@/lib/core/documents/document-service'
|
||||
import { ensureInvoiceNumber } from '@/lib/invoices/ensure-invoice-number'
|
||||
import { eventBus } from '@/lib/events'
|
||||
import type { CompanySettings, Customer, EntityType, Invoice, InvoiceItem } from '@/types'
|
||||
|
||||
const INVOICE_SEND_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 InvoiceSendResponse = z.object({
|
||||
id: z.string().uuid(),
|
||||
invoice_number: z.string(),
|
||||
status: z.literal('sent'),
|
||||
total: z.number(),
|
||||
message_id: z.string().nullable(),
|
||||
sent_to: z.string(),
|
||||
cc: z.string().nullable(),
|
||||
journal_entry_id: z.string().uuid().nullable(),
|
||||
warnings: z
|
||||
.array(z.object({ code: z.string(), message: z.string() }))
|
||||
.optional(),
|
||||
})
|
||||
|
||||
registerEndpoint({
|
||||
operation: 'invoices.send',
|
||||
method: 'POST',
|
||||
path: '/api/v1/companies/:companyId/invoices/:id/send',
|
||||
summary: 'Send a draft invoice to the customer by email.',
|
||||
description:
|
||||
'The full send pipeline: preflight PDF render → allocate F-series number atomically → final PDF render → email via Resend (PDF attachment, copy to company) → flip status to sent → post journal entry (accrual + real invoice) → archive PDF as underlag → emit invoice.sent. Email failure is a hard 502 before state changes; post-email failures surface as warnings but the invoice IS marked sent.',
|
||||
useWhen:
|
||||
'You want gnubok to deliver the invoice to the customer via email. For invoices delivered through another channel (Peppol, postal, own SMTP) use :mark-sent instead.',
|
||||
doNotUseFor:
|
||||
'Re-sending an already-sent invoice (returns 409 INVOICE_UPDATE_NOT_DRAFT). Sending a delivery note (no F-series lifecycle). Sending a credit note (use the :credit endpoint to issue the kreditfaktura; subsequent re-send of the credit note via :mark-sent is the supported path).',
|
||||
pitfalls: [
|
||||
'Idempotency-Key is mandatory.',
|
||||
'Email service must be configured — without RESEND_API_KEY + RESEND_FROM_EMAIL the endpoint returns 503 INVOICE_SEND_EMAIL_NOT_CONFIGURED.',
|
||||
'Customer must have an email address. 400 INVOICE_SEND_NO_CUSTOMER_EMAIL otherwise.',
|
||||
'A cancelled invoice is rejected (400 INVOICE_SEND_CANCELLED) — its F-series number is preserved for compliance but the document is not a valid faktura.',
|
||||
'Email failure before the status flip leaves the F-series number consumed but the invoice in `draft` status. Same orphan window as :mark-sent (architecturally tracked, matches internal route).',
|
||||
'After the email succeeds, journal-entry/archive/event failures become warnings on the response; the invoice IS marked sent regardless.',
|
||||
],
|
||||
example: {
|
||||
response: {
|
||||
data: {
|
||||
id: '0e9c…',
|
||||
invoice_number: '2026-0042',
|
||||
status: 'sent',
|
||||
total: 12500,
|
||||
message_id: 're_abc123',
|
||||
sent_to: 'finance@acme.test',
|
||||
cc: 'billing@gnubok-user.test',
|
||||
journal_entry_id: '7b3a…',
|
||||
},
|
||||
meta: { request_id: 'req_…', api_version: '2026-05-12' },
|
||||
},
|
||||
},
|
||||
scope: 'invoices:write',
|
||||
risk: 'high',
|
||||
idempotent: true,
|
||||
reversible: false,
|
||||
dryRunSupported: true,
|
||||
response: { success: InvoiceSendResponse },
|
||||
})
|
||||
|
||||
export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string }> }>(
|
||||
'invoices.send',
|
||||
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.' },
|
||||
})
|
||||
}
|
||||
|
||||
// Step 1: email service configured?
|
||||
const emailService = getEmailService()
|
||||
if (!emailService.isConfigured()) {
|
||||
return v1ErrorResponseFromCode('INVOICE_SEND_EMAIL_NOT_CONFIGURED', ctx.log, {
|
||||
requestId: ctx.requestId,
|
||||
})
|
||||
}
|
||||
|
||||
// Fetch invoice + customer + items.
|
||||
const { data: invoice, error: fetchErr } = await ctx.supabase
|
||||
.from('invoices')
|
||||
.select(
|
||||
`${INVOICE_SEND_RESPONSE_COLUMNS}, customer:customers(id, name, email, customer_type, country, address_line1, address_line2, postal_code, city, vat_number), 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.send: 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?: Customer
|
||||
items?: InvoiceItem[]
|
||||
}
|
||||
|
||||
// Step 4: cancelled invoices.
|
||||
if (typed.status === 'cancelled') {
|
||||
return v1ErrorResponseFromCode('INVOICE_SEND_CANCELLED', ctx.log, {
|
||||
requestId: ctx.requestId,
|
||||
})
|
||||
}
|
||||
|
||||
// Reject already-sent — same contract as :mark-sent. Re-send is not a
|
||||
// supported v1 operation; use the dashboard or a fresh credit-and-reissue.
|
||||
if (typed.status !== 'draft') {
|
||||
return v1ErrorResponseFromCode('INVOICE_UPDATE_NOT_DRAFT', ctx.log, {
|
||||
requestId: ctx.requestId,
|
||||
details: { current_status: typed.status },
|
||||
})
|
||||
}
|
||||
|
||||
// Reject delivery notes — they have a different (D-series) lifecycle.
|
||||
if (typed.document_type === 'delivery_note') {
|
||||
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
|
||||
requestId: ctx.requestId,
|
||||
details: {
|
||||
field: 'document_type',
|
||||
message: 'Delivery notes are not sent via this endpoint; use the dashboard or a custom channel.',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Reject credit notes. `:credit` creates them atomically in 'sent' state
|
||||
// with their own number — there is no v1 path that produces a draft
|
||||
// credit note, so reaching :send with credited_invoice_id set is either
|
||||
// a misuse or a manual DB edit. Allowing it would give a credit note
|
||||
// an F-series number; ML 17 kap 22–23§ require (a) a distinct
|
||||
// kreditfaktura series and (b) an explicit back-reference to the
|
||||
// original invoice's löpnummer — neither enforced by this route.
|
||||
// Any future "send a credit note" v1 path MUST honor both.
|
||||
if (typed.credited_invoice_id) {
|
||||
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
|
||||
requestId: ctx.requestId,
|
||||
details: {
|
||||
field: 'credited_invoice_id',
|
||||
message:
|
||||
'Credit notes cannot be sent via this endpoint. Use POST /invoices/{id}/credit, which creates and sends the credit note atomically.',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
if (!typed.moms_ruta) {
|
||||
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
|
||||
requestId: ctx.requestId,
|
||||
details: {
|
||||
field: 'moms_ruta',
|
||||
message: 'Invoice has no moms_ruta set; re-create the draft via POST /invoices.',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Step 2: customer email.
|
||||
const customer = typed.customer
|
||||
if (!customer?.email) {
|
||||
return v1ErrorResponseFromCode('INVOICE_SEND_NO_CUSTOMER_EMAIL', ctx.log, {
|
||||
requestId: ctx.requestId,
|
||||
details: { customer_id: typed.customer_id },
|
||||
})
|
||||
}
|
||||
|
||||
// Step 3: company settings. The whole CompanySettings shape is passed to
|
||||
// the InvoicePDF template — header info, bank details, contact, address,
|
||||
// entity type. `select('*')` is intentional: CompanySettings is a flat
|
||||
// owner-facing config object with no sensitive columns today (no API
|
||||
// tokens, no billing data — those live in scoped tables). If a future
|
||||
// migration adds a sensitive column, the right fix is to put it in a
|
||||
// separate table, not retrofit a column allow-list here.
|
||||
const { data: company, error: companyErr } = await ctx.supabase
|
||||
.from('company_settings')
|
||||
.select('*')
|
||||
.eq('company_id', ctx.companyId!)
|
||||
.maybeSingle()
|
||||
if (companyErr || !company) {
|
||||
return v1ErrorResponseFromCode('INVOICE_SEND_COMPANY_SETTINGS_MISSING', ctx.log, {
|
||||
requestId: ctx.requestId,
|
||||
})
|
||||
}
|
||||
const settings = company as CompanySettings & { accounting_method?: string }
|
||||
|
||||
const items = (typed.items ?? []).slice().sort((a, b) => a.sort_order - b.sort_order)
|
||||
// Credit notes are rejected above, so originalInvoiceNumber is never
|
||||
// needed on this code path. Kept undefined to satisfy the InvoicePDF
|
||||
// signature (it tolerates undefined for non-credit-notes).
|
||||
const originalInvoiceNumber: string | undefined = undefined
|
||||
|
||||
// Step 5: preflight PDF render. Validate the pipeline with a placeholder
|
||||
// number BEFORE consuming an F-series number.
|
||||
const isFreshAllocation = !typed.invoice_number
|
||||
if (isFreshAllocation) {
|
||||
try {
|
||||
await renderToBuffer(
|
||||
InvoicePDF({
|
||||
invoice: { ...(typed as Invoice), invoice_number: 'F-PREVIEW' },
|
||||
customer,
|
||||
items,
|
||||
company: settings,
|
||||
originalInvoiceNumber,
|
||||
}),
|
||||
)
|
||||
} catch (err) {
|
||||
ctx.log.error('invoices.send: preflight PDF render failed', err as Error, {
|
||||
invoiceId,
|
||||
companyId: ctx.companyId,
|
||||
})
|
||||
return v1ErrorResponseFromCode('INVOICE_SEND_PDF_RENDER_FAILED', ctx.log, {
|
||||
requestId: ctx.requestId,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (ctx.dryRun) {
|
||||
// Dry-run stops here. Validated everything that doesn't have side
|
||||
// effects; preview the would-be sent state.
|
||||
return dryRunPreview(
|
||||
{
|
||||
...typed,
|
||||
status: 'sent' as const,
|
||||
invoice_number: typed.invoice_number ?? '(allocated atomically on commit)',
|
||||
would_send_to: customer.email,
|
||||
would_cc: settings.email || null,
|
||||
would_create_journal_entry:
|
||||
(!typed.document_type || typed.document_type === 'invoice') &&
|
||||
(settings.accounting_method ?? 'accrual') === 'accrual',
|
||||
accounting_method: settings.accounting_method ?? 'accrual',
|
||||
preflight_pdf_render: 'ok',
|
||||
},
|
||||
{ requestId: ctx.requestId, log: ctx.log },
|
||||
)
|
||||
}
|
||||
|
||||
// Step 6: allocate F-series number atomically.
|
||||
try {
|
||||
await ensureInvoiceNumber(ctx.supabase, ctx.companyId!, typed as Invoice)
|
||||
} catch (err) {
|
||||
ctx.log.error('invoices.send: ensureInvoiceNumber failed', err as Error, {
|
||||
invoiceId,
|
||||
companyId: ctx.companyId,
|
||||
})
|
||||
return v1ErrorResponseFromCode('INVOICE_SEND_NUMBER_ASSIGN_FAILED', ctx.log, {
|
||||
requestId: ctx.requestId,
|
||||
})
|
||||
}
|
||||
|
||||
// Step 7: final PDF render with the assigned number. typed.invoice_number
|
||||
// was mutated by ensureInvoiceNumber. Re-read to be safe. A re-read
|
||||
// failure (transient connection error) is non-fatal — `typed.invoice_number`
|
||||
// was just written by the RPC in step 6, so it's the authoritative
|
||||
// in-memory value. Log a warning and fall back.
|
||||
const { data: numbered, error: reReadErr } = await ctx.supabase
|
||||
.from('invoices')
|
||||
.select('invoice_number')
|
||||
.eq('id', invoiceId)
|
||||
.eq('company_id', ctx.companyId!)
|
||||
.single()
|
||||
if (reReadErr) {
|
||||
ctx.log.warn(
|
||||
'invoices.send: re-read after number allocation failed, falling back to in-memory value',
|
||||
{
|
||||
invoiceId,
|
||||
companyId: ctx.companyId,
|
||||
err: reReadErr,
|
||||
},
|
||||
)
|
||||
}
|
||||
const finalInvoiceNumber =
|
||||
(numbered as { invoice_number?: string } | null)?.invoice_number ?? typed.invoice_number
|
||||
const renderableInvoice: Invoice = {
|
||||
...(typed as Invoice),
|
||||
invoice_number: finalInvoiceNumber,
|
||||
}
|
||||
|
||||
let pdfBuffer: Buffer
|
||||
try {
|
||||
pdfBuffer = await renderToBuffer(
|
||||
InvoicePDF({
|
||||
invoice: renderableInvoice,
|
||||
customer,
|
||||
items,
|
||||
company: settings,
|
||||
originalInvoiceNumber,
|
||||
}),
|
||||
)
|
||||
} catch (err) {
|
||||
// F-series number IS consumed at this point (orphan window).
|
||||
ctx.log.error('invoices.send: final PDF render failed AFTER number allocation', err as Error, {
|
||||
invoiceId,
|
||||
companyId: ctx.companyId,
|
||||
invoiceNumber: finalInvoiceNumber,
|
||||
})
|
||||
return v1ErrorResponseFromCode('INVOICE_SEND_PDF_RENDER_FAILED', ctx.log, {
|
||||
requestId: ctx.requestId,
|
||||
})
|
||||
}
|
||||
|
||||
// Step 8: send the email. Delivery notes AND credit notes were rejected
|
||||
// earlier so docType is 'invoice' or 'proforma' here.
|
||||
const docType = typed.document_type ?? 'invoice'
|
||||
const filename =
|
||||
docType === 'proforma'
|
||||
? `proformafaktura-${finalInvoiceNumber}.pdf`
|
||||
: `faktura-${finalInvoiceNumber}.pdf`
|
||||
|
||||
const ccAddress = settings.email ?? null
|
||||
const emailData = { invoice: renderableInvoice, customer, company: settings }
|
||||
const result = await emailService.sendEmail({
|
||||
to: customer.email,
|
||||
cc: ccAddress ?? undefined,
|
||||
subject: generateInvoiceEmailSubject(emailData),
|
||||
html: generateInvoiceEmailHtml(emailData),
|
||||
text: generateInvoiceEmailText(emailData),
|
||||
replyTo: settings.email ?? undefined,
|
||||
fromName: settings.company_name ?? undefined,
|
||||
attachments: [
|
||||
{
|
||||
filename,
|
||||
content: pdfBuffer,
|
||||
contentType: 'application/pdf',
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
if (!result.success) {
|
||||
ctx.log.error('invoices.send: email provider failed', new Error(result.error ?? 'unknown'), {
|
||||
invoiceId,
|
||||
companyId: ctx.companyId,
|
||||
})
|
||||
return v1ErrorResponseFromCode('INVOICE_SEND_PROVIDER_FAILED', ctx.log, {
|
||||
requestId: ctx.requestId,
|
||||
})
|
||||
}
|
||||
|
||||
// ── POINT OF NO RETURN ────────────────────────────────────────────
|
||||
// Email has been delivered. Subsequent failures surface as warnings.
|
||||
const warnings: { code: string; message: string }[] = []
|
||||
|
||||
// Step 9a: status flip to 'sent'. The `.eq('status', 'draft')` is an
|
||||
// optimistic-lock guard against a concurrent state change between fetch
|
||||
// and write. PostgREST returns `{ error: null }` for 0-row updates, so
|
||||
// we MUST `.select('id')` and check the row count — a silent zero-row
|
||||
// miss would leave the DB in 'draft' while the response claims 'sent'
|
||||
// and the email is already gone.
|
||||
let statusFlipped = true
|
||||
const { data: flipRows, error: statusErr } = await ctx.supabase
|
||||
.from('invoices')
|
||||
.update({ status: 'sent', updated_at: new Date().toISOString() })
|
||||
.eq('id', invoiceId)
|
||||
.eq('company_id', ctx.companyId!)
|
||||
.eq('status', 'draft')
|
||||
.select('id')
|
||||
if (statusErr || !flipRows || flipRows.length === 0) {
|
||||
statusFlipped = false
|
||||
ctx.log.error(
|
||||
'invoices.send: status flip failed AFTER email delivery',
|
||||
(statusErr ?? new Error('0 rows matched (concurrent state change)')) as Error,
|
||||
{
|
||||
invoiceId,
|
||||
companyId: ctx.companyId,
|
||||
rowsMatched: flipRows?.length ?? 0,
|
||||
},
|
||||
)
|
||||
warnings.push({
|
||||
code: 'STATUS_UPDATE_FAILED',
|
||||
message:
|
||||
'Email delivered but the invoice could not be marked as sent. Reconcile manually — the DB row may still be in draft.',
|
||||
})
|
||||
}
|
||||
|
||||
// Step 9b: journal entry (accrual + real invoices).
|
||||
let journalEntryId: string | null = null
|
||||
const isRealInvoice = !typed.document_type || typed.document_type === 'invoice'
|
||||
const accountingMethod = settings.accounting_method ?? 'accrual'
|
||||
if (isRealInvoice && accountingMethod === 'accrual') {
|
||||
try {
|
||||
const entry = await createInvoiceJournalEntry(
|
||||
ctx.supabase,
|
||||
ctx.companyId!,
|
||||
ctx.userId,
|
||||
renderableInvoice,
|
||||
(settings.entity_type ?? 'enskild_firma') as EntityType,
|
||||
customer.name,
|
||||
)
|
||||
if (entry) {
|
||||
journalEntryId = entry.id
|
||||
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('invoices.send: journal_entry_id write-back failed', writeBackErr as Error, {
|
||||
invoiceId,
|
||||
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.',
|
||||
})
|
||||
}
|
||||
} else {
|
||||
warnings.push({
|
||||
code: 'JOURNAL_ENTRY_NOT_POSTED',
|
||||
message: 'Invoice was sent but the journal entry was not posted (likely no open fiscal period). Reconcile before period close.',
|
||||
})
|
||||
}
|
||||
} catch (err) {
|
||||
ctx.log.error('invoices.send: journal entry creation failed', err as Error, {
|
||||
invoiceId,
|
||||
companyId: ctx.companyId,
|
||||
})
|
||||
warnings.push({
|
||||
code: 'JOURNAL_ENTRY_NOT_POSTED',
|
||||
message: 'Invoice was sent but the journal entry posting failed. Check engine logs; reconcile for BFL 5 kap compliance.',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Step 9c: archive the PDF as underlag.
|
||||
if (isRealInvoice) {
|
||||
try {
|
||||
const pdfArrayBuffer = new Uint8Array(pdfBuffer).buffer as ArrayBuffer
|
||||
await uploadDocument(
|
||||
ctx.supabase,
|
||||
ctx.userId,
|
||||
ctx.companyId!,
|
||||
{
|
||||
name: filename,
|
||||
buffer: pdfArrayBuffer,
|
||||
type: 'application/pdf',
|
||||
},
|
||||
{
|
||||
upload_source: 'system',
|
||||
journal_entry_id: journalEntryId ?? undefined,
|
||||
},
|
||||
)
|
||||
} catch (err) {
|
||||
ctx.log.error('invoices.send: PDF archival failed', err as Error, {
|
||||
invoiceId,
|
||||
companyId: ctx.companyId,
|
||||
})
|
||||
warnings.push({
|
||||
code: 'PDF_ARCHIVE_FAILED',
|
||||
message: 'Invoice was sent but the PDF could not be archived as underlag. Manual upload required for BFL 7 kap retention.',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Step 9d: emit invoice.sent.
|
||||
try {
|
||||
await eventBus.emit({
|
||||
type: 'invoice.sent',
|
||||
payload: {
|
||||
invoice: renderableInvoice,
|
||||
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 may miss this transition.',
|
||||
})
|
||||
}
|
||||
|
||||
ctx.log.info('invoices.send success', {
|
||||
invoiceId,
|
||||
companyId: ctx.companyId,
|
||||
userId: ctx.userId,
|
||||
invoiceNumber: finalInvoiceNumber,
|
||||
sentTo: customer.email,
|
||||
journalEntryId,
|
||||
hadWarnings: warnings.length > 0,
|
||||
})
|
||||
|
||||
return ok(
|
||||
{
|
||||
id: invoiceId,
|
||||
invoice_number: finalInvoiceNumber ?? typed.invoice_number ?? null,
|
||||
status: statusFlipped ? ('sent' as const) : ('draft' as const),
|
||||
total: typed.total,
|
||||
message_id: result.messageId ?? null,
|
||||
sent_to: customer.email,
|
||||
cc: ccAddress,
|
||||
journal_entry_id: journalEntryId,
|
||||
...(warnings.length > 0 ? { warnings } : {}),
|
||||
},
|
||||
{ requestId: ctx.requestId },
|
||||
)
|
||||
},
|
||||
{ requireIdempotencyKey: true },
|
||||
)
|
||||
@@ -0,0 +1,318 @@
|
||||
/**
|
||||
* Integration tests for POST /api/v1/companies/:companyId/invoices/bulk-create.
|
||||
*/
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
beforeAll(() => {
|
||||
if (process.env.NODE_ENV !== 'test') {
|
||||
throw new Error(
|
||||
`bulk-create 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({}) }
|
||||
})
|
||||
vi.mock('@/lib/currency/riksbanken', async () => {
|
||||
const actual = await vi.importActual<typeof import('@/lib/currency/riksbanken')>('@/lib/currency/riksbanken')
|
||||
return { ...actual, fetchExchangeRate: vi.fn().mockResolvedValue(null) }
|
||||
})
|
||||
|
||||
import { validateApiKey, createServiceClientNoCookies } from '@/lib/auth/api-keys'
|
||||
import { POST as bulkCreate } from '../route'
|
||||
|
||||
const mockValidate = validateApiKey as ReturnType<typeof vi.fn>
|
||||
const mockServiceClient = createServiceClientNoCookies as ReturnType<typeof vi.fn>
|
||||
|
||||
type MockResult = { data?: unknown; error?: unknown }
|
||||
function makeFlexibleSupabase(byTable: Record<string, MockResult | MockResult[]>) {
|
||||
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 CUSTOMER_ID = 'cccccccc-cccc-4ccc-8ccc-cccccccccccc'
|
||||
const USER_ID = 'user-1'
|
||||
|
||||
function makeRequest(url: string, body: unknown): Request {
|
||||
return new Request(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: 'Bearer test-fixture-not-a-real-key',
|
||||
'Content-Type': 'application/json',
|
||||
'Idempotency-Key': 'idem1234-4040-4abc-8def-1234567890ab',
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
}
|
||||
function companyParams(companyId: string) {
|
||||
return { params: Promise.resolve({ companyId }) }
|
||||
}
|
||||
|
||||
const VALID_CUSTOMER = {
|
||||
id: CUSTOMER_ID,
|
||||
customer_type: 'swedish_business',
|
||||
vat_number_validated: true,
|
||||
}
|
||||
|
||||
const SAMPLE_ITEM = (description = 'A') => ({
|
||||
description,
|
||||
quantity: 1,
|
||||
unit: 'st',
|
||||
unit_price: 1000,
|
||||
})
|
||||
|
||||
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/bulk-create', () => {
|
||||
it('creates two invoices and returns a partial-success summary', async () => {
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
customers: { data: VALID_CUSTOMER, error: null },
|
||||
invoices: [
|
||||
{ data: { id: 'inv-1', invoice_number: null, status: 'draft', total: 1250 }, error: null },
|
||||
{ data: { id: 'inv-2', invoice_number: null, status: 'draft', total: 1250 }, error: null },
|
||||
],
|
||||
invoice_items: { data: null, error: null },
|
||||
}),
|
||||
)
|
||||
|
||||
const res = await bulkCreate(
|
||||
makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/invoices/bulk-create`, {
|
||||
invoices: [
|
||||
{
|
||||
customer_id: CUSTOMER_ID,
|
||||
invoice_date: '2026-05-12',
|
||||
due_date: '2026-06-11',
|
||||
currency: 'SEK',
|
||||
items: [SAMPLE_ITEM('A')],
|
||||
},
|
||||
{
|
||||
customer_id: CUSTOMER_ID,
|
||||
invoice_date: '2026-05-12',
|
||||
due_date: '2026-06-11',
|
||||
currency: 'SEK',
|
||||
items: [SAMPLE_ITEM('B')],
|
||||
},
|
||||
],
|
||||
}),
|
||||
companyParams(COMPANY_ID),
|
||||
)
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
const body = await res.json()
|
||||
expect(body.data.summary.total).toBe(2)
|
||||
expect(body.data.summary.succeeded).toBe(2)
|
||||
expect(body.data.summary.failed).toBe(0)
|
||||
expect(body.data.results[0].ok).toBe(true)
|
||||
expect(body.data.results[0].request_index).toBe(0)
|
||||
expect(body.data.results[1].ok).toBe(true)
|
||||
})
|
||||
|
||||
it('returns per-item failure when customer not found', async () => {
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
customers: { data: null, error: null }, // not found for every fetch
|
||||
}),
|
||||
)
|
||||
|
||||
const res = await bulkCreate(
|
||||
makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/invoices/bulk-create`, {
|
||||
invoices: [
|
||||
{
|
||||
customer_id: CUSTOMER_ID,
|
||||
invoice_date: '2026-05-12',
|
||||
due_date: '2026-06-11',
|
||||
currency: 'SEK',
|
||||
items: [SAMPLE_ITEM('A')],
|
||||
},
|
||||
],
|
||||
}),
|
||||
companyParams(COMPANY_ID),
|
||||
)
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
const body = await res.json()
|
||||
expect(body.data.results[0].ok).toBe(false)
|
||||
expect(body.data.results[0].error.code).toBe('INVOICE_CUSTOMER_NOT_FOUND')
|
||||
expect(body.data.summary.failed).toBe(1)
|
||||
expect(body.data.summary.succeeded).toBe(0)
|
||||
})
|
||||
|
||||
it('returns 400 VALIDATION_ERROR when the bulk envelope is malformed', async () => {
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
}),
|
||||
)
|
||||
|
||||
const res = await bulkCreate(
|
||||
makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/invoices/bulk-create`, {
|
||||
invoices: [], // min(1) violation
|
||||
}),
|
||||
companyParams(COMPANY_ID),
|
||||
)
|
||||
|
||||
expect(res.status).toBe(400)
|
||||
const body = await res.json()
|
||||
expect(body.error.code).toBe('VALIDATION_ERROR')
|
||||
})
|
||||
|
||||
it('rejects more than 50 invoices in one request', async () => {
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
}),
|
||||
)
|
||||
|
||||
const invoices = Array.from({ length: 51 }, () => ({
|
||||
customer_id: CUSTOMER_ID,
|
||||
invoice_date: '2026-05-12',
|
||||
due_date: '2026-06-11',
|
||||
currency: 'SEK',
|
||||
items: [SAMPLE_ITEM('A')],
|
||||
}))
|
||||
|
||||
const res = await bulkCreate(
|
||||
makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/invoices/bulk-create`, {
|
||||
invoices,
|
||||
}),
|
||||
companyParams(COMPANY_ID),
|
||||
)
|
||||
|
||||
expect(res.status).toBe(400)
|
||||
const body = await res.json()
|
||||
expect(body.error.code).toBe('VALIDATION_ERROR')
|
||||
})
|
||||
|
||||
it('dry-run returns previews without inserting', async () => {
|
||||
const supabaseMock = makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
customers: { data: VALID_CUSTOMER, error: null },
|
||||
})
|
||||
mockServiceClient.mockReturnValue(supabaseMock)
|
||||
|
||||
const res = await bulkCreate(
|
||||
makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/invoices/bulk-create?dry_run=true`, {
|
||||
invoices: [
|
||||
{
|
||||
customer_id: CUSTOMER_ID,
|
||||
invoice_date: '2026-05-12',
|
||||
due_date: '2026-06-11',
|
||||
currency: 'SEK',
|
||||
items: [SAMPLE_ITEM('A')],
|
||||
},
|
||||
],
|
||||
}),
|
||||
companyParams(COMPANY_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.summary.succeeded).toBe(1)
|
||||
expect(body.data.preview.results[0].ok).toBe(true)
|
||||
expect(body.data.preview.results[0].data.preview.total).toBe(1250)
|
||||
// No `invoices` insert was called.
|
||||
const insertedInvoice = supabaseMock.from.mock.calls.some((c) => c[0] === 'invoices')
|
||||
expect(insertedInvoice).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects all_or_nothing: true with 501 NOT_IMPLEMENTED', async () => {
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
}),
|
||||
)
|
||||
|
||||
const res = await bulkCreate(
|
||||
makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/invoices/bulk-create`, {
|
||||
all_or_nothing: true,
|
||||
invoices: [
|
||||
{
|
||||
customer_id: CUSTOMER_ID,
|
||||
invoice_date: '2026-05-12',
|
||||
due_date: '2026-06-11',
|
||||
currency: 'SEK',
|
||||
items: [SAMPLE_ITEM('A')],
|
||||
},
|
||||
],
|
||||
}),
|
||||
companyParams(COMPANY_ID),
|
||||
)
|
||||
|
||||
expect(res.status).toBe(501)
|
||||
const body = await res.json()
|
||||
expect(body.error.code).toBe('NOT_IMPLEMENTED')
|
||||
})
|
||||
|
||||
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 bulkCreate(
|
||||
makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/invoices/bulk-create`, {
|
||||
invoices: [
|
||||
{
|
||||
customer_id: CUSTOMER_ID,
|
||||
invoice_date: '2026-05-12',
|
||||
due_date: '2026-06-11',
|
||||
currency: 'SEK',
|
||||
items: [SAMPLE_ITEM('A')],
|
||||
},
|
||||
],
|
||||
}),
|
||||
companyParams(COMPANY_ID),
|
||||
)
|
||||
|
||||
expect(res.status).toBe(403)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,478 @@
|
||||
/**
|
||||
* POST /api/v1/companies/{companyId}/invoices/bulk-create
|
||||
*
|
||||
* Bulk create draft invoices in one call. Each invoice in the request array
|
||||
* is validated and inserted independently. The response is partial-success
|
||||
* by default — items that fail don't roll back items that succeeded.
|
||||
*
|
||||
* Request:
|
||||
* {
|
||||
* invoices: CreateInvoiceSchema[], // 1..50 items
|
||||
* all_or_nothing?: boolean // default false. Passing true returns
|
||||
* // 501 NOT_IMPLEMENTED — the flag is
|
||||
* // reserved for a future DB-side RPC.
|
||||
* }
|
||||
*
|
||||
* Response (200):
|
||||
* {
|
||||
* results: [
|
||||
* { ok: true, request_index: 0, data: { id, invoice_number, total, ... } },
|
||||
* { ok: false, request_index: 1, error: { code, message, details? } },
|
||||
* ...
|
||||
* ],
|
||||
* summary: { total: N, succeeded: X, failed: Y }
|
||||
* }
|
||||
*
|
||||
* Idempotent (mandatory Idempotency-Key applies to the whole batch — replays
|
||||
* return the cached full result, not per-item retries). Dry-runnable.
|
||||
*
|
||||
* Limits:
|
||||
* - 50 invoices per request. Larger imports should be split.
|
||||
* - No transactional guarantee between items today; the all_or_nothing
|
||||
* flag is reserved for a future RPC implementation.
|
||||
*/
|
||||
|
||||
import { z } from 'zod'
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
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 { v1ErrorResponseFromCode } from '@/lib/api/v1/errors'
|
||||
import { CreateInvoiceSchema } from '@/lib/api/schemas'
|
||||
import { getAvailableVatRates, getVatRules } from '@/lib/invoices/vat-rules'
|
||||
import { convertToSEK, fetchExchangeRate } from '@/lib/currency/riksbanken'
|
||||
import { eventBus } from '@/lib/events'
|
||||
import type { Logger } from '@/lib/logger'
|
||||
import type { Invoice, InvoiceDocumentType } from '@/types'
|
||||
|
||||
const BulkCreateRequest = z.object({
|
||||
invoices: z.array(CreateInvoiceSchema).min(1).max(50),
|
||||
all_or_nothing: z.boolean().optional().default(false),
|
||||
})
|
||||
|
||||
const BulkResultItem = z.object({
|
||||
ok: z.boolean(),
|
||||
request_index: z.number().int().nonnegative(),
|
||||
data: z.unknown().optional(),
|
||||
error: z.object({
|
||||
code: z.string(),
|
||||
message: z.string(),
|
||||
details: z.unknown().optional(),
|
||||
}).optional(),
|
||||
})
|
||||
|
||||
const BulkCreateResponse = z.object({
|
||||
results: z.array(BulkResultItem),
|
||||
summary: z.object({
|
||||
total: z.number().int(),
|
||||
succeeded: z.number().int(),
|
||||
failed: z.number().int(),
|
||||
}),
|
||||
})
|
||||
|
||||
const INVOICE_BULK_RESPONSE_COLUMNS =
|
||||
'id, invoice_number, customer_id, invoice_date, due_date, status, currency, subtotal, vat_amount, total, document_type, created_at'
|
||||
|
||||
registerEndpoint({
|
||||
operation: 'invoices.bulk-create',
|
||||
method: 'POST',
|
||||
path: '/api/v1/companies/:companyId/invoices/bulk-create',
|
||||
summary: 'Create up to 50 draft invoices in one call (partial-success).',
|
||||
description:
|
||||
'Bulk-creation endpoint. Each invoice in the request array is validated and inserted independently. By default, individual failures do not roll back successes — the response carries a per-item results array with ok/error markers and a summary. Idempotent (the whole batch is keyed by the single Idempotency-Key). Dry-runnable.',
|
||||
useWhen:
|
||||
'You\'re importing a batch of invoices from another system, or producing many invoices programmatically (e.g. monthly subscription billing). Use dry-run first to validate the whole batch before committing.',
|
||||
doNotUseFor:
|
||||
'Sending the same invoice to multiple customers — POST /invoices once per customer. Long-running imports of > 50 invoices — split into pages. Transactional all-or-nothing imports — not yet supported (passing all_or_nothing: true returns 501 NOT_IMPLEMENTED; the flag is reserved for a future RPC).',
|
||||
pitfalls: [
|
||||
'Idempotency-Key is mandatory and covers the WHOLE batch. A retried bulk-create returns the cached full response — it does not retry only the failed items.',
|
||||
'Passing all_or_nothing: true returns 501 NOT_IMPLEMENTED. Today only partial-success batches exist; omit the flag (or pass false).',
|
||||
'Each per-item invoice still goes through the same VAT-rule validation as POST /invoices. A mismatched per-item vat_rate produces a per-item failure, not a whole-batch failure.',
|
||||
'Currency conversion is best-effort PER ITEM. A failed Riksbanken fetch leaves that item\'s SEK columns null but does NOT fail the item.',
|
||||
],
|
||||
example: {
|
||||
request: {
|
||||
invoices: [
|
||||
{
|
||||
customer_id: 'a8f1…',
|
||||
invoice_date: '2026-05-12',
|
||||
due_date: '2026-06-11',
|
||||
currency: 'SEK',
|
||||
items: [{ description: 'A', quantity: 1, unit: 'st', unit_price: 1000 }],
|
||||
},
|
||||
],
|
||||
},
|
||||
response: {
|
||||
data: {
|
||||
results: [
|
||||
{
|
||||
ok: true,
|
||||
request_index: 0,
|
||||
data: {
|
||||
id: '0e9c…',
|
||||
invoice_number: null,
|
||||
status: 'draft',
|
||||
total: 1250,
|
||||
},
|
||||
},
|
||||
],
|
||||
summary: { total: 1, succeeded: 1, failed: 0 },
|
||||
},
|
||||
meta: { request_id: 'req_…', api_version: '2026-05-12' },
|
||||
},
|
||||
},
|
||||
scope: 'invoices:write',
|
||||
risk: 'medium',
|
||||
idempotent: true,
|
||||
reversible: true,
|
||||
dryRunSupported: true,
|
||||
request: { body: BulkCreateRequest },
|
||||
response: { success: BulkCreateResponse },
|
||||
})
|
||||
|
||||
interface ResultItem {
|
||||
ok: boolean
|
||||
request_index: number
|
||||
data?: unknown
|
||||
error?: { code: string; message: string; details?: unknown }
|
||||
}
|
||||
|
||||
/**
|
||||
* Create one invoice (the bulk-create item path). Mirrors the inline logic
|
||||
* in POST /invoices/route.ts. Returns a ResultItem instead of an HTTP
|
||||
* response so the caller can aggregate.
|
||||
*
|
||||
* TODO: extract POST /invoices's create logic into a shared lib function
|
||||
* so this and the per-call POST share one implementation. Tracked as
|
||||
* cross-route cleanup.
|
||||
*/
|
||||
async function createOneInvoice(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
userId: string,
|
||||
index: number,
|
||||
input: z.infer<typeof CreateInvoiceSchema>,
|
||||
dryRun: boolean,
|
||||
log: Logger,
|
||||
): Promise<ResultItem> {
|
||||
const documentType: InvoiceDocumentType = input.document_type || 'invoice'
|
||||
|
||||
// Customer fetch (scoped to company). We use the DB-returned `customer.id`
|
||||
// (not `input.customer_id`) downstream as defense in depth — the .eq()
|
||||
// pair already enforces company scoping, but echoing the trusted value
|
||||
// from the query makes the guarantee explicit at the call site and
|
||||
// immune to refactoring drift.
|
||||
const { data: customer } = await supabase
|
||||
.from('customers')
|
||||
.select('id, customer_type, vat_number_validated')
|
||||
.eq('company_id', companyId)
|
||||
.eq('id', input.customer_id)
|
||||
.maybeSingle()
|
||||
if (!customer) {
|
||||
return {
|
||||
ok: false,
|
||||
request_index: index,
|
||||
error: { code: 'INVOICE_CUSTOMER_NOT_FOUND', message: 'Customer not found in this company.' },
|
||||
}
|
||||
}
|
||||
const verifiedCustomerId = (customer as { id: string }).id
|
||||
|
||||
const vatRules = getVatRules(
|
||||
customer.customer_type as Parameters<typeof getVatRules>[0],
|
||||
customer.vat_number_validated,
|
||||
)
|
||||
const availableRates = getAvailableVatRates(
|
||||
customer.customer_type as Parameters<typeof getAvailableVatRates>[0],
|
||||
customer.vat_number_validated,
|
||||
)
|
||||
const allowedRates = new Set(availableRates.map((r) => r.rate))
|
||||
|
||||
const subtotal = input.items.reduce((sum, item) => sum + item.quantity * item.unit_price, 0)
|
||||
let vatAmount = 0
|
||||
if (documentType !== 'delivery_note') {
|
||||
for (const item of input.items) {
|
||||
const itemRate = item.vat_rate !== undefined ? item.vat_rate : vatRules.rate
|
||||
if (!allowedRates.has(itemRate)) {
|
||||
return {
|
||||
ok: false,
|
||||
request_index: index,
|
||||
error: {
|
||||
code: 'INVOICE_CREATE_VAT_RULE_VIOLATION',
|
||||
message: 'A line item carries a VAT rate not allowed for the customer type.',
|
||||
details: {
|
||||
attempted_rate: itemRate,
|
||||
allowed_rates: Array.from(allowedRates),
|
||||
customer_type: customer.customer_type,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
const lineTotal = item.quantity * item.unit_price
|
||||
vatAmount += Math.round((lineTotal * itemRate) / 100 * 100) / 100
|
||||
}
|
||||
}
|
||||
const total = documentType === 'delivery_note' ? 0 : subtotal + vatAmount
|
||||
const uniqueRates = new Set(input.items.map((it) => it.vat_rate ?? vatRules.rate))
|
||||
const isMixedRate = uniqueRates.size > 1
|
||||
const headerVatRate = documentType === 'delivery_note'
|
||||
? 0
|
||||
: isMixedRate
|
||||
? null
|
||||
: (uniqueRates.values().next().value ?? vatRules.rate)
|
||||
|
||||
// Currency conversion (best-effort per item).
|
||||
let exchangeRate: number | null = null
|
||||
let exchangeRateDate: string | null = null
|
||||
let subtotalSek: number | null = null
|
||||
let vatAmountSek: number | null = null
|
||||
let totalSek: number | null = null
|
||||
if (input.currency !== 'SEK') {
|
||||
try {
|
||||
const rate = await fetchExchangeRate(input.currency)
|
||||
if (rate) {
|
||||
exchangeRate = rate.rate
|
||||
exchangeRateDate = rate.date
|
||||
subtotalSek = convertToSEK(subtotal, exchangeRate)
|
||||
vatAmountSek = convertToSEK(vatAmount, exchangeRate)
|
||||
totalSek = convertToSEK(total, exchangeRate)
|
||||
}
|
||||
} catch (err) {
|
||||
log.warn('bulk-create: exchange-rate fetch failed for item', err as Error, {
|
||||
request_index: index,
|
||||
currency: input.currency,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const itemRows = input.items.map((item, sortOrder) => {
|
||||
const itemRate = item.vat_rate !== undefined ? item.vat_rate : vatRules.rate
|
||||
const lineTotal = item.quantity * item.unit_price
|
||||
const itemVat = documentType === 'delivery_note'
|
||||
? 0
|
||||
: Math.round((lineTotal * itemRate) / 100 * 100) / 100
|
||||
return {
|
||||
sort_order: sortOrder,
|
||||
description: item.description,
|
||||
quantity: item.quantity,
|
||||
unit: item.unit,
|
||||
unit_price: item.unit_price,
|
||||
line_total: lineTotal,
|
||||
vat_rate: itemRate,
|
||||
vat_amount: itemVat,
|
||||
}
|
||||
})
|
||||
|
||||
if (dryRun) {
|
||||
return {
|
||||
ok: true,
|
||||
request_index: index,
|
||||
data: {
|
||||
preview: {
|
||||
invoice_number: null,
|
||||
customer_id: verifiedCustomerId,
|
||||
invoice_date: input.invoice_date,
|
||||
due_date: input.due_date,
|
||||
status: 'draft' as const,
|
||||
currency: input.currency,
|
||||
subtotal: documentType === 'delivery_note' ? 0 : subtotal,
|
||||
vat_amount: vatAmount,
|
||||
total,
|
||||
document_type: documentType,
|
||||
items: itemRows,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Commit path. Insert invoice header.
|
||||
const { data: invoice, error: invoiceErr } = await supabase
|
||||
.from('invoices')
|
||||
.insert({
|
||||
user_id: userId,
|
||||
company_id: companyId,
|
||||
customer_id: verifiedCustomerId,
|
||||
invoice_number: null,
|
||||
invoice_date: input.invoice_date,
|
||||
due_date: input.due_date,
|
||||
delivery_date: input.delivery_date ?? null,
|
||||
currency: input.currency,
|
||||
exchange_rate: exchangeRate,
|
||||
exchange_rate_date: exchangeRateDate,
|
||||
subtotal: documentType === 'delivery_note' ? 0 : subtotal,
|
||||
subtotal_sek: documentType === 'delivery_note' ? null : subtotalSek,
|
||||
vat_amount: vatAmount,
|
||||
vat_amount_sek: documentType === 'delivery_note' ? null : vatAmountSek,
|
||||
total,
|
||||
total_sek: documentType === 'delivery_note' ? null : totalSek,
|
||||
remaining_amount: documentType === 'invoice' ? total : 0,
|
||||
vat_treatment: vatRules.treatment,
|
||||
vat_rate: headerVatRate,
|
||||
moms_ruta: vatRules.momsRuta,
|
||||
reverse_charge_text: vatRules.reverseChargeText || null,
|
||||
your_reference: input.your_reference,
|
||||
our_reference: input.our_reference,
|
||||
notes: input.notes,
|
||||
document_type: documentType,
|
||||
})
|
||||
.select(INVOICE_BULK_RESPONSE_COLUMNS)
|
||||
.single()
|
||||
|
||||
if (invoiceErr) {
|
||||
log.error('bulk-create: invoice insert failed', invoiceErr, {
|
||||
request_index: index,
|
||||
companyId,
|
||||
pgCode: invoiceErr.code,
|
||||
})
|
||||
return {
|
||||
ok: false,
|
||||
request_index: index,
|
||||
error: {
|
||||
code: 'INVOICE_CREATE_INSERT_FAILED',
|
||||
message: 'Invoice insert failed.',
|
||||
details: { pg_code: invoiceErr.code },
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const invoiceId = (invoice as { id: string }).id
|
||||
const itemsToInsert = itemRows.map((r) => ({ ...r, invoice_id: invoiceId }))
|
||||
const { error: itemsErr } = await supabase.from('invoice_items').insert(itemsToInsert)
|
||||
if (itemsErr) {
|
||||
// Roll back this invoice; other batch items are unaffected.
|
||||
const { error: rbErr } = await supabase
|
||||
.from('invoices')
|
||||
.delete()
|
||||
.eq('id', invoiceId)
|
||||
.eq('company_id', companyId)
|
||||
if (rbErr) {
|
||||
log.error(
|
||||
'bulk-create: items insert failed AND rollback delete failed — orphaned header',
|
||||
rbErr,
|
||||
{ request_index: index, invoiceId, companyId, originalPgCode: itemsErr.code },
|
||||
)
|
||||
} else {
|
||||
log.error('bulk-create: items insert failed; rolled back invoice', itemsErr, {
|
||||
request_index: index,
|
||||
invoiceId,
|
||||
companyId,
|
||||
})
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
request_index: index,
|
||||
error: {
|
||||
code: 'INVOICE_CREATE_ITEMS_FAILED',
|
||||
message: 'Invoice items insert failed; the invoice was rolled back.',
|
||||
details: { pg_code: itemsErr.code },
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Emit invoice.created per successful item (matches POST /invoices).
|
||||
if (documentType === 'invoice') {
|
||||
try {
|
||||
await eventBus.emit({
|
||||
type: 'invoice.created',
|
||||
payload: { invoice: invoice as unknown as Invoice, companyId, userId },
|
||||
})
|
||||
} catch (err) {
|
||||
log.warn('bulk-create: invoice.created emit failed', err as Error, {
|
||||
request_index: index,
|
||||
invoiceId,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return { ok: true, request_index: index, data: invoice }
|
||||
}
|
||||
|
||||
export const POST = withApiV1<{ params: Promise<{ companyId: string }> }>(
|
||||
'invoices.bulk-create',
|
||||
async (request, ctx) => {
|
||||
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.' },
|
||||
})
|
||||
}
|
||||
|
||||
let rawBody: unknown
|
||||
try {
|
||||
rawBody = await request.json()
|
||||
} catch {
|
||||
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
|
||||
requestId: ctx.requestId,
|
||||
details: { field: 'body', message: 'Body is not valid JSON.' },
|
||||
})
|
||||
}
|
||||
|
||||
const parsed = BulkCreateRequest.safeParse(rawBody)
|
||||
if (!parsed.success) {
|
||||
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
|
||||
requestId: ctx.requestId,
|
||||
details: {
|
||||
issues: parsed.error.issues.map((i) => ({
|
||||
field: i.path.join('.'),
|
||||
message: i.message,
|
||||
})),
|
||||
},
|
||||
})
|
||||
}
|
||||
const body = parsed.data
|
||||
|
||||
// Reject all_or_nothing: true loudly. The schema accepts it for forward
|
||||
// compatibility, but a caller asking for atomic semantics must not get
|
||||
// partial-success behaviour silently — that would let an automation
|
||||
// depend on a guarantee that doesn't exist. The flag will be honored
|
||||
// once a DB-side RPC ships; until then it's 501.
|
||||
if (body.all_or_nothing) {
|
||||
return v1ErrorResponseFromCode('NOT_IMPLEMENTED', ctx.log, {
|
||||
requestId: ctx.requestId,
|
||||
details: {
|
||||
field: 'all_or_nothing',
|
||||
message:
|
||||
'all_or_nothing: true is not yet implemented. Omit the flag (or pass false) to use partial-success semantics.',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Run items sequentially. Parallel would be faster but the database-side
|
||||
// sequence allocation for delivery notes and the auditability of the
|
||||
// log stream are easier to reason about sequentially. 50-item cap keeps
|
||||
// the worst-case latency bounded.
|
||||
const results: ResultItem[] = []
|
||||
for (let i = 0; i < body.invoices.length; i++) {
|
||||
const item = await createOneInvoice(
|
||||
ctx.supabase,
|
||||
ctx.companyId!,
|
||||
ctx.userId,
|
||||
i,
|
||||
body.invoices[i],
|
||||
ctx.dryRun,
|
||||
ctx.log,
|
||||
)
|
||||
results.push(item)
|
||||
}
|
||||
|
||||
const summary = {
|
||||
total: results.length,
|
||||
succeeded: results.filter((r) => r.ok).length,
|
||||
failed: results.filter((r) => !r.ok).length,
|
||||
}
|
||||
|
||||
ctx.log.info('invoices.bulk-create completed', {
|
||||
companyId: ctx.companyId,
|
||||
userId: ctx.userId,
|
||||
...summary,
|
||||
dryRun: ctx.dryRun,
|
||||
})
|
||||
|
||||
if (ctx.dryRun) {
|
||||
return dryRunPreview({ results, summary }, { requestId: ctx.requestId, log: ctx.log })
|
||||
}
|
||||
|
||||
return ok({ results, summary }, { requestId: ctx.requestId })
|
||||
},
|
||||
{ requireIdempotencyKey: true },
|
||||
)
|
||||
@@ -24,5 +24,7 @@ import '@/app/api/v1/companies/[companyId]/customers/[id]/route'
|
||||
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/bulk-create/route'
|
||||
|
||||
export {}
|
||||
|
||||
@@ -63,6 +63,8 @@ export const V1_ENDPOINT_SCOPES: Record<string, ApiKeyScope> = {
|
||||
'POST /api/v1/companies/:companyId/invoices/:id/mark-sent': 'invoices:write',
|
||||
'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/bulk-create': 'invoices:write',
|
||||
|
||||
// Webhooks (Phase 6 — placeholder so the catalogue is complete)
|
||||
'GET /api/v1/companies/:companyId/webhooks': 'webhooks:manage',
|
||||
|
||||
@@ -80,6 +80,11 @@ const GENERIC: Record<string, StructuredErrorEntry> = {
|
||||
message_sv: 'För många förfrågningar. Vänta en stund och försök igen.',
|
||||
message_en: 'Rate limit exceeded.',
|
||||
},
|
||||
NOT_IMPLEMENTED: {
|
||||
httpStatus: 501,
|
||||
message_sv: 'Funktionen är inte implementerad ännu.',
|
||||
message_en: 'This feature is accepted by the schema but not yet implemented.',
|
||||
},
|
||||
COMPANY_CONTEXT_MISSING: {
|
||||
httpStatus: 400,
|
||||
message_sv: 'Ingen aktiv företagskontext. Välj ett företag och försök igen.',
|
||||
|
||||
@@ -58,12 +58,21 @@ export async function updateSession(request: NextRequest) {
|
||||
return supabaseResponse
|
||||
}
|
||||
|
||||
// Reset-password is reachable in both auth states. The recovery flow lands
|
||||
// here with a fresh session (created by the OTP exchange in /auth/callback)
|
||||
// precisely so the user can call supabase.auth.updateUser({ password }). If
|
||||
// we bounce authenticated users to '/', the recovery email link silently
|
||||
// fails. An already-logged-in user typing /reset-password directly just gets
|
||||
// the same "change password" experience as in settings — no security loss.
|
||||
if (pathname.startsWith('/reset-password')) {
|
||||
return supabaseResponse
|
||||
}
|
||||
|
||||
// Public auth routes — allow access
|
||||
if (
|
||||
pathname.startsWith('/login') ||
|
||||
pathname.startsWith('/register') ||
|
||||
pathname.startsWith('/auth') ||
|
||||
pathname.startsWith('/reset-password') ||
|
||||
pathname.startsWith('/sandbox')
|
||||
) {
|
||||
// If user is logged in and trying to access auth pages, redirect to dashboard
|
||||
|
||||
Reference in New Issue
Block a user