Bernt Christian Egeland 7 時間 前
親
コミット
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 {
 function emptyCompany(overrides: Answer = () => ({ status: 599 })): Answer {
   let nextSale = 9001
   let nextSale = 9001
   let nextPayment = 7001
   let nextPayment = 7001
+  const sales = new Map<string, Record<string, unknown>>()
   return (call) => {
   return (call) => {
     const special = overrides(call)
     const special = overrides(call)
     if (special.status !== 599) return special
     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 === 'GET') return { json: [] }
     if (call.method === 'POST' && call.path === `${BASE}/contacts`) return { created: 501 }
     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')) {
     if (call.method === 'POST' && call.path.endsWith('/attachments')) {
       // An attachment is known by a uuid, so its Location does not end in a number.
       // An attachment is known by a uuid, so its Location does not end in a number.
       return {
       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 () => {
   it('creates the contact, then the sale under its own number, then files the PDF on it', async () => {
     const t = makeCtx({ answer: emptyCompany() })
     const t = makeCtx({ answer: emptyCompany() })
     const out = await push(t)
     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.host).toBe('api.fiken.no')
     expect(findContact.path).toBe(`${BASE}/contacts`)
     expect(findContact.path).toBe(`${BASE}/contacts`)
     expect(findContact.query).toEqual({ name: 'Anna Berg', customer: 'true', ...PAGE })
     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.query).toEqual({})
     expect(createSale.body).toEqual(saleBody)
     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.method).toBe('POST')
     expect(attach.path).toBe(`${BASE}/sales/9001/attachments`)
     expect(attach.path).toBe(`${BASE}/sales/9001/attachments`)
     expect(attach.query).toEqual({ attachToSale: 'true' })
     expect(attach.query).toEqual({ attachToSale: 'true' })
@@ -376,10 +390,13 @@ describe('Fiken: pushing an invoice', () => {
     t.reset()
     t.reset()
     loadInvoice.mockResolvedValue({ ...invoice, dueDate: new Date('2026-09-30T10:00:00Z') })
     loadInvoice.mockResolvedValue({ ...invoice, dueDate: new Date('2026-09-30T10:00:00Z') })
     const out = await push(t)
     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(readSale.path).toBe(`${BASE}/sales/9001`)
     expect(reverse.method).toBe('PATCH')
     expect(reverse.method).toBe('PATCH')
     expect(reverse.path).toBe(`${BASE}/sales/9001/delete`)
     expect(reverse.path).toBe(`${BASE}/sales/9001/delete`)
@@ -447,7 +464,9 @@ describe('Fiken: pushing an invoice', () => {
       ),
       ),
     })
     })
     const out = await push(t)
     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(
     expect(t.logs.some((l) => l.level === 'warn' && l.message.includes('added beside it'))).toBe(
       true
       true
     )
     )
@@ -464,7 +483,9 @@ describe('Fiken: pushing an invoice', () => {
       ),
       ),
     })
     })
     const out = await push(t)
     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.links.get('ServiceRecord:svc1')?.metadata?.attached).toBe(false)
     expect(t.logs.find((l) => l.level === 'warn')?.message).toContain('Ugyldig fil')
     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)
     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 () => {
   it('does not render or send a PDF when the workshop switched that off', async () => {
     const t = makeCtx({ settings: { attachPdf: false }, answer: emptyCompany() })
     const t = makeCtx({ settings: { attachPdf: false }, answer: emptyCompany() })
     await push(t)
     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 () => {
   it('sends a completed job before it is issued when asked to', async () => {
     const t = makeCtx({ settings: { pushOnComplete: true }, answer: emptyCompany() })
     const t = makeCtx({ settings: { pushOnComplete: true }, answer: emptyCompany() })
     loadInvoice.mockResolvedValue({ ...invoice, issuedAt: null })
     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 () => {
   it('does not put another currency into books kept in kroner', async () => {
@@ -579,11 +673,10 @@ describe('Fiken: what stays out', () => {
     await push(t)
     await push(t)
     const sale = t.calls.find((c) => c.method === 'POST' && c.path === `${BASE}/sales`)
     const sale = t.calls.find((c) => c.method === 'POST' && c.path === `${BASE}/sales`)
     expect(sale?.body?.lines).toEqual([
     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',
         description: 'BP-100 Brake pads',
         netPrice: 10000,
         netPrice: 10000,
-        vat: 0,
         account: '3100',
         account: '3100',
         vatType: 'EXEMPT',
         vatType: 'EXEMPT',
       },
       },
@@ -769,6 +862,7 @@ describe('Fiken: moving to another company', () => {
       `POST ${other}/contacts`,
       `POST ${other}/contacts`,
       `GET ${other}/sales`,
       `GET ${other}/sales`,
       `POST ${other}/sales`,
       `POST ${other}/sales`,
+      `GET ${other}/sales/9500`,
       `POST ${other}/sales/9500/attachments`,
       `POST ${other}/sales/9500/attachments`,
     ])
     ])
     expect(t.calls[3].body?.customerId).toBe(601)
     expect(t.calls[3].body?.customerId).toBe(601)

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

@@ -4,8 +4,12 @@ import type {
   AccountingInvoice,
   AccountingInvoice,
 } from '@/features/integrations/Lib/accounting-sync'
 } from '@/features/integrations/Lib/accounting-sync'
 import { manifest } from '@/integrations/fiken/manifest'
 import { manifest } from '@/integrations/fiken/manifest'
+import { formatCurrency } from '@/lib/format'
+import { calculateTotals } from '@/lib/tax'
 import {
 import {
+  type SaleOptions,
   allocate,
   allocate,
+  bookedTotals,
   buildContact,
   buildContact,
   buildPayment,
   buildPayment,
   buildSale,
   buildSale,
@@ -16,6 +20,7 @@ import {
   idFromLocation,
   idFromLocation,
   localPaymentMethod,
   localPaymentMethod,
   mergeContact,
   mergeContact,
+  money,
   organizationNumber,
   organizationNumber,
   toCents,
   toCents,
   vatTypeForRate,
   vatTypeForRate,
@@ -77,7 +82,7 @@ const invoice: AccountingInvoice = {
   payments: [],
   payments: [],
 }
 }
 
 
-const options = {
+const options: SaleOptions = {
   customerId: 501,
   customerId: 501,
   laborAccount: '3000',
   laborAccount: '3000',
   partsAccount: '3010',
   partsAccount: '3010',
@@ -87,15 +92,25 @@ const options = {
   timezone: 'Europe/Oslo',
   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 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', () => {
 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(1875)).toBe(187500)
     expect(toCents(0.29)).toBe(29)
     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)
     expect(fromCents(187550)).toBe(1875.5)
   })
   })
 
 
@@ -212,14 +227,12 @@ describe('Fiken mapping: a sale', () => {
       {
       {
         description: 'Brake service',
         description: 'Brake service',
         netPrice: 100000,
         netPrice: 100000,
-        vat: 0,
         account: '3100',
         account: '3100',
         vatType: 'EXEMPT',
         vatType: 'EXEMPT',
       },
       },
       {
       {
         description: 'BP-100 Brake pads',
         description: 'BP-100 Brake pads',
         netPrice: 50000,
         netPrice: 50000,
-        vat: 0,
         account: '3100',
         account: '3100',
         vatType: 'EXEMPT',
         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', () => {
 describe('Fiken mapping: contacts and payments', () => {
   it('reads a Norwegian organisation number however it was typed', () => {
   it('reads a Norwegian organisation number however it was typed', () => {
     expect(organizationNumber('NO 987 654 321 MVA')).toBe('987654321')
     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 })
     ).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', () => {
   it('reads cash from the account a Fiken payment landed on', () => {
     expect(localPaymentMethod('1900')).toBe('cash')
     expect(localPaymentMethod('1900')).toBe('cash')
     expect(localPaymentMethod('1920:10001')).toBe('transfer')
     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' },
       { description: 'Shop supplies', hours: 1, rate: 12.5, total: 12.5, pricingType: 'shopFee' },
     ])
     ])
     expect(data.subtotal).toBe(12.5)
     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 () => {
   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 { 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('calculateTotals', () => {
   describe('exclusive mode', () => {
   describe('exclusive mode', () => {
@@ -109,7 +111,7 @@ describe('calculateTotals', () => {
       })
       })
       // base (gross after discount) = 90, net = 81.82, tax = 8.18, total = 90
       // base (gross after discount) = 90, net = 81.82, tax = 8.18, total = 90
       expect(r.totalAmount).toBeCloseTo(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', () => {
     it('net base equals totalAmount - taxAmount in both modes', () => {
@@ -459,3 +461,97 @@ describe('universal invoice display formulas', () => {
     expect(exclusive.displayTotal).toBeCloseTo(inclusive.displayTotal)
     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}
           aria-expanded={open}
           disabled={disabled || loading}
           disabled={disabled || loading}
           className="h-8 w-full justify-between font-normal"
           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')}>
           <span className={cn('truncate', !label && 'text-muted-foreground')}>
             {loading ? t('connection.loading') : (label ?? t('connection.choose'))}
             {loading ? t('connection.loading') : (label ?? t('connection.choose'))}
@@ -64,7 +66,12 @@ export function OptionPicker({
           )}
           )}
         </Button>
         </Button>
       </PopoverTrigger>
       </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>
         <Command>
           <CommandInput placeholder={t('connection.search')} />
           <CommandInput placeholder={t('connection.search')} />
           <CommandList>
           <CommandList>
@@ -94,9 +101,12 @@ export function OptionPicker({
                   }}
                   }}
                 >
                 >
                   <Check
                   <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>
                 </CommandItem>
               ))}
               ))}
             </CommandGroup>
             </CommandGroup>

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

@@ -15,7 +15,7 @@ import {
   FileCheck2,
   FileCheck2,
 } from 'lucide-react'
 } from 'lucide-react'
 import { formatCurrency, formatDate as fmtDate, DEFAULT_DATE_FORMAT } from '@/lib/format'
 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 { useLocale, useTranslations } from 'next-intl'
 import {
 import {
   isCustomFieldId,
   isCustomFieldId,
@@ -296,12 +296,11 @@ export function InvoiceView({
   const partsSubtotalStored = record.partItems.reduce((sum, p) => sum + p.total, 0)
   const partsSubtotalStored = record.partItems.reduce((sum, p) => sum + p.total, 0)
   const laborSubtotalStored = record.laborItems.reduce((sum, l) => sum + l.total, 0)
   const laborSubtotalStored = record.laborItems.reduce((sum, l) => sum + l.total, 0)
   const computedSubtotalStored = partsSubtotalStored + laborSubtotalStored
   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 recordTaxInclusive = record.taxInclusive ?? false
   const { taxAmount: computedTax, totalAmount: computedTotal } = calculateTotals({
   const { taxAmount: computedTax, totalAmount: computedTotal } = calculateTotals({
     subtotal: computedSubtotalStored,
     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 { writeLog } from '@/features/integrations/Lib/connections'
 import { rateLimit } from '@/lib/rate-limit'
 import { rateLimit } from '@/lib/rate-limit'
 import { resolvePortalOrg } from '@/lib/portal-slug'
 import { resolvePortalOrg } from '@/lib/portal-slug'
-import { calculateTotals } from '@/lib/tax'
+import { calculateTotals, discountAmountFor } from '@/lib/tax'
 import { getFeatures } from '@/lib/features'
 import { getFeatures } from '@/lib/features'
 
 
 const checkoutSchema = z.object({
 const checkoutSchema = z.object({
@@ -68,12 +68,11 @@ export async function POST(
     const partsSubtotal = record.partItems.reduce((sum, p) => sum + p.total, 0)
     const partsSubtotal = record.partItems.reduce((sum, p) => sum + p.total, 0)
     const laborSubtotal = record.laborItems.reduce((sum, l) => sum + l.total, 0)
     const laborSubtotal = record.laborItems.reduce((sum, l) => sum + l.total, 0)
     const computedSubtotal = partsSubtotal + laborSubtotal
     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({
     const { totalAmount: computedTotal } = calculateTotals({
       subtotal: computedSubtotal,
       subtotal: computedSubtotal,
       discountAmount: computedDiscount,
       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 { z } from 'zod'
 import { db } from '@/lib/db'
 import { db } from '@/lib/db'
 import { documentTotals } from '@/features/settings/Lib/workshopTax'
 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 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({
         const { taxAmount, totalAmount, taxComponents } = documentTotals({
           subtotal,
           subtotal,
           discountAmount,
           discountAmount,

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

@@ -27,6 +27,7 @@ import {
 } from '../Lib/connections'
 } from '../Lib/connections'
 import { enqueueJob, runJob } from '../Lib/jobs'
 import { enqueueJob, runJob } from '../Lib/jobs'
 import { oauthSpec, platformClient, redirectUriFor } from '../Lib/oauth'
 import { oauthSpec, platformClient, redirectUriFor } from '../Lib/oauth'
+import { describeSettingChanges } from '../Lib/setting-changes'
 import type {
 import type {
   ActivityBatch,
   ActivityBatch,
   ActivityItem,
   ActivityItem,
@@ -520,7 +521,11 @@ export async function updateIntegrationSettings(connectorId: string, raw: unknow
         where: { id: row.id },
         where: { id: row.id },
         data: { settings: settings as object },
         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}`)
       revalidatePath(`/settings/integrations/${connectorId}`)
       return { settings }
       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
  * 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
  * part: what the customer is charged, the markup that price implies, and the
@@ -37,30 +39,7 @@ export interface ResolvedPrice {
   markupPercent: number
   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.
  * 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 { DEFAULT_DATE_FORMAT, formatCurrency, formatDateForPdf } from '@/lib/format'
 import { documentLaborLines, isShopFeeLine } from '@/features/settings/Lib/shopFee'
 import { documentLaborLines, isShopFeeLine } from '@/features/settings/Lib/shopFee'
 import { formatQuantity } from '@/lib/format-quantity'
 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 { parseTaxComponents } from '@/lib/tax-components'
 import { taxLines } from './taxLines'
 import { taxLines } from './taxLines'
 import {
 import {
@@ -155,12 +155,11 @@ export function buildInvoicePrintSpec(input: InvoicePrintInput): DocumentSpec {
   const partsSubtotal = data.partItems.reduce((sum, p) => sum + p.total, 0)
   const partsSubtotal = data.partItems.reduce((sum, p) => sum + p.total, 0)
   const laborSubtotal = data.laborItems.reduce((sum, l) => sum + l.total, 0)
   const laborSubtotal = data.laborItems.reduce((sum, l) => sum + l.total, 0)
   const computedSubtotal = partsSubtotal + laborSubtotal
   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({
   const { totalAmount: computedTotal } = calculateTotals({
     subtotal: computedSubtotal,
     subtotal: computedSubtotal,
     discountAmount: computedDiscount,
     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 { DEFAULT_DATE_FORMAT, formatCurrency, formatDateForPdf } from '@/lib/format'
 import { documentLaborLines, isShopFeeLine } from '@/features/settings/Lib/shopFee'
 import { documentLaborLines, isShopFeeLine } from '@/features/settings/Lib/shopFee'
 import { formatQuantity } from '@/lib/format-quantity'
 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 { parseTaxComponents } from '@/lib/tax-components'
 import { taxLines } from './taxLines'
 import { taxLines } from './taxLines'
 import {
 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 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 partsTotal = data.partItems.reduce((sum, p) => (p.excluded ? sum : sum + p.total), 0)
   const subtotal = laborTotal + partsTotal
   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({
   const { taxAmount, totalAmount } = calculateTotals({
     subtotal,
     subtotal,
     discountAmount: discount,
     discountAmount: discount,

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

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

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

@@ -13,7 +13,7 @@ import {
   convertQuoteToServiceRecord,
   convertQuoteToServiceRecord,
 } from '@/features/quotes/Actions/quoteActions'
 } from '@/features/quotes/Actions/quoteActions'
 import { acknowledgeQuoteResponse } from '@/features/quotes/Actions/quoteResponseActions'
 import { acknowledgeQuoteResponse } from '@/features/quotes/Actions/quoteResponseActions'
-import { calculateTotals } from '@/lib/tax'
+import { calculateTotals, discountAmountFor } from '@/lib/tax'
 import { zonedDateInput } from '@/lib/timezone'
 import { zonedDateInput } from '@/lib/timezone'
 import { parseTaxComponents } from '@/lib/tax-components'
 import { parseTaxComponents } from '@/lib/tax-components'
 import { normalizeWarranty, WARRANTY_NONE, type WarrantyFields } from '@/lib/warranty'
 import { normalizeWarranty, WARRANTY_NONE, type WarrantyFields } from '@/lib/warranty'
@@ -275,12 +275,7 @@ export function useQuoteFormState({
   }, [laborItems, partsSubtotal, shopFee, locked, markDirty])
   }, [laborItems, partsSubtotal, shopFee, locked, markDirty])
 
 
   const subtotal = partsSubtotal + laborSubtotal
   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 {
   const {
     taxAmount,
     taxAmount,
     totalAmount,
     totalAmount,

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

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

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

@@ -1,5 +1,5 @@
 import { useState, useCallback, useRef, useEffect } from 'react'
 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 { normalizeWarranty, type WarrantyFields } from '@/lib/warranty'
 import { useDeferredCommit } from '@/hooks/use-deferred-commit'
 import { useDeferredCommit } from '@/hooks/use-deferred-commit'
 import { lineTotal, repricePartRow } from '@/features/inventory/Lib/partPricing'
 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 laborSubtotal = laborItems.reduce((sum, l) => sum + l.total, 0)
   const subtotal = partsSubtotal + laborSubtotal
   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 {
   const {
     taxAmount,
     taxAmount,
     totalAmount,
     totalAmount,

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

@@ -1,3 +1,4 @@
+import { discountAmountFor } from '@/lib/tax'
 import { db } from '@/lib/db'
 import { db } from '@/lib/db'
 import { refreshShopFeeLines } from './shopFeeLines'
 import { refreshShopFeeLines } from './shopFeeLines'
 import { documentTotals } from '@/features/settings/Lib/workshopTax'
 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 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({
     const { taxAmount, totalAmount, taxComponents } = documentTotals({
       subtotal,
       subtotal,
       discountAmount,
       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 { db, type TxClient } from '@/lib/db'
 import { documentTotals } from '@/features/settings/Lib/workshopTax'
 import { documentTotals } from '@/features/settings/Lib/workshopTax'
 import { refreshShopFeeLines } from './shopFeeLines'
 import { refreshShopFeeLines } from './shopFeeLines'
@@ -39,12 +40,7 @@ export async function retotalServiceRecord(
   ])
   ])
 
 
   const subtotal = (parts._sum.total || 0) + (labor._sum.total || 0)
   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({
   const { taxAmount, totalAmount, taxComponents } = documentTotals({
     subtotal,
     subtotal,

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

@@ -13,6 +13,7 @@ import type {
   AccountingInvoice,
   AccountingInvoice,
   AccountingPayment,
   AccountingPayment,
 } from '@/features/integrations/Lib/accounting-sync'
 } from '@/features/integrations/Lib/accounting-sync'
+import { roundAsPrinted } from '@/lib/money'
 import { zonedDayKey } from '@/lib/timezone'
 import { zonedDayKey } from '@/lib/timezone'
 
 
 export { checksumOf } from '@/features/integrations/Lib/checksum'
 export { checksumOf } from '@/features/integrations/Lib/checksum'
@@ -107,8 +108,8 @@ export interface FikenOrderLine {
   description: string
   description: string
   /** Øre, without VAT. */
   /** Øre, without VAT. */
   netPrice: number
   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
   account: string
   vatType: string
   vatType: string
 }
 }
@@ -124,12 +125,17 @@ export interface FikenSale {
   totalPaid?: number
   totalPaid?: number
   outstandingBalance?: number
   outstandingBalance?: number
   salePayments?: FikenPayment[]
   salePayments?: FikenPayment[]
+  lines?: Partial<FikenOrderLine>[]
   deleted?: boolean
   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 {
 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 {
 export function fromCents(cents: number): number {
@@ -315,7 +321,7 @@ export function buildSale(inv: AccountingInvoice, o: SaleOptions): Record<string
     lines = charged.map((line, i) => ({
     lines = charged.map((line, i) => ({
       description: lineDescription(line),
       description: lineDescription(line),
       netPrice: nets[i],
       netPrice: nets[i],
-      vat: vats[i],
+      ...(vats[i] !== 0 && { vat: vats[i] }),
       account: accountFor(line.kind),
       account: accountFor(line.kind),
       vatType,
       vatType,
     }))
     }))
@@ -325,7 +331,7 @@ export function buildSale(inv: AccountingInvoice, o: SaleOptions): Record<string
       {
       {
         description: `Faktura ${inv.invoiceNumber ?? ''}`.trim().slice(0, TEXT_MAX),
         description: `Faktura ${inv.invoiceNumber ?? ''}`.trim().slice(0, TEXT_MAX),
         netPrice: net,
         netPrice: net,
-        vat,
+        ...(vat !== 0 && { vat }),
         account: accountFor('labor'),
         account: accountFor('labor'),
         vatType,
         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
  * 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
  * 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,
   REVOKE_URL,
   WALK_IN_NAME,
   WALK_IN_NAME,
   type ZeroVatType,
   type ZeroVatType,
+  bookedTotals,
   buildContact,
   buildContact,
   buildPayment,
   buildPayment,
   buildSale,
   buildSale,
@@ -48,6 +49,7 @@ import {
   isZeroVatType,
   isZeroVatType,
   localPaymentMethod,
   localPaymentMethod,
   mergeContact,
   mergeContact,
+  money,
   organizationNumber,
   organizationNumber,
   toCents,
   toCents,
   vatTypeForRate,
   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`
       `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 }
   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. */
 /** The invoice the customer received, filed behind the sale as its voucher. */
 async function attachInvoice(
 async function attachInvoice(
   ctx: ConnectorContext,
   ctx: ConnectorContext,
@@ -596,6 +646,7 @@ async function pushInvoice(ctx: ConnectorContext, serviceRecordId: string): Prom
   let link = await linkFor(ctx, slug, INVOICE_ENTITY, serviceRecordId)
   let link = await linkFor(ctx, slug, INVOICE_ENTITY, serviceRecordId)
 
 
   let action = 'unchanged'
   let action = 'unchanged'
+  let confirmed: string | null = null
   if (link && link.checksum !== checksum) {
   if (link && link.checksum !== checksum) {
     try {
     try {
       const sale = await read<FikenSale>(ctx, companyPath(slug, `/sales/${link.remoteId}`))
       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)
     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')
   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++
       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 ---------- */
 /* ---------- payments ---------- */

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

@@ -13,6 +13,7 @@ import type {
   AccountingInvoice,
   AccountingInvoice,
   AccountingPayment,
   AccountingPayment,
 } from '@/features/integrations/Lib/accounting-sync'
 } from '@/features/integrations/Lib/accounting-sync'
+import { roundAsPrinted } from '@/lib/money'
 import { zonedDayKey } from '@/lib/timezone'
 import { zonedDayKey } from '@/lib/timezone'
 
 
 /** Intuit retires older minor versions; this one carries the current field set. */
 /** 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)}`
   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 {
 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. */
 /** 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
  * (net in exclusive mode, gross in inclusive mode), so existing PDFs and DB
  * rows remain consistent.
  * 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
  * 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
  * GST and QST, or an Indian one CGST and SGST. Those are `components`: the
  * combined `taxRate` and `taxAmount` keep their meaning, and the components
  * combined `taxRate` and `taxAmount` keep their meaning, and the components
@@ -30,6 +36,8 @@
  * formula above applies unchanged.
  * formula above applies unchanged.
  */
  */
 
 
+import { roundMoney } from './money'
+
 /** One tax on a document, as the workshop defines it in settings. */
 /** One tax on a document, as the workshop defines it in settings. */
 export interface TaxComponentDefinition {
 export interface TaxComponentDefinition {
   /** Printed on the invoice and used to group the tax report: "GST", "QST". */
   /** Printed on the invoice and used to group the tax report: "GST", "QST". */
@@ -52,16 +60,6 @@ export interface TaxComponent extends TaxComponentDefinition {
   amount: number
   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.
  * Each component's tax on a net base, in exclusive terms, before rounding.
  * A compounding component takes the base plus everything charged before it.
  * A compounding component takes the base plus everything charged before it.
@@ -114,11 +112,11 @@ export function splitTaxAmount(args: {
     const shares = exclusiveShares(base, components)
     const shares = exclusiveShares(base, components)
     return components.map((component, index) => ({
     return components.map((component, index) => ({
       ...component,
       ...component,
-      amount: roundCents(shares[index]),
+      amount: roundMoney(shares[index]),
     }))
     }))
   }
   }
 
 
-  const target = roundCents(taxAmount)
+  const target = roundMoney(taxAmount)
   const shares = exclusiveShares(1, components)
   const shares = exclusiveShares(1, components)
   const total = shares.reduce((sum, share) => sum + share, 0)
   const total = shares.reduce((sum, share) => sum + share, 0)
   const split: TaxComponent[] = []
   const split: TaxComponent[] = []
@@ -126,9 +124,9 @@ export function splitTaxAmount(args: {
   components.forEach((component, index) => {
   components.forEach((component, index) => {
     const last = index === components.length - 1
     const last = index === components.length - 1
     const amount = last
     const amount = last
-      ? roundCents(target - allocated)
+      ? roundMoney(target - allocated)
       : total > 0
       : total > 0
-        ? roundCents(target * (shares[index] / total))
+        ? roundMoney(target * (shares[index] / total))
         : 0
         : 0
     allocated += amount
     allocated += amount
     split.push({ ...component, amount })
     split.push({ ...component, amount })
@@ -154,7 +152,8 @@ export function calculateTotals({
    */
    */
   components?: TaxComponentDefinition[] | null
   components?: TaxComponentDefinition[] | null
 }): { taxAmount: number; totalAmount: number; components: TaxComponent[] | 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 split = components && components.length > 0 ? components : null
   const rate = split ? combinedTaxRate(split) : taxRate
   const rate = split ? combinedTaxRate(split) : taxRate
 
 
@@ -163,7 +162,7 @@ export function calculateTotals({
       return { taxAmount: 0, totalAmount: base, components: split ? withZero(split) : null }
       return { taxAmount: 0, totalAmount: base, components: split ? withZero(split) : null }
     }
     }
     const net = base / (1 + rate / 100)
     const net = base / (1 + rate / 100)
-    const taxAmount = base - net
+    const taxAmount = roundMoney(base - net)
     return {
     return {
       taxAmount,
       taxAmount,
       totalAmount: base,
       totalAmount: base,
@@ -175,11 +174,28 @@ export function calculateTotals({
 
 
   if (split) {
   if (split) {
     const parts = splitTaxAmount({ base, taxAmount: 0, taxInclusive: false, components: 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[] {
 function withZero(components: TaxComponentDefinition[]): TaxComponent[] {