feat: add quick payment action and improve copy button UX

Mini App Home Screen:
- Add 'Record Payment' button to utilities and rent period cards
- Pre-fill payment amount with member's share (rentShare/utilityShare)
- Modal dialog with amount input and currency display
- Toast notifications for copy and payment success/failure feedback

Copy Button Improvements:
- Increase spacing between icon and text (4px → 8px)
- Add hover background and padding for better touch target
- Green background highlight when copied (in addition to icon color change)
- Toast notification appears when copying any value

Backend:
- Add /api/miniapp/payments/add endpoint for quick payments
- Payment notifications sent to 'reminders' topic in Telegram
- Include member name, payment type, amount, and period in notification

Files:
- New: apps/miniapp/src/components/ui/toast.tsx
- Modified: apps/miniapp/src/routes/home.tsx, apps/miniapp/src/index.css,
  apps/miniapp/src/theme.css, apps/miniapp/src/i18n.ts,
  apps/bot/src/miniapp-billing.ts, apps/bot/src/server.ts

Quality Gates:  format, lint, typecheck, build, test

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
This commit is contained in:
2026-03-14 08:51:53 +04:00
parent 771d64aa4e
commit 488a488137
45 changed files with 2236 additions and 101 deletions

View File

@@ -22,6 +22,7 @@ import {
type HouseholdMemberRecord,
type HouseholdPaymentBalanceAdjustmentPolicy,
type HouseholdPendingMemberRecord,
type HouseholdRentPaymentDestination,
type HouseholdTelegramChatRecord,
type HouseholdTopicBindingRecord,
type HouseholdTopicRole,
@@ -218,6 +219,38 @@ function toCurrencyCode(raw: string): CurrencyCode {
return normalized
}
function normalizeOptionalString(value: unknown): string | null {
if (typeof value !== 'string') return null
const trimmed = value.trim()
return trimmed.length > 0 ? trimmed : null
}
function parseRentPaymentDestinations(
value: unknown
): readonly HouseholdRentPaymentDestination[] | null {
if (value === null || value === undefined) return null
if (!Array.isArray(value)) return null
return value
.map((entry): HouseholdRentPaymentDestination | null => {
if (!entry || typeof entry !== 'object') return null
const record = entry as Record<string, unknown>
const label = normalizeOptionalString(record.label) ?? ''
const account = normalizeOptionalString(record.account) ?? ''
if (!label || !account) return null
return {
label,
recipientName: normalizeOptionalString(record.recipientName),
bankName: normalizeOptionalString(record.bankName),
account,
note: normalizeOptionalString(record.note),
link: normalizeOptionalString(record.link)
}
})
.filter((entry): entry is HouseholdRentPaymentDestination => Boolean(entry))
}
function toHouseholdBillingSettingsRecord(row: {
householdId: string
settlementCurrency: string
@@ -229,6 +262,7 @@ function toHouseholdBillingSettingsRecord(row: {
utilitiesDueDay: number
utilitiesReminderDay: number
timezone: string
rentPaymentDestinations: unknown
}): HouseholdBillingSettingsRecord {
return {
householdId: row.householdId,
@@ -242,7 +276,8 @@ function toHouseholdBillingSettingsRecord(row: {
rentWarningDay: row.rentWarningDay,
utilitiesDueDay: row.utilitiesDueDay,
utilitiesReminderDay: row.utilitiesReminderDay,
timezone: row.timezone
timezone: row.timezone,
rentPaymentDestinations: parseRentPaymentDestinations(row.rentPaymentDestinations)
}
}
@@ -956,7 +991,8 @@ export function createDbHouseholdConfigurationRepository(databaseUrl: string): {
rentWarningDay: schema.householdBillingSettings.rentWarningDay,
utilitiesDueDay: schema.householdBillingSettings.utilitiesDueDay,
utilitiesReminderDay: schema.householdBillingSettings.utilitiesReminderDay,
timezone: schema.householdBillingSettings.timezone
timezone: schema.householdBillingSettings.timezone,
rentPaymentDestinations: schema.householdBillingSettings.rentPaymentDestinations
})
.from(schema.householdBillingSettings)
.where(eq(schema.householdBillingSettings.householdId, householdId))
@@ -1040,6 +1076,11 @@ export function createDbHouseholdConfigurationRepository(databaseUrl: string): {
timezone: input.timezone
}
: {}),
...(input.rentPaymentDestinations !== undefined
? {
rentPaymentDestinations: input.rentPaymentDestinations
}
: {}),
updatedAt: instantToDate(nowInstant())
})
.where(eq(schema.householdBillingSettings.householdId, input.householdId))
@@ -1054,7 +1095,8 @@ export function createDbHouseholdConfigurationRepository(databaseUrl: string): {
rentWarningDay: schema.householdBillingSettings.rentWarningDay,
utilitiesDueDay: schema.householdBillingSettings.utilitiesDueDay,
utilitiesReminderDay: schema.householdBillingSettings.utilitiesReminderDay,
timezone: schema.householdBillingSettings.timezone
timezone: schema.householdBillingSettings.timezone,
rentPaymentDestinations: schema.householdBillingSettings.rentPaymentDestinations
})
const row = rows[0]

