Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
684a6c8
feat(datarights): enrich export with currency snapshots and precision
ItsThompson Aug 15, 2026
2a3e7cd
feat(frontend): send transactionCurrency in correction payloads
ItsThompson Aug 15, 2026
317f42e
feat(frontend): render transaction and reporting money in expense detail
ItsThompson Aug 15, 2026
a74690d
fix(datarights): count default-settings currency fallback exports
ItsThompson Aug 15, 2026
abf7b78
feat(finance,expense): pro-rata schedule capture with trusted period …
ItsThompson Aug 15, 2026
fca1233
feat(expense): currency-aware corrections with money snapshots and al…
ItsThompson Aug 15, 2026
55dffdf
fix(finance,datarights): copy new module deps in Dockerfiles
ItsThompson Aug 15, 2026
e1a96f8
fix(expense,datarights,frontend): drop legacy amount/currency left by…
ItsThompson Aug 23, 2026
a99409e
feat(apierr): add Forbidden error constructor
ItsThompson Aug 31, 2026
196ada0
feat(shared): add validator package for declarative field validation
ItsThompson Aug 31, 2026
15feaa1
refactor(expense): split pro-rata contract from business logic and us…
ItsThompson Aug 31, 2026
5f98ec5
refactor(finance): use shared validator for pro-rata request validation
ItsThompson Aug 31, 2026
7fb3bcf
refactor(frontend): drop pass-through money snapshot getters
ItsThompson Aug 31, 2026
e0c6063
fix(finance,expense): copy shared/validator module into Docker build …
ItsThompson Aug 31, 2026
1c90424
refactor(expense): use shared validator for trusted period context ch…
ItsThompson Aug 31, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,7 @@ services:
environment:
- FINANCE_DB_URL=postgres://gofin:gofin@postgresql:5432/gofin?sslmode=disable&search_path=finance
- EXPENSE_SERVICE_ADDR=expense-service:9082
- FX_SERVICE_ADDR=fx-service:9085
- LOG_LEVEL=${LOG_LEVEL:-info}
- ENVIRONMENT=${ENVIRONMENT:-development}
- SENTRY_DSN_BACKEND=${SENTRY_DSN_BACKEND:-}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import type { Expense, Tag } from "@gofin/core";
import { CorrectionTimeline } from "../components/CorrectionTimeline";

const tags: Tag[] = [
{
id: "tag-food",
name: "Food",
isDefault: true,
createdAt: "2026-01-01T00:00:00Z",
updatedAt: "2026-01-01T00:00:00Z",
},
];

function buildExpense(overrides: Partial<Expense> = {}): Expense {
return {
id: "exp-1",
userId: "user-1",
name: "Groceries",
transactionCurrency: "USD",
transactionAmount: 5000,
reportingAmount: 5000,
reportingCurrency: "USD",
expenseType: "essentials",
tagId: "tag-food",
expenseDate: "2026-05-02",
periodYear: 2026,
periodMonth: 5,
status: "active",
isProRata: false,
createdAt: "2026-05-02T10:00:00Z",
...overrides,
};
}

