feat(WHE-18): implement money, billing period, and typed domain ids

This commit is contained in:
2026-03-05 03:57:44 +04:00
parent ac1aa2765c
commit 1fda4bfc14
9 changed files with 632 additions and 1 deletions

View File

@@ -0,0 +1,39 @@
import { describe, expect, test } from 'bun:test'
import { DOMAIN_ERROR_CODE, DomainError } from './errors'
import { BillingPeriod } from './billing-period'
describe('BillingPeriod', () => {
test('parses canonical YYYY-MM format', () => {
const period = BillingPeriod.fromString('2026-03')
expect(period.year).toBe(2026)
expect(period.month).toBe(3)
expect(period.toString()).toBe('2026-03')
})
test('rejects malformed format', () => {
expect(() => BillingPeriod.fromString('2026/03')).toThrow(
new DomainError(
DOMAIN_ERROR_CODE.INVALID_BILLING_PERIOD,
'Billing period must match YYYY-MM: 2026/03'
)
)
})
test('navigates next and previous correctly', () => {
const december = BillingPeriod.from(2026, 12)
expect(december.next().toString()).toBe('2027-01')
expect(december.previous().toString()).toBe('2026-11')
})
test('compares periods', () => {
const left = BillingPeriod.from(2026, 3)
const right = BillingPeriod.from(2026, 4)
expect(left.compare(right)).toBe(-1)
expect(right.compare(left)).toBe(1)
expect(left.compare(BillingPeriod.from(2026, 3))).toBe(0)
})
})

View File

@@ -0,0 +1,98 @@
import { DOMAIN_ERROR_CODE, DomainError } from './errors'
const BILLING_PERIOD_PATTERN = /^(\d{4})-(\d{2})$/
function isIntegerInRange(value: number, min: number, max: number): boolean {
return Number.isInteger(value) && value >= min && value <= max
}
export class BillingPeriod {
readonly year: number
readonly month: number
private constructor(year: number, month: number) {
this.year = year
this.month = month
}
static from(year: number, month: number): BillingPeriod {
if (!isIntegerInRange(year, 1970, 9999)) {
throw new DomainError(
DOMAIN_ERROR_CODE.INVALID_BILLING_PERIOD,
`Invalid billing year: ${year}`
)
}
if (!isIntegerInRange(month, 1, 12)) {
throw new DomainError(
DOMAIN_ERROR_CODE.INVALID_BILLING_PERIOD,
`Invalid billing month: ${month}`
)
}
return new BillingPeriod(year, month)
}
static fromString(value: string): BillingPeriod {
const match = BILLING_PERIOD_PATTERN.exec(value)
if (!match) {
throw new DomainError(
DOMAIN_ERROR_CODE.INVALID_BILLING_PERIOD,
`Billing period must match YYYY-MM: ${value}`
)
}
const [, yearString, monthString] = match
return BillingPeriod.from(Number(yearString), Number(monthString))
}
static fromDate(date: Date): BillingPeriod {
return BillingPeriod.from(date.getUTCFullYear(), date.getUTCMonth() + 1)
}
next(): BillingPeriod {
if (this.month === 12) {
return BillingPeriod.from(this.year + 1, 1)
}
return BillingPeriod.from(this.year, this.month + 1)
}
previous(): BillingPeriod {
if (this.month === 1) {
return BillingPeriod.from(this.year - 1, 12)
}
return BillingPeriod.from(this.year, this.month - 1)
}
compare(other: BillingPeriod): -1 | 0 | 1 {
if (this.year < other.year) {
return -1
}
if (this.year > other.year) {
return 1
}
if (this.month < other.month) {
return -1
}
if (this.month > other.month) {
return 1
}
return 0
}
equals(other: BillingPeriod): boolean {
return this.year === other.year && this.month === other.month
}
toString(): string {
return `${this.year.toString().padStart(4, '0')}-${this.month.toString().padStart(2, '0')}`
}
}

View File

@@ -0,0 +1,19 @@
export class DomainError extends Error {
readonly code: string
constructor(code: string, message: string) {
super(message)
this.name = 'DomainError'
this.code = code
}
}
export const DOMAIN_ERROR_CODE = {
INVALID_MONEY_AMOUNT: 'INVALID_MONEY_AMOUNT',
INVALID_MONEY_MAJOR_FORMAT: 'INVALID_MONEY_MAJOR_FORMAT',
CURRENCY_MISMATCH: 'CURRENCY_MISMATCH',
INVALID_SPLIT_PARTS: 'INVALID_SPLIT_PARTS',
INVALID_SPLIT_WEIGHTS: 'INVALID_SPLIT_WEIGHTS',
INVALID_BILLING_PERIOD: 'INVALID_BILLING_PERIOD',
INVALID_ENTITY_ID: 'INVALID_ENTITY_ID'
} as const

