- {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} + + ))} + + )} +
- {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) {
+ + {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) {
- {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) {