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:
@@ -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
|
||||
// ============================================================
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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', {
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user