fix(invoices): honour defer_invoice_booking on MCP, REST v1 and inbox convert (#1921)
The #967 "Registrera men bokför inte" setting was only respected by the dashboard routes. Six other paths decided whether to post the issue-time verifikat with `accounting_method === 'accrual'` alone, so a company that had switched booking to the explicit Bokför step still got vouchers posted at issue through MCP, the REST v1 API and the invoice-inbox convert route: - lib/pending-operations/commit.ts: send_invoice, mark_invoice_sent, create_supplier_invoice_from_inbox executors - app/api/v1/.../invoices/[id]/send and mark-sent (commit + dry-run preview) - app/api/v1/.../supplier-invoices POST - extensions/general/invoice-inbox convert All of them now call booksInvoicesOnIssue() from lib/bookkeeping/booking-mode, the helper the dashboard already uses, and select defer_invoice_booking where the settings projection did not include it. Behaviour for accrual companies without the flag and for kontantmetoden companies is unchanged. Tests: one deferred-company case per door (8 new), verified to fail without the fix. skills/accounted-api regenerated for the changed v1 descriptions. Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Jakob Wennberg
Claude Fable 5
parent
8ddc77fdfd
commit
f08fc2c274
@@ -499,3 +499,68 @@ describe('POST /api/v1/companies/:companyId/invoices/:id/mark-sent', () => {
|
||||
expect(body.error.code).toBe('VALIDATION_ERROR')
|
||||
})
|
||||
})
|
||||
|
||||
describe('POST /api/v1/companies/:companyId/invoices/:id/mark-sent honours defer_invoice_booking (#967)', () => {
|
||||
it('marks sent WITHOUT a journal entry when the company defers booking', async () => {
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
invoices: [
|
||||
{ data: DRAFT_INVOICE, error: null },
|
||||
{ data: SENT_INVOICE, error: null },
|
||||
],
|
||||
company_settings: {
|
||||
data: {
|
||||
accounting_method: 'accrual',
|
||||
defer_invoice_booking: true,
|
||||
entity_type: 'enskild_firma',
|
||||
bankgiro: '123-4567',
|
||||
},
|
||||
error: null,
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
const res = await markSent(
|
||||
makeMarkSentRequest(
|
||||
`https://x.test/api/v1/companies/${COMPANY_ID}/invoices/${INVOICE_ID}/mark-sent`,
|
||||
),
|
||||
detailParams(COMPANY_ID, INVOICE_ID),
|
||||
)
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
const body = await res.json()
|
||||
expect(body.data.journal_entry_id ?? null).toBeNull()
|
||||
expect(mockCreateJournalEntry).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('dry-run preview reports would_create_journal_entry=false when booking is deferred', async () => {
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
invoices: { data: DRAFT_INVOICE, error: null },
|
||||
company_settings: {
|
||||
data: {
|
||||
accounting_method: 'accrual',
|
||||
defer_invoice_booking: true,
|
||||
entity_type: 'enskild_firma',
|
||||
bankgiro: '123-4567',
|
||||
},
|
||||
error: null,
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
const res = await markSent(
|
||||
makeMarkSentRequest(
|
||||
`https://x.test/api/v1/companies/${COMPANY_ID}/invoices/${INVOICE_ID}/mark-sent?dry_run=true`,
|
||||
),
|
||||
detailParams(COMPANY_ID, INVOICE_ID),
|
||||
)
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
const body = await res.json()
|
||||
expect(body.data.preview.would_create_journal_entry).toBe(false)
|
||||
expect(body.data.preview.accounting_method).toBe('accrual')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -11,7 +11,8 @@
|
||||
* issued invoices consume numbers; this is where the F-series
|
||||
* number gets assigned, NOT at draft-create per PR-B-2a's design).
|
||||
* 2. Invoice status flips to 'sent'.
|
||||
* 3. If accounting_method='accrual' AND document_type='invoice', a
|
||||
* 3. If the company books at issue (faktureringsmetoden without
|
||||
* defer_invoice_booking) AND document_type='invoice', a
|
||||
* journal entry is posted via createInvoiceJournalEntry (Debit AR
|
||||
* 1510, Credit revenue 3xxx, Credit output VAT 2611/2621/2631).
|
||||
* Under kontantmetoden ('cash') no journal entry is created here:
|
||||
@@ -40,6 +41,7 @@ import { registerEndpoint, dataEnvelope } from '@/lib/api/v1/registry'
|
||||
import { withApiV1 } from '@/lib/api/v1/with-api-v1'
|
||||
import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors'
|
||||
import { createInvoiceJournalEntry } from '@/lib/bookkeeping/invoice-entries'
|
||||
import { booksInvoicesOnIssue } from '@/lib/bookkeeping/booking-mode'
|
||||
import { ensureInvoiceNumber } from '@/lib/invoices/ensure-invoice-number'
|
||||
import { recordManualInvoiceDelivery } from '@/lib/invoices/invoice-deliveries'
|
||||
import {
|
||||
@@ -76,7 +78,7 @@ registerEndpoint({
|
||||
path: '/api/v1/companies/:companyId/invoices/:id/mark-sent',
|
||||
summary: 'Transition a draft invoice to sent (without emailing).',
|
||||
description:
|
||||
'Marks a draft invoice as sent: for invoices delivered outside Accounted (Peppol, postal, manual email). Allocates the F-series invoice_number atomically (ML 17 kap 24§ p.2). On accounting_method=accrual, also posts the invoice journal entry (Debit AR 1510 / Credit revenue + output VAT). Emits invoice.sent. Idempotent and dry-runnable. The companion :send action (PR-B-2b-3) adds PDF rendering and email delivery on top of this same flow.',
|
||||
'Marks a draft invoice as sent: for invoices delivered outside Accounted (Peppol, postal, manual email). Allocates the F-series invoice_number atomically (ML 17 kap 24§ p.2). When the company books at issue (faktureringsmetoden without defer_invoice_booking), also posts the invoice journal entry (Debit AR 1510 / Credit revenue + output VAT). Emits invoice.sent. Idempotent and dry-runnable. The companion :send action (PR-B-2b-3) adds PDF rendering and email delivery on top of this same flow.',
|
||||
useWhen:
|
||||
'You delivered the invoice through a channel other than Accounted\'s email (Peppol, postal, your own SMTP) and need to record it as sent so the F-series number is allocated and the journal entry is posted.',
|
||||
doNotUseFor:
|
||||
@@ -211,7 +213,7 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
|
||||
// decision, payable invoices need a currency-matching account.
|
||||
const { data: settings, error: settingsError } = await ctx.supabase
|
||||
.from('company_settings')
|
||||
.select('accounting_method, entity_type, invoice_payment_accounts, bank_name, clearing_number, account_number, bankgiro, plusgiro, swish, iban, bic')
|
||||
.select('accounting_method, defer_invoice_booking, entity_type, invoice_payment_accounts, bank_name, clearing_number, account_number, bankgiro, plusgiro, swish, iban, bic')
|
||||
.eq('company_id', ctx.companyId!)
|
||||
.maybeSingle()
|
||||
if (settingsError || !settings) {
|
||||
@@ -235,7 +237,9 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
|
||||
const accountingMethod = companySettings.accounting_method ?? 'accrual'
|
||||
const entityType = (companySettings.entity_type ?? 'enskild_firma') as EntityType
|
||||
const isRealInvoice = !typed.document_type || typed.document_type === 'invoice'
|
||||
const wouldCreateJournalEntry = isRealInvoice && accountingMethod === 'accrual'
|
||||
// #967: kontantmetoden and defer_invoice_booking companies mark sent
|
||||
// WITHOUT booking (same gate as the dashboard, issue-and-book-invoice.ts).
|
||||
const wouldCreateJournalEntry = isRealInvoice && booksInvoicesOnIssue(companySettings)
|
||||
|
||||
if (ctx.dryRun) {
|
||||
// Preview the post-send state. invoice_number can't be predicted
|
||||
@@ -311,7 +315,7 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
|
||||
// fully posted.
|
||||
const warnings: { code: string; message: string }[] = []
|
||||
|
||||
// Step 3: journal entry for accrual + real invoices. Failure escalates
|
||||
// Step 3: journal entry for real invoices when the company books at issue. Failure escalates
|
||||
// to error-level log AND surfaces in the response as a warning.
|
||||
let journalEntryId: string | null = null
|
||||
if (wouldCreateJournalEntry) {
|
||||
|
||||
@@ -866,3 +866,59 @@ describe('POST /api/v1/companies/:companyId/invoices/:id/send', () => {
|
||||
expect(res.status).toBe(403)
|
||||
})
|
||||
})
|
||||
|
||||
describe('POST /api/v1/companies/:companyId/invoices/:id/send honours defer_invoice_booking (#967)', () => {
|
||||
it('sends the invoice WITHOUT posting a journal entry when the company defers booking', async () => {
|
||||
const { createInvoiceJournalEntry } = await import('@/lib/bookkeeping/invoice-entries')
|
||||
vi.mocked(createInvoiceJournalEntry).mockClear()
|
||||
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, defer_invoice_booking: true },
|
||||
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.journal_entry_id ?? null).toBeNull()
|
||||
expect(createInvoiceJournalEntry).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('dry-run preview reports would_create_journal_entry=false when booking is deferred', 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, defer_invoice_booking: true },
|
||||
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)
|
||||
const body = await res.json()
|
||||
expect(body.data.preview.would_create_journal_entry).toBe(false)
|
||||
expect(body.data.preview.accounting_method).toBe('accrual')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
*
|
||||
* 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
|
||||
* journal entry when the company books at issue, 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
|
||||
@@ -31,7 +31,7 @@
|
||||
* 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,
|
||||
* (book-at-issue + real invoice), PDF archival via uploadDocument,
|
||||
* invoice.sent event emission.
|
||||
*
|
||||
* Idempotent (mandatory Idempotency-Key). Dry-runnable: dry-run goes
|
||||
@@ -57,6 +57,7 @@ import {
|
||||
generateInvoiceEmailText,
|
||||
} from '@/lib/email/invoice-templates'
|
||||
import { createInvoiceJournalEntry } from '@/lib/bookkeeping/invoice-entries'
|
||||
import { booksInvoicesOnIssue } from '@/lib/bookkeeping/booking-mode'
|
||||
import { linkToJournalEntry } from '@/lib/core/documents/document-service'
|
||||
import { ensureInvoiceNumber } from '@/lib/invoices/ensure-invoice-number'
|
||||
import { invoicePdfFilename } from '@/lib/invoices/pdf-filename'
|
||||
@@ -122,7 +123,7 @@ registerEndpoint({
|
||||
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.',
|
||||
'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 (real invoice, unless kontantmetoden or defer_invoice_booking) → 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 Accounted to deliver the invoice to the customer via email. For invoices delivered through another channel (Peppol, postal, own SMTP) use :mark-sent instead.',
|
||||
doNotUseFor:
|
||||
@@ -463,7 +464,7 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
|
||||
would_cc_addresses: recipients.cc,
|
||||
would_create_journal_entry:
|
||||
(!typed.document_type || typed.document_type === 'invoice') &&
|
||||
(settings.accounting_method ?? 'accrual') === 'accrual',
|
||||
booksInvoicesOnIssue(settings),
|
||||
accounting_method: settings.accounting_method ?? 'accrual',
|
||||
preflight_pdf_render: 'ok',
|
||||
},
|
||||
@@ -708,11 +709,12 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
|
||||
})
|
||||
}
|
||||
|
||||
// Step 9b: journal entry (accrual + real invoices).
|
||||
// Step 9b: journal entry for real invoices when the company books at
|
||||
// issue. Kontantmetoden books at payment; defer_invoice_booking (#967)
|
||||
// books via the explicit Bokför step. Same gate as the dashboard.
|
||||
let journalEntryId: string | null = null
|
||||
const isRealInvoice = !typed.document_type || typed.document_type === 'invoice'
|
||||
const accountingMethod = settings.accounting_method ?? 'accrual'
|
||||
if (isRealInvoice && accountingMethod === 'accrual') {
|
||||
if (isRealInvoice && booksInvoicesOnIssue(settings)) {
|
||||
try {
|
||||
const entry = await createInvoiceJournalEntry(
|
||||
ctx.supabase,
|
||||
|
||||
@@ -1813,3 +1813,42 @@ describe('POST /api/v1/companies/:companyId/supplier-invoices/:id/credit', () =>
|
||||
expect(mockedCredit).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('POST /api/v1/companies/:companyId/supplier-invoices honours defer_invoice_booking (#967)', () => {
|
||||
it('registers the SI WITHOUT the registration JE when the company defers booking', async () => {
|
||||
mockedReg.mockClear()
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
suppliers: { data: SAMPLE_SUPPLIER, error: null },
|
||||
company_settings: {
|
||||
data: { accounting_method: 'accrual', defer_invoice_booking: true },
|
||||
error: null,
|
||||
},
|
||||
fiscal_periods: { data: { id: 'fp-1', is_closed: false, locked_at: null }, error: null },
|
||||
supplier_invoices: { data: SAMPLE_SI, error: null },
|
||||
supplier_invoice_items: { data: null, error: null },
|
||||
idempotency_keys: { data: null, error: null },
|
||||
}),
|
||||
)
|
||||
|
||||
const res = await createSI(
|
||||
makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/supplier-invoices`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
supplier_id: SUPPLIER_ID,
|
||||
supplier_invoice_number: '2026-1234',
|
||||
invoice_date: '2026-05-10',
|
||||
due_date: '2026-06-09',
|
||||
items: [
|
||||
{ description: 'Office supplies', amount: 1000, account_number: '5410', vat_rate: 0.25 },
|
||||
],
|
||||
}),
|
||||
}),
|
||||
companyParams(COMPANY_ID),
|
||||
)
|
||||
|
||||
expect(res.status).toBe(201)
|
||||
expect(mockedReg).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -39,6 +39,7 @@ import {
|
||||
supplierInvoiceSekAmounts,
|
||||
} from '@/lib/currency/supplier-invoice-rate'
|
||||
import { createSupplierInvoiceRegistrationEntry } from '@/lib/bookkeeping/supplier-invoice-entries'
|
||||
import { booksInvoicesOnIssue } from '@/lib/bookkeeping/booking-mode'
|
||||
import { isSlpPensionAccount } from '@/lib/bookkeeping/slp-lines'
|
||||
import { reverseEntry } from '@/lib/bookkeeping/engine'
|
||||
import { isBookkeepingError } from '@/lib/bookkeeping/errors'
|
||||
@@ -727,16 +728,20 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string }> }>(
|
||||
})
|
||||
}
|
||||
|
||||
// Determine accounting method: registration JE is only posted under accrual.
|
||||
// Registration JE is only posted when the company books at issue:
|
||||
// kontantmetoden books at payment, and defer_invoice_booking (#967)
|
||||
// books via the explicit Bokför step. Same gate as POST /api/supplier-invoices.
|
||||
const { data: settings } = await ctx.supabase
|
||||
.from('company_settings')
|
||||
.select('accounting_method')
|
||||
.select('accounting_method, defer_invoice_booking')
|
||||
.eq('company_id', ctx.companyId!)
|
||||
.maybeSingle()
|
||||
const accountingMethod = (settings as { accounting_method?: string } | null)?.accounting_method ?? 'accrual'
|
||||
const bookingSettings = settings as
|
||||
| { accounting_method?: string | null; defer_invoice_booking?: boolean | null }
|
||||
| null
|
||||
|
||||
let registrationJournalEntryId: string | null = null
|
||||
if (accountingMethod === 'accrual') {
|
||||
if (booksInvoicesOnIssue(bookingSettings)) {
|
||||
try {
|
||||
const entry = await createSupplierInvoiceRegistrationEntry(
|
||||
ctx.supabase,
|
||||
|
||||
Reference in New Issue
Block a user