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:
Mattsson
2026-06-04 13:14:57 +02:00
committed by GitHub
parent c1be9f15dd
commit 3e42fc6f32
45 changed files with 2675 additions and 529 deletions
+28
View File
@@ -216,6 +216,34 @@ export const CreateCreditNoteSchema = z.object({
reason: z.string().optional(),
})
// Self-billing received (mottagen självfaktura, ML 17 kap 15§). The customer
// issued the invoice on our behalf; for us it is a sale. We store the
// counterparty's number in external_invoice_number and never assign one from
// our own series. No ROT/RUT (that is a B2C, own-issued concept), so the item
// schema is the lean revenue-only shape — vat_rate is constrained to the legal
// Swedish set so the booked output VAT is always reportable.
export const SelfBillingInvoiceItemSchema = z.object({
description: z.string().min(1, 'Item description is required'),
quantity: z.number().positive('Quantity must be positive'),
unit: z.string().min(1, 'Unit is required').default('st'),
unit_price: z.number(),
vat_rate: z
.union([z.literal(0), z.literal(6), z.literal(12), z.literal(25)])
.optional(),
})
export const CreateSelfBillingInvoiceSchema = z.object({
customer_id: uuid,
external_invoice_number: z.string().min(1, 'External invoice number is required').max(64),
self_billing_agreement_ref: z.string().max(128).optional(),
invoice_date: isoDate,
received_date: isoDate,
due_date: isoDate,
currency: CurrencySchema,
notes: z.string().optional(),
items: z.array(SelfBillingInvoiceItemSchema).min(1, 'At least one item is required'),
})
// ============================================================
// Recurring invoice schedule schemas
// ============================================================
@@ -1205,6 +1205,111 @@ describe('createSupplierInvoiceCashEntry', () => {
})
})
// ============================================================
// createSupplierInvoiceCashEntry — foreign-currency settlement
// (kontantmetoden books the expense at the PAYMENT-date rate; the
// payment-account credit must equal the SEK that left the bank)
// ============================================================
describe('createSupplierInvoiceCashEntry — foreign-currency settlement', () => {
beforeEach(() => {
vi.clearAllMocks()
mockedFindFiscalPeriod.mockResolvedValue('period-1')
})
it('books a no-VAT foreign invoice at the payment-date rate, not the invoice rate (the reported bug)', async () => {
// 19 USD invoice. The invoice was captured at rate 9.20 (→ 174.80 SEK),
// but the bank actually paid 175.28 SEK at the payment-date rate. Under
// kontantmetoden the expense belongs at the payment rate, so 1930 must
// equal the bank movement exactly — and there is NO kursdifferens.
const invoice = makeSupplierInvoice({
currency: 'USD', exchange_rate: 9.20, subtotal: 19, vat_amount: 0, total: 19,
})
const items = [makeItem({ line_total: 19, account_number: '4000', vat_rate: 0, vat_amount: 0 })]
await createSupplierInvoiceCashEntry(
null as never, 'company-1', 'user-1', invoice, items, '2026-01-19', 'non_eu_business',
undefined, undefined, 175.28,
)
const input = mockedCreateEntry.mock.calls[0][3]
// Payment-date rate (175.28 / 19), NOT the invoice's 9.20 (which would give 174.80).
expect(findByAccount(input.lines, '4000')[0].debit_amount).toBe(175.28)
expect(findByAccount(input.lines, '1930')[0].credit_amount).toBe(175.28)
// No kursvinst/kursförlust under the cash method.
expect(findByAccount(input.lines, '7960')).toHaveLength(0)
expect(findByAccount(input.lines, '3960')).toHaveLength(0)
expect(findByAccount(input.lines, '2641')).toHaveLength(0)
assertBalanced(input)
})
it('translates a foreign reverse-charge invoice (fiktiv moms base) at the payment rate', async () => {
// 100 USD EU-service invoice, reverse charge. Bank paid 922.50 SEK.
const invoice = makeSupplierInvoice({
currency: 'USD', exchange_rate: 9.20, subtotal: 100, vat_amount: 0, total: 100, reverse_charge: true,
})
const items = [makeItem({ line_total: 100, account_number: '6540', vat_rate: 0.25, vat_amount: 0 })]
await createSupplierInvoiceCashEntry(
null as never, 'company-1', 'user-1', invoice, items, '2026-01-19', 'eu_business',
undefined, undefined, 922.50,
)
const input = mockedCreateEntry.mock.calls[0][3]
expect(findByAccount(input.lines, '6540')[0].debit_amount).toBe(922.50)
// Fiktiv moms on the payment-rate base (922.50 × 25%), and it nets out so
// 1930 still equals the bank movement.
expect(findByAccount(input.lines, '2645')[0].debit_amount).toBeCloseTo(230.63, 2)
expect(findByAccount(input.lines, '2614')[0].credit_amount).toBeCloseTo(230.63, 2)
expect(findByAccount(input.lines, '1930')[0].credit_amount).toBe(922.50)
assertBalanced(input)
})
it('folds a sub-öre rounding residual into the largest expense line so 1930 = bank SEK', async () => {
// Two expense lines whose per-line payment-rate rounding sums to 175.29,
// one öre over the 175.28 that actually left the bank. The residual is
// folded into the larger line so the bank credit lands exactly on 175.28.
const invoice = makeSupplierInvoice({
currency: 'USD', exchange_rate: 1.75, subtotal: 100, vat_amount: 0, total: 100,
})
const items = [
makeItem({ id: 'a', line_total: 33.33, account_number: '4000', vat_rate: 0, vat_amount: 0 }),
makeItem({ id: 'b', line_total: 66.67, account_number: '5000', vat_rate: 0, vat_amount: 0 }),
]
await createSupplierInvoiceCashEntry(
null as never, 'company-1', 'user-1', invoice, items, '2026-01-19', 'swedish_business',
undefined, undefined, 175.28,
)
const input = mockedCreateEntry.mock.calls[0][3]
const debitSum = input.lines
.filter((l) => l.debit_amount > 0)
.reduce((s, l) => s + l.debit_amount, 0)
expect(Math.round(debitSum * 100) / 100).toBe(175.28)
expect(findByAccount(input.lines, '1930')[0].credit_amount).toBe(175.28)
assertBalanced(input)
})
it('ignores settledBankSek for a SEK invoice (behaviour unchanged)', async () => {
const invoice = makeSupplierInvoice({
currency: 'SEK', subtotal: 8000, vat_amount: 2000, total: 10000,
})
const items = [makeItem({ line_total: 8000, account_number: '6200', vat_rate: 0.25 })]
await createSupplierInvoiceCashEntry(
null as never, 'company-1', 'user-1', invoice, items, '2024-07-01', 'swedish_business',
undefined, undefined, 9999, // bogus settlement SEK must be ignored for a SEK invoice
)
const input = mockedCreateEntry.mock.calls[0][3]
expect(findByAccount(input.lines, '6200')[0].debit_amount).toBe(8000)
expect(findByAccount(input.lines, '2641')[0].debit_amount).toBe(2000)
expect(findByAccount(input.lines, '1930')[0].credit_amount).toBe(10000)
assertBalanced(input)
})
})
// ============================================================
// createSupplierCreditNoteEntry
// ============================================================
+16 -3
View File
@@ -231,7 +231,15 @@ export async function createInvoiceJournalEntry(
userId: string,
invoice: Invoice,
entityType: EntityType = 'enskild_firma',
customerName?: string
customerName?: string,
/**
* Overrides for non-standard sales that still book identically to a customer
* invoice. Used by self-billing received (mottagen självfaktura): the
* verifikation should read "Självfaktura <external number>" rather than
* "Kundfaktura <our number>", and the number tag must be the counterparty's
* external number because the row has no own `invoice_number`.
*/
options?: { descriptionPrefix?: string; numberOverride?: string | null }
): Promise<JournalEntry | null> {
const fiscalPeriodId = await findFiscalPeriod(supabase, companyId, invoice.invoice_date)
if (!fiscalPeriodId) {
@@ -241,7 +249,7 @@ export async function createInvoiceJournalEntry(
const lines: CreateJournalEntryLineInput[] = []
const isForeign = invoice.currency !== 'SEK'
const tag = invoiceTag(invoice)
const tag = options?.numberOverride ?? invoiceTag(invoice)
// Credit lines: revenue + VAT per rate group (compute first to guarantee balance)
const creditLines: CreateJournalEntryLineInput[] = []
@@ -314,7 +322,12 @@ export async function createInvoiceJournalEntry(
const input: CreateJournalEntryInput = {
fiscal_period_id: fiscalPeriodId,
entry_date: invoice.invoice_date,
description: buildInvoiceDescription('Kundfaktura', invoice.invoice_number, customerName, invoice.id),
description: buildInvoiceDescription(
options?.descriptionPrefix ?? 'Kundfaktura',
options?.numberOverride ?? invoice.invoice_number,
customerName,
invoice.id,
),
source_type: 'invoice_created',
source_id: invoice.id,
lines,
+56 -8
View File
@@ -7,6 +7,7 @@ import {
resolveReverseChargeRate,
} from './vat-entries'
import { createLogger } from '@/lib/logger'
import { roundOre } from '@/lib/money'
import type { SupabaseClient } from '@supabase/supabase-js'
import type {
CreateJournalEntryInput,
@@ -282,7 +283,12 @@ export async function createSupplierInvoiceCashEntry(
paymentDate: string,
supplierType: string,
supplierName?: string,
paymentAccount?: string
paymentAccount?: string,
// SEK that actually settled the invoice (the amount that left the bank). For
// a foreign-currency invoice this pins the whole entry to the PAYMENT-date
// rate — see the kontantmetoden note below. Omit for SEK invoices and the
// behaviour is byte-identical to before.
settledBankSek?: number
): Promise<JournalEntry | null> {
const creditAccount = paymentAccount || '1930'
const fiscalPeriodId = await findFiscalPeriod(supabase, companyId, paymentDate)
@@ -291,25 +297,46 @@ export async function createSupplierInvoiceCashEntry(
return null
}
// Under kontantmetoden the booked affärshändelse IS the payment (BFL 5 kap —
// "bokföring vid betalningstillfället"), so the entire verifikat is translated
// at the PAYMENT-date rate (ÅRL 4 kap 6 §). There is no kursvinst/kursförlust
// because no leverantörsskuld was ever carried at a historical rate — that
// only happens under faktureringsmetoden (handled by the 2440-clearing path
// with 7960/3960). When the caller passes the SEK that actually settled the
// invoice, we derive the implied payment-date rate from it so the payment-
// account credit equals the bank movement to the öre. For SEK invoices, or
// when no settlement SEK is supplied, we keep the invoice's stored rate.
const isForeign = invoice.currency !== 'SEK'
const useSettlementRate =
settledBankSek != null && settledBankSek > 0 && isForeign && invoice.total > 0
const effectiveRate = useSettlementRate
? settledBankSek / invoice.total
: invoice.exchange_rate
const desc = buildSupplierDescription('Kontantbetalning leverantörsfaktura', invoice.supplier_invoice_number, supplierName)
const lines: CreateJournalEntryLineInput[] = []
// Expense debit lines tracked separately so a sub-öre translation residual
// can be folded into the largest one (öresavrundning step below).
const expenseLines: CreateJournalEntryLineInput[] = []
// Aggregate expense amounts by account number and convert to SEK
const expenseByAccount = new Map<string, number>()
for (const item of items) {
const current = expenseByAccount.get(item.account_number) || 0
const itemSek = resolveSekAmount(item.line_total, null, invoice.currency, invoice.exchange_rate)
const itemSek = resolveSekAmount(item.line_total, null, invoice.currency, effectiveRate)
expenseByAccount.set(item.account_number, current + itemSek)
}
// Debit: Expense accounts (in SEK)
for (const [accountNumber, amount] of expenseByAccount) {
lines.push({
const line: CreateJournalEntryLineInput = {
account_number: accountNumber,
debit_amount: Math.round(amount * 100) / 100,
credit_amount: 0,
line_description: desc,
})
}
lines.push(line)
expenseLines.push(line)
}
const isReverseCharge = (supplierType === 'eu_business' || supplierType === 'non_eu_business' || supplierType === 'swedish_business') && invoice.reverse_charge
@@ -327,8 +354,10 @@ export async function createSupplierInvoiceCashEntry(
// Per-rate bucketing: see registration entry above for the FK004 rationale.
// Drive iteration off the basis (line_total per rate) — fiktiv moms is
// always statutory base × rate; manual vat_amount overrides don't apply.
const baseByRate = groupBaseByRate(items, invoice.currency, invoice.exchange_rate)
const nonBasisBaseByRate = groupNonBasisBaseByRate(items, invoice.currency, invoice.exchange_rate)
// effectiveRate (payment-date rate under kontantmetoden) keeps the fiktiv
// moms base consistent with the expense lines above.
const baseByRate = groupBaseByRate(items, invoice.currency, effectiveRate)
const nonBasisBaseByRate = groupNonBasisBaseByRate(items, invoice.currency, effectiveRate)
const rcSupplierType = supplierType as 'eu_business' | 'non_eu_business' | 'swedish_business'
for (const [rate, baseAmount] of baseByRate) {
if (rate > 0 && baseAmount > 0) {
@@ -342,8 +371,9 @@ export async function createSupplierInvoiceCashEntry(
}
}
} else if (invoice.vat_amount > 0) {
// Domestic standard: Debit ingående moms per rate group
const vatByRate = groupVatByRate(items, invoice.currency, invoice.exchange_rate)
// Domestic standard: Debit ingående moms per rate group (at the payment-
// date rate when settling a foreign invoice — see effectiveRate above).
const vatByRate = groupVatByRate(items, invoice.currency, effectiveRate)
for (const [rate, amount] of vatByRate) {
if (amount > 0) {
lines.push({
@@ -356,6 +386,24 @@ export async function createSupplierInvoiceCashEntry(
}
}
// Öresavrundning: when translating a foreign invoice at the payment-date
// rate, per-line rounding can drift the implied bank total by an öre or two.
// Fold that residual into the largest expense line so the payment-account
// credit lands exactly on the SEK that left the bank (1930 reconciles to the
// bank transaction). Immaterial to the momsdeklaration — rutor are whole
// kronor. The |residual| ≤ 1 guard ensures we only absorb rounding noise,
// never a real shortfall (a partial settlement is blocked upstream).
if (useSettlementRate && expenseLines.length > 0) {
const debitSum = lines.reduce((sum, l) => sum + l.debit_amount, 0)
const creditSum = lines.reduce((sum, l) => sum + l.credit_amount, 0)
const provisionalCredit = roundOre(debitSum - creditSum)
const residual = roundOre(settledBankSek! - provisionalCredit)
if (residual !== 0 && Math.abs(residual) <= 1) {
const target = expenseLines.reduce((a, b) => (b.debit_amount >= a.debit_amount ? b : a))
target.debit_amount = roundOre(target.debit_amount + residual)
}
}
// Credit: payment account — balance guarantee: ensures sum(debits) === sum(credits)
// For reverse charge, intermediate credits (2614/2624/2634) already exist, so we subtract them
const totalDebits = lines.reduce((sum, l) => sum + l.debit_amount, 0)
+2 -167
View File
@@ -6,44 +6,16 @@ vi.mock('next/cache', () => ({
vi.mock('@/lib/supabase/server', () => ({
createClient: vi.fn(),
createServiceClient: vi.fn(),
}))
vi.mock('@/lib/company/context', () => ({
setActiveCompany: vi.fn().mockResolvedValue(undefined),
}))
import { createClient, createServiceClient } from '@/lib/supabase/server'
import { createClient } from '@/lib/supabase/server'
import { createCompanyFromOnboarding } from '../actions'
const mockCreateClient = vi.mocked(createClient)
const mockCreateServiceClient = vi.mocked(createServiceClient)
/**
* Build a service-role client mock. Seed `existingOrgNumber` when you want
* the duplicate-org guard in createCompanyFromOnboarding to find a match.
* Any other service-role query resolves to `{ data: null, error: null }`.
*/
function mockServiceClientForOrgNumber(existingOrgNumber?: string) {
const serviceFrom = vi.fn().mockImplementation(() => {
const chain: Record<string, unknown> = {}
const methods = ['select', 'eq', 'is', 'in', 'order', 'limit', 'maybeSingle']
for (const m of methods) {
chain[m] = () => {
if (m === 'maybeSingle') {
return Promise.resolve({
data: existingOrgNumber ? { id: 'other-company', name: 'Other AB' } : null,
error: null,
})
}
return chain
}
}
chain.then = (resolve: (v: unknown) => void) => resolve({ data: null, error: null })
return chain
})
mockCreateServiceClient.mockReturnValue({ from: serviceFrom } as never)
}
type CapturedCall = { table: string; method: string; args: unknown[] }
@@ -106,101 +78,9 @@ function buildSupabase(opts: {
beforeEach(() => {
vi.clearAllMocks()
// Default: no existing company with this org_number. Individual tests can
// override by calling mockServiceClientForOrgNumber('...') inside the test.
mockServiceClientForOrgNumber(undefined)
})
describe('createCompanyFromOnboarding — duplicate org_number guard', () => {
it('refuses to create a company when the org number already exists', async () => {
const { supabase, calls } = buildSupabase({
user: { id: 'user-1' },
rpcResults: {
create_company_with_owner: { data: 'should-not-be-called' },
},
})
mockCreateClient.mockResolvedValue(supabase as never)
mockServiceClientForOrgNumber('5560125790') // pretend this org is already taken
const result = await createCompanyFromOnboarding({
teamId: 'team-1',
settings: {
entity_type: 'aktiebolag',
company_name: 'Acme AB',
org_number: '5560125790',
},
fiscalPeriod: {
startDate: '2026-01-01',
endDate: '2026-12-31',
name: 'Räkenskapsår 2026',
},
})
expect(result.error).toBe('org_number_exists')
expect(result.companyId).toBeUndefined()
// Guard must short-circuit before the create RPC runs — otherwise we'd
// leave a ghost company behind when the duplicate is detected.
const rpcCreate = supabase.rpc.mock.calls.find(([name]) => name === 'create_company_with_owner')
expect(rpcCreate).toBeUndefined()
// And no company_settings upsert should have happened.
expect(calls.find((c) => c.table === 'company_settings' && c.method === 'upsert')).toBeUndefined()
})
it('tolerates formatted org_numbers when detecting duplicates (hyphens/spaces stripped)', async () => {
const { supabase } = buildSupabase({
user: { id: 'user-1' },
rpcResults: { create_company_with_owner: { data: 'x' } },
})
mockCreateClient.mockResolvedValue(supabase as never)
mockServiceClientForOrgNumber('5560125790')
const result = await createCompanyFromOnboarding({
teamId: 'team-1',
settings: {
entity_type: 'aktiebolag',
company_name: 'Acme AB',
// User-typed format — the guard should still catch this as a duplicate.
org_number: '556677-8899',
},
fiscalPeriod: {
startDate: '2026-01-01',
endDate: '2026-12-31',
name: 'Räkenskapsår 2026',
},
})
expect(result.error).toBe('org_number_exists')
})
it('normalizes 12-digit personnummer input down to the 10-digit canonical form', async () => {
const { supabase } = buildSupabase({
user: { id: 'user-1' },
rpcResults: { create_company_with_owner: { data: 'x' } },
})
mockCreateClient.mockResolvedValue(supabase as never)
// The existing company is stored as the 10-digit canonical form.
mockServiceClientForOrgNumber('8001011231')
const result = await createCompanyFromOnboarding({
teamId: 'team-1',
settings: {
entity_type: 'enskild_firma',
company_name: 'Anna EF',
// User types full 12-digit personnummer with century prefix.
org_number: '19800101-1231',
},
fiscalPeriod: {
startDate: '2026-01-01',
endDate: '2026-12-31',
name: 'Räkenskapsår 2026',
},
})
// Should detect the duplicate despite the 12-digit input.
expect(result.error).toBe('org_number_exists')
})
describe('createCompanyFromOnboarding — org_number validation', () => {
it('rejects malformed org_numbers at the guard boundary', async () => {
const { supabase } = buildSupabase({
user: { id: 'user-1' },
@@ -258,51 +138,6 @@ describe('createCompanyFromOnboarding — duplicate org_number guard', () => {
const rpcCreate = supabase.rpc.mock.calls.find(([name]) => name === 'create_company_with_owner')
expect(rpcCreate).toBeUndefined()
})
it('fails closed when the duplicate lookup errors out (does not silently allow duplicates)', async () => {
const { supabase } = buildSupabase({
user: { id: 'user-1' },
rpcResults: { create_company_with_owner: { data: 'x' } },
})
mockCreateClient.mockResolvedValue(supabase as never)
// Seed a service client that errors on maybeSingle — simulating a DB
// outage or RLS misconfiguration.
mockCreateServiceClient.mockReturnValue({
from: vi.fn().mockReturnValue({
select: vi.fn().mockReturnThis(),
eq: vi.fn().mockReturnThis(),
is: vi.fn().mockReturnThis(),
limit: vi.fn().mockReturnThis(),
maybeSingle: vi.fn().mockResolvedValue({
data: null,
error: { message: 'connection lost' },
}),
}),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any)
const result = await createCompanyFromOnboarding({
teamId: 'team-1',
settings: {
entity_type: 'aktiebolag',
company_name: 'Acme AB',
org_number: '5560125790',
},
fiscalPeriod: {
startDate: '2026-01-01',
endDate: '2026-12-31',
name: 'Räkenskapsår 2026',
},
})
// Must return a user-facing error, NOT silently proceed with creation.
expect(result.companyId).toBeUndefined()
expect(result.error).toBeTruthy()
// And the create RPC must not have been called.
const rpcCreate = supabase.rpc.mock.calls.find(([name]) => name === 'create_company_with_owner')
expect(rpcCreate).toBeUndefined()
})
})
describe('createCompanyFromOnboarding — TIC snapshot persistence', () => {
+6 -50
View File
@@ -1,41 +1,11 @@
'use server'
import { createClient, createServiceClient } from '@/lib/supabase/server'
import { createClient } from '@/lib/supabase/server'
import { setActiveCompany } from '@/lib/company/context'
import { revalidatePath } from 'next/cache'
import { normalizeOrgNumber } from '@/lib/company-lookup/normalize-org-number'
import type { CompanyLookupResult } from '@/lib/company-lookup/types'
/**
* Check whether an org number is already registered in any non-archived
* Accounted company. Uses the service role because RLS hides rows the caller
* isn't a member of — and "other users' duplicates" is exactly what we
* need to detect. Returns null when `orgNumber` is empty/malformed. Throws
* if the underlying query fails — callers must not silently treat that as
* "no duplicate," or the whole guard gets bypassed on transient DB errors.
*/
async function findExistingCompanyByOrgNumber(
orgNumber: string | null | undefined,
): Promise<{ id: string; name: string } | null> {
const cleaned = normalizeOrgNumber(orgNumber)
if (!cleaned) return null
const service = createServiceClient()
const { data, error } = await service
.from('companies')
.select('id, name')
.eq('org_number', cleaned)
.is('archived_at', null)
.limit(1)
.maybeSingle()
if (error) {
throw new Error(`Duplicate-org lookup failed: ${error.message}`)
}
return data ?? null
}
export async function switchCompany(companyId: string): Promise<{ error?: string }> {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
@@ -113,11 +83,11 @@ async function createCompanyFromOnboardingImpl(params: {
const companyName = (params.settings.company_name as string | undefined) || 'Mitt företag'
// Duplicate-org guard. We don't have a DB unique constraint on
// companies.org_number (can't add one safely without cleaning up any
// existing duplicates first), so enforce uniqueness at the application
// boundary. Must run before the create RPC so we don't leave a ghost
// company if the duplicate is detected mid-flow.
// Org-number format validation. We intentionally do NOT enforce
// uniqueness: the same org number may legitimately appear on multiple
// companies (a separate test copy of your real company, or a consultant
// and the owner each tracking the same entity). Tenant isolation
// (RLS + company_id) is the real boundary — not org-number uniqueness.
//
// normalizeOrgNumber returns null for malformed input — we refuse rather
// than storing a value that would break SIE/SRU exports later.
@@ -126,20 +96,6 @@ async function createCompanyFromOnboardingImpl(params: {
if (rawOrgNumber && rawOrgNumber.trim() && !cleanedOrgNumber) {
return { error: 'org_number_invalid' }
}
if (cleanedOrgNumber) {
try {
const existing = await findExistingCompanyByOrgNumber(cleanedOrgNumber)
if (existing) {
return { error: 'org_number_exists' }
}
} catch (err) {
// Guard must fail closed: if we can't confirm uniqueness, don't create
// a company. A silent pass-through would let transient DB errors
// through as duplicates (exactly the bug Greptile flagged).
console.error('[createCompanyFromOnboarding] duplicate-org lookup failed', err)
return { error: 'Kunde inte verifiera organisationsnummer. Försök igen.' }
}
}
// 1. Create company + owner membership atomically via RPC
const { data: newCompanyId, error: companyError } = await supabase.rpc('create_company_with_owner', {
+9 -2
View File
@@ -534,9 +534,9 @@ const MATCH_SI: Record<string, StructuredErrorEntry> = {
MATCH_SI_CASH_FX_UNSUPPORTED: {
httpStatus: 400,
message_sv:
'Kontantmetoden stödjer inte valutakursdifferenser. Byt till löpande bokföring eller bokför valutakursdifferensen manuellt.',
'Kontantmetoden kan inte dela upp en delbetalning i utländsk valuta. Betala hela fakturan på en gång, byt till löpande bokföring eller bokför betalningen manuellt.',
message_en:
'Cash accounting does not support exchange-rate differences. Switch to accrual or book the FX difference manually.',
'The cash method cannot handle a partial foreign-currency payment. Pay the invoice in full, switch to accrual, or book the payment manually.',
},
MATCH_SI_AMOUNT_EXCEEDS_REMAINING: {
httpStatus: 400,
@@ -1276,6 +1276,13 @@ const PROVIDER_MIGRATION: Record<string, StructuredErrorEntry> = {
message_sv: 'SIE-export stöds för närvarande endast för Fortnox.',
message_en: 'SIE export is currently only supported for Fortnox.',
},
PROVIDER_SIE_IMPORT_REQUIRED: {
httpStatus: 409,
message_sv:
'Bokföringsdata (SIE) måste importeras först. Ladda upp en SIE-fil med kontoplan, ingående balanser och verifikationer innan du hämtar kunder, leverantörer och fakturor från den här leverantören.',
message_en:
'A completed SIE import is required first. Import the SIE file (chart of accounts, opening balances and verifications) before importing customers, suppliers and invoices from this provider.',
},
PROVIDER_MIGRATE_FAILED: {
httpStatus: 500,
message_sv: 'Migrationen från leverantören misslyckades.',
@@ -86,4 +86,34 @@ describe('ensureInvoiceNumber', () => {
ensureInvoiceNumber(supabase as never, 'company-1', invoice)
).rejects.toThrow('no value returned')
})
it('returns the external number for a self-billed invoice without touching the RPC', async () => {
// A received self-billing invoice carries the counterparty's number; we must
// never consume our own löpnummerserie (BFL 5 kap 6§).
const invoice = {
id: 'inv-1',
invoice_number: null,
is_self_billed: true,
external_invoice_number: 'KUND-55012',
}
const result = await ensureInvoiceNumber(supabase as never, 'company-1', invoice)
expect(result).toBe('KUND-55012')
expect(supabase.rpc).not.toHaveBeenCalled()
})
it('throws when a self-billed invoice is missing the external number', async () => {
const invoice = {
id: 'inv-1',
invoice_number: null,
is_self_billed: true,
external_invoice_number: null,
}
await expect(
ensureInvoiceNumber(supabase as never, 'company-1', invoice)
).rejects.toThrow('missing external_invoice_number')
expect(supabase.rpc).not.toHaveBeenCalled()
})
})
+13
View File
@@ -3,3 +3,16 @@ export const INVOICE_NUMBER_DRAFT_LABEL = '(Utkast)'
export function invoiceNumberDisplay(value: string | null | undefined): string {
return value ?? INVOICE_NUMBER_DRAFT_LABEL
}
/**
* The number to show for an invoice. Self-billing invoices we received carry
* the counterparty's number in `external_invoice_number` (our own
* `invoice_number` is null by design), so fall back to it before the draft
* label.
*/
export function invoiceDisplayNumber(invoice: {
invoice_number?: string | null
external_invoice_number?: string | null
}): string {
return invoice.invoice_number ?? invoice.external_invoice_number ?? INVOICE_NUMBER_DRAFT_LABEL
}
+13
View File
@@ -4,6 +4,8 @@ import type { Invoice, InvoiceDocumentType } from '@/types'
type InvoiceShape = Pick<Invoice, 'id' | 'invoice_number'> & {
invoice_number: string | null
document_type?: InvoiceDocumentType | null
is_self_billed?: boolean | null
external_invoice_number?: string | null
}
/**
@@ -24,6 +26,17 @@ export async function ensureInvoiceNumber(
return invoice.invoice_number
}
// Self-billing invoices we received carry the COUNTERPARTY's number; we must
// never consume our own löpnummerserie for them (BFL 5 kap 6§). The DB
// constraint invoices_self_billed_numbering guarantees the external number is
// present, but guard here so a future caller can't silently mint an F-number.
if (invoice.is_self_billed) {
if (!invoice.external_invoice_number) {
throw new Error('Self-billed invoice is missing external_invoice_number')
}
return invoice.external_invoice_number
}
const { data: assigned, error: rpcError } = await supabase.rpc('generate_invoice_number', {
p_company_id: companyId,
p_invoice_id: invoice.id,
+3 -1
View File
@@ -129,7 +129,9 @@ export async function generateARLedger(
// Add invoice detail (always — even if unconvertible, so it's visible)
entry.invoices.push({
invoice_id: inv.id,
invoice_number: inv.invoice_number || '',
// Self-billing invoices we received have no own number — show the
// counterparty's external number instead.
invoice_number: inv.invoice_number || inv.external_invoice_number || '',
invoice_date: inv.invoice_date || '',
due_date: inv.due_date,
total,