import { createHash } from 'node:crypto' import type { ExchangeRateProvider, FinanceCycleRecord, FinanceMemberRecord, FinancePaymentKind, FinanceRentRuleRecord, FinanceRepository, HouseholdConfigurationRepository, HouseholdMemberAbsencePolicy, HouseholdMemberAbsencePolicyRecord, HouseholdMemberRecord } from '@household/ports' import { BillingCycleId, BillingPeriod, MemberId, Money, PurchaseEntryId, Temporal, convertMoney, nowInstant, type CurrencyCode } from '@household/domain' import { calculateMonthlySettlement } from './settlement-engine' function parseCurrency(raw: string | undefined, fallback: CurrencyCode): CurrencyCode { if (!raw || raw.trim().length === 0) { return fallback } const normalized = raw.trim().toUpperCase() if (normalized !== 'USD' && normalized !== 'GEL') { throw new Error(`Unsupported currency: ${raw}`) } return normalized } function monthRange(period: BillingPeriod): { start: Temporal.Instant end: Temporal.Instant } { return { start: Temporal.Instant.from(`${period.toString()}-01T00:00:00Z`), end: Temporal.Instant.from(`${period.next().toString()}-01T00:00:00Z`) } } function computeInputHash(payload: object): string { return createHash('sha256').update(JSON.stringify(payload)).digest('hex') } async function getCycleByPeriodOrLatest( repository: FinanceRepository, periodArg?: string ): Promise { if (periodArg) { return repository.getCycleByPeriod(BillingPeriod.fromString(periodArg).toString()) } return (await repository.getOpenCycle()) ?? repository.getLatestCycle() } function billingPeriodLockDate(period: BillingPeriod, day: number): Temporal.PlainDate { const firstDay = Temporal.PlainDate.from({ year: period.year, month: period.month, day: 1 }) const clampedDay = Math.min(day, firstDay.daysInMonth) return Temporal.PlainDate.from({ year: period.year, month: period.month, day: clampedDay }) } 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 status?: 'active' | 'away' | 'left' absencePolicy?: HouseholdMemberAbsencePolicy absencePolicyEffectiveFromPeriod?: string | null rentShare: Money utilityShare: Money purchaseOffset: Money netDue: Money paid: Money remaining: Money explanations: readonly string[] } export interface FinanceDashboardLedgerEntry { id: string kind: 'purchase' | 'utility' | 'payment' title: string memberId: string | null amount: Money currency: CurrencyCode displayAmount: Money displayCurrency: CurrencyCode fxRateMicros: bigint | null fxEffectiveDate: string | null actorDisplayName: string | null occurredAt: string | null paymentKind: FinancePaymentKind | null purchaseSplitMode?: 'equal' | 'custom_amounts' purchaseParticipants?: readonly { memberId: string included: boolean shareAmount: Money | null }[] } export interface FinanceDashboard { period: string currency: CurrencyCode totalDue: Money totalPaid: Money totalRemaining: Money rentSourceAmount: Money rentDisplayAmount: Money rentFxRateMicros: bigint | null rentFxEffectiveDate: string | null members: readonly FinanceDashboardMemberLine[] ledger: readonly FinanceDashboardLedgerEntry[] } export interface FinanceAdminCycleState { cycle: FinanceCycleRecord | null rentRule: FinanceRentRuleRecord | null utilityBills: readonly { id: string billName: string amount: Money currency: CurrencyCode createdByMemberId: string | null createdAt: Temporal.Instant }[] } interface FinanceCommandServiceDependencies { householdId: string repository: FinanceRepository householdConfigurationRepository: Pick< HouseholdConfigurationRepository, 'getHouseholdBillingSettings' | 'listHouseholdMembers' | 'listHouseholdMemberAbsencePolicies' > exchangeRateProvider: ExchangeRateProvider } interface ResolvedMemberAbsencePolicy { memberId: string policy: HouseholdMemberAbsencePolicy effectiveFromPeriod: string | null } function resolveMemberAbsencePolicies(input: { members: readonly HouseholdMemberRecord[] policies: readonly HouseholdMemberAbsencePolicyRecord[] period: string }): ReadonlyMap { const resolved = new Map() for (const member of input.members) { const applicable = input.policies .filter( (policy) => policy.memberId === member.id && policy.effectiveFromPeriod.localeCompare(input.period) <= 0 ) .sort((left, right) => left.effectiveFromPeriod.localeCompare(right.effectiveFromPeriod)) .at(-1) resolved.set(member.id, { memberId: member.id, policy: applicable?.policy ?? (member.status === 'away' ? 'away_rent_and_utilities' : 'resident'), effectiveFromPeriod: applicable?.effectiveFromPeriod ?? null }) } return resolved } interface ConvertedCycleMoney { originalAmount: Money settlementAmount: Money fxRateMicros: bigint | null fxEffectiveDate: string | null } async function convertIntoCycleCurrency( dependencies: FinanceCommandServiceDependencies, input: { cycle: FinanceCycleRecord period: BillingPeriod lockDay: number timezone: string amount: Money } ): Promise { if (input.amount.currency === input.cycle.currency) { return { originalAmount: input.amount, settlementAmount: input.amount, fxRateMicros: null, fxEffectiveDate: null } } const existingRate = await dependencies.repository.getCycleExchangeRate( input.cycle.id, input.amount.currency, input.cycle.currency ) if (existingRate) { return { originalAmount: input.amount, settlementAmount: convertMoney(input.amount, input.cycle.currency, existingRate.rateMicros), fxRateMicros: existingRate.rateMicros, fxEffectiveDate: existingRate.effectiveDate } } const lockDate = billingPeriodLockDate(input.period, input.lockDay) const currentLocalDate = localDateInTimezone(input.timezone) const shouldPersist = Temporal.PlainDate.compare(currentLocalDate, lockDate) >= 0 const quote = await dependencies.exchangeRateProvider.getRate({ baseCurrency: input.amount.currency, quoteCurrency: input.cycle.currency, effectiveDate: lockDate.toString() }) if (shouldPersist) { await dependencies.repository.saveCycleExchangeRate({ cycleId: input.cycle.id, sourceCurrency: quote.baseCurrency, targetCurrency: quote.quoteCurrency, rateMicros: quote.rateMicros, effectiveDate: quote.effectiveDate, source: quote.source }) } return { originalAmount: input.amount, settlementAmount: convertMoney(input.amount, input.cycle.currency, quote.rateMicros), fxRateMicros: quote.rateMicros, fxEffectiveDate: quote.effectiveDate } } async function buildFinanceDashboard( dependencies: FinanceCommandServiceDependencies, periodArg?: string ): Promise { const cycle = await getCycleByPeriodOrLatest(dependencies.repository, periodArg) if (!cycle) { return null } const [members, memberAbsencePolicies, rentRule, settings] = await Promise.all([ dependencies.householdConfigurationRepository.listHouseholdMembers(dependencies.householdId), dependencies.householdConfigurationRepository.listHouseholdMemberAbsencePolicies( dependencies.householdId ), dependencies.repository.getRentRuleForPeriod(cycle.period), dependencies.householdConfigurationRepository.getHouseholdBillingSettings( dependencies.householdId ) ]) if (members.length === 0) { throw new Error('No household members configured') } if (!rentRule) { throw new Error('No rent rule configured for this cycle period') } const period = BillingPeriod.fromString(cycle.period) const { start, end } = monthRange(period) const resolvedAbsencePolicies = resolveMemberAbsencePolicies({ members, policies: memberAbsencePolicies, period: cycle.period }) const [purchases, utilityBills] = await Promise.all([ dependencies.repository.listParsedPurchasesForRange(start, end), dependencies.repository.listUtilityBillsForCycle(cycle.id) ]) const paymentRecords = await dependencies.repository.listPaymentRecordsForCycle(cycle.id) const convertedRent = await convertIntoCycleCurrency(dependencies, { cycle, period, lockDay: settings.rentWarningDay, timezone: settings.timezone, amount: Money.fromMinor(rentRule.amountMinor, rentRule.currency) }) const convertedUtilityBills = await Promise.all( utilityBills.map(async (bill) => { const converted = await convertIntoCycleCurrency(dependencies, { cycle, period, lockDay: settings.utilitiesReminderDay, timezone: settings.timezone, amount: Money.fromMinor(bill.amountMinor, bill.currency) }) return { bill, converted } }) ) const convertedPurchases = await Promise.all( purchases.map(async (purchase) => { const converted = await convertIntoCycleCurrency(dependencies, { cycle, period, lockDay: settings.rentWarningDay, timezone: settings.timezone, amount: Money.fromMinor(purchase.amountMinor, purchase.currency) }) return { purchase, converted } }) ) const utilities = convertedUtilityBills.reduce( (sum, current) => sum.add(current.converted.settlementAmount), Money.zero(cycle.currency) ) const settlement = calculateMonthlySettlement({ cycleId: BillingCycleId.from(cycle.id), period, rent: convertedRent.settlementAmount, utilities, utilitySplitMode: 'equal', members: 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: 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' } 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 ) } : {}) })) } return nextPurchase }) }) await dependencies.repository.replaceSettlementSnapshot({ cycleId: cycle.id, inputHash: computeInputHash({ cycleId: cycle.id, rentMinor: convertedRent.settlementAmount.amountMinor.toString(), utilitiesMinor: utilities.amountMinor.toString(), purchaseMinors: convertedPurchases.map(({ purchase, converted }) => ({ id: purchase.id, minor: converted.settlementAmount.amountMinor.toString(), currency: converted.settlementAmount.currency })), memberCount: members.length }), totalDueMinor: settlement.totalDue.amountMinor, currency: cycle.currency, metadata: { generatedBy: 'bot-command', source: 'finance-service', rentSourceMinor: convertedRent.originalAmount.amountMinor.toString(), rentSourceCurrency: convertedRent.originalAmount.currency, rentFxRateMicros: convertedRent.fxRateMicros?.toString() ?? null, rentFxEffectiveDate: convertedRent.fxEffectiveDate }, lines: settlement.lines.map((line) => ({ memberId: line.memberId.toString(), rentShareMinor: line.rentShare.amountMinor, utilityShareMinor: line.utilityShare.amountMinor, purchaseOffsetMinor: line.purchaseOffset.amountMinor, netDueMinor: line.netDue.amountMinor, explanations: line.explanations })) }) const memberNameById = new Map(members.map((member) => [member.id, member.displayName])) const paymentsByMemberId = new Map() for (const payment of paymentRecords) { const current = paymentsByMemberId.get(payment.memberId) ?? Money.zero(cycle.currency) paymentsByMemberId.set( payment.memberId, current.add(Money.fromMinor(payment.amountMinor, payment.currency)) ) } const dashboardMembers = settlement.lines.map((line) => ({ memberId: line.memberId.toString(), displayName: memberNameById.get(line.memberId.toString()) ?? line.memberId.toString(), status: members.find((member) => member.id === line.memberId.toString())?.status ?? 'active', absencePolicy: resolvedAbsencePolicies.get(line.memberId.toString())?.policy ?? 'resident', absencePolicyEffectiveFromPeriod: resolvedAbsencePolicies.get(line.memberId.toString())?.effectiveFromPeriod ?? null, rentShare: line.rentShare, utilityShare: line.utilityShare, purchaseOffset: line.purchaseOffset, netDue: line.netDue, paid: paymentsByMemberId.get(line.memberId.toString()) ?? Money.zero(cycle.currency), remaining: line.netDue.subtract( paymentsByMemberId.get(line.memberId.toString()) ?? Money.zero(cycle.currency) ), explanations: line.explanations })) const ledger: FinanceDashboardLedgerEntry[] = [ ...convertedUtilityBills.map(({ bill, converted }) => ({ id: bill.id, kind: 'utility' as const, title: bill.billName, memberId: bill.createdByMemberId, amount: converted.originalAmount, currency: bill.currency, displayAmount: converted.settlementAmount, displayCurrency: cycle.currency, fxRateMicros: converted.fxRateMicros, fxEffectiveDate: converted.fxEffectiveDate, actorDisplayName: bill.createdByMemberId ? (memberNameById.get(bill.createdByMemberId) ?? null) : null, occurredAt: bill.createdAt.toString(), paymentKind: null })), ...convertedPurchases.map(({ purchase, converted }) => { const entry: FinanceDashboardLedgerEntry = { id: purchase.id, kind: 'purchase', title: purchase.description ?? 'Shared purchase', memberId: purchase.payerMemberId, amount: converted.originalAmount, currency: purchase.currency, displayAmount: converted.settlementAmount, displayCurrency: cycle.currency, fxRateMicros: converted.fxRateMicros, fxEffectiveDate: converted.fxEffectiveDate, actorDisplayName: memberNameById.get(purchase.payerMemberId) ?? null, occurredAt: purchase.occurredAt?.toString() ?? null, paymentKind: null, purchaseSplitMode: purchase.splitMode ?? 'equal' } if (purchase.participants) { entry.purchaseParticipants = purchase.participants.map((participant) => ({ memberId: participant.memberId, included: participant.included !== false, shareAmount: participant.shareAmountMinor !== null ? Money.fromMinor(participant.shareAmountMinor, converted.settlementAmount.currency) : null })) } return entry }), ...paymentRecords.map((payment) => ({ 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), displayCurrency: payment.currency, fxRateMicros: null, fxEffectiveDate: null, actorDisplayName: memberNameById.get(payment.memberId) ?? null, occurredAt: payment.recordedAt.toString(), paymentKind: payment.kind })) ].sort((left, right) => { if (left.occurredAt === right.occurredAt) { return left.title.localeCompare(right.title) } return (left.occurredAt ?? '').localeCompare(right.occurredAt ?? '') }) return { period: cycle.period, currency: cycle.currency, totalDue: settlement.totalDue, totalPaid: paymentRecords.reduce( (sum, payment) => sum.add(Money.fromMinor(payment.amountMinor, payment.currency)), Money.zero(cycle.currency) ), totalRemaining: dashboardMembers.reduce( (sum, member) => sum.add(member.remaining), Money.zero(cycle.currency) ), rentSourceAmount: convertedRent.originalAmount, rentDisplayAmount: convertedRent.settlementAmount, rentFxRateMicros: convertedRent.fxRateMicros, rentFxEffectiveDate: convertedRent.fxEffectiveDate, members: dashboardMembers, ledger } } export interface FinanceCommandService { getMemberByTelegramUserId(telegramUserId: string): Promise getOpenCycle(): Promise ensureExpectedCycle(referenceInstant?: Temporal.Instant): Promise getAdminCycleState(periodArg?: string): Promise openCycle(periodArg: string, currencyArg?: string): Promise closeCycle(periodArg?: string): Promise setRent( amountArg: string, currencyArg?: string, periodArg?: string ): Promise<{ amount: Money currency: CurrencyCode period: string } | null> addUtilityBill( billName: string, amountArg: string, createdByMemberId: string, currencyArg?: string ): Promise<{ amount: Money currency: CurrencyCode period: string } | null> updateUtilityBill( billId: string, billName: string, amountArg: string, currencyArg?: string ): Promise<{ billId: string amount: Money currency: CurrencyCode } | null> deleteUtilityBill(billId: string): Promise updatePurchase( purchaseId: string, description: string, amountArg: string, currencyArg?: string, split?: { mode: 'equal' | 'custom_amounts' participants: readonly { memberId: string shareAmountMajor?: string }[] } ): Promise<{ purchaseId: string amount: Money currency: CurrencyCode } | null> deletePurchase(purchaseId: string): Promise 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 generateDashboard(periodArg?: string): Promise generateStatement(periodArg?: string): Promise } export function createFinanceCommandService( dependencies: FinanceCommandServiceDependencies ): FinanceCommandService { const { repository, householdConfigurationRepository } = dependencies async function ensureExpectedCycle(referenceInstant = nowInstant()): Promise { 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) }, getOpenCycle() { return repository.getOpenCycle() }, ensureExpectedCycle(referenceInstant) { return ensureExpectedCycle(referenceInstant) }, async getAdminCycleState(periodArg) { const cycle = periodArg ? await repository.getCycleByPeriod(BillingPeriod.fromString(periodArg).toString()) : await ensureExpectedCycle() if (!cycle) { return { cycle: null, rentRule: null, utilityBills: [] } } const [rentRule, utilityBills] = await Promise.all([ repository.getRentRuleForPeriod(cycle.period), repository.listUtilityBillsForCycle(cycle.id) ]) return { cycle, rentRule, utilityBills: utilityBills.map((bill) => ({ id: bill.id, billName: bill.billName, amount: Money.fromMinor(bill.amountMinor, bill.currency), currency: bill.currency, createdByMemberId: bill.createdByMemberId, createdAt: bill.createdAt })) } }, async openCycle(periodArg, currencyArg) { const period = BillingPeriod.fromString(periodArg).toString() const settings = await householdConfigurationRepository.getHouseholdBillingSettings( dependencies.householdId ) const currency = parseCurrency(currencyArg, settings.settlementCurrency) await repository.openCycle(period, currency) const cycle = await repository.getCycleByPeriod(period) if (!cycle) { throw new Error(`Failed to load billing cycle for period ${period}`) } return cycle }, async closeCycle(periodArg) { const cycle = await getCycleByPeriodOrLatest(repository, periodArg) if (!cycle) { return null } await repository.closeCycle(cycle.id, nowInstant()) return cycle }, async setRent(amountArg, currencyArg, periodArg) { const [settings, cycle] = await Promise.all([ householdConfigurationRepository.getHouseholdBillingSettings(dependencies.householdId), periodArg ? Promise.resolve(null) : ensureExpectedCycle() ]) const period = periodArg ?? cycle?.period if (!period) { return null } const currency = parseCurrency(currencyArg, settings.rentCurrency) const amount = Money.fromMajor(amountArg, currency) await repository.saveRentRule( BillingPeriod.fromString(period).toString(), amount.amountMinor, currency ) return { amount, currency, period: BillingPeriod.fromString(period).toString() } }, async addUtilityBill(billName, amountArg, createdByMemberId, currencyArg) { const [openCycle, settings] = await Promise.all([ ensureExpectedCycle(), householdConfigurationRepository.getHouseholdBillingSettings(dependencies.householdId) ]) const currency = parseCurrency(currencyArg, settings.settlementCurrency) const amount = Money.fromMajor(amountArg, currency) await repository.addUtilityBill({ cycleId: openCycle.id, billName, amountMinor: amount.amountMinor, currency, createdByMemberId }) return { amount, currency, period: openCycle.period } }, async updateUtilityBill(billId, billName, 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.updateUtilityBill({ billId, billName, amountMinor: amount.amountMinor, currency }) if (!updated) { return null } return { billId: updated.id, amount, currency } }, deleteUtilityBill(billId) { return repository.deleteUtilityBill(billId) }, async updatePurchase(purchaseId, description, amountArg, currencyArg, split) { 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, ...(split ? { splitMode: split.mode, participants: split.participants.map((participant) => ({ memberId: participant.memberId, shareAmountMinor: participant.shareAmountMajor !== undefined ? Money.fromMajor(participant.shareAmountMajor, currency).amountMinor : 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 } const statementLines = dashboard.members.map((line) => { return `- ${line.displayName}: due ${line.netDue.toMajorString()} ${dashboard.currency}, paid ${line.paid.toMajorString()} ${dashboard.currency}, remaining ${line.remaining.toMajorString()} ${dashboard.currency}` }) const rentLine = dashboard.rentSourceAmount.currency === dashboard.rentDisplayAmount.currency ? `Rent: ${dashboard.rentDisplayAmount.toMajorString()} ${dashboard.currency}` : `Rent: ${dashboard.rentSourceAmount.toMajorString()} ${dashboard.rentSourceAmount.currency} (~${dashboard.rentDisplayAmount.toMajorString()} ${dashboard.currency})` return [ `Statement for ${dashboard.period}`, rentLine, ...statementLines, `Total due: ${dashboard.totalDue.toMajorString()} ${dashboard.currency}`, `Total paid: ${dashboard.totalPaid.toMajorString()} ${dashboard.currency}`, `Total remaining: ${dashboard.totalRemaining.toMajorString()} ${dashboard.currency}` ].join('\n') }, generateDashboard(periodArg) { return periodArg ? buildFinanceDashboard(dependencies, periodArg) : ensureExpectedCycle().then(() => buildFinanceDashboard(dependencies)) } } }