fix(currency): show and settle foreign transactions in AUD
ci / lint-test (push) Successful in 1m24s

The transactions page rendered the statement's native amount through a
formatter hardcoded to AUD, so a USD row displayed its USD figure labelled as
dollars while every analytics query counted the converted amount_aud. Same
transaction, two different numbers depending on the page.

getTransactions now returns the statement currency, the amount column shows
amount_aud with the native figure beneath it when the two differ, and the split
and duplicate modals seed from the converted amount (a duplicate becomes a
manual AUD row, so the native figure would be wrong there).

Settlement balances had the same split: getParticipantBalances and the
per-participant balance route summed raw amount while trip totals summed
amount_aud, so a shared foreign expense would net a USD figure against AUD
ones. All three now agree on amount_aud. No change to current balances - every
statement in the database is AUD today - but correct once Wise data lands.
This commit is contained in:
2026-07-26 10:00:15 +10:00
parent 315bc06d0d
commit 02495e4173
3 changed files with 24 additions and 8 deletions
@@ -19,7 +19,7 @@ export async function GET(
const rows = await queryRaw<BalanceRow>(
`SELECT ts.participant_id, p.name,
SUM(t.amount * ts.share_percent / 100)::numeric(12,2) as total_owed,
SUM(COALESCE(t.amount_aud, t.amount) * ts.share_percent / 100)::numeric(12,2) as total_owed,
COUNT(*)::int as transaction_count
FROM transaction_splits ts
JOIN transactions t ON t.id = ts.transaction_id
+15 -5
View File
@@ -22,10 +22,13 @@ function formatDate(d: string) {
const SPEND_TYPES = new Set(["debit", "fee", "interest"]);
function formatAmount(amount: number, type: string) {
// `amount` is in the statement's native currency; pass the currency to label it
// correctly. Callers showing a headline figure should pass amount_aud, which is
// what every analytics query totals.
function formatAmount(amount: number, type: string, currency = "AUD") {
const formatted = new Intl.NumberFormat("en-AU", {
style: "currency",
currency: "AUD",
currency,
}).format(amount);
return SPEND_TYPES.has(type) ? formatted : `+${formatted}`;
}
@@ -947,7 +950,12 @@ function TransactionsContent() {
<td className={`p-2 text-right whitespace-nowrap font-mono ${
SPEND_TYPES.has(t.transaction_type) ? "text-red-400" : "text-green-400"
}`}>
{formatAmount(t.amount, t.transaction_type)}
{formatAmount(t.amount_aud ?? t.amount, t.transaction_type)}
{t.currency && t.currency !== "AUD" && (
<div className="text-[10px] text-zinc-500 mt-0.5">
{formatAmount(t.amount, t.transaction_type, t.currency)}
</div>
)}
</td>
<td className="p-2">
<EditableTypeBadge
@@ -1000,7 +1008,7 @@ function TransactionsContent() {
</span>
))}
<button
onClick={() => setSplitModal({ transactionId: t.id, amount: t.amount, description: t.description, merchant: t.effective_merchant || undefined, transactionIds: undefined })}
onClick={() => setSplitModal({ transactionId: t.id, amount: t.amount_aud ?? t.amount, description: t.description, merchant: t.effective_merchant || undefined, transactionIds: undefined })}
className={`text-xs px-2 py-0.5 rounded transition-colors ${
t.splits?.some((s) => s.participant_id !== me?.id)
? "text-amber-400 hover:text-amber-200 hover:bg-zinc-800"
@@ -1033,7 +1041,9 @@ function TransactionsContent() {
prefill: {
date: new Date().toISOString().slice(0, 10),
description: t.description,
amount: t.amount,
// Duplicates become manual (AUD) transactions, so seed
// them with the converted figure, not the native one.
amount: t.amount_aud ?? t.amount,
transaction_type: t.transaction_type,
merchant_normalized: t.effective_merchant || undefined,
category: t.effective_category || undefined,