mirror of
https://github.com/whekin/household-bot.git
synced 2026-03-31 13:54:02 +00:00
feat(WHE-18): implement money, billing period, and typed domain ids
This commit is contained in:
39
packages/domain/src/billing-period.test.ts
Normal file
39
packages/domain/src/billing-period.test.ts
Normal 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
98
packages/domain/src/billing-period.ts
Normal file
98
packages/domain/src/billing-period.ts
Normal 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')}`
|
||||||
|
}
|
||||||
|
}
|
||||||
19
packages/domain/src/errors.ts
Normal file
19
packages/domain/src/errors.ts
Normal 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
|
||||||
34
packages/domain/src/ids.test.ts
Normal file
34
packages/domain/src/ids.test.ts
Normal 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'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
60
packages/domain/src/ids.ts
Normal file
60
packages/domain/src/ids.ts
Normal 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'))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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'
|
||||||
|
|||||||
66
packages/domain/src/money.test.ts
Normal file
66
packages/domain/src/money.test.ts
Normal 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
259
packages/domain/src/money.ts
Normal file
259
packages/domain/src/money.ts
Normal 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}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
44
packages/domain/src/settlement-primitives.ts
Normal file
44
packages/domain/src/settlement-primitives.ts
Normal 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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user