mirror of
https://github.com/whekin/household-bot.git
synced 2026-03-31 10:24:02 +00:00
feat(miniapp): redesign admin payment management
This commit is contained in:
@@ -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,
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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} за текущий цикл.',
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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}>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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'
|
||||||
|
)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user