feat(finance): add billing correction APIs and cycle rollover

This commit is contained in:
2026-03-10 22:03:30 +04:00
parent 05561a397d
commit 753286a1f6
11 changed files with 943 additions and 26 deletions

View File

@@ -68,7 +68,7 @@ class FinanceRepositoryStub implements FinanceRepository {
}
async getCycleByPeriod(): Promise<FinanceCycleRecord | null> {
return this.cycleByPeriodRecord
return this.cycleByPeriodRecord ?? this.openCycleRecord ?? this.latestCycleRecord
}
async getLatestCycle(): Promise<FinanceCycleRecord | null> {
@@ -76,11 +76,14 @@ class FinanceRepositoryStub implements FinanceRepository {
}
async openCycle(period: string, currency: 'USD' | 'GEL'): Promise<void> {
this.openCycleRecord = {
const cycle = {
id: 'opened-cycle',
period,
currency
}
this.openCycleRecord = cycle
this.cycleByPeriodRecord = cycle
this.latestCycleRecord = cycle
}
async closeCycle(): Promise<void> {}
@@ -129,6 +132,40 @@ class FinanceRepositoryStub implements FinanceRepository {
return false
}
async updateParsedPurchase() {
return null
}
async deleteParsedPurchase() {
return false
}
async addPaymentRecord(input: {
cycleId: string
memberId: string
kind: 'rent' | 'utilities'
amountMinor: bigint
currency: 'USD' | 'GEL'
recordedAt: Instant
}) {
return {
id: 'payment-record-1',
memberId: input.memberId,
kind: input.kind,
amountMinor: input.amountMinor,
currency: input.currency,
recordedAt: input.recordedAt
}
}
async updatePaymentRecord() {
return null
}
async deletePaymentRecord() {
return false
}
async getRentRuleForPeriod(): Promise<FinanceRentRuleRecord | null> {
return this.rentRule
}
@@ -304,14 +341,21 @@ describe('createFinanceCommandService', () => {
})
})
test('addUtilityBill returns null when no open cycle exists', async () => {
test('addUtilityBill auto-opens the expected cycle when none is active', async () => {
const repository = new FinanceRepositoryStub()
const service = createService(repository)
const result = await service.addUtilityBill('Electricity', '55.20', 'member-1')
expect(result).toBeNull()
expect(repository.lastUtilityBill).toBeNull()
expect(result).not.toBeNull()
expect(result?.period).toBe('2026-03')
expect(repository.lastUtilityBill).toEqual({
cycleId: 'opened-cycle',
billName: 'Electricity',
amountMinor: 5520n,
currency: 'GEL',
createdByMemberId: 'member-1'
})
})
test('generateStatement settles into cycle currency and persists snapshot', async () => {

View File

@@ -80,6 +80,23 @@ function localDateInTimezone(timezone: string): Temporal.PlainDate {
return nowInstant().toZonedDateTimeISO(timezone).toPlainDate()
}
function periodFromLocalDate(localDate: Temporal.PlainDate): BillingPeriod {
return BillingPeriod.fromString(`${localDate.year}-${String(localDate.month).padStart(2, '0')}`)
}
function expectedOpenCyclePeriod(
settings: {
rentDueDay: number
timezone: string
},
instant: Temporal.Instant
): BillingPeriod {
const localDate = instant.toZonedDateTimeISO(settings.timezone).toPlainDate()
const currentPeriod = periodFromLocalDate(localDate)
return localDate.day > settings.rentDueDay ? currentPeriod.next() : currentPeriod
}
export interface FinanceDashboardMemberLine {
memberId: string
displayName: string
@@ -96,6 +113,7 @@ export interface FinanceDashboardLedgerEntry {
id: string
kind: 'purchase' | 'utility' | 'payment'
title: string
memberId: string | null
amount: Money
currency: CurrencyCode
displayAmount: Money
@@ -372,6 +390,7 @@ async function buildFinanceDashboard(
id: bill.id,
kind: 'utility' as const,
title: bill.billName,
memberId: bill.createdByMemberId,
amount: converted.originalAmount,
currency: bill.currency,
displayAmount: converted.settlementAmount,
@@ -388,6 +407,7 @@ async function buildFinanceDashboard(
id: purchase.id,
kind: 'purchase' as const,
title: purchase.description ?? 'Shared purchase',
memberId: purchase.payerMemberId,
amount: converted.originalAmount,
currency: purchase.currency,
displayAmount: converted.settlementAmount,
@@ -402,6 +422,7 @@ async function buildFinanceDashboard(
id: payment.id,
kind: 'payment' as const,
title: payment.kind,
memberId: payment.memberId,
amount: Money.fromMinor(payment.amountMinor, payment.currency),
currency: payment.currency,
displayAmount: Money.fromMinor(payment.amountMinor, payment.currency),
@@ -444,6 +465,7 @@ async function buildFinanceDashboard(
export interface FinanceCommandService {
getMemberByTelegramUserId(telegramUserId: string): Promise<FinanceMemberRecord | null>
getOpenCycle(): Promise<FinanceCycleRecord | null>
ensureExpectedCycle(referenceInstant?: Temporal.Instant): Promise<FinanceCycleRecord>
getAdminCycleState(periodArg?: string): Promise<FinanceAdminCycleState>
openCycle(periodArg: string, currencyArg?: string): Promise<FinanceCycleRecord>
closeCycle(periodArg?: string): Promise<FinanceCycleRecord | null>
@@ -477,6 +499,40 @@ export interface FinanceCommandService {
currency: CurrencyCode
} | null>
deleteUtilityBill(billId: string): Promise<boolean>
updatePurchase(
purchaseId: string,
description: string,
amountArg: string,
currencyArg?: string
): Promise<{
purchaseId: string
amount: Money
currency: CurrencyCode
} | null>
deletePurchase(purchaseId: string): Promise<boolean>
addPayment(
memberId: string,
kind: FinancePaymentKind,
amountArg: string,
currencyArg?: string
): Promise<{
paymentId: string
amount: Money
currency: CurrencyCode
period: string
} | null>
updatePayment(
paymentId: string,
memberId: string,
kind: FinancePaymentKind,
amountArg: string,
currencyArg?: string
): Promise<{
paymentId: string
amount: Money
currency: CurrencyCode
} | null>
deletePayment(paymentId: string): Promise<boolean>
generateDashboard(periodArg?: string): Promise<FinanceDashboard | null>
generateStatement(periodArg?: string): Promise<string | null>
}
@@ -486,6 +542,34 @@ export function createFinanceCommandService(
): FinanceCommandService {
const { repository, householdConfigurationRepository } = dependencies
async function ensureExpectedCycle(referenceInstant = nowInstant()): Promise<FinanceCycleRecord> {
const settings = await householdConfigurationRepository.getHouseholdBillingSettings(
dependencies.householdId
)
const period = expectedOpenCyclePeriod(settings, referenceInstant).toString()
let cycle = await repository.getCycleByPeriod(period)
if (!cycle) {
await repository.openCycle(period, settings.settlementCurrency)
cycle = await repository.getCycleByPeriod(period)
}
if (!cycle) {
throw new Error(`Failed to ensure billing cycle for period ${period}`)
}
const openCycle = await repository.getOpenCycle()
if (openCycle && openCycle.id !== cycle.id) {
await repository.closeCycle(openCycle.id, referenceInstant)
}
if (settings.rentAmountMinor !== null) {
await repository.saveRentRule(period, settings.rentAmountMinor, settings.rentCurrency)
}
return cycle
}
return {
getMemberByTelegramUserId(telegramUserId) {
return repository.getMemberByTelegramUserId(telegramUserId)
@@ -495,10 +579,14 @@ export function createFinanceCommandService(
return repository.getOpenCycle()
},
ensureExpectedCycle(referenceInstant) {
return ensureExpectedCycle(referenceInstant)
},
async getAdminCycleState(periodArg) {
const cycle = periodArg
? await repository.getCycleByPeriod(BillingPeriod.fromString(periodArg).toString())
: ((await repository.getOpenCycle()) ?? (await repository.getLatestCycle()))
: await ensureExpectedCycle()
if (!cycle) {
return {
@@ -555,11 +643,11 @@ export function createFinanceCommandService(
},
async setRent(amountArg, currencyArg, periodArg) {
const [openCycle, settings] = await Promise.all([
repository.getOpenCycle(),
householdConfigurationRepository.getHouseholdBillingSettings(dependencies.householdId)
const [settings, cycle] = await Promise.all([
householdConfigurationRepository.getHouseholdBillingSettings(dependencies.householdId),
periodArg ? Promise.resolve(null) : ensureExpectedCycle()
])
const period = periodArg ?? openCycle?.period
const period = periodArg ?? cycle?.period
if (!period) {
return null
}
@@ -582,12 +670,9 @@ export function createFinanceCommandService(
async addUtilityBill(billName, amountArg, createdByMemberId, currencyArg) {
const [openCycle, settings] = await Promise.all([
repository.getOpenCycle(),
ensureExpectedCycle(),
householdConfigurationRepository.getHouseholdBillingSettings(dependencies.householdId)
])
if (!openCycle) {
return null
}
const currency = parseCurrency(currencyArg, settings.settlementCurrency)
const amount = Money.fromMajor(amountArg, currency)
@@ -635,7 +720,93 @@ export function createFinanceCommandService(
return repository.deleteUtilityBill(billId)
},
async updatePurchase(purchaseId, description, amountArg, currencyArg) {
const settings = await householdConfigurationRepository.getHouseholdBillingSettings(
dependencies.householdId
)
const currency = parseCurrency(currencyArg, settings.settlementCurrency)
const amount = Money.fromMajor(amountArg, currency)
const updated = await repository.updateParsedPurchase({
purchaseId,
amountMinor: amount.amountMinor,
currency,
description: description.trim().length > 0 ? description.trim() : null
})
if (!updated) {
return null
}
return {
purchaseId: updated.id,
amount,
currency
}
},
deletePurchase(purchaseId) {
return repository.deleteParsedPurchase(purchaseId)
},
async addPayment(memberId, kind, amountArg, currencyArg) {
const [openCycle, settings] = await Promise.all([
ensureExpectedCycle(),
householdConfigurationRepository.getHouseholdBillingSettings(dependencies.householdId)
])
const currency = parseCurrency(currencyArg, settings.settlementCurrency)
const amount = Money.fromMajor(amountArg, currency)
const payment = await repository.addPaymentRecord({
cycleId: openCycle.id,
memberId,
kind,
amountMinor: amount.amountMinor,
currency,
recordedAt: nowInstant()
})
return {
paymentId: payment.id,
amount,
currency,
period: openCycle.period
}
},
async updatePayment(paymentId, memberId, kind, amountArg, currencyArg) {
const settings = await householdConfigurationRepository.getHouseholdBillingSettings(
dependencies.householdId
)
const currency = parseCurrency(currencyArg, settings.settlementCurrency)
const amount = Money.fromMajor(amountArg, currency)
const payment = await repository.updatePaymentRecord({
paymentId,
memberId,
kind,
amountMinor: amount.amountMinor,
currency
})
if (!payment) {
return null
}
return {
paymentId: payment.id,
amount,
currency
}
},
deletePayment(paymentId) {
return repository.deletePaymentRecord(paymentId)
},
async generateStatement(periodArg) {
if (!periodArg) {
await ensureExpectedCycle()
}
const dashboard = await buildFinanceDashboard(dependencies, periodArg)
if (!dashboard) {
return null
@@ -661,7 +832,9 @@ export function createFinanceCommandService(
},
generateDashboard(periodArg) {
return buildFinanceDashboard(dependencies, periodArg)
return periodArg
? buildFinanceDashboard(dependencies, periodArg)
: ensureExpectedCycle().then(() => buildFinanceDashboard(dependencies))
}
}
}