mirror of
https://github.com/whekin/household-bot.git
synced 2026-04-01 07:34:03 +00:00
Mini App Home Screen: - Add 'Record Payment' button to utilities and rent period cards - Pre-fill payment amount with member's share (rentShare/utilityShare) - Modal dialog with amount input and currency display - Toast notifications for copy and payment success/failure feedback Copy Button Improvements: - Increase spacing between icon and text (4px → 8px) - Add hover background and padding for better touch target - Green background highlight when copied (in addition to icon color change) - Toast notification appears when copying any value Backend: - Add /api/miniapp/payments/add endpoint for quick payments - Payment notifications sent to 'reminders' topic in Telegram - Include member name, payment type, amount, and period in notification Files: - New: apps/miniapp/src/components/ui/toast.tsx - Modified: apps/miniapp/src/routes/home.tsx, apps/miniapp/src/index.css, apps/miniapp/src/theme.css, apps/miniapp/src/i18n.ts, apps/bot/src/miniapp-billing.ts, apps/bot/src/server.ts Quality Gates: ✅ format, lint, typecheck, build, test Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
846 lines
23 KiB
TypeScript
846 lines
23 KiB
TypeScript
import { describe, expect, test } from 'bun:test'
|
|
|
|
import { instantFromIso, Money, type Instant } from '@household/domain'
|
|
import type {
|
|
ExchangeRateProvider,
|
|
FinanceCycleExchangeRateRecord,
|
|
FinanceCycleRecord,
|
|
FinanceMemberRecord,
|
|
FinanceParsedPurchaseRecord,
|
|
FinanceRentRuleRecord,
|
|
FinanceRepository,
|
|
HouseholdConfigurationRepository,
|
|
SettlementSnapshotRecord
|
|
} from '@household/ports'
|
|
|
|
import { createFinanceCommandService } from './finance-command-service'
|
|
|
|
class FinanceRepositoryStub implements FinanceRepository {
|
|
householdId = 'household-1'
|
|
member: FinanceMemberRecord | null = null
|
|
members: readonly FinanceMemberRecord[] = []
|
|
memberStatuses = new Map<string, 'active' | 'away' | 'left'>()
|
|
memberAbsencePolicies: readonly {
|
|
memberId: string
|
|
effectiveFromPeriod: string
|
|
policy: 'resident' | 'away_rent_and_utilities' | 'away_rent_only' | 'inactive'
|
|
}[] = []
|
|
openCycleRecord: FinanceCycleRecord | null = null
|
|
cycleByPeriodRecord: FinanceCycleRecord | null = null
|
|
latestCycleRecord: FinanceCycleRecord | null = null
|
|
rentRule: FinanceRentRuleRecord | null = null
|
|
purchases: readonly FinanceParsedPurchaseRecord[] = []
|
|
utilityBills: readonly {
|
|
id: string
|
|
billName: string
|
|
amountMinor: bigint
|
|
currency: 'USD' | 'GEL'
|
|
createdByMemberId: string | null
|
|
createdAt: Instant
|
|
}[] = []
|
|
paymentRecords: readonly {
|
|
id: string
|
|
memberId: string
|
|
kind: 'rent' | 'utilities'
|
|
amountMinor: bigint
|
|
currency: 'USD' | 'GEL'
|
|
recordedAt: Instant
|
|
}[] = []
|
|
lastSavedRentRule: {
|
|
period: string
|
|
amountMinor: bigint
|
|
currency: 'USD' | 'GEL'
|
|
} | null = null
|
|
lastUtilityBill: {
|
|
cycleId: string
|
|
billName: string
|
|
amountMinor: bigint
|
|
currency: 'USD' | 'GEL'
|
|
createdByMemberId: string
|
|
} | null = null
|
|
replacedSnapshot: SettlementSnapshotRecord | null = null
|
|
cycleExchangeRates = new Map<string, FinanceCycleExchangeRateRecord>()
|
|
lastUpdatedPurchaseInput: Parameters<FinanceRepository['updateParsedPurchase']>[0] | null = null
|
|
lastAddedPurchaseInput: Parameters<FinanceRepository['addParsedPurchase']>[0] | null = null
|
|
|
|
async getMemberByTelegramUserId(): Promise<FinanceMemberRecord | null> {
|
|
return this.member
|
|
}
|
|
|
|
async listMembers(): Promise<readonly FinanceMemberRecord[]> {
|
|
return this.members
|
|
}
|
|
|
|
async getOpenCycle(): Promise<FinanceCycleRecord | null> {
|
|
return this.openCycleRecord
|
|
}
|
|
|
|
async getCycleByPeriod(): Promise<FinanceCycleRecord | null> {
|
|
return this.cycleByPeriodRecord ?? this.openCycleRecord ?? this.latestCycleRecord
|
|
}
|
|
|
|
async getLatestCycle(): Promise<FinanceCycleRecord | null> {
|
|
return this.latestCycleRecord
|
|
}
|
|
|
|
async openCycle(period: string, currency: 'USD' | 'GEL'): Promise<void> {
|
|
const cycle = {
|
|
id: 'opened-cycle',
|
|
period,
|
|
currency
|
|
}
|
|
this.openCycleRecord = cycle
|
|
this.cycleByPeriodRecord = cycle
|
|
this.latestCycleRecord = cycle
|
|
}
|
|
|
|
async closeCycle(): Promise<void> {}
|
|
|
|
async saveRentRule(period: string, amountMinor: bigint, currency: 'USD' | 'GEL'): Promise<void> {
|
|
this.lastSavedRentRule = {
|
|
period,
|
|
amountMinor,
|
|
currency
|
|
}
|
|
}
|
|
|
|
async getCycleExchangeRate(
|
|
cycleId: string,
|
|
sourceCurrency: 'USD' | 'GEL',
|
|
targetCurrency: 'USD' | 'GEL'
|
|
): Promise<FinanceCycleExchangeRateRecord | null> {
|
|
return this.cycleExchangeRates.get(`${cycleId}:${sourceCurrency}:${targetCurrency}`) ?? null
|
|
}
|
|
|
|
async saveCycleExchangeRate(
|
|
input: FinanceCycleExchangeRateRecord
|
|
): Promise<FinanceCycleExchangeRateRecord> {
|
|
this.cycleExchangeRates.set(
|
|
`${input.cycleId}:${input.sourceCurrency}:${input.targetCurrency}`,
|
|
input
|
|
)
|
|
return input
|
|
}
|
|
|
|
async addUtilityBill(input: {
|
|
cycleId: string
|
|
billName: string
|
|
amountMinor: bigint
|
|
currency: 'USD' | 'GEL'
|
|
createdByMemberId: string
|
|
}): Promise<void> {
|
|
this.lastUtilityBill = input
|
|
}
|
|
|
|
async addParsedPurchase(input: Parameters<FinanceRepository['addParsedPurchase']>[0]) {
|
|
this.lastAddedPurchaseInput = input
|
|
return {
|
|
id: 'purchase-1',
|
|
payerMemberId: input.payerMemberId,
|
|
amountMinor: input.amountMinor,
|
|
currency: input.currency,
|
|
description: input.description,
|
|
occurredAt: input.occurredAt,
|
|
splitMode: input.splitMode ?? 'equal',
|
|
participants: (input.participants ?? []).map((p) => ({
|
|
memberId: p.memberId,
|
|
included: p.included ?? true,
|
|
shareAmountMinor: p.shareAmountMinor
|
|
}))
|
|
}
|
|
}
|
|
|
|
async updateUtilityBill() {
|
|
return null
|
|
}
|
|
|
|
async deleteUtilityBill() {
|
|
return false
|
|
}
|
|
|
|
async updateParsedPurchase(input: Parameters<FinanceRepository['updateParsedPurchase']>[0]) {
|
|
this.lastUpdatedPurchaseInput = input
|
|
return {
|
|
id: input.purchaseId,
|
|
payerMemberId: 'alice',
|
|
amountMinor: input.amountMinor,
|
|
currency: input.currency,
|
|
description: input.description,
|
|
occurredAt: instantFromIso('2026-03-12T11:00:00.000Z'),
|
|
splitMode: input.splitMode ?? 'equal',
|
|
...(input.participants
|
|
? {
|
|
participants: input.participants.map((participant, index) => ({
|
|
id: `participant-${index + 1}`,
|
|
memberId: participant.memberId,
|
|
included: participant.included !== false,
|
|
shareAmountMinor: participant.shareAmountMinor
|
|
}))
|
|
}
|
|
: {})
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
async getUtilityTotalForCycle(): Promise<bigint> {
|
|
return this.utilityBills.reduce((sum, bill) => sum + bill.amountMinor, 0n)
|
|
}
|
|
|
|
async listUtilityBillsForCycle() {
|
|
return this.utilityBills
|
|
}
|
|
|
|
async listPaymentRecordsForCycle() {
|
|
return this.paymentRecords
|
|
}
|
|
|
|
async listParsedPurchasesForRange(): Promise<readonly FinanceParsedPurchaseRecord[]> {
|
|
return this.purchases
|
|
}
|
|
|
|
async getSettlementSnapshotLines() {
|
|
return []
|
|
}
|
|
|
|
async savePaymentConfirmation() {
|
|
return {
|
|
status: 'needs_review' as const,
|
|
reviewReason: 'settlement_not_ready' as const
|
|
}
|
|
}
|
|
|
|
async replaceSettlementSnapshot(snapshot: SettlementSnapshotRecord): Promise<void> {
|
|
this.replacedSnapshot = snapshot
|
|
}
|
|
}
|
|
|
|
const householdConfigurationRepository: Pick<
|
|
HouseholdConfigurationRepository,
|
|
'getHouseholdBillingSettings' | 'listHouseholdMembers' | 'listHouseholdMemberAbsencePolicies'
|
|
> = {
|
|
async getHouseholdBillingSettings(householdId) {
|
|
return {
|
|
householdId,
|
|
settlementCurrency: 'GEL',
|
|
rentAmountMinor: 70000n,
|
|
rentCurrency: 'USD',
|
|
rentDueDay: 20,
|
|
rentWarningDay: 17,
|
|
utilitiesDueDay: 4,
|
|
utilitiesReminderDay: 3,
|
|
timezone: 'Asia/Tbilisi',
|
|
rentPaymentDestinations: null
|
|
}
|
|
},
|
|
async listHouseholdMembers(householdId) {
|
|
const repository = financeRepositoryForHousehold(householdId)
|
|
|
|
return repository.members.map((member) => ({
|
|
id: member.id,
|
|
householdId,
|
|
telegramUserId: member.telegramUserId,
|
|
displayName: member.displayName,
|
|
status: repository.memberStatuses.get(member.id) ?? 'active',
|
|
preferredLocale: null,
|
|
householdDefaultLocale: 'en' as const,
|
|
rentShareWeight: member.rentShareWeight,
|
|
isAdmin: member.isAdmin
|
|
}))
|
|
},
|
|
async listHouseholdMemberAbsencePolicies(householdId) {
|
|
return financeRepositoryForHousehold(householdId).memberAbsencePolicies.map((policy) => ({
|
|
householdId,
|
|
memberId: policy.memberId,
|
|
effectiveFromPeriod: policy.effectiveFromPeriod,
|
|
policy: policy.policy
|
|
}))
|
|
}
|
|
}
|
|
|
|
const financeRepositories = new Map<string, FinanceRepositoryStub>()
|
|
|
|
function financeRepositoryForHousehold(householdId: string): FinanceRepositoryStub {
|
|
const repository = financeRepositories.get(householdId)
|
|
if (!repository) {
|
|
throw new Error(`Missing finance repository stub for ${householdId}`)
|
|
}
|
|
|
|
return repository
|
|
}
|
|
|
|
const exchangeRateProvider: ExchangeRateProvider = {
|
|
async getRate(input) {
|
|
if (input.baseCurrency === input.quoteCurrency) {
|
|
return {
|
|
baseCurrency: input.baseCurrency,
|
|
quoteCurrency: input.quoteCurrency,
|
|
rateMicros: 1_000_000n,
|
|
effectiveDate: input.effectiveDate,
|
|
source: 'nbg'
|
|
}
|
|
}
|
|
|
|
if (input.baseCurrency === 'USD' && input.quoteCurrency === 'GEL') {
|
|
return {
|
|
baseCurrency: 'USD',
|
|
quoteCurrency: 'GEL',
|
|
rateMicros: 2_700_000n,
|
|
effectiveDate: input.effectiveDate,
|
|
source: 'nbg'
|
|
}
|
|
}
|
|
|
|
return {
|
|
baseCurrency: input.baseCurrency,
|
|
quoteCurrency: input.quoteCurrency,
|
|
rateMicros: 370_370n,
|
|
effectiveDate: input.effectiveDate,
|
|
source: 'nbg'
|
|
}
|
|
}
|
|
}
|
|
|
|
function createService(repository: FinanceRepositoryStub) {
|
|
financeRepositories.set(repository.householdId, repository)
|
|
|
|
return createFinanceCommandService({
|
|
householdId: repository.householdId,
|
|
repository,
|
|
householdConfigurationRepository,
|
|
exchangeRateProvider
|
|
})
|
|
}
|
|
|
|
describe('createFinanceCommandService', () => {
|
|
test('setRent falls back to the open cycle period when one is active', async () => {
|
|
const repository = new FinanceRepositoryStub()
|
|
repository.openCycleRecord = {
|
|
id: 'cycle-1',
|
|
period: '2026-03',
|
|
currency: 'GEL'
|
|
}
|
|
|
|
const service = createService(repository)
|
|
const result = await service.setRent('700', undefined, undefined)
|
|
|
|
expect(result).not.toBeNull()
|
|
expect(result?.period).toBe('2026-03')
|
|
expect(result?.currency).toBe('USD')
|
|
expect(result?.amount.amountMinor).toBe(70000n)
|
|
expect(repository.lastSavedRentRule).toEqual({
|
|
period: '2026-03',
|
|
amountMinor: 70000n,
|
|
currency: 'USD'
|
|
})
|
|
})
|
|
|
|
test('getAdminCycleState prefers the open cycle and returns rent plus utility bills', async () => {
|
|
const repository = new FinanceRepositoryStub()
|
|
repository.openCycleRecord = {
|
|
id: 'cycle-1',
|
|
period: '2026-03',
|
|
currency: 'GEL'
|
|
}
|
|
repository.latestCycleRecord = {
|
|
id: 'cycle-0',
|
|
period: '2026-02',
|
|
currency: 'GEL'
|
|
}
|
|
repository.rentRule = {
|
|
amountMinor: 70000n,
|
|
currency: 'USD'
|
|
}
|
|
repository.utilityBills = [
|
|
{
|
|
id: 'utility-1',
|
|
billName: 'Electricity',
|
|
amountMinor: 12000n,
|
|
currency: 'GEL',
|
|
createdByMemberId: 'alice',
|
|
createdAt: instantFromIso('2026-03-12T12:00:00.000Z')
|
|
}
|
|
]
|
|
|
|
const service = createService(repository)
|
|
const result = await service.getAdminCycleState()
|
|
|
|
expect(result).toEqual({
|
|
cycle: {
|
|
id: 'cycle-1',
|
|
period: '2026-03',
|
|
currency: 'GEL'
|
|
},
|
|
rentRule: {
|
|
amountMinor: 70000n,
|
|
currency: 'USD'
|
|
},
|
|
utilityBills: [
|
|
{
|
|
id: 'utility-1',
|
|
billName: 'Electricity',
|
|
amount: expect.objectContaining({
|
|
amountMinor: 12000n,
|
|
currency: 'GEL'
|
|
}),
|
|
currency: 'GEL',
|
|
createdByMemberId: 'alice',
|
|
createdAt: instantFromIso('2026-03-12T12:00:00.000Z')
|
|
}
|
|
]
|
|
})
|
|
})
|
|
|
|
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).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 () => {
|
|
const repository = new FinanceRepositoryStub()
|
|
repository.latestCycleRecord = {
|
|
id: 'cycle-2026-03',
|
|
period: '2026-03',
|
|
currency: 'GEL'
|
|
}
|
|
repository.members = [
|
|
{
|
|
id: 'alice',
|
|
telegramUserId: '100',
|
|
displayName: 'Alice',
|
|
rentShareWeight: 1,
|
|
isAdmin: true
|
|
},
|
|
{
|
|
id: 'bob',
|
|
telegramUserId: '200',
|
|
displayName: 'Bob',
|
|
rentShareWeight: 1,
|
|
isAdmin: false
|
|
}
|
|
]
|
|
repository.rentRule = {
|
|
amountMinor: 70000n,
|
|
currency: 'USD'
|
|
}
|
|
repository.utilityBills = [
|
|
{
|
|
id: 'utility-1',
|
|
billName: 'Electricity',
|
|
amountMinor: 12000n,
|
|
currency: 'GEL',
|
|
createdByMemberId: 'alice',
|
|
createdAt: instantFromIso('2026-03-12T12:00:00.000Z')
|
|
}
|
|
]
|
|
repository.purchases = [
|
|
{
|
|
id: 'purchase-1',
|
|
payerMemberId: 'alice',
|
|
amountMinor: 3000n,
|
|
currency: 'GEL',
|
|
description: 'Soap',
|
|
occurredAt: instantFromIso('2026-03-12T11:00:00.000Z')
|
|
}
|
|
]
|
|
repository.paymentRecords = [
|
|
{
|
|
id: 'payment-1',
|
|
memberId: 'alice',
|
|
kind: 'rent',
|
|
amountMinor: 50000n,
|
|
currency: 'GEL',
|
|
recordedAt: instantFromIso('2026-03-18T12:00:00.000Z')
|
|
}
|
|
]
|
|
|
|
const service = createService(repository)
|
|
const dashboard = await service.generateDashboard()
|
|
const statement = await service.generateStatement()
|
|
|
|
expect(dashboard).not.toBeNull()
|
|
expect(dashboard?.currency).toBe('GEL')
|
|
expect(dashboard?.rentSourceAmount.toMajorString()).toBe('700.00')
|
|
expect(dashboard?.rentDisplayAmount.toMajorString()).toBe('1890.00')
|
|
expect(dashboard?.members.map((line) => line.netDue.amountMinor)).toEqual([99000n, 102000n])
|
|
expect(dashboard?.ledger.map((entry) => entry.title)).toEqual(['Soap', 'Electricity', 'rent'])
|
|
expect(dashboard?.ledger.map((entry) => entry.kind)).toEqual(['purchase', 'utility', 'payment'])
|
|
expect(dashboard?.ledger.map((entry) => entry.currency)).toEqual(['GEL', 'GEL', 'GEL'])
|
|
expect(dashboard?.ledger.map((entry) => entry.displayCurrency)).toEqual(['GEL', 'GEL', 'GEL'])
|
|
expect(dashboard?.ledger.map((entry) => entry.paymentKind)).toEqual([null, null, 'rent'])
|
|
expect(statement).toBe(
|
|
[
|
|
'Statement for 2026-03',
|
|
'Rent: 700.00 USD (~1890.00 GEL)',
|
|
'- Alice: due 990.00 GEL, paid 500.00 GEL, remaining 490.00 GEL',
|
|
'- Bob: due 1020.00 GEL, paid 0.00 GEL, remaining 1020.00 GEL',
|
|
'Total due: 2010.00 GEL',
|
|
'Total paid: 500.00 GEL',
|
|
'Total remaining: 1510.00 GEL'
|
|
].join('\n')
|
|
)
|
|
expect(repository.replacedSnapshot).not.toBeNull()
|
|
expect(repository.replacedSnapshot?.cycleId).toBe('cycle-2026-03')
|
|
expect(repository.replacedSnapshot?.currency).toBe('GEL')
|
|
expect(repository.replacedSnapshot?.totalDueMinor).toBe(201000n)
|
|
expect(repository.replacedSnapshot?.lines.map((line) => line.netDueMinor)).toEqual([
|
|
99000n,
|
|
102000n
|
|
])
|
|
})
|
|
|
|
test('generateDashboard prefers the open cycle over a later latest cycle', async () => {
|
|
const repository = new FinanceRepositoryStub()
|
|
repository.members = [
|
|
{
|
|
id: 'stas',
|
|
telegramUserId: '100',
|
|
displayName: 'Stas',
|
|
rentShareWeight: 1,
|
|
isAdmin: true
|
|
}
|
|
]
|
|
repository.openCycleRecord = {
|
|
id: 'cycle-2026-03',
|
|
period: '2026-03',
|
|
currency: 'GEL'
|
|
}
|
|
repository.latestCycleRecord = {
|
|
id: 'cycle-2026-04',
|
|
period: '2026-04',
|
|
currency: 'GEL'
|
|
}
|
|
repository.rentRule = {
|
|
amountMinor: 70000n,
|
|
currency: 'USD'
|
|
}
|
|
|
|
const service = createService(repository)
|
|
const dashboard = await service.generateDashboard()
|
|
|
|
expect(dashboard?.period).toBe('2026-03')
|
|
})
|
|
|
|
test('generateDashboard excludes away members from purchases and utilities based on absence policy', 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
|
|
},
|
|
{
|
|
id: 'carol',
|
|
telegramUserId: '3',
|
|
displayName: 'Carol',
|
|
rentShareWeight: 1,
|
|
isAdmin: false
|
|
}
|
|
]
|
|
repository.memberStatuses.set('carol', 'away')
|
|
repository.memberAbsencePolicies = [
|
|
{
|
|
memberId: 'carol',
|
|
effectiveFromPeriod: '2026-03',
|
|
policy: 'away_rent_only'
|
|
}
|
|
]
|
|
repository.openCycleRecord = {
|
|
id: 'cycle-2026-03',
|
|
period: '2026-03',
|
|
currency: 'GEL'
|
|
}
|
|
repository.rentRule = {
|
|
amountMinor: 90000n,
|
|
currency: 'GEL'
|
|
}
|
|
repository.utilityBills = [
|
|
{
|
|
id: 'utility-1',
|
|
billName: 'Gas',
|
|
amountMinor: 12000n,
|
|
currency: 'GEL',
|
|
createdByMemberId: 'alice',
|
|
createdAt: instantFromIso('2026-03-12T12:00:00.000Z')
|
|
}
|
|
]
|
|
repository.purchases = [
|
|
{
|
|
id: 'purchase-1',
|
|
payerMemberId: 'alice',
|
|
amountMinor: 3000n,
|
|
currency: 'GEL',
|
|
description: 'Kitchen towels',
|
|
occurredAt: instantFromIso('2026-03-10T12:00:00.000Z')
|
|
}
|
|
]
|
|
|
|
const service = createService(repository)
|
|
const dashboard = await service.generateDashboard()
|
|
|
|
expect(
|
|
dashboard?.members.map((line) => ({
|
|
memberId: line.memberId,
|
|
utility: line.utilityShare.amountMinor,
|
|
purchaseOffset: line.purchaseOffset.amountMinor
|
|
}))
|
|
).toEqual([
|
|
{ memberId: 'alice', utility: 6000n, purchaseOffset: -1500n },
|
|
{ memberId: 'bob', utility: 6000n, purchaseOffset: 1500n },
|
|
{ memberId: 'carol', utility: 0n, purchaseOffset: 0n }
|
|
])
|
|
})
|
|
|
|
test('updatePurchase persists explicit participant splits', async () => {
|
|
const repository = new FinanceRepositoryStub()
|
|
const service = createService(repository)
|
|
|
|
const result = await service.updatePurchase('purchase-1', 'Kitchen towels', '30.00', 'GEL', {
|
|
mode: 'custom_amounts',
|
|
participants: [
|
|
{
|
|
memberId: 'alice',
|
|
shareAmountMajor: '20.00'
|
|
},
|
|
{
|
|
memberId: 'bob',
|
|
shareAmountMajor: '10.00'
|
|
}
|
|
]
|
|
})
|
|
|
|
expect(result).toMatchObject({
|
|
purchaseId: 'purchase-1',
|
|
currency: 'GEL'
|
|
})
|
|
expect(repository.lastUpdatedPurchaseInput).toEqual({
|
|
purchaseId: 'purchase-1',
|
|
amountMinor: 3000n,
|
|
currency: 'GEL',
|
|
description: 'Kitchen towels',
|
|
splitMode: 'custom_amounts',
|
|
participants: [
|
|
{
|
|
memberId: 'alice',
|
|
included: true,
|
|
shareAmountMinor: 2000n
|
|
},
|
|
{
|
|
memberId: 'bob',
|
|
included: true,
|
|
shareAmountMinor: 1000n
|
|
}
|
|
]
|
|
})
|
|
})
|
|
|
|
test('generateDashboard exposes purchase participant splits in the ledger', 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
|
|
},
|
|
{
|
|
id: 'carol',
|
|
telegramUserId: '3',
|
|
displayName: 'Carol',
|
|
rentShareWeight: 1,
|
|
isAdmin: false
|
|
}
|
|
]
|
|
repository.openCycleRecord = {
|
|
id: 'cycle-2026-03',
|
|
period: '2026-03',
|
|
currency: 'GEL'
|
|
}
|
|
repository.rentRule = {
|
|
amountMinor: 90000n,
|
|
currency: 'GEL'
|
|
}
|
|
repository.purchases = [
|
|
{
|
|
id: 'purchase-1',
|
|
payerMemberId: 'alice',
|
|
amountMinor: 3000n,
|
|
currency: 'GEL',
|
|
description: 'Kettle',
|
|
occurredAt: instantFromIso('2026-03-10T12:00:00.000Z'),
|
|
splitMode: 'custom_amounts',
|
|
participants: [
|
|
{
|
|
memberId: 'alice',
|
|
included: true,
|
|
shareAmountMinor: 2000n
|
|
},
|
|
{
|
|
memberId: 'bob',
|
|
included: true,
|
|
shareAmountMinor: 1000n
|
|
},
|
|
{
|
|
memberId: 'carol',
|
|
included: false,
|
|
shareAmountMinor: null
|
|
}
|
|
]
|
|
}
|
|
]
|
|
|
|
const service = createService(repository)
|
|
const dashboard = await service.generateDashboard()
|
|
const purchaseEntry = dashboard?.ledger.find((entry) => entry.id === 'purchase-1')
|
|
|
|
expect(purchaseEntry?.kind).toBe('purchase')
|
|
expect(purchaseEntry?.purchaseSplitMode).toBe('custom_amounts')
|
|
expect(purchaseEntry?.purchaseParticipants).toEqual([
|
|
{
|
|
memberId: 'alice',
|
|
included: true,
|
|
shareAmount: Money.fromMinor(2000n, 'GEL')
|
|
},
|
|
{
|
|
memberId: 'bob',
|
|
included: true,
|
|
shareAmount: Money.fromMinor(1000n, 'GEL')
|
|
},
|
|
{
|
|
memberId: 'carol',
|
|
included: false,
|
|
shareAmount: null
|
|
}
|
|
])
|
|
})
|
|
|
|
test('generateDashboard should not 500 on legacy malformed custom split purchases (mixed null/explicit shares)', 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-03',
|
|
period: '2026-03',
|
|
currency: 'GEL'
|
|
}
|
|
repository.rentRule = {
|
|
amountMinor: 70000n,
|
|
currency: 'USD'
|
|
}
|
|
repository.purchases = [
|
|
{
|
|
id: 'malformed-purchase-1',
|
|
payerMemberId: 'alice',
|
|
amountMinor: 1000n, // Total is 10.00 GEL
|
|
currency: 'GEL',
|
|
description: 'Legacy purchase',
|
|
occurredAt: instantFromIso('2026-03-12T11:00:00.000Z'),
|
|
splitMode: 'custom_amounts',
|
|
participants: [
|
|
{
|
|
memberId: 'alice',
|
|
included: true,
|
|
shareAmountMinor: 1000n // Explicitly Alice takes full 10.00 GEL
|
|
},
|
|
{
|
|
memberId: 'bob',
|
|
// Missing included: false, and shareAmountMinor is null
|
|
// This is the malformed data that used to cause 500
|
|
shareAmountMinor: null
|
|
}
|
|
]
|
|
}
|
|
]
|
|
|
|
const service = createService(repository)
|
|
const dashboard = await service.generateDashboard()
|
|
|
|
expect(dashboard).not.toBeNull()
|
|
const purchase = dashboard?.ledger.find((e) => e.id === 'malformed-purchase-1')
|
|
expect(purchase?.purchaseSplitMode).toBe('custom_amounts')
|
|
|
|
// Bob should be treated as excluded from the settlement calculation
|
|
const bobLine = dashboard?.members.find((m) => m.memberId === 'bob')
|
|
expect(bobLine?.purchaseOffset.amountMinor).toBe(0n)
|
|
|
|
const aliceLine = dashboard?.members.find((m) => m.memberId === 'alice')
|
|
// Alice paid 1000n and her share is 1000n -> offset 0n
|
|
expect(aliceLine?.purchaseOffset.amountMinor).toBe(0n)
|
|
})
|
|
})
|