feat(supplier-invoices): sarskild loneskatt (SLP) pair on pension premium lines (#1534)
* feat(supplier-invoices): sarskild loneskatt (SLP) pair on pension premium lines Booking a tjanstepension invoice (e.g. Avanza) needs the buyer's own SLP beyond the payable: debit 7533 / credit 2514 at 24.26% of the premium (SLF 1991:687). The item-based debit-only form could not express the self-balancing pair, so users had to hand-edit the verifikat. - new leaf module lib/bookkeeping/slp-lines.ts: SLP_RATE (single source, re-exported by the bokslut calculator), isSlpPensionAccount (741x), generateSlpLines (7533 D / 2514 K, nets to zero) - migration adds supplier_invoice_items.apply_slp boolean default false - registration, cash, and privately-paid generators inject the pair for flagged 741x items, mirroring the reverse-charge injection; the balance guarantees keep 2440/1930/2893 at exactly the invoice total; the credit note generator reverses the pair (7533 K / 2514 D) - privately-paid balance guarantee now subtracts existing credits so the SLP 2514 leg never inflates the owner account - schema field apply_slp + guards in all create paths (main route, inbox convert, v1 REST, pending-operations executor): 400 SI_CREATE_SLP_INVALID_ACCOUNT on non-741x accounts, 400 SI_CREATE_SLP_ACCRUAL combined with periodisering - form: advisory hint on unflagged 741x rows with one-click opt-in and a quiet confirmation line when applied; totals box untouched (the invoice total stays the payable); AB review preview injects the same pair via the same generator for parity - year-end double-count guard: calculateSarskildLoneskatt subtracts SLP already posted to 7533 during the year (floored at zero) so bokslut never provisions flagged premiums twice Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * chore(api-skill): regenerate suppliers reference for apply_slp The apiskill:check CI gate requires the generated accounted-api skill to stay in sync with the endpoint registry after the apply_slp addition. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(slp): carry apply_slp through v1 routes, MCP staging, preview and credit reversal Review findings on the SLP PR: - v1 credit route: SI_FULL_COLUMNS now projects items.apply_slp, so createSupplierCreditNoteEntry sees the flag and reverses the 7533/2514 pair booked at registration (it previously stood forever and the year-end netting under-provisioned). The flag is also copied onto the created credit-note items for parity with the web credit route. - v1 mark-paid: the items sub-select now includes apply_slp, so a kontantmetoden payment via v1 books the cash entry WITH the SLP pair, matching the web mark-paid. - v1 GET ?expand=items: SI_ITEM_COLUMNS includes apply_slp so the flag is readable back through the public API. - credit-note SLP base is abs of the SIGNED sum of flagged line_totals, not per-item abs: a mixed-sign flagged original (+10000/-2000) booked SLP on 8000 at registration and now reverses exactly that, not 12000. The expense-bucket per-item abs convention is untouched. - kontantmetod bank-match preview appends the same generateSlpLines pair the POST books, so the approved lines equal the committed lines. - MCP gnubok_create_supplier_invoice_from_inbox: line_overrides accepts apply_slp (optional boolean), plumbs it into the staged operation's items, and rejects non-741x resolved accounts at staging time with the bilingual SI_CREATE_SLP_INVALID_ACCOUNT texts. - DECISIONS.md: five entries for today's decisions. Every behavioral fix has a test verified to fail without it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
Jakob Wennberg
parent
3a7688c163
commit
7cf0e34434
@@ -61,6 +61,12 @@ const SI_RESPONSE_COLUMNS =
|
||||
// decide whether a kontantmetoden credit note must reverse an entry the
|
||||
// payment already posted. `status` alone is too weak, it misses a
|
||||
// part-paid-but-booked original (rows predating the #1413 guard).
|
||||
//
|
||||
// Items keep `apply_slp` in the projection for the same reason as
|
||||
// default_dimensions above: createSupplierCreditNoteEntry reads the flag off
|
||||
// the ORIGINAL items to reverse the 7533/2514 SLP pair the registration
|
||||
// booked. Dropping it would leave the pension cost and the 2514 liability
|
||||
// standing forever after the credit.
|
||||
const SI_FULL_COLUMNS = `
|
||||
id, supplier_id, supplier_invoice_number, invoice_date, status,
|
||||
currency, exchange_rate,
|
||||
@@ -69,7 +75,7 @@ const SI_FULL_COLUMNS = `
|
||||
registration_journal_entry_id, payment_journal_entry_id, paid_at, paid_amount,
|
||||
is_credit_note, credited_invoice_id, arrival_number, default_dimensions,
|
||||
supplier:suppliers(id, name, supplier_type),
|
||||
items:supplier_invoice_items(id, sort_order, description, quantity, unit, unit_price, line_total, account_number, vat_code, vat_rate, vat_amount, reverse_charge_rate, dimensions)
|
||||
items:supplier_invoice_items(id, sort_order, description, quantity, unit, unit_price, line_total, account_number, vat_code, vat_rate, vat_amount, reverse_charge_rate, apply_slp, dimensions)
|
||||
`
|
||||
|
||||
const SupplierInvoiceCredited = z.object({
|
||||
@@ -184,6 +190,7 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
|
||||
vat_rate: number
|
||||
vat_amount: number
|
||||
reverse_charge_rate: number | null
|
||||
apply_slp: boolean | null
|
||||
dimensions: Record<string, string> | null
|
||||
}>
|
||||
} & Record<string, unknown>
|
||||
@@ -332,6 +339,10 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
|
||||
// Preserve the self-assessed RC rate so the credit note reverses fiktiv
|
||||
// moms at the same rate the original was booked at.
|
||||
reverse_charge_rate: item.reverse_charge_rate,
|
||||
// Preserve the SLP flag for display parity with the web credit route;
|
||||
// the journal reversal reads the ORIGINAL items, so the 7533/2514 swap
|
||||
// is correct either way.
|
||||
apply_slp: item.apply_slp ?? false,
|
||||
// Same reasoning: the reversal must carry the exact per-item bag the
|
||||
// original booked with (dimensions PR7).
|
||||
dimensions: item.dimensions ?? {},
|
||||
|
||||
@@ -175,7 +175,7 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
|
||||
subtotal, subtotal_sek, vat_amount, vat_amount_sek, total_sek, due_date, received_date,
|
||||
is_credit_note, credited_invoice_id, payment_journal_entry_id, default_dimensions,
|
||||
supplier:suppliers(id, name, supplier_type),
|
||||
items:supplier_invoice_items(id, sort_order, description, quantity, unit, unit_price, line_total, account_number, vat_code, vat_rate, vat_amount, reverse_charge_rate, dimensions)
|
||||
items:supplier_invoice_items(id, sort_order, description, quantity, unit, unit_price, line_total, account_number, vat_code, vat_rate, vat_amount, reverse_charge_rate, apply_slp, dimensions)
|
||||
`)
|
||||
.eq('company_id', ctx.companyId!)
|
||||
.eq('id', invoiceId)
|
||||
|
||||
@@ -38,7 +38,7 @@ const SI_DETAIL_COLUMNS =
|
||||
'id, supplier_id, arrival_number, supplier_invoice_number, invoice_date, due_date, received_date, delivery_date, status, currency, exchange_rate, exchange_rate_date, subtotal, subtotal_sek, vat_amount, vat_amount_sek, total, total_sek, vat_treatment, reverse_charge, payment_reference, paid_at, paid_amount, remaining_amount, is_credit_note, credited_invoice_id, registration_journal_entry_id, payment_journal_entry_id, transaction_id, document_id, notes, default_dimensions, reversed_at, created_at, updated_at'
|
||||
|
||||
const SI_ITEM_COLUMNS =
|
||||
'id, sort_order, description, quantity, unit, unit_price, line_total, account_number, vat_code, vat_rate, vat_amount, reverse_charge_rate, dimensions'
|
||||
'id, sort_order, description, quantity, unit, unit_price, line_total, account_number, vat_code, vat_rate, vat_amount, reverse_charge_rate, apply_slp, dimensions'
|
||||
|
||||
const SI_PAYMENT_COLUMNS =
|
||||
'id, payment_date, amount, currency, exchange_rate, exchange_rate_difference, journal_entry_id, transaction_id, notes, created_at'
|
||||
|
||||
@@ -84,11 +84,19 @@ interface TableResp {
|
||||
/** Payload handed to `.insert()`, recorded per table so writes can be asserted. */
|
||||
type InsertRecord = { table: string; payload: Record<string, unknown> }
|
||||
|
||||
/** Column string handed to `.select()`, recorded per table. The Proxy mock
|
||||
* returns fixture rows regardless of projection, so a column dropped from a
|
||||
* SELECT never fails these tests by itself: capturing the projection is the
|
||||
* only way to regression-test "this route must fetch column X". */
|
||||
type SelectRecord = { table: string; columns: string }
|
||||
|
||||
function makeFlexibleSupabase(
|
||||
byTable: Record<string, TableResp | TableResp[]>,
|
||||
// Opt-in sink for insert payloads: the Proxy chain is otherwise write-only,
|
||||
// and the route echoes back the fixture row rather than what it wrote.
|
||||
insertSink?: InsertRecord[],
|
||||
// Opt-in sink for select projections (see SelectRecord).
|
||||
selectSink?: SelectRecord[],
|
||||
) {
|
||||
// Per-table queue: TableResp[] consumes one entry per await, then sticks
|
||||
// on the last entry. Plain TableResp is treated as a constant.
|
||||
@@ -116,6 +124,9 @@ function makeFlexibleSupabase(
|
||||
) {
|
||||
insertSink.push({ table, payload: args[0] as Record<string, unknown> })
|
||||
}
|
||||
if (selectSink && prop === 'select' && typeof args[0] === 'string') {
|
||||
selectSink.push({ table, columns: args[0] })
|
||||
}
|
||||
return buildChain(table)
|
||||
}
|
||||
},
|
||||
@@ -286,6 +297,28 @@ describe('GET /api/v1/companies/:companyId/supplier-invoices/:id', () => {
|
||||
const body = await res.json()
|
||||
expect(body.error.code).toBe('SI_NOT_FOUND')
|
||||
})
|
||||
|
||||
it('?expand=items projects apply_slp so the flag is readable back', async () => {
|
||||
const selects: Array<{ table: string; columns: string }> = []
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase(
|
||||
{
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
supplier_invoices: { data: { ...SAMPLE_SI, items: [] }, error: null },
|
||||
},
|
||||
undefined,
|
||||
selects,
|
||||
),
|
||||
)
|
||||
const res = await getSI(
|
||||
makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/supplier-invoices/${SI_ID}?expand=items`),
|
||||
detailParams(COMPANY_ID, SI_ID),
|
||||
)
|
||||
expect(res.status).toBe(200)
|
||||
const siSelect = selects.find((s) => s.table === 'supplier_invoices')
|
||||
expect(siSelect).toBeDefined()
|
||||
expect(siSelect!.columns).toMatch(/items:supplier_invoice_items\([^)]*apply_slp/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('POST /api/v1/companies/:companyId/supplier-invoices', () => {
|
||||
@@ -1443,6 +1476,69 @@ describe('POST /api/v1/companies/:companyId/supplier-invoices/:id/mark-paid', ()
|
||||
expect(mockedPayment).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('kontantmetod: fetches apply_slp on the items and hands them to the cash entry builder', async () => {
|
||||
// Regression: the items sub-select omitted apply_slp, so a kontantmetoden
|
||||
// payment via v1 booked the cash entry WITHOUT the 7533/2514 SLP pair
|
||||
// while the web mark-paid (select *) booked it. The projection is the
|
||||
// bug surface: the Proxy mock returns fixture rows regardless, so the
|
||||
// select string itself is asserted alongside the pass-through.
|
||||
const slpItem = {
|
||||
id: 'item-slp-1',
|
||||
sort_order: 0,
|
||||
description: 'Tjänstepension',
|
||||
quantity: 1,
|
||||
unit: 'st',
|
||||
unit_price: 1250,
|
||||
line_total: 1250,
|
||||
account_number: '7412',
|
||||
vat_code: null,
|
||||
vat_rate: 0,
|
||||
vat_amount: 0,
|
||||
reverse_charge_rate: null,
|
||||
apply_slp: true,
|
||||
dimensions: {},
|
||||
}
|
||||
const cashSI = {
|
||||
...SAMPLE_SI,
|
||||
status: 'approved',
|
||||
vat_amount: 0,
|
||||
subtotal: 1250,
|
||||
supplier: { id: SUPPLIER_ID, name: 'Avanza Pension', supplier_type: 'swedish_business' },
|
||||
items: [slpItem],
|
||||
}
|
||||
const selects: Array<{ table: string; columns: string }> = []
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase(
|
||||
{
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
supplier_invoices: { data: cashSI, error: null },
|
||||
company_settings: { data: { accounting_method: 'cash' }, error: null },
|
||||
fiscal_periods: { data: { id: 'fp-1', is_closed: false, locked_at: null }, error: null },
|
||||
supplier_invoice_payments: { data: null, error: null },
|
||||
idempotency_keys: { data: null, error: null },
|
||||
},
|
||||
undefined,
|
||||
selects,
|
||||
),
|
||||
)
|
||||
const res = await markPaidSI(
|
||||
makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/supplier-invoices/${SI_ID}/mark-paid`, {
|
||||
method: 'POST',
|
||||
}),
|
||||
detailParams(COMPANY_ID, SI_ID),
|
||||
)
|
||||
expect(res.status).toBe(200)
|
||||
expect(mockedCash).toHaveBeenCalledTimes(1)
|
||||
// The projection must carry the flag: without it the engine can never
|
||||
// see apply_slp and silently skips the SLP pair.
|
||||
const siSelect = selects.find((s) => s.table === 'supplier_invoices')
|
||||
expect(siSelect).toBeDefined()
|
||||
expect(siSelect!.columns).toMatch(/items:supplier_invoice_items\([^)]*apply_slp/)
|
||||
// And the fetched items flow to createSupplierInvoiceCashEntry intact.
|
||||
const passedItems = mockedCash.mock.calls[0]?.[4] as Array<{ apply_slp?: boolean }>
|
||||
expect(passedItems[0]?.apply_slp).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects payment amount exceeding remaining_amount', async () => {
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
@@ -1558,6 +1654,104 @@ describe('POST /api/v1/companies/:companyId/supplier-invoices/:id/credit', () =>
|
||||
expect(body.data.original_id).toBe(SI_ID)
|
||||
})
|
||||
|
||||
it('reverses the SLP pair: fetches apply_slp, hands the flagged originals to the engine, copies the flag', async () => {
|
||||
// Regression (blocker): SI_FULL_COLUMNS omitted apply_slp, so the credit
|
||||
// path passed items without the flag to createSupplierCreditNoteEntry and
|
||||
// the 7533/2514 pair booked at registration was never reversed: the
|
||||
// pension cost and the 2514 liability stood forever, and the year-end
|
||||
// netting then under-provisioned.
|
||||
const slpSI = {
|
||||
...registeredSI,
|
||||
subtotal: 10000,
|
||||
vat_amount: 0,
|
||||
total: 10000,
|
||||
remaining_amount: 10000,
|
||||
items: [
|
||||
{
|
||||
sort_order: 0,
|
||||
description: 'Tjänstepension',
|
||||
quantity: 1,
|
||||
unit: 'st',
|
||||
unit_price: 10000,
|
||||
line_total: 10000,
|
||||
account_number: '7412',
|
||||
vat_code: null,
|
||||
vat_rate: 0,
|
||||
vat_amount: 0,
|
||||
reverse_charge_rate: null,
|
||||
apply_slp: true,
|
||||
dimensions: {},
|
||||
},
|
||||
],
|
||||
}
|
||||
const creditNoteRow = {
|
||||
...SAMPLE_SI,
|
||||
id: 'dddddddd-dddd-4ddd-8ddd-dddddddddddd',
|
||||
arrival_number: 43,
|
||||
supplier_invoice_number: 'KREDIT-2026-1234',
|
||||
is_credit_note: true,
|
||||
credited_invoice_id: SI_ID,
|
||||
}
|
||||
let siReadCount = 0
|
||||
let insertedItems: Array<{ apply_slp?: boolean }> = []
|
||||
const siSelects: string[] = []
|
||||
mockServiceClient.mockReturnValue({
|
||||
from: (table: string) => {
|
||||
return new Proxy({}, {
|
||||
get(_t, prop) {
|
||||
if (prop === 'then') {
|
||||
return (resolve: (v: unknown) => void) => {
|
||||
if (table === 'company_members') {
|
||||
resolve({ data: { company_id: COMPANY_ID, role: 'owner' }, error: null })
|
||||
} else if (table === 'supplier_invoices') {
|
||||
const n = siReadCount++
|
||||
if (n === 0) resolve({ data: slpSI, error: null })
|
||||
else if (n === 1) resolve({ data: creditNoteRow, error: null })
|
||||
else resolve({ data: { id: SI_ID, status: 'credited' }, error: null })
|
||||
} else if (table === 'company_settings') {
|
||||
resolve({ data: { accounting_method: 'accrual' }, error: null })
|
||||
} else if (table === 'fiscal_periods') {
|
||||
resolve({ data: { id: 'fp-1', is_closed: false, locked_at: null }, error: null })
|
||||
} else {
|
||||
resolve({ data: null, error: null })
|
||||
}
|
||||
}
|
||||
}
|
||||
return (...args: unknown[]) => {
|
||||
if (table === 'supplier_invoices' && prop === 'select' && typeof args[0] === 'string') {
|
||||
siSelects.push(args[0])
|
||||
}
|
||||
if (table === 'supplier_invoice_items' && prop === 'insert') {
|
||||
insertedItems = args[0] as Array<{ apply_slp?: boolean }>
|
||||
}
|
||||
return new Proxy({}, this!)
|
||||
}
|
||||
},
|
||||
})
|
||||
},
|
||||
rpc: vi.fn(() => Promise.resolve({ data: 43, error: null })),
|
||||
})
|
||||
|
||||
const res = await creditSI(
|
||||
makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/supplier-invoices/${SI_ID}/credit`, {
|
||||
method: 'POST',
|
||||
}),
|
||||
detailParams(COMPANY_ID, SI_ID),
|
||||
)
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
// The original fetch must project apply_slp: the Proxy returns the
|
||||
// fixture regardless, so the select string is the regression surface.
|
||||
expect(siSelects[0]).toMatch(/items:supplier_invoice_items\([^)]*apply_slp/)
|
||||
// The ORIGINAL flagged items reach the engine so it can reverse the pair.
|
||||
expect(mockedCredit).toHaveBeenCalledTimes(1)
|
||||
const passedItems = mockedCredit.mock.calls[0]?.[4] as Array<{ apply_slp?: boolean }>
|
||||
expect(passedItems[0]?.apply_slp).toBe(true)
|
||||
// Parity with the web credit route: the flag is copied onto the new
|
||||
// credit-note items for display.
|
||||
expect(insertedItems[0]?.apply_slp).toBe(true)
|
||||
})
|
||||
|
||||
it('returns 409 SI_CREDIT_ALREADY_CREDITED when status=credited', async () => {
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
|
||||
@@ -39,6 +39,7 @@ import {
|
||||
supplierInvoiceSekAmounts,
|
||||
} from '@/lib/currency/supplier-invoice-rate'
|
||||
import { createSupplierInvoiceRegistrationEntry } from '@/lib/bookkeeping/supplier-invoice-entries'
|
||||
import { isSlpPensionAccount } from '@/lib/bookkeeping/slp-lines'
|
||||
import { reverseEntry } from '@/lib/bookkeeping/engine'
|
||||
import { isBookkeepingError } from '@/lib/bookkeeping/errors'
|
||||
import { eventBus } from '@/lib/events'
|
||||
@@ -281,7 +282,7 @@ const SI_RESPONSE_COLUMNS =
|
||||
'id, supplier_id, arrival_number, supplier_invoice_number, invoice_date, due_date, received_date, delivery_date, status, currency, exchange_rate, subtotal, subtotal_sek, vat_amount, vat_amount_sek, total, total_sek, vat_treatment, reverse_charge, payment_reference, paid_amount, remaining_amount, is_credit_note, credited_invoice_id, registration_journal_entry_id, payment_journal_entry_id, notes, default_dimensions, created_at, updated_at'
|
||||
|
||||
const SI_ITEMS_RESPONSE_COLUMNS =
|
||||
'id, sort_order, description, quantity, unit, unit_price, line_total, account_number, vat_code, vat_rate, vat_amount, reverse_charge_rate, dimensions'
|
||||
'id, sort_order, description, quantity, unit, unit_price, line_total, account_number, vat_code, vat_rate, vat_amount, reverse_charge_rate, apply_slp, dimensions'
|
||||
|
||||
const SupplierInvoiceCreated = z.object({
|
||||
id: z.string().uuid(),
|
||||
@@ -321,6 +322,7 @@ registerEndpoint({
|
||||
'Foreign currency: omit exchange_rate and the server fetches Riksbanken\'s rate for invoice_date (ML 8 kap 21-23 §). If no rate can be resolved the create is refused with 400 SI_FX_RATE_MISSING rather than stored unconverted: pass exchange_rate explicitly to proceed. A SEK invoice needs no rate and gets total_sek = total.',
|
||||
'exchange_rate is SEK per 1 unit of the invoice currency and must satisfy 0 < rate < 100000, the same bounds the supplier_invoices CHECK enforces. Out-of-range values return 400 VALIDATION_ERROR; passing an invoice total where a rate belongs is the usual cause.',
|
||||
'Project/cost-center tagging: pass default_dimensions ({"6":"P001"} = project, {"1":"KS01"} = kostnadsställe) for the whole invoice and/or items[].dimensions per line (per-line wins per key). The registration JE lines are tagged accordingly. When the company has the dimension registry enabled, unknown or archived codes are rejected with 400 DIMENSION_VALIDATION_FAILED — list valid codes via GET /dimensions.',
|
||||
'Tjänstepension invoices (Avanza etc.): set items[].apply_slp=true on the 741x premium line and the registration JE also books särskild löneskatt (debit 7533 / credit 2514 at 24.26% of the line amount) beyond the payable: 2440 stays at the invoice total. apply_slp on a non-741x account returns 400 SI_CREATE_SLP_INVALID_ACCOUNT.',
|
||||
],
|
||||
example: {
|
||||
request: {
|
||||
@@ -367,6 +369,7 @@ interface ComputedItem {
|
||||
vat_rate: number
|
||||
vat_amount: number
|
||||
reverse_charge_rate: number | null
|
||||
apply_slp: boolean
|
||||
dimensions: Record<string, string>
|
||||
}
|
||||
|
||||
@@ -412,6 +415,10 @@ function computeItemsAndTotals(input: z.infer<typeof CreateSupplierInvoiceSchema
|
||||
// line vat_rate is 0 (validated below); the engine self-assesses at this
|
||||
// rate, defaulting to 25% huvudregeln when null.
|
||||
reverse_charge_rate: item.reverse_charge_rate ?? null,
|
||||
// Särskild löneskatt (SLP): booking injects the self-balancing
|
||||
// 7533/2514 pair for this line. Validated in the POST handler
|
||||
// (741x accounts only, never together with periodisering).
|
||||
apply_slp: item.apply_slp === true,
|
||||
// Dimensions PR7: per-item bag, merged over the invoice's
|
||||
// default_dimensions on the expense line at booking.
|
||||
dimensions: item.dimensions ?? {},
|
||||
@@ -463,6 +470,26 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string }> }>(
|
||||
}
|
||||
const body = parsed.data
|
||||
|
||||
// Särskild löneskatt (SLP): the 7533/2514 pair is only lawful on 741x
|
||||
// pension premiums and cannot be combined with periodisering on the same
|
||||
// item. Same guards as POST /api/supplier-invoices.
|
||||
if (body.items.some((it) => it.apply_slp && !isSlpPensionAccount(it.account_number))) {
|
||||
return v1ErrorResponseFromCode('SI_CREATE_SLP_INVALID_ACCOUNT', ctx.log, {
|
||||
requestId: ctx.requestId,
|
||||
})
|
||||
}
|
||||
if (
|
||||
body.items.some(
|
||||
(it) =>
|
||||
it.apply_slp &&
|
||||
(it.accrual_period_start || it.accrual_period_end || it.accrual_balance_account),
|
||||
)
|
||||
) {
|
||||
return v1ErrorResponseFromCode('SI_CREATE_SLP_ACCRUAL', ctx.log, {
|
||||
requestId: ctx.requestId,
|
||||
})
|
||||
}
|
||||
|
||||
// Supplier lookup. Scoped to company; deny soft-archived.
|
||||
const { data: supplier, error: supplierErr } = await ctx.supabase
|
||||
.from('suppliers')
|
||||
|
||||
Reference in New Issue
Block a user