feat(miniapp): show payment activity in dashboard

This commit is contained in:
2026-03-10 17:16:16 +04:00
parent 1b490fa4a5
commit c292518760
7 changed files with 135 additions and 15 deletions

View File

@@ -61,7 +61,16 @@ function repository(
createdAt: instantFromIso('2026-03-12T12:00:00.000Z') 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 () => [ listParsedPurchasesForRange: async () => [
{ {
id: 'purchase-1', id: 'purchase-1',
@@ -272,6 +281,8 @@ describe('createMiniAppDashboardHandler', () => {
period: '2026-03', period: '2026-03',
currency: 'GEL', currency: 'GEL',
totalDueMajor: '2010.00', totalDueMajor: '2010.00',
totalPaidMajor: '500.00',
totalRemainingMajor: '1510.00',
rentSourceAmountMajor: '700.00', rentSourceAmountMajor: '700.00',
rentSourceCurrency: 'USD', rentSourceCurrency: 'USD',
rentDisplayAmountMajor: '1890.00', rentDisplayAmountMajor: '1890.00',
@@ -279,6 +290,8 @@ describe('createMiniAppDashboardHandler', () => {
{ {
displayName: 'Stan', displayName: 'Stan',
netDueMajor: '2010.00', netDueMajor: '2010.00',
paidMajor: '500.00',
remainingMajor: '1510.00',
rentShareMajor: '1890.00', rentShareMajor: '1890.00',
utilityShareMajor: '120.00', utilityShareMajor: '120.00',
purchaseOffsetMajor: '0.00' purchaseOffsetMajor: '0.00'
@@ -294,6 +307,13 @@ describe('createMiniAppDashboardHandler', () => {
title: 'Electricity', title: 'Electricity',
currency: 'GEL', currency: 'GEL',
displayCurrency: 'GEL' displayCurrency: 'GEL'
},
{
kind: 'payment',
title: 'rent',
paymentKind: 'rent',
currency: 'GEL',
displayCurrency: 'GEL'
} }
] ]
} }

View File

@@ -111,6 +111,7 @@ export function createMiniAppDashboardHandler(options: {
id: entry.id, id: entry.id,
kind: entry.kind, kind: entry.kind,
title: entry.title, title: entry.title,
paymentKind: entry.paymentKind,
amountMajor: entry.amount.toMajorString(), amountMajor: entry.amount.toMajorString(),
currency: entry.currency, currency: entry.currency,
displayAmountMajor: entry.displayAmount.toMajorString(), displayAmountMajor: entry.displayAmount.toMajorString(),

View File

@@ -257,8 +257,21 @@ function App() {
const utilityLedger = createMemo(() => const utilityLedger = createMemo(() =>
(dashboard()?.ledger ?? []).filter((entry) => entry.kind === 'utility') (dashboard()?.ledger ?? []).filter((entry) => entry.kind === 'utility')
) )
const paymentLedger = createMemo(() =>
(dashboard()?.ledger ?? []).filter((entry) => entry.kind === 'payment')
)
const webApp = getTelegramWebApp() 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) { async function loadDashboard(initData: string) {
try { try {
setDashboard(await fetchMiniAppDashboard(initData)) setDashboard(await fetchMiniAppDashboard(initData))
@@ -455,6 +468,7 @@ function App() {
id: 'purchase-1', id: 'purchase-1',
kind: 'purchase', kind: 'purchase',
title: 'Soap', title: 'Soap',
paymentKind: null,
amountMajor: '30.00', amountMajor: '30.00',
currency: 'GEL', currency: 'GEL',
displayAmountMajor: '30.00', displayAmountMajor: '30.00',
@@ -468,6 +482,7 @@ function App() {
id: 'utility-1', id: 'utility-1',
kind: 'utility', kind: 'utility',
title: 'Electricity', title: 'Electricity',
paymentKind: null,
amountMajor: '120.00', amountMajor: '120.00',
currency: 'GEL', currency: 'GEL',
displayAmountMajor: '120.00', displayAmountMajor: '120.00',
@@ -476,6 +491,20 @@ function App() {
fxEffectiveDate: null, fxEffectiveDate: null,
actorDisplayName: 'Alice', actorDisplayName: 'Alice',
occurredAt: '2026-03-12T12:00:00.000Z' 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) => ( {purchaseLedger().map((entry) => (
<article class="ledger-item"> <article class="ledger-item">
<header> <header>
<strong>{entry.title}</strong> <strong>{ledgerTitle(entry)}</strong>
<span>{ledgerPrimaryAmount(entry)}</span> <span>{ledgerPrimaryAmount(entry)}</span>
</header> </header>
<Show when={ledgerSecondaryAmount(entry)}> <Show when={ledgerSecondaryAmount(entry)}>
@@ -1009,7 +1038,30 @@ function App() {
{utilityLedger().map((entry) => ( {utilityLedger().map((entry) => (
<article class="ledger-item"> <article class="ledger-item">
<header> <header>
<strong>{entry.title}</strong> <strong>{ledgerTitle(entry)}</strong>
<span>{ledgerPrimaryAmount(entry)}</span>
</header>
<Show when={ledgerSecondaryAmount(entry)}>
{(secondary) => <p>{secondary()}</p>}
</Show>
<p>{entry.actorDisplayName ?? copy().ledgerActorFallback}</p>
</article>
))}
</div>
)}
</article>
<article class="balance-item">
<header>
<strong>{copy().paymentsTitle}</strong>
</header>
{paymentLedger().length === 0 ? (
<p>{copy().paymentsEmpty}</p>
) : (
<div class="ledger-list">
{paymentLedger().map((entry) => (
<article class="ledger-item">
<header>
<strong>{ledgerTitle(entry)}</strong>
<span>{ledgerPrimaryAmount(entry)}</span> <span>{ledgerPrimaryAmount(entry)}</span>
</header> </header>
<Show when={ledgerSecondaryAmount(entry)}> <Show when={ledgerSecondaryAmount(entry)}>
@@ -1775,7 +1827,7 @@ function App() {
{data.ledger.slice(0, 3).map((entry) => ( {data.ledger.slice(0, 3).map((entry) => (
<article class="ledger-item"> <article class="ledger-item">
<header> <header>
<strong>{entry.title}</strong> <strong>{ledgerTitle(entry)}</strong>
<span>{ledgerPrimaryAmount(entry)}</span> <span>{ledgerPrimaryAmount(entry)}</span>
</header> </header>
<Show when={ledgerSecondaryAmount(entry)}> <Show when={ledgerSecondaryAmount(entry)}>

View File

@@ -57,6 +57,10 @@ export const dictionary = {
purchasesEmpty: 'No shared purchases recorded for this cycle yet.', purchasesEmpty: 'No shared purchases recorded for this cycle yet.',
utilityLedgerTitle: 'Utility bills', utilityLedgerTitle: 'Utility bills',
utilityLedgerEmpty: 'No utility bills recorded for this cycle yet.', 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', ledgerActorFallback: 'Household',
shareRent: 'Rent', shareRent: 'Rent',
shareUtilities: 'Utilities', shareUtilities: 'Utilities',
@@ -176,6 +180,10 @@ export const dictionary = {
purchasesEmpty: 'Пока нет общих покупок в этом цикле.', purchasesEmpty: 'Пока нет общих покупок в этом цикле.',
utilityLedgerTitle: 'Коммунальные платежи', utilityLedgerTitle: 'Коммунальные платежи',
utilityLedgerEmpty: 'Пока нет коммунальных платежей в этом цикле.', utilityLedgerEmpty: 'Пока нет коммунальных платежей в этом цикле.',
paymentsTitle: 'Оплаты',
paymentsEmpty: 'В этом цикле пока нет подтверждённых оплат.',
paymentLedgerRent: 'Оплата аренды',
paymentLedgerUtilities: 'Оплата коммуналки',
ledgerActorFallback: 'Household', ledgerActorFallback: 'Household',
shareRent: 'Аренда', shareRent: 'Аренда',
shareUtilities: 'Коммуналка', shareUtilities: 'Коммуналка',

View File

@@ -88,8 +88,9 @@ export interface MiniAppDashboard {
}[] }[]
ledger: { ledger: {
id: string id: string
kind: 'purchase' | 'utility' kind: 'purchase' | 'utility' | 'payment'
title: string title: string
paymentKind: 'rent' | 'utilities' | null
amountMajor: string amountMajor: string
currency: 'USD' | 'GEL' currency: 'USD' | 'GEL'
displayAmountMajor: string displayAmountMajor: string

View File

@@ -32,6 +32,14 @@ class FinanceRepositoryStub implements FinanceRepository {
createdByMemberId: string | null createdByMemberId: string | null
createdAt: Instant createdAt: Instant
}[] = [] }[] = []
paymentRecords: readonly {
id: string
memberId: string
kind: 'rent' | 'utilities'
amountMinor: bigint
currency: 'USD' | 'GEL'
recordedAt: Instant
}[] = []
lastSavedRentRule: { lastSavedRentRule: {
period: string period: string
amountMinor: bigint amountMinor: bigint
@@ -126,7 +134,7 @@ class FinanceRepositoryStub implements FinanceRepository {
} }
async listPaymentRecordsForCycle() { async listPaymentRecordsForCycle() {
return [] return this.paymentRecords
} }
async listParsedPurchasesForRange(): Promise<readonly FinanceParsedPurchaseRecord[]> { async listParsedPurchasesForRange(): Promise<readonly FinanceParsedPurchaseRecord[]> {
@@ -345,6 +353,16 @@ describe('createFinanceCommandService', () => {
occurredAt: instantFromIso('2026-03-12T11:00:00.000Z') 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 service = createService(repository)
const dashboard = await service.generateDashboard() const dashboard = await service.generateDashboard()
@@ -355,18 +373,20 @@ describe('createFinanceCommandService', () => {
expect(dashboard?.rentSourceAmount.toMajorString()).toBe('700.00') expect(dashboard?.rentSourceAmount.toMajorString()).toBe('700.00')
expect(dashboard?.rentDisplayAmount.toMajorString()).toBe('1890.00') expect(dashboard?.rentDisplayAmount.toMajorString()).toBe('1890.00')
expect(dashboard?.members.map((line) => line.netDue.amountMinor)).toEqual([99000n, 102000n]) 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.title)).toEqual(['Soap', 'Electricity', 'rent'])
expect(dashboard?.ledger.map((entry) => entry.currency)).toEqual(['GEL', 'GEL']) expect(dashboard?.ledger.map((entry) => entry.kind)).toEqual(['purchase', 'utility', 'payment'])
expect(dashboard?.ledger.map((entry) => entry.displayCurrency)).toEqual(['GEL', 'GEL']) 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( expect(statement).toBe(
[ [
'Statement for 2026-03', 'Statement for 2026-03',
'Rent: 700.00 USD (~1890.00 GEL)', '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', '- Bob: due 1020.00 GEL, paid 0.00 GEL, remaining 1020.00 GEL',
'Total due: 2010.00 GEL', 'Total due: 2010.00 GEL',
'Total paid: 0.00 GEL', 'Total paid: 500.00 GEL',
'Total remaining: 2010.00 GEL' 'Total remaining: 1510.00 GEL'
].join('\n') ].join('\n')
) )
expect(repository.replacedSnapshot).not.toBeNull() expect(repository.replacedSnapshot).not.toBeNull()

View File

@@ -4,6 +4,7 @@ import type {
ExchangeRateProvider, ExchangeRateProvider,
FinanceCycleRecord, FinanceCycleRecord,
FinanceMemberRecord, FinanceMemberRecord,
FinancePaymentKind,
FinanceRentRuleRecord, FinanceRentRuleRecord,
FinanceRepository, FinanceRepository,
HouseholdConfigurationRepository HouseholdConfigurationRepository
@@ -93,7 +94,7 @@ export interface FinanceDashboardMemberLine {
export interface FinanceDashboardLedgerEntry { export interface FinanceDashboardLedgerEntry {
id: string id: string
kind: 'purchase' | 'utility' kind: 'purchase' | 'utility' | 'payment'
title: string title: string
amount: Money amount: Money
currency: CurrencyCode currency: CurrencyCode
@@ -103,6 +104,7 @@ export interface FinanceDashboardLedgerEntry {
fxEffectiveDate: string | null fxEffectiveDate: string | null
actorDisplayName: string | null actorDisplayName: string | null
occurredAt: string | null occurredAt: string | null
paymentKind: FinancePaymentKind | null
} }
export interface FinanceDashboard { export interface FinanceDashboard {
@@ -379,7 +381,8 @@ async function buildFinanceDashboard(
actorDisplayName: bill.createdByMemberId actorDisplayName: bill.createdByMemberId
? (memberNameById.get(bill.createdByMemberId) ?? null) ? (memberNameById.get(bill.createdByMemberId) ?? null)
: null, : null,
occurredAt: bill.createdAt.toString() occurredAt: bill.createdAt.toString(),
paymentKind: null
})), })),
...convertedPurchases.map(({ purchase, converted }) => ({ ...convertedPurchases.map(({ purchase, converted }) => ({
id: purchase.id, id: purchase.id,
@@ -392,7 +395,22 @@ async function buildFinanceDashboard(
fxRateMicros: converted.fxRateMicros, fxRateMicros: converted.fxRateMicros,
fxEffectiveDate: converted.fxEffectiveDate, fxEffectiveDate: converted.fxEffectiveDate,
actorDisplayName: memberNameById.get(purchase.payerMemberId) ?? null, 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) => { ].sort((left, right) => {
if (left.occurredAt === right.occurredAt) { if (left.occurredAt === right.occurredAt) {