mirror of
https://github.com/whekin/household-bot.git
synced 2026-03-31 17:44:03 +00:00
feat(finance): add billing correction APIs and cycle rollover
This commit is contained in:
@@ -296,6 +296,63 @@ export function createDbFinanceRepository(
|
||||
})
|
||||
},
|
||||
|
||||
async updateParsedPurchase(input) {
|
||||
const rows = await db
|
||||
.update(schema.purchaseMessages)
|
||||
.set({
|
||||
parsedAmountMinor: input.amountMinor,
|
||||
parsedCurrency: input.currency,
|
||||
parsedItemDescription: input.description,
|
||||
needsReview: 0,
|
||||
processingStatus: 'parsed',
|
||||
parserError: null
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(schema.purchaseMessages.householdId, householdId),
|
||||
eq(schema.purchaseMessages.id, input.purchaseId)
|
||||
)
|
||||
)
|
||||
.returning({
|
||||
id: schema.purchaseMessages.id,
|
||||
payerMemberId: schema.purchaseMessages.senderMemberId,
|
||||
amountMinor: schema.purchaseMessages.parsedAmountMinor,
|
||||
currency: schema.purchaseMessages.parsedCurrency,
|
||||
description: schema.purchaseMessages.parsedItemDescription,
|
||||
occurredAt: schema.purchaseMessages.messageSentAt
|
||||
})
|
||||
|
||||
const row = rows[0]
|
||||
if (!row || !row.payerMemberId || row.amountMinor == null || row.currency == null) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
id: row.id,
|
||||
payerMemberId: row.payerMemberId,
|
||||
amountMinor: row.amountMinor,
|
||||
currency: toCurrencyCode(row.currency),
|
||||
description: row.description,
|
||||
occurredAt: instantFromDatabaseValue(row.occurredAt)
|
||||
}
|
||||
},
|
||||
|
||||
async deleteParsedPurchase(purchaseId) {
|
||||
const rows = await db
|
||||
.delete(schema.purchaseMessages)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.purchaseMessages.householdId, householdId),
|
||||
eq(schema.purchaseMessages.id, purchaseId)
|
||||
)
|
||||
)
|
||||
.returning({
|
||||
id: schema.purchaseMessages.id
|
||||
})
|
||||
|
||||
return rows.length > 0
|
||||
},
|
||||
|
||||
async updateUtilityBill(input) {
|
||||
const rows = await db
|
||||
.update(schema.utilityBills)
|
||||
@@ -344,6 +401,97 @@ export function createDbFinanceRepository(
|
||||
return rows.length > 0
|
||||
},
|
||||
|
||||
async addPaymentRecord(input) {
|
||||
const rows = await db
|
||||
.insert(schema.paymentRecords)
|
||||
.values({
|
||||
householdId,
|
||||
cycleId: input.cycleId,
|
||||
memberId: input.memberId,
|
||||
kind: input.kind,
|
||||
amountMinor: input.amountMinor,
|
||||
currency: input.currency,
|
||||
recordedAt: instantToDate(input.recordedAt)
|
||||
})
|
||||
.returning({
|
||||
id: schema.paymentRecords.id,
|
||||
memberId: schema.paymentRecords.memberId,
|
||||
kind: schema.paymentRecords.kind,
|
||||
amountMinor: schema.paymentRecords.amountMinor,
|
||||
currency: schema.paymentRecords.currency,
|
||||
recordedAt: schema.paymentRecords.recordedAt
|
||||
})
|
||||
|
||||
const row = rows[0]
|
||||
if (!row) {
|
||||
throw new Error('Failed to add payment record')
|
||||
}
|
||||
|
||||
return {
|
||||
id: row.id,
|
||||
memberId: row.memberId,
|
||||
kind: row.kind === 'utilities' ? 'utilities' : 'rent',
|
||||
amountMinor: row.amountMinor,
|
||||
currency: toCurrencyCode(row.currency),
|
||||
recordedAt: instantFromDatabaseValue(row.recordedAt)!
|
||||
}
|
||||
},
|
||||
|
||||
async updatePaymentRecord(input) {
|
||||
const rows = await db
|
||||
.update(schema.paymentRecords)
|
||||
.set({
|
||||
memberId: input.memberId,
|
||||
kind: input.kind,
|
||||
amountMinor: input.amountMinor,
|
||||
currency: input.currency
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(schema.paymentRecords.householdId, householdId),
|
||||
eq(schema.paymentRecords.id, input.paymentId)
|
||||
)
|
||||
)
|
||||
.returning({
|
||||
id: schema.paymentRecords.id,
|
||||
memberId: schema.paymentRecords.memberId,
|
||||
kind: schema.paymentRecords.kind,
|
||||
amountMinor: schema.paymentRecords.amountMinor,
|
||||
currency: schema.paymentRecords.currency,
|
||||
recordedAt: schema.paymentRecords.recordedAt
|
||||
})
|
||||
|
||||
const row = rows[0]
|
||||
if (!row) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
id: row.id,
|
||||
memberId: row.memberId,
|
||||
kind: row.kind === 'utilities' ? 'utilities' : 'rent',
|
||||
amountMinor: row.amountMinor,
|
||||
currency: toCurrencyCode(row.currency),
|
||||
recordedAt: instantFromDatabaseValue(row.recordedAt)!
|
||||
}
|
||||
},
|
||||
|
||||
async deletePaymentRecord(paymentId) {
|
||||
const rows = await db
|
||||
.delete(schema.paymentRecords)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.paymentRecords.householdId, householdId),
|
||||
eq(schema.paymentRecords.id, paymentId)
|
||||
)
|
||||
)
|
||||
.returning({
|
||||
id: schema.paymentRecords.id
|
||||
})
|
||||
|
||||
return rows.length > 0
|
||||
},
|
||||
|
||||
async getRentRuleForPeriod(period) {
|
||||
const rows = await db
|
||||
.select({
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -165,6 +165,13 @@ export interface FinanceRepository {
|
||||
currency: CurrencyCode
|
||||
createdByMemberId: string
|
||||
}): Promise<void>
|
||||
updateParsedPurchase(input: {
|
||||
purchaseId: string
|
||||
amountMinor: bigint
|
||||
currency: CurrencyCode
|
||||
description: string | null
|
||||
}): Promise<FinanceParsedPurchaseRecord | null>
|
||||
deleteParsedPurchase(purchaseId: string): Promise<boolean>
|
||||
updateUtilityBill(input: {
|
||||
billId: string
|
||||
billName: string
|
||||
@@ -172,6 +179,22 @@ export interface FinanceRepository {
|
||||
currency: CurrencyCode
|
||||
}): Promise<FinanceUtilityBillRecord | null>
|
||||
deleteUtilityBill(billId: string): Promise<boolean>
|
||||
addPaymentRecord(input: {
|
||||
cycleId: string
|
||||
memberId: string
|
||||
kind: FinancePaymentKind
|
||||
amountMinor: bigint
|
||||
currency: CurrencyCode
|
||||
recordedAt: Instant
|
||||
}): Promise<FinancePaymentRecord>
|
||||
updatePaymentRecord(input: {
|
||||
paymentId: string
|
||||
memberId: string
|
||||
kind: FinancePaymentKind
|
||||
amountMinor: bigint
|
||||
currency: CurrencyCode
|
||||
}): Promise<FinancePaymentRecord | null>
|
||||
deletePaymentRecord(paymentId: string): Promise<boolean>
|
||||
getRentRuleForPeriod(period: string): Promise<FinanceRentRuleRecord | null>
|
||||
getUtilityTotalForCycle(cycleId: string): Promise<bigint>
|
||||
listUtilityBillsForCycle(cycleId: string): Promise<readonly FinanceUtilityBillRecord[]>
|
||||
|
||||
Reference in New Issue
Block a user