Bernt Christian Egeland 6 godzin temu
rodzic
commit
d1a2fa4b18

+ 106 - 12
src/__tests__/features/integrations/fiken-server.test.ts

@@ -256,12 +256,20 @@ const PAGE = { page: '0', pageSize: '100' }
 function emptyCompany(overrides: Answer = () => ({ status: 599 })): Answer {
   let nextSale = 9001
   let nextPayment = 7001
+  const sales = new Map<string, Record<string, unknown>>()
   return (call) => {
     const special = overrides(call)
     if (special.status !== 599) return special
+    // A sale reads back the way it was entered, as Fiken answers it.
+    const saleId = call.method === 'GET' ? call.path.match(/\/sales\/(\d+)$/)?.[1] : undefined
+    if (saleId && sales.has(saleId)) return { json: sales.get(saleId) }
     if (call.method === 'GET') return { json: [] }
     if (call.method === 'POST' && call.path === `${BASE}/contacts`) return { created: 501 }
-    if (call.method === 'POST' && call.path === `${BASE}/sales`) return { created: nextSale++ }
+    if (call.method === 'POST' && call.path === `${BASE}/sales`) {
+      const id = nextSale++
+      sales.set(String(id), { saleId: id, ...call.body, salePayments: [] })
+      return { created: id }
+    }
     if (call.method === 'POST' && call.path.endsWith('/attachments')) {
       // An attachment is known by a uuid, so its Location does not end in a number.
       return {
@@ -301,10 +309,12 @@ describe('Fiken: pushing an invoice', () => {
   it('creates the contact, then the sale under its own number, then files the PDF on it', async () => {
     const t = makeCtx({ answer: emptyCompany() })
     const out = await push(t)
-    expect(out?.summary).toBe('invoice INV-1001 created')
+    expect(out?.summary).toBe(
+      'invoice INV-1001 created, 300.00 net and 75.00 VAT confirmed in Fiken'
+    )
 
-    const [findContact, createContact, findSale, createSale, attach] = t.calls
-    expect(t.calls).toHaveLength(5)
+    const [findContact, createContact, findSale, createSale, readBack, attach] = t.calls
+    expect(t.calls).toHaveLength(6)
     expect(findContact.host).toBe('api.fiken.no')
     expect(findContact.path).toBe(`${BASE}/contacts`)
     expect(findContact.query).toEqual({ name: 'Anna Berg', customer: 'true', ...PAGE })
@@ -329,6 +339,10 @@ describe('Fiken: pushing an invoice', () => {
     expect(createSale.query).toEqual({})
     expect(createSale.body).toEqual(saleBody)
 
+    // Fiken answers a create with no body, so the sale is read back to check its amounts.
+    expect(readBack.method).toBe('GET')
+    expect(readBack.path).toBe(`${BASE}/sales/9001`)
+
     expect(attach.method).toBe('POST')
     expect(attach.path).toBe(`${BASE}/sales/9001/attachments`)
     expect(attach.query).toEqual({ attachToSale: 'true' })
@@ -376,10 +390,13 @@ describe('Fiken: pushing an invoice', () => {
     t.reset()
     loadInvoice.mockResolvedValue({ ...invoice, dueDate: new Date('2026-09-30T10:00:00Z') })
     const out = await push(t)
-    expect(out?.summary).toBe('invoice INV-1001 reversed and entered again')
+    expect(out?.summary).toBe(
+      'invoice INV-1001 reversed and entered again, 300.00 net and 75.00 VAT confirmed in Fiken'
+    )
 
-    const [readSale, reverse, findSale, createSale, attach] = t.calls
-    expect(t.calls).toHaveLength(5)
+    const [readSale, reverse, findSale, createSale, readBack, attach] = t.calls
+    expect(t.calls).toHaveLength(6)
+    expect(readBack.path).toBe(`${BASE}/sales/9002`)
     expect(readSale.path).toBe(`${BASE}/sales/9001`)
     expect(reverse.method).toBe('PATCH')
     expect(reverse.path).toBe(`${BASE}/sales/9001/delete`)
@@ -447,7 +464,9 @@ describe('Fiken: pushing an invoice', () => {
       ),
     })
     const out = await push(t)
-    expect(out?.summary).toBe('invoice INV-1001 created')
+    expect(out?.summary).toBe(
+      'invoice INV-1001 created, 300.00 net and 75.00 VAT confirmed in Fiken'
+    )
     expect(t.logs.some((l) => l.level === 'warn' && l.message.includes('added beside it'))).toBe(
       true
     )
@@ -464,7 +483,9 @@ describe('Fiken: pushing an invoice', () => {
       ),
     })
     const out = await push(t)
-    expect(out?.summary).toBe('invoice INV-1001 created')
+    expect(out?.summary).toBe(
+      'invoice INV-1001 created, 300.00 net and 75.00 VAT confirmed in Fiken'
+    )
     expect(t.links.get('ServiceRecord:svc1')?.metadata?.attached).toBe(false)
     expect(t.logs.find((l) => l.level === 'warn')?.message).toContain('Ugyldig fil')
 
@@ -475,6 +496,77 @@ describe('Fiken: pushing an invoice', () => {
     expect(t.links.get('ServiceRecord:svc1')?.metadata?.attached).toBe(true)
   })
 
+  it('reads the new sale back and says so when Fiken booked other amounts than were billed', async () => {
+    const t = makeCtx({
+      answer: emptyCompany((call) =>
+        call.method === 'GET' && call.path === `${BASE}/sales/9001`
+          ? {
+              json: {
+                saleId: 9001,
+                lines: [
+                  { netPrice: 20000, vat: 5000 },
+                  // One øre short on the VAT of the second line.
+                  { netPrice: 10000, vat: 2499 },
+                ],
+              },
+            }
+          : pass
+      ),
+    })
+    const out = await push(t)
+    expect(out?.summary).toBe('invoice INV-1001 created, amounts differ in Fiken')
+    expect(t.logs).toEqual([
+      {
+        level: 'warn',
+        message:
+          'Invoice INV-1001: Fiken booked 300.00 net and 74.99 VAT, the invoice here has 300.00 net and 75.00 VAT',
+      },
+    ])
+  })
+
+  it('confirms against the totals of the sale when Fiken lists no lines', async () => {
+    const t = makeCtx({
+      answer: emptyCompany((call) =>
+        call.method === 'GET' && call.path === `${BASE}/sales/9001`
+          ? { json: { saleId: 9001, netAmount: 30000, vatAmount: 7500 } }
+          : pass
+      ),
+    })
+    expect((await push(t))?.summary).toBe(
+      'invoice INV-1001 created, 300.00 net and 75.00 VAT confirmed in Fiken'
+    )
+    expect(t.logs).toHaveLength(0)
+  })
+
+  it('keeps the sale when it cannot be read back, with a warning', async () => {
+    const t = makeCtx({
+      answer: emptyCompany((call) =>
+        call.method === 'GET' && call.path === `${BASE}/sales/9001`
+          ? { status: 500, json: { message: 'Midlertidig feil' } }
+          : pass
+      ),
+    })
+    const out = await push(t)
+    expect(out?.summary).toBe('invoice INV-1001 created')
+    expect(t.logs[0].message).toContain('could not be read back to check its amounts')
+    expect(t.links.get('ServiceRecord:svc1')?.remoteId).toBe('9001')
+  })
+
+  it('puts the lines it sent in the log when Fiken refuses the sale', async () => {
+    const t = makeCtx({
+      answer: emptyCompany((call) =>
+        call.method === 'POST' && call.path === `${BASE}/sales`
+          ? { status: 400, json: { error_description: 'Lines with 0 amount is not allowed' } }
+          : pass
+      ),
+    })
+    await expect(push(t)).rejects.toThrow('Fiken: Lines with 0 amount is not allowed')
+    expect(t.logs).toEqual([
+      { level: 'warn', message: 'Fiken refused the sale for invoice INV-1001' },
+    ])
+    expect(t.links.has('ServiceRecord:svc1')).toBe(false)
+  })
+
   it('does not render or send a PDF when the workshop switched that off', async () => {
     const t = makeCtx({ settings: { attachPdf: false }, answer: emptyCompany() })
     await push(t)
@@ -542,7 +634,9 @@ describe('Fiken: what stays out', () => {
   it('sends a completed job before it is issued when asked to', async () => {
     const t = makeCtx({ settings: { pushOnComplete: true }, answer: emptyCompany() })
     loadInvoice.mockResolvedValue({ ...invoice, issuedAt: null })
-    expect((await push(t))?.summary).toBe('invoice INV-1001 created')
+    expect((await push(t))?.summary).toBe(
+      'invoice INV-1001 created, 300.00 net and 75.00 VAT confirmed in Fiken'
+    )
   })
 
   it('does not put another currency into books kept in kroner', async () => {
@@ -579,11 +673,10 @@ describe('Fiken: what stays out', () => {
     await push(t)
     const sale = t.calls.find((c) => c.method === 'POST' && c.path === `${BASE}/sales`)
     expect(sale?.body?.lines).toEqual([
-      { description: 'Brake service', netPrice: 20000, vat: 0, account: '3100', vatType: 'EXEMPT' },
+      { description: 'Brake service', netPrice: 20000, account: '3100', vatType: 'EXEMPT' },
       {
         description: 'BP-100 Brake pads',
         netPrice: 10000,
-        vat: 0,
         account: '3100',
         vatType: 'EXEMPT',
       },
@@ -769,6 +862,7 @@ describe('Fiken: moving to another company', () => {
       `POST ${other}/contacts`,
       `GET ${other}/sales`,
       `POST ${other}/sales`,
+      `GET ${other}/sales/9500`,
       `POST ${other}/sales/9500/attachments`,
     ])
     expect(t.calls[3].body?.customerId).toBe(601)

+ 233 - 7
src/__tests__/features/integrations/fiken.test.ts

@@ -4,8 +4,12 @@ import type {
   AccountingInvoice,
 } from '@/features/integrations/Lib/accounting-sync'
 import { manifest } from '@/integrations/fiken/manifest'
+import { formatCurrency } from '@/lib/format'
+import { calculateTotals } from '@/lib/tax'
 import {
+  type SaleOptions,
   allocate,
+  bookedTotals,
   buildContact,
   buildPayment,
   buildSale,
@@ -16,6 +20,7 @@ import {
   idFromLocation,
   localPaymentMethod,
   mergeContact,
+  money,
   organizationNumber,
   toCents,
   vatTypeForRate,
@@ -77,7 +82,7 @@ const invoice: AccountingInvoice = {
   payments: [],
 }
 
-const options = {
+const options: SaleOptions = {
   customerId: 501,
   laborAccount: '3000',
   partsAccount: '3010',
@@ -87,15 +92,25 @@ const options = {
   timezone: 'Europe/Oslo',
 }
 
-type Line = { description: string; netPrice: number; vat: number; account: string; vatType: string }
+type Line = {
+  description: string
+  netPrice: number
+  vat?: number
+  account: string
+  vatType: string
+}
 const linesOf = (body: Record<string, unknown>) => body.lines as Line[]
-const sum = (rows: Line[], key: 'netPrice' | 'vat') => rows.reduce((a, r) => a + r[key], 0)
+const sum = (rows: Line[], key: 'netPrice' | 'vat') => rows.reduce((a, r) => a + (r[key] ?? 0), 0)
 
 describe('Fiken mapping: money', () => {
-  it('counts in whole øre', () => {
+  it('counts in whole øre, rounded the way the invoice prints the amount', () => {
     expect(toCents(1875)).toBe(187500)
     expect(toCents(0.29)).toBe(29)
-    expect(toCents(1.005)).toBe(101)
+    expect(toCents(187.375)).toBe(18738)
+    // 0.18 plus 25% is stored as 0.22499999999999998 and printed as 0.22.
+    expect(toCents(0.22499999999999998)).toBe(22)
+    // 25% of 1.90 is stored as 0.475, a hair under it in binary, and printed as 0.48.
+    expect(toCents(0.475)).toBe(48)
     expect(fromCents(187550)).toBe(1875.5)
   })
 
@@ -212,14 +227,12 @@ describe('Fiken mapping: a sale', () => {
       {
         description: 'Brake service',
         netPrice: 100000,
-        vat: 0,
         account: '3100',
         vatType: 'EXEMPT',
       },
       {
         description: 'BP-100 Brake pads',
         netPrice: 50000,
-        vat: 0,
         account: '3100',
         vatType: 'EXEMPT',
       },
@@ -268,6 +281,199 @@ describe('Fiken mapping: a sale', () => {
   })
 })
 
+/**
+ * The invoices of the acceptance run, with their totals worked out by the
+ * app's own calculation and not typed in, so the sale is checked against
+ * what Torqvoice really stores for each of them.
+ */
+describe('Fiken mapping: invoices as Torqvoice totals them', () => {
+  /** The amount in øre as the PDF's own formatter shows it: "kr 187,38" is 18738. */
+  const onPaper = (amount: number) => Number(formatCurrency(amount, 'NOK').replace(/\D/g, ''))
+
+  function totalled(input: {
+    lines: [number, number][]
+    discountPercent?: number
+    taxRate?: number
+    taxInclusive?: boolean
+  }): AccountingInvoice {
+    const [labor, ...parts] = input.lines
+    const lines = [
+      { ...invoice.lines[0], quantity: labor[0], unitPrice: labor[1], total: labor[0] * labor[1] },
+      ...parts.map(([quantity, unitPrice]) => ({
+        ...invoice.lines[1],
+        quantity,
+        unitPrice,
+        total: quantity * unitPrice,
+      })),
+    ]
+    const subtotal = lines.reduce((a, l) => a + l.total, 0)
+    const discountAmount = subtotal * ((input.discountPercent ?? 0) / 100)
+    const taxRate = input.taxRate ?? 25
+    const taxInclusive = input.taxInclusive ?? false
+    const totals = calculateTotals({ subtotal, discountAmount, taxRate, taxInclusive })
+    return {
+      ...invoice,
+      lines,
+      subtotal,
+      discountType: input.discountPercent ? 'percentage' : null,
+      discountValue: input.discountPercent ?? 0,
+      discountAmount,
+      taxRate,
+      taxInclusive,
+      taxAmount: totals.taxAmount,
+      totalAmount: totals.totalAmount,
+    }
+  }
+
+  const booked = (inv: AccountingInvoice, o = options) =>
+    linesOf(buildSale(inv, o)).map((l) => [l.netPrice, l.vat, l.account, l.vatType])
+
+  it('A: labour and a part with 25% on top', () => {
+    expect(
+      booked(
+        totalled({
+          lines: [
+            [2, 500],
+            [1, 500],
+          ],
+        })
+      )
+    ).toEqual([
+      [100000, 25000, '3000', 'HIGH'],
+      [50000, 12500, '3010', 'HIGH'],
+    ])
+  })
+
+  it('B: the same with a 10% discount', () => {
+    expect(
+      booked(
+        totalled({
+          lines: [
+            [2, 500],
+            [1, 500],
+          ],
+          discountPercent: 10,
+        })
+      )
+    ).toEqual([
+      [90000, 22500, '3000', 'HIGH'],
+      [45000, 11250, '3010', 'HIGH'],
+    ])
+  })
+
+  it('C: three lines of 111.11, where the VAT does not divide evenly', () => {
+    const inv = totalled({
+      lines: [
+        [1, 111.11],
+        [1, 111.11],
+        [1, 111.11],
+      ],
+    })
+    expect(booked(inv)).toEqual([
+      [11111, 2778, '3000', 'HIGH'],
+      [11111, 2778, '3010', 'HIGH'],
+      [11111, 2777, '3010', 'HIGH'],
+    ])
+    // 333.33 net, 83.33 VAT and 416.66 to pay, as the invoice prints them.
+    expect(onPaper(inv.taxAmount)).toBe(8333)
+    expect(onPaper(inv.totalAmount)).toBe(41666)
+  })
+
+  it('D: a tax-exempt customer, booked without VAT under the chosen type and account', () => {
+    const inv = totalled({
+      lines: [
+        [2, 500],
+        [1, 500],
+      ],
+      taxRate: 0,
+    })
+    // No vat key at all: Fiken answered "Lines with 0 amount is not allowed" to vat: 0.
+    expect(
+      linesOf(buildSale(inv, { ...options, zeroVatType: 'EXEMPT', zeroAccount: '3100' }))
+    ).toEqual([
+      { description: 'Brake service', netPrice: 100000, account: '3100', vatType: 'EXEMPT' },
+      { description: 'BP-100 Brake pads', netPrice: 50000, account: '3100', vatType: 'EXEMPT' },
+    ])
+  })
+
+  it('E: prices typed in with the VAT included', () => {
+    expect(
+      booked(
+        totalled({
+          lines: [
+            [2, 625],
+            [1, 625],
+          ],
+          taxInclusive: true,
+        })
+      )
+    ).toEqual([
+      [100000, 25000, '3000', 'HIGH'],
+      [50000, 12500, '3010', 'HIGH'],
+    ])
+  })
+
+  it('a part priced in øre: the sale carries the total and the VAT the invoice prints', () => {
+    // 149.90 plus 25% is 187.375 with 37.475 VAT, which no sheet can print.
+    const inv = totalled({ lines: [[1, 149.9]] })
+    const [line] = linesOf(buildSale(inv, options))
+    expect(line.netPrice + (line.vat ?? 0)).toBe(onPaper(inv.totalAmount))
+    expect(line.vat).toBe(onPaper(inv.taxAmount))
+    expect(line.netPrice + (line.vat ?? 0)).toBe(18738)
+  })
+
+  it('books the printed total and the printed VAT for every amount, rate, mode and discount', () => {
+    const wrong: string[] = []
+    let checked = 0
+    for (const taxRate of [25, 15, 12]) {
+      for (const taxInclusive of [false, true]) {
+        for (const discountPercent of [0, 10, 7.5]) {
+          for (let cents = 1; cents <= 300000; cents += 401) {
+            const subtotal = cents / 100
+            const discountAmount = subtotal * (discountPercent / 100)
+            const totals = calculateTotals({ subtotal, discountAmount, taxRate, taxInclusive })
+            if (onPaper(totals.totalAmount) <= 0 || onPaper(totals.taxAmount) <= 0) continue
+            const inv: AccountingInvoice = {
+              ...invoice,
+              subtotal,
+              discountAmount,
+              taxRate,
+              taxInclusive,
+              taxAmount: totals.taxAmount,
+              totalAmount: totals.totalAmount,
+              lines: [
+                {
+                  ...invoice.lines[0],
+                  quantity: 1,
+                  unitPrice: subtotal * 0.6,
+                  total: subtotal * 0.6,
+                },
+                {
+                  ...invoice.lines[1],
+                  quantity: 1,
+                  unitPrice: subtotal * 0.4,
+                  total: subtotal * 0.4,
+                },
+              ],
+            }
+            const lines = linesOf(buildSale(inv, options))
+            const vat = sum(lines, 'vat')
+            const gross = sum(lines, 'netPrice') + vat
+            checked++
+            if (vat !== onPaper(inv.taxAmount) || gross !== onPaper(inv.totalAmount)) {
+              wrong.push(
+                `${subtotal} at ${taxRate}%, inclusive ${taxInclusive}, -${discountPercent}%`
+              )
+            }
+          }
+        }
+      }
+    }
+    expect(checked).toBeGreaterThan(12_000)
+    expect(wrong).toEqual([])
+  })
+})
+
 describe('Fiken mapping: contacts and payments', () => {
   it('reads a Norwegian organisation number however it was typed', () => {
     expect(organizationNumber('NO 987 654 321 MVA')).toBe('987654321')
@@ -328,6 +534,26 @@ describe('Fiken mapping: contacts and payments', () => {
     ).toEqual({ date: '2026-09-06', account: '1920:10001', amount: 187500 })
   })
 
+  it('adds up what Fiken booked, from the lines or from the totals of the sale', () => {
+    expect(
+      bookedTotals({
+        saleId: 1,
+        netAmount: 1,
+        lines: [
+          { netPrice: 100000, vat: 25000 },
+          { netPrice: 50000, vat: 12500 },
+        ],
+      })
+    ).toEqual({ net: 150000, vat: 37500 })
+    expect(bookedTotals({ saleId: 1, netAmount: 150000, vatAmount: 37500 })).toEqual({
+      net: 150000,
+      vat: 37500,
+    })
+    expect(bookedTotals({ saleId: 1, lines: [] })).toBeNull()
+    expect(money(37500)).toBe('375.00')
+    expect(money(5)).toBe('0.05')
+  })
+
   it('reads cash from the account a Fiken payment landed on', () => {
     expect(localPaymentMethod('1900')).toBe('cash')
     expect(localPaymentMethod('1920:10001')).toBe('transfer')

+ 58 - 0
src/__tests__/features/integrations/setting-changes.test.ts

@@ -0,0 +1,58 @@
+import { describe, expect, it } from 'vitest'
+import { effectiveSettings } from '@/features/integrations/Lib/connections'
+import { describeSettingChanges } from '@/features/integrations/Lib/setting-changes'
+import { manifest as fiken } from '@/integrations/fiken/manifest'
+import { vi } from 'vitest'
+
+vi.mock('@/lib/db', () => ({ db: {} }))
+
+/**
+ * The settings form sends every field on every save. The log line used to
+ * list them all, which told the reader nothing; it now names what changed.
+ */
+describe('integration setting changes', () => {
+  const saved = { companySlug: 'torqvoice-test', pushInvoices: false, zeroVatType: 'NONE' }
+  const before = effectiveSettings('fiken', saved)
+
+  it('names each changed setting by its label, with the old and the new value', () => {
+    const after = {
+      ...before,
+      pushInvoices: true,
+      laborAccount: '3020',
+      zeroVatType: 'EXEMPT',
+      zeroAccount: '3100',
+      startDate: '2026-10-01',
+      manualPaidAsPayment: true,
+    }
+    expect(describeSettingChanges(fiken, before, after)).toEqual([
+      'Send issued invoices to Fiken: off → on',
+      'Only invoices dated on or after: empty → 2026-10-01',
+      'Income account for labour: empty → 3020',
+      'VAT type for invoices without VAT: None (no VAT handling) → Exempt (fritatt)',
+      'Income account for invoices without VAT: empty → 3100',
+      'Record a payment when an invoice is marked paid by hand: off → on',
+    ])
+  })
+
+  it('says nothing when a save changed nothing, defaults included', () => {
+    // The form sends the defaults back for settings that were never saved.
+    const resent = {
+      ...before,
+      attachPdf: true,
+      pushPayments: true,
+      startDate: '',
+      laborAccount: '',
+    }
+    expect(describeSettingChanges(fiken, before, resent)).toEqual([])
+  })
+
+  it('reports a cleared value as empty', () => {
+    expect(
+      describeSettingChanges(
+        fiken,
+        { ...before, paymentAccount: '1920:10001' },
+        { paymentAccount: '' }
+      )
+    ).toEqual(['Deposit payments to: 1920:10001 → empty'])
+  })
+})

+ 2 - 1
src/__tests__/features/workorders/create-workorder.test.ts

@@ -976,7 +976,8 @@ describe('createDraftServiceRecord — shop fee', () => {
       { description: 'Shop supplies', hours: 1, rate: 12.5, total: 12.5, pricingType: 'shopFee' },
     ])
     expect(data.subtotal).toBe(12.5)
-    expect(data.totalAmount).toBeCloseTo(15.625, 3)
+    // 25% of 12.50 is 3.125, which is 3.13 on the sheet, so the total is 15.63.
+    expect(data.totalAmount).toBe(15.63)
   })
 
   it('adds nothing when the fee is for quotes only', async () => {

+ 98 - 2
src/__tests__/lib/tax.test.ts

@@ -1,5 +1,7 @@
 import { describe, it, expect } from 'vitest'
-import { calculateTotals, netLineTotal } from '@/lib/tax'
+import { formatCurrency } from '@/lib/format'
+import { roundAsPrinted, roundMoney } from '@/lib/money'
+import { calculateTotals, discountAmountFor, netLineTotal } from '@/lib/tax'
 
 describe('calculateTotals', () => {
   describe('exclusive mode', () => {
@@ -109,7 +111,7 @@ describe('calculateTotals', () => {
       })
       // base (gross after discount) = 90, net = 81.82, tax = 8.18, total = 90
       expect(r.totalAmount).toBeCloseTo(90)
-      expect(r.taxAmount).toBeCloseTo(8.1818, 3)
+      expect(r.taxAmount).toBe(8.18)
     })
 
     it('net base equals totalAmount - taxAmount in both modes', () => {
@@ -459,3 +461,97 @@ describe('universal invoice display formulas', () => {
     expect(exclusive.displayTotal).toBeCloseTo(inclusive.displayTotal)
   })
 })
+
+/**
+ * A sheet has to add up. Each figure used to be stored unrounded and rounded
+ * on its own when printed, and 0.94 plus 25% came out as 0.94 + 0.24 = 1.17.
+ * The tax is rounded once and the total built from the rounded figures.
+ */
+describe('totals that add up to the cent', () => {
+  /** The amount in cents as the documents print it. */
+  const onPaper = (amount: number) => Math.round(roundAsPrinted(amount) * 100)
+
+  it('reads a printed amount the way the PDF formatter writes it', () => {
+    for (const value of [0.22499999999999998, 0.475, 37.475, 187.375, 1124.875, 83.3325, 1500]) {
+      const digits = Number(formatCurrency(value, 'NOK').replace(/\D/g, ''))
+      expect(onPaper(value)).toBe(digits)
+    }
+  })
+
+  it('rounds the tax once and builds the total from it', () => {
+    expect(
+      calculateTotals({ subtotal: 0.94, discountAmount: 0, taxRate: 25, taxInclusive: false })
+    ).toEqual({
+      taxAmount: 0.24,
+      totalAmount: 1.18,
+      components: null,
+    })
+    // A part at 149.90: 37.475 of VAT is 37.48, and the total follows.
+    expect(
+      calculateTotals({ subtotal: 149.9, discountAmount: 0, taxRate: 25, taxInclusive: false })
+    ).toEqual({
+      taxAmount: 37.48,
+      totalAmount: 187.38,
+      components: null,
+    })
+  })
+
+  it('keeps the typed total when prices include tax, and rounds the tax inside it', () => {
+    expect(
+      calculateTotals({ subtotal: 149.9, discountAmount: 0, taxRate: 25, taxInclusive: true })
+    ).toEqual({
+      taxAmount: 29.98,
+      totalAmount: 149.9,
+      components: null,
+    })
+  })
+
+  it('rounds a percentage discount to the cent and caps a fixed one at the subtotal', () => {
+    expect(discountAmountFor(12.25, 'percentage', 10)).toBe(1.23)
+    expect(discountAmountFor(1500, 'percentage', 10)).toBe(150)
+    expect(discountAmountFor(100, 'fixed', 250)).toBe(100)
+    expect(discountAmountFor(100, 'fixed', 25.5)).toBe(25.5)
+    expect(discountAmountFor(100, null, 10)).toBe(0)
+    expect(discountAmountFor(100, 'percentage', null)).toBe(0)
+  })
+
+  it('prints as it is stored: a rounded figure is the figure on the sheet', () => {
+    for (const value of [0.225, 1.175, 37.475, 187.375, 8.1818, 83.3325]) {
+      const stored = roundMoney(value)
+      expect(roundAsPrinted(stored)).toBe(stored)
+    }
+    // An old unrounded total prints a cent under what arithmetic gives.
+    expect(roundAsPrinted(0.22499999999999998)).toBe(0.22)
+    expect(roundMoney(0.22499999999999998)).toBe(0.23)
+  })
+
+  it('adds up for every amount, rate, mode and discount', () => {
+    const wrong: string[] = []
+    let checked = 0
+    for (const taxRate of [25, 15, 12, 8.875]) {
+      for (const taxInclusive of [false, true]) {
+        for (const percent of [0, 10, 7.5]) {
+          for (let cents = 1; cents <= 300000; cents += 97) {
+            const subtotal = cents / 100
+            const discountAmount = discountAmountFor(subtotal, 'percentage', percent)
+            const t = calculateTotals({ subtotal, discountAmount, taxRate, taxInclusive })
+            const base = onPaper(subtotal) - onPaper(discountAmount)
+            const tax = onPaper(t.taxAmount)
+            const total = onPaper(t.totalAmount)
+            checked++
+            // Tax on top: base + tax is the total. Tax inside: the base is the total.
+            const adds = taxInclusive ? total === base && tax <= total : base + tax === total
+            // What is stored is what is printed, to the cent.
+            const exact =
+              Math.round(t.taxAmount * 100) === tax && Math.round(t.totalAmount * 100) === total
+            if (!adds || !exact) {
+              wrong.push(`${subtotal} at ${taxRate}%, inclusive ${taxInclusive}, -${percent}%`)
+            }
+          }
+        }
+      }
+    }
+    expect(checked).toBeGreaterThan(70_000)
+    expect(wrong).toEqual([])
+  })
+})

+ 13 - 3
src/app/(authenticated)/settings/integrations/[connector]/option-picker.tsx

@@ -53,6 +53,8 @@ export function OptionPicker({
           aria-expanded={open}
           disabled={disabled || loading}
           className="h-8 w-full justify-between font-normal"
+          // The button is narrow and cuts a long name short; hovering shows all of it.
+          title={label ?? undefined}
         >
           <span className={cn('truncate', !label && 'text-muted-foreground')}>
             {loading ? t('connection.loading') : (label ?? t('connection.choose'))}
@@ -64,7 +66,12 @@ export function OptionPicker({
           )}
         </Button>
       </PopoverTrigger>
-      <PopoverContent className="w-[var(--radix-popover-trigger-width)] min-w-64 p-0" align="end">
+      {/* As wide as its longest entry, within the screen: an account is told
+          apart by the end of its name, which a list the width of the button cut off. */}
+      <PopoverContent
+        className="w-max min-w-[max(16rem,var(--radix-popover-trigger-width))] max-w-[min(32rem,calc(100vw-2rem))] p-0"
+        align="end"
+      >
         <Command>
           <CommandInput placeholder={t('connection.search')} />
           <CommandList>
@@ -94,9 +101,12 @@ export function OptionPicker({
                   }}
                 >
                   <Check
-                    className={cn('mr-2 h-4 w-4', o.value === value ? 'opacity-100' : 'opacity-0')}
+                    className={cn(
+                      'mr-2 h-4 w-4 shrink-0',
+                      o.value === value ? 'opacity-100' : 'opacity-0'
+                    )}
                   />
-                  <span className="truncate">{o.label}</span>
+                  <span className="min-w-0 break-words">{o.label}</span>
                 </CommandItem>
               ))}
             </CommandGroup>

+ 6 - 7
src/app/(public)/share/invoice/[orgId]/[token]/invoice-view.tsx

@@ -15,7 +15,7 @@ import {
   FileCheck2,
 } from 'lucide-react'
 import { formatCurrency, formatDate as fmtDate, DEFAULT_DATE_FORMAT } from '@/lib/format'
-import { calculateTotals, netLineTotal } from '@/lib/tax'
+import { calculateTotals, netLineTotal, discountAmountFor } from '@/lib/tax'
 import { useLocale, useTranslations } from 'next-intl'
 import {
   isCustomFieldId,
@@ -296,12 +296,11 @@ export function InvoiceView({
   const partsSubtotalStored = record.partItems.reduce((sum, p) => sum + p.total, 0)
   const laborSubtotalStored = record.laborItems.reduce((sum, l) => sum + l.total, 0)
   const computedSubtotalStored = partsSubtotalStored + laborSubtotalStored
-  const computedDiscountStored =
-    record.discountType === 'percentage'
-      ? computedSubtotalStored * (record.discountValue / 100)
-      : record.discountType === 'fixed'
-        ? Math.min(record.discountValue, computedSubtotalStored)
-        : 0
+  const computedDiscountStored = discountAmountFor(
+    computedSubtotalStored,
+    record.discountType,
+    record.discountValue
+  )
   const recordTaxInclusive = record.taxInclusive ?? false
   const { taxAmount: computedTax, totalAmount: computedTotal } = calculateTotals({
     subtotal: computedSubtotalStored,

+ 6 - 7
src/app/api/public/share/invoice/[orgId]/[token]/checkout/route.ts

@@ -9,7 +9,7 @@ import {
 import { writeLog } from '@/features/integrations/Lib/connections'
 import { rateLimit } from '@/lib/rate-limit'
 import { resolvePortalOrg } from '@/lib/portal-slug'
-import { calculateTotals } from '@/lib/tax'
+import { calculateTotals, discountAmountFor } from '@/lib/tax'
 import { getFeatures } from '@/lib/features'
 
 const checkoutSchema = z.object({
@@ -68,12 +68,11 @@ export async function POST(
     const partsSubtotal = record.partItems.reduce((sum, p) => sum + p.total, 0)
     const laborSubtotal = record.laborItems.reduce((sum, l) => sum + l.total, 0)
     const computedSubtotal = partsSubtotal + laborSubtotal
-    const computedDiscount =
-      record.discountType === 'percentage'
-        ? computedSubtotal * (record.discountValue / 100)
-        : record.discountType === 'fixed'
-          ? Math.min(record.discountValue, computedSubtotal)
-          : 0
+    const computedDiscount = discountAmountFor(
+      computedSubtotal,
+      record.discountType,
+      record.discountValue
+    )
     const { totalAmount: computedTotal } = calculateTotals({
       subtotal: computedSubtotal,
       discountAmount: computedDiscount,

+ 2 - 6
src/app/api/v1/tech/jobs/[id]/labor/route.ts

@@ -1,3 +1,4 @@
+import { discountAmountFor } from '@/lib/tax'
 import { z } from 'zod'
 import { db } from '@/lib/db'
 import { documentTotals } from '@/features/settings/Lib/workshopTax'
@@ -90,12 +91,7 @@ export async function POST(request: Request, { params }: { params: Promise<{ id:
         ])
 
         const subtotal = (partsAgg._sum.total || 0) + (laborAgg._sum.total || 0)
-        const discountAmount =
-          job.discountType === 'percentage'
-            ? subtotal * ((job.discountValue ?? 0) / 100)
-            : job.discountType === 'fixed'
-              ? Math.min(job.discountValue ?? 0, subtotal)
-              : 0
+        const discountAmount = discountAmountFor(subtotal, job.discountType, job.discountValue)
         const { taxAmount, totalAmount, taxComponents } = documentTotals({
           subtotal,
           discountAmount,

+ 6 - 1
src/features/integrations/Actions/integrationActions.ts

@@ -27,6 +27,7 @@ import {
 } from '../Lib/connections'
 import { enqueueJob, runJob } from '../Lib/jobs'
 import { oauthSpec, platformClient, redirectUriFor } from '../Lib/oauth'
+import { describeSettingChanges } from '../Lib/setting-changes'
 import type {
   ActivityBatch,
   ActivityItem,
@@ -520,7 +521,11 @@ export async function updateIntegrationSettings(connectorId: string, raw: unknow
         where: { id: row.id },
         data: { settings: settings as object },
       })
-      await writeLog(row.id, 'info', 'Settings updated', { keys: Object.keys(clean) })
+      // One line per setting that really changed; a save that changed nothing logs nothing.
+      const before = effectiveSettings(connectorId, (row.settings as Record<string, unknown>) ?? {})
+      for (const change of describeSettingChanges(manifest, before, clean)) {
+        await writeLog(row.id, 'info', `Setting changed: ${change}`)
+      }
       revalidatePath(`/settings/integrations/${connectorId}`)
       return { settings }
     },

+ 63 - 0
src/features/integrations/Lib/setting-changes.ts

@@ -0,0 +1,63 @@
+/**
+ * What a settings save actually changed, in words.
+ *
+ * The form sends every field on every save, so "which keys were sent" says
+ * nothing: it is the whole form each time. This compares what was in effect
+ * with what is now, and names each setting that moved by its label and its
+ * old and new value, the way the page shows them. The log is written in
+ * English like every other line in it.
+ */
+
+import enIntegrations from '../../../../messages/en/integrations.json'
+import type { ConnectorManifest, SettingField } from './types'
+
+type Labels = Record<string, string>
+
+function labelsOf(connectorId: string): Labels {
+  const connectors = enIntegrations.connectors as Record<string, { settings?: Labels }>
+  return connectors[connectorId]?.settings ?? {}
+}
+
+function isEmpty(value: unknown): boolean {
+  return value === undefined || value === null || value === ''
+}
+
+function shown(field: SettingField, value: unknown, labels: Labels): string {
+  if (field.type === 'boolean') return value === true ? 'on' : 'off'
+  if (isEmpty(value)) return 'empty'
+  if (field.type === 'select') {
+    const option = field.options?.find((o) => o.value === String(value))
+    return option ? (labels[option.label] ?? option.label) : String(value)
+  }
+  return String(value)
+}
+
+function same(field: SettingField, a: unknown, b: unknown): boolean {
+  if (field.type === 'boolean') return (a === true) === (b === true)
+  if (isEmpty(a) && isEmpty(b)) return true
+  return String(a) === String(b)
+}
+
+/**
+ * One line per setting whose value changed, such as
+ * "Send issued invoices to Fiken: off → on". Empty when the save changed
+ * nothing. `before` is what was in effect, defaults included, so a first
+ * save does not report every default as a change.
+ */
+export function describeSettingChanges(
+  manifest: ConnectorManifest,
+  before: Record<string, unknown>,
+  after: Record<string, unknown>
+): string[] {
+  const labels = labelsOf(manifest.id)
+  const lines: string[] = []
+  for (const field of manifest.settings) {
+    if (!(field.key in after)) continue
+    if (same(field, before[field.key], after[field.key])) continue
+    const name = labels[field.label] ?? field.key
+    lines.push(
+      `${name}: ${shown(field, before[field.key], labels)} → ${shown(field, after[field.key], labels)}`
+    )
+  }
+  return lines
+}

+ 3 - 24
src/features/inventory/Lib/partPricing.ts

@@ -1,3 +1,5 @@
+import { roundMoney } from '@/lib/money'
+
 /**
  * The single source of truth for every money figure derived from a stocked
  * part: what the customer is charged, the markup that price implies, and the
@@ -37,30 +39,7 @@ export interface ResolvedPrice {
   markupPercent: number
 }
 
-/**
- * Money is held to the cent, so every derived amount rounds the same way.
- *
- * Binary floats cannot represent most decimal amounts exactly, so a bare
- * `cost * 1.5` yields values like 44.980000000000004. Left unrounded those
- * reach the database and are summed into subtotals, where the error compounds
- * into a visible penny discrepancy on the document.
- */
-export function roundMoney(value: unknown): number {
-  const parsed = Number(value)
-  if (!Number.isFinite(parsed)) return 0
-  // Round on the decimal value, not the binary approximation of it. A plain
-  // Math.round(v * 100) / 100 bills 2.5 x 19.99 as 49.97, because that product
-  // is held as 49.974999999999994 and so falls just short of the halfway point
-  // it should sit exactly on. The same flaw rounds 1.005 down to 1.00.
-  //
-  // Twelve significant digits is well past where the noise lives and well
-  // short of the ~15 a double carries, so this restores the decimal figure
-  // without inventing precision. Rounding is symmetric about zero, so a credit
-  // line rounds by the same magnitude as the charge it reverses.
-  const normalized = Number(parsed.toPrecision(12))
-  const scaled = Number((normalized * 100).toPrecision(12))
-  return (scaled < 0 ? -Math.round(-scaled) : Math.round(scaled)) / 100
-}
+export { roundMoney }
 
 /**
  * Read a value that may still be raw input from a number field.

+ 6 - 7
src/features/invoice-designer/Pdf/buildInvoicePrint.ts

@@ -1,7 +1,7 @@
 import { DEFAULT_DATE_FORMAT, formatCurrency, formatDateForPdf } from '@/lib/format'
 import { documentLaborLines, isShopFeeLine } from '@/features/settings/Lib/shopFee'
 import { formatQuantity } from '@/lib/format-quantity'
-import { calculateTotals, netLineTotal } from '@/lib/tax'
+import { calculateTotals, netLineTotal, discountAmountFor } from '@/lib/tax'
 import { parseTaxComponents } from '@/lib/tax-components'
 import { taxLines } from './taxLines'
 import {
@@ -155,12 +155,11 @@ export function buildInvoicePrintSpec(input: InvoicePrintInput): DocumentSpec {
   const partsSubtotal = data.partItems.reduce((sum, p) => sum + p.total, 0)
   const laborSubtotal = data.laborItems.reduce((sum, l) => sum + l.total, 0)
   const computedSubtotal = partsSubtotal + laborSubtotal
-  const computedDiscount =
-    data.discountType === 'percentage'
-      ? computedSubtotal * ((data.discountValue || 0) / 100)
-      : data.discountType === 'fixed'
-        ? Math.min(data.discountValue || 0, computedSubtotal)
-        : 0
+  const computedDiscount = discountAmountFor(
+    computedSubtotal,
+    data.discountType,
+    data.discountValue
+  )
   const { totalAmount: computedTotal } = calculateTotals({
     subtotal: computedSubtotal,
     discountAmount: computedDiscount,

+ 2 - 7
src/features/invoice-designer/Pdf/buildQuotePrint.ts

@@ -1,7 +1,7 @@
 import { DEFAULT_DATE_FORMAT, formatCurrency, formatDateForPdf } from '@/lib/format'
 import { documentLaborLines, isShopFeeLine } from '@/features/settings/Lib/shopFee'
 import { formatQuantity } from '@/lib/format-quantity'
-import { calculateTotals, netLineTotal } from '@/lib/tax'
+import { calculateTotals, netLineTotal, discountAmountFor } from '@/lib/tax'
 import { parseTaxComponents } from '@/lib/tax-components'
 import { taxLines } from './taxLines'
 import {
@@ -282,12 +282,7 @@ export function buildQuotePrintSpec(input: QuotePrintInput): DocumentSpec {
   const laborTotal = data.laborItems.reduce((sum, l) => (l.excluded ? sum : sum + l.total), 0)
   const partsTotal = data.partItems.reduce((sum, p) => (p.excluded ? sum : sum + p.total), 0)
   const subtotal = laborTotal + partsTotal
-  const discount =
-    data.discountType === 'percentage'
-      ? subtotal * (data.discountValue / 100)
-      : data.discountType === 'fixed'
-        ? Math.min(data.discountValue, subtotal)
-        : 0
+  const discount = discountAmountFor(subtotal, data.discountType, data.discountValue)
   const { taxAmount, totalAmount } = calculateTotals({
     subtotal,
     discountAmount: discount,

+ 6 - 7
src/features/invoice-designer/Pdf/buildWorkOrderPrint.ts

@@ -7,7 +7,7 @@ import {
 } from '@/lib/format'
 import { documentLaborLines, isShopFeeLine } from '@/features/settings/Lib/shopFee'
 import { formatQuantity } from '@/lib/format-quantity'
-import { calculateTotals, netLineTotal } from '@/lib/tax'
+import { calculateTotals, netLineTotal, discountAmountFor } from '@/lib/tax'
 import { parseTaxComponents } from '@/lib/tax-components'
 import { taxLines } from './taxLines'
 import {
@@ -164,12 +164,11 @@ export function buildWorkOrderPrintSpec(input: WorkOrderPrintInput): DocumentSpe
   const partsSubtotal = data.partItems.reduce((sum, p) => sum + p.total, 0)
   const laborSubtotal = data.laborItems.reduce((sum, l) => sum + l.total, 0)
   const computedSubtotal = partsSubtotal + laborSubtotal
-  const computedDiscount =
-    data.discountType === 'percentage'
-      ? computedSubtotal * ((data.discountValue || 0) / 100)
-      : data.discountType === 'fixed'
-        ? Math.min(data.discountValue || 0, computedSubtotal)
-        : 0
+  const computedDiscount = discountAmountFor(
+    computedSubtotal,
+    data.discountType,
+    data.discountValue
+  )
   const { totalAmount: computedTotal } = calculateTotals({
     subtotal: computedSubtotal,
     discountAmount: computedDiscount,

+ 2 - 7
src/features/quotes/Components/useQuoteFormState.ts

@@ -13,7 +13,7 @@ import {
   convertQuoteToServiceRecord,
 } from '@/features/quotes/Actions/quoteActions'
 import { acknowledgeQuoteResponse } from '@/features/quotes/Actions/quoteResponseActions'
-import { calculateTotals } from '@/lib/tax'
+import { calculateTotals, discountAmountFor } from '@/lib/tax'
 import { zonedDateInput } from '@/lib/timezone'
 import { parseTaxComponents } from '@/lib/tax-components'
 import { normalizeWarranty, WARRANTY_NONE, type WarrantyFields } from '@/lib/warranty'
@@ -275,12 +275,7 @@ export function useQuoteFormState({
   }, [laborItems, partsSubtotal, shopFee, locked, markDirty])
 
   const subtotal = partsSubtotal + laborSubtotal
-  const discountAmount =
-    discountType === 'percentage'
-      ? subtotal * (discountValue / 100)
-      : discountType === 'fixed'
-        ? Math.min(discountValue, subtotal)
-        : 0
+  const discountAmount = discountAmountFor(subtotal, discountType, discountValue)
   const {
     taxAmount,
     totalAmount,

+ 2 - 1
src/features/quotes/Lib/createQuoteRecord.ts

@@ -1,3 +1,4 @@
+import { discountAmountFor } from '@/lib/tax'
 import 'server-only'
 
 import { db } from '@/lib/db'
@@ -90,7 +91,7 @@ export async function createQuoteRecord(
     if (feeLine.total > 0) {
       data.subtotal += feeLine.total
       if (data.discountType === 'percentage') {
-        data.discountAmount = data.subtotal * (data.discountValue / 100)
+        data.discountAmount = discountAmountFor(data.subtotal, 'percentage', data.discountValue)
       }
       feeAdded = true
     }

+ 2 - 7
src/features/vehicles/Components/service-page/useServiceFormState.ts

@@ -1,5 +1,5 @@
 import { useState, useCallback, useRef, useEffect } from 'react'
-import { calculateTotals } from '@/lib/tax'
+import { calculateTotals, discountAmountFor } from '@/lib/tax'
 import { normalizeWarranty, type WarrantyFields } from '@/lib/warranty'
 import { useDeferredCommit } from '@/hooks/use-deferred-commit'
 import { lineTotal, repricePartRow } from '@/features/inventory/Lib/partPricing'
@@ -274,12 +274,7 @@ export function useServiceFormState({
   )
   const laborSubtotal = laborItems.reduce((sum, l) => sum + l.total, 0)
   const subtotal = partsSubtotal + laborSubtotal
-  const discountAmount =
-    discountType === 'percentage'
-      ? subtotal * (discountValue / 100)
-      : discountType === 'fixed'
-        ? Math.min(discountValue, subtotal)
-        : 0
+  const discountAmount = discountAmountFor(subtotal, discountType, discountValue)
   const {
     taxAmount,
     totalAmount,

+ 2 - 6
src/features/vehicles/Lib/addPart.ts

@@ -1,3 +1,4 @@
+import { discountAmountFor } from '@/lib/tax'
 import { db } from '@/lib/db'
 import { refreshShopFeeLines } from './shopFeeLines'
 import { documentTotals } from '@/features/settings/Lib/workshopTax'
@@ -95,12 +96,7 @@ export async function addPart(args: {
     ])
 
     const subtotal = (partsAgg._sum.total || 0) + (laborAgg._sum.total || 0)
-    const discountAmount =
-      record.discountType === 'percentage'
-        ? subtotal * ((record.discountValue ?? 0) / 100)
-        : record.discountType === 'fixed'
-          ? Math.min(record.discountValue ?? 0, subtotal)
-          : 0
+    const discountAmount = discountAmountFor(subtotal, record.discountType, record.discountValue)
     const { taxAmount, totalAmount, taxComponents } = documentTotals({
       subtotal,
       discountAmount,

+ 2 - 6
src/features/vehicles/Lib/retotalServiceRecord.ts

@@ -1,3 +1,4 @@
+import { discountAmountFor } from '@/lib/tax'
 import { db, type TxClient } from '@/lib/db'
 import { documentTotals } from '@/features/settings/Lib/workshopTax'
 import { refreshShopFeeLines } from './shopFeeLines'
@@ -39,12 +40,7 @@ export async function retotalServiceRecord(
   ])
 
   const subtotal = (parts._sum.total || 0) + (labor._sum.total || 0)
-  const discountAmount =
-    record.discountType === 'percentage'
-      ? subtotal * ((record.discountValue ?? 0) / 100)
-      : record.discountType === 'fixed'
-        ? Math.min(record.discountValue ?? 0, subtotal)
-        : 0
+  const discountAmount = discountAmountFor(subtotal, record.discountType, record.discountValue)
 
   const { taxAmount, totalAmount, taxComponents } = documentTotals({
     subtotal,

+ 32 - 6
src/integrations/fiken/mapping.ts

@@ -13,6 +13,7 @@ import type {
   AccountingInvoice,
   AccountingPayment,
 } from '@/features/integrations/Lib/accounting-sync'
+import { roundAsPrinted } from '@/lib/money'
 import { zonedDayKey } from '@/lib/timezone'
 
 export { checksumOf } from '@/features/integrations/Lib/checksum'
@@ -107,8 +108,8 @@ export interface FikenOrderLine {
   description: string
   /** Øre, without VAT. */
   netPrice: number
-  /** Øre. */
-  vat: number
+  /** Øre. Left out when there is none: Fiken refuses a line that names an amount of 0. */
+  vat?: number
   account: string
   vatType: string
 }
@@ -124,12 +125,17 @@ export interface FikenSale {
   totalPaid?: number
   outstandingBalance?: number
   salePayments?: FikenPayment[]
+  lines?: Partial<FikenOrderLine>[]
   deleted?: boolean
 }
 
-/** An amount as Fiken counts it: whole øre. */
+/**
+ * An amount as Fiken counts it: whole øre, rounded exactly the way the
+ * invoice prints it, so a sale is never one øre away from the invoice the
+ * customer holds and pays.
+ */
 export function toCents(amount: number): number {
-  return Math.round((amount + Number.EPSILON) * 100)
+  return Math.round(roundAsPrinted(amount) * 100)
 }
 
 export function fromCents(cents: number): number {
@@ -315,7 +321,7 @@ export function buildSale(inv: AccountingInvoice, o: SaleOptions): Record<string
     lines = charged.map((line, i) => ({
       description: lineDescription(line),
       netPrice: nets[i],
-      vat: vats[i],
+      ...(vats[i] !== 0 && { vat: vats[i] }),
       account: accountFor(line.kind),
       vatType,
     }))
@@ -325,7 +331,7 @@ export function buildSale(inv: AccountingInvoice, o: SaleOptions): Record<string
       {
         description: `Faktura ${inv.invoiceNumber ?? ''}`.trim().slice(0, TEXT_MAX),
         netPrice: net,
-        vat,
+        ...(vat !== 0 && { vat }),
         account: accountFor('labor'),
         vatType,
       },
@@ -353,6 +359,26 @@ export function buildPayment(
   }
 }
 
+/**
+ * What Fiken booked for a sale, in øre: the sum of its lines, or its own
+ * totals when it lists none. Null when the answer carries neither.
+ */
+export function bookedTotals(sale: FikenSale): { net: number; vat: number } | null {
+  if (Array.isArray(sale.lines) && sale.lines.length > 0) {
+    return {
+      net: sale.lines.reduce((a, l) => a + (l.netPrice ?? 0), 0),
+      vat: sale.lines.reduce((a, l) => a + (l.vat ?? 0), 0),
+    }
+  }
+  if (typeof sale.netAmount === 'number') return { net: sale.netAmount, vat: sale.vatAmount ?? 0 }
+  return null
+}
+
+/** Øre as an amount with two decimals, for a log line. */
+export function money(cents: number): string {
+  return fromCents(cents).toFixed(2)
+}
+
 /**
  * A Fiken payment account as one of the app's own methods. 1900 is cash in
  * hand in the standard chart; everything else a payment lands on is a bank

+ 61 - 3
src/integrations/fiken/server.ts

@@ -36,6 +36,7 @@ import {
   REVOKE_URL,
   WALK_IN_NAME,
   type ZeroVatType,
+  bookedTotals,
   buildContact,
   buildPayment,
   buildSale,
@@ -48,6 +49,7 @@ import {
   isZeroVatType,
   localPaymentMethod,
   mergeContact,
+  money,
   organizationNumber,
   toCents,
   vatTypeForRate,
@@ -508,10 +510,58 @@ async function createSale(
       `Fiken already has a sale numbered ${inv.invoiceNumber} with other amounts; invoice ${inv.invoiceNumber} was added beside it`
     )
   }
-  const saleId = await create(ctx, companyPath(slug, '/sales'), { body })
+  let saleId: number
+  try {
+    saleId = await create(ctx, companyPath(slug, '/sales'), { body })
+  } catch (err) {
+    // Fiken's reason alone rarely says which figure it objects to; the lines
+    // that were sent, amounts and accounts only, go in the log beside it.
+    if (err instanceof FikenError) {
+      await ctx.log('warn', `Fiken refused the sale for invoice ${inv.invoiceNumber}`, {
+        date: body.date,
+        lines: body.lines,
+      })
+    }
+    throw err
+  }
   return { saleId, adopted: false }
 }
 
+/**
+ * Read a new sale back and hold what Fiken booked against what was billed.
+ * Fiken answers a create with no body, so this is the only way to know that
+ * the net and the VAT in the books are the ones on the invoice. The outcome
+ * goes on the job's line in the log; a difference is a warning of its own.
+ */
+async function confirmSale(
+  ctx: ConnectorContext,
+  slug: string,
+  saleId: number,
+  body: Record<string, unknown>,
+  inv: AccountingInvoice
+): Promise<string | null> {
+  const sent = bookedTotals({ saleId, lines: body.lines as FikenSale['lines'] })
+  let booked: ReturnType<typeof bookedTotals> = null
+  try {
+    booked = bookedTotals(await read<FikenSale>(ctx, companyPath(slug, `/sales/${saleId}`)))
+  } catch (err) {
+    await ctx.log(
+      'warn',
+      `Invoice ${inv.invoiceNumber} is in Fiken but could not be read back to check its amounts: ${err instanceof Error ? err.message : String(err)}`
+    )
+  }
+  if (!sent || !booked) return null
+  if (booked.net === sent.net && booked.vat === sent.vat) {
+    return `${money(sent.net)} net and ${money(sent.vat)} VAT confirmed in Fiken`
+  }
+  await ctx.log(
+    'warn',
+    `Invoice ${inv.invoiceNumber}: Fiken booked ${money(booked.net)} net and ${money(booked.vat)} VAT, the invoice here has ${money(sent.net)} net and ${money(sent.vat)} VAT`,
+    { fiken: booked, torqvoice: sent }
+  )
+  return 'amounts differ in Fiken'
+}
+
 /** The invoice the customer received, filed behind the sale as its voucher. */
 async function attachInvoice(
   ctx: ConnectorContext,
@@ -596,6 +646,7 @@ async function pushInvoice(ctx: ConnectorContext, serviceRecordId: string): Prom
   let link = await linkFor(ctx, slug, INVOICE_ENTITY, serviceRecordId)
 
   let action = 'unchanged'
+  let confirmed: string | null = null
   if (link && link.checksum !== checksum) {
     try {
       const sale = await read<FikenSale>(ctx, companyPath(slug, `/sales/${link.remoteId}`))
@@ -641,6 +692,7 @@ async function pushInvoice(ctx: ConnectorContext, serviceRecordId: string): Prom
       },
     })
     link = await linkFor(ctx, slug, INVOICE_ENTITY, serviceRecordId)
+    if (!made.adopted) confirmed = await confirmSale(ctx, slug, made.saleId, body, inv)
   }
   if (!link) throw new Error('The sale was saved in Fiken but its link could not be read back')
 
@@ -660,8 +712,14 @@ async function pushInvoice(ctx: ConnectorContext, serviceRecordId: string): Prom
       if (await settleByHand(ctx, slug, inv, link.remoteId)) paymentsPushed++
     }
   }
-  const summary = `invoice ${inv.invoiceNumber} ${action}`
-  return { summary: paymentsPushed ? `${summary}, ${paymentsPushed} payments recorded` : summary }
+  const summary = [
+    `invoice ${inv.invoiceNumber} ${action}`,
+    confirmed,
+    paymentsPushed ? `${paymentsPushed} payments recorded` : null,
+  ]
+    .filter(Boolean)
+    .join(', ')
+  return { summary }
 }
 
 /* ---------- payments ---------- */

+ 3 - 1
src/integrations/quickbooks/mapping.ts

@@ -13,6 +13,7 @@ import type {
   AccountingInvoice,
   AccountingPayment,
 } from '@/features/integrations/Lib/accounting-sync'
+import { roundAsPrinted } from '@/lib/money'
 import { zonedDayKey } from '@/lib/timezone'
 
 /** Intuit retires older minor versions; this one carries the current field set. */
@@ -159,8 +160,9 @@ export function paymentUrl(env: Environment, id: string): string {
   return `${appHost(env)}/app/recvpayment?txnId=${encodeURIComponent(id)}`
 }
 
+/** To the cent, the way the invoice prints the amount, so the ledger carries the printed figure. */
 export function round2(n: number): number {
-  return Math.round((n + Number.EPSILON) * 100) / 100
+  return roundAsPrinted(n)
 }
 
 /** A value for the query language, which escapes a quote with a backslash. */

+ 46 - 0
src/lib/money.ts

@@ -0,0 +1,46 @@
+/**
+ * Money is held to the cent, so every derived amount rounds the same way.
+ *
+ * Binary floats cannot represent most decimal amounts exactly, so a bare
+ * `cost * 1.5` yields values like 44.980000000000004. Left unrounded those
+ * reach the database and are summed into subtotals, where the error compounds
+ * into a visible penny discrepancy on the document.
+ */
+export function roundMoney(value: unknown): number {
+  const parsed = Number(value)
+  if (!Number.isFinite(parsed)) return 0
+  // Round on the decimal value, not the binary approximation of it. A plain
+  // Math.round(v * 100) / 100 bills 2.5 x 19.99 as 49.97, because that product
+  // is held as 49.974999999999994 and so falls just short of the halfway point
+  // it should sit exactly on. The same flaw rounds 1.005 down to 1.00.
+  //
+  // Twelve significant digits is well past where the noise lives and well
+  // short of the ~15 a double carries, so this restores the decimal figure
+  // without inventing precision. Rounding is symmetric about zero, so a credit
+  // line rounds by the same magnitude as the charge it reverses.
+  const normalized = Number(parsed.toPrecision(12))
+  const scaled = Number((normalized * 100).toPrecision(12))
+  return (scaled < 0 ? -Math.round(-scaled) : Math.round(scaled)) / 100
+}
+
+/** Two decimals, rounded the way the formatter behind every printed amount rounds. */
+const PRINTED = new Intl.NumberFormat('en-US', {
+  minimumFractionDigits: 2,
+  maximumFractionDigits: 2,
+  useGrouping: false,
+})
+
+/**
+ * A stored amount to two decimals exactly as the documents print it.
+ *
+ * roundMoney above decides what a new figure should be. This answers a
+ * different question: what does the sheet show for a figure that is already
+ * stored? Totals written before they were held to the cent sit a hair off
+ * half a cent (0.18 plus 25% was stored as 0.22499999999999998), and the
+ * formatter prints that as 0.22 where arithmetic says 0.23. Anything that
+ * passes an invoice on, to a ledger or a payment, has to carry the printed
+ * figure, because that is the one the customer holds and pays.
+ */
+export function roundAsPrinted(amount: number): number {
+  return Number(PRINTED.format(amount))
+}

+ 36 - 20
src/lib/tax.ts

@@ -22,6 +22,12 @@
  * (net in exclusive mode, gross in inclusive mode), so existing PDFs and DB
  * rows remain consistent.
  *
+ * Every figure is held to the cent, and the total is built from the rounded
+ * ones, so the sheet always adds up: subtotal less discount plus tax is the
+ * total, to the cent. The tax is rounded once and the other side follows
+ * from it. Left unrounded, each figure was rounded on its own when printed,
+ * and 0.94 plus 25% came out as 0.94 + 0.24 = 1.17.
+ *
  * A document may carry more than one tax, the way a Québec invoice carries
  * GST and QST, or an Indian one CGST and SGST. Those are `components`: the
  * combined `taxRate` and `taxAmount` keep their meaning, and the components
@@ -30,6 +36,8 @@
  * formula above applies unchanged.
  */
 
+import { roundMoney } from './money'
+
 /** One tax on a document, as the workshop defines it in settings. */
 export interface TaxComponentDefinition {
   /** Printed on the invoice and used to group the tax report: "GST", "QST". */
@@ -52,16 +60,6 @@ export interface TaxComponent extends TaxComponentDefinition {
   amount: number
 }
 
-/**
- * Money is kept to the cent per component, because that is how each tax is
- * declared and how each line prints. The nudge keeps a value that sits
- * exactly on a half-cent in binary (89.775 is really 89.77499...) rounding
- * the way a person expects.
- */
-function roundCents(value: number): number {
-  return Math.round((value + Number.EPSILON) * 100) / 100
-}
-
 /**
  * Each component's tax on a net base, in exclusive terms, before rounding.
  * A compounding component takes the base plus everything charged before it.
@@ -114,11 +112,11 @@ export function splitTaxAmount(args: {
     const shares = exclusiveShares(base, components)
     return components.map((component, index) => ({
       ...component,
-      amount: roundCents(shares[index]),
+      amount: roundMoney(shares[index]),
     }))
   }
 
-  const target = roundCents(taxAmount)
+  const target = roundMoney(taxAmount)
   const shares = exclusiveShares(1, components)
   const total = shares.reduce((sum, share) => sum + share, 0)
   const split: TaxComponent[] = []
@@ -126,9 +124,9 @@ export function splitTaxAmount(args: {
   components.forEach((component, index) => {
     const last = index === components.length - 1
     const amount = last
-      ? roundCents(target - allocated)
+      ? roundMoney(target - allocated)
       : total > 0
-        ? roundCents(target * (shares[index] / total))
+        ? roundMoney(target * (shares[index] / total))
         : 0
     allocated += amount
     split.push({ ...component, amount })
@@ -154,7 +152,8 @@ export function calculateTotals({
    */
   components?: TaxComponentDefinition[] | null
 }): { taxAmount: number; totalAmount: number; components: TaxComponent[] | null } {
-  const base = Math.max(0, subtotal - discountAmount)
+  // The printed subtotal less the printed discount, so the sum on the sheet holds.
+  const base = Math.max(0, roundMoney(roundMoney(subtotal) - roundMoney(discountAmount)))
   const split = components && components.length > 0 ? components : null
   const rate = split ? combinedTaxRate(split) : taxRate
 
@@ -163,7 +162,7 @@ export function calculateTotals({
       return { taxAmount: 0, totalAmount: base, components: split ? withZero(split) : null }
     }
     const net = base / (1 + rate / 100)
-    const taxAmount = base - net
+    const taxAmount = roundMoney(base - net)
     return {
       taxAmount,
       totalAmount: base,
@@ -175,11 +174,28 @@ export function calculateTotals({
 
   if (split) {
     const parts = splitTaxAmount({ base, taxAmount: 0, taxInclusive: false, components: split })
-    const taxAmount = parts.reduce((sum, part) => sum + part.amount, 0)
-    return { taxAmount, totalAmount: base + taxAmount, components: parts }
+    const taxAmount = roundMoney(parts.reduce((sum, part) => sum + part.amount, 0))
+    return { taxAmount, totalAmount: roundMoney(base + taxAmount), components: parts }
   }
-  const taxAmount = base * (rate / 100)
-  return { taxAmount, totalAmount: base + taxAmount, components: null }
+  const taxAmount = roundMoney(base * (rate / 100))
+  return { taxAmount, totalAmount: roundMoney(base + taxAmount), components: null }
+}
+
+/**
+ * The discount on a subtotal, to the cent: a percentage of it, or a fixed
+ * amount that cannot take more than there is. Every place that totals a
+ * document asks here, so the discount that is stored and printed is the one
+ * the total was built from.
+ */
+export function discountAmountFor(
+  subtotal: number,
+  discountType: string | null | undefined,
+  discountValue: number | null | undefined
+): number {
+  const value = Number(discountValue) || 0
+  if (discountType === 'percentage') return roundMoney(subtotal * (value / 100))
+  if (discountType === 'fixed') return roundMoney(Math.min(value, subtotal))
+  return 0
 }
 
 function withZero(components: TaxComponentDefinition[]): TaxComponent[] {