View File

@@ -0,0 +1,34 @@
import { describe, expect, test } from 'bun:test'
import { DOMAIN_ERROR_CODE, DomainError } from './errors'
import { BillingCycleId, HouseholdId, MemberId, PurchaseEntryId } from './ids'
describe('IDs', () => {
test('creates and compares typed ids', () => {
const left = MemberId.from('member_1')
const right = MemberId.from('member_1')
expect(left.equals(right)).toBe(true)
expect(left.toString()).toBe('member_1')
})
test('typed ids with same value and different type are not equal', () => {
const member = MemberId.from('abc')
const household = HouseholdId.from('abc')
expect(member.equals(household)).toBe(false)
})
test('rejects invalid id values', () => {
expect(() => BillingCycleId.from('')).toThrow(
new DomainError(DOMAIN_ERROR_CODE.INVALID_ENTITY_ID, 'BillingCycleId cannot be empty')
)
expect(() => PurchaseEntryId.from('bad value with space')).toThrow(
new DomainError(
DOMAIN_ERROR_CODE.INVALID_ENTITY_ID,
'PurchaseEntryId contains invalid characters: bad value with space'
)
)
})
})

View File

@@ -0,0 +1,60 @@
import { DOMAIN_ERROR_CODE, DomainError } from './errors'
const ID_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9:_-]{0,127}$/
function normalizeId(raw: string, label: string): string {
const value = raw.trim()
if (!value) {
throw new DomainError(DOMAIN_ERROR_CODE.INVALID_ENTITY_ID, `${label} cannot be empty`)
}
if (!ID_PATTERN.test(value)) {
throw new DomainError(
DOMAIN_ERROR_CODE.INVALID_ENTITY_ID,
`${label} contains invalid characters: ${value}`
)
}
return value
}
abstract class BaseId {
readonly value: string
protected constructor(value: string) {
this.value = value
}
equals(other: BaseId): boolean {
return this.value === other.value && this.constructor === other.constructor
}
toString(): string {
return this.value
}
}
export class HouseholdId extends BaseId {
static from(value: string): HouseholdId {
return new HouseholdId(normalizeId(value, 'HouseholdId'))
}
}
export class MemberId extends BaseId {
static from(value: string): MemberId {
return new MemberId(normalizeId(value, 'MemberId'))
}
}
export class BillingCycleId extends BaseId {
static from(value: string): BillingCycleId {
return new BillingCycleId(normalizeId(value, 'BillingCycleId'))
}
}
export class PurchaseEntryId extends BaseId {
static from(value: string): PurchaseEntryId {
return new PurchaseEntryId(normalizeId(value, 'PurchaseEntryId'))
}
}

View File

@@ -1 +1,13 @@
export const domainReady = true
export { BillingPeriod } from './billing-period'
export { DOMAIN_ERROR_CODE, DomainError } from './errors'
export { BillingCycleId, HouseholdId, MemberId, PurchaseEntryId } from './ids'
export { CURRENCIES, Money } from './money'
export type { CurrencyCode } from './money'
export type {
SettlementInput,
SettlementMemberInput,
SettlementMemberLine,
SettlementPurchaseInput,
SettlementResult,
UtilitySplitMode
} from './settlement-primitives'

View File

@@ -0,0 +1,66 @@
import { describe, expect, test } from 'bun:test'
import { DOMAIN_ERROR_CODE, DomainError } from './errors'
import { Money } from './money'
describe('Money', () => {
test('parses major units and formats back deterministically', () => {
const money = Money.fromMajor('12.34', 'GEL')
expect(money.amountMinor).toBe(1234n)
expect(money.toMajorString()).toBe('12.34')
})
test('rejects non-integer minor units', () => {
expect(() => Money.fromMinor(10.5, 'GEL')).toThrow(
new DomainError(
DOMAIN_ERROR_CODE.INVALID_MONEY_AMOUNT,
'Money minor amount must be an integer'
)
)
})
test('adds and subtracts money in same currency', () => {
const base = Money.fromMinor(1000n, 'USD')
const delta = Money.fromMinor(250n, 'USD')
expect(base.add(delta).amountMinor).toBe(1250n)
expect(base.subtract(delta).amountMinor).toBe(750n)
})
test('throws on currency mismatch', () => {
const gel = Money.fromMinor(1000n, 'GEL')
const usd = Money.fromMinor(1000n, 'USD')
expect(() => gel.add(usd)).toThrow(
new DomainError(
DOMAIN_ERROR_CODE.CURRENCY_MISMATCH,
'Money operation currency mismatch: GEL vs USD'
)
)
})
test('splits evenly with deterministic remainder allocation', () => {
const amount = Money.fromMinor(10n, 'GEL')
const parts = amount.splitEvenly(3)
expect(parts.map((part) => part.amountMinor)).toEqual([4n, 3n, 3n])
expect(parts.reduce((sum, current) => sum + current.amountMinor, 0n)).toBe(10n)
})
test('splits by weights deterministically', () => {
const amount = Money.fromMinor(100n, 'GEL')
const parts = amount.splitByWeights([3, 2, 1])
expect(parts.map((part) => part.amountMinor)).toEqual([50n, 33n, 17n])
expect(parts.reduce((sum, current) => sum + current.amountMinor, 0n)).toBe(100n)
})
test('splits negative values with same deterministic rules', () => {
const amount = Money.fromMinor(-10n, 'GEL')
const parts = amount.splitEvenly(3)
expect(parts.map((part) => part.amountMinor)).toEqual([-4n, -3n, -3n])
expect(parts.reduce((sum, current) => sum + current.amountMinor, 0n)).toBe(-10n)
})
})

