fix(notifications,mcp): carry the record's currency in amount strings (#1179)

Fixes #1171. Three currency-blind format sites:

- Invoice due/overdue push notifications rendered every total as kr;
  the scheduler query did not even select currency. Builders now take
  the invoice currency ("kr" only for SEK, ISO code otherwise).
- The receipt-matcher MCP widget hardcoded ' kr' although the tool
  handler passes each transaction's currency through.
- The duplicate-booking warning in gnubok_categorize_transaction
  interpolated "N kr" for a transaction whose currency was already
  selected; the string reaches the agent, so a mislabeled currency can
  mislead the model, not just the user.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-07-25 12:58:55 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent bb78f8fce8
commit 9b3e344796
5 changed files with 39 additions and 5 deletions
+2 -1
View File
@@ -3482,9 +3482,10 @@ export const tools: McpTool[] = [
})
if (dup) {
const amountAbs = roundOre(Math.abs(Number(tx.amount)))
const amountUnit = !tx.currency || tx.currency === 'SEK' ? 'kr' : tx.currency
const voucher = dup.voucher_label ? `verifikat ${dup.voucher_label}` : 'en befintlig verifikation'
throw new Error(
`Möjlig dubblettbokföring: ${voucher} (${dup.entry_date}) bokför redan ${amountAbs} kr på bankkontot. ` +
`Möjlig dubblettbokföring: ${voucher} (${dup.entry_date}) bokför redan ${amountAbs} ${amountUnit} på bankkontot. ` +
`Den här affärshändelsen ser redan ut att vara bokförd: länka transaktionen till den befintliga ` +
`verifikationen i stället. Anropa igen med allow_duplicate=true först om det är en genuint separat affärshändelse.`,
)
@@ -199,7 +199,9 @@ export const RECEIPT_MATCHER_HTML = `<!DOCTYPE html>
const cls = isBooked ? 'booked' : (hasError ? 'error-row' : '');
const amt = Number(tx.amount);
const amtClass = amt < 0 ? 'negative' : 'positive';
const formatted = amt.toLocaleString('sv-SE', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) + ' kr';
// The tool handler passes each transaction's own currency through.
const suffix = !tx.currency || tx.currency === 'SEK' ? 'kr' : tx.currency;
const formatted = amt.toLocaleString('sv-SE', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) + ' ' + esc(suffix);
html += '<tr class="' + cls + '" data-idx="' + i + '">';
html += '<td>' + esc(tx.date || '') + '</td>';
@@ -0,0 +1,22 @@
import { describe, it, expect } from 'vitest'
import { createInvoiceOverduePayload, createInvoiceDuePayload } from '../payload-builders'
describe('invoice notification payloads', () => {
// sv-SE grouping uses a non-breaking space: build expectations via the same
// formatter instead of typing literals with a plain space.
it('labels SEK amounts with kr', () => {
const payload = createInvoiceDuePayload('1042', 'Acme AB', 12500, 'SEK', '2026-08-01', 'inv-1')
expect(payload.body).toContain(`${(12500).toLocaleString('sv-SE')} kr`)
})
it('labels non-SEK amounts with their ISO code instead of kr', () => {
const payload = createInvoiceOverduePayload('1043', 'Odin Aero GmbH', 9800, 'EUR', '2026-07-01', 'inv-2')
expect(payload.body).toContain(`${(9800).toLocaleString('sv-SE')} EUR`)
expect(payload.body).not.toContain('kr (')
})
it('falls back to kr when currency is missing on legacy rows', () => {
const payload = createInvoiceDuePayload('1044', 'Acme AB', 100, '', '2026-08-01', 'inv-3')
expect(payload.body).toContain('100 kr')
})
})
@@ -135,7 +135,7 @@ export async function sendInvoiceNotifications(
const { data: invoices } = await supabase
.from('invoices')
.select('id, user_id, invoice_number, total, due_date, customer:customers(name)')
.select('id, user_id, invoice_number, total, currency, due_date, customer:customers(name)')
.in('status', ['sent', 'overdue'])
.in('due_date', [in3DaysStr, todayStr, daysAgo3Str, daysAgo7Str])
@@ -180,6 +180,7 @@ export async function sendInvoiceNotifications(
invoice.invoice_number,
customerName,
invoice.total,
invoice.currency,
invoice.due_date,
invoice.id
)
@@ -187,6 +188,7 @@ export async function sendInvoiceNotifications(
invoice.invoice_number,
customerName,
invoice.total,
invoice.currency,
invoice.due_date,
invoice.id
)
@@ -163,16 +163,22 @@ export function createTaxDeadlinePayload(
}
}
// Invoices carry their own currency; "kr" is only correct for SEK.
function formatInvoiceAmount(amount: number, currency: string): string {
return `${amount.toLocaleString('sv-SE')} ${!currency || currency === 'SEK' ? 'kr' : currency}`
}
export function createInvoiceOverduePayload(
invoiceNumber: string,
customerName: string,
amount: number,
currency: string,
dueDate: string,
invoiceId: string
): NotificationPayload {
return {
title: `Obetald faktura #${invoiceNumber}`,
body: `${customerName} - ${amount.toLocaleString('sv-SE')} kr (förföll ${formatDate(dueDate)})`,
body: `${customerName} - ${formatInvoiceAmount(amount, currency)} (förföll ${formatDate(dueDate)})`,
icon: '/icons/icon-192.png',
badge: '/icons/badge-72.png',
tag: `invoice-${invoiceId}`,
@@ -189,12 +195,13 @@ export function createInvoiceDuePayload(
invoiceNumber: string,
customerName: string,
amount: number,
currency: string,
dueDate: string,
invoiceId: string
): NotificationPayload {
return {
title: `Faktura #${invoiceNumber} förfaller`,
body: `${customerName} - ${amount.toLocaleString('sv-SE')} kr (${formatDate(dueDate)})`,
body: `${customerName} - ${formatInvoiceAmount(amount, currency)} (${formatDate(dueDate)})`,
icon: '/icons/icon-192.png',
badge: '/icons/badge-72.png',
tag: `invoice-${invoiceId}`,