From e1ac6d9fac57b76800eb72fad7d908fa6bb431b4 Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Tue, 1 Sep 2026 16:22:40 +0200 Subject: [PATCH] feat(domain): add domain types --- src/domain/types.test.ts | 12 +++++ src/domain/types.ts | 94 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 106 insertions(+) create mode 100644 src/domain/types.test.ts create mode 100644 src/domain/types.ts diff --git a/src/domain/types.test.ts b/src/domain/types.test.ts new file mode 100644 index 0000000..e5f3641 --- /dev/null +++ b/src/domain/types.test.ts @@ -0,0 +1,12 @@ +import { test, expect } from "bun:test"; +import { CURRENCIES, CURRENCY_DIGITS } from "./types.ts"; + +test("supported currencies are exactly DKK, USD, EUR", () => { + expect(CURRENCIES).toEqual(["DKK", "USD", "EUR"]); +}); + +test("every supported currency has 2 decimal digits", () => { + for (const c of CURRENCIES) { + expect(CURRENCY_DIGITS[c]).toBe(2); + } +}); diff --git a/src/domain/types.ts b/src/domain/types.ts new file mode 100644 index 0000000..6a8e247 --- /dev/null +++ b/src/domain/types.ts @@ -0,0 +1,94 @@ +export const CURRENCIES = ["DKK", "USD", "EUR"] as const; +export type Currency = (typeof CURRENCIES)[number]; + +export const CURRENCY_DIGITS: Record = { + DKK: 2, + USD: 2, + EUR: 2, +}; + +/** A float amount paired with its currency. See CLAUDE.md for rounding rules. */ +export interface Money { + amount: number; + currency: Currency; +} + +/** "YYYY-MM". Never a Date. */ +export type MonthId = string; +/** "YYYY-MM-DD". Never a Date. */ +export type IsoDate = string; +export type PostId = string; +export type PurchaseId = string; + +export type Rule = + | { kind: "fixed"; amount: Money } + | { kind: "percentOfIncome"; percent: number }; + +export interface Post { + id: PostId; + name: string; + order: number; + archived: boolean; + /** Display currency for this post's own views only. All totals use base. */ + currency: Currency; + standingRule: Rule; +} + +export interface Month { + id: MonthId; + /** Always in Settings.baseCurrency. */ + income: Money; + ruleOverrides: Record; +} + +export interface Split { + postId: PostId; + /** A percentage, or an amount in the purchase's currency, per Purchase.splitMode. */ + value: number; + absorbsRemainder: boolean; +} + +export interface ScheduleSlice { + month: MonthId; + amount: Money; +} + +export interface Schedule { + slices: ScheduleSlice[]; + /** Slices in this month AND every month after it are ignored by the fold. */ + cancelledFromMonth?: MonthId; +} + +export interface Purchase { + id: PurchaseId; + date: IsoDate; + description: string; + /** In the purchase's own currency. */ + total: Money; + splitMode: "percent" | "fixed"; + splits: Split[]; + /** null means the whole total is charged in the month of `date`. */ + schedule: Schedule | null; +} + +export interface FxRate { + currency: Currency; + /** How many base units one unit of `currency` buys. Base has no row. */ + baseUnitsPerOne: number; + updatedAt: string; + source: "manual" | "api"; +} + +export interface Settings { + baseCurrency: Currency; + foldStartMonth: MonthId; + schemaVersion: number; +} + +export interface Dataset { + settings: Settings; + fxRates: FxRate[]; + posts: Post[]; + months: Month[]; + purchases: Purchase[]; +}