mirror of
https://github.com/whekin/household-bot.git
synced 2026-03-31 10:24:02 +00:00
feat(payments): add transparent balance guidance
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}))
|
||||
}
|
||||
: {})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -40,3 +40,4 @@ export {
|
||||
parsePaymentConfirmationMessage,
|
||||
type ParsedPaymentConfirmation
|
||||
} from './payment-confirmation-parser'
|
||||
export { buildMemberPaymentGuidance, type MemberPaymentGuidance } from './payment-guidance'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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({
|
||||
|
||||
76
packages/application/src/payment-guidance.ts
Normal file
76
packages/application/src/payment-guidance.ts
Normal 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
|
||||
}
|
||||
}
|
||||
1
packages/db/drizzle/0017_gigantic_selene.sql
Normal file
1
packages/db/drizzle/0017_gigantic_selene.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE "household_billing_settings" ADD COLUMN "payment_balance_adjustment_policy" text DEFAULT 'utilities' NOT NULL;
|
||||
3222
packages/db/drizzle/meta/0017_snapshot.json
Normal file
3222
packages/db/drizzle/meta/0017_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user