From a50b826b401859b416ca9779831e66c5113c3ce6 Mon Sep 17 00:00:00 2001 From: whekin Date: Thu, 12 Mar 2026 11:37:57 +0400 Subject: [PATCH] feat(miniapp): improve billing settings controls --- apps/bot/src/miniapp-admin.test.ts | 58 +++++++++ apps/miniapp/src/i18n.ts | 50 ++++++-- apps/miniapp/src/index.css | 20 +++ apps/miniapp/src/lib/timezones.ts | 93 ++++++++++++++ apps/miniapp/src/screens/house-screen.tsx | 121 ++++++++++++++++-- .../src/miniapp-admin-service.test.ts | 19 +++ .../application/src/miniapp-admin-service.ts | 22 +++- 7 files changed, 358 insertions(+), 25 deletions(-) create mode 100644 apps/miniapp/src/lib/timezones.ts diff --git a/apps/bot/src/miniapp-admin.test.ts b/apps/bot/src/miniapp-admin.test.ts index 5dd36d9..8db4a17 100644 --- a/apps/bot/src/miniapp-admin.test.ts +++ b/apps/bot/src/miniapp-admin.test.ts @@ -591,6 +591,64 @@ describe('createMiniAppUpdateSettingsHandler', () => { } }) }) + + test('rejects invalid timezone updates for an authenticated admin', async () => { + const authDate = Math.floor(Date.now() / 1000) + const repository = onboardingRepository() + repository.listHouseholdMembersByTelegramUserId = async () => [ + { + id: 'member-123456', + householdId: 'household-1', + telegramUserId: '123456', + displayName: 'Stan', + status: 'active', + preferredLocale: null, + householdDefaultLocale: 'ru', + rentShareWeight: 1, + isAdmin: true + } + ] + + const handler = createMiniAppUpdateSettingsHandler({ + allowedOrigins: ['http://localhost:5173'], + botToken: 'test-bot-token', + onboardingService: createHouseholdOnboardingService({ + repository + }), + miniAppAdminService: createMiniAppAdminService(repository) + }) + + const response = await handler.handler( + new Request('http://localhost/api/miniapp/admin/settings/update', { + method: 'POST', + headers: { + origin: 'http://localhost:5173', + 'content-type': 'application/json' + }, + body: JSON.stringify({ + initData: buildMiniAppInitData('test-bot-token', authDate, { + id: 123456, + first_name: 'Stan', + username: 'stanislav', + language_code: 'ru' + }), + rentAmountMajor: '750', + rentCurrency: 'USD', + rentDueDay: 22, + rentWarningDay: 19, + utilitiesDueDay: 6, + utilitiesReminderDay: 5, + timezone: 'Moon/Base' + }) + }) + ) + + expect(response.status).toBe(400) + expect(await response.json()).toEqual({ + ok: false, + error: 'Invalid billing settings' + }) + }) }) describe('createMiniAppPromoteMemberHandler', () => { diff --git a/apps/miniapp/src/i18n.ts b/apps/miniapp/src/i18n.ts index 0268b0d..dfa1e49 100644 --- a/apps/miniapp/src/i18n.ts +++ b/apps/miniapp/src/i18n.ts @@ -180,14 +180,14 @@ export const dictionary = { billingCycleStatus: 'Current cycle currency: {currency}', billingCycleOpenHint: 'Open a cycle before entering rent and utility bills.', billingCyclePeriod: 'Cycle period', - manageCycleAction: 'Manage cycle', + manageCycleAction: 'Edit cycle rent', cycleEditorBody: - 'Keep the billing cycle controls in one focused editor instead of a long page.', + 'Change the rent for the current cycle only. Keep billing settings for the long-term default.', openCycleAction: 'Open cycle', openingCycle: 'Opening cycle…', closeCycleAction: 'Close cycle', closingCycle: 'Closing cycle…', - saveCycleRentAction: 'Save current cycle rent', + saveCycleRentAction: 'Save cycle rent', savingCycleRent: 'Saving rent…', utilityCategoryLabel: 'Utility category', utilityAmount: 'Utility amount', @@ -197,14 +197,27 @@ export const dictionary = { deleteUtilityBillAction: 'Delete', deletingUtilityBill: 'Deleting utility bill…', utilityBillsEmpty: 'No utility bills recorded for this cycle yet.', + currencyLabel: 'Currency', rentAmount: 'Rent amount', + defaultRentAmount: 'Default rent', + defaultRentHint: + 'New current cycles start from this rent unless you override a specific month.', + currentCycleRentLabel: 'Current cycle rent', + currentCycleRentHint: + 'Only use this when the current month needs a different rent from the default.', + currentCycleRentEmpty: 'No rent saved for this cycle yet.', + currentCycleUsesDefaultRent: 'Using the default rent', + currentCycleOverrideRent: 'Cycle override active', rentDueDay: 'Rent due day', rentWarningDay: 'Rent warning day', utilitiesDueDay: 'Utilities due day', utilitiesReminderDay: 'Utilities reminder day', timezone: 'Timezone', + timezoneHint: 'Use an IANA timezone like Asia/Tbilisi.', + timezoneInvalidHint: 'Pick a valid IANA timezone such as Asia/Tbilisi.', manageSettingsAction: 'Manage settings', - billingSettingsEditorBody: 'Household billing defaults live here when you need to change them.', + billingSettingsEditorBody: + 'Household defaults live here. New current cycles start from these values.', saveSettingsAction: 'Save settings', savingSettings: 'Saving settings…', utilityCategoriesTitle: 'Utility categories', @@ -441,18 +454,19 @@ export const dictionary = { paymentBalanceAdjustmentUtilities: 'Зачитывать через коммуналку', paymentBalanceAdjustmentRent: 'Зачитывать через аренду', paymentBalanceAdjustmentSeparate: 'Держать покупки отдельно', - billingCycleTitle: 'Текущий billing cycle', + billingCycleTitle: 'Текущий расчётный цикл', billingCycleEmpty: 'Нет открытого цикла', billingCycleStatus: 'Валюта текущего цикла: {currency}', billingCycleOpenHint: 'Открой цикл перед тем, как вносить аренду и коммунальные счета.', billingCyclePeriod: 'Период цикла', - manageCycleAction: 'Управлять циклом', - cycleEditorBody: 'Все действия по циклу собраны в отдельном окне, а не растянуты по странице.', + manageCycleAction: 'Изменить аренду цикла', + cycleEditorBody: + 'Меняй аренду только для текущего цикла. Долгосрочное значение по умолчанию остаётся в настройках.', openCycleAction: 'Открыть цикл', openingCycle: 'Открываем цикл…', closeCycleAction: 'Закрыть цикл', closingCycle: 'Закрываем цикл…', - saveCycleRentAction: 'Сохранить аренду для цикла', + saveCycleRentAction: 'Сохранить аренду цикла', savingCycleRent: 'Сохраняем аренду…', utilityCategoryLabel: 'Категория коммуналки', utilityAmount: 'Сумма коммуналки', @@ -462,14 +476,27 @@ export const dictionary = { deleteUtilityBillAction: 'Удалить', deletingUtilityBill: 'Удаляем счёт…', utilityBillsEmpty: 'Для этого цикла пока нет коммунальных счетов.', + currencyLabel: 'Валюта', rentAmount: 'Сумма аренды', + defaultRentAmount: 'Аренда по умолчанию', + defaultRentHint: + 'Новые текущие циклы стартуют с этой суммой, если для конкретного месяца нет переопределения.', + currentCycleRentLabel: 'Аренда текущего цикла', + currentCycleRentHint: + 'Используй это только когда в текущем месяце аренда отличается от значения по умолчанию.', + currentCycleRentEmpty: 'Для этого цикла аренда пока не задана.', + currentCycleUsesDefaultRent: 'Используется аренда по умолчанию', + currentCycleOverrideRent: 'Есть переопределение для цикла', rentDueDay: 'День оплаты аренды', rentWarningDay: 'День напоминания по аренде', utilitiesDueDay: 'День оплаты коммуналки', utilitiesReminderDay: 'День напоминания по коммуналке', timezone: 'Часовой пояс', + timezoneHint: 'Используй IANA-таймзону, например Asia/Tbilisi.', + timezoneInvalidHint: 'Выбери корректную IANA-таймзону, например Asia/Tbilisi.', manageSettingsAction: 'Управлять настройками', - billingSettingsEditorBody: 'Основные правила биллинга собраны в отдельном окне.', + billingSettingsEditorBody: + 'Здесь живут значения по умолчанию для household. Новые текущие циклы стартуют отсюда.', saveSettingsAction: 'Сохранить настройки', savingSettings: 'Сохраняем настройки…', utilityCategoriesTitle: 'Категории коммуналки', @@ -491,11 +518,10 @@ export const dictionary = { adminsTitle: 'Админы', adminsBody: 'Повышай доверенных участников, чтобы они могли управлять биллингом и подтверждениями.', - displayNameLabel: 'Имя в household', + displayNameLabel: 'Имя в доме', displayNameHint: 'Это имя будет видно в балансе, леджере и ответах ассистента.', manageProfileAction: 'Редактировать профиль', - profileEditorBody: - 'Своё имя для household лучше менять в отдельном окне, а не на самой странице.', + profileEditorBody: 'Своё имя для дома лучше менять в отдельном окне, а не на самой странице.', memberEditorBody: 'Статус, политика и админские действия по участнику собраны в одном окне.', editMemberAction: 'Редактировать участника', saveMemberChangesAction: 'Сохранить изменения', diff --git a/apps/miniapp/src/index.css b/apps/miniapp/src/index.css index 847cc61..878893f 100644 --- a/apps/miniapp/src/index.css +++ b/apps/miniapp/src/index.css @@ -858,6 +858,11 @@ button { line-height: 1.2; } +.settings-field input[aria-invalid='true'] { + border-color: rgb(247 115 115 / 0.45); + background: rgb(247 115 115 / 0.08); +} + .settings-field select { appearance: none; -webkit-appearance: none; @@ -967,6 +972,21 @@ button { cursor: pointer; } +.timezone-suggestions { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.timezone-chip { + border: 1px solid transparent; +} + +.timezone-chip--active { + border-color: rgb(247 179 137 / 0.44); + background: rgb(247 179 137 / 0.18); +} + .testing-card { gap: 12px; } diff --git a/apps/miniapp/src/lib/timezones.ts b/apps/miniapp/src/lib/timezones.ts new file mode 100644 index 0000000..8f30863 --- /dev/null +++ b/apps/miniapp/src/lib/timezones.ts @@ -0,0 +1,93 @@ +const CURATED_TIMEZONES = [ + 'Asia/Tbilisi', + 'Europe/Berlin', + 'Europe/London', + 'Europe/Paris', + 'Europe/Warsaw', + 'America/New_York', + 'America/Chicago', + 'America/Denver', + 'America/Los_Angeles', + 'Asia/Dubai', + 'Asia/Bangkok', + 'Asia/Tokyo', + 'Australia/Sydney' +] as const + +const CURATED_TIMEZONE_SET = new Set(CURATED_TIMEZONES) + +type IntlWithSupportedValues = typeof Intl & { + supportedValuesOf?: (key: 'timeZone') => readonly string[] +} + +function supportedTimezones(): readonly string[] { + const withSupportedValues = Intl as IntlWithSupportedValues + + if (typeof withSupportedValues.supportedValuesOf === 'function') { + try { + const supported = withSupportedValues.supportedValuesOf('timeZone') + if (supported.length > 0) { + return supported + } + } catch { + // Ignore and fall back to the curated list below. + } + } + + return CURATED_TIMEZONES +} + +const TIMEZONES = [ + ...CURATED_TIMEZONES, + ...supportedTimezones() + .filter((timezone) => !CURATED_TIMEZONE_SET.has(timezone)) + .sort((left, right) => left.localeCompare(right)) +] + +export function canonicalizeTimezone(value: string): string | null { + const trimmed = value.trim() + + if (trimmed.length === 0) { + return null + } + + try { + return new Intl.DateTimeFormat('en-US', { + timeZone: trimmed + }).resolvedOptions().timeZone + } catch { + return null + } +} + +export function isValidTimezone(value: string): boolean { + return canonicalizeTimezone(value) !== null +} + +export function searchTimezones(query: string, limit = 10): readonly string[] { + const trimmed = query.trim() + + if (trimmed.length === 0) { + return TIMEZONES.slice(0, limit) + } + + const normalized = trimmed.toLowerCase() + const matches = TIMEZONES.filter((timezone) => timezone.toLowerCase().includes(normalized)) + + if (matches.length === 0) { + return TIMEZONES.slice(0, limit) + } + + matches.sort((left, right) => { + const leftStartsWith = left.toLowerCase().startsWith(normalized) + const rightStartsWith = right.toLowerCase().startsWith(normalized) + + if (leftStartsWith !== rightStartsWith) { + return leftStartsWith ? -1 : 1 + } + + return left.localeCompare(right) + }) + + return matches.slice(0, limit) +} diff --git a/apps/miniapp/src/screens/house-screen.tsx b/apps/miniapp/src/screens/house-screen.tsx index b787c05..605c3bd 100644 --- a/apps/miniapp/src/screens/house-screen.tsx +++ b/apps/miniapp/src/screens/house-screen.tsx @@ -1,4 +1,4 @@ -import { For, Show, type JSX } from 'solid-js' +import { For, Show, createMemo, type JSX } from 'solid-js' import { Button, @@ -13,6 +13,7 @@ import { TrashIcon } from '../components/ui' import { formatCyclePeriod, formatFriendlyDate } from '../lib/dates' +import { isValidTimezone, searchTimezones } from '../lib/timezones' import type { MiniAppAdminCycleState, MiniAppAdminSettingsPayload, @@ -205,8 +206,59 @@ export function HouseScreen(props: Props) { return parsed } + function parseAmountMinor(value: string): bigint | null { + const trimmed = value.trim() + + if (!/^\d+(?:\.\d{0,2})?$/.test(trimmed)) { + return null + } + + const [whole, fraction] = trimmed.split('.') + + return BigInt(whole ?? '0') * 100n + BigInt(((fraction ?? '') + '00').slice(0, 2)) + } + const enabledLabel = () => props.copy.onLabel ?? 'ON' const disabledLabel = () => props.copy.offLabel ?? 'OFF' + const timezoneSuggestions = createMemo(() => searchTimezones(props.billingForm.timezone, 8)) + const timezoneValid = createMemo(() => isValidTimezone(props.billingForm.timezone)) + const defaultRentMinor = createMemo(() => parseAmountMinor(props.billingForm.rentAmountMajor)) + const cycleRentMinor = createMemo(() => + props.cycleState?.rentRule ? BigInt(props.cycleState.rentRule.amountMinor) : null + ) + const currentCycleUsesDefaultRent = createMemo(() => { + if (!props.cycleState?.rentRule) { + return false + } + + return ( + defaultRentMinor() === cycleRentMinor() && + props.cycleState.rentRule.currency === props.billingForm.rentCurrency + ) + }) + const currentCycleRentStatus = createMemo(() => { + if (!props.cycleState?.rentRule) { + return props.copy.currentCycleRentEmpty ?? '—' + } + + return currentCycleUsesDefaultRent() + ? (props.copy.currentCycleUsesDefaultRent ?? '') + : (props.copy.currentCycleOverrideRent ?? '') + }) + const defaultRentSummary = createMemo(() => + defaultRentMinor() === null + ? '—' + : `${props.billingForm.rentAmountMajor.trim()} ${props.billingForm.rentCurrency}` + ) + const currentCycleRentSummary = createMemo(() => { + if (!props.cycleState?.rentRule) { + return props.copy.currentCycleRentEmpty ?? '—' + } + + return `${props.minorToMajorString(BigInt(props.cycleState.rentRule.amountMinor))} ${ + props.cycleState.rentRule.currency + }` + }) return ( +
+ + {props.copy.defaultRentAmount ?? props.copy.rentAmount ?? ''}:{' '} + {defaultRentSummary()} + + {currentCycleRentStatus()} +
+

+ {props.copy.currentCycleRentLabel ?? props.copy.rentAmount ?? ''}:{' '} + {currentCycleRentSummary()} +

{(data) => (

- {props.copy.shareRent ?? ''}: {data().rentSourceAmountMajor}{' '} - {data().rentSourceCurrency} {data().rentSourceCurrency !== data().currency - ? ` -> ${data().rentDisplayAmountMajor} ${data().currency}` - : ''} + ? `${data().rentSourceAmountMajor} ${data().rentSourceCurrency} = ${data().rentDisplayAmountMajor} ${data().currency}` + : `${data().rentDisplayAmountMajor} ${data().currency}`}

)}
@@ -406,13 +467,16 @@ export function HouseScreen(props: Props) { > {props.cycleState?.cycle ? (
- + props.onCycleRentAmountChange(event.currentTarget.value)} /> - + - + props.onBillingRentAmountChange(event.currentTarget.value)} /> - + props.onBillingTimezoneChange(event.currentTarget.value)} /> + + + {(timezone) => } + + +
+ + {(timezone) => ( + + )} + +
{ }) }) + test('rejects invalid timezones when updating billing settings', async () => { + const service = createMiniAppAdminService(repository()) + + const result = await service.updateSettings({ + householdId: 'household-1', + actorIsAdmin: true, + rentDueDay: 21, + rentWarningDay: 18, + utilitiesDueDay: 5, + utilitiesReminderDay: 4, + timezone: 'Moon/Base' + }) + + expect(result).toEqual({ + status: 'rejected', + reason: 'invalid_settings' + }) + }) + test('stores an away absence policy from the current local period', async () => { const service = createMiniAppAdminService(repository()) diff --git a/packages/application/src/miniapp-admin-service.ts b/packages/application/src/miniapp-admin-service.ts index 0512e04..98482ee 100644 --- a/packages/application/src/miniapp-admin-service.ts +++ b/packages/application/src/miniapp-admin-service.ts @@ -231,6 +231,22 @@ function normalizeHouseholdName(raw: string | undefined): string | null | undefi return trimmed.replace(/\s+/g, ' ') } +function normalizeTimezone(raw: string): string | null { + const trimmed = raw.trim() + + if (trimmed.length === 0) { + return null + } + + try { + return new Intl.DateTimeFormat('en-US', { + timeZone: trimmed + }).resolvedOptions().timeZone + } catch { + return null + } +} + function defaultAssistantConfig(householdId: string): HouseholdAssistantConfigRecord { return { householdId, @@ -308,12 +324,14 @@ export function createMiniAppAdminService( } } + const timezone = normalizeTimezone(input.timezone) + if ( !isValidDay(input.rentDueDay) || !isValidDay(input.rentWarningDay) || !isValidDay(input.utilitiesDueDay) || !isValidDay(input.utilitiesReminderDay) || - input.timezone.trim().length === 0 || + timezone === null || input.rentWarningDay > input.rentDueDay || input.utilitiesReminderDay > input.utilitiesDueDay ) { @@ -401,7 +419,7 @@ export function createMiniAppAdminService( rentWarningDay: input.rentWarningDay, utilitiesDueDay: input.utilitiesDueDay, utilitiesReminderDay: input.utilitiesReminderDay, - timezone: input.timezone.trim() + timezone }), repository.updateHouseholdAssistantConfig && shouldUpdateAssistantConfig ? repository.updateHouseholdAssistantConfig({