describe("CorrectionTimeline", () => {
it("renders each row with its own transaction and reporting snapshot and status", () => {
const original = buildExpense({
id: "exp-original",
name: "Original",
status: "corrected",
transactionCurrency: "EUR",
transactionAmount: 1250,
reportingCurrency: "USD",
reportingAmount: 1364,
exchangeRate: "1.0912",
});
const correction = buildExpense({
id: "exp-correction",
name: "Updated Coffee",
status: "active",
correctsId: "exp-original",
transactionCurrency: "USD",
transactionAmount: 1400,
reportingCurrency: "USD",
reportingAmount: 1400,
exchangeRate: "1",
});

render(
<CorrectionTimeline
entries={[original, correction]}
currency="USD"
tags={tags}
currentExpenseId="exp-correction"
/>,
);

expect(screen.getByText("Original")).toBeInTheDocument();
expect(screen.getByText("Correction 1")).toBeInTheDocument();
expect(screen.getByText("Active")).toBeInTheDocument();
expect(screen.getAllByText("Corrected").length).toBeGreaterThanOrEqual(1);

// The foreign-currency original shows its transaction amount and its
// reporting amount in parentheses.
// €12.50 appears in the row and in the Amount change chip.
expect(screen.getAllByText(/€12\.50/).length).toBeGreaterThan(0);
expect(screen.getByText(/\(\$13\.64\)/)).toBeInTheDocument();
// The same-currency correction shows the period amount without parentheses.
expect(screen.getByText(/Updated Coffee · \$14\.00/)).toBeInTheDocument();
});

it("does not duplicate reporting amount for same-currency rows", () => {
const entry = buildExpense({
id: "exp-original",
transactionCurrency: "USD",
transactionAmount: 5000,
reportingCurrency: "USD",
reportingAmount: 5000,
exchangeRate: "1",
});

const { container } = render(
<CorrectionTimeline
entries={[entry]}
currency="USD"
tags={tags}
currentExpenseId="exp-original"
/>,
);

expect(screen.getByText(/\$50\.00/)).toBeInTheDocument();
expect(container.textContent).not.toContain("($50.00)");
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import type { Expense, Tag } from "@gofin/core";
import { DetailView } from "../components/DetailView";

const tags: Tag[] = [
{
id: "tag-food",
name: "Food",
isDefault: true,
createdAt: "2026-01-01T00:00:00Z",
updatedAt: "2026-01-01T00:00:00Z",
},
];

function buildExpense(overrides: Partial<Expense> = {}): Expense {
return {
id: "exp-1",
userId: "user-1",
name: "Groceries",
transactionCurrency: "USD",
transactionAmount: 5000,
reportingAmount: 5000,
reportingCurrency: "USD",
expenseType: "essentials",
tagId: "tag-food",
expenseDate: "2026-05-02",
periodYear: 2026,
periodMonth: 5,
status: "active",
isProRata: false,
createdAt: "2026-05-02T10:00:00Z",
...overrides,
};
}

function renderDetail(expense: Expense, history: Expense[] = [expense]) {
return render(
<DetailView
expense={expense}
currency="USD"
tags={tags}
history={history}
proRataGroup={[]}
currentYear={2026}
currentMonth={5}
onCorrectClick={vi.fn()}
/>,
);
}

describe("DetailView money display", () => {
it("labels same-currency amounts as the period amount without duplicate rows", () => {
renderDetail(
buildExpense({
transactionCurrency: "USD",
transactionAmount: 5000,
reportingCurrency: "USD",
reportingAmount: 5000,
exchangeRate: "1",
exchangeRateSource: "identity",
}),
);

expect(screen.getByText("Period Amount")).toBeInTheDocument();
expect(screen.getByText("$50.00")).toBeInTheDocument();
expect(screen.queryByText("Transaction Amount")).not.toBeInTheDocument();
expect(screen.queryByText("Budget Impact")).not.toBeInTheDocument();
});

it("shows transaction, budget impact, rate, and timestamp for foreign currency", () => {
renderDetail(
buildExpense({
transactionCurrency: "EUR",
transactionAmount: 1250,
reportingCurrency: "USD",
reportingAmount: 1364,
exchangeRate: "1.0912",
exchangeRateSource: "open_exchange_rates",
exchangeRateTimestamp: "2026-08-14T10:00:00Z",
}),
);

expect(screen.getByText("Transaction Amount")).toBeInTheDocument();
expect(screen.getByText("€12.50")).toBeInTheDocument();
expect(screen.getByText("Budget Impact")).toBeInTheDocument();
expect(screen.getByText("$13.64")).toBeInTheDocument();
expect(screen.getByText("Exchange Rate")).toBeInTheDocument();
expect(screen.getByText("1.0912")).toBeInTheDocument();
expect(screen.getByText("Rate Timestamp")).toBeInTheDocument();
expect(screen.getByText("2026-08-14T10:00:00Z")).toBeInTheDocument();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,7 @@ describe("computeChanges", () => {
id: "exp-2",
name: "Updated Groceries",
transactionAmount: 6000,
reportingAmount: 6000,
correctsId: "exp-1",
};

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { describe, it, expect } from "vitest";
import type { Expense } from "@gofin/core";
import { hasSameCurrencySnapshot } from "../utils/moneyFacts";

function buildExpense(overrides: Partial<Expense> = {}): Expense {
return {
id: "exp-1",
userId: "user-1",
name: "Groceries",
transactionCurrency: "USD",
transactionAmount: 5000,
reportingAmount: 5000,
reportingCurrency: "USD",
expenseType: "essentials",
tagId: "tag-food",
expenseDate: "2026-05-02",
periodYear: 2026,
periodMonth: 5,
status: "active",
isProRata: false,
createdAt: "2026-05-02T10:00:00Z",
...overrides,
};
}

describe("moneyFacts", () => {
it("detects same-currency snapshots", () => {
const expense = buildExpense({
transactionCurrency: "USD",
reportingCurrency: "USD",
});

expect(hasSameCurrencySnapshot(expense)).toBe(true);
});

it("detects foreign-currency snapshots", () => {
const expense = buildExpense({
transactionCurrency: "EUR",
reportingCurrency: "USD",
});

expect(hasSameCurrencySnapshot(expense)).toBe(false);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,8 @@ describe("useCorrectionForm", () => {
it("derives amountDollars correctly from cents", () => {
const expenseWithOddAmount: Expense = {
...mockExpense,
transactionAmount: 1299, // $12.99
transactionAmount: 1299,
reportingAmount: 1299,
};
const onSubmit = vi.fn();
const { result } = renderHook(() =>
Expand Down Expand Up @@ -227,6 +228,7 @@ describe("useCorrectionForm", () => {
expect(onSubmit).toHaveBeenCalledWith({
name: "Groceries",
amount: 5000,
transactionCurrency: "USD",
expenseType: "essentials",
tagId: "tag-food",
expenseDate: "2026-05-02",
Expand Down Expand Up @@ -388,6 +390,7 @@ describe("useCorrectionForm", () => {
expect(onSubmit).toHaveBeenCalledWith({
name: "Updated Name",
amount: 7550,
transactionCurrency: "USD",
expenseType: "desires",
tagId: "tag-transport",
expenseDate: "2026-05-10",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { formatCurrency } from "@gofin/core";
import type { Expense, Tag } from "@gofin/core";
import { ArrowRight } from "lucide-react";
import { computeChanges } from "../utils/computeChanges";
import { hasSameCurrencySnapshot } from "../utils/moneyFacts";

interface CorrectionTimelineProps {
entries: Expense[];
Expand Down Expand Up @@ -54,8 +55,21 @@ export function CorrectionTimeline({
</span>
</div>
<div className="text-muted-foreground">
{entry.name} · {formatCurrency(entry.transactionAmount, currency)} ·{" "}
{entry.expenseType} · {tagMap.get(entry.tagId) ?? entry.tagId}
{entry.name} ·{" "}
{formatCurrency(
entry.transactionAmount,
entry.transactionCurrency,
)}
{!hasSameCurrencySnapshot(entry) && (
<span className="text-muted-foreground/80">
{" "}
({formatCurrency(
entry.reportingAmount,
entry.reportingCurrency,
)})
</span>
)}{" "}
· {entry.expenseType} · {tagMap.get(entry.tagId) ?? entry.tagId}
</div>
{changes.length > 0 && (
<div className="mt-2 flex flex-wrap gap-1">
Expand Down
Loading
Loading