diff --git a/apps/bot/src/index.ts b/apps/bot/src/index.ts index dfb9607..59c890b 100644 --- a/apps/bot/src/index.ts +++ b/apps/bot/src/index.ts @@ -49,8 +49,10 @@ import { createMiniAppAddUtilityBillHandler, createMiniAppBillingCycleHandler, createMiniAppCloseCycleHandler, + createMiniAppDeleteUtilityBillHandler, createMiniAppOpenCycleHandler, - createMiniAppRentUpdateHandler + createMiniAppRentUpdateHandler, + createMiniAppUpdateUtilityBillHandler } from './miniapp-billing' import { createMiniAppLocalePreferenceHandler } from './miniapp-locale' import { createNbgExchangeRateProvider } from './nbg-exchange-rates' @@ -463,6 +465,24 @@ const server = createBotWebhookServer({ logger: getLogger('miniapp-billing') }) : undefined, + miniAppUpdateUtilityBill: householdOnboardingService + ? createMiniAppUpdateUtilityBillHandler({ + allowedOrigins: runtime.miniAppAllowedOrigins, + botToken: runtime.telegramBotToken, + onboardingService: householdOnboardingService, + financeServiceForHousehold, + logger: getLogger('miniapp-billing') + }) + : undefined, + miniAppDeleteUtilityBill: householdOnboardingService + ? createMiniAppDeleteUtilityBillHandler({ + allowedOrigins: runtime.miniAppAllowedOrigins, + botToken: runtime.telegramBotToken, + onboardingService: householdOnboardingService, + financeServiceForHousehold, + logger: getLogger('miniapp-billing') + }) + : undefined, miniAppLocalePreference: householdOnboardingService ? createMiniAppLocalePreferenceHandler({ allowedOrigins: runtime.miniAppAllowedOrigins, diff --git a/apps/bot/src/miniapp-admin.ts b/apps/bot/src/miniapp-admin.ts index be7f368..ddd9c10 100644 --- a/apps/bot/src/miniapp-admin.ts +++ b/apps/bot/src/miniapp-admin.ts @@ -449,6 +449,11 @@ export function createMiniAppUpdateSettingsHandler(options: { const result = await options.miniAppAdminService.updateSettings({ householdId: session.member.householdId, actorIsAdmin: session.member.isAdmin, + ...(payload.settlementCurrency + ? { + settlementCurrency: payload.settlementCurrency + } + : {}), ...(payload.rentAmountMajor !== undefined ? { rentAmountMajor: payload.rentAmountMajor diff --git a/apps/bot/src/miniapp-billing.test.ts b/apps/bot/src/miniapp-billing.test.ts index 7b280a7..3e1f476 100644 --- a/apps/bot/src/miniapp-billing.test.ts +++ b/apps/bot/src/miniapp-billing.test.ts @@ -11,8 +11,10 @@ import type { import { createMiniAppAddUtilityBillHandler, createMiniAppBillingCycleHandler, + createMiniAppDeleteUtilityBillHandler, createMiniAppOpenCycleHandler, - createMiniAppRentUpdateHandler + createMiniAppRentUpdateHandler, + createMiniAppUpdateUtilityBillHandler } from './miniapp-billing' import { buildMiniAppInitData } from './telegram-miniapp-test-helpers' @@ -179,6 +181,12 @@ function createFinanceServiceStub(): FinanceCommandService { currency: 'USD', period: '2026-03' }), + updateUtilityBill: async () => ({ + billId: 'utility-1', + amount: Money.fromMinor(4500n, 'USD'), + currency: 'USD' + }), + deleteUtilityBill: async () => true, generateDashboard: async () => null, generateStatement: async () => null } @@ -249,6 +257,87 @@ describe('createMiniAppBillingCycleHandler', () => { }) }) +test('createMiniAppUpdateUtilityBillHandler updates a utility bill for the current cycle', async () => { + const repository = onboardingRepository() + const handler = createMiniAppUpdateUtilityBillHandler({ + allowedOrigins: ['http://localhost:5173'], + botToken: 'test-bot-token', + onboardingService: createHouseholdOnboardingService({ + repository + }), + financeServiceForHousehold: () => createFinanceServiceStub() + }) + + const response = await handler.handler( + new Request('http://localhost/api/miniapp/admin/utility-bills/update', { + method: 'POST', + headers: { + origin: 'http://localhost:5173', + 'content-type': 'application/json' + }, + body: JSON.stringify({ + initData: initData(), + billId: 'utility-1', + billName: 'Electricity', + amountMajor: '45.00', + currency: 'GEL' + }) + }) + ) + + expect(response.status).toBe(200) + expect(await response.json()).toMatchObject({ + ok: true, + authorized: true, + cycleState: { + utilityBills: [ + { + id: 'utility-1' + } + ] + } + }) +}) + +test('createMiniAppDeleteUtilityBillHandler deletes a utility bill for the current cycle', async () => { + const repository = onboardingRepository() + const handler = createMiniAppDeleteUtilityBillHandler({ + allowedOrigins: ['http://localhost:5173'], + botToken: 'test-bot-token', + onboardingService: createHouseholdOnboardingService({ + repository + }), + financeServiceForHousehold: () => createFinanceServiceStub() + }) + + const response = await handler.handler( + new Request('http://localhost/api/miniapp/admin/utility-bills/delete', { + method: 'POST', + headers: { + origin: 'http://localhost:5173', + 'content-type': 'application/json' + }, + body: JSON.stringify({ + initData: initData(), + billId: 'utility-1' + }) + }) + ) + + expect(response.status).toBe(200) + expect(await response.json()).toMatchObject({ + ok: true, + authorized: true, + cycleState: { + utilityBills: [ + { + id: 'utility-1' + } + ] + } + }) +}) + describe('createMiniAppOpenCycleHandler', () => { test('opens a billing cycle for an authenticated admin', async () => { const repository = onboardingRepository() diff --git a/apps/bot/src/miniapp-billing.ts b/apps/bot/src/miniapp-billing.ts index 0b74942..b7012fa 100644 --- a/apps/bot/src/miniapp-billing.ts +++ b/apps/bot/src/miniapp-billing.ts @@ -217,6 +217,72 @@ async function readUtilityBillPayload(request: Request): Promise<{ } } +async function readUtilityBillUpdatePayload(request: Request): Promise<{ + initData: string + billId: string + billName: string + amountMajor: string + currency?: string +}> { + const parsed = await parseJsonBody<{ + initData?: string + billId?: string + billName?: string + amountMajor?: string + currency?: string + }>(request) + const initData = parsed.initData?.trim() + if (!initData) { + throw new Error('Missing initData') + } + const billId = parsed.billId?.trim() + const billName = parsed.billName?.trim() + const amountMajor = parsed.amountMajor?.trim() + + if (!billId) { + throw new Error('Missing utility bill id') + } + if (!billName) { + throw new Error('Missing utility bill name') + } + if (!amountMajor) { + throw new Error('Missing utility bill amount') + } + + const currency = parsed.currency?.trim() + + return { + initData, + billId, + billName, + amountMajor, + ...(currency ? { currency } : {}) + } +} + +async function readUtilityBillDeletePayload(request: Request): Promise<{ + initData: string + billId: string +}> { + const parsed = await parseJsonBody<{ + initData?: string + billId?: string + }>(request) + const initData = parsed.initData?.trim() + if (!initData) { + throw new Error('Missing initData') + } + const billId = parsed.billId?.trim() + if (!billId) { + throw new Error('Missing utility bill id') + } + + return { + initData, + billId + } +} + export function createMiniAppBillingCycleHandler(options: { allowedOrigins: readonly string[] botToken: string @@ -523,3 +589,132 @@ export function createMiniAppAddUtilityBillHandler(options: { } } } + +export function createMiniAppUpdateUtilityBillHandler(options: { + allowedOrigins: readonly string[] + botToken: string + financeServiceForHousehold: (householdId: string) => FinanceCommandService + onboardingService: HouseholdOnboardingService + 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 auth = await authenticateAdminSession( + request.clone() as Request, + sessionService, + origin + ) + if (auth instanceof Response) { + return auth + } + + const payload = await readUtilityBillUpdatePayload(request) + const service = options.financeServiceForHousehold(auth.member.householdId) + const result = await service.updateUtilityBill( + payload.billId, + payload.billName, + payload.amountMajor, + payload.currency + ) + + if (!result) { + return miniAppJsonResponse({ ok: false, error: 'Utility bill not found' }, 404, origin) + } + + const cycleState = await service.getAdminCycleState() + + return miniAppJsonResponse( + { + ok: true, + authorized: true, + cycleState: serializeCycleState(cycleState) + }, + 200, + origin + ) + } catch (error) { + return miniAppErrorResponse(error, origin, options.logger) + } + } + } +} + +export function createMiniAppDeleteUtilityBillHandler(options: { + allowedOrigins: readonly string[] + botToken: string + financeServiceForHousehold: (householdId: string) => FinanceCommandService + onboardingService: HouseholdOnboardingService + 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 auth = await authenticateAdminSession( + request.clone() as Request, + sessionService, + origin + ) + if (auth instanceof Response) { + return auth + } + + const payload = await readUtilityBillDeletePayload(request) + const service = options.financeServiceForHousehold(auth.member.householdId) + const deleted = await service.deleteUtilityBill(payload.billId) + + if (!deleted) { + return miniAppJsonResponse({ ok: false, error: 'Utility bill not found' }, 404, origin) + } + + const cycleState = await service.getAdminCycleState() + + return miniAppJsonResponse( + { + ok: true, + authorized: true, + cycleState: serializeCycleState(cycleState) + }, + 200, + origin + ) + } catch (error) { + return miniAppErrorResponse(error, origin, options.logger) + } + } + } +} diff --git a/apps/bot/src/miniapp-dashboard.test.ts b/apps/bot/src/miniapp-dashboard.test.ts index d4d1a1f..ba5d8f0 100644 --- a/apps/bot/src/miniapp-dashboard.test.ts +++ b/apps/bot/src/miniapp-dashboard.test.ts @@ -46,6 +46,8 @@ function repository( getCycleExchangeRate: async () => null, saveCycleExchangeRate: async (input) => input, addUtilityBill: async () => {}, + updateUtilityBill: async () => null, + deleteUtilityBill: async () => false, getRentRuleForPeriod: async () => ({ amountMinor: 70000n, currency: 'USD' diff --git a/apps/bot/src/server.test.ts b/apps/bot/src/server.test.ts index 600b34e..40d6600 100644 --- a/apps/bot/src/server.test.ts +++ b/apps/bot/src/server.test.ts @@ -127,6 +127,24 @@ describe('createBotWebhookServer', () => { } }) }, + miniAppUpdateUtilityBill: { + handler: async () => + new Response(JSON.stringify({ ok: true, authorized: true, cycleState: {} }), { + status: 200, + headers: { + 'content-type': 'application/json; charset=utf-8' + } + }) + }, + miniAppDeleteUtilityBill: { + handler: async () => + new Response(JSON.stringify({ ok: true, authorized: true, cycleState: {} }), { + status: 200, + headers: { + 'content-type': 'application/json; charset=utf-8' + } + }) + }, miniAppApproveMember: { handler: async () => new Response(JSON.stringify({ ok: true, authorized: true, member: {} }), { @@ -409,6 +427,38 @@ describe('createBotWebhookServer', () => { }) }) + test('accepts mini app utility bill update request', async () => { + const response = await server.fetch( + new Request('http://localhost/api/miniapp/admin/utility-bills/update', { + method: 'POST', + body: JSON.stringify({ initData: 'payload', billId: 'utility-1' }) + }) + ) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ + ok: true, + authorized: true, + cycleState: {} + }) + }) + + test('accepts mini app utility bill delete request', async () => { + const response = await server.fetch( + new Request('http://localhost/api/miniapp/admin/utility-bills/delete', { + method: 'POST', + body: JSON.stringify({ initData: 'payload', billId: 'utility-1' }) + }) + ) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ + ok: true, + authorized: true, + cycleState: {} + }) + }) + test('accepts mini app approve member request', async () => { const response = await server.fetch( new Request('http://localhost/api/miniapp/admin/approve-member', { diff --git a/apps/bot/src/server.ts b/apps/bot/src/server.ts index c07260a..19fadb2 100644 --- a/apps/bot/src/server.ts +++ b/apps/bot/src/server.ts @@ -92,6 +92,18 @@ export interface BotWebhookServerOptions { handler: (request: Request) => Promise } | undefined + miniAppUpdateUtilityBill?: + | { + path?: string + handler: (request: Request) => Promise + } + | undefined + miniAppDeleteUtilityBill?: + | { + path?: string + handler: (request: Request) => Promise + } + | undefined miniAppLocalePreference?: | { path?: string @@ -153,6 +165,10 @@ export function createBotWebhookServer(options: BotWebhookServerOptions): { const miniAppRentUpdatePath = options.miniAppRentUpdate?.path ?? '/api/miniapp/admin/rent/update' const miniAppAddUtilityBillPath = options.miniAppAddUtilityBill?.path ?? '/api/miniapp/admin/utility-bills/add' + const miniAppUpdateUtilityBillPath = + options.miniAppUpdateUtilityBill?.path ?? '/api/miniapp/admin/utility-bills/update' + const miniAppDeleteUtilityBillPath = + options.miniAppDeleteUtilityBill?.path ?? '/api/miniapp/admin/utility-bills/delete' const miniAppLocalePreferencePath = options.miniAppLocalePreference?.path ?? '/api/miniapp/preferences/locale' const schedulerPathPrefix = options.scheduler @@ -233,6 +249,14 @@ export function createBotWebhookServer(options: BotWebhookServerOptions): { return await options.miniAppAddUtilityBill.handler(request) } + if (options.miniAppUpdateUtilityBill && url.pathname === miniAppUpdateUtilityBillPath) { + return await options.miniAppUpdateUtilityBill.handler(request) + } + + if (options.miniAppDeleteUtilityBill && url.pathname === miniAppDeleteUtilityBillPath) { + return await options.miniAppDeleteUtilityBill.handler(request) + } + if (options.miniAppLocalePreference && url.pathname === miniAppLocalePreferencePath) { return await options.miniAppLocalePreference.handler(request) } diff --git a/apps/miniapp/src/App.tsx b/apps/miniapp/src/App.tsx index 0edd8b5..69483c8 100644 --- a/apps/miniapp/src/App.tsx +++ b/apps/miniapp/src/App.tsx @@ -5,6 +5,7 @@ import { addMiniAppUtilityBill, approveMiniAppPendingMember, closeMiniAppBillingCycle, + deleteMiniAppUtilityBill, fetchMiniAppAdminSettings, fetchMiniAppBillingCycle, fetchMiniAppDashboard, @@ -20,6 +21,7 @@ import { updateMiniAppBillingSettings, updateMiniAppCycleRent, upsertMiniAppUtilityCategory, + updateMiniAppUtilityBill, type MiniAppDashboard, type MiniAppPendingMember } from './miniapp-api' @@ -62,6 +64,12 @@ type SessionState = type NavigationKey = 'home' | 'balances' | 'ledger' | 'house' +type UtilityBillDraft = { + billName: string + amountMajor: string + currency: 'USD' | 'GEL' +} + const demoSession: Extract = { status: 'ready', mode: 'demo', @@ -186,6 +194,21 @@ function ledgerSecondaryAmount(entry: MiniAppDashboard['ledger'][number]): strin return `${entry.amountMajor} ${entry.currency}` } +function cycleUtilityBillDrafts( + bills: MiniAppAdminCycleState['utilityBills'] +): Record { + return Object.fromEntries( + bills.map((bill) => [ + bill.id, + { + billName: bill.billName, + amountMajor: minorToMajorString(BigInt(bill.amountMinor)), + currency: bill.currency + } + ]) + ) +} + function App() { const [locale, setLocale] = createSignal('en') const [session, setSession] = createSignal({ @@ -209,6 +232,11 @@ function App() { const [closingCycle, setClosingCycle] = createSignal(false) const [savingCycleRent, setSavingCycleRent] = createSignal(false) const [savingUtilityBill, setSavingUtilityBill] = createSignal(false) + const [savingUtilityBillId, setSavingUtilityBillId] = createSignal(null) + const [deletingUtilityBillId, setDeletingUtilityBillId] = createSignal(null) + const [utilityBillDrafts, setUtilityBillDrafts] = createSignal>( + {} + ) const [billingForm, setBillingForm] = createSignal({ settlementCurrency: 'GEL' as 'USD' | 'GEL', rentAmountMajor: '', @@ -222,7 +250,8 @@ function App() { const [newCategoryName, setNewCategoryName] = createSignal('') const [cycleForm, setCycleForm] = createSignal({ period: defaultCyclePeriod(), - currency: 'GEL' as 'USD' | 'GEL', + rentCurrency: 'USD' as 'USD' | 'GEL', + utilityCurrency: 'GEL' as 'USD' | 'GEL', rentAmountMajor: '', utilityCategorySlug: '', utilityAmountMajor: '' @@ -320,7 +349,8 @@ function App() { ) setCycleForm((current) => ({ ...current, - currency: current.currency || payload.settings.settlementCurrency, + rentCurrency: payload.settings.rentCurrency, + utilityCurrency: payload.settings.settlementCurrency, utilityCategorySlug: current.utilityCategorySlug || payload.categories.find((category) => category.isActive)?.slug || @@ -351,13 +381,15 @@ function App() { try { const payload = await fetchMiniAppBillingCycle(initData) setCycleState(payload) + setUtilityBillDrafts(cycleUtilityBillDrafts(payload.utilityBills)) setCycleForm((current) => ({ ...current, period: payload.cycle?.period ?? current.period, - currency: - payload.cycle?.currency ?? - adminSettings()?.settings.settlementCurrency ?? - current.currency, + rentCurrency: + payload.rentRule?.currency ?? + adminSettings()?.settings.rentCurrency ?? + current.rentCurrency, + utilityCurrency: adminSettings()?.settings.settlementCurrency ?? current.utilityCurrency, rentAmountMajor: payload.rentRule ? (Number(payload.rentRule.amountMinor) / 100).toFixed(2) : '', @@ -707,7 +739,8 @@ function App() { ) setCycleForm((current) => ({ ...current, - currency: cycleState()?.cycle?.currency ?? settings.settlementCurrency + rentCurrency: settings.rentCurrency, + utilityCurrency: settings.settlementCurrency })) } finally { setSavingBillingSettings(false) @@ -726,13 +759,14 @@ function App() { try { const state = await openMiniAppBillingCycle(initData, { period: cycleForm().period, - currency: cycleForm().currency + currency: billingForm().settlementCurrency }) setCycleState(state) + setUtilityBillDrafts(cycleUtilityBillDrafts(state.utilityBills)) setCycleForm((current) => ({ ...current, period: state.cycle?.period ?? current.period, - currency: state.cycle?.currency ?? current.currency + utilityCurrency: billingForm().settlementCurrency })) } finally { setOpeningCycle(false) @@ -751,6 +785,7 @@ function App() { try { const state = await closeMiniAppBillingCycle(initData, cycleState()?.cycle?.period) setCycleState(state) + setUtilityBillDrafts(cycleUtilityBillDrafts(state.utilityBills)) } finally { setClosingCycle(false) } @@ -768,7 +803,7 @@ function App() { try { const state = await updateMiniAppCycleRent(initData, { amountMajor: cycleForm().rentAmountMajor, - currency: cycleForm().currency, + currency: cycleForm().rentCurrency, ...(cycleState()?.cycle?.period ? { period: cycleState()!.cycle!.period @@ -776,6 +811,7 @@ function App() { : {}) }) setCycleState(state) + setUtilityBillDrafts(cycleUtilityBillDrafts(state.utilityBills)) } finally { setSavingCycleRent(false) } @@ -803,9 +839,10 @@ function App() { const state = await addMiniAppUtilityBill(initData, { billName: selectedCategory.name, amountMajor: cycleForm().utilityAmountMajor, - currency: cycleForm().currency + currency: cycleForm().utilityCurrency }) setCycleState(state) + setUtilityBillDrafts(cycleUtilityBillDrafts(state.utilityBills)) setCycleForm((current) => ({ ...current, utilityAmountMajor: '' @@ -815,6 +852,56 @@ function App() { } } + async function handleUpdateUtilityBill(billId: string) { + const initData = webApp?.initData?.trim() + const currentReady = readySession() + const draft = utilityBillDrafts()[billId] + + if ( + !initData || + currentReady?.mode !== 'live' || + !currentReady.member.isAdmin || + !draft || + draft.billName.trim().length === 0 || + draft.amountMajor.trim().length === 0 + ) { + return + } + + setSavingUtilityBillId(billId) + + try { + const state = await updateMiniAppUtilityBill(initData, { + billId, + billName: draft.billName, + amountMajor: draft.amountMajor, + currency: draft.currency + }) + setCycleState(state) + setUtilityBillDrafts(cycleUtilityBillDrafts(state.utilityBills)) + } finally { + setSavingUtilityBillId(null) + } + } + + async function handleDeleteUtilityBill(billId: string) { + const initData = webApp?.initData?.trim() + const currentReady = readySession() + if (!initData || currentReady?.mode !== 'live' || !currentReady.member.isAdmin) { + return + } + + setDeletingUtilityBillId(billId) + + try { + const state = await deleteMiniAppUtilityBill(initData, billId) + setCycleState(state) + setUtilityBillDrafts(cycleUtilityBillDrafts(state.utilityBills)) + } finally { + setDeletingUtilityBillId(null) + } + } + async function handleSaveUtilityCategory(input: { slug?: string name: string @@ -1138,7 +1225,7 @@ function App() {

{copy().billingCycleStatus.replace( '{currency}', - cycleState()?.cycle?.currency ?? cycleForm().currency + cycleState()?.cycle?.currency ?? billingForm().settlementCurrency )}

@@ -1168,11 +1255,11 @@ function App() { +
+ {billingForm().settlementCurrency} +
+ -
+
{cycleState()?.utilityBills.length ? ( cycleState()?.utilityBills.map((bill) => ( -
+
- {bill.billName} - - {(Number(bill.amountMinor) / 100).toFixed(2)} {bill.currency} - + + {utilityBillDrafts()[bill.id]?.billName ?? bill.billName} + + {bill.createdAt.slice(0, 10)}
-

{bill.createdAt.slice(0, 10)}

+
+ + + +
+
+ + +
)) ) : ( @@ -1515,9 +1694,9 @@ function App() { {copy().utilityCategoriesTitle} {String(adminSettings()?.categories.length ?? 0)} -
+
{adminSettings()?.categories.map((category) => ( -
+
{category.name} {category.isActive ? 'ON' : 'OFF'} @@ -1646,7 +1825,7 @@ function App() {
{adminSettings()?.members.map((member) => ( -
+
{member.displayName} {member.isAdmin ? copy().adminTag : copy().residentTag} @@ -1709,7 +1888,7 @@ function App() { {pendingMembers().length === 0 ? (

{copy().pendingMembersEmpty}

) : ( -
+
{pendingMembers().map((member) => (
@@ -1751,7 +1930,7 @@ function App() { ) default: return ( -
+
{copy().totalDue} @@ -1852,7 +2031,7 @@ function App() {
)} -
+
{copy().latestActivityTitle}
@@ -1863,9 +2042,9 @@ function App() { data.ledger.length === 0 ? (

{copy().latestActivityEmpty}

) : ( -
+
{data.ledger.slice(0, 3).map((entry) => ( -
+
{ledgerTitle(entry)} {ledgerPrimaryAmount(entry)} diff --git a/apps/miniapp/src/i18n.ts b/apps/miniapp/src/i18n.ts index 949be07..113851d 100644 --- a/apps/miniapp/src/i18n.ts +++ b/apps/miniapp/src/i18n.ts @@ -97,6 +97,9 @@ export const dictionary = { utilityAmount: 'Utility amount', addUtilityBillAction: 'Add utility bill', savingUtilityBill: 'Saving utility bill…', + saveUtilityBillAction: 'Save utility bill', + deleteUtilityBillAction: 'Delete utility bill', + deletingUtilityBill: 'Deleting utility bill…', utilityBillsEmpty: 'No utility bills recorded for this cycle yet.', rentAmount: 'Rent amount', rentDueDay: 'Rent due day', @@ -229,6 +232,9 @@ export const dictionary = { utilityAmount: 'Сумма коммуналки', addUtilityBillAction: 'Добавить коммунальный счёт', savingUtilityBill: 'Сохраняем счёт…', + saveUtilityBillAction: 'Сохранить счёт', + deleteUtilityBillAction: 'Удалить счёт', + deletingUtilityBill: 'Удаляем счёт…', utilityBillsEmpty: 'Для этого цикла пока нет коммунальных счетов.', rentAmount: 'Сумма аренды', rentDueDay: 'День оплаты аренды', diff --git a/apps/miniapp/src/index.css b/apps/miniapp/src/index.css index fc6111a..60a1323 100644 --- a/apps/miniapp/src/index.css +++ b/apps/miniapp/src/index.css @@ -231,14 +231,17 @@ button { .ledger-list, .home-grid, .admin-layout, -.admin-sublist { +.admin-sublist, +.activity-list { display: grid; gap: 12px; } .balance-item, .ledger-item, -.stat-card { +.stat-card, +.activity-row, +.utility-bill-row { border: 1px solid rgb(255 255 255 / 0.08); border-radius: 18px; padding: 14px; @@ -253,7 +256,9 @@ button { } .balance-item header, -.ledger-item header { +.ledger-item header, +.activity-row header, +.utility-bill-row header { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: start; @@ -262,13 +267,17 @@ button { } .balance-item strong, -.ledger-item strong { +.ledger-item strong, +.activity-row strong, +.utility-bill-row strong { font-size: 1rem; overflow-wrap: anywhere; } .balance-item p, -.ledger-item p { +.ledger-item p, +.activity-row p, +.utility-bill-row p { margin-top: 6px; } @@ -285,7 +294,7 @@ button { } .home-grid { - grid-template-columns: repeat(2, minmax(0, 1fr)); + grid-template-columns: minmax(0, 1fr); } .stat-card { @@ -360,6 +369,10 @@ button { margin-top: 12px; } +.admin-sublist--plain { + gap: 10px; +} + .admin-card--wide { min-width: 0; } @@ -379,6 +392,19 @@ button { width: 100%; border: 1px solid rgb(255 255 255 / 0.12); border-radius: 14px; + min-height: 48px; + padding: 12px 14px; + background: rgb(255 255 255 / 0.04); + color: inherit; + line-height: 1.35; +} + +.settings-field__value { + display: flex; + align-items: center; + min-height: 48px; + border: 1px solid rgb(255 255 255 / 0.12); + border-radius: 14px; padding: 12px 14px; background: rgb(255 255 255 / 0.04); color: inherit; @@ -399,10 +425,33 @@ button { margin-top: 0; } +.ghost-button--danger { + border-color: rgb(247 115 115 / 0.28); + color: #ffc5c5; +} + .panel--wide { min-height: 170px; } +.activity-row, +.utility-bill-row { + background: rgb(255 255 255 / 0.02); +} + +.activity-row header, +.utility-bill-row header { + margin-bottom: 4px; +} + +.activity-row strong { + word-break: break-word; +} + +.balance-item--wide { + grid-column: 1 / -1; +} + @media (min-width: 760px) { .shell { max-width: 920px; @@ -418,6 +467,10 @@ button { grid-template-columns: repeat(4, minmax(0, 1fr)); } + .home-grid--summary { + grid-template-columns: repeat(4, minmax(0, 1fr)); + } + .balance-breakdown { grid-template-columns: repeat(3, minmax(0, 1fr)); } @@ -441,6 +494,10 @@ button { .admin-card--wide { grid-column: 1 / -1; } + + .balance-item--wide { + grid-column: 1 / -1; + } } @media (min-width: 980px) { @@ -448,3 +505,37 @@ button { grid-template-columns: repeat(3, minmax(0, 1fr)); } } + +@media (max-width: 759px) { + .shell { + padding: 18px 14px 28px; + } + + .topbar { + flex-direction: column; + } + + .locale-switch { + width: 100%; + min-width: 0; + } + + .nav-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .balance-breakdown { + grid-template-columns: minmax(0, 1fr); + } + + .admin-section__header { + align-items: start; + } + + .activity-row header, + .ledger-item header, + .utility-bill-row header, + .balance-item header { + grid-template-columns: minmax(0, 1fr); + } +} diff --git a/apps/miniapp/src/miniapp-api.ts b/apps/miniapp/src/miniapp-api.ts index 3f78ab4..f82ddfc 100644 --- a/apps/miniapp/src/miniapp-api.ts +++ b/apps/miniapp/src/miniapp-api.ts @@ -668,3 +668,66 @@ export async function addMiniAppUtilityBill( return payload.cycleState } + +export async function updateMiniAppUtilityBill( + initData: string, + input: { + billId: string + billName: string + amountMajor: string + currency: 'USD' | 'GEL' + } +): Promise { + const response = await fetch(`${apiBaseUrl()}/api/miniapp/admin/utility-bills/update`, { + method: 'POST', + headers: { + 'content-type': 'application/json' + }, + body: JSON.stringify({ + initData, + ...input + }) + }) + + const payload = (await response.json()) as { + ok: boolean + authorized?: boolean + cycleState?: MiniAppAdminCycleState + error?: string + } + + if (!response.ok || !payload.authorized || !payload.cycleState) { + throw new Error(payload.error ?? 'Failed to update utility bill') + } + + return payload.cycleState +} + +export async function deleteMiniAppUtilityBill( + initData: string, + billId: string +): Promise { + const response = await fetch(`${apiBaseUrl()}/api/miniapp/admin/utility-bills/delete`, { + method: 'POST', + headers: { + 'content-type': 'application/json' + }, + body: JSON.stringify({ + initData, + billId + }) + }) + + const payload = (await response.json()) as { + ok: boolean + authorized?: boolean + cycleState?: MiniAppAdminCycleState + error?: string + } + + if (!response.ok || !payload.authorized || !payload.cycleState) { + throw new Error(payload.error ?? 'Failed to delete utility bill') + } + + return payload.cycleState +} diff --git a/packages/adapters-db/src/finance-repository.ts b/packages/adapters-db/src/finance-repository.ts index 5d47cc5..fe1cde1 100644 --- a/packages/adapters-db/src/finance-repository.ts +++ b/packages/adapters-db/src/finance-repository.ts @@ -296,6 +296,54 @@ export function createDbFinanceRepository( }) }, + async updateUtilityBill(input) { + const rows = await db + .update(schema.utilityBills) + .set({ + billName: input.billName, + amountMinor: input.amountMinor, + currency: input.currency + }) + .where( + and( + eq(schema.utilityBills.householdId, householdId), + eq(schema.utilityBills.id, input.billId) + ) + ) + .returning({ + id: schema.utilityBills.id, + billName: schema.utilityBills.billName, + amountMinor: schema.utilityBills.amountMinor, + currency: schema.utilityBills.currency, + createdByMemberId: schema.utilityBills.createdByMemberId, + createdAt: schema.utilityBills.createdAt + }) + + const row = rows[0] + if (!row) { + return null + } + + return { + ...row, + currency: toCurrencyCode(row.currency), + createdAt: instantFromDatabaseValue(row.createdAt)! + } + }, + + async deleteUtilityBill(billId) { + const rows = await db + .delete(schema.utilityBills) + .where( + and(eq(schema.utilityBills.householdId, householdId), eq(schema.utilityBills.id, billId)) + ) + .returning({ + id: schema.utilityBills.id + }) + + return rows.length > 0 + }, + async getRentRuleForPeriod(period) { const rows = await db .select({ diff --git a/packages/application/src/finance-command-service.test.ts b/packages/application/src/finance-command-service.test.ts index a5288f6..8e15457 100644 --- a/packages/application/src/finance-command-service.test.ts +++ b/packages/application/src/finance-command-service.test.ts @@ -121,6 +121,14 @@ class FinanceRepositoryStub implements FinanceRepository { this.lastUtilityBill = input } + async updateUtilityBill() { + return null + } + + async deleteUtilityBill() { + return false + } + async getRentRuleForPeriod(): Promise { return this.rentRule } diff --git a/packages/application/src/finance-command-service.ts b/packages/application/src/finance-command-service.ts index b21254f..315bb2d 100644 --- a/packages/application/src/finance-command-service.ts +++ b/packages/application/src/finance-command-service.ts @@ -466,6 +466,17 @@ export interface FinanceCommandService { currency: CurrencyCode period: string } | null> + updateUtilityBill( + billId: string, + billName: string, + amountArg: string, + currencyArg?: string + ): Promise<{ + billId: string + amount: Money + currency: CurrencyCode + } | null> + deleteUtilityBill(billId: string): Promise generateDashboard(periodArg?: string): Promise generateStatement(periodArg?: string): Promise } @@ -596,6 +607,34 @@ export function createFinanceCommandService( } }, + async updateUtilityBill(billId, billName, amountArg, currencyArg) { + const settings = await householdConfigurationRepository.getHouseholdBillingSettings( + dependencies.householdId + ) + const currency = parseCurrency(currencyArg, settings.settlementCurrency) + const amount = Money.fromMajor(amountArg, currency) + const updated = await repository.updateUtilityBill({ + billId, + billName, + amountMinor: amount.amountMinor, + currency + }) + + if (!updated) { + return null + } + + return { + billId: updated.id, + amount, + currency + } + }, + + deleteUtilityBill(billId) { + return repository.deleteUtilityBill(billId) + }, + async generateStatement(periodArg) { const dashboard = await buildFinanceDashboard(dependencies, periodArg) if (!dashboard) { diff --git a/packages/ports/src/finance.ts b/packages/ports/src/finance.ts index 5512e60..2951d79 100644 --- a/packages/ports/src/finance.ts +++ b/packages/ports/src/finance.ts @@ -165,6 +165,13 @@ export interface FinanceRepository { currency: CurrencyCode createdByMemberId: string }): Promise + updateUtilityBill(input: { + billId: string + billName: string + amountMinor: bigint + currency: CurrencyCode + }): Promise + deleteUtilityBill(billId: string): Promise getRentRuleForPeriod(period: string): Promise getUtilityTotalForCycle(cycleId: string): Promise listUtilityBillsForCycle(cycleId: string): Promise