diff --git a/docker-compose.yml b/docker-compose.yml index 21f8eeb1..56992ca3 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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:-} diff --git a/frontend/apps/finance/src/features/expense-detail/__tests__/CorrectionTimeline.test.tsx b/frontend/apps/finance/src/features/expense-detail/__tests__/CorrectionTimeline.test.tsx new file mode 100644 index 00000000..fdfcef51 --- /dev/null +++ b/frontend/apps/finance/src/features/expense-detail/__tests__/CorrectionTimeline.test.tsx @@ -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 { + 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( + , + ); + + 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( + , + ); + + expect(screen.getByText(/\$50\.00/)).toBeInTheDocument(); + expect(container.textContent).not.toContain("($50.00)"); + }); +}); diff --git a/frontend/apps/finance/src/features/expense-detail/__tests__/DetailView.test.tsx b/frontend/apps/finance/src/features/expense-detail/__tests__/DetailView.test.tsx new file mode 100644 index 00000000..3f22fef0 --- /dev/null +++ b/frontend/apps/finance/src/features/expense-detail/__tests__/DetailView.test.tsx @@ -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 { + 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( + , + ); +} + +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(); + }); +}); diff --git a/frontend/apps/finance/src/features/expense-detail/__tests__/computeChanges.test.ts b/frontend/apps/finance/src/features/expense-detail/__tests__/computeChanges.test.ts index 7daefcd9..485951d5 100644 --- a/frontend/apps/finance/src/features/expense-detail/__tests__/computeChanges.test.ts +++ b/frontend/apps/finance/src/features/expense-detail/__tests__/computeChanges.test.ts @@ -169,6 +169,7 @@ describe("computeChanges", () => { id: "exp-2", name: "Updated Groceries", transactionAmount: 6000, + reportingAmount: 6000, correctsId: "exp-1", }; diff --git a/frontend/apps/finance/src/features/expense-detail/__tests__/moneyFacts.test.ts b/frontend/apps/finance/src/features/expense-detail/__tests__/moneyFacts.test.ts new file mode 100644 index 00000000..f7b93289 --- /dev/null +++ b/frontend/apps/finance/src/features/expense-detail/__tests__/moneyFacts.test.ts @@ -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 { + 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); + }); +}); diff --git a/frontend/apps/finance/src/features/expense-detail/__tests__/useCorrectionForm.test.ts b/frontend/apps/finance/src/features/expense-detail/__tests__/useCorrectionForm.test.ts index b7898f7a..34b18980 100644 --- a/frontend/apps/finance/src/features/expense-detail/__tests__/useCorrectionForm.test.ts +++ b/frontend/apps/finance/src/features/expense-detail/__tests__/useCorrectionForm.test.ts @@ -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(() => @@ -227,6 +228,7 @@ describe("useCorrectionForm", () => { expect(onSubmit).toHaveBeenCalledWith({ name: "Groceries", amount: 5000, + transactionCurrency: "USD", expenseType: "essentials", tagId: "tag-food", expenseDate: "2026-05-02", @@ -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", diff --git a/frontend/apps/finance/src/features/expense-detail/components/CorrectionTimeline.tsx b/frontend/apps/finance/src/features/expense-detail/components/CorrectionTimeline.tsx index 38b8e47b..11dc6ee7 100644 --- a/frontend/apps/finance/src/features/expense-detail/components/CorrectionTimeline.tsx +++ b/frontend/apps/finance/src/features/expense-detail/components/CorrectionTimeline.tsx @@ -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[]; @@ -54,8 +55,21 @@ export function CorrectionTimeline({
- {entry.name} · {formatCurrency(entry.transactionAmount, currency)} ·{" "} - {entry.expenseType} · {tagMap.get(entry.tagId) ?? entry.tagId} + {entry.name} ·{" "} + {formatCurrency( + entry.transactionAmount, + entry.transactionCurrency, + )} + {!hasSameCurrencySnapshot(entry) && ( + + {" "} + ({formatCurrency( + entry.reportingAmount, + entry.reportingCurrency, + )}) + + )}{" "} + · {entry.expenseType} · {tagMap.get(entry.tagId) ?? entry.tagId}
{changes.length > 0 && (
diff --git a/frontend/apps/finance/src/features/expense-detail/components/DetailView.tsx b/frontend/apps/finance/src/features/expense-detail/components/DetailView.tsx index 58be76c1..adbc2bec 100644 --- a/frontend/apps/finance/src/features/expense-detail/components/DetailView.tsx +++ b/frontend/apps/finance/src/features/expense-detail/components/DetailView.tsx @@ -3,6 +3,7 @@ import type { Expense, Tag } from "@gofin/core"; import { History, Pencil } from "lucide-react"; import { Button } from "@gofin/ui/components/button"; import { CorrectionTimeline } from "./CorrectionTimeline"; +import { hasSameCurrencySnapshot } from "../utils/moneyFacts"; interface DetailViewProps { expense: Expense; @@ -42,28 +43,64 @@ export function DetailView({ ? history.find((entry) => entry.id === expense.correctsId) : null; + const transactionCurrency = expense.transactionCurrency; + const transactionAmount = expense.transactionAmount; + const reportingCurrency = expense.reportingCurrency; + const reportingAmount = expense.reportingAmount; + const sameCurrency = hasSameCurrencySnapshot(expense); + return (
{correctedBy && (
This expense was corrected. See correction: {correctedBy.name} ( - {formatCurrency(correctedBy.transactionAmount, currency)}) + {formatCurrency( + correctedBy.transactionAmount, + correctedBy.transactionCurrency, + )} + )
)} {correctsEntry && (
This corrects expense: {correctsEntry.name} ( - {formatCurrency(correctsEntry.transactionAmount, currency)}) + {formatCurrency( + correctsEntry.transactionAmount, + correctsEntry.transactionCurrency, + )} + )
)}
- + {sameCurrency ? ( + + ) : ( + <> + + + {expense.exchangeRate && ( + + )} + {expense.exchangeRateTimestamp && ( + + )} + + )} - void, tags: Tag[] = [], ): { state: CorrectionFormState; actions: CorrectionFormActions } { - const [transactionCurrency, setTransactionCurrencyState] = useState(expense.transactionCurrency); + const transactionAmount = expense.transactionAmount; + const initialTransactionCurrency = expense.transactionCurrency; + const [transactionCurrency, setTransactionCurrencyState] = useState(initialTransactionCurrency); const expenseFields = useExpenseFields( { name: expense.name, - amountDollars: toMajorUnits(expense.transactionAmount, expense.transactionCurrency).toFixed( - getMinorUnitDigits(expense.transactionCurrency), + amountDollars: toMajorUnits(transactionAmount, initialTransactionCurrency).toFixed( + getMinorUnitDigits(initialTransactionCurrency), ), expenseType: expense.expenseType, tagId: expense.tagId, @@ -104,6 +106,7 @@ export function useCorrectionForm( expenseType: fields.expenseType, tagId: fields.tagId, expenseDate: fields.expenseDate, + transactionCurrency, }; onSubmit(body); diff --git a/frontend/apps/finance/src/features/expense-detail/utils/computeChanges.ts b/frontend/apps/finance/src/features/expense-detail/utils/computeChanges.ts index 225d7af2..381be1e8 100644 --- a/frontend/apps/finance/src/features/expense-detail/utils/computeChanges.ts +++ b/frontend/apps/finance/src/features/expense-detail/utils/computeChanges.ts @@ -10,6 +10,7 @@ export interface ExpenseChange { interface CorrectionValues { name: string; transactionAmount: number; + transactionCurrency?: string; expenseType: ExpenseType; tagId: string; expenseDate: string; @@ -36,8 +37,14 @@ export function computeChanges( if (original.transactionAmount !== corrected.transactionAmount) { changes.push({ field: "Amount", - from: formatCurrency(original.transactionAmount, currency), - to: formatCurrency(corrected.transactionAmount, currency), + from: formatCurrency( + original.transactionAmount, + original.transactionCurrency, + ), + to: formatCurrency( + corrected.transactionAmount, + corrected.transactionCurrency ?? currency, + ), }); } diff --git a/frontend/apps/finance/src/features/expense-detail/utils/moneyFacts.ts b/frontend/apps/finance/src/features/expense-detail/utils/moneyFacts.ts new file mode 100644 index 00000000..c6596887 --- /dev/null +++ b/frontend/apps/finance/src/features/expense-detail/utils/moneyFacts.ts @@ -0,0 +1,8 @@ +import type { Expense } from "@gofin/core"; + +/** + * Whether the expense's transaction and reporting currency snapshots match. + */ +export function hasSameCurrencySnapshot(expense: Expense): boolean { + return expense.transactionCurrency === expense.reportingCurrency; +} diff --git a/frontend/packages/core/src/types/expenses.ts b/frontend/packages/core/src/types/expenses.ts index 913ddc0a..29bbd1b9 100644 --- a/frontend/packages/core/src/types/expenses.ts +++ b/frontend/packages/core/src/types/expenses.ts @@ -29,9 +29,9 @@ export interface Expense { transactionAmount: number; /** Converted amount in the period reporting currency minor units. */ reportingAmount: number; - /** Budget period reporting currency for this ledger row. */ + /** Budget period reporting currency. */ reportingCurrency: string; - /** Source-to-target exchange rate used for this row. */ + /** Source-to-target exchange rate. */ exchangeRate?: string; /** "open_exchange_rates" | "identity" | "migration". */ exchangeRateSource?: string; @@ -65,6 +65,8 @@ export interface CorrectExpenseRequest { name: string; /** Amount in transaction currency minor units. */ amount: number; + /** Canonical transaction currency for the correction. */ + transactionCurrency?: string; expenseType: ExpenseType; tagId: string; /** ISO date string (YYYY-MM-DD). */ diff --git a/services/apierr/error.go b/services/apierr/error.go index b5e9ea62..41464ad3 100644 --- a/services/apierr/error.go +++ b/services/apierr/error.go @@ -26,6 +26,11 @@ func NotFound(msg string) *Error { return &Error{Code: CodeNotFound, Message: msg, Status: http.StatusNotFound} } +// Forbidden builds a 403 FORBIDDEN error. +func Forbidden(msg string) *Error { + return &Error{Code: CodeForbidden, Message: msg, Status: http.StatusForbidden} +} + // Validation builds a 400 VALIDATION_ERROR error carrying optional field detail. func Validation(msg string, fields map[string]string) *Error { return &Error{Code: CodeValidation, Message: msg, Status: http.StatusBadRequest, Fields: fields} diff --git a/services/apierr/error_test.go b/services/apierr/error_test.go index 21ace0aa..463e89ba 100644 --- a/services/apierr/error_test.go +++ b/services/apierr/error_test.go @@ -24,6 +24,7 @@ func TestConstructors_SetCodeAndStatus(t *testing.T) { }{ {"unauthorized", apierr.Unauthorized("no auth"), apierr.CodeUnauthorized, http.StatusUnauthorized, "no auth"}, {"not found", apierr.NotFound("gone"), apierr.CodeNotFound, http.StatusNotFound, "gone"}, + {"forbidden", apierr.Forbidden("no access"), apierr.CodeForbidden, http.StatusForbidden, "no access"}, {"validation", apierr.Validation("bad", nil), apierr.CodeValidation, http.StatusBadRequest, "bad"}, {"conflict", apierr.Conflict("DUPLICATE_TAG", "dup"), "DUPLICATE_TAG", http.StatusConflict, "dup"}, {"internal", apierr.Internal("boom"), apierr.CodeInternal, http.StatusInternalServerError, "boom"}, diff --git a/services/datarights/Dockerfile b/services/datarights/Dockerfile index a57b825c..9c1b94db 100644 --- a/services/datarights/Dockerfile +++ b/services/datarights/Dockerfile @@ -21,6 +21,7 @@ COPY apierr/go.mod apierr/go.sum* ./apierr/ COPY errkit/go.mod errkit/go.sum* ./errkit/ COPY httpx/go.mod httpx/go.sum* ./httpx/ COPY pgutil/go.mod pgutil/go.sum* ./pgutil/ +COPY shared/currency/go.mod ./shared/currency/ RUN --mount=type=cache,target=/go/pkg/mod \ cd datarights && GOWORK=off go mod download @@ -39,6 +40,7 @@ COPY apierr/ ./apierr/ COPY errkit/ ./errkit/ COPY httpx/ ./httpx/ COPY pgutil/ ./pgutil/ +COPY shared/currency/ ./shared/currency/ RUN --mount=type=cache,target=/go/pkg/mod \ --mount=type=cache,target=/root/.cache/go-build \ diff --git a/services/datarights/cmd/main.go b/services/datarights/cmd/main.go index b55773b7..77a328e7 100644 --- a/services/datarights/cmd/main.go +++ b/services/datarights/cmd/main.go @@ -124,7 +124,7 @@ func run() error { newExportProviders := func(financeData *financepb.AllUserDataResponse) []engine.DataProvider { return []engine.DataProvider{ providers.NewProfileProvider(authClient), - providers.NewExpensesProvider(expenseClient, providers.BuildTagMap(financeData)), + providers.NewExpensesProvider(expenseClient, providers.BuildTagMap(financeData), providers.BuildPeriodCurrencyMap(financeData)), providers.NewTagsProvider(financeData), providers.NewBudgetPeriodsProvider(financeData), providers.NewDefaultSettingsProvider(financeData), diff --git a/services/datarights/go.mod b/services/datarights/go.mod index 8d30de39..7685b283 100644 --- a/services/datarights/go.mod +++ b/services/datarights/go.mod @@ -14,6 +14,7 @@ require ( github.com/ItsThompson/gofin/services/perf v0.0.0 github.com/ItsThompson/gofin/services/pgutil v0.0.0 github.com/ItsThompson/gofin/services/serverkit v0.0.0 + github.com/ItsThompson/gofin/services/shared/currency v0.0.0 github.com/gin-gonic/gin v1.12.0 github.com/jackc/pgx/v5 v5.9.2 github.com/prometheus/client_golang v1.22.0 @@ -52,6 +53,8 @@ replace github.com/ItsThompson/gofin/services/pgutil => ../pgutil replace github.com/ItsThompson/gofin/services/serverkit => ../serverkit +replace github.com/ItsThompson/gofin/services/shared/currency => ../shared/currency + require ( github.com/ItsThompson/gofin/services/dbmigrate v0.0.0 // indirect github.com/ItsThompson/gofin/services/metrics v0.0.0 // indirect diff --git a/services/datarights/internal/engine/export_helpers_test.go b/services/datarights/internal/engine/export_helpers_test.go index c49eaa0a..c0f308e3 100644 --- a/services/datarights/internal/engine/export_helpers_test.go +++ b/services/datarights/internal/engine/export_helpers_test.go @@ -110,7 +110,7 @@ func buildRealProviders( ) []engine.DataProvider { return []engine.DataProvider{ providers.NewProfileProvider(auth), - providers.NewExpensesProvider(expense, providers.BuildTagMap(financeData)), + providers.NewExpensesProvider(expense, providers.BuildTagMap(financeData), providers.BuildPeriodCurrencyMap(financeData)), providers.NewTagsProvider(financeData), providers.NewBudgetPeriodsProvider(financeData), providers.NewDefaultSettingsProvider(financeData), @@ -145,7 +145,7 @@ func cannedAllUserData() *financepb.AllUserDataResponse { { Id: "period-1", Year: 2026, Month: 5, BudgetAmount: 250000, EssentialsPercent: 50, DesiresPercent: 30, SavingsPercent: 20, - CreatedAt: "2026-05-01T00:00:00Z", + ReportingCurrency: "USD", CreatedAt: "2026-05-01T00:00:00Z", }, }, Defaults: &financepb.DefaultsData{ @@ -167,13 +167,17 @@ func cannedExpensePages() []*expensepb.ExpenseListResponse { Id: "exp-1", Name: "Groceries", TransactionAmount: 4599, TransactionCurrency: "USD", ExpenseType: "essentials", TagId: "tag-1", ExpenseDate: "2026-05-01", PeriodYear: 2026, PeriodMonth: 5, Status: "active", - CreatedAt: "2026-05-01T12:00:00Z", + CreatedAt: "2026-05-01T12:00:00Z", + ReportingAmount: 4599, ReportingCurrency: "USD", + ExchangeRate: "1", ExchangeRateSource: "identity", ExchangeRateTimestamp: "2026-05-01T12:00:00Z", }, { Id: "exp-2", Name: "Bus pass", TransactionAmount: 3000, TransactionCurrency: "USD", ExpenseType: "essentials", TagId: "tag-2", ExpenseDate: "2026-05-02", PeriodYear: 2026, PeriodMonth: 5, Status: "active", - CreatedAt: "2026-05-02T09:00:00Z", + CreatedAt: "2026-05-02T09:00:00Z", + ReportingAmount: 3000, ReportingCurrency: "USD", + ExchangeRate: "1", ExchangeRateSource: "identity", ExchangeRateTimestamp: "2026-05-02T09:00:00Z", }, }, HasMore: false, diff --git a/services/datarights/internal/engine/providers/budget_periods.go b/services/datarights/internal/engine/providers/budget_periods.go index 9115ca57..8ef9d0e5 100644 --- a/services/datarights/internal/engine/providers/budget_periods.go +++ b/services/datarights/internal/engine/providers/budget_periods.go @@ -2,6 +2,7 @@ package providers import ( "context" + "fmt" "strconv" "github.com/ItsThompson/gofin/services/datarights/internal/engine" @@ -31,7 +32,7 @@ func (p *BudgetPeriodsProvider) Name() string { // Headers returns the CSV column headers for budget period data. func (p *BudgetPeriodsProvider) Headers() []string { return []string{ - "id", "year", "month", "budget_amount", + "id", "year", "month", "budget_amount", "reporting_currency", "essentials_percent", "desires_percent", "savings_percent", "created_at", } } @@ -42,11 +43,16 @@ func (p *BudgetPeriodsProvider) Collect(_ context.Context, _ string) ([][]string periods := p.data.GetPeriods() rows := make([][]string, 0, len(periods)) for _, period := range periods { + budgetAmount, err := formatMinorUnits(period.GetBudgetAmount(), period.GetReportingCurrency()) + if err != nil { + return nil, fmt.Errorf("period %s budget amount: %w", period.GetId(), err) + } rows = append(rows, []string{ period.GetId(), strconv.FormatInt(int64(period.GetYear()), 10), strconv.FormatInt(int64(period.GetMonth()), 10), - formatCentsToDollars(period.GetBudgetAmount()), + budgetAmount, + period.GetReportingCurrency(), strconv.FormatInt(int64(period.GetEssentialsPercent()), 10), strconv.FormatInt(int64(period.GetDesiresPercent()), 10), strconv.FormatInt(int64(period.GetSavingsPercent()), 10), diff --git a/services/datarights/internal/engine/providers/budget_periods_test.go b/services/datarights/internal/engine/providers/budget_periods_test.go index 8a21c1fd..25e362bd 100644 --- a/services/datarights/internal/engine/providers/budget_periods_test.go +++ b/services/datarights/internal/engine/providers/budget_periods_test.go @@ -18,35 +18,39 @@ func TestBudgetPeriodsProvider_Name(t *testing.T) { func TestBudgetPeriodsProvider_Headers(t *testing.T) { p := NewBudgetPeriodsProvider(nil) expected := []string{ - "id", "year", "month", "budget_amount", + "id", "year", "month", "budget_amount", "reporting_currency", "essentials_percent", "desires_percent", "savings_percent", "created_at", } assert.Equal(t, expected, p.Headers()) - assert.Len(t, p.Headers(), 8) + assert.Len(t, p.Headers(), 9) } func TestBudgetPeriodsProvider_Collect_Success(t *testing.T) { data := &financepb.AllUserDataResponse{ Periods: []*financepb.PeriodData{ { - Id: "period-1", + Id: "p1", Year: 2026, - Month: 5, - BudgetAmount: 500000, - EssentialsPercent: 50, - DesiresPercent: 30, - SavingsPercent: 20, - CreatedAt: "2026-05-01T00:00:00Z", + Month: 1, + BudgetAmount: 99, // 0.99 dollars + ReportingCurrency: "USD", + CreatedAt: "2026-01-01T00:00:00Z", }, { - Id: "period-2", + Id: "p2", Year: 2026, - Month: 4, - BudgetAmount: 450000, - EssentialsPercent: 60, - DesiresPercent: 25, - SavingsPercent: 15, - CreatedAt: "2026-04-01T00:00:00Z", + Month: 2, + BudgetAmount: 100000, // 1000.00 dollars + ReportingCurrency: "USD", + CreatedAt: "2026-02-01T00:00:00Z", + }, + { + Id: "p3", + Year: 2026, + Month: 3, + BudgetAmount: 12345, // 12345 yen, no decimals + ReportingCurrency: "JPY", + CreatedAt: "2026-03-01T00:00:00Z", }, }, } @@ -55,30 +59,25 @@ func TestBudgetPeriodsProvider_Collect_Success(t *testing.T) { rows, err := p.Collect(context.Background(), "user-123") require.NoError(t, err) - require.Len(t, rows, 2) + require.Len(t, rows, 3) - expected1 := []string{"period-1", "2026", "5", "5000.00", "50", "30", "20", "2026-05-01T00:00:00Z"} - expected2 := []string{"period-2", "2026", "4", "4500.00", "60", "25", "15", "2026-04-01T00:00:00Z"} + expected1 := []string{"p1", "2026", "1", "0.99", "USD", "0", "0", "0", "2026-01-01T00:00:00Z"} + expected2 := []string{"p2", "2026", "2", "1000.00", "USD", "0", "0", "0", "2026-02-01T00:00:00Z"} + expected3 := []string{"p3", "2026", "3", "12345", "JPY", "0", "0", "0", "2026-03-01T00:00:00Z"} assert.Equal(t, expected1, rows[0]) assert.Equal(t, expected2, rows[1]) + assert.Equal(t, expected3, rows[2]) } -func TestBudgetPeriodsProvider_Collect_AmountFormatting(t *testing.T) { +func TestBudgetPeriodsProvider_Collect_UnsupportedCurrencyFails(t *testing.T) { data := &financepb.AllUserDataResponse{ Periods: []*financepb.PeriodData{ { - Id: "p1", - Year: 2026, - Month: 1, - BudgetAmount: 99, // 0.99 dollars - CreatedAt: "2026-01-01T00:00:00Z", - }, - { - Id: "p2", - Year: 2026, - Month: 2, - BudgetAmount: 100000, // 1000.00 dollars - CreatedAt: "2026-02-01T00:00:00Z", + Id: "p1", + Year: 2026, + Month: 1, + BudgetAmount: 100, + ReportingCurrency: "XXX", }, }, } @@ -86,9 +85,9 @@ func TestBudgetPeriodsProvider_Collect_AmountFormatting(t *testing.T) { p := NewBudgetPeriodsProvider(data) rows, err := p.Collect(context.Background(), "user-123") - require.NoError(t, err) - assert.Equal(t, "0.99", rows[0][3]) - assert.Equal(t, "1000.00", rows[1][3]) + assert.Nil(t, rows) + require.Error(t, err) + assert.Contains(t, err.Error(), "unsupported currency") } func TestBudgetPeriodsProvider_Collect_EmptyData(t *testing.T) { diff --git a/services/datarights/internal/engine/providers/default_settings.go b/services/datarights/internal/engine/providers/default_settings.go index d6134362..a13c3ba8 100644 --- a/services/datarights/internal/engine/providers/default_settings.go +++ b/services/datarights/internal/engine/providers/default_settings.go @@ -5,6 +5,7 @@ import ( "strconv" "github.com/ItsThompson/gofin/services/datarights/internal/engine" + exportmetrics "github.com/ItsThompson/gofin/services/datarights/internal/metrics" "github.com/ItsThompson/gofin/services/finance/proto/financepb" ) @@ -45,8 +46,18 @@ func (p *DefaultSettingsProvider) Collect(_ context.Context, _ string) ([][]stri return [][]string{}, nil } + budgetAmount, err := formatMinorUnits(defaults.GetBudgetAmount(), defaults.GetCurrency()) + if err != nil { + // Legacy default settings may carry an unsupported currency. Render the + // amount with the pre-multi-currency two-decimal behavior instead of + // failing the whole data-rights export for a future-period default, and + // count the fallback so unsupported legacy rows stay observable. + exportmetrics.ExportCurrencyFormattingFallbackTotal.Inc() + budgetAmount = formatMinorUnitsWithDigits(defaults.GetBudgetAmount(), 2) + } + row := []string{ - formatCentsToDollars(defaults.GetBudgetAmount()), + budgetAmount, strconv.FormatInt(int64(defaults.GetEssentialsPercent()), 10), strconv.FormatInt(int64(defaults.GetDesiresPercent()), 10), strconv.FormatInt(int64(defaults.GetSavingsPercent()), 10), diff --git a/services/datarights/internal/engine/providers/default_settings_test.go b/services/datarights/internal/engine/providers/default_settings_test.go index 007c517c..ef9b069b 100644 --- a/services/datarights/internal/engine/providers/default_settings_test.go +++ b/services/datarights/internal/engine/providers/default_settings_test.go @@ -65,6 +65,42 @@ func TestDefaultSettingsProvider_Collect_AmountFormatting(t *testing.T) { assert.Equal(t, "45.99", rows[0][0]) } +func TestDefaultSettingsProvider_Collect_JPYHasNoDecimals(t *testing.T) { + data := &financepb.AllUserDataResponse{ + Defaults: &financepb.DefaultsData{ + BudgetAmount: 1250, + EssentialsPercent: 50, + DesiresPercent: 30, + SavingsPercent: 20, + Currency: "JPY", + }, + } + + p := NewDefaultSettingsProvider(data) + rows, err := p.Collect(context.Background(), "user-123") + + require.NoError(t, err) + require.Len(t, rows, 1) + assert.Equal(t, "1250", rows[0][0]) +} + +func TestDefaultSettingsProvider_Collect_UnsupportedCurrencyFallsBackToTwoDecimals(t *testing.T) { + data := &financepb.AllUserDataResponse{ + Defaults: &financepb.DefaultsData{ + BudgetAmount: 100, + Currency: "XXX", + }, + } + + p := NewDefaultSettingsProvider(data) + rows, err := p.Collect(context.Background(), "user-123") + + require.NoError(t, err) + require.Len(t, rows, 1) + assert.Equal(t, "1.00", rows[0][0]) + assert.Equal(t, "XXX", rows[0][4]) +} + func TestDefaultSettingsProvider_Collect_NilDefaults(t *testing.T) { data := &financepb.AllUserDataResponse{ Defaults: nil, diff --git a/services/datarights/internal/engine/providers/expenses.go b/services/datarights/internal/engine/providers/expenses.go index adf561c5..d9b3ec51 100644 --- a/services/datarights/internal/engine/providers/expenses.go +++ b/services/datarights/internal/engine/providers/expenses.go @@ -20,24 +20,36 @@ var _ engine.DataProvider = (*ExpensesProvider)(nil) // O(expensesPageSize) instead of O(total rows). const expensesPageSize = 100 +const ( + exchangeSourceIdentity = "identity" + exchangeSourceOpenExchangeRates = "open_exchange_rates" + exchangeSourceMigration = "migration" +) + // ExpensesProvider streams all user expenses with pagination and resolves tag // names from a tag map derived once from the shared per-job finance response. +// It also normalizes legacy migration rows to each period's reporting currency +// using the period currency map derived from the same response. type ExpensesProvider struct { - expenseClient expensepb.ExpenseServiceClient - tagMap map[string]string + expenseClient expensepb.ExpenseServiceClient + tagMap map[string]string + periodCurrencies map[string]string } // NewExpensesProvider creates an ExpensesProvider backed by the expense gRPC -// client. The tag map (tag id -> name) is derived once upfront from the shared -// finance response, so the expenses provider self-fetches only its expense -// stream and never calls finance itself. +// client. The tag map (tag id -> name) and period currency map ("year:month" -> +// reporting currency) are derived once upfront from the shared finance +// response, so the expenses provider self-fetches only its expense stream and +// never calls finance itself. func NewExpensesProvider( expenseClient expensepb.ExpenseServiceClient, tagMap map[string]string, + periodCurrencies map[string]string, ) *ExpensesProvider { return &ExpensesProvider{ - expenseClient: expenseClient, - tagMap: tagMap, + expenseClient: expenseClient, + tagMap: tagMap, + periodCurrencies: periodCurrencies, } } @@ -49,8 +61,10 @@ func (p *ExpensesProvider) Name() string { // Headers returns the CSV column headers for expense data. func (p *ExpensesProvider) Headers() []string { return []string{ - "id", "name", "transaction_amount", "transaction_currency", "expense_type", "tag_name", - "expense_date", "period_year", "period_month", "status", + "id", "name", "transaction_amount", "transaction_currency", + "reporting_amount", "reporting_currency", "exchange_rate", + "exchange_rate_source", "exchange_rate_timestamp", "expense_type", + "tag_name", "expense_date", "period_year", "period_month", "status", "corrects_id", "is_pro_rata", "pro_rata_group", "pro_rata_index", "pro_rata_total", "created_at", } @@ -66,7 +80,7 @@ func (p *ExpensesProvider) Headers() []string { // consumer itself at O(pageSize) (see the bounded-memory benchmark). func (p *ExpensesProvider) Collect(ctx context.Context, userID string) ([][]string, error) { var rows [][]string - if err := p.streamExpenses(ctx, userID, p.tagMap, func(row []string) error { + if err := p.streamExpenses(ctx, userID, func(row []string) error { rows = append(rows, row) return nil }); err != nil { @@ -88,7 +102,6 @@ func (p *ExpensesProvider) Collect(ctx context.Context, userID string) ([][]stri func (p *ExpensesProvider) streamExpenses( ctx context.Context, userID string, - tagMap map[string]string, emit func(row []string) error, ) error { // Derive a cancellable context and cancel on every return path so the gRPC @@ -119,19 +132,34 @@ func (p *ExpensesProvider) streamExpenses( return err } - if err := emit(p.formatRow(exp, tagMap)); err != nil { + row, err := p.formatRow(exp) + if err != nil { + return err + } + if err := emit(row); err != nil { return err } } } // formatRow converts a single expense into a CSV row with all transformations applied. -func (p *ExpensesProvider) formatRow(exp *expensepb.ExpenseData, tagMap map[string]string) []string { - tagName := resolveTagName(exp.GetTagId(), tagMap) - amount := formatCentsToDollars(exp.GetTransactionAmount()) - isProRata := formatBool(exp.GetIsProRata()) +func (p *ExpensesProvider) formatRow(exp *expensepb.ExpenseData) ([]string, error) { + snapshot, err := p.resolveSnapshot(exp) + if err != nil { + return nil, err + } + + transactionAmount, err := formatMinorUnits(snapshot.transactionAmount, snapshot.transactionCurrency) + if err != nil { + return nil, fmt.Errorf("expense %s transaction amount: %w", exp.GetId(), err) + } + reportingAmount, err := formatMinorUnits(snapshot.reportingAmount, snapshot.reportingCurrency) + if err != nil { + return nil, fmt.Errorf("expense %s reporting amount: %w", exp.GetId(), err) + } - // Pro-rata fields: render empty string for non-pro-rata expenses + tagName := resolveTagName(exp.GetTagId(), p.tagMap) + isProRata := formatBool(exp.GetIsProRata()) proRataGroup := exp.GetProRataGroup() proRataIndex := formatOptionalInt(exp.GetProRataIndex(), exp.GetIsProRata()) proRataTotal := formatOptionalInt(exp.GetProRataTotal(), exp.GetIsProRata()) @@ -139,8 +167,13 @@ func (p *ExpensesProvider) formatRow(exp *expensepb.ExpenseData, tagMap map[stri return []string{ exp.GetId(), exp.GetName(), - amount, - exp.GetTransactionCurrency(), + transactionAmount, + snapshot.transactionCurrency, + reportingAmount, + snapshot.reportingCurrency, + snapshot.exchangeRate, + snapshot.exchangeRateSource, + snapshot.exchangeRateTimestamp, exp.GetExpenseType(), tagName, exp.GetExpenseDate(), @@ -153,5 +186,72 @@ func (p *ExpensesProvider) formatRow(exp *expensepb.ExpenseData, tagMap map[stri proRataIndex, proRataTotal, exp.GetCreatedAt(), + }, nil +} + +// expenseSnapshot carries the resolved money fields for one export row. +type expenseSnapshot struct { + transactionAmount int64 + transactionCurrency string + reportingAmount int64 + reportingCurrency string + exchangeRate string + exchangeRateSource string + exchangeRateTimestamp string +} + +// resolveSnapshot selects the money facts to export for one expense row. +// +// Version-1 rows (identity or open_exchange_rates) must carry a complete +// snapshot; a missing required field fails the export rather than emitting +// incorrect money facts. Legacy migration rows are normalized to the period's +// reporting currency using the per-job period currency map. +func (p *ExpensesProvider) resolveSnapshot(exp *expensepb.ExpenseData) (expenseSnapshot, error) { + source := exp.GetExchangeRateSource() + + switch source { + case exchangeSourceIdentity, exchangeSourceOpenExchangeRates: + if exp.GetTransactionAmount() == 0 || exp.GetTransactionCurrency() == "" || + exp.GetReportingAmount() == 0 || exp.GetReportingCurrency() == "" || + exp.GetExchangeRate() == "" || exp.GetExchangeRateTimestamp() == "" { + return expenseSnapshot{}, fmt.Errorf("expense %s has an incomplete version 1 money snapshot", exp.GetId()) + } + return expenseSnapshot{ + transactionAmount: exp.GetTransactionAmount(), + transactionCurrency: exp.GetTransactionCurrency(), + reportingAmount: exp.GetReportingAmount(), + reportingCurrency: exp.GetReportingCurrency(), + exchangeRate: exp.GetExchangeRate(), + exchangeRateSource: source, + exchangeRateTimestamp: exp.GetExchangeRateTimestamp(), + }, nil + + case exchangeSourceMigration: + currency := p.resolvePeriodCurrency(exp) + if currency == "" { + return expenseSnapshot{}, fmt.Errorf("expense %s legacy row has no resolvable period reporting currency", exp.GetId()) + } + return expenseSnapshot{ + transactionAmount: exp.GetTransactionAmount(), + transactionCurrency: currency, + reportingAmount: exp.GetReportingAmount(), + reportingCurrency: currency, + exchangeRate: "1", + exchangeRateSource: exchangeSourceMigration, + exchangeRateTimestamp: exp.GetExchangeRateTimestamp(), + }, nil + + default: + return expenseSnapshot{}, fmt.Errorf("expense %s has invalid exchange_rate_source %q", exp.GetId(), source) + } +} + +// resolvePeriodCurrency returns the immutable reporting currency for the row's +// period, falling back to the reporting currency the expense stream already +// resolved when the period is absent from the finance response. +func (p *ExpensesProvider) resolvePeriodCurrency(exp *expensepb.ExpenseData) string { + if currency, ok := p.periodCurrencies[periodCurrencyKey(exp.GetPeriodYear(), exp.GetPeriodMonth())]; ok { + return currency } + return exp.GetReportingCurrency() } diff --git a/services/datarights/internal/engine/providers/expenses_stream_test.go b/services/datarights/internal/engine/providers/expenses_stream_test.go index bd70ef1b..dfab5d55 100644 --- a/services/datarights/internal/engine/providers/expenses_stream_test.go +++ b/services/datarights/internal/engine/providers/expenses_stream_test.go @@ -15,32 +15,35 @@ import ( ) // streamRowFixtures builds n expense rows for the stream in chronological order. -// Every row carries a tag id so tag resolution and formatRow run on each one, -// exercising the same per-row work the real consumer does. +// Every row carries a tag id and a complete version-1 identity snapshot so tag +// resolution and formatRow run on each one, exercising the same per-row work the +// real consumer does. func streamRowFixtures(n int) []*expensepb.ExpenseData { rows := make([]*expensepb.ExpenseData, n) for i := range rows { + amount := int64(1000 + i) rows[i] = &expensepb.ExpenseData{ - Id: fmt.Sprintf("exp-%08d", i), - Name: "Expense", - TransactionAmount: int64(1000 + i), - TransactionCurrency: "USD", - ExpenseType: "essentials", - TagId: "tag-1", - ExpenseDate: "2026-05-01", - PeriodYear: 2026, - PeriodMonth: 5, - Status: "active", - CreatedAt: fmt.Sprintf("2026-05-01T%02d:%02d:%02dZ", i/3600%24, i/60%60, i%60), + Id: fmt.Sprintf("exp-%08d", i), + Name: "Expense", + ExpenseType: "essentials", + TagId: "tag-1", + ExpenseDate: "2026-05-01", + PeriodYear: 2026, + PeriodMonth: 5, + Status: "active", + CreatedAt: fmt.Sprintf("2026-05-01T%02d:%02d:%02dZ", i/3600%24, i/60%60, i%60), + TransactionCurrency: "USD", + TransactionAmount: amount, + ReportingAmount: amount, + ReportingCurrency: "USD", + ExchangeRate: "1", + ExchangeRateSource: "identity", + ExchangeRateTimestamp: fmt.Sprintf("2026-05-01T%02d:%02d:%02dZ", i/3600%24, i/60%60, i%60), } } return rows } -func streamTagMap() map[string]string { - return map[string]string{"tag-1": "Food"} -} - // discardCSVSink returns an emit callback that writes each formatted row into a // csv.Writer backed by io.Discard, modelling the incremental ZIP write without // retaining any row. flush surfaces encoding errors. @@ -61,15 +64,14 @@ func discardCSVSink() (emit func([]string) error, flush func() error) { // still scale with row count because every row is formatted, which is expected. // The committed baseline records both shapes. func BenchmarkExpensesProvider_StreamIncrementalWrite(b *testing.B) { - tagMap := streamTagMap() for _, n := range []int{1000, 50000} { rows := streamRowFixtures(n) - p := NewExpensesProvider(&mockExpenseServiceClient{streamRows: rows}, nil) + p := NewExpensesProvider(&mockExpenseServiceClient{streamRows: rows}, nil, nil) b.Run(fmt.Sprintf("rows=%d", n), func(b *testing.B) { b.ReportAllocs() for b.Loop() { emit, flush := discardCSVSink() - if err := p.streamExpenses(context.Background(), "user-1", tagMap, emit); err != nil { + if err := p.streamExpenses(context.Background(), "user-1", emit); err != nil { b.Fatal(err) } if err := flush(); err != nil { @@ -126,7 +128,6 @@ func TestExpensesProvider_StreamedConsumptionIsMemoryBounded(t *testing.T) { // a hard structural floor well above this bound. noiseFloorBytes = 64 * 1024 ) - tagMap := streamTagMap() streamed := func(n int) func() any { return func() any { @@ -134,9 +135,9 @@ func TestExpensesProvider_StreamedConsumptionIsMemoryBounded(t *testing.T) { // not live during retainedHeapBytes' baseline (before) read; only what // the consumer retains after the call should count. rows := streamRowFixtures(n) - p := NewExpensesProvider(&mockExpenseServiceClient{streamRows: rows}, nil) + p := NewExpensesProvider(&mockExpenseServiceClient{streamRows: rows}, nil, nil) emit, flush := discardCSVSink() - require.NoError(t, p.streamExpenses(context.Background(), "user-1", tagMap, emit)) + require.NoError(t, p.streamExpenses(context.Background(), "user-1", emit)) require.NoError(t, flush()) return nil // the streamed consumer retains nothing } @@ -145,9 +146,9 @@ func TestExpensesProvider_StreamedConsumptionIsMemoryBounded(t *testing.T) { buffered := func(n int) func() any { return func() any { rows := streamRowFixtures(n) - p := NewExpensesProvider(&mockExpenseServiceClient{streamRows: rows}, nil) + p := NewExpensesProvider(&mockExpenseServiceClient{streamRows: rows}, nil, nil) var out [][]string - require.NoError(t, p.streamExpenses(context.Background(), "user-1", tagMap, func(row []string) error { + require.NoError(t, p.streamExpenses(context.Background(), "user-1", func(row []string) error { out = append(out, row) return nil })) @@ -185,14 +186,13 @@ func TestExpensesProvider_StreamedConsumptionIsMemoryBounded(t *testing.T) { // the stream. func TestExpensesProvider_StreamCancellation_StopsPromptly(t *testing.T) { const total = 1000 - tagMap := streamTagMap() - p := NewExpensesProvider(&mockExpenseServiceClient{streamRows: streamRowFixtures(total)}, nil) + p := NewExpensesProvider(&mockExpenseServiceClient{streamRows: streamRowFixtures(total)}, nil, nil) ctx, cancel := context.WithCancel(context.Background()) defer cancel() consumed := 0 - err := p.streamExpenses(ctx, "user-1", tagMap, func(_ []string) error { + err := p.streamExpenses(ctx, "user-1", func(_ []string) error { consumed++ if consumed == 3 { cancel() diff --git a/services/datarights/internal/engine/providers/expenses_test.go b/services/datarights/internal/engine/providers/expenses_test.go index f220befe..4de3b80c 100644 --- a/services/datarights/internal/engine/providers/expenses_test.go +++ b/services/datarights/internal/engine/providers/expenses_test.go @@ -11,21 +11,50 @@ import ( "github.com/ItsThompson/gofin/services/expense/proto/expensepb" ) +// buildVersion1Expense returns a complete version-1 identity expense snapshot, +// the shape the expense stream sends for post-cutover same-currency rows. +func buildVersion1Expense(id string, amount int64, currency string, overrides ...func(*expensepb.ExpenseData)) *expensepb.ExpenseData { + exp := &expensepb.ExpenseData{ + Id: id, + Name: "Groceries", + ExpenseType: "essentials", + TagId: "tag-1", + ExpenseDate: "2026-05-01", + PeriodYear: 2026, + PeriodMonth: 5, + Status: "active", + CreatedAt: "2026-05-01T12:00:00Z", + TransactionCurrency: currency, + TransactionAmount: amount, + ReportingAmount: amount, + ReportingCurrency: currency, + ExchangeRate: "1", + ExchangeRateSource: "identity", + ExchangeRateTimestamp: "2026-05-01T12:00:00Z", + } + for _, override := range overrides { + override(exp) + } + return exp +} + func TestExpensesProvider_Name(t *testing.T) { - p := NewExpensesProvider(nil, nil) + p := NewExpensesProvider(nil, nil, nil) assert.Equal(t, "expenses", p.Name()) } func TestExpensesProvider_Headers(t *testing.T) { - p := NewExpensesProvider(nil, nil) + p := NewExpensesProvider(nil, nil, nil) expected := []string{ - "id", "name", "transaction_amount", "transaction_currency", "expense_type", "tag_name", - "expense_date", "period_year", "period_month", "status", + "id", "name", "transaction_amount", "transaction_currency", + "reporting_amount", "reporting_currency", "exchange_rate", + "exchange_rate_source", "exchange_rate_timestamp", "expense_type", + "tag_name", "expense_date", "period_year", "period_month", "status", "corrects_id", "is_pro_rata", "pro_rata_group", "pro_rata_index", "pro_rata_total", "created_at", } assert.Equal(t, expected, p.Headers()) - assert.Len(t, p.Headers(), 16) + assert.Len(t, p.Headers(), 21) } func TestExpensesProvider_Collect_Success(t *testing.T) { @@ -33,32 +62,19 @@ func TestExpensesProvider_Collect_Success(t *testing.T) { expenseClient := &mockExpenseServiceClient{ streamRows: []*expensepb.ExpenseData{ - { - Id: "exp-1", - Name: "Groceries", - TransactionAmount: 4599, - TransactionCurrency: "USD", - ExpenseType: "essentials", - TagId: "tag-1", - ExpenseDate: "2026-05-01", - PeriodYear: 2026, - PeriodMonth: 5, - Status: "active", - IsProRata: false, - CreatedAt: "2026-05-01T12:00:00Z", - }, + buildVersion1Expense("exp-1", 4599, "USD"), }, } - p := NewExpensesProvider(expenseClient, tagMap) + p := NewExpensesProvider(expenseClient, tagMap, nil) rows, err := p.Collect(context.Background(), "user-123") require.NoError(t, err) require.Len(t, rows, 1) expected := []string{ - "exp-1", "Groceries", "45.99", "USD", "essentials", "Food", - "2026-05-01", "2026", "5", "active", "", "false", "", "", "", + "exp-1", "Groceries", "45.99", "USD", "45.99", "USD", "1", "identity", "2026-05-01T12:00:00Z", + "essentials", "Food", "2026-05-01", "2026", "5", "active", "", "false", "", "", "", "2026-05-01T12:00:00Z", } assert.Equal(t, expected, rows[0]) @@ -69,35 +85,27 @@ func TestExpensesProvider_Collect_ProRataExpense(t *testing.T) { expenseClient := &mockExpenseServiceClient{ streamRows: []*expensepb.ExpenseData{ - { - Id: "exp-pr-1", - Name: "Rent (1/3)", - TransactionAmount: 50000, - TransactionCurrency: "USD", - ExpenseType: "essentials", - TagId: "tag-1", - ExpenseDate: "2026-05-01", - PeriodYear: 2026, - PeriodMonth: 5, - Status: "active", - IsProRata: true, - ProRataGroup: "group-abc", - ProRataIndex: 1, - ProRataTotal: 3, - CreatedAt: "2026-05-01T10:00:00Z", - }, + buildVersion1Expense("exp-pr-1", 50000, "USD", func(exp *expensepb.ExpenseData) { + exp.Name = "Rent (1/3)" + exp.TagId = "tag-1" + exp.IsProRata = true + exp.ProRataGroup = "group-abc" + exp.ProRataIndex = 1 + exp.ProRataTotal = 3 + exp.CreatedAt = "2026-05-01T10:00:00Z" + }), }, } - p := NewExpensesProvider(expenseClient, tagMap) + p := NewExpensesProvider(expenseClient, tagMap, nil) rows, err := p.Collect(context.Background(), "user-123") require.NoError(t, err) require.Len(t, rows, 1) expected := []string{ - "exp-pr-1", "Rent (1/3)", "500.00", "USD", "essentials", "Rent", - "2026-05-01", "2026", "5", "active", "", "true", "group-abc", "1", "3", + "exp-pr-1", "Rent (1/3)", "500.00", "USD", "500.00", "USD", "1", "identity", "2026-05-01T12:00:00Z", + "essentials", "Rent", "2026-05-01", "2026", "5", "active", "", "true", "group-abc", "1", "3", "2026-05-01T10:00:00Z", } assert.Equal(t, expected, rows[0]) @@ -108,28 +116,23 @@ func TestExpensesProvider_Collect_MissingTagResolvesToUnknown(t *testing.T) { expenseClient := &mockExpenseServiceClient{ streamRows: []*expensepb.ExpenseData{ - { - Id: "exp-1", - Name: "Mystery", - TransactionAmount: 1000, - TransactionCurrency: "USD", - ExpenseType: "desires", - TagId: "deleted-tag-id", - ExpenseDate: "2026-01-15", - PeriodYear: 2026, - PeriodMonth: 1, - Status: "active", - CreatedAt: "2026-01-15T08:00:00Z", - }, + buildVersion1Expense("exp-1", 1000, "USD", func(exp *expensepb.ExpenseData) { + exp.Name = "Mystery" + exp.ExpenseType = "desires" + exp.TagId = "deleted-tag-id" + exp.ExpenseDate = "2026-01-15" + exp.PeriodMonth = 1 + exp.CreatedAt = "2026-01-15T08:00:00Z" + }), }, } - p := NewExpensesProvider(expenseClient, tagMap) + p := NewExpensesProvider(expenseClient, tagMap, nil) rows, err := p.Collect(context.Background(), "user-123") require.NoError(t, err) require.Len(t, rows, 1) - assert.Equal(t, "Unknown", rows[0][5]) // tag_name column + assert.Equal(t, "Unknown", rows[0][10]) // tag_name column } func TestExpensesProvider_Collect_MultipleRowsInStreamOrder(t *testing.T) { @@ -139,13 +142,25 @@ func TestExpensesProvider_Collect_MultipleRowsInStreamOrder(t *testing.T) { // created_at ASC, id ASC); the consumer does not page. expenseClient := &mockExpenseServiceClient{ streamRows: []*expensepb.ExpenseData{ - {Id: "exp-1", Name: "First", TransactionAmount: 100, TagId: "tag-1", PeriodYear: 2026, PeriodMonth: 1, CreatedAt: "2026-01-01T00:00:00Z"}, - {Id: "exp-2", Name: "Second", TransactionAmount: 200, TagId: "tag-1", PeriodYear: 2026, PeriodMonth: 1, CreatedAt: "2026-01-02T00:00:00Z"}, - {Id: "exp-3", Name: "Third", TransactionAmount: 300, TagId: "tag-1", PeriodYear: 2026, PeriodMonth: 2, CreatedAt: "2026-02-01T00:00:00Z"}, + buildVersion1Expense("exp-1", 100, "USD", func(exp *expensepb.ExpenseData) { + exp.Name = "First" + exp.PeriodMonth = 1 + exp.CreatedAt = "2026-01-01T00:00:00Z" + }), + buildVersion1Expense("exp-2", 200, "USD", func(exp *expensepb.ExpenseData) { + exp.Name = "Second" + exp.PeriodMonth = 1 + exp.CreatedAt = "2026-01-02T00:00:00Z" + }), + buildVersion1Expense("exp-3", 300, "USD", func(exp *expensepb.ExpenseData) { + exp.Name = "Third" + exp.PeriodMonth = 2 + exp.CreatedAt = "2026-02-01T00:00:00Z" + }), }, } - p := NewExpensesProvider(expenseClient, tagMap) + p := NewExpensesProvider(expenseClient, tagMap, nil) rows, err := p.Collect(context.Background(), "user-123") require.NoError(t, err) @@ -158,10 +173,199 @@ func TestExpensesProvider_Collect_MultipleRowsInStreamOrder(t *testing.T) { assert.Equal(t, int32(expensesPageSize), expenseClient.lastStreamReq.GetPageSize()) } +func TestExpensesProvider_Collect_ForeignCurrencyRow(t *testing.T) { + tagMap := map[string]string{"tag-1": "Food"} + + expenseClient := &mockExpenseServiceClient{ + streamRows: []*expensepb.ExpenseData{ + { + Id: "exp-fx", + Name: "Hotel", + ExpenseType: "essentials", + TagId: "tag-1", + ExpenseDate: "2026-05-01", + PeriodYear: 2026, + PeriodMonth: 5, + Status: "active", + CreatedAt: "2026-05-01T12:00:00Z", + TransactionCurrency: "EUR", + TransactionAmount: 1250, + ReportingAmount: 1364, + ReportingCurrency: "USD", + ExchangeRate: "1.0912", + ExchangeRateSource: "open_exchange_rates", + ExchangeRateTimestamp: "2026-08-14T10:00:00Z", + }, + }, + } + + p := NewExpensesProvider(expenseClient, tagMap, nil) + rows, err := p.Collect(context.Background(), "user-123") + + require.NoError(t, err) + require.Len(t, rows, 1) + assert.Equal(t, "12.50", rows[0][2]) // transaction_amount in EUR + assert.Equal(t, "EUR", rows[0][3]) // transaction_currency + assert.Equal(t, "13.64", rows[0][4]) // reporting_amount in USD + assert.Equal(t, "USD", rows[0][5]) // reporting_currency + assert.Equal(t, "1.0912", rows[0][6]) // exchange_rate + assert.Equal(t, "open_exchange_rates", rows[0][7]) + assert.Equal(t, "2026-08-14T10:00:00Z", rows[0][8]) +} + +func TestExpensesProvider_Collect_JPYHasNoForcedDecimals(t *testing.T) { + tagMap := map[string]string{"tag-1": "Food"} + + expenseClient := &mockExpenseServiceClient{ + streamRows: []*expensepb.ExpenseData{ + buildVersion1Expense("exp-jpy", 4599, "JPY"), + }, + } + + p := NewExpensesProvider(expenseClient, tagMap, nil) + rows, err := p.Collect(context.Background(), "user-123") + + require.NoError(t, err) + require.Len(t, rows, 1) + assert.Equal(t, "4599", rows[0][2]) + assert.Equal(t, "4599", rows[0][4]) + assert.Equal(t, "JPY", rows[0][3]) + assert.Equal(t, "JPY", rows[0][5]) +} + +func TestExpensesProvider_Collect_LegacyRowNormalizesToPeriodCurrency(t *testing.T) { + tagMap := map[string]string{"tag-1": "Food"} + periodCurrencies := map[string]string{"2026:5": "USD"} + + expenseClient := &mockExpenseServiceClient{ + streamRows: []*expensepb.ExpenseData{ + { + Id: "exp-legacy", + Name: "Groceries", + ExpenseType: "essentials", + TagId: "tag-1", + ExpenseDate: "2026-05-01", + PeriodYear: 2026, + PeriodMonth: 5, + Status: "active", + CreatedAt: "2026-05-01T12:00:00Z", + TransactionAmount: 4599, + TransactionCurrency: "EUR", + ReportingAmount: 4599, + ReportingCurrency: "EUR", + ExchangeRate: "1", + ExchangeRateSource: "migration", + ExchangeRateTimestamp: "2026-05-01T12:00:00Z", + }, + }, + } + + p := NewExpensesProvider(expenseClient, tagMap, periodCurrencies) + rows, err := p.Collect(context.Background(), "user-123") + + require.NoError(t, err) + require.Len(t, rows, 1) + assert.Equal(t, "45.99", rows[0][2]) + assert.Equal(t, "USD", rows[0][3]) + assert.Equal(t, "45.99", rows[0][4]) + assert.Equal(t, "USD", rows[0][5]) + assert.Equal(t, "1", rows[0][6]) + assert.Equal(t, "migration", rows[0][7]) + assert.Equal(t, "2026-05-01T12:00:00Z", rows[0][8]) +} + +func TestExpensesProvider_Collect_LegacyRowFallsBackToStreamCurrency(t *testing.T) { + tagMap := map[string]string{"tag-1": "Food"} + + expenseClient := &mockExpenseServiceClient{ + streamRows: []*expensepb.ExpenseData{ + { + Id: "exp-legacy", + Name: "Groceries", + ExpenseType: "essentials", + TagId: "tag-1", + ExpenseDate: "2026-05-01", + PeriodYear: 2026, + PeriodMonth: 5, + Status: "active", + CreatedAt: "2026-05-01T12:00:00Z", + TransactionAmount: 4599, + TransactionCurrency: "USD", + ReportingAmount: 4599, + ReportingCurrency: "USD", + ExchangeRate: "1", + ExchangeRateSource: "migration", + ExchangeRateTimestamp: "2026-05-01T12:00:00Z", + }, + }, + } + + // No period currency map: the provider trusts the reporting currency the + // expense stream already resolved. + p := NewExpensesProvider(expenseClient, tagMap, nil) + rows, err := p.Collect(context.Background(), "user-123") + + require.NoError(t, err) + require.Len(t, rows, 1) + assert.Equal(t, "USD", rows[0][3]) + assert.Equal(t, "USD", rows[0][5]) +} + +func TestExpensesProvider_Collect_Version1IncompleteSnapshotFails(t *testing.T) { + tagMap := map[string]string{"tag-1": "Food"} + + expenseClient := &mockExpenseServiceClient{ + streamRows: []*expensepb.ExpenseData{ + { + Id: "exp-bad", + Name: "Broken", + ExpenseType: "essentials", + TagId: "tag-1", + PeriodYear: 2026, + PeriodMonth: 5, + Status: "active", + TransactionCurrency: "USD", + TransactionAmount: 4599, + ReportingAmount: 4599, + ReportingCurrency: "USD", + ExchangeRate: "1", + ExchangeRateSource: "identity", + }, + }, + } + + p := NewExpensesProvider(expenseClient, tagMap, nil) + rows, err := p.Collect(context.Background(), "user-123") + + assert.Nil(t, rows) + require.Error(t, err) + assert.Contains(t, err.Error(), "fetching expenses") + assert.Contains(t, err.Error(), "incomplete version 1") +} + +func TestExpensesProvider_Collect_UnknownSourceFails(t *testing.T) { + tagMap := map[string]string{"tag-1": "Food"} + + expenseClient := &mockExpenseServiceClient{ + streamRows: []*expensepb.ExpenseData{ + buildVersion1Expense("exp-bad", 100, "USD", func(exp *expensepb.ExpenseData) { + exp.ExchangeRateSource = "" + }), + }, + } + + p := NewExpensesProvider(expenseClient, tagMap, nil) + rows, err := p.Collect(context.Background(), "user-123") + + assert.Nil(t, rows) + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid exchange_rate_source") +} + func TestExpensesProvider_Collect_EmptyData(t *testing.T) { expenseClient := &mockExpenseServiceClient{streamRows: nil} - p := NewExpensesProvider(expenseClient, map[string]string{}) + p := NewExpensesProvider(expenseClient, map[string]string{}, nil) rows, err := p.Collect(context.Background(), "user-123") require.NoError(t, err) @@ -173,7 +377,7 @@ func TestExpensesProvider_Collect_StreamOpenError(t *testing.T) { streamOpenErr: fmt.Errorf("connection refused"), } - p := NewExpensesProvider(expenseClient, map[string]string{}) + p := NewExpensesProvider(expenseClient, map[string]string{}, nil) rows, err := p.Collect(context.Background(), "user-123") assert.Nil(t, rows) @@ -188,14 +392,20 @@ func TestExpensesProvider_Collect_MidStreamRecvError(t *testing.T) { // (not be swallowed as a clean EOF). expenseClient := &mockExpenseServiceClient{ streamRows: []*expensepb.ExpenseData{ - {Id: "exp-1", TagId: "tag-1", PeriodYear: 2026, PeriodMonth: 1, CreatedAt: "2026-01-01T00:00:00Z"}, - {Id: "exp-2", TagId: "tag-1", PeriodYear: 2026, PeriodMonth: 1, CreatedAt: "2026-01-02T00:00:00Z"}, + buildVersion1Expense("exp-1", 100, "USD", func(exp *expensepb.ExpenseData) { + exp.PeriodMonth = 1 + exp.CreatedAt = "2026-01-01T00:00:00Z" + }), + buildVersion1Expense("exp-2", 200, "USD", func(exp *expensepb.ExpenseData) { + exp.PeriodMonth = 1 + exp.CreatedAt = "2026-01-02T00:00:00Z" + }), }, recvErr: fmt.Errorf("stream reset"), recvErrAt: 3, } - p := NewExpensesProvider(expenseClient, tagMap) + p := NewExpensesProvider(expenseClient, tagMap, nil) rows, err := p.Collect(context.Background(), "user-123") assert.Nil(t, rows) @@ -209,28 +419,20 @@ func TestExpensesProvider_Collect_CorrectedExpense(t *testing.T) { expenseClient := &mockExpenseServiceClient{ streamRows: []*expensepb.ExpenseData{ - { - Id: "exp-correction", - Name: "Groceries (corrected)", - TransactionAmount: 5099, - TransactionCurrency: "USD", - ExpenseType: "essentials", - TagId: "tag-1", - ExpenseDate: "2026-05-01", - PeriodYear: 2026, - PeriodMonth: 5, - Status: "corrected", - CorrectsId: "exp-original", - CreatedAt: "2026-05-02T09:00:00Z", - }, + buildVersion1Expense("exp-correction", 5099, "USD", func(exp *expensepb.ExpenseData) { + exp.Name = "Groceries (corrected)" + exp.Status = "corrected" + exp.CorrectsId = "exp-original" + exp.CreatedAt = "2026-05-02T09:00:00Z" + }), }, } - p := NewExpensesProvider(expenseClient, tagMap) + p := NewExpensesProvider(expenseClient, tagMap, nil) rows, err := p.Collect(context.Background(), "user-123") require.NoError(t, err) require.Len(t, rows, 1) - assert.Equal(t, "corrected", rows[0][9]) // status - assert.Equal(t, "exp-original", rows[0][10]) // corrects_id + assert.Equal(t, "corrected", rows[0][14]) // status + assert.Equal(t, "exp-original", rows[0][15]) // corrects_id } diff --git a/services/datarights/internal/engine/providers/format.go b/services/datarights/internal/engine/providers/format.go index e3399f12..c2d5f561 100644 --- a/services/datarights/internal/engine/providers/format.go +++ b/services/datarights/internal/engine/providers/format.go @@ -3,11 +3,52 @@ package providers import ( "fmt" "strconv" + + sharedcurrency "github.com/ItsThompson/gofin/services/shared/currency" ) -// formatCentsToDollars converts an amount in cents to a decimal string with 2 decimal places. -func formatCentsToDollars(cents int64) string { - return fmt.Sprintf("%.2f", float64(cents)/100.0) +// formatMinorUnits converts an integer minor-unit amount to a decimal string +// using the currency's minor-unit digit count. Zero-digit currencies such as +// JPY render as a plain integer with no forced decimal places. +func formatMinorUnits(amount int64, code string) (string, error) { + definition, ok := sharedcurrency.Get(code) + if !ok { + return "", fmt.Errorf("unsupported currency %q", code) + } + return formatMinorUnitsWithDigits(amount, definition.MinorUnitDigits), nil +} + +// formatMinorUnitsWithDigits scales an integer minor-unit amount into a decimal +// string with exactly the given number of fraction digits. Callers that already +// resolved a digit count (for example, the two-digit fallback for legacy +// default settings) use this directly. +func formatMinorUnitsWithDigits(amount int64, digits int) string { + if digits == 0 { + return strconv.FormatInt(amount, 10) + } + + factor := int64(1) + for range digits { + factor *= 10 + } + + negative := amount < 0 + if negative { + amount = -amount + } + + whole := amount / factor + fraction := amount % factor + fractionStr := strconv.FormatInt(fraction, 10) + for len(fractionStr) < digits { + fractionStr = "0" + fractionStr + } + + result := strconv.FormatInt(whole, 10) + "." + fractionStr + if negative { + return "-" + result + } + return result } // formatBool converts a boolean to "true" or "false" string. diff --git a/services/datarights/internal/engine/providers/format_test.go b/services/datarights/internal/engine/providers/format_test.go index be7edf51..c1d605c9 100644 --- a/services/datarights/internal/engine/providers/format_test.go +++ b/services/datarights/internal/engine/providers/format_test.go @@ -4,24 +4,58 @@ import ( "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) -func TestFormatCentsToDollars(t *testing.T) { +func TestFormatMinorUnits(t *testing.T) { tests := []struct { name string - cents int64 + amount int64 + code string expect string }{ - {name: "typical amount", cents: 4599, expect: "45.99"}, - {name: "zero", cents: 0, expect: "0.00"}, - {name: "one cent", cents: 1, expect: "0.01"}, - {name: "exact dollar", cents: 10000, expect: "100.00"}, - {name: "large amount", cents: 1234567, expect: "12345.67"}, + {name: "typical USD amount", amount: 4599, code: "USD", expect: "45.99"}, + {name: "zero", amount: 0, code: "USD", expect: "0.00"}, + {name: "one cent", amount: 1, code: "USD", expect: "0.01"}, + {name: "exact dollar", amount: 10000, code: "USD", expect: "100.00"}, + {name: "large amount", amount: 1234567, code: "USD", expect: "12345.67"}, + {name: "JPY has no forced decimals", amount: 1250, code: "JPY", expect: "1250"}, + {name: "JPY zero", amount: 0, code: "JPY", expect: "0"}, + {name: "negative amount", amount: -500, code: "USD", expect: "-5.00"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - assert.Equal(t, tt.expect, formatCentsToDollars(tt.cents)) + got, err := formatMinorUnits(tt.amount, tt.code) + require.NoError(t, err) + assert.Equal(t, tt.expect, got) + }) + } +} + +func TestFormatMinorUnits_UnsupportedCurrency(t *testing.T) { + _, err := formatMinorUnits(100, "XXX") + require.Error(t, err) + assert.Contains(t, err.Error(), "unsupported currency") +} + +func TestFormatMinorUnitsWithDigits(t *testing.T) { + tests := []struct { + name string + amount int64 + digits int + expect string + }{ + {name: "two digits", amount: 4599, digits: 2, expect: "45.99"}, + {name: "zero digits", amount: 4599, digits: 0, expect: "4599"}, + {name: "three digits", amount: 12345, digits: 3, expect: "12.345"}, + {name: "pads fraction", amount: 5, digits: 3, expect: "0.005"}, + {name: "negative", amount: -500, digits: 2, expect: "-5.00"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.expect, formatMinorUnitsWithDigits(tt.amount, tt.digits)) }) } } diff --git a/services/datarights/internal/engine/providers/mock_clients_test.go b/services/datarights/internal/engine/providers/mock_clients_test.go index 11c00251..5ed13ea2 100644 --- a/services/datarights/internal/engine/providers/mock_clients_test.go +++ b/services/datarights/internal/engine/providers/mock_clients_test.go @@ -73,6 +73,9 @@ func (m *mockExpenseServiceClient) CountExpensesByTag(_ context.Context, _ *expe func (m *mockExpenseServiceClient) CorrectExpense(_ context.Context, _ *expensepb.CorrectExpenseRequest, _ ...grpc.CallOption) (*expensepb.ExpenseResponse, error) { return nil, nil } +func (m *mockExpenseServiceClient) CreateProRataInstallment(_ context.Context, _ *expensepb.CreateProRataInstallmentRequest, _ ...grpc.CallOption) (*expensepb.ExpenseResponse, error) { + return nil, nil +} func (m *mockExpenseServiceClient) GetCorrectionHistory(_ context.Context, _ *expensepb.GetCorrectionHistoryRequest, _ ...grpc.CallOption) (*expensepb.CorrectionHistoryResponse, error) { return nil, nil } diff --git a/services/datarights/internal/engine/providers/periodmap.go b/services/datarights/internal/engine/providers/periodmap.go new file mode 100644 index 00000000..4cfcbd13 --- /dev/null +++ b/services/datarights/internal/engine/providers/periodmap.go @@ -0,0 +1,26 @@ +package providers + +import ( + "fmt" + + "github.com/ItsThompson/gofin/services/finance/proto/financepb" +) + +// BuildPeriodCurrencyMap derives a "year:month" -> reporting currency lookup +// from the shared per-job finance response. The expenses provider uses it to +// normalize legacy migration rows to each period's immutable reporting +// currency, without calling finance again during collection. +func BuildPeriodCurrencyMap(data *financepb.AllUserDataResponse) map[string]string { + periods := data.GetPeriods() + periodCurrencies := make(map[string]string, len(periods)) + for _, period := range periods { + periodCurrencies[periodCurrencyKey(period.GetYear(), period.GetMonth())] = period.GetReportingCurrency() + } + return periodCurrencies +} + +// periodCurrencyKey is the canonical key format shared by the expense service's +// own period-context cache, keeping the two services' lookups aligned. +func periodCurrencyKey(year, month int32) string { + return fmt.Sprintf("%d:%d", year, month) +} diff --git a/services/datarights/internal/engine/testdata/export/budget_periods.csv b/services/datarights/internal/engine/testdata/export/budget_periods.csv index 2f738223..60d80268 100644 --- a/services/datarights/internal/engine/testdata/export/budget_periods.csv +++ b/services/datarights/internal/engine/testdata/export/budget_periods.csv @@ -1,2 +1,2 @@ -id,year,month,budget_amount,essentials_percent,desires_percent,savings_percent,created_at -period-1,2026,5,2500.00,50,30,20,2026-05-01T00:00:00Z +id,year,month,budget_amount,reporting_currency,essentials_percent,desires_percent,savings_percent,created_at +period-1,2026,5,2500.00,USD,50,30,20,2026-05-01T00:00:00Z diff --git a/services/datarights/internal/engine/testdata/export/expenses.csv b/services/datarights/internal/engine/testdata/export/expenses.csv index 93085ede..57204846 100644 --- a/services/datarights/internal/engine/testdata/export/expenses.csv +++ b/services/datarights/internal/engine/testdata/export/expenses.csv @@ -1,3 +1,3 @@ -id,name,transaction_amount,transaction_currency,expense_type,tag_name,expense_date,period_year,period_month,status,corrects_id,is_pro_rata,pro_rata_group,pro_rata_index,pro_rata_total,created_at -exp-1,Groceries,45.99,USD,essentials,Food,2026-05-01,2026,5,active,,false,,,,2026-05-01T12:00:00Z -exp-2,Bus pass,30.00,USD,essentials,Transport,2026-05-02,2026,5,active,,false,,,,2026-05-02T09:00:00Z +id,name,transaction_amount,transaction_currency,reporting_amount,reporting_currency,exchange_rate,exchange_rate_source,exchange_rate_timestamp,expense_type,tag_name,expense_date,period_year,period_month,status,corrects_id,is_pro_rata,pro_rata_group,pro_rata_index,pro_rata_total,created_at +exp-1,Groceries,45.99,USD,45.99,USD,1,identity,2026-05-01T12:00:00Z,essentials,Food,2026-05-01,2026,5,active,,false,,,,2026-05-01T12:00:00Z +exp-2,Bus pass,30.00,USD,30.00,USD,1,identity,2026-05-02T09:00:00Z,essentials,Transport,2026-05-02,2026,5,active,,false,,,,2026-05-02T09:00:00Z diff --git a/services/datarights/internal/metrics/metrics.go b/services/datarights/internal/metrics/metrics.go index 14ac0894..9e2bb40f 100644 --- a/services/datarights/internal/metrics/metrics.go +++ b/services/datarights/internal/metrics/metrics.go @@ -39,6 +39,17 @@ var ( Help: "Total number of requests rejected due to 30-day cooldown", }, ) + + // ExportCurrencyFormattingFallbackTotal counts export rows rendered with the + // two-decimal fallback because the stored currency was unsupported. Only the + // default-settings provider falls back; budget periods and expenses fail the + // export instead of rendering with the wrong precision. + ExportCurrencyFormattingFallbackTotal = promauto.NewCounter( + prometheus.CounterOpts{ + Name: "export_currency_formatting_fallback_total", + Help: "Total number of export rows rendered with the two-decimal fallback for an unsupported currency", + }, + ) ) // --------------------------------------------------------------------------- diff --git a/services/datarights/internal/metrics/metrics_test.go b/services/datarights/internal/metrics/metrics_test.go index 853e30de..8eec01c0 100644 --- a/services/datarights/internal/metrics/metrics_test.go +++ b/services/datarights/internal/metrics/metrics_test.go @@ -30,6 +30,7 @@ func TestAllMetricsRegistered(t *testing.T) { "export_pool_active_jobs", "export_pool_queued_jobs", "export_rate_limit_rejections_total", + "export_currency_formatting_fallback_total", } foundMetrics := make(map[string]bool) @@ -69,6 +70,13 @@ func TestExportRateLimitRejectionsTotal_Increments(t *testing.T) { assert.GreaterOrEqual(t, value, float64(1)) } +func TestExportCurrencyFormattingFallbackTotal_Increments(t *testing.T) { + ExportCurrencyFormattingFallbackTotal.Inc() + + value := testutil.ToFloat64(ExportCurrencyFormattingFallbackTotal) + assert.GreaterOrEqual(t, value, float64(1)) +} + func TestExportPoolGauges_ReflectLivePoolStats(t *testing.T) { var active, queued atomic.Int64 SetPoolStats( diff --git a/services/expense/Dockerfile b/services/expense/Dockerfile index 97eaa199..e08ca83c 100644 --- a/services/expense/Dockerfile +++ b/services/expense/Dockerfile @@ -19,6 +19,7 @@ COPY httpx/go.mod httpx/go.sum* ./httpx/ COPY finance/go.mod finance/go.sum* ./finance/ COPY fx/go.mod fx/go.sum* ./fx/ COPY shared/currency/go.mod ./shared/currency/ +COPY shared/validator/go.mod ./shared/validator/ RUN --mount=type=cache,target=/go/pkg/mod \ cd expense && GOWORK=off go mod download @@ -35,6 +36,7 @@ COPY httpx/ ./httpx/ COPY finance/proto/ ./finance/proto/ COPY fx/proto/ ./fx/proto/ COPY shared/currency/ ./shared/currency/ +COPY shared/validator/ ./shared/validator/ RUN --mount=type=cache,target=/go/pkg/mod \ --mount=type=cache,target=/root/.cache/go-build \ diff --git a/services/expense/go.mod b/services/expense/go.mod index 9d5d0f46..b9a7df75 100644 --- a/services/expense/go.mod +++ b/services/expense/go.mod @@ -13,6 +13,7 @@ require ( github.com/ItsThompson/gofin/services/metrics v0.0.0 github.com/ItsThompson/gofin/services/serverkit v0.0.0 github.com/ItsThompson/gofin/services/shared/currency v0.0.0 + github.com/ItsThompson/gofin/services/shared/validator v0.0.0 github.com/codenotary/immudb v1.11.0 github.com/gin-gonic/gin v1.12.0 github.com/google/uuid v1.6.0 @@ -125,6 +126,8 @@ replace github.com/ItsThompson/gofin/services/finance => ../finance replace github.com/ItsThompson/gofin/services/shared/currency => ../shared/currency +replace github.com/ItsThompson/gofin/services/shared/validator => ../shared/validator + replace github.com/ItsThompson/gofin/services/errkit => ../errkit replace github.com/ItsThompson/gofin/services/fx => ../fx diff --git a/services/expense/internal/handler/grpc.go b/services/expense/internal/handler/grpc.go index cda02234..6eec63f3 100644 --- a/services/expense/internal/handler/grpc.go +++ b/services/expense/internal/handler/grpc.go @@ -31,13 +31,14 @@ type operation struct { } var ( - opCreate = operation{name: "expense.create", rpc: "CreateExpense"} - opList = operation{name: "expense.list", rpc: "GetExpensesForPeriod"} - opGet = operation{name: "expense.get", rpc: "GetExpense"} - opCorrect = operation{name: "expense.correct", rpc: "CorrectExpense"} - opCountByTag = operation{name: "expense.count_by_tag", rpc: "CountExpensesByTag"} - opStreamAll = operation{name: "expense.stream_all", rpc: "StreamAllUserExpenses"} - opAnonymize = operation{name: "expense.anonymize", rpc: "AnonymizeAllUserExpenses"} + opCreate = operation{name: "expense.create", rpc: "CreateExpense"} + opProRataInstallment = operation{name: "expense.create_pro_rata_installment", rpc: "CreateProRataInstallment"} + opList = operation{name: "expense.list", rpc: "GetExpensesForPeriod"} + opGet = operation{name: "expense.get", rpc: "GetExpense"} + opCorrect = operation{name: "expense.correct", rpc: "CorrectExpense"} + opCountByTag = operation{name: "expense.count_by_tag", rpc: "CountExpensesByTag"} + opStreamAll = operation{name: "expense.stream_all", rpc: "StreamAllUserExpenses"} + opAnonymize = operation{name: "expense.anonymize", rpc: "AnonymizeAllUserExpenses"} ) // GRPCHandler implements the ExpenseService gRPC server. Each RPC delegates to @@ -78,6 +79,51 @@ func (h *GRPCHandler) CreateExpense(ctx context.Context, req *pb.CreateExpenseRe }, nil } +func (h *GRPCHandler) CreateProRataInstallment(ctx context.Context, req *pb.CreateProRataInstallmentRequest) (*pb.ExpenseResponse, error) { + reqModel := &service.CreateProRataInstallmentRequest{ + UserID: req.GetUserId(), + Name: req.GetName(), + Amount: req.GetAmount(), + TransactionCurrency: req.GetTransactionCurrency(), + ExpenseType: req.GetExpenseType(), + TagID: req.GetTagId(), + ExpenseDate: req.GetExpenseDate(), + ProRataGroup: req.GetProRataGroup(), + ProRataIndex: req.GetProRataIndex(), + ProRataTotal: req.GetProRataTotal(), + } + if pc := req.GetPeriodContext(); pc != nil { + reqModel.PeriodContext = service.TrustedPeriodContext{ + PeriodID: pc.GetPeriodId(), + UserID: pc.GetUserId(), + Year: pc.GetYear(), + Month: pc.GetMonth(), + ReportingCurrency: pc.GetReportingCurrency(), + Source: pc.GetSource(), + } + } + if snap := req.GetCapturedRateSnapshot(); snap != nil { + reqModel.CapturedRateSnapshot = &service.CapturedRateSnapshot{ + SnapshotVersion: snap.GetSnapshotVersion(), + Source: snap.GetSource(), + BaseCurrency: snap.GetBaseCurrency(), + RateTimestamp: snap.GetRateTimestamp(), + CapturedAt: snap.GetCapturedAt(), + ExpiresAt: snap.GetExpiresAt(), + RatesByCurrency: snap.GetRatesByCurrency(), + } + } + + expense, err := h.expenseService.CreateProRataInstallment(ctx, reqModel) + if err != nil { + return nil, h.mapServiceError(ctx, err, opProRataInstallment, req.GetUserId()) + } + + return &pb.ExpenseResponse{ + Expense: expenseToProto(expense), + }, nil +} + func (h *GRPCHandler) GetExpensesForPeriod(ctx context.Context, req *pb.GetExpensesForPeriodRequest) (*pb.ExpenseListResponse, error) { result, err := h.expenseService.GetExpensesForPeriod(ctx, &model.GetExpensesRequest{ UserID: req.GetUserId(), @@ -117,11 +163,12 @@ func (h *GRPCHandler) GetExpense(ctx context.Context, req *pb.GetExpenseRequest) func (h *GRPCHandler) CorrectExpense(ctx context.Context, req *pb.CorrectExpenseRequest) (*pb.ExpenseResponse, error) { expense, err := h.expenseService.CorrectExpense(ctx, req.GetUserId(), req.GetExpenseId(), &model.CorrectExpenseRequest{ - Name: req.GetName(), - Amount: req.GetAmount(), - ExpenseType: req.GetExpenseType(), - TagID: req.GetTagId(), - ExpenseDate: req.GetExpenseDate(), + Name: req.GetName(), + Amount: req.GetAmount(), + TransactionCurrency: req.GetTransactionCurrency(), + ExpenseType: req.GetExpenseType(), + TagID: req.GetTagId(), + ExpenseDate: req.GetExpenseDate(), }) if err != nil { return nil, h.mapServiceError(ctx, err, opCorrect, req.GetUserId()) @@ -261,6 +308,8 @@ func (h *GRPCHandler) mapServiceError(ctx context.Context, err error, op operati // FX provider unavailable: a client-retryable outcome, not an internal // failure, so it is not reported. return status.Error(codes.Unavailable, apiErr.Message) + case model.ErrSnapshotCurrencyMissing: + return status.Error(codes.FailedPrecondition, apiErr.Message) default: reportServerFailure(ctx, err, errkit.Meta{ Op: op.name, diff --git a/services/expense/internal/handler/grpc_test.go b/services/expense/internal/handler/grpc_test.go index c5d58be7..c21bc89b 100644 --- a/services/expense/internal/handler/grpc_test.go +++ b/services/expense/internal/handler/grpc_test.go @@ -40,6 +40,14 @@ func (m *mockFxClient) ConvertAmount(ctx context.Context, req service.FxConvertR return args.Get(0).(*service.FxConvertResponse), args.Error(1) } +func (m *mockFxClient) ConvertWithSnapshot(ctx context.Context, req service.FxConvertWithSnapshotRequest) (*service.FxConvertResponse, error) { + args := m.Called(ctx, req) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*service.FxConvertResponse), args.Error(1) +} + // stubFxClient is a non-nil FxClient for handler tests that never exercise FX. // A call to it means a test accidentally routed a foreign-currency request. type stubFxClient struct{} @@ -48,6 +56,10 @@ func (stubFxClient) ConvertAmount(ctx context.Context, req service.FxConvertRequ return nil, fmt.Errorf("stubFxClient: unexpected ConvertAmount call") } +func (stubFxClient) ConvertWithSnapshot(ctx context.Context, req service.FxConvertWithSnapshotRequest) (*service.FxConvertResponse, error) { + return nil, fmt.Errorf("stubFxClient: unexpected ConvertWithSnapshot call") +} + // TestGRPC_RemovedReadRPCsAreNotRegistered asserts GetCorrectionHistory and // GetProRataGroup are served over REST, not gRPC. This guards against // re-introducing an unscoped read RPC on the gRPC surface. @@ -125,6 +137,79 @@ func TestGRPC_CreateExpense_UsesTransactionCurrency(t *testing.T) { repo.AssertExpectations(t) } +// TestGRPC_CorrectExpense_MapsTransactionCurrency asserts the gRPC correction +// handler maps transaction_currency into the service request. The deprecated +// currency alias is no longer mapped. +func TestGRPC_CorrectExpense_MapsTransactionCurrency(t *testing.T) { + repo := new(mockExpenseRepository) + periodClient := new(mockPeriodContextClient) + logger := slog.New(slog.NewJSONHandler(io.Discard, nil)) + now := time.Date(2026, 5, 3, 10, 0, 0, 0, time.UTC) + expenseSvc := service.NewExpenseService(repo, periodClient, &stubFxClient{}, func() time.Time { return now }, logger) + handler := NewGRPCHandler(expenseSvc) + + original := &model.Expense{ + ID: "exp-original", + UserID: "user-1", + Name: "Coffee", + TransactionCurrency: "USD", + ExpenseType: "desires", + TagID: "tag-food", + ExpenseDate: "2026-05-01", + PeriodYear: 2026, + PeriodMonth: 5, + Status: "active", + CreatedAt: "2026-05-01T10:00:00Z", + TransactionAmount: 500, + ReportingAmount: 500, + ReportingCurrency: "USD", + ExchangeRate: "1", + ExchangeRateSource: model.ExchangeSourceIdentity, + ExchangeRateTimestamp: "2026-05-01T10:00:00Z", + } + repo.On("GetExpenseByID", mock.Anything, "exp-original", "user-1").Return(original, nil) + periodClient.On("GetPeriodContext", mock.Anything, "user-1", int32(2026), int32(5)).Return(&service.PeriodContext{ + PeriodID: "period-1", + UserID: "user-1", + Year: 2026, + Month: 5, + ReportingCurrency: "USD", + }, nil) + + repo.On("CorrectExpense", mock.Anything, original, mock.MatchedBy(func(correction *model.Expense) bool { + return correction.TransactionCurrency == "USD" + })).Return(&model.Expense{ + ID: "exp-correction", + UserID: "user-1", + Name: "Updated Coffee", + TransactionCurrency: "USD", + ExpenseType: "desires", + TagID: "tag-food", + ExpenseDate: "2026-05-01", + PeriodYear: 2026, + PeriodMonth: 5, + Status: "active", + CorrectsID: "exp-original", + CreatedAt: "2026-05-03T10:00:00Z", + }, nil) + + resp, err := handler.CorrectExpense(context.Background(), &pb.CorrectExpenseRequest{ + ExpenseId: "exp-original", + UserId: "user-1", + Name: "Updated Coffee", + Amount: 600, + TransactionCurrency: "USD", + ExpenseType: "desires", + TagId: "tag-food", + ExpenseDate: "2026-05-01", + }) + + require.NoError(t, err) + require.NotNil(t, resp) + assert.Equal(t, "USD", resp.GetExpense().GetTransactionCurrency()) + repo.AssertExpectations(t) +} + func TestGRPC_CreateExpense_MissingPeriodReturnsNotFoundWithYearMonth(t *testing.T) { repo := new(mockExpenseRepository) periodClient := new(mockPeriodContextClient) diff --git a/services/expense/internal/handler/prorata_test.go b/services/expense/internal/handler/prorata_test.go new file mode 100644 index 00000000..a4321c61 --- /dev/null +++ b/services/expense/internal/handler/prorata_test.go @@ -0,0 +1,116 @@ +package handler + +import ( + "context" + "io" + "log/slog" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + + "github.com/ItsThompson/gofin/services/expense/internal/model" + "github.com/ItsThompson/gofin/services/expense/internal/service" + pb "github.com/ItsThompson/gofin/services/expense/proto/expensepb" +) + +func newProRataTestHandler(repo *mockExpenseRepository, fx service.FxClient) *GRPCHandler { + logger := slog.New(slog.NewJSONHandler(io.Discard, nil)) + svc := service.NewExpenseService(repo, newTestPeriodClient(), fx, time.Now, logger) + return NewGRPCHandler(svc) +} + +func TestGRPC_CreateProRataInstallment_Success(t *testing.T) { + repo := new(mockExpenseRepository) + fx := new(mockFxClient) + handler := newProRataTestHandler(repo, fx) + + fx.On("ConvertWithSnapshot", mock.Anything, mock.Anything).Return(&service.FxConvertResponse{ + ConvertedAmount: 3334, + ExchangeRate: "1", + RateTimestamp: "2026-05-15T10:00:00Z", + Source: model.ExchangeSourceOpenExchangeRates, + ExpiresAt: "2026-05-15T13:00:00Z", + }, nil) + + repo.On("CreateExpense", mock.Anything, mock.AnythingOfType("*model.Expense")). + Return(&model.Expense{ID: "exp-1", UserID: "user-1"}, nil) + + resp, err := handler.CreateProRataInstallment(context.Background(), &pb.CreateProRataInstallmentRequest{ + UserId: "user-1", + PeriodContext: &pb.TrustedPeriodContext{ + PeriodId: "period-1", + UserId: "user-1", + Year: 2026, + Month: 5, + ReportingCurrency: "USD", + Source: "finance_service", + }, + Name: "Annual subscription", + Amount: 3334, + TransactionCurrency: "USD", + ExpenseType: "essentials", + TagId: "tag-1", + ExpenseDate: "2026-05-15", + ProRataGroup: "group-1", + ProRataIndex: 1, + ProRataTotal: 3, + CapturedRateSnapshot: &pb.CapturedRateSnapshot{ + SnapshotVersion: 1, + Source: "open_exchange_rates", + BaseCurrency: "USD", + RateTimestamp: "2026-05-15T10:00:00Z", + CapturedAt: "2026-05-15T12:00:00Z", + ExpiresAt: "2026-05-15T13:00:00Z", + RatesByCurrency: map[string]string{"USD": "1"}, + }, + }) + + require.NoError(t, err) + assert.Equal(t, "exp-1", resp.GetExpense().GetId()) + repo.AssertExpectations(t) +} + +func TestGRPC_CreateProRataInstallment_MissingSnapshotCoverage(t *testing.T) { + repo := new(mockExpenseRepository) + fx := new(mockFxClient) + handler := newProRataTestHandler(repo, fx) + + _, err := handler.CreateProRataInstallment(context.Background(), &pb.CreateProRataInstallmentRequest{ + UserId: "user-1", + PeriodContext: &pb.TrustedPeriodContext{ + PeriodId: "period-1", + UserId: "user-1", + Year: 2026, + Month: 5, + ReportingCurrency: "USD", + Source: "finance_service", + }, + Name: "Annual subscription", + Amount: 3334, + TransactionCurrency: "EUR", + ExpenseType: "essentials", + TagId: "tag-1", + ExpenseDate: "2026-05-15", + ProRataGroup: "group-1", + ProRataIndex: 1, + ProRataTotal: 3, + CapturedRateSnapshot: &pb.CapturedRateSnapshot{ + SnapshotVersion: 1, + Source: "open_exchange_rates", + BaseCurrency: "USD", + RateTimestamp: "2026-05-15T10:00:00Z", + RatesByCurrency: map[string]string{"USD": "1"}, + }, + }) + + st, ok := status.FromError(err) + require.True(t, ok) + assert.Equal(t, codes.FailedPrecondition, st.Code()) + fx.AssertNotCalled(t, "ConvertWithSnapshot", mock.Anything, mock.Anything) + repo.AssertNotCalled(t, "CreateExpense", mock.Anything, mock.Anything) +} diff --git a/services/expense/internal/handler/rest_test.go b/services/expense/internal/handler/rest_test.go index c1034cff..1264a92e 100644 --- a/services/expense/internal/handler/rest_test.go +++ b/services/expense/internal/handler/rest_test.go @@ -627,10 +627,16 @@ func TestCorrectExpenseHandler_Success(t *testing.T) { original := &model.Expense{ ID: "exp-original", UserID: "user-1", Name: "Coffee", - ExpenseType: "desires", - TagID: "tag-food", ExpenseDate: "2026-05-01", + TransactionCurrency: "USD", ExpenseType: "desires", + TagID: "tag-food", ExpenseDate: "2026-05-01", PeriodYear: 2026, PeriodMonth: 5, Status: "active", - CreatedAt: "2026-05-01T10:00:00Z", + CreatedAt: "2026-05-01T10:00:00Z", + TransactionAmount: 500, + ReportingAmount: 500, + ReportingCurrency: "USD", + ExchangeRate: "1", + ExchangeRateSource: model.ExchangeSourceIdentity, + ExchangeRateTimestamp: "2026-05-01T10:00:00Z", } repo.On("GetExpenseByID", mock.Anything, "exp-original", "user-1").Return(original, nil) diff --git a/services/expense/internal/model/errors.go b/services/expense/internal/model/errors.go index 62a44465..57619995 100644 --- a/services/expense/internal/model/errors.go +++ b/services/expense/internal/model/errors.go @@ -12,4 +12,7 @@ const ( // be converted because the FX provider is unavailable (not yet wired or down). // No ledger row is written. Mapped to HTTP 503 / gRPC codes.Unavailable. ErrConversionUnavailable = "CONVERSION_UNAVAILABLE" + // ErrSnapshotCurrencyMissing is returned when a captured pro-rata snapshot + // lacks a rate needed to derive an installment reporting amount. + ErrSnapshotCurrencyMissing = "SNAPSHOT_CURRENCY_MISSING" ) diff --git a/services/expense/internal/model/requests.go b/services/expense/internal/model/requests.go index 29e950fe..8e00b696 100644 --- a/services/expense/internal/model/requests.go +++ b/services/expense/internal/model/requests.go @@ -49,11 +49,12 @@ type ExpenseListResponse struct { // CorrectExpenseRequest is the input for POST /api/expenses/:id/correct. type CorrectExpenseRequest struct { - Name string `json:"name"` - Amount int64 `json:"amount"` - ExpenseType string `json:"expenseType"` - TagID string `json:"tagId"` - ExpenseDate string `json:"expenseDate"` + Name string `json:"name"` + Amount int64 `json:"amount"` + TransactionCurrency string `json:"transactionCurrency"` + ExpenseType string `json:"expenseType"` + TagID string `json:"tagId"` + ExpenseDate string `json:"expenseDate"` } // CorrectionHistoryResponse is the response for GET /api/expenses/:id/history. diff --git a/services/expense/internal/service/expense.go b/services/expense/internal/service/expense.go index 3a2f8991..6d2c50a4 100644 --- a/services/expense/internal/service/expense.go +++ b/services/expense/internal/service/expense.go @@ -252,11 +252,48 @@ func (s *ExpenseService) CorrectExpense(ctx context.Context, userID string, expe } } - transactionCurrency := original.TransactionCurrency // correction currency is inherited, not changeable - createdAt := s.clock().UTC().Format(time.RFC3339) - // Same-currency identity is correct here: correction currency is inherited - // and foreign-currency corrections are not yet supported. - snapshot := buildIdentitySnapshot(req.Amount, transactionCurrency, original.ReportingCurrency, createdAt) + // Resolve the original expense's period reporting currency before any + // conversion. Corrections convert against the original period currency, not + // the current user currency or default settings (US-CORRECTION-02). + period, err := s.periodClient.GetPeriodContext(ctx, userID, original.PeriodYear, original.PeriodMonth) + if err != nil { + return nil, err + } + + reportingCurrency := normalizeCurrencyCode(period.ReportingCurrency) + if err := validateReportingCurrency(reportingCurrency); err != nil { + s.logger.Error("unsupported reporting currency from period context", + slog.String("event", "unsupported_reporting_currency"), + slog.String("reporting_currency", reportingCurrency), + ) + return nil, err + } + + transactionCurrency, err := s.resolveCorrectionTransactionCurrency(original, req) + if err != nil { + return nil, err + } + + nowTS := now.UTC().Format(time.RFC3339) + + // Resolve the correction snapshot before any ledger mutation. A foreign- + // currency correction calls FX first; on failure the original remains active + // and no correction row is appended. + var snapshot model.Expense + if transactionCurrency == reportingCurrency { + snapshot = buildIdentitySnapshot(req.Amount, transactionCurrency, reportingCurrency, nowTS) + } else { + fxResp, convErr := s.fxClient.ConvertAmount(ctx, FxConvertRequest{ + Amount: req.Amount, + SourceCurrency: transactionCurrency, + TargetCurrency: reportingCurrency, + RequestedAt: nowTS, + }) + if convErr != nil { + return nil, s.handleFxConversionFailure(convErr, transactionCurrency, reportingCurrency) + } + snapshot = buildProviderSnapshot(req.Amount, transactionCurrency, reportingCurrency, fxResp) + } correction := &model.Expense{ ID: uuid.New().String(), @@ -266,15 +303,15 @@ func (s *ExpenseService) CorrectExpense(ctx context.Context, userID string, expe ExpenseType: req.ExpenseType, TagID: req.TagID, ExpenseDate: req.ExpenseDate, - PeriodYear: original.PeriodYear, // Period is immutable - PeriodMonth: original.PeriodMonth, // Period is immutable + PeriodYear: original.PeriodYear, + PeriodMonth: original.PeriodMonth, Status: "active", CorrectsID: original.ID, IsProRata: original.IsProRata, ProRataGroup: original.ProRataGroup, ProRataIndex: original.ProRataIndex, ProRataTotal: original.ProRataTotal, - CreatedAt: createdAt, + CreatedAt: nowTS, TransactionAmount: snapshot.TransactionAmount, ReportingAmount: snapshot.ReportingAmount, ReportingCurrency: snapshot.ReportingCurrency, @@ -576,6 +613,18 @@ func (s *ExpenseService) resolveCreateTransactionCurrency(period *PeriodContext, return s.validateTransactionCurrency(transactionCurrency) } +func (s *ExpenseService) resolveCorrectionTransactionCurrency(original *model.Expense, req *model.CorrectExpenseRequest) (string, error) { + transactionCurrency := normalizeCurrencyCode(req.TransactionCurrency) + if transactionCurrency == "" { + transactionCurrency = normalizeCurrencyCode(original.TransactionCurrency) + s.logger.Info("correction currency preserved", + slog.String("event", "correction_currency_preserved"), + slog.String("transaction_currency", transactionCurrency), + ) + } + return s.validateTransactionCurrency(transactionCurrency) +} + func (s *ExpenseService) validateTransactionCurrency(currencyCode string) (string, error) { if currencycatalog.IsSupported(currencyCode) { return currencyCode, nil diff --git a/services/expense/internal/service/expense_test.go b/services/expense/internal/service/expense_test.go index 3aa067f8..6f6726f0 100644 --- a/services/expense/internal/service/expense_test.go +++ b/services/expense/internal/service/expense_test.go @@ -217,6 +217,14 @@ func (m *mockFxClient) ConvertAmount(ctx context.Context, req FxConvertRequest) return args.Get(0).(*FxConvertResponse), args.Error(1) } +func (m *mockFxClient) ConvertWithSnapshot(ctx context.Context, req FxConvertWithSnapshotRequest) (*FxConvertResponse, error) { + args := m.Called(ctx, req) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*FxConvertResponse), args.Error(1) +} + // stubFxClient is a non-nil FxClient for tests that never exercise FX. A call // to it means a same-currency test accidentally routed a foreign-currency // request, so it fails loudly instead of returning zero values. @@ -226,6 +234,10 @@ func (stubFxClient) ConvertAmount(ctx context.Context, req FxConvertRequest) (*F return nil, fmt.Errorf("stubFxClient: unexpected ConvertAmount call") } +func (stubFxClient) ConvertWithSnapshot(ctx context.Context, req FxConvertWithSnapshotRequest) (*FxConvertResponse, error) { + return nil, fmt.Errorf("stubFxClient: unexpected ConvertWithSnapshot call") +} + // TestCreateExpense_ForeignCurrencySuccessCallsFxAndWritesProviderSnapshot // asserts that when transactionCurrency != reportingCurrency, the service calls // FX ConvertAmount with the exact request, builds a provider snapshot from the @@ -951,22 +963,23 @@ func TestGetExpense_EmptyID(t *testing.T) { func activeExpenseInCurrentPeriod(now time.Time) *model.Expense { return &model.Expense{ - ID: "exp-original", - UserID: "user-1", - Name: "Coffee", - TransactionCurrency: "USD", - ExpenseType: "desires", - TagID: "tag-food", - ExpenseDate: now.Format("2006-01-02"), - PeriodYear: int32(now.Year()), - PeriodMonth: int32(now.Month()), - Status: "active", - CreatedAt: now.Format(time.RFC3339), - TransactionAmount: 500, - ReportingAmount: 500, - ReportingCurrency: "USD", - ExchangeRate: "1", - ExchangeRateSource: model.ExchangeSourceIdentity, + ID: "exp-original", + UserID: "user-1", + Name: "Coffee", + TransactionCurrency: "USD", + ExpenseType: "desires", + TagID: "tag-food", + ExpenseDate: now.Format("2006-01-02"), + PeriodYear: int32(now.Year()), + PeriodMonth: int32(now.Month()), + Status: "active", + CreatedAt: now.Format(time.RFC3339), + TransactionAmount: 500, + ReportingAmount: 500, + ReportingCurrency: "USD", + ExchangeRate: "1", + ExchangeRateSource: model.ExchangeSourceIdentity, + ExchangeRateTimestamp: now.Format(time.RFC3339), } } @@ -1162,6 +1175,135 @@ func TestCorrectExpense_ValidationErrors(t *testing.T) { } } +// --- Correction currency and snapshot tests --- + +// TestCorrectExpense_ForeignCurrencySuccessCallsFxAndWritesProviderSnapshot +// asserts that changing the transaction currency to a foreign currency calls FX +// before the ledger mutation and writes a provider snapshot on the correction row. +func TestCorrectExpense_ForeignCurrencySuccessCallsFxAndWritesProviderSnapshot(t *testing.T) { + repo := new(mockExpenseRepository) + periodClient := new(mockPeriodContextClient) + fxClient := new(mockFxClient) + now := time.Date(2026, 5, 3, 10, 0, 0, 0, time.UTC) + svc := newTestServiceWithFxClock(repo, periodClient, fxClient, now) + + original := activeExpenseInCurrentPeriod(now) + repo.On("GetExpenseByID", mock.Anything, "exp-original", "user-1").Return(original, nil) + + periodClient.On("GetPeriodContext", mock.Anything, "user-1", int32(2026), int32(5)).Return(&PeriodContext{ + PeriodID: "period-1", + UserID: "user-1", + Year: 2026, + Month: 5, + ReportingCurrency: "USD", + }, nil) + + requestedAt := now.UTC().Format(time.RFC3339) + fxResp := &FxConvertResponse{ + ConvertedAmount: 655, + ExchangeRate: "1.0912", + RateTimestamp: "2026-08-14T10:00:00Z", + Source: model.ExchangeSourceOpenExchangeRates, + ExpiresAt: "2026-08-14T11:00:00Z", + } + + fxClient.On("ConvertAmount", mock.Anything, mock.MatchedBy(func(req FxConvertRequest) bool { + return req.Amount == 600 && + req.SourceCurrency == "EUR" && + req.TargetCurrency == "USD" && + req.RequestedAt == requestedAt + })).Return(fxResp, nil) + + var captured *model.Expense + repo.On("CorrectExpense", mock.Anything, original, mock.AnythingOfType("*model.Expense")). + Run(func(args mock.Arguments) { + captured = args.Get(2).(*model.Expense) + }).Return(&model.Expense{ + ID: "exp-correction", + UserID: "user-1", + Name: "Updated Coffee", + TransactionCurrency: "EUR", + ExpenseType: "desires", + TagID: "tag-food", + ExpenseDate: "2026-05-03", + PeriodYear: 2026, + PeriodMonth: 5, + Status: "active", + CorrectsID: "exp-original", + CreatedAt: requestedAt, + TransactionAmount: 600, + ReportingAmount: 655, + ReportingCurrency: "USD", + ExchangeRate: "1.0912", + ExchangeRateSource: model.ExchangeSourceOpenExchangeRates, + ExchangeRateTimestamp: "2026-08-14T10:00:00Z", + ExchangeRateExpiresAt: "2026-08-14T11:00:00Z", + }, nil) + + req := validCorrectRequest() + req.TransactionCurrency = "EUR" + + resp, err := svc.CorrectExpense(context.Background(), "user-1", "exp-original", req) + + require.NoError(t, err) + require.NotNil(t, captured) + + assert.Equal(t, "EUR", captured.TransactionCurrency) + assert.Equal(t, "USD", captured.ReportingCurrency) + assert.Equal(t, int64(600), captured.TransactionAmount) + assert.Equal(t, int64(655), captured.ReportingAmount) + assert.Equal(t, "1.0912", captured.ExchangeRate) + assert.Equal(t, model.ExchangeSourceOpenExchangeRates, captured.ExchangeRateSource) + assert.Equal(t, "2026-08-14T10:00:00Z", captured.ExchangeRateTimestamp) + assert.Equal(t, "2026-08-14T11:00:00Z", captured.ExchangeRateExpiresAt) + + assert.Equal(t, int64(655), resp.ReportingAmount) + assert.Equal(t, "USD", resp.ReportingCurrency) + + fxClient.AssertExpectations(t) + repo.AssertExpectations(t) +} + +// TestCorrectExpense_ForeignCurrencyFxUnavailableDoesNotCorrect asserts that a +// failed foreign-currency conversion does not call the repository correction +// method, so the original remains active and correction history is unchanged. +func TestCorrectExpense_ForeignCurrencyFxUnavailableDoesNotCorrect(t *testing.T) { + repo := new(mockExpenseRepository) + periodClient := new(mockPeriodContextClient) + fxClient := new(mockFxClient) + now := time.Date(2026, 5, 3, 10, 0, 0, 0, time.UTC) + logger := slog.New(slog.NewJSONHandler(io.Discard, nil)) + svc := NewExpenseService(repo, periodClient, fxClient, func() time.Time { return now }, logger) + + original := activeExpenseInCurrentPeriod(now) + repo.On("GetExpenseByID", mock.Anything, "exp-original", "user-1").Return(original, nil) + + periodClient.On("GetPeriodContext", mock.Anything, "user-1", int32(2026), int32(5)).Return(&PeriodContext{ + PeriodID: "period-1", + UserID: "user-1", + Year: 2026, + Month: 5, + ReportingCurrency: "USD", + }, nil) + + fxClient.On("ConvertAmount", mock.Anything, mock.MatchedBy(func(req FxConvertRequest) bool { + return req.SourceCurrency == "EUR" && req.TargetCurrency == "USD" + })).Return(nil, conversionUnavailableError()) + + req := validCorrectRequest() + req.TransactionCurrency = "EUR" + + _, err := svc.CorrectExpense(context.Background(), "user-1", "exp-original", req) + + svcErr := requireAPIError(t, err) + assert.Equal(t, model.ErrConversionUnavailable, svcErr.Code) + assert.Equal(t, http.StatusServiceUnavailable, svcErr.Status) + + repo.AssertNotCalled(t, "CorrectExpense", mock.Anything, mock.Anything, mock.Anything) + assert.Equal(t, "active", original.Status) + fxClient.AssertExpectations(t) +} + // --- GetCorrectionHistory tests --- func TestGetCorrectionHistory_Success(t *testing.T) { diff --git a/services/expense/internal/service/fx_client.go b/services/expense/internal/service/fx_client.go index 7aded212..5bcb52f3 100644 --- a/services/expense/internal/service/fx_client.go +++ b/services/expense/internal/service/fx_client.go @@ -37,11 +37,34 @@ type FxConvertResponse struct { ExpiresAt string } +// FxCapturedRateSnapshot is the Expense-side view of a captured provider +// snapshot. It carries only the money facts needed for convert-with-snapshot. +type FxCapturedRateSnapshot struct { + SnapshotVersion int32 + Source string + BaseCurrency string + RateTimestamp string + CapturedAt string + ExpiresAt string + RatesByCurrency map[string]string +} + +// FxConvertWithSnapshotRequest derives a conversion from a previously captured +// snapshot instead of a live provider rate. +type FxConvertWithSnapshotRequest struct { + Amount int64 + SourceCurrency string + TargetCurrency string + RequestedAt string + Snapshot *FxCapturedRateSnapshot +} + // FxClient converts amounts between currencies using the FX Service. The // Expense service depends on this interface so tests can inject a mock; the // gRPC implementation is wired in main.go. type FxClient interface { ConvertAmount(ctx context.Context, req FxConvertRequest) (*FxConvertResponse, error) + ConvertWithSnapshot(ctx context.Context, req FxConvertWithSnapshotRequest) (*FxConvertResponse, error) } // GRPCFxClient implements FxClient over the FX Service gRPC API. @@ -66,11 +89,10 @@ func NewGRPCFxClientFromAddr(addr string) (*GRPCFxClient, *grpc.ClientConn, erro return NewGRPCFxClient(fxpb.NewFxServiceClient(conn)), conn, nil } -// ConvertAmount calls the FX Service ConvertAmount RPC and maps gRPC status -// errors to the Expense service's safe CONVERSION_UNAVAILABLE error. Per the -// spec error matrix, CONVERSION_UNAVAILABLE, provider auth failure, provider -// response invalid, and missing live rate all map to the same safe REST error -// and must not result in a ledger write. +// ConvertAmount converts an amount through the FX Service ConvertAmount RPC. +// Per the spec error matrix, CONVERSION_UNAVAILABLE, provider auth failure, +// provider response invalid, and missing live rate all map to the same safe +// REST error and must not result in a ledger write. func (c *GRPCFxClient) ConvertAmount(ctx context.Context, req FxConvertRequest) (*FxConvertResponse, error) { resp, err := c.client.ConvertAmount(ctx, &fxpb.ConvertAmountRequest{ Amount: req.Amount, @@ -90,6 +112,44 @@ func (c *GRPCFxClient) ConvertAmount(ctx context.Context, req FxConvertRequest) }, nil } +// ConvertWithSnapshot converts an amount through the FX Service +// ConvertWithSnapshot RPC. It never calls the provider: the snapshot is the +// caller's captured intent. +func (c *GRPCFxClient) ConvertWithSnapshot(ctx context.Context, req FxConvertWithSnapshotRequest) (*FxConvertResponse, error) { + resp, err := c.client.ConvertWithSnapshot(ctx, &fxpb.ConvertWithSnapshotRequest{ + Amount: req.Amount, + SourceCurrency: req.SourceCurrency, + TargetCurrency: req.TargetCurrency, + RequestedAt: req.RequestedAt, + Snapshot: snapshotToFxProto(req.Snapshot), + }) + if err != nil { + return nil, mapFxError(err) + } + return &FxConvertResponse{ + ConvertedAmount: resp.GetConvertedAmount(), + ExchangeRate: resp.GetExchangeRate(), + RateTimestamp: resp.GetRateTimestamp(), + Source: resp.GetSource(), + ExpiresAt: resp.GetExpiresAt(), + }, nil +} + +func snapshotToFxProto(s *FxCapturedRateSnapshot) *fxpb.CapturedRateSnapshot { + if s == nil { + return nil + } + return &fxpb.CapturedRateSnapshot{ + SnapshotVersion: s.SnapshotVersion, + Source: s.Source, + BaseCurrency: s.BaseCurrency, + RateTimestamp: s.RateTimestamp, + CapturedAt: s.CapturedAt, + ExpiresAt: s.ExpiresAt, + RatesByCurrency: s.RatesByCurrency, + } +} + // mapFxError maps an FX gRPC failure to the Expense service's REST error while // preserving the FX error category (spec 05). Expense validates the amount and // both currencies before calling FX, so InvalidArgument is normally unreachable, diff --git a/services/expense/internal/service/prorata.go b/services/expense/internal/service/prorata.go new file mode 100644 index 00000000..158affe3 --- /dev/null +++ b/services/expense/internal/service/prorata.go @@ -0,0 +1,115 @@ +package service + +import ( + "context" + "fmt" + "log/slog" + "time" + + "github.com/google/uuid" + + "github.com/ItsThompson/gofin/services/expense/internal/model" +) + +// CreateProRataInstallment writes a Finance-originated pro-rata installment +// ledger row. Unlike public CreateExpense, it does not call the Finance period +// context client: the period context and captured snapshot arrive from Finance +// and are validated here before any ledger write. +func (s *ExpenseService) CreateProRataInstallment(ctx context.Context, req *CreateProRataInstallmentRequest) (*model.Expense, error) { + if err := validateProRataInstallmentRequest(req); err != nil { + return nil, err + } + + if err := validateTrustedPeriodContext(req.UserID, req.PeriodContext); err != nil { + return nil, err + } + + reportingCurrency := normalizeCurrencyCode(req.PeriodContext.ReportingCurrency) + if err := validateReportingCurrency(reportingCurrency); err != nil { + return nil, err + } + + transactionCurrency, err := s.validateTransactionCurrency(normalizeCurrencyCode(req.TransactionCurrency)) + if err != nil { + return nil, err + } + + if err := validateSnapshotCoverage(req.CapturedRateSnapshot, transactionCurrency, reportingCurrency); err != nil { + return nil, err + } + + now := s.clock().UTC().Format(time.RFC3339) + + // Pro-rata installments always derive their reporting amount from the + // captured snapshot (spec 06), so same-currency first installments also go + // through ConvertWithSnapshot. FX returns source/timestamp from the snapshot, + // which keeps the first installment on the same snapshot facts as future rows. + fxResp, convErr := s.fxClient.ConvertWithSnapshot(ctx, FxConvertWithSnapshotRequest{ + Amount: req.Amount, + SourceCurrency: transactionCurrency, + TargetCurrency: reportingCurrency, + RequestedAt: now, + Snapshot: toFxCapturedRateSnapshot(req.CapturedRateSnapshot), + }) + if convErr != nil { + return nil, s.handleFxConversionFailure(convErr, transactionCurrency, reportingCurrency) + } + + snapshot := buildProviderSnapshot(req.Amount, transactionCurrency, reportingCurrency, fxResp) + + expense := &model.Expense{ + ID: uuid.New().String(), + UserID: req.UserID, + Name: req.Name, + TransactionCurrency: transactionCurrency, + ExpenseType: req.ExpenseType, + TagID: req.TagID, + ExpenseDate: req.ExpenseDate, + PeriodYear: req.PeriodContext.Year, + PeriodMonth: req.PeriodContext.Month, + Status: "active", + CorrectsID: "", + IsProRata: true, + ProRataGroup: req.ProRataGroup, + ProRataIndex: req.ProRataIndex, + ProRataTotal: req.ProRataTotal, + CreatedAt: now, + TransactionAmount: snapshot.TransactionAmount, + ReportingAmount: snapshot.ReportingAmount, + ReportingCurrency: snapshot.ReportingCurrency, + ExchangeRate: snapshot.ExchangeRate, + ExchangeRateSource: snapshot.ExchangeRateSource, + ExchangeRateTimestamp: snapshot.ExchangeRateTimestamp, + ExchangeRateExpiresAt: snapshot.ExchangeRateExpiresAt, + } + + created, err := s.repo.CreateExpense(ctx, expense) + if err != nil { + return nil, fmt.Errorf("creating pro-rata installment: %w", err) + } + + s.logger.Info("pro-rata installment created", + slog.String("method", "CreateProRataInstallment"), + slog.String("user_id", req.UserID), + slog.String("expense_id", created.ID), + slog.String("pro_rata_group", req.ProRataGroup), + slog.Int("pro_rata_index", int(req.ProRataIndex)), + ) + + return created, nil +} + +func toFxCapturedRateSnapshot(s *CapturedRateSnapshot) *FxCapturedRateSnapshot { + if s == nil { + return nil + } + return &FxCapturedRateSnapshot{ + SnapshotVersion: s.SnapshotVersion, + Source: s.Source, + BaseCurrency: s.BaseCurrency, + RateTimestamp: s.RateTimestamp, + CapturedAt: s.CapturedAt, + ExpiresAt: s.ExpiresAt, + RatesByCurrency: s.RatesByCurrency, + } +} diff --git a/services/expense/internal/service/prorata_request.go b/services/expense/internal/service/prorata_request.go new file mode 100644 index 00000000..f3598b2c --- /dev/null +++ b/services/expense/internal/service/prorata_request.go @@ -0,0 +1,132 @@ +package service + +import ( + "net/http" + + "github.com/ItsThompson/gofin/services/apierr" + "github.com/ItsThompson/gofin/services/expense/internal/model" + "github.com/ItsThompson/gofin/services/shared/validator" +) + +// TrustedPeriodContext is period context Finance resolved before asking Expense +// to write a pro-rata installment. Expense validates its consistency but never +// calls Finance again for Finance-originated writes. +type TrustedPeriodContext struct { + PeriodID string + UserID string + Year int32 + Month int32 + ReportingCurrency string + Source string +} + +// CapturedRateSnapshot is the USD-based provider snapshot captured at schedule +// creation and used to derive installment reporting amounts without live rates. +type CapturedRateSnapshot struct { + SnapshotVersion int32 + Source string + BaseCurrency string + RateTimestamp string + CapturedAt string + ExpiresAt string + RatesByCurrency map[string]string +} + +// CreateProRataInstallmentRequest is the Finance-originated internal write +// contract. It carries trusted period context and the captured snapshot so the +// Expense service can write a ledger row without a Finance round trip. +type CreateProRataInstallmentRequest struct { + UserID string + PeriodContext TrustedPeriodContext + Name string + Amount int64 + TransactionCurrency string + ExpenseType string + TagID string + ExpenseDate string + ProRataGroup string + ProRataIndex int32 + ProRataTotal int32 + CapturedRateSnapshot *CapturedRateSnapshot +} + +func validateProRataInstallmentRequest(req *CreateProRataInstallmentRequest) *apierr.Error { + v := validator.New() + + v.Check(req.UserID != "", "userId", "user_id is required") + v.Check(req.Name != "", "name", "name is required") + v.Check(req.Amount > 0, "amount", "amount must be positive") + v.Check(model.ValidExpenseTypes[req.ExpenseType], "expenseType", "expense_type must be one of: essentials, desires, savings") + v.Check(req.TagID != "", "tagId", "tag_id is required") + v.Check(req.ExpenseDate != "", "expenseDate", "expense_date is required") + v.Check(isoDateRegex.MatchString(req.ExpenseDate), "expenseDate", "expense_date must be in ISO format (YYYY-MM-DD)") + v.Check(req.ProRataGroup != "", "proRataGroup", "pro_rata_group is required") + v.Check(req.ProRataIndex >= 1, "proRataIndex", "pro_rata_index must be positive") + v.Check(req.ProRataTotal >= req.ProRataIndex, "proRataTotal", "pro_rata_total must be >= pro_rata_index") + + if v.HasErrors() { + return apierr.Validation("validation failed", v.Errors()) + } + return nil +} + +// validateTrustedPeriodContext enforces the trusted-context invariants: the +// context must originate from Finance, belong to the requesting user, and +// carry a valid year/month. A user mismatch is a security invariant breach +// (403 FORBIDDEN); a structural violation is an internal contract violation +// (500 INTERNAL_SERVER_ERROR). Neither is user input, so neither is a 400 +// VALIDATION_ERROR. +func validateTrustedPeriodContext(userID string, pc TrustedPeriodContext) *apierr.Error { + if pc.UserID != userID { + return &apierr.Error{ + Code: apierr.CodeForbidden, + Message: "trusted period context user does not match the request user", + Status: http.StatusForbidden, + Fields: map[string]string{"userId": "mismatch"}, + } + } + v := validator.New() + v.Check(pc.Source == "finance_service", "source", "must be finance_service") + v.Check(pc.PeriodID != "", "periodId", "required") + v.Check(pc.Year >= 1, "year", "must be positive") + v.Check(pc.Month >= 1 && pc.Month <= 12, "month", "must be between 1 and 12") + if v.HasErrors() { + return internalContractError("trusted period context is invalid", v.Errors()) + } + return nil +} + +// internalContractError builds a 500 for a trusted-context structural +// violation: the context is Finance's contract to uphold, so a malformed one +// is an internal bug, not caller input. +func internalContractError(msg string, fields map[string]string) *apierr.Error { + return &apierr.Error{ + Code: apierr.CodeInternal, + Message: msg, + Status: http.StatusInternalServerError, + Fields: fields, + } +} + +func validateSnapshotCoverage(snapshot *CapturedRateSnapshot, currencies ...string) *apierr.Error { + v := validator.New() + v.Check(snapshot != nil && len(snapshot.RatesByCurrency) > 0, "snapshot", "snapshot is required") + if snapshot != nil { + for _, currency := range currencies { + _, ok := snapshot.RatesByCurrency[currency] + v.Check(ok, currency, "currency is missing from the snapshot") + } + } + if v.HasErrors() { + return snapshotCurrencyMissingError() + } + return nil +} + +func snapshotCurrencyMissingError() *apierr.Error { + return &apierr.Error{ + Code: model.ErrSnapshotCurrencyMissing, + Message: "The captured rate snapshot does not contain a required currency", + Status: http.StatusConflict, + } +} diff --git a/services/expense/internal/service/prorata_test.go b/services/expense/internal/service/prorata_test.go new file mode 100644 index 00000000..66973c29 --- /dev/null +++ b/services/expense/internal/service/prorata_test.go @@ -0,0 +1,266 @@ +package service + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + + "github.com/ItsThompson/gofin/services/apierr" + "github.com/ItsThompson/gofin/services/expense/internal/model" +) + +func prorataSnapshot() *CapturedRateSnapshot { + return &CapturedRateSnapshot{ + SnapshotVersion: 1, + Source: "open_exchange_rates", + BaseCurrency: "USD", + RateTimestamp: "2026-05-15T10:00:00Z", + CapturedAt: "2026-05-15T12:00:00Z", + ExpiresAt: "2026-05-15T13:00:00Z", + RatesByCurrency: map[string]string{"USD": "1", "EUR": "0.92", "GBP": "0.79"}, + } +} + +func validProRataInstallmentRequest() *CreateProRataInstallmentRequest { + return &CreateProRataInstallmentRequest{ + UserID: "user-1", + PeriodContext: TrustedPeriodContext{ + PeriodID: "period-1", + UserID: "user-1", + Year: 2026, + Month: 5, + ReportingCurrency: "USD", + Source: "finance_service", + }, + Name: "Annual subscription", + Amount: 3334, + TransactionCurrency: "USD", + ExpenseType: "essentials", + TagID: "tag-1", + ExpenseDate: "2026-05-15", + ProRataGroup: "group-1", + ProRataIndex: 1, + ProRataTotal: 3, + CapturedRateSnapshot: prorataSnapshot(), + } +} + +func TestCreateProRataInstallment_ForeignCurrencyUsesCapturedSnapshot(t *testing.T) { + repo := new(mockExpenseRepository) + periodClient := new(mockPeriodContextClient) + fxClient := new(mockFxClient) + now := time.Date(2026, 5, 15, 12, 0, 0, 0, time.UTC) + svc := newTestServiceWithFxClock(repo, periodClient, fxClient, now) + + req := validProRataInstallmentRequest() + req.TransactionCurrency = "EUR" + + fxClient.On("ConvertWithSnapshot", mock.Anything, mock.MatchedBy(func(r FxConvertWithSnapshotRequest) bool { + return r.Amount == 3334 && + r.SourceCurrency == "EUR" && + r.TargetCurrency == "USD" && + r.Snapshot != nil && + r.Snapshot.RateTimestamp == "2026-05-15T10:00:00Z" + })).Return(&FxConvertResponse{ + ConvertedAmount: 3624, + ExchangeRate: "1.0872", + RateTimestamp: "2026-05-15T10:00:00Z", + Source: model.ExchangeSourceOpenExchangeRates, + ExpiresAt: "2026-05-15T13:00:00Z", + }, nil) + + var captured *model.Expense + repo.On("CreateExpense", mock.Anything, mock.AnythingOfType("*model.Expense")). + Run(func(args mock.Arguments) { + captured = args.Get(1).(*model.Expense) + }).Return(&model.Expense{ID: "exp-1", UserID: "user-1"}, nil) + + created, err := svc.CreateProRataInstallment(context.Background(), req) + + require.NoError(t, err) + require.NotNil(t, created) + require.NotNil(t, captured) + assert.Equal(t, "user-1", captured.UserID) + assert.Equal(t, int32(2026), captured.PeriodYear) + assert.Equal(t, int32(5), captured.PeriodMonth) + assert.True(t, captured.IsProRata) + assert.Equal(t, "group-1", captured.ProRataGroup) + assert.Equal(t, int32(1), captured.ProRataIndex) + assert.Equal(t, int32(3), captured.ProRataTotal) + assert.Equal(t, "EUR", captured.TransactionCurrency) + assert.Equal(t, int64(3334), captured.TransactionAmount) + assert.Equal(t, "USD", captured.ReportingCurrency) + assert.Equal(t, int64(3624), captured.ReportingAmount) + assert.Equal(t, model.ExchangeSourceOpenExchangeRates, captured.ExchangeRateSource) + assert.Equal(t, "2026-05-15T10:00:00Z", captured.ExchangeRateTimestamp) + + // Finance-originated writes must not call back into Finance for period context. + periodClient.AssertNotCalled(t, "GetPeriodContext", mock.Anything, mock.Anything, mock.Anything, mock.Anything) +} + +func TestCreateProRataInstallment_SameCurrencyStillUsesSnapshotSource(t *testing.T) { + repo := new(mockExpenseRepository) + periodClient := new(mockPeriodContextClient) + fxClient := new(mockFxClient) + now := time.Date(2026, 5, 15, 12, 0, 0, 0, time.UTC) + svc := newTestServiceWithFxClock(repo, periodClient, fxClient, now) + + fxClient.On("ConvertWithSnapshot", mock.Anything, mock.Anything).Return(&FxConvertResponse{ + ConvertedAmount: 3334, + ExchangeRate: "1", + RateTimestamp: "2026-05-15T10:00:00Z", + Source: model.ExchangeSourceOpenExchangeRates, + ExpiresAt: "2026-05-15T13:00:00Z", + }, nil) + + var captured *model.Expense + repo.On("CreateExpense", mock.Anything, mock.AnythingOfType("*model.Expense")). + Run(func(args mock.Arguments) { + captured = args.Get(1).(*model.Expense) + }).Return(&model.Expense{ID: "exp-1", UserID: "user-1"}, nil) + + _, err := svc.CreateProRataInstallment(context.Background(), validProRataInstallmentRequest()) + + require.NoError(t, err) + require.NotNil(t, captured) + assert.Equal(t, model.ExchangeSourceOpenExchangeRates, captured.ExchangeRateSource) + assert.Equal(t, "2026-05-15T10:00:00Z", captured.ExchangeRateTimestamp) + assert.Equal(t, int64(3334), captured.ReportingAmount) +} + +func TestCreateProRataInstallment_MismatchedContextUser(t *testing.T) { + repo := new(mockExpenseRepository) + periodClient := new(mockPeriodContextClient) + fxClient := new(mockFxClient) + svc := newTestServiceWithFxClock(repo, periodClient, fxClient, time.Date(2026, 5, 15, 12, 0, 0, 0, time.UTC)) + + req := validProRataInstallmentRequest() + req.PeriodContext.UserID = "user-2" + + _, err := svc.CreateProRataInstallment(context.Background(), req) + + svcErr := requireAPIError(t, err) + assert.Equal(t, apierr.CodeForbidden, svcErr.Code) + fxClient.AssertNotCalled(t, "ConvertWithSnapshot", mock.Anything, mock.Anything) + repo.AssertNotCalled(t, "CreateExpense", mock.Anything, mock.Anything) +} + +func TestCreateProRataInstallment_NonFinanceSource(t *testing.T) { + repo := new(mockExpenseRepository) + periodClient := new(mockPeriodContextClient) + fxClient := new(mockFxClient) + svc := newTestServiceWithFxClock(repo, periodClient, fxClient, time.Date(2026, 5, 15, 12, 0, 0, 0, time.UTC)) + + req := validProRataInstallmentRequest() + req.PeriodContext.Source = "browser" + + _, err := svc.CreateProRataInstallment(context.Background(), req) + + svcErr := requireAPIError(t, err) + assert.Equal(t, apierr.CodeInternal, svcErr.Code) + repo.AssertNotCalled(t, "CreateExpense", mock.Anything, mock.Anything) +} + +func TestCreateProRataInstallment_StructuralContextViolationsAreInternal(t *testing.T) { + cases := []struct { + name string + mut func(req *CreateProRataInstallmentRequest) + }{ + {"missing period id", func(req *CreateProRataInstallmentRequest) { req.PeriodContext.PeriodID = "" }}, + {"zero year", func(req *CreateProRataInstallmentRequest) { req.PeriodContext.Year = 0 }}, + {"month below range", func(req *CreateProRataInstallmentRequest) { req.PeriodContext.Month = 0 }}, + {"month above range", func(req *CreateProRataInstallmentRequest) { req.PeriodContext.Month = 13 }}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + repo := new(mockExpenseRepository) + periodClient := new(mockPeriodContextClient) + fxClient := new(mockFxClient) + svc := newTestServiceWithFxClock(repo, periodClient, fxClient, time.Date(2026, 5, 15, 12, 0, 0, 0, time.UTC)) + + req := validProRataInstallmentRequest() + tc.mut(req) + + _, err := svc.CreateProRataInstallment(context.Background(), req) + + svcErr := requireAPIError(t, err) + assert.Equal(t, apierr.CodeInternal, svcErr.Code) + fxClient.AssertNotCalled(t, "ConvertWithSnapshot", mock.Anything, mock.Anything) + repo.AssertNotCalled(t, "CreateExpense", mock.Anything, mock.Anything) + }) + } +} + +func TestCreateProRataInstallment_UnsupportedTransactionCurrency(t *testing.T) { + repo := new(mockExpenseRepository) + periodClient := new(mockPeriodContextClient) + fxClient := new(mockFxClient) + svc := newTestServiceWithFxClock(repo, periodClient, fxClient, time.Date(2026, 5, 15, 12, 0, 0, 0, time.UTC)) + + req := validProRataInstallmentRequest() + req.TransactionCurrency = "XYZ" + + _, err := svc.CreateProRataInstallment(context.Background(), req) + + svcErr := requireAPIError(t, err) + assert.Equal(t, model.ErrUnsupportedCurrency, svcErr.Code) + fxClient.AssertNotCalled(t, "ConvertWithSnapshot", mock.Anything, mock.Anything) + repo.AssertNotCalled(t, "CreateExpense", mock.Anything, mock.Anything) +} + +func TestCreateProRataInstallment_MissingSnapshotCoverage(t *testing.T) { + repo := new(mockExpenseRepository) + periodClient := new(mockPeriodContextClient) + fxClient := new(mockFxClient) + svc := newTestServiceWithFxClock(repo, periodClient, fxClient, time.Date(2026, 5, 15, 12, 0, 0, 0, time.UTC)) + + req := validProRataInstallmentRequest() + req.TransactionCurrency = "EUR" + delete(req.CapturedRateSnapshot.RatesByCurrency, "EUR") + + _, err := svc.CreateProRataInstallment(context.Background(), req) + + svcErr := requireAPIError(t, err) + assert.Equal(t, model.ErrSnapshotCurrencyMissing, svcErr.Code) + fxClient.AssertNotCalled(t, "ConvertWithSnapshot", mock.Anything, mock.Anything) + repo.AssertNotCalled(t, "CreateExpense", mock.Anything, mock.Anything) +} + +func TestCreateProRataInstallment_NilSnapshot(t *testing.T) { + repo := new(mockExpenseRepository) + periodClient := new(mockPeriodContextClient) + fxClient := new(mockFxClient) + svc := newTestServiceWithFxClock(repo, periodClient, fxClient, time.Date(2026, 5, 15, 12, 0, 0, 0, time.UTC)) + + req := validProRataInstallmentRequest() + req.CapturedRateSnapshot = nil + + _, err := svc.CreateProRataInstallment(context.Background(), req) + + svcErr := requireAPIError(t, err) + assert.Equal(t, model.ErrSnapshotCurrencyMissing, svcErr.Code) + repo.AssertNotCalled(t, "CreateExpense", mock.Anything, mock.Anything) +} + +func TestCreateProRataInstallment_FxFailureDoesNotWrite(t *testing.T) { + repo := new(mockExpenseRepository) + periodClient := new(mockPeriodContextClient) + fxClient := new(mockFxClient) + svc := newTestServiceWithFxClock(repo, periodClient, fxClient, time.Date(2026, 5, 15, 12, 0, 0, 0, time.UTC)) + + req := validProRataInstallmentRequest() + req.TransactionCurrency = "EUR" + + fxClient.On("ConvertWithSnapshot", mock.Anything, mock.Anything).Return(nil, conversionUnavailableError()) + + _, err := svc.CreateProRataInstallment(context.Background(), req) + + svcErr := requireAPIError(t, err) + assert.Equal(t, model.ErrConversionUnavailable, svcErr.Code) + repo.AssertNotCalled(t, "CreateExpense", mock.Anything, mock.Anything) +} diff --git a/services/expense/proto/expense.proto b/services/expense/proto/expense.proto index de33f9c0..cbb7cd0f 100644 --- a/services/expense/proto/expense.proto +++ b/services/expense/proto/expense.proto @@ -6,6 +6,7 @@ option go_package = "github.com/ItsThompson/gofin/services/expense/proto/expense service ExpenseService { rpc CreateExpense(CreateExpenseRequest) returns (ExpenseResponse); + rpc CreateProRataInstallment(CreateProRataInstallmentRequest) returns (ExpenseResponse); rpc GetExpensesForPeriod(GetExpensesForPeriodRequest) returns (ExpenseListResponse); rpc GetExpense(GetExpenseRequest) returns (ExpenseResponse); @@ -82,6 +83,48 @@ message ExpenseResponse { ExpenseData expense = 1; } +// TrustedPeriodContext is period context Finance resolved before asking Expense +// to write a pro-rata installment. Expense validates its consistency but does +// not call Finance again for Finance-originated pro-rata writes. +message TrustedPeriodContext { + string period_id = 1; + string user_id = 2; + int32 year = 3; + int32 month = 4; + string reporting_currency = 5; + string source = 6; +} + +// CapturedRateSnapshot is a full USD-based provider snapshot captured at +// pro-rata schedule creation. Future installments derive target reporting +// amounts from this snapshot instead of live rates. +message CapturedRateSnapshot { + int32 snapshot_version = 1; + string source = 2; + string base_currency = 3; + string rate_timestamp = 4; + string captured_at = 5; + string expires_at = 6; + map rates_by_currency = 7; +} + +// CreateProRataInstallmentRequest is the Finance-originated internal write +// contract for the first (and future) pro-rata installments. +message CreateProRataInstallmentRequest { + string user_id = 1; + TrustedPeriodContext period_context = 2; + string name = 3; + int64 amount = 4; + string transaction_currency = 5; + string expense_type = 6; + string tag_id = 7; + string expense_date = 8; + string pro_rata_group = 9; + int32 pro_rata_index = 10; + int32 pro_rata_total = 11; + CapturedRateSnapshot captured_rate_snapshot = 12; +} + // --------------------------------------------------------------------------- // GetExpensesForPeriod // --------------------------------------------------------------------------- @@ -123,6 +166,8 @@ message CorrectExpenseRequest { string expense_type = 5; string tag_id = 6; string expense_date = 7; + string transaction_currency = 8; + string currency = 9; // Deprecated alias during rollout. } // --------------------------------------------------------------------------- diff --git a/services/expense/proto/expensepb/expense.pb.go b/services/expense/proto/expensepb/expense.pb.go index 322d6b27..ecf37783 100644 --- a/services/expense/proto/expensepb/expense.pb.go +++ b/services/expense/proto/expensepb/expense.pb.go @@ -426,6 +426,322 @@ func (x *ExpenseResponse) GetExpense() *ExpenseData { return nil } +// TrustedPeriodContext is period context Finance resolved before asking Expense +// to write a pro-rata installment. Expense validates its consistency but does +// not call Finance again for Finance-originated pro-rata writes. +type TrustedPeriodContext struct { + state protoimpl.MessageState `protogen:"open.v1"` + PeriodId string `protobuf:"bytes,1,opt,name=period_id,json=periodId,proto3" json:"period_id,omitempty"` + UserId string `protobuf:"bytes,2,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"` + Year int32 `protobuf:"varint,3,opt,name=year,proto3" json:"year,omitempty"` + Month int32 `protobuf:"varint,4,opt,name=month,proto3" json:"month,omitempty"` + ReportingCurrency string `protobuf:"bytes,5,opt,name=reporting_currency,json=reportingCurrency,proto3" json:"reporting_currency,omitempty"` + Source string `protobuf:"bytes,6,opt,name=source,proto3" json:"source,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *TrustedPeriodContext) Reset() { + *x = TrustedPeriodContext{} + mi := &file_proto_expense_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *TrustedPeriodContext) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*TrustedPeriodContext) ProtoMessage() {} + +func (x *TrustedPeriodContext) ProtoReflect() protoreflect.Message { + mi := &file_proto_expense_proto_msgTypes[3] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use TrustedPeriodContext.ProtoReflect.Descriptor instead. +func (*TrustedPeriodContext) Descriptor() ([]byte, []int) { + return file_proto_expense_proto_rawDescGZIP(), []int{3} +} + +func (x *TrustedPeriodContext) GetPeriodId() string { + if x != nil { + return x.PeriodId + } + return "" +} + +func (x *TrustedPeriodContext) GetUserId() string { + if x != nil { + return x.UserId + } + return "" +} + +func (x *TrustedPeriodContext) GetYear() int32 { + if x != nil { + return x.Year + } + return 0 +} + +func (x *TrustedPeriodContext) GetMonth() int32 { + if x != nil { + return x.Month + } + return 0 +} + +func (x *TrustedPeriodContext) GetReportingCurrency() string { + if x != nil { + return x.ReportingCurrency + } + return "" +} + +func (x *TrustedPeriodContext) GetSource() string { + if x != nil { + return x.Source + } + return "" +} + +// CapturedRateSnapshot is a full USD-based provider snapshot captured at +// pro-rata schedule creation. Future installments derive target reporting +// amounts from this snapshot instead of live rates. +type CapturedRateSnapshot struct { + state protoimpl.MessageState `protogen:"open.v1"` + SnapshotVersion int32 `protobuf:"varint,1,opt,name=snapshot_version,json=snapshotVersion,proto3" json:"snapshot_version,omitempty"` + Source string `protobuf:"bytes,2,opt,name=source,proto3" json:"source,omitempty"` + BaseCurrency string `protobuf:"bytes,3,opt,name=base_currency,json=baseCurrency,proto3" json:"base_currency,omitempty"` + RateTimestamp string `protobuf:"bytes,4,opt,name=rate_timestamp,json=rateTimestamp,proto3" json:"rate_timestamp,omitempty"` + CapturedAt string `protobuf:"bytes,5,opt,name=captured_at,json=capturedAt,proto3" json:"captured_at,omitempty"` + ExpiresAt string `protobuf:"bytes,6,opt,name=expires_at,json=expiresAt,proto3" json:"expires_at,omitempty"` + RatesByCurrency map[string]string `protobuf:"bytes,7,rep,name=rates_by_currency,json=ratesByCurrency,proto3" json:"rates_by_currency,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *CapturedRateSnapshot) Reset() { + *x = CapturedRateSnapshot{} + mi := &file_proto_expense_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *CapturedRateSnapshot) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CapturedRateSnapshot) ProtoMessage() {} + +func (x *CapturedRateSnapshot) ProtoReflect() protoreflect.Message { + mi := &file_proto_expense_proto_msgTypes[4] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use CapturedRateSnapshot.ProtoReflect.Descriptor instead. +func (*CapturedRateSnapshot) Descriptor() ([]byte, []int) { + return file_proto_expense_proto_rawDescGZIP(), []int{4} +} + +func (x *CapturedRateSnapshot) GetSnapshotVersion() int32 { + if x != nil { + return x.SnapshotVersion + } + return 0 +} + +func (x *CapturedRateSnapshot) GetSource() string { + if x != nil { + return x.Source + } + return "" +} + +func (x *CapturedRateSnapshot) GetBaseCurrency() string { + if x != nil { + return x.BaseCurrency + } + return "" +} + +func (x *CapturedRateSnapshot) GetRateTimestamp() string { + if x != nil { + return x.RateTimestamp + } + return "" +} + +func (x *CapturedRateSnapshot) GetCapturedAt() string { + if x != nil { + return x.CapturedAt + } + return "" +} + +func (x *CapturedRateSnapshot) GetExpiresAt() string { + if x != nil { + return x.ExpiresAt + } + return "" +} + +func (x *CapturedRateSnapshot) GetRatesByCurrency() map[string]string { + if x != nil { + return x.RatesByCurrency + } + return nil +} + +// CreateProRataInstallmentRequest is the Finance-originated internal write +// contract for the first (and future) pro-rata installments. +type CreateProRataInstallmentRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + UserId string `protobuf:"bytes,1,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"` + PeriodContext *TrustedPeriodContext `protobuf:"bytes,2,opt,name=period_context,json=periodContext,proto3" json:"period_context,omitempty"` + Name string `protobuf:"bytes,3,opt,name=name,proto3" json:"name,omitempty"` + Amount int64 `protobuf:"varint,4,opt,name=amount,proto3" json:"amount,omitempty"` + TransactionCurrency string `protobuf:"bytes,5,opt,name=transaction_currency,json=transactionCurrency,proto3" json:"transaction_currency,omitempty"` + ExpenseType string `protobuf:"bytes,6,opt,name=expense_type,json=expenseType,proto3" json:"expense_type,omitempty"` + TagId string `protobuf:"bytes,7,opt,name=tag_id,json=tagId,proto3" json:"tag_id,omitempty"` + ExpenseDate string `protobuf:"bytes,8,opt,name=expense_date,json=expenseDate,proto3" json:"expense_date,omitempty"` + ProRataGroup string `protobuf:"bytes,9,opt,name=pro_rata_group,json=proRataGroup,proto3" json:"pro_rata_group,omitempty"` + ProRataIndex int32 `protobuf:"varint,10,opt,name=pro_rata_index,json=proRataIndex,proto3" json:"pro_rata_index,omitempty"` + ProRataTotal int32 `protobuf:"varint,11,opt,name=pro_rata_total,json=proRataTotal,proto3" json:"pro_rata_total,omitempty"` + CapturedRateSnapshot *CapturedRateSnapshot `protobuf:"bytes,12,opt,name=captured_rate_snapshot,json=capturedRateSnapshot,proto3" json:"captured_rate_snapshot,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *CreateProRataInstallmentRequest) Reset() { + *x = CreateProRataInstallmentRequest{} + mi := &file_proto_expense_proto_msgTypes[5] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *CreateProRataInstallmentRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CreateProRataInstallmentRequest) ProtoMessage() {} + +func (x *CreateProRataInstallmentRequest) ProtoReflect() protoreflect.Message { + mi := &file_proto_expense_proto_msgTypes[5] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use CreateProRataInstallmentRequest.ProtoReflect.Descriptor instead. +func (*CreateProRataInstallmentRequest) Descriptor() ([]byte, []int) { + return file_proto_expense_proto_rawDescGZIP(), []int{5} +} + +func (x *CreateProRataInstallmentRequest) GetUserId() string { + if x != nil { + return x.UserId + } + return "" +} + +func (x *CreateProRataInstallmentRequest) GetPeriodContext() *TrustedPeriodContext { + if x != nil { + return x.PeriodContext + } + return nil +} + +func (x *CreateProRataInstallmentRequest) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *CreateProRataInstallmentRequest) GetAmount() int64 { + if x != nil { + return x.Amount + } + return 0 +} + +func (x *CreateProRataInstallmentRequest) GetTransactionCurrency() string { + if x != nil { + return x.TransactionCurrency + } + return "" +} + +func (x *CreateProRataInstallmentRequest) GetExpenseType() string { + if x != nil { + return x.ExpenseType + } + return "" +} + +func (x *CreateProRataInstallmentRequest) GetTagId() string { + if x != nil { + return x.TagId + } + return "" +} + +func (x *CreateProRataInstallmentRequest) GetExpenseDate() string { + if x != nil { + return x.ExpenseDate + } + return "" +} + +func (x *CreateProRataInstallmentRequest) GetProRataGroup() string { + if x != nil { + return x.ProRataGroup + } + return "" +} + +func (x *CreateProRataInstallmentRequest) GetProRataIndex() int32 { + if x != nil { + return x.ProRataIndex + } + return 0 +} + +func (x *CreateProRataInstallmentRequest) GetProRataTotal() int32 { + if x != nil { + return x.ProRataTotal + } + return 0 +} + +func (x *CreateProRataInstallmentRequest) GetCapturedRateSnapshot() *CapturedRateSnapshot { + if x != nil { + return x.CapturedRateSnapshot + } + return nil +} + type GetExpensesForPeriodRequest struct { state protoimpl.MessageState `protogen:"open.v1"` UserId string `protobuf:"bytes,1,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"` @@ -439,7 +755,7 @@ type GetExpensesForPeriodRequest struct { func (x *GetExpensesForPeriodRequest) Reset() { *x = GetExpensesForPeriodRequest{} - mi := &file_proto_expense_proto_msgTypes[3] + mi := &file_proto_expense_proto_msgTypes[6] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -451,7 +767,7 @@ func (x *GetExpensesForPeriodRequest) String() string { func (*GetExpensesForPeriodRequest) ProtoMessage() {} func (x *GetExpensesForPeriodRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_expense_proto_msgTypes[3] + mi := &file_proto_expense_proto_msgTypes[6] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -464,7 +780,7 @@ func (x *GetExpensesForPeriodRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use GetExpensesForPeriodRequest.ProtoReflect.Descriptor instead. func (*GetExpensesForPeriodRequest) Descriptor() ([]byte, []int) { - return file_proto_expense_proto_rawDescGZIP(), []int{3} + return file_proto_expense_proto_rawDescGZIP(), []int{6} } func (x *GetExpensesForPeriodRequest) GetUserId() string { @@ -515,7 +831,7 @@ type ExpenseListResponse struct { func (x *ExpenseListResponse) Reset() { *x = ExpenseListResponse{} - mi := &file_proto_expense_proto_msgTypes[4] + mi := &file_proto_expense_proto_msgTypes[7] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -527,7 +843,7 @@ func (x *ExpenseListResponse) String() string { func (*ExpenseListResponse) ProtoMessage() {} func (x *ExpenseListResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_expense_proto_msgTypes[4] + mi := &file_proto_expense_proto_msgTypes[7] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -540,7 +856,7 @@ func (x *ExpenseListResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ExpenseListResponse.ProtoReflect.Descriptor instead. func (*ExpenseListResponse) Descriptor() ([]byte, []int) { - return file_proto_expense_proto_rawDescGZIP(), []int{4} + return file_proto_expense_proto_rawDescGZIP(), []int{7} } func (x *ExpenseListResponse) GetData() []*ExpenseData { @@ -588,7 +904,7 @@ type GetExpenseRequest struct { func (x *GetExpenseRequest) Reset() { *x = GetExpenseRequest{} - mi := &file_proto_expense_proto_msgTypes[5] + mi := &file_proto_expense_proto_msgTypes[8] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -600,7 +916,7 @@ func (x *GetExpenseRequest) String() string { func (*GetExpenseRequest) ProtoMessage() {} func (x *GetExpenseRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_expense_proto_msgTypes[5] + mi := &file_proto_expense_proto_msgTypes[8] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -613,7 +929,7 @@ func (x *GetExpenseRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use GetExpenseRequest.ProtoReflect.Descriptor instead. func (*GetExpenseRequest) Descriptor() ([]byte, []int) { - return file_proto_expense_proto_rawDescGZIP(), []int{5} + return file_proto_expense_proto_rawDescGZIP(), []int{8} } func (x *GetExpenseRequest) GetId() string { @@ -631,21 +947,23 @@ func (x *GetExpenseRequest) GetUserId() string { } type CorrectExpenseRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - ExpenseId string `protobuf:"bytes,1,opt,name=expense_id,json=expenseId,proto3" json:"expense_id,omitempty"` - UserId string `protobuf:"bytes,2,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"` - Name string `protobuf:"bytes,3,opt,name=name,proto3" json:"name,omitempty"` - Amount int64 `protobuf:"varint,4,opt,name=amount,proto3" json:"amount,omitempty"` - ExpenseType string `protobuf:"bytes,5,opt,name=expense_type,json=expenseType,proto3" json:"expense_type,omitempty"` - TagId string `protobuf:"bytes,6,opt,name=tag_id,json=tagId,proto3" json:"tag_id,omitempty"` - ExpenseDate string `protobuf:"bytes,7,opt,name=expense_date,json=expenseDate,proto3" json:"expense_date,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + state protoimpl.MessageState `protogen:"open.v1"` + ExpenseId string `protobuf:"bytes,1,opt,name=expense_id,json=expenseId,proto3" json:"expense_id,omitempty"` + UserId string `protobuf:"bytes,2,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"` + Name string `protobuf:"bytes,3,opt,name=name,proto3" json:"name,omitempty"` + Amount int64 `protobuf:"varint,4,opt,name=amount,proto3" json:"amount,omitempty"` + ExpenseType string `protobuf:"bytes,5,opt,name=expense_type,json=expenseType,proto3" json:"expense_type,omitempty"` + TagId string `protobuf:"bytes,6,opt,name=tag_id,json=tagId,proto3" json:"tag_id,omitempty"` + ExpenseDate string `protobuf:"bytes,7,opt,name=expense_date,json=expenseDate,proto3" json:"expense_date,omitempty"` + TransactionCurrency string `protobuf:"bytes,8,opt,name=transaction_currency,json=transactionCurrency,proto3" json:"transaction_currency,omitempty"` + Currency string `protobuf:"bytes,9,opt,name=currency,proto3" json:"currency,omitempty"` // Deprecated alias during rollout. + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *CorrectExpenseRequest) Reset() { *x = CorrectExpenseRequest{} - mi := &file_proto_expense_proto_msgTypes[6] + mi := &file_proto_expense_proto_msgTypes[9] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -657,7 +975,7 @@ func (x *CorrectExpenseRequest) String() string { func (*CorrectExpenseRequest) ProtoMessage() {} func (x *CorrectExpenseRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_expense_proto_msgTypes[6] + mi := &file_proto_expense_proto_msgTypes[9] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -670,7 +988,7 @@ func (x *CorrectExpenseRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use CorrectExpenseRequest.ProtoReflect.Descriptor instead. func (*CorrectExpenseRequest) Descriptor() ([]byte, []int) { - return file_proto_expense_proto_rawDescGZIP(), []int{6} + return file_proto_expense_proto_rawDescGZIP(), []int{9} } func (x *CorrectExpenseRequest) GetExpenseId() string { @@ -722,6 +1040,20 @@ func (x *CorrectExpenseRequest) GetExpenseDate() string { return "" } +func (x *CorrectExpenseRequest) GetTransactionCurrency() string { + if x != nil { + return x.TransactionCurrency + } + return "" +} + +func (x *CorrectExpenseRequest) GetCurrency() string { + if x != nil { + return x.Currency + } + return "" +} + type GetCorrectionHistoryRequest struct { state protoimpl.MessageState `protogen:"open.v1"` ExpenseId string `protobuf:"bytes,1,opt,name=expense_id,json=expenseId,proto3" json:"expense_id,omitempty"` @@ -731,7 +1063,7 @@ type GetCorrectionHistoryRequest struct { func (x *GetCorrectionHistoryRequest) Reset() { *x = GetCorrectionHistoryRequest{} - mi := &file_proto_expense_proto_msgTypes[7] + mi := &file_proto_expense_proto_msgTypes[10] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -743,7 +1075,7 @@ func (x *GetCorrectionHistoryRequest) String() string { func (*GetCorrectionHistoryRequest) ProtoMessage() {} func (x *GetCorrectionHistoryRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_expense_proto_msgTypes[7] + mi := &file_proto_expense_proto_msgTypes[10] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -756,7 +1088,7 @@ func (x *GetCorrectionHistoryRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use GetCorrectionHistoryRequest.ProtoReflect.Descriptor instead. func (*GetCorrectionHistoryRequest) Descriptor() ([]byte, []int) { - return file_proto_expense_proto_rawDescGZIP(), []int{7} + return file_proto_expense_proto_rawDescGZIP(), []int{10} } func (x *GetCorrectionHistoryRequest) GetExpenseId() string { @@ -775,7 +1107,7 @@ type CorrectionHistoryResponse struct { func (x *CorrectionHistoryResponse) Reset() { *x = CorrectionHistoryResponse{} - mi := &file_proto_expense_proto_msgTypes[8] + mi := &file_proto_expense_proto_msgTypes[11] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -787,7 +1119,7 @@ func (x *CorrectionHistoryResponse) String() string { func (*CorrectionHistoryResponse) ProtoMessage() {} func (x *CorrectionHistoryResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_expense_proto_msgTypes[8] + mi := &file_proto_expense_proto_msgTypes[11] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -800,7 +1132,7 @@ func (x *CorrectionHistoryResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use CorrectionHistoryResponse.ProtoReflect.Descriptor instead. func (*CorrectionHistoryResponse) Descriptor() ([]byte, []int) { - return file_proto_expense_proto_rawDescGZIP(), []int{8} + return file_proto_expense_proto_rawDescGZIP(), []int{11} } func (x *CorrectionHistoryResponse) GetEntries() []*ExpenseData { @@ -819,7 +1151,7 @@ type GetProRataGroupRequest struct { func (x *GetProRataGroupRequest) Reset() { *x = GetProRataGroupRequest{} - mi := &file_proto_expense_proto_msgTypes[9] + mi := &file_proto_expense_proto_msgTypes[12] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -831,7 +1163,7 @@ func (x *GetProRataGroupRequest) String() string { func (*GetProRataGroupRequest) ProtoMessage() {} func (x *GetProRataGroupRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_expense_proto_msgTypes[9] + mi := &file_proto_expense_proto_msgTypes[12] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -844,7 +1176,7 @@ func (x *GetProRataGroupRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use GetProRataGroupRequest.ProtoReflect.Descriptor instead. func (*GetProRataGroupRequest) Descriptor() ([]byte, []int) { - return file_proto_expense_proto_rawDescGZIP(), []int{9} + return file_proto_expense_proto_rawDescGZIP(), []int{12} } func (x *GetProRataGroupRequest) GetGroupId() string { @@ -864,7 +1196,7 @@ type StreamAllUserExpensesRequest struct { func (x *StreamAllUserExpensesRequest) Reset() { *x = StreamAllUserExpensesRequest{} - mi := &file_proto_expense_proto_msgTypes[10] + mi := &file_proto_expense_proto_msgTypes[13] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -876,7 +1208,7 @@ func (x *StreamAllUserExpensesRequest) String() string { func (*StreamAllUserExpensesRequest) ProtoMessage() {} func (x *StreamAllUserExpensesRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_expense_proto_msgTypes[10] + mi := &file_proto_expense_proto_msgTypes[13] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -889,7 +1221,7 @@ func (x *StreamAllUserExpensesRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use StreamAllUserExpensesRequest.ProtoReflect.Descriptor instead. func (*StreamAllUserExpensesRequest) Descriptor() ([]byte, []int) { - return file_proto_expense_proto_rawDescGZIP(), []int{10} + return file_proto_expense_proto_rawDescGZIP(), []int{13} } func (x *StreamAllUserExpensesRequest) GetUserId() string { @@ -916,7 +1248,7 @@ type CountExpensesByTagRequest struct { func (x *CountExpensesByTagRequest) Reset() { *x = CountExpensesByTagRequest{} - mi := &file_proto_expense_proto_msgTypes[11] + mi := &file_proto_expense_proto_msgTypes[14] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -928,7 +1260,7 @@ func (x *CountExpensesByTagRequest) String() string { func (*CountExpensesByTagRequest) ProtoMessage() {} func (x *CountExpensesByTagRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_expense_proto_msgTypes[11] + mi := &file_proto_expense_proto_msgTypes[14] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -941,7 +1273,7 @@ func (x *CountExpensesByTagRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use CountExpensesByTagRequest.ProtoReflect.Descriptor instead. func (*CountExpensesByTagRequest) Descriptor() ([]byte, []int) { - return file_proto_expense_proto_rawDescGZIP(), []int{11} + return file_proto_expense_proto_rawDescGZIP(), []int{14} } func (x *CountExpensesByTagRequest) GetTagId() string { @@ -967,7 +1299,7 @@ type CountExpensesByTagResponse struct { func (x *CountExpensesByTagResponse) Reset() { *x = CountExpensesByTagResponse{} - mi := &file_proto_expense_proto_msgTypes[12] + mi := &file_proto_expense_proto_msgTypes[15] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -979,7 +1311,7 @@ func (x *CountExpensesByTagResponse) String() string { func (*CountExpensesByTagResponse) ProtoMessage() {} func (x *CountExpensesByTagResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_expense_proto_msgTypes[12] + mi := &file_proto_expense_proto_msgTypes[15] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -992,7 +1324,7 @@ func (x *CountExpensesByTagResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use CountExpensesByTagResponse.ProtoReflect.Descriptor instead. func (*CountExpensesByTagResponse) Descriptor() ([]byte, []int) { - return file_proto_expense_proto_rawDescGZIP(), []int{12} + return file_proto_expense_proto_rawDescGZIP(), []int{15} } func (x *CountExpensesByTagResponse) GetCount() int64 { @@ -1011,7 +1343,7 @@ type AnonymizeRequest struct { func (x *AnonymizeRequest) Reset() { *x = AnonymizeRequest{} - mi := &file_proto_expense_proto_msgTypes[13] + mi := &file_proto_expense_proto_msgTypes[16] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1023,7 +1355,7 @@ func (x *AnonymizeRequest) String() string { func (*AnonymizeRequest) ProtoMessage() {} func (x *AnonymizeRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_expense_proto_msgTypes[13] + mi := &file_proto_expense_proto_msgTypes[16] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1036,7 +1368,7 @@ func (x *AnonymizeRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use AnonymizeRequest.ProtoReflect.Descriptor instead. func (*AnonymizeRequest) Descriptor() ([]byte, []int) { - return file_proto_expense_proto_rawDescGZIP(), []int{13} + return file_proto_expense_proto_rawDescGZIP(), []int{16} } func (x *AnonymizeRequest) GetUserId() string { @@ -1054,7 +1386,7 @@ type AnonymizeResponse struct { func (x *AnonymizeResponse) Reset() { *x = AnonymizeResponse{} - mi := &file_proto_expense_proto_msgTypes[14] + mi := &file_proto_expense_proto_msgTypes[17] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1066,7 +1398,7 @@ func (x *AnonymizeResponse) String() string { func (*AnonymizeResponse) ProtoMessage() {} func (x *AnonymizeResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_expense_proto_msgTypes[14] + mi := &file_proto_expense_proto_msgTypes[17] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1079,7 +1411,7 @@ func (x *AnonymizeResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use AnonymizeResponse.ProtoReflect.Descriptor instead. func (*AnonymizeResponse) Descriptor() ([]byte, []int) { - return file_proto_expense_proto_rawDescGZIP(), []int{14} + return file_proto_expense_proto_rawDescGZIP(), []int{17} } var File_proto_expense_proto protoreflect.FileDescriptor @@ -1132,7 +1464,41 @@ const file_proto_expense_proto_rawDesc = "" + "\x0epro_rata_total\x18\r \x01(\x05R\fproRataTotal\x121\n" + "\x14transaction_currency\x18\x0e \x01(\tR\x13transactionCurrencyJ\x04\b\x04\x10\x05\"A\n" + "\x0fExpenseResponse\x12.\n" + - "\aexpense\x18\x01 \x01(\v2\x14.expense.ExpenseDataR\aexpense\"\x91\x01\n" + + "\aexpense\x18\x01 \x01(\v2\x14.expense.ExpenseDataR\aexpense\"\xbd\x01\n" + + "\x14TrustedPeriodContext\x12\x1b\n" + + "\tperiod_id\x18\x01 \x01(\tR\bperiodId\x12\x17\n" + + "\auser_id\x18\x02 \x01(\tR\x06userId\x12\x12\n" + + "\x04year\x18\x03 \x01(\x05R\x04year\x12\x14\n" + + "\x05month\x18\x04 \x01(\x05R\x05month\x12-\n" + + "\x12reporting_currency\x18\x05 \x01(\tR\x11reportingCurrency\x12\x16\n" + + "\x06source\x18\x06 \x01(\tR\x06source\"\x89\x03\n" + + "\x14CapturedRateSnapshot\x12)\n" + + "\x10snapshot_version\x18\x01 \x01(\x05R\x0fsnapshotVersion\x12\x16\n" + + "\x06source\x18\x02 \x01(\tR\x06source\x12#\n" + + "\rbase_currency\x18\x03 \x01(\tR\fbaseCurrency\x12%\n" + + "\x0erate_timestamp\x18\x04 \x01(\tR\rrateTimestamp\x12\x1f\n" + + "\vcaptured_at\x18\x05 \x01(\tR\n" + + "capturedAt\x12\x1d\n" + + "\n" + + "expires_at\x18\x06 \x01(\tR\texpiresAt\x12^\n" + + "\x11rates_by_currency\x18\a \x03(\v22.expense.CapturedRateSnapshot.RatesByCurrencyEntryR\x0fratesByCurrency\x1aB\n" + + "\x14RatesByCurrencyEntry\x12\x10\n" + + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"\x83\x04\n" + + "\x1fCreateProRataInstallmentRequest\x12\x17\n" + + "\auser_id\x18\x01 \x01(\tR\x06userId\x12D\n" + + "\x0eperiod_context\x18\x02 \x01(\v2\x1d.expense.TrustedPeriodContextR\rperiodContext\x12\x12\n" + + "\x04name\x18\x03 \x01(\tR\x04name\x12\x16\n" + + "\x06amount\x18\x04 \x01(\x03R\x06amount\x121\n" + + "\x14transaction_currency\x18\x05 \x01(\tR\x13transactionCurrency\x12!\n" + + "\fexpense_type\x18\x06 \x01(\tR\vexpenseType\x12\x15\n" + + "\x06tag_id\x18\a \x01(\tR\x05tagId\x12!\n" + + "\fexpense_date\x18\b \x01(\tR\vexpenseDate\x12$\n" + + "\x0epro_rata_group\x18\t \x01(\tR\fproRataGroup\x12$\n" + + "\x0epro_rata_index\x18\n" + + " \x01(\x05R\fproRataIndex\x12$\n" + + "\x0epro_rata_total\x18\v \x01(\x05R\fproRataTotal\x12S\n" + + "\x16captured_rate_snapshot\x18\f \x01(\v2\x1d.expense.CapturedRateSnapshotR\x14capturedRateSnapshot\"\x91\x01\n" + "\x1bGetExpensesForPeriodRequest\x12\x17\n" + "\auser_id\x18\x01 \x01(\tR\x06userId\x12\x12\n" + "\x04year\x18\x02 \x01(\x05R\x04year\x12\x14\n" + @@ -1147,7 +1513,7 @@ const file_proto_expense_proto_rawDesc = "" + "\bhas_more\x18\x05 \x01(\bR\ahasMore\"<\n" + "\x11GetExpenseRequest\x12\x0e\n" + "\x02id\x18\x01 \x01(\tR\x02id\x12\x17\n" + - "\auser_id\x18\x02 \x01(\tR\x06userId\"\xd8\x01\n" + + "\auser_id\x18\x02 \x01(\tR\x06userId\"\xa7\x02\n" + "\x15CorrectExpenseRequest\x12\x1d\n" + "\n" + "expense_id\x18\x01 \x01(\tR\texpenseId\x12\x17\n" + @@ -1156,7 +1522,9 @@ const file_proto_expense_proto_rawDesc = "" + "\x06amount\x18\x04 \x01(\x03R\x06amount\x12!\n" + "\fexpense_type\x18\x05 \x01(\tR\vexpenseType\x12\x15\n" + "\x06tag_id\x18\x06 \x01(\tR\x05tagId\x12!\n" + - "\fexpense_date\x18\a \x01(\tR\vexpenseDate\"<\n" + + "\fexpense_date\x18\a \x01(\tR\vexpenseDate\x121\n" + + "\x14transaction_currency\x18\b \x01(\tR\x13transactionCurrency\x12\x1a\n" + + "\bcurrency\x18\t \x01(\tR\bcurrency\"<\n" + "\x1bGetCorrectionHistoryRequest\x12\x1d\n" + "\n" + "expense_id\x18\x01 \x01(\tR\texpenseId\"K\n" + @@ -1174,9 +1542,10 @@ const file_proto_expense_proto_rawDesc = "" + "\x05count\x18\x01 \x01(\x03R\x05count\"+\n" + "\x10AnonymizeRequest\x12\x17\n" + "\auser_id\x18\x01 \x01(\tR\x06userId\"\x13\n" + - "\x11AnonymizeResponse2\xd0\x04\n" + + "\x11AnonymizeResponse2\xb0\x05\n" + "\x0eExpenseService\x12H\n" + - "\rCreateExpense\x12\x1d.expense.CreateExpenseRequest\x1a\x18.expense.ExpenseResponse\x12Z\n" + + "\rCreateExpense\x12\x1d.expense.CreateExpenseRequest\x1a\x18.expense.ExpenseResponse\x12^\n" + + "\x18CreateProRataInstallment\x12(.expense.CreateProRataInstallmentRequest\x1a\x18.expense.ExpenseResponse\x12Z\n" + "\x14GetExpensesForPeriod\x12$.expense.GetExpensesForPeriodRequest\x1a\x1c.expense.ExpenseListResponse\x12B\n" + "\n" + "GetExpense\x12\x1a.expense.GetExpenseRequest\x1a\x18.expense.ExpenseResponse\x12]\n" + @@ -1197,47 +1566,56 @@ func file_proto_expense_proto_rawDescGZIP() []byte { return file_proto_expense_proto_rawDescData } -var file_proto_expense_proto_msgTypes = make([]protoimpl.MessageInfo, 15) +var file_proto_expense_proto_msgTypes = make([]protoimpl.MessageInfo, 19) var file_proto_expense_proto_goTypes = []any{ - (*ExpenseData)(nil), // 0: expense.ExpenseData - (*CreateExpenseRequest)(nil), // 1: expense.CreateExpenseRequest - (*ExpenseResponse)(nil), // 2: expense.ExpenseResponse - (*GetExpensesForPeriodRequest)(nil), // 3: expense.GetExpensesForPeriodRequest - (*ExpenseListResponse)(nil), // 4: expense.ExpenseListResponse - (*GetExpenseRequest)(nil), // 5: expense.GetExpenseRequest - (*CorrectExpenseRequest)(nil), // 6: expense.CorrectExpenseRequest - (*GetCorrectionHistoryRequest)(nil), // 7: expense.GetCorrectionHistoryRequest - (*CorrectionHistoryResponse)(nil), // 8: expense.CorrectionHistoryResponse - (*GetProRataGroupRequest)(nil), // 9: expense.GetProRataGroupRequest - (*StreamAllUserExpensesRequest)(nil), // 10: expense.StreamAllUserExpensesRequest - (*CountExpensesByTagRequest)(nil), // 11: expense.CountExpensesByTagRequest - (*CountExpensesByTagResponse)(nil), // 12: expense.CountExpensesByTagResponse - (*AnonymizeRequest)(nil), // 13: expense.AnonymizeRequest - (*AnonymizeResponse)(nil), // 14: expense.AnonymizeResponse + (*ExpenseData)(nil), // 0: expense.ExpenseData + (*CreateExpenseRequest)(nil), // 1: expense.CreateExpenseRequest + (*ExpenseResponse)(nil), // 2: expense.ExpenseResponse + (*TrustedPeriodContext)(nil), // 3: expense.TrustedPeriodContext + (*CapturedRateSnapshot)(nil), // 4: expense.CapturedRateSnapshot + (*CreateProRataInstallmentRequest)(nil), // 5: expense.CreateProRataInstallmentRequest + (*GetExpensesForPeriodRequest)(nil), // 6: expense.GetExpensesForPeriodRequest + (*ExpenseListResponse)(nil), // 7: expense.ExpenseListResponse + (*GetExpenseRequest)(nil), // 8: expense.GetExpenseRequest + (*CorrectExpenseRequest)(nil), // 9: expense.CorrectExpenseRequest + (*GetCorrectionHistoryRequest)(nil), // 10: expense.GetCorrectionHistoryRequest + (*CorrectionHistoryResponse)(nil), // 11: expense.CorrectionHistoryResponse + (*GetProRataGroupRequest)(nil), // 12: expense.GetProRataGroupRequest + (*StreamAllUserExpensesRequest)(nil), // 13: expense.StreamAllUserExpensesRequest + (*CountExpensesByTagRequest)(nil), // 14: expense.CountExpensesByTagRequest + (*CountExpensesByTagResponse)(nil), // 15: expense.CountExpensesByTagResponse + (*AnonymizeRequest)(nil), // 16: expense.AnonymizeRequest + (*AnonymizeResponse)(nil), // 17: expense.AnonymizeResponse + nil, // 18: expense.CapturedRateSnapshot.RatesByCurrencyEntry } var file_proto_expense_proto_depIdxs = []int32{ 0, // 0: expense.ExpenseResponse.expense:type_name -> expense.ExpenseData - 0, // 1: expense.ExpenseListResponse.data:type_name -> expense.ExpenseData - 0, // 2: expense.CorrectionHistoryResponse.entries:type_name -> expense.ExpenseData - 1, // 3: expense.ExpenseService.CreateExpense:input_type -> expense.CreateExpenseRequest - 3, // 4: expense.ExpenseService.GetExpensesForPeriod:input_type -> expense.GetExpensesForPeriodRequest - 5, // 5: expense.ExpenseService.GetExpense:input_type -> expense.GetExpenseRequest - 11, // 6: expense.ExpenseService.CountExpensesByTag:input_type -> expense.CountExpensesByTagRequest - 10, // 7: expense.ExpenseService.StreamAllUserExpenses:input_type -> expense.StreamAllUserExpensesRequest - 13, // 8: expense.ExpenseService.AnonymizeAllUserExpenses:input_type -> expense.AnonymizeRequest - 6, // 9: expense.ExpenseService.CorrectExpense:input_type -> expense.CorrectExpenseRequest - 2, // 10: expense.ExpenseService.CreateExpense:output_type -> expense.ExpenseResponse - 4, // 11: expense.ExpenseService.GetExpensesForPeriod:output_type -> expense.ExpenseListResponse - 2, // 12: expense.ExpenseService.GetExpense:output_type -> expense.ExpenseResponse - 12, // 13: expense.ExpenseService.CountExpensesByTag:output_type -> expense.CountExpensesByTagResponse - 0, // 14: expense.ExpenseService.StreamAllUserExpenses:output_type -> expense.ExpenseData - 14, // 15: expense.ExpenseService.AnonymizeAllUserExpenses:output_type -> expense.AnonymizeResponse - 2, // 16: expense.ExpenseService.CorrectExpense:output_type -> expense.ExpenseResponse - 10, // [10:17] is the sub-list for method output_type - 3, // [3:10] is the sub-list for method input_type - 3, // [3:3] is the sub-list for extension type_name - 3, // [3:3] is the sub-list for extension extendee - 0, // [0:3] is the sub-list for field type_name + 18, // 1: expense.CapturedRateSnapshot.rates_by_currency:type_name -> expense.CapturedRateSnapshot.RatesByCurrencyEntry + 3, // 2: expense.CreateProRataInstallmentRequest.period_context:type_name -> expense.TrustedPeriodContext + 4, // 3: expense.CreateProRataInstallmentRequest.captured_rate_snapshot:type_name -> expense.CapturedRateSnapshot + 0, // 4: expense.ExpenseListResponse.data:type_name -> expense.ExpenseData + 0, // 5: expense.CorrectionHistoryResponse.entries:type_name -> expense.ExpenseData + 1, // 6: expense.ExpenseService.CreateExpense:input_type -> expense.CreateExpenseRequest + 5, // 7: expense.ExpenseService.CreateProRataInstallment:input_type -> expense.CreateProRataInstallmentRequest + 6, // 8: expense.ExpenseService.GetExpensesForPeriod:input_type -> expense.GetExpensesForPeriodRequest + 8, // 9: expense.ExpenseService.GetExpense:input_type -> expense.GetExpenseRequest + 14, // 10: expense.ExpenseService.CountExpensesByTag:input_type -> expense.CountExpensesByTagRequest + 13, // 11: expense.ExpenseService.StreamAllUserExpenses:input_type -> expense.StreamAllUserExpensesRequest + 16, // 12: expense.ExpenseService.AnonymizeAllUserExpenses:input_type -> expense.AnonymizeRequest + 9, // 13: expense.ExpenseService.CorrectExpense:input_type -> expense.CorrectExpenseRequest + 2, // 14: expense.ExpenseService.CreateExpense:output_type -> expense.ExpenseResponse + 2, // 15: expense.ExpenseService.CreateProRataInstallment:output_type -> expense.ExpenseResponse + 7, // 16: expense.ExpenseService.GetExpensesForPeriod:output_type -> expense.ExpenseListResponse + 2, // 17: expense.ExpenseService.GetExpense:output_type -> expense.ExpenseResponse + 15, // 18: expense.ExpenseService.CountExpensesByTag:output_type -> expense.CountExpensesByTagResponse + 0, // 19: expense.ExpenseService.StreamAllUserExpenses:output_type -> expense.ExpenseData + 17, // 20: expense.ExpenseService.AnonymizeAllUserExpenses:output_type -> expense.AnonymizeResponse + 2, // 21: expense.ExpenseService.CorrectExpense:output_type -> expense.ExpenseResponse + 14, // [14:22] is the sub-list for method output_type + 6, // [6:14] is the sub-list for method input_type + 6, // [6:6] is the sub-list for extension type_name + 6, // [6:6] is the sub-list for extension extendee + 0, // [0:6] is the sub-list for field type_name } func init() { file_proto_expense_proto_init() } @@ -1251,7 +1629,7 @@ func file_proto_expense_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_proto_expense_proto_rawDesc), len(file_proto_expense_proto_rawDesc)), NumEnums: 0, - NumMessages: 15, + NumMessages: 19, NumExtensions: 0, NumServices: 1, }, diff --git a/services/expense/proto/expensepb/expense_grpc.pb.go b/services/expense/proto/expensepb/expense_grpc.pb.go index 5b13d118..55b39ce3 100644 --- a/services/expense/proto/expensepb/expense_grpc.pb.go +++ b/services/expense/proto/expensepb/expense_grpc.pb.go @@ -20,6 +20,7 @@ const _ = grpc.SupportPackageIsVersion9 const ( ExpenseService_CreateExpense_FullMethodName = "/expense.ExpenseService/CreateExpense" + ExpenseService_CreateProRataInstallment_FullMethodName = "/expense.ExpenseService/CreateProRataInstallment" ExpenseService_GetExpensesForPeriod_FullMethodName = "/expense.ExpenseService/GetExpensesForPeriod" ExpenseService_GetExpense_FullMethodName = "/expense.ExpenseService/GetExpense" ExpenseService_CountExpensesByTag_FullMethodName = "/expense.ExpenseService/CountExpensesByTag" @@ -33,6 +34,7 @@ const ( // For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. type ExpenseServiceClient interface { CreateExpense(ctx context.Context, in *CreateExpenseRequest, opts ...grpc.CallOption) (*ExpenseResponse, error) + CreateProRataInstallment(ctx context.Context, in *CreateProRataInstallmentRequest, opts ...grpc.CallOption) (*ExpenseResponse, error) GetExpensesForPeriod(ctx context.Context, in *GetExpensesForPeriodRequest, opts ...grpc.CallOption) (*ExpenseListResponse, error) GetExpense(ctx context.Context, in *GetExpenseRequest, opts ...grpc.CallOption) (*ExpenseResponse, error) // Tag usage check (called by finance service during tag deletion) @@ -66,6 +68,16 @@ func (c *expenseServiceClient) CreateExpense(ctx context.Context, in *CreateExpe return out, nil } +func (c *expenseServiceClient) CreateProRataInstallment(ctx context.Context, in *CreateProRataInstallmentRequest, opts ...grpc.CallOption) (*ExpenseResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(ExpenseResponse) + err := c.cc.Invoke(ctx, ExpenseService_CreateProRataInstallment_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + func (c *expenseServiceClient) GetExpensesForPeriod(ctx context.Context, in *GetExpensesForPeriodRequest, opts ...grpc.CallOption) (*ExpenseListResponse, error) { cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) out := new(ExpenseListResponse) @@ -140,6 +152,7 @@ func (c *expenseServiceClient) CorrectExpense(ctx context.Context, in *CorrectEx // for forward compatibility. type ExpenseServiceServer interface { CreateExpense(context.Context, *CreateExpenseRequest) (*ExpenseResponse, error) + CreateProRataInstallment(context.Context, *CreateProRataInstallmentRequest) (*ExpenseResponse, error) GetExpensesForPeriod(context.Context, *GetExpensesForPeriodRequest) (*ExpenseListResponse, error) GetExpense(context.Context, *GetExpenseRequest) (*ExpenseResponse, error) // Tag usage check (called by finance service during tag deletion) @@ -166,6 +179,9 @@ type UnimplementedExpenseServiceServer struct{} func (UnimplementedExpenseServiceServer) CreateExpense(context.Context, *CreateExpenseRequest) (*ExpenseResponse, error) { return nil, status.Error(codes.Unimplemented, "method CreateExpense not implemented") } +func (UnimplementedExpenseServiceServer) CreateProRataInstallment(context.Context, *CreateProRataInstallmentRequest) (*ExpenseResponse, error) { + return nil, status.Error(codes.Unimplemented, "method CreateProRataInstallment not implemented") +} func (UnimplementedExpenseServiceServer) GetExpensesForPeriod(context.Context, *GetExpensesForPeriodRequest) (*ExpenseListResponse, error) { return nil, status.Error(codes.Unimplemented, "method GetExpensesForPeriod not implemented") } @@ -223,6 +239,24 @@ func _ExpenseService_CreateExpense_Handler(srv interface{}, ctx context.Context, return interceptor(ctx, in, info, handler) } +func _ExpenseService_CreateProRataInstallment_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(CreateProRataInstallmentRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(ExpenseServiceServer).CreateProRataInstallment(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: ExpenseService_CreateProRataInstallment_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(ExpenseServiceServer).CreateProRataInstallment(ctx, req.(*CreateProRataInstallmentRequest)) + } + return interceptor(ctx, in, info, handler) +} + func _ExpenseService_GetExpensesForPeriod_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { in := new(GetExpensesForPeriodRequest) if err := dec(in); err != nil { @@ -335,6 +369,10 @@ var ExpenseService_ServiceDesc = grpc.ServiceDesc{ MethodName: "CreateExpense", Handler: _ExpenseService_CreateExpense_Handler, }, + { + MethodName: "CreateProRataInstallment", + Handler: _ExpenseService_CreateProRataInstallment_Handler, + }, { MethodName: "GetExpensesForPeriod", Handler: _ExpenseService_GetExpensesForPeriod_Handler, diff --git a/services/finance/Dockerfile b/services/finance/Dockerfile index 977e9ff0..62e96a88 100644 --- a/services/finance/Dockerfile +++ b/services/finance/Dockerfile @@ -20,6 +20,8 @@ COPY errkit/go.mod errkit/go.sum* ./errkit/ COPY httpx/go.mod httpx/go.sum* ./httpx/ COPY pgutil/go.mod pgutil/go.sum* ./pgutil/ COPY shared/currency/go.mod ./shared/currency/ +COPY shared/validator/go.mod ./shared/validator/ +COPY fx/go.mod fx/go.sum* ./fx/ RUN --mount=type=cache,target=/go/pkg/mod \ cd finance && GOWORK=off go mod download @@ -37,6 +39,8 @@ COPY errkit/ ./errkit/ COPY httpx/ ./httpx/ COPY pgutil/ ./pgutil/ COPY shared/currency/ ./shared/currency/ +COPY shared/validator/ ./shared/validator/ +COPY fx/proto/ ./fx/proto/ RUN --mount=type=cache,target=/go/pkg/mod \ --mount=type=cache,target=/root/.cache/go-build \ diff --git a/services/finance/cmd/main.go b/services/finance/cmd/main.go index 57be72ca..c734c035 100644 --- a/services/finance/cmd/main.go +++ b/services/finance/cmd/main.go @@ -85,7 +85,18 @@ func run() error { expenseClient := service.NewGRPCExpenseClient( expensepb.NewExpenseServiceClient(expenseConn), ) - financeSvc := service.NewFinanceService(repo, txBeginner, expenseClient, time.Now, logger) + + // Connect to the FX service for pro-rata snapshot capture. + fxClient, fxConn, err := service.NewGRPCFxClientFromAddr(cfg.FxServiceAddr) + if err != nil { + return fmt.Errorf("connecting to fx service at %s: %w", cfg.FxServiceAddr, err) + } + defer func() { _ = fxConn.Close() }() + logger.Info("fx service gRPC client created", + slog.String("addr", cfg.FxServiceAddr), + ) + + financeSvc := service.NewFinanceServiceWithFx(repo, txBeginner, expenseClient, fxClient, time.Now, logger) // Build the gRPC server and pre-bind its listener so a bind failure surfaces. grpcServer := serverkit.NewGRPCServer() diff --git a/services/finance/db/migrations/000007_add_pro_rata_capture_fields.down.sql b/services/finance/db/migrations/000007_add_pro_rata_capture_fields.down.sql new file mode 100644 index 00000000..b4d3c090 --- /dev/null +++ b/services/finance/db/migrations/000007_add_pro_rata_capture_fields.down.sql @@ -0,0 +1,21 @@ +ALTER TABLE finance.pro_rata_schedules +DROP CONSTRAINT pro_rata_schedules_status_check; + +ALTER TABLE finance.pro_rata_schedules +ADD CONSTRAINT pro_rata_schedules_status_check +CHECK (status IN ('pending', 'applied')); + +ALTER TABLE finance.pro_rata_schedules +DROP COLUMN failure_reason; + +ALTER TABLE finance.pro_rata_schedules +DROP COLUMN captured_rate_snapshot; + +ALTER TABLE finance.pro_rata_schedules +DROP COLUMN creation_reporting_currency; + +ALTER TABLE finance.pro_rata_schedules +DROP COLUMN transaction_currency; + +ALTER TABLE finance.pro_rata_schedules +DROP COLUMN transaction_amount; diff --git a/services/finance/db/migrations/000007_add_pro_rata_capture_fields.up.sql b/services/finance/db/migrations/000007_add_pro_rata_capture_fields.up.sql new file mode 100644 index 00000000..fd338b82 --- /dev/null +++ b/services/finance/db/migrations/000007_add_pro_rata_capture_fields.up.sql @@ -0,0 +1,21 @@ +ALTER TABLE finance.pro_rata_schedules +ADD COLUMN transaction_amount BIGINT; + +ALTER TABLE finance.pro_rata_schedules +ADD COLUMN transaction_currency VARCHAR(3); + +ALTER TABLE finance.pro_rata_schedules +ADD COLUMN creation_reporting_currency VARCHAR(3); + +ALTER TABLE finance.pro_rata_schedules +ADD COLUMN captured_rate_snapshot JSONB; + +ALTER TABLE finance.pro_rata_schedules +ADD COLUMN failure_reason VARCHAR(50); + +ALTER TABLE finance.pro_rata_schedules +DROP CONSTRAINT pro_rata_schedules_status_check; + +ALTER TABLE finance.pro_rata_schedules +ADD CONSTRAINT pro_rata_schedules_status_check +CHECK (status IN ('pending', 'applied', 'failed')); diff --git a/services/finance/db/queries/finance.sql b/services/finance/db/queries/finance.sql index 7afdd8e0..cc1c12b4 100644 --- a/services/finance/db/queries/finance.sql +++ b/services/finance/db/queries/finance.sql @@ -79,8 +79,11 @@ LIMIT 1; -- name: CreateProRataSchedule :one INSERT INTO finance.pro_rata_schedules (user_id, pro_rata_group, name, amount, currency, expense_type, tag_id, - target_year, target_month, installment_index, installment_total) -VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) + target_year, target_month, installment_index, installment_total, + transaction_amount, transaction_currency, creation_reporting_currency, + captured_rate_snapshot, failure_reason) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, + $12, $13, $14, $15, $16) RETURNING *; -- name: GetPendingProRata :many diff --git a/services/finance/go.mod b/services/finance/go.mod index d070d22b..12c360f3 100644 --- a/services/finance/go.mod +++ b/services/finance/go.mod @@ -7,12 +7,14 @@ require ( github.com/ItsThompson/gofin/services/apierr v0.0.0 github.com/ItsThompson/gofin/services/errkit v0.0.0 github.com/ItsThompson/gofin/services/expense v0.0.0-00010101000000-000000000000 + github.com/ItsThompson/gofin/services/fx v0.0.0 github.com/ItsThompson/gofin/services/healthcheck v0.0.0 github.com/ItsThompson/gofin/services/httpx v0.0.0 github.com/ItsThompson/gofin/services/perf v0.0.0 github.com/ItsThompson/gofin/services/pgutil v0.0.0 - github.com/ItsThompson/gofin/services/shared/currency v0.0.0 github.com/ItsThompson/gofin/services/serverkit v0.0.0 + github.com/ItsThompson/gofin/services/shared/currency v0.0.0 + github.com/ItsThompson/gofin/services/shared/validator v0.0.0 github.com/gin-gonic/gin v1.12.0 github.com/google/uuid v1.6.0 github.com/jackc/pgx/v5 v5.9.2 @@ -50,6 +52,8 @@ replace github.com/ItsThompson/gofin/services/pgutil => ../pgutil replace github.com/ItsThompson/gofin/services/shared/currency => ../shared/currency +replace github.com/ItsThompson/gofin/services/shared/validator => ../shared/validator + require ( github.com/ItsThompson/gofin/services/dbmigrate v0.0.0 // indirect github.com/ItsThompson/gofin/services/metrics v0.0.0 // indirect @@ -104,3 +108,5 @@ require ( ) replace github.com/ItsThompson/gofin/services/errkit => ../errkit + +replace github.com/ItsThompson/gofin/services/fx => ../fx diff --git a/services/finance/internal/config/config.go b/services/finance/internal/config/config.go index 00f159ea..34cf90ae 100644 --- a/services/finance/internal/config/config.go +++ b/services/finance/internal/config/config.go @@ -24,6 +24,7 @@ func ResolveRESTPort() string { type Config struct { DBUrl string ExpenseServiceAddr string // gRPC address for expense service (e.g., "expense-service:9082") + FxServiceAddr string // gRPC address for fx service (e.g., "fx-service:9085") LogLevel string Environment string RESTPort string @@ -43,6 +44,11 @@ func Load() (*Config, error) { return nil, fmt.Errorf("EXPENSE_SERVICE_ADDR is required") } + fxAddr := os.Getenv("FX_SERVICE_ADDR") + if fxAddr == "" { + return nil, fmt.Errorf("FX_SERVICE_ADDR is required") + } + logLevel := os.Getenv("LOG_LEVEL") if logLevel == "" { logLevel = "info" @@ -63,6 +69,7 @@ func Load() (*Config, error) { return &Config{ DBUrl: dbURL, ExpenseServiceAddr: expenseAddr, + FxServiceAddr: fxAddr, LogLevel: logLevel, Environment: environment, RESTPort: restPort, diff --git a/services/finance/internal/config/config_test.go b/services/finance/internal/config/config_test.go index 6b73c324..6ea8eb1e 100644 --- a/services/finance/internal/config/config_test.go +++ b/services/finance/internal/config/config_test.go @@ -21,20 +21,33 @@ func TestLoad_RequiredVars(t *testing.T) { func TestLoad_MissingExpenseAddr(t *testing.T) { t.Setenv("FINANCE_DB_URL", "postgres://localhost/test") _ = os.Unsetenv("EXPENSE_SERVICE_ADDR") + t.Setenv("FX_SERVICE_ADDR", "localhost:9085") _, err := Load() require.Error(t, err) assert.Contains(t, err.Error(), "EXPENSE_SERVICE_ADDR") } +func TestLoad_MissingFxAddr(t *testing.T) { + t.Setenv("FINANCE_DB_URL", "postgres://localhost/test") + t.Setenv("EXPENSE_SERVICE_ADDR", "localhost:9082") + _ = os.Unsetenv("FX_SERVICE_ADDR") + + _, err := Load() + require.Error(t, err) + assert.Contains(t, err.Error(), "FX_SERVICE_ADDR") +} + func TestLoad_Defaults(t *testing.T) { t.Setenv("FINANCE_DB_URL", "postgres://localhost/test") t.Setenv("EXPENSE_SERVICE_ADDR", "localhost:9082") + t.Setenv("FX_SERVICE_ADDR", "localhost:9085") cfg, err := Load() require.NoError(t, err) assert.Equal(t, "postgres://localhost/test", cfg.DBUrl) assert.Equal(t, "localhost:9082", cfg.ExpenseServiceAddr) + assert.Equal(t, "localhost:9085", cfg.FxServiceAddr) assert.Equal(t, "info", cfg.LogLevel) assert.Equal(t, "development", cfg.Environment) assert.Equal(t, "8083", cfg.RESTPort) @@ -45,6 +58,7 @@ func TestLoad_Defaults(t *testing.T) { func TestLoad_Production(t *testing.T) { t.Setenv("FINANCE_DB_URL", "postgres://localhost/test") t.Setenv("EXPENSE_SERVICE_ADDR", "localhost:9082") + t.Setenv("FX_SERVICE_ADDR", "localhost:9085") t.Setenv("ENVIRONMENT", "production") cfg, err := Load() diff --git a/services/finance/internal/db/finance.sql.go b/services/finance/internal/db/finance.sql.go index 4afc7432..e09d4e99 100644 --- a/services/finance/internal/db/finance.sql.go +++ b/services/finance/internal/db/finance.sql.go @@ -43,7 +43,7 @@ const createPeriod = `-- name: CreatePeriod :one INSERT INTO finance.budget_periods (user_id, year, month, budget_amount, reporting_currency, essentials_percent, desires_percent, savings_percent) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) -RETURNING id, user_id, year, month, budget_amount, reporting_currency, essentials_percent, desires_percent, savings_percent, created_at, updated_at +RETURNING id, user_id, year, month, budget_amount, essentials_percent, desires_percent, savings_percent, created_at, updated_at, reporting_currency ` type CreatePeriodParams struct { @@ -75,12 +75,12 @@ func (q *Queries) CreatePeriod(ctx context.Context, arg CreatePeriodParams) (Fin &i.Year, &i.Month, &i.BudgetAmount, - &i.ReportingCurrency, &i.EssentialsPercent, &i.DesiresPercent, &i.SavingsPercent, &i.CreatedAt, &i.UpdatedAt, + &i.ReportingCurrency, ) return i, err } @@ -88,23 +88,31 @@ func (q *Queries) CreatePeriod(ctx context.Context, arg CreatePeriodParams) (Fin const createProRataSchedule = `-- name: CreateProRataSchedule :one INSERT INTO finance.pro_rata_schedules (user_id, pro_rata_group, name, amount, currency, expense_type, tag_id, - target_year, target_month, installment_index, installment_total) -VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) -RETURNING id, user_id, pro_rata_group, name, amount, currency, expense_type, tag_id, target_year, target_month, installment_index, installment_total, status, created_at, applied_at + target_year, target_month, installment_index, installment_total, + transaction_amount, transaction_currency, creation_reporting_currency, + captured_rate_snapshot, failure_reason) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, + $12, $13, $14, $15, $16) +RETURNING id, user_id, pro_rata_group, name, amount, currency, expense_type, tag_id, target_year, target_month, installment_index, installment_total, status, created_at, applied_at, transaction_amount, transaction_currency, creation_reporting_currency, captured_rate_snapshot, failure_reason ` type CreateProRataScheduleParams struct { - UserID pgtype.UUID `json:"user_id"` - ProRataGroup pgtype.UUID `json:"pro_rata_group"` - Name string `json:"name"` - Amount int64 `json:"amount"` - Currency string `json:"currency"` - ExpenseType string `json:"expense_type"` - TagID pgtype.UUID `json:"tag_id"` - TargetYear int32 `json:"target_year"` - TargetMonth int32 `json:"target_month"` - InstallmentIndex int32 `json:"installment_index"` - InstallmentTotal int32 `json:"installment_total"` + UserID pgtype.UUID `json:"user_id"` + ProRataGroup pgtype.UUID `json:"pro_rata_group"` + Name string `json:"name"` + Amount int64 `json:"amount"` + Currency string `json:"currency"` + ExpenseType string `json:"expense_type"` + TagID pgtype.UUID `json:"tag_id"` + TargetYear int32 `json:"target_year"` + TargetMonth int32 `json:"target_month"` + InstallmentIndex int32 `json:"installment_index"` + InstallmentTotal int32 `json:"installment_total"` + TransactionAmount pgtype.Int8 `json:"transaction_amount"` + TransactionCurrency pgtype.Text `json:"transaction_currency"` + CreationReportingCurrency pgtype.Text `json:"creation_reporting_currency"` + CapturedRateSnapshot []byte `json:"captured_rate_snapshot"` + FailureReason pgtype.Text `json:"failure_reason"` } func (q *Queries) CreateProRataSchedule(ctx context.Context, arg CreateProRataScheduleParams) (FinanceProRataSchedule, error) { @@ -120,6 +128,11 @@ func (q *Queries) CreateProRataSchedule(ctx context.Context, arg CreateProRataSc arg.TargetMonth, arg.InstallmentIndex, arg.InstallmentTotal, + arg.TransactionAmount, + arg.TransactionCurrency, + arg.CreationReportingCurrency, + arg.CapturedRateSnapshot, + arg.FailureReason, ) var i FinanceProRataSchedule err := row.Scan( @@ -138,6 +151,11 @@ func (q *Queries) CreateProRataSchedule(ctx context.Context, arg CreateProRataSc &i.Status, &i.CreatedAt, &i.AppliedAt, + &i.TransactionAmount, + &i.TransactionCurrency, + &i.CreationReportingCurrency, + &i.CapturedRateSnapshot, + &i.FailureReason, ) return i, err } @@ -228,7 +246,7 @@ func (q *Queries) DeleteTag(ctx context.Context, arg DeleteTagParams) error { } const getCurrentPeriod = `-- name: GetCurrentPeriod :one -SELECT id, user_id, year, month, budget_amount, reporting_currency, essentials_percent, desires_percent, savings_percent, created_at, updated_at FROM finance.budget_periods +SELECT id, user_id, year, month, budget_amount, essentials_percent, desires_percent, savings_percent, created_at, updated_at, reporting_currency FROM finance.budget_periods WHERE user_id = $1 AND year = $2 AND month = $3 ` @@ -247,12 +265,12 @@ func (q *Queries) GetCurrentPeriod(ctx context.Context, arg GetCurrentPeriodPara &i.Year, &i.Month, &i.BudgetAmount, - &i.ReportingCurrency, &i.EssentialsPercent, &i.DesiresPercent, &i.SavingsPercent, &i.CreatedAt, &i.UpdatedAt, + &i.ReportingCurrency, ) return i, err } @@ -305,7 +323,7 @@ func (q *Queries) GetHealthScore(ctx context.Context, arg GetHealthScoreParams) } const getLatestPeriod = `-- name: GetLatestPeriod :one -SELECT id, user_id, year, month, budget_amount, reporting_currency, essentials_percent, desires_percent, savings_percent, created_at, updated_at FROM finance.budget_periods +SELECT id, user_id, year, month, budget_amount, essentials_percent, desires_percent, savings_percent, created_at, updated_at, reporting_currency FROM finance.budget_periods WHERE user_id = $1 ORDER BY year DESC, month DESC LIMIT 1 @@ -320,18 +338,18 @@ func (q *Queries) GetLatestPeriod(ctx context.Context, userID pgtype.UUID) (Fina &i.Year, &i.Month, &i.BudgetAmount, - &i.ReportingCurrency, &i.EssentialsPercent, &i.DesiresPercent, &i.SavingsPercent, &i.CreatedAt, &i.UpdatedAt, + &i.ReportingCurrency, ) return i, err } const getPendingProRata = `-- name: GetPendingProRata :many -SELECT id, user_id, pro_rata_group, name, amount, currency, expense_type, tag_id, target_year, target_month, installment_index, installment_total, status, created_at, applied_at FROM finance.pro_rata_schedules +SELECT id, user_id, pro_rata_group, name, amount, currency, expense_type, tag_id, target_year, target_month, installment_index, installment_total, status, created_at, applied_at, transaction_amount, transaction_currency, creation_reporting_currency, captured_rate_snapshot, failure_reason FROM finance.pro_rata_schedules WHERE user_id = $1 AND target_year = $2 AND target_month = $3 AND status = 'pending' ORDER BY installment_index ASC ` @@ -367,6 +385,11 @@ func (q *Queries) GetPendingProRata(ctx context.Context, arg GetPendingProRataPa &i.Status, &i.CreatedAt, &i.AppliedAt, + &i.TransactionAmount, + &i.TransactionCurrency, + &i.CreationReportingCurrency, + &i.CapturedRateSnapshot, + &i.FailureReason, ); err != nil { return nil, err } @@ -379,7 +402,7 @@ func (q *Queries) GetPendingProRata(ctx context.Context, arg GetPendingProRataPa } const getPeriodByID = `-- name: GetPeriodByID :one -SELECT id, user_id, year, month, budget_amount, reporting_currency, essentials_percent, desires_percent, savings_percent, created_at, updated_at FROM finance.budget_periods +SELECT id, user_id, year, month, budget_amount, essentials_percent, desires_percent, savings_percent, created_at, updated_at, reporting_currency FROM finance.budget_periods WHERE id = $1 AND user_id = $2 ` @@ -397,12 +420,12 @@ func (q *Queries) GetPeriodByID(ctx context.Context, arg GetPeriodByIDParams) (F &i.Year, &i.Month, &i.BudgetAmount, - &i.ReportingCurrency, &i.EssentialsPercent, &i.DesiresPercent, &i.SavingsPercent, &i.CreatedAt, &i.UpdatedAt, + &i.ReportingCurrency, ) return i, err } @@ -432,7 +455,7 @@ func (q *Queries) GetTagByID(ctx context.Context, arg GetTagByIDParams) (Finance } const getUpcomingProRata = `-- name: GetUpcomingProRata :many -SELECT id, user_id, pro_rata_group, name, amount, currency, expense_type, tag_id, target_year, target_month, installment_index, installment_total, status, created_at, applied_at FROM finance.pro_rata_schedules +SELECT id, user_id, pro_rata_group, name, amount, currency, expense_type, tag_id, target_year, target_month, installment_index, installment_total, status, created_at, applied_at, transaction_amount, transaction_currency, creation_reporting_currency, captured_rate_snapshot, failure_reason FROM finance.pro_rata_schedules WHERE user_id = $1 AND status = 'pending' ORDER BY target_year ASC, target_month ASC, installment_index ASC ` @@ -462,6 +485,11 @@ func (q *Queries) GetUpcomingProRata(ctx context.Context, userID pgtype.UUID) ([ &i.Status, &i.CreatedAt, &i.AppliedAt, + &i.TransactionAmount, + &i.TransactionCurrency, + &i.CreationReportingCurrency, + &i.CapturedRateSnapshot, + &i.FailureReason, ); err != nil { return nil, err } @@ -517,7 +545,7 @@ func (q *Queries) ListHealthScoreScalars(ctx context.Context, userID pgtype.UUID } const listPeriods = `-- name: ListPeriods :many -SELECT id, user_id, year, month, budget_amount, reporting_currency, essentials_percent, desires_percent, savings_percent, created_at, updated_at FROM finance.budget_periods +SELECT id, user_id, year, month, budget_amount, essentials_percent, desires_percent, savings_percent, created_at, updated_at, reporting_currency FROM finance.budget_periods WHERE user_id = $1 ORDER BY year DESC, month DESC ` @@ -537,12 +565,12 @@ func (q *Queries) ListPeriods(ctx context.Context, userID pgtype.UUID) ([]Financ &i.Year, &i.Month, &i.BudgetAmount, - &i.ReportingCurrency, &i.EssentialsPercent, &i.DesiresPercent, &i.SavingsPercent, &i.CreatedAt, &i.UpdatedAt, + &i.ReportingCurrency, ); err != nil { return nil, err } @@ -603,7 +631,7 @@ UPDATE finance.budget_periods SET budget_amount = $1, essentials_percent = $2, desires_percent = $3, savings_percent = $4, updated_at = now() WHERE id = $5 AND user_id = $6 -RETURNING id, user_id, year, month, budget_amount, reporting_currency, essentials_percent, desires_percent, savings_percent, created_at, updated_at +RETURNING id, user_id, year, month, budget_amount, essentials_percent, desires_percent, savings_percent, created_at, updated_at, reporting_currency ` type UpdatePeriodParams struct { @@ -631,12 +659,12 @@ func (q *Queries) UpdatePeriod(ctx context.Context, arg UpdatePeriodParams) (Fin &i.Year, &i.Month, &i.BudgetAmount, - &i.ReportingCurrency, &i.EssentialsPercent, &i.DesiresPercent, &i.SavingsPercent, &i.CreatedAt, &i.UpdatedAt, + &i.ReportingCurrency, ) return i, err } diff --git a/services/finance/internal/db/models.go b/services/finance/internal/db/models.go index 9a0f25fa..0365eb42 100644 --- a/services/finance/internal/db/models.go +++ b/services/finance/internal/db/models.go @@ -14,12 +14,12 @@ type FinanceBudgetPeriod struct { Year int32 `json:"year"` Month int32 `json:"month"` BudgetAmount int64 `json:"budget_amount"` - ReportingCurrency string `json:"reporting_currency"` EssentialsPercent int32 `json:"essentials_percent"` DesiresPercent int32 `json:"desires_percent"` SavingsPercent int32 `json:"savings_percent"` CreatedAt pgtype.Timestamptz `json:"created_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"` + ReportingCurrency string `json:"reporting_currency"` } type FinanceDefaultSetting struct { @@ -44,22 +44,35 @@ type FinanceHealthScore struct { ComputedAt pgtype.Timestamptz `json:"computed_at"` } +type FinancePeriodReportingCurrencyMigrationReport struct { + PeriodID pgtype.UUID `json:"period_id"` + UserID pgtype.UUID `json:"user_id"` + ReportingCurrency string `json:"reporting_currency"` + Reason string `json:"reason"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + type FinanceProRataSchedule struct { - ID pgtype.UUID `json:"id"` - UserID pgtype.UUID `json:"user_id"` - ProRataGroup pgtype.UUID `json:"pro_rata_group"` - Name string `json:"name"` - Amount int64 `json:"amount"` - Currency string `json:"currency"` - ExpenseType string `json:"expense_type"` - TagID pgtype.UUID `json:"tag_id"` - TargetYear int32 `json:"target_year"` - TargetMonth int32 `json:"target_month"` - InstallmentIndex int32 `json:"installment_index"` - InstallmentTotal int32 `json:"installment_total"` - Status string `json:"status"` - CreatedAt pgtype.Timestamptz `json:"created_at"` - AppliedAt pgtype.Timestamptz `json:"applied_at"` + ID pgtype.UUID `json:"id"` + UserID pgtype.UUID `json:"user_id"` + ProRataGroup pgtype.UUID `json:"pro_rata_group"` + Name string `json:"name"` + Amount int64 `json:"amount"` + Currency string `json:"currency"` + ExpenseType string `json:"expense_type"` + TagID pgtype.UUID `json:"tag_id"` + TargetYear int32 `json:"target_year"` + TargetMonth int32 `json:"target_month"` + InstallmentIndex int32 `json:"installment_index"` + InstallmentTotal int32 `json:"installment_total"` + Status string `json:"status"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + AppliedAt pgtype.Timestamptz `json:"applied_at"` + TransactionAmount pgtype.Int8 `json:"transaction_amount"` + TransactionCurrency pgtype.Text `json:"transaction_currency"` + CreationReportingCurrency pgtype.Text `json:"creation_reporting_currency"` + CapturedRateSnapshot []byte `json:"captured_rate_snapshot"` + FailureReason pgtype.Text `json:"failure_reason"` } type FinanceTag struct { @@ -70,3 +83,13 @@ type FinanceTag struct { CreatedAt pgtype.Timestamptz `json:"created_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"` } + +type PeriodReportingCurrencyBackfill struct { + PeriodID pgtype.UUID `json:"period_id"` + ReportingCurrency string `json:"reporting_currency"` + Source string `json:"source"` +} + +type PeriodReportingCurrencySupported struct { + Code string `json:"code"` +} diff --git a/services/finance/internal/handler/grpc.go b/services/finance/internal/handler/grpc.go index 22143718..28a854ac 100644 --- a/services/finance/internal/handler/grpc.go +++ b/services/finance/internal/handler/grpc.go @@ -129,6 +129,8 @@ func financeErrorStatus(err error) error { return status.Error(codes.NotFound, apiErr.Message) case model.ErrPeriodLocked: return status.Error(codes.PermissionDenied, apiErr.Message) + case model.ErrConversionUnavailable: + return status.Error(codes.Unavailable, apiErr.Message) } return nil } @@ -472,6 +474,39 @@ func (h *GRPCHandler) GetAllUserData(ctx context.Context, req *pb.GetAllUserData }, nil } +func (h *GRPCHandler) CreateProRataExpense(ctx context.Context, req *pb.CreateProRataExpenseRequest) (*pb.ProRataResponse, error) { + _, err := h.financeService.CreateProRataExpense(ctx, req.GetUserId(), &model.CreateProRataRequest{ + Name: req.GetName(), + TotalAmount: req.GetTotalAmount(), + TransactionCurrency: req.GetTransactionCurrency(), + ExpenseType: req.GetExpenseType(), + TagID: req.GetTagId(), + ExpenseDate: req.GetExpenseDate(), + Months: req.GetMonths(), + PeriodYear: req.GetPeriodYear(), + PeriodMonth: req.GetPeriodMonth(), + }) + if err != nil { + if statusErr := financeErrorStatus(err); statusErr != nil { + return nil, statusErr + } + reportServerFailure(ctx, err, errkit.Meta{ + Op: "finance.create_pro_rata_expense", + Domain: reportDomain, + Msg: "failed to create pro-rata expense", + Data: map[string]any{ + "method": "CreateProRataExpense", + "user_id": req.GetUserId(), + }, + }) + return nil, status.Error(codes.Internal, "failed to create pro-rata expense") + } + + return &pb.ProRataResponse{ + Message: "pro-rata schedule created", + }, nil +} + func (h *GRPCHandler) DeleteAllUserData(ctx context.Context, req *pb.DeleteAllUserDataRequest) (*pb.DeleteAllUserDataResponse, error) { userID := req.GetUserId() if userID == "" { diff --git a/services/finance/internal/handler/grpc_prorata_test.go b/services/finance/internal/handler/grpc_prorata_test.go new file mode 100644 index 00000000..a6b86e53 --- /dev/null +++ b/services/finance/internal/handler/grpc_prorata_test.go @@ -0,0 +1,92 @@ +package handler + +import ( + "context" + "io" + "log/slog" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + + "github.com/ItsThompson/gofin/services/finance/internal/model" + "github.com/ItsThompson/gofin/services/finance/internal/service" + pb "github.com/ItsThompson/gofin/services/finance/proto/financepb" +) + +func setupProRataGRPCHandler(repo *mockFinanceRepository, exp *mockExpenseClient, fx *mockFxClient) *GRPCHandler { + logger := slog.New(slog.NewJSONHandler(io.Discard, nil)) + now := time.Date(2026, 5, 15, 12, 0, 0, 0, time.UTC) + financeSvc := service.NewFinanceServiceWithFx(repo, new(mockTxBeginner), exp, fx, func() time.Time { return now }, logger) + return NewGRPCHandler(financeSvc) +} + +func TestGRPC_CreateProRataExpense_Success(t *testing.T) { + repo := new(mockFinanceRepository) + exp := new(mockExpenseClient) + fx := new(mockFxClient) + handler := setupProRataGRPCHandler(repo, exp, fx) + + repo.On("GetCurrentPeriod", mock.Anything, "user-1", int32(2026), int32(5)). + Return(&model.BudgetPeriod{ID: "period-1", UserID: "user-1", Year: 2026, Month: 5, ReportingCurrency: "USD"}, nil) + + snapshot := &model.CapturedRateSnapshot{ + SnapshotVersion: 1, + Source: "open_exchange_rates", + BaseCurrency: "USD", + RateTimestamp: "2026-05-15T10:00:00Z", + RatesByCurrency: map[string]string{"USD": "1"}, + } + fx.On("CaptureRateSnapshot", mock.Anything, mock.Anything).Return(snapshot, nil) + exp.On("CreateProRataInstallment", mock.Anything, mock.MatchedBy(func(req service.CreateProRataInstallmentInput) bool { + return req.Currency == "USD" && req.PeriodContext.Year == 2026 && req.PeriodContext.Month == 5 + })).Return(&service.CreatedExpenseData{ID: "exp-1", CreatedAt: "2026-05-15T12:00:00Z"}, nil) + repo.On("CreateProRataSchedule", mock.Anything, mock.Anything). + Return(&model.ProRataSchedule{ID: "sched-1", Status: "pending"}, nil) + + resp, err := handler.CreateProRataExpense(context.Background(), &pb.CreateProRataExpenseRequest{ + UserId: "user-1", + Name: "Annual subscription", + TotalAmount: 6000, + TransactionCurrency: "USD", + ExpenseType: "essentials", + TagId: "tag-1", + ExpenseDate: "2026-05-15", + Months: 2, + PeriodYear: 2026, + PeriodMonth: 5, + }) + + require.NoError(t, err) + assert.Equal(t, "pro-rata schedule created", resp.GetMessage()) + exp.AssertExpectations(t) +} + +func TestGRPC_CreateProRataExpense_MissingPeriod(t *testing.T) { + repo := new(mockFinanceRepository) + exp := new(mockExpenseClient) + fx := new(mockFxClient) + handler := setupProRataGRPCHandler(repo, exp, fx) + + resp, err := handler.CreateProRataExpense(context.Background(), &pb.CreateProRataExpenseRequest{ + UserId: "user-1", + Name: "Annual subscription", + TotalAmount: 6000, + TransactionCurrency: "USD", + ExpenseType: "essentials", + TagId: "tag-1", + ExpenseDate: "2026-05-15", + Months: 2, + }) + + assert.Nil(t, resp) + require.Error(t, err) + st, ok := status.FromError(err) + require.True(t, ok) + assert.Equal(t, codes.InvalidArgument, st.Code()) + exp.AssertNotCalled(t, "CreateProRataInstallment", mock.Anything, mock.Anything) +} diff --git a/services/finance/internal/handler/mocks_test.go b/services/finance/internal/handler/mocks_test.go index b9a9af47..ac483f78 100644 --- a/services/finance/internal/handler/mocks_test.go +++ b/services/finance/internal/handler/mocks_test.go @@ -242,3 +242,24 @@ func (m *mockExpenseClient) CreateExpense(ctx context.Context, req service.Creat } return args.Get(0).(*service.CreatedExpenseData), args.Error(1) } + +func (m *mockExpenseClient) CreateProRataInstallment(ctx context.Context, req service.CreateProRataInstallmentInput) (*service.CreatedExpenseData, error) { + args := m.Called(ctx, req) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*service.CreatedExpenseData), args.Error(1) +} + +// mockFxClient implements service.FxClient for handler tests. +type mockFxClient struct { + mock.Mock +} + +func (m *mockFxClient) CaptureRateSnapshot(ctx context.Context, req service.FxCaptureRequest) (*model.CapturedRateSnapshot, error) { + args := m.Called(ctx, req) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*model.CapturedRateSnapshot), args.Error(1) +} diff --git a/services/finance/internal/handler/prorata_test.go b/services/finance/internal/handler/prorata_test.go index dcca1959..e062fe15 100644 --- a/services/finance/internal/handler/prorata_test.go +++ b/services/finance/internal/handler/prorata_test.go @@ -21,22 +21,47 @@ func TestCreateProRataExpenseHandler_TransactionCurrencyOnly(t *testing.T) { repo := new(mockFinanceRepository) txBeginner := new(mockTxBeginner) expClient := new(mockExpenseClient) + fxClient := new(mockFxClient) now := time.Date(2026, 5, 15, 12, 0, 0, 0, time.UTC) logger := slog.New(slog.NewJSONHandler(io.Discard, nil)) - financeSvc := service.NewFinanceService(repo, txBeginner, expClient, func() time.Time { return now }, logger) + financeSvc := service.NewFinanceServiceWithFx(repo, txBeginner, expClient, fxClient, func() time.Time { return now }, logger) gin.SetMode(gin.TestMode) h := NewRESTHandler(financeSvc) r := gin.New() h.RegisterRoutes(r) - expClient.On("CreateExpense", mock.Anything, mock.MatchedBy(func(req service.CreateExpenseInput) bool { - return req.TransactionCurrency == "EUR" && req.IsProRata + repo.On("GetCurrentPeriod", mock.Anything, "user-1", int32(2026), int32(5)). + Return(&model.BudgetPeriod{ + ID: "period-1", UserID: "user-1", Year: 2026, Month: 5, ReportingCurrency: "USD", + }, nil) + + snapshot := &model.CapturedRateSnapshot{ + SnapshotVersion: 1, + Source: "open_exchange_rates", + BaseCurrency: "USD", + RateTimestamp: "2026-05-15T10:00:00Z", + CapturedAt: "2026-05-15T12:00:00Z", + ExpiresAt: "2026-05-15T13:00:00Z", + RatesByCurrency: map[string]string{"USD": "1", "EUR": "0.92"}, + } + fxClient.On("CaptureRateSnapshot", mock.Anything, mock.MatchedBy(func(req service.FxCaptureRequest) bool { + return len(req.RequiredCurrencies) == 2 && req.RequiredCurrencies[0] == "EUR" && req.RequiredCurrencies[1] == "USD" + })).Return(snapshot, nil) + + expClient.On("CreateProRataInstallment", mock.Anything, mock.MatchedBy(func(req service.CreateProRataInstallmentInput) bool { + return req.Currency == "EUR" && + req.PeriodContext.Year == 2026 && + req.PeriodContext.Month == 5 && + req.PeriodContext.ReportingCurrency == "USD" && + req.CapturedRateSnapshot == snapshot })).Return(&service.CreatedExpenseData{ID: "exp-1", CreatedAt: "2026-05-15T12:00:00Z"}, nil) repo.On("CreateProRataSchedule", mock.Anything, mock.MatchedBy(func(s *model.ProRataSchedule) bool { - return s.Currency == "EUR" + return s.TransactionCurrency == "EUR" && + s.CreationReportingCurrency == "USD" && + s.CapturedRateSnapshot.RateTimestamp == snapshot.RateTimestamp })).Return(&model.ProRataSchedule{ ID: "sched-1", Status: "pending", }, nil) @@ -49,6 +74,8 @@ func TestCreateProRataExpenseHandler_TransactionCurrencyOnly(t *testing.T) { "tagId": "tag-1", "expenseDate": "2026-05-15", "months": 2, + "periodYear": 2026, + "periodMonth": 5, }) assert.Equal(t, http.StatusCreated, w.Code) @@ -56,6 +83,7 @@ func TestCreateProRataExpenseHandler_TransactionCurrencyOnly(t *testing.T) { var resp model.ProRataResponse require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) assert.Equal(t, "exp-1", resp.Expense.ID) - assert.Equal(t, "EUR", resp.Expense.Currency) + assert.Equal(t, "EUR", resp.Expense.TransactionCurrency) expClient.AssertExpectations(t) + fxClient.AssertExpectations(t) } diff --git a/services/finance/internal/model/errors.go b/services/finance/internal/model/errors.go index 2923e1ec..99fa203a 100644 --- a/services/finance/internal/model/errors.go +++ b/services/finance/internal/model/errors.go @@ -12,4 +12,5 @@ const ( ErrPeriodLocked = "PERIOD_LOCKED" ErrUnsupportedCurrency = "UNSUPPORTED_CURRENCY" ErrReportingCurrencyImmutable = "REPORTING_CURRENCY_IMMUTABLE" + ErrConversionUnavailable = "CONVERSION_UNAVAILABLE" ) diff --git a/services/finance/internal/model/prorata.go b/services/finance/internal/model/prorata.go index a06fc210..7301efd4 100644 --- a/services/finance/internal/model/prorata.go +++ b/services/finance/internal/model/prorata.go @@ -19,6 +19,27 @@ type ProRataSchedule struct { Status string `json:"status"` CreatedAt time.Time `json:"createdAt"` AppliedAt *time.Time `json:"appliedAt"` + + // Capture intent fields for schedules created after the multi-currency + // cutover. Legacy rows leave these empty and are resolved by migration. + TransactionAmount int64 `json:"transactionAmount"` + TransactionCurrency string `json:"transactionCurrency"` + CreationReportingCurrency string `json:"creationReportingCurrency"` + CapturedRateSnapshot *CapturedRateSnapshot `json:"capturedRateSnapshot,omitempty"` + FailureReason string `json:"failureReason,omitempty"` +} + +// CapturedRateSnapshot is the USD-based provider snapshot stored on pro-rata +// schedule rows so future target periods can derive reporting amounts without +// a live provider rate. +type CapturedRateSnapshot struct { + SnapshotVersion int32 `json:"snapshotVersion"` + Source string `json:"source"` + BaseCurrency string `json:"baseCurrency"` + RateTimestamp string `json:"rateTimestamp"` + CapturedAt string `json:"capturedAt"` + ExpiresAt string `json:"expiresAt"` + RatesByCurrency map[string]string `json:"ratesByCurrency"` } // CreateProRataRequest is the input for POST /api/finance/prorata. @@ -30,6 +51,8 @@ type CreateProRataRequest struct { TagID string `json:"tagId" binding:"required"` ExpenseDate string `json:"expenseDate" binding:"required"` Months int32 `json:"months" binding:"required"` + PeriodYear int32 `json:"periodYear" binding:"required"` + PeriodMonth int32 `json:"periodMonth" binding:"required"` } // ProRataResponse is the JSON body returned for POST /api/finance/prorata. @@ -41,20 +64,21 @@ type ProRataResponse struct { // CreatedExpense is a simplified expense representation returned by the finance service // after the expense service creates it via gRPC. type CreatedExpense struct { - ID string `json:"id"` - Name string `json:"name"` - Amount int64 `json:"amount"` - Currency string `json:"currency"` - ExpenseType string `json:"expenseType"` - TagID string `json:"tagId"` - ExpenseDate string `json:"expenseDate"` - PeriodYear int32 `json:"periodYear"` - PeriodMonth int32 `json:"periodMonth"` - IsProRata bool `json:"isProRata"` - ProRataGroup string `json:"proRataGroup"` - ProRataIndex int32 `json:"proRataIndex"` - ProRataTotal int32 `json:"proRataTotal"` - CreatedAt string `json:"createdAt"` + ID string `json:"id"` + Name string `json:"name"` + Amount int64 `json:"amount"` + TransactionCurrency string `json:"transactionCurrency"` + Currency string `json:"currency"` + ExpenseType string `json:"expenseType"` + TagID string `json:"tagId"` + ExpenseDate string `json:"expenseDate"` + PeriodYear int32 `json:"periodYear"` + PeriodMonth int32 `json:"periodMonth"` + IsProRata bool `json:"isProRata"` + ProRataGroup string `json:"proRataGroup"` + ProRataIndex int32 `json:"proRataIndex"` + ProRataTotal int32 `json:"proRataTotal"` + CreatedAt string `json:"createdAt"` } // UpcomingProRataResponse is the JSON body returned for GET /api/finance/prorata/upcoming. diff --git a/services/finance/internal/repository/postgres.go b/services/finance/internal/repository/postgres.go index 0e95667c..c1f9ecbe 100644 --- a/services/finance/internal/repository/postgres.go +++ b/services/finance/internal/repository/postgres.go @@ -7,6 +7,7 @@ import ( "github.com/google/uuid" "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" "github.com/jackc/pgx/v5/pgxpool" "github.com/ItsThompson/gofin/services/finance/internal/db" @@ -308,18 +309,28 @@ func (r *PostgresFinanceRepository) CreateProRataSchedule(ctx context.Context, s return nil, err } + snapl, err := marshalCapturedSnapshot(schedule.CapturedRateSnapshot) + if err != nil { + return nil, err + } + row, err := r.queries.CreateProRataSchedule(ctx, db.CreateProRataScheduleParams{ - UserID: uid, - ProRataGroup: groupID, - Name: schedule.Name, - Amount: schedule.Amount, - Currency: schedule.Currency, - ExpenseType: schedule.ExpenseType, - TagID: tagID, - TargetYear: schedule.TargetYear, - TargetMonth: schedule.TargetMonth, - InstallmentIndex: schedule.InstallmentIndex, - InstallmentTotal: schedule.InstallmentTotal, + UserID: uid, + ProRataGroup: groupID, + Name: schedule.Name, + Amount: schedule.Amount, + Currency: schedule.Currency, + ExpenseType: schedule.ExpenseType, + TagID: tagID, + TargetYear: schedule.TargetYear, + TargetMonth: schedule.TargetMonth, + InstallmentIndex: schedule.InstallmentIndex, + InstallmentTotal: schedule.InstallmentTotal, + TransactionAmount: pgtype.Int8{Int64: schedule.TransactionAmount, Valid: true}, + TransactionCurrency: pgtype.Text{String: schedule.TransactionCurrency, Valid: schedule.TransactionCurrency != ""}, + CreationReportingCurrency: pgtype.Text{String: schedule.CreationReportingCurrency, Valid: schedule.CreationReportingCurrency != ""}, + CapturedRateSnapshot: snapl, + FailureReason: pgtype.Text{String: schedule.FailureReason, Valid: schedule.FailureReason != ""}, }) if err != nil { return nil, err @@ -547,9 +558,39 @@ func dbScheduleToModel(s db.FinanceProRataSchedule) *model.ProRataSchedule { appliedAt := s.AppliedAt.Time result.AppliedAt = &appliedAt } + if s.TransactionAmount.Valid { + result.TransactionAmount = s.TransactionAmount.Int64 + } + if s.TransactionCurrency.Valid { + result.TransactionCurrency = s.TransactionCurrency.String + } + if s.CreationReportingCurrency.Valid { + result.CreationReportingCurrency = s.CreationReportingCurrency.String + } + if s.FailureReason.Valid { + result.FailureReason = s.FailureReason.String + } + if len(s.CapturedRateSnapshot) > 0 { + result.CapturedRateSnapshot = unmarshalCapturedSnapshot(s.CapturedRateSnapshot) + } return result } +func marshalCapturedSnapshot(snapshot *model.CapturedRateSnapshot) ([]byte, error) { + if snapshot == nil { + return nil, nil + } + return json.Marshal(snapshot) +} + +func unmarshalCapturedSnapshot(payload []byte) *model.CapturedRateSnapshot { + var snapshot model.CapturedRateSnapshot + if err := json.Unmarshal(payload, &snapshot); err != nil { + return nil + } + return &snapshot +} + // PostgresTxBeginner implements TxBeginner using pgxpool. type PostgresTxBeginner struct { pool *pgxpool.Pool diff --git a/services/finance/internal/service/expense_client.go b/services/finance/internal/service/expense_client.go index 64d6f1e0..3c1c18d8 100644 --- a/services/finance/internal/service/expense_client.go +++ b/services/finance/internal/service/expense_client.go @@ -1,6 +1,10 @@ package service -import "context" +import ( + "context" + + "github.com/ItsThompson/gofin/services/finance/internal/model" +) // ExpenseData holds the fields the finance service needs from an expense record. type ExpenseData struct { @@ -18,6 +22,35 @@ type ExpenseClient interface { GetExpensesForPeriod(ctx context.Context, userID string, year, month int32) ([]ExpenseData, error) CountExpensesByTag(ctx context.Context, userID, tagID string) (int64, error) CreateExpense(ctx context.Context, req CreateExpenseInput) (*CreatedExpenseData, error) + CreateProRataInstallment(ctx context.Context, req CreateProRataInstallmentInput) (*CreatedExpenseData, error) +} + +// TrustedPeriodContext is period context Finance resolved locally before asking +// Expense to write a pro-rata installment. Expense validates it but does not call +// Finance again for Finance-originated writes. +type TrustedPeriodContext struct { + PeriodID string + UserID string + Year int32 + Month int32 + ReportingCurrency string + Source string +} + +// CreateProRataInstallmentInput is the Finance-originated internal write contract. +type CreateProRataInstallmentInput struct { + UserID string + PeriodContext TrustedPeriodContext + Name string + Amount int64 + Currency string + ExpenseType string + TagID string + ExpenseDate string + ProRataGroup string + ProRataIndex int32 + ProRataTotal int32 + CapturedRateSnapshot *model.CapturedRateSnapshot } // CreateExpenseInput is the data needed to create an expense via the expense service. diff --git a/services/finance/internal/service/fanout_helpers_test.go b/services/finance/internal/service/fanout_helpers_test.go index 34580fd4..715ef860 100644 --- a/services/finance/internal/service/fanout_helpers_test.go +++ b/services/finance/internal/service/fanout_helpers_test.go @@ -114,6 +114,10 @@ func (c *countingExpenseClient) CreateExpense(context.Context, CreateExpenseInpu return nil, fmt.Errorf("CreateExpense not expected in dashboard fan-out tests") } +func (c *countingExpenseClient) CreateProRataInstallment(context.Context, CreateProRataInstallmentInput) (*CreatedExpenseData, error) { + return nil, fmt.Errorf("CreateProRataInstallment not expected in dashboard fan-out tests") +} + // fakeFanoutRepo returns canned periods for the dashboard read paths. Only the // two methods those paths call (ListPeriods, GetCurrentPeriod) are implemented; // the embedded interface is nil, so any other repo call panics and surfaces an diff --git a/services/finance/internal/service/finance.go b/services/finance/internal/service/finance.go index 271218d3..13b9ddee 100644 --- a/services/finance/internal/service/finance.go +++ b/services/finance/internal/service/finance.go @@ -35,6 +35,7 @@ type FinanceService struct { repo repository.FinanceRepository txBeginner repository.TxBeginner expenseClient ExpenseClient + fxClient FxClient nowFunc func() time.Time logger *slog.Logger } @@ -42,13 +43,29 @@ type FinanceService struct { // NewFinanceService creates a new FinanceService with all dependencies injected. // expenseClient is always supplied, so the dashboard and pro-rata paths // dereference it without a nil guard. nowFunc is the clock seam (pass time.Now -// in production); a nil nowFunc defaults to time.Now. +// in production); a nil nowFunc defaults to time.Now. fxClient is nil: callers +// that create pro-rata schedules must use NewFinanceServiceWithFx instead. func NewFinanceService( repo repository.FinanceRepository, txBeginner repository.TxBeginner, expenseClient ExpenseClient, nowFunc func() time.Time, logger *slog.Logger, +) *FinanceService { + return NewFinanceServiceWithFx(repo, txBeginner, expenseClient, nil, nowFunc, logger) +} + +// NewFinanceServiceWithFx creates a FinanceService with the FX client used for +// pro-rata snapshot capture. Production wiring passes a real GRPCFxClient; tests +// pass a mock. A nil fxClient makes CreateProRataExpense fail with an internal +// error rather than skipping the required capture. +func NewFinanceServiceWithFx( + repo repository.FinanceRepository, + txBeginner repository.TxBeginner, + expenseClient ExpenseClient, + fxClient FxClient, + nowFunc func() time.Time, + logger *slog.Logger, ) *FinanceService { if nowFunc == nil { nowFunc = time.Now @@ -57,6 +74,7 @@ func NewFinanceService( repo: repo, txBeginner: txBeginner, expenseClient: expenseClient, + fxClient: fxClient, nowFunc: nowFunc, logger: logger, } diff --git a/services/finance/internal/service/fx_client.go b/services/finance/internal/service/fx_client.go new file mode 100644 index 00000000..3ff0b09e --- /dev/null +++ b/services/finance/internal/service/fx_client.go @@ -0,0 +1,90 @@ +package service + +import ( + "context" + "fmt" + "net/http" + + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" + + "github.com/ItsThompson/gofin/services/apierr" + "github.com/ItsThompson/gofin/services/finance/internal/model" + fxpb "github.com/ItsThompson/gofin/services/fx/proto/fxpb" +) + +// FxCaptureRequest is the Finance-side view of a snapshot-capture request. It +// carries only the currencies known at capture time plus a caller-observed +// request time; FX returns the full USD-based rate map so future target +// currencies can be derived. +type FxCaptureRequest struct { + RequiredCurrencies []string + RequestedAt string +} + +// FxClient captures full provider snapshots for pro-rata schedules. Finance +// needs only this one operation: per-installment conversion is delegated to +// Expense, which owns ledger-write conversion. +type FxClient interface { + CaptureRateSnapshot(ctx context.Context, req FxCaptureRequest) (*model.CapturedRateSnapshot, error) +} + +// GRPCFxClient implements FxClient over the FX Service gRPC API. +type GRPCFxClient struct { + client fxpb.FxServiceClient +} + +// NewGRPCFxClient wraps an existing FX gRPC client. +func NewGRPCFxClient(client fxpb.FxServiceClient) *GRPCFxClient { + return &GRPCFxClient{client: client} +} + +// NewGRPCFxClientFromAddr builds an FX gRPC client and returns the underlying +// connection so the caller owns its lifetime and closes it. FX is +// compute-network only, so the transport is insecure. +func NewGRPCFxClientFromAddr(addr string) (*GRPCFxClient, *grpc.ClientConn, error) { + conn, err := grpc.NewClient(addr, grpc.WithTransportCredentials(insecure.NewCredentials())) + if err != nil { + return nil, nil, fmt.Errorf("creating fx service client for %s: %w", addr, err) + } + return NewGRPCFxClient(fxpb.NewFxServiceClient(conn)), conn, nil +} + +// CaptureRateSnapshot calls the FX Service CaptureRateSnapshot RPC. Any failure +// maps to CONVERSION_UNAVAILABLE: the pro-rata schedule must not be written when +// a fresh snapshot cannot be captured (spec 05/06). +func (c *GRPCFxClient) CaptureRateSnapshot(ctx context.Context, req FxCaptureRequest) (*model.CapturedRateSnapshot, error) { + resp, err := c.client.CaptureRateSnapshot(ctx, &fxpb.CaptureRateSnapshotRequest{ + RequiredCurrencies: req.RequiredCurrencies, + RequestedAt: req.RequestedAt, + }) + if err != nil { + return nil, fxCaptureUnavailableError() + } + return snapshotFromProto(resp.GetSnapshot()), nil +} + +func snapshotFromProto(pb *fxpb.CapturedRateSnapshot) *model.CapturedRateSnapshot { + if pb == nil { + return nil + } + return &model.CapturedRateSnapshot{ + SnapshotVersion: pb.GetSnapshotVersion(), + Source: pb.GetSource(), + BaseCurrency: pb.GetBaseCurrency(), + RateTimestamp: pb.GetRateTimestamp(), + CapturedAt: pb.GetCapturedAt(), + ExpiresAt: pb.GetExpiresAt(), + RatesByCurrency: pb.GetRatesByCurrency(), + } +} + +func fxCaptureUnavailableError() *apierr.Error { + return &apierr.Error{ + Code: model.ErrConversionUnavailable, + Message: "Conversion unavailable. Try again later, or enter the manually converted amount in the period currency.", + Status: http.StatusServiceUnavailable, + } +} + +var _ FxClient = (*GRPCFxClient)(nil) diff --git a/services/finance/internal/service/grpc_expense_client.go b/services/finance/internal/service/grpc_expense_client.go index e701a77b..9cdaef19 100644 --- a/services/finance/internal/service/grpc_expense_client.go +++ b/services/finance/internal/service/grpc_expense_client.go @@ -5,6 +5,8 @@ import ( "fmt" expensepb "github.com/ItsThompson/gofin/services/expense/proto/expensepb" + + "github.com/ItsThompson/gofin/services/finance/internal/model" ) // GRPCExpenseClient implements ExpenseClient by calling the expense service gRPC API. @@ -80,3 +82,53 @@ func (c *GRPCExpenseClient) CreateExpense(ctx context.Context, req CreateExpense CreatedAt: resp.GetExpense().GetCreatedAt(), }, nil } + +// CreateProRataInstallment calls the Expense internal pro-rata write RPC with +// trusted period context and the captured snapshot. Expense does not re-fetch +// Finance context for this path. +func (c *GRPCExpenseClient) CreateProRataInstallment(ctx context.Context, req CreateProRataInstallmentInput) (*CreatedExpenseData, error) { + resp, err := c.client.CreateProRataInstallment(ctx, &expensepb.CreateProRataInstallmentRequest{ + UserId: req.UserID, + PeriodContext: &expensepb.TrustedPeriodContext{ + PeriodId: req.PeriodContext.PeriodID, + UserId: req.PeriodContext.UserID, + Year: req.PeriodContext.Year, + Month: req.PeriodContext.Month, + ReportingCurrency: req.PeriodContext.ReportingCurrency, + Source: req.PeriodContext.Source, + }, + Name: req.Name, + Amount: req.Amount, + TransactionCurrency: req.Currency, + ExpenseType: req.ExpenseType, + TagId: req.TagID, + ExpenseDate: req.ExpenseDate, + ProRataGroup: req.ProRataGroup, + ProRataIndex: req.ProRataIndex, + ProRataTotal: req.ProRataTotal, + CapturedRateSnapshot: snapshotToProto(req.CapturedRateSnapshot), + }) + if err != nil { + return nil, fmt.Errorf("gRPC CreateProRataInstallment: %w", err) + } + + return &CreatedExpenseData{ + ID: resp.GetExpense().GetId(), + CreatedAt: resp.GetExpense().GetCreatedAt(), + }, nil +} + +func snapshotToProto(s *model.CapturedRateSnapshot) *expensepb.CapturedRateSnapshot { + if s == nil { + return nil + } + return &expensepb.CapturedRateSnapshot{ + SnapshotVersion: s.SnapshotVersion, + Source: s.Source, + BaseCurrency: s.BaseCurrency, + RateTimestamp: s.RateTimestamp, + CapturedAt: s.CapturedAt, + ExpiresAt: s.ExpiresAt, + RatesByCurrency: s.RatesByCurrency, + } +} diff --git a/services/finance/internal/service/prorata.go b/services/finance/internal/service/prorata.go index d24d43c7..58fd27a4 100644 --- a/services/finance/internal/service/prorata.go +++ b/services/finance/internal/service/prorata.go @@ -11,6 +11,8 @@ import ( "github.com/ItsThompson/gofin/services/apierr" "github.com/ItsThompson/gofin/services/finance/internal/model" + currencycatalog "github.com/ItsThompson/gofin/services/shared/currency" + "github.com/ItsThompson/gofin/services/shared/validator" ) // CalculateInstallments divides totalAmount across months using integer division. @@ -51,80 +53,115 @@ func monthLabel(year int32, month int32) string { return t.Format("January 2006") } -// CreateProRataExpense creates a pro-rata expense: writes the first installment via -// gRPC to the expense service, then creates PostgreSQL schedules for months 2-N. +// CreateProRataExpense creates a pro-rata schedule with explicit selected-period +// context and captured FX intent. Finance validates the creation period, captures +// one full provider snapshot, writes the first installment through the trusted +// internal Expense contract, and stores future rows with the same snapshot. func (s *FinanceService) CreateProRataExpense(ctx context.Context, userID string, req *model.CreateProRataRequest) (*model.ProRataResponse, error) { - if strings.TrimSpace(req.Name) == "" { - return nil, apierr.Validation("Name is required", map[string]string{"name": "required"}) - } - if req.TotalAmount <= 0 { - return nil, apierr.Validation("Total amount must be positive", map[string]string{"totalAmount": "must be positive"}) - } - if req.Months < 2 { - return nil, apierr.Validation("Pro-rata requires at least 2 months", map[string]string{"months": "must be at least 2"}) - } + v := validator.New() + v.Check(strings.TrimSpace(req.Name) != "", "name", "required") + v.Check(req.TotalAmount > 0, "totalAmount", "must be positive") + v.Check(req.Months >= 2, "months", "must be at least 2") validTypes := map[string]bool{"essentials": true, "desires": true, "savings": true} - if !validTypes[req.ExpenseType] { - return nil, apierr.Validation("Expense type must be essentials, desires, or savings", map[string]string{"expenseType": "must be essentials, desires, or savings"}) - } - if strings.TrimSpace(req.TagID) == "" { - return nil, apierr.Validation("Tag ID is required", map[string]string{"tagId": "required"}) + v.Check(validTypes[req.ExpenseType], "expenseType", "must be essentials, desires, or savings") + v.Check(strings.TrimSpace(req.TagID) != "", "tagId", "required") + v.Check(strings.TrimSpace(req.ExpenseDate) != "", "expenseDate", "required") + v.Check(req.PeriodYear >= 1, "periodYear", "required") + v.Check(req.PeriodMonth >= 1 && req.PeriodMonth <= 12, "periodMonth", "must be between 1 and 12") + if v.HasErrors() { + return nil, apierr.Validation("validation failed", v.Errors()) + } + + // The creation period is the schedule's first target. Validate it exists + // before any first-installment write or future schedule insert. + period, err := s.GetCurrentPeriod(ctx, userID, req.PeriodYear, req.PeriodMonth) + if err != nil { + return nil, err } - if strings.TrimSpace(req.ExpenseDate) == "" { - return nil, apierr.Validation("Expense date is required", map[string]string{"expenseDate": "required"}) + reportingCurrency := normalizeCurrencyCode(period.ReportingCurrency) + if !currencycatalog.IsSupported(reportingCurrency) { + return nil, apierr.Internal("creation period reporting currency is not supported") } - resolvedCurrency := normalizeCurrencyCode(req.TransactionCurrency) - if resolvedCurrency == "" { - return nil, apierr.Validation("Transaction currency is required", map[string]string{"transactionCurrency": "required"}) - } - if verr := validateSupportedCurrency("transactionCurrency", resolvedCurrency); verr != nil { - return nil, verr + transactionCurrency, err := s.resolveProRataTransactionCurrency(period, req) + if err != nil { + return nil, err } + installments := CalculateInstallments(req.TotalAmount, req.Months) proRataGroup := uuid.New().String() + now := s.nowFunc().UTC().Format(time.RFC3339) + + // Pro-rata always spans at least two months, so capture the full provider + // snapshot before the first installment or any future row is written. + if s.fxClient == nil { + return nil, apierr.Internal("pro-rata snapshot capture is not configured") + } + snapshot, err := s.fxClient.CaptureRateSnapshot(ctx, FxCaptureRequest{ + RequiredCurrencies: []string{transactionCurrency, reportingCurrency}, + RequestedAt: now, + }) + if err != nil { + s.logger.Info("pro-rata snapshot capture failed", + slog.String("method", "CreateProRataExpense"), + slog.String("user_id", userID), + slog.String("transaction_currency", transactionCurrency), + slog.String("reporting_currency", reportingCurrency), + slog.String("error", err.Error()), + ) + return nil, err + } + if snapshot == nil { + return nil, apierr.Internal("FX returned an empty pro-rata snapshot") + } - now := s.nowFunc() - currentYear := int32(now.Year()) - currentMonth := int32(now.Month()) - - created, err := s.expenseClient.CreateExpense(ctx, CreateExpenseInput{ - UserID: userID, - Name: req.Name, - Amount: installments[0], - TransactionCurrency: resolvedCurrency, - ExpenseType: req.ExpenseType, - TagID: req.TagID, - ExpenseDate: req.ExpenseDate, - PeriodYear: currentYear, - PeriodMonth: currentMonth, - IsProRata: true, - ProRataGroup: proRataGroup, - ProRataIndex: 1, - ProRataTotal: req.Months, + created, err := s.expenseClient.CreateProRataInstallment(ctx, CreateProRataInstallmentInput{ + UserID: userID, + PeriodContext: TrustedPeriodContext{ + PeriodID: period.ID, + UserID: period.UserID, + Year: period.Year, + Month: period.Month, + ReportingCurrency: reportingCurrency, + Source: "finance_service", + }, + Name: req.Name, + Amount: installments[0], + Currency: transactionCurrency, + ExpenseType: req.ExpenseType, + TagID: req.TagID, + ExpenseDate: req.ExpenseDate, + ProRataGroup: proRataGroup, + ProRataIndex: 1, + ProRataTotal: req.Months, + CapturedRateSnapshot: snapshot, }) if err != nil { return nil, fmt.Errorf("creating first installment via expense service: %w", err) } schedules := make([]*model.ProRataSchedule, 0, req.Months-1) - targetYear, targetMonth := currentYear, currentMonth + targetYear, targetMonth := req.PeriodYear, req.PeriodMonth for i := int32(2); i <= req.Months; i++ { targetYear, targetMonth = AdvanceMonth(targetYear, targetMonth) schedule, err := s.repo.CreateProRataSchedule(ctx, &model.ProRataSchedule{ - UserID: userID, - ProRataGroup: proRataGroup, - Name: req.Name, - Amount: installments[i-1], - Currency: resolvedCurrency, - ExpenseType: req.ExpenseType, - TagID: req.TagID, - TargetYear: targetYear, - TargetMonth: targetMonth, - InstallmentIndex: i, - InstallmentTotal: req.Months, + UserID: userID, + ProRataGroup: proRataGroup, + Name: req.Name, + Amount: installments[i-1], + Currency: transactionCurrency, + ExpenseType: req.ExpenseType, + TagID: req.TagID, + TargetYear: targetYear, + TargetMonth: targetMonth, + InstallmentIndex: i, + InstallmentTotal: req.Months, + TransactionAmount: installments[i-1], + TransactionCurrency: transactionCurrency, + CreationReportingCurrency: reportingCurrency, + CapturedRateSnapshot: snapshot, }) if err != nil { // Log the inconsistency and return an error (the first installment is already written). @@ -146,29 +183,54 @@ func (s *FinanceService) CreateProRataExpense(ctx context.Context, userID string slog.String("pro_rata_group", proRataGroup), slog.Int("months", int(req.Months)), slog.Int64("total_amount", req.TotalAmount), + slog.String("snapshot_rate_timestamp", snapshot.RateTimestamp), ) return &model.ProRataResponse{ Expense: &model.CreatedExpense{ - ID: created.ID, - Name: req.Name, - Amount: installments[0], - Currency: resolvedCurrency, - ExpenseType: req.ExpenseType, - TagID: req.TagID, - ExpenseDate: req.ExpenseDate, - PeriodYear: currentYear, - PeriodMonth: currentMonth, - IsProRata: true, - ProRataGroup: proRataGroup, - ProRataIndex: 1, - ProRataTotal: req.Months, - CreatedAt: created.CreatedAt, + ID: created.ID, + Name: req.Name, + Amount: installments[0], + TransactionCurrency: transactionCurrency, + Currency: transactionCurrency, + ExpenseType: req.ExpenseType, + TagID: req.TagID, + ExpenseDate: req.ExpenseDate, + PeriodYear: req.PeriodYear, + PeriodMonth: req.PeriodMonth, + IsProRata: true, + ProRataGroup: proRataGroup, + ProRataIndex: 1, + ProRataTotal: req.Months, + CreatedAt: created.CreatedAt, }, Schedules: schedules, }, nil } +// resolveProRataTransactionCurrency resolves the transaction currency from the +// request. When absent, it defaults to the creation period reporting currency. +func (s *FinanceService) resolveProRataTransactionCurrency(period *model.BudgetPeriod, req *model.CreateProRataRequest) (string, error) { + transactionCurrency := normalizeCurrencyCode(req.TransactionCurrency) + if transactionCurrency != "" { + return s.validateProRataTransactionCurrency(transactionCurrency) + } + + defaultCurrency := normalizeCurrencyCode(period.ReportingCurrency) + s.logger.Info("transaction currency defaulted", + slog.String("event", "transaction_currency_defaulted"), + slog.String("reporting_currency", defaultCurrency), + ) + return s.validateProRataTransactionCurrency(defaultCurrency) +} + +func (s *FinanceService) validateProRataTransactionCurrency(currencyCode string) (string, error) { + if verr := validateSupportedCurrency("transactionCurrency", currencyCode); verr != nil { + return "", verr + } + return currencyCode, nil +} + // GetUpcomingProRata returns all pending pro-rata schedules for the user. func (s *FinanceService) GetUpcomingProRata(ctx context.Context, userID string) ([]*model.ProRataSchedule, error) { schedules, err := s.repo.GetUpcomingProRata(ctx, userID) diff --git a/services/finance/internal/service/prorata_test.go b/services/finance/internal/service/prorata_test.go index 97e0dbd0..ed3d1c66 100644 --- a/services/finance/internal/service/prorata_test.go +++ b/services/finance/internal/service/prorata_test.go @@ -3,7 +3,10 @@ package service import ( "context" "fmt" + "io" + "log/slog" "testing" + "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" @@ -16,6 +19,18 @@ import ( // int64Ptr returns a pointer to v for building *int64 request fields. func int64Ptr(v int64) *int64 { return &v } +// mockFxClient implements FxClient for service tests. +type mockFxClient struct { + mock.Mock +} + +func (m *mockFxClient) CaptureRateSnapshot(ctx context.Context, req FxCaptureRequest) (*model.CapturedRateSnapshot, error) { + args := m.Called(ctx, req) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*model.CapturedRateSnapshot), args.Error(1) +} // --- CalculateInstallments Tests --- func TestCalculateInstallments_EvenSplit(t *testing.T) { @@ -135,43 +150,101 @@ func TestComputeMissedMonths_SameMonth(t *testing.T) { // --- CreateProRataExpense Tests --- +func snapshotFixture() *model.CapturedRateSnapshot { + return &model.CapturedRateSnapshot{ + SnapshotVersion: 1, + Source: "open_exchange_rates", + BaseCurrency: "USD", + RateTimestamp: "2026-05-15T10:00:00Z", + CapturedAt: "2026-05-15T12:00:00Z", + ExpiresAt: "2026-05-15T13:00:00Z", + RatesByCurrency: map[string]string{ + "USD": "1", + "EUR": "0.92", + "GBP": "0.79", + "JPY": "150.00", + }, + } +} + +func newProRataTestService(repo *mockRepo, txBeg *mockTxBeg, expClient *mockExpClient, fxClient *mockFxClient, nowFunc func() time.Time) *FinanceService { + logger := slog.New(slog.NewJSONHandler(io.Discard, nil)) + var ec ExpenseClient + if expClient != nil { + ec = expClient + } + var fc FxClient + if fxClient != nil { + fc = fxClient + } + return NewFinanceServiceWithFx(repo, txBeg, ec, fc, nowFunc, logger) +} + +// stubProRataRepo wires GetCurrentPeriod for the creation period so tests that +// reach the period check do not need to repeat the mock setup. +func stubCreationPeriod(repo *mockRepo, year, month int32) { + repo.On("GetCurrentPeriod", mock.Anything, "user-1", year, month). + Return(makePeriod("period-"+fmt.Sprintf("%d-%02d", year, month), year, month), nil) +} + func TestCreateProRataExpense_Success(t *testing.T) { repo := new(mockRepo) txBeg := new(mockTxBeg) expClient := new(mockExpClient) - svc := newTagTestServiceNow(repo, txBeg, expClient, fixedNow(2026, 5, 15)) + fxClient := new(mockFxClient) + svc := newProRataTestService(repo, txBeg, expClient, fxClient, fixedNow(2026, 5, 15)) - expClient.On("CreateExpense", mock.Anything, mock.MatchedBy(func(req CreateExpenseInput) bool { + stubCreationPeriod(repo, 2026, 5) + snapshot := snapshotFixture() + fxClient.On("CaptureRateSnapshot", mock.Anything, mock.MatchedBy(func(req FxCaptureRequest) bool { + return len(req.RequiredCurrencies) == 2 && req.RequiredCurrencies[0] == "USD" && req.RequiredCurrencies[1] == "USD" + })).Return(snapshot, nil) + + expClient.On("CreateProRataInstallment", mock.Anything, mock.MatchedBy(func(req CreateProRataInstallmentInput) bool { return req.Name == "Annual subscription" && - req.Amount == int64(3334) && // 10000/3 = 3333, first gets +1 - req.IsProRata && + req.Amount == int64(3334) && + req.Currency == "USD" && req.ProRataIndex == int32(1) && req.ProRataTotal == int32(3) && - req.PeriodYear == int32(2026) && - req.PeriodMonth == int32(5) + req.PeriodContext.PeriodID == "period-2026-05" && + req.PeriodContext.UserID == "user-1" && + req.PeriodContext.Year == 2026 && + req.PeriodContext.Month == 5 && + req.PeriodContext.ReportingCurrency == "USD" && + req.PeriodContext.Source == "finance_service" && + req.CapturedRateSnapshot == snapshot })).Return(&CreatedExpenseData{ID: "exp-1", CreatedAt: "2026-05-15T12:00:00Z"}, nil) - // Schedules for months 2 and 3 repo.On("CreateProRataSchedule", mock.Anything, mock.MatchedBy(func(s *model.ProRataSchedule) bool { - return s.InstallmentIndex == 2 && s.TargetYear == 2026 && s.TargetMonth == 6 + return s.InstallmentIndex == 2 && s.TargetYear == 2026 && s.TargetMonth == 6 && + s.TransactionAmount == 3333 && s.TransactionCurrency == "USD" && + s.CreationReportingCurrency == "USD" && + s.CapturedRateSnapshot.RateTimestamp == snapshot.RateTimestamp && + s.CapturedRateSnapshot.Source == snapshot.Source })).Return(&model.ProRataSchedule{ ID: "sched-1", InstallmentIndex: 2, TargetYear: 2026, TargetMonth: 6, Amount: 3333, Status: "pending", }, nil) repo.On("CreateProRataSchedule", mock.Anything, mock.MatchedBy(func(s *model.ProRataSchedule) bool { - return s.InstallmentIndex == 3 && s.TargetYear == 2026 && s.TargetMonth == 7 + return s.InstallmentIndex == 3 && s.TargetYear == 2026 && s.TargetMonth == 7 && + s.TransactionAmount == 3333 && s.TransactionCurrency == "USD" && + s.CreationReportingCurrency == "USD" && + s.CapturedRateSnapshot.RateTimestamp == snapshot.RateTimestamp && + s.CapturedRateSnapshot.Source == snapshot.Source })).Return(&model.ProRataSchedule{ ID: "sched-2", InstallmentIndex: 3, TargetYear: 2026, TargetMonth: 7, Amount: 3333, Status: "pending", }, nil) result, err := svc.CreateProRataExpense(context.Background(), "user-1", &model.CreateProRataRequest{ - Name: "Annual subscription", - TotalAmount: 10000, + Name: "Annual subscription", + TotalAmount: 10000, TransactionCurrency: "USD", - ExpenseType: "essentials", - TagID: "tag-1", - ExpenseDate: "2026-05-15", - Months: 3, + ExpenseType: "essentials", + TagID: "tag-1", + ExpenseDate: "2026-05-15", + Months: 3, + PeriodYear: 2026, + PeriodMonth: 5, }) require.NoError(t, err) @@ -181,15 +254,21 @@ func TestCreateProRataExpense_Success(t *testing.T) { assert.Len(t, result.Schedules, 2) assert.Equal(t, int32(6), result.Schedules[0].TargetMonth) assert.Equal(t, int32(7), result.Schedules[1].TargetMonth) + fxClient.AssertExpectations(t) + expClient.AssertExpectations(t) + repo.AssertExpectations(t) } func TestCreateProRataExpense_YearRollover(t *testing.T) { repo := new(mockRepo) txBeg := new(mockTxBeg) expClient := new(mockExpClient) - svc := newTagTestServiceNow(repo, txBeg, expClient, fixedNow(2026, 11, 1)) + fxClient := new(mockFxClient) + svc := newProRataTestService(repo, txBeg, expClient, fxClient, fixedNow(2026, 11, 1)) - expClient.On("CreateExpense", mock.Anything, mock.Anything). + stubCreationPeriod(repo, 2026, 11) + fxClient.On("CaptureRateSnapshot", mock.Anything, mock.Anything).Return(snapshotFixture(), nil) + expClient.On("CreateProRataInstallment", mock.Anything, mock.Anything). Return(&CreatedExpenseData{ID: "exp-1", CreatedAt: "2026-11-01T00:00:00Z"}, nil) repo.On("CreateProRataSchedule", mock.Anything, mock.MatchedBy(func(s *model.ProRataSchedule) bool { @@ -202,7 +281,7 @@ func TestCreateProRataExpense_YearRollover(t *testing.T) { result, err := svc.CreateProRataExpense(context.Background(), "user-1", &model.CreateProRataRequest{ Name: "Insurance", TotalAmount: 6000, TransactionCurrency: "USD", ExpenseType: "essentials", - TagID: "tag-1", ExpenseDate: "2026-11-01", Months: 3, + TagID: "tag-1", ExpenseDate: "2026-11-01", Months: 3, PeriodYear: 2026, PeriodMonth: 11, }) require.NoError(t, err) @@ -217,14 +296,17 @@ func TestCreateProRataExpense_TransactionCurrencyOnly(t *testing.T) { repo := new(mockRepo) txBeg := new(mockTxBeg) expClient := new(mockExpClient) - svc := newTagTestServiceNow(repo, txBeg, expClient, fixedNow(2026, 5, 15)) + fxClient := new(mockFxClient) + svc := newProRataTestService(repo, txBeg, expClient, fxClient, fixedNow(2026, 5, 15)) - expClient.On("CreateExpense", mock.Anything, mock.MatchedBy(func(req CreateExpenseInput) bool { - return req.TransactionCurrency == "EUR" + stubCreationPeriod(repo, 2026, 5) + fxClient.On("CaptureRateSnapshot", mock.Anything, mock.Anything).Return(snapshotFixture(), nil) + expClient.On("CreateProRataInstallment", mock.Anything, mock.MatchedBy(func(req CreateProRataInstallmentInput) bool { + return req.Currency == "EUR" })).Return(&CreatedExpenseData{ID: "exp-1", CreatedAt: "2026-05-15T12:00:00Z"}, nil) repo.On("CreateProRataSchedule", mock.Anything, mock.MatchedBy(func(s *model.ProRataSchedule) bool { - return s.Currency == "EUR" + return s.TransactionCurrency == "EUR" })).Return(&model.ProRataSchedule{ ID: "sched-1", Status: "pending", }, nil) @@ -237,47 +319,127 @@ func TestCreateProRataExpense_TransactionCurrencyOnly(t *testing.T) { TagID: "tag-1", ExpenseDate: "2026-05-15", Months: 2, + PeriodYear: 2026, + PeriodMonth: 5, }) require.NoError(t, err) - assert.Equal(t, "EUR", result.Expense.Currency) + assert.Equal(t, "EUR", result.Expense.TransactionCurrency) expClient.AssertExpectations(t) repo.AssertExpectations(t) } -func TestCreateProRataExpense_MissingCurrency(t *testing.T) { +func TestCreateProRataExpense_MissingCurrencyDefaultsToPeriodReportingCurrency(t *testing.T) { repo := new(mockRepo) txBeg := new(mockTxBeg) - svc := newTagTestService(repo, txBeg, nil) + expClient := new(mockExpClient) + fxClient := new(mockFxClient) + svc := newProRataTestService(repo, txBeg, expClient, fxClient, fixedNow(2026, 5, 15)) - _, err := svc.CreateProRataExpense(context.Background(), "user-1", &model.CreateProRataRequest{ - Name: "Test", + stubCreationPeriod(repo, 2026, 5) + fxClient.On("CaptureRateSnapshot", mock.Anything, mock.Anything).Return(snapshotFixture(), nil) + expClient.On("CreateProRataInstallment", mock.Anything, mock.MatchedBy(func(req CreateProRataInstallmentInput) bool { + return req.Currency == "USD" + })).Return(&CreatedExpenseData{ID: "exp-1", CreatedAt: "2026-05-15T12:00:00Z"}, nil) + + repo.On("CreateProRataSchedule", mock.Anything, mock.Anything). + Return(&model.ProRataSchedule{ID: "sched-1", Status: "pending"}, nil) + + result, err := svc.CreateProRataExpense(context.Background(), "user-1", &model.CreateProRataRequest{ + Name: "Insurance", TotalAmount: 6000, ExpenseType: "essentials", TagID: "tag-1", ExpenseDate: "2026-05-15", Months: 2, + PeriodYear: 2026, + PeriodMonth: 5, + }) + + require.NoError(t, err) + assert.Equal(t, "USD", result.Expense.TransactionCurrency) +} + +func TestCreateProRataExpense_MissingPeriodFields(t *testing.T) { + repo := new(mockRepo) + txBeg := new(mockTxBeg) + expClient := new(mockExpClient) + fxClient := new(mockFxClient) + svc := newProRataTestService(repo, txBeg, expClient, fxClient, fixedNow(2026, 5, 15)) + + _, err := svc.CreateProRataExpense(context.Background(), "user-1", &model.CreateProRataRequest{ + Name: "Insurance", TotalAmount: 6000, TransactionCurrency: "USD", ExpenseType: "essentials", + TagID: "tag-1", ExpenseDate: "2026-05-15", Months: 2, }) svcErr := requireAPIError(t, err) assert.Equal(t, apierr.CodeValidation, svcErr.Code) - assert.Contains(t, svcErr.Fields, "transactionCurrency") + assert.Contains(t, svcErr.Fields, "periodYear") + repo.AssertNotCalled(t, "GetCurrentPeriod", mock.Anything, mock.Anything, mock.Anything, mock.Anything) + fxClient.AssertNotCalled(t, "CaptureRateSnapshot", mock.Anything, mock.Anything) + expClient.AssertNotCalled(t, "CreateProRataInstallment", mock.Anything, mock.Anything) + repo.AssertNotCalled(t, "CreateProRataSchedule", mock.Anything, mock.Anything) +} + +func TestCreateProRataExpense_MissingCreationPeriod(t *testing.T) { + repo := new(mockRepo) + txBeg := new(mockTxBeg) + expClient := new(mockExpClient) + fxClient := new(mockFxClient) + svc := newProRataTestService(repo, txBeg, expClient, fxClient, fixedNow(2026, 5, 15)) + + repo.On("GetCurrentPeriod", mock.Anything, "user-1", int32(2026), int32(6)).Return(nil, nil) + + _, err := svc.CreateProRataExpense(context.Background(), "user-1", &model.CreateProRataRequest{ + Name: "Insurance", TotalAmount: 6000, TransactionCurrency: "USD", ExpenseType: "essentials", + TagID: "tag-1", ExpenseDate: "2026-05-15", Months: 2, PeriodYear: 2026, PeriodMonth: 6, + }) + + svcErr := requireAPIError(t, err) + assert.Equal(t, model.ErrPeriodNotFound, svcErr.Code) + fxClient.AssertNotCalled(t, "CaptureRateSnapshot", mock.Anything, mock.Anything) + expClient.AssertNotCalled(t, "CreateProRataInstallment", mock.Anything, mock.Anything) + repo.AssertNotCalled(t, "CreateProRataSchedule", mock.Anything, mock.Anything) +} + +func TestCreateProRataExpense_FxCaptureFailure(t *testing.T) { + repo := new(mockRepo) + txBeg := new(mockTxBeg) + expClient := new(mockExpClient) + fxClient := new(mockFxClient) + svc := newProRataTestService(repo, txBeg, expClient, fxClient, fixedNow(2026, 5, 15)) + + stubCreationPeriod(repo, 2026, 5) + fxClient.On("CaptureRateSnapshot", mock.Anything, mock.Anything). + Return(nil, &apierr.Error{Code: model.ErrConversionUnavailable, Message: "conversion unavailable", Status: 503}) + + _, err := svc.CreateProRataExpense(context.Background(), "user-1", &model.CreateProRataRequest{ + Name: "Insurance", TotalAmount: 6000, TransactionCurrency: "USD", ExpenseType: "essentials", + TagID: "tag-1", ExpenseDate: "2026-05-15", Months: 2, PeriodYear: 2026, PeriodMonth: 5, + }) + + svcErr := requireAPIError(t, err) + assert.Equal(t, model.ErrConversionUnavailable, svcErr.Code) + expClient.AssertNotCalled(t, "CreateProRataInstallment", mock.Anything, mock.Anything) + repo.AssertNotCalled(t, "CreateProRataSchedule", mock.Anything, mock.Anything) } func TestCreateProRataExpense_Validation(t *testing.T) { repo := new(mockRepo) txBeg := new(mockTxBeg) - svc := newTagTestService(repo, txBeg, nil) + fxClient := new(mockFxClient) + svc := newProRataTestService(repo, txBeg, nil, fxClient, fixedNow(2026, 5, 15)) tests := []struct { - name string - req *model.CreateProRataRequest - msg string + name string + req *model.CreateProRataRequest + field string + msg string }{ - {"empty name", &model.CreateProRataRequest{TotalAmount: 100, Months: 2, TransactionCurrency: "USD", ExpenseType: "essentials", TagID: "t", ExpenseDate: "2026-05-01"}, "Name is required"}, - {"zero amount", &model.CreateProRataRequest{Name: "X", TotalAmount: 0, Months: 2, TransactionCurrency: "USD", ExpenseType: "essentials", TagID: "t", ExpenseDate: "2026-05-01"}, "positive"}, - {"one month", &model.CreateProRataRequest{Name: "X", TotalAmount: 100, Months: 1, TransactionCurrency: "USD", ExpenseType: "essentials", TagID: "t", ExpenseDate: "2026-05-01"}, "at least 2"}, - {"bad type", &model.CreateProRataRequest{Name: "X", TotalAmount: 100, Months: 2, TransactionCurrency: "USD", ExpenseType: "invalid", TagID: "t", ExpenseDate: "2026-05-01"}, "essentials, desires, or savings"}, + {"empty name", &model.CreateProRataRequest{TotalAmount: 100, Months: 2, TransactionCurrency: "USD", ExpenseType: "essentials", TagID: "t", ExpenseDate: "2026-05-01", PeriodYear: 2026, PeriodMonth: 5}, "name", "required"}, + {"zero amount", &model.CreateProRataRequest{Name: "X", TotalAmount: 0, Months: 2, TransactionCurrency: "USD", ExpenseType: "essentials", TagID: "t", ExpenseDate: "2026-05-01", PeriodYear: 2026, PeriodMonth: 5}, "totalAmount", "must be positive"}, + {"one month", &model.CreateProRataRequest{Name: "X", TotalAmount: 100, Months: 1, TransactionCurrency: "USD", ExpenseType: "essentials", TagID: "t", ExpenseDate: "2026-05-01", PeriodYear: 2026, PeriodMonth: 5}, "months", "must be at least 2"}, + {"bad type", &model.CreateProRataRequest{Name: "X", TotalAmount: 100, Months: 2, TransactionCurrency: "USD", ExpenseType: "invalid", TagID: "t", ExpenseDate: "2026-05-01", PeriodYear: 2026, PeriodMonth: 5}, "expenseType", "must be essentials, desires, or savings"}, } for _, tt := range tests { @@ -286,18 +448,49 @@ func TestCreateProRataExpense_Validation(t *testing.T) { require.Error(t, err) svcErr := requireAPIError(t, err) assert.Equal(t, apierr.CodeValidation, svcErr.Code) - assert.Contains(t, svcErr.Message, tt.msg) + assert.Equal(t, "validation failed", svcErr.Message) + assert.Equal(t, tt.msg, svcErr.Fields[tt.field]) }) } } +func TestCreateProRataExpense_ValidationAggregatesAllErrors(t *testing.T) { + repo := new(mockRepo) + txBeg := new(mockTxBeg) + fxClient := new(mockFxClient) + svc := newProRataTestService(repo, txBeg, nil, fxClient, fixedNow(2026, 5, 15)) + + _, err := svc.CreateProRataExpense(context.Background(), "user-1", &model.CreateProRataRequest{ + Name: " ", TotalAmount: 0, Months: 1, TransactionCurrency: "USD", + ExpenseType: "invalid", TagID: " ", ExpenseDate: " ", PeriodYear: 0, PeriodMonth: 0, + }) + + require.Error(t, err) + svcErr := requireAPIError(t, err) + assert.Equal(t, apierr.CodeValidation, svcErr.Code) + assert.Equal(t, "validation failed", svcErr.Message) + assert.Equal(t, map[string]string{ + "name": "required", + "totalAmount": "must be positive", + "months": "must be at least 2", + "expenseType": "must be essentials, desires, or savings", + "tagId": "required", + "expenseDate": "required", + "periodYear": "required", + "periodMonth": "must be between 1 and 12", + }, svcErr.Fields) +} + func TestCreateProRataExpense_ScheduleFailure(t *testing.T) { repo := new(mockRepo) txBeg := new(mockTxBeg) expClient := new(mockExpClient) - svc := newTagTestServiceNow(repo, txBeg, expClient, fixedNow(2026, 5, 15)) + fxClient := new(mockFxClient) + svc := newProRataTestService(repo, txBeg, expClient, fxClient, fixedNow(2026, 5, 15)) - expClient.On("CreateExpense", mock.Anything, mock.Anything). + stubCreationPeriod(repo, 2026, 5) + fxClient.On("CaptureRateSnapshot", mock.Anything, mock.Anything).Return(snapshotFixture(), nil) + expClient.On("CreateProRataInstallment", mock.Anything, mock.Anything). Return(&CreatedExpenseData{ID: "exp-1", CreatedAt: "2026-05-15T12:00:00Z"}, nil) repo.On("CreateProRataSchedule", mock.Anything, mock.Anything). @@ -305,7 +498,7 @@ func TestCreateProRataExpense_ScheduleFailure(t *testing.T) { _, err := svc.CreateProRataExpense(context.Background(), "user-1", &model.CreateProRataRequest{ Name: "Test", TotalAmount: 6000, TransactionCurrency: "USD", ExpenseType: "essentials", - TagID: "tag-1", ExpenseDate: "2026-05-15", Months: 2, + TagID: "tag-1", ExpenseDate: "2026-05-15", Months: 2, PeriodYear: 2026, PeriodMonth: 5, }) require.Error(t, err) diff --git a/services/finance/internal/service/tag_test.go b/services/finance/internal/service/tag_test.go index a7926a20..f5e07973 100644 --- a/services/finance/internal/service/tag_test.go +++ b/services/finance/internal/service/tag_test.go @@ -211,8 +211,8 @@ type mockTxn struct { } func (m *mockTxn) Commit(ctx context.Context) error { return m.Called(ctx).Error(0) } -func (m *mockTxn) Rollback(ctx context.Context) error { return m.Called(ctx).Error(0) } -func (m *mockTxn) Repo() repository.FinanceRepository { return m.repo } +func (m *mockTxn) Rollback(ctx context.Context) error { return m.Called(ctx).Error(0) } +func (m *mockTxn) Repo() repository.FinanceRepository { return m.repo } // mockExpClient implements ExpenseClient for service tests. type mockExpClient struct { @@ -240,6 +240,14 @@ func (m *mockExpClient) CreateExpense(ctx context.Context, req CreateExpenseInpu return args.Get(0).(*CreatedExpenseData), args.Error(1) } +func (m *mockExpClient) CreateProRataInstallment(ctx context.Context, req CreateProRataInstallmentInput) (*CreatedExpenseData, error) { + args := m.Called(ctx, req) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*CreatedExpenseData), args.Error(1) +} + func newTagTestService(repo *mockRepo, txBeg *mockTxBeg, expClient *mockExpClient) *FinanceService { return newTagTestServiceNow(repo, txBeg, expClient, time.Now) } diff --git a/services/finance/proto/finance.proto b/services/finance/proto/finance.proto index 571f9ee1..967d3923 100644 --- a/services/finance/proto/finance.proto +++ b/services/finance/proto/finance.proto @@ -261,6 +261,8 @@ message CreateProRataExpenseRequest { string expense_date = 7; int32 months = 8; string transaction_currency = 9; + int32 period_year = 10; + int32 period_month = 11; } message ProRataResponse { diff --git a/services/finance/proto/financepb/finance.pb.go b/services/finance/proto/financepb/finance.pb.go index b8d3429d..cc5f8480 100644 --- a/services/finance/proto/financepb/finance.pb.go +++ b/services/finance/proto/financepb/finance.pb.go @@ -1860,6 +1860,8 @@ type CreateProRataExpenseRequest struct { ExpenseDate string `protobuf:"bytes,7,opt,name=expense_date,json=expenseDate,proto3" json:"expense_date,omitempty"` Months int32 `protobuf:"varint,8,opt,name=months,proto3" json:"months,omitempty"` TransactionCurrency string `protobuf:"bytes,9,opt,name=transaction_currency,json=transactionCurrency,proto3" json:"transaction_currency,omitempty"` + PeriodYear int32 `protobuf:"varint,10,opt,name=period_year,json=periodYear,proto3" json:"period_year,omitempty"` + PeriodMonth int32 `protobuf:"varint,11,opt,name=period_month,json=periodMonth,proto3" json:"period_month,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -1957,6 +1959,20 @@ func (x *CreateProRataExpenseRequest) GetTransactionCurrency() string { return "" } +func (x *CreateProRataExpenseRequest) GetPeriodYear() int32 { + if x != nil { + return x.PeriodYear + } + return 0 +} + +func (x *CreateProRataExpenseRequest) GetPeriodMonth() int32 { + if x != nil { + return x.PeriodMonth + } + return 0 +} + type ProRataResponse struct { state protoimpl.MessageState `protogen:"open.v1"` Message string `protobuf:"bytes,1,opt,name=message,proto3" json:"message,omitempty"` @@ -2636,7 +2652,7 @@ const file_proto_finance_proto_rawDesc = "" + "\vTagResponse\x12\"\n" + "\x03tag\x18\x01 \x01(\v2\x10.finance.TagDataR\x03tag\"7\n" + "\x0fTagListResponse\x12$\n" + - "\x04tags\x18\x01 \x03(\v2\x10.finance.TagDataR\x04tags\"\xb1\x02\n" + + "\x04tags\x18\x01 \x03(\v2\x10.finance.TagDataR\x04tags\"\xf5\x02\n" + "\x1bCreateProRataExpenseRequest\x12\x17\n" + "\auser_id\x18\x01 \x01(\tR\x06userId\x12\x12\n" + "\x04name\x18\x02 \x01(\tR\x04name\x12!\n" + @@ -2646,7 +2662,11 @@ const file_proto_finance_proto_rawDesc = "" + "\x06tag_id\x18\x06 \x01(\tR\x05tagId\x12!\n" + "\fexpense_date\x18\a \x01(\tR\vexpenseDate\x12\x16\n" + "\x06months\x18\b \x01(\x05R\x06months\x121\n" + - "\x14transaction_currency\x18\t \x01(\tR\x13transactionCurrency\"+\n" + + "\x14transaction_currency\x18\t \x01(\tR\x13transactionCurrency\x12\x1f\n" + + "\vperiod_year\x18\n" + + " \x01(\x05R\n" + + "periodYear\x12!\n" + + "\fperiod_month\x18\v \x01(\x05R\vperiodMonth\"+\n" + "\x0fProRataResponse\x12\x18\n" + "\amessage\x18\x01 \x01(\tR\amessage\"4\n" + "\x19GetUpcomingProRataRequest\x12\x17\n" + diff --git a/services/go.work b/services/go.work index 07377056..3ae64efe 100644 --- a/services/go.work +++ b/services/go.work @@ -18,4 +18,5 @@ use ( ./pgutil ./serverkit ./shared/currency + ./shared/validator ) diff --git a/services/shared/validator/go.mod b/services/shared/validator/go.mod new file mode 100644 index 00000000..ec5a105d --- /dev/null +++ b/services/shared/validator/go.mod @@ -0,0 +1,3 @@ +module github.com/ItsThompson/gofin/services/shared/validator + +go 1.26 diff --git a/services/shared/validator/validator.go b/services/shared/validator/validator.go new file mode 100644 index 00000000..fbfe1284 --- /dev/null +++ b/services/shared/validator/validator.go @@ -0,0 +1,36 @@ +// Package validator accumulates field-level request validation errors. It +// records the first error per field so a caller can collect every violation +// in one pass and render them as the fields detail of a validation error. +package validator + +// RequestValidator accumulates field-level validation errors, keeping the +// first error recorded per field. +type RequestValidator struct { + fields map[string]string +} + +// New returns an empty RequestValidator. +func New() *RequestValidator { + return &RequestValidator{fields: make(map[string]string)} +} + +// Check records msg for field when cond is false. The first error per field +// wins: later checks for the same field are ignored. +func (v *RequestValidator) Check(cond bool, field, msg string) { + if cond { + return + } + if _, ok := v.fields[field]; !ok { + v.fields[field] = msg + } +} + +// HasErrors reports whether any field failed a check. +func (v *RequestValidator) HasErrors() bool { + return len(v.fields) > 0 +} + +// Errors returns the accumulated field errors. +func (v *RequestValidator) Errors() map[string]string { + return v.fields +} diff --git a/services/shared/validator/validator_test.go b/services/shared/validator/validator_test.go new file mode 100644 index 00000000..dae99384 --- /dev/null +++ b/services/shared/validator/validator_test.go @@ -0,0 +1,74 @@ +package validator + +import "testing" + +func TestNew_HasNoErrors(t *testing.T) { + v := New() + + if v.HasErrors() { + t.Fatal("New validator reports errors before any check") + } + if len(v.Errors()) != 0 { + t.Fatalf("New validator errors = %v, want empty", v.Errors()) + } +} + +func TestCheck_RecordsErrorForFailedCondition(t *testing.T) { + v := New() + + v.Check(false, "amount", "amount is required") + + if !v.HasErrors() { + t.Fatal("HasErrors = false after a failed check, want true") + } + want := map[string]string{"amount": "amount is required"} + if got := v.Errors(); got["amount"] != want["amount"] || len(got) != 1 { + t.Fatalf("Errors = %v, want %v", got, want) + } +} + +func TestCheck_IgnoresPassingCondition(t *testing.T) { + v := New() + + v.Check(true, "amount", "amount is required") + + if v.HasErrors() { + t.Fatal("HasErrors = true after a passing check, want false") + } + if len(v.Errors()) != 0 { + t.Fatalf("Errors = %v, want empty", v.Errors()) + } +} + +func TestCheck_FirstErrorPerFieldWins(t *testing.T) { + v := New() + + v.Check(false, "expenseDate", "expense_date is required") + v.Check(false, "expenseDate", "expense_date must be in ISO format (YYYY-MM-DD)") + + errors := v.Errors() + if len(errors) != 1 { + t.Fatalf("Errors = %v, want exactly one entry", errors) + } + if got := errors["expenseDate"]; got != "expense_date is required" { + t.Fatalf("expenseDate error = %q, want the first recorded message", got) + } +} + +func TestCheck_AccumulatesDistinctFields(t *testing.T) { + v := New() + + v.Check(false, "name", "name is required") + v.Check(false, "amount", "amount must be positive") + + errors := v.Errors() + if len(errors) != 2 { + t.Fatalf("Errors = %v, want two entries", errors) + } + if errors["name"] != "name is required" { + t.Fatalf("name error = %q, want %q", errors["name"], "name is required") + } + if errors["amount"] != "amount must be positive" { + t.Fatalf("amount error = %q, want %q", errors["amount"], "amount must be positive") + } +}