From f08fc2c2747c768d71ec88725944d6d8ffda22cc Mon Sep 17 00:00:00 2001 From: Jakob Wennberg Date: Wed, 26 Aug 2026 13:18:57 +0200 Subject: [PATCH] fix(invoices): honour defer_invoice_booking on MCP, REST v1 and inbox convert (#1921) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- DECISIONS.md | 1 + .../[id]/mark-sent/__tests__/route.test.ts | 65 +++++++++++++++++++ .../invoices/[id]/mark-sent/route.ts | 14 ++-- .../[id]/send/__tests__/route.test.ts | 56 ++++++++++++++++ .../[companyId]/invoices/[id]/send/route.ts | 16 +++-- .../supplier-invoices/__tests__/route.test.ts | 39 +++++++++++ .../[companyId]/supplier-invoices/route.ts | 13 ++-- .../__tests__/convert-route.test.ts | 32 +++++++++ extensions/general/invoice-inbox/index.ts | 8 ++- ...create-supplier-invoice-from-inbox.test.ts | 41 ++++++++++++ .../__tests__/executors.test.ts | 43 ++++++++++++ lib/pending-operations/commit.ts | 18 +++-- skills/accounted-api/references/invoices.md | 4 +- 13 files changed, 322 insertions(+), 28 deletions(-) diff --git a/DECISIONS.md b/DECISIONS.md index b6b09aad..f3aa3763 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1247,5 +1247,6 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and [2026-08-25] Woo bulk revenue template = per-rate account choice, no hardcoded varor/tjanster preset: BAS 2026 has no standard 30xx goods/services subdivision (3040-series is company-specific), so presets would invent accounts; chosen accounts are validated against the company chart instead, and only diffs from the 3001-series default are sent. [2026-08-26] Support-dialog attachments use the existing email delivery path without storage or schema changes: this keeps the feature scoped to the contact form. The budget is 5 files / 4 MB total under the 4.5 MB hosted request-body ceiling, with client-side image shrinking when needed. [2026-08-26] RFC 9728 protected-resource metadata is served at THREE locations (root, path-based /.well-known/oauth-protected-resource/, and /.well-known/oauth-protected-resource): Claude.ai's connector setup derives the metadata URL from the server URL and fetches it before any 401, so the root document our WWW-Authenticate header points at was not enough ('Authorization with Accounted failed' with only 404s in the logs). One builder, three routes; the path-based route answers 404 for any path other than the MCP endpoint so no phantom resource is advertised. +[2026-08-26] defer_invoice_booking (#967) now gates booking on every door, not just the dashboard: MCP send_invoice / mark_invoice_sent / create_supplier_invoice_from_inbox, v1 invoices send / mark-sent and supplier-invoices create, and the inbox convert route all checked accounting_method === 'accrual' and posted a verifikat at issue for deferred companies. All six now call booksInvoicesOnIssue() (lib/bookkeeping/booking-mode.ts), the same helper the dashboard routes use, so the setting has one meaning. No data repair attempted: vouchers already posted for deferred companies through these doors are legitimate entries and stay. [2026-08-20] The swedish-e-invoicing skill now names Upphandlingsmyndigheten as Sweden Peppol Authority across all eight files, not just the one that was flagged: the handover completed 1 July 2026 (regeringsbeslut Fi2025/01826) and the skill was written in future tense, so a partial fix would have left the atom internally contradictory and still pointed agents at peppol@digg.se. Four digg.se URLs were repointed to their verified 301 targets on upphandlingsmyndigheten.se; the fifth, DIGG Peppol testbadd, is a hard 404 with no redirect and no successor page at the new authority, so it was replaced with the SFTI Validex verification service (https://sfti.validex.net/) rather than left dead or guessed at. Historical attributions (Q4 2025 traffic statistics, the 0007:2021006883 Peppol-ID example) deliberately still say DIGG because they were accurate when published. [2026-08-26] gnubok_connect_bank / gnubok_connect_skatteverket moved from catalogVisibility 'search' to the default catalog: Claude.ai can only invoke tools present in tools/list, so search-only tools are discover-only there and the onboarding skill's steps 3-4 dead-ended on client-side tool-not-found (verified via event_log: the server never received the calls). Search-only visibility remains fine for tools an agent reads about before asking the user, but anything a skill instructs the agent to CALL must be in the default catalog. diff --git a/app/api/v1/companies/[companyId]/invoices/[id]/mark-sent/__tests__/route.test.ts b/app/api/v1/companies/[companyId]/invoices/[id]/mark-sent/__tests__/route.test.ts index 465ba1da..c496cce1 100644 --- a/app/api/v1/companies/[companyId]/invoices/[id]/mark-sent/__tests__/route.test.ts +++ b/app/api/v1/companies/[companyId]/invoices/[id]/mark-sent/__tests__/route.test.ts @@ -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') + }) +}) diff --git a/app/api/v1/companies/[companyId]/invoices/[id]/mark-sent/route.ts b/app/api/v1/companies/[companyId]/invoices/[id]/mark-sent/route.ts index 785e7995..74339a00 100644 --- a/app/api/v1/companies/[companyId]/invoices/[id]/mark-sent/route.ts +++ b/app/api/v1/companies/[companyId]/invoices/[id]/mark-sent/route.ts @@ -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) { diff --git a/app/api/v1/companies/[companyId]/invoices/[id]/send/__tests__/route.test.ts b/app/api/v1/companies/[companyId]/invoices/[id]/send/__tests__/route.test.ts index c9ba7558..e9579ede 100644 --- a/app/api/v1/companies/[companyId]/invoices/[id]/send/__tests__/route.test.ts +++ b/app/api/v1/companies/[companyId]/invoices/[id]/send/__tests__/route.test.ts @@ -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') + }) +}) diff --git a/app/api/v1/companies/[companyId]/invoices/[id]/send/route.ts b/app/api/v1/companies/[companyId]/invoices/[id]/send/route.ts index ed16144e..b6b9c55f 100644 --- a/app/api/v1/companies/[companyId]/invoices/[id]/send/route.ts +++ b/app/api/v1/companies/[companyId]/invoices/[id]/send/route.ts @@ -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, diff --git a/app/api/v1/companies/[companyId]/supplier-invoices/__tests__/route.test.ts b/app/api/v1/companies/[companyId]/supplier-invoices/__tests__/route.test.ts index 9ecea1bb..965949b0 100644 --- a/app/api/v1/companies/[companyId]/supplier-invoices/__tests__/route.test.ts +++ b/app/api/v1/companies/[companyId]/supplier-invoices/__tests__/route.test.ts @@ -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() + }) +}) diff --git a/app/api/v1/companies/[companyId]/supplier-invoices/route.ts b/app/api/v1/companies/[companyId]/supplier-invoices/route.ts index f60506ad..6236cfbb 100644 --- a/app/api/v1/companies/[companyId]/supplier-invoices/route.ts +++ b/app/api/v1/companies/[companyId]/supplier-invoices/route.ts @@ -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, diff --git a/extensions/general/invoice-inbox/__tests__/convert-route.test.ts b/extensions/general/invoice-inbox/__tests__/convert-route.test.ts index 545785b0..5c4e7a91 100644 --- a/extensions/general/invoice-inbox/__tests__/convert-route.test.ts +++ b/extensions/general/invoice-inbox/__tests__/convert-route.test.ts @@ -607,3 +607,35 @@ describe('DELETE /items/:id', () => { expect(body.data.deleted).toBe(true) }) }) + +describe('POST /items/:id/convert honours defer_invoice_booking (#967)', () => { + const route = findRoute('POST', '/items/:id/convert') + + it('registers WITHOUT the registration JE when the company defers booking', async () => { + const { createSupplierInvoiceRegistrationEntry } = await import('@/lib/bookkeeping/supplier-invoice-entries') + vi.mocked(createSupplierInvoiceRegistrationEntry).mockClear() + + const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ data: makeInvoiceInboxItem({ status: 'received' }) }) + enqueue({ data: makeSupplier({ id: SUPPLIER_UUID }) }) + enqueue({ data: 42 }) + enqueue({ data: { id: 'invoice-1', status: 'registered' } }) + enqueue({ data: null, error: null }) + enqueue({ data: makeCompanySettings({ accounting_method: 'accrual', defer_invoice_booking: true }) }) + enqueue({ data: null, error: null }) + enqueue({ data: null, error: null }) + + const ctx = buildCtx(supabase) + const request = createMockRequest('/items/item-1/convert', { + method: 'POST', + body: VALID_CONVERT_BODY, + searchParams: { _id: 'item-1' }, + }) + const res = await route.handler(request, ctx) + const { status, body } = await parseJsonResponse<{ data: { registration_journal_entry_id: string | null } }>(res) + + expect(status).toBe(200) + expect(body.data.registration_journal_entry_id).toBeNull() + expect(createSupplierInvoiceRegistrationEntry).not.toHaveBeenCalled() + }) +}) diff --git a/extensions/general/invoice-inbox/index.ts b/extensions/general/invoice-inbox/index.ts index 60c1ccf9..cfdd86aa 100644 --- a/extensions/general/invoice-inbox/index.ts +++ b/extensions/general/invoice-inbox/index.ts @@ -44,6 +44,7 @@ import { applyDomainStatusFromWebhook, } from './lib/custom-domains' import { createSupplierInvoiceRegistrationEntry } from '@/lib/bookkeeping/supplier-invoice-entries' +import { booksInvoicesOnIssue } from '@/lib/bookkeeping/booking-mode' import { createSchedulesForSupplierInvoice } from '@/lib/bookkeeping/accruals/from-invoices' import { suggestBalanceAccount } from '@/lib/bookkeeping/accruals/account-suggestions' import { isSlpPensionAccount } from '@/lib/bookkeeping/slp-lines' @@ -2151,14 +2152,15 @@ export const invoiceInboxExtension: Extension = { const { data: settings } = await ctx.supabase .from('company_settings') - .select('accounting_method') + .select('accounting_method, defer_invoice_booking') .eq('company_id', ctx.companyId) .single() - const accountingMethod = settings?.accounting_method || 'accrual' let registrationJournalEntryId: string | null = null - if (accountingMethod === 'accrual') { + // #967: deferred companies register WITHOUT booking (same gate as + // POST /api/supplier-invoices); ekonomi books later via Bokför. + if (booksInvoicesOnIssue(settings)) { try { const journalEntry = await createSupplierInvoiceRegistrationEntry( ctx.supabase, diff --git a/lib/pending-operations/__tests__/create-supplier-invoice-from-inbox.test.ts b/lib/pending-operations/__tests__/create-supplier-invoice-from-inbox.test.ts index e7ebea32..051c719b 100644 --- a/lib/pending-operations/__tests__/create-supplier-invoice-from-inbox.test.ts +++ b/lib/pending-operations/__tests__/create-supplier-invoice-from-inbox.test.ts @@ -794,3 +794,44 @@ describe('commitPendingOperation: create_supplier_invoice_from_inbox: dimensions expect(captured.items![0]).toMatchObject({ dimensions: {} }) }) }) + +describe('commitPendingOperation: create_supplier_invoice_from_inbox honours defer_invoice_booking (#967)', () => { + it('registers WITHOUT the registration JE when the company defers invoice booking', async () => { + vi.mocked(createSupplierInvoiceRegistrationEntry).mockClear() + vi.mocked(linkToJournalEntry).mockClear() + const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ data: { id: 'op-1' }, error: null }) + enqueue({ + data: { id: 'inbox-1', created_supplier_invoice_id: null, status: 'ready' }, + error: null, + }) + enqueue({ + data: { id: 'supplier-1', name: 'Acme AB', supplier_type: 'swedish_business' }, + error: null, + }) + enqueue({ data: 42, error: null }) // arrival number + enqueue({ + data: makeSupplierInvoice({ id: 'inv-deferred', supplier_invoice_number: 'INV-100' }), + error: null, + }) // invoice insert + enqueue({ data: null, error: null }) // items insert + enqueue({ data: { accounting_method: 'accrual', defer_invoice_booking: true }, error: null }) // company_settings + enqueue({ data: null, error: null }) // invoice_inbox_items update + enqueue({ data: null, error: null }) // dispatcher's commit update + + const result = await commitPendingOperation( + supabase as never, + 'user-1', + 'company-1', + makePendingOp(), + ) + + expect(result.status).toBe('committed') + expect(result.data).toMatchObject({ + supplier_invoice_id: 'inv-deferred', + registration_journal_entry_id: null, + }) + expect(createSupplierInvoiceRegistrationEntry).not.toHaveBeenCalled() + expect(linkToJournalEntry).not.toHaveBeenCalled() + }) +}) diff --git a/lib/pending-operations/__tests__/executors.test.ts b/lib/pending-operations/__tests__/executors.test.ts index 38954068..88282b63 100644 --- a/lib/pending-operations/__tests__/executors.test.ts +++ b/lib/pending-operations/__tests__/executors.test.ts @@ -1727,3 +1727,46 @@ describe('commitPendingOperation: categorize_transaction account_override', () = expect(opts.accountOverride).toBeUndefined() }) }) + +describe('commitPendingOperation: mark_invoice_sent honours defer_invoice_booking (#967)', () => { + it('marks the invoice sent WITHOUT booking when the company defers invoice booking', async () => { + const invoiceEntries = await import('@/lib/bookkeeping/invoice-entries') + const bookSpy = vi.spyOn(invoiceEntries, 'createInvoiceJournalEntry') + const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim + enqueue({ + data: makeInvoice({ + id: 'invoice-1', + status: 'draft', + invoice_number: 'F-2026001', + credited_invoice_id: null, + }), + error: null, + }) + enqueue({ + data: { + accounting_method: 'accrual', + defer_invoice_booking: true, + entity_type: 'enskild_firma', + bankgiro: '123-4567', + }, + error: null, + }) + enqueue({ data: null, error: null }) // status update + enqueue({ data: null, error: null }) // dispatcher update + + const op = makePendingOp({ + operation_type: 'mark_invoice_sent', + params: { invoice_id: 'invoice-1' }, + }) + + const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op) + + expect(result.status).toBe('committed') + expect(result.data).toMatchObject({ status: 'sent', journal_entry_id: null }) + // Same gate as the dashboard: deferred companies book via the explicit + // Bokför step, never at mark-sent. + expect(bookSpy).not.toHaveBeenCalled() + bookSpy.mockRestore() + }) +}) diff --git a/lib/pending-operations/commit.ts b/lib/pending-operations/commit.ts index 1d3750f7..d4e0a9ca 100644 --- a/lib/pending-operations/commit.ts +++ b/lib/pending-operations/commit.ts @@ -51,7 +51,7 @@ import { import { resolveSettlementAccount } from '@/lib/bookkeeping/settlement-account' import { buildInvoicePaymentClearingLines } from '@/lib/bookkeeping/invoice-payment-lines' import { resolveSekAmount } from '@/lib/bookkeeping/currency-utils' -import { cashPartialBlockReason, supplierCreditNoteNeedsJournalEntry } from '@/lib/bookkeeping/booking-mode' +import { booksInvoicesOnIssue, cashPartialBlockReason, supplierCreditNoteNeedsJournalEntry } from '@/lib/bookkeeping/booking-mode' import { ensureManualCashAccount } from '@/lib/cash-accounts/service' import { createJournalEntry, findFiscalPeriod, getSwedishLocalDate, reverseEntry, validateBalance } from '@/lib/bookkeeping/engine' import { @@ -2554,7 +2554,9 @@ async function commitSendInvoice( const isRealInvoice = !invoice.document_type || invoice.document_type === 'invoice' let createdJournalEntryId: string | undefined - if (isRealInvoice && (company.accounting_method === 'accrual' || !company.accounting_method)) { + // #967: kontantmetoden and defer_invoice_booking companies send WITHOUT + // booking; the verifikat comes at payment or via the explicit Bokför step. + if (isRealInvoice && booksInvoicesOnIssue(company)) { try { const je = await createInvoiceJournalEntry( supabase, companyId, userId, invoice as Invoice, (company as CompanySettings).entity_type @@ -2612,7 +2614,7 @@ async function commitMarkInvoiceSent( const { data: settings, error: settingsError } = await 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', companyId) .single() @@ -2653,7 +2655,8 @@ async function commitMarkInvoiceSent( const isRealInvoice = !invoice.document_type || invoice.document_type === 'invoice' let journalEntryId: string | null = null - if (isRealInvoice && (settings?.accounting_method === 'accrual' || !settings?.accounting_method)) { + // #967: same gate as the dashboard mark-sent path (issue-and-book-invoice.ts). + if (isRealInvoice && booksInvoicesOnIssue(settings)) { try { const je = await createInvoiceJournalEntry( supabase, companyId, userId, invoice as Invoice, @@ -4163,14 +4166,15 @@ async function commitCreateSupplierInvoiceFromInbox( const { data: settings } = await supabase .from('company_settings') - .select('accounting_method') + .select('accounting_method, defer_invoice_booking') .eq('company_id', companyId) .single() - const accountingMethod = (settings?.accounting_method as AccountingMethod) || 'accrual' let registrationJournalEntryId: string | null = null - if (accountingMethod === 'accrual') { + // #967: deferred companies register WITHOUT booking (same gate as + // POST /api/supplier-invoices); ekonomi books later via the Bokför step. + if (booksInvoicesOnIssue(settings)) { try { const journalEntry = await createSupplierInvoiceRegistrationEntry( supabase, diff --git a/skills/accounted-api/references/invoices.md b/skills/accounted-api/references/invoices.md index 17017f9a..2b025d6d 100644 --- a/skills/accounted-api/references/invoices.md +++ b/skills/accounted-api/references/invoices.md @@ -356,7 +356,7 @@ Response `200`: **Transition a draft invoice to sent (without emailing).** `scope:invoices:write · risk:medium · idempotent · dry-run` -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. **Use when:** 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. **Do not use for:** Sending the invoice via Accounted email: use :send (PR-B-2b-3) for that. Marking an already-sent invoice as paid: use :mark-paid (PR-B-2b-2). @@ -424,7 +424,7 @@ Response `200` (`application/pdf`). **Send a draft invoice to the customer by email.** `scope:invoices:write · risk:high · idempotent · dry-run` -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. **Use when:** 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. **Do not use for:** 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).