feat(supplier-invoices): dokument-forst editor rebuild (prototype shell + 4 flow optimizations) (#1653)

* refactor(supplier-invoices): extract payload builder and form hooks, pin wire contract with parity tests

Zero visual/behavioral change. Pulls the pure payload builder
(buildSupplierInvoicePayload + inferVatTreatment + vatRateFromAi) out of
NewSupplierInvoiceForm into lib/supplier-invoices/form-payload.ts and pins
it with a mode/feature-matrix parity test suite (document_id vs inbox,
privately paid due-date default, reverse charge rate forcing, accrual
attach/drop, dimensions bags, apply_slp validity, FX parsing, empty-string
stripping, ore_rounding passthrough).

Also extracts, verbatim: the VatRateCell/RcRateSelect cells, the reference
data loading hook (suppliers/accounts/settings/periods), the inbox AI
prefill hook (exposing applyInboxItem for reuse), and the submit
orchestration hook (endpoint chooser, three submit paths, duplicate-number
conflict recovery, inbox field sync-back).

Deliberately NOT moved: the effect-ordering couplings
(pendingAccountFillRef/accountFillTick supplier-defaults dance, the
icke-momsregistrerad gross-up re-run keyed on hasPrefilled, the RC
accrual-clearing effect, per-currency FX touched flags) stay in the
component untouched; their ordering semantics are load-bearing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(supplier-invoices): dokument-forst editor rebuild with prototype shell and four flow optimizations

Rebuilds NewSupplierInvoiceForm to the approved Leverantorsflodet prototype:
single 640px column, flat sections (Underlag first, then Leverantor,
Fakturauppgifter, Kontering, Forval, Summering), honest state marks
(RequiredMark, sage checks for binary facts, muted row counts), a single
ochre next-step line (aria-live polite) whose link focuses the missing
field, and a sticky bottom action bar with the live total that binds to the
dialog scroll container in bare mode and the page panel scroll standalone.

Dokument-forst (1): the standalone upload now tries the invoice-inbox
pipeline over HTTP first (POST upload, poll items/:id past 'processing'),
then runs the same applyInboxItem prefill path as an inbox arrival
(settle tint on filled fields, reset(getValues()) dirty baseline, submit
through the convert endpoint so the document links and the item is stamped).
Extension off or extraction failed degrades to the plain /api/documents
attachment; manual entry is never blocked.

Total cross-check (2): optional "Totalt enligt fakturan" field in
Summering, client-only compare against the displayed payable (sage match
line, terracotta diff line), prefilled from extraction totals.

Duplicate advisory (3): new index-only GET /api/supplier-invoices/exists
(withRouteContext + validateQuery, mirrors the partial unique index's
credited/reversed exclusion, full route tests), debounce-called on
fakturanummer change; terracotta field-adjacent line with a link to the
existing invoice. The structured 409 conflict dialog stays the backstop.

Terms-based due date (4): muted caption "Fran leverantorens villkor
(N dagar)" when auto-set, re-derives on invoice-date and supplier change,
stops the moment the user or the AI supplies a date; terms 0 leaves the
field empty with "Star pa fakturan".

OCR hint (5): "Anvands i betalningsfilen." under the payment reference when
the chosen supplier has bankgiro or plusgiro.

Table model: rows start empty; the ghost tfoot entry row (never part of
form state) commits an account via the existing AccountCombobox (opens on
focus, Enter commits) and moves focus to the new row's amount cell; the
supplier default/history fill plants the first row when the table is empty.
Row controls are hover-revealed via HOVER_REVEAL_CLASS at a 24px hit area
with per-row aria-labels carrying the description. The primary button is
never disabled pre-click for writable users (in-flight only); every
submit-time hard block stays in onSubmit; viewers keep the lock treatment.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(supplier-invoices): re-run gross-up per apply, guard deferred prefill, honest un-plant

- Gross-up/zero-rate pass for icke momsregistrerade re-runs per applied
  extraction (applyCount bumps in applyInboxItem) instead of keying on the
  one-shot hasPrefilled flag: a remove + re-upload could previously push AI
  25 % rates to the convert endpoint with the moms columns hidden.
- Deferred extraction on the standalone upload path no longer overwrites what
  the user typed mid-poll: the result auto-applies only while the form is
  pristine (live isDirty ref), otherwise it is buffered behind a quiet
  "Tolkning klar" click-to-apply line. Inbox arrivals are unchanged.
- Supplier-switch un-plant keeps rows the user edited in ANY field, not just
  amount (plant-time snapshot compare in lib/supplier-invoices/planted-rows.ts,
  since dirtyFields is unreliable for appended array rows), clearing only the
  stale account; untouched plant-created rows are still removed and rows that
  existed before the fill are never removed.
- default_expense_account plants now register in plantedRef too, so a supplier
  switch un-plants them under the same rules as history plants.
- applyInboxItem reads suppliers through a ref: the 90 s poll no longer
  resolves matched suppliers against a stale empty list.
- The duplicate advisory bumps its seq in the clear branch, so an in-flight
  exists response cannot resurrect a warning under a cleared field.
- Drop 7 orphaned supplier_invoice_editor keys from both message files.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(supplier-invoices): retry the entry-row focus hand-off on the next frame

A single requestAnimationFrame after appending the row can fire before the
new amount input's ref is mounted, silently dropping the focus hand-off
(observed in headless verification). One retry frame makes the signature
interaction reliable.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(supplier-invoices): deterministic entry-row focus hand-off via effect

The rAF retry still lost to the dialog focus scope re-parking focus when
the entry input remounts mid-commit. An effect keyed on the pending row
index runs after the new row's input has mounted and wins deterministically.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(supplier-invoices): comma-tolerant amount cell and surviving focus routing

The focus trace exposed two real issues behind a probe mystery: the amount
cell was type=number (ArrowDown decrements money by 0.01, Enter fires the
form's implicit submit mid-edit, and Swedish comma decimals are rejected
outright), and the supplier menu's close-autofocus yanked focus back to
the trigger, undoing the routed hand-off to the invoice-number field.

AmountCell mirrors VatRateCell's draft pattern: text input with decimal
inputMode, digits-and-one-separator whitelist, Enter commits via blur.
The supplier DropdownMenuContent prevents default close autofocus.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(supplier-invoices): show comma decimals in the amount cell display

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(supplier-invoices): stop dialog grid item overflowing small viewports

min-w-0 on the form root (DialogContent is display:grid, so the kontering
table's min-w otherwise forces the column past narrow screens) and wrap
the sticky-bar action cluster.

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:
Jakob Wennberg
2026-08-18 09:50:48 +02:00
committed by GitHub
co-authored by Claude Fable 5 Jakob Wennberg
parent dc311726de
commit 93e99012d7
16 changed files with 3569 additions and 1596 deletions
+10
View File
@@ -1164,6 +1164,16 @@ export const CreateSupplierInvoiceSchema = z.object({
items: z.array(CreateSupplierInvoiceItemSchema).min(1, 'At least one item is required'),
})
// Pre-submit duplicate lookup for the supplier-invoice editor. Mirrors the
// partial unique index idx_supplier_invoices_company_supplier_number on
// (company_id, supplier_id, supplier_invoice_number), which excludes
// credited/reversed invoices, so the advisory never warns on the re-issue
// pattern the index was widened to allow.
export const SupplierInvoiceExistsQuerySchema = z.object({
supplier_id: uuid,
number: z.string().min(1, 'number is required'),
})
export const MarkSupplierInvoicePaidSchema = z.object({
amount: z.number().positive().optional(),
payment_date: isoDate.optional(),
@@ -0,0 +1,339 @@
import { describe, it, expect } from 'vitest'
import {
buildSupplierInvoicePayload,
inferVatTreatment,
vatRateFromAi,
rateToPctString,
type SupplierInvoiceFormData,
type SupplierInvoiceLineItem,
type BuildSupplierInvoicePayloadOptions,
} from '../form-payload'
function makeItem(overrides: Partial<SupplierInvoiceLineItem> = {}): SupplierInvoiceLineItem {
return {
description: 'Lokalhyra augusti',
amount: 1000,
account_number: '5010',
vat_rate: 0.25,
reverse_charge_rate: 0.25,
...overrides,
}
}
function makeFormData(overrides: Partial<SupplierInvoiceFormData> = {}): SupplierInvoiceFormData {
return {
supplier_id: 'supplier-1',
supplier_invoice_number: 'F-2026-881',
invoice_date: '2026-08-01',
due_date: '2026-08-31',
delivery_date: '',
currency: 'SEK',
exchange_rate: '',
reverse_charge: false,
payment_reference: '',
notes: '',
paid_with_private_funds: false,
items: [makeItem()],
...overrides,
}
}
function makeOpts(
overrides: Partial<BuildSupplierInvoicePayloadOptions> = {},
): BuildSupplierInvoicePayloadOptions {
return {
inboxItemId: null,
uploadedDocumentId: undefined,
oreRounding: true,
defaultDims: {},
canUseAccrual: true,
...overrides,
}
}
describe('inferVatTreatment', () => {
it('maps a uniform rate to the matching treatment', () => {
expect(inferVatTreatment([makeItem({ vat_rate: 0.25 })], false)).toBe('standard_25')
expect(inferVatTreatment([makeItem({ vat_rate: 0.12 })], false)).toBe('reduced_12')
expect(inferVatTreatment([makeItem({ vat_rate: 0.06 })], false)).toBe('reduced_6')
expect(inferVatTreatment([makeItem({ vat_rate: 0 })], false)).toBe('exempt')
})
it('falls back to standard_25 on mixed rates', () => {
expect(
inferVatTreatment([makeItem({ vat_rate: 0.25 }), makeItem({ vat_rate: 0.12 })], false),
).toBe('standard_25')
})
it('reverse charge wins over everything', () => {
expect(inferVatTreatment([makeItem({ vat_rate: 0.12 })], true)).toBe('reverse_charge')
})
})
describe('vatRateFromAi', () => {
it('maps integer percent to decimals, null to the 25 % default', () => {
expect(vatRateFromAi(25)).toBe(0.25)
expect(vatRateFromAi(12)).toBe(0.12)
expect(vatRateFromAi(6)).toBe(0.06)
expect(vatRateFromAi(0)).toBe(0)
expect(vatRateFromAi(null)).toBe(0.25)
expect(vatRateFromAi(undefined)).toBe(0.25)
// Anything unrecognized collapses to 0, never an illegal rate.
expect(vatRateFromAi(19)).toBe(0)
})
})
describe('rateToPctString', () => {
it('renders decimals as percent strings', () => {
expect(rateToPctString(0.25)).toBe('25')
expect(rateToPctString(0.12)).toBe('12')
expect(rateToPctString(0.06)).toBe('6')
expect(rateToPctString(0)).toBe('0')
expect(rateToPctString(0.1234)).toBe('12.34')
})
})
describe('buildSupplierInvoicePayload', () => {
it('builds the plain create payload (SEK, no extras)', () => {
const payload = buildSupplierInvoicePayload(makeFormData(), makeOpts())
expect(payload).toEqual({
supplier_id: 'supplier-1',
supplier_invoice_number: 'F-2026-881',
invoice_date: '2026-08-01',
due_date: '2026-08-31',
delivery_date: undefined,
currency: 'SEK',
exchange_rate: undefined,
vat_treatment: 'standard_25',
reverse_charge: false,
payment_reference: undefined,
notes: undefined,
paid_with_private_funds: false,
ore_rounding: true,
items: [
{
description: 'Lokalhyra augusti',
amount: 1000,
account_number: '5010',
vat_rate: 0.25,
reverse_charge_rate: undefined,
},
],
})
// Conditional keys must be absent, not undefined-valued.
expect(payload).not.toHaveProperty('document_id')
expect(payload).not.toHaveProperty('default_dimensions')
expect(payload.items[0]).not.toHaveProperty('accrual_period_start')
expect(payload.items[0]).not.toHaveProperty('dimensions')
expect(payload.items[0]).not.toHaveProperty('apply_slp')
})
it('attaches document_id only on non-inbox submits', () => {
const withDoc = buildSupplierInvoicePayload(
makeFormData(),
makeOpts({ uploadedDocumentId: 'doc-1' }),
)
expect(withDoc).toHaveProperty('document_id', 'doc-1')
const inbox = buildSupplierInvoicePayload(
makeFormData(),
makeOpts({ inboxItemId: 'item-1', uploadedDocumentId: 'doc-1' }),
)
expect(inbox).not.toHaveProperty('document_id')
})
it('privately paid: empty due_date defaults to invoice_date', () => {
const payload = buildSupplierInvoicePayload(
makeFormData({ paid_with_private_funds: true, due_date: '' }),
makeOpts({ canUseAccrual: false }),
)
expect(payload.due_date).toBe('2026-08-01')
expect(payload.paid_with_private_funds).toBe(true)
})
it('privately paid: an explicit due_date is kept', () => {
const payload = buildSupplierInvoicePayload(
makeFormData({ paid_with_private_funds: true, due_date: '2026-09-15' }),
makeOpts({ canUseAccrual: false }),
)
expect(payload.due_date).toBe('2026-09-15')
})
it('reverse charge: vat_rate forced to 0, reverse_charge_rate travels with 0.25 default', () => {
const payload = buildSupplierInvoicePayload(
makeFormData({
reverse_charge: true,
items: [
makeItem({ vat_rate: 0.25, reverse_charge_rate: 0.12 }),
makeItem({ vat_rate: 0.25, reverse_charge_rate: undefined }),
],
}),
makeOpts({ canUseAccrual: false }),
)
expect(payload.vat_treatment).toBe('reverse_charge')
expect(payload.items[0].vat_rate).toBe(0)
expect(payload.items[0].reverse_charge_rate).toBe(0.12)
expect(payload.items[1].vat_rate).toBe(0)
expect(payload.items[1].reverse_charge_rate).toBe(0.25)
})
it('non-RC: reverse_charge_rate is stripped to undefined', () => {
const payload = buildSupplierInvoicePayload(makeFormData(), makeOpts())
expect(payload.items[0].reverse_charge_rate).toBeUndefined()
})
it('accrual fields travel only when canUseAccrual and the period is complete', () => {
const item = makeItem({
accrual_period_start: '2026-08-01',
accrual_period_end: '2026-12-31',
accrual_balance_account: '1790',
})
const withAccrual = buildSupplierInvoicePayload(
makeFormData({ items: [item] }),
makeOpts({ canUseAccrual: true }),
)
expect(withAccrual.items[0]).toMatchObject({
accrual_period_start: '2026-08-01',
accrual_period_end: '2026-12-31',
accrual_balance_account: '1790',
})
// Kontantmetod / eget utlägg / RC: canUseAccrual false drops the fields.
const withoutAccrual = buildSupplierInvoicePayload(
makeFormData({ items: [item] }),
makeOpts({ canUseAccrual: false }),
)
expect(withoutAccrual.items[0]).not.toHaveProperty('accrual_period_start')
// Incomplete period (end missing) also drops the fields.
const incomplete = buildSupplierInvoicePayload(
makeFormData({ items: [makeItem({ accrual_period_start: '2026-08-01' })] }),
makeOpts({ canUseAccrual: true }),
)
expect(incomplete.items[0]).not.toHaveProperty('accrual_period_start')
})
it('empty accrual balance account is sent as undefined, not empty string', () => {
const payload = buildSupplierInvoicePayload(
makeFormData({
items: [
makeItem({
accrual_period_start: '2026-08-01',
accrual_period_end: '2026-12-31',
accrual_balance_account: '',
}),
],
}),
makeOpts({ canUseAccrual: true }),
)
expect(payload.items[0]).toHaveProperty('accrual_period_start')
expect(payload.items[0].accrual_balance_account).toBeUndefined()
})
it('default_dimensions only when non-empty; item bags only when carrying values', () => {
const bare = buildSupplierInvoicePayload(
makeFormData({ items: [makeItem({ dimensions: {} })] }),
makeOpts(),
)
expect(bare).not.toHaveProperty('default_dimensions')
// Open-but-empty per-row panel means inherit: the bag is dropped.
expect(bare.items[0]).not.toHaveProperty('dimensions')
const dims = buildSupplierInvoicePayload(
makeFormData({ items: [makeItem({ dimensions: { '1': 'KS01' } })] }),
makeOpts({ defaultDims: { '6': 'P001' } }),
)
expect(dims).toHaveProperty('default_dimensions', { '6': 'P001' })
expect(dims.items[0]).toHaveProperty('dimensions', { '1': 'KS01' })
})
it('apply_slp travels only on 741x rows without an accrual period', () => {
const valid = buildSupplierInvoicePayload(
makeFormData({ items: [makeItem({ account_number: '7412', apply_slp: true })] }),
makeOpts(),
)
expect(valid.items[0]).toHaveProperty('apply_slp', true)
// Stale flag on a non-pension account is stripped.
const wrongAccount = buildSupplierInvoicePayload(
makeFormData({ items: [makeItem({ account_number: '5010', apply_slp: true })] }),
makeOpts(),
)
expect(wrongAccount.items[0]).not.toHaveProperty('apply_slp')
// SLP + periodisering on the same row: accrual wins, flag stripped.
const withAccrual = buildSupplierInvoicePayload(
makeFormData({
items: [
makeItem({
account_number: '7412',
apply_slp: true,
accrual_period_start: '2026-08-01',
accrual_period_end: '2026-12-31',
accrual_balance_account: '1790',
}),
],
}),
makeOpts({ canUseAccrual: true }),
)
expect(withAccrual.items[0]).not.toHaveProperty('apply_slp')
// But when the flow cannot accrue, the period is dropped and SLP survives.
const accrualBlocked = buildSupplierInvoicePayload(
makeFormData({
items: [
makeItem({
account_number: '7412',
apply_slp: true,
accrual_period_start: '2026-08-01',
accrual_period_end: '2026-12-31',
accrual_balance_account: '1790',
}),
],
}),
makeOpts({ canUseAccrual: false }),
)
expect(accrualBlocked.items[0]).not.toHaveProperty('accrual_period_start')
expect(accrualBlocked.items[0]).toHaveProperty('apply_slp', true)
})
it('FX: exchange_rate string parses to a number, empty stays undefined', () => {
const fx = buildSupplierInvoicePayload(
makeFormData({ currency: 'EUR', exchange_rate: '11.2345' }),
makeOpts(),
)
expect(fx.currency).toBe('EUR')
expect(fx.exchange_rate).toBe(11.2345)
const noFx = buildSupplierInvoicePayload(makeFormData(), makeOpts())
expect(noFx.exchange_rate).toBeUndefined()
})
it('empty-string optionals collapse to undefined', () => {
const payload = buildSupplierInvoicePayload(
makeFormData({ delivery_date: '', payment_reference: '', notes: '' }),
makeOpts(),
)
expect(payload.delivery_date).toBeUndefined()
expect(payload.payment_reference).toBeUndefined()
expect(payload.notes).toBeUndefined()
const filled = buildSupplierInvoicePayload(
makeFormData({
delivery_date: '2026-08-05',
payment_reference: '78912345678',
notes: 'intern anteckning',
}),
makeOpts(),
)
expect(filled.delivery_date).toBe('2026-08-05')
expect(filled.payment_reference).toBe('78912345678')
expect(filled.notes).toBe('intern anteckning')
})
it('ore_rounding passes through both ways', () => {
expect(buildSupplierInvoicePayload(makeFormData(), makeOpts({ oreRounding: true })).ore_rounding).toBe(true)
expect(buildSupplierInvoicePayload(makeFormData(), makeOpts({ oreRounding: false })).ore_rounding).toBe(false)
})
})
@@ -0,0 +1,86 @@
import { describe, it, expect } from 'vitest'
import { plantedRowTouched, snapshotPlantedRow } from '@/lib/supplier-invoices/planted-rows'
import type { SupplierInvoiceLineItem } from '@/lib/supplier-invoices/form-payload'
function plantedRow(overrides: Partial<SupplierInvoiceLineItem> = {}): SupplierInvoiceLineItem {
return {
description: 'Programvaror',
amount: 0,
account_number: '5420',
vat_rate: 0.25,
reverse_charge_rate: 0.25,
...overrides,
}
}
describe('plantedRowTouched', () => {
it('is false for a row identical to its snapshot', () => {
const snapshot = snapshotPlantedRow(plantedRow())
expect(plantedRowTouched(plantedRow(), snapshot)).toBe(false)
})
it('is true when the user typed an amount', () => {
const snapshot = snapshotPlantedRow(plantedRow())
expect(plantedRowTouched(plantedRow({ amount: 1250 }), snapshot)).toBe(true)
})
// The review finding: description/moms/dimensions edits on a 0-amount
// planted row were treated as untouched and the row was deleted on a
// supplier switch.
it('is true when the user edited the description with amount still 0', () => {
const snapshot = snapshotPlantedRow(plantedRow())
expect(plantedRowTouched(plantedRow({ description: 'Licens Q3' }), snapshot)).toBe(true)
})
it('is true when the user changed the moms rate with amount still 0', () => {
const snapshot = snapshotPlantedRow(plantedRow())
expect(plantedRowTouched(plantedRow({ vat_rate: 0.12 }), snapshot)).toBe(true)
})
it('is true when the user set dimensions with amount still 0', () => {
const snapshot = snapshotPlantedRow(plantedRow())
expect(plantedRowTouched(plantedRow({ dimensions: { '1': 'STO' } }), snapshot)).toBe(true)
})
it('is true when the user opened periodisering with amount still 0', () => {
const snapshot = snapshotPlantedRow(plantedRow())
expect(
plantedRowTouched(
plantedRow({
accrual_period_start: '2026-08-01',
accrual_period_end: '2026-10-31',
accrual_balance_account: '1790',
}),
snapshot,
),
).toBe(true)
})
it('is true when the user opted into SLP with amount still 0', () => {
const snapshot = snapshotPlantedRow(plantedRow({ account_number: '7410' }))
expect(
plantedRowTouched(plantedRow({ account_number: '7410', apply_slp: true }), snapshot),
).toBe(true)
})
it('treats an open-but-empty dimensions bag as untouched', () => {
const snapshot = snapshotPlantedRow(plantedRow())
expect(plantedRowTouched(plantedRow({ dimensions: {} }), snapshot)).toBe(false)
})
it('ignores account_number: the caller keys on it separately', () => {
const snapshot = snapshotPlantedRow(plantedRow())
expect(plantedRowTouched(plantedRow({ account_number: '4010' }), snapshot)).toBe(false)
})
})
describe('snapshotPlantedRow', () => {
it('does not alias the live row or its dimensions bag', () => {
const row = plantedRow({ dimensions: { '1': 'STO' } })
const snapshot = snapshotPlantedRow(row)
row.description = 'edited'
row.dimensions!['1'] = 'GBG'
expect(snapshot.description).toBe('Programvaror')
expect(snapshot.dimensions).toEqual({ '1': 'STO' })
})
})
+160
View File
@@ -0,0 +1,160 @@
/**
* Pure payload builders for the supplier-invoice editor
* (components/supplier-invoices/NewSupplierInvoiceForm.tsx).
*
* Extracted verbatim from the form component so the wire contract against
* POST /api/supplier-invoices and the invoice-inbox convert endpoint (both
* validating CreateSupplierInvoiceSchema) is pinned by unit tests. Behavior
* changes here are wire-format changes: keep byte-compatible with the
* schema in lib/api/schemas.ts.
*/
import { isSlpPensionAccount } from '@/lib/bookkeeping/slp-lines'
import type { VatTreatment } from '@/types'
export interface SupplierInvoiceLineItem {
description: string
amount: number
account_number: string
vat_rate: number
// Self-assessed VAT rate for omvänd skattskyldighet (0.25/0.12/0.06). Only
// meaningful when reverse_charge is on; the line's vat_rate is then 0.
reverse_charge_rate?: number
// Periodisering (förutbetald kostnad): both dates + 17xx interim account.
// Present only while the row's periodisering panel is active.
accrual_period_start?: string
accrual_period_end?: string
accrual_balance_account?: string
// Per-item dimensions bag ({sie_dim_no: code}, dimensions PR7). Defined
// (possibly empty) while the row's dimensions panel is open; the server
// merges it over the invoice's default_dimensions at booking time.
dimensions?: Record<string, string>
// Särskild löneskatt på pensionskostnader: booking injects a self-balancing
// 7533 D / 2514 K pair at 24.26 % of the line amount. Only offered on 741x
// pension-premium accounts; never changes the invoice total.
apply_slp?: boolean
}
export interface SupplierInvoiceFormData {
supplier_id: string
supplier_invoice_number: string
invoice_date: string
due_date: string
delivery_date: string
currency: string
exchange_rate: string
reverse_charge: boolean
payment_reference: string
notes: string
paid_with_private_funds: boolean
items: SupplierInvoiceLineItem[]
}
export function inferVatTreatment(
items: SupplierInvoiceLineItem[],
reverseCharge: boolean,
): VatTreatment {
if (reverseCharge) return 'reverse_charge'
const rates = new Set(items.map((i) => i.vat_rate))
if (rates.size === 1) {
const rate = rates.values().next().value!
if (rate === 0.25) return 'standard_25'
if (rate === 0.12) return 'reduced_12'
if (rate === 0.06) return 'reduced_6'
if (rate === 0) return 'exempt'
}
return 'standard_25'
}
// AI returns VAT as integer percent (25, 12, 6, 0). The form stores decimals.
export function vatRateFromAi(rate: number | null | undefined): number {
if (rate == null) return 0.25
if (rate === 25) return 0.25
if (rate === 12) return 0.12
if (rate === 6) return 0.06
return 0
}
export function rateToPctString(rate: number): string {
const pct = Math.round(rate * 10000) / 100
return Number.isFinite(pct) ? String(pct) : ''
}
export interface BuildSupplierInvoicePayloadOptions {
/** Inbox item being converted; when set the convert endpoint links the document itself. */
inboxItemId: string | null
/** Uploaded document to attach on the plain create path (non-inbox only). */
uploadedDocumentId: string | undefined
/** Display-only öresavrundning override sent on every payload. */
oreRounding: boolean
/** Invoice-level default dimensions bag; only sent when non-empty. */
defaultDims: Record<string, string>
/** Whether the flow supports periodisering (accrual method, not privately paid, not RC). */
canUseAccrual: boolean
}
export function buildSupplierInvoicePayload(
data: SupplierInvoiceFormData,
opts: BuildSupplierInvoicePayloadOptions,
) {
const { inboxItemId, uploadedDocumentId, oreRounding, defaultDims, canUseAccrual } = opts
const vatTreatment = inferVatTreatment(data.items, data.reverse_charge)
// When paid privately, due_date is irrelevant: but the API still requires
// a YYYY-MM-DD value. Default to invoice_date so the field passes validation.
const dueDate = data.paid_with_private_funds && !data.due_date
? data.invoice_date
: data.due_date
return {
supplier_id: data.supplier_id,
...(!inboxItemId && uploadedDocumentId ? { document_id: uploadedDocumentId } : {}),
supplier_invoice_number: data.supplier_invoice_number,
invoice_date: data.invoice_date,
due_date: dueDate,
delivery_date: data.delivery_date || undefined,
currency: data.currency,
exchange_rate: data.exchange_rate ? parseFloat(data.exchange_rate) : undefined,
vat_treatment: vatTreatment,
reverse_charge: data.reverse_charge,
payment_reference: data.payment_reference || undefined,
notes: data.notes || undefined,
paid_with_private_funds: data.paid_with_private_funds,
ore_rounding: oreRounding,
// Invoice-level default dimensions (kostnadsställe/projekt): only sent
// when the user actually picked something.
...(Object.keys(defaultDims).length > 0 ? { default_dimensions: defaultDims } : {}),
items: data.items.map((item) => ({
description: item.description,
amount: item.amount,
account_number: item.account_number,
// Reverse charge: the supplier charges no VAT, so the line rate is 0 and
// the self-assessed rate travels on reverse_charge_rate (25% default).
vat_rate: data.reverse_charge ? 0 : item.vat_rate,
reverse_charge_rate: data.reverse_charge ? (item.reverse_charge_rate ?? 0.25) : undefined,
// Periodisering: only sent when the row has a complete period AND the
// flow supports it (kontantmetod/eget utlägg would be rejected by the
// API: an AI prefill must never block those submits).
...(canUseAccrual && item.accrual_period_start && item.accrual_period_end
? {
accrual_period_start: item.accrual_period_start,
accrual_period_end: item.accrual_period_end,
accrual_balance_account: item.accrual_balance_account || undefined,
}
: {}),
// Per-item dimensions override: only when the bag carries values
// (an open-but-empty panel means "inherit the invoice default").
...(item.dimensions && Object.keys(item.dimensions).length > 0
? { dimensions: item.dimensions }
: {}),
// Särskild löneskatt (SLP): only sent when the opt-in is valid for
// the row (741x account, no periodisering): a stale flag must never
// 400 the submit.
...(item.apply_slp &&
isSlpPensionAccount(item.account_number) &&
!(canUseAccrual && item.accrual_period_start && item.accrual_period_end)
? { apply_slp: true }
: {}),
})),
}
}
+55
View File
@@ -0,0 +1,55 @@
/**
* Touched-detection for kontering rows planted by a supplier default or the
* counterparty-history prefill (components/supplier-invoices/
* NewSupplierInvoiceForm.tsx). A supplier SWITCH un-plants those rows; a row
* the user edited since the plant must survive with only the stale account
* cleared, never be removed. react-hook-form's dirtyFields is unreliable here
* (an appended row is born all-dirty against the array defaults), so the form
* snapshots each planted row at plant time and compares against the snapshot.
*/
import type { SupplierInvoiceLineItem } from './form-payload'
function normalizeDims(dims: Record<string, string> | undefined): Record<string, string> {
const out: Record<string, string> = {}
for (const [key, value] of Object.entries(dims ?? {})) {
if (value) out[key] = value
}
return out
}
/**
* True when the row diverged from its plant-time snapshot in any user-editable
* field: description, belopp, moms, omvand-moms rate, periodisering,
* dimensioner or the SLP opt-in. `account_number` is deliberately excluded:
* the caller already keys on it (a changed account means the row is no longer
* the planted row at all).
*/
export function plantedRowTouched(
row: SupplierInvoiceLineItem,
snapshot: SupplierInvoiceLineItem,
): boolean {
if ((row.amount || 0) !== (snapshot.amount || 0)) return true
if ((row.description || '') !== (snapshot.description || '')) return true
if ((row.vat_rate ?? null) !== (snapshot.vat_rate ?? null)) return true
if ((row.reverse_charge_rate ?? null) !== (snapshot.reverse_charge_rate ?? null)) return true
if ((row.accrual_period_start || '') !== (snapshot.accrual_period_start || '')) return true
if ((row.accrual_period_end || '') !== (snapshot.accrual_period_end || '')) return true
if ((row.accrual_balance_account || '') !== (snapshot.accrual_balance_account || '')) return true
if ((row.apply_slp ?? false) !== (snapshot.apply_slp ?? false)) return true
const rowDims = normalizeDims(row.dimensions)
const snapDims = normalizeDims(snapshot.dimensions)
const keys = new Set([...Object.keys(rowDims), ...Object.keys(snapDims)])
for (const key of keys) {
if ((rowDims[key] || '') !== (snapDims[key] || '')) return true
}
return false
}
/** Deep-copies a row so the snapshot cannot alias live form state. */
export function snapshotPlantedRow(row: SupplierInvoiceLineItem): SupplierInvoiceLineItem {
return {
...row,
...(row.dimensions ? { dimensions: { ...row.dimensions } } : {}),
}
}