feat(miniapp): carry overdue billing and admin role flows

This commit is contained in:
2026-03-23 15:44:55 +04:00
parent ee8c53d89b
commit 5af14e101e
44 changed files with 2965 additions and 329 deletions

View File

@@ -153,6 +153,23 @@ export function createDbFinanceRepository(
}
},
async listCycles() {
const rows = await db
.select({
id: schema.billingCycles.id,
period: schema.billingCycles.period,
currency: schema.billingCycles.currency
})
.from(schema.billingCycles)
.where(eq(schema.billingCycles.householdId, householdId))
.orderBy(schema.billingCycles.period)
return rows.map((row) => ({
...row,
currency: toCurrencyCode(row.currency)
}))
},
async getCycleByPeriod(period) {
const rows = await db
.select({
@@ -354,6 +371,7 @@ export function createDbFinanceRepository(
await db.insert(schema.purchaseMessages).values({
id: purchaseId,
householdId,
cycleId: input.cycleId,
senderMemberId: input.payerMemberId,
payerMemberId: input.payerMemberId,
senderTelegramUserId: 'miniapp',
@@ -415,11 +433,13 @@ export function createDbFinanceRepository(
return {
id: row.id,
cycleId: input.cycleId,
payerMemberId: row.payerMemberId,
amountMinor: row.amountMinor,
currency: toCurrencyCode(row.currency),
description: row.description,
occurredAt: row.occurredAt ? instantFromDatabaseValue(row.occurredAt) : null,
cyclePeriod: null,
splitMode: row.splitMode as 'equal' | 'custom_amounts',
participants: participantRows.map((p) => ({
memberId: p.memberId,
@@ -502,11 +522,13 @@ export function createDbFinanceRepository(
return {
id: row.id,
cycleId: null,
payerMemberId: row.payerMemberId,
amountMinor: row.amountMinor,
currency: toCurrencyCode(row.currency),
description: row.description,
occurredAt: instantFromDatabaseValue(row.occurredAt),
cyclePeriod: null,
splitMode: row.splitMode === 'custom_amounts' ? 'custom_amounts' : 'equal',
participants: participants.map((participant) => ({
id: participant.id,
@@ -596,6 +618,7 @@ export function createDbFinanceRepository(
})
.returning({
id: schema.paymentRecords.id,
cycleId: schema.paymentRecords.cycleId,
memberId: schema.paymentRecords.memberId,
kind: schema.paymentRecords.kind,
amountMinor: schema.paymentRecords.amountMinor,
@@ -610,6 +633,8 @@ export function createDbFinanceRepository(
return {
id: row.id,
cycleId: row.cycleId,
cyclePeriod: null,
memberId: row.memberId,
kind: row.kind === 'utilities' ? 'utilities' : 'rent',
amountMinor: row.amountMinor,
@@ -618,6 +643,66 @@ export function createDbFinanceRepository(
}
},
async getPaymentRecord(paymentId) {
const rows = await db
.select({
id: schema.paymentRecords.id,
cycleId: schema.paymentRecords.cycleId,
cyclePeriod: schema.billingCycles.period,
memberId: schema.paymentRecords.memberId,
kind: schema.paymentRecords.kind,
amountMinor: schema.paymentRecords.amountMinor,
currency: schema.paymentRecords.currency,
recordedAt: schema.paymentRecords.recordedAt
})
.from(schema.paymentRecords)
.innerJoin(schema.billingCycles, eq(schema.paymentRecords.cycleId, schema.billingCycles.id))
.where(
and(
eq(schema.paymentRecords.householdId, householdId),
eq(schema.paymentRecords.id, paymentId)
)
)
.limit(1)
const row = rows[0]
if (!row) {
return null
}
return {
id: row.id,
cycleId: row.cycleId,
cyclePeriod: row.cyclePeriod,
memberId: row.memberId,
kind: row.kind === 'utilities' ? 'utilities' : 'rent',
amountMinor: row.amountMinor,
currency: toCurrencyCode(row.currency),
recordedAt: instantFromDatabaseValue(row.recordedAt)!
}
},
async replacePaymentPurchaseAllocations(input) {
await db.transaction(async (tx) => {
await tx
.delete(schema.paymentPurchaseAllocations)
.where(eq(schema.paymentPurchaseAllocations.paymentRecordId, input.paymentRecordId))
if (input.allocations.length === 0) {
return
}
await tx.insert(schema.paymentPurchaseAllocations).values(
input.allocations.map((allocation) => ({
paymentRecordId: input.paymentRecordId,
purchaseId: allocation.purchaseId,
memberId: allocation.memberId,
amountMinor: allocation.amountMinor
}))
)
})
},
async updatePaymentRecord(input) {
const rows = await db
.update(schema.paymentRecords)
@@ -635,6 +720,7 @@ export function createDbFinanceRepository(
)
.returning({
id: schema.paymentRecords.id,
cycleId: schema.paymentRecords.cycleId,
memberId: schema.paymentRecords.memberId,
kind: schema.paymentRecords.kind,
amountMinor: schema.paymentRecords.amountMinor,
@@ -649,6 +735,8 @@ export function createDbFinanceRepository(
return {
id: row.id,
cycleId: row.cycleId,
cyclePeriod: null,
memberId: row.memberId,
kind: row.kind === 'utilities' ? 'utilities' : 'rent',
amountMinor: row.amountMinor,
@@ -741,6 +829,8 @@ export function createDbFinanceRepository(
const rows = await db
.select({
id: schema.paymentRecords.id,
cycleId: schema.paymentRecords.cycleId,
cyclePeriod: schema.billingCycles.period,
memberId: schema.paymentRecords.memberId,
kind: schema.paymentRecords.kind,
amountMinor: schema.paymentRecords.amountMinor,
@@ -748,11 +838,14 @@ export function createDbFinanceRepository(
recordedAt: schema.paymentRecords.recordedAt
})
.from(schema.paymentRecords)
.innerJoin(schema.billingCycles, eq(schema.paymentRecords.cycleId, schema.billingCycles.id))
.where(eq(schema.paymentRecords.cycleId, cycleId))
.orderBy(schema.paymentRecords.recordedAt)
return rows.map((row) => ({
id: row.id,
cycleId: row.cycleId,
cyclePeriod: row.cyclePeriod,
memberId: row.memberId,
kind: row.kind === 'utilities' ? 'utilities' : 'rent',
amountMinor: row.amountMinor,
@@ -765,6 +858,8 @@ export function createDbFinanceRepository(
const rows = await db
.select({
id: schema.purchaseMessages.id,
cycleId: schema.purchaseMessages.cycleId,
cyclePeriod: schema.billingCycles.period,
payerMemberId: schema.purchaseMessages.payerMemberId,
amountMinor: schema.purchaseMessages.parsedAmountMinor,
currency: schema.purchaseMessages.parsedCurrency,
@@ -773,6 +868,10 @@ export function createDbFinanceRepository(
splitMode: schema.purchaseMessages.participantSplitMode
})
.from(schema.purchaseMessages)
.leftJoin(
schema.billingCycles,
eq(schema.purchaseMessages.cycleId, schema.billingCycles.id)
)
.where(
and(
eq(schema.purchaseMessages.householdId, householdId),
@@ -792,6 +891,8 @@ export function createDbFinanceRepository(
return rows.map((row) => ({
id: row.id,
cycleId: row.cycleId,
cyclePeriod: row.cyclePeriod,
payerMemberId: row.payerMemberId!,
amountMinor: row.amountMinor!,
currency: toCurrencyCode(row.currency!),
@@ -802,6 +903,82 @@ export function createDbFinanceRepository(
}))
},
async listParsedPurchases() {
const rows = await db
.select({
id: schema.purchaseMessages.id,
cycleId: schema.purchaseMessages.cycleId,
cyclePeriod: schema.billingCycles.period,
payerMemberId: schema.purchaseMessages.payerMemberId,
amountMinor: schema.purchaseMessages.parsedAmountMinor,
currency: schema.purchaseMessages.parsedCurrency,
description: schema.purchaseMessages.parsedItemDescription,
occurredAt: schema.purchaseMessages.messageSentAt,
splitMode: schema.purchaseMessages.participantSplitMode
})
.from(schema.purchaseMessages)
.leftJoin(
schema.billingCycles,
eq(schema.purchaseMessages.cycleId, schema.billingCycles.id)
)
.where(
and(
eq(schema.purchaseMessages.householdId, householdId),
isNotNull(schema.purchaseMessages.payerMemberId),
isNotNull(schema.purchaseMessages.parsedAmountMinor),
isNotNull(schema.purchaseMessages.parsedCurrency),
or(
eq(schema.purchaseMessages.processingStatus, 'parsed'),
eq(schema.purchaseMessages.processingStatus, 'confirmed')
)
)
)
.orderBy(schema.purchaseMessages.messageSentAt, schema.purchaseMessages.id)
const participantsByPurchaseId = await loadPurchaseParticipants(rows.map((row) => row.id))
return rows.map((row) => ({
id: row.id,
cycleId: row.cycleId,
cyclePeriod: row.cyclePeriod,
payerMemberId: row.payerMemberId!,
amountMinor: row.amountMinor!,
currency: toCurrencyCode(row.currency!),
description: row.description,
occurredAt: instantFromDatabaseValue(row.occurredAt),
splitMode: row.splitMode === 'custom_amounts' ? 'custom_amounts' : 'equal',
participants: participantsByPurchaseId.get(row.id) ?? []
}))
},
async listPaymentPurchaseAllocations() {
const rows = await db
.select({
id: schema.paymentPurchaseAllocations.id,
paymentRecordId: schema.paymentPurchaseAllocations.paymentRecordId,
purchaseId: schema.paymentPurchaseAllocations.purchaseId,
memberId: schema.paymentPurchaseAllocations.memberId,
amountMinor: schema.paymentPurchaseAllocations.amountMinor,
recordedAt: schema.paymentRecords.recordedAt
})
.from(schema.paymentPurchaseAllocations)
.innerJoin(
schema.paymentRecords,
eq(schema.paymentPurchaseAllocations.paymentRecordId, schema.paymentRecords.id)
)
.where(eq(schema.paymentRecords.householdId, householdId))
.orderBy(
schema.paymentPurchaseAllocations.purchaseId,
schema.paymentPurchaseAllocations.memberId,
schema.paymentPurchaseAllocations.createdAt
)
return rows.map((row) => ({
...row,
recordedAt: instantFromDatabaseValue(row.recordedAt)!
}))
},
async getSettlementSnapshotLines(cycleId) {
const rows = await db
.select({
@@ -907,6 +1084,8 @@ export function createDbFinanceRepository(
status: 'recorded' as const,
paymentRecord: {
id: paymentRow.id,
cycleId: input.cycleId,
cyclePeriod: null,
memberId: paymentRow.memberId,
kind: paymentRow.kind === 'utilities' ? 'utilities' : 'rent',
amountMinor: paymentRow.amountMinor,

View File

@@ -1512,6 +1512,40 @@ export function createDbHouseholdConfigurationRepository(databaseUrl: string): {
})
},
async demoteHouseholdAdmin(householdId, memberId) {
const rows = await db
.update(schema.members)
.set({
isAdmin: 0
})
.where(and(eq(schema.members.householdId, householdId), eq(schema.members.id, memberId)))
.returning({
id: schema.members.id,
householdId: schema.members.householdId,
telegramUserId: schema.members.telegramUserId,
displayName: schema.members.displayName,
lifecycleStatus: schema.members.lifecycleStatus,
preferredLocale: schema.members.preferredLocale,
rentShareWeight: schema.members.rentShareWeight,
isAdmin: schema.members.isAdmin
})
const row = rows[0]
if (!row) {
return null
}
const household = await this.getHouseholdChatByHouseholdId(householdId)
if (!household) {
throw new Error('Failed to resolve household chat after household admin demotion')
}
return toHouseholdMemberRecord({
...row,
defaultLocale: household.defaultLocale
})
},
async updateHouseholdMemberRentShareWeight(householdId, memberId, rentShareWeight) {
const rows = await db
.update(schema.members)

View File

@@ -45,10 +45,12 @@ class FinanceRepositoryStub implements FinanceRepository {
openCycleRecord: FinanceCycleRecord | null = null
cycleByPeriodRecord: FinanceCycleRecord | null = null
latestCycleRecord: FinanceCycleRecord | null = null
cycles: readonly FinanceCycleRecord[] = []
rentRule: FinanceRentRuleRecord | null = null
purchases: readonly FinanceParsedPurchaseRecord[] = []
utilityBills: readonly {
id: string
cycleId?: string
billName: string
amountMinor: bigint
currency: 'USD' | 'GEL'
@@ -57,6 +59,8 @@ class FinanceRepositoryStub implements FinanceRepository {
}[] = []
paymentRecords: readonly {
id: string
cycleId: string
cyclePeriod?: string | null
memberId: string
kind: 'rent' | 'utilities'
amountMinor: bigint
@@ -79,6 +83,10 @@ class FinanceRepositoryStub implements FinanceRepository {
cycleExchangeRates = new Map<string, FinanceCycleExchangeRateRecord>()
lastUpdatedPurchaseInput: Parameters<FinanceRepository['updateParsedPurchase']>[0] | null = null
lastAddedPurchaseInput: Parameters<FinanceRepository['addParsedPurchase']>[0] | null = null
lastReplacedPaymentPurchaseAllocations:
| Parameters<FinanceRepository['replacePaymentPurchaseAllocations']>[0]
| null = null
addedPaymentRecords: Parameters<FinanceRepository['addPaymentRecord']>[0][] = []
async getMemberByTelegramUserId(): Promise<FinanceMemberRecord | null> {
return this.member
@@ -88,12 +96,27 @@ class FinanceRepositoryStub implements FinanceRepository {
return this.members
}
async listCycles(): Promise<readonly FinanceCycleRecord[]> {
if (this.cycles.length > 0) {
return this.cycles
}
return [this.openCycleRecord ?? this.cycleByPeriodRecord ?? this.latestCycleRecord].filter(
(cycle): cycle is FinanceCycleRecord => Boolean(cycle)
)
}
async getOpenCycle(): Promise<FinanceCycleRecord | null> {
return this.openCycleRecord
}
async getCycleByPeriod(): Promise<FinanceCycleRecord | null> {
return this.cycleByPeriodRecord ?? this.openCycleRecord ?? this.latestCycleRecord
async getCycleByPeriod(period: string): Promise<FinanceCycleRecord | null> {
return (
this.cycles.find((cycle) => cycle.period === period) ??
(this.cycleByPeriodRecord?.period === period ? this.cycleByPeriodRecord : null) ??
(this.openCycleRecord?.period === period ? this.openCycleRecord : null) ??
(this.latestCycleRecord?.period === period ? this.latestCycleRecord : null)
)
}
async getLatestCycle(): Promise<FinanceCycleRecord | null> {
@@ -153,6 +176,8 @@ class FinanceRepositoryStub implements FinanceRepository {
this.lastAddedPurchaseInput = input
return {
id: 'purchase-1',
cycleId: input.cycleId,
cyclePeriod: null,
payerMemberId: input.payerMemberId,
amountMinor: input.amountMinor,
currency: input.currency,
@@ -179,6 +204,8 @@ class FinanceRepositoryStub implements FinanceRepository {
this.lastUpdatedPurchaseInput = input
return {
id: input.purchaseId,
cycleId: null,
cyclePeriod: null,
payerMemberId: 'alice',
amountMinor: input.amountMinor,
currency: input.currency,
@@ -210,8 +237,15 @@ class FinanceRepositoryStub implements FinanceRepository {
currency: 'USD' | 'GEL'
recordedAt: Instant
}) {
this.addedPaymentRecords.push(input)
return {
id: 'payment-record-1',
id: `payment-record-${this.addedPaymentRecords.length}`,
cycleId: input.cycleId,
cyclePeriod:
this.cycles.find((cycle) => cycle.id === input.cycleId)?.period ??
this.openCycleRecord?.period ??
null,
memberId: input.memberId,
kind: input.kind,
amountMinor: input.amountMinor,
@@ -220,6 +254,25 @@ class FinanceRepositoryStub implements FinanceRepository {
}
}
async getPaymentRecord(paymentId: string) {
return {
id: paymentId,
cycleId: this.openCycleRecord?.id ?? 'cycle-1',
cyclePeriod: this.openCycleRecord?.period ?? '2026-03',
memberId: 'alice',
kind: 'utilities' as const,
amountMinor: 0n,
currency: 'GEL' as const,
recordedAt: instantFromIso('2026-03-20T10:00:00.000Z')
}
}
async replacePaymentPurchaseAllocations(
input: Parameters<FinanceRepository['replacePaymentPurchaseAllocations']>[0]
) {
this.lastReplacedPaymentPurchaseAllocations = input
}
async updatePaymentRecord() {
return null
}
@@ -236,18 +289,26 @@ class FinanceRepositoryStub implements FinanceRepository {
return this.utilityBills.reduce((sum, bill) => sum + bill.amountMinor, 0n)
}
async listUtilityBillsForCycle() {
return this.utilityBills
async listUtilityBillsForCycle(cycleId: string) {
return this.utilityBills.filter((bill) => !bill.cycleId || bill.cycleId === cycleId)
}
async listPaymentRecordsForCycle() {
return this.paymentRecords
async listPaymentRecordsForCycle(cycleId: string) {
return this.paymentRecords.filter((payment) => payment.cycleId === cycleId)
}
async listParsedPurchasesForRange(): Promise<readonly FinanceParsedPurchaseRecord[]> {
return this.purchases
}
async listParsedPurchases(): Promise<readonly FinanceParsedPurchaseRecord[]> {
return this.purchases
}
async listPaymentPurchaseAllocations() {
return []
}
async getSettlementSnapshotLines() {
return []
}
@@ -364,9 +425,10 @@ function createService(repository: FinanceRepositoryStub) {
describe('createFinanceCommandService', () => {
test('setRent falls back to the open cycle period when one is active', async () => {
const repository = new FinanceRepositoryStub()
const currentPeriod = expectedCurrentCyclePeriod('Asia/Tbilisi', 20)
repository.openCycleRecord = {
id: 'cycle-1',
period: '2026-03',
period: currentPeriod,
currency: 'GEL'
}
@@ -374,11 +436,11 @@ describe('createFinanceCommandService', () => {
const result = await service.setRent('700', undefined, undefined)
expect(result).not.toBeNull()
expect(result?.period).toBe('2026-03')
expect(result?.period).toBe(currentPeriod)
expect(result?.currency).toBe('USD')
expect(result?.amount.amountMinor).toBe(70000n)
expect(repository.lastSavedRentRule).toEqual({
period: '2026-03',
period: currentPeriod,
amountMinor: 70000n,
currency: 'USD'
})
@@ -386,9 +448,10 @@ describe('createFinanceCommandService', () => {
test('getAdminCycleState prefers the open cycle and returns rent plus utility bills', async () => {
const repository = new FinanceRepositoryStub()
const currentPeriod = expectedCurrentCyclePeriod('Asia/Tbilisi', 20)
repository.openCycleRecord = {
id: 'cycle-1',
period: '2026-03',
period: currentPeriod,
currency: 'GEL'
}
repository.latestCycleRecord = {
@@ -417,7 +480,7 @@ describe('createFinanceCommandService', () => {
expect(result).toEqual({
cycle: {
id: 'cycle-1',
period: '2026-03',
period: currentPeriod,
currency: 'GEL'
},
rentRule: {
@@ -498,6 +561,8 @@ describe('createFinanceCommandService', () => {
repository.purchases = [
{
id: 'purchase-1',
cycleId: 'cycle-2026-03',
cyclePeriod: '2026-03',
payerMemberId: 'alice',
amountMinor: 3000n,
currency: 'GEL',
@@ -508,6 +573,8 @@ describe('createFinanceCommandService', () => {
repository.paymentRecords = [
{
id: 'payment-1',
cycleId: 'cycle-2026-03',
cyclePeriod: '2026-03',
memberId: 'alice',
kind: 'rent',
amountMinor: 50000n,
@@ -517,8 +584,8 @@ describe('createFinanceCommandService', () => {
]
const service = createService(repository)
const dashboard = await service.generateDashboard()
const statement = await service.generateStatement()
const dashboard = await service.generateDashboard('2026-03')
const statement = await service.generateStatement('2026-03')
expect(dashboard).not.toBeNull()
expect(dashboard?.currency).toBe('GEL')
@@ -578,7 +645,7 @@ describe('createFinanceCommandService', () => {
}
const service = createService(repository)
const dashboard = await service.generateDashboard()
const dashboard = await service.generateDashboard('2026-03')
expect(dashboard?.period).toBe('2026-03')
})
@@ -638,6 +705,8 @@ describe('createFinanceCommandService', () => {
repository.purchases = [
{
id: 'purchase-1',
cycleId: 'cycle-2026-03',
cyclePeriod: '2026-03',
payerMemberId: 'alice',
amountMinor: 3000n,
currency: 'GEL',
@@ -742,6 +811,8 @@ describe('createFinanceCommandService', () => {
repository.purchases = [
{
id: 'purchase-1',
cycleId: 'cycle-2026-03',
cyclePeriod: '2026-03',
payerMemberId: 'alice',
amountMinor: 3000n,
currency: 'GEL',
@@ -823,6 +894,8 @@ describe('createFinanceCommandService', () => {
repository.purchases = [
{
id: 'malformed-purchase-1',
cycleId: 'cycle-2026-03',
cyclePeriod: '2026-03',
payerMemberId: 'alice',
amountMinor: 1000n, // Total is 10.00 GEL
currency: 'GEL',
@@ -882,7 +955,7 @@ describe('createFinanceCommandService', () => {
repository.rentRule = null
const service = createService(repository)
const dashboard = await service.generateDashboard()
const dashboard = await service.generateDashboard('2026-03')
expect(dashboard).not.toBeNull()
expect(dashboard?.period).toBe('2026-03')
@@ -890,4 +963,310 @@ describe('createFinanceCommandService', () => {
expect(dashboard?.rentDisplayAmount.amountMinor).toBe(0n)
expect(dashboard?.totalDue.amountMinor).toBe(0n)
})
test('generateDashboard carries unresolved purchases from prior cycles into the current cycle', async () => {
const repository = new FinanceRepositoryStub()
repository.members = [
{
id: 'alice',
telegramUserId: '1',
displayName: 'Alice',
rentShareWeight: 1,
isAdmin: true
},
{
id: 'bob',
telegramUserId: '2',
displayName: 'Bob',
rentShareWeight: 1,
isAdmin: false
}
]
repository.openCycleRecord = {
id: 'cycle-2026-04',
period: '2026-04',
currency: 'GEL'
}
repository.rentRule = {
amountMinor: 0n,
currency: 'GEL'
}
repository.utilityBills = [
{
id: 'utility-1',
billName: 'Electricity',
amountMinor: 5000n,
currency: 'GEL',
createdByMemberId: 'alice',
createdAt: instantFromIso('2026-04-05T12:00:00.000Z')
}
]
repository.purchases = [
{
id: 'purchase-1',
cycleId: 'cycle-2026-03',
cyclePeriod: '2026-03',
payerMemberId: 'alice',
amountMinor: 3000n,
currency: 'GEL',
description: 'Soap',
occurredAt: instantFromIso('2026-03-12T11:00:00.000Z'),
splitMode: 'custom_amounts',
participants: [
{
memberId: 'alice',
included: true,
shareAmountMinor: 1500n
},
{
memberId: 'bob',
included: true,
shareAmountMinor: 1500n
}
]
}
]
const service = createService(repository)
const dashboard = await service.generateDashboard()
const bobLine = dashboard?.members.find((member) => member.memberId === 'bob')
const purchaseEntry = dashboard?.ledger.find((entry) => entry.id === 'purchase-1')
expect(bobLine?.purchaseOffset.amountMinor).toBe(1500n)
expect(bobLine?.utilityShare.amountMinor).toBe(2500n)
expect(purchaseEntry?.kind).toBe('purchase')
expect(purchaseEntry?.originPeriod).toBe('2026-03')
expect(purchaseEntry?.resolutionStatus).toBe('unresolved')
expect(purchaseEntry?.outstandingByMember).toEqual([
{
memberId: 'bob',
amount: Money.fromMinor(1500n, 'GEL')
}
])
})
test('addPayment allocates utilities overage to the oldest unresolved purchase balance', async () => {
const repository = new FinanceRepositoryStub()
repository.members = [
{
id: 'alice',
telegramUserId: '1',
displayName: 'Alice',
rentShareWeight: 1,
isAdmin: true
},
{
id: 'bob',
telegramUserId: '2',
displayName: 'Bob',
rentShareWeight: 1,
isAdmin: false
}
]
repository.openCycleRecord = {
id: 'cycle-2026-04',
period: '2026-04',
currency: 'GEL'
}
repository.rentRule = {
amountMinor: 0n,
currency: 'GEL'
}
repository.utilityBills = [
{
id: 'utility-1',
billName: 'Electricity',
amountMinor: 5000n,
currency: 'GEL',
createdByMemberId: 'alice',
createdAt: instantFromIso('2026-04-05T12:00:00.000Z')
}
]
repository.purchases = [
{
id: 'purchase-oldest',
cycleId: 'cycle-2026-03',
cyclePeriod: '2026-03',
payerMemberId: 'alice',
amountMinor: 3000n,
currency: 'GEL',
description: 'Old soap',
occurredAt: instantFromIso('2026-03-12T11:00:00.000Z'),
splitMode: 'custom_amounts',
participants: [
{
memberId: 'alice',
included: true,
shareAmountMinor: 1500n
},
{
memberId: 'bob',
included: true,
shareAmountMinor: 1500n
}
]
},
{
id: 'purchase-newer',
cycleId: 'cycle-2026-04',
cyclePeriod: '2026-04',
payerMemberId: 'alice',
amountMinor: 2000n,
currency: 'GEL',
description: 'New sponge',
occurredAt: instantFromIso('2026-04-07T11:00:00.000Z'),
splitMode: 'custom_amounts',
participants: [
{
memberId: 'alice',
included: true,
shareAmountMinor: 1000n
},
{
memberId: 'bob',
included: true,
shareAmountMinor: 1000n
}
]
}
]
const service = createService(repository)
await service.addPayment('bob', 'utilities', '40.00', 'GEL', '2026-04')
expect(repository.lastReplacedPaymentPurchaseAllocations).toEqual({
paymentRecordId: 'payment-record-1',
allocations: [
{
purchaseId: 'purchase-oldest',
memberId: 'bob',
amountMinor: 1500n
}
]
})
})
test('generateDashboard aggregates overdue payments by kind across unresolved past cycles', async () => {
const repository = new FinanceRepositoryStub()
repository.members = [
{
id: 'alice',
telegramUserId: '1',
displayName: 'Alice',
rentShareWeight: 1,
isAdmin: true
},
{
id: 'bob',
telegramUserId: '2',
displayName: 'Bob',
rentShareWeight: 1,
isAdmin: false
}
]
repository.cycles = [
{ id: 'cycle-2026-01', period: '2026-01', currency: 'GEL' },
{ id: 'cycle-2026-02', period: '2026-02', currency: 'GEL' },
{ id: 'cycle-2026-03', period: '2026-03', currency: 'GEL' }
]
repository.openCycleRecord = repository.cycles[2]!
repository.latestCycleRecord = repository.cycles[2]!
repository.rentRule = {
amountMinor: 2000n,
currency: 'GEL'
}
repository.utilityBills = [
{
id: 'utility-2026-02',
cycleId: 'cycle-2026-02',
billName: 'Electricity',
amountMinor: 600n,
currency: 'GEL',
createdByMemberId: 'alice',
createdAt: instantFromIso('2026-02-10T12:00:00.000Z')
}
]
repository.paymentRecords = [
{
id: 'payment-1',
cycleId: 'cycle-2026-03',
cyclePeriod: '2026-03',
memberId: 'bob',
kind: 'rent',
amountMinor: 1000n,
currency: 'GEL',
recordedAt: instantFromIso('2026-03-18T12:00:00.000Z')
}
]
const service = createService(repository)
const dashboard = await service.generateDashboard()
const bobLine = dashboard?.members.find((member) => member.memberId === 'bob')
expect(bobLine?.overduePayments).toEqual([
{
kind: 'rent',
amountMinor: 2000n,
periods: ['2026-01', '2026-02']
},
{
kind: 'utilities',
amountMinor: 300n,
periods: ['2026-02']
}
])
})
test('addPayment without explicit period applies overdue payments oldest-first across cycles', async () => {
const repository = new FinanceRepositoryStub()
repository.members = [
{
id: 'alice',
telegramUserId: '1',
displayName: 'Alice',
rentShareWeight: 1,
isAdmin: true
},
{
id: 'bob',
telegramUserId: '2',
displayName: 'Bob',
rentShareWeight: 1,
isAdmin: false
}
]
repository.cycles = [
{ id: 'cycle-2026-01', period: '2026-01', currency: 'GEL' },
{ id: 'cycle-2026-02', period: '2026-02', currency: 'GEL' },
{ id: 'cycle-2026-03', period: '2026-03', currency: 'GEL' }
]
repository.openCycleRecord = repository.cycles[2]!
repository.latestCycleRecord = repository.cycles[2]!
repository.rentRule = {
amountMinor: 2000n,
currency: 'GEL'
}
const service = createService(repository)
await service.addPayment('bob', 'rent', '15.00', 'GEL')
expect(repository.addedPaymentRecords).toEqual([
{
cycleId: 'cycle-2026-01',
memberId: 'bob',
kind: 'rent',
amountMinor: 1000n,
currency: 'GEL',
recordedAt: repository.addedPaymentRecords[0]!.recordedAt
},
{
cycleId: 'cycle-2026-02',
memberId: 'bob',
kind: 'rent',
amountMinor: 500n,
currency: 'GEL',
recordedAt: repository.addedPaymentRecords[1]!.recordedAt
}
])
})
})

View File

@@ -4,9 +4,12 @@ import type {
ExchangeRateProvider,
FinanceCycleRecord,
FinanceMemberRecord,
FinanceMemberOverduePaymentRecord,
FinancePaymentKind,
FinancePaymentPurchaseAllocationRecord,
FinanceRentRuleRecord,
FinanceRepository,
HouseholdBillingSettingsRecord,
HouseholdConfigurationRepository,
HouseholdMemberAbsencePolicy,
HouseholdMemberAbsencePolicyRecord,
@@ -116,6 +119,7 @@ export interface FinanceDashboardMemberLine {
netDue: Money
paid: Money
remaining: Money
overduePayments: readonly FinanceMemberOverduePaymentRecord[]
explanations: readonly string[]
}
@@ -140,6 +144,13 @@ export interface FinanceDashboardLedgerEntry {
shareAmount: Money | null
}[]
payerMemberId?: string
originPeriod?: string | null
resolutionStatus?: 'unresolved' | 'resolved'
resolvedAt?: string | null
outstandingByMember?: readonly {
memberId: string
amount: Money
}[]
}
export interface FinanceDashboard {
@@ -227,6 +238,95 @@ interface ConvertedCycleMoney {
fxEffectiveDate: string | null
}
interface PurchaseHistoryState {
purchase: Awaited<ReturnType<FinanceRepository['listParsedPurchases']>>[number]
converted: ConvertedCycleMoney
outstandingByMemberId: ReadonlyMap<string, Money>
outstandingTotal: Money
resolvedAt: string | null
}
interface CycleBaseMemberLine {
memberId: string
rentShare: Money
utilityShare: Money
rentPaid: Money
utilityPaid: Money
}
interface MutableOverdueSummary {
rent: { amountMinor: bigint; periods: string[] }
utilities: { amountMinor: bigint; periods: string[] }
}
function periodFromInstant(instant: Temporal.Instant | null | undefined): string | null {
if (!instant) {
return null
}
const zdt = instant.toZonedDateTimeISO('UTC')
return `${zdt.year}-${String(zdt.month).padStart(2, '0')}`
}
function purchaseOriginPeriod(
purchase: Awaited<ReturnType<FinanceRepository['listParsedPurchases']>>[number]
): string | null {
return purchase.cyclePeriod ?? periodFromInstant(purchase.occurredAt)
}
function buildPurchaseShareMap(input: {
purchase: Awaited<ReturnType<FinanceRepository['listParsedPurchases']>>[number]
amount: Money
activePurchaseParticipantIds: readonly string[]
}): ReadonlyMap<string, Money> {
const shares = new Map<string, Money>()
const explicitParticipants =
input.purchase.participants?.filter((participant) => participant.included !== false) ?? []
if (explicitParticipants.length > 0) {
const explicitShares = explicitParticipants.filter(
(participant) => participant.shareAmountMinor !== null
)
if (explicitShares.length > 0) {
for (const participant of explicitShares) {
shares.set(
participant.memberId,
Money.fromMinor(participant.shareAmountMinor!, input.amount.currency)
)
}
return shares
}
const splitShares = input.amount.splitEvenly(explicitParticipants.length)
for (const [index, participant] of explicitParticipants.entries()) {
shares.set(participant.memberId, splitShares[index] ?? Money.zero(input.amount.currency))
}
return shares
}
const fallbackIds = input.activePurchaseParticipantIds
const splitShares = input.amount.splitEvenly(fallbackIds.length)
for (const [index, memberId] of fallbackIds.entries()) {
shares.set(memberId, splitShares[index] ?? Money.zero(input.amount.currency))
}
return shares
}
function sumAllocationMinor(
allocations: readonly FinancePaymentPurchaseAllocationRecord[],
purchaseId: string,
memberId: string
): bigint {
return allocations
.filter(
(allocation) => allocation.purchaseId === purchaseId && allocation.memberId === memberId
)
.reduce((sum, allocation) => sum + allocation.amountMinor, 0n)
}
async function convertIntoCycleCurrency(
dependencies: FinanceCommandServiceDependencies,
input: {
@@ -289,6 +389,260 @@ async function convertIntoCycleCurrency(
}
}
async function buildCycleBaseMemberLines(input: {
dependencies: FinanceCommandServiceDependencies
cycle: FinanceCycleRecord
members: readonly HouseholdMemberRecord[]
memberAbsencePolicies: readonly HouseholdMemberAbsencePolicyRecord[]
settings: HouseholdBillingSettingsRecord
}): Promise<readonly CycleBaseMemberLine[]> {
const period = BillingPeriod.fromString(input.cycle.period)
const resolvedAbsencePolicies = resolveMemberAbsencePolicies({
members: input.members,
policies: input.memberAbsencePolicies,
period: input.cycle.period
})
const [rentRule, utilityBills, paymentRecords] = await Promise.all([
input.dependencies.repository.getRentRuleForPeriod(input.cycle.period),
input.dependencies.repository.listUtilityBillsForCycle(input.cycle.id),
input.dependencies.repository.listPaymentRecordsForCycle(input.cycle.id)
])
const rentAmountMinor = rentRule?.amountMinor ?? 0n
const rentCurrency = rentRule?.currency ?? input.cycle.currency
const convertedRent = await convertIntoCycleCurrency(input.dependencies, {
cycle: input.cycle,
period,
lockDay: input.settings.rentWarningDay,
timezone: input.settings.timezone,
amount: Money.fromMinor(rentAmountMinor, rentCurrency)
})
const convertedUtilityBills = await Promise.all(
utilityBills.map(async (bill) => {
const converted = await convertIntoCycleCurrency(input.dependencies, {
cycle: input.cycle,
period,
lockDay: input.settings.utilitiesReminderDay,
timezone: input.settings.timezone,
amount: Money.fromMinor(bill.amountMinor, bill.currency)
})
return converted.settlementAmount
})
)
const utilities = convertedUtilityBills.reduce(
(sum, amount) => sum.add(amount),
Money.zero(input.cycle.currency)
)
const settlement = calculateMonthlySettlement({
cycleId: BillingCycleId.from(input.cycle.id),
period,
rent: convertedRent.settlementAmount,
utilities,
utilitySplitMode: 'equal',
members: input.members.map((member) => ({
memberId: MemberId.from(member.id),
active: member.status !== 'left',
participatesInRent:
member.status === 'left'
? false
: (resolvedAbsencePolicies.get(member.id)?.policy ?? 'resident') !== 'inactive',
participatesInUtilities:
member.status === 'away'
? (resolvedAbsencePolicies.get(member.id)?.policy ?? 'resident') ===
'away_rent_and_utilities'
: member.status !== 'left',
participatesInPurchases: member.status === 'active',
rentWeight: member.rentShareWeight
})),
purchases: []
})
const rentPaidByMemberId = new Map<string, Money>()
const utilityPaidByMemberId = new Map<string, Money>()
for (const payment of paymentRecords) {
const targetMap = payment.kind === 'rent' ? rentPaidByMemberId : utilityPaidByMemberId
const current = targetMap.get(payment.memberId) ?? Money.zero(input.cycle.currency)
targetMap.set(
payment.memberId,
current.add(Money.fromMinor(payment.amountMinor, payment.currency))
)
}
return settlement.lines.map((line) => ({
memberId: line.memberId.toString(),
rentShare: line.rentShare,
utilityShare: line.utilityShare,
rentPaid: rentPaidByMemberId.get(line.memberId.toString()) ?? Money.zero(input.cycle.currency),
utilityPaid:
utilityPaidByMemberId.get(line.memberId.toString()) ?? Money.zero(input.cycle.currency)
}))
}
async function computeMemberOverduePayments(input: {
dependencies: FinanceCommandServiceDependencies
currentCycle: FinanceCycleRecord
members: readonly HouseholdMemberRecord[]
memberAbsencePolicies: readonly HouseholdMemberAbsencePolicyRecord[]
settings: HouseholdBillingSettingsRecord
}): Promise<ReadonlyMap<string, readonly FinanceMemberOverduePaymentRecord[]>> {
const localDate = localDateInTimezone(input.settings.timezone)
const overdueByMemberId = new Map<string, MutableOverdueSummary>()
const cycles = (await input.dependencies.repository.listCycles()).filter(
(cycle) => cycle.period.localeCompare(input.currentCycle.period) <= 0
)
for (const cycle of cycles) {
const baseLines = await buildCycleBaseMemberLines({
dependencies: input.dependencies,
cycle,
members: input.members,
memberAbsencePolicies: input.memberAbsencePolicies,
settings: input.settings
})
const rentDueDate = billingPeriodLockDate(
BillingPeriod.fromString(cycle.period),
input.settings.rentDueDay
)
const utilitiesDueDate = billingPeriodLockDate(
BillingPeriod.fromString(cycle.period),
input.settings.utilitiesDueDay
)
for (const line of baseLines) {
const current = overdueByMemberId.get(line.memberId) ?? {
rent: { amountMinor: 0n, periods: [] },
utilities: { amountMinor: 0n, periods: [] }
}
const rentRemainingMinor = line.rentShare.subtract(line.rentPaid).amountMinor
if (Temporal.PlainDate.compare(localDate, rentDueDate) > 0 && rentRemainingMinor > 0n) {
current.rent.amountMinor += rentRemainingMinor
current.rent.periods.push(cycle.period)
}
const utilityRemainingMinor = line.utilityShare.subtract(line.utilityPaid).amountMinor
if (
Temporal.PlainDate.compare(localDate, utilitiesDueDate) > 0 &&
utilityRemainingMinor > 0n
) {
current.utilities.amountMinor += utilityRemainingMinor
current.utilities.periods.push(cycle.period)
}
overdueByMemberId.set(line.memberId, current)
}
}
return new Map(
[...overdueByMemberId.entries()].map(([memberId, overdue]) => {
const items: FinanceMemberOverduePaymentRecord[] = []
if (overdue.rent.amountMinor > 0n) {
items.push({
kind: 'rent',
amountMinor: overdue.rent.amountMinor,
periods: overdue.rent.periods
})
}
if (overdue.utilities.amountMinor > 0n) {
items.push({
kind: 'utilities',
amountMinor: overdue.utilities.amountMinor,
periods: overdue.utilities.periods
})
}
return [memberId, items] as const
})
)
}
async function resolveAutomaticPaymentTargets(input: {
dependencies: FinanceCommandServiceDependencies
currentCycle: FinanceCycleRecord
members: readonly HouseholdMemberRecord[]
memberAbsencePolicies: readonly HouseholdMemberAbsencePolicyRecord[]
settings: HouseholdBillingSettingsRecord
memberId: string
kind: FinancePaymentKind
}): Promise<
readonly {
cycle: FinanceCycleRecord
baseRemainingMinor: bigint
allowOverflow: boolean
}[]
> {
const localDate = localDateInTimezone(input.settings.timezone)
const cycles = (await input.dependencies.repository.listCycles()).filter(
(cycle) => cycle.period.localeCompare(input.currentCycle.period) <= 0
)
const overdueTargets: {
cycle: FinanceCycleRecord
baseRemainingMinor: bigint
allowOverflow: boolean
}[] = []
for (const cycle of cycles) {
const baseLine = (
await buildCycleBaseMemberLines({
dependencies: input.dependencies,
cycle,
members: input.members,
memberAbsencePolicies: input.memberAbsencePolicies,
settings: input.settings
})
).find((line) => line.memberId === input.memberId)
if (!baseLine) {
continue
}
const dueDate = billingPeriodLockDate(
BillingPeriod.fromString(cycle.period),
input.kind === 'rent' ? input.settings.rentDueDay : input.settings.utilitiesDueDay
)
if (Temporal.PlainDate.compare(localDate, dueDate) <= 0) {
continue
}
const remainingMinor =
input.kind === 'rent'
? baseLine.rentShare.subtract(baseLine.rentPaid).amountMinor
: baseLine.utilityShare.subtract(baseLine.utilityPaid).amountMinor
if (remainingMinor <= 0n) {
continue
}
overdueTargets.push({
cycle,
baseRemainingMinor: remainingMinor,
allowOverflow: false
})
}
const currentCycleAlreadyIncluded = overdueTargets.some(
(target) => target.cycle.id === input.currentCycle.id
)
if (currentCycleAlreadyIncluded) {
return overdueTargets.map((target, index) => ({
...target,
allowOverflow: index === overdueTargets.length - 1
}))
}
return [
...overdueTargets,
{
cycle: input.currentCycle,
baseRemainingMinor: 0n,
allowOverflow: true
}
]
}
async function buildFinanceDashboard(
dependencies: FinanceCommandServiceDependencies,
periodArg?: string
@@ -323,15 +677,23 @@ async function buildFinanceDashboard(
policies: memberAbsencePolicies,
period: cycle.period
})
const [purchases, utilityBills] = await Promise.all([
dependencies.repository.listParsedPurchasesForRange(start, end),
dependencies.repository.listUtilityBillsForCycle(cycle.id)
const [allPurchases, utilityBills, paymentPurchaseAllocations] = await Promise.all([
dependencies.repository.listParsedPurchases(),
dependencies.repository.listUtilityBillsForCycle(cycle.id),
dependencies.repository.listPaymentPurchaseAllocations()
])
const paymentRecords = await dependencies.repository.listPaymentRecordsForCycle(cycle.id)
const previousCycle = await dependencies.repository.getCycleByPeriod(period.previous().toString())
const previousSnapshotLines = previousCycle
? await dependencies.repository.getSettlementSnapshotLines(previousCycle.id)
: []
const overduePaymentsByMemberId = await computeMemberOverduePayments({
dependencies,
currentCycle: cycle,
members,
memberAbsencePolicies,
settings
})
const previousUtilityShareByMemberId = new Map(
previousSnapshotLines.map((line) => [
line.memberId,
@@ -365,7 +727,7 @@ async function buildFinanceDashboard(
)
const convertedPurchases = await Promise.all(
purchases.map(async (purchase) => {
allPurchases.map(async (purchase) => {
const converted = await convertIntoCycleCurrency(dependencies, {
cycle,
period,
@@ -381,6 +743,82 @@ async function buildFinanceDashboard(
})
)
const currentCyclePurchaseIds = new Set(
allPurchases
.filter((purchase) => {
if (purchase.cycleId === cycle.id || purchase.cyclePeriod === cycle.period) {
return true
}
if (purchase.cycleId) {
return false
}
if (!purchase.occurredAt) {
return false
}
return (
Temporal.Instant.compare(purchase.occurredAt, start) >= 0 &&
Temporal.Instant.compare(purchase.occurredAt, end) < 0
)
})
.map((purchase) => purchase.id)
)
const activePurchaseParticipantIds = members
.filter((member) => member.status === 'active')
.map((member) => member.id)
const purchaseHistory: PurchaseHistoryState[] = convertedPurchases.map(
({ purchase, converted }) => {
const shareMap = buildPurchaseShareMap({
purchase,
amount: converted.settlementAmount,
activePurchaseParticipantIds
})
const outstandingEntries = [...shareMap.entries()]
.filter(([memberId]) => memberId !== purchase.payerMemberId)
.map(([memberId, shareAmount]) => {
const allocatedMinor = sumAllocationMinor(
paymentPurchaseAllocations,
purchase.id,
memberId
)
const outstandingMinor =
shareAmount.amountMinor > allocatedMinor ? shareAmount.amountMinor - allocatedMinor : 0n
return [
memberId,
Money.fromMinor(outstandingMinor, converted.settlementAmount.currency)
] as const
})
.filter(([, amount]) => amount.amountMinor > 0n)
const outstandingByMemberId = new Map<string, Money>(outstandingEntries)
const outstandingTotal = outstandingEntries.reduce(
(sum, [, amount]) => sum.add(amount),
Money.zero(converted.settlementAmount.currency)
)
const resolvedAt =
outstandingEntries.length === 0
? (paymentPurchaseAllocations
.filter((allocation) => allocation.purchaseId === purchase.id)
.map((allocation) => allocation.recordedAt.toString())
.sort()
.at(-1) ?? null)
: null
return {
purchase,
converted,
outstandingByMemberId,
outstandingTotal,
resolvedAt
}
}
)
const utilities = convertedUtilityBills.reduce(
(sum, current) => sum.add(current.converted.settlementAmount),
Money.zero(cycle.currency)
@@ -407,41 +845,49 @@ async function buildFinanceDashboard(
participatesInPurchases: member.status === 'active',
rentWeight: member.rentShareWeight
})),
purchases: convertedPurchases.map(({ purchase, converted }) => {
const nextPurchase: {
purchaseId: PurchaseEntryId
payerId: MemberId
amount: Money
splitMode: 'equal' | 'custom_amounts'
participants?: {
memberId: MemberId
shareAmount?: Money
}[]
} = {
purchaseId: PurchaseEntryId.from(purchase.id),
payerId: MemberId.from(purchase.payerMemberId),
amount: converted.settlementAmount,
splitMode: purchase.splitMode ?? 'equal'
}
purchases: purchaseHistory
.filter(
({ purchase, outstandingTotal }) =>
currentCyclePurchaseIds.has(purchase.id) || outstandingTotal.amountMinor > 0n
)
.map(({ purchase, converted, outstandingByMemberId, outstandingTotal }) => {
const nextPurchase: {
purchaseId: PurchaseEntryId
payerId: MemberId
amount: Money
splitMode: 'equal' | 'custom_amounts'
participants?: {
memberId: MemberId
shareAmount?: Money
}[]
} = {
purchaseId: PurchaseEntryId.from(purchase.id),
payerId: MemberId.from(purchase.payerMemberId),
amount: currentCyclePurchaseIds.has(purchase.id)
? converted.settlementAmount
: outstandingTotal,
splitMode: 'custom_amounts'
}
if (purchase.participants) {
nextPurchase.participants = purchase.participants
.filter((participant) => participant.included !== false)
.map((participant) => ({
memberId: MemberId.from(participant.memberId),
...(participant.shareAmountMinor !== null
? {
shareAmount: Money.fromMinor(
participant.shareAmountMinor,
converted.settlementAmount.currency
)
}
: {})
const participantShareMap = currentCyclePurchaseIds.has(purchase.id)
? buildPurchaseShareMap({
purchase,
amount: converted.settlementAmount,
activePurchaseParticipantIds
})
: outstandingByMemberId
nextPurchase.participants = [...participantShareMap.entries()]
.filter(([memberId]) =>
currentCyclePurchaseIds.has(purchase.id) ? true : memberId !== purchase.payerMemberId
)
.map(([memberId, shareAmount]) => ({
memberId: MemberId.from(memberId),
shareAmount
}))
}
return nextPurchase
})
return nextPurchase
})
})
await dependencies.repository.replaceSettlementSnapshot({
@@ -502,6 +948,12 @@ async function buildFinanceDashboard(
remaining: line.netDue.subtract(
paymentsByMemberId.get(line.memberId.toString()) ?? Money.zero(cycle.currency)
),
overduePayments:
overduePaymentsByMemberId.get(line.memberId.toString())?.map((overdue) => ({
kind: overdue.kind,
amountMinor: overdue.amountMinor,
periods: overdue.periods
})) ?? [],
explanations: line.explanations
}))
@@ -523,7 +975,7 @@ async function buildFinanceDashboard(
occurredAt: bill.createdAt.toString(),
paymentKind: null
})),
...convertedPurchases.map(({ purchase, converted }) => {
...purchaseHistory.map(({ purchase, converted, outstandingByMemberId, resolvedAt }) => {
const entry: FinanceDashboardLedgerEntry = {
id: purchase.id,
kind: 'purchase',
@@ -539,7 +991,14 @@ async function buildFinanceDashboard(
actorDisplayName: memberNameById.get(purchase.payerMemberId) ?? null,
occurredAt: purchase.occurredAt?.toString() ?? null,
paymentKind: null,
purchaseSplitMode: purchase.splitMode ?? 'equal'
purchaseSplitMode: purchase.splitMode ?? 'equal',
originPeriod: purchaseOriginPeriod(purchase),
resolutionStatus: outstandingByMemberId.size === 0 ? 'resolved' : 'unresolved',
resolvedAt,
outstandingByMember: [...outstandingByMemberId.entries()].map(([memberId, amount]) => ({
memberId,
amount
}))
}
if (purchase.participants) {
@@ -606,6 +1065,97 @@ async function buildFinanceDashboard(
}
}
async function allocatePaymentPurchaseOverage(input: {
dependencies: FinanceCommandServiceDependencies
cyclePeriod: string
memberId: string
kind: FinancePaymentKind
paymentAmount: Money
settings: HouseholdBillingSettingsRecord
}): Promise<
readonly {
purchaseId: string
memberId: string
amountMinor: bigint
}[]
> {
const policy = input.settings.paymentBalanceAdjustmentPolicy ?? 'utilities'
if (policy === 'separate' || policy !== input.kind) {
return []
}
const dashboard = await buildFinanceDashboard(input.dependencies, input.cyclePeriod)
if (!dashboard) {
return []
}
const memberLine = dashboard.members.find((member) => member.memberId === input.memberId)
if (!memberLine) {
return []
}
const baseAmount = input.kind === 'rent' ? memberLine.rentShare : memberLine.utilityShare
let remainingMinor = input.paymentAmount.amountMinor - baseAmount.amountMinor
if (remainingMinor <= 0n) {
return []
}
const purchaseEntries = dashboard.ledger
.filter(
(
entry
): entry is FinanceDashboardLedgerEntry & {
kind: 'purchase'
outstandingByMember: readonly { memberId: string; amount: Money }[]
} =>
entry.kind === 'purchase' &&
entry.resolutionStatus === 'unresolved' &&
Array.isArray(entry.outstandingByMember)
)
.sort((left, right) => {
const leftKey = `${left.originPeriod ?? ''}:${left.occurredAt ?? ''}:${left.id}`
const rightKey = `${right.originPeriod ?? ''}:${right.occurredAt ?? ''}:${right.id}`
return leftKey.localeCompare(rightKey)
})
const allocations: {
purchaseId: string
memberId: string
amountMinor: bigint
}[] = []
for (const entry of purchaseEntries) {
const memberOutstanding = entry.outstandingByMember.find(
(outstanding) => outstanding.memberId === input.memberId
)
if (!memberOutstanding || memberOutstanding.amount.amountMinor <= 0n) {
continue
}
const allocatedMinor =
remainingMinor >= memberOutstanding.amount.amountMinor
? memberOutstanding.amount.amountMinor
: remainingMinor
if (allocatedMinor <= 0n) {
continue
}
allocations.push({
purchaseId: entry.id,
memberId: input.memberId,
amountMinor: allocatedMinor
})
remainingMinor -= allocatedMinor
if (remainingMinor === 0n) {
break
}
}
return allocations
}
export interface FinanceCommandService {
getMemberByTelegramUserId(telegramUserId: string): Promise<FinanceMemberRecord | null>
getOpenCycle(): Promise<FinanceCycleRecord | null>
@@ -685,7 +1235,8 @@ export interface FinanceCommandService {
memberId: string,
kind: FinancePaymentKind,
amountArg: string,
currencyArg?: string
currencyArg?: string,
periodArg?: string
): Promise<{
paymentId: string
amount: Money
@@ -1019,28 +1570,100 @@ export function createFinanceCommandService(
return repository.deleteParsedPurchase(purchaseId)
},
async addPayment(memberId, kind, amountArg, currencyArg) {
const [openCycle, settings] = await Promise.all([
ensureExpectedCycle(),
householdConfigurationRepository.getHouseholdBillingSettings(dependencies.householdId)
async addPayment(memberId, kind, amountArg, currencyArg, periodArg) {
const [settings, members, memberAbsencePolicies] = await Promise.all([
householdConfigurationRepository.getHouseholdBillingSettings(dependencies.householdId),
householdConfigurationRepository.listHouseholdMembers(dependencies.householdId),
householdConfigurationRepository.listHouseholdMemberAbsencePolicies(
dependencies.householdId
)
])
const currentCycle = periodArg
? await repository.getCycleByPeriod(BillingPeriod.fromString(periodArg).toString())
: await ensureExpectedCycle()
if (!currentCycle) {
return null
}
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()
})
const paymentTargets = periodArg
? [
{
cycle: currentCycle,
baseRemainingMinor: 0n,
allowOverflow: true
}
]
: await resolveAutomaticPaymentTargets({
dependencies,
currentCycle,
members,
memberAbsencePolicies,
settings,
memberId,
kind
})
let remainingMinor = amount.amountMinor
let firstPayment: Awaited<ReturnType<FinanceRepository['addPaymentRecord']>> | null = null
for (const target of paymentTargets) {
if (remainingMinor <= 0n) {
break
}
const amountMinor =
target.allowOverflow || target.baseRemainingMinor <= 0n
? remainingMinor
: remainingMinor > target.baseRemainingMinor
? target.baseRemainingMinor
: remainingMinor
if (amountMinor <= 0n) {
continue
}
const payment = await repository.addPaymentRecord({
cycleId: target.cycle.id,
memberId,
kind,
amountMinor,
currency,
recordedAt: nowInstant()
})
if (!firstPayment) {
firstPayment = payment
}
const allocations = target.allowOverflow
? await allocatePaymentPurchaseOverage({
dependencies,
cyclePeriod: target.cycle.period,
memberId,
kind,
paymentAmount: Money.fromMinor(amountMinor, currency),
settings
})
: []
await repository.replacePaymentPurchaseAllocations({
paymentRecordId: payment.id,
allocations
})
remainingMinor -= amountMinor
}
if (!firstPayment) {
return null
}
return {
paymentId: payment.id,
paymentId: firstPayment.id,
amount,
currency,
period: openCycle.period
period: firstPayment.cyclePeriod ?? currentCycle.period
}
},
@@ -1050,6 +1673,10 @@ export function createFinanceCommandService(
)
const currency = parseCurrency(currencyArg, settings.settlementCurrency)
const amount = Money.fromMajor(amountArg, currency)
const existingPayment = await repository.getPaymentRecord(paymentId)
if (!existingPayment) {
return null
}
const payment = await repository.updatePaymentRecord({
paymentId,
memberId,
@@ -1062,6 +1689,25 @@ export function createFinanceCommandService(
return null
}
await repository.replacePaymentPurchaseAllocations({
paymentRecordId: paymentId,
allocations: []
})
const allocations = await allocatePaymentPurchaseOverage({
dependencies,
cyclePeriod:
existingPayment.cyclePeriod ?? expectedOpenCyclePeriod(settings, nowInstant()).toString(),
memberId,
kind,
paymentAmount: amount,
settings
})
await repository.replacePaymentPurchaseAllocations({
paymentRecordId: paymentId,
allocations
})
return {
paymentId: payment.id,
amount,

View File

@@ -183,6 +183,7 @@ function createRepositoryStub() {
isActive: input.isActive
}),
promoteHouseholdAdmin: async () => null,
demoteHouseholdAdmin: async () => null,
updateHouseholdMemberRentShareWeight: async () => null,
updateHouseholdMemberStatus: async () => null,
listHouseholdMemberAbsencePolicies: async () => [],

View File

@@ -207,6 +207,9 @@ function createRepositoryStub() {
async promoteHouseholdAdmin() {
return null
},
async demoteHouseholdAdmin() {
return null
},
async updateHouseholdMemberRentShareWeight() {
return null
},

View File

@@ -316,6 +316,21 @@ function createRepositoryStub() {
members.set(`${householdId}:${member.telegramUserId}`, next)
return next
},
async demoteHouseholdAdmin(householdId, memberId) {
const member = [...members.values()].find(
(entry) => entry.householdId === householdId && entry.id === memberId
)
if (!member) {
return null
}
const next = {
...member,
isAdmin: false
}
members.set(`${householdId}:${member.telegramUserId}`, next)
return next
},
async updateHouseholdMemberRentShareWeight(householdId, memberId, rentShareWeight) {
const member = [...members.values()].find(

View File

@@ -117,6 +117,7 @@ function createRepository(): HouseholdConfigurationRepository {
isActive: input.isActive
}),
promoteHouseholdAdmin: async () => null,
demoteHouseholdAdmin: async () => null,
updateHouseholdMemberRentShareWeight: async () => null,
updateHouseholdMemberStatus: async () => null,
listHouseholdMemberAbsencePolicies: async () => [],

View File

@@ -215,6 +215,20 @@ function repository(): HouseholdConfigurationRepository {
isAdmin: true
}
: null,
demoteHouseholdAdmin: async (householdId, memberId) =>
memberId === 'member-123456'
? {
id: memberId,
householdId,
telegramUserId: '123456',
displayName: 'Stan',
status: 'active',
preferredLocale: null,
householdDefaultLocale: 'ru',
rentShareWeight: 1,
isAdmin: false
}
: null,
updateHouseholdMemberRentShareWeight: async (_householdId, memberId, rentShareWeight) =>
memberId === 'member-123456'
? {
@@ -512,6 +526,87 @@ describe('createMiniAppAdminService', () => {
})
})
test('demotes a household admin when another admin still exists', async () => {
const service = createMiniAppAdminService({
...repository(),
listHouseholdMembers: async () => [
{
id: 'member-123456',
householdId: 'household-1',
telegramUserId: '123456',
displayName: 'Stan',
status: 'active',
preferredLocale: null,
householdDefaultLocale: 'ru',
rentShareWeight: 1,
isAdmin: true
},
{
id: 'member-999999',
householdId: 'household-1',
telegramUserId: '999999',
displayName: 'Mia',
status: 'active',
preferredLocale: null,
householdDefaultLocale: 'ru',
rentShareWeight: 1,
isAdmin: true
}
]
})
const result = await service.demoteMemberFromAdmin({
householdId: 'household-1',
actorIsAdmin: true,
memberId: 'member-123456'
})
expect(result).toEqual({
status: 'ok',
member: {
id: 'member-123456',
householdId: 'household-1',
telegramUserId: '123456',
displayName: 'Stan',
status: 'active',
preferredLocale: null,
householdDefaultLocale: 'ru',
rentShareWeight: 1,
isAdmin: false
}
})
})
test('rejects demoting the last household admin', async () => {
const service = createMiniAppAdminService({
...repository(),
listHouseholdMembers: async () => [
{
id: 'member-123456',
householdId: 'household-1',
telegramUserId: '123456',
displayName: 'Stan',
status: 'active',
preferredLocale: null,
householdDefaultLocale: 'ru',
rentShareWeight: 1,
isAdmin: true
}
]
})
const result = await service.demoteMemberFromAdmin({
householdId: 'household-1',
actorIsAdmin: true,
memberId: 'member-123456'
})
expect(result).toEqual({
status: 'rejected',
reason: 'last_admin'
})
})
test('updates the acting member display name', async () => {
const service = createMiniAppAdminService(repository())

View File

@@ -173,6 +173,20 @@ export interface MiniAppAdminService {
reason: 'not_admin' | 'member_not_found'
}
>
demoteMemberFromAdmin(input: {
householdId: string
actorIsAdmin: boolean
memberId: string
}): Promise<
| {
status: 'ok'
member: HouseholdMemberRecord
}
| {
status: 'rejected'
reason: 'not_admin' | 'member_not_found' | 'last_admin'
}
>
updateMemberRentShareWeight(input: {
householdId: string
actorIsAdmin: boolean
@@ -649,6 +663,47 @@ export function createMiniAppAdminService(
}
},
async demoteMemberFromAdmin(input) {
if (!input.actorIsAdmin) {
return {
status: 'rejected',
reason: 'not_admin'
}
}
const members = await repository.listHouseholdMembers(input.householdId)
const targetMember = members.find((member) => member.id === input.memberId)
if (!targetMember) {
return {
status: 'rejected',
reason: 'member_not_found'
}
}
const adminCount = members.filter((member) => member.isAdmin).length
if (targetMember.isAdmin && adminCount <= 1) {
return {
status: 'rejected',
reason: 'last_admin'
}
}
const member = targetMember.isAdmin
? await repository.demoteHouseholdAdmin(input.householdId, input.memberId)
: targetMember
if (!member) {
return {
status: 'rejected',
reason: 'member_not_found'
}
}
return {
status: 'ok',
member
}
},
async updateMemberRentShareWeight(input) {
if (!input.actorIsAdmin) {
return {

View File

@@ -86,6 +86,8 @@ function createRepositoryStub(): Pick<
status: 'recorded',
paymentRecord: {
id: 'payment-1',
cycleId: input.cycleId,
cyclePeriod: null,
memberId: input.memberId,
kind: input.kind,
amountMinor: input.amountMinor,
@@ -137,6 +139,7 @@ describe('createPaymentConfirmationService', () => {
netDue: Money.fromMajor('500.50', 'GEL'),
paid: Money.zero('GEL'),
remaining: Money.fromMajor('500.50', 'GEL'),
overduePayments: [],
explanations: []
}
],
@@ -206,6 +209,7 @@ describe('createPaymentConfirmationService', () => {
netDue: Money.fromMajor('500.50', 'GEL'),
paid: Money.zero('GEL'),
remaining: Money.fromMajor('500.50', 'GEL'),
overduePayments: [],
explanations: []
}
],

View File

@@ -23,6 +23,7 @@
"0019_faithful_madame_masque.sql": "38711341799b04a7c47fcc64fd19faf5b26e6f183d6a4c01d492b9929cd63641",
"0020_natural_mauler.sql": "a80a4a0196a3b4931040850089346d1bc99b34a5afca77d6d62478ee4b8902c1",
"0020_silver_payments.sql": "9686235c75453f1eaa016f2f4ab7fce8fe964c76a4e3515987a2b9f90bd7b1ad",
"0021_sharp_payer.sql": "973596e154382984ba7769979ea58298b6d93c5139540854be01e8b283ddb4f1"
"0021_sharp_payer.sql": "973596e154382984ba7769979ea58298b6d93c5139540854be01e8b283ddb4f1",
"0022_carry_purchase_history.sql": "f031c9736e43e71eec3263a323332c29de9324c6409db034b0760051c8a9f074"
}
}

View File

@@ -0,0 +1,34 @@
ALTER TABLE "purchase_messages"
ADD COLUMN "cycle_id" uuid REFERENCES "billing_cycles"("id") ON DELETE SET NULL;
--> statement-breakpoint
CREATE INDEX "purchase_messages_cycle_idx" ON "purchase_messages" USING btree ("cycle_id");
--> statement-breakpoint
CREATE TABLE "payment_purchase_allocations" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"payment_record_id" uuid NOT NULL,
"purchase_id" uuid NOT NULL,
"member_id" uuid NOT NULL,
"amount_minor" bigint NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "payment_purchase_allocations"
ADD CONSTRAINT "payment_purchase_allocations_payment_record_id_payment_records_id_fk"
FOREIGN KEY ("payment_record_id") REFERENCES "public"."payment_records"("id")
ON DELETE cascade ON UPDATE no action;
--> statement-breakpoint
ALTER TABLE "payment_purchase_allocations"
ADD CONSTRAINT "payment_purchase_allocations_purchase_id_purchase_messages_id_fk"
FOREIGN KEY ("purchase_id") REFERENCES "public"."purchase_messages"("id")
ON DELETE cascade ON UPDATE no action;
--> statement-breakpoint
ALTER TABLE "payment_purchase_allocations"
ADD CONSTRAINT "payment_purchase_allocations_member_id_members_id_fk"
FOREIGN KEY ("member_id") REFERENCES "public"."members"("id")
ON DELETE cascade ON UPDATE no action;
--> statement-breakpoint
CREATE INDEX "payment_purchase_allocations_payment_idx"
ON "payment_purchase_allocations" USING btree ("payment_record_id");
--> statement-breakpoint
CREATE INDEX "payment_purchase_allocations_purchase_member_idx"
ON "payment_purchase_allocations" USING btree ("purchase_id","member_id");

View File

@@ -155,6 +155,13 @@
"when": 1774200000000,
"tag": "0021_sharp_payer",
"breakpoints": true
},
{
"idx": 22,
"version": "7",
"when": 1774205000000,
"tag": "0022_carry_purchase_history",
"breakpoints": true
}
]
}

View File

@@ -414,6 +414,9 @@ export const purchaseMessages = pgTable(
householdId: uuid('household_id')
.notNull()
.references(() => households.id, { onDelete: 'cascade' }),
cycleId: uuid('cycle_id').references(() => billingCycles.id, {
onDelete: 'set null'
}),
senderMemberId: uuid('sender_member_id').references(() => members.id, {
onDelete: 'set null'
}),
@@ -444,6 +447,7 @@ export const purchaseMessages = pgTable(
table.householdId,
table.telegramThreadId
),
cycleIdx: index('purchase_messages_cycle_idx').on(table.cycleId),
senderIdx: index('purchase_messages_sender_idx').on(table.senderTelegramUserId),
tgMessageUnique: uniqueIndex('purchase_messages_household_tg_message_unique').on(
table.householdId,
@@ -662,6 +666,31 @@ export const paymentRecords = pgTable(
})
)
export const paymentPurchaseAllocations = pgTable(
'payment_purchase_allocations',
{
id: uuid('id').defaultRandom().primaryKey(),
paymentRecordId: uuid('payment_record_id')
.notNull()
.references(() => paymentRecords.id, { onDelete: 'cascade' }),
purchaseId: uuid('purchase_id')
.notNull()
.references(() => purchaseMessages.id, { onDelete: 'cascade' }),
memberId: uuid('member_id')
.notNull()
.references(() => members.id, { onDelete: 'cascade' }),
amountMinor: bigint('amount_minor', { mode: 'bigint' }).notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull()
},
(table) => ({
paymentIdx: index('payment_purchase_allocations_payment_idx').on(table.paymentRecordId),
purchaseMemberIdx: index('payment_purchase_allocations_purchase_member_idx').on(
table.purchaseId,
table.memberId
)
})
)
export const settlements = pgTable(
'settlements',
{
@@ -732,4 +761,5 @@ export type TopicMessage = typeof topicMessages.$inferSelect
export type AnonymousMessage = typeof anonymousMessages.$inferSelect
export type PaymentConfirmation = typeof paymentConfirmations.$inferSelect
export type PaymentRecord = typeof paymentRecords.$inferSelect
export type PaymentPurchaseAllocation = typeof paymentPurchaseAllocations.$inferSelect
export type Settlement = typeof settlements.$inferSelect

View File

@@ -14,6 +14,12 @@ export interface FinanceCycleRecord {
currency: CurrencyCode
}
export interface FinanceMemberOverduePaymentRecord {
kind: FinancePaymentKind
amountMinor: bigint
periods: readonly string[]
}
export interface FinanceCycleExchangeRateRecord {
cycleId: string
sourceCurrency: CurrencyCode
@@ -30,6 +36,8 @@ export interface FinanceRentRuleRecord {
export interface FinanceParsedPurchaseRecord {
id: string
cycleId: string | null
cyclePeriod?: string | null
payerMemberId: string
amountMinor: bigint
currency: CurrencyCode
@@ -44,6 +52,15 @@ export interface FinanceParsedPurchaseRecord {
}[]
}
export interface FinancePaymentPurchaseAllocationRecord {
id: string
paymentRecordId: string
purchaseId: string
memberId: string
amountMinor: bigint
recordedAt: Instant
}
export interface FinanceUtilityBillRecord {
id: string
billName: string
@@ -57,6 +74,8 @@ export type FinancePaymentKind = 'rent' | 'utilities'
export interface FinancePaymentRecord {
id: string
cycleId: string
cyclePeriod?: string | null
memberId: string
kind: FinancePaymentKind
amountMinor: bigint
@@ -151,6 +170,7 @@ export interface SettlementSnapshotRecord {
export interface FinanceRepository {
getMemberByTelegramUserId(telegramUserId: string): Promise<FinanceMemberRecord | null>
listMembers(): Promise<readonly FinanceMemberRecord[]>
listCycles(): Promise<readonly FinanceCycleRecord[]>
getOpenCycle(): Promise<FinanceCycleRecord | null>
getCycleByPeriod(period: string): Promise<FinanceCycleRecord | null>
getLatestCycle(): Promise<FinanceCycleRecord | null>
@@ -215,6 +235,15 @@ export interface FinanceRepository {
currency: CurrencyCode
recordedAt: Instant
}): Promise<FinancePaymentRecord>
getPaymentRecord(paymentId: string): Promise<FinancePaymentRecord | null>
replacePaymentPurchaseAllocations(input: {
paymentRecordId: string
allocations: readonly {
purchaseId: string
memberId: string
amountMinor: bigint
}[]
}): Promise<void>
updatePaymentRecord(input: {
paymentId: string
memberId: string
@@ -231,6 +260,8 @@ export interface FinanceRepository {
start: Instant,
end: Instant
): Promise<readonly FinanceParsedPurchaseRecord[]>
listParsedPurchases(): Promise<readonly FinanceParsedPurchaseRecord[]>
listPaymentPurchaseAllocations(): Promise<readonly FinancePaymentPurchaseAllocationRecord[]>
getSettlementSnapshotLines(
cycleId: string
): Promise<readonly FinanceSettlementSnapshotLineRecord[]>

View File

@@ -252,6 +252,7 @@ export interface HouseholdConfigurationRepository {
householdId: string,
memberId: string
): Promise<HouseholdMemberRecord | null>
demoteHouseholdAdmin(householdId: string, memberId: string): Promise<HouseholdMemberRecord | null>
updateHouseholdMemberRentShareWeight(
householdId: string,
memberId: string,

View File

@@ -45,11 +45,13 @@ export type {
} from './anonymous-feedback'
export type {
FinanceCycleRecord,
FinanceMemberOverduePaymentRecord,
FinanceCycleExchangeRateRecord,
FinancePaymentConfirmationReviewReason,
FinancePaymentConfirmationSaveInput,
FinancePaymentConfirmationSaveResult,
FinancePaymentKind,
FinancePaymentPurchaseAllocationRecord,
FinancePaymentRecord,
FinanceSettlementSnapshotLineRecord,
FinanceMemberRecord,