feat(payments): add transparent balance guidance

This commit is contained in:
2026-03-11 14:52:09 +04:00
parent 8401688032
commit 79f96ba45b
25 changed files with 3855 additions and 93 deletions

View File

@@ -10,6 +10,7 @@ import {
import {
HOUSEHOLD_MEMBER_ABSENCE_POLICIES,
HOUSEHOLD_MEMBER_LIFECYCLE_STATUSES,
HOUSEHOLD_PAYMENT_BALANCE_ADJUSTMENT_POLICIES,
HOUSEHOLD_TOPIC_ROLES,
type HouseholdMemberAbsencePolicy,
type HouseholdMemberAbsencePolicyRecord,
@@ -18,6 +19,7 @@ import {
type HouseholdJoinTokenRecord,
type HouseholdMemberLifecycleStatus,
type HouseholdMemberRecord,
type HouseholdPaymentBalanceAdjustmentPolicy,
type HouseholdPendingMemberRecord,
type HouseholdTelegramChatRecord,
type HouseholdTopicBindingRecord,
@@ -47,6 +49,18 @@ function normalizeMemberLifecycleStatus(raw: string): HouseholdMemberLifecycleSt
throw new Error(`Unsupported household member lifecycle status: ${raw}`)
}
function normalizePaymentBalanceAdjustmentPolicy(
raw: string
): HouseholdPaymentBalanceAdjustmentPolicy {
const normalized = raw.trim().toLowerCase()
if ((HOUSEHOLD_PAYMENT_BALANCE_ADJUSTMENT_POLICIES as readonly string[]).includes(normalized)) {
return normalized as HouseholdPaymentBalanceAdjustmentPolicy
}
return 'utilities'
}
function normalizeMemberAbsencePolicy(raw: string): HouseholdMemberAbsencePolicy {
const normalized = raw.trim().toLowerCase()
@@ -206,6 +220,7 @@ function toCurrencyCode(raw: string): CurrencyCode {
function toHouseholdBillingSettingsRecord(row: {
householdId: string
settlementCurrency: string
paymentBalanceAdjustmentPolicy: string
rentAmountMinor: bigint | null
rentCurrency: string
rentDueDay: number
@@ -217,6 +232,9 @@ function toHouseholdBillingSettingsRecord(row: {
return {
householdId: row.householdId,
settlementCurrency: toCurrencyCode(row.settlementCurrency),
paymentBalanceAdjustmentPolicy: normalizePaymentBalanceAdjustmentPolicy(
row.paymentBalanceAdjustmentPolicy
),
rentAmountMinor: row.rentAmountMinor,
rentCurrency: toCurrencyCode(row.rentCurrency),
rentDueDay: row.rentDueDay,
@@ -917,6 +935,8 @@ export function createDbHouseholdConfigurationRepository(databaseUrl: string): {
.select({
householdId: schema.householdBillingSettings.householdId,
settlementCurrency: schema.householdBillingSettings.settlementCurrency,
paymentBalanceAdjustmentPolicy:
schema.householdBillingSettings.paymentBalanceAdjustmentPolicy,
rentAmountMinor: schema.householdBillingSettings.rentAmountMinor,
rentCurrency: schema.householdBillingSettings.rentCurrency,
rentDueDay: schema.householdBillingSettings.rentDueDay,
@@ -948,6 +968,11 @@ export function createDbHouseholdConfigurationRepository(databaseUrl: string): {
settlementCurrency: input.settlementCurrency
}
: {}),
...(input.paymentBalanceAdjustmentPolicy
? {
paymentBalanceAdjustmentPolicy: input.paymentBalanceAdjustmentPolicy
}
: {}),
...(input.rentAmountMinor !== undefined
? {
rentAmountMinor: input.rentAmountMinor
@@ -989,6 +1014,8 @@ export function createDbHouseholdConfigurationRepository(databaseUrl: string): {
.returning({
householdId: schema.householdBillingSettings.householdId,
settlementCurrency: schema.householdBillingSettings.settlementCurrency,
paymentBalanceAdjustmentPolicy:
schema.householdBillingSettings.paymentBalanceAdjustmentPolicy,
rentAmountMinor: schema.householdBillingSettings.rentAmountMinor,
rentCurrency: schema.householdBillingSettings.rentCurrency,
rentDueDay: schema.householdBillingSettings.rentDueDay,

View File

@@ -139,7 +139,7 @@ class FinanceRepositoryStub implements FinanceRepository {
return false
}
async updateParsedPurchase(input) {
async updateParsedPurchase(input: Parameters<FinanceRepository['updateParsedPurchase']>[0]) {
this.lastUpdatedPurchaseInput = input
return {
id: input.purchaseId,
@@ -149,12 +149,16 @@ class FinanceRepositoryStub implements FinanceRepository {
description: input.description,
occurredAt: instantFromIso('2026-03-12T11:00:00.000Z'),
splitMode: input.splitMode ?? 'equal',
participants: input.participants?.map((participant, index) => ({
id: `participant-${index + 1}`,
memberId: participant.memberId,
included: participant.included !== false,
shareAmountMinor: participant.shareAmountMinor
}))
...(input.participants
? {
participants: input.participants.map((participant, index) => ({
id: `participant-${index + 1}`,
memberId: participant.memberId,
included: participant.included !== false,
shareAmountMinor: participant.shareAmountMinor
}))
}
: {})
}
}

View File

@@ -40,3 +40,4 @@ export {
parsePaymentConfirmationMessage,
type ParsedPaymentConfirmation
} from './payment-confirmation-parser'
export { buildMemberPaymentGuidance, type MemberPaymentGuidance } from './payment-guidance'

View File

@@ -43,6 +43,7 @@ export interface MiniAppAdminService {
householdId: string
actorIsAdmin: boolean
settlementCurrency?: string
paymentBalanceAdjustmentPolicy?: string
rentAmountMajor?: string
rentCurrency?: string
rentDueDay: number
@@ -228,6 +229,20 @@ export function createMiniAppAdminService(
const settlementCurrency = input.settlementCurrency
? parseCurrency(input.settlementCurrency)
: undefined
const paymentBalanceAdjustmentPolicy = input.paymentBalanceAdjustmentPolicy
? input.paymentBalanceAdjustmentPolicy === 'utilities' ||
input.paymentBalanceAdjustmentPolicy === 'rent' ||
input.paymentBalanceAdjustmentPolicy === 'separate'
? input.paymentBalanceAdjustmentPolicy
: null
: undefined
if (paymentBalanceAdjustmentPolicy === null) {
return {
status: 'rejected',
reason: 'invalid_settings'
}
}
if (input.rentAmountMajor && input.rentAmountMajor.trim().length > 0) {
rentCurrency = parseCurrency(input.rentCurrency ?? 'USD')
@@ -244,6 +259,11 @@ export function createMiniAppAdminService(
settlementCurrency
}
: {}),
...(paymentBalanceAdjustmentPolicy
? {
paymentBalanceAdjustmentPolicy
}
: {}),
...(rentAmountMinor !== undefined
? {
rentAmountMinor

View File

@@ -15,6 +15,7 @@ import {
import type { FinanceCommandService } from './finance-command-service'
import { parsePaymentConfirmationMessage } from './payment-confirmation-parser'
import { buildMemberPaymentGuidance } from './payment-guidance'
function billingPeriodLockDate(period: BillingPeriod, day: number): Temporal.PlainDate {
const firstDay = Temporal.PlainDate.from({
@@ -284,10 +285,12 @@ export function createPaymentConfirmationService(input: {
}
}
const inferredAmount =
parsed.kind === 'rent'
? memberLine.rentShare
: memberLine.utilityShare.add(memberLine.purchaseOffset)
const guidance = buildMemberPaymentGuidance({
kind: parsed.kind,
period: cycle.period,
memberLine,
settings
})
const resolvedAmount = parsed.explicitAmount
? (
@@ -305,7 +308,7 @@ export function createPaymentConfirmationService(input: {
parsed.explicitAmount
)
).amount
: inferredAmount
: guidance.proposalAmount
if (resolvedAmount.amountMinor <= 0n) {
const saveResult = await input.repository.savePaymentConfirmation({

View File

@@ -0,0 +1,76 @@
import { BillingPeriod, Money, Temporal } from '@household/domain'
import type {
HouseholdBillingSettingsRecord,
HouseholdPaymentBalanceAdjustmentPolicy
} from '@household/ports'
import type { FinanceDashboardMemberLine } from './finance-command-service'
export interface MemberPaymentGuidance {
kind: 'rent' | 'utilities'
adjustmentPolicy: HouseholdPaymentBalanceAdjustmentPolicy
baseAmount: Money
purchaseOffset: Money
proposalAmount: Money
totalRemaining: Money
reminderDate: string
dueDate: string
paymentWindowOpen: boolean
paymentDue: boolean
}
function cycleDate(period: string, day: number): Temporal.PlainDate {
const billingPeriod = BillingPeriod.fromString(period)
const [yearRaw, monthRaw] = billingPeriod.toString().split('-')
const year = Number(yearRaw)
const month = Number(monthRaw)
const yearMonth = new Temporal.PlainYearMonth(year, month)
const boundedDay = Math.min(Math.max(day, 1), yearMonth.daysInMonth)
return new Temporal.PlainDate(year, month, boundedDay)
}
function adjustmentApplies(
policy: HouseholdPaymentBalanceAdjustmentPolicy,
kind: 'rent' | 'utilities'
): boolean {
return (policy === 'utilities' && kind === 'utilities') || (policy === 'rent' && kind === 'rent')
}
export function buildMemberPaymentGuidance(input: {
kind: 'rent' | 'utilities'
period: string
memberLine: FinanceDashboardMemberLine
settings: HouseholdBillingSettingsRecord
referenceInstant?: Temporal.Instant
}): MemberPaymentGuidance {
const policy = input.settings.paymentBalanceAdjustmentPolicy ?? 'utilities'
const baseAmount =
input.kind === 'rent' ? input.memberLine.rentShare : input.memberLine.utilityShare
const purchaseOffset = input.memberLine.purchaseOffset
const proposalAmount = adjustmentApplies(policy, input.kind)
? baseAmount.add(purchaseOffset)
: baseAmount
const reminderDay =
input.kind === 'rent' ? input.settings.rentWarningDay : input.settings.utilitiesReminderDay
const dueDay = input.kind === 'rent' ? input.settings.rentDueDay : input.settings.utilitiesDueDay
const reminderDate = cycleDate(input.period, reminderDay)
const dueDate = cycleDate(input.period, dueDay)
const localDate = (input.referenceInstant ?? Temporal.Now.instant())
.toZonedDateTimeISO(input.settings.timezone)
.toPlainDate()
return {
kind: input.kind,
adjustmentPolicy: policy,
baseAmount,
purchaseOffset,
proposalAmount,
totalRemaining: input.memberLine.remaining,
reminderDate: reminderDate.toString(),
dueDate: dueDate.toString(),
paymentWindowOpen: Temporal.PlainDate.compare(localDate, reminderDate) >= 0,
paymentDue: Temporal.PlainDate.compare(localDate, dueDate) >= 0
}
}

View File

@@ -0,0 +1 @@
ALTER TABLE "household_billing_settings" ADD COLUMN "payment_balance_adjustment_policy" text DEFAULT 'utilities' NOT NULL;

File diff suppressed because it is too large Load Diff

View File

@@ -120,6 +120,13 @@
"when": 1773225121790,
"tag": "0016_equal_susan_delgado",
"breakpoints": true
},
{
"idx": 17,
"version": "7",
"when": 1773226133315,
"tag": "0017_gigantic_selene",
"breakpoints": true
}
]
}

View File

@@ -27,6 +27,9 @@ export const householdBillingSettings = pgTable(
.notNull()
.references(() => households.id, { onDelete: 'cascade' }),
settlementCurrency: text('settlement_currency').default('GEL').notNull(),
paymentBalanceAdjustmentPolicy: text('payment_balance_adjustment_policy')
.default('utilities')
.notNull(),
rentAmountMinor: bigint('rent_amount_minor', { mode: 'bigint' }),
rentCurrency: text('rent_currency').default('USD').notNull(),
rentDueDay: integer('rent_due_day').default(20).notNull(),

View File

@@ -9,10 +9,17 @@ export const HOUSEHOLD_MEMBER_ABSENCE_POLICIES = [
'away_rent_only',
'inactive'
] as const
export const HOUSEHOLD_PAYMENT_BALANCE_ADJUSTMENT_POLICIES = [
'utilities',
'rent',
'separate'
] as const
export type HouseholdTopicRole = (typeof HOUSEHOLD_TOPIC_ROLES)[number]
export type HouseholdMemberLifecycleStatus = (typeof HOUSEHOLD_MEMBER_LIFECYCLE_STATUSES)[number]
export type HouseholdMemberAbsencePolicy = (typeof HOUSEHOLD_MEMBER_ABSENCE_POLICIES)[number]
export type HouseholdPaymentBalanceAdjustmentPolicy =
(typeof HOUSEHOLD_PAYMENT_BALANCE_ADJUSTMENT_POLICIES)[number]
export interface HouseholdTelegramChatRecord {
householdId: string
@@ -69,6 +76,7 @@ export interface HouseholdMemberAbsencePolicyRecord {
export interface HouseholdBillingSettingsRecord {
householdId: string
settlementCurrency: CurrencyCode
paymentBalanceAdjustmentPolicy?: HouseholdPaymentBalanceAdjustmentPolicy
rentAmountMinor: bigint | null
rentCurrency: CurrencyCode
rentDueDay: number
@@ -161,6 +169,7 @@ export interface HouseholdConfigurationRepository {
updateHouseholdBillingSettings(input: {
householdId: string
settlementCurrency?: CurrencyCode
paymentBalanceAdjustmentPolicy?: HouseholdPaymentBalanceAdjustmentPolicy
rentAmountMinor?: bigint | null
rentCurrency?: CurrencyCode
rentDueDay?: number

View File

@@ -15,9 +15,11 @@ export type {
export {
HOUSEHOLD_MEMBER_ABSENCE_POLICIES,
HOUSEHOLD_MEMBER_LIFECYCLE_STATUSES,
HOUSEHOLD_PAYMENT_BALANCE_ADJUSTMENT_POLICIES,
HOUSEHOLD_TOPIC_ROLES,
type HouseholdMemberAbsencePolicy,
type HouseholdMemberAbsencePolicyRecord,
type HouseholdPaymentBalanceAdjustmentPolicy,
type HouseholdConfigurationRepository,
type HouseholdBillingSettingsRecord,
type HouseholdJoinTokenRecord,