From 488a4881375b7f95a795051618a5401a049f070b Mon Sep 17 00:00:00 2001 From: whekin Date: Sat, 14 Mar 2026 08:51:53 +0400 Subject: [PATCH] feat: add quick payment action and improve copy button UX MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mini App Home Screen: - Add 'Record Payment' button to utilities and rent period cards - Pre-fill payment amount with member's share (rentShare/utilityShare) - Modal dialog with amount input and currency display - Toast notifications for copy and payment success/failure feedback Copy Button Improvements: - Increase spacing between icon and text (4px → 8px) - Add hover background and padding for better touch target - Green background highlight when copied (in addition to icon color change) - Toast notification appears when copying any value Backend: - Add /api/miniapp/payments/add endpoint for quick payments - Payment notifications sent to 'reminders' topic in Telegram - Include member name, payment type, amount, and period in notification Files: - New: apps/miniapp/src/components/ui/toast.tsx - Modified: apps/miniapp/src/routes/home.tsx, apps/miniapp/src/index.css, apps/miniapp/src/theme.css, apps/miniapp/src/i18n.ts, apps/bot/src/miniapp-billing.ts, apps/bot/src/server.ts Quality Gates: ✅ format, lint, typecheck, build, test Co-authored-by: Qwen-Coder --- .../plan-home-no-payment-and-qa-overrides.md | 60 ++ ...niapp-home-periods-and-rent-credentials.md | 195 ++++ .trae/documents/ui_tweaks_and_reactivity.md | 62 ++ apps/bot/src/anonymous-feedback.test.ts | 6 +- apps/bot/src/bot-i18n.test.ts | 3 +- apps/bot/src/dm-assistant.test.ts | 6 +- apps/bot/src/finance-commands.test.ts | 6 +- apps/bot/src/household-setup.test.ts | 6 +- apps/bot/src/index.ts | 10 + apps/bot/src/miniapp-admin.test.ts | 12 +- apps/bot/src/miniapp-admin.ts | 15 +- apps/bot/src/miniapp-auth.test.ts | 6 +- apps/bot/src/miniapp-billing.test.ts | 6 +- apps/bot/src/miniapp-billing.ts | 229 +++++ apps/bot/src/miniapp-dashboard.test.ts | 6 +- apps/bot/src/miniapp-dashboard.ts | 3 + apps/bot/src/miniapp-locale.test.ts | 6 +- apps/bot/src/payment-topic-ingestion.test.ts | 3 + apps/bot/src/purchase-topic-ingestion.test.ts | 3 +- apps/bot/src/server.ts | 23 + apps/miniapp/src/components/layout/shell.tsx | 50 +- apps/miniapp/src/components/ui/toast.tsx | 50 ++ .../src/contexts/dashboard-context.tsx | 10 + apps/miniapp/src/demo/miniapp-demo.ts | 15 +- apps/miniapp/src/i18n.ts | 84 ++ apps/miniapp/src/index.css | 92 ++ apps/miniapp/src/lib/dates.ts | 51 +- apps/miniapp/src/miniapp-api.ts | 44 + apps/miniapp/src/routes/home.tsx | 845 ++++++++++++++++-- apps/miniapp/src/routes/settings.tsx | 250 +++++- apps/miniapp/src/theme.css | 6 + .../src/household-config-repository.ts | 48 +- .../src/finance-command-service.test.ts | 3 +- .../src/finance-command-service.ts | 9 +- .../src/household-admin-service.test.ts | 6 +- .../src/household-onboarding-service.test.ts | 6 +- .../src/household-setup-service.test.ts | 6 +- .../src/locale-preference-service.test.ts | 6 +- .../src/miniapp-admin-service.test.ts | 12 +- .../application/src/miniapp-admin-service.ts | 55 +- .../src/payment-confirmation-service.test.ts | 9 +- packages/db/drizzle/0020_silver_payments.sql | 1 + packages/db/src/schema.ts | 1 + packages/ports/src/household-config.ts | 11 + packages/ports/src/index.ts | 1 + 45 files changed, 2236 insertions(+), 101 deletions(-) create mode 100644 .trae/documents/plan-home-no-payment-and-qa-overrides.md create mode 100644 .trae/documents/plan-miniapp-home-periods-and-rent-credentials.md create mode 100644 .trae/documents/ui_tweaks_and_reactivity.md create mode 100644 apps/miniapp/src/components/ui/toast.tsx create mode 100644 packages/db/drizzle/0020_silver_payments.sql diff --git a/.trae/documents/plan-home-no-payment-and-qa-overrides.md b/.trae/documents/plan-home-no-payment-and-qa-overrides.md new file mode 100644 index 0000000..30e6c74 --- /dev/null +++ b/.trae/documents/plan-home-no-payment-and-qa-overrides.md @@ -0,0 +1,60 @@ +# Plan: Fix Home “No Payment” + Add QA Period Overrides + +## Goals + +- Remove the “Due” chip from the **No payment period** card. +- In **No payment period**, don’t show rent/utilities balances; show only purchase-related balance and household info (FX if available). +- Fix “Upcoming” dates so they never show negative days (e.g., “-11d left”); if the reminder/warning already passed in the current period, show the next period’s start date instead. +- Add **period/date overrides** to the hidden QA “Testing view” so you can reliably test all Home variants. + +## Changes + +### 1) Home: remove “Due” chip from No-payment card + +- In [home.tsx](file:///Users/whekin/Projects/kojori-tg-bot/apps/miniapp/src/routes/home.tsx), stop rendering the `focusBadge()` inside the `mode() === 'none'` card. +- Keep the existing Due/Settled chip behavior for utilities/rent modes unchanged. + +### 2) Home: No-payment mode shows purchases-only balance + +- In [home.tsx](file:///Users/whekin/Projects/kojori-tg-bot/apps/miniapp/src/routes/home.tsx): + - When `homeMode() === 'none'`, hide the current “Your balance” card (which includes rent + utilities). + - Replace it with a purchases-focused card that shows: + - Member purchase offset (from `currentMemberLine().purchaseOffsetMajor`) as the primary amount. + - Household purchase totals (count + sum) computed from the existing dashboard ledger entries where `kind === 'purchase'`. + - Household member count (from dashboard member lines length). + - Keep household informational cards that are not “due/balance for rent/utilities” (e.g., the FX card if present/available). + +### 3) Home: Upcoming utilities/rent start date never goes negative + +- Update upcoming calculations in No-payment mode: + - If `daysUntilPeriodDay(period, reminderDay, timezone)` is `>= 0`, show as-is. + - If it is `< 0`, compute the next period (`BillingPeriod.fromString(period).next().toString()`) and compute: + - `formatPeriodDay(nextPeriod, reminderDay, locale)` + - `daysUntilPeriodDay(nextPeriod, reminderDay, timezone)` + - Apply the same logic for rent warning day and utilities reminder day. +- This ensures “Utilities starts …” always points to a future date and shows a non-negative countdown. + +### 4) QA Testing View: add period/date overrides + +- Extend the existing hidden “Testing view” (opened by 5 taps on the role badge) in: + - [shell.tsx](file:///Users/whekin/Projects/kojori-tg-bot/apps/miniapp/src/components/layout/shell.tsx) + - [dashboard-context.tsx](file:///Users/whekin/Projects/kojori-tg-bot/apps/miniapp/src/contexts/dashboard-context.tsx) +- Add two optional overrides stored in `DashboardContext`: + - `testingPeriodOverride?: string | null` (format `YYYY-MM`) + - `testingTodayOverride?: string | null` (format `YYYY-MM-DD`) +- Home uses `effectivePeriod = testingPeriodOverride ?? dashboard.period`. +- Date helpers used by Home (`daysUntilPeriodDay`, `compareTodayToPeriodDay`) accept an optional “today override” so Home can behave as if it’s a different day without changing system time. + +### 5) Copy updates + +- Add/adjust i18n strings needed for the purchases-only card and QA fields in: + - [i18n.ts](file:///Users/whekin/Projects/kojori-tg-bot/apps/miniapp/src/i18n.ts) + +## Verification + +- Run: `bun run format:check`, `bun run lint`, `bun run typecheck`, `bun run test`, `bun run build` +- Manual checks in miniapp: + - Set QA overrides to land inside utilities window / rent window / no-payment window and confirm Home variant changes. + - Confirm no-payment “Upcoming” countdown never shows negative values. + - Confirm no-payment mode hides rent/utilities balance and no longer shows “Due” chip on that card. + - Confirm no-payment mode shows purchase offset + household purchase stats + member count. diff --git a/.trae/documents/plan-miniapp-home-periods-and-rent-credentials.md b/.trae/documents/plan-miniapp-home-periods-and-rent-credentials.md new file mode 100644 index 0000000..eaeecc2 --- /dev/null +++ b/.trae/documents/plan-miniapp-home-periods-and-rent-credentials.md @@ -0,0 +1,195 @@ +# Plan: Miniapp Home “Current Period” + Rent Credentials + +## Summary + +Implement a “current payment period” focused Home screen with three modes: + +- **Utilities period** (between utilities reminder day and utilities due day, inclusive) +- **Rent period** (between rent warning day and rent due day, inclusive) +- **No payment period** (everything else) + +Add **rent payment credentials** (one-or-more destinations) to the backend + database, editable by admins and visible to all members. Also add a **resident-accessible utility bill submission** flow surfaced on Home during the utilities window when no utility bills are recorded yet. + +## Current State Analysis (repo-grounded) + +### Miniapp UI and data flow + +- Home route today is a single view rendering “Your balance”, optional rent FX, and latest activity, driven by `MiniAppDashboard` from `DashboardContext`. + - [home.tsx](file:///Users/whekin/Projects/kojori-tg-bot/apps/miniapp/src/routes/home.tsx#L1-L178) + - [dashboard-context.tsx](file:///Users/whekin/Projects/kojori-tg-bot/apps/miniapp/src/contexts/dashboard-context.tsx#L240-L366) +- `MiniAppDashboard` already carries `period`, `timezone`, `rentDueDay`, `utilitiesDueDay`, and `paymentBalanceAdjustmentPolicy`, but **does not include** `rentWarningDay` / `utilitiesReminderDay` or any payment destinations. + - [miniapp-api.ts](file:///Users/whekin/Projects/kojori-tg-bot/apps/miniapp/src/miniapp-api.ts#L95-L143) +- Date helpers exist to compare “today in timezone” against a day inside a given `period` and to compute days remaining. + - [dates.ts](file:///Users/whekin/Projects/kojori-tg-bot/apps/miniapp/src/lib/dates.ts#L105-L172) + +### Backend / domain + +- Miniapp dashboard API maps `FinanceCommandService.generateDashboard()` into `MiniAppDashboard`. + - [miniapp-dashboard.ts](file:///Users/whekin/Projects/kojori-tg-bot/apps/bot/src/miniapp-dashboard.ts#L12-L150) +- `FinanceDashboard` is built from billing settings + cycle state; it uses `rentWarningDay` and `utilitiesReminderDay` internally for FX lock dates, but it currently only exposes `rentDueDay` and `utilitiesDueDay` to the miniapp. + - [finance-command-service.ts](file:///Users/whekin/Projects/kojori-tg-bot/packages/application/src/finance-command-service.ts#L287-L599) +- Billing settings are persisted in Postgres via Drizzle table `household_billing_settings` (already includes rent/utilities due and reminder/warning days + timezone). + - [schema.ts](file:///Users/whekin/Projects/kojori-tg-bot/packages/db/src/schema.ts#L24-L50) + - Repository accessors: [household-config-repository.ts](file:///Users/whekin/Projects/kojori-tg-bot/packages/adapters-db/src/household-config-repository.ts#L944-L1066) +- Utility bills are currently **admin-only** in the miniapp (`/api/miniapp/admin/utility-bills/add`) and the UI hides add/edit behind `effectiveIsAdmin()`. + - UI: [ledger.tsx](file:///Users/whekin/Projects/kojori-tg-bot/apps/miniapp/src/routes/ledger.tsx#L617-L652) + - API handler: [miniapp-billing.ts](file:///Users/whekin/Projects/kojori-tg-bot/apps/bot/src/miniapp-billing.ts#L721-L790) + +## Proposed Changes (decision-complete) + +### 1) Add rent payment destinations to DB + ports + +**Decision:** store rent credentials as a JSON array on `household_billing_settings` to support multiple destinations without introducing a new table (pre-1.0 simplicity + cohesion with billing config). + +- Add a new `jsonb` column on `household_billing_settings`: + - `rent_payment_destinations` (nullable, default `null`) +- Add a strongly-typed record shape in ports: + - `HouseholdRentPaymentDestination`: + - `label: string` (e.g., “TBC card”, “Bank transfer”) + - `recipientName: string | null` + - `bankName: string | null` + - `account: string` (account number / card number / IBAN; stored as plain text) + - `note: string | null` + - `link: string | null` (optional URL/deeplink) + - Add `rentPaymentDestinations?: readonly HouseholdRentPaymentDestination[] | null` to `HouseholdBillingSettingsRecord` and the `updateHouseholdBillingSettings` input. + +Files: + +- DB schema + migration: + - [schema.ts](file:///Users/whekin/Projects/kojori-tg-bot/packages/db/src/schema.ts) + - `packages/db/drizzle/00xx_*.sql` (new migration) +- Ports: + - [household-config.ts](file:///Users/whekin/Projects/kojori-tg-bot/packages/ports/src/household-config.ts) +- DB adapter mapping: + - [household-config-repository.ts](file:///Users/whekin/Projects/kojori-tg-bot/packages/adapters-db/src/household-config-repository.ts#L944-L1066) + +### 2) Expose needed fields to the miniapp dashboard contract + +**Goal:** let the miniapp compute “current period” locally and render period-specific UI consistently. + +- Extend `FinanceDashboard` to include: + - `rentWarningDay` + - `utilitiesReminderDay` + - `rentPaymentDestinations` +- Return these from `buildFinanceDashboard(...)` using the persisted billing settings. +- Extend bot miniapp dashboard handler serialization: + - Include those fields in the `dashboard` JSON payload. +- Extend miniapp client types: + - `MiniAppDashboard` adds `rentWarningDay`, `utilitiesReminderDay`, and `rentPaymentDestinations`. +- Update demo fixtures so the miniapp still renders in demo mode. + +Files: + +- Application: + - [finance-command-service.ts](file:///Users/whekin/Projects/kojori-tg-bot/packages/application/src/finance-command-service.ts) +- Bot: + - [miniapp-dashboard.ts](file:///Users/whekin/Projects/kojori-tg-bot/apps/bot/src/miniapp-dashboard.ts) +- Miniapp API types + demo: + - [miniapp-api.ts](file:///Users/whekin/Projects/kojori-tg-bot/apps/miniapp/src/miniapp-api.ts) + - [miniapp-demo.ts](file:///Users/whekin/Projects/kojori-tg-bot/apps/miniapp/src/demo/miniapp-demo.ts) + +### 3) Add admin editing UI for rent payment destinations + +**Decision:** rent credentials are visible to everyone, but **only admins can edit** (implemented in Settings screen next to other billing settings). + +- Extend the Settings “Billing settings” modal form state to include a list editor: + - Add destination + - Remove destination + - Edit fields (label, recipient, bank, account, note, link) +- Extend `updateMiniAppBillingSettings(...)` request/response types to carry the new field. +- Extend backend handler that parses settings update payload and calls `updateHouseholdBillingSettings`. + +Files: + +- Miniapp: + - [settings.tsx](file:///Users/whekin/Projects/kojori-tg-bot/apps/miniapp/src/routes/settings.tsx) + - [miniapp-api.ts](file:///Users/whekin/Projects/kojori-tg-bot/apps/miniapp/src/miniapp-api.ts) (`MiniAppBillingSettings` + `updateMiniAppBillingSettings`) + - [i18n.ts](file:///Users/whekin/Projects/kojori-tg-bot/apps/miniapp/src/i18n.ts) (new strings) +- Bot: + - [miniapp-admin.ts](file:///Users/whekin/Projects/kojori-tg-bot/apps/bot/src/miniapp-admin.ts) (payload parsing) +- Application: + - [miniapp-admin-service.ts](file:///Users/whekin/Projects/kojori-tg-bot/packages/application/src/miniapp-admin-service.ts) (include field into repository update input) + +### 4) Implement “3 versions of Home” (utilities / rent / no payment) + +**Decision:** Home determines an active mode as “Reminder→Due” (inclusive). It uses: + +- `dashboard.period` +- `dashboard.timezone` +- `dashboard.utilitiesReminderDay` / `dashboard.utilitiesDueDay` +- `dashboard.rentWarningDay` / `dashboard.rentDueDay` + +#### 4.1 Utilities mode + +- Show a primary “Utilities” card: + - Amount to pay = utilities base share + purchase offset if policy is `utilities` + - Show due date and days left using existing copy keys (`dueOnLabel`, `daysLeftLabel`, etc.) +- If **no utility bills recorded yet** (`utilityLedger().length === 0`): + - Show an inline “Fill utilities” call-to-action: + - A simple add-utility-bill form embedded on Home (visible to all members). + - After successful submission + refresh, the CTA disappears and the normal utilities card renders. + - Optional: provide a link to the Ledger screen as fallback (if the user prefers to do it there). + +#### 4.2 Rent mode + +- Show a primary “Rent” card: + - Amount to pay = rent base share + purchase offset if policy is `rent` + - Show due date and days left/overdue visuals. +- Show one-or-more “Payment destination” cards listing: + - Label, recipient, bank, account, note/link + +#### 4.3 No payment mode + +- Show an “Upcoming” card: + - Days until utilities reminder day + - Days until rent warning day +- Continue to show “Your balance” and latest activity as secondary content (so the screen stays useful). + +Files: + +- [home.tsx](file:///Users/whekin/Projects/kojori-tg-bot/apps/miniapp/src/routes/home.tsx) +- Potentially add a tiny helper in `apps/miniapp/src/lib/` for `computeHomePeriodMode(...)` if Home gets too large. +- [i18n.ts](file:///Users/whekin/Projects/kojori-tg-bot/apps/miniapp/src/i18n.ts) (strings for new cards/actions) + +### 5) Allow non-admin utility bill submission (for Home CTA) + +**Decision:** add a new miniapp endpoint that allows any authorized member to add a utility bill, used by the Home CTA. Admin endpoints remain unchanged for editing/deleting. + +- Add a new bot handler: + - `POST /api/miniapp/utility-bills/add` (name can be finalized during implementation) + - Auth: authorized member session required + - Action: call `FinanceCommandService.addUtilityBill(billName, amountMajor, memberId, currency)` + - Response: `{ ok, authorized, cycleState }` or `{ ok, error }` +- Wire it into the bot server router. +- Add a miniapp client function to call it (parallel to `addMiniAppUtilityBill`, but non-admin path). +- Home CTA uses this endpoint, then triggers `refreshHouseholdData(true, true)` so the dashboard updates. + +Files: + +- Bot: + - [miniapp-billing.ts](file:///Users/whekin/Projects/kojori-tg-bot/apps/bot/src/miniapp-billing.ts) (new handler) + - [server.ts](file:///Users/whekin/Projects/kojori-tg-bot/apps/bot/src/server.ts) (new route option + dispatch) + - [index.ts](file:///Users/whekin/Projects/kojori-tg-bot/apps/bot/src/index.ts) (compose and pass the new handler into `createBotWebhookServer`) +- Miniapp: + - [miniapp-api.ts](file:///Users/whekin/Projects/kojori-tg-bot/apps/miniapp/src/miniapp-api.ts) (new function) + - [home.tsx](file:///Users/whekin/Projects/kojori-tg-bot/apps/miniapp/src/routes/home.tsx) (use it) + +## Assumptions & Decisions + +- Period selection is **Reminder→Due inclusive** (utilities: `utilitiesReminderDay..utilitiesDueDay`, rent: `rentWarningDay..rentDueDay`). +- Rent payment credentials are **structured** and stored as **plain text** fields (no secrets); they are visible to all household members and editable by admins only. +- Utilities “fill” flow is initially “rent-only credentials now”; utilities destinations are out of scope. +- Utility bill submission from Home is allowed for any authorized member; edit/delete remains admin-only. + +## Verification Steps + +- Typecheck, lint, test, build (repo quality gates): + - `bun run format:check` + - `bun run lint` + - `bun run typecheck` + - `bun run test` + - `bun run build` +- Manual miniapp checks: + - Home renders correctly in all 3 modes by adjusting due/reminder days in Settings. + - Utilities window + no bills: Home CTA allows submission and then switches to normal utilities view after refresh. + - Rent window: rent credentials render correctly; multiple destinations show; admin edits persist and reload. diff --git a/.trae/documents/ui_tweaks_and_reactivity.md b/.trae/documents/ui_tweaks_and_reactivity.md new file mode 100644 index 0000000..cf81fbb --- /dev/null +++ b/.trae/documents/ui_tweaks_and_reactivity.md @@ -0,0 +1,62 @@ +# Plan - UI Tweaks and Reactive Updates + +This plan outlines the changes needed to ensure data reactivity after updates, improve chart visibility with better colors, and enhance the "Latest activity" section with a "show more" functionality. + +## 1. Reactive Data Updates + +### Analysis + +Currently, when a purchase or payment is added in [ledger.tsx](file:///Users/whekin/Projects/kojori-tg-bot/apps/miniapp/src/routes/ledger.tsx), `refreshHouseholdData(true, true)` is called. This function invalidates the TanStack Query cache in [session-context.tsx](file:///Users/whekin/Projects/kojori-tg-bot/apps/miniapp/src/contexts/session-context.tsx), but [DashboardProvider](file:///Users/whekin/Projects/kojori-tg-bot/apps/miniapp/src/contexts/dashboard-context.tsx) stores data in local signals (`setDashboard`) and does not automatically refetch when the cache is invalidated. + +### Proposed Changes + +- **session-context.tsx**: + - Add a way to register "data listeners" or simply a list of refresh callbacks. + - Update `refreshHouseholdData` to execute these callbacks. +- **dashboard-context.tsx**: + - In `DashboardProvider`, register `loadDashboardData` as a listener in the session context on mount. +- **App.tsx**: + - Ensure `DashboardProvider` is correctly integrated with the session's refresh mechanism. + +## 2. Chart Colors Improvement + +### Analysis + +Current chart colors in [dashboard-context.tsx](file:///Users/whekin/Projects/kojori-tg-bot/apps/miniapp/src/contexts/dashboard-context.tsx) and [theme.css](file:///Users/whekin/Projects/kojori-tg-bot/apps/miniapp/src/theme.css) are somewhat similar, making them hard to distinguish. + +### Proposed Changes + +- **dashboard-context.tsx**: + - Update `chartPalette` with more distinct, high-contrast colors. + - Proposed palette: `#3ecf8e` (Emerald), `#3b82f6` (Blue), `#ef4444` (Red), `#f59e0b` (Amber), `#8b5cf6` (Violet), `#ec4899` (Pink). +- **theme.css**: + - Update `--chart-1` through `--chart-6` variables to match the new palette for consistency across the app. + +## 3. "Show More" for Latest Activity + +### Analysis + +The "Latest activity" section in [home.tsx](file:///Users/whekin/Projects/kojori-tg-bot/apps/miniapp/src/routes/home.tsx) currently only shows the first 5 entries of the ledger. + +### Proposed Changes + +- **home.tsx**: + - Add a local signal `showAllActivity` (default `false`). + - Update the `For` loop to show either `slice(0, 5)` or the full `ledger` based on the signal. + - Add a "Show more" button that appears if `ledger.length > 5`. + - Style the button to match the app's UI. +- **i18n.ts**: + - Add translations for "Show more" and "Show less" (or "Collapse"). + +## Verification Plan + +### Automated Tests + +- Since this is mostly UI/UX, manual verification in the browser is preferred. +- Check if `invalidateQueries` is called after adding a purchase (can be checked via network tab). + +### Manual Verification + +1. **Reactivity**: Add a purchase and verify that the dashboard balances and "Latest activity" update immediately without manual page refresh. +2. **Chart Colors**: Navigate to the balances page and verify that chart slices are easily distinguishable. +3. **Show More**: On the home page, ensure "Show more" appears when there are > 5 activities and correctly expands the list. diff --git a/apps/bot/src/anonymous-feedback.test.ts b/apps/bot/src/anonymous-feedback.test.ts index 958734b..cd5f0c5 100644 --- a/apps/bot/src/anonymous-feedback.test.ts +++ b/apps/bot/src/anonymous-feedback.test.ts @@ -231,7 +231,8 @@ function createHouseholdConfigurationRepository(): HouseholdConfigurationReposit rentWarningDay: 17, utilitiesDueDay: 4, utilitiesReminderDay: 3, - timezone: 'Asia/Tbilisi' + timezone: 'Asia/Tbilisi', + rentPaymentDestinations: null }), updateHouseholdBillingSettings: async (input) => ({ householdId: input.householdId, @@ -242,7 +243,8 @@ function createHouseholdConfigurationRepository(): HouseholdConfigurationReposit rentWarningDay: input.rentWarningDay ?? 17, utilitiesDueDay: input.utilitiesDueDay ?? 4, utilitiesReminderDay: input.utilitiesReminderDay ?? 3, - timezone: input.timezone ?? 'Asia/Tbilisi' + timezone: input.timezone ?? 'Asia/Tbilisi', + rentPaymentDestinations: input.rentPaymentDestinations ?? null }), listHouseholdUtilityCategories: async () => [], upsertHouseholdUtilityCategory: async (input) => ({ diff --git a/apps/bot/src/bot-i18n.test.ts b/apps/bot/src/bot-i18n.test.ts index a1a1c71..6275fa7 100644 --- a/apps/bot/src/bot-i18n.test.ts +++ b/apps/bot/src/bot-i18n.test.ts @@ -100,7 +100,8 @@ function createRepository(isAdmin = false): HouseholdConfigurationRepository { rentWarningDay: 17, utilitiesDueDay: 4, utilitiesReminderDay: 3, - timezone: 'Asia/Tbilisi' + timezone: 'Asia/Tbilisi', + rentPaymentDestinations: null }), updateHouseholdBillingSettings: async () => { throw new Error('not implemented') diff --git a/apps/bot/src/dm-assistant.test.ts b/apps/bot/src/dm-assistant.test.ts index b3bfb4a..e4ecf23 100644 --- a/apps/bot/src/dm-assistant.test.ts +++ b/apps/bot/src/dm-assistant.test.ts @@ -237,7 +237,8 @@ function createHouseholdRepository(): HouseholdConfigurationRepository { rentWarningDay: 17, utilitiesDueDay: 4, utilitiesReminderDay: 3, - timezone: 'Asia/Tbilisi' + timezone: 'Asia/Tbilisi', + rentPaymentDestinations: null }), updateHouseholdBillingSettings: async () => { throw new Error('not used') @@ -342,9 +343,12 @@ function createFinanceService(): FinanceCommandService { period: '2026-03', currency: 'GEL', timezone: 'Asia/Tbilisi', + rentWarningDay: 17, rentDueDay: 20, + utilitiesReminderDay: 3, utilitiesDueDay: 4, paymentBalanceAdjustmentPolicy: 'utilities', + rentPaymentDestinations: null, totalDue: Money.fromMajor('1000.00', 'GEL'), totalPaid: Money.fromMajor('500.00', 'GEL'), totalRemaining: Money.fromMajor('500.00', 'GEL'), diff --git a/apps/bot/src/finance-commands.test.ts b/apps/bot/src/finance-commands.test.ts index 01f1991..8f3e38e 100644 --- a/apps/bot/src/finance-commands.test.ts +++ b/apps/bot/src/finance-commands.test.ts @@ -94,7 +94,8 @@ function createRepository(): HouseholdConfigurationRepository { rentWarningDay: 17, utilitiesDueDay: 4, utilitiesReminderDay: 3, - timezone: 'Asia/Tbilisi' + timezone: 'Asia/Tbilisi', + rentPaymentDestinations: null }), updateHouseholdBillingSettings: async () => { throw new Error('not implemented') @@ -126,9 +127,12 @@ function createDashboard(): NonNullable< period: '2026-03', currency: 'GEL', timezone: 'Asia/Tbilisi', + rentWarningDay: 17, rentDueDay: 20, + utilitiesReminderDay: 3, utilitiesDueDay: 4, paymentBalanceAdjustmentPolicy: 'utilities', + rentPaymentDestinations: null, totalDue: Money.fromMajor('400', 'GEL'), totalPaid: Money.fromMajor('100', 'GEL'), totalRemaining: Money.fromMajor('300', 'GEL'), diff --git a/apps/bot/src/household-setup.test.ts b/apps/bot/src/household-setup.test.ts index ea377c8..287218e 100644 --- a/apps/bot/src/household-setup.test.ts +++ b/apps/bot/src/household-setup.test.ts @@ -463,7 +463,8 @@ function createHouseholdConfigurationRepository(): HouseholdConfigurationReposit rentWarningDay: 17, utilitiesDueDay: 4, utilitiesReminderDay: 3, - timezone: 'Asia/Tbilisi' + timezone: 'Asia/Tbilisi', + rentPaymentDestinations: null } }, async updateHouseholdBillingSettings(input) { @@ -476,7 +477,8 @@ function createHouseholdConfigurationRepository(): HouseholdConfigurationReposit rentWarningDay: input.rentWarningDay ?? 17, utilitiesDueDay: input.utilitiesDueDay ?? 4, utilitiesReminderDay: input.utilitiesReminderDay ?? 3, - timezone: input.timezone ?? 'Asia/Tbilisi' + timezone: input.timezone ?? 'Asia/Tbilisi', + rentPaymentDestinations: input.rentPaymentDestinations ?? null } }, async listHouseholdUtilityCategories() { diff --git a/apps/bot/src/index.ts b/apps/bot/src/index.ts index 2b78446..f9a13fe 100644 --- a/apps/bot/src/index.ts +++ b/apps/bot/src/index.ts @@ -73,6 +73,7 @@ import { createMiniAppDeleteUtilityBillHandler, createMiniAppOpenCycleHandler, createMiniAppRentUpdateHandler, + createMiniAppSubmitUtilityBillHandler, createMiniAppUpdatePaymentHandler, createMiniAppUpdatePurchaseHandler, createMiniAppUpdateUtilityBillHandler @@ -714,6 +715,15 @@ const server = createBotWebhookServer({ logger: getLogger('miniapp-billing') }) : undefined, + miniAppSubmitUtilityBill: householdOnboardingService + ? createMiniAppSubmitUtilityBillHandler({ + allowedOrigins: runtime.miniAppAllowedOrigins, + botToken: runtime.telegramBotToken, + onboardingService: householdOnboardingService, + financeServiceForHousehold, + logger: getLogger('miniapp-billing') + }) + : undefined, miniAppUpdateUtilityBill: householdOnboardingService ? createMiniAppUpdateUtilityBillHandler({ allowedOrigins: runtime.miniAppAllowedOrigins, diff --git a/apps/bot/src/miniapp-admin.test.ts b/apps/bot/src/miniapp-admin.test.ts index b52431b..a874068 100644 --- a/apps/bot/src/miniapp-admin.test.ts +++ b/apps/bot/src/miniapp-admin.test.ts @@ -174,7 +174,8 @@ function onboardingRepository(): HouseholdConfigurationRepository { rentWarningDay: 17, utilitiesDueDay: 4, utilitiesReminderDay: 3, - timezone: 'Asia/Tbilisi' + timezone: 'Asia/Tbilisi', + rentPaymentDestinations: null }), updateHouseholdBillingSettings: async (input) => ({ householdId: input.householdId, @@ -185,7 +186,8 @@ function onboardingRepository(): HouseholdConfigurationRepository { rentWarningDay: input.rentWarningDay ?? 17, utilitiesDueDay: input.utilitiesDueDay ?? 4, utilitiesReminderDay: input.utilitiesReminderDay ?? 3, - timezone: input.timezone ?? 'Asia/Tbilisi' + timezone: input.timezone ?? 'Asia/Tbilisi', + rentPaymentDestinations: input.rentPaymentDestinations ?? null }), getHouseholdAssistantConfig: async (householdId) => ({ householdId, @@ -536,7 +538,8 @@ describe('createMiniAppSettingsHandler', () => { utilitiesDueDay: 4, utilitiesReminderDay: 3, timezone: 'Asia/Tbilisi', - paymentBalanceAdjustmentPolicy: 'utilities' + paymentBalanceAdjustmentPolicy: 'utilities', + rentPaymentDestinations: null }, assistantConfig: { householdId: 'household-1', @@ -638,7 +641,8 @@ describe('createMiniAppUpdateSettingsHandler', () => { utilitiesDueDay: 6, utilitiesReminderDay: 5, timezone: 'Asia/Tbilisi', - paymentBalanceAdjustmentPolicy: 'utilities' + paymentBalanceAdjustmentPolicy: 'utilities', + rentPaymentDestinations: null }, assistantConfig: { householdId: 'household-1', diff --git a/apps/bot/src/miniapp-admin.ts b/apps/bot/src/miniapp-admin.ts index de6df3a..ee2ffca 100644 --- a/apps/bot/src/miniapp-admin.ts +++ b/apps/bot/src/miniapp-admin.ts @@ -59,6 +59,7 @@ async function readSettingsUpdatePayload(request: Request): Promise<{ utilitiesDueDay: number utilitiesReminderDay: number timezone: string + rentPaymentDestinations?: unknown assistantContext?: string assistantTone?: string }> { @@ -80,6 +81,7 @@ async function readSettingsUpdatePayload(request: Request): Promise<{ utilitiesDueDay?: number utilitiesReminderDay?: number timezone?: string + rentPaymentDestinations?: unknown assistantContext?: string assistantTone?: string } @@ -136,6 +138,11 @@ async function readSettingsUpdatePayload(request: Request): Promise<{ assistantTone: parsed.assistantTone } : {}), + ...(parsed.rentPaymentDestinations !== undefined + ? { + rentPaymentDestinations: parsed.rentPaymentDestinations + } + : {}), rentDueDay: parsed.rentDueDay, rentWarningDay: parsed.rentWarningDay, utilitiesDueDay: parsed.utilitiesDueDay, @@ -369,7 +376,8 @@ function serializeBillingSettings(settings: HouseholdBillingSettingsRecord) { rentWarningDay: settings.rentWarningDay, utilitiesDueDay: settings.utilitiesDueDay, utilitiesReminderDay: settings.utilitiesReminderDay, - timezone: settings.timezone + timezone: settings.timezone, + rentPaymentDestinations: settings.rentPaymentDestinations ?? null } } @@ -658,6 +666,11 @@ export function createMiniAppUpdateSettingsHandler(options: { utilitiesDueDay: payload.utilitiesDueDay, utilitiesReminderDay: payload.utilitiesReminderDay, timezone: payload.timezone, + ...(payload.rentPaymentDestinations !== undefined + ? { + rentPaymentDestinations: payload.rentPaymentDestinations + } + : {}), ...(payload.assistantContext !== undefined ? { assistantContext: payload.assistantContext diff --git a/apps/bot/src/miniapp-auth.test.ts b/apps/bot/src/miniapp-auth.test.ts index 3d87018..bd6333e 100644 --- a/apps/bot/src/miniapp-auth.test.ts +++ b/apps/bot/src/miniapp-auth.test.ts @@ -167,7 +167,8 @@ function onboardingRepository(): HouseholdConfigurationRepository { rentWarningDay: 17, utilitiesDueDay: 4, utilitiesReminderDay: 3, - timezone: 'Asia/Tbilisi' + timezone: 'Asia/Tbilisi', + rentPaymentDestinations: null }), updateHouseholdBillingSettings: async (input) => ({ householdId: input.householdId, @@ -178,7 +179,8 @@ function onboardingRepository(): HouseholdConfigurationRepository { rentWarningDay: input.rentWarningDay ?? 17, utilitiesDueDay: input.utilitiesDueDay ?? 4, utilitiesReminderDay: input.utilitiesReminderDay ?? 3, - timezone: input.timezone ?? 'Asia/Tbilisi' + timezone: input.timezone ?? 'Asia/Tbilisi', + rentPaymentDestinations: input.rentPaymentDestinations ?? null }), listHouseholdUtilityCategories: async () => [], upsertHouseholdUtilityCategory: async (input) => ({ diff --git a/apps/bot/src/miniapp-billing.test.ts b/apps/bot/src/miniapp-billing.test.ts index cae65dc..d09b3ea 100644 --- a/apps/bot/src/miniapp-billing.test.ts +++ b/apps/bot/src/miniapp-billing.test.ts @@ -90,7 +90,8 @@ function onboardingRepository(): HouseholdConfigurationRepository { rentWarningDay: 17, utilitiesDueDay: 4, utilitiesReminderDay: 3, - timezone: 'Asia/Tbilisi' + timezone: 'Asia/Tbilisi', + rentPaymentDestinations: null }), updateHouseholdBillingSettings: async (input) => ({ householdId: input.householdId, @@ -101,7 +102,8 @@ function onboardingRepository(): HouseholdConfigurationRepository { rentWarningDay: input.rentWarningDay ?? 17, utilitiesDueDay: input.utilitiesDueDay ?? 4, utilitiesReminderDay: input.utilitiesReminderDay ?? 3, - timezone: input.timezone ?? 'Asia/Tbilisi' + timezone: input.timezone ?? 'Asia/Tbilisi', + rentPaymentDestinations: input.rentPaymentDestinations ?? null }), listHouseholdUtilityCategories: async () => [], upsertHouseholdUtilityCategory: async (input) => ({ diff --git a/apps/bot/src/miniapp-billing.ts b/apps/bot/src/miniapp-billing.ts index 7f57842..a6ba350 100644 --- a/apps/bot/src/miniapp-billing.ts +++ b/apps/bot/src/miniapp-billing.ts @@ -1,6 +1,7 @@ import type { FinanceCommandService, HouseholdOnboardingService } from '@household/application' import { BillingPeriod } from '@household/domain' import type { Logger } from '@household/observability' +import type { HouseholdConfigurationRepository } from '@household/ports' import type { MiniAppSessionResult } from './miniapp-auth' import { @@ -70,6 +71,39 @@ async function authenticateAdminSession( } } +async function authenticateMemberSession( + request: Request, + sessionService: ReturnType, + origin: string | undefined +): Promise< + | Response + | { + member: NonNullable + } +> { + const payload = await readMiniAppRequestPayload(request) + if (!payload.initData) { + return miniAppJsonResponse({ ok: false, error: 'Missing initData' }, 400, origin) + } + + const session = await sessionService.authenticate(payload) + if (!session) { + return miniAppJsonResponse({ ok: false, error: 'Invalid Telegram init data' }, 401, origin) + } + + if (!session.authorized || !session.member || session.member.status !== 'active') { + return miniAppJsonResponse( + { ok: false, error: 'Access limited to active household members' }, + 403, + origin + ) + } + + return { + member: session.member + } +} + async function parseJsonBody(request: Request): Promise { const text = await request.clone().text() if (text.trim().length === 0) { @@ -789,6 +823,201 @@ export function createMiniAppAddUtilityBillHandler(options: { } } +export function createMiniAppSubmitUtilityBillHandler(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 authenticateMemberSession( + request.clone() as Request, + sessionService, + origin + ) + if (auth instanceof Response) { + return auth + } + + const payload = await readUtilityBillPayload(request) + const service = options.financeServiceForHousehold(auth.member.householdId) + const result = await service.addUtilityBill( + payload.billName, + payload.amountMajor, + auth.member.id, + payload.currency + ) + + if (!result) { + return miniAppJsonResponse( + { ok: false, error: 'No billing cycle available' }, + 404, + origin + ) + } + + return miniAppJsonResponse( + { + ok: true, + authorized: true + }, + 200, + origin + ) + } catch (error) { + return miniAppErrorResponse(error, origin, options.logger) + } + } + } +} + +export function createMiniAppSubmitPaymentHandler(options: { + allowedOrigins: readonly string[] + botToken: string + financeServiceForHousehold: (householdId: string) => FinanceCommandService + onboardingService: HouseholdOnboardingService + householdConfigurationRepository: HouseholdConfigurationRepository + logger?: Logger +}): { + handler: (request: Request) => Promise +} { + const sessionService = createMiniAppSessionService({ + botToken: options.botToken, + onboardingService: options.onboardingService + }) + + async function notifyPaymentRecorded(input: { + householdId: string + memberName: string + kind: 'rent' | 'utilities' + amountMajor: string + currency: string + period: string + }) { + const [chat, topic] = await Promise.all([ + options.householdConfigurationRepository.getHouseholdChatByHouseholdId(input.householdId), + options.householdConfigurationRepository.getHouseholdTopicBinding( + input.householdId, + 'reminders' + ) + ]) + + if (!chat || !topic) { + return + } + + const threadId = Number.parseInt(topic.telegramThreadId, 10) + if (!Number.isFinite(threadId)) { + return + } + + const response = await fetch(`https://api.telegram.org/bot${options.botToken}/sendMessage`, { + method: 'POST', + headers: { + 'content-type': 'application/json' + }, + body: JSON.stringify({ + chat_id: chat.telegramChatId, + message_thread_id: threadId, + text: `${input.memberName} recorded a ${input.kind} payment: ${input.amountMajor} ${input.currency} (${input.period})` + }) + }) + + if (!response.ok && options.logger) { + options.logger.warn( + { + event: 'miniapp.payment_notification_failed', + householdId: input.householdId, + status: response.status + }, + 'Failed to notify payment topic' + ) + } + } + + 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 authenticateMemberSession( + request.clone() as Request, + sessionService, + origin + ) + if (auth instanceof Response) { + return auth + } + + const payload = await readPaymentMutationPayload(request) + if (!payload.kind || !payload.amountMajor) { + return miniAppJsonResponse({ ok: false, error: 'Missing payment fields' }, 400, origin) + } + + const service = options.financeServiceForHousehold(auth.member.householdId) + const payment = await service.addPayment( + auth.member.id, + payload.kind, + payload.amountMajor, + payload.currency + ) + + if (!payment) { + return miniAppJsonResponse({ ok: false, error: 'Failed to record payment' }, 500, origin) + } + + await notifyPaymentRecorded({ + householdId: auth.member.householdId, + memberName: auth.member.displayName, + kind: payload.kind, + amountMajor: payment.amount.toMajorString(), + currency: payment.currency, + period: payment.period + }) + + return miniAppJsonResponse( + { + ok: true, + authorized: true + }, + 200, + origin + ) + } catch (error) { + return miniAppErrorResponse(error, origin, options.logger) + } + } + } +} + export function createMiniAppUpdateUtilityBillHandler(options: { allowedOrigins: readonly string[] botToken: string diff --git a/apps/bot/src/miniapp-dashboard.test.ts b/apps/bot/src/miniapp-dashboard.test.ts index f619e1c..f4866c7 100644 --- a/apps/bot/src/miniapp-dashboard.test.ts +++ b/apps/bot/src/miniapp-dashboard.test.ts @@ -232,7 +232,8 @@ function onboardingRepository(): HouseholdConfigurationRepository { rentWarningDay: 17, utilitiesDueDay: 4, utilitiesReminderDay: 3, - timezone: 'Asia/Tbilisi' + timezone: 'Asia/Tbilisi', + rentPaymentDestinations: null }), updateHouseholdBillingSettings: async (input) => ({ householdId: input.householdId, @@ -243,7 +244,8 @@ function onboardingRepository(): HouseholdConfigurationRepository { rentWarningDay: input.rentWarningDay ?? 17, utilitiesDueDay: input.utilitiesDueDay ?? 4, utilitiesReminderDay: input.utilitiesReminderDay ?? 3, - timezone: input.timezone ?? 'Asia/Tbilisi' + timezone: input.timezone ?? 'Asia/Tbilisi', + rentPaymentDestinations: input.rentPaymentDestinations ?? null }), listHouseholdUtilityCategories: async () => [], upsertHouseholdUtilityCategory: async (input) => ({ diff --git a/apps/bot/src/miniapp-dashboard.ts b/apps/bot/src/miniapp-dashboard.ts index 5d6eab7..7716a8a 100644 --- a/apps/bot/src/miniapp-dashboard.ts +++ b/apps/bot/src/miniapp-dashboard.ts @@ -89,9 +89,12 @@ export function createMiniAppDashboardHandler(options: { period: dashboard.period, currency: dashboard.currency, timezone: dashboard.timezone, + rentWarningDay: dashboard.rentWarningDay, rentDueDay: dashboard.rentDueDay, + utilitiesReminderDay: dashboard.utilitiesReminderDay, utilitiesDueDay: dashboard.utilitiesDueDay, paymentBalanceAdjustmentPolicy: dashboard.paymentBalanceAdjustmentPolicy, + rentPaymentDestinations: dashboard.rentPaymentDestinations, totalDueMajor: dashboard.totalDue.toMajorString(), totalPaidMajor: dashboard.totalPaid.toMajorString(), totalRemainingMajor: dashboard.totalRemaining.toMajorString(), diff --git a/apps/bot/src/miniapp-locale.test.ts b/apps/bot/src/miniapp-locale.test.ts index 2aac123..4673d8b 100644 --- a/apps/bot/src/miniapp-locale.test.ts +++ b/apps/bot/src/miniapp-locale.test.ts @@ -143,7 +143,8 @@ function repository(): HouseholdConfigurationRepository { rentWarningDay: 17, utilitiesDueDay: 4, utilitiesReminderDay: 3, - timezone: 'Asia/Tbilisi' + timezone: 'Asia/Tbilisi', + rentPaymentDestinations: null }), updateHouseholdBillingSettings: async (input) => ({ householdId: input.householdId, @@ -154,7 +155,8 @@ function repository(): HouseholdConfigurationRepository { rentWarningDay: input.rentWarningDay ?? 17, utilitiesDueDay: input.utilitiesDueDay ?? 4, utilitiesReminderDay: input.utilitiesReminderDay ?? 3, - timezone: input.timezone ?? 'Asia/Tbilisi' + timezone: input.timezone ?? 'Asia/Tbilisi', + rentPaymentDestinations: input.rentPaymentDestinations ?? null }), listHouseholdUtilityCategories: async () => [], upsertHouseholdUtilityCategory: async (input) => ({ diff --git a/apps/bot/src/payment-topic-ingestion.test.ts b/apps/bot/src/payment-topic-ingestion.test.ts index a1b14de..7de5bc9 100644 --- a/apps/bot/src/payment-topic-ingestion.test.ts +++ b/apps/bot/src/payment-topic-ingestion.test.ts @@ -174,9 +174,12 @@ function createFinanceService(): FinanceCommandService { period: '2026-03', currency: 'GEL', timezone: 'Asia/Tbilisi', + rentWarningDay: 17, rentDueDay: 20, + utilitiesReminderDay: 3, utilitiesDueDay: 4, paymentBalanceAdjustmentPolicy: 'utilities', + rentPaymentDestinations: null, totalDue: Money.fromMajor('1000', 'GEL'), totalPaid: Money.zero('GEL'), totalRemaining: Money.fromMajor('1000', 'GEL'), diff --git a/apps/bot/src/purchase-topic-ingestion.test.ts b/apps/bot/src/purchase-topic-ingestion.test.ts index 0cea2ac..5d6c62f 100644 --- a/apps/bot/src/purchase-topic-ingestion.test.ts +++ b/apps/bot/src/purchase-topic-ingestion.test.ts @@ -2021,7 +2021,8 @@ Confirm or cancel below.`, utilitiesDueDay: 12, utilitiesReminderDay: 10, timezone: 'Asia/Tbilisi', - settlementCurrency: 'GEL' as const + settlementCurrency: 'GEL' as const, + rentPaymentDestinations: null }), getHouseholdChatByHouseholdId: async () => ({ householdId: config.householdId, diff --git a/apps/bot/src/server.ts b/apps/bot/src/server.ts index 99173f2..465d9d1 100644 --- a/apps/bot/src/server.ts +++ b/apps/bot/src/server.ts @@ -122,6 +122,12 @@ export interface BotWebhookServerOptions { handler: (request: Request) => Promise } | undefined + miniAppSubmitUtilityBill?: + | { + path?: string + handler: (request: Request) => Promise + } + | undefined miniAppUpdateUtilityBill?: | { path?: string @@ -158,6 +164,12 @@ export interface BotWebhookServerOptions { handler: (request: Request) => Promise } | undefined + miniAppSubmitPayment?: + | { + path?: string + handler: (request: Request) => Promise + } + | undefined miniAppUpdatePayment?: | { path?: string @@ -241,6 +253,8 @@ 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 miniAppSubmitUtilityBillPath = + options.miniAppSubmitUtilityBill?.path ?? '/api/miniapp/utility-bills/add' const miniAppUpdateUtilityBillPath = options.miniAppUpdateUtilityBill?.path ?? '/api/miniapp/admin/utility-bills/update' const miniAppDeleteUtilityBillPath = @@ -252,6 +266,7 @@ export function createBotWebhookServer(options: BotWebhookServerOptions): { const miniAppDeletePurchasePath = options.miniAppDeletePurchase?.path ?? '/api/miniapp/admin/purchases/delete' const miniAppAddPaymentPath = options.miniAppAddPayment?.path ?? '/api/miniapp/admin/payments/add' + const miniAppSubmitPaymentPath = options.miniAppSubmitPayment?.path ?? '/api/miniapp/payments/add' const miniAppUpdatePaymentPath = options.miniAppUpdatePayment?.path ?? '/api/miniapp/admin/payments/update' const miniAppDeletePaymentPath = @@ -362,6 +377,10 @@ export function createBotWebhookServer(options: BotWebhookServerOptions): { return await options.miniAppAddUtilityBill.handler(request) } + if (options.miniAppSubmitUtilityBill && url.pathname === miniAppSubmitUtilityBillPath) { + return await options.miniAppSubmitUtilityBill.handler(request) + } + if (options.miniAppUpdateUtilityBill && url.pathname === miniAppUpdateUtilityBillPath) { return await options.miniAppUpdateUtilityBill.handler(request) } @@ -386,6 +405,10 @@ export function createBotWebhookServer(options: BotWebhookServerOptions): { return await options.miniAppAddPayment.handler(request) } + if (options.miniAppSubmitPayment && url.pathname === miniAppSubmitPaymentPath) { + return await options.miniAppSubmitPayment.handler(request) + } + if (options.miniAppUpdatePayment && url.pathname === miniAppUpdatePaymentPath) { return await options.miniAppUpdatePayment.handler(request) } diff --git a/apps/miniapp/src/components/layout/shell.tsx b/apps/miniapp/src/components/layout/shell.tsx index 57f5ae1..382026a 100644 --- a/apps/miniapp/src/components/layout/shell.tsx +++ b/apps/miniapp/src/components/layout/shell.tsx @@ -9,11 +9,22 @@ import { NavigationTabs } from './navigation-tabs' import { Badge } from '../ui/badge' import { Button, IconButton } from '../ui/button' import { Modal } from '../ui/dialog' +import { Field } from '../ui/field' +import { Input } from '../ui/input' export function AppShell(props: ParentProps) { const { readySession } = useSession() const { copy, locale, setLocale } = useI18n() - const { effectiveIsAdmin, testingRolePreview, setTestingRolePreview } = useDashboard() + const { + dashboard, + effectiveIsAdmin, + testingRolePreview, + setTestingRolePreview, + testingPeriodOverride, + setTestingPeriodOverride, + testingTodayOverride, + setTestingTodayOverride + } = useDashboard() const navigate = useNavigate() const [testingSurfaceOpen, setTestingSurfaceOpen] = createSignal(false) @@ -157,6 +168,43 @@ export function AppShell(props: ParentProps) { {copy().testingPreviewResidentAction ?? ''} +
+ {copy().testingPeriodCurrentLabel ?? ''} + {dashboard()?.period ?? '—'} +
+
+ + { + const next = e.currentTarget.value.trim() + setTestingPeriodOverride(next.length > 0 ? next : null) + }} + /> + + + { + const next = e.currentTarget.value.trim() + setTestingTodayOverride(next.length > 0 ? next : null) + }} + /> + + +
diff --git a/apps/miniapp/src/components/ui/toast.tsx b/apps/miniapp/src/components/ui/toast.tsx new file mode 100644 index 0000000..fd0e2d4 --- /dev/null +++ b/apps/miniapp/src/components/ui/toast.tsx @@ -0,0 +1,50 @@ +import { Show, createEffect, onCleanup } from 'solid-js' + +import { cn } from '../../lib/cn' + +export interface ToastOptions { + message: string + type?: 'success' | 'info' | 'error' + duration?: number +} + +export interface ToastState { + visible: boolean + message: string + type: 'success' | 'info' | 'error' +} + +const toastVariants = { + success: 'toast--success', + info: 'toast--info', + error: 'toast--error' +} + +export function Toast(props: { state: ToastState; onClose: () => void }) { + let timeoutId: ReturnType | undefined + + createEffect(() => { + if (props.state.visible) { + timeoutId = setTimeout( + () => { + props.onClose() + }, + props.state.type === 'error' ? 4000 : 2000 + ) + } + }) + + onCleanup(() => { + if (timeoutId) { + clearTimeout(timeoutId) + } + }) + + return ( + +
+ {props.state.message} +
+
+ ) +} diff --git a/apps/miniapp/src/contexts/dashboard-context.tsx b/apps/miniapp/src/contexts/dashboard-context.tsx index 0f7e54a..89eae1a 100644 --- a/apps/miniapp/src/contexts/dashboard-context.tsx +++ b/apps/miniapp/src/contexts/dashboard-context.tsx @@ -103,6 +103,10 @@ type DashboardContextValue = { purchaseInvestmentChart: () => ReturnType testingRolePreview: () => TestingRolePreview | null setTestingRolePreview: (value: TestingRolePreview | null) => void + testingPeriodOverride: () => string | null + setTestingPeriodOverride: (value: string | null) => void + testingTodayOverride: () => string | null + setTestingTodayOverride: (value: string | null) => void loadDashboardData: (initData: string, isAdmin: boolean) => Promise applyDemoState: () => void } @@ -246,6 +250,8 @@ export function DashboardProvider(props: ParentProps) { const [cycleState, setCycleState] = createSignal(null) const [pendingMembers, setPendingMembers] = createSignal([]) const [testingRolePreview, setTestingRolePreview] = createSignal(null) + const [testingPeriodOverride, setTestingPeriodOverride] = createSignal(null) + const [testingTodayOverride, setTestingTodayOverride] = createSignal(null) const effectiveIsAdmin = createMemo(() => { const current = readySession() @@ -356,6 +362,10 @@ export function DashboardProvider(props: ParentProps) { purchaseInvestmentChart, testingRolePreview, setTestingRolePreview, + testingPeriodOverride, + setTestingPeriodOverride, + testingTodayOverride, + setTestingTodayOverride, loadDashboardData, applyDemoState }} diff --git a/apps/miniapp/src/demo/miniapp-demo.ts b/apps/miniapp/src/demo/miniapp-demo.ts index d4137ce..ee4dc61 100644 --- a/apps/miniapp/src/demo/miniapp-demo.ts +++ b/apps/miniapp/src/demo/miniapp-demo.ts @@ -27,9 +27,21 @@ 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', @@ -209,7 +221,8 @@ export const demoAdminSettings: MiniAppAdminSettingsPayload = { rentWarningDay: 17, utilitiesDueDay: 4, utilitiesReminderDay: 3, - timezone: 'Asia/Tbilisi' + timezone: 'Asia/Tbilisi', + rentPaymentDestinations: demoDashboard.rentPaymentDestinations }, assistantConfig: { householdId: 'demo-household', diff --git a/apps/miniapp/src/i18n.ts b/apps/miniapp/src/i18n.ts index 6e56a1b..97b6128 100644 --- a/apps/miniapp/src/i18n.ts +++ b/apps/miniapp/src/i18n.ts @@ -64,6 +64,22 @@ export const dictionary = { payNowBody: '', homeDueTitle: 'Due', homeSettledTitle: 'Settled', + homeUtilitiesTitle: 'Utilities payment', + homeRentTitle: 'Rent payment', + homeNoPaymentTitle: 'No payment period', + homeUtilitiesUpcomingLabel: 'Utilities starts {date}', + homeRentUpcomingLabel: 'Rent starts {date}', + homeFillUtilitiesTitle: 'Fill utilities', + homeFillUtilitiesBody: + 'No utility bills are recorded for this cycle yet. Add at least one bill to calculate utilities.', + homeFillUtilitiesSubmitAction: 'Save utility bill', + homeFillUtilitiesSubmitting: 'Saving…', + homeFillUtilitiesOpenLedgerAction: 'Open ledger', + homeUtilitiesBillsTitle: 'Utility bills', + homePurchasesTitle: 'Purchases', + homePurchasesOffsetLabel: 'Your purchases balance', + homePurchasesTotalLabel: 'Household purchases ({count})', + homeMembersCountLabel: 'Members', whyAction: 'Why?', currentCycleLabel: 'Current cycle', cycleTotalLabel: 'Cycle total', @@ -137,6 +153,12 @@ export const dictionary = { testingPreviewResidentAction: 'Preview resident', testingCurrentRoleLabel: 'Real access', testingPreviewRoleLabel: 'Previewing', + testingPeriodCurrentLabel: 'Dashboard period', + testingPeriodOverrideLabel: 'Period override', + testingPeriodOverridePlaceholder: 'YYYY-MM', + testingTodayOverrideLabel: 'Today override', + testingTodayOverridePlaceholder: 'YYYY-MM-DD', + testingClearOverridesAction: 'Clear overrides', purchaseReviewTitle: 'Purchases', purchaseReviewBody: 'Edit or remove purchases if the bot recorded the wrong item.', purchaseSplitTitle: 'Split', @@ -151,6 +173,15 @@ export const dictionary = { paymentsAdminTitle: 'Payments', paymentsAdminBody: 'Add, fix, or remove payment records for the current cycle.', paymentsAddAction: 'Add payment', + copiedToast: 'Copied!', + quickPaymentTitle: 'Record payment', + quickPaymentBody: 'Quickly record a {type} payment for the current cycle.', + quickPaymentAmountLabel: 'Amount', + quickPaymentCurrencyLabel: 'Currency', + quickPaymentSubmitAction: 'Save payment', + quickPaymentSubmitting: 'Saving…', + quickPaymentSuccess: 'Payment recorded successfully', + quickPaymentFailed: 'Failed to record payment', addingPayment: 'Adding payment…', paymentCreateBody: 'Create a payment record in a focused editor instead of a long inline form.', paymentKind: 'Payment kind', @@ -220,6 +251,7 @@ export const dictionary = { currencyLabel: 'Currency', rentAmount: 'Rent amount', defaultRentAmount: 'Default rent', + rentCurrencyLabel: 'Rent currency', defaultRentHint: 'New current cycles start from this rent unless you override a specific month.', currentCycleRentLabel: 'Current cycle rent', @@ -235,6 +267,16 @@ export const dictionary = { timezone: 'Timezone', timezoneHint: 'Use an IANA timezone like Asia/Tbilisi.', timezoneInvalidHint: 'Pick a valid IANA timezone such as Asia/Tbilisi.', + rentPaymentDestinationsTitle: 'Rent payment destinations', + rentPaymentDestinationsEmpty: 'No rent payment destinations saved yet.', + rentPaymentDestinationAddAction: 'Add destination', + rentPaymentDestinationRemoveAction: 'Remove destination', + rentPaymentDestinationLabel: 'Label', + rentPaymentDestinationRecipient: 'Recipient name', + rentPaymentDestinationBank: 'Bank name', + rentPaymentDestinationAccount: 'Account / card / IBAN', + rentPaymentDestinationLink: 'Payment link', + rentPaymentDestinationNote: 'Note', manageSettingsAction: 'Manage settings', billingSettingsEditorBody: 'Household defaults live here. New current cycles start from these values.', @@ -367,6 +409,22 @@ export const dictionary = { payNowBody: '', homeDueTitle: 'К оплате', homeSettledTitle: 'Закрыто', + homeUtilitiesTitle: 'Оплата коммуналки', + homeRentTitle: 'Оплата аренды', + homeNoPaymentTitle: 'Период без оплаты', + homeUtilitiesUpcomingLabel: 'Коммуналка с {date}', + homeRentUpcomingLabel: 'Аренда с {date}', + homeFillUtilitiesTitle: 'Внести коммуналку', + homeFillUtilitiesBody: + 'Для этого цикла коммунальные счета ещё не внесены. Добавь хотя бы один счёт, чтобы рассчитать коммуналку.', + homeFillUtilitiesSubmitAction: 'Сохранить счёт', + homeFillUtilitiesSubmitting: 'Сохраняем…', + homeFillUtilitiesOpenLedgerAction: 'Открыть леджер', + homeUtilitiesBillsTitle: 'Коммунальные счета', + homePurchasesTitle: 'Покупки', + homePurchasesOffsetLabel: 'Ваш баланс покупок', + homePurchasesTotalLabel: 'Покупок в доме ({count})', + homeMembersCountLabel: 'Жильцов', whyAction: 'Почему?', currentCycleLabel: 'Текущий цикл', cycleTotalLabel: 'Всего за цикл', @@ -440,6 +498,12 @@ export const dictionary = { testingPreviewResidentAction: 'Вид жителя', testingCurrentRoleLabel: 'Реальный доступ', testingPreviewRoleLabel: 'Сейчас показан', + testingPeriodCurrentLabel: 'Период (из API)', + testingPeriodOverrideLabel: 'Переопределить период', + testingPeriodOverridePlaceholder: 'YYYY-MM', + testingTodayOverrideLabel: 'Переопределить сегодня', + testingTodayOverridePlaceholder: 'YYYY-MM-DD', + testingClearOverridesAction: 'Сбросить переопределения', purchaseReviewTitle: 'Покупки', purchaseReviewBody: 'Здесь можно исправить или удалить покупку, если бот распознал её неправильно.', @@ -456,6 +520,15 @@ export const dictionary = { paymentsAdminTitle: 'Оплаты', paymentsAdminBody: 'Добавляй, исправляй или удаляй оплаты за текущий цикл.', paymentsAddAction: 'Добавить оплату', + copiedToast: 'Скопировано!', + quickPaymentTitle: 'Записать оплату', + quickPaymentBody: 'Быстро запиши оплату {type} за текущий цикл.', + quickPaymentAmountLabel: 'Сумма', + quickPaymentCurrencyLabel: 'Валюта', + quickPaymentSubmitAction: 'Сохранить оплату', + quickPaymentSubmitting: 'Сохраняем…', + quickPaymentSuccess: 'Оплата успешно записана', + quickPaymentFailed: 'Не удалось записать оплату', addingPayment: 'Добавляем оплату…', paymentCreateBody: 'Создай оплату в отдельном окне вместо длинной встроенной формы.', paymentKind: 'Тип оплаты', @@ -524,6 +597,7 @@ export const dictionary = { currencyLabel: 'Валюта', rentAmount: 'Сумма аренды', defaultRentAmount: 'Аренда по умолчанию', + rentCurrencyLabel: 'Валюта аренды', defaultRentHint: 'Новые текущие циклы стартуют с этой суммой, если для конкретного месяца нет переопределения.', currentCycleRentLabel: 'Аренда текущего цикла', @@ -539,6 +613,16 @@ export const dictionary = { timezone: 'Часовой пояс', timezoneHint: 'Используй IANA-таймзону, например Asia/Tbilisi.', timezoneInvalidHint: 'Выбери корректную IANA-таймзону, например Asia/Tbilisi.', + rentPaymentDestinationsTitle: 'Реквизиты для оплаты аренды', + rentPaymentDestinationsEmpty: 'Реквизиты для оплаты аренды ещё не добавлены.', + rentPaymentDestinationAddAction: 'Добавить реквизиты', + rentPaymentDestinationRemoveAction: 'Удалить', + rentPaymentDestinationLabel: 'Название', + rentPaymentDestinationRecipient: 'Получатель', + rentPaymentDestinationBank: 'Банк', + rentPaymentDestinationAccount: 'Счёт / карта / IBAN', + rentPaymentDestinationLink: 'Ссылка на оплату', + rentPaymentDestinationNote: 'Комментарий', manageSettingsAction: 'Управлять настройками', billingSettingsEditorBody: 'Здесь живут значения по умолчанию для дома. Новые текущие циклы стартуют отсюда.', diff --git a/apps/miniapp/src/index.css b/apps/miniapp/src/index.css index e785ac2..52c6f10 100644 --- a/apps/miniapp/src/index.css +++ b/apps/miniapp/src/index.css @@ -1033,6 +1033,46 @@ a { font-weight: 600; } +.copyable-detail { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 4px 8px; + border-radius: 6px; + border: 0; + background: transparent; + color: inherit; + font: inherit; + cursor: pointer; + transition: background-color 150ms ease; +} + +.copyable-detail:hover { + background: rgba(0, 0, 0, 0.04); +} + +.copyable-detail svg { + opacity: 0.65; + transition: + opacity 120ms ease, + transform 120ms ease, + color 120ms ease; +} + +.copyable-detail:hover svg { + opacity: 0.9; +} + +.copyable-detail.is-copied svg { + opacity: 1; + color: var(--status-credit); + transform: scale(1.1); +} + +.copyable-detail.is-copied { + background: rgba(34, 197, 94, 0.12); +} + .balance-card__remaining { padding-top: var(--spacing-sm); margin-top: var(--spacing-sm); @@ -1696,3 +1736,55 @@ a { .balance-item--accent { border-color: var(--accent-border); } + +/* ── Toast Notifications ─────────────────────── */ + +.toast { + position: fixed; + bottom: 24px; + left: 50%; + transform: translateX(-50%) translateY(0); + background: var(--text-primary); + color: var(--bg-root); + padding: 12px 20px; + border-radius: 999px; + font-size: var(--text-sm); + font-weight: 500; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.16); + z-index: var(--z-toast); + animation: toast-slide-up 200ms ease-out; + max-width: calc(100vw - 48px); + text-align: center; +} + +.toast--success { + background: var(--status-credit); + color: #fff; +} + +.toast--info { + background: var(--text-primary); + color: var(--bg-root); +} + +.toast--error { + background: var(--status-danger); + color: #fff; +} + +.toast__message { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +@keyframes toast-slide-up { + from { + opacity: 0; + transform: translateX(-50%) translateY(16px); + } + to { + opacity: 1; + transform: translateX(-50%) translateY(0); + } +} diff --git a/apps/miniapp/src/lib/dates.ts b/apps/miniapp/src/lib/dates.ts index 57a2cbe..44ebe88 100644 --- a/apps/miniapp/src/lib/dates.ts +++ b/apps/miniapp/src/lib/dates.ts @@ -1,5 +1,7 @@ import type { Locale } from '../i18n' +export type CalendarDateParts = { year: number; month: number; day: number } + function localeTag(locale: Locale): string { return locale === 'ru' ? 'ru-RU' : 'en-US' } @@ -51,7 +53,40 @@ function daysInMonth(year: number, month: number): number { return new Date(Date.UTC(year, month, 0)).getUTCDate() } -function formatTodayParts(timezone: string): { year: number; month: number; day: number } | null { +export function parseCalendarDate(value: string): CalendarDateParts | null { + const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value) + if (!match) return null + const year = Number.parseInt(match[1] ?? '', 10) + const month = Number.parseInt(match[2] ?? '', 10) + const day = Number.parseInt(match[3] ?? '', 10) + + if ( + !Number.isInteger(year) || + !Number.isInteger(month) || + !Number.isInteger(day) || + month < 1 || + month > 12 || + day < 1 || + day > 31 + ) { + return null + } + + return { year, month, day } +} + +export function nextCyclePeriod(period: string): string | null { + const parsed = parsePeriod(period) + if (!parsed) return null + + const month = parsed.month === 12 ? 1 : parsed.month + 1 + const year = parsed.month === 12 ? parsed.year + 1 : parsed.year + const monthLabel = String(month).padStart(2, '0') + + return `${year}-${monthLabel}` +} + +function formatTodayParts(timezone: string): CalendarDateParts | null { try { const parts = new Intl.DateTimeFormat('en-CA', { timeZone: timezone, @@ -134,10 +169,11 @@ export function formatPeriodDay(period: string, day: number, locale: Locale): st export function compareTodayToPeriodDay( period: string, day: number, - timezone: string + timezone: string, + todayOverride?: CalendarDateParts | null ): -1 | 0 | 1 | null { const parsed = parsePeriod(period) - const today = formatTodayParts(timezone) + const today = todayOverride ?? formatTodayParts(timezone) if (!parsed || !today) { return null } @@ -157,9 +193,14 @@ export function compareTodayToPeriodDay( return 0 } -export function daysUntilPeriodDay(period: string, day: number, timezone: string): number | null { +export function daysUntilPeriodDay( + period: string, + day: number, + timezone: string, + todayOverride?: CalendarDateParts | null +): number | null { const parsed = parsePeriod(period) - const today = formatTodayParts(timezone) + const today = todayOverride ?? formatTodayParts(timezone) if (!parsed || !today) { return null } diff --git a/apps/miniapp/src/miniapp-api.ts b/apps/miniapp/src/miniapp-api.ts index 96659b6..833373d 100644 --- a/apps/miniapp/src/miniapp-api.ts +++ b/apps/miniapp/src/miniapp-api.ts @@ -69,6 +69,16 @@ export interface MiniAppBillingSettings { utilitiesDueDay: number utilitiesReminderDay: number timezone: string + rentPaymentDestinations: readonly MiniAppRentPaymentDestination[] | null +} + +export interface MiniAppRentPaymentDestination { + label: string + recipientName: string | null + bankName: string | null + account: string + note: string | null + link: string | null } export interface MiniAppAssistantConfig { @@ -96,9 +106,12 @@ export interface MiniAppDashboard { period: string currency: 'USD' | 'GEL' timezone: string + rentWarningDay: number rentDueDay: number + utilitiesReminderDay: number utilitiesDueDay: number paymentBalanceAdjustmentPolicy: 'utilities' | 'rent' | 'separate' + rentPaymentDestinations: readonly MiniAppRentPaymentDestination[] | null totalDueMajor: string totalPaidMajor: string totalRemainingMajor: string @@ -466,6 +479,7 @@ export async function updateMiniAppBillingSettings( utilitiesDueDay: number utilitiesReminderDay: number timezone: string + rentPaymentDestinations?: readonly MiniAppRentPaymentDestination[] | null assistantContext?: string assistantTone?: string } @@ -883,6 +897,36 @@ export async function addMiniAppUtilityBill( return payload.cycleState } +export async function submitMiniAppUtilityBill( + initData: string, + input: { + billName: string + amountMajor: string + currency: 'USD' | 'GEL' + } +): Promise { + const response = await fetch(`${apiBaseUrl()}/api/miniapp/utility-bills/add`, { + method: 'POST', + headers: { + 'content-type': 'application/json' + }, + body: JSON.stringify({ + initData, + ...input + }) + }) + + const payload = (await response.json()) as { + ok: boolean + authorized?: boolean + error?: string + } + + if (!response.ok || !payload.authorized) { + throw new Error(payload.error ?? 'Failed to submit utility bill') + } +} + export async function updateMiniAppUtilityBill( initData: string, input: { diff --git a/apps/miniapp/src/routes/home.tsx b/apps/miniapp/src/routes/home.tsx index 120eaef..d93b49b 100644 --- a/apps/miniapp/src/routes/home.tsx +++ b/apps/miniapp/src/routes/home.tsx @@ -1,19 +1,93 @@ -import { Show, For, createSignal } from 'solid-js' -import { Clock, ChevronDown, ChevronUp } from 'lucide-solid' +import { Show, For, createMemo, createSignal } from 'solid-js' +import { Clock, ChevronDown, ChevronUp, Copy, Check, CreditCard } from 'lucide-solid' +import { useNavigate } from '@solidjs/router' import { useSession } from '../contexts/session-context' import { useI18n } from '../contexts/i18n-context' import { useDashboard } from '../contexts/dashboard-context' import { Card } from '../components/ui/card' import { Badge } from '../components/ui/badge' +import { Button } from '../components/ui/button' +import { Field } from '../components/ui/field' +import { Input } from '../components/ui/input' +import { Modal } from '../components/ui/dialog' +import { Toast } from '../components/ui/toast' import { memberRemainingClass, ledgerPrimaryAmount } from '../lib/ledger-helpers' import { majorStringToMinor, minorToMajorString } from '../lib/money' +import { + compareTodayToPeriodDay, + daysUntilPeriodDay, + formatPeriodDay, + nextCyclePeriod, + parseCalendarDate +} from '../lib/dates' +import { submitMiniAppUtilityBill, addMiniAppPayment } from '../miniapp-api' export default function HomeRoute() { - const { readySession } = useSession() - const { copy } = useI18n() - const { dashboard, currentMemberLine } = useDashboard() + const navigate = useNavigate() + const { readySession, initData, refreshHouseholdData } = useSession() + const { copy, locale } = useI18n() + const { + dashboard, + currentMemberLine, + utilityLedger, + utilityTotalMajor, + purchaseLedger, + purchaseTotalMajor, + testingPeriodOverride, + testingTodayOverride + } = useDashboard() const [showAllActivity, setShowAllActivity] = createSignal(false) + const [utilityDraft, setUtilityDraft] = createSignal({ + billName: '', + amountMajor: '', + currency: (dashboard()?.currency as 'USD' | 'GEL') ?? 'GEL' + }) + const [submittingUtilities, setSubmittingUtilities] = createSignal(false) + const [copiedValue, setCopiedValue] = createSignal(null) + const [quickPaymentOpen, setQuickPaymentOpen] = createSignal(false) + const [quickPaymentType, setQuickPaymentType] = createSignal<'rent' | 'utilities'>('rent') + const [quickPaymentAmount, setQuickPaymentAmount] = createSignal('') + const [submittingPayment, setSubmittingPayment] = createSignal(false) + const [toastState, setToastState] = createSignal<{ + visible: boolean + message: string + type: 'success' | 'info' | 'error' + }>({ visible: false, message: '', type: 'info' }) + + async function copyText(value: string): Promise { + try { + await navigator.clipboard.writeText(value) + return true + } catch { + try { + const element = document.createElement('textarea') + element.value = value + element.setAttribute('readonly', 'true') + element.style.position = 'absolute' + element.style.left = '-9999px' + document.body.appendChild(element) + element.select() + document.execCommand('copy') + document.body.removeChild(element) + return true + } catch {} + } + + return false + } + + async function handleCopy(value: string) { + if (await copyText(value)) { + setCopiedValue(value) + setToastState({ visible: true, message: copy().copiedToast, type: 'success' }) + setTimeout(() => { + if (copiedValue() === value) { + setCopiedValue(null) + } + }, 1400) + } + } function dueStatusBadge() { const data = dashboard() @@ -24,6 +98,168 @@ export default function HomeRoute() { return { label: copy().homeDueTitle, variant: 'danger' as const } } + function paymentWindowStatus(input: { + period: string + timezone: string + reminderDay: number + dueDay: number + todayOverride?: ReturnType + }): { active: boolean; daysUntilDue: number | null } { + if (!Number.isInteger(input.reminderDay) || !Number.isInteger(input.dueDay)) { + return { active: false, daysUntilDue: null } + } + + const start = compareTodayToPeriodDay( + input.period, + input.reminderDay, + input.timezone, + input.todayOverride + ) + const end = compareTodayToPeriodDay( + input.period, + input.dueDay, + input.timezone, + input.todayOverride + ) + if (start === null || end === null) { + return { active: false, daysUntilDue: null } + } + + const reminderPassed = start !== -1 + const dueNotPassed = end !== 1 + const daysUntilDue = daysUntilPeriodDay( + input.period, + input.dueDay, + input.timezone, + input.todayOverride + ) + + return { + active: reminderPassed && dueNotPassed, + daysUntilDue + } + } + + const todayOverride = createMemo(() => { + const raw = testingTodayOverride() + if (!raw) return null + return parseCalendarDate(raw) + }) + + const effectivePeriod = createMemo(() => { + const data = dashboard() + if (!data) return null + const override = testingPeriodOverride() + if (!override) return data.period + const match = /^(\d{4})-(\d{2})$/.exec(override) + if (!match) return data.period + const month = Number.parseInt(match[2] ?? '', 10) + if (!Number.isInteger(month) || month < 1 || month > 12) return data.period + return override + }) + + const homeMode = createMemo(() => { + const data = dashboard() + if (!data) return 'none' as const + const period = effectivePeriod() ?? data.period + const today = todayOverride() + + const utilities = paymentWindowStatus({ + period, + timezone: data.timezone, + reminderDay: data.utilitiesReminderDay, + dueDay: data.utilitiesDueDay, + todayOverride: today + }) + const rent = paymentWindowStatus({ + period, + timezone: data.timezone, + reminderDay: data.rentWarningDay, + dueDay: data.rentDueDay, + todayOverride: today + }) + + if (utilities.active && rent.active) { + const utilitiesDays = utilities.daysUntilDue ?? Number.POSITIVE_INFINITY + const rentDays = rent.daysUntilDue ?? Number.POSITIVE_INFINITY + return utilitiesDays <= rentDays ? ('utilities' as const) : ('rent' as const) + } + + if (utilities.active) return 'utilities' as const + if (rent.active) return 'rent' as const + return 'none' as const + }) + + async function handleSubmitUtilities() { + const data = initData() + const current = dashboard() + const draft = utilityDraft() + if (!data || !current || submittingUtilities()) return + if (!draft.billName.trim() || !draft.amountMajor.trim()) return + + setSubmittingUtilities(true) + try { + await submitMiniAppUtilityBill(data, { + billName: draft.billName, + amountMajor: draft.amountMajor, + currency: draft.currency + }) + setUtilityDraft({ + billName: '', + amountMajor: '', + currency: current.currency + }) + await refreshHouseholdData(true, true) + } finally { + setSubmittingUtilities(false) + } + } + + function openQuickPayment(type: 'rent' | 'utilities') { + const data = dashboard() + if (!data || !currentMemberLine()) return + + const member = currentMemberLine()! + const amount = type === 'rent' ? member.rentShareMajor : member.utilityShareMajor + + setQuickPaymentType(type) + setQuickPaymentAmount(amount) + setQuickPaymentOpen(true) + } + + async function handleQuickPaymentSubmit() { + const data = initData() + const amount = quickPaymentAmount() + const type = quickPaymentType() + + if (!data || !amount.trim() || !currentMemberLine()) return + + setSubmittingPayment(true) + try { + await addMiniAppPayment(data, { + memberId: currentMemberLine()!.memberId, + kind: type, + amountMajor: amount, + currency: (dashboard()?.currency as 'USD' | 'GEL') ?? 'GEL' + }) + setQuickPaymentOpen(false) + setToastState({ + visible: true, + message: copy().quickPaymentSuccess, + type: 'success' + }) + await refreshHouseholdData(true, true) + } catch { + setToastState({ + visible: true, + message: copy().quickPaymentFailed, + type: 'error' + }) + } finally { + setSubmittingPayment(false) + } + } + return (
{/* ── Welcome hero ────────────────────────────── */} @@ -43,63 +279,517 @@ export default function HomeRoute() { > {(data) => ( <> - {/* Your balance card */} {(member) => { - const subtotalMinor = - majorStringToMinor(member().rentShareMajor) + - majorStringToMinor(member().utilityShareMajor) - const subtotalMajor = minorToMajorString(subtotalMinor) + const policy = () => data().paymentBalanceAdjustmentPolicy + + const rentBaseMinor = () => majorStringToMinor(member().rentShareMajor) + const utilitiesBaseMinor = () => majorStringToMinor(member().utilityShareMajor) + const purchaseOffsetMinor = () => majorStringToMinor(member().purchaseOffsetMajor) + + const rentProposalMinor = () => + policy() === 'rent' ? rentBaseMinor() + purchaseOffsetMinor() : rentBaseMinor() + const utilitiesProposalMinor = () => + policy() === 'utilities' + ? utilitiesBaseMinor() + purchaseOffsetMinor() + : utilitiesBaseMinor() + + const mode = () => homeMode() + const currency = () => data().currency + const timezone = () => data().timezone + const period = () => effectivePeriod() ?? data().period + const today = () => todayOverride() + + function upcomingDay(day: number): { dateLabel: string; daysUntil: number | null } { + const withinPeriodDays = daysUntilPeriodDay(period(), day, timezone(), today()) + if (withinPeriodDays === null) { + return { dateLabel: '—', daysUntil: null } + } + + if (withinPeriodDays >= 0) { + return { + dateLabel: formatPeriodDay(period(), day, locale()), + daysUntil: withinPeriodDays + } + } + + const next = nextCyclePeriod(period()) + if (!next) { + return { dateLabel: formatPeriodDay(period(), day, locale()), daysUntil: null } + } + + return { + dateLabel: formatPeriodDay(next, day, locale()), + daysUntil: daysUntilPeriodDay(next, day, timezone(), today()) + } + } + + const rentDueDate = () => formatPeriodDay(period(), data().rentDueDay, locale()) + const utilitiesDueDate = () => + formatPeriodDay(period(), data().utilitiesDueDay, locale()) + + const rentDaysUntilDue = () => + daysUntilPeriodDay(period(), data().rentDueDay, timezone(), today()) + const utilitiesDaysUntilDue = () => + daysUntilPeriodDay(period(), data().utilitiesDueDay, timezone(), today()) + + const rentUpcoming = () => upcomingDay(data().rentWarningDay) + const utilitiesUpcoming = () => upcomingDay(data().utilitiesReminderDay) + + const focusBadge = () => { + const badge = dueStatusBadge() + return badge ? {badge.label} : null + } + + const dueBadge = (days: number | null) => { + if (days === null) return null + if (days < 0) return {copy().overdueLabel} + if (days === 0) return {copy().dueTodayLabel} + return ( + + {copy().daysLeftLabel.replace('{count}', String(days))} + + ) + } return ( - -
-
- {copy().yourBalanceTitle} - - {(badge) => {badge().label}} - + <> + + +
+
+ {copy().homeUtilitiesTitle} +
+ {focusBadge()} + +
+
+
+
+ {copy().finalDue} + + {minorToMajorString(utilitiesProposalMinor())} {currency()} + +
+
+ {copy().dueOnLabel.replace('{date}', utilitiesDueDate())} + {dueBadge(utilitiesDaysUntilDue())} +
+
+ {copy().baseDue} + + {member().utilityShareMajor} {currency()} + +
+ +
+ {copy().balanceAdjustmentLabel} + + {member().purchaseOffsetMajor} {currency()} + +
+
+ 0}> +
+ {copy().homeUtilitiesBillsTitle} + + {utilityTotalMajor()} {currency()} + +
+ + {(entry) => ( +
+ {entry.title} + {ledgerPrimaryAmount(entry)} +
+ )} +
+
+
+
+
+
+ + + +
+
+ {copy().homeRentTitle} +
+ {focusBadge()} + +
+
+
+
+ {copy().finalDue} + + {minorToMajorString(rentProposalMinor())} {currency()} + +
+
+ {copy().dueOnLabel.replace('{date}', rentDueDate())} + {dueBadge(rentDaysUntilDue())} +
+
+ {copy().baseDue} + + {member().rentShareMajor} {currency()} + +
+ +
+ {copy().balanceAdjustmentLabel} + + {member().purchaseOffsetMajor} {currency()} + +
+
+
+
+
+
+ + + +
+
+ {copy().homeNoPaymentTitle} +
+
+
+ + {copy().homeUtilitiesUpcomingLabel.replace( + '{date}', + utilitiesUpcoming().dateLabel + )} + + + {utilitiesUpcoming().daysUntil !== null + ? copy().daysLeftLabel.replace( + '{count}', + String(utilitiesUpcoming().daysUntil) + ) + : '—'} + +
+
+ + {copy().homeRentUpcomingLabel.replace( + '{date}', + rentUpcoming().dateLabel + )} + + + {rentUpcoming().daysUntil !== null + ? copy().daysLeftLabel.replace( + '{count}', + String(rentUpcoming().daysUntil) + ) + : '—'} + +
+
+
+
+
+ + + +
+
+ {copy().homeFillUtilitiesTitle} +
+

{copy().homeFillUtilitiesBody}

+
+ + + setUtilityDraft((d) => ({ + ...d, + billName: e.currentTarget.value + })) + } + /> + + + + setUtilityDraft((d) => ({ + ...d, + amountMajor: e.currentTarget.value + })) + } + /> + +
+ + +
+
+
+
+
+ + +
+ + {(destination) => ( + +
+
+ {destination.label} +
+
+ + {(value) => ( +
+ {copy().rentPaymentDestinationRecipient} + + + +
+ )} +
+ + {(value) => ( +
+ {copy().rentPaymentDestinationBank} + + + +
+ )} +
+
+ {copy().rentPaymentDestinationAccount} + + + +
+ + {(value) => ( +
+ {copy().rentPaymentDestinationLink} + + + +
+ )} +
+ + {(value) => ( +
+ {copy().rentPaymentDestinationNote} + + + +
+ )} +
+
+
+
+ )} +
-
-
- {copy().shareRent} - - {member().rentShareMajor} {data().currency} - -
-
- {copy().shareUtilities} - - {member().utilityShareMajor} {data().currency} - -
-
- {copy().totalDueLabel} - - {subtotalMajor} {data().currency} - -
-
- {copy().balanceAdjustmentLabel} - - {member().purchaseOffsetMajor} {data().currency} - -
-
- {copy().remainingLabel} - - {member().remainingMajor} {data().currency} - -
-
-
- + + ) }} + {/* Your balance card */} + + {(member) => ( + <> + + {(() => { + const subtotalMinor = + majorStringToMinor(member().rentShareMajor) + + majorStringToMinor(member().utilityShareMajor) + const subtotalMajor = minorToMajorString(subtotalMinor) + + return ( + +
+
+ {copy().yourBalanceTitle} + + {(badge) => ( + {badge().label} + )} + +
+
+
+ {copy().shareRent} + + {member().rentShareMajor} {data().currency} + +
+
+ {copy().shareUtilities} + + {member().utilityShareMajor} {data().currency} + +
+
+ {copy().totalDueLabel} + + {subtotalMajor} {data().currency} + +
+
+ {copy().balanceAdjustmentLabel} + + {member().purchaseOffsetMajor} {data().currency} + +
+
+ {copy().remainingLabel} + + {member().remainingMajor} {data().currency} + +
+
+
+
+ ) + })()} +
+ + + +
+
+ {copy().homePurchasesTitle} +
+
+
+ {copy().homePurchasesOffsetLabel} + + {member().purchaseOffsetMajor} {data().currency} + +
+
+ + {copy().homePurchasesTotalLabel.replace( + '{count}', + String(purchaseLedger().length) + )} + + + {purchaseTotalMajor()} {data().currency} + +
+
+ {copy().homeMembersCountLabel} + {data().members.length} +
+
+
+
+
+ + )} +
+ {/* Rent FX card */} @@ -173,6 +863,55 @@ export default function HomeRoute() { )} + + {/* Quick Payment Modal */} + setQuickPaymentOpen(false)} + footer={ + <> + + + + } + > +
+ + setQuickPaymentAmount(e.currentTarget.value)} + placeholder="0.00" + /> + + + + +
+
+ + {/* Toast Notifications */} + setToastState({ ...toastState(), visible: false })} + />
) } diff --git a/apps/miniapp/src/routes/settings.tsx b/apps/miniapp/src/routes/settings.tsx index 828d2f8..d4c061a 100644 --- a/apps/miniapp/src/routes/settings.tsx +++ b/apps/miniapp/src/routes/settings.tsx @@ -65,10 +65,34 @@ export default function SettingsRoute() { utilitiesDueDay: adminSettings()?.settings.utilitiesDueDay ?? 4, utilitiesReminderDay: adminSettings()?.settings.utilitiesReminderDay ?? 3, timezone: adminSettings()?.settings.timezone ?? 'Asia/Tbilisi', + rentPaymentDestinations: [...(adminSettings()?.settings.rentPaymentDestinations ?? [])], assistantContext: adminSettings()?.assistantConfig?.assistantContext ?? '', assistantTone: adminSettings()?.assistantConfig?.assistantTone ?? '' }) + function openBillingEditor() { + const settings = adminSettings() + if (settings) { + setBillingForm({ + householdName: settings.householdName ?? '', + settlementCurrency: settings.settings.settlementCurrency ?? 'GEL', + paymentBalanceAdjustmentPolicy: + settings.settings.paymentBalanceAdjustmentPolicy ?? 'utilities', + rentAmountMajor: minorToMajorString(BigInt(settings.settings.rentAmountMinor ?? '0')), + rentCurrency: settings.settings.rentCurrency ?? 'USD', + rentDueDay: settings.settings.rentDueDay ?? 20, + rentWarningDay: settings.settings.rentWarningDay ?? 17, + utilitiesDueDay: settings.settings.utilitiesDueDay ?? 4, + utilitiesReminderDay: settings.settings.utilitiesReminderDay ?? 3, + timezone: settings.settings.timezone ?? 'Asia/Tbilisi', + rentPaymentDestinations: [...(settings.settings.rentPaymentDestinations ?? [])], + assistantContext: settings.assistantConfig?.assistantContext ?? '', + assistantTone: settings.assistantConfig?.assistantTone ?? '' + }) + } + setBillingEditorOpen(true) + } + // ── Pending members ────────────────────────────── const [approvingId, setApprovingId] = createSignal(null) const [rejectingId, setRejectingId] = createSignal(null) @@ -267,7 +291,7 @@ export default function SettingsRoute() { {copy().timezone} {settings().settings.timezone}
- @@ -477,12 +501,236 @@ export default function SettingsRoute() { } /> + + + setBillingForm((f) => ({ + ...f, + paymentBalanceAdjustmentPolicy: value as 'utilities' | 'rent' | 'separate' + })) + } + /> + + + + setBillingForm((f) => ({ + ...f, + rentWarningDay: Number(e.currentTarget.value) || 0 + })) + } + /> + + + + setBillingForm((f) => ({ ...f, rentDueDay: Number(e.currentTarget.value) || 0 })) + } + /> + + + + setBillingForm((f) => ({ + ...f, + utilitiesReminderDay: Number(e.currentTarget.value) || 0 + })) + } + /> + + + + setBillingForm((f) => ({ + ...f, + utilitiesDueDay: Number(e.currentTarget.value) || 0 + })) + } + /> + setBillingForm((f) => ({ ...f, timezone: e.currentTarget.value }))} /> + +
+ 0} + fallback={

{copy().rentPaymentDestinationsEmpty}

} + > +
+ + {(destination, index) => ( + +
+ + + setBillingForm((f) => { + const next = [...f.rentPaymentDestinations] + next[index()] = { + ...next[index()]!, + label: e.currentTarget.value + } + return { ...f, rentPaymentDestinations: next } + }) + } + /> + + + + setBillingForm((f) => { + const next = [...f.rentPaymentDestinations] + next[index()] = { + ...next[index()]!, + recipientName: e.currentTarget.value || null + } + return { ...f, rentPaymentDestinations: next } + }) + } + /> + + + + setBillingForm((f) => { + const next = [...f.rentPaymentDestinations] + next[index()] = { + ...next[index()]!, + bankName: e.currentTarget.value || null + } + return { ...f, rentPaymentDestinations: next } + }) + } + /> + + + + setBillingForm((f) => { + const next = [...f.rentPaymentDestinations] + next[index()] = { + ...next[index()]!, + account: e.currentTarget.value + } + return { ...f, rentPaymentDestinations: next } + }) + } + /> + + + + setBillingForm((f) => { + const next = [...f.rentPaymentDestinations] + next[index()] = { + ...next[index()]!, + link: e.currentTarget.value || null + } + return { ...f, rentPaymentDestinations: next } + }) + } + /> + + +