diff --git a/HISTORY.md b/HISTORY.md
index 11aa8ba..c890071 100644
--- a/HISTORY.md
+++ b/HISTORY.md
@@ -16,6 +16,10 @@
### ✨ Features
+- **Bill due notifications — push channels (ntfy / Gotify / Discord / Telegram)** — The email notification system (`runNotifications()`) was already fully built and running in the daily worker at 6 AM. What was missing was push notification support. Four new channels are now wired in: ntfy (HTTP POST with priority/tags headers and optional Bearer auth), Gotify (JSON message with urgency-mapped priority 4–9), Discord (webhook embed with urgency-matched color and timestamp), and Telegram (Bot API `sendMessage` with Markdown). Urgency levels (`upcoming`, `soon`, `today`, `overdue`) map to channel-appropriate priorities and ntfy tags (`bell`, `warning`, `rotating_light`, `red_circle`). The early-exit guard in `runNotifications()` was loosened — notifications now fire if either SMTP or any user's push is configured. Each recipient can receive both email and push for the same bill. `push_url` and `push_token` are encrypted at rest with the existing AES-256-GCM service. DB migration `v0.80` adds five columns to `users`: `notify_push_enabled`, `push_channel`, `push_url`, `push_token`, `push_chat_id`. The Profile page gains a "Push Notifications" card with a master toggle, pill-button channel picker, context-aware inputs (URL, token shown as "✓ saved" rather than pre-filled, Telegram-only chat ID field), and a "Send test" button that fires `POST /api/notifications/test-push` and surfaces the exact error if the channel is misconfigured.
+
+- **Cash flow projection** — A new `CashFlowCard` on the Calendar page answers "what will I have left after all my bills clear?" — distinct from the existing remaining balance which only reflects what's already been paid. The card shows two panels in the first half of the month (by period end, by month end) and collapses to one panel in the second half since the dates converge. Progress bars are amount-based (`$420 of $650 paid`) rather than count-based so high-value bills are weighted correctly. When any projection goes negative a prominent red alert banner appears with the shortfall amount and a prompt to review unpaid bills or adjust starting amounts. The "X unpaid →" count is a live link that opens the Tracker pre-filtered to exactly those bills for that period. On TrackerPage the Starting card hint now shows `→ $1,247 projected by Jun 14` when cashflow data is available, surfacing the projection without leaving the tracker view. When bank tracking is active the projection uses the live effective bank balance as its starting point. Backend: a `cashflow` block added to the `trackerService` response containing period and month projections, amount-paid totals, paid/total counts, and an `end_label` string for the period cutoff date.
+
- **Bill bank matching rules** — Bills can now be linked to bank transaction patterns so payments import automatically without manual matching. A new "Bank matching rules" section in the Bill Modal (Transactions tab) shows all existing patterns for a bill as removable chips and lets the user add new ones by typing a merchant name or picking from a dropdown of recent unmatched transactions. As the user types, a live preview badge shows how many existing unmatched transactions the pattern would match (debounced, updates as-you-type). If the pattern is already claimed by another bill a conflict warning appears inline with the other bill's name, prompting the user to be more specific. On save the rule is applied retroactively — `syncBillPaymentsFromSimplefin` runs immediately and a green feedback banner reports how many historical payments were imported (e.g. "3 existing payments imported from your transaction history"). Bills with at least one active rule show a green **Bank** chip in the bill list with a tooltip. Four new endpoints: `GET /api/bills/:id/merchant-rules` (list rules + suggestions), `GET /api/bills/:id/merchant-rules/preview?merchant=X` (match count + conflict check), `POST /api/bills/:id/merchant-rules` (add + retroactive apply), `DELETE /api/bills/:id/merchant-rules/:ruleId` (remove).
- **SimpleFIN bank budget tracking** — A new opt-in mode replaces manually-entered starting amounts with the live balance of a connected bank account. When enabled from the Bank Sync settings page (Data → Bank Sync → Bank Budget Tracking), the user selects which financial account to track and configures a pending payment window (0–7 days, default 3). Budget remaining is calculated as: `bank balance − pending payments − unpaid bills this month`. Bills already marked paid are not double-counted — the bank balance already reflects them. Payments made within the pending window appear in the tracker with an amber **Pending** badge, flagging that the bank may not have processed the debit yet. The CalendarPage Monthly Money Map switches to four live metrics (Balance / Pending / Unpaid Bills / After Bills) when bank mode is active. The TrackerPage starting-amounts card shows the account name and "live balance" hint; the manual-edit button is hidden since there is nothing to manually set. Implementation: three new `user_settings` keys (`bank_tracking_enabled`, `bank_tracking_account_id`, `bank_tracking_pending_days`), a new `GET /api/data-sources/accounts/all` endpoint for the account picker, `buildBankTrackingSummary()` in both `summary.js` and `trackerService.js`, and `pending_cleared` flag on tracker rows.
diff --git a/client/api.js b/client/api.js
index 833b316..6469f61 100644
--- a/client/api.js
+++ b/client/api.js
@@ -127,6 +127,7 @@ export const api = {
notifAdmin: () => get('/notifications/admin'),
saveNotifAdmin: (data) => put('/notifications/admin', data),
testEmail: (data) => post('/notifications/test', data),
+ testPushNotification: () => post('/notifications/test-push', {}),
notifMe: () => get('/notifications/me'),
saveNotifMe: (data) => put('/notifications/me', data),
diff --git a/client/pages/ProfilePage.jsx b/client/pages/ProfilePage.jsx
index 62d6a2f..e9e5264 100644
--- a/client/pages/ProfilePage.jsx
+++ b/client/pages/ProfilePage.jsx
@@ -2,6 +2,7 @@ import React, { useEffect, useState } from 'react';
import { toast } from 'sonner';
import {
User, Mail, KeyRound, ShieldCheck, Loader2, History, Monitor, Smartphone, ChevronRight,
+ Bell, SendHorizontal,
} from 'lucide-react';
import { api } from '@/api';
import { useAuth } from '@/hooks/useAuth';
@@ -385,6 +386,188 @@ function NotificationPreferences({ settings, onSaved }) {
);
}
+const PUSH_CHANNELS = [
+ { value: 'ntfy', label: 'ntfy', urlLabel: 'Topic URL', urlHint: 'https://pulse.scheller.ltd/bills', tokenLabel: 'Access token (optional)' },
+ { value: 'gotify', label: 'Gotify', urlLabel: 'Server URL', urlHint: 'http://192.168.1.11:8077', tokenLabel: 'App token' },
+ { value: 'discord', label: 'Discord', urlLabel: 'Webhook URL', urlHint: 'https://discord.com/api/webhooks/…', tokenLabel: null },
+ { value: 'telegram', label: 'Telegram', urlLabel: 'Server URL / n/a', urlHint: 'Leave blank for api.telegram.org', tokenLabel: 'Bot token', chatIdLabel: 'Chat ID' },
+];
+
+function PushNotifications({ settings, onSaved }) {
+ const [enabled, setEnabled] = useState(!!settings.notify_push_enabled);
+ const [channel, setChannel] = useState(settings.push_channel || 'ntfy');
+ const [url, setUrl] = useState(settings.push_url || '');
+ const [token, setToken] = useState(''); // never pre-filled for security
+ const [chatId, setChatId] = useState(settings.push_chat_id || '');
+ const [tokenSet, setTokenSet] = useState(!!settings.push_token_set);
+ const [saving, setSaving] = useState(false);
+ const [testing, setTesting] = useState(false);
+
+ const ch = PUSH_CHANNELS.find(c => c.value === channel) || PUSH_CHANNELS[0];
+
+ const save = async () => {
+ setSaving(true);
+ try {
+ const patch = {
+ notify_push_enabled: enabled,
+ push_channel: channel,
+ push_url: url.trim() || null,
+ push_chat_id: chatId.trim() || null,
+ };
+ if (token.trim()) patch.push_token = token.trim();
+ await api.updateProfileSettings(patch);
+ setTokenSet(!!token.trim() || tokenSet);
+ setToken('');
+ toast.success('Push notification settings saved.');
+ onSaved?.();
+ } catch (err) {
+ toast.error(err.message || 'Failed to save push settings.');
+ } finally {
+ setSaving(false);
+ }
+ };
+
+ const test = async () => {
+ setTesting(true);
+ try {
+ await api.testPushNotification();
+ toast.success('Test notification sent — check your device.');
+ } catch (err) {
+ toast.error(err.message || 'Test failed. Check your channel settings.');
+ } finally {
+ setTesting(false);
+ }
+ };
+
+ return (
+
+
+
+ {/* Master toggle — same CheckRow pattern as the email section */}
+
+ {enabled && (
+
+ Sent at 6 AM alongside email reminders. Use the test button below to verify immediately.
+