mirror of
https://github.com/whekin/household-bot.git
synced 2026-03-31 19:14:03 +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