View File

@@ -0,0 +1,259 @@
import { DOMAIN_ERROR_CODE, DomainError } from './errors'
export const CURRENCIES = ['GEL', 'USD'] as const
export type CurrencyCode = (typeof CURRENCIES)[number]
const MAJOR_MONEY_PATTERN = /^([+-]?)(\d+)(?:\.(\d{1,2}))?$/
function isIntegerNumber(value: number): boolean {
return Number.isFinite(value) && Number.isInteger(value)
}
function parseMinorUnits(value: bigint | number | string): bigint {
if (typeof value === 'bigint') {
return value
}
if (typeof value === 'number') {
if (!isIntegerNumber(value)) {
throw new DomainError(
DOMAIN_ERROR_CODE.INVALID_MONEY_AMOUNT,
'Money minor amount must be an integer'
)
}
return BigInt(value)
}
if (!/^[+-]?\d+$/.test(value)) {
throw new DomainError(
DOMAIN_ERROR_CODE.INVALID_MONEY_AMOUNT,
'Money minor amount string must contain only integer digits'
)
}
return BigInt(value)
}
function parseMajorUnits(value: string): bigint {
const trimmed = value.trim()
const match = MAJOR_MONEY_PATTERN.exec(trimmed)
if (!match) {
throw new DomainError(
DOMAIN_ERROR_CODE.INVALID_MONEY_MAJOR_FORMAT,
`Invalid money major format: ${value}`
)
}
const [, sign, wholePart, fractionalPart = ''] = match
const normalizedFraction = fractionalPart.padEnd(2, '0')
const composed = `${wholePart}${normalizedFraction}`
const signPrefix = sign === '-' ? '-' : ''
return BigInt(`${signPrefix}${composed}`)
}
function ensureSupportedCurrency(currency: string): CurrencyCode {
if ((CURRENCIES as readonly string[]).includes(currency)) {
return currency as CurrencyCode
}
throw new DomainError(DOMAIN_ERROR_CODE.INVALID_MONEY_AMOUNT, `Unsupported currency: ${currency}`)
}
function formatMajorUnits(minor: bigint): string {
const sign = minor < 0n ? '-' : ''
const absolute = minor < 0n ? -minor : minor
const whole = absolute / 100n
const fraction = absolute % 100n
const fractionString = fraction.toString().padStart(2, '0')
return `${sign}${whole.toString()}.${fractionString}`
}
export class Money {
readonly amountMinor: bigint
readonly currency: CurrencyCode
private constructor(amountMinor: bigint, currency: CurrencyCode) {
this.amountMinor = amountMinor
this.currency = currency
}
static fromMinor(amountMinor: bigint | number | string, currency: CurrencyCode = 'GEL'): Money {
const supportedCurrency = ensureSupportedCurrency(currency)
return new Money(parseMinorUnits(amountMinor), supportedCurrency)
}
static fromMajor(amountMajor: string, currency: CurrencyCode = 'GEL'): Money {
const supportedCurrency = ensureSupportedCurrency(currency)
return new Money(parseMajorUnits(amountMajor), supportedCurrency)
}
static zero(currency: CurrencyCode = 'GEL'): Money {
return Money.fromMinor(0n, currency)
}
add(other: Money): Money {
this.assertSameCurrency(other)
return new Money(this.amountMinor + other.amountMinor, this.currency)
}
subtract(other: Money): Money {
this.assertSameCurrency(other)
return new Money(this.amountMinor - other.amountMinor, this.currency)
}
multiplyBy(multiplier: bigint | number): Money {
const parsedMultiplier = typeof multiplier === 'number' ? BigInt(multiplier) : multiplier
if (typeof multiplier === 'number' && !isIntegerNumber(multiplier)) {
throw new DomainError(DOMAIN_ERROR_CODE.INVALID_MONEY_AMOUNT, 'Multiplier must be an integer')
}
return new Money(this.amountMinor * parsedMultiplier, this.currency)
}
splitEvenly(parts: number): readonly Money[] {
if (!isIntegerNumber(parts) || parts <= 0) {
throw new DomainError(
DOMAIN_ERROR_CODE.INVALID_SPLIT_PARTS,
'Split parts must be a positive integer'
)
}
return this.splitByWeights(Array.from({ length: parts }, () => 1n))
}
splitByWeights(weightsInput: readonly (bigint | number)[]): readonly Money[] {
if (weightsInput.length === 0) {
throw new DomainError(
DOMAIN_ERROR_CODE.INVALID_SPLIT_WEIGHTS,
'At least one weight is required'
)
}
const weights = weightsInput.map((weight) => {
const parsed = typeof weight === 'number' ? BigInt(weight) : weight
if (typeof weight === 'number' && !isIntegerNumber(weight)) {
throw new DomainError(
DOMAIN_ERROR_CODE.INVALID_SPLIT_WEIGHTS,
'Split weights must be integers'
)
}
if (parsed <= 0n) {
throw new DomainError(
DOMAIN_ERROR_CODE.INVALID_SPLIT_WEIGHTS,
'Split weights must be positive'
)
}
return parsed
})
const totalWeight = weights.reduce((sum, current) => sum + current, 0n)
if (totalWeight <= 0n) {
throw new DomainError(
DOMAIN_ERROR_CODE.INVALID_SPLIT_WEIGHTS,
'Total split weight must be positive'
)
}
const isNegative = this.amountMinor < 0n
const absoluteAmount = isNegative ? -this.amountMinor : this.amountMinor
const baseAllocations = weights.map((weight) => (absoluteAmount * weight) / totalWeight)
const remainders = weights.map((weight, index) => ({
index,
remainder: (absoluteAmount * weight) % totalWeight
}))
const allocatedBase = baseAllocations.reduce((sum, current) => sum + current, 0n)
const leftover = absoluteAmount - allocatedBase
remainders.sort((left, right) => {
if (left.remainder === right.remainder) {
return left.index - right.index
}
return left.remainder > right.remainder ? -1 : 1
})
const finalAllocations = [...baseAllocations]
for (let offset = 0n; offset < leftover; offset += 1n) {
const target = remainders[Number(offset)]
if (!target) {
break
}
const currentAllocation = finalAllocations[target.index]
if (currentAllocation === undefined) {
throw new DomainError(
DOMAIN_ERROR_CODE.INVALID_SPLIT_WEIGHTS,
'Unexpected split allocation index state'
)
}
finalAllocations[target.index] = currentAllocation + 1n
}
return finalAllocations.map(
(allocatedMinor) => new Money(isNegative ? -allocatedMinor : allocatedMinor, this.currency)
)
}
equals(other: Money): boolean {
return this.currency === other.currency && this.amountMinor === other.amountMinor
}
isNegative(): boolean {
return this.amountMinor < 0n
}
isZero(): boolean {
return this.amountMinor === 0n
}
compare(other: Money): -1 | 0 | 1 {
this.assertSameCurrency(other)
if (this.amountMinor < other.amountMinor) {
return -1
}
if (this.amountMinor > other.amountMinor) {
return 1
}
return 0
}
toMajorString(): string {
return formatMajorUnits(this.amountMinor)
}
toJSON(): { amountMinor: string; currency: CurrencyCode } {
return {
amountMinor: this.amountMinor.toString(),
currency: this.currency
}
}
private assertSameCurrency(other: Money): void {
if (this.currency !== other.currency) {
throw new DomainError(
DOMAIN_ERROR_CODE.CURRENCY_MISMATCH,
`Money operation currency mismatch: ${this.currency} vs ${other.currency}`
)
}
}
}

View File

@@ -0,0 +1,44 @@
import type { BillingPeriod } from './billing-period'
import type { BillingCycleId, MemberId, PurchaseEntryId } from './ids'
import type { Money } from './money'
export type UtilitySplitMode = 'equal' | 'weighted_by_days'
export interface SettlementMemberInput {
memberId: MemberId
active: boolean
utilityDays?: number
}
export interface SettlementPurchaseInput {
purchaseId: PurchaseEntryId
payerId: MemberId
amount: Money
description?: string
}
export interface SettlementInput {
cycleId: BillingCycleId
period: BillingPeriod
rent: Money
utilities: Money
utilitySplitMode: UtilitySplitMode
members: readonly SettlementMemberInput[]
purchases: readonly SettlementPurchaseInput[]
}
export interface SettlementMemberLine {
memberId: MemberId
rentShare: Money
utilityShare: Money
purchaseOffset: Money
netDue: Money
explanations: readonly string[]
}
export interface SettlementResult {
cycleId: BillingCycleId
period: BillingPeriod
lines: readonly SettlementMemberLine[]
totalDue: Money
}