diff --git a/apps/bot/src/anonymous-feedback.test.ts b/apps/bot/src/anonymous-feedback.test.ts index cd5f0c5..5e426d7 100644 --- a/apps/bot/src/anonymous-feedback.test.ts +++ b/apps/bot/src/anonymous-feedback.test.ts @@ -256,6 +256,7 @@ function createHouseholdConfigurationRepository(): HouseholdConfigurationReposit isActive: input.isActive }), promoteHouseholdAdmin: async () => null, + demoteHouseholdAdmin: async () => null, updateHouseholdMemberRentShareWeight: async () => null, updateHouseholdMemberStatus: async () => null, listHouseholdMemberAbsencePolicies: async () => [], diff --git a/apps/bot/src/app.ts b/apps/bot/src/app.ts index 6cd2886..60e390c 100644 --- a/apps/bot/src/app.ts +++ b/apps/bot/src/app.ts @@ -40,6 +40,7 @@ import { createMiniAppApproveMemberHandler, createMiniAppPendingMembersHandler, createMiniAppPromoteMemberHandler, + createMiniAppDemoteMemberHandler, createMiniAppRejectMemberHandler, createMiniAppSettingsHandler, createMiniAppUpdateMemberAbsencePolicyHandler, @@ -634,6 +635,15 @@ export async function createBotRuntimeApp(): Promise { logger: getLogger('miniapp-admin') }) : undefined, + miniAppDemoteMember: householdOnboardingService + ? createMiniAppDemoteMemberHandler({ + allowedOrigins: runtime.miniAppAllowedOrigins, + botToken: runtime.telegramBotToken, + onboardingService: householdOnboardingService, + miniAppAdminService: miniAppAdminService!, + logger: getLogger('miniapp-admin') + }) + : undefined, miniAppUpdateOwnDisplayName: householdOnboardingService ? createMiniAppUpdateOwnDisplayNameHandler({ allowedOrigins: runtime.miniAppAllowedOrigins, diff --git a/apps/bot/src/bot-i18n.test.ts b/apps/bot/src/bot-i18n.test.ts index 6275fa7..c06da4b 100644 --- a/apps/bot/src/bot-i18n.test.ts +++ b/apps/bot/src/bot-i18n.test.ts @@ -135,6 +135,7 @@ function createRepository(isAdmin = false): HouseholdConfigurationRepository { updateMemberPreferredLocale: async () => null, updateHouseholdMemberDisplayName: async () => null, promoteHouseholdAdmin: async () => null, + demoteHouseholdAdmin: async () => null, updateHouseholdMemberRentShareWeight: async () => null, updateHouseholdMemberStatus: async () => null, listHouseholdMemberAbsencePolicies: async () => [], diff --git a/apps/bot/src/dm-assistant.test.ts b/apps/bot/src/dm-assistant.test.ts index a93a71e..8e8b09f 100644 --- a/apps/bot/src/dm-assistant.test.ts +++ b/apps/bot/src/dm-assistant.test.ts @@ -267,6 +267,7 @@ function createHouseholdRepository(): HouseholdConfigurationRepository { updateMemberPreferredLocale: async () => null, updateHouseholdMemberDisplayName: async () => null, promoteHouseholdAdmin: async () => null, + demoteHouseholdAdmin: async () => null, updateHouseholdMemberRentShareWeight: async () => null, updateHouseholdMemberStatus: async () => null, listHouseholdMemberAbsencePolicies: async () => [], @@ -366,6 +367,7 @@ function createFinanceService(): FinanceCommandService { netDue: Money.fromMajor('850.00', 'GEL'), paid: Money.fromMajor('500.00', 'GEL'), remaining: Money.fromMajor('350.00', 'GEL'), + overduePayments: [], explanations: [] }, { @@ -377,6 +379,7 @@ function createFinanceService(): FinanceCommandService { netDue: Money.fromMajor('815.00', 'GEL'), paid: Money.fromMajor('200.00', 'GEL'), remaining: Money.fromMajor('615.00', 'GEL'), + overduePayments: [], explanations: [] }, { @@ -388,6 +391,7 @@ function createFinanceService(): FinanceCommandService { netDue: Money.fromMajor('680.00', 'GEL'), paid: Money.fromMajor('100.00', 'GEL'), remaining: Money.fromMajor('580.00', 'GEL'), + overduePayments: [], explanations: [] } ], diff --git a/apps/bot/src/finance-commands.test.ts b/apps/bot/src/finance-commands.test.ts index 8f3e38e..b6060bb 100644 --- a/apps/bot/src/finance-commands.test.ts +++ b/apps/bot/src/finance-commands.test.ts @@ -113,6 +113,7 @@ function createRepository(): HouseholdConfigurationRepository { updateMemberPreferredLocale: async () => null, updateHouseholdMemberDisplayName: async () => null, promoteHouseholdAdmin: async () => null, + demoteHouseholdAdmin: async () => null, updateHouseholdMemberRentShareWeight: async () => null, updateHouseholdMemberStatus: async () => null, listHouseholdMemberAbsencePolicies: async () => [], @@ -150,6 +151,7 @@ function createDashboard(): NonNullable< netDue: Money.fromMajor('210', 'GEL'), paid: Money.fromMajor('100', 'GEL'), remaining: Money.fromMajor('110', 'GEL'), + overduePayments: [], explanations: [] }, { @@ -161,6 +163,7 @@ function createDashboard(): NonNullable< netDue: Money.fromMajor('190', 'GEL'), paid: Money.zero('GEL'), remaining: Money.fromMajor('190', 'GEL'), + overduePayments: [], explanations: [] } ], diff --git a/apps/bot/src/household-setup.test.ts b/apps/bot/src/household-setup.test.ts index 134493d..ed071ec 100644 --- a/apps/bot/src/household-setup.test.ts +++ b/apps/bot/src/household-setup.test.ts @@ -484,6 +484,9 @@ function createHouseholdConfigurationRepository(): HouseholdConfigurationReposit async promoteHouseholdAdmin() { return null }, + async demoteHouseholdAdmin() { + return null + }, async updateHouseholdMemberRentShareWeight() { return null }, diff --git a/apps/bot/src/miniapp-admin.test.ts b/apps/bot/src/miniapp-admin.test.ts index a874068..89aa1a9 100644 --- a/apps/bot/src/miniapp-admin.test.ts +++ b/apps/bot/src/miniapp-admin.test.ts @@ -8,6 +8,7 @@ import type { import { createMiniAppApproveMemberHandler, + createMiniAppDemoteMemberHandler, createMiniAppRejectMemberHandler, createMiniAppPendingMembersHandler, createMiniAppPromoteMemberHandler, @@ -230,6 +231,28 @@ function onboardingRepository(): HouseholdConfigurationRepository { } : null }, + demoteHouseholdAdmin: async (householdId, memberId) => { + const member = [ + { + id: 'member-123456', + householdId, + telegramUserId: '123456', + displayName: 'Stan', + status: 'active' as const, + preferredLocale: null, + householdDefaultLocale: household.defaultLocale, + rentShareWeight: 1, + isAdmin: true + } + ].find((entry) => entry.id === memberId) + + return member + ? { + ...member, + isAdmin: false + } + : null + }, updateHouseholdMemberRentShareWeight: async (_householdId, memberId, rentShareWeight) => memberId === 'member-123456' ? { @@ -776,6 +799,95 @@ describe('createMiniAppPromoteMemberHandler', () => { }) }) +describe('createMiniAppDemoteMemberHandler', () => { + test('removes admin access from a household member 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 + } + ] + repository.listHouseholdMembers = async () => [ + { + id: 'member-123456', + householdId: 'household-1', + telegramUserId: '123456', + displayName: 'Stan', + status: 'active', + preferredLocale: null, + householdDefaultLocale: 'ru', + rentShareWeight: 1, + isAdmin: true + }, + { + id: 'member-555777', + householdId: 'household-1', + telegramUserId: '555777', + displayName: 'Mia', + status: 'active', + preferredLocale: null, + householdDefaultLocale: 'ru', + rentShareWeight: 1, + isAdmin: true + } + ] + + const handler = createMiniAppDemoteMemberHandler({ + 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/members/demote', { + 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' + }), + memberId: 'member-123456' + }) + }) + ) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ + ok: true, + authorized: true, + member: { + id: 'member-123456', + householdId: 'household-1', + telegramUserId: '123456', + displayName: 'Stan', + status: 'active', + preferredLocale: null, + householdDefaultLocale: 'ru', + rentShareWeight: 1, + isAdmin: false + } + }) + }) +}) + describe('createMiniAppUpdateOwnDisplayNameHandler', () => { test('updates the acting member display name for an authenticated member', async () => { const authDate = Math.floor(Date.now() / 1000) diff --git a/apps/bot/src/miniapp-admin.ts b/apps/bot/src/miniapp-admin.ts index ee2ffca..0b2277d 100644 --- a/apps/bot/src/miniapp-admin.ts +++ b/apps/bot/src/miniapp-admin.ts @@ -898,6 +898,94 @@ export function createMiniAppPromoteMemberHandler(options: { } } +export function createMiniAppDemoteMemberHandler(options: { + allowedOrigins: readonly string[] + botToken: string + onboardingService: HouseholdOnboardingService + miniAppAdminService: MiniAppAdminService + logger?: Logger +}): { + handler: (request: Request) => Promise +} { + const sessionService = createMiniAppSessionService({ + botToken: options.botToken, + onboardingService: options.onboardingService + }) + + return { + handler: async (request) => { + const origin = allowedMiniAppOrigin(request, options.allowedOrigins) + + if (request.method === 'OPTIONS') { + return miniAppJsonResponse({ ok: true }, 204, origin) + } + + if (request.method !== 'POST') { + return miniAppJsonResponse({ ok: false, error: 'Method Not Allowed' }, 405, origin) + } + + try { + const payload = await readPromoteMemberPayload(request) + const session = await sessionService.authenticate({ + initData: payload.initData + }) + + if (!session) { + return miniAppJsonResponse( + { ok: false, error: 'Invalid Telegram init data' }, + 401, + origin + ) + } + + if ( + !session.authorized || + !session.member || + session.member.status !== 'active' || + !session.member.isAdmin + ) { + return miniAppJsonResponse( + { ok: false, error: 'Admin access required for active household members' }, + 403, + origin + ) + } + + const result = await options.miniAppAdminService.demoteMemberFromAdmin({ + householdId: session.member.householdId, + actorIsAdmin: session.member.isAdmin, + memberId: payload.memberId + }) + + if (result.status === 'rejected') { + const status = + result.reason === 'member_not_found' ? 404 : result.reason === 'last_admin' ? 409 : 403 + const error = + result.reason === 'member_not_found' + ? 'Member not found' + : result.reason === 'last_admin' + ? 'Cannot remove the last household admin' + : 'Admin access required' + + return miniAppJsonResponse({ ok: false, error }, status, origin) + } + + return miniAppJsonResponse( + { + ok: true, + authorized: true, + member: result.member + }, + 200, + origin + ) + } catch (error) { + return miniAppErrorResponse(error, origin, options.logger) + } + } + } +} + export function createMiniAppUpdateOwnDisplayNameHandler(options: { allowedOrigins: readonly string[] botToken: string diff --git a/apps/bot/src/miniapp-auth.test.ts b/apps/bot/src/miniapp-auth.test.ts index bd6333e..229b117 100644 --- a/apps/bot/src/miniapp-auth.test.ts +++ b/apps/bot/src/miniapp-auth.test.ts @@ -191,7 +191,8 @@ function onboardingRepository(): HouseholdConfigurationRepository { sortOrder: input.sortOrder, isActive: input.isActive }), - promoteHouseholdAdmin: async () => null + promoteHouseholdAdmin: async () => null, + demoteHouseholdAdmin: async () => null } } diff --git a/apps/bot/src/miniapp-billing.test.ts b/apps/bot/src/miniapp-billing.test.ts index d09b3ea..6b4b2c3 100644 --- a/apps/bot/src/miniapp-billing.test.ts +++ b/apps/bot/src/miniapp-billing.test.ts @@ -137,6 +137,7 @@ function onboardingRepository(): HouseholdConfigurationRepository { updateMemberPreferredLocale: async () => null, updateHouseholdMemberDisplayName: async () => null, promoteHouseholdAdmin: async () => null, + demoteHouseholdAdmin: async () => null, updateHouseholdMemberRentShareWeight: async () => null, updateHouseholdMemberStatus: async () => null, listHouseholdMemberAbsencePolicies: async () => [], diff --git a/apps/bot/src/miniapp-billing.ts b/apps/bot/src/miniapp-billing.ts index 87fb554..5bb36bf 100644 --- a/apps/bot/src/miniapp-billing.ts +++ b/apps/bot/src/miniapp-billing.ts @@ -486,6 +486,7 @@ async function readPaymentMutationPayload(request: Request): Promise<{ kind?: 'rent' | 'utilities' amountMajor?: string currency?: string + period?: string }> { const parsed = await parseJsonBody<{ initData?: string @@ -494,6 +495,7 @@ async function readPaymentMutationPayload(request: Request): Promise<{ kind?: 'rent' | 'utilities' amountMajor?: string currency?: string + period?: string }>(request) const initData = parsed.initData?.trim() if (!initData) { @@ -526,6 +528,11 @@ async function readPaymentMutationPayload(request: Request): Promise<{ ? { currency: parsed.currency.trim() } + : {}), + ...(parsed.period?.trim() + ? { + period: BillingPeriod.fromString(parsed.period.trim()).toString() + } : {}) } } @@ -1001,7 +1008,8 @@ export function createMiniAppSubmitPaymentHandler(options: { auth.member.id, payload.kind, payload.amountMajor, - payload.currency + payload.currency, + payload.period ) if (!payment) { @@ -1374,7 +1382,8 @@ export function createMiniAppAddPaymentHandler(options: { payload.memberId, payload.kind, payload.amountMajor, - payload.currency + payload.currency, + payload.period ) if (!payment) { diff --git a/apps/bot/src/miniapp-dashboard.test.ts b/apps/bot/src/miniapp-dashboard.test.ts index e7e521c..cf44f6f 100644 --- a/apps/bot/src/miniapp-dashboard.test.ts +++ b/apps/bot/src/miniapp-dashboard.test.ts @@ -54,6 +54,7 @@ function repository( isAdmin: true } ], + listCycles: async () => [cycle], getOpenCycle: async () => cycle, getCycleByPeriod: async (period) => (period === cycle.period ? cycle : null), getLatestCycle: async () => cycle, @@ -72,6 +73,8 @@ function repository( updateParsedPurchase: async () => null, addParsedPurchase: async (input) => ({ id: 'purchase-new', + cycleId: input.cycleId, + cyclePeriod: null, payerMemberId: input.payerMemberId, amountMinor: input.amountMinor, currency: input.currency, @@ -90,6 +93,8 @@ function repository( deleteUtilityBill: async () => false, addPaymentRecord: async (input) => ({ id: 'payment-new', + cycleId: input.cycleId, + cyclePeriod: null, memberId: input.memberId, kind: input.kind, amountMinor: input.amountMinor, @@ -97,6 +102,8 @@ function repository( recordedAt: input.recordedAt }), updatePaymentRecord: async () => null, + getPaymentRecord: async () => null, + replacePaymentPurchaseAllocations: async () => {}, deletePaymentRecord: async () => false, getRentRuleForPeriod: async () => ({ amountMinor: 70000n, @@ -116,6 +123,8 @@ function repository( listPaymentRecordsForCycle: async () => [ { id: 'payment-1', + cycleId: cycle.id, + cyclePeriod: cycle.period, memberId: member?.id ?? 'member-1', kind: 'rent', amountMinor: 50000n, @@ -126,6 +135,8 @@ function repository( listParsedPurchasesForRange: async () => [ { id: 'purchase-1', + cycleId: cycle.id, + cyclePeriod: cycle.period, payerMemberId: member?.id ?? 'member-1', amountMinor: 3000n, currency: 'GEL', @@ -133,6 +144,19 @@ function repository( occurredAt: instantFromIso('2026-03-12T11:00:00.000Z') } ], + listParsedPurchases: async () => [ + { + id: 'purchase-1', + cycleId: cycle.id, + cyclePeriod: cycle.period, + payerMemberId: member?.id ?? 'member-1', + amountMinor: 3000n, + currency: 'GEL', + description: 'Soap', + occurredAt: instantFromIso('2026-03-12T11:00:00.000Z') + } + ], + listPaymentPurchaseAllocations: async () => [], getSettlementSnapshotLines: async () => [], savePaymentConfirmation: async () => ({ @@ -282,6 +306,7 @@ function onboardingRepository(): HouseholdConfigurationRepository { isActive: input.isActive }), promoteHouseholdAdmin: async () => null, + demoteHouseholdAdmin: async () => null, updateHouseholdMemberRentShareWeight: async () => null, updateHouseholdMemberStatus: async () => null, listHouseholdMemberAbsencePolicies: async () => [], @@ -364,6 +389,7 @@ describe('createMiniAppDashboardHandler', () => { { displayName: 'Stan', netDueMajor: '2010.00', + overduePayments: [], paidMajor: '500.00', remainingMajor: '1510.00', rentShareMajor: '1890.00', @@ -408,6 +434,8 @@ describe('createMiniAppDashboardHandler', () => { financeRepository.listParsedPurchasesForRange = async () => [ { id: 'purchase-1', + cycleId: 'cycle-1', + cyclePeriod: '2026-03', payerMemberId: 'member-1', amountMinor: 3000n, currency: 'GEL', @@ -433,6 +461,11 @@ describe('createMiniAppDashboardHandler', () => { ] } ] + financeRepository.listParsedPurchases = async () => + financeRepository.listParsedPurchasesForRange( + instantFromIso('2026-03-01T00:00:00.000Z'), + instantFromIso('2026-04-01T00:00:00.000Z') + ) financeRepository.listMembers = async () => [ { id: 'member-1', diff --git a/apps/bot/src/miniapp-dashboard.ts b/apps/bot/src/miniapp-dashboard.ts index 7716a8a..84890d5 100644 --- a/apps/bot/src/miniapp-dashboard.ts +++ b/apps/bot/src/miniapp-dashboard.ts @@ -1,4 +1,5 @@ import type { FinanceCommandService, HouseholdOnboardingService } from '@household/application' +import { Money } from '@household/domain' import type { Logger } from '@household/observability' import { @@ -113,6 +114,14 @@ export function createMiniAppDashboardHandler(options: { netDueMajor: line.netDue.toMajorString(), paidMajor: line.paid.toMajorString(), remainingMajor: line.remaining.toMajorString(), + overduePayments: line.overduePayments.map((overdue) => ({ + kind: overdue.kind, + amountMajor: Money.fromMinor( + overdue.amountMinor, + dashboard.currency + ).toMajorString(), + periods: overdue.periods + })), explanations: line.explanations })), ledger: dashboard.ledger.map((entry) => ({ @@ -132,6 +141,14 @@ export function createMiniAppDashboardHandler(options: { ...(entry.kind === 'purchase' ? { purchaseSplitMode: entry.purchaseSplitMode ?? 'equal', + originPeriod: entry.originPeriod ?? null, + resolutionStatus: entry.resolutionStatus ?? 'unresolved', + resolvedAt: entry.resolvedAt ?? null, + outstandingByMember: + entry.outstandingByMember?.map((outstanding) => ({ + memberId: outstanding.memberId, + amountMajor: outstanding.amount.toMajorString() + })) ?? [], purchaseParticipants: entry.purchaseParticipants?.map((participant) => ({ memberId: participant.memberId, diff --git a/apps/bot/src/miniapp-locale.test.ts b/apps/bot/src/miniapp-locale.test.ts index 4673d8b..3425956 100644 --- a/apps/bot/src/miniapp-locale.test.ts +++ b/apps/bot/src/miniapp-locale.test.ts @@ -168,6 +168,7 @@ function repository(): HouseholdConfigurationRepository { isActive: input.isActive }), promoteHouseholdAdmin: async () => null, + demoteHouseholdAdmin: async () => null, updateHouseholdMemberRentShareWeight: async () => null, updateHouseholdMemberStatus: async () => null, listHouseholdMemberAbsencePolicies: async () => [], diff --git a/apps/bot/src/payment-topic-ingestion.test.ts b/apps/bot/src/payment-topic-ingestion.test.ts index 730bb7d..7e9330c 100644 --- a/apps/bot/src/payment-topic-ingestion.test.ts +++ b/apps/bot/src/payment-topic-ingestion.test.ts @@ -198,6 +198,7 @@ function createFinanceService(): FinanceCommandService { netDue: Money.fromMajor('500.50', 'GEL'), paid: Money.zero('GEL'), remaining: Money.fromMajor('500.50', 'GEL'), + overduePayments: [], explanations: [] } ], diff --git a/apps/bot/src/server.ts b/apps/bot/src/server.ts index 465d9d1..ebad101 100644 --- a/apps/bot/src/server.ts +++ b/apps/bot/src/server.ts @@ -62,6 +62,12 @@ export interface BotWebhookServerOptions { handler: (request: Request) => Promise } | undefined + miniAppDemoteMember?: + | { + path?: string + handler: (request: Request) => Promise + } + | undefined miniAppUpdateOwnDisplayName?: | { path?: string @@ -234,6 +240,8 @@ export function createBotWebhookServer(options: BotWebhookServerOptions): { options.miniAppUpsertUtilityCategory?.path ?? '/api/miniapp/admin/utility-categories/upsert' const miniAppPromoteMemberPath = options.miniAppPromoteMember?.path ?? '/api/miniapp/admin/members/promote' + const miniAppDemoteMemberPath = + options.miniAppDemoteMember?.path ?? '/api/miniapp/admin/members/demote' const miniAppUpdateOwnDisplayNamePath = options.miniAppUpdateOwnDisplayName?.path ?? '/api/miniapp/member/display-name' const miniAppUpdateMemberDisplayNamePath = @@ -328,6 +336,10 @@ export function createBotWebhookServer(options: BotWebhookServerOptions): { return await options.miniAppPromoteMember.handler(request) } + if (options.miniAppDemoteMember && url.pathname === miniAppDemoteMemberPath) { + return await options.miniAppDemoteMember.handler(request) + } + if (options.miniAppUpdateOwnDisplayName && url.pathname === miniAppUpdateOwnDisplayNamePath) { return await options.miniAppUpdateOwnDisplayName.handler(request) } diff --git a/apps/miniapp/src/components/layout/shell.tsx b/apps/miniapp/src/components/layout/shell.tsx index 382026a..c2f5a54 100644 --- a/apps/miniapp/src/components/layout/shell.tsx +++ b/apps/miniapp/src/components/layout/shell.tsx @@ -20,10 +20,13 @@ export function AppShell(props: ParentProps) { effectiveIsAdmin, testingRolePreview, setTestingRolePreview, + demoScenario, + setDemoScenario, testingPeriodOverride, setTestingPeriodOverride, testingTodayOverride, - setTestingTodayOverride + setTestingTodayOverride, + applyDemoState } = useDashboard() const navigate = useNavigate() @@ -38,6 +41,28 @@ export function AppShell(props: ParentProps) { return labels[status] } + function demoScenarioLabel( + id: 'current-cycle' | 'overdue-utilities' | 'overdue-rent-and-utilities' + ) { + const labels = { + 'current-cycle': copy().testingScenarioCurrentCycle ?? '', + 'overdue-utilities': copy().testingScenarioOverdueUtilities ?? '', + 'overdue-rent-and-utilities': copy().testingScenarioOverdueBoth ?? '' + } + return labels[id] + } + + function demoScenarioDescription( + id: 'current-cycle' | 'overdue-utilities' | 'overdue-rent-and-utilities' + ) { + const descriptions = { + 'current-cycle': copy().testingScenarioCurrentCycleBody ?? '', + 'overdue-utilities': copy().testingScenarioOverdueUtilitiesBody ?? '', + 'overdue-rent-and-utilities': copy().testingScenarioOverdueBothBody ?? '' + } + return descriptions[id] + } + let tapCount = 0 let tapTimer: ReturnType | undefined function handleRoleChipTap() { @@ -92,6 +117,9 @@ export function AppShell(props: ParentProps) { {readySession()?.mode === 'demo' ? copy().demoBadge : copy().liveBadge} + + {demoScenarioLabel(demoScenario())} + + +
+ {copy().testingScenarioLabel ?? ''} +
+ {demoScenarioLabel(demoScenario())} +

+ {demoScenarioDescription(demoScenario())} +

+
+
+
+ + + +
+ +
{copy().testingPeriodCurrentLabel ?? ''} {dashboard()?.period ?? '—'}
-
+
MemberBalanceItem[] testingRolePreview: () => TestingRolePreview | null setTestingRolePreview: (value: TestingRolePreview | null) => void + demoScenario: () => DemoScenarioId + setDemoScenario: (value: DemoScenarioId) => void testingPeriodOverride: () => string | null setTestingPeriodOverride: (value: string | null) => void testingTodayOverride: () => string | null @@ -297,6 +294,7 @@ export function DashboardProvider(props: ParentProps) { const [cycleState, setCycleState] = createSignal(null) const [pendingMembers, setPendingMembers] = createSignal([]) const [testingRolePreview, setTestingRolePreview] = createSignal(null) + const [demoScenario, setDemoScenarioSignal] = createSignal('current-cycle') const [testingPeriodOverride, setTestingPeriodOverride] = createSignal(null) const [testingTodayOverride, setTestingTodayOverride] = createSignal(null) @@ -393,10 +391,22 @@ export function DashboardProvider(props: ParentProps) { } function applyDemoState() { - setDashboard(demoDashboard) - setPendingMembers([...demoPendingMembers]) - setAdminSettings(demoAdminSettings) - setCycleState(demoCycleState) + const state = getDemoScenarioState(demoScenario()) + setDashboard(state.dashboard) + setPendingMembers(state.pendingMembers) + setAdminSettings(state.adminSettings) + setCycleState(state.cycleState) + } + + function setDemoScenario(value: DemoScenarioId) { + setDemoScenarioSignal(value) + if (readySession()?.mode === 'demo') { + const state = getDemoScenarioState(value) + setDashboard(state.dashboard) + setPendingMembers(state.pendingMembers) + setAdminSettings(state.adminSettings) + setCycleState(state.cycleState) + } } return ( @@ -424,6 +434,8 @@ export function DashboardProvider(props: ParentProps) { memberUtilityBalanceVisuals, testingRolePreview, setTestingRolePreview, + demoScenario, + setDemoScenario, testingPeriodOverride, setTestingPeriodOverride, testingTodayOverride, diff --git a/apps/miniapp/src/demo/miniapp-demo.ts b/apps/miniapp/src/demo/miniapp-demo.ts index ee4dc61..26acafb 100644 --- a/apps/miniapp/src/demo/miniapp-demo.ts +++ b/apps/miniapp/src/demo/miniapp-demo.ts @@ -6,6 +6,15 @@ import type { MiniAppSession } from '../miniapp-api' +export type DemoScenarioId = 'current-cycle' | 'overdue-utilities' | 'overdue-rent-and-utilities' + +type DemoScenarioState = { + dashboard: MiniAppDashboard + pendingMembers: readonly MiniAppPendingMember[] + adminSettings: MiniAppAdminSettingsPayload + cycleState: MiniAppAdminCycleState +} + export const demoMember: NonNullable = { id: 'demo-member', householdId: 'demo-household', @@ -23,178 +32,26 @@ export const demoTelegramUser: NonNullable = { languageCode: 'en' } -export const demoDashboard: MiniAppDashboard = { - period: '2026-03', - currency: 'GEL', - timezone: 'Asia/Tbilisi', - rentWarningDay: 17, - rentDueDay: 20, - utilitiesReminderDay: 3, - utilitiesDueDay: 4, - paymentBalanceAdjustmentPolicy: 'utilities', - rentPaymentDestinations: [ - { - label: 'TBC card', - recipientName: 'Landlord', - bankName: 'TBC Bank', - account: '1234 5678 9012 3456', - note: null, - link: null - } - ], - totalDueMajor: '2410.00', - totalPaidMajor: '650.00', - totalRemainingMajor: '1760.00', - rentSourceAmountMajor: '875.00', - rentSourceCurrency: 'USD', - rentDisplayAmountMajor: '2415.00', - rentFxRateMicros: '2760000', - rentFxEffectiveDate: '2026-03-17', - members: [ - { - memberId: 'demo-member', - displayName: 'Stas', - predictedUtilityShareMajor: '78.00', - rentShareMajor: '603.75', - utilityShareMajor: '78.00', - purchaseOffsetMajor: '-66.00', - netDueMajor: '615.75', - paidMajor: '615.75', - remainingMajor: '0.00', - explanations: ['Weighted rent share', 'Custom purchase split credit'] - }, - { - memberId: 'member-chorb', - displayName: 'Chorbanaut', - predictedUtilityShareMajor: '78.00', - rentShareMajor: '603.75', - utilityShareMajor: '78.00', - purchaseOffsetMajor: '12.00', - netDueMajor: '693.75', - paidMajor: '0.00', - remainingMajor: '693.75', - explanations: ['Standard resident share'] - }, - { - memberId: 'member-el', - displayName: 'El', - predictedUtilityShareMajor: '0.00', - rentShareMajor: '1207.50', - utilityShareMajor: '0.00', - purchaseOffsetMajor: '54.00', - netDueMajor: '1261.50', - paidMajor: '34.25', - remainingMajor: '1227.25', - explanations: ['Away policy applied to utilities'] - } - ], - ledger: [ - { - id: 'purchase-1', - kind: 'purchase', - title: 'Bought kitchen towels', - memberId: 'demo-member', - paymentKind: null, - amountMajor: '24.00', - currency: 'GEL', - displayAmountMajor: '24.00', - displayCurrency: 'GEL', - fxRateMicros: null, - fxEffectiveDate: null, - actorDisplayName: 'Stas', - occurredAt: '2026-03-04T11:00:00.000Z', - purchaseSplitMode: 'equal', - purchaseParticipants: [ - { memberId: 'demo-member', included: true, shareAmountMajor: null }, - { memberId: 'member-chorb', included: true, shareAmountMajor: null }, - { memberId: 'member-el', included: false, shareAmountMajor: null } - ] - }, - { - id: 'purchase-2', - kind: 'purchase', - title: 'Electric kettle', - memberId: 'member-chorb', - paymentKind: null, - amountMajor: '96.00', - currency: 'GEL', - displayAmountMajor: '96.00', - displayCurrency: 'GEL', - fxRateMicros: null, - fxEffectiveDate: null, - actorDisplayName: 'Chorbanaut', - occurredAt: '2026-03-08T16:20:00.000Z', - purchaseSplitMode: 'custom_amounts', - purchaseParticipants: [ - { memberId: 'demo-member', included: true, shareAmountMajor: '42.00' }, - { memberId: 'member-chorb', included: true, shareAmountMajor: '24.00' }, - { memberId: 'member-el', included: true, shareAmountMajor: '30.00' } - ] - }, - { - id: 'utility-1', - kind: 'utility', - title: 'Electricity', - memberId: null, - paymentKind: null, - amountMajor: '154.00', - currency: 'GEL', - displayAmountMajor: '154.00', - displayCurrency: 'GEL', - fxRateMicros: null, - fxEffectiveDate: null, - actorDisplayName: 'Stas', - occurredAt: '2026-03-09T12:00:00.000Z' - }, - { - id: 'utility-2', - kind: 'utility', - title: 'Internet', - memberId: null, - paymentKind: null, - amountMajor: '80.00', - currency: 'GEL', - displayAmountMajor: '80.00', - displayCurrency: 'GEL', - fxRateMicros: null, - fxEffectiveDate: null, - actorDisplayName: 'Stas', - occurredAt: '2026-03-10T10:30:00.000Z' - }, - { - id: 'payment-1', - kind: 'payment', - title: 'rent', - memberId: 'demo-member', - paymentKind: 'rent', - amountMajor: '615.75', - currency: 'GEL', - displayAmountMajor: '615.75', - displayCurrency: 'GEL', - fxRateMicros: null, - fxEffectiveDate: null, - actorDisplayName: 'Stas', - occurredAt: '2026-03-11T18:10:00.000Z' - }, - { - id: 'payment-2', - kind: 'payment', - title: 'utilities', - memberId: 'member-el', - paymentKind: 'utilities', - amountMajor: '34.25', - currency: 'GEL', - displayAmountMajor: '34.25', - displayCurrency: 'GEL', - fxRateMicros: null, - fxEffectiveDate: null, - actorDisplayName: 'El', - occurredAt: '2026-03-13T09:00:00.000Z' - } - ] -} +const rentPaymentDestinations = [ + { + label: 'Landlord TBC card', + recipientName: 'Nana Beridze', + bankName: 'TBC Bank', + account: '1234 5678 9012 3456', + note: 'Message: Kojori House rent', + link: null + }, + { + label: 'USD fallback transfer', + recipientName: 'Nana Beridze', + bankName: 'Bank of Georgia', + account: 'GE29BG0000000123456789', + note: 'Use only if GEL transfer is unavailable', + link: 'https://bank.example/rent' + } +] as const -export const demoPendingMembers: readonly MiniAppPendingMember[] = [ +const pendingMembers: readonly MiniAppPendingMember[] = [ { telegramUserId: '555777', displayName: 'Mia', @@ -206,10 +63,16 @@ export const demoPendingMembers: readonly MiniAppPendingMember[] = [ displayName: 'Dima', username: 'dima', languageCode: 'en' + }, + { + telegramUserId: '888111', + displayName: 'Nika', + username: 'nika_forest', + languageCode: 'en' } ] -export const demoAdminSettings: MiniAppAdminSettingsPayload = { +const adminSettings: MiniAppAdminSettingsPayload = { householdName: 'Kojori House', settings: { householdId: 'demo-household', @@ -222,11 +85,12 @@ export const demoAdminSettings: MiniAppAdminSettingsPayload = { utilitiesDueDay: 4, utilitiesReminderDay: 3, timezone: 'Asia/Tbilisi', - rentPaymentDestinations: demoDashboard.rentPaymentDestinations + rentPaymentDestinations }, assistantConfig: { householdId: 'demo-household', - assistantContext: 'The household is a house in Kojori with a backyard and pine forest nearby.', + assistantContext: + 'The household is a large shared house in Kojori with a backyard, a guest room, and a long-running purchase ledger.', assistantTone: 'Playful but concise' }, topics: [ @@ -252,12 +116,20 @@ export const demoAdminSettings: MiniAppAdminSettingsPayload = { sortOrder: 1, isActive: true }, + { + id: 'cat-water', + householdId: 'demo-household', + slug: 'water', + name: 'Water', + sortOrder: 2, + isActive: true + }, { id: 'cat-gas', householdId: 'demo-household', slug: 'gas', name: 'Gas', - sortOrder: 2, + sortOrder: 3, isActive: false } ], @@ -281,7 +153,7 @@ export const demoAdminSettings: MiniAppAdminSettingsPayload = { ] } -export const demoCycleState: MiniAppAdminCycleState = { +const cycleState: MiniAppAdminCycleState = { cycle: { id: 'cycle-demo-2026-03', period: '2026-03', @@ -295,10 +167,10 @@ export const demoCycleState: MiniAppAdminCycleState = { { id: 'utility-bill-1', billName: 'Electricity', - amountMinor: '15400', + amountMinor: '16400', currency: 'GEL', createdByMemberId: 'demo-member', - createdAt: '2026-03-09T12:00:00.000Z' + createdAt: '2026-03-02T09:15:00.000Z' }, { id: 'utility-bill-2', @@ -306,7 +178,487 @@ export const demoCycleState: MiniAppAdminCycleState = { amountMinor: '8000', currency: 'GEL', createdByMemberId: 'demo-member', - createdAt: '2026-03-10T10:30:00.000Z' + createdAt: '2026-03-03T10:30:00.000Z' + }, + { + id: 'utility-bill-3', + billName: 'Water', + amountMinor: '4200', + currency: 'GEL', + createdByMemberId: 'member-chorb', + createdAt: '2026-03-03T12:45:00.000Z' } ] } + +function baseLedger(): MiniAppDashboard['ledger'] { + return [ + { + id: 'purchase-resolved-1', + kind: 'purchase', + title: 'Bulk cleaning supplies', + memberId: 'demo-member', + paymentKind: null, + amountMajor: '72.00', + currency: 'GEL', + displayAmountMajor: '72.00', + displayCurrency: 'GEL', + fxRateMicros: null, + fxEffectiveDate: null, + actorDisplayName: 'Stas', + occurredAt: '2026-01-28T18:30:00.000Z', + originPeriod: '2026-01', + resolutionStatus: 'resolved', + resolvedAt: '2026-02-04T09:10:00.000Z', + outstandingByMember: [], + payerMemberId: 'demo-member', + purchaseSplitMode: 'equal', + purchaseParticipants: [ + { memberId: 'demo-member', included: true, shareAmountMajor: null }, + { memberId: 'member-chorb', included: true, shareAmountMajor: null }, + { memberId: 'member-el', included: true, shareAmountMajor: null } + ] + }, + { + id: 'purchase-unresolved-1', + kind: 'purchase', + title: 'Gas heater refill', + memberId: 'member-chorb', + paymentKind: null, + amountMajor: '54.00', + currency: 'GEL', + displayAmountMajor: '54.00', + displayCurrency: 'GEL', + fxRateMicros: null, + fxEffectiveDate: null, + actorDisplayName: 'Chorbanaut', + occurredAt: '2026-02-17T20:15:00.000Z', + originPeriod: '2026-02', + resolutionStatus: 'unresolved', + resolvedAt: null, + outstandingByMember: [ + { memberId: 'demo-member', amountMajor: '18.00' }, + { memberId: 'member-el', amountMajor: '18.00' } + ], + payerMemberId: 'member-chorb', + purchaseSplitMode: 'equal', + purchaseParticipants: [ + { memberId: 'demo-member', included: true, shareAmountMajor: null }, + { memberId: 'member-chorb', included: true, shareAmountMajor: null }, + { memberId: 'member-el', included: true, shareAmountMajor: null } + ] + }, + { + id: 'purchase-unresolved-2', + kind: 'purchase', + title: 'Water filter cartridges', + memberId: 'demo-member', + paymentKind: null, + amountMajor: '96.00', + currency: 'GEL', + displayAmountMajor: '96.00', + displayCurrency: 'GEL', + fxRateMicros: null, + fxEffectiveDate: null, + actorDisplayName: 'Stas', + occurredAt: '2026-03-03T19:00:00.000Z', + originPeriod: '2026-03', + resolutionStatus: 'unresolved', + resolvedAt: null, + outstandingByMember: [ + { memberId: 'member-chorb', amountMajor: '24.00' }, + { memberId: 'member-el', amountMajor: '34.00' } + ], + payerMemberId: 'demo-member', + purchaseSplitMode: 'custom_amounts', + purchaseParticipants: [ + { memberId: 'demo-member', included: true, shareAmountMajor: '38.00' }, + { memberId: 'member-chorb', included: true, shareAmountMajor: '24.00' }, + { memberId: 'member-el', included: true, shareAmountMajor: '34.00' } + ] + }, + { + id: 'utility-1', + kind: 'utility', + title: 'Electricity', + memberId: null, + paymentKind: null, + amountMajor: '164.00', + currency: 'GEL', + displayAmountMajor: '164.00', + displayCurrency: 'GEL', + fxRateMicros: null, + fxEffectiveDate: null, + actorDisplayName: 'Stas', + occurredAt: '2026-03-02T09:15:00.000Z' + }, + { + id: 'utility-2', + kind: 'utility', + title: 'Internet', + memberId: null, + paymentKind: null, + amountMajor: '80.00', + currency: 'GEL', + displayAmountMajor: '80.00', + displayCurrency: 'GEL', + fxRateMicros: null, + fxEffectiveDate: null, + actorDisplayName: 'Stas', + occurredAt: '2026-03-03T10:30:00.000Z' + }, + { + id: 'utility-3', + kind: 'utility', + title: 'Water', + memberId: null, + paymentKind: null, + amountMajor: '42.00', + currency: 'GEL', + displayAmountMajor: '42.00', + displayCurrency: 'GEL', + fxRateMicros: null, + fxEffectiveDate: null, + actorDisplayName: 'Chorbanaut', + occurredAt: '2026-03-03T12:45:00.000Z' + }, + { + id: 'payment-rent-demo', + kind: 'payment', + title: 'rent', + memberId: 'demo-member', + paymentKind: 'rent', + amountMajor: '603.75', + currency: 'GEL', + displayAmountMajor: '603.75', + displayCurrency: 'GEL', + fxRateMicros: null, + fxEffectiveDate: null, + actorDisplayName: 'Stas', + occurredAt: '2026-03-18T18:10:00.000Z' + }, + { + id: 'payment-utilities-demo', + kind: 'payment', + title: 'utilities', + memberId: 'demo-member', + paymentKind: 'utilities', + amountMajor: '58.00', + currency: 'GEL', + displayAmountMajor: '58.00', + displayCurrency: 'GEL', + fxRateMicros: null, + fxEffectiveDate: null, + actorDisplayName: 'Stas', + occurredAt: '2026-03-04T20:00:00.000Z' + }, + { + id: 'payment-rent-el', + kind: 'payment', + title: 'rent', + memberId: 'member-el', + paymentKind: 'rent', + amountMajor: '377.00', + currency: 'GEL', + displayAmountMajor: '377.00', + displayCurrency: 'GEL', + fxRateMicros: null, + fxEffectiveDate: null, + actorDisplayName: 'El', + occurredAt: '2026-03-21T08:20:00.000Z' + } + ] +} + +function createDashboard(state: { + totalDueMajor: string + totalPaidMajor: string + totalRemainingMajor: string + members: MiniAppDashboard['members'] + ledger?: MiniAppDashboard['ledger'] +}): MiniAppDashboard { + return { + period: '2026-03', + currency: 'GEL', + timezone: 'Asia/Tbilisi', + rentWarningDay: 17, + rentDueDay: 20, + utilitiesReminderDay: 3, + utilitiesDueDay: 4, + paymentBalanceAdjustmentPolicy: 'utilities', + rentPaymentDestinations, + totalDueMajor: state.totalDueMajor, + totalPaidMajor: state.totalPaidMajor, + totalRemainingMajor: state.totalRemainingMajor, + rentSourceAmountMajor: '875.00', + rentSourceCurrency: 'USD', + rentDisplayAmountMajor: '2415.00', + rentFxRateMicros: '2760000', + rentFxEffectiveDate: '2026-03-17', + members: state.members, + ledger: state.ledger ?? baseLedger() + } +} + +const demoScenarioCatalog: Record = { + 'current-cycle': { + dashboard: createDashboard({ + totalDueMajor: '2571.00', + totalPaidMajor: '1288.75', + totalRemainingMajor: '1282.25', + members: [ + { + memberId: 'demo-member', + displayName: 'Stas', + predictedUtilityShareMajor: '95.33', + rentShareMajor: '603.75', + utilityShareMajor: '95.33', + purchaseOffsetMajor: '-37.33', + netDueMajor: '661.75', + paidMajor: '661.75', + remainingMajor: '0.00', + overduePayments: [], + explanations: [ + 'Weighted rent share', + 'Utilities reflect three posted bills', + 'Purchase credit from January supplies and March water filters' + ] + }, + { + memberId: 'member-chorb', + displayName: 'Chorbanaut', + predictedUtilityShareMajor: '95.33', + rentShareMajor: '603.75', + utilityShareMajor: '95.33', + purchaseOffsetMajor: '44.67', + netDueMajor: '743.75', + paidMajor: '250.00', + remainingMajor: '493.75', + overduePayments: [], + explanations: [ + 'Standard resident share', + 'Still owes current-cycle utilities and purchases' + ] + }, + { + memberId: 'member-el', + displayName: 'El', + predictedUtilityShareMajor: '0.00', + rentShareMajor: '1207.50', + utilityShareMajor: '0.00', + purchaseOffsetMajor: '-42.00', + netDueMajor: '1165.50', + paidMajor: '377.00', + remainingMajor: '788.50', + overduePayments: [], + explanations: ['Away policy applied to utilities', 'Purchase credit offsets part of rent'] + } + ] + }), + pendingMembers, + adminSettings, + cycleState + }, + 'overdue-utilities': { + dashboard: createDashboard({ + totalDueMajor: '2623.00', + totalPaidMajor: '783.75', + totalRemainingMajor: '1839.25', + members: [ + { + memberId: 'demo-member', + displayName: 'Stas', + predictedUtilityShareMajor: '104.00', + rentShareMajor: '603.75', + utilityShareMajor: '104.00', + purchaseOffsetMajor: '18.00', + netDueMajor: '725.75', + paidMajor: '603.75', + remainingMajor: '122.00', + overduePayments: [ + { + kind: 'utilities', + amountMajor: '182.00', + periods: ['2026-01', '2026-02'] + } + ], + explanations: [ + 'Current rent is paid', + 'Utilities remain overdue from two prior periods', + 'Purchase carry-over stays separate from overdue closure' + ] + }, + { + memberId: 'member-chorb', + displayName: 'Chorbanaut', + predictedUtilityShareMajor: '104.00', + rentShareMajor: '603.75', + utilityShareMajor: '104.00', + purchaseOffsetMajor: '12.00', + netDueMajor: '719.75', + paidMajor: '180.00', + remainingMajor: '539.75', + overduePayments: [ + { + kind: 'utilities', + amountMajor: '91.00', + periods: ['2026-02'] + } + ], + explanations: ['Partial utilities payment recorded this month'] + }, + { + memberId: 'member-el', + displayName: 'El', + predictedUtilityShareMajor: '0.00', + rentShareMajor: '1207.50', + utilityShareMajor: '0.00', + purchaseOffsetMajor: '-30.00', + netDueMajor: '1177.50', + paidMajor: '0.00', + remainingMajor: '1177.50', + overduePayments: [], + explanations: [ + 'Away policy applied to utilities', + 'No overdue utility base because away policy removed the share' + ] + } + ], + ledger: [ + ...baseLedger(), + { + id: 'payment-overdue-utilities-jan', + kind: 'payment', + title: 'utilities', + memberId: 'member-chorb', + paymentKind: 'utilities', + amountMajor: '52.00', + currency: 'GEL', + displayAmountMajor: '52.00', + displayCurrency: 'GEL', + fxRateMicros: null, + fxEffectiveDate: null, + actorDisplayName: 'Chorbanaut', + occurredAt: '2026-02-07T21:10:00.000Z' + } + ] + }), + pendingMembers, + adminSettings, + cycleState + }, + 'overdue-rent-and-utilities': { + dashboard: createDashboard({ + totalDueMajor: '2629.00', + totalPaidMajor: '200.00', + totalRemainingMajor: '2429.00', + members: [ + { + memberId: 'demo-member', + displayName: 'Stas', + predictedUtilityShareMajor: '88.00', + rentShareMajor: '603.75', + utilityShareMajor: '88.00', + purchaseOffsetMajor: '14.00', + netDueMajor: '705.75', + paidMajor: '0.00', + remainingMajor: '705.75', + overduePayments: [ + { + kind: 'rent', + amountMajor: '603.75', + periods: ['2026-02'] + }, + { + kind: 'utilities', + amountMajor: '166.00', + periods: ['2026-01', '2026-02'] + } + ], + explanations: [ + 'Both rent and utilities are overdue', + 'Current-cycle purchases remain visible but do not keep overdue open' + ] + }, + { + memberId: 'member-chorb', + displayName: 'Chorbanaut', + predictedUtilityShareMajor: '88.00', + rentShareMajor: '603.75', + utilityShareMajor: '88.00', + purchaseOffsetMajor: '36.00', + netDueMajor: '727.75', + paidMajor: '0.00', + remainingMajor: '727.75', + overduePayments: [ + { + kind: 'rent', + amountMajor: '603.75', + periods: ['2026-02'] + }, + { + kind: 'utilities', + amountMajor: '88.00', + periods: ['2026-02'] + } + ], + explanations: ['No backfilled payments have been entered yet'] + }, + { + memberId: 'member-el', + displayName: 'El', + predictedUtilityShareMajor: '0.00', + rentShareMajor: '1207.50', + utilityShareMajor: '0.00', + purchaseOffsetMajor: '-12.00', + netDueMajor: '1195.50', + paidMajor: '200.00', + remainingMajor: '995.50', + overduePayments: [ + { + kind: 'rent', + amountMajor: '1207.50', + periods: ['2026-02'] + } + ], + explanations: [ + 'Away policy still charges rent', + 'One partial rent payment was entered late' + ] + } + ], + ledger: [ + ...baseLedger(), + { + id: 'payment-overdue-rent-el', + kind: 'payment', + title: 'rent', + memberId: 'member-el', + paymentKind: 'rent', + amountMajor: '200.00', + currency: 'GEL', + displayAmountMajor: '200.00', + displayCurrency: 'GEL', + fxRateMicros: null, + fxEffectiveDate: null, + actorDisplayName: 'El', + occurredAt: '2026-02-23T14:40:00.000Z' + } + ] + }), + pendingMembers, + adminSettings, + cycleState + } +} + +export function getDemoScenarioState(id: DemoScenarioId): DemoScenarioState { + return structuredClone(demoScenarioCatalog[id]) +} + +const defaultScenarioState = getDemoScenarioState('current-cycle') + +export const demoDashboard = defaultScenarioState.dashboard +export const demoPendingMembers = defaultScenarioState.pendingMembers +export const demoAdminSettings = defaultScenarioState.adminSettings +export const demoCycleState = defaultScenarioState.cycleState diff --git a/apps/miniapp/src/i18n.ts b/apps/miniapp/src/i18n.ts index e6c06da..46d60c5 100644 --- a/apps/miniapp/src/i18n.ts +++ b/apps/miniapp/src/i18n.ts @@ -67,6 +67,9 @@ export const dictionary = { homeUtilitiesTitle: 'Utilities payment', homeRentTitle: 'Rent payment', homeNoPaymentTitle: 'No payment period', + homeOverdueRentTitle: 'Overdue rent', + homeOverdueUtilitiesTitle: 'Overdue utilities', + homeOverduePeriodsLabel: 'Overdue periods: {periods}', homeUtilitiesUpcomingLabel: 'Utilities starts {date}', homeRentUpcomingLabel: 'Rent starts {date}', homeFillUtilitiesTitle: 'Fill utilities', @@ -139,7 +142,7 @@ export const dictionary = { shareRent: 'Rent', shareUtilities: 'Utilities', shareOffset: 'Shared buys', - rentFxTitle: 'House rent FX', + rentFxTitle: 'Rent exchange rate', sourceAmountLabel: 'Source', settlementAmountLabel: 'Settlement', fxEffectiveDateLabel: 'Locked', @@ -158,6 +161,17 @@ export const dictionary = { testingPreviewResidentAction: 'Preview resident', testingCurrentRoleLabel: 'Real access', testingPreviewRoleLabel: 'Previewing', + testingScenarioLabel: 'Demo scenario', + testingScenarioCurrentCycle: 'Current cycle', + testingScenarioCurrentCycleBody: + 'Balanced current-period data with resolved and unresolved purchases, current utility bills, and partial payments from other members.', + testingScenarioOverdueUtilities: 'Overdue utilities', + testingScenarioOverdueUtilitiesBody: + 'Shows utility overdue cards, current-cycle utility debt, and purchase carry-over that should survive after overdue closes.', + testingScenarioOverdueBoth: 'Overdue rent + utilities', + testingScenarioOverdueBothBody: + 'Shows both overdue cards at once so you can test oldest-first payment routing and admin backfill flows.', + testingResetDemoStateAction: 'Reset demo state', testingPeriodCurrentLabel: 'Dashboard period', testingPeriodOverrideLabel: 'Period override', testingPeriodOverridePlaceholder: 'YYYY-MM', @@ -184,6 +198,8 @@ export const dictionary = { copiedToast: 'Copied!', quickPaymentTitle: 'Record payment', quickPaymentBody: 'Quickly record a {type} payment for the current cycle.', + quickPaymentCurrentBody: 'Quickly record a {type} payment for the current cycle.', + quickPaymentOverdueBody: 'Quickly record a {type} payment for overdue periods.', quickPaymentAmountLabel: 'Amount', quickPaymentCurrencyLabel: 'Currency', quickPaymentSubmitAction: 'Save payment', @@ -202,6 +218,10 @@ export const dictionary = { purchaseSaveAction: 'Save purchase', purchaseBalanceAction: 'Balance', purchaseRebalanceAction: 'Rebalance', + unresolvedPurchasesTitle: 'Outstanding purchases', + resolvedPurchasesTitle: 'Settled purchases', + unresolvedPurchasesEmpty: 'No unresolved purchases.', + resolvedPurchasesEmpty: 'No resolved purchases yet.', purchaseDeleteAction: 'Delete', deletingPurchase: 'Deleting purchase…', savingPurchase: 'Saving purchase…', @@ -320,6 +340,9 @@ export const dictionary = { saveDisplayName: 'Save name', savingDisplayName: 'Saving name…', memberStatusLabel: 'Member status', + memberRoleLabel: 'Role', + memberRoleResident: 'Resident', + memberRoleAdmin: 'Admin', saveMemberStatusAction: 'Save status', savingMemberStatus: 'Saving status…', memberStatusActive: 'Active', @@ -341,6 +364,8 @@ export const dictionary = { promoteAdminAction: 'Promote to admin', promoteAdminLabel: 'Admin access', promotingAdmin: 'Promoting…', + demoteAdminAction: 'Remove admin access', + demotingAdmin: 'Removing…', residentHouseTitle: 'Household access', residentHouseBody: 'Your admins manage household settings and approvals here. You can still switch your own language above.', @@ -422,6 +447,9 @@ export const dictionary = { homeUtilitiesTitle: 'Оплата коммуналки', homeRentTitle: 'Оплата аренды', homeNoPaymentTitle: 'Период без оплаты', + homeOverdueRentTitle: 'Просроченная аренда', + homeOverdueUtilitiesTitle: 'Просроченная коммуналка', + homeOverduePeriodsLabel: 'Просроченные периоды: {periods}', homeUtilitiesUpcomingLabel: 'Коммуналка с {date}', homeRentUpcomingLabel: 'Аренда с {date}', homeFillUtilitiesTitle: 'Внести коммуналку', @@ -494,7 +522,7 @@ export const dictionary = { shareRent: 'Аренда', shareUtilities: 'Коммуналка', shareOffset: 'Общие покупки', - rentFxTitle: 'FX по аренде дома', + rentFxTitle: 'Курс для аренды', sourceAmountLabel: 'Исходник', settlementAmountLabel: 'Расчёт', fxEffectiveDateLabel: 'Зафиксировано', @@ -513,6 +541,17 @@ export const dictionary = { testingPreviewResidentAction: 'Вид жителя', testingCurrentRoleLabel: 'Реальный доступ', testingPreviewRoleLabel: 'Сейчас показан', + testingScenarioLabel: 'Демо-сценарий', + testingScenarioCurrentCycle: 'Текущий цикл', + testingScenarioCurrentCycleBody: + 'Сбалансированный текущий период: есть закрытые и незакрытые покупки, актуальные коммунальные счета и частичные оплаты от других участников.', + testingScenarioOverdueUtilities: 'Просроченная коммуналка', + testingScenarioOverdueUtilitiesBody: + 'Показывает карточку просроченной коммуналки, долг текущего цикла и перенос покупок, который должен остаться после закрытия просрочки.', + testingScenarioOverdueBoth: 'Просрочены аренда и коммуналка', + testingScenarioOverdueBothBody: + 'Показывает обе просроченные карточки сразу, чтобы можно было проверить oldest-first распределение оплат и админский ввод задним числом.', + testingResetDemoStateAction: 'Сбросить демо-данные', testingPeriodCurrentLabel: 'Период (из API)', testingPeriodOverrideLabel: 'Переопределить период', testingPeriodOverridePlaceholder: 'YYYY-MM', @@ -541,6 +580,8 @@ export const dictionary = { copiedToast: 'Скопировано!', quickPaymentTitle: 'Записать оплату', quickPaymentBody: 'Быстро запиши оплату {type} за текущий цикл.', + quickPaymentCurrentBody: 'Быстро запиши оплату {type} за текущий цикл.', + quickPaymentOverdueBody: 'Быстро запиши оплату {type} за просроченные периоды.', quickPaymentAmountLabel: 'Сумма', quickPaymentCurrencyLabel: 'Валюта', quickPaymentSubmitAction: 'Сохранить оплату', @@ -559,6 +600,10 @@ export const dictionary = { purchaseSaveAction: 'Сохранить покупку', purchaseBalanceAction: 'Сбалансировать', purchaseRebalanceAction: 'Перераспределить', + unresolvedPurchasesTitle: 'Незакрытые покупки', + resolvedPurchasesTitle: 'Закрытые покупки', + unresolvedPurchasesEmpty: 'Незакрытых покупок нет.', + resolvedPurchasesEmpty: 'Закрытых покупок пока нет.', purchaseDeleteAction: 'Удалить', deletingPurchase: 'Удаляем покупку…', savingPurchase: 'Сохраняем покупку…', @@ -678,6 +723,9 @@ export const dictionary = { saveDisplayName: 'Сохранить имя', savingDisplayName: 'Сохраняем имя…', memberStatusLabel: 'Статус участника', + memberRoleLabel: 'Роль', + memberRoleResident: 'Житель', + memberRoleAdmin: 'Админ', saveMemberStatusAction: 'Сохранить статус', savingMemberStatus: 'Сохраняем статус…', memberStatusActive: 'Активный', @@ -699,6 +747,8 @@ export const dictionary = { promoteAdminAction: 'Сделать админом', promoteAdminLabel: 'Доступ админа', promotingAdmin: 'Повышаем…', + demoteAdminAction: 'Убрать доступ админа', + demotingAdmin: 'Убираем…', residentHouseTitle: 'Доступ к дому', residentHouseBody: 'Настройками дома и подтверждением заявок управляют админы. Свой язык можно менять переключателем выше.', diff --git a/apps/miniapp/src/index.css b/apps/miniapp/src/index.css index 493a91f..4b83b10 100644 --- a/apps/miniapp/src/index.css +++ b/apps/miniapp/src/index.css @@ -58,6 +58,13 @@ a { border-width: 0; } +.ui-icon { + width: 18px; + height: 18px; + display: block; + flex-shrink: 0; +} + .empty-state { color: var(--text-muted); font-size: var(--text-sm); @@ -822,9 +829,9 @@ a { } .modal-sheet { - width: 100%; + width: min(100%, 480px); max-width: 480px; - max-height: 85dvh; + max-height: min(92dvh, 900px); background: var(--bg-surface); border: 1px solid var(--border); border-radius: var(--radius-xl) var(--radius-xl) 0 0; @@ -848,6 +855,7 @@ a { display: flex; justify-content: space-between; align-items: flex-start; + gap: var(--spacing-md); padding: var(--spacing-xl) var(--spacing-xl) var(--spacing-md); position: sticky; top: 0; @@ -869,6 +877,17 @@ a { .modal-close-button { flex-shrink: 0; + width: 40px; + min-width: 40px; + min-height: 40px; + color: var(--text-primary); + background: var(--bg-root); + border-color: var(--border); +} + +.modal-close-button:hover:not(:disabled) { + background: var(--bg-elevated); + border-color: var(--border-hover); } .modal-sheet__body { @@ -887,6 +906,7 @@ a { display: flex; gap: var(--spacing-sm); justify-content: flex-end; + flex-wrap: wrap; } .modal-action-row--single { @@ -1677,6 +1697,8 @@ a { display: flex; justify-content: space-between; align-items: center; + gap: var(--spacing-sm); + flex-wrap: wrap; padding: var(--spacing-sm); background: var(--bg-root); border-radius: var(--radius-sm); @@ -1687,11 +1709,67 @@ a { color: var(--text-muted); } +.testing-card__section strong { + margin-left: auto; + text-align: right; +} + +.testing-card__section--stack { + align-items: flex-start; +} + +.testing-card__section--stack strong { + margin-left: 0; + text-align: left; +} + +.testing-card__section-content { + flex: 1 1 220px; + min-width: 0; +} + +.testing-card__section-description { + margin-top: 4px; + color: var(--text-secondary); + line-height: 1.45; +} + .testing-card__actions { display: flex; gap: var(--spacing-sm); } +.testing-card__actions--wrap { + flex-wrap: wrap; +} + +.testing-card__actions--wrap .ui-button { + flex: 1 1 160px; +} + +.testing-card__actions--stack { + flex-direction: column; + gap: 12px; +} + +@media (max-width: 480px) { + .modal-sheet { + width: calc(100% - 16px); + } + + .modal-sheet__header, + .modal-sheet__body, + .modal-sheet__footer { + padding-left: var(--spacing-lg); + padding-right: var(--spacing-lg); + } + + .testing-card__actions .ui-button, + .modal-action-row .ui-button { + flex: 1 1 100%; + } +} + /* ── Balance Item (legacy compat) ─────────────────────── */ .balance-item { diff --git a/apps/miniapp/src/lib/ledger-helpers.ts b/apps/miniapp/src/lib/ledger-helpers.ts index edefa0b..e8d81c8 100644 --- a/apps/miniapp/src/lib/ledger-helpers.ts +++ b/apps/miniapp/src/lib/ledger-helpers.ts @@ -44,6 +44,7 @@ export type PaymentDraft = { kind: 'rent' | 'utilities' amountMajor: string currency: 'USD' | 'GEL' + period: string } /* ── Pure helpers ───────────────────────────────────── */ @@ -170,7 +171,8 @@ export function paymentDrafts( memberId: entry.memberId ?? '', kind: entry.paymentKind ?? 'rent', amountMajor: entry.amountMajor, - currency: entry.currency + currency: entry.currency, + period: '' } ]) ) @@ -181,7 +183,8 @@ export function paymentDraftForEntry(entry: MiniAppDashboard['ledger'][number]): memberId: entry.memberId ?? '', kind: entry.paymentKind ?? 'rent', amountMajor: entry.amountMajor, - currency: entry.currency + currency: entry.currency, + period: '' } } diff --git a/apps/miniapp/src/miniapp-api.ts b/apps/miniapp/src/miniapp-api.ts index ebbb84d..368f8ba 100644 --- a/apps/miniapp/src/miniapp-api.ts +++ b/apps/miniapp/src/miniapp-api.ts @@ -130,6 +130,11 @@ export interface MiniAppDashboard { netDueMajor: string paidMajor: string remainingMajor: string + overduePayments: readonly { + kind: 'rent' | 'utilities' + amountMajor: string + periods: readonly string[] + }[] explanations: readonly string[] }[] ledger: { @@ -147,6 +152,13 @@ export interface MiniAppDashboard { actorDisplayName: string | null occurredAt: string | null purchaseSplitMode?: 'equal' | 'custom_amounts' + originPeriod?: string | null + resolutionStatus?: 'unresolved' | 'resolved' + resolvedAt?: string | null + outstandingByMember?: readonly { + memberId: string + amountMajor: string + }[] purchaseParticipants?: readonly { memberId: string included: boolean @@ -711,6 +723,35 @@ export async function updateMiniAppMemberStatus( return payload.member } +export async function demoteMiniAppMember( + initData: string, + memberId: string +): Promise { + const response = await fetch(`${apiBaseUrl()}/api/miniapp/admin/members/demote`, { + method: 'POST', + headers: { + 'content-type': 'application/json' + }, + body: JSON.stringify({ + initData, + memberId + }) + }) + + const payload = (await response.json()) as { + ok: boolean + authorized?: boolean + member?: MiniAppMember + error?: string + } + + if (!response.ok || !payload.member) { + throw new Error(payload.error ?? 'Failed to remove admin access') + } + + return payload.member +} + export async function updateMiniAppMemberAbsencePolicy( initData: string, memberId: string, @@ -1085,6 +1126,7 @@ export async function addMiniAppPayment( kind: 'rent' | 'utilities' amountMajor: string currency: 'USD' | 'GEL' + period?: string } ): Promise { const response = await fetch(`${apiBaseUrl()}/api/miniapp/admin/payments/add`, { diff --git a/apps/miniapp/src/routes/home.tsx b/apps/miniapp/src/routes/home.tsx index 472f60e..f99acbb 100644 --- a/apps/miniapp/src/routes/home.tsx +++ b/apps/miniapp/src/routes/home.tsx @@ -92,6 +92,9 @@ export default function HomeRoute() { const [copiedValue, setCopiedValue] = createSignal(null) const [quickPaymentOpen, setQuickPaymentOpen] = createSignal(false) const [quickPaymentType, setQuickPaymentType] = createSignal<'rent' | 'utilities'>('rent') + const [quickPaymentContext, setQuickPaymentContext] = createSignal<'current' | 'overdue'>( + 'current' + ) const [quickPaymentAmount, setQuickPaymentAmount] = createSignal('') const [submittingPayment, setSubmittingPayment] = createSignal(false) const [toastState, setToastState] = createSignal<{ @@ -203,10 +206,10 @@ export default function HomeRoute() { return override }) - const homeMode = createMemo(() => { + const currentPaymentModes = createMemo(() => { const data = dashboard() const member = currentMemberLine() - if (!data || !member) return 'none' as const + if (!data || !member) return [] as ('rent' | 'utilities')[] const period = effectivePeriod() ?? data.period const today = todayOverride() @@ -229,17 +232,21 @@ export default function HomeRoute() { const utilitiesActive = utilities.active && utilitiesDueMinor > 0n const rentActive = rent.active && rentDueMinor > 0n - if (utilitiesActive && rentActive) { - const utilitiesDays = utilities.daysUntilDue ?? Number.POSITIVE_INFINITY - const rentDays = rent.daysUntilDue ?? Number.POSITIVE_INFINITY - return utilitiesDays <= rentDays ? ('utilities' as const) : ('rent' as const) + const modes: ('rent' | 'utilities')[] = [] + if (utilitiesActive) { + modes.push('utilities') + } + if (rentActive) { + modes.push('rent') } - if (utilitiesActive) return 'utilities' as const - if (rentActive) return 'rent' as const - return 'none' as const + return modes }) + function overduePaymentFor(kind: 'rent' | 'utilities') { + return currentMemberLine()?.overduePayments.find((payment) => payment.kind === kind) ?? null + } + async function handleSubmitUtilities() { const data = initData() const current = dashboard() @@ -265,14 +272,21 @@ export default function HomeRoute() { } } - function openQuickPayment(type: 'rent' | 'utilities') { + function openQuickPayment( + type: 'rent' | 'utilities', + context: 'current' | 'overdue' = 'current' + ) { const data = dashboard() if (!data || !currentMemberLine()) return const member = currentMemberLine()! - const amount = minorToMajorString(paymentRemainingMinor(data, member, type)) + const amount = + context === 'overdue' + ? (overduePaymentFor(type)?.amountMajor ?? '0.00') + : minorToMajorString(paymentRemainingMinor(data, member, type)) setQuickPaymentType(type) + setQuickPaymentContext(context) setQuickPaymentAmount(amount) setQuickPaymentOpen(true) } @@ -365,7 +379,7 @@ export default function HomeRoute() { const utilitiesRemainingMinor = () => paymentRemainingMinor(data(), member(), 'utilities') - const mode = () => homeMode() + const modes = () => currentPaymentModes() const currency = () => data().currency const timezone = () => data().timezone const period = () => effectivePeriod() ?? data().period @@ -431,7 +445,93 @@ export default function HomeRoute() { return ( <> - + + {(overdue) => ( + +
+
+ + {copy().homeOverdueUtilitiesTitle} + +
+ {copy().overdueLabel} + +
+
+
+
+ {copy().finalDue} + + {overdue().amountMajor} {currency()} + +
+
+ + {copy().homeOverduePeriodsLabel.replace( + '{periods}', + overdue().periods.join(', ') + )} + +
+
+
+
+ )} +
+ + + {(overdue) => ( + +
+
+ + {copy().homeOverdueRentTitle} + +
+ {copy().overdueLabel} + +
+
+
+
+ {copy().finalDue} + + {overdue().amountMajor} {currency()} + +
+
+ + {copy().homeOverduePeriodsLabel.replace( + '{periods}', + overdue().periods.join(', ') + )} + +
+
+
+
+ )} +
+ +
@@ -441,7 +541,7 @@ export default function HomeRoute() { - )} - +
+
+ {copy().unresolvedPurchasesTitle} + 0} + fallback={

{copy().unresolvedPurchasesEmpty}

} + > +
+ + {(entry) => ( + + )} + +
+
+
+ +
+ {copy().resolvedPurchasesTitle} + 0} + fallback={

{copy().resolvedPurchasesEmpty}

} + > +
+ + {(entry) => ( + + )} + +
+
+
@@ -691,7 +775,17 @@ export default function LedgerRoute() { >
- @@ -1023,6 +1117,15 @@ export default function LedgerRoute() { } /> + + { @@ -906,16 +911,17 @@ export default function SettingsRoute() { } /> - - - - - + +