From c292518760dce0e15826710a2984392fa4fc19e9 Mon Sep 17 00:00:00 2001 From: whekin Date: Tue, 10 Mar 2026 17:16:16 +0400 Subject: [PATCH] feat(miniapp): show payment activity in dashboard --- apps/bot/src/miniapp-dashboard.test.ts | 22 ++++++- apps/bot/src/miniapp-dashboard.ts | 1 + apps/miniapp/src/App.tsx | 58 ++++++++++++++++++- apps/miniapp/src/i18n.ts | 8 +++ apps/miniapp/src/miniapp-api.ts | 3 +- .../src/finance-command-service.test.ts | 34 ++++++++--- .../src/finance-command-service.ts | 24 +++++++- 7 files changed, 135 insertions(+), 15 deletions(-) diff --git a/apps/bot/src/miniapp-dashboard.test.ts b/apps/bot/src/miniapp-dashboard.test.ts index 20b5fa5..d4d1a1f 100644 --- a/apps/bot/src/miniapp-dashboard.test.ts +++ b/apps/bot/src/miniapp-dashboard.test.ts @@ -61,7 +61,16 @@ function repository( createdAt: instantFromIso('2026-03-12T12:00:00.000Z') } ], - listPaymentRecordsForCycle: async () => [], + listPaymentRecordsForCycle: async () => [ + { + id: 'payment-1', + memberId: member?.id ?? 'member-1', + kind: 'rent', + amountMinor: 50000n, + currency: 'GEL', + recordedAt: instantFromIso('2026-03-18T12:00:00.000Z') + } + ], listParsedPurchasesForRange: async () => [ { id: 'purchase-1', @@ -272,6 +281,8 @@ describe('createMiniAppDashboardHandler', () => { period: '2026-03', currency: 'GEL', totalDueMajor: '2010.00', + totalPaidMajor: '500.00', + totalRemainingMajor: '1510.00', rentSourceAmountMajor: '700.00', rentSourceCurrency: 'USD', rentDisplayAmountMajor: '1890.00', @@ -279,6 +290,8 @@ describe('createMiniAppDashboardHandler', () => { { displayName: 'Stan', netDueMajor: '2010.00', + paidMajor: '500.00', + remainingMajor: '1510.00', rentShareMajor: '1890.00', utilityShareMajor: '120.00', purchaseOffsetMajor: '0.00' @@ -294,6 +307,13 @@ describe('createMiniAppDashboardHandler', () => { title: 'Electricity', currency: 'GEL', displayCurrency: 'GEL' + }, + { + kind: 'payment', + title: 'rent', + paymentKind: 'rent', + currency: 'GEL', + displayCurrency: 'GEL' } ] } diff --git a/apps/bot/src/miniapp-dashboard.ts b/apps/bot/src/miniapp-dashboard.ts index d2a67b5..6d810cb 100644 --- a/apps/bot/src/miniapp-dashboard.ts +++ b/apps/bot/src/miniapp-dashboard.ts @@ -111,6 +111,7 @@ export function createMiniAppDashboardHandler(options: { id: entry.id, kind: entry.kind, title: entry.title, + paymentKind: entry.paymentKind, amountMajor: entry.amount.toMajorString(), currency: entry.currency, displayAmountMajor: entry.displayAmount.toMajorString(), diff --git a/apps/miniapp/src/App.tsx b/apps/miniapp/src/App.tsx index c543842..595c41c 100644 --- a/apps/miniapp/src/App.tsx +++ b/apps/miniapp/src/App.tsx @@ -257,8 +257,21 @@ function App() { const utilityLedger = createMemo(() => (dashboard()?.ledger ?? []).filter((entry) => entry.kind === 'utility') ) + const paymentLedger = createMemo(() => + (dashboard()?.ledger ?? []).filter((entry) => entry.kind === 'payment') + ) const webApp = getTelegramWebApp() + function ledgerTitle(entry: MiniAppDashboard['ledger'][number]): string { + if (entry.kind !== 'payment') { + return entry.title + } + + return entry.paymentKind === 'utilities' + ? copy().paymentLedgerUtilities + : copy().paymentLedgerRent + } + async function loadDashboard(initData: string) { try { setDashboard(await fetchMiniAppDashboard(initData)) @@ -455,6 +468,7 @@ function App() { id: 'purchase-1', kind: 'purchase', title: 'Soap', + paymentKind: null, amountMajor: '30.00', currency: 'GEL', displayAmountMajor: '30.00', @@ -468,6 +482,7 @@ function App() { id: 'utility-1', kind: 'utility', title: 'Electricity', + paymentKind: null, amountMajor: '120.00', currency: 'GEL', displayAmountMajor: '120.00', @@ -476,6 +491,20 @@ function App() { fxEffectiveDate: null, actorDisplayName: 'Alice', occurredAt: '2026-03-12T12:00:00.000Z' + }, + { + id: 'payment-1', + kind: 'payment', + title: 'rent', + paymentKind: 'rent', + amountMajor: '501.00', + currency: 'GEL', + displayAmountMajor: '501.00', + displayCurrency: 'GEL', + fxRateMicros: null, + fxEffectiveDate: null, + actorDisplayName: 'Demo Resident', + occurredAt: '2026-03-18T15:10:00.000Z' } ] }) @@ -986,7 +1015,7 @@ function App() { {purchaseLedger().map((entry) => (
- {entry.title} + {ledgerTitle(entry)} {ledgerPrimaryAmount(entry)}
@@ -1009,7 +1038,30 @@ function App() { {utilityLedger().map((entry) => (
- {entry.title} + {ledgerTitle(entry)} + {ledgerPrimaryAmount(entry)} +
+ + {(secondary) =>

{secondary()}

} +
+

{entry.actorDisplayName ?? copy().ledgerActorFallback}

+
+ ))} + + )} +
+
+
+ {copy().paymentsTitle} +
+ {paymentLedger().length === 0 ? ( +

{copy().paymentsEmpty}

+ ) : ( +
+ {paymentLedger().map((entry) => ( +
+
+ {ledgerTitle(entry)} {ledgerPrimaryAmount(entry)}
@@ -1775,7 +1827,7 @@ function App() { {data.ledger.slice(0, 3).map((entry) => (
- {entry.title} + {ledgerTitle(entry)} {ledgerPrimaryAmount(entry)}
diff --git a/apps/miniapp/src/i18n.ts b/apps/miniapp/src/i18n.ts index 0fd6765..ba0cebf 100644 --- a/apps/miniapp/src/i18n.ts +++ b/apps/miniapp/src/i18n.ts @@ -57,6 +57,10 @@ export const dictionary = { purchasesEmpty: 'No shared purchases recorded for this cycle yet.', utilityLedgerTitle: 'Utility bills', utilityLedgerEmpty: 'No utility bills recorded for this cycle yet.', + paymentsTitle: 'Payments', + paymentsEmpty: 'No payment confirmations recorded for this cycle yet.', + paymentLedgerRent: 'Rent payment', + paymentLedgerUtilities: 'Utilities payment', ledgerActorFallback: 'Household', shareRent: 'Rent', shareUtilities: 'Utilities', @@ -176,6 +180,10 @@ export const dictionary = { purchasesEmpty: 'Пока нет общих покупок в этом цикле.', utilityLedgerTitle: 'Коммунальные платежи', utilityLedgerEmpty: 'Пока нет коммунальных платежей в этом цикле.', + paymentsTitle: 'Оплаты', + paymentsEmpty: 'В этом цикле пока нет подтверждённых оплат.', + paymentLedgerRent: 'Оплата аренды', + paymentLedgerUtilities: 'Оплата коммуналки', ledgerActorFallback: 'Household', shareRent: 'Аренда', shareUtilities: 'Коммуналка', diff --git a/apps/miniapp/src/miniapp-api.ts b/apps/miniapp/src/miniapp-api.ts index 42f69cb..89c63ce 100644 --- a/apps/miniapp/src/miniapp-api.ts +++ b/apps/miniapp/src/miniapp-api.ts @@ -88,8 +88,9 @@ export interface MiniAppDashboard { }[] ledger: { id: string - kind: 'purchase' | 'utility' + kind: 'purchase' | 'utility' | 'payment' title: string + paymentKind: 'rent' | 'utilities' | null amountMajor: string currency: 'USD' | 'GEL' displayAmountMajor: string diff --git a/packages/application/src/finance-command-service.test.ts b/packages/application/src/finance-command-service.test.ts index 8bdda46..a5288f6 100644 --- a/packages/application/src/finance-command-service.test.ts +++ b/packages/application/src/finance-command-service.test.ts @@ -32,6 +32,14 @@ class FinanceRepositoryStub implements FinanceRepository { 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 @@ -126,7 +134,7 @@ class FinanceRepositoryStub implements FinanceRepository { } async listPaymentRecordsForCycle() { - return [] + return this.paymentRecords } async listParsedPurchasesForRange(): Promise { @@ -345,6 +353,16 @@ describe('createFinanceCommandService', () => { 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() @@ -355,18 +373,20 @@ describe('createFinanceCommandService', () => { 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']) - expect(dashboard?.ledger.map((entry) => entry.currency)).toEqual(['GEL', 'GEL']) - expect(dashboard?.ledger.map((entry) => entry.displayCurrency)).toEqual(['GEL', 'GEL']) + 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 0.00 GEL, remaining 990.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: 0.00 GEL', - 'Total remaining: 2010.00 GEL' + 'Total paid: 500.00 GEL', + 'Total remaining: 1510.00 GEL' ].join('\n') ) expect(repository.replacedSnapshot).not.toBeNull() diff --git a/packages/application/src/finance-command-service.ts b/packages/application/src/finance-command-service.ts index 21e3d8e..b21254f 100644 --- a/packages/application/src/finance-command-service.ts +++ b/packages/application/src/finance-command-service.ts @@ -4,6 +4,7 @@ import type { ExchangeRateProvider, FinanceCycleRecord, FinanceMemberRecord, + FinancePaymentKind, FinanceRentRuleRecord, FinanceRepository, HouseholdConfigurationRepository @@ -93,7 +94,7 @@ export interface FinanceDashboardMemberLine { export interface FinanceDashboardLedgerEntry { id: string - kind: 'purchase' | 'utility' + kind: 'purchase' | 'utility' | 'payment' title: string amount: Money currency: CurrencyCode @@ -103,6 +104,7 @@ export interface FinanceDashboardLedgerEntry { fxEffectiveDate: string | null actorDisplayName: string | null occurredAt: string | null + paymentKind: FinancePaymentKind | null } export interface FinanceDashboard { @@ -379,7 +381,8 @@ async function buildFinanceDashboard( actorDisplayName: bill.createdByMemberId ? (memberNameById.get(bill.createdByMemberId) ?? null) : null, - occurredAt: bill.createdAt.toString() + occurredAt: bill.createdAt.toString(), + paymentKind: null })), ...convertedPurchases.map(({ purchase, converted }) => ({ id: purchase.id, @@ -392,7 +395,22 @@ async function buildFinanceDashboard( fxRateMicros: converted.fxRateMicros, fxEffectiveDate: converted.fxEffectiveDate, actorDisplayName: memberNameById.get(purchase.payerMemberId) ?? null, - occurredAt: purchase.occurredAt?.toString() ?? null + occurredAt: purchase.occurredAt?.toString() ?? null, + paymentKind: null + })), + ...paymentRecords.map((payment) => ({ + id: payment.id, + kind: 'payment' as const, + title: payment.kind, + 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) {