From 3e42fc6f323cbc43070579bec20cade77f26b1cb Mon Sep 17 00:00:00 2001 From: Mattsson <111893710+mattssonn@users.noreply.github.com> Date: Thu, 4 Jun 2026 13:14:57 +0200 Subject: [PATCH] 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 --- app/(dashboard)/invoices/[id]/page.tsx | 39 ++- app/(dashboard)/invoices/new/page.tsx | 234 ++++++++++--- app/(dashboard)/invoices/page.tsx | 15 +- .../check-org-number/__tests__/route.test.ts | 145 ++++---- app/api/company/check-org-number/route.ts | 56 +-- .../[id]/link/__tests__/route.test.ts | 132 +++++++ app/api/documents/[id]/link/route.ts | 45 ++- .../inbox-available/__tests__/route.test.ts | 127 +++++++ app/api/documents/inbox-available/route.ts | 114 ++++++ app/api/invoices/[id]/mark-sent/route.ts | 21 +- .../self-billed/__tests__/route.test.ts | 240 +++++++++++++ app/api/invoices/self-billed/route.ts | 311 +++++++++++++++++ app/api/sandbox/seed/route.ts | 1 - .../__tests__/route.test.ts | 69 +++- .../match-supplier-invoice/preview/route.ts | 33 +- .../[id]/match-supplier-invoice/route.ts | 69 ++-- .../[id]/match-supplier-invoice/route.ts | 39 ++- .../bookkeeping/InboxDocumentPicker.tsx | 329 ++++++++++++++++++ .../bookkeeping/JournalEntryAttachments.tsx | 39 ++- components/dashboard/WelcomeOnboarding.tsx | 6 +- .../general/ArcimMigrationWorkspace.tsx | 29 +- components/import/SIEUploadStep.tsx | 2 +- components/onboarding/BankIdCompanyPicker.tsx | 34 +- components/onboarding/Step2CompanyDetails.tsx | 32 +- components/reports/views/index.tsx | 3 +- .../transactions/InvoiceMatchDialog.tsx | 18 +- .../__tests__/migrate-guard.test.ts | 102 ++++++ extensions/general/arcim-migration/index.ts | 24 ++ lib/api/schemas.ts | 28 ++ .../supplier-invoice-entries.test.ts | 105 ++++++ lib/bookkeeping/invoice-entries.ts | 19 +- lib/bookkeeping/supplier-invoice-entries.ts | 64 +++- lib/company/__tests__/actions.test.ts | 169 +-------- lib/company/actions.ts | 56 +-- lib/errors/structured-errors.ts | 11 +- .../__tests__/ensure-invoice-number.test.ts | 30 ++ lib/invoices/display.ts | 13 + lib/invoices/ensure-invoice-number.ts | 13 + lib/reports/ar-ledger.ts | 4 +- messages/en.json | 79 ++++- messages/sv.json | 79 ++++- ...y_with_owner_business_profile_overload.sql | 30 ++ ...3100000_self_billing_received_invoices.sql | 79 +++++ tests/pg/self-billing-invoice.pg.test.ts | 102 ++++++ types/index.ts | 15 + 45 files changed, 2675 insertions(+), 529 deletions(-) create mode 100644 app/api/documents/[id]/link/__tests__/route.test.ts create mode 100644 app/api/documents/inbox-available/__tests__/route.test.ts create mode 100644 app/api/documents/inbox-available/route.ts create mode 100644 app/api/invoices/self-billed/__tests__/route.test.ts create mode 100644 app/api/invoices/self-billed/route.ts create mode 100644 components/bookkeeping/InboxDocumentPicker.tsx create mode 100644 extensions/general/arcim-migration/__tests__/migrate-guard.test.ts create mode 100644 supabase/migrations/20260612130000_drop_create_company_with_owner_business_profile_overload.sql create mode 100644 supabase/migrations/20260613100000_self_billing_received_invoices.sql create mode 100644 tests/pg/self-billing-invoice.pg.test.ts diff --git a/app/(dashboard)/invoices/[id]/page.tsx b/app/(dashboard)/invoices/[id]/page.tsx index ba431916..20380e7c 100644 --- a/app/(dashboard)/invoices/[id]/page.tsx +++ b/app/(dashboard)/invoices/[id]/page.tsx @@ -12,7 +12,7 @@ import { Separator } from '@/components/ui/separator' import { useToast } from '@/components/ui/use-toast' import { formatCurrency, formatDate, cn } from '@/lib/utils' import { getVatTreatmentLabel } from '@/lib/invoices/vat-rules' -import { invoiceNumberDisplay } from '@/lib/invoices/display' +import { invoiceNumberDisplay, invoiceDisplayNumber } from '@/lib/invoices/display' import { getDisplayTotal } from '@/lib/invoices/rounding' import { Loader2, @@ -427,6 +427,9 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st const isProforma = docType === 'proforma' const isDeliveryNote = docType === 'delivery_note' const isRealInvoice = docType === 'invoice' + // Self-billing invoices we received: the document is the counterparty's, so + // there is no own PDF to render and no send step — it arrives already booked. + const isSelfBilled = !!invoice.is_self_billed return (
{t('bank_missing_warning')}
@@ -635,8 +725,8 @@ export default function NewInvoicePage() { {/* Customer selection */}{errors.external_invoice_number.message}
+ )} +{errors.received_date.message}
+ )} +Inställningar → Import/Export → Exportera SIE
Visma eEkonomi
+Visma
Rapporter → Övrigt → Exportera till SIE
{role.legalName}
-- {cleaned} · {entityLabel} -
-- {t('ask_admin_invite')} -
-+ {t('already_in_app', { appName: branding.appName.toLowerCase() })} +
+ ) : null if (!mappable) { return ( @@ -236,6 +224,7 @@ export default function BankIdCompanyPicker({ {t('setup_manually')}{lookupError}
)} - {orgNumberExists && ( -{t('booking_unbalanced', { - diff: formatCurrency(Math.abs(editValidation.diff), transaction.currency), + diff: formatCurrency(Math.abs(editValidation.diff), 'SEK'), })}
)} diff --git a/extensions/general/arcim-migration/__tests__/migrate-guard.test.ts b/extensions/general/arcim-migration/__tests__/migrate-guard.test.ts new file mode 100644 index 00000000..f9368bc8 --- /dev/null +++ b/extensions/general/arcim-migration/__tests__/migrate-guard.test.ts @@ -0,0 +1,102 @@ +import { describe, it, expect, beforeEach, vi, type Mock } from 'vitest' +import { createMockSupabase, createMockRequest, parseJsonResponse } from '@/tests/helpers' +import type { ExtensionContext } from '@/lib/extensions/types' + +/** + * Guards the server-side "SIE import required first" rule on the entity-migration + * route (POST /migrate). + * + * Provider API import only ever writes subledger entities (customers, suppliers, + * invoices) — it never posts to the general ledger. The GL (kontoplan, ingående + * balanser, verifikationer) arrives via SIE. Importing entities without the + * SIE-derived ledger leaves an incomplete bokföring under BFL, so the route MUST + * refuse to run for non-Fortnox providers until a completed SIE import exists. + * Fortnox is exempt because it pulls SIE itself via API. + * + * Previously this was only an advisory banner + step-gating in the React wizard, + * which a direct API call or a stale client could bypass. This test locks the + * enforcement at the authoritative seam: the route handler. + */ + +vi.mock('../lib/migration-orchestrator', () => ({ + executeMigration: vi.fn().mockResolvedValue({ customers: { total: 0, imported: 0, skipped: 0 } }), +})) + +// index.ts imports many helpers from provider-client at module load; stub the +// whole module and give getConsent/acceptConsent controllable behaviour. +vi.mock('../lib/provider-client', () => ({ + createConsent: vi.fn(), + getConsent: vi.fn(), + listConsents: vi.fn(), + generateOtc: vi.fn(), + getAuthUrl: vi.fn(), + exchangeAuthToken: vi.fn(), + submitProviderToken: vi.fn(), + acceptConsent: vi.fn().mockResolvedValue(undefined), + deleteConsent: vi.fn(), + resolveConsent: vi.fn(), + fetchCompanyInfoDirect: vi.fn(), +})) + +import { arcimMigrationExtension } from '../index' +import { executeMigration } from '../lib/migration-orchestrator' +import { getConsent } from '../lib/provider-client' + +const migrateRoute = (arcimMigrationExtension.apiRoutes ?? []).find( + (r) => r.method === 'POST' && r.path === '/migrate', +)! + +type RouteHandler = (request: Request, ctx?: ExtensionContext) => Promise