mirror of
https://github.com/whekin/household-bot.git
synced 2026-03-31 16:04:03 +00:00
feat(miniapp): improve billing settings controls
This commit is contained in:
@@ -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', () => {
|
describe('createMiniAppPromoteMemberHandler', () => {
|
||||||
|
|||||||
@@ -180,14 +180,14 @@ export const dictionary = {
|
|||||||
billingCycleStatus: 'Current cycle currency: {currency}',
|
billingCycleStatus: 'Current cycle currency: {currency}',
|
||||||
billingCycleOpenHint: 'Open a cycle before entering rent and utility bills.',
|
billingCycleOpenHint: 'Open a cycle before entering rent and utility bills.',
|
||||||
billingCyclePeriod: 'Cycle period',
|
billingCyclePeriod: 'Cycle period',
|
||||||
manageCycleAction: 'Manage cycle',
|
manageCycleAction: 'Edit cycle rent',
|
||||||
cycleEditorBody:
|
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',
|
openCycleAction: 'Open cycle',
|
||||||
openingCycle: 'Opening cycle…',
|
openingCycle: 'Opening cycle…',
|
||||||
closeCycleAction: 'Close cycle',
|
closeCycleAction: 'Close cycle',
|
||||||
closingCycle: 'Closing cycle…',
|
closingCycle: 'Closing cycle…',
|
||||||
saveCycleRentAction: 'Save current cycle rent',
|
saveCycleRentAction: 'Save cycle rent',
|
||||||
savingCycleRent: 'Saving rent…',
|
savingCycleRent: 'Saving rent…',
|
||||||
utilityCategoryLabel: 'Utility category',
|
utilityCategoryLabel: 'Utility category',
|
||||||
utilityAmount: 'Utility amount',
|
utilityAmount: 'Utility amount',
|
||||||
@@ -197,14 +197,27 @@ export const dictionary = {
|
|||||||
deleteUtilityBillAction: 'Delete',
|
deleteUtilityBillAction: 'Delete',
|
||||||
deletingUtilityBill: 'Deleting utility bill…',
|
deletingUtilityBill: 'Deleting utility bill…',
|
||||||
utilityBillsEmpty: 'No utility bills recorded for this cycle yet.',
|
utilityBillsEmpty: 'No utility bills recorded for this cycle yet.',
|
||||||
|
currencyLabel: 'Currency',
|
||||||
rentAmount: 'Rent amount',
|
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',
|
rentDueDay: 'Rent due day',
|
||||||
rentWarningDay: 'Rent warning day',
|
rentWarningDay: 'Rent warning day',
|
||||||
utilitiesDueDay: 'Utilities due day',
|
utilitiesDueDay: 'Utilities due day',
|
||||||
utilitiesReminderDay: 'Utilities reminder day',
|
utilitiesReminderDay: 'Utilities reminder day',
|
||||||
timezone: 'Timezone',
|
timezone: 'Timezone',
|
||||||
|
timezoneHint: 'Use an IANA timezone like Asia/Tbilisi.',
|
||||||
|
timezoneInvalidHint: 'Pick a valid IANA timezone such as Asia/Tbilisi.',
|
||||||
manageSettingsAction: 'Manage settings',
|
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',
|
saveSettingsAction: 'Save settings',
|
||||||
savingSettings: 'Saving settings…',
|
savingSettings: 'Saving settings…',
|
||||||
utilityCategoriesTitle: 'Utility categories',
|
utilityCategoriesTitle: 'Utility categories',
|
||||||
@@ -441,18 +454,19 @@ export const dictionary = {
|
|||||||
paymentBalanceAdjustmentUtilities: 'Зачитывать через коммуналку',
|
paymentBalanceAdjustmentUtilities: 'Зачитывать через коммуналку',
|
||||||
paymentBalanceAdjustmentRent: 'Зачитывать через аренду',
|
paymentBalanceAdjustmentRent: 'Зачитывать через аренду',
|
||||||
paymentBalanceAdjustmentSeparate: 'Держать покупки отдельно',
|
paymentBalanceAdjustmentSeparate: 'Держать покупки отдельно',
|
||||||
billingCycleTitle: 'Текущий billing cycle',
|
billingCycleTitle: 'Текущий расчётный цикл',
|
||||||
billingCycleEmpty: 'Нет открытого цикла',
|
billingCycleEmpty: 'Нет открытого цикла',
|
||||||
billingCycleStatus: 'Валюта текущего цикла: {currency}',
|
billingCycleStatus: 'Валюта текущего цикла: {currency}',
|
||||||
billingCycleOpenHint: 'Открой цикл перед тем, как вносить аренду и коммунальные счета.',
|
billingCycleOpenHint: 'Открой цикл перед тем, как вносить аренду и коммунальные счета.',
|
||||||
billingCyclePeriod: 'Период цикла',
|
billingCyclePeriod: 'Период цикла',
|
||||||
manageCycleAction: 'Управлять циклом',
|
manageCycleAction: 'Изменить аренду цикла',
|
||||||
cycleEditorBody: 'Все действия по циклу собраны в отдельном окне, а не растянуты по странице.',
|
cycleEditorBody:
|
||||||
|
'Меняй аренду только для текущего цикла. Долгосрочное значение по умолчанию остаётся в настройках.',
|
||||||
openCycleAction: 'Открыть цикл',
|
openCycleAction: 'Открыть цикл',
|
||||||
openingCycle: 'Открываем цикл…',
|
openingCycle: 'Открываем цикл…',
|
||||||
closeCycleAction: 'Закрыть цикл',
|
closeCycleAction: 'Закрыть цикл',
|
||||||
closingCycle: 'Закрываем цикл…',
|
closingCycle: 'Закрываем цикл…',
|
||||||
saveCycleRentAction: 'Сохранить аренду для цикла',
|
saveCycleRentAction: 'Сохранить аренду цикла',
|
||||||
savingCycleRent: 'Сохраняем аренду…',
|
savingCycleRent: 'Сохраняем аренду…',
|
||||||
utilityCategoryLabel: 'Категория коммуналки',
|
utilityCategoryLabel: 'Категория коммуналки',
|
||||||
utilityAmount: 'Сумма коммуналки',
|
utilityAmount: 'Сумма коммуналки',
|
||||||
@@ -462,14 +476,27 @@ export const dictionary = {
|
|||||||
deleteUtilityBillAction: 'Удалить',
|
deleteUtilityBillAction: 'Удалить',
|
||||||
deletingUtilityBill: 'Удаляем счёт…',
|
deletingUtilityBill: 'Удаляем счёт…',
|
||||||
utilityBillsEmpty: 'Для этого цикла пока нет коммунальных счетов.',
|
utilityBillsEmpty: 'Для этого цикла пока нет коммунальных счетов.',
|
||||||
|
currencyLabel: 'Валюта',
|
||||||
rentAmount: 'Сумма аренды',
|
rentAmount: 'Сумма аренды',
|
||||||
|
defaultRentAmount: 'Аренда по умолчанию',
|
||||||
|
defaultRentHint:
|
||||||
|
'Новые текущие циклы стартуют с этой суммой, если для конкретного месяца нет переопределения.',
|
||||||
|
currentCycleRentLabel: 'Аренда текущего цикла',
|
||||||
|
currentCycleRentHint:
|
||||||
|
'Используй это только когда в текущем месяце аренда отличается от значения по умолчанию.',
|
||||||
|
currentCycleRentEmpty: 'Для этого цикла аренда пока не задана.',
|
||||||
|
currentCycleUsesDefaultRent: 'Используется аренда по умолчанию',
|
||||||
|
currentCycleOverrideRent: 'Есть переопределение для цикла',
|
||||||
rentDueDay: 'День оплаты аренды',
|
rentDueDay: 'День оплаты аренды',
|
||||||
rentWarningDay: 'День напоминания по аренде',
|
rentWarningDay: 'День напоминания по аренде',
|
||||||
utilitiesDueDay: 'День оплаты коммуналки',
|
utilitiesDueDay: 'День оплаты коммуналки',
|
||||||
utilitiesReminderDay: 'День напоминания по коммуналке',
|
utilitiesReminderDay: 'День напоминания по коммуналке',
|
||||||
timezone: 'Часовой пояс',
|
timezone: 'Часовой пояс',
|
||||||
|
timezoneHint: 'Используй IANA-таймзону, например Asia/Tbilisi.',
|
||||||
|
timezoneInvalidHint: 'Выбери корректную IANA-таймзону, например Asia/Tbilisi.',
|
||||||
manageSettingsAction: 'Управлять настройками',
|
manageSettingsAction: 'Управлять настройками',
|
||||||
billingSettingsEditorBody: 'Основные правила биллинга собраны в отдельном окне.',
|
billingSettingsEditorBody:
|
||||||
|
'Здесь живут значения по умолчанию для household. Новые текущие циклы стартуют отсюда.',
|
||||||
saveSettingsAction: 'Сохранить настройки',
|
saveSettingsAction: 'Сохранить настройки',
|
||||||
savingSettings: 'Сохраняем настройки…',
|
savingSettings: 'Сохраняем настройки…',
|
||||||
utilityCategoriesTitle: 'Категории коммуналки',
|
utilityCategoriesTitle: 'Категории коммуналки',
|
||||||
@@ -491,11 +518,10 @@ export const dictionary = {
|
|||||||
adminsTitle: 'Админы',
|
adminsTitle: 'Админы',
|
||||||
adminsBody:
|
adminsBody:
|
||||||
'Повышай доверенных участников, чтобы они могли управлять биллингом и подтверждениями.',
|
'Повышай доверенных участников, чтобы они могли управлять биллингом и подтверждениями.',
|
||||||
displayNameLabel: 'Имя в household',
|
displayNameLabel: 'Имя в доме',
|
||||||
displayNameHint: 'Это имя будет видно в балансе, леджере и ответах ассистента.',
|
displayNameHint: 'Это имя будет видно в балансе, леджере и ответах ассистента.',
|
||||||
manageProfileAction: 'Редактировать профиль',
|
manageProfileAction: 'Редактировать профиль',
|
||||||
profileEditorBody:
|
profileEditorBody: 'Своё имя для дома лучше менять в отдельном окне, а не на самой странице.',
|
||||||
'Своё имя для household лучше менять в отдельном окне, а не на самой странице.',
|
|
||||||
memberEditorBody: 'Статус, политика и админские действия по участнику собраны в одном окне.',
|
memberEditorBody: 'Статус, политика и админские действия по участнику собраны в одном окне.',
|
||||||
editMemberAction: 'Редактировать участника',
|
editMemberAction: 'Редактировать участника',
|
||||||
saveMemberChangesAction: 'Сохранить изменения',
|
saveMemberChangesAction: 'Сохранить изменения',
|
||||||
|
|||||||
@@ -858,6 +858,11 @@ button {
|
|||||||
line-height: 1.2;
|
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 {
|
.settings-field select {
|
||||||
appearance: none;
|
appearance: none;
|
||||||
-webkit-appearance: none;
|
-webkit-appearance: none;
|
||||||
@@ -967,6 +972,21 @@ button {
|
|||||||
cursor: pointer;
|
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 {
|
.testing-card {
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
|||||||
93
apps/miniapp/src/lib/timezones.ts
Normal file
93
apps/miniapp/src/lib/timezones.ts
Normal 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)
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { For, Show, type JSX } from 'solid-js'
|
import { For, Show, createMemo, type JSX } from 'solid-js'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
TrashIcon
|
TrashIcon
|
||||||
} from '../components/ui'
|
} from '../components/ui'
|
||||||
import { formatCyclePeriod, formatFriendlyDate } from '../lib/dates'
|
import { formatCyclePeriod, formatFriendlyDate } from '../lib/dates'
|
||||||
|
import { isValidTimezone, searchTimezones } from '../lib/timezones'
|
||||||
import type {
|
import type {
|
||||||
MiniAppAdminCycleState,
|
MiniAppAdminCycleState,
|
||||||
MiniAppAdminSettingsPayload,
|
MiniAppAdminSettingsPayload,
|
||||||
@@ -205,8 +206,59 @@ export function HouseScreen(props: Props) {
|
|||||||
return parsed
|
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 enabledLabel = () => props.copy.onLabel ?? 'ON'
|
||||||
const disabledLabel = () => props.copy.offLabel ?? 'OFF'
|
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 (
|
return (
|
||||||
<Show
|
<Show
|
||||||
@@ -315,14 +367,23 @@ export function HouseScreen(props: Props) {
|
|||||||
)
|
)
|
||||||
: props.copy.billingCycleOpenHint}
|
: props.copy.billingCycleOpenHint}
|
||||||
</p>
|
</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}>
|
<Show when={props.dashboard}>
|
||||||
{(data) => (
|
{(data) => (
|
||||||
<p>
|
<p>
|
||||||
{props.copy.shareRent ?? ''}: {data().rentSourceAmountMajor}{' '}
|
|
||||||
{data().rentSourceCurrency}
|
|
||||||
{data().rentSourceCurrency !== data().currency
|
{data().rentSourceCurrency !== data().currency
|
||||||
? ` -> ${data().rentDisplayAmountMajor} ${data().currency}`
|
? `${data().rentSourceAmountMajor} ${data().rentSourceCurrency} = ${data().rentDisplayAmountMajor} ${data().currency}`
|
||||||
: ''}
|
: `${data().rentDisplayAmountMajor} ${data().currency}`}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</Show>
|
</Show>
|
||||||
@@ -406,13 +467,16 @@ export function HouseScreen(props: Props) {
|
|||||||
>
|
>
|
||||||
{props.cycleState?.cycle ? (
|
{props.cycleState?.cycle ? (
|
||||||
<div class="editor-grid">
|
<div class="editor-grid">
|
||||||
<Field label={props.copy.rentAmount ?? ''}>
|
<Field
|
||||||
|
label={props.copy.currentCycleRentLabel ?? props.copy.rentAmount ?? ''}
|
||||||
|
hint={props.copy.currentCycleRentHint ?? ''}
|
||||||
|
>
|
||||||
<input
|
<input
|
||||||
value={props.cycleForm.rentAmountMajor}
|
value={props.cycleForm.rentAmountMajor}
|
||||||
onInput={(event) => props.onCycleRentAmountChange(event.currentTarget.value)}
|
onInput={(event) => props.onCycleRentAmountChange(event.currentTarget.value)}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
<Field label={props.copy.shareRent ?? ''}>
|
<Field label={props.copy.currencyLabel ?? props.copy.settlementCurrency ?? ''}>
|
||||||
<select
|
<select
|
||||||
value={props.cycleForm.rentCurrency}
|
value={props.cycleForm.rentCurrency}
|
||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
@@ -451,7 +515,7 @@ export function HouseScreen(props: Props) {
|
|||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="primary"
|
variant="primary"
|
||||||
disabled={props.savingBillingSettings}
|
disabled={props.savingBillingSettings || !timezoneValid()}
|
||||||
onClick={() => void props.onSaveBillingSettings()}
|
onClick={() => void props.onSaveBillingSettings()}
|
||||||
>
|
>
|
||||||
{props.savingBillingSettings
|
{props.savingBillingSettings
|
||||||
@@ -503,13 +567,16 @@ export function HouseScreen(props: Props) {
|
|||||||
<option value="separate">{props.copy.paymentBalanceAdjustmentSeparate}</option>
|
<option value="separate">{props.copy.paymentBalanceAdjustmentSeparate}</option>
|
||||||
</select>
|
</select>
|
||||||
</Field>
|
</Field>
|
||||||
<Field label={props.copy.rentAmount ?? ''}>
|
<Field
|
||||||
|
label={props.copy.defaultRentAmount ?? props.copy.rentAmount ?? ''}
|
||||||
|
hint={props.copy.defaultRentHint ?? ''}
|
||||||
|
>
|
||||||
<input
|
<input
|
||||||
value={props.billingForm.rentAmountMajor}
|
value={props.billingForm.rentAmountMajor}
|
||||||
onInput={(event) => props.onBillingRentAmountChange(event.currentTarget.value)}
|
onInput={(event) => props.onBillingRentAmountChange(event.currentTarget.value)}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
<Field label={props.copy.shareRent ?? ''}>
|
<Field label={props.copy.currencyLabel ?? props.copy.settlementCurrency ?? ''}>
|
||||||
<select
|
<select
|
||||||
value={props.billingForm.rentCurrency}
|
value={props.billingForm.rentCurrency}
|
||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
@@ -572,11 +639,43 @@ export function HouseScreen(props: Props) {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
<Field label={props.copy.timezone ?? ''} wide>
|
<Field
|
||||||
|
label={props.copy.timezone ?? ''}
|
||||||
|
hint={
|
||||||
|
timezoneValid()
|
||||||
|
? (props.copy.timezoneHint ?? '')
|
||||||
|
: (props.copy.timezoneInvalidHint ?? '')
|
||||||
|
}
|
||||||
|
wide
|
||||||
|
>
|
||||||
<input
|
<input
|
||||||
|
aria-invalid={!timezoneValid()}
|
||||||
|
list="billing-timezone-options"
|
||||||
|
placeholder="Asia/Tbilisi"
|
||||||
value={props.billingForm.timezone}
|
value={props.billingForm.timezone}
|
||||||
onInput={(event) => props.onBillingTimezoneChange(event.currentTarget.value)}
|
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>
|
||||||
<Field label={props.copy.assistantToneLabel ?? ''} wide>
|
<Field label={props.copy.assistantToneLabel ?? ''} wide>
|
||||||
<input
|
<input
|
||||||
|
|||||||
@@ -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 () => {
|
test('stores an away absence policy from the current local period', async () => {
|
||||||
const service = createMiniAppAdminService(repository())
|
const service = createMiniAppAdminService(repository())
|
||||||
|
|
||||||
|
|||||||
@@ -231,6 +231,22 @@ function normalizeHouseholdName(raw: string | undefined): string | null | undefi
|
|||||||
return trimmed.replace(/\s+/g, ' ')
|
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 {
|
function defaultAssistantConfig(householdId: string): HouseholdAssistantConfigRecord {
|
||||||
return {
|
return {
|
||||||
householdId,
|
householdId,
|
||||||
@@ -308,12 +324,14 @@ export function createMiniAppAdminService(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const timezone = normalizeTimezone(input.timezone)
|
||||||
|
|
||||||
if (
|
if (
|
||||||
!isValidDay(input.rentDueDay) ||
|
!isValidDay(input.rentDueDay) ||
|
||||||
!isValidDay(input.rentWarningDay) ||
|
!isValidDay(input.rentWarningDay) ||
|
||||||
!isValidDay(input.utilitiesDueDay) ||
|
!isValidDay(input.utilitiesDueDay) ||
|
||||||
!isValidDay(input.utilitiesReminderDay) ||
|
!isValidDay(input.utilitiesReminderDay) ||
|
||||||
input.timezone.trim().length === 0 ||
|
timezone === null ||
|
||||||
input.rentWarningDay > input.rentDueDay ||
|
input.rentWarningDay > input.rentDueDay ||
|
||||||
input.utilitiesReminderDay > input.utilitiesDueDay
|
input.utilitiesReminderDay > input.utilitiesDueDay
|
||||||
) {
|
) {
|
||||||
@@ -401,7 +419,7 @@ export function createMiniAppAdminService(
|
|||||||
rentWarningDay: input.rentWarningDay,
|
rentWarningDay: input.rentWarningDay,
|
||||||
utilitiesDueDay: input.utilitiesDueDay,
|
utilitiesDueDay: input.utilitiesDueDay,
|
||||||
utilitiesReminderDay: input.utilitiesReminderDay,
|
utilitiesReminderDay: input.utilitiesReminderDay,
|
||||||
timezone: input.timezone.trim()
|
timezone
|
||||||
}),
|
}),
|
||||||
repository.updateHouseholdAssistantConfig && shouldUpdateAssistantConfig
|
repository.updateHouseholdAssistantConfig && shouldUpdateAssistantConfig
|
||||||
? repository.updateHouseholdAssistantConfig({
|
? repository.updateHouseholdAssistantConfig({
|
||||||
|
|||||||
Reference in New Issue
Block a user