mirror of
https://github.com/whekin/household-bot.git
synced 2026-03-31 14:04:04 +00:00
feat(miniapp): show payment activity in dashboard
This commit is contained in:
@@ -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'
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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)}>
|
||||||
|
|||||||
@@ -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: 'Коммуналка',
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user