diff --git a/packages/domain/src/billing-period.test.ts b/packages/domain/src/billing-period.test.ts new file mode 100644 index 0000000..53a3a8f --- /dev/null +++ b/packages/domain/src/billing-period.test.ts @@ -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) + }) +}) diff --git a/packages/domain/src/billing-period.ts b/packages/domain/src/billing-period.ts new file mode 100644 index 0000000..4ac5942 --- /dev/null +++ b/packages/domain/src/billing-period.ts @@ -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')}` + } +} diff --git a/packages/domain/src/errors.ts b/packages/domain/src/errors.ts new file mode 100644 index 0000000..716c202 --- /dev/null +++ b/packages/domain/src/errors.ts @@ -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 diff --git a/packages/domain/src/ids.test.ts b/packages/domain/src/ids.test.ts new file mode 100644 index 0000000..3d02540 --- /dev/null +++ b/packages/domain/src/ids.test.ts @@ -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' + ) + ) + }) +}) diff --git a/packages/domain/src/ids.ts b/packages/domain/src/ids.ts new file mode 100644 index 0000000..4249823 --- /dev/null +++ b/packages/domain/src/ids.ts @@ -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')) + } +} diff --git a/packages/domain/src/index.ts b/packages/domain/src/index.ts index 1196bf9..9ba467e 100644 --- a/packages/domain/src/index.ts +++ b/packages/domain/src/index.ts @@ -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' diff --git a/packages/domain/src/money.test.ts b/packages/domain/src/money.test.ts new file mode 100644 index 0000000..1257c1f --- /dev/null +++ b/packages/domain/src/money.test.ts @@ -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) + }) +}) diff --git a/packages/domain/src/money.ts b/packages/domain/src/money.ts new file mode 100644 index 0000000..f8b613c --- /dev/null +++ b/packages/domain/src/money.ts @@ -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}` + ) + } + } +} diff --git a/packages/domain/src/settlement-primitives.ts b/packages/domain/src/settlement-primitives.ts new file mode 100644 index 0000000..c09d56f --- /dev/null +++ b/packages/domain/src/settlement-primitives.ts @@ -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 +}