feat(miniapp): improve billing settings controls

This commit is contained in:
2026-03-12 11:37:57 +04:00
parent 4c19ee798d
commit a50b826b40
7 changed files with 358 additions and 25 deletions

View File

@@ -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', () => {

View File

@@ -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: 'Сохранить изменения',

View File

@@ -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;
}

View File

@@ -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<string>(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)
}

View File

@@ -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 (
<Show
@@ -315,14 +367,23 @@ export function HouseScreen(props: Props) {
)
: props.copy.billingCycleOpenHint}
</p>
<div class="ledger-compact-card__meta">
<span class="mini-chip">
{props.copy.defaultRentAmount ?? props.copy.rentAmount ?? ''}:{' '}
{defaultRentSummary()}
</span>
<span class="mini-chip mini-chip--muted">{currentCycleRentStatus()}</span>
</div>
<p>
{props.copy.currentCycleRentLabel ?? props.copy.rentAmount ?? ''}:{' '}
{currentCycleRentSummary()}
</p>
<Show when={props.dashboard}>
{(data) => (
<p>
{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}`}
</p>
)}
</Show>
@@ -406,13 +467,16 @@ export function HouseScreen(props: Props) {
>
{props.cycleState?.cycle ? (
<div class="editor-grid">
<Field label={props.copy.rentAmount ?? ''}>
<Field
label={props.copy.currentCycleRentLabel ?? props.copy.rentAmount ?? ''}
hint={props.copy.currentCycleRentHint ?? ''}
>
<input
value={props.cycleForm.rentAmountMajor}
onInput={(event) => props.onCycleRentAmountChange(event.currentTarget.value)}
/>
</Field>
<Field label={props.copy.shareRent ?? ''}>
<Field label={props.copy.currencyLabel ?? props.copy.settlementCurrency ?? ''}>
<select
value={props.cycleForm.rentCurrency}
onChange={(event) =>
@@ -451,7 +515,7 @@ export function HouseScreen(props: Props) {
</Button>
<Button
variant="primary"
disabled={props.savingBillingSettings}
disabled={props.savingBillingSettings || !timezoneValid()}
onClick={() => void props.onSaveBillingSettings()}
>
{props.savingBillingSettings
@@ -503,13 +567,16 @@ export function HouseScreen(props: Props) {
<option value="separate">{props.copy.paymentBalanceAdjustmentSeparate}</option>
</select>
</Field>
<Field label={props.copy.rentAmount ?? ''}>
<Field
label={props.copy.defaultRentAmount ?? props.copy.rentAmount ?? ''}
hint={props.copy.defaultRentHint ?? ''}
>
<input
value={props.billingForm.rentAmountMajor}
onInput={(event) => props.onBillingRentAmountChange(event.currentTarget.value)}
/>
</Field>
<Field label={props.copy.shareRent ?? ''}>
<Field label={props.copy.currencyLabel ?? props.copy.settlementCurrency ?? ''}>
<select
value={props.billingForm.rentCurrency}
onChange={(event) =>
@@ -572,11 +639,43 @@ export function HouseScreen(props: Props) {
}
/>
</Field>
<Field label={props.copy.timezone ?? ''} wide>
<Field
label={props.copy.timezone ?? ''}
hint={
timezoneValid()
? (props.copy.timezoneHint ?? '')
: (props.copy.timezoneInvalidHint ?? '')
}
wide
>
<input
aria-invalid={!timezoneValid()}
list="billing-timezone-options"
placeholder="Asia/Tbilisi"
value={props.billingForm.timezone}
onInput={(event) => props.onBillingTimezoneChange(event.currentTarget.value)}
/>
<datalist id="billing-timezone-options">
<For each={timezoneSuggestions()}>
{(timezone) => <option value={timezone}>{timezone}</option>}
</For>
</datalist>
<div class="timezone-suggestions">
<For each={timezoneSuggestions()}>
{(timezone) => (
<button
class="mini-chip mini-chip-button timezone-chip"
classList={{
'timezone-chip--active': props.billingForm.timezone === timezone
}}
type="button"
onClick={() => props.onBillingTimezoneChange(timezone)}
>
{timezone}
</button>
)}
</For>
</div>
</Field>
<Field label={props.copy.assistantToneLabel ?? ''} wide>
<input

View File

@@ -355,6 +355,25 @@ describe('createMiniAppAdminService', () => {
})
})
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())

View File

@@ -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({