feat(miniapp): redesign admin payment management

This commit is contained in:
2026-03-23 22:17:51 +04:00
parent 5af14e101e
commit 621bd75148
13 changed files with 983 additions and 161 deletions

View File

@@ -124,6 +124,27 @@ export function createMiniAppDashboardHandler(options: {
})), })),
explanations: line.explanations explanations: line.explanations
})), })),
paymentPeriods: (dashboard.paymentPeriods ?? []).map((period) => ({
period: period.period,
utilityTotalMajor: period.utilityTotal.toMajorString(),
hasOverdueBalance: period.hasOverdueBalance,
isCurrentPeriod: period.isCurrentPeriod,
kinds: period.kinds.map((kind) => ({
kind: kind.kind,
totalDueMajor: kind.totalDue.toMajorString(),
totalPaidMajor: kind.totalPaid.toMajorString(),
totalRemainingMajor: kind.totalRemaining.toMajorString(),
unresolvedMembers: kind.unresolvedMembers.map((member) => ({
memberId: member.memberId,
displayName: member.displayName,
suggestedAmountMajor: member.suggestedAmount.toMajorString(),
baseDueMajor: member.baseDue.toMajorString(),
paidMajor: member.paid.toMajorString(),
remainingMajor: member.remaining.toMajorString(),
effectivelySettled: member.effectivelySettled
}))
}))
})),
ledger: dashboard.ledger.map((entry) => ({ ledger: dashboard.ledger.map((entry) => ({
id: entry.id, id: entry.id,
kind: entry.kind, kind: entry.kind,

View File

@@ -5,6 +5,7 @@ import { Settings } from 'lucide-solid'
import { useSession } from '../../contexts/session-context' import { useSession } from '../../contexts/session-context'
import { useI18n } from '../../contexts/i18n-context' import { useI18n } from '../../contexts/i18n-context'
import { useDashboard } from '../../contexts/dashboard-context' import { useDashboard } from '../../contexts/dashboard-context'
import { formatCyclePeriod } from '../../lib/dates'
import { NavigationTabs } from './navigation-tabs' import { NavigationTabs } from './navigation-tabs'
import { Badge } from '../ui/badge' import { Badge } from '../ui/badge'
import { Button, IconButton } from '../ui/button' import { Button, IconButton } from '../ui/button'
@@ -234,7 +235,9 @@ export function AppShell(props: ParentProps) {
</Show> </Show>
<article class="testing-card__section"> <article class="testing-card__section">
<span>{copy().testingPeriodCurrentLabel ?? ''}</span> <span>{copy().testingPeriodCurrentLabel ?? ''}</span>
<strong>{dashboard()?.period ?? '—'}</strong> <strong>
{dashboard()?.period ? formatCyclePeriod(dashboard()!.period, locale()) : '—'}
</strong>
</article> </article>
<div class="testing-card__actions testing-card__actions--stack"> <div class="testing-card__actions testing-card__actions--stack">
<Field label={copy().testingPeriodOverrideLabel ?? ''} wide> <Field label={copy().testingPeriodOverrideLabel ?? ''} wide>

View File

@@ -377,6 +377,59 @@ function createDashboard(state: {
members: MiniAppDashboard['members'] members: MiniAppDashboard['members']
ledger?: MiniAppDashboard['ledger'] ledger?: MiniAppDashboard['ledger']
}): MiniAppDashboard { }): MiniAppDashboard {
const paymentPeriods: MiniAppDashboard['paymentPeriods'] = [
{
period: '2026-03',
utilityTotalMajor: '286.00',
hasOverdueBalance: state.members.some((member) => member.overduePayments.length > 0),
isCurrentPeriod: true,
kinds: [
{
kind: 'rent',
totalDueMajor: state.members
.reduce((sum, member) => sum + Number(member.rentShareMajor), 0)
.toFixed(2),
totalPaidMajor: '0.00',
totalRemainingMajor: state.members
.reduce((sum, member) => sum + Number(member.rentShareMajor), 0)
.toFixed(2),
unresolvedMembers: state.members
.filter((member) => Number(member.rentShareMajor) > 0)
.map((member) => ({
memberId: member.memberId,
displayName: member.displayName,
suggestedAmountMajor: member.rentShareMajor,
baseDueMajor: member.rentShareMajor,
paidMajor: '0.00',
remainingMajor: member.rentShareMajor,
effectivelySettled: false
}))
},
{
kind: 'utilities',
totalDueMajor: state.members
.reduce((sum, member) => sum + Number(member.utilityShareMajor), 0)
.toFixed(2),
totalPaidMajor: '0.00',
totalRemainingMajor: state.members
.reduce((sum, member) => sum + Number(member.utilityShareMajor), 0)
.toFixed(2),
unresolvedMembers: state.members
.filter((member) => Number(member.utilityShareMajor) > 0)
.map((member) => ({
memberId: member.memberId,
displayName: member.displayName,
suggestedAmountMajor: member.utilityShareMajor,
baseDueMajor: member.utilityShareMajor,
paidMajor: '0.00',
remainingMajor: member.utilityShareMajor,
effectivelySettled: false
}))
}
]
}
]
return { return {
period: '2026-03', period: '2026-03',
currency: 'GEL', currency: 'GEL',
@@ -396,6 +449,7 @@ function createDashboard(state: {
rentFxRateMicros: '2760000', rentFxRateMicros: '2760000',
rentFxEffectiveDate: '2026-03-17', rentFxEffectiveDate: '2026-03-17',
members: state.members, members: state.members,
paymentPeriods,
ledger: state.ledger ?? baseLedger() ledger: state.ledger ?? baseLedger()
} }
} }

View File

@@ -133,6 +133,7 @@ export const dictionary = {
purchasesTitle: 'Shared purchases', purchasesTitle: 'Shared purchases',
purchasesEmpty: 'No shared purchases recorded for this cycle yet.', purchasesEmpty: 'No shared purchases recorded for this cycle yet.',
utilityLedgerTitle: 'Utility bills', utilityLedgerTitle: 'Utility bills',
utilityHistoryTitle: 'Utilities by period',
utilityLedgerEmpty: 'No utility bills recorded for this cycle yet.', utilityLedgerEmpty: 'No utility bills recorded for this cycle yet.',
paymentsTitle: 'Payments', paymentsTitle: 'Payments',
paymentsEmpty: 'No payment confirmations recorded for this cycle yet.', paymentsEmpty: 'No payment confirmations recorded for this cycle yet.',
@@ -193,8 +194,17 @@ export const dictionary = {
purchaseEditorBody: 'Review the purchase details and adjust the split only when needed.', purchaseEditorBody: 'Review the purchase details and adjust the split only when needed.',
purchasePayerLabel: 'Paid by', purchasePayerLabel: 'Paid by',
paymentsAdminTitle: 'Payments', paymentsAdminTitle: 'Payments',
paymentsAdminBody: 'Add, fix, or remove payment records for the current cycle.', paymentsAdminBody:
'Resolve open rent and utility obligations period by period, or add a custom payment when needed.',
paymentsAddAction: 'Add payment', paymentsAddAction: 'Add payment',
paymentsResolveAction: 'Resolve',
paymentsCustomAmountAction: 'Custom amount',
paymentsHistoryTitle: 'Payment history',
paymentsPeriodTitle: 'Period {period}',
paymentsPeriodCurrentBody: 'Current payment obligations for this billing period.',
paymentsPeriodOverdueBody: 'This period still has overdue base rent or utility payments.',
paymentsPeriodHistoryBody: 'Review and resolve older payment periods from here.',
paymentsBaseDueLabel: 'Base due {amount} · Remaining {remaining}',
copiedToast: 'Copied!', copiedToast: 'Copied!',
quickPaymentTitle: 'Record payment', quickPaymentTitle: 'Record payment',
quickPaymentBody: 'Quickly record a {type} payment for the current cycle.', quickPaymentBody: 'Quickly record a {type} payment for the current cycle.',
@@ -513,6 +523,7 @@ export const dictionary = {
purchasesTitle: 'Общие покупки', purchasesTitle: 'Общие покупки',
purchasesEmpty: 'Пока нет общих покупок в этом цикле.', purchasesEmpty: 'Пока нет общих покупок в этом цикле.',
utilityLedgerTitle: 'Коммунальные платежи', utilityLedgerTitle: 'Коммунальные платежи',
utilityHistoryTitle: 'Коммуналка по периодам',
utilityLedgerEmpty: 'Пока нет коммунальных платежей в этом цикле.', utilityLedgerEmpty: 'Пока нет коммунальных платежей в этом цикле.',
paymentsTitle: 'Оплаты', paymentsTitle: 'Оплаты',
paymentsEmpty: 'В этом цикле пока нет подтверждённых оплат.', paymentsEmpty: 'В этом цикле пока нет подтверждённых оплат.',
@@ -575,8 +586,17 @@ export const dictionary = {
'Проверь покупку и меняй детали разделения только если это действительно нужно.', 'Проверь покупку и меняй детали разделения только если это действительно нужно.',
purchasePayerLabel: 'Оплатил', purchasePayerLabel: 'Оплатил',
paymentsAdminTitle: 'Оплаты', paymentsAdminTitle: 'Оплаты',
paymentsAdminBody: 'Добавляй, исправляй или удаляй оплаты за текущий цикл.', paymentsAdminBody:
'Закрывай открытые платежи по аренде и коммуналке по периодам или добавляй оплату с произвольной суммой.',
paymentsAddAction: 'Добавить оплату', paymentsAddAction: 'Добавить оплату',
paymentsResolveAction: 'Закрыть',
paymentsCustomAmountAction: 'Своя сумма',
paymentsHistoryTitle: 'История оплат',
paymentsPeriodTitle: 'Период {period}',
paymentsPeriodCurrentBody: 'Текущие обязательства по оплатам за этот биллинговый период.',
paymentsPeriodOverdueBody: 'В этом периоде остались просроченные базовые оплаты.',
paymentsPeriodHistoryBody: 'Здесь можно быстро проверить и закрыть старые периоды.',
paymentsBaseDueLabel: 'База {amount} · Осталось {remaining}',
copiedToast: 'Скопировано!', copiedToast: 'Скопировано!',
quickPaymentTitle: 'Записать оплату', quickPaymentTitle: 'Записать оплату',
quickPaymentBody: 'Быстро запиши оплату {type} за текущий цикл.', quickPaymentBody: 'Быстро запиши оплату {type} за текущий цикл.',

View File

@@ -589,11 +589,13 @@ a {
.ui-badge { .ui-badge {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center;
padding: 2px 8px; padding: 2px 8px;
border-radius: var(--radius-full); border-radius: var(--radius-full);
font-size: var(--text-xs); font-size: var(--text-xs);
font-weight: 600; font-weight: 600;
letter-spacing: 0.02em; letter-spacing: 0.02em;
white-space: nowrap;
background: var(--accent-soft); background: var(--accent-soft);
color: var(--accent); color: var(--accent);
border: none; border: none;
@@ -739,7 +741,8 @@ a {
transition: transform var(--transition-base); transition: transform var(--transition-base);
} }
.ui-collapsible[data-expanded] .ui-collapsible__chevron { .ui-collapsible__trigger[data-expanded] .ui-collapsible__chevron,
.ui-collapsible__trigger[aria-expanded='true'] .ui-collapsible__chevron {
transform: rotate(180deg); transform: rotate(180deg);
} }
@@ -1532,6 +1535,15 @@ a {
flex-direction: column; flex-direction: column;
} }
.editable-list-section-title {
font-size: var(--text-xs);
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--text-secondary);
margin-bottom: var(--spacing-sm);
}
.editable-list-row { .editable-list-row {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
@@ -1554,6 +1566,15 @@ a {
background: var(--bg-input); background: var(--bg-input);
} }
.editable-list-row--static {
cursor: default;
}
.editable-list-row--stacked {
align-items: flex-start;
gap: var(--spacing-sm);
}
.editable-list-row:disabled { .editable-list-row:disabled {
cursor: default; cursor: default;
} }
@@ -1591,6 +1612,13 @@ a {
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
} }
.editable-list-inline-actions {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: var(--spacing-xs);
}
.editable-list-row__secondary { .editable-list-row__secondary {
font-size: var(--text-xs); font-size: var(--text-xs);
color: var(--text-muted); color: var(--text-muted);

View File

@@ -99,6 +99,25 @@ export function ledgerSecondaryAmount(entry: MiniAppDashboard['ledger'][number])
return `${entry.amountMajor} ${entry.currency}` return `${entry.amountMajor} ${entry.currency}`
} }
export function localizedCurrencyLabel(
locale: 'en' | 'ru',
currency: MiniAppDashboard['currency']
): string {
if (locale === 'ru' && currency === 'GEL') {
return 'Лари'
}
return currency
}
export function formatMoneyLabel(
amountMajor: string,
currency: MiniAppDashboard['currency'],
locale: 'en' | 'ru'
): string {
return `${amountMajor} ${localizedCurrencyLabel(locale, currency)}`
}
export function cycleUtilityBillDrafts( export function cycleUtilityBillDrafts(
bills: MiniAppAdminCycleState['utilityBills'] bills: MiniAppAdminCycleState['utilityBills']
): Record<string, UtilityBillDraft> { ): Record<string, UtilityBillDraft> {
@@ -407,29 +426,30 @@ export function resolvedMemberAbsencePolicy(
* Bug #5 fix: Prefill with the remaining amount for the selected payment kind. * Bug #5 fix: Prefill with the remaining amount for the selected payment kind.
*/ */
export function computePaymentPrefill( export function computePaymentPrefill(
member: MiniAppDashboard['members'][number] | null | undefined, dashboard: MiniAppDashboard | null | undefined,
kind: 'rent' | 'utilities' memberId: string,
kind: 'rent' | 'utilities',
period: string
): string { ): string {
if (!member) { if (!dashboard) {
return '' return ''
} }
const rentMinor = majorStringToMinor(member.rentShareMajor) const periodSummary = (dashboard.paymentPeriods ?? []).find((entry) => entry.period === period)
const utilityMinor = majorStringToMinor(member.utilityShareMajor) const kindSummary = periodSummary?.kinds.find((entry) => entry.kind === kind)
const remainingMinor = majorStringToMinor(member.remainingMajor) const memberSummary = kindSummary?.unresolvedMembers.find((entry) => entry.memberId === memberId)
if (!memberSummary) {
if (remainingMinor <= 0n) {
return '0.00' return '0.00'
} }
// Estimate unpaid per kind (simplified: if total due matches, let prefillMinor = majorStringToMinor(memberSummary.remainingMajor)
// use share for that kind as an approximation) if (periodSummary?.isCurrentPeriod && dashboard.paymentBalanceAdjustmentPolicy === kind) {
const dueMinor = kind === 'rent' ? rentMinor : utilityMinor const member = dashboard.members.find((entry) => entry.memberId === memberId)
if (dueMinor <= 0n) { const purchaseOffsetMinor = majorStringToMinor(member?.purchaseOffsetMajor ?? '0.00')
return '0.00' if (purchaseOffsetMinor > 0n) {
prefillMinor += purchaseOffsetMinor
}
} }
// If remaining is less than due for this kind, use remaining return minorToMajorString(prefillMinor > 0n ? prefillMinor : 0n)
const prefillMinor = remainingMinor < dueMinor ? remainingMinor : dueMinor
return minorToMajorString(prefillMinor)
} }

View File

@@ -137,6 +137,27 @@ export interface MiniAppDashboard {
}[] }[]
explanations: readonly string[] explanations: readonly string[]
}[] }[]
paymentPeriods?: {
period: string
utilityTotalMajor: string
hasOverdueBalance: boolean
isCurrentPeriod: boolean
kinds: {
kind: 'rent' | 'utilities'
totalDueMajor: string
totalPaidMajor: string
totalRemainingMajor: string
unresolvedMembers: {
memberId: string
displayName: string
suggestedAmountMajor: string
baseDueMajor: string
paidMajor: string
remainingMajor: string
effectivelySettled: boolean
}[]
}[]
}[]
ledger: { ledger: {
id: string id: string
kind: 'purchase' | 'utility' | 'payment' kind: 'purchase' | 'utility' | 'payment'

View File

@@ -13,11 +13,12 @@ import { Input } from '../components/ui/input'
import { Modal } from '../components/ui/dialog' import { Modal } from '../components/ui/dialog'
import { Toast } from '../components/ui/toast' import { Toast } from '../components/ui/toast'
import { Skeleton } from '../components/ui/skeleton' import { Skeleton } from '../components/ui/skeleton'
import { ledgerPrimaryAmount } from '../lib/ledger-helpers' import { formatMoneyLabel, localizedCurrencyLabel } from '../lib/ledger-helpers'
import { majorStringToMinor, minorToMajorString } from '../lib/money' import { majorStringToMinor, minorToMajorString } from '../lib/money'
import { import {
compareTodayToPeriodDay, compareTodayToPeriodDay,
daysUntilPeriodDay, daysUntilPeriodDay,
formatCyclePeriod,
formatPeriodDay, formatPeriodDay,
nextCyclePeriod, nextCyclePeriod,
parseCalendarDate parseCalendarDate
@@ -50,11 +51,17 @@ function paymentProposalMinor(
? majorStringToMinor(member.rentShareMajor) ? majorStringToMinor(member.rentShareMajor)
: majorStringToMinor(member.utilityShareMajor) : majorStringToMinor(member.utilityShareMajor)
if (data.paymentBalanceAdjustmentPolicy === kind) { const proposalMinor =
return baseMinor + purchaseOffsetMinor data.paymentBalanceAdjustmentPolicy === kind ? baseMinor + purchaseOffsetMinor : baseMinor
if (kind !== 'rent' || proposalMinor <= 0n) {
return proposalMinor
} }
return baseMinor const wholeMinor = proposalMinor / 100n
const remainderMinor = proposalMinor % 100n
return (remainderMinor >= 50n ? wholeMinor + 1n : wholeMinor) * 100n
} }
function paymentRemainingMinor( function paymentRemainingMinor(
@@ -380,7 +387,10 @@ export default function HomeRoute() {
paymentRemainingMinor(data(), member(), 'utilities') paymentRemainingMinor(data(), member(), 'utilities')
const modes = () => currentPaymentModes() const modes = () => currentPaymentModes()
const currency = () => data().currency const formatMajorAmount = (
amountMajor: string,
currencyCode: 'USD' | 'GEL' = data().currency
) => formatMoneyLabel(amountMajor, currencyCode, locale())
const timezone = () => data().timezone const timezone = () => data().timezone
const period = () => effectivePeriod() ?? data().period const period = () => effectivePeriod() ?? data().period
const today = () => todayOverride() const today = () => todayOverride()
@@ -470,15 +480,17 @@ export default function HomeRoute() {
<div class="balance-card__amounts"> <div class="balance-card__amounts">
<div class="balance-card__row balance-card__row--subtotal"> <div class="balance-card__row balance-card__row--subtotal">
<span>{copy().finalDue}</span> <span>{copy().finalDue}</span>
<strong> <strong>{formatMajorAmount(overdue().amountMajor)}</strong>
{overdue().amountMajor} {currency()}
</strong>
</div> </div>
<div class="balance-card__row"> <div class="balance-card__row">
<span> <span>
{copy().homeOverduePeriodsLabel.replace( {copy().homeOverduePeriodsLabel.replace(
'{periods}', '{periods}',
overdue().periods.join(', ') overdue()
.periods.map((period) =>
formatCyclePeriod(period, locale())
)
.join(', ')
)} )}
</span> </span>
</div> </div>
@@ -513,15 +525,17 @@ export default function HomeRoute() {
<div class="balance-card__amounts"> <div class="balance-card__amounts">
<div class="balance-card__row balance-card__row--subtotal"> <div class="balance-card__row balance-card__row--subtotal">
<span>{copy().finalDue}</span> <span>{copy().finalDue}</span>
<strong> <strong>{formatMajorAmount(overdue().amountMajor)}</strong>
{overdue().amountMajor} {currency()}
</strong>
</div> </div>
<div class="balance-card__row"> <div class="balance-card__row">
<span> <span>
{copy().homeOverduePeriodsLabel.replace( {copy().homeOverduePeriodsLabel.replace(
'{periods}', '{periods}',
overdue().periods.join(', ') overdue()
.periods.map((period) =>
formatCyclePeriod(period, locale())
)
.join(', ')
)} )}
</span> </span>
</div> </div>
@@ -552,7 +566,7 @@ export default function HomeRoute() {
<div class="balance-card__row balance-card__row--subtotal"> <div class="balance-card__row balance-card__row--subtotal">
<span>{copy().finalDue}</span> <span>{copy().finalDue}</span>
<strong> <strong>
{minorToMajorString(utilitiesRemainingMinor())} {currency()} {formatMajorAmount(minorToMajorString(utilitiesRemainingMinor()))}
</strong> </strong>
</div> </div>
<div class="balance-card__row"> <div class="balance-card__row">
@@ -563,30 +577,30 @@ export default function HomeRoute() {
</div> </div>
<div class="balance-card__row"> <div class="balance-card__row">
<span>{copy().baseDue}</span> <span>{copy().baseDue}</span>
<strong> <strong>{formatMajorAmount(member().utilityShareMajor)}</strong>
{member().utilityShareMajor} {currency()}
</strong>
</div> </div>
<Show when={policy() === 'utilities'}> <Show when={policy() === 'utilities'}>
<div class="balance-card__row"> <div class="balance-card__row">
<span>{copy().balanceAdjustmentLabel}</span> <span>{copy().balanceAdjustmentLabel}</span>
<strong> <strong>{formatMajorAmount(member().purchaseOffsetMajor)}</strong>
{member().purchaseOffsetMajor} {currency()}
</strong>
</div> </div>
</Show> </Show>
<Show when={utilityLedger().length > 0}> <Show when={utilityLedger().length > 0}>
<div class="balance-card__row balance-card__row--subtotal"> <div class="balance-card__row balance-card__row--subtotal">
<span>{copy().homeUtilitiesBillsTitle}</span> <span>{copy().homeUtilitiesBillsTitle}</span>
<strong> <strong>{formatMajorAmount(utilityTotalMajor())}</strong>
{utilityTotalMajor()} {currency()}
</strong>
</div> </div>
<For each={utilityLedger()}> <For each={utilityLedger()}>
{(entry) => ( {(entry) => (
<div class="balance-card__row"> <div class="balance-card__row">
<span>{entry.title}</span> <span>{entry.title}</span>
<strong>{ledgerPrimaryAmount(entry)}</strong> <strong>
{formatMoneyLabel(
entry.displayAmountMajor,
entry.displayCurrency,
locale()
)}
</strong>
</div> </div>
)} )}
</For> </For>
@@ -617,7 +631,7 @@ export default function HomeRoute() {
<div class="balance-card__row balance-card__row--subtotal"> <div class="balance-card__row balance-card__row--subtotal">
<span>{copy().finalDue}</span> <span>{copy().finalDue}</span>
<strong> <strong>
{minorToMajorString(rentRemainingMinor())} {currency()} {formatMajorAmount(minorToMajorString(rentRemainingMinor()))}
</strong> </strong>
</div> </div>
<div class="balance-card__row"> <div class="balance-card__row">
@@ -626,16 +640,12 @@ export default function HomeRoute() {
</div> </div>
<div class="balance-card__row"> <div class="balance-card__row">
<span>{copy().baseDue}</span> <span>{copy().baseDue}</span>
<strong> <strong>{formatMajorAmount(member().rentShareMajor)}</strong>
{member().rentShareMajor} {currency()}
</strong>
</div> </div>
<Show when={policy() === 'rent'}> <Show when={policy() === 'rent'}>
<div class="balance-card__row"> <div class="balance-card__row">
<span>{copy().balanceAdjustmentLabel}</span> <span>{copy().balanceAdjustmentLabel}</span>
<strong> <strong>{formatMajorAmount(member().purchaseOffsetMajor)}</strong>
{member().purchaseOffsetMajor} {currency()}
</strong>
</div> </div>
</Show> </Show>
</div> </div>
@@ -924,7 +934,13 @@ export default function HomeRoute() {
{(entry) => ( {(entry) => (
<div class="activity-card__item"> <div class="activity-card__item">
<span class="activity-card__title">{entry.title}</span> <span class="activity-card__title">{entry.title}</span>
<span class="activity-card__amount">{ledgerPrimaryAmount(entry)}</span> <span class="activity-card__amount">
{formatMoneyLabel(
entry.displayAmountMajor,
entry.displayCurrency,
locale()
)}
</span>
</div> </div>
)} )}
</For> </For>
@@ -997,7 +1013,14 @@ export default function HomeRoute() {
/> />
</Field> </Field>
<Field label={copy().quickPaymentCurrencyLabel}> <Field label={copy().quickPaymentCurrencyLabel}>
<Input type="text" value={(dashboard()?.currency as 'USD' | 'GEL') ?? 'GEL'} disabled /> <Input
type="text"
value={localizedCurrencyLabel(
locale(),
(dashboard()?.currency as 'USD' | 'GEL') ?? 'GEL'
)}
disabled
/>
</Field> </Field>
</div> </div>
</Modal> </Modal>

View File

@@ -15,17 +15,19 @@ import { Collapsible } from '../components/ui/collapsible'
import { Toggle } from '../components/ui/toggle' import { Toggle } from '../components/ui/toggle'
import { Skeleton } from '../components/ui/skeleton' import { Skeleton } from '../components/ui/skeleton'
import { import {
ledgerPrimaryAmount, formatMoneyLabel,
ledgerSecondaryAmount, ledgerSecondaryAmount,
purchaseDraftForEntry, purchaseDraftForEntry,
paymentDraftForEntry, paymentDraftForEntry,
computePaymentPrefill, computePaymentPrefill,
localizedCurrencyLabel,
rebalancePurchaseSplit, rebalancePurchaseSplit,
validatePurchaseDraft, validatePurchaseDraft,
type PurchaseDraft, type PurchaseDraft,
type PaymentDraft type PaymentDraft
} from '../lib/ledger-helpers' } from '../lib/ledger-helpers'
import { minorToMajorString, majorStringToMinor } from '../lib/money' import { minorToMajorString, majorStringToMinor } from '../lib/money'
import { formatCyclePeriod, formatFriendlyDate } from '../lib/dates'
import { import {
addMiniAppPurchase, addMiniAppPurchase,
updateMiniAppPurchase, updateMiniAppPurchase,
@@ -39,6 +41,10 @@ import {
type MiniAppDashboard type MiniAppDashboard
} from '../miniapp-api' } from '../miniapp-api'
function joinSubtitleParts(parts: readonly (string | null | undefined)[]): string {
return parts.filter(Boolean).join(' · ')
}
interface ParticipantSplitInputsProps { interface ParticipantSplitInputsProps {
draft: PurchaseDraft draft: PurchaseDraft
updateDraft: (fn: (d: PurchaseDraft) => PurchaseDraft) => void updateDraft: (fn: (d: PurchaseDraft) => PurchaseDraft) => void
@@ -203,7 +209,7 @@ function ParticipantSplitInputs(props: ParticipantSplitInputsProps) {
export default function LedgerRoute() { export default function LedgerRoute() {
const { initData, refreshHouseholdData, session } = useSession() const { initData, refreshHouseholdData, session } = useSession()
const { copy } = useI18n() const { copy, locale } = useI18n()
const { dashboard, loading, effectiveIsAdmin, purchaseLedger, utilityLedger, paymentLedger } = const { dashboard, loading, effectiveIsAdmin, purchaseLedger, utilityLedger, paymentLedger } =
useDashboard() useDashboard()
const unresolvedPurchaseLedger = createMemo(() => const unresolvedPurchaseLedger = createMemo(() =>
@@ -214,26 +220,15 @@ export default function LedgerRoute() {
) )
const paymentPeriodOptions = createMemo(() => { const paymentPeriodOptions = createMemo(() => {
const periods = new Set<string>() const periods = new Set<string>()
if (dashboard()?.period) { for (const summary of dashboard()?.paymentPeriods ?? []) {
periods.add(dashboard()!.period) periods.add(summary.period)
} }
for (const entry of purchaseLedger()) { return [...periods]
if (entry.originPeriod) { .sort()
periods.add(entry.originPeriod) .map((period) => ({ value: period, label: formatCyclePeriod(period, locale()) }))
}
}
for (const member of dashboard()?.members ?? []) {
for (const overdue of member.overduePayments) {
for (const period of overdue.periods) {
periods.add(period)
}
}
}
return [...periods].sort().map((period) => ({ value: period, label: period }))
}) })
const paymentPeriodSummaries = createMemo(() => dashboard()?.paymentPeriods ?? [])
// ── Purchase editor ────────────────────────────── // ── Purchase editor ──────────────────────────────
const [editingPurchase, setEditingPurchase] = createSignal< const [editingPurchase, setEditingPurchase] = createSignal<
@@ -294,6 +289,7 @@ export default function LedgerRoute() {
period: dashboard()?.period ?? '' period: dashboard()?.period ?? ''
}) })
const [addingPayment, setAddingPayment] = createSignal(false) const [addingPayment, setAddingPayment] = createSignal(false)
const [paymentActionError, setPaymentActionError] = createSignal<string | null>(null)
const addPurchaseButtonText = createMemo(() => { const addPurchaseButtonText = createMemo(() => {
if (addingPurchase()) return copy().savingPurchase if (addingPurchase()) return copy().savingPurchase
@@ -543,6 +539,7 @@ export default function LedgerRoute() {
setAddingPayment(true) setAddingPayment(true)
try { try {
setPaymentActionError(null)
await addMiniAppPayment(data, { await addMiniAppPayment(data, {
memberId: draft.memberId, memberId: draft.memberId,
kind: draft.kind, kind: draft.kind,
@@ -559,13 +556,58 @@ export default function LedgerRoute() {
period: dashboard()?.period ?? '' period: dashboard()?.period ?? ''
}) })
await refreshHouseholdData(true, true) await refreshHouseholdData(true, true)
} catch (error) {
setPaymentActionError(error instanceof Error ? error.message : copy().quickPaymentFailed)
} finally { } finally {
setAddingPayment(false) setAddingPayment(false)
} }
} }
async function handleResolveSuggestedPayment(input: {
memberId: string
kind: 'rent' | 'utilities'
period: string
amountMajor: string
}) {
const data = initData()
if (!data) return
setAddingPayment(true)
try {
setPaymentActionError(null)
await addMiniAppPayment(data, {
memberId: input.memberId,
kind: input.kind,
period: input.period,
amountMajor: input.amountMajor,
currency: (dashboard()?.currency as 'USD' | 'GEL') ?? 'GEL'
})
await refreshHouseholdData(true, true)
} catch (error) {
setPaymentActionError(error instanceof Error ? error.message : copy().quickPaymentFailed)
} finally {
setAddingPayment(false)
}
}
function openCustomPayment(input: {
memberId: string
kind: 'rent' | 'utilities'
period: string
}) {
setPaymentActionError(null)
setNewPayment({
memberId: input.memberId,
kind: input.kind,
amountMajor: computePaymentPrefill(dashboard(), input.memberId, input.kind, input.period),
currency: (dashboard()?.currency as 'USD' | 'GEL') ?? 'GEL',
period: input.period
})
setAddPaymentOpen(true)
}
const currencyOptions = () => [ const currencyOptions = () => [
{ value: 'GEL', label: 'GEL' }, { value: 'GEL', label: localizedCurrencyLabel(locale(), 'GEL') },
{ value: 'USD', label: 'USD' } { value: 'USD', label: 'USD' }
] ]
@@ -648,7 +690,9 @@ export default function LedgerRoute() {
> >
<div style={{ display: 'flex', 'flex-direction': 'column', gap: '12px' }}> <div style={{ display: 'flex', 'flex-direction': 'column', gap: '12px' }}>
<div> <div>
<strong>{copy().unresolvedPurchasesTitle}</strong> <div class="editable-list-section-title">
{copy().unresolvedPurchasesTitle}
</div>
<Show <Show
when={unresolvedPurchaseLedger().length > 0} when={unresolvedPurchaseLedger().length > 0}
fallback={<p class="empty-state">{copy().unresolvedPurchasesEmpty}</p>} fallback={<p class="empty-state">{copy().unresolvedPurchasesEmpty}</p>}
@@ -664,13 +708,23 @@ export default function LedgerRoute() {
<div class="editable-list-row__main"> <div class="editable-list-row__main">
<span class="editable-list-row__title">{entry.title}</span> <span class="editable-list-row__title">{entry.title}</span>
<span class="editable-list-row__subtitle"> <span class="editable-list-row__subtitle">
{[entry.actorDisplayName, entry.originPeriod, 'Unresolved'] {joinSubtitleParts([
.filter(Boolean) entry.actorDisplayName,
.join(' · ')} entry.originPeriod
? formatCyclePeriod(entry.originPeriod, locale())
: null,
'Unresolved'
])}
</span> </span>
</div> </div>
<div class="editable-list-row__meta"> <div class="editable-list-row__meta">
<strong>{ledgerPrimaryAmount(entry)}</strong> <strong>
{formatMoneyLabel(
entry.displayAmountMajor,
entry.displayCurrency,
locale()
)}
</strong>
<Show when={ledgerSecondaryAmount(entry)}> <Show when={ledgerSecondaryAmount(entry)}>
{(secondary) => ( {(secondary) => (
<span class="editable-list-row__secondary"> <span class="editable-list-row__secondary">
@@ -686,8 +740,7 @@ export default function LedgerRoute() {
</Show> </Show>
</div> </div>
<div> <Collapsible title={copy().resolvedPurchasesTitle} defaultOpen={false}>
<strong>{copy().resolvedPurchasesTitle}</strong>
<Show <Show
when={resolvedPurchaseLedger().length > 0} when={resolvedPurchaseLedger().length > 0}
fallback={<p class="empty-state">{copy().resolvedPurchasesEmpty}</p>} fallback={<p class="empty-state">{copy().resolvedPurchasesEmpty}</p>}
@@ -703,13 +756,25 @@ export default function LedgerRoute() {
<div class="editable-list-row__main"> <div class="editable-list-row__main">
<span class="editable-list-row__title">{entry.title}</span> <span class="editable-list-row__title">{entry.title}</span>
<span class="editable-list-row__subtitle"> <span class="editable-list-row__subtitle">
{[entry.actorDisplayName, entry.originPeriod, entry.resolvedAt] {joinSubtitleParts([
.filter(Boolean) entry.actorDisplayName,
.join(' · ')} entry.originPeriod
? formatCyclePeriod(entry.originPeriod, locale())
: null,
entry.resolvedAt
? formatFriendlyDate(entry.resolvedAt, locale())
: null
])}
</span> </span>
</div> </div>
<div class="editable-list-row__meta"> <div class="editable-list-row__meta">
<strong>{ledgerPrimaryAmount(entry)}</strong> <strong>
{formatMoneyLabel(
entry.displayAmountMajor,
entry.displayCurrency,
locale()
)}
</strong>
<Show when={ledgerSecondaryAmount(entry)}> <Show when={ledgerSecondaryAmount(entry)}>
{(secondary) => ( {(secondary) => (
<span class="editable-list-row__secondary"> <span class="editable-list-row__secondary">
@@ -723,47 +788,91 @@ export default function LedgerRoute() {
</For> </For>
</div> </div>
</Show> </Show>
</div> </Collapsible>
</div> </div>
</Show> </Show>
</Collapsible> </Collapsible>
{/* ── Utility bills ──────────────────────── */} {/* ── Utility bills ──────────────────────── */}
<Collapsible title={copy().utilityLedgerTitle}> <Collapsible title={copy().utilityLedgerTitle}>
<Show when={effectiveIsAdmin()}> <div style={{ display: 'flex', 'flex-direction': 'column', gap: '12px' }}>
<div class="editable-list-actions"> <Show when={effectiveIsAdmin()}>
<Button variant="primary" size="sm" onClick={() => setAddUtilityOpen(true)}> <div class="editable-list-actions">
<Plus size={14} /> <Button variant="primary" size="sm" onClick={() => setAddUtilityOpen(true)}>
{copy().addUtilityBillAction} <Plus size={14} />
</Button> {copy().addUtilityBillAction}
</div> </Button>
</Show> </div>
<Show </Show>
when={utilityLedger().length > 0} <Show
fallback={<p class="empty-state">{copy().utilityLedgerEmpty}</p>} when={utilityLedger().length > 0}
> fallback={<p class="empty-state">{copy().utilityLedgerEmpty}</p>}
<div class="editable-list"> >
<For each={utilityLedger()}> <div class="editable-list">
{(entry) => ( <For each={utilityLedger()}>
<button {(entry) => (
class="editable-list-row" <button
onClick={() => effectiveIsAdmin() && openUtilityEditor(entry)} class="editable-list-row"
disabled={!effectiveIsAdmin()} onClick={() => effectiveIsAdmin() && openUtilityEditor(entry)}
> disabled={!effectiveIsAdmin()}
<div class="editable-list-row__main"> >
<span class="editable-list-row__title">{entry.title}</span> <div class="editable-list-row__main">
<span class="editable-list-row__subtitle"> <span class="editable-list-row__title">{entry.title}</span>
{entry.actorDisplayName} <span class="editable-list-row__subtitle">
</span> {entry.actorDisplayName}
</div> </span>
<div class="editable-list-row__meta"> </div>
<strong>{ledgerPrimaryAmount(entry)}</strong> <div class="editable-list-row__meta">
</div> <strong>
</button> {formatMoneyLabel(
)} entry.displayAmountMajor,
</For> entry.displayCurrency,
</div> locale()
</Show> )}
</strong>
</div>
</button>
)}
</For>
</div>
</Show>
<Collapsible title={copy().utilityHistoryTitle} defaultOpen={false}>
<Show
when={paymentPeriodSummaries().length > 0}
fallback={<p class="empty-state">{copy().utilityLedgerEmpty}</p>}
>
<div class="editable-list">
<For each={paymentPeriodSummaries()}>
{(summary) => (
<div class="editable-list-row editable-list-row--static">
<div class="editable-list-row__main">
<span class="editable-list-row__title">
{formatCyclePeriod(summary.period, locale())}
</span>
<span class="editable-list-row__subtitle">
{summary.isCurrentPeriod
? copy().currentCycleLabel
: summary.hasOverdueBalance
? copy().overdueLabel
: copy().homeSettledTitle}
</span>
</div>
<div class="editable-list-row__meta">
<strong>
{formatMoneyLabel(
summary.utilityTotalMajor,
(dashboard()?.currency as 'USD' | 'GEL') ?? 'GEL',
locale()
)}
</strong>
</div>
</div>
)}
</For>
</div>
</Show>
</Collapsible>
</div>
</Collapsible> </Collapsible>
{/* ── Payments ───────────────────────────── */} {/* ── Payments ───────────────────────────── */}
@@ -776,7 +885,7 @@ export default function LedgerRoute() {
<Show when={effectiveIsAdmin()}> <Show when={effectiveIsAdmin()}>
<div class="editable-list-actions"> <div class="editable-list-actions">
<Button <Button
variant="primary" variant="ghost"
size="sm" size="sm"
onClick={() => { onClick={() => {
setNewPayment((payment) => ({ setNewPayment((payment) => ({
@@ -791,34 +900,186 @@ export default function LedgerRoute() {
</Button> </Button>
</div> </div>
</Show> </Show>
<Show when={paymentActionError()}>
{(error) => <p class="empty-state">{error()}</p>}
</Show>
<Show <Show
when={paymentLedger().length > 0} when={paymentPeriodSummaries().length > 0}
fallback={<p class="empty-state">{copy().paymentsEmpty}</p>} fallback={<p class="empty-state">{copy().paymentsEmpty}</p>}
> >
<div class="editable-list"> <div style={{ display: 'flex', 'flex-direction': 'column', gap: '12px' }}>
<For each={paymentLedger()}> <For each={paymentPeriodSummaries()}>
{(entry) => ( {(summary) => (
<button <Collapsible
class="editable-list-row" title={copy().paymentsPeriodTitle.replace(
onClick={() => effectiveIsAdmin() && openPaymentEditor(entry)} '{period}',
disabled={!effectiveIsAdmin()} formatCyclePeriod(summary.period, locale())
)}
body={
summary.hasOverdueBalance
? copy().paymentsPeriodOverdueBody
: summary.isCurrentPeriod
? copy().paymentsPeriodCurrentBody
: copy().paymentsPeriodHistoryBody
}
defaultOpen={summary.isCurrentPeriod || summary.hasOverdueBalance}
> >
<div class="editable-list-row__main"> <div style={{ display: 'flex', 'flex-direction': 'column', gap: '12px' }}>
<span class="editable-list-row__title"> <For each={summary.kinds}>
{entry.paymentKind === 'rent' {(kindSummary) => (
? copy().paymentLedgerRent <Show
: copy().paymentLedgerUtilities} when={kindSummary.unresolvedMembers.length > 0}
</span> fallback={
<span class="editable-list-row__subtitle"> <div class="editable-list-row editable-list-row--static">
{entry.actorDisplayName} <div class="editable-list-row__main">
</span> <span class="editable-list-row__title">
{kindSummary.kind === 'rent'
? copy().shareRent
: copy().shareUtilities}
</span>
<span class="editable-list-row__subtitle">
{copy().homeSettledTitle}
</span>
</div>
<div class="editable-list-row__meta">
<strong>
{formatMoneyLabel(
kindSummary.totalPaidMajor,
(dashboard()?.currency as 'USD' | 'GEL') ?? 'GEL',
locale()
)}
</strong>
</div>
</div>
}
>
<div>
<div class="editable-list-section-title">
{kindSummary.kind === 'rent'
? copy().shareRent
: copy().shareUtilities}
</div>
<div class="editable-list">
<For each={kindSummary.unresolvedMembers}>
{(memberSummary) => (
<div class="editable-list-row editable-list-row--stacked">
<div class="editable-list-row__main">
<span class="editable-list-row__title">
{memberSummary.displayName}
</span>
<span class="editable-list-row__subtitle">
{copy()
.paymentsBaseDueLabel.replace(
'{amount}',
formatMoneyLabel(
memberSummary.baseDueMajor,
(dashboard()?.currency as 'USD' | 'GEL') ??
'GEL',
locale()
)
)
.replace(
'{remaining}',
formatMoneyLabel(
memberSummary.remainingMajor,
(dashboard()?.currency as 'USD' | 'GEL') ??
'GEL',
locale()
)
)}
</span>
</div>
<div class="editable-list-row__meta">
<strong>
{formatMoneyLabel(
memberSummary.suggestedAmountMajor,
(dashboard()?.currency as 'USD' | 'GEL') ?? 'GEL',
locale()
)}
</strong>
<div class="editable-list-inline-actions">
<Button
variant="primary"
size="sm"
loading={addingPayment()}
onClick={() =>
void handleResolveSuggestedPayment({
memberId: memberSummary.memberId,
kind: kindSummary.kind,
period: summary.period,
amountMajor:
memberSummary.suggestedAmountMajor
})
}
>
{copy().paymentsResolveAction}
</Button>
<Button
variant="ghost"
size="sm"
onClick={() =>
openCustomPayment({
memberId: memberSummary.memberId,
kind: kindSummary.kind,
period: summary.period
})
}
>
{copy().paymentsCustomAmountAction}
</Button>
</div>
</div>
</div>
)}
</For>
</div>
</div>
</Show>
)}
</For>
</div> </div>
<div class="editable-list-row__meta"> </Collapsible>
<strong>{ledgerPrimaryAmount(entry)}</strong>
</div>
</button>
)} )}
</For> </For>
<Collapsible title={copy().paymentsHistoryTitle} defaultOpen={false}>
<Show
when={paymentLedger().length > 0}
fallback={<p class="empty-state">{copy().paymentsEmpty}</p>}
>
<div class="editable-list">
<For each={paymentLedger()}>
{(entry) => (
<button
class="editable-list-row"
onClick={() => effectiveIsAdmin() && openPaymentEditor(entry)}
disabled={!effectiveIsAdmin()}
>
<div class="editable-list-row__main">
<span class="editable-list-row__title">
{entry.paymentKind === 'rent'
? copy().paymentLedgerRent
: copy().paymentLedgerUtilities}
</span>
<span class="editable-list-row__subtitle">
{entry.actorDisplayName}
</span>
</div>
<div class="editable-list-row__meta">
<strong>
{formatMoneyLabel(
entry.displayAmountMajor,
entry.displayCurrency,
locale()
)}
</strong>
</div>
</button>
)}
</For>
</div>
</Show>
</Collapsible>
</div> </div>
</Show> </Show>
</Collapsible> </Collapsible>
@@ -1093,6 +1354,7 @@ export default function LedgerRoute() {
</div> </div>
} }
> >
<Show when={paymentActionError()}>{(error) => <p class="empty-state">{error()}</p>}</Show>
<div class="editor-grid"> <div class="editor-grid">
<Field label={copy().paymentMember}> <Field label={copy().paymentMember}>
<Select <Select
@@ -1101,8 +1363,12 @@ export default function LedgerRoute() {
placeholder="—" placeholder="—"
options={[{ value: '', label: '—' }, ...memberOptions()]} options={[{ value: '', label: '—' }, ...memberOptions()]}
onChange={(memberId) => { onChange={(memberId) => {
const member = dashboard()?.members.find((m) => m.memberId === memberId) const prefill = computePaymentPrefill(
const prefill = computePaymentPrefill(member, newPayment().kind) dashboard(),
memberId,
newPayment().kind,
newPayment().period || dashboard()?.period || ''
)
setNewPayment((p) => ({ ...p, memberId, amountMajor: prefill })) setNewPayment((p) => ({ ...p, memberId, amountMajor: prefill }))
}} }}
/> />
@@ -1113,7 +1379,18 @@ export default function LedgerRoute() {
ariaLabel={copy().paymentKind} ariaLabel={copy().paymentKind}
options={kindOptions()} options={kindOptions()}
onChange={(value) => onChange={(value) =>
setNewPayment((p) => ({ ...p, kind: value as 'rent' | 'utilities' })) setNewPayment((p) => ({
...p,
kind: value as 'rent' | 'utilities',
amountMajor: p.memberId
? computePaymentPrefill(
dashboard(),
p.memberId,
value as 'rent' | 'utilities',
p.period || dashboard()?.period || ''
)
: p.amountMajor
}))
} }
/> />
</Field> </Field>
@@ -1123,7 +1400,20 @@ export default function LedgerRoute() {
placeholder="—" placeholder="—"
ariaLabel="Billing period" ariaLabel="Billing period"
options={[{ value: '', label: '—' }, ...paymentPeriodOptions()]} options={[{ value: '', label: '—' }, ...paymentPeriodOptions()]}
onChange={(value) => setNewPayment((p) => ({ ...p, period: value }))} onChange={(value) =>
setNewPayment((p) => ({
...p,
period: value,
amountMajor: p.memberId
? computePaymentPrefill(
dashboard(),
p.memberId,
p.kind,
value || dashboard()?.period || ''
)
: p.amountMajor
}))
}
/> />
</Field> </Field>
<Field label={copy().paymentAmount}> <Field label={copy().paymentAmount}>

View File

@@ -5,6 +5,7 @@ import { useNavigate } from '@solidjs/router'
import { useSession } from '../contexts/session-context' import { useSession } from '../contexts/session-context'
import { useI18n } from '../contexts/i18n-context' import { useI18n } from '../contexts/i18n-context'
import { useDashboard } from '../contexts/dashboard-context' import { useDashboard } from '../contexts/dashboard-context'
import { formatCyclePeriod } from '../lib/dates'
import { Card } from '../components/ui/card' import { Card } from '../components/ui/card'
import { Button } from '../components/ui/button' import { Button } from '../components/ui/button'
import { Badge } from '../components/ui/badge' import { Badge } from '../components/ui/badge'
@@ -418,7 +419,7 @@ export default function SettingsRoute() {
<div class="settings-billing-summary"> <div class="settings-billing-summary">
<div class="settings-row"> <div class="settings-row">
<span>{copy().billingCyclePeriod}</span> <span>{copy().billingCyclePeriod}</span>
<Badge variant="accent">{cycle().period}</Badge> <Badge variant="accent">{formatCyclePeriod(cycle().period, locale())}</Badge>
</div> </div>
<div class="settings-row"> <div class="settings-row">
<span>{copy().currencyLabel}</span> <span>{copy().currencyLabel}</span>

View File

@@ -1269,4 +1269,76 @@ describe('createFinanceCommandService', () => {
} }
]) ])
}) })
test('generateDashboard rounds rent suggestions in payment period summaries', async () => {
const repository = new FinanceRepositoryStub()
repository.members = [
{
id: 'alice',
telegramUserId: '1',
displayName: 'Alice',
rentShareWeight: 1,
isAdmin: true
}
]
repository.openCycleRecord = {
id: 'cycle-2026-03',
period: '2026-03',
currency: 'GEL'
}
repository.latestCycleRecord = repository.openCycleRecord
repository.cycles = [repository.openCycleRecord]
repository.rentRule = {
amountMinor: 47256n,
currency: 'GEL'
}
const service = createService(repository)
const dashboard = await service.generateDashboard('2026-03')
const rentSummary = dashboard?.paymentPeriods?.[0]?.kinds.find((kind) => kind.kind === 'rent')
expect(rentSummary?.unresolvedMembers[0]?.suggestedAmount.toMajorString()).toBe('473.00')
})
test('addPayment rejects duplicate explicit payments when the period is already effectively settled', async () => {
const repository = new FinanceRepositoryStub()
repository.members = [
{
id: 'alice',
telegramUserId: '1',
displayName: 'Alice',
rentShareWeight: 1,
isAdmin: true
}
]
repository.openCycleRecord = {
id: 'cycle-2026-03',
period: '2026-03',
currency: 'GEL'
}
repository.latestCycleRecord = repository.openCycleRecord
repository.cycles = [repository.openCycleRecord]
repository.rentRule = {
amountMinor: 47256n,
currency: 'GEL'
}
repository.paymentRecords = [
{
id: 'payment-1',
cycleId: 'cycle-2026-03',
cyclePeriod: '2026-03',
memberId: 'alice',
kind: 'rent',
amountMinor: 47200n,
currency: 'GEL',
recordedAt: instantFromIso('2026-03-18T12:00:00.000Z')
}
]
const service = createService(repository)
await expect(service.addPayment('alice', 'rent', '10.00', 'GEL', '2026-03')).rejects.toThrow(
'Payment period is already settled'
)
})
}) })

View File

@@ -123,6 +123,32 @@ export interface FinanceDashboardMemberLine {
explanations: readonly string[] explanations: readonly string[]
} }
export interface FinanceDashboardPaymentMemberSummary {
memberId: string
displayName: string
suggestedAmount: Money
baseDue: Money
paid: Money
remaining: Money
effectivelySettled: boolean
}
export interface FinanceDashboardPaymentKindSummary {
kind: FinancePaymentKind
totalDue: Money
totalPaid: Money
totalRemaining: Money
unresolvedMembers: readonly FinanceDashboardPaymentMemberSummary[]
}
export interface FinanceDashboardPaymentPeriodSummary {
period: string
utilityTotal: Money
hasOverdueBalance: boolean
isCurrentPeriod: boolean
kinds: readonly FinanceDashboardPaymentKindSummary[]
}
export interface FinanceDashboardLedgerEntry { export interface FinanceDashboardLedgerEntry {
id: string id: string
kind: 'purchase' | 'utility' | 'payment' kind: 'purchase' | 'utility' | 'payment'
@@ -171,6 +197,7 @@ export interface FinanceDashboard {
rentFxRateMicros: bigint | null rentFxRateMicros: bigint | null
rentFxEffectiveDate: string | null rentFxEffectiveDate: string | null
members: readonly FinanceDashboardMemberLine[] members: readonly FinanceDashboardMemberLine[]
paymentPeriods?: readonly FinanceDashboardPaymentPeriodSummary[]
ledger: readonly FinanceDashboardLedgerEntry[] ledger: readonly FinanceDashboardLedgerEntry[]
} }
@@ -259,6 +286,33 @@ interface MutableOverdueSummary {
utilities: { amountMinor: bigint; periods: string[] } utilities: { amountMinor: bigint; periods: string[] }
} }
const PAYMENT_SETTLEMENT_TOLERANCE_MINOR = 200n
function effectiveRemainingMinor(expectedMinor: bigint, paidMinor: bigint): bigint {
const shortfallMinor = expectedMinor - paidMinor
if (shortfallMinor <= PAYMENT_SETTLEMENT_TOLERANCE_MINOR) {
return 0n
}
return shortfallMinor
}
function roundSuggestedPaymentMinor(kind: FinancePaymentKind, amountMinor: bigint): bigint {
if (kind !== 'rent') {
return amountMinor
}
if (amountMinor <= 0n) {
return 0n
}
const wholeMinor = amountMinor / 100n
const remainderMinor = amountMinor % 100n
return (remainderMinor >= 50n ? wholeMinor + 1n : wholeMinor) * 100n
}
function periodFromInstant(instant: Temporal.Instant | null | undefined): string | null { function periodFromInstant(instant: Temporal.Instant | null | undefined): string | null {
if (!instant) { if (!instant) {
return null return null
@@ -516,13 +570,19 @@ async function computeMemberOverduePayments(input: {
utilities: { amountMinor: 0n, periods: [] } utilities: { amountMinor: 0n, periods: [] }
} }
const rentRemainingMinor = line.rentShare.subtract(line.rentPaid).amountMinor const rentRemainingMinor = effectiveRemainingMinor(
line.rentShare.amountMinor,
line.rentPaid.amountMinor
)
if (Temporal.PlainDate.compare(localDate, rentDueDate) > 0 && rentRemainingMinor > 0n) { if (Temporal.PlainDate.compare(localDate, rentDueDate) > 0 && rentRemainingMinor > 0n) {
current.rent.amountMinor += rentRemainingMinor current.rent.amountMinor += rentRemainingMinor
current.rent.periods.push(cycle.period) current.rent.periods.push(cycle.period)
} }
const utilityRemainingMinor = line.utilityShare.subtract(line.utilityPaid).amountMinor const utilityRemainingMinor = effectiveRemainingMinor(
line.utilityShare.amountMinor,
line.utilityPaid.amountMinor
)
if ( if (
Temporal.PlainDate.compare(localDate, utilitiesDueDate) > 0 && Temporal.PlainDate.compare(localDate, utilitiesDueDate) > 0 &&
utilityRemainingMinor > 0n utilityRemainingMinor > 0n
@@ -558,6 +618,161 @@ async function computeMemberOverduePayments(input: {
) )
} }
async function buildPaymentPeriodSummaries(input: {
dependencies: FinanceCommandServiceDependencies
currentCycle: FinanceCycleRecord
members: readonly HouseholdMemberRecord[]
memberAbsencePolicies: readonly HouseholdMemberAbsencePolicyRecord[]
settings: HouseholdBillingSettingsRecord
}): Promise<readonly FinanceDashboardPaymentPeriodSummary[]> {
const localDate = localDateInTimezone(input.settings.timezone)
const memberNameById = new Map(input.members.map((member) => [member.id, member.displayName]))
const cycles = (await input.dependencies.repository.listCycles())
.filter((cycle) => cycle.period.localeCompare(input.currentCycle.period) <= 0)
.sort((left, right) => right.period.localeCompare(left.period))
const summaries: FinanceDashboardPaymentPeriodSummary[] = []
for (const cycle of cycles) {
const [baseLines, utilityBills] = await Promise.all([
buildCycleBaseMemberLines({
dependencies: input.dependencies,
cycle,
members: input.members,
memberAbsencePolicies: input.memberAbsencePolicies,
settings: input.settings
}),
input.dependencies.repository.listUtilityBillsForCycle(cycle.id)
])
const utilityTotal = utilityBills.reduce(
(sum, bill) => sum.add(Money.fromMinor(bill.amountMinor, bill.currency)),
Money.zero(cycle.currency)
)
const rentDueDate = billingPeriodLockDate(
BillingPeriod.fromString(cycle.period),
input.settings.rentDueDay
)
const utilitiesDueDate = billingPeriodLockDate(
BillingPeriod.fromString(cycle.period),
input.settings.utilitiesDueDay
)
const rentMembers = baseLines.map((line) => {
const remainingMinor = effectiveRemainingMinor(
line.rentShare.amountMinor,
line.rentPaid.amountMinor
)
const baseDue = line.rentShare
return {
memberId: line.memberId,
displayName: memberNameById.get(line.memberId) ?? line.memberId,
suggestedAmount: Money.fromMinor(
roundSuggestedPaymentMinor('rent', remainingMinor),
cycle.currency
),
baseDue,
paid: line.rentPaid,
remaining: Money.fromMinor(remainingMinor, cycle.currency),
effectivelySettled: remainingMinor === 0n
} satisfies FinanceDashboardPaymentMemberSummary
})
const utilitiesMembers = baseLines.map((line) => {
const remainingMinor = effectiveRemainingMinor(
line.utilityShare.amountMinor,
line.utilityPaid.amountMinor
)
return {
memberId: line.memberId,
displayName: memberNameById.get(line.memberId) ?? line.memberId,
suggestedAmount: Money.fromMinor(remainingMinor, cycle.currency),
baseDue: line.utilityShare,
paid: line.utilityPaid,
remaining: Money.fromMinor(remainingMinor, cycle.currency),
effectivelySettled: remainingMinor === 0n
} satisfies FinanceDashboardPaymentMemberSummary
})
const hasOverdueBalance =
(Temporal.PlainDate.compare(localDate, rentDueDate) > 0 &&
rentMembers.some((member) => !member.effectivelySettled)) ||
(Temporal.PlainDate.compare(localDate, utilitiesDueDate) > 0 &&
utilitiesMembers.some((member) => !member.effectivelySettled))
summaries.push({
period: cycle.period,
utilityTotal,
hasOverdueBalance,
isCurrentPeriod: cycle.period === input.currentCycle.period,
kinds: [
{
kind: 'rent',
totalDue: rentMembers.reduce(
(sum, member) => sum.add(member.baseDue),
Money.zero(cycle.currency)
),
totalPaid: rentMembers.reduce(
(sum, member) => sum.add(member.paid),
Money.zero(cycle.currency)
),
totalRemaining: rentMembers.reduce(
(sum, member) => sum.add(member.remaining),
Money.zero(cycle.currency)
),
unresolvedMembers: rentMembers.filter((member) => !member.effectivelySettled)
},
{
kind: 'utilities',
totalDue: utilitiesMembers.reduce(
(sum, member) => sum.add(member.baseDue),
Money.zero(cycle.currency)
),
totalPaid: utilitiesMembers.reduce(
(sum, member) => sum.add(member.paid),
Money.zero(cycle.currency)
),
totalRemaining: utilitiesMembers.reduce(
(sum, member) => sum.add(member.remaining),
Money.zero(cycle.currency)
),
unresolvedMembers: utilitiesMembers.filter((member) => !member.effectivelySettled)
}
]
})
}
return summaries
}
async function getCycleKindBaseRemaining(input: {
dependencies: FinanceCommandServiceDependencies
cycle: FinanceCycleRecord
members: readonly HouseholdMemberRecord[]
memberAbsencePolicies: readonly HouseholdMemberAbsencePolicyRecord[]
settings: HouseholdBillingSettingsRecord
memberId: string
kind: FinancePaymentKind
}): Promise<bigint> {
const baseLine = (
await buildCycleBaseMemberLines({
dependencies: input.dependencies,
cycle: input.cycle,
members: input.members,
memberAbsencePolicies: input.memberAbsencePolicies,
settings: input.settings
})
).find((line) => line.memberId === input.memberId)
if (!baseLine) {
return 0n
}
return input.kind === 'rent'
? effectiveRemainingMinor(baseLine.rentShare.amountMinor, baseLine.rentPaid.amountMinor)
: effectiveRemainingMinor(baseLine.utilityShare.amountMinor, baseLine.utilityPaid.amountMinor)
}
async function resolveAutomaticPaymentTargets(input: { async function resolveAutomaticPaymentTargets(input: {
dependencies: FinanceCommandServiceDependencies dependencies: FinanceCommandServiceDependencies
currentCycle: FinanceCycleRecord currentCycle: FinanceCycleRecord
@@ -608,8 +823,11 @@ async function resolveAutomaticPaymentTargets(input: {
const remainingMinor = const remainingMinor =
input.kind === 'rent' input.kind === 'rent'
? baseLine.rentShare.subtract(baseLine.rentPaid).amountMinor ? effectiveRemainingMinor(baseLine.rentShare.amountMinor, baseLine.rentPaid.amountMinor)
: baseLine.utilityShare.subtract(baseLine.utilityPaid).amountMinor : effectiveRemainingMinor(
baseLine.utilityShare.amountMinor,
baseLine.utilityPaid.amountMinor
)
if (remainingMinor <= 0n) { if (remainingMinor <= 0n) {
continue continue
@@ -687,13 +905,22 @@ async function buildFinanceDashboard(
const previousSnapshotLines = previousCycle const previousSnapshotLines = previousCycle
? await dependencies.repository.getSettlementSnapshotLines(previousCycle.id) ? await dependencies.repository.getSettlementSnapshotLines(previousCycle.id)
: [] : []
const overduePaymentsByMemberId = await computeMemberOverduePayments({ const [overduePaymentsByMemberId, paymentPeriods] = await Promise.all([
dependencies, computeMemberOverduePayments({
currentCycle: cycle, dependencies,
members, currentCycle: cycle,
memberAbsencePolicies, members,
settings memberAbsencePolicies,
}) settings
}),
buildPaymentPeriodSummaries({
dependencies,
currentCycle: cycle,
members,
memberAbsencePolicies,
settings
})
])
const previousUtilityShareByMemberId = new Map( const previousUtilityShareByMemberId = new Map(
previousSnapshotLines.map((line) => [ previousSnapshotLines.map((line) => [
line.memberId, line.memberId,
@@ -1061,6 +1288,7 @@ async function buildFinanceDashboard(
rentFxRateMicros: convertedRent.fxRateMicros, rentFxRateMicros: convertedRent.fxRateMicros,
rentFxEffectiveDate: convertedRent.fxEffectiveDate, rentFxEffectiveDate: convertedRent.fxEffectiveDate,
members: dashboardMembers, members: dashboardMembers,
paymentPeriods,
ledger ledger
} }
} }
@@ -1095,7 +1323,8 @@ async function allocatePaymentPurchaseOverage(input: {
} }
const baseAmount = input.kind === 'rent' ? memberLine.rentShare : memberLine.utilityShare const baseAmount = input.kind === 'rent' ? memberLine.rentShare : memberLine.utilityShare
let remainingMinor = input.paymentAmount.amountMinor - baseAmount.amountMinor const baseThresholdMinor = roundSuggestedPaymentMinor(input.kind, baseAmount.amountMinor)
let remainingMinor = input.paymentAmount.amountMinor - baseThresholdMinor
if (remainingMinor <= 0n) { if (remainingMinor <= 0n) {
return [] return []
} }
@@ -1588,6 +1817,22 @@ export function createFinanceCommandService(
const currency = parseCurrency(currencyArg, settings.settlementCurrency) const currency = parseCurrency(currencyArg, settings.settlementCurrency)
const amount = Money.fromMajor(amountArg, currency) const amount = Money.fromMajor(amountArg, currency)
if (periodArg) {
const explicitRemainingMinor = await getCycleKindBaseRemaining({
dependencies,
cycle: currentCycle,
members,
memberAbsencePolicies,
settings,
memberId,
kind
})
if (explicitRemainingMinor === 0n) {
throw new Error('Payment period is already settled')
}
}
const paymentTargets = periodArg const paymentTargets = periodArg
? [ ? [
{ {
@@ -1606,6 +1851,15 @@ export function createFinanceCommandService(
kind kind
}) })
if (
!periodArg &&
paymentTargets.every(
(target) => target.baseRemainingMinor <= 0n && target.cycle.id === currentCycle.id
)
) {
throw new Error('Payment period is already settled')
}
let remainingMinor = amount.amountMinor let remainingMinor = amount.amountMinor
let firstPayment: Awaited<ReturnType<FinanceRepository['addPaymentRecord']>> | null = null let firstPayment: Awaited<ReturnType<FinanceRepository['addPaymentRecord']>> | null = null

View File

@@ -37,6 +37,20 @@ function adjustmentApplies(
return (policy === 'utilities' && kind === 'utilities') || (policy === 'rent' && kind === 'rent') return (policy === 'utilities' && kind === 'utilities') || (policy === 'rent' && kind === 'rent')
} }
function roundSuggestedPayment(kind: 'rent' | 'utilities', amount: Money): Money {
if (kind !== 'rent' || amount.amountMinor <= 0n) {
return amount
}
const wholeMinor = amount.amountMinor / 100n
const remainderMinor = amount.amountMinor % 100n
return Money.fromMinor(
(remainderMinor >= 50n ? wholeMinor + 1n : wholeMinor) * 100n,
amount.currency
)
}
export function buildMemberPaymentGuidance(input: { export function buildMemberPaymentGuidance(input: {
kind: 'rent' | 'utilities' kind: 'rent' | 'utilities'
period: string period: string
@@ -48,9 +62,10 @@ export function buildMemberPaymentGuidance(input: {
const baseAmount = const baseAmount =
input.kind === 'rent' ? input.memberLine.rentShare : input.memberLine.utilityShare input.kind === 'rent' ? input.memberLine.rentShare : input.memberLine.utilityShare
const purchaseOffset = input.memberLine.purchaseOffset const purchaseOffset = input.memberLine.purchaseOffset
const proposalAmount = adjustmentApplies(policy, input.kind) const proposalAmount = roundSuggestedPayment(
? baseAmount.add(purchaseOffset) input.kind,
: baseAmount adjustmentApplies(policy, input.kind) ? baseAmount.add(purchaseOffset) : baseAmount
)
const reminderDay = const reminderDay =
input.kind === 'rent' ? input.settings.rentWarningDay : input.settings.utilitiesReminderDay input.kind === 'rent' ? input.settings.rentWarningDay : input.settings.utilitiesReminderDay