Feat/voucher docs (#664)
* feat: implement inbox document picker and linking functionality * feat: implement self-billing invoice functionality - Added support for registering self-billed invoices received from customers. - Updated the invoice schema to include fields for self-billing metadata such as `is_self_billed`, `external_invoice_number`, `self_billing_agreement_ref`, and `received_date`. - Created API route for handling self-billed invoice submissions, including validation and error handling. - Implemented database migrations to add necessary columns and constraints for self-billing invoices. - Developed tests to ensure correct behavior of self-billing invoice creation and validation rules. - Updated Swedish localization files to include new terms related to self-billing. * feat: enforce SIE import requirement for non-Fortnox providers in migration process * feat: streamline invoice processing and enhance error logging across APIs
This commit is contained in:
@@ -9,6 +9,7 @@ import { prepareInvoicePdfRender } from '@/lib/invoices/pdf-render-helpers'
|
||||
import { uploadDocument } from '@/lib/core/documents/document-service'
|
||||
import { requireCompanyId } from '@/lib/company/context'
|
||||
import { requireWritePermission } from '@/lib/auth/require-write'
|
||||
import { createLogger } from '@/lib/logger'
|
||||
import type { CompanySettings, Customer, EntityType, Invoice, InvoiceItem } from '@/types'
|
||||
|
||||
ensureInitialized()
|
||||
@@ -37,6 +38,7 @@ export async function POST(
|
||||
if (!writeCheck.ok) return writeCheck.response
|
||||
|
||||
const companyId = await requireCompanyId(supabase, user.id)
|
||||
const log = createLogger('invoice.mark-sent', { companyId, invoiceId: id })
|
||||
|
||||
// Fetch invoice
|
||||
const { data: invoice, error: invoiceError } = await supabase
|
||||
@@ -61,7 +63,7 @@ export async function POST(
|
||||
try {
|
||||
await ensureInvoiceNumber(supabase, companyId, invoice as Invoice)
|
||||
} catch (err) {
|
||||
console.error('Failed to assign invoice number on mark-sent:', err)
|
||||
log.error('failed to assign invoice number on mark-sent', err as Error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Kunde inte tilldela fakturanummer. Försök igen.' },
|
||||
{ status: 500 }
|
||||
@@ -103,13 +105,24 @@ export async function POST(
|
||||
)
|
||||
if (journalEntry) {
|
||||
journalEntryId = journalEntry.id
|
||||
await supabase
|
||||
const { error: linkError } = await supabase
|
||||
.from('invoices')
|
||||
.update({ journal_entry_id: journalEntry.id })
|
||||
.eq('id', id)
|
||||
if (linkError) {
|
||||
// Don't fail mark-sent — the verifikat committed; only the link
|
||||
// failed. But log it through the structured logger so it reaches log
|
||||
// aggregation/alerting: this write silently no-ops when the
|
||||
// journal_entry_id column is missing (it was absent in prod until the
|
||||
// 20260613100000 migration), which leaves mark-paid unable to detect
|
||||
// an already-booked sale.
|
||||
log.error('mark-sent: journal_entry_id link to invoice failed', linkError, {
|
||||
journalEntryId: journalEntry.id,
|
||||
})
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to create invoice journal entry on mark-sent:', err)
|
||||
log.error('failed to create invoice journal entry on mark-sent', err as Error)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -162,7 +175,7 @@ export async function POST(
|
||||
journal_entry_id: journalEntryId ?? undefined,
|
||||
})
|
||||
} catch (err) {
|
||||
console.error('Failed to archive invoice PDF on mark-sent:', err)
|
||||
log.error('failed to archive invoice PDF on mark-sent', err as Error)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import {
|
||||
createMockRequest,
|
||||
parseJsonResponse,
|
||||
createQueuedMockSupabase,
|
||||
makeInvoice,
|
||||
makeCustomer,
|
||||
} from '@/tests/helpers'
|
||||
import { eventBus } from '@/lib/events'
|
||||
|
||||
const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase()
|
||||
vi.mock('@/lib/supabase/server', () => ({
|
||||
createClient: () => Promise.resolve(mockSupabase),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/init', () => ({
|
||||
ensureInitialized: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/company/context', () => ({
|
||||
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/auth/require-write', () => ({
|
||||
requireWritePermission: vi.fn().mockResolvedValue({ ok: true }),
|
||||
}))
|
||||
|
||||
const mockGetVatRules = vi.fn()
|
||||
const mockGetAvailableVatRates = vi.fn()
|
||||
vi.mock('@/lib/invoices/vat-rules', () => ({
|
||||
getVatRules: (...args: unknown[]) => mockGetVatRules(...args),
|
||||
getAvailableVatRates: (...args: unknown[]) => mockGetAvailableVatRates(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/currency/riksbanken', () => ({
|
||||
fetchExchangeRate: vi.fn().mockResolvedValue(null),
|
||||
convertToSEK: vi.fn(),
|
||||
}))
|
||||
|
||||
const mockCreateInvoiceJournalEntry = vi.fn()
|
||||
vi.mock('@/lib/bookkeeping/invoice-entries', () => ({
|
||||
createInvoiceJournalEntry: (...args: unknown[]) => mockCreateInvoiceJournalEntry(...args),
|
||||
}))
|
||||
|
||||
import { POST } from '../route'
|
||||
|
||||
const VALID_UUID = '550e8400-e29b-41d4-a716-446655440000'
|
||||
const mockUser = { id: 'user-1', email: 'test@test.se' }
|
||||
|
||||
const validBody = {
|
||||
customer_id: VALID_UUID,
|
||||
external_invoice_number: 'KUND-55012',
|
||||
self_billing_agreement_ref: 'Avtal 2026-01',
|
||||
invoice_date: '2026-06-01',
|
||||
received_date: '2026-06-02',
|
||||
due_date: '2026-06-30',
|
||||
currency: 'SEK',
|
||||
items: [{ description: 'Konsulttjänst', quantity: 10, unit: 'tim', unit_price: 1000 }],
|
||||
}
|
||||
|
||||
function mockDomesticVat() {
|
||||
mockGetVatRules.mockReturnValue({
|
||||
treatment: 'standard_25',
|
||||
rate: 25,
|
||||
momsRuta: '05',
|
||||
reverseChargeText: null,
|
||||
})
|
||||
mockGetAvailableVatRates.mockReturnValue([
|
||||
{ rate: 25, label: '25%', treatment: 'standard_25' },
|
||||
{ rate: 12, label: '12%', treatment: 'reduced_12' },
|
||||
{ rate: 6, label: '6%', treatment: 'reduced_6' },
|
||||
{ rate: 0, label: '0% (momsfri)', treatment: 'exempt' },
|
||||
])
|
||||
}
|
||||
|
||||
describe('POST /api/invoices/self-billed', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
reset()
|
||||
eventBus.clear()
|
||||
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } })
|
||||
})
|
||||
|
||||
it('returns 401 when not authenticated', async () => {
|
||||
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: null } })
|
||||
|
||||
const request = createMockRequest('/api/invoices/self-billed', { method: 'POST', body: validBody })
|
||||
const response = await POST(request)
|
||||
const { status } = await parseJsonResponse(response)
|
||||
|
||||
expect(status).toBe(401)
|
||||
})
|
||||
|
||||
it('returns 400 when external_invoice_number is missing', async () => {
|
||||
const { external_invoice_number, ...rest } = validBody
|
||||
void external_invoice_number
|
||||
|
||||
const request = createMockRequest('/api/invoices/self-billed', { method: 'POST', body: rest })
|
||||
const response = await POST(request)
|
||||
const { status, body } = await parseJsonResponse<{ type: string }>(response)
|
||||
|
||||
expect(status).toBe(400)
|
||||
expect(body.type).toBe('validation_error')
|
||||
})
|
||||
|
||||
it('returns 404 when the customer (issuer) is not found', async () => {
|
||||
enqueue({ data: null, error: { message: 'Not found' } })
|
||||
|
||||
const request = createMockRequest('/api/invoices/self-billed', { method: 'POST', body: validBody })
|
||||
const response = await POST(request)
|
||||
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
|
||||
|
||||
expect(status).toBe(404)
|
||||
expect(body.error.code).toBe('INVOICE_CUSTOMER_NOT_FOUND')
|
||||
})
|
||||
|
||||
it('rejects an item VAT rate the customer is not allowed to use', async () => {
|
||||
mockGetVatRules.mockReturnValue({ treatment: 'standard_25', rate: 25, momsRuta: '05', reverseChargeText: null })
|
||||
// Domestic-only set: 0% is NOT allowed for this customer.
|
||||
mockGetAvailableVatRates.mockReturnValue([
|
||||
{ rate: 25, label: '25%', treatment: 'standard_25' },
|
||||
{ rate: 12, label: '12%', treatment: 'reduced_12' },
|
||||
{ rate: 6, label: '6%', treatment: 'reduced_6' },
|
||||
])
|
||||
enqueue({ data: makeCustomer({ id: VALID_UUID }), error: null })
|
||||
|
||||
const request = createMockRequest('/api/invoices/self-billed', {
|
||||
method: 'POST',
|
||||
body: { ...validBody, items: [{ description: 'X', quantity: 1, unit: 'st', unit_price: 100, vat_rate: 0 }] },
|
||||
})
|
||||
const response = await POST(request)
|
||||
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
|
||||
|
||||
expect(status).toBe(400)
|
||||
expect(body.error.code).toBe('INVOICE_CREATE_VAT_RULE_VIOLATION')
|
||||
})
|
||||
|
||||
it('creates a self-billed sale, books it (accrual), skips own numbering, and emits invoice.created', async () => {
|
||||
mockDomesticVat()
|
||||
const customer = makeCustomer({ id: VALID_UUID, name: 'Stora Bolaget AB' })
|
||||
const created = makeInvoice({
|
||||
id: 'inv-1',
|
||||
invoice_number: null,
|
||||
is_self_billed: true,
|
||||
external_invoice_number: 'KUND-55012',
|
||||
total: 12500,
|
||||
})
|
||||
|
||||
enqueue({ data: customer, error: null }) // fetch customer
|
||||
enqueue({ data: created, error: null }) // insert invoice
|
||||
enqueue({ data: null, error: null }) // insert items
|
||||
enqueue({ data: { accounting_method: 'accrual', entity_type: 'aktiebolag' }, error: null }) // settings
|
||||
enqueue({ data: { ...created, customer, items: [] }, error: null }) // fetch complete
|
||||
enqueue({ data: null, error: null }) // update journal_entry_id
|
||||
enqueue({ data: { ...created, customer, items: [], journal_entry_id: 'je-1' }, error: null }) // fetch final
|
||||
|
||||
mockCreateInvoiceJournalEntry.mockResolvedValue({ id: 'je-1' })
|
||||
const emitSpy = vi.spyOn(eventBus, 'emit')
|
||||
|
||||
const request = createMockRequest('/api/invoices/self-billed', { method: 'POST', body: validBody })
|
||||
const response = await POST(request)
|
||||
const { status, body } = await parseJsonResponse<{ data: { id: string } }>(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.data).toBeTruthy()
|
||||
|
||||
// Booked as a sale with the self-billing label + the counterparty's number.
|
||||
expect(mockCreateInvoiceJournalEntry).toHaveBeenCalledTimes(1)
|
||||
const opts = mockCreateInvoiceJournalEntry.mock.calls[0][6]
|
||||
expect(opts).toEqual({ descriptionPrefix: 'Självfaktura', numberOverride: 'KUND-55012' })
|
||||
|
||||
// Never consumes our own invoice-number series.
|
||||
expect(mockSupabase.rpc).not.toHaveBeenCalledWith('generate_invoice_number', expect.anything())
|
||||
|
||||
expect(emitSpy).toHaveBeenCalledWith(expect.objectContaining({ type: 'invoice.created' }))
|
||||
})
|
||||
|
||||
it('does NOT book at registration under kontantmetoden (cash) — books at payment instead', async () => {
|
||||
mockDomesticVat()
|
||||
const customer = makeCustomer({ id: VALID_UUID })
|
||||
const created = makeInvoice({ id: 'inv-1', invoice_number: null, is_self_billed: true, external_invoice_number: 'KUND-55012' })
|
||||
|
||||
enqueue({ data: customer, error: null }) // fetch customer
|
||||
enqueue({ data: created, error: null }) // insert invoice
|
||||
enqueue({ data: null, error: null }) // insert items
|
||||
enqueue({ data: { accounting_method: 'cash', entity_type: 'enskild_firma' }, error: null }) // settings
|
||||
enqueue({ data: { ...created, customer, items: [] }, error: null }) // fetch complete
|
||||
enqueue({ data: { ...created, customer, items: [] }, error: null }) // fetch final
|
||||
|
||||
const request = createMockRequest('/api/invoices/self-billed', { method: 'POST', body: validBody })
|
||||
const response = await POST(request)
|
||||
const { status } = await parseJsonResponse(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(mockCreateInvoiceJournalEntry).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rolls back when there is no open fiscal period for the invoice date', async () => {
|
||||
mockDomesticVat()
|
||||
const customer = makeCustomer({ id: VALID_UUID })
|
||||
const created = makeInvoice({ id: 'inv-1', invoice_number: null, is_self_billed: true, external_invoice_number: 'KUND-55012' })
|
||||
|
||||
enqueue({ data: customer, error: null }) // fetch customer
|
||||
enqueue({ data: created, error: null }) // insert invoice
|
||||
enqueue({ data: null, error: null }) // insert items
|
||||
enqueue({ data: { accounting_method: 'accrual', entity_type: 'aktiebolag' }, error: null }) // settings
|
||||
enqueue({ data: { ...created, customer, items: [] }, error: null }) // fetch complete
|
||||
enqueue({ data: null, error: null }) // rollback delete
|
||||
|
||||
mockCreateInvoiceJournalEntry.mockResolvedValue(null) // no fiscal period
|
||||
|
||||
const request = createMockRequest('/api/invoices/self-billed', { method: 'POST', body: validBody })
|
||||
const response = await POST(request)
|
||||
const { status } = await parseJsonResponse(response)
|
||||
|
||||
expect(status).toBe(400)
|
||||
expect(mockSupabase.from).toHaveBeenCalledWith('invoices')
|
||||
})
|
||||
|
||||
it('rejects a foreign-currency self-billed invoice when no FX rate is available', async () => {
|
||||
mockDomesticVat()
|
||||
// fetchExchangeRate is mocked to resolve null (rate unavailable for the
|
||||
// invoice date). Booking would otherwise fall through to a silent 1:1 SEK
|
||||
// conversion, so the route must refuse up front — before any insert.
|
||||
enqueue({ data: makeCustomer({ id: VALID_UUID }), error: null }) // fetch customer
|
||||
|
||||
const request = createMockRequest('/api/invoices/self-billed', {
|
||||
method: 'POST',
|
||||
body: { ...validBody, currency: 'EUR' },
|
||||
})
|
||||
const response = await POST(request)
|
||||
const { status, body } = await parseJsonResponse<{ type: string; error: string }>(response)
|
||||
|
||||
expect(status).toBe(400)
|
||||
expect(body.error).toMatch(/växelkurs/i)
|
||||
// Never books a wrong-magnitude verifikat and never inserts the invoice.
|
||||
expect(mockCreateInvoiceJournalEntry).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,311 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { eventBus } from '@/lib/events'
|
||||
import { ensureInitialized } from '@/lib/init'
|
||||
import { CreateSelfBillingInvoiceSchema } from '@/lib/api/schemas'
|
||||
import { getVatRules, getAvailableVatRates } from '@/lib/invoices/vat-rules'
|
||||
import { fetchExchangeRate, convertToSEK } from '@/lib/currency/riksbanken'
|
||||
import { createInvoiceJournalEntry } from '@/lib/bookkeeping/invoice-entries'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error'
|
||||
import { roundOre } from '@/lib/money'
|
||||
import type { EntityType, Invoice } from '@/types'
|
||||
|
||||
ensureInitialized()
|
||||
|
||||
/**
|
||||
* POST /api/invoices/self-billed
|
||||
*
|
||||
* Register a self-billing invoice we RECEIVED (mottagen självfaktura, ML 17 kap
|
||||
* 15§). The customer issued the invoice on our behalf; for us it is a sale, so
|
||||
* it books exactly like a customer invoice (Debit 1510, Credit 30xx + 26xx) and
|
||||
* the output VAT lands in our momsdeklaration.
|
||||
*
|
||||
* It differs from a normal customer invoice in two ways:
|
||||
* - We do NOT assign a number from our own series — the counterparty's number
|
||||
* is stored in external_invoice_number and our invoice_number stays null
|
||||
* (BFL 5 kap 6§). Enforced by the invoices_self_billed_numbering constraint.
|
||||
* - There is no send step. Under faktureringsmetoden (accrual) we book the
|
||||
* registration entry here. Under kontantmetoden (cash) we leave it unbooked
|
||||
* until payment — identical to a normal invoice — and the existing mark-paid
|
||||
* flow books the cash entry then.
|
||||
*
|
||||
* Payment is handled by the existing flows: the row is created with status
|
||||
* 'sent', so "Markera som betald" / bank matching work unchanged.
|
||||
*/
|
||||
export const POST = withRouteContext(
|
||||
'invoice.self_billed.create',
|
||||
async (request, ctx) => {
|
||||
const { user, supabase, companyId, log, requestId } = ctx
|
||||
|
||||
let rawBody: unknown
|
||||
try {
|
||||
rawBody = await request.json()
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid JSON in request body', type: 'validation_error' },
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
|
||||
const parsed = CreateSelfBillingInvoiceSchema.safeParse(rawBody)
|
||||
if (!parsed.success) {
|
||||
log.warn('self-billed invoice validation failed', { issueCount: parsed.error.issues.length })
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Validation failed',
|
||||
type: 'validation_error',
|
||||
errors: parsed.error.issues.map((i) => ({ field: i.path.join('.'), message: i.message, code: i.code })),
|
||||
},
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
const input = parsed.data
|
||||
|
||||
// The issuer of a self-billing invoice is, in our books, the customer we
|
||||
// sold to. Require an existing customer row so VAT rules + reporting work.
|
||||
// Project only the fields used below (data minimisation — GDPR Art. 25 /
|
||||
// SOC 2 CC6.3): VAT treatment derivation and the verifikat description.
|
||||
const { data: customer, error: customerError } = await supabase
|
||||
.from('customers')
|
||||
.select('id, name, customer_type, vat_number_validated')
|
||||
.eq('id', input.customer_id)
|
||||
.eq('company_id', companyId!)
|
||||
.single()
|
||||
|
||||
if (customerError || !customer) {
|
||||
return errorResponseFromCode('INVOICE_CUSTOMER_NOT_FOUND', log, {
|
||||
requestId,
|
||||
details: { customerId: input.customer_id },
|
||||
})
|
||||
}
|
||||
|
||||
// VAT treatment is driven by who the customer is (domestic / EU reverse
|
||||
// charge / export), exactly like an own-issued invoice.
|
||||
const vatRules = getVatRules(customer.customer_type, customer.vat_number_validated)
|
||||
const availableRates = getAvailableVatRates(customer.customer_type, customer.vat_number_validated)
|
||||
const allowedRates = new Set(availableRates.map((r) => r.rate))
|
||||
|
||||
let vatAmount = 0
|
||||
for (const item of input.items) {
|
||||
const itemRate = item.vat_rate !== undefined ? item.vat_rate : vatRules.rate
|
||||
if (!allowedRates.has(itemRate)) {
|
||||
return errorResponseFromCode('INVOICE_CREATE_VAT_RULE_VIOLATION', log, {
|
||||
requestId,
|
||||
details: {
|
||||
attemptedRate: itemRate,
|
||||
allowedRates: Array.from(allowedRates),
|
||||
customerType: customer.customer_type,
|
||||
},
|
||||
})
|
||||
}
|
||||
const lineTotal = item.quantity * item.unit_price
|
||||
vatAmount += roundOre((lineTotal * itemRate) / 100)
|
||||
}
|
||||
|
||||
const subtotal = input.items.reduce((sum, item) => sum + item.quantity * item.unit_price, 0)
|
||||
const total = roundOre(subtotal + vatAmount)
|
||||
|
||||
const uniqueRates = new Set(input.items.map((item) => item.vat_rate ?? vatRules.rate))
|
||||
const isMixedRate = uniqueRates.size > 1
|
||||
|
||||
// Foreign currency: convert using the rate on the INVOICE date (ML 7 kap 7§),
|
||||
// not today's rate.
|
||||
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') {
|
||||
const rateData = await fetchExchangeRate(input.currency, new Date(input.invoice_date))
|
||||
if (!rateData) {
|
||||
// No FX rate for the invoice date — refuse rather than letting the
|
||||
// booking fall through to resolveSekAmount's legacy 1:1 fallback, which
|
||||
// would treat e.g. 1 000 USD as 1 000 SEK and commit a balanced but
|
||||
// silently wrong-magnitude verifikat. ML 7 kap 7§ requires the
|
||||
// invoice-date rate; we never substitute today's. The user can retry
|
||||
// once the rate is published.
|
||||
log.warn('self-billed invoice rejected: no FX rate for invoice date', {
|
||||
currency: input.currency,
|
||||
invoiceDate: input.invoice_date,
|
||||
})
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: `Kunde inte hämta växelkurs för ${input.currency} på fakturadatumet (${input.invoice_date}). Försök igen senare.`,
|
||||
type: 'validation_error',
|
||||
},
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
exchangeRate = rateData.rate
|
||||
exchangeRateDate = rateData.date
|
||||
subtotalSek = convertToSEK(subtotal, exchangeRate)
|
||||
vatAmountSek = convertToSEK(vatAmount, exchangeRate)
|
||||
totalSek = convertToSEK(total, exchangeRate)
|
||||
}
|
||||
|
||||
const { data: invoice, error: invoiceError } = await supabase
|
||||
.from('invoices')
|
||||
.insert({
|
||||
user_id: user.id,
|
||||
company_id: companyId,
|
||||
customer_id: input.customer_id,
|
||||
// No own number — the counterparty's number lives in external_invoice_number.
|
||||
invoice_number: null,
|
||||
is_self_billed: true,
|
||||
external_invoice_number: input.external_invoice_number,
|
||||
self_billing_agreement_ref: input.self_billing_agreement_ref ?? null,
|
||||
received_date: input.received_date,
|
||||
invoice_date: input.invoice_date,
|
||||
due_date: input.due_date,
|
||||
// Booked + awaiting/with payment — never a draft, so it shows in the AR
|
||||
// ledger and is payable via the existing mark-paid / matching flows.
|
||||
status: 'sent',
|
||||
currency: input.currency,
|
||||
exchange_rate: exchangeRate,
|
||||
exchange_rate_date: exchangeRateDate,
|
||||
subtotal,
|
||||
subtotal_sek: subtotalSek,
|
||||
vat_amount: vatAmount,
|
||||
vat_amount_sek: vatAmountSek,
|
||||
total,
|
||||
total_sek: totalSek,
|
||||
remaining_amount: total,
|
||||
vat_treatment: vatRules.treatment,
|
||||
vat_rate: isMixedRate ? null : (uniqueRates.values().next().value ?? vatRules.rate),
|
||||
moms_ruta: vatRules.momsRuta,
|
||||
reverse_charge_text: vatRules.reverseChargeText || null,
|
||||
notes: input.notes,
|
||||
document_type: 'invoice',
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (invoiceError || !invoice) {
|
||||
log.error('self-billed invoice insert failed', invoiceError)
|
||||
return errorResponseFromCode('INVOICE_CREATE_INSERT_FAILED', log, {
|
||||
requestId,
|
||||
details: { pgCode: invoiceError?.code, pgMessage: invoiceError?.message },
|
||||
})
|
||||
}
|
||||
|
||||
const items = input.items.map((item, index) => {
|
||||
const itemRate = item.vat_rate !== undefined ? item.vat_rate : vatRules.rate
|
||||
const lineTotal = item.quantity * item.unit_price
|
||||
return {
|
||||
invoice_id: invoice.id,
|
||||
sort_order: index,
|
||||
description: item.description,
|
||||
quantity: item.quantity,
|
||||
unit: item.unit,
|
||||
unit_price: item.unit_price,
|
||||
line_total: lineTotal,
|
||||
vat_rate: itemRate,
|
||||
vat_amount: roundOre((lineTotal * itemRate) / 100),
|
||||
}
|
||||
})
|
||||
|
||||
const { error: itemsError } = await supabase.from('invoice_items').insert(items)
|
||||
if (itemsError) {
|
||||
// The item insert failed, so nothing was written there — just remove the
|
||||
// orphaned invoice header.
|
||||
await supabase.from('invoices').delete().eq('id', invoice.id)
|
||||
log.error('self-billed invoice items insert failed; rolled back', itemsError, { invoiceId: invoice.id })
|
||||
return errorResponseFromCode('INVOICE_CREATE_ITEMS_FAILED', log, {
|
||||
requestId,
|
||||
details: { pgCode: itemsError.code, pgMessage: itemsError.message },
|
||||
})
|
||||
}
|
||||
|
||||
const { data: settings } = await supabase
|
||||
.from('company_settings')
|
||||
.select('accounting_method, entity_type')
|
||||
.eq('company_id', companyId!)
|
||||
.single()
|
||||
const accountingMethod = settings?.accounting_method || 'accrual'
|
||||
const entityType = (settings?.entity_type as EntityType) || 'enskild_firma'
|
||||
|
||||
const { data: completeInvoice } = await supabase
|
||||
.from('invoices')
|
||||
.select('*, customer:customers(*), items:invoice_items(*)')
|
||||
.eq('id', invoice.id)
|
||||
.single()
|
||||
|
||||
// Faktureringsmetoden: book the registration entry now (Debit 1510, Credit
|
||||
// 30xx + 26xx). Kontantmetoden: leave unbooked until payment, exactly like a
|
||||
// normal invoice — the mark-paid flow books the cash entry then.
|
||||
if (accountingMethod === 'accrual') {
|
||||
if (!completeInvoice) {
|
||||
// The row was inserted but the re-fetch came back empty (transient DB
|
||||
// issue). Roll back rather than crash on a null cast inside the engine —
|
||||
// and surface it as a fetch failure, not an opaque booking error.
|
||||
await supabase.from('invoices').delete().eq('id', invoice.id)
|
||||
log.error('self-billed invoice re-fetch returned no row before booking; rolled back', undefined, {
|
||||
invoiceId: invoice.id,
|
||||
})
|
||||
return errorResponseFromCode('INVOICE_CREATE_INSERT_FAILED', log, {
|
||||
requestId,
|
||||
details: { stage: 'refetch_before_booking' },
|
||||
})
|
||||
}
|
||||
try {
|
||||
const journalEntry = await createInvoiceJournalEntry(
|
||||
supabase,
|
||||
companyId!,
|
||||
user.id,
|
||||
completeInvoice as Invoice,
|
||||
entityType,
|
||||
customer.name,
|
||||
{ descriptionPrefix: 'Självfaktura', numberOverride: input.external_invoice_number },
|
||||
)
|
||||
if (!journalEntry) {
|
||||
// No open fiscal period for the invoice date — roll the row back so we
|
||||
// never leave an unbooked self-billing sale sitting as 'sent'.
|
||||
await supabase.from('invoices').delete().eq('id', invoice.id)
|
||||
return NextResponse.json(
|
||||
{ error: 'Ingen öppen bokföringsperiod för fakturadatumet', type: 'validation_error' },
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
const { error: linkError } = await supabase
|
||||
.from('invoices')
|
||||
.update({ journal_entry_id: journalEntry.id })
|
||||
.eq('id', invoice.id)
|
||||
.eq('company_id', companyId!)
|
||||
if (linkError) {
|
||||
// The verifikat is already committed (immutable) — don't roll it back
|
||||
// over a failed convenience link. Log loudly: this is the exact write
|
||||
// that silently no-ops if the journal_entry_id column is ever missing
|
||||
// again (it was absent in prod for months before 20260613100000).
|
||||
log.error('self-billed invoice booked but journal_entry_id link failed', linkError, {
|
||||
invoiceId: invoice.id,
|
||||
journalEntryId: journalEntry.id,
|
||||
})
|
||||
}
|
||||
} catch (err) {
|
||||
await supabase.from('invoices').delete().eq('id', invoice.id)
|
||||
log.error('failed to book self-billed invoice; rolled back', err as Error, { invoiceId: invoice.id })
|
||||
return errorResponse(err, log, { requestId })
|
||||
}
|
||||
}
|
||||
|
||||
const { data: finalInvoice } = await supabase
|
||||
.from('invoices')
|
||||
.select('*, customer:customers(*), items:invoice_items(*)')
|
||||
.eq('id', invoice.id)
|
||||
.single()
|
||||
|
||||
// The invoice is committed (and, under accrual, booked) by this point. If the
|
||||
// final re-fetch comes back empty under transient load, fall back to the
|
||||
// shapes we already hold so the 200 always carries a usable id — otherwise
|
||||
// the client's redirect to /invoices/{id} would throw on a null result.
|
||||
const responseInvoice = (finalInvoice ?? completeInvoice ?? invoice) as Invoice
|
||||
|
||||
await eventBus.emit({
|
||||
type: 'invoice.created',
|
||||
payload: { invoice: responseInvoice, companyId: companyId!, userId: user.id },
|
||||
})
|
||||
|
||||
return NextResponse.json({ data: responseInvoice })
|
||||
},
|
||||
{ requireWrite: true },
|
||||
)
|
||||
Reference in New Issue
Block a user