View File

@@ -261,7 +261,8 @@ const householdConfigurationRepository: Pick<
rentWarningDay: 17,
utilitiesDueDay: 4,
utilitiesReminderDay: 3,
timezone: 'Asia/Tbilisi'
timezone: 'Asia/Tbilisi',
rentPaymentDestinations: null
}
},
async listHouseholdMembers(householdId) {

View File

@@ -10,7 +10,8 @@ import type {
HouseholdConfigurationRepository,
HouseholdMemberAbsencePolicy,
HouseholdMemberAbsencePolicyRecord,
HouseholdMemberRecord
HouseholdMemberRecord,
HouseholdRentPaymentDestination
} from '@household/ports'
import {
BillingCycleId,
@@ -144,9 +145,12 @@ export interface FinanceDashboard {
period: string
currency: CurrencyCode
timezone: string
rentWarningDay: number
rentDueDay: number
utilitiesReminderDay: number
utilitiesDueDay: number
paymentBalanceAdjustmentPolicy: 'utilities' | 'rent' | 'separate'
rentPaymentDestinations: readonly HouseholdRentPaymentDestination[] | null
totalDue: Money
totalPaid: Money
totalRemaining: Money
@@ -577,9 +581,12 @@ async function buildFinanceDashboard(
period: cycle.period,
currency: cycle.currency,
timezone: settings.timezone,
rentWarningDay: settings.rentWarningDay,
rentDueDay: settings.rentDueDay,
utilitiesReminderDay: settings.utilitiesReminderDay,
utilitiesDueDay: settings.utilitiesDueDay,
paymentBalanceAdjustmentPolicy: settings.paymentBalanceAdjustmentPolicy ?? 'utilities',
rentPaymentDestinations: settings.rentPaymentDestinations ?? null,
totalDue: settlement.totalDue,
totalPaid: paymentRecords.reduce(
(sum, payment) => sum.add(Money.fromMinor(payment.amountMinor, payment.currency)),

View File

@@ -158,7 +158,8 @@ function createRepositoryStub() {
rentWarningDay: 17,
utilitiesDueDay: 4,
utilitiesReminderDay: 3,
timezone: 'Asia/Tbilisi'
timezone: 'Asia/Tbilisi',
rentPaymentDestinations: null
}),
updateHouseholdBillingSettings: async (input) => ({
householdId: input.householdId,
@@ -169,7 +170,8 @@ function createRepositoryStub() {
rentWarningDay: input.rentWarningDay ?? 17,
utilitiesDueDay: input.utilitiesDueDay ?? 4,
utilitiesReminderDay: input.utilitiesReminderDay ?? 3,
timezone: input.timezone ?? 'Asia/Tbilisi'
timezone: input.timezone ?? 'Asia/Tbilisi',
rentPaymentDestinations: input.rentPaymentDestinations ?? null
}),
listHouseholdUtilityCategories: async () => [],
upsertHouseholdUtilityCategory: async (input) => ({

View File

@@ -173,7 +173,8 @@ function createRepositoryStub() {
rentWarningDay: 17,
utilitiesDueDay: 4,
utilitiesReminderDay: 3,
timezone: 'Asia/Tbilisi'
timezone: 'Asia/Tbilisi',
rentPaymentDestinations: null
}
},
async updateHouseholdBillingSettings(input) {
@@ -186,7 +187,8 @@ function createRepositoryStub() {
rentWarningDay: input.rentWarningDay ?? 17,
utilitiesDueDay: input.utilitiesDueDay ?? 4,
utilitiesReminderDay: input.utilitiesReminderDay ?? 3,
timezone: input.timezone ?? 'Asia/Tbilisi'
timezone: input.timezone ?? 'Asia/Tbilisi',
rentPaymentDestinations: input.rentPaymentDestinations ?? null
}
},
async listHouseholdUtilityCategories() {

View File

@@ -270,7 +270,8 @@ function createRepositoryStub() {
rentWarningDay: 17,
utilitiesDueDay: 4,
utilitiesReminderDay: 3,
timezone: 'Asia/Tbilisi'
timezone: 'Asia/Tbilisi',
rentPaymentDestinations: null
}
},
async updateHouseholdBillingSettings(input) {
@@ -283,7 +284,8 @@ function createRepositoryStub() {
rentWarningDay: input.rentWarningDay ?? 17,
utilitiesDueDay: input.utilitiesDueDay ?? 4,
utilitiesReminderDay: input.utilitiesReminderDay ?? 3,
timezone: input.timezone ?? 'Asia/Tbilisi'
timezone: input.timezone ?? 'Asia/Tbilisi',
rentPaymentDestinations: input.rentPaymentDestinations ?? null
}
},
async listHouseholdUtilityCategories() {

View File

@@ -92,7 +92,8 @@ function createRepository(): HouseholdConfigurationRepository {
rentWarningDay: 17,
utilitiesDueDay: 4,
utilitiesReminderDay: 3,
timezone: 'Asia/Tbilisi'
timezone: 'Asia/Tbilisi',
rentPaymentDestinations: null
}),
updateHouseholdBillingSettings: async (input) => ({
householdId: input.householdId,
@@ -103,7 +104,8 @@ function createRepository(): HouseholdConfigurationRepository {
rentWarningDay: input.rentWarningDay ?? 17,
utilitiesDueDay: input.utilitiesDueDay ?? 4,
utilitiesReminderDay: input.utilitiesReminderDay ?? 3,
timezone: input.timezone ?? 'Asia/Tbilisi'
timezone: input.timezone ?? 'Asia/Tbilisi',
rentPaymentDestinations: input.rentPaymentDestinations ?? null
}),
listHouseholdUtilityCategories: async () => [],
upsertHouseholdUtilityCategory: async (input) => ({

View File

@@ -167,7 +167,8 @@ function repository(): HouseholdConfigurationRepository {
rentWarningDay: 17,
utilitiesDueDay: 4,
utilitiesReminderDay: 3,
timezone: 'Asia/Tbilisi'
timezone: 'Asia/Tbilisi',
rentPaymentDestinations: null
}),
updateHouseholdBillingSettings: async (input) => ({
householdId: input.householdId,
@@ -178,7 +179,8 @@ function repository(): HouseholdConfigurationRepository {
rentWarningDay: input.rentWarningDay ?? 17,
utilitiesDueDay: input.utilitiesDueDay ?? 4,
utilitiesReminderDay: input.utilitiesReminderDay ?? 3,
timezone: input.timezone ?? 'Asia/Tbilisi'
timezone: input.timezone ?? 'Asia/Tbilisi',
rentPaymentDestinations: input.rentPaymentDestinations ?? null
}),
getHouseholdAssistantConfig: async (householdId) => ({
householdId,
@@ -286,7 +288,8 @@ describe('createMiniAppAdminService', () => {
rentWarningDay: 17,
utilitiesDueDay: 4,
utilitiesReminderDay: 3,
timezone: 'Asia/Tbilisi'
timezone: 'Asia/Tbilisi',
rentPaymentDestinations: null
},
assistantConfig: {
householdId: 'household-1',
@@ -346,7 +349,8 @@ describe('createMiniAppAdminService', () => {
rentWarningDay: 18,
utilitiesDueDay: 5,
utilitiesReminderDay: 4,
timezone: 'Asia/Tbilisi'
timezone: 'Asia/Tbilisi',
rentPaymentDestinations: null
},
assistantConfig: {
householdId: 'household-1',

View File

@@ -7,6 +7,7 @@ import type {
HouseholdMemberLifecycleStatus,
HouseholdMemberRecord,
HouseholdPendingMemberRecord,
HouseholdRentPaymentDestination,
HouseholdTopicBindingRecord,
HouseholdUtilityCategoryRecord
} from '@household/ports'
@@ -25,6 +26,40 @@ function parseCurrency(raw: string): CurrencyCode {
return normalized
}
function normalizeOptionalString(value: unknown): string | null {
if (typeof value !== 'string') return null
const trimmed = value.trim()
return trimmed.length > 0 ? trimmed : null
}
function normalizeRentPaymentDestinations(
value: unknown
): readonly HouseholdRentPaymentDestination[] | null {
if (value === null) return null
if (!Array.isArray(value)) {
throw new Error('Invalid rent payment destinations')
}
return value
.map((entry): HouseholdRentPaymentDestination | null => {
if (!entry || typeof entry !== 'object') return null
const record = entry as Record<string, unknown>
const label = normalizeOptionalString(record.label)
const account = normalizeOptionalString(record.account)
if (!label || !account) return null
return {
label,
recipientName: normalizeOptionalString(record.recipientName),
bankName: normalizeOptionalString(record.bankName),
account,
note: normalizeOptionalString(record.note),
link: normalizeOptionalString(record.link)
}
})
.filter((entry): entry is HouseholdRentPaymentDestination => Boolean(entry))
}
export interface MiniAppAdminService {
getSettings(input: { householdId: string; actorIsAdmin: boolean }): Promise<
| {
@@ -55,6 +90,7 @@ export interface MiniAppAdminService {
utilitiesDueDay: number
utilitiesReminderDay: number
timezone: string
rentPaymentDestinations?: unknown
assistantContext?: string
assistantTone?: string
}): Promise<
@@ -402,6 +438,18 @@ export function createMiniAppAdminService(
rentCurrency = parseCurrency(input.rentCurrency ?? 'USD')
}
let rentPaymentDestinations: readonly HouseholdRentPaymentDestination[] | null | undefined
if (input.rentPaymentDestinations !== undefined) {
try {
rentPaymentDestinations = normalizeRentPaymentDestinations(input.rentPaymentDestinations)
} catch {
return {
status: 'rejected',
reason: 'invalid_settings'
}
}
}
const shouldUpdateAssistantConfig =
assistantContext !== undefined || assistantTone !== undefined
@@ -432,7 +480,12 @@ export function createMiniAppAdminService(
rentWarningDay: input.rentWarningDay,
utilitiesDueDay: input.utilitiesDueDay,
utilitiesReminderDay: input.utilitiesReminderDay,
timezone
timezone,
...(rentPaymentDestinations !== undefined
? {
rentPaymentDestinations
}
: {})
}),
repository.updateHouseholdAssistantConfig && shouldUpdateAssistantConfig
? repository.updateHouseholdAssistantConfig({

View File

@@ -22,7 +22,8 @@ const settingsRepository: Pick<HouseholdConfigurationRepository, 'getHouseholdBi
rentWarningDay: 17,
utilitiesDueDay: 4,
utilitiesReminderDay: 3,
timezone: 'Asia/Tbilisi'
timezone: 'Asia/Tbilisi',
rentPaymentDestinations: null
}
}
}
@@ -113,9 +114,12 @@ describe('createPaymentConfirmationService', () => {
period: '2026-03',
currency: 'GEL',
timezone: 'Asia/Tbilisi',
rentWarningDay: 17,
rentDueDay: 20,
utilitiesReminderDay: 3,
utilitiesDueDay: 4,
paymentBalanceAdjustmentPolicy: 'utilities',
rentPaymentDestinations: null,
totalDue: Money.fromMajor('1030', 'GEL'),
totalPaid: Money.zero('GEL'),
totalRemaining: Money.fromMajor('1030', 'GEL'),
@@ -179,9 +183,12 @@ describe('createPaymentConfirmationService', () => {
period: '2026-03',
currency: 'GEL',
timezone: 'Asia/Tbilisi',
rentWarningDay: 17,
rentDueDay: 20,
utilitiesReminderDay: 3,
utilitiesDueDay: 4,
paymentBalanceAdjustmentPolicy: 'utilities',
rentPaymentDestinations: null,
totalDue: Money.fromMajor('1030', 'GEL'),
totalPaid: Money.zero('GEL'),
totalRemaining: Money.fromMajor('1030', 'GEL'),

View File

@@ -0,0 +1 @@
ALTER TABLE "household_billing_settings" ADD COLUMN "rent_payment_destinations" jsonb;

View File

@@ -39,6 +39,7 @@ export const householdBillingSettings = pgTable(
utilitiesDueDay: integer('utilities_due_day').default(4).notNull(),
utilitiesReminderDay: integer('utilities_reminder_day').default(3).notNull(),
timezone: text('timezone').default('Asia/Tbilisi').notNull(),
rentPaymentDestinations: jsonb('rent_payment_destinations'),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull()
},

View File

@@ -73,6 +73,15 @@ export interface HouseholdMemberAbsencePolicyRecord {
policy: HouseholdMemberAbsencePolicy
}
export interface HouseholdRentPaymentDestination {
label: string
recipientName: string | null
bankName: string | null
account: string
note: string | null
link: string | null
}
export interface HouseholdBillingSettingsRecord {
householdId: string
settlementCurrency: CurrencyCode
@@ -84,6 +93,7 @@ export interface HouseholdBillingSettingsRecord {
utilitiesDueDay: number
utilitiesReminderDay: number
timezone: string
rentPaymentDestinations: readonly HouseholdRentPaymentDestination[] | null
}
export interface HouseholdAssistantConfigRecord {
@@ -184,6 +194,7 @@ export interface HouseholdConfigurationRepository {
utilitiesDueDay?: number
utilitiesReminderDay?: number
timezone?: string
rentPaymentDestinations?: readonly HouseholdRentPaymentDestination[] | null
}): Promise<HouseholdBillingSettingsRecord>
updateHouseholdAssistantConfig?(input: {
householdId: string

View File

@@ -20,6 +20,7 @@ export {
type HouseholdMemberAbsencePolicy,
type HouseholdMemberAbsencePolicyRecord,
type HouseholdAssistantConfigRecord,
type HouseholdRentPaymentDestination,
type HouseholdPaymentBalanceAdjustmentPolicy,
type HouseholdConfigurationRepository,
type HouseholdBillingSettingsRecord,