fix(currency): show and settle foreign transactions in AUD
ci / lint-test (push) Successful in 1m24s
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:
@@ -19,7 +19,7 @@ export async function GET(
|
|||||||
|
|
||||||
const rows = await queryRaw<BalanceRow>(
|
const rows = await queryRaw<BalanceRow>(
|
||||||
`SELECT ts.participant_id, p.name,
|
`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
|
COUNT(*)::int as transaction_count
|
||||||
FROM transaction_splits ts
|
FROM transaction_splits ts
|
||||||
JOIN transactions t ON t.id = ts.transaction_id
|
JOIN transactions t ON t.id = ts.transaction_id
|
||||||
|
|||||||
@@ -22,10 +22,13 @@ function formatDate(d: string) {
|
|||||||
|
|
||||||
const SPEND_TYPES = new Set(["debit", "fee", "interest"]);
|
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", {
|
const formatted = new Intl.NumberFormat("en-AU", {
|
||||||
style: "currency",
|
style: "currency",
|
||||||
currency: "AUD",
|
currency,
|
||||||
}).format(amount);
|
}).format(amount);
|
||||||
return SPEND_TYPES.has(type) ? formatted : `+${formatted}`;
|
return SPEND_TYPES.has(type) ? formatted : `+${formatted}`;
|
||||||
}
|
}
|
||||||
@@ -947,7 +950,12 @@ function TransactionsContent() {
|
|||||||
<td className={`p-2 text-right whitespace-nowrap font-mono ${
|
<td className={`p-2 text-right whitespace-nowrap font-mono ${
|
||||||
SPEND_TYPES.has(t.transaction_type) ? "text-red-400" : "text-green-400"
|
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>
|
||||||
<td className="p-2">
|
<td className="p-2">
|
||||||
<EditableTypeBadge
|
<EditableTypeBadge
|
||||||
@@ -1000,7 +1008,7 @@ function TransactionsContent() {
|
|||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
<button
|
<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 ${
|
className={`text-xs px-2 py-0.5 rounded transition-colors ${
|
||||||
t.splits?.some((s) => s.participant_id !== me?.id)
|
t.splits?.some((s) => s.participant_id !== me?.id)
|
||||||
? "text-amber-400 hover:text-amber-200 hover:bg-zinc-800"
|
? "text-amber-400 hover:text-amber-200 hover:bg-zinc-800"
|
||||||
@@ -1033,7 +1041,9 @@ function TransactionsContent() {
|
|||||||
prefill: {
|
prefill: {
|
||||||
date: new Date().toISOString().slice(0, 10),
|
date: new Date().toISOString().slice(0, 10),
|
||||||
description: t.description,
|
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,
|
transaction_type: t.transaction_type,
|
||||||
merchant_normalized: t.effective_merchant || undefined,
|
merchant_normalized: t.effective_merchant || undefined,
|
||||||
category: t.effective_category || undefined,
|
category: t.effective_category || undefined,
|
||||||
|
|||||||
+8
-2
@@ -34,6 +34,9 @@ export interface TransactionRow {
|
|||||||
effective_merchant: string;
|
effective_merchant: string;
|
||||||
// statement context (null for manual transactions)
|
// statement context (null for manual transactions)
|
||||||
bank_name: string;
|
bank_name: string;
|
||||||
|
// Native currency of the statement this row came from ('AUD' for manual rows).
|
||||||
|
// `amount` is in this currency; `amount_aud` is the converted figure.
|
||||||
|
currency: string;
|
||||||
owner_id: number;
|
owner_id: number;
|
||||||
owner_name: string;
|
owner_name: string;
|
||||||
// tags
|
// tags
|
||||||
@@ -198,6 +201,7 @@ export async function getTransactions(ownerId: number, filters: TransactionFilte
|
|||||||
COALESCE(o.category_override, t.category) as effective_category,
|
COALESCE(o.category_override, t.category) as effective_category,
|
||||||
COALESCE(o.merchant_normalized, t.merchant_normalized, t.merchant_name) as effective_merchant,
|
COALESCE(o.merchant_normalized, t.merchant_normalized, t.merchant_name) as effective_merchant,
|
||||||
COALESCE(s.bank_name, 'Manual') as bank_name,
|
COALESCE(s.bank_name, 'Manual') as bank_name,
|
||||||
|
COALESCE(s.currency, 'AUD') as currency,
|
||||||
COALESCE(t.owner_id, s.owner_id) as owner_id,
|
COALESCE(t.owner_id, s.owner_id) as owner_id,
|
||||||
p.name as owner_name,
|
p.name as owner_name,
|
||||||
COALESCE(src.created_at, t.created_at) as created_at,
|
COALESCE(src.created_at, t.created_at) as created_at,
|
||||||
@@ -362,8 +366,10 @@ export async function getParticipantBalances(ownerId: number, tagIds?: number[])
|
|||||||
|
|
||||||
LEFT JOIN (
|
LEFT JOIN (
|
||||||
-- They owe me: their splits on transactions I own
|
-- They owe me: their splits on transactions I own
|
||||||
|
-- Settle in AUD: on a foreign-currency row the amount column is in its own
|
||||||
|
-- currency, so splitting on it nets a USD figure against AUD ones.
|
||||||
SELECT ts.participant_id AS pid,
|
SELECT ts.participant_id AS pid,
|
||||||
(CASE WHEN t.transaction_type IN ('debit', 'fee', 'interest') THEN t.amount ELSE -t.amount END) * ts.share_percent / 100 AS signed_amount,
|
(CASE WHEN t.transaction_type IN ('debit', 'fee', 'interest') THEN COALESCE(t.amount_aud, t.amount) ELSE -COALESCE(t.amount_aud, t.amount) END) * ts.share_percent / 100 AS signed_amount,
|
||||||
1 AS split_count
|
1 AS split_count
|
||||||
FROM transaction_splits ts
|
FROM transaction_splits ts
|
||||||
JOIN transactions t ON t.id = ts.transaction_id
|
JOIN transactions t ON t.id = ts.transaction_id
|
||||||
@@ -376,7 +382,7 @@ export async function getParticipantBalances(ownerId: number, tagIds?: number[])
|
|||||||
|
|
||||||
-- I owe them: my splits on transactions they own
|
-- I owe them: my splits on transactions they own
|
||||||
SELECT COALESCE(t.owner_id, s.owner_id) AS pid,
|
SELECT COALESCE(t.owner_id, s.owner_id) AS pid,
|
||||||
-((CASE WHEN t.transaction_type IN ('debit', 'fee', 'interest') THEN t.amount ELSE -t.amount END) * ts.share_percent / 100) AS signed_amount,
|
-((CASE WHEN t.transaction_type IN ('debit', 'fee', 'interest') THEN COALESCE(t.amount_aud, t.amount) ELSE -COALESCE(t.amount_aud, t.amount) END) * ts.share_percent / 100) AS signed_amount,
|
||||||
0 AS split_count
|
0 AS split_count
|
||||||
FROM transaction_splits ts
|
FROM transaction_splits ts
|
||||||
JOIN transactions t ON t.id = ts.transaction_id
|
JOIN transactions t ON t.id = ts.transaction_id
|
||||||
|
|||||||
Reference in New Issue
Block a user