Compare commits

...
2 Commits
Author SHA1 Message Date
LibrePortal aba12eb250 Replace MoneyApp with LibreLedger, an encrypted money ledger
LibreLedger encrypts everything in the browser, so the server only ever
holds the ciphertext. It installs private (trusted places only), with its
/data included in backups, on https://ledger.<domain> when a domain is set
up, and on plain http://ip:port with its built-in encryption otherwise.
The image comes from git.libreportal.org, pinned by version and digest,
and updates are manual.

MoneyApp is gone from the catalog. An existing install keeps running but
gets no updates, and nothing is migrated; the upgrade notes explain this.

Assisted-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 01:29:41 +01:00
LibrePortal 0aede3e75b Update apps whose image is pinned by digest
Upgrading an app whose compose pins repo:tag@sha256:... split the ref
inside the digest, so the new tag could not be written. The upgrade now
reads the tag before the "@" and writes the new tag without the old digest,
which belonged to the previous build.

Assisted-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 01:29:41 +01:00
85 changed files with 179 additions and 13963 deletions
+11
View File
@@ -0,0 +1,11 @@
{
"compat": { "range": ">=1.0.0 <2.0.0", "verified": [] },
"integrations": [
{
"id": "backup_files",
"label": "Backups",
"description": "LibrePortal's backups copy /data (ledger.enc and its dated copies under backups/) through the libreportal.backup.files label. The files stay encrypted; restoring one needs the passphrase it was saved with.",
"compat": { "verified": [] }
}
]
}
+64
View File
@@ -0,0 +1,64 @@
networks:
DOCKER_NETWORK_DATA: #LIBREPORTAL|DOCKER_NETWORK_TAG|DOCKER_NETWORK_DATA
external: true
services:
# LibreLedger — encrypted personal money ledger. The browser encrypts; the
# container (a standard-library Python server) only stores the ciphertext in
# /data: ledger.enc plus dated copies under backups/.
libreledger-service: #LIBREPORTAL|SERVICE_TAG_1|libreledger-service
container_name: libreledger-service
# Pinned by version AND digest: built from git.libreportal.org/LibrePortal/LibreLedger.
# A new release changes both, together.
image: git.libreportal.org/libreportal/libreledger:1.0.0@sha256:74c58ed3e4563327dc66445dfdf7675902a91b1803b80377d195057b6693cb80 #LIBREPORTAL|LIBRELEDGER_VERSION_TAG|1.0.0
# The image runs as uid 10001, which under rootless Docker maps to a host
# sub-UID that owns nothing in ./data. USER_TAG resolves to the identity
# that owns the bind mount: 0:0 under rootless (container root == the
# unprivileged install user on the host), the real uid:gid under rooted.
user: "USER_DATA" #LIBREPORTAL|USER_TAG|USER_DATA
restart: unless-stopped
hostname: libreledger
read_only: true
cap_drop:
- ALL
security_opt:
- no-new-privileges:true
# GLUETUN_OFF_BEGIN
ports:
- "PORTS_DATA_1" #LIBREPORTAL|PORTS_TAG_1|PORTS_DATA_1
# GLUETUN_OFF_END
environment:
- TZ=TIMEZONE_DATA #LIBREPORTAL|TIMEZONE_TAG|TIMEZONE_DATA
- LIBRELEDGER_DATA_DIR=/data
volumes:
- ./data:/data
labels:
libreportal.category: "CATEGORY_DATA" #LIBREPORTAL|CATEGORY_TAG|CATEGORY_DATA
libreportal.title: "TITLE_DATA" #LIBREPORTAL|TITLE_TAG|TITLE_DATA
libreportal.backup.files: "libreledger-service:/data:data"
traefik.enable: TRAEFIK_ENABLE_DATA #LIBREPORTAL|TRAEFIK_ENABLE_TAG|TRAEFIK_ENABLE_DATA
# TRAEFIK_PORT_1_BEGIN
traefik.http.routers.libreledger-service.entrypoints: web,websecure
traefik.http.routers.libreledger-service.rule: Host(`DOMAINSUBNAME_DATA_1`) #LIBREPORTAL|DOMAINSUBNAME_TAG_1|DOMAINSUBNAME_DATA_1
traefik.http.routers.libreledger-service.tls: true
traefik.http.routers.libreledger-service.tls.certresolver: production
traefik.http.services.libreledger-service.loadbalancer.server.port: PORT_INTERNAL_DATA_1 #LIBREPORTAL|PORT_INTERNAL_TAG_1|PORT_INTERNAL_DATA_1
traefik.http.routers.libreledger-service.middlewares: MIDDLEWARE_DATA_1 #LIBREPORTAL|MIDDLEWARE_TAG_1|MIDDLEWARE_DATA_1
# TRAEFIK_PORT_1_END
traefik.docker.network: DOCKER_NETWORK_DATA #LIBREPORTAL|DOCKER_NETWORK_TAG|DOCKER_NETWORK_DATA
# The image's own check polls /api/health every 30s.
healthcheck:
disable: HEALTHCHECK_DATA #LIBREPORTAL|HEALTHCHECK_TAG|HEALTHCHECK_DATA
# GLUETUN_OFF_BEGIN
networks:
DOCKER_NETWORK_DATA: #LIBREPORTAL|DOCKER_NETWORK_TAG|DOCKER_NETWORK_DATA
ipv4_address: IP_DATA_1 #LIBREPORTAL|IP_TAG_1|IP_DATA_1
# GLUETUN_OFF_END
# GLUETUN_ON_BEGIN
# network_mode: "container:gluetun-service"
# GLUETUN_ON_END
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
+76
View File
@@ -0,0 +1,76 @@
#
# =============================================================================
# GENERAL CONFIGURATION
# =============================================================================
# APP_NAME = name of application for use in scripts
# COMPOSE_FILE = default for no app_name in docker-compose file name, app if there is
# BACKUP = if true, include this application in backup operations
# UPDATE_TYPE = auto: new image builds are applied automatically (a recovery snapshot is taken first), manual: only when you press Update
# HEALTHCHECK = if true, default docker health checks for that container will be enabled
# AUTHELIA = if true, use Authelia authentication, if false turned off.
#
CFG_LIBRELEDGER_APP_NAME=libreledger
# MULTI_INSTANCE = if true, this app can run as multiple isolated instances
# (own data/DB/subdomain/backups) via `libreportal instance create`. Only set on
# apps whose compose identity (container_name, Traefik routers, backup labels)
# is instance-safe — see scripts/instance/instance_create.sh.
CFG_LIBRELEDGER_MULTI_INSTANCE=true
CFG_LIBRELEDGER_BACKUP=true
# STORAGE = which storage location holds this app's data. Change it with
# `libreportal app move libreledger <location>` — editing here records intent only.
CFG_LIBRELEDGER_STORAGE=default # Storage Location - Which disk holds this app's data [default:Primary]
CFG_LIBRELEDGER_BACKUP_STRATEGY=auto
# Manual: a new LibreLedger release is applied only when you press Update. The
# image is pinned by version and digest, so nothing changes underneath you.
CFG_LIBRELEDGER_UPDATE_TYPE=manual
CFG_LIBRELEDGER_COMPOSE_FILE=default
CFG_LIBRELEDGER_HEALTHCHECK=true
CFG_LIBRELEDGER_AUTHELIA=false
#
# =============================================================================
# METADATA
# =============================================================================
# CATEGORY = application category for grouping
# TITLE = display name for the application
# DESCRIPTION = short description of the application
# LONG_DESCRIPTION = detailed description of the application
# URL = source repository or documentation URL
# ACTIONS = available actions for this application
#
CFG_LIBRELEDGER_CATEGORY="productivity"
CFG_LIBRELEDGER_TITLE="LibreLedger"
CFG_LIBRELEDGER_DESCRIPTION="Encrypted Money Ledger"
CFG_LIBRELEDGER_LONG_DESCRIPTION="Encrypted personal money ledger: your data is encrypted in your browser; the server never sees it."
CFG_LIBRELEDGER_URL="https://git.libreportal.org/LibrePortal/LibreLedger"
CFG_LIBRELEDGER_ACTIONS="configure|install|restart|shutdown|uninstall"
#
# =============================================================================
# NETWORK CONFIGURATION
# =============================================================================
# DOMAIN = number of domain from the general config, useful when using multiple domains
# WHITELIST = if true only allow whitelisted ips on traefik, if false allow all
# NETWORK = default | gluetun (route outbound traffic through the VPN gateway)
#
CFG_LIBRELEDGER_DOMAIN=1
CFG_LIBRELEDGER_WHITELIST=false
CFG_LIBRELEDGER_NETWORK=default
#
# =============================================================================
# PORT CONFIGURATION
# =============================================================================
# PORT_ = port configuration: app|name|external:internal|access|protocol|login|traefik|webui|description
# - app: application name
# - name: service identifier (webui, api, ssh, etc.)
# - external:internal: port mapping (external can be 'random' for auto-allocation)
# - access: 'public' (internet accessible), 'private' (trusted places only: this home network, your addresses, later your VPN devices), 'disabled' (not running)
# - protocol: 'tcp' or 'udp'
# - login: if true, this port requires basic-auth via Traefik (only meaningful when traefik=true)
# - traefik: if true, Traefik handles this port (reverse proxy)
# - webui: if true, this port serves the main web interface
# - description: human-readable description of the service
#
# Private by default: LibreLedger has no login of its own (the passphrase only
# decrypts), so anyone who can reach it could fetch or overwrite the encrypted
# file. On plain http://<ip>:<port> the app uses its built-in encryption; with a
# domain, https://ledger.<domain> uses the browser's own.
CFG_LIBRELEDGER_PORT_1="libreledger-service|webui|random:8080|private|tcp|false|true|true|LibreLedger||ledger"
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><rect width="512" height="512" rx="112" fill="#1f6f5c"/><rect x="112" y="104" width="288" height="320" rx="32" fill="#f4f1e8"/><path d="M160 184h192M160 248h192M160 312h120" stroke="#1f6f5c" stroke-width="24" stroke-linecap="round"/><circle cx="352" cy="352" r="76" fill="#e0a526"/><rect x="322" y="340" width="60" height="48" rx="8" fill="#1f2a26"/><path d="M334 340v-14a18 18 0 0 1 36 0v14" fill="none" stroke="#1f2a26" stroke-width="10"/><circle cx="352" cy="362" r="7" fill="#e0a526"/></svg>

After

Width:  |  Height:  |  Size: 558 B

-36
View File
@@ -1,36 +0,0 @@
# Build context exclusions. Anything listed here doesn't get sent to
# the Docker daemon at build time — keeps uploads small and ensures
# we always start from a clean state inside the image.
# Node + Next.js artefacts (regenerated inside the container).
**/node_modules
**/.next
**/dist
**/build
# Local runtime data (the DB lives in /data via the bind mount).
**/data
**/*.db
**/*.db-journal
# Editor / VCS / OS junk.
.git
.gitignore
.vscode
.idea
.DS_Store
*.log
# Local env files — secrets stay out of the image.
**/.env
**/.env.*
!**/.env.example
# LibrePortal artefacts — managed outside the build context.
moneyapp.config
moneyapp.tools.json
moneyapp.svg
moneyapp.sh
docker-compose.yml
docker-compose.*.yml
README.md
-41
View File
@@ -1,41 +0,0 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
-81
View File
@@ -1,81 +0,0 @@
# syntax=docker/dockerfile:1.7
# Multi-stage build for moneyapp.
#
# Stages:
# deps — install all deps with the npm cache mounted, so re-builds
# reuse it instead of re-downloading every package.
# builder — compile migrate.ts → migrate.js (drops the runtime tsx
# dep), build the standalone Next.js bundle, then prune dev
# dependencies so only runtime packages survive into runner.
# runner — minimal final image: standalone Next.js bundle + the
# pruned production node_modules (better-sqlite3's native
# binding lives there).
#
# Notes:
# * We do NOT run `npm run db:generate` at build time. The drizzle/
# SQL migrations are committed to the repo, so generating them on
# every build is wasted work and a frequent source of permission /
# filesystem errors during `docker build`.
# * `output: "standalone"` in next.config.ts means we don't need to
# copy the entire builder node_modules into runner — just the bits
# standalone marks as external (better-sqlite3, drizzle).
FROM node:22-bookworm-slim AS deps
WORKDIR /build
RUN apt-get update && apt-get install -y --no-install-recommends \
python3 build-essential \
&& rm -rf /var/lib/apt/lists/*
COPY app/package.json app/package-lock.json* ./
RUN --mount=type=cache,target=/root/.npm npm ci
FROM node:22-bookworm-slim AS builder
WORKDIR /build
COPY --from=deps /build/node_modules ./node_modules
COPY app/ ./
ENV NEXT_TELEMETRY_DISABLED=1
# Compile the TS migrator into a single self-contained JS file so the
# runtime image doesn't need tsx (50+ MB) just to run migrations.
# better-sqlite3 + drizzle stay external so they resolve from
# node_modules at runtime.
RUN npx --yes esbuild src/db/migrate.ts \
--bundle --platform=node --target=node22 \
--external:better-sqlite3 --external:drizzle-orm \
--outfile=migrate.js
# Next.js's "Collecting page data" phase imports server modules to
# gather route metadata. Auth.js + the Drizzle adapter open a sqlite
# connection on import, so /data has to exist and AUTH_SECRET has to
# be non-empty *during the build* even though both get overridden at
# runtime by the real values.
RUN mkdir -p /tmp/build-db \
&& DATABASE_FILE=/tmp/build-db/moneyapp.db \
AUTH_SECRET=build-time-placeholder-not-used-at-runtime \
AUTH_URL=http://localhost:3000 \
npm run build
# Drop devDependencies so the node_modules we copy into runner has
# only what production needs (no typescript, drizzle-kit, eslint...).
RUN npm prune --omit=dev
FROM node:22-bookworm-slim AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
ENV DATABASE_FILE=/data/moneyapp.db
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates \
&& rm -rf /var/lib/apt/lists/* \
&& groupadd -r app && useradd -r -g app app \
&& mkdir -p /data && chown app:app /data
COPY --from=builder /build/public ./public
COPY --from=builder /build/.next/standalone ./
COPY --from=builder /build/.next/static ./.next/static
COPY --from=builder /build/drizzle ./drizzle
COPY --from=builder /build/migrate.js ./migrate.js
# Last so it overlays standalone's traced node_modules with the full
# pruned tree — that way better-sqlite3's native .node binding (which
# standalone marks external) is present at runtime.
COPY --from=builder /build/node_modules ./node_modules
USER app
EXPOSE 3000
VOLUME ["/data"]
CMD ["sh", "-c", "node migrate.js && node server.js"]
-94
View File
@@ -1,94 +0,0 @@
# MoneyApp
Self-hosted money management for one or more users in a household. Recurring rules, projected ledger, budgets, savings goals + compound-interest calculator, and a 12-month forecast with what-if sliders.
Stack: Next.js 15 + SQLite (Drizzle ORM) + Auth.js. Runs as a single Docker container.
## Requirements
- Docker 20.10+ with Compose v2 (`docker compose version` should print v2.x or v5.x)
- Port 3000 free on the host
## Install
```bash
# 1. From inside the MoneyApp folder, create the environment file
cp .env.example .env
# 2. Edit .env and set AUTH_SECRET to a random 32-byte string.
# Generate one with:
echo "AUTH_SECRET=$(openssl rand -base64 32)" >> .env
# 3. Build and start
docker compose up -d --build
```
The first build takes 3-5 minutes (it compiles `better-sqlite3` natively and runs `next build` inside the container). Subsequent starts are instant.
Open <http://localhost:3000> and sign up. The first account becomes the household owner.
## Daily commands
| What | Command |
|---|---|
| Start | `docker compose up -d` |
| Stop | `docker compose down` (data preserved) |
| Logs | `docker compose logs -f` |
| Restart after pulling new code | `docker compose up -d --build` |
| Shell inside container | `docker compose exec moneyapp sh` |
## Updating
Replace the source folder with the new version (keep your `.env` and the `moneyapp-data` Docker volume), then:
```bash
docker compose up -d --build
```
Migrations run automatically on container start.
## Backup
The entire database is one SQLite file at `/data/moneyapp.db` inside the container, mounted from the named volume `moneyapp-data`.
```bash
# Snapshot
docker compose exec moneyapp sh -c 'cat /data/moneyapp.db' > backup-$(date +%F).db
# Restore (with the app stopped)
docker compose down
docker run --rm -v moneyapp_moneyapp-data:/data -v "$PWD":/host alpine \
sh -c 'cp /host/backup-2026-05-07.db /data/moneyapp.db'
docker compose up -d
```
## Accessing from other devices on the LAN
By default the container listens on `0.0.0.0:3000`, so from the same network just use the host's IP: `http://<host-ip>:3000`. For HTTPS or remote access, put a reverse proxy (Caddy, Traefik, nginx) in front.
If you do put it behind a domain, update `AUTH_URL` in `.env` to the public URL — Auth.js uses it to construct callback URLs.
## Troubleshooting
- **`set AUTH_SECRET in .env`** on `up` — you skipped step 2 above. Create `.env` and set `AUTH_SECRET`.
- **Port 3000 already in use** — stop whatever else is on 3000, or change the host-side port in `docker-compose.yml` (e.g. `"3001:3000"`).
- **Database looks empty after a rebuild** — that means the volume was removed. `docker compose down` keeps it; `docker compose down -v` deletes it. Restore from a backup if needed.
## Project layout
```
MoneyApp/
├── Dockerfile multi-stage build (deps → build → runtime)
├── docker-compose.yml service + volume definition
├── .dockerignore
├── .env.example template; copy to .env and fill in
├── README.md
└── app/ the Next.js project
├── src/ application code
├── drizzle/ generated SQL migrations
├── public/ static assets
├── package.json + lock
└── *.ts/.mjs build configs (tsconfig, tailwind, postcss, etc.)
```
The deployment files stay at the root so it's clear what the installer interacts with. Everything inside `app/` is the application itself — the Dockerfile copies it in during the build.
-21
View File
@@ -1,21 +0,0 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/app/globals.css",
"baseColor": "zinc",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"iconLibrary": "lucide"
}
-13
View File
@@ -1,13 +0,0 @@
import type { Config } from "drizzle-kit";
import path from "node:path";
const dbPath = process.env.DATABASE_FILE
?? path.resolve(process.cwd(), "data", "moneyapp.db");
export default {
schema: "./src/db/schema.ts",
out: "./drizzle",
dialect: "sqlite",
dbCredentials: { url: dbPath },
casing: "snake_case",
} satisfies Config;
@@ -1,186 +0,0 @@
CREATE TABLE `auth_accounts` (
`user_id` text NOT NULL,
`type` text NOT NULL,
`provider` text NOT NULL,
`provider_account_id` text NOT NULL,
`refresh_token` text,
`access_token` text,
`expires_at` integer,
`token_type` text,
`scope` text,
`id_token` text,
`session_state` text,
PRIMARY KEY(`provider`, `provider_account_id`),
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE TABLE `bank_accounts` (
`id` text PRIMARY KEY NOT NULL,
`household_id` text NOT NULL,
`owner_user_id` text,
`name` text NOT NULL,
`type` text DEFAULT 'current' NOT NULL,
`currency` text DEFAULT 'GBP' NOT NULL,
`opening_balance_minor` integer DEFAULT 0 NOT NULL,
`opening_date` text NOT NULL,
`is_shared` integer DEFAULT false NOT NULL,
`is_archived` integer DEFAULT false NOT NULL,
`color` text,
`created_at` integer DEFAULT (unixepoch() * 1000) NOT NULL,
`updated_at` integer DEFAULT (unixepoch() * 1000) NOT NULL,
FOREIGN KEY (`household_id`) REFERENCES `households`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`owner_user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE set null
);
--> statement-breakpoint
CREATE INDEX `bank_accounts_household_idx` ON `bank_accounts` (`household_id`);--> statement-breakpoint
CREATE TABLE `bank_connections` (
`id` text PRIMARY KEY NOT NULL,
`household_id` text NOT NULL,
`provider` text NOT NULL,
`provider_institution_id` text,
`status` text DEFAULT 'pending' NOT NULL,
`access_token_encrypted` text,
`refresh_token_encrypted` text,
`expires_at` integer,
`created_at` integer DEFAULT (unixepoch() * 1000) NOT NULL,
`updated_at` integer DEFAULT (unixepoch() * 1000) NOT NULL,
FOREIGN KEY (`household_id`) REFERENCES `households`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE TABLE `budgets` (
`id` text PRIMARY KEY NOT NULL,
`household_id` text NOT NULL,
`category_id` text NOT NULL,
`period` text DEFAULT 'monthly' NOT NULL,
`amount_minor` integer NOT NULL,
`start_date` text NOT NULL,
`created_at` integer DEFAULT (unixepoch() * 1000) NOT NULL,
`updated_at` integer DEFAULT (unixepoch() * 1000) NOT NULL,
FOREIGN KEY (`household_id`) REFERENCES `households`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`category_id`) REFERENCES `categories`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE INDEX `budgets_household_idx` ON `budgets` (`household_id`);--> statement-breakpoint
CREATE TABLE `categories` (
`id` text PRIMARY KEY NOT NULL,
`household_id` text NOT NULL,
`name` text NOT NULL,
`kind` text DEFAULT 'expense' NOT NULL,
`color` text,
`icon` text,
`created_at` integer DEFAULT (unixepoch() * 1000) NOT NULL,
`updated_at` integer DEFAULT (unixepoch() * 1000) NOT NULL,
FOREIGN KEY (`household_id`) REFERENCES `households`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE INDEX `categories_household_idx` ON `categories` (`household_id`);--> statement-breakpoint
CREATE TABLE `households` (
`id` text PRIMARY KEY NOT NULL,
`name` text NOT NULL,
`base_currency` text DEFAULT 'GBP' NOT NULL,
`created_at` integer DEFAULT (unixepoch() * 1000) NOT NULL,
`updated_at` integer DEFAULT (unixepoch() * 1000) NOT NULL
);
--> statement-breakpoint
CREATE TABLE `recurring_rules` (
`id` text PRIMARY KEY NOT NULL,
`household_id` text NOT NULL,
`bank_account_id` text NOT NULL,
`category_id` text,
`description` text NOT NULL,
`amount_minor` integer NOT NULL,
`rrule` text NOT NULL,
`start_date` text NOT NULL,
`end_date` text,
`is_active` integer DEFAULT true NOT NULL,
`created_at` integer DEFAULT (unixepoch() * 1000) NOT NULL,
`updated_at` integer DEFAULT (unixepoch() * 1000) NOT NULL,
FOREIGN KEY (`household_id`) REFERENCES `households`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`bank_account_id`) REFERENCES `bank_accounts`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`category_id`) REFERENCES `categories`(`id`) ON UPDATE no action ON DELETE set null
);
--> statement-breakpoint
CREATE INDEX `recurring_rules_household_idx` ON `recurring_rules` (`household_id`);--> statement-breakpoint
CREATE INDEX `recurring_rules_account_idx` ON `recurring_rules` (`bank_account_id`);--> statement-breakpoint
CREATE TABLE `savings_goals` (
`id` text PRIMARY KEY NOT NULL,
`household_id` text NOT NULL,
`bank_account_id` text,
`name` text NOT NULL,
`target_amount_minor` integer NOT NULL,
`current_amount_minor` integer DEFAULT 0 NOT NULL,
`target_date` text,
`monthly_contribution_minor` integer DEFAULT 0 NOT NULL,
`annual_interest_rate_bp` integer DEFAULT 0 NOT NULL,
`created_at` integer DEFAULT (unixepoch() * 1000) NOT NULL,
`updated_at` integer DEFAULT (unixepoch() * 1000) NOT NULL,
FOREIGN KEY (`household_id`) REFERENCES `households`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`bank_account_id`) REFERENCES `bank_accounts`(`id`) ON UPDATE no action ON DELETE set null
);
--> statement-breakpoint
CREATE INDEX `goals_household_idx` ON `savings_goals` (`household_id`);--> statement-breakpoint
CREATE TABLE `sessions` (
`session_token` text PRIMARY KEY NOT NULL,
`user_id` text NOT NULL,
`expires` integer NOT NULL,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE TABLE `splits` (
`id` text PRIMARY KEY NOT NULL,
`transaction_id` text NOT NULL,
`user_id` text NOT NULL,
`share_minor` integer NOT NULL,
FOREIGN KEY (`transaction_id`) REFERENCES `transactions`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE INDEX `splits_transaction_idx` ON `splits` (`transaction_id`);--> statement-breakpoint
CREATE TABLE `transactions` (
`id` text PRIMARY KEY NOT NULL,
`household_id` text NOT NULL,
`bank_account_id` text NOT NULL,
`category_id` text,
`recurring_rule_id` text,
`date` text NOT NULL,
`amount_minor` integer NOT NULL,
`description` text NOT NULL,
`notes` text,
`status` text DEFAULT 'posted' NOT NULL,
`source` text DEFAULT 'manual' NOT NULL,
`external_id` text,
`created_by_user_id` text,
`created_at` integer DEFAULT (unixepoch() * 1000) NOT NULL,
`updated_at` integer DEFAULT (unixepoch() * 1000) NOT NULL,
FOREIGN KEY (`household_id`) REFERENCES `households`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`bank_account_id`) REFERENCES `bank_accounts`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`category_id`) REFERENCES `categories`(`id`) ON UPDATE no action ON DELETE set null,
FOREIGN KEY (`recurring_rule_id`) REFERENCES `recurring_rules`(`id`) ON UPDATE no action ON DELETE set null,
FOREIGN KEY (`created_by_user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE set null
);
--> statement-breakpoint
CREATE INDEX `transactions_household_date_idx` ON `transactions` (`household_id`,`date`);--> statement-breakpoint
CREATE INDEX `transactions_account_date_idx` ON `transactions` (`bank_account_id`,`date`);--> statement-breakpoint
CREATE INDEX `transactions_rule_idx` ON `transactions` (`recurring_rule_id`);--> statement-breakpoint
CREATE INDEX `transactions_external_idx` ON `transactions` (`external_id`);--> statement-breakpoint
CREATE TABLE `users` (
`id` text PRIMARY KEY NOT NULL,
`name` text,
`email` text NOT NULL,
`email_verified` integer,
`image` text,
`password_hash` text,
`household_id` text,
`role` text DEFAULT 'member' NOT NULL,
`created_at` integer DEFAULT (unixepoch() * 1000) NOT NULL,
`updated_at` integer DEFAULT (unixepoch() * 1000) NOT NULL,
FOREIGN KEY (`household_id`) REFERENCES `households`(`id`) ON UPDATE no action ON DELETE set null
);
--> statement-breakpoint
CREATE UNIQUE INDEX `users_email_unique` ON `users` (`email`);--> statement-breakpoint
CREATE TABLE `verification_tokens` (
`identifier` text NOT NULL,
`token` text NOT NULL,
`expires` integer NOT NULL,
PRIMARY KEY(`identifier`, `token`)
);
File diff suppressed because it is too large Load Diff
@@ -1,13 +0,0 @@
{
"version": "7",
"dialect": "sqlite",
"entries": [
{
"idx": 0,
"version": "6",
"when": 1778161520872,
"tag": "0000_fresh_silverclaw",
"breakpoints": true
}
]
}
-25
View File
@@ -1,25 +0,0 @@
import { dirname } from "path";
import { fileURLToPath } from "url";
import { FlatCompat } from "@eslint/eslintrc";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const compat = new FlatCompat({
baseDirectory: __dirname,
});
const eslintConfig = [
...compat.extends("next/core-web-vitals", "next/typescript"),
{
ignores: [
"node_modules/**",
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
],
},
];
export default eslintConfig;
-8
View File
@@ -1,8 +0,0 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
output: "standalone",
serverExternalPackages: ["better-sqlite3"],
};
export default nextConfig;
File diff suppressed because it is too large Load Diff
-53
View File
@@ -1,53 +0,0 @@
{
"name": "moneyapp",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint",
"db:generate": "drizzle-kit generate",
"db:migrate": "tsx src/db/migrate.ts",
"db:studio": "drizzle-kit studio"
},
"dependencies": {
"@auth/drizzle-adapter": "^1.7.4",
"@tanstack/react-table": "^8.20.5",
"bcryptjs": "^2.4.3",
"better-sqlite3": "^11.5.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"drizzle-orm": "^0.36.4",
"framer-motion": "^11.13.1",
"lucide-react": "^0.468.0",
"next": "15.5.16",
"next-auth": "5.0.0-beta.25",
"papaparse": "^5.4.1",
"react": "19.1.0",
"react-dom": "19.1.0",
"recharts": "^2.13.3",
"rrule": "^2.8.1",
"tailwind-merge": "^2.5.5",
"tailwindcss-animate": "^1.0.7",
"zod": "^3.23.8"
},
"devDependencies": {
"@eslint/eslintrc": "^3",
"@types/bcryptjs": "^2.4.6",
"@types/better-sqlite3": "^7.6.12",
"@types/node": "^20",
"@types/papaparse": "^5.3.15",
"@types/react": "^19",
"@types/react-dom": "^19",
"autoprefixer": "^10.4.20",
"drizzle-kit": "^0.28.1",
"eslint": "^9",
"eslint-config-next": "15.5.16",
"postcss": "^8.4.49",
"tailwindcss": "^3.4.15",
"tsx": "^4.19.2",
"typescript": "^5"
}
}
@@ -1,8 +0,0 @@
const config = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
export default config;
-1
View File
@@ -1 +0,0 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

Before

Width:  |  Height:  |  Size: 391 B

-1
View File
@@ -1 +0,0 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

Before

Width:  |  Height:  |  Size: 1.0 KiB

-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

@@ -1 +0,0 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

Before

Width:  |  Height:  |  Size: 128 B

@@ -1 +0,0 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

Before

Width:  |  Height:  |  Size: 385 B

@@ -1,7 +0,0 @@
"use server";
import { signOut } from "@/auth";
export async function signOutAction() {
await signOut({ redirectTo: "/login" });
}
@@ -1,40 +0,0 @@
"use server";
import { db } from "@/db";
import { budgets } from "@/db/schema";
import { requireHousehold } from "@/lib/session";
import { and, eq } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { z } from "zod";
const BudgetSchema = z.object({
categoryId: z.string().min(1),
amount: z.coerce.number().nonnegative(),
period: z.enum(["weekly", "monthly", "yearly"]),
});
export async function addBudgetAction(formData: FormData) {
const { householdId } = await requireHousehold();
const parsed = BudgetSchema.parse({
categoryId: formData.get("categoryId"),
amount: formData.get("amount"),
period: formData.get("period"),
});
await db.insert(budgets).values({
householdId,
categoryId: parsed.categoryId,
amountMinor: Math.round(parsed.amount * 100),
period: parsed.period,
startDate: new Date().toISOString().slice(0, 10),
});
revalidatePath("/budgets");
revalidatePath("/");
}
export async function deleteBudgetAction(formData: FormData) {
const { householdId } = await requireHousehold();
const id = String(formData.get("id"));
await db.delete(budgets).where(and(eq(budgets.id, id), eq(budgets.householdId, householdId)));
revalidatePath("/budgets");
revalidatePath("/");
}
@@ -1,119 +0,0 @@
import { db } from "@/db";
import { budgets, categories, transactions } from "@/db/schema";
import { and, eq, gte, lte } from "drizzle-orm";
import { requireHousehold } from "@/lib/session";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Progress } from "@/components/ui/progress";
import { formatMoney } from "@/lib/utils";
import { addBudgetAction, deleteBudgetAction } from "./actions";
import { startOfMonth, endOfMonth } from "date-fns";
import { Trash2 } from "lucide-react";
export default async function BudgetsPage() {
const { householdId } = await requireHousehold();
const today = new Date();
const cats = await db.select().from(categories).where(eq(categories.householdId, householdId));
const all = await db.select().from(budgets).where(eq(budgets.householdId, householdId));
const monthStartIso = startOfMonth(today).toISOString().slice(0, 10);
const monthEndIso = endOfMonth(today).toISOString().slice(0, 10);
const txs = await db
.select()
.from(transactions)
.where(
and(
eq(transactions.householdId, householdId),
eq(transactions.status, "posted"),
gte(transactions.date, monthStartIso),
lte(transactions.date, monthEndIso),
),
);
const spent: Record<string, number> = {};
for (const t of txs) {
if (!t.categoryId || t.amountMinor >= 0) continue;
spent[t.categoryId] = (spent[t.categoryId] ?? 0) + Math.abs(t.amountMinor);
}
const expenseCats = cats.filter((c) => c.kind === "expense");
return (
<div className="space-y-6 max-w-5xl mx-auto">
<div>
<h1 className="text-2xl font-bold tracking-tight">Budgets</h1>
<p className="text-sm text-muted-foreground">Set spending caps per category. Monthly progress is computed from posted transactions.</p>
</div>
<Card>
<CardHeader>
<CardTitle>New budget</CardTitle>
</CardHeader>
<CardContent>
<form action={addBudgetAction} className="grid grid-cols-1 sm:grid-cols-[1fr_140px_140px_auto] gap-3 items-end">
<div className="space-y-1.5">
<Label>Category</Label>
<select name="categoryId" required className="h-9 w-full rounded-md border bg-input/40 px-2 text-sm">
{expenseCats.map((c) => <option key={c.id} value={c.id}>{c.name}</option>)}
</select>
</div>
<div className="space-y-1.5">
<Label>Amount</Label>
<Input name="amount" type="number" step="0.01" required placeholder="400.00" />
</div>
<div className="space-y-1.5">
<Label>Period</Label>
<select name="period" defaultValue="monthly" className="h-9 w-full rounded-md border bg-input/40 px-2 text-sm">
<option value="weekly">Weekly</option>
<option value="monthly">Monthly</option>
<option value="yearly">Yearly</option>
</select>
</div>
<Button type="submit">Add</Button>
</form>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>This month</CardTitle>
<CardDescription>{all.length} budgets configured</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{all.length === 0 ? (
<p className="text-sm text-muted-foreground">No budgets yet.</p>
) : all.map((b) => {
const cat = cats.find((c) => c.id === b.categoryId);
const used = spent[b.categoryId] ?? 0;
const pct = Math.round((used / b.amountMinor) * 100);
const over = used > b.amountMinor;
return (
<div key={b.id} className="space-y-1.5">
<div className="flex items-center justify-between text-sm">
<div className="font-medium flex items-center gap-2">
{cat?.name ?? "Category"}
<span className="text-xs text-muted-foreground capitalize">{b.period}</span>
</div>
<div className="flex items-center gap-3">
<div className="font-mono tabular text-sm">
<span className={over ? "text-destructive" : ""}>{formatMoney(used)}</span>
<span className="text-muted-foreground"> / {formatMoney(b.amountMinor)}</span>
</div>
<form action={deleteBudgetAction}>
<input type="hidden" name="id" value={b.id} />
<Button type="submit" variant="ghost" size="icon" className="text-muted-foreground hover:text-destructive">
<Trash2 className="size-4" />
</Button>
</form>
</div>
</div>
<Progress value={pct} className={over ? "[&>div]:bg-destructive" : ""} />
</div>
);
})}
</CardContent>
</Card>
</div>
);
}
@@ -1,98 +0,0 @@
"use client";
import { useMemo, useState } from "react";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { formatMoney } from "@/lib/utils";
import { ResponsiveContainer, AreaChart, Area, CartesianGrid, XAxis, YAxis, Tooltip, ReferenceLine } from "recharts";
export function ForecastChart({
months,
startBalance,
}: {
months: { label: string; date: string; balance: number; deltaMonth: number }[];
startBalance: number;
}) {
const [extraIncome, setExtraIncome] = useState(0);
const [extraSpend, setExtraSpend] = useState(0);
const data = useMemo(() => {
let bal = startBalance;
const baseline: { month: string; Baseline: number; Adjusted: number }[] = [];
let adj = startBalance;
for (let i = 0; i < months.length; i++) {
bal += months[i].deltaMonth;
adj += months[i].deltaMonth + Math.round(extraIncome * 100) - Math.round(extraSpend * 100);
baseline.push({
month: months[i].label,
Baseline: Math.round(bal / 100),
Adjusted: Math.round(adj / 100),
});
}
return baseline;
}, [months, extraIncome, extraSpend, startBalance]);
const finalBaseline = data.at(-1)?.Baseline ?? 0;
const finalAdjusted = data.at(-1)?.Adjusted ?? 0;
return (
<div className="space-y-4">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-1.5">
<Label>+ Extra monthly income</Label>
<Input type="number" step="50" value={extraIncome} onChange={(e) => setExtraIncome(Number(e.target.value))} />
</div>
<div className="space-y-1.5">
<Label> Extra monthly spend</Label>
<Input type="number" step="50" value={extraSpend} onChange={(e) => setExtraSpend(Number(e.target.value))} />
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3 text-sm">
<div className="rounded-md border bg-card/40 p-3">
<div className="text-xs text-muted-foreground">In 12 months (baseline)</div>
<div className="font-mono tabular text-xl mt-1">{formatMoney(finalBaseline * 100)}</div>
</div>
<div className="rounded-md border bg-card/40 p-3">
<div className="text-xs text-muted-foreground">In 12 months (adjusted)</div>
<div className={`font-mono tabular text-xl mt-1 ${finalAdjusted >= finalBaseline ? "text-success" : "text-destructive"}`}>
{formatMoney(finalAdjusted * 100)}
</div>
</div>
<div className="rounded-md border bg-card/40 p-3">
<div className="text-xs text-muted-foreground">Difference</div>
<div className={`font-mono tabular text-xl mt-1 ${finalAdjusted - finalBaseline >= 0 ? "text-success" : "text-destructive"}`}>
{formatMoney((finalAdjusted - finalBaseline) * 100)}
</div>
</div>
</div>
<div className="h-80">
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={data} margin={{ top: 8, right: 12, bottom: 0, left: 0 }}>
<defs>
<linearGradient id="baseGrad" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="oklch(0.72 0.13 195)" stopOpacity={0.45} />
<stop offset="100%" stopColor="oklch(0.72 0.13 195)" stopOpacity={0} />
</linearGradient>
<linearGradient id="adjGrad" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="oklch(0.72 0.17 150)" stopOpacity={0.35} />
<stop offset="100%" stopColor="oklch(0.72 0.17 150)" stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid stroke="oklch(0.28 0.014 235 / 0.4)" strokeDasharray="3 3" />
<XAxis dataKey="month" tickLine={false} axisLine={false} tick={{ fill: "oklch(0.7 0.018 220)", fontSize: 11 }} />
<YAxis tickLine={false} axisLine={false} tick={{ fill: "oklch(0.7 0.018 220)", fontSize: 11 }} width={70} />
<Tooltip
contentStyle={{ background: "oklch(0.19 0.014 235)", border: "1px solid oklch(0.28 0.014 235)", borderRadius: 8, fontSize: 12 }}
formatter={(v: number) => formatMoney(Math.round(v * 100))}
/>
<ReferenceLine y={0} stroke="oklch(0.65 0.22 25 / 0.6)" strokeDasharray="2 2" />
<Area type="monotone" dataKey="Baseline" stroke="oklch(0.72 0.13 195)" fill="url(#baseGrad)" strokeWidth={2} />
<Area type="monotone" dataKey="Adjusted" stroke="oklch(0.72 0.17 150)" fill="url(#adjGrad)" strokeWidth={2} />
</AreaChart>
</ResponsiveContainer>
</div>
</div>
);
}
@@ -1,70 +0,0 @@
import { db } from "@/db";
import { bankAccounts, transactions } from "@/db/schema";
import { and, eq } from "drizzle-orm";
import { requireHousehold } from "@/lib/session";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { loadLedger } from "@/lib/ledger-service";
import { addMonths, format, startOfMonth } from "date-fns";
import { ForecastChart } from "./forecast-chart";
export default async function ForecastPage() {
const { householdId } = await requireHousehold();
const today = new Date();
const horizonEnd = addMonths(today, 12);
const accts = await db.select().from(bankAccounts).where(and(eq(bankAccounts.householdId, householdId), eq(bankAccounts.isArchived, false)));
// Today's total balance
let totalToday = 0;
for (const a of accts) {
const txs = await db
.select()
.from(transactions)
.where(and(eq(transactions.bankAccountId, a.id), eq(transactions.householdId, householdId), eq(transactions.status, "posted")));
const sum = txs.reduce((s, t) => s + t.amountMinor, 0);
totalToday += a.openingBalanceMinor + sum;
}
const { rows } = await loadLedger({ householdId, from: startOfMonth(today), to: horizonEnd });
const future = rows.filter((r) => r.date >= today.toISOString().slice(0, 10));
// Monthly snapshot of total balance after the last txn that month
const months: { label: string; date: string; balance: number; deltaMonth: number }[] = [];
let running = totalToday;
for (let i = 0; i < 12; i++) {
const monthStart = startOfMonth(addMonths(today, i));
const monthEnd = startOfMonth(addMonths(today, i + 1));
const monthIso = monthStart.toISOString().slice(0, 10);
const nextIso = monthEnd.toISOString().slice(0, 10);
const monthTxs = future.filter((r) => r.date >= monthIso && r.date < nextIso);
const delta = monthTxs.reduce((s, r) => s + r.amountMinor, 0);
running += delta;
months.push({
label: format(monthStart, "MMM yy"),
date: monthIso,
balance: running,
deltaMonth: delta,
});
}
return (
<div className="space-y-6 max-w-7xl mx-auto">
<div>
<h1 className="text-2xl font-bold tracking-tight">Forecast</h1>
<p className="text-sm text-muted-foreground">
Projected total balance across all accounts based on your recurring rules. Adjust the sliders to see what-ifs.
</p>
</div>
<Card>
<CardHeader>
<CardTitle>Next 12 months</CardTitle>
<CardDescription>Starting at {new Intl.NumberFormat("en-GB", { style: "currency", currency: "GBP" }).format(totalToday / 100)} today</CardDescription>
</CardHeader>
<CardContent>
<ForecastChart months={months} startBalance={totalToday} />
</CardContent>
</Card>
</div>
);
}
@@ -1,46 +0,0 @@
"use server";
import { db } from "@/db";
import { savingsGoals } from "@/db/schema";
import { requireHousehold } from "@/lib/session";
import { and, eq } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { z } from "zod";
const GoalSchema = z.object({
name: z.string().min(1).max(80),
targetAmount: z.coerce.number().nonnegative(),
currentAmount: z.coerce.number().nonnegative().default(0),
targetDate: z.string().optional().nullable().or(z.literal("")),
monthlyContribution: z.coerce.number().nonnegative().default(0),
annualInterestRatePct: z.coerce.number().nonnegative().default(0),
});
export async function addGoalAction(formData: FormData) {
const { householdId } = await requireHousehold();
const parsed = GoalSchema.parse({
name: formData.get("name"),
targetAmount: formData.get("targetAmount"),
currentAmount: formData.get("currentAmount") ?? 0,
targetDate: formData.get("targetDate") || null,
monthlyContribution: formData.get("monthlyContribution") ?? 0,
annualInterestRatePct: formData.get("annualInterestRatePct") ?? 0,
});
await db.insert(savingsGoals).values({
householdId,
name: parsed.name,
targetAmountMinor: Math.round(parsed.targetAmount * 100),
currentAmountMinor: Math.round(parsed.currentAmount * 100),
targetDate: parsed.targetDate || null,
monthlyContributionMinor: Math.round(parsed.monthlyContribution * 100),
annualInterestRate: Math.round(parsed.annualInterestRatePct * 100), // basis points
});
revalidatePath("/goals");
}
export async function deleteGoalAction(formData: FormData) {
const { householdId } = await requireHousehold();
const id = String(formData.get("id"));
await db.delete(savingsGoals).where(and(eq(savingsGoals.id, id), eq(savingsGoals.householdId, householdId)));
revalidatePath("/goals");
}
@@ -1,111 +0,0 @@
"use client";
import { useMemo, useState } from "react";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { compoundInterest, monthsToReachGoal } from "@/lib/finance";
import { formatMoney } from "@/lib/utils";
import { LineChart, Line, ResponsiveContainer, XAxis, YAxis, Tooltip, CartesianGrid, Area, AreaChart } from "recharts";
export function InterestCalculator() {
const [principal, setPrincipal] = useState(1000);
const [monthly, setMonthly] = useState(400);
const [rate, setRate] = useState(4.5);
const [years, setYears] = useState(5);
const result = useMemo(
() =>
compoundInterest({
principalMinor: Math.round(principal * 100),
monthlyContributionMinor: Math.round(monthly * 100),
annualRate: rate / 100,
months: Math.round(years * 12),
}),
[principal, monthly, rate, years],
);
const months20k = useMemo(
() =>
monthsToReachGoal({
principalMinor: Math.round(principal * 100),
monthlyContributionMinor: Math.round(monthly * 100),
annualRate: rate / 100,
goalMinor: 20000_00,
}),
[principal, monthly, rate],
);
const data = result.series.map((s) => ({
month: s.month,
Balance: s.balanceMinor / 100,
Contributed: s.contributedMinor / 100,
Interest: s.interestMinor / 100,
}));
return (
<div className="space-y-4">
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label>Starting balance</Label>
<Input type="number" step="0.01" value={principal} onChange={(e) => setPrincipal(Number(e.target.value))} />
</div>
<div className="space-y-1.5">
<Label>Monthly contribution</Label>
<Input type="number" step="0.01" value={monthly} onChange={(e) => setMonthly(Number(e.target.value))} />
</div>
<div className="space-y-1.5">
<Label>Annual rate (%)</Label>
<Input type="number" step="0.01" value={rate} onChange={(e) => setRate(Number(e.target.value))} />
</div>
<div className="space-y-1.5">
<Label>Years</Label>
<Input type="number" step="0.5" value={years} onChange={(e) => setYears(Number(e.target.value))} />
</div>
</div>
<div className="rounded-md border bg-card/40 p-3 text-sm space-y-1">
<div className="flex justify-between">
<span className="text-muted-foreground">Final balance</span>
<span className="font-mono tabular text-lg font-semibold">{formatMoney(result.finalMinor)}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Total contributed</span>
<span className="font-mono tabular">{formatMoney(result.series.at(-1)?.contributedMinor ?? 0)}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Interest earned</span>
<span className="font-mono tabular text-success">{formatMoney(result.series.at(-1)?.interestMinor ?? 0)}</span>
</div>
{months20k && (
<div className="flex justify-between">
<span className="text-muted-foreground">Months to £20,000</span>
<span className="font-mono tabular">{months20k}m</span>
</div>
)}
</div>
<div className="h-56">
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={data} margin={{ top: 8, right: 12, bottom: 0, left: 0 }}>
<defs>
<linearGradient id="balGrad" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="oklch(0.72 0.13 195)" stopOpacity={0.5} />
<stop offset="100%" stopColor="oklch(0.72 0.13 195)" stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid stroke="oklch(0.28 0.014 235 / 0.4)" strokeDasharray="3 3" />
<XAxis dataKey="month" tickLine={false} axisLine={false} tick={{ fill: "oklch(0.7 0.018 220)", fontSize: 11 }} />
<YAxis tickLine={false} axisLine={false} tick={{ fill: "oklch(0.7 0.018 220)", fontSize: 11 }} width={60} />
<Tooltip
contentStyle={{ background: "oklch(0.19 0.014 235)", border: "1px solid oklch(0.28 0.014 235)", borderRadius: 8, fontSize: 12 }}
labelFormatter={(m) => `Month ${m}`}
formatter={(v: number) => formatMoney(Math.round(v * 100))}
/>
<Area type="monotone" dataKey="Balance" stroke="oklch(0.72 0.13 195)" fill="url(#balGrad)" strokeWidth={2} />
<Line type="monotone" dataKey="Contributed" stroke="oklch(0.7 0.018 220)" strokeWidth={1.5} dot={false} />
</AreaChart>
</ResponsiveContainer>
</div>
</div>
);
}
@@ -1,116 +0,0 @@
import { db } from "@/db";
import { savingsGoals } from "@/db/schema";
import { eq } from "drizzle-orm";
import { requireHousehold } from "@/lib/session";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Progress } from "@/components/ui/progress";
import { formatMoney } from "@/lib/utils";
import { Trash2 } from "lucide-react";
import { addGoalAction, deleteGoalAction } from "./actions";
import { InterestCalculator } from "./calculator";
export default async function GoalsPage() {
const { householdId } = await requireHousehold();
const goals = await db.select().from(savingsGoals).where(eq(savingsGoals.householdId, householdId));
return (
<div className="space-y-6 max-w-6xl mx-auto">
<div>
<h1 className="text-2xl font-bold tracking-tight">Goals & calculator</h1>
<p className="text-sm text-muted-foreground">
Set savings targets and project growth with compound interest.
</p>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<Card>
<CardHeader>
<CardTitle>Add savings goal</CardTitle>
</CardHeader>
<CardContent>
<form action={addGoalAction} className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div className="space-y-1.5 sm:col-span-2">
<Label>Name</Label>
<Input name="name" required placeholder="House deposit" />
</div>
<div className="space-y-1.5">
<Label>Target amount</Label>
<Input name="targetAmount" type="number" step="0.01" required placeholder="20000" />
</div>
<div className="space-y-1.5">
<Label>Current saved</Label>
<Input name="currentAmount" type="number" step="0.01" placeholder="0" />
</div>
<div className="space-y-1.5">
<Label>Target date</Label>
<Input name="targetDate" type="date" />
</div>
<div className="space-y-1.5">
<Label>Monthly contribution</Label>
<Input name="monthlyContribution" type="number" step="0.01" placeholder="400" />
</div>
<div className="space-y-1.5 sm:col-span-2">
<Label>Annual interest rate (%)</Label>
<Input name="annualInterestRatePct" type="number" step="0.01" placeholder="4.5" />
</div>
<div className="sm:col-span-2">
<Button type="submit" className="w-full">Add goal</Button>
</div>
</form>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Compound interest calculator</CardTitle>
<CardDescription>Project a savings amount over time</CardDescription>
</CardHeader>
<CardContent>
<InterestCalculator />
</CardContent>
</Card>
</div>
<Card>
<CardHeader>
<CardTitle>Your goals</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{goals.length === 0 ? (
<p className="text-sm text-muted-foreground">None yet add one above.</p>
) : goals.map((g) => {
const pct = Math.min(100, Math.round((g.currentAmountMinor / g.targetAmountMinor) * 100));
return (
<div key={g.id} className="space-y-1.5">
<div className="flex items-center justify-between text-sm">
<div className="font-medium">{g.name}</div>
<div className="flex items-center gap-3">
<div className="font-mono tabular text-sm">
{formatMoney(g.currentAmountMinor)}
<span className="text-muted-foreground"> / {formatMoney(g.targetAmountMinor)}</span>
</div>
<form action={deleteGoalAction}>
<input type="hidden" name="id" value={g.id} />
<Button type="submit" variant="ghost" size="icon" className="text-muted-foreground hover:text-destructive">
<Trash2 className="size-4" />
</Button>
</form>
</div>
</div>
<Progress value={pct} />
<div className="text-xs text-muted-foreground">
{g.monthlyContributionMinor > 0 ? `${formatMoney(g.monthlyContributionMinor)}/mo` : "No monthly contribution set"}
{g.annualInterestRate > 0 ? ` · ${(g.annualInterestRate / 100).toFixed(2)}% APR` : ""}
{g.targetDate ? ` · target ${g.targetDate}` : ""}
</div>
</div>
);
})}
</CardContent>
</Card>
</div>
);
}
@@ -1,95 +0,0 @@
"use server";
import { db } from "@/db";
import { transactions } from "@/db/schema";
import { requireHousehold } from "@/lib/session";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import Papa from "papaparse";
const ImportSchema = z.object({
csv: z.string().min(1),
bankAccountId: z.string().min(1),
});
type Row = Record<string, string>;
function parseAmount(s: string | undefined): number {
if (!s) return 0;
const cleaned = s.replace(/[^\d.\-]/g, "");
const n = parseFloat(cleaned);
return isNaN(n) ? 0 : Math.round(n * 100);
}
function parseDate(s: string | undefined): string | null {
if (!s) return null;
const trimmed = s.trim();
// ISO
const iso = /^(\d{4})-(\d{2})-(\d{2})/.exec(trimmed);
if (iso) return `${iso[1]}-${iso[2]}-${iso[3]}`;
// dd/mm/yyyy
const dmy = /^(\d{1,2})[\/\-](\d{1,2})[\/\-](\d{4})/.exec(trimmed);
if (dmy) {
const d = String(dmy[1]).padStart(2, "0");
const m = String(dmy[2]).padStart(2, "0");
return `${dmy[3]}-${m}-${d}`;
}
// Month "May 9th"
const md = /^([A-Za-z]+)\s+(\d{1,2})/.exec(trimmed);
if (md) {
const months: Record<string, number> = {
jan: 1, feb: 2, mar: 3, apr: 4, may: 5, jun: 6,
jul: 7, aug: 8, sep: 9, oct: 10, nov: 11, dec: 12,
};
const monKey = md[1].slice(0, 3).toLowerCase();
const mon = months[monKey];
if (!mon) return null;
const day = String(parseInt(md[2], 10)).padStart(2, "0");
const yr = new Date().getFullYear();
return `${yr}-${String(mon).padStart(2, "0")}-${day}`;
}
return null;
}
export async function importCsvAction(formData: FormData) {
const { householdId, user } = await requireHousehold();
const parsed = ImportSchema.parse({
csv: formData.get("csv"),
bankAccountId: formData.get("bankAccountId"),
});
const result = Papa.parse<Row>(parsed.csv, { header: true, skipEmptyLines: true });
if (result.errors.length > 0) throw new Error(result.errors[0].message);
const inserts: typeof transactions.$inferInsert[] = [];
for (const row of result.data) {
const lower: Row = {};
for (const [k, v] of Object.entries(row)) lower[k.trim().toLowerCase()] = String(v ?? "").trim();
const date = parseDate(lower.date) ?? parseDate(lower.day);
if (!date) continue;
const description = lower.description || lower.desc || lower.memo || "";
if (!description) continue;
const inAmount = parseAmount(lower.in || lower.income || lower.credit);
const outAmount = parseAmount(lower.out || lower.expense || lower.debit);
let amountMinor = parseAmount(lower.amount);
if (amountMinor === 0 && (inAmount || outAmount)) {
amountMinor = inAmount - Math.abs(outAmount);
}
if (amountMinor === 0) continue;
inserts.push({
householdId,
bankAccountId: parsed.bankAccountId,
date,
amountMinor,
description,
status: "posted",
source: "import",
createdByUserId: user.id,
});
}
if (inserts.length > 0) await db.insert(transactions).values(inserts);
revalidatePath("/ledger");
revalidatePath("/");
}
@@ -1,53 +0,0 @@
import { db } from "@/db";
import { bankAccounts } from "@/db/schema";
import { eq } from "drizzle-orm";
import { requireHousehold } from "@/lib/session";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { importCsvAction } from "./actions";
export default async function ImportPage() {
const { householdId } = await requireHousehold();
const accts = await db.select().from(bankAccounts).where(eq(bankAccounts.householdId, householdId));
return (
<div className="space-y-6 max-w-4xl mx-auto">
<div>
<h1 className="text-2xl font-bold tracking-tight">Import CSV</h1>
<p className="text-sm text-muted-foreground">
Paste a CSV with headers like <code className="font-mono text-xs">date, description, in, out</code>{" "}
(or <code className="font-mono text-xs">amount</code>). Rows are imported as posted transactions.
</p>
</div>
<Card>
<CardHeader>
<CardTitle>Paste CSV</CardTitle>
<CardDescription>From your spreadsheet copy the rows including the header.</CardDescription>
</CardHeader>
<CardContent>
<form action={importCsvAction} className="space-y-3">
<div className="space-y-1.5">
<Label>Target account</Label>
<select name="bankAccountId" required className="h-9 w-full rounded-md border bg-input/40 px-2 text-sm">
{accts.map((a) => <option key={a.id} value={a.id}>{a.name}</option>)}
</select>
</div>
<div className="space-y-1.5">
<Label>CSV</Label>
<textarea
name="csv"
required
rows={14}
className="w-full rounded-md border bg-input/40 p-3 text-sm font-mono"
placeholder="date,description,in,out&#10;May 9th,Netflix,,10.00&#10;May 11th,Weekly Rent,,400.00"
/>
</div>
<Button type="submit">Import</Button>
</form>
</CardContent>
</Card>
</div>
);
}
@@ -1,30 +0,0 @@
import { redirect } from "next/navigation";
import { db } from "@/db";
import { bankAccounts, users } from "@/db/schema";
import { eq } from "drizzle-orm";
import { requireUser } from "@/lib/session";
import { Sidebar } from "@/components/sidebar";
import { Topbar } from "@/components/topbar";
export default async function AppLayout({ children }: { children: React.ReactNode }) {
const user = await requireUser();
const [u] = await db.select().from(users).where(eq(users.id, user.id));
if (!u?.householdId) redirect("/onboarding");
const accts = await db
.select({ id: bankAccounts.id })
.from(bankAccounts)
.where(eq(bankAccounts.householdId, u.householdId));
if (accts.length === 0) redirect("/onboarding");
return (
<div className="min-h-screen flex">
<Sidebar />
<div className="flex-1 flex flex-col min-w-0">
<Topbar name={user.name ?? user.email} />
<div className="flex-1 px-4 sm:px-8 py-6">{children}</div>
</div>
</div>
);
}
@@ -1,86 +0,0 @@
"use server";
import { db } from "@/db";
import { transactions } from "@/db/schema";
import { requireHousehold } from "@/lib/session";
import { eq, and } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { z } from "zod";
const TxSchema = z.object({
bankAccountId: z.string().min(1),
date: z.string().min(8),
description: z.string().min(1).max(200),
amount: z.coerce.number(),
categoryId: z.string().optional().nullable().or(z.literal("")),
status: z.enum(["posted", "projected"]).default("posted"),
});
export async function addTransactionAction(formData: FormData) {
const { householdId, user } = await requireHousehold();
const parsed = TxSchema.parse({
bankAccountId: formData.get("bankAccountId"),
date: formData.get("date"),
description: formData.get("description"),
amount: formData.get("amount"),
categoryId: formData.get("categoryId") || null,
status: formData.get("status") || "posted",
});
await db.insert(transactions).values({
householdId,
bankAccountId: parsed.bankAccountId,
categoryId: parsed.categoryId || null,
date: parsed.date,
amountMinor: Math.round(parsed.amount * 100),
description: parsed.description,
status: parsed.status,
source: "manual",
createdByUserId: user.id,
});
revalidatePath("/ledger");
revalidatePath("/");
}
export async function markPostedAction(formData: FormData) {
const { householdId, user } = await requireHousehold();
const id = String(formData.get("id"));
const date = String(formData.get("date"));
const recurringRuleId = formData.get("recurringRuleId")?.toString() || null;
const bankAccountId = String(formData.get("bankAccountId"));
const description = String(formData.get("description"));
const amount = Number(formData.get("amount") ?? 0);
const categoryId = formData.get("categoryId")?.toString() || null;
if (id.startsWith("proj_")) {
await db.insert(transactions).values({
householdId,
bankAccountId,
categoryId,
date,
amountMinor: Math.round(amount * 100),
description,
status: "posted",
source: "recurring",
recurringRuleId,
createdByUserId: user.id,
});
} else {
await db
.update(transactions)
.set({ status: "posted" })
.where(and(eq(transactions.id, id), eq(transactions.householdId, householdId)));
}
revalidatePath("/ledger");
revalidatePath("/");
}
export async function deleteTransactionAction(formData: FormData) {
const { householdId } = await requireHousehold();
const id = String(formData.get("id"));
if (id.startsWith("proj_")) return; // can't delete a projection without cancelling the rule
await db
.delete(transactions)
.where(and(eq(transactions.id, id), eq(transactions.householdId, householdId)));
revalidatePath("/ledger");
revalidatePath("/");
}
@@ -1,317 +0,0 @@
"use client";
import { useMemo, useState } from "react";
import {
flexRender,
getCoreRowModel,
useReactTable,
type ColumnDef,
} from "@tanstack/react-table";
import { motion } from "framer-motion";
import { Check, Trash2 } from "lucide-react";
import Link from "next/link";
import { useRouter, useSearchParams } from "next/navigation";
import type { LedgerRow } from "@/lib/recurring";
import type { BankAccount, Category } from "@/db/schema";
import { cn, formatMoney } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { addTransactionAction, deleteTransactionAction, markPostedAction } from "./actions";
const RANGES = [
{ v: "1", l: "1m" },
{ v: "3", l: "3m" },
{ v: "6", l: "6m" },
{ v: "12", l: "1y" },
];
export function LedgerTable({
rows,
accounts,
categories,
selectedAccountId,
range,
}: {
rows: LedgerRow[];
accounts: BankAccount[];
categories: Category[];
selectedAccountId?: string;
range: string;
}) {
const router = useRouter();
const sp = useSearchParams();
const today = new Date().toISOString().slice(0, 10);
const accountMap = useMemo(() => new Map(accounts.map((a) => [a.id, a])), [accounts]);
const catMap = useMemo(() => new Map(categories.map((c) => [c.id, c])), [categories]);
const columns = useMemo<ColumnDef<LedgerRow>[]>(
() => [
{
id: "date",
header: "Date",
cell: ({ row }) => {
const r = row.original;
const isToday = r.date === today;
return (
<div className="flex items-center gap-2">
<span className={cn("font-mono text-xs", isToday ? "text-primary font-semibold" : "text-muted-foreground")}>
{isToday ? "TODAY" : r.date}
</span>
</div>
);
},
},
{
id: "type",
header: "Type",
cell: ({ row }) => {
const r = row.original;
const income = r.amountMinor > 0;
return (
<Badge variant={income ? "success" : "warning"} className="text-[10px] uppercase tracking-wide">
{income ? "In" : "Out"}
</Badge>
);
},
},
{
id: "description",
header: "Description",
cell: ({ row }) => {
const r = row.original;
const acct = accountMap.get(r.bankAccountId);
const cat = r.categoryId ? catMap.get(r.categoryId) : null;
return (
<div className="min-w-0">
<div className="font-medium truncate">{r.description}</div>
<div className="text-xs text-muted-foreground truncate">
{acct?.name}
{cat ? ` · ${cat.name}` : ""}
</div>
</div>
);
},
},
{
id: "in",
header: () => <div className="text-right">In</div>,
cell: ({ row }) => {
const r = row.original;
if (r.amountMinor <= 0) return <div />;
return (
<div className="text-right font-mono tabular text-success">
{formatMoney(r.amountMinor)}
</div>
);
},
},
{
id: "out",
header: () => <div className="text-right">Out</div>,
cell: ({ row }) => {
const r = row.original;
if (r.amountMinor >= 0) return <div />;
return (
<div className="text-right font-mono tabular text-destructive">
{formatMoney(Math.abs(r.amountMinor))}
</div>
);
},
},
{
id: "balance",
header: () => <div className="text-right">Balance</div>,
cell: ({ row }) => (
<div className="text-right font-mono tabular font-medium">
{formatMoney(row.original.runningBalanceMinor)}
</div>
),
},
{
id: "actions",
header: "",
cell: ({ row }) => {
const r = row.original;
if (r.status === "projected") {
return (
<form action={markPostedAction}>
<input type="hidden" name="id" value={r.id} />
<input type="hidden" name="date" value={r.date} />
<input type="hidden" name="bankAccountId" value={r.bankAccountId} />
<input type="hidden" name="description" value={r.description} />
<input type="hidden" name="amount" value={r.amountMinor / 100} />
<input type="hidden" name="categoryId" value={r.categoryId ?? ""} />
<input type="hidden" name="recurringRuleId" value={r.recurringRuleId ?? ""} />
<Button type="submit" size="icon" variant="ghost" title="Mark as posted">
<Check className="size-4" />
</Button>
</form>
);
}
return (
<form action={deleteTransactionAction}>
<input type="hidden" name="id" value={r.id} />
<Button type="submit" size="icon" variant="ghost" className="text-muted-foreground hover:text-destructive">
<Trash2 className="size-4" />
</Button>
</form>
);
},
},
],
[accountMap, catMap, today],
);
const table = useReactTable({ data: rows, columns, getCoreRowModel: getCoreRowModel() });
const setQuery = (key: string, value: string | undefined) => {
const params = new URLSearchParams(sp.toString());
if (value) params.set(key, value);
else params.delete(key);
router.push(`/ledger?${params.toString()}`);
};
return (
<div className="space-y-4">
<div className="flex flex-wrap gap-3 items-center justify-between">
<div className="flex gap-2 items-center">
<select
value={selectedAccountId ?? ""}
onChange={(e) => setQuery("account", e.target.value || undefined)}
className="h-9 rounded-md border bg-input/40 px-2 text-sm"
>
<option value="">All accounts</option>
{accounts.map((a) => (
<option key={a.id} value={a.id}>{a.name}</option>
))}
</select>
<div className="flex rounded-md border bg-input/40 overflow-hidden">
{RANGES.map((r) => (
<Link
key={r.v}
href={`/ledger?${new URLSearchParams({ ...(selectedAccountId ? { account: selectedAccountId } : {}), range: r.v }).toString()}`}
className={cn(
"px-3 h-9 grid place-items-center text-xs",
r.v === range ? "bg-primary/20 text-primary" : "text-muted-foreground hover:text-foreground",
)}
>
{r.l}
</Link>
))}
</div>
</div>
</div>
<AddTransactionForm accounts={accounts} categories={categories} defaultAccountId={selectedAccountId ?? accounts[0]?.id} />
<div className="rounded-md border overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-muted/40">
{table.getHeaderGroups().map((hg) => (
<tr key={hg.id}>
{hg.headers.map((h) => (
<th key={h.id} className="text-left font-medium px-4 py-2 text-xs uppercase tracking-wide text-muted-foreground">
{flexRender(h.column.columnDef.header, h.getContext())}
</th>
))}
</tr>
))}
</thead>
<tbody>
{table.getRowModel().rows.map((row, i) => {
const r = row.original;
const projected = r.status === "projected";
const income = r.amountMinor > 0;
const isToday = r.date === today;
return (
<motion.tr
key={r.id}
initial={{ opacity: 0, y: 4 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: Math.min(i * 0.005, 0.2) }}
className={cn(
"border-t border-border/50",
projected && "opacity-70",
isToday && "bg-primary/5",
income && !projected && "bg-success/5",
)}
style={projected ? { borderStyle: "dashed" } : undefined}
>
{row.getVisibleCells().map((c) => (
<td key={c.id} className="px-4 py-2 align-middle">
{flexRender(c.column.columnDef.cell, c.getContext())}
</td>
))}
</motion.tr>
);
})}
{rows.length === 0 ? (
<tr>
<td colSpan={7} className="text-center text-sm text-muted-foreground py-12">
No transactions in this range.
</td>
</tr>
) : null}
</tbody>
</table>
</div>
</div>
);
}
function AddTransactionForm({
accounts,
categories,
defaultAccountId,
}: {
accounts: BankAccount[];
categories: Category[];
defaultAccountId?: string;
}) {
const [open, setOpen] = useState(false);
const today = new Date().toISOString().slice(0, 10);
if (!open) {
return (
<Button variant="outline" size="sm" onClick={() => setOpen(true)}>
+ Add transaction
</Button>
);
}
return (
<form
action={addTransactionAction}
className="grid grid-cols-2 sm:grid-cols-[140px_1fr_140px_160px_120px_auto] gap-2 items-end rounded-md border bg-card/40 p-3"
>
<div className="space-y-1">
<label className="text-xs text-muted-foreground">Date</label>
<Input type="date" name="date" defaultValue={today} required />
</div>
<div className="space-y-1">
<label className="text-xs text-muted-foreground">Description</label>
<Input name="description" placeholder="Tesco shop" required />
</div>
<div className="space-y-1">
<label className="text-xs text-muted-foreground">Account</label>
<select name="bankAccountId" defaultValue={defaultAccountId} className="h-9 w-full rounded-md border bg-input/40 px-2 text-sm">
{accounts.map((a) => <option key={a.id} value={a.id}>{a.name}</option>)}
</select>
</div>
<div className="space-y-1">
<label className="text-xs text-muted-foreground">Category</label>
<select name="categoryId" className="h-9 w-full rounded-md border bg-input/40 px-2 text-sm">
<option value=""> None </option>
{categories.map((c) => <option key={c.id} value={c.id}>{c.name}</option>)}
</select>
</div>
<div className="space-y-1">
<label className="text-xs text-muted-foreground">Amount (signed)</label>
<Input type="number" step="0.01" name="amount" placeholder="-9.99" required />
</div>
<div className="flex gap-2">
<Button type="submit">Save</Button>
<Button type="button" variant="ghost" onClick={() => setOpen(false)}>Cancel</Button>
</div>
</form>
);
}
@@ -1,61 +0,0 @@
import { db } from "@/db";
import { bankAccounts, categories } from "@/db/schema";
import { eq } from "drizzle-orm";
import { requireHousehold } from "@/lib/session";
import { loadLedger } from "@/lib/ledger-service";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { LedgerTable } from "./ledger-table";
import { addMonths, startOfMonth, endOfMonth } from "date-fns";
export default async function LedgerPage({
searchParams,
}: {
searchParams: Promise<{ account?: string; range?: string }>;
}) {
const sp = await searchParams;
const { householdId } = await requireHousehold();
const accts = await db.select().from(bankAccounts).where(eq(bankAccounts.householdId, householdId));
const cats = await db.select().from(categories).where(eq(categories.householdId, householdId));
const today = new Date();
const months = sp.range === "12" ? 12 : sp.range === "6" ? 6 : sp.range === "1" ? 1 : 3;
const from = startOfMonth(today);
const to = endOfMonth(addMonths(today, months - 1));
const { rows } = await loadLedger({
householdId,
from,
to,
bankAccountId: sp.account || undefined,
});
return (
<div className="space-y-6 max-w-7xl mx-auto">
<div>
<h1 className="text-2xl font-bold tracking-tight">Ledger</h1>
<p className="text-sm text-muted-foreground">
Posted transactions plus everything projected from your recurring rules.
</p>
</div>
<Card>
<CardHeader>
<CardTitle>Cashflow</CardTitle>
<CardDescription>
Showing {rows.length} rows. Projected rows are dashed click to post.
</CardDescription>
</CardHeader>
<CardContent>
<LedgerTable
rows={rows}
accounts={accts}
categories={cats}
selectedAccountId={sp.account}
range={String(months)}
/>
</CardContent>
</Card>
</div>
);
}
@@ -1,216 +0,0 @@
import { db } from "@/db";
import { bankAccounts, transactions, recurringRules, budgets, categories } from "@/db/schema";
import { eq, and, gte, lte } from "drizzle-orm";
import { requireHousehold } from "@/lib/session";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Progress } from "@/components/ui/progress";
import { AnimatedMoney } from "@/components/animated-number";
import { formatMoney } from "@/lib/utils";
import { loadLedger } from "@/lib/ledger-service";
import { addDays, startOfMonth, endOfMonth } from "date-fns";
import { ArrowDownRight, ArrowUpRight, Calendar, Wallet } from "lucide-react";
export default async function DashboardPage() {
const { householdId } = await requireHousehold();
const today = new Date();
const monthStart = startOfMonth(today);
const monthEnd = endOfMonth(today);
const accts = await db
.select()
.from(bankAccounts)
.where(and(eq(bankAccounts.householdId, householdId), eq(bankAccounts.isArchived, false)));
// current balances (opening + all posted)
const currentByAccount: Record<string, number> = {};
for (const a of accts) {
const txs = await db
.select()
.from(transactions)
.where(
and(
eq(transactions.bankAccountId, a.id),
eq(transactions.householdId, householdId),
),
);
const sum = txs
.filter((t) => t.status === "posted")
.reduce((s, t) => s + t.amountMinor, 0);
currentByAccount[a.id] = a.openingBalanceMinor + sum;
}
const totalBalance = Object.values(currentByAccount).reduce((s, v) => s + v, 0);
// upcoming projected over next 14 days
const { rows: upcomingRows } = await loadLedger({
householdId,
from: addDays(today, 1),
to: addDays(today, 14),
});
const upcoming = upcomingRows.filter((r) => r.status === "projected").slice(0, 8);
// forecast end-of-month spendable
const { rows: monthRows } = await loadLedger({
householdId,
from: today,
to: monthEnd,
});
const projectedEoM = monthRows.length > 0 ? monthRows[monthRows.length - 1].runningBalanceMinor : totalBalance;
// current month spending vs budgets
const monthStartIso = monthStart.toISOString().slice(0, 10);
const monthEndIso = monthEnd.toISOString().slice(0, 10);
const monthTxs = await db
.select()
.from(transactions)
.where(
and(
eq(transactions.householdId, householdId),
eq(transactions.status, "posted"),
gte(transactions.date, monthStartIso),
lte(transactions.date, monthEndIso),
),
);
const allBudgets = await db.select().from(budgets).where(eq(budgets.householdId, householdId));
const cats = await db.select().from(categories).where(eq(categories.householdId, householdId));
const catMap = new Map(cats.map((c) => [c.id, c]));
const spentByCat: Record<string, number> = {};
for (const t of monthTxs) {
if (!t.categoryId || t.amountMinor >= 0) continue;
spentByCat[t.categoryId] = (spentByCat[t.categoryId] ?? 0) + Math.abs(t.amountMinor);
}
const incomeMonth = monthTxs.filter((t) => t.amountMinor > 0).reduce((s, t) => s + t.amountMinor, 0);
const expenseMonth = monthTxs.filter((t) => t.amountMinor < 0).reduce((s, t) => s + Math.abs(t.amountMinor), 0);
return (
<div className="space-y-6 max-w-7xl mx-auto">
<div className="flex items-end justify-between">
<div>
<p className="text-sm text-muted-foreground">Spendable across all accounts</p>
<h1 className="text-5xl font-bold tracking-tight tabular text-foreground">
<AnimatedMoney amountMinor={totalBalance} />
</h1>
<p className="text-sm text-muted-foreground mt-1">
Projected end-of-month: <span className="text-foreground tabular">{formatMoney(projectedEoM)}</span>
</p>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<Card>
<CardHeader>
<CardDescription className="flex items-center gap-2">
<ArrowUpRight className="size-4 text-success" />
Income this month
</CardDescription>
<CardTitle className="text-2xl tabular text-success">{formatMoney(incomeMonth)}</CardTitle>
</CardHeader>
</Card>
<Card>
<CardHeader>
<CardDescription className="flex items-center gap-2">
<ArrowDownRight className="size-4 text-destructive" />
Spent this month
</CardDescription>
<CardTitle className="text-2xl tabular text-destructive">{formatMoney(expenseMonth)}</CardTitle>
</CardHeader>
</Card>
<Card>
<CardHeader>
<CardDescription className="flex items-center gap-2">
<Wallet className="size-4 text-primary" />
Accounts
</CardDescription>
<CardTitle className="text-2xl tabular">{accts.length}</CardTitle>
</CardHeader>
</Card>
<Card>
<CardHeader>
<CardDescription className="flex items-center gap-2">
<Calendar className="size-4 text-warning" />
Upcoming (14d)
</CardDescription>
<CardTitle className="text-2xl tabular">{upcoming.length}</CardTitle>
</CardHeader>
</Card>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
<Card className="lg:col-span-2">
<CardHeader>
<CardTitle>Accounts</CardTitle>
<CardDescription>Current balance per account</CardDescription>
</CardHeader>
<CardContent className="space-y-2">
{accts.map((a) => (
<div key={a.id} className="flex items-center justify-between rounded-md bg-card/40 border px-4 py-3">
<div>
<div className="font-medium">{a.name}</div>
<div className="text-xs text-muted-foreground capitalize">{a.type}</div>
</div>
<div className="font-mono tabular text-lg">{formatMoney(currentByAccount[a.id] ?? 0, a.currency)}</div>
</div>
))}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Upcoming bills</CardTitle>
<CardDescription>Next 14 days</CardDescription>
</CardHeader>
<CardContent className="space-y-2">
{upcoming.length === 0 ? (
<p className="text-sm text-muted-foreground">Nothing scheduled.</p>
) : (
upcoming.map((u) => (
<div key={u.id} className="flex items-center justify-between text-sm">
<div>
<div className="font-medium">{u.description}</div>
<div className="text-xs text-muted-foreground">{u.date}</div>
</div>
<span
className={`font-mono tabular ${u.amountMinor < 0 ? "text-destructive" : "text-success"}`}
>
{formatMoney(u.amountMinor)}
</span>
</div>
))
)}
</CardContent>
</Card>
</div>
{allBudgets.length > 0 && (
<Card>
<CardHeader>
<CardTitle>Budgets</CardTitle>
<CardDescription>This month, by category</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{allBudgets.map((b) => {
const cat = catMap.get(b.categoryId);
const spent = spentByCat[b.categoryId] ?? 0;
const pct = Math.round((spent / b.amountMinor) * 100);
const over = spent > b.amountMinor;
return (
<div key={b.id} className="space-y-1.5">
<div className="flex items-center justify-between text-sm">
<div className="font-medium">{cat?.name ?? "Category"}</div>
<div className="font-mono tabular">
<span className={over ? "text-destructive" : ""}>{formatMoney(spent)}</span>
<span className="text-muted-foreground"> / {formatMoney(b.amountMinor)}</span>
</div>
</div>
<Progress value={pct} className={over ? "[&>div]:bg-destructive" : ""} />
</div>
);
})}
</CardContent>
</Card>
)}
</div>
);
}
@@ -1,68 +0,0 @@
"use server";
import { db } from "@/db";
import { recurringRules } from "@/db/schema";
import { requireHousehold } from "@/lib/session";
import { and, eq } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { z } from "zod";
const RuleSchema = z.object({
bankAccountId: z.string().min(1),
categoryId: z.string().optional().nullable().or(z.literal("")),
description: z.string().min(1).max(200),
amount: z.coerce.number(),
rrule: z.string().min(3),
startDate: z.string().min(8),
endDate: z.string().optional().nullable().or(z.literal("")),
});
export async function addRuleAction(formData: FormData) {
const { householdId } = await requireHousehold();
const parsed = RuleSchema.parse({
bankAccountId: formData.get("bankAccountId"),
categoryId: formData.get("categoryId") || null,
description: formData.get("description"),
amount: formData.get("amount"),
rrule: formData.get("rrule"),
startDate: formData.get("startDate"),
endDate: formData.get("endDate") || null,
});
await db.insert(recurringRules).values({
householdId,
bankAccountId: parsed.bankAccountId,
categoryId: parsed.categoryId || null,
description: parsed.description,
amountMinor: Math.round(parsed.amount * 100),
rrule: parsed.rrule,
startDate: parsed.startDate,
endDate: parsed.endDate || null,
isActive: true,
});
revalidatePath("/recurring");
revalidatePath("/ledger");
revalidatePath("/");
}
export async function toggleRuleAction(formData: FormData) {
const { householdId } = await requireHousehold();
const id = String(formData.get("id"));
const isActive = String(formData.get("isActive")) === "true";
await db
.update(recurringRules)
.set({ isActive: !isActive })
.where(and(eq(recurringRules.id, id), eq(recurringRules.householdId, householdId)));
revalidatePath("/recurring");
revalidatePath("/ledger");
}
export async function deleteRuleAction(formData: FormData) {
const { householdId } = await requireHousehold();
const id = String(formData.get("id"));
await db
.delete(recurringRules)
.where(and(eq(recurringRules.id, id), eq(recurringRules.householdId, householdId)));
revalidatePath("/recurring");
revalidatePath("/ledger");
revalidatePath("/");
}
@@ -1,139 +0,0 @@
import { db } from "@/db";
import { recurringRules, bankAccounts, categories } from "@/db/schema";
import { eq } from "drizzle-orm";
import { requireHousehold } from "@/lib/session";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge";
import { formatMoney } from "@/lib/utils";
import { addRuleAction, deleteRuleAction, toggleRuleAction } from "./actions";
import { RRULE_PRESETS } from "@/lib/recurring";
import { Pause, Play, Trash2 } from "lucide-react";
export default async function RecurringPage() {
const { householdId } = await requireHousehold();
const rules = await db.select().from(recurringRules).where(eq(recurringRules.householdId, householdId));
const accts = await db.select().from(bankAccounts).where(eq(bankAccounts.householdId, householdId));
const cats = await db.select().from(categories).where(eq(categories.householdId, householdId));
const acctMap = new Map(accts.map((a) => [a.id, a]));
const catMap = new Map(cats.map((c) => [c.id, c]));
const today = new Date().toISOString().slice(0, 10);
return (
<div className="space-y-6 max-w-7xl mx-auto">
<div>
<h1 className="text-2xl font-bold tracking-tight">Recurring rules</h1>
<p className="text-sm text-muted-foreground">
Set it once. The ledger and forecasts use these to project the future.
</p>
</div>
<Card>
<CardHeader>
<CardTitle>New rule</CardTitle>
<CardDescription>Use a negative amount for an expense, positive for income.</CardDescription>
</CardHeader>
<CardContent>
<form action={addRuleAction} className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
<div className="space-y-1.5 lg:col-span-2">
<Label>Description</Label>
<Input name="description" required placeholder="Weekly Rent" />
</div>
<div className="space-y-1.5">
<Label>Amount</Label>
<Input name="amount" type="number" step="0.01" required placeholder="-262.00" />
</div>
<div className="space-y-1.5">
<Label>Account</Label>
<select name="bankAccountId" className="h-9 w-full rounded-md border bg-input/40 px-2 text-sm" required>
{accts.map((a) => <option key={a.id} value={a.id}>{a.name}</option>)}
</select>
</div>
<div className="space-y-1.5">
<Label>Category</Label>
<select name="categoryId" className="h-9 w-full rounded-md border bg-input/40 px-2 text-sm">
<option value=""> None </option>
{cats.map((c) => <option key={c.id} value={c.id}>{c.name}</option>)}
</select>
</div>
<div className="space-y-1.5">
<Label>Schedule</Label>
<select name="rrule" className="h-9 w-full rounded-md border bg-input/40 px-2 text-sm">
{RRULE_PRESETS.map((p) => <option key={p.rrule} value={p.rrule}>{p.label}</option>)}
</select>
</div>
<div className="space-y-1.5">
<Label>Start date</Label>
<Input name="startDate" type="date" defaultValue={today} required />
</div>
<div className="space-y-1.5">
<Label>End date (optional)</Label>
<Input name="endDate" type="date" />
</div>
<div className="flex items-end">
<Button type="submit" className="w-full">Add rule</Button>
</div>
</form>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Active rules</CardTitle>
<CardDescription>{rules.length} configured</CardDescription>
</CardHeader>
<CardContent className="space-y-2">
{rules.length === 0 ? (
<p className="text-sm text-muted-foreground">None yet.</p>
) : rules.map((r) => (
<div key={r.id} className="flex items-center justify-between rounded-md border bg-card/40 px-4 py-3">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="font-medium truncate">{r.description}</span>
{!r.isActive && <Badge variant="secondary">Paused</Badge>}
</div>
<div className="text-xs text-muted-foreground truncate">
{acctMap.get(r.bankAccountId)?.name}
{r.categoryId ? ` · ${catMap.get(r.categoryId)?.name}` : ""}
{" · "}{prettyRRule(r.rrule)}
</div>
</div>
<div className={`font-mono tabular mr-3 ${r.amountMinor < 0 ? "text-destructive" : "text-success"}`}>
{formatMoney(r.amountMinor)}
</div>
<form action={toggleRuleAction} className="mr-1">
<input type="hidden" name="id" value={r.id} />
<input type="hidden" name="isActive" value={String(r.isActive)} />
<Button type="submit" variant="ghost" size="icon">
{r.isActive ? <Pause className="size-4" /> : <Play className="size-4" />}
</Button>
</form>
<form action={deleteRuleAction}>
<input type="hidden" name="id" value={r.id} />
<Button type="submit" variant="ghost" size="icon" className="text-muted-foreground hover:text-destructive">
<Trash2 className="size-4" />
</Button>
</form>
</div>
))}
</CardContent>
</Card>
</div>
);
}
function prettyRRule(rrule: string): string {
const map: Record<string, string> = {
"FREQ=DAILY": "Daily",
"FREQ=WEEKLY;BYDAY=MO": "Weekly · Mon",
"FREQ=WEEKLY;BYDAY=FR": "Weekly · Fri",
"FREQ=WEEKLY;INTERVAL=2": "Every 2 weeks",
"FREQ=MONTHLY": "Monthly",
"FREQ=MONTHLY;BYMONTHDAY=1": "Monthly · 1st",
"FREQ=MONTHLY;BYMONTHDAY=-1": "Monthly · last day",
"FREQ=YEARLY": "Yearly",
};
return map[rrule] ?? rrule;
}
@@ -1,39 +0,0 @@
"use server";
import { db } from "@/db";
import { households, bankAccounts } from "@/db/schema";
import { requireHousehold } from "@/lib/session";
import { and, eq } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { z } from "zod";
const HouseholdSchema = z.object({ name: z.string().min(1).max(80) });
export async function renameHouseholdAction(formData: FormData) {
const { householdId } = await requireHousehold();
const parsed = HouseholdSchema.parse({ name: formData.get("name") });
await db.update(households).set({ name: parsed.name }).where(eq(households.id, householdId));
revalidatePath("/settings");
}
export async function archiveAccountAction(formData: FormData) {
const { householdId } = await requireHousehold();
const id = String(formData.get("id"));
await db
.update(bankAccounts)
.set({ isArchived: true })
.where(and(eq(bankAccounts.id, id), eq(bankAccounts.householdId, householdId)));
revalidatePath("/settings");
revalidatePath("/");
}
export async function unarchiveAccountAction(formData: FormData) {
const { householdId } = await requireHousehold();
const id = String(formData.get("id"));
await db
.update(bankAccounts)
.set({ isArchived: false })
.where(and(eq(bankAccounts.id, id), eq(bankAccounts.householdId, householdId)));
revalidatePath("/settings");
revalidatePath("/");
}
@@ -1,86 +0,0 @@
import { db } from "@/db";
import { households, bankAccounts, users } from "@/db/schema";
import { eq } from "drizzle-orm";
import { requireHousehold } from "@/lib/session";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { formatMoney } from "@/lib/utils";
import { archiveAccountAction, renameHouseholdAction, unarchiveAccountAction } from "./actions";
export default async function SettingsPage() {
const { householdId } = await requireHousehold();
const [hh] = await db.select().from(households).where(eq(households.id, householdId));
const accts = await db.select().from(bankAccounts).where(eq(bankAccounts.householdId, householdId));
const members = await db.select().from(users).where(eq(users.householdId, householdId));
return (
<div className="space-y-6 max-w-4xl mx-auto">
<div>
<h1 className="text-2xl font-bold tracking-tight">Settings</h1>
</div>
<Card>
<CardHeader>
<CardTitle>Household</CardTitle>
<CardDescription>Shared between members of this household.</CardDescription>
</CardHeader>
<CardContent>
<form action={renameHouseholdAction} className="flex gap-3 items-end">
<div className="space-y-1.5 flex-1">
<Label>Name</Label>
<Input name="name" defaultValue={hh?.name} required />
</div>
<Button type="submit">Save</Button>
</form>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Members</CardTitle>
<CardDescription>{members.length} in this household</CardDescription>
</CardHeader>
<CardContent className="space-y-2">
{members.map((m) => (
<div key={m.id} className="flex items-center justify-between rounded-md border bg-card/40 px-4 py-3">
<div>
<div className="font-medium">{m.name ?? m.email}</div>
<div className="text-xs text-muted-foreground">{m.email}</div>
</div>
<Badge variant={m.role === "owner" ? "default" : "secondary"} className="capitalize">{m.role}</Badge>
</div>
))}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Bank accounts</CardTitle>
<CardDescription>Archive accounts you no longer use.</CardDescription>
</CardHeader>
<CardContent className="space-y-2">
{accts.map((a) => (
<div key={a.id} className="flex items-center justify-between rounded-md border bg-card/40 px-4 py-3">
<div className="flex items-center gap-3">
<div>
<div className="font-medium">{a.name} {a.isArchived ? <Badge variant="secondary">Archived</Badge> : null}</div>
<div className="text-xs text-muted-foreground capitalize">{a.type} · opened {a.openingDate}</div>
</div>
</div>
<div className="flex items-center gap-3">
<span className="font-mono tabular text-sm text-muted-foreground">opening: {formatMoney(a.openingBalanceMinor, a.currency)}</span>
<form action={a.isArchived ? unarchiveAccountAction : archiveAccountAction}>
<input type="hidden" name="id" value={a.id} />
<Button type="submit" variant="ghost" size="sm">{a.isArchived ? "Restore" : "Archive"}</Button>
</form>
</div>
</div>
))}
</CardContent>
</Card>
</div>
);
}
@@ -1,18 +0,0 @@
"use server";
import { signIn } from "@/auth";
import { AuthError } from "next-auth";
import { redirect } from "next/navigation";
export async function loginAction(formData: FormData) {
const email = String(formData.get("email") ?? "");
const password = String(formData.get("password") ?? "");
const callbackUrl = String(formData.get("callbackUrl") ?? "/");
try {
await signIn("credentials", { email, password, redirect: false });
} catch (err) {
if (err instanceof AuthError) redirect(`/login?error=1&callbackUrl=${encodeURIComponent(callbackUrl)}`);
throw err;
}
redirect(callbackUrl || "/");
}
@@ -1,45 +0,0 @@
import Link from "next/link";
import { loginAction } from "./actions";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
export default async function LoginPage({
searchParams,
}: {
searchParams: Promise<{ error?: string; callbackUrl?: string }>;
}) {
const sp = await searchParams;
return (
<main className="min-h-screen flex items-center justify-center px-4">
<Card className="w-full max-w-md">
<CardHeader>
<CardTitle className="text-xl">Welcome back</CardTitle>
<CardDescription>Sign in to your MoneyApp account.</CardDescription>
</CardHeader>
<CardContent>
<form action={loginAction} className="space-y-4">
<input type="hidden" name="callbackUrl" value={sp.callbackUrl ?? "/"} />
<div className="space-y-1.5">
<Label htmlFor="email">Email</Label>
<Input id="email" name="email" type="email" required />
</div>
<div className="space-y-1.5">
<Label htmlFor="password">Password</Label>
<Input id="password" name="password" type="password" required />
</div>
{sp.error ? (
<p className="text-sm text-destructive">Invalid email or password.</p>
) : null}
<Button type="submit" className="w-full">Sign in</Button>
<p className="text-sm text-muted-foreground text-center">
No account?{" "}
<Link href="/signup" className="text-primary hover:underline">Create one</Link>
</p>
</form>
</CardContent>
</Card>
</main>
);
}
@@ -1,51 +0,0 @@
"use server";
import { db } from "@/db";
import { users, households } from "@/db/schema";
import bcrypt from "bcryptjs";
import { eq } from "drizzle-orm";
import { redirect } from "next/navigation";
import { signIn } from "@/auth";
import { seedHouseholdDefaults } from "@/lib/seed";
import { z } from "zod";
const SignupSchema = z.object({
name: z.string().min(1).max(80),
email: z.string().email().toLowerCase().trim(),
password: z.string().min(8).max(200),
});
export async function signupAction(formData: FormData) {
const parsed = SignupSchema.parse({
name: formData.get("name"),
email: formData.get("email"),
password: formData.get("password"),
});
const [existing] = await db.select().from(users).where(eq(users.email, parsed.email));
if (existing) throw new Error("Email already registered");
const passwordHash = await bcrypt.hash(parsed.password, 10);
const [household] = await db
.insert(households)
.values({ name: `${parsed.name}'s household` })
.returning();
await db.insert(users).values({
name: parsed.name,
email: parsed.email,
passwordHash,
householdId: household.id,
role: "owner",
});
await seedHouseholdDefaults(household.id);
await signIn("credentials", {
email: parsed.email,
password: parsed.password,
redirect: false,
});
redirect("/onboarding");
}
@@ -1,40 +0,0 @@
import Link from "next/link";
import { signupAction } from "./actions";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
export default function SignupPage() {
return (
<main className="min-h-screen flex items-center justify-center px-4">
<Card className="w-full max-w-md">
<CardHeader>
<CardTitle className="text-xl">Create your MoneyApp account</CardTitle>
<CardDescription>Track everything in one place. Free, local-first.</CardDescription>
</CardHeader>
<CardContent>
<form action={signupAction} className="space-y-4">
<div className="space-y-1.5">
<Label htmlFor="name">Name</Label>
<Input id="name" name="name" placeholder="Alex" required />
</div>
<div className="space-y-1.5">
<Label htmlFor="email">Email</Label>
<Input id="email" name="email" type="email" placeholder="you@example.com" required />
</div>
<div className="space-y-1.5">
<Label htmlFor="password">Password</Label>
<Input id="password" name="password" type="password" required minLength={8} />
</div>
<Button type="submit" className="w-full">Create account</Button>
<p className="text-sm text-muted-foreground text-center">
Already have an account?{" "}
<Link href="/login" className="text-primary hover:underline">Sign in</Link>
</p>
</form>
</CardContent>
</Card>
</main>
);
}
@@ -1,2 +0,0 @@
import { handlers } from "@/auth";
export const { GET, POST } = handlers;
Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

@@ -1,93 +0,0 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
color-scheme: dark;
--radius: 0.75rem;
--background: oklch(0.145 0.012 240);
--foreground: oklch(0.985 0.005 200);
--card: oklch(0.19 0.014 235);
--card-foreground: oklch(0.985 0.005 200);
--popover: oklch(0.19 0.014 235);
--popover-foreground: oklch(0.985 0.005 200);
--primary: oklch(0.72 0.13 195);
--primary-foreground: oklch(0.16 0.02 235);
--secondary: oklch(0.27 0.018 235);
--secondary-foreground: oklch(0.97 0.005 200);
--muted: oklch(0.24 0.014 235);
--muted-foreground: oklch(0.7 0.018 220);
--accent: oklch(0.32 0.05 195);
--accent-foreground: oklch(0.97 0.005 200);
--destructive: oklch(0.65 0.22 25);
--destructive-foreground: oklch(0.985 0.005 200);
--success: oklch(0.72 0.17 150);
--success-foreground: oklch(0.14 0.02 150);
--warning: oklch(0.78 0.15 75);
--warning-foreground: oklch(0.18 0.02 75);
--border: oklch(0.28 0.014 235 / 0.6);
--input: oklch(0.28 0.014 235);
--ring: oklch(0.72 0.13 195);
--chart-1: oklch(0.72 0.13 195);
--chart-2: oklch(0.72 0.17 150);
--chart-3: oklch(0.78 0.15 75);
--chart-4: oklch(0.7 0.18 320);
--chart-5: oklch(0.65 0.22 25);
--sidebar: oklch(0.165 0.012 240);
--sidebar-foreground: oklch(0.985 0.005 200);
--sidebar-primary: oklch(0.72 0.13 195);
--sidebar-primary-foreground: oklch(0.16 0.02 235);
--sidebar-accent: oklch(0.24 0.014 235);
--sidebar-accent-foreground: oklch(0.985 0.005 200);
--sidebar-border: oklch(0.28 0.014 235 / 0.6);
--sidebar-ring: oklch(0.72 0.13 195);
}
@layer base {
* {
@apply border-border;
}
html, body {
height: 100%;
}
body {
background:
radial-gradient(1200px 600px at 10% -10%, oklch(0.25 0.06 195 / 0.18), transparent 60%),
radial-gradient(900px 500px at 110% 10%, oklch(0.22 0.08 280 / 0.14), transparent 55%),
var(--background);
color: var(--foreground);
font-family: var(--font-geist-sans), ui-sans-serif, system-ui, sans-serif;
-webkit-font-smoothing: antialiased;
}
.tabular { font-variant-numeric: tabular-nums; }
input, select, textarea {
color: var(--foreground);
color-scheme: dark;
}
input::placeholder, textarea::placeholder {
color: var(--muted-foreground);
}
select option {
background-color: var(--card);
color: var(--foreground);
}
.glass {
background: color-mix(in oklch, var(--card) 70%, transparent);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
border: 1px solid color-mix(in oklch, var(--border) 100%, transparent);
}
}
@@ -1,34 +0,0 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "MoneyApp",
description: "Multi-user money management with recurring rules, projected ledger, and forecasts.",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en" className="dark" suppressHydrationWarning>
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased min-h-screen`}
>
{children}
</body>
</html>
);
}
@@ -1,38 +0,0 @@
"use server";
import { db } from "@/db";
import { bankAccounts } from "@/db/schema";
import { requireHousehold } from "@/lib/session";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { z } from "zod";
const AddAccountSchema = z.object({
name: z.string().min(1).max(80),
type: z.enum(["current", "savings", "credit", "cash", "investment"]),
balance: z.coerce.number(),
});
export async function addAccountAction(formData: FormData) {
const { householdId, user } = await requireHousehold();
const parsed = AddAccountSchema.parse({
name: formData.get("name"),
type: formData.get("type"),
balance: formData.get("balance"),
});
await db.insert(bankAccounts).values({
householdId,
ownerUserId: user.id,
name: parsed.name,
type: parsed.type,
openingBalanceMinor: Math.round(parsed.balance * 100),
openingDate: new Date().toISOString().slice(0, 10),
isShared: false,
});
revalidatePath("/onboarding");
}
export async function finishOnboardingAction() {
await requireHousehold();
redirect("/");
}
@@ -1,85 +0,0 @@
import { db } from "@/db";
import { bankAccounts } from "@/db/schema";
import { eq } from "drizzle-orm";
import { requireHousehold } from "@/lib/session";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { formatMoney } from "@/lib/utils";
import { addAccountAction, finishOnboardingAction } from "./actions";
export default async function OnboardingPage() {
const { user, householdId } = await requireHousehold();
const accounts = await db
.select()
.from(bankAccounts)
.where(eq(bankAccounts.householdId, householdId));
return (
<main className="min-h-screen flex items-center justify-center px-4 py-12">
<Card className="w-full max-w-2xl">
<CardHeader>
<CardTitle className="text-2xl">Welcome, {user.name ?? user.email}</CardTitle>
<CardDescription>
Let&apos;s add your bank accounts. You can edit these later just enter what&apos;s in each one today.
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<form action={addAccountAction} className="grid grid-cols-1 sm:grid-cols-[1fr_140px_140px_auto] gap-3 items-end">
<div className="space-y-1.5">
<Label htmlFor="name">Account name</Label>
<Input id="name" name="name" required placeholder="Alex - Bank 1" />
</div>
<div className="space-y-1.5">
<Label htmlFor="type">Type</Label>
<select
id="type"
name="type"
className="h-9 w-full rounded-md border bg-input/40 px-2 text-sm"
defaultValue="current"
>
<option value="current">Current</option>
<option value="savings">Savings</option>
<option value="credit">Credit card</option>
<option value="cash">Cash</option>
<option value="investment">Investment</option>
</select>
</div>
<div className="space-y-1.5">
<Label htmlFor="balance">Opening balance</Label>
<Input id="balance" name="balance" type="number" step="0.01" placeholder="500.00" required />
</div>
<Button type="submit">Add</Button>
</form>
<div className="space-y-2">
{accounts.length === 0 ? (
<p className="text-sm text-muted-foreground">No accounts yet.</p>
) : (
accounts.map((a) => (
<div key={a.id} className="flex items-center justify-between rounded-md border bg-card/40 px-4 py-3">
<div>
<div className="font-medium">{a.name}</div>
<div className="text-xs text-muted-foreground capitalize">{a.type}</div>
</div>
<Badge variant="secondary" className="font-mono tabular">
{formatMoney(a.openingBalanceMinor, a.currency)}
</Badge>
</div>
))
)}
</div>
<form action={finishOnboardingAction}>
<Button type="submit" variant={accounts.length === 0 ? "outline" : "default"} className="w-full">
{accounts.length === 0 ? "Skip for now" : "Continue to dashboard"}
</Button>
</form>
</CardContent>
</Card>
</main>
);
}
@@ -1,19 +0,0 @@
import type { NextAuthConfig } from "next-auth";
export const authConfig = {
pages: { signIn: "/login" },
providers: [],
callbacks: {
authorized({ auth, request }) {
const { pathname } = request.nextUrl;
const isPublic =
pathname === "/login" ||
pathname === "/signup" ||
pathname.startsWith("/api/auth") ||
pathname.startsWith("/_next") ||
pathname === "/favicon.ico";
if (isPublic) return true;
return !!auth?.user;
},
},
} satisfies NextAuthConfig;
-74
View File
@@ -1,74 +0,0 @@
import NextAuth from "next-auth";
import Credentials from "next-auth/providers/credentials";
import { DrizzleAdapter } from "@auth/drizzle-adapter";
import bcrypt from "bcryptjs";
import { eq } from "drizzle-orm";
import { db } from "@/db";
import {
users,
authAccounts,
sessions,
verificationTokens,
} from "@/db/schema";
import { authConfig } from "@/auth.config";
export const { handlers, signIn, signOut, auth } = NextAuth({
...authConfig,
adapter: DrizzleAdapter(db, {
usersTable: users,
accountsTable: authAccounts,
sessionsTable: sessions,
verificationTokensTable: verificationTokens,
}),
session: { strategy: "jwt" },
providers: [
Credentials({
name: "Credentials",
credentials: {
email: { label: "Email", type: "email" },
password: { label: "Password", type: "password" },
},
authorize: async (credentials) => {
const email = String(credentials?.email ?? "").toLowerCase().trim();
const password = String(credentials?.password ?? "");
if (!email || !password) return null;
const [u] = await db.select().from(users).where(eq(users.email, email));
if (!u || !u.passwordHash) return null;
const ok = await bcrypt.compare(password, u.passwordHash);
if (!ok) return null;
return {
id: u.id,
email: u.email,
name: u.name ?? u.email,
image: u.image ?? undefined,
};
},
}),
],
callbacks: {
...authConfig.callbacks,
async jwt({ token, user }) {
if (user?.id) token.userId = user.id;
if (token.userId) {
const [u] = await db
.select({ householdId: users.householdId, role: users.role })
.from(users)
.where(eq(users.id, token.userId as string));
if (u) {
token.householdId = u.householdId ?? null;
token.role = u.role;
}
}
return token;
},
async session({ session, token }) {
if (session.user) {
session.user.id = token.userId as string;
(session.user as { householdId?: string | null }).householdId =
(token.householdId as string | null) ?? null;
(session.user as { role?: string }).role = (token.role as string) ?? "member";
}
return session;
},
},
});
@@ -1,28 +0,0 @@
"use client";
import { useEffect } from "react";
import { motion, useMotionValue, useTransform, animate } from "framer-motion";
export function AnimatedMoney({
amountMinor,
currency = "GBP",
className,
}: {
amountMinor: number;
currency?: string;
className?: string;
}) {
const mv = useMotionValue(amountMinor);
const text = useTransform(mv, (v) =>
new Intl.NumberFormat("en-GB", {
style: "currency",
currency,
maximumFractionDigits: 2,
}).format(v / 100),
);
useEffect(() => {
const controls = animate(mv, amountMinor, { duration: 0.8, ease: "easeOut" });
return () => controls.stop();
}, [amountMinor, mv]);
return <motion.span className={className}>{text}</motion.span>;
}
@@ -1,61 +0,0 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import {
LayoutDashboard,
ListOrdered,
Repeat,
Target,
TrendingUp,
Upload,
Settings,
Wallet,
} from "lucide-react";
import { cn } from "@/lib/utils";
const NAV = [
{ href: "/", label: "Dashboard", icon: LayoutDashboard },
{ href: "/ledger", label: "Ledger", icon: ListOrdered },
{ href: "/recurring", label: "Recurring", icon: Repeat },
{ href: "/budgets", label: "Budgets", icon: Wallet },
{ href: "/goals", label: "Goals", icon: Target },
{ href: "/forecast", label: "Forecast", icon: TrendingUp },
{ href: "/import", label: "Import", icon: Upload },
{ href: "/settings", label: "Settings", icon: Settings },
];
export function Sidebar() {
const pathname = usePathname();
return (
<aside className="hidden md:flex md:w-60 shrink-0 flex-col gap-2 border-r border-border/60 bg-sidebar/80 px-3 py-5">
<div className="px-2 pb-3 flex items-center gap-2">
<div className="size-8 rounded-md bg-primary/20 grid place-items-center">
<span className="text-primary font-bold">£</span>
</div>
<span className="font-semibold tracking-tight">MoneyApp</span>
</div>
<nav className="flex flex-col gap-0.5">
{NAV.map((item) => {
const active = pathname === item.href || (item.href !== "/" && pathname.startsWith(item.href));
const Icon = item.icon;
return (
<Link
key={item.href}
href={item.href}
className={cn(
"flex items-center gap-3 rounded-md px-3 py-2 text-sm transition-colors",
active
? "bg-sidebar-accent text-sidebar-accent-foreground font-medium"
: "text-sidebar-foreground/80 hover:bg-sidebar-accent/60 hover:text-sidebar-foreground",
)}
>
<Icon className="size-4" />
<span>{item.label}</span>
</Link>
);
})}
</nav>
</aside>
);
}
@@ -1,19 +0,0 @@
import { LogOut } from "lucide-react";
import { signOutAction } from "@/app/(app)/actions";
import { Button } from "@/components/ui/button";
export function Topbar({ name }: { name?: string | null }) {
return (
<header className="flex items-center justify-between px-4 sm:px-8 py-4 border-b border-border/60 bg-background/40 backdrop-blur">
<div className="text-sm text-muted-foreground">
Hello, <span className="text-foreground font-medium">{name ?? "there"}</span>
</div>
<form action={signOutAction}>
<Button type="submit" variant="ghost" size="sm">
<LogOut className="size-4" />
Sign out
</Button>
</form>
</header>
);
}
@@ -1,30 +0,0 @@
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const badgeVariants = cva(
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-medium transition-colors",
{
variants: {
variant: {
default: "border-transparent bg-primary/15 text-primary",
secondary: "border-transparent bg-secondary text-secondary-foreground",
success: "border-transparent bg-success/15 text-success",
warning: "border-transparent bg-warning/15 text-warning",
destructive: "border-transparent bg-destructive/15 text-destructive",
outline: "text-foreground",
},
},
defaultVariants: { variant: "default" },
},
);
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return <div className={cn(badgeVariants({ variant }), className)} {...props} />;
}
export { Badge, badgeVariants };
@@ -1,49 +0,0 @@
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 outline-none focus-visible:ring-2 focus-visible:ring-ring/60",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground shadow hover:bg-primary/90 active:bg-primary/80",
destructive:
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
outline:
"border bg-transparent shadow-sm hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2",
sm: "h-8 rounded-md px-3 text-xs",
lg: "h-10 rounded-md px-6",
icon: "h-9 w-9",
},
},
defaultVariants: { variant: "default", size: "default" },
},
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, ...props }, ref) => (
<button
ref={ref}
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
),
);
Button.displayName = "Button";
export { Button, buttonVariants };
@@ -1,50 +0,0 @@
import * as React from "react";
import { cn } from "@/lib/utils";
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("glass rounded-xl text-card-foreground shadow-sm", className)}
{...props}
/>
),
);
Card.displayName = "Card";
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn("flex flex-col gap-1 p-5", className)} {...props} />
),
);
CardHeader.displayName = "CardHeader";
const CardTitle = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn("text-base font-semibold leading-none tracking-tight", className)} {...props} />
),
);
CardTitle.displayName = "CardTitle";
const CardDescription = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn("text-sm text-muted-foreground", className)} {...props} />
),
);
CardDescription.displayName = "CardDescription";
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-5 pt-0", className)} {...props} />
),
);
CardContent.displayName = "CardContent";
const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn("flex items-center p-5 pt-0", className)} {...props} />
),
);
CardFooter.displayName = "CardFooter";
export { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter };
@@ -1,25 +0,0 @@
import * as React from "react";
import { cn } from "@/lib/utils";
export type InputProps = React.InputHTMLAttributes<HTMLInputElement>;
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, ...props }, ref) => (
<input
type={type}
ref={ref}
className={cn(
"flex h-9 w-full rounded-md border bg-input/40 px-3 py-1 text-sm text-foreground shadow-sm transition-colors",
"placeholder:text-muted-foreground",
"file:border-0 file:bg-transparent file:text-sm file:font-medium",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/60",
"disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
{...props}
/>
),
);
Input.displayName = "Input";
export { Input };
@@ -1,19 +0,0 @@
import * as React from "react";
import { cn } from "@/lib/utils";
const Label = React.forwardRef<
HTMLLabelElement,
React.LabelHTMLAttributes<HTMLLabelElement>
>(({ className, ...props }, ref) => (
<label
ref={ref}
className={cn(
"text-sm font-medium leading-none text-muted-foreground peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
className,
)}
{...props}
/>
));
Label.displayName = "Label";
export { Label };
@@ -1,21 +0,0 @@
import * as React from "react";
import { cn } from "@/lib/utils";
export function Progress({
value = 0,
className,
...props
}: React.HTMLAttributes<HTMLDivElement> & { value?: number }) {
const clamped = Math.max(0, Math.min(100, value));
return (
<div
className={cn("relative h-2 w-full overflow-hidden rounded-full bg-secondary", className)}
{...props}
>
<div
className="h-full bg-primary transition-all"
style={{ width: `${clamped}%` }}
/>
</div>
);
}
@@ -1,20 +0,0 @@
import * as React from "react";
import { cn } from "@/lib/utils";
export function Separator({
className,
orientation = "horizontal",
...props
}: React.HTMLAttributes<HTMLDivElement> & { orientation?: "horizontal" | "vertical" }) {
return (
<div
role="separator"
className={cn(
"shrink-0 bg-border",
orientation === "horizontal" ? "h-px w-full" : "w-px h-full",
className,
)}
{...props}
/>
);
}
-14
View File
@@ -1,14 +0,0 @@
import Database from "better-sqlite3";
import { drizzle } from "drizzle-orm/better-sqlite3";
import path from "node:path";
import * as schema from "./schema";
const dbPath = process.env.DATABASE_FILE
?? path.resolve(process.cwd(), "data", "moneyapp.db");
const sqlite = new Database(dbPath);
sqlite.pragma("journal_mode = WAL");
sqlite.pragma("foreign_keys = ON");
export const db = drizzle(sqlite, { schema, casing: "snake_case" });
export { schema };
-18
View File
@@ -1,18 +0,0 @@
import Database from "better-sqlite3";
import { drizzle } from "drizzle-orm/better-sqlite3";
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
import path from "node:path";
import fs from "node:fs";
const dbPath = process.env.DATABASE_FILE
?? path.resolve(process.cwd(), "data", "moneyapp.db");
fs.mkdirSync(path.dirname(dbPath), { recursive: true });
const sqlite = new Database(dbPath);
sqlite.pragma("journal_mode = WAL");
const db = drizzle(sqlite);
migrate(db, { migrationsFolder: path.resolve(process.cwd(), "drizzle") });
console.log(`Migrated ${dbPath}`);
sqlite.close();
-373
View File
@@ -1,373 +0,0 @@
import {
sqliteTable,
text,
integer,
primaryKey,
index,
} from "drizzle-orm/sqlite-core";
import { relations, sql } from "drizzle-orm";
const id = (name = "id") =>
text(name).primaryKey().$defaultFn(() => crypto.randomUUID());
const timestamps = {
createdAt: integer("created_at", { mode: "timestamp_ms" })
.notNull()
.default(sql`(unixepoch() * 1000)`),
updatedAt: integer("updated_at", { mode: "timestamp_ms" })
.notNull()
.default(sql`(unixepoch() * 1000)`),
};
export const households = sqliteTable("households", {
id: id(),
name: text("name").notNull(),
baseCurrency: text("base_currency").notNull().default("GBP"),
...timestamps,
});
export const users = sqliteTable("users", {
id: id(),
name: text("name"),
email: text("email").notNull().unique(),
emailVerified: integer("email_verified", { mode: "timestamp_ms" }),
image: text("image"),
passwordHash: text("password_hash"),
householdId: text("household_id").references(() => households.id, {
onDelete: "set null",
}),
role: text("role", { enum: ["owner", "member"] }).notNull().default("member"),
...timestamps,
});
export const authAccounts = sqliteTable(
"auth_accounts",
{
userId: text("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
type: text("type").notNull(),
provider: text("provider").notNull(),
providerAccountId: text("provider_account_id").notNull(),
refresh_token: text("refresh_token"),
access_token: text("access_token"),
expires_at: integer("expires_at"),
token_type: text("token_type"),
scope: text("scope"),
id_token: text("id_token"),
session_state: text("session_state"),
},
(t) => ({
pk: primaryKey({ columns: [t.provider, t.providerAccountId] }),
}),
);
export const sessions = sqliteTable("sessions", {
sessionToken: text("session_token").primaryKey(),
userId: text("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
expires: integer("expires", { mode: "timestamp_ms" }).notNull(),
});
export const verificationTokens = sqliteTable(
"verification_tokens",
{
identifier: text("identifier").notNull(),
token: text("token").notNull(),
expires: integer("expires", { mode: "timestamp_ms" }).notNull(),
},
(t) => ({ pk: primaryKey({ columns: [t.identifier, t.token] }) }),
);
export const bankAccounts = sqliteTable(
"bank_accounts",
{
id: id(),
householdId: text("household_id")
.notNull()
.references(() => households.id, { onDelete: "cascade" }),
ownerUserId: text("owner_user_id").references(() => users.id, {
onDelete: "set null",
}),
name: text("name").notNull(),
type: text("type", {
enum: ["current", "savings", "credit", "cash", "investment"],
})
.notNull()
.default("current"),
currency: text("currency").notNull().default("GBP"),
openingBalanceMinor: integer("opening_balance_minor")
.notNull()
.default(0),
openingDate: text("opening_date").notNull(),
isShared: integer("is_shared", { mode: "boolean" }).notNull().default(false),
isArchived: integer("is_archived", { mode: "boolean" })
.notNull()
.default(false),
color: text("color"),
...timestamps,
},
(t) => ({
byHousehold: index("bank_accounts_household_idx").on(t.householdId),
}),
);
export const categories = sqliteTable(
"categories",
{
id: id(),
householdId: text("household_id")
.notNull()
.references(() => households.id, { onDelete: "cascade" }),
name: text("name").notNull(),
kind: text("kind", { enum: ["income", "expense", "transfer"] })
.notNull()
.default("expense"),
color: text("color"),
icon: text("icon"),
...timestamps,
},
(t) => ({
byHousehold: index("categories_household_idx").on(t.householdId),
}),
);
export const recurringRules = sqliteTable(
"recurring_rules",
{
id: id(),
householdId: text("household_id")
.notNull()
.references(() => households.id, { onDelete: "cascade" }),
bankAccountId: text("bank_account_id")
.notNull()
.references(() => bankAccounts.id, { onDelete: "cascade" }),
categoryId: text("category_id").references(() => categories.id, {
onDelete: "set null",
}),
description: text("description").notNull(),
amountMinor: integer("amount_minor").notNull(),
rrule: text("rrule").notNull(),
startDate: text("start_date").notNull(),
endDate: text("end_date"),
isActive: integer("is_active", { mode: "boolean" })
.notNull()
.default(true),
...timestamps,
},
(t) => ({
byHousehold: index("recurring_rules_household_idx").on(t.householdId),
byAccount: index("recurring_rules_account_idx").on(t.bankAccountId),
}),
);
export const transactions = sqliteTable(
"transactions",
{
id: id(),
householdId: text("household_id")
.notNull()
.references(() => households.id, { onDelete: "cascade" }),
bankAccountId: text("bank_account_id")
.notNull()
.references(() => bankAccounts.id, { onDelete: "cascade" }),
categoryId: text("category_id").references(() => categories.id, {
onDelete: "set null",
}),
recurringRuleId: text("recurring_rule_id").references(
() => recurringRules.id,
{ onDelete: "set null" },
),
date: text("date").notNull(),
amountMinor: integer("amount_minor").notNull(),
description: text("description").notNull(),
notes: text("notes"),
status: text("status", {
enum: ["posted", "projected", "cancelled"],
})
.notNull()
.default("posted"),
source: text("source", {
enum: ["manual", "recurring", "import", "bank_sync"],
})
.notNull()
.default("manual"),
externalId: text("external_id"),
createdByUserId: text("created_by_user_id").references(() => users.id, {
onDelete: "set null",
}),
...timestamps,
},
(t) => ({
byHouseholdDate: index("transactions_household_date_idx").on(
t.householdId,
t.date,
),
byAccountDate: index("transactions_account_date_idx").on(
t.bankAccountId,
t.date,
),
byRule: index("transactions_rule_idx").on(t.recurringRuleId),
byExternal: index("transactions_external_idx").on(t.externalId),
}),
);
export const budgets = sqliteTable(
"budgets",
{
id: id(),
householdId: text("household_id")
.notNull()
.references(() => households.id, { onDelete: "cascade" }),
categoryId: text("category_id")
.notNull()
.references(() => categories.id, { onDelete: "cascade" }),
period: text("period", { enum: ["weekly", "monthly", "yearly"] })
.notNull()
.default("monthly"),
amountMinor: integer("amount_minor").notNull(),
startDate: text("start_date").notNull(),
...timestamps,
},
(t) => ({
byHousehold: index("budgets_household_idx").on(t.householdId),
}),
);
export const savingsGoals = sqliteTable(
"savings_goals",
{
id: id(),
householdId: text("household_id")
.notNull()
.references(() => households.id, { onDelete: "cascade" }),
bankAccountId: text("bank_account_id").references(() => bankAccounts.id, {
onDelete: "set null",
}),
name: text("name").notNull(),
targetAmountMinor: integer("target_amount_minor").notNull(),
currentAmountMinor: integer("current_amount_minor").notNull().default(0),
targetDate: text("target_date"),
monthlyContributionMinor: integer("monthly_contribution_minor")
.notNull()
.default(0),
annualInterestRate: integer("annual_interest_rate_bp")
.notNull()
.default(0),
...timestamps,
},
(t) => ({
byHousehold: index("goals_household_idx").on(t.householdId),
}),
);
export const splits = sqliteTable(
"splits",
{
id: id(),
transactionId: text("transaction_id")
.notNull()
.references(() => transactions.id, { onDelete: "cascade" }),
userId: text("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
shareMinor: integer("share_minor").notNull(),
},
(t) => ({
byTransaction: index("splits_transaction_idx").on(t.transactionId),
}),
);
export const bankConnections = sqliteTable("bank_connections", {
id: id(),
householdId: text("household_id")
.notNull()
.references(() => households.id, { onDelete: "cascade" }),
provider: text("provider", {
enum: ["gocardless", "truelayer", "plaid"],
}).notNull(),
providerInstitutionId: text("provider_institution_id"),
status: text("status", {
enum: ["connected", "expired", "error", "pending"],
})
.notNull()
.default("pending"),
accessTokenEncrypted: text("access_token_encrypted"),
refreshTokenEncrypted: text("refresh_token_encrypted"),
expiresAt: integer("expires_at", { mode: "timestamp_ms" }),
...timestamps,
});
export const householdsRelations = relations(households, ({ many }) => ({
users: many(users),
bankAccounts: many(bankAccounts),
categories: many(categories),
transactions: many(transactions),
recurringRules: many(recurringRules),
budgets: many(budgets),
savingsGoals: many(savingsGoals),
}));
export const usersRelations = relations(users, ({ one, many }) => ({
household: one(households, {
fields: [users.householdId],
references: [households.id],
}),
ownedAccounts: many(bankAccounts),
}));
export const bankAccountsRelations = relations(bankAccounts, ({ one, many }) => ({
household: one(households, {
fields: [bankAccounts.householdId],
references: [households.id],
}),
owner: one(users, {
fields: [bankAccounts.ownerUserId],
references: [users.id],
}),
transactions: many(transactions),
}));
export const transactionsRelations = relations(transactions, ({ one, many }) => ({
household: one(households, {
fields: [transactions.householdId],
references: [households.id],
}),
bankAccount: one(bankAccounts, {
fields: [transactions.bankAccountId],
references: [bankAccounts.id],
}),
category: one(categories, {
fields: [transactions.categoryId],
references: [categories.id],
}),
recurringRule: one(recurringRules, {
fields: [transactions.recurringRuleId],
references: [recurringRules.id],
}),
splits: many(splits),
}));
export const recurringRulesRelations = relations(recurringRules, ({ one }) => ({
bankAccount: one(bankAccounts, {
fields: [recurringRules.bankAccountId],
references: [bankAccounts.id],
}),
category: one(categories, {
fields: [recurringRules.categoryId],
references: [categories.id],
}),
}));
export type Household = typeof households.$inferSelect;
export type User = typeof users.$inferSelect;
export type BankAccount = typeof bankAccounts.$inferSelect;
export type NewBankAccount = typeof bankAccounts.$inferInsert;
export type Category = typeof categories.$inferSelect;
export type Transaction = typeof transactions.$inferSelect;
export type NewTransaction = typeof transactions.$inferInsert;
export type RecurringRule = typeof recurringRules.$inferSelect;
export type NewRecurringRule = typeof recurringRules.$inferInsert;
export type Budget = typeof budgets.$inferSelect;
export type SavingsGoal = typeof savingsGoals.$inferSelect;
@@ -1,47 +0,0 @@
export type CompoundFrequency = "monthly" | "quarterly" | "yearly";
export function compoundInterest(args: {
principalMinor: number;
monthlyContributionMinor: number;
annualRate: number;
months: number;
frequency?: CompoundFrequency;
}): { finalMinor: number; series: { month: number; balanceMinor: number; contributedMinor: number; interestMinor: number }[] } {
const { principalMinor, monthlyContributionMinor, annualRate, months } = args;
const r = annualRate / 12;
let balance = principalMinor;
let contributed = principalMinor;
let interest = 0;
const series: { month: number; balanceMinor: number; contributedMinor: number; interestMinor: number }[] = [];
for (let m = 1; m <= months; m++) {
const monthlyInterest = Math.round(balance * r);
interest += monthlyInterest;
balance += monthlyInterest + monthlyContributionMinor;
contributed += monthlyContributionMinor;
series.push({
month: m,
balanceMinor: balance,
contributedMinor: contributed,
interestMinor: interest,
});
}
return { finalMinor: balance, series };
}
export function monthsToReachGoal(args: {
principalMinor: number;
monthlyContributionMinor: number;
annualRate: number;
goalMinor: number;
cap?: number;
}): number | null {
const cap = args.cap ?? 1200;
const r = args.annualRate / 12;
let balance = args.principalMinor;
for (let m = 1; m <= cap; m++) {
balance += Math.round(balance * r) + args.monthlyContributionMinor;
if (balance >= args.goalMinor) return m;
}
return null;
}
@@ -1,100 +0,0 @@
import { db } from "@/db";
import {
bankAccounts,
transactions,
recurringRules,
categories,
type BankAccount,
} from "@/db/schema";
import { and, eq, gte, lte, asc } from "drizzle-orm";
import { buildLedger, type LedgerRow } from "@/lib/recurring";
export type LedgerOptions = {
householdId: string;
from: Date;
to: Date;
bankAccountId?: string;
};
function toISODate(d: Date): string {
const y = d.getUTCFullYear();
const m = String(d.getUTCMonth() + 1).padStart(2, "0");
const day = String(d.getUTCDate()).padStart(2, "0");
return `${y}-${m}-${day}`;
}
export async function loadLedger(opts: LedgerOptions): Promise<{
rows: LedgerRow[];
accounts: BankAccount[];
}> {
const accts = await db
.select()
.from(bankAccounts)
.where(eq(bankAccounts.householdId, opts.householdId));
const accountIds = opts.bankAccountId ? [opts.bankAccountId] : accts.map((a) => a.id);
const fromIso = toISODate(opts.from);
const toIso = toISODate(opts.to);
const filterAccount = opts.bankAccountId
? eq(transactions.bankAccountId, opts.bankAccountId)
: undefined;
const posted = await db
.select()
.from(transactions)
.where(
and(
eq(transactions.householdId, opts.householdId),
gte(transactions.date, fromIso),
lte(transactions.date, toIso),
...(filterAccount ? [filterAccount] : []),
),
)
.orderBy(asc(transactions.date));
const rules = await db
.select()
.from(recurringRules)
.where(
and(
eq(recurringRules.householdId, opts.householdId),
eq(recurringRules.isActive, true),
...(opts.bankAccountId
? [eq(recurringRules.bankAccountId, opts.bankAccountId)]
: []),
),
);
const openingByAccount: Record<string, number> = {};
for (const a of accts) {
if (!accountIds.includes(a.id)) continue;
const opening = a.openingBalanceMinor;
const beforePosted = await db
.select()
.from(transactions)
.where(
and(
eq(transactions.householdId, opts.householdId),
eq(transactions.bankAccountId, a.id),
lte(transactions.date, fromIso),
),
);
const sumBefore = beforePosted
.filter((t) => t.date < fromIso && t.status !== "cancelled")
.reduce((s, t) => s + t.amountMinor, 0);
openingByAccount[a.id] = opening + sumBefore;
}
const rows = buildLedger(posted, rules, opts.from, opts.to, openingByAccount);
return { rows, accounts: accts };
}
export async function getCategoriesMap(householdId: string) {
const rows = await db
.select()
.from(categories)
.where(eq(categories.householdId, householdId));
return new Map(rows.map((c) => [c.id, c]));
}
@@ -1,158 +0,0 @@
import { RRule, rrulestr } from "rrule";
import type { RecurringRule, Transaction } from "@/db/schema";
export type ProjectedTransaction = {
id: string;
bankAccountId: string;
categoryId: string | null;
recurringRuleId: string;
date: string;
amountMinor: number;
description: string;
status: "projected";
source: "recurring";
};
function toISODate(d: Date): string {
const y = d.getUTCFullYear();
const m = String(d.getUTCMonth() + 1).padStart(2, "0");
const day = String(d.getUTCDate()).padStart(2, "0");
return `${y}-${m}-${day}`;
}
function parseISODate(s: string): Date {
const [y, m, d] = s.split("-").map(Number);
return new Date(Date.UTC(y, (m ?? 1) - 1, d ?? 1));
}
export function rruleFromRule(rule: Pick<RecurringRule, "rrule" | "startDate" | "endDate">): RRule {
const dtstart = parseISODate(rule.startDate);
const until = rule.endDate ? parseISODate(rule.endDate) : undefined;
const trimmed = rule.rrule.trim();
if (trimmed.startsWith("RRULE:") || trimmed.startsWith("DTSTART")) {
const r = rrulestr(trimmed) as RRule;
return new RRule({ ...r.options, dtstart, until: until ?? r.options.until });
}
const opts = RRule.parseString(trimmed);
return new RRule({ ...opts, dtstart, until: until ?? opts.until });
}
export function generateOccurrences(
rule: Pick<RecurringRule, "rrule" | "startDate" | "endDate">,
from: Date,
to: Date,
): Date[] {
const r = rruleFromRule(rule);
return r.between(from, to, true);
}
export function projectTransactionsFromRule(
rule: RecurringRule,
from: Date,
to: Date,
postedKeyset: Set<string> = new Set(),
): ProjectedTransaction[] {
if (!rule.isActive) return [];
const dates = generateOccurrences(rule, from, to);
return dates
.map((d) => {
const dateStr = toISODate(d);
const dedupKey = `${rule.id}|${dateStr}`;
if (postedKeyset.has(dedupKey)) return null;
return {
id: `proj_${rule.id}_${dateStr}`,
bankAccountId: rule.bankAccountId,
categoryId: rule.categoryId,
recurringRuleId: rule.id,
date: dateStr,
amountMinor: rule.amountMinor,
description: rule.description,
status: "projected" as const,
source: "recurring" as const,
} satisfies ProjectedTransaction;
})
.filter((x): x is ProjectedTransaction => x !== null);
}
export type LedgerRow = {
id: string;
bankAccountId: string;
categoryId: string | null;
recurringRuleId: string | null;
date: string;
amountMinor: number;
description: string;
status: "posted" | "projected" | "cancelled";
source: string;
runningBalanceMinor: number;
};
export function buildLedger(
posted: Transaction[],
rules: RecurringRule[],
from: Date,
to: Date,
openingBalanceByAccount: Record<string, number>,
): LedgerRow[] {
const postedKeyset = new Set(
posted
.filter((t) => t.recurringRuleId)
.map((t) => `${t.recurringRuleId}|${t.date}`),
);
const projected = rules.flatMap((r) =>
projectTransactionsFromRule(r, from, to, postedKeyset),
);
const all = [
...posted.map((p) => ({
id: p.id,
bankAccountId: p.bankAccountId,
categoryId: p.categoryId,
recurringRuleId: p.recurringRuleId,
date: p.date,
amountMinor: p.amountMinor,
description: p.description,
status: p.status,
source: p.source,
})),
...projected.map((p) => ({
id: p.id,
bankAccountId: p.bankAccountId,
categoryId: p.categoryId,
recurringRuleId: p.recurringRuleId,
date: p.date,
amountMinor: p.amountMinor,
description: p.description,
status: p.status as "projected",
source: p.source as string,
})),
].sort((a, b) => {
if (a.date < b.date) return -1;
if (a.date > b.date) return 1;
if (a.status === "posted" && b.status !== "posted") return -1;
if (a.status !== "posted" && b.status === "posted") return 1;
return 0;
});
const balances: Record<string, number> = { ...openingBalanceByAccount };
return all.map((row) => {
const prev = balances[row.bankAccountId] ?? 0;
const next = row.status === "cancelled" ? prev : prev + row.amountMinor;
balances[row.bankAccountId] = next;
return { ...row, runningBalanceMinor: next };
});
}
export const RRULE_PRESETS: Array<{ label: string; rrule: string }> = [
{ label: "Daily", rrule: "FREQ=DAILY" },
{ label: "Weekly (Mon)", rrule: "FREQ=WEEKLY;BYDAY=MO" },
{ label: "Weekly (Fri)", rrule: "FREQ=WEEKLY;BYDAY=FR" },
{ label: "Every 2 weeks", rrule: "FREQ=WEEKLY;INTERVAL=2" },
{ label: "Monthly (same day)", rrule: "FREQ=MONTHLY" },
{ label: "Monthly (1st)", rrule: "FREQ=MONTHLY;BYMONTHDAY=1" },
{ label: "Monthly (last day)", rrule: "FREQ=MONTHLY;BYMONTHDAY=-1" },
{ label: "Yearly", rrule: "FREQ=YEARLY" },
];
-30
View File
@@ -1,30 +0,0 @@
import { db } from "@/db";
import { categories } from "@/db/schema";
export const DEFAULT_CATEGORIES: Array<{
name: string;
kind: "income" | "expense" | "transfer";
color: string;
icon: string;
}> = [
{ name: "Salary", kind: "income", color: "#10b981", icon: "Briefcase" },
{ name: "Business", kind: "income", color: "#06b6d4", icon: "Store" },
{ name: "Other Income", kind: "income", color: "#22c55e", icon: "PiggyBank" },
{ name: "Rent", kind: "expense", color: "#f97316", icon: "Home" },
{ name: "Mortgage", kind: "expense", color: "#fb923c", icon: "House" },
{ name: "Groceries", kind: "expense", color: "#eab308", icon: "ShoppingCart" },
{ name: "Food & Dining", kind: "expense", color: "#f59e0b", icon: "UtensilsCrossed" },
{ name: "Subscriptions", kind: "expense", color: "#a855f7", icon: "PlaySquare" },
{ name: "Utilities", kind: "expense", color: "#0ea5e9", icon: "Zap" },
{ name: "Transport", kind: "expense", color: "#3b82f6", icon: "Car" },
{ name: "Insurance", kind: "expense", color: "#ec4899", icon: "ShieldCheck" },
{ name: "Debt Repayment", kind: "expense", color: "#ef4444", icon: "CreditCard" },
{ name: "Savings", kind: "transfer", color: "#14b8a6", icon: "Landmark" },
{ name: "Other", kind: "expense", color: "#6b7280", icon: "Tag" },
];
export async function seedHouseholdDefaults(householdId: string) {
await db.insert(categories).values(
DEFAULT_CATEGORIES.map((c) => ({ householdId, ...c })),
);
}
@@ -1,21 +0,0 @@
import { auth } from "@/auth";
import { redirect } from "next/navigation";
import { db } from "@/db";
import { users } from "@/db/schema";
import { eq } from "drizzle-orm";
export async function requireUser() {
const session = await auth();
if (!session?.user?.id) redirect("/login");
return session.user;
}
export async function requireHousehold() {
const user = await requireUser();
if (!user.householdId) {
const [u] = await db.select().from(users).where(eq(users.id, user.id));
if (!u?.householdId) redirect("/onboarding");
return { user, householdId: u.householdId };
}
return { user, householdId: user.householdId };
}
-24
View File
@@ -1,24 +0,0 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
export function formatMoney(
amountMinor: number,
currency: string = "GBP",
locale: string = "en-GB",
): string {
return new Intl.NumberFormat(locale, {
style: "currency",
currency,
}).format(amountMinor / 100);
}
export function parseMoney(input: string): number {
const cleaned = input.replace(/[^\d.\-]/g, "");
const value = parseFloat(cleaned);
if (isNaN(value)) return 0;
return Math.round(value * 100);
}
@@ -1,8 +0,0 @@
import NextAuth from "next-auth";
import { authConfig } from "@/auth.config";
export default NextAuth(authConfig).auth;
export const config = {
matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
};
-23
View File
@@ -1,23 +0,0 @@
import "next-auth";
import "next-auth/jwt";
declare module "next-auth" {
interface Session {
user: {
id: string;
email?: string | null;
name?: string | null;
image?: string | null;
householdId?: string | null;
role?: string;
};
}
}
declare module "next-auth/jwt" {
interface JWT {
userId?: string;
householdId?: string | null;
role?: string;
}
}
@@ -1,98 +0,0 @@
import type { Config } from "tailwindcss";
import animate from "tailwindcss-animate";
const config = {
darkMode: ["class"],
content: ["./src/**/*.{ts,tsx,js,jsx,mdx}"],
theme: {
container: { center: true, padding: "2rem", screens: { "2xl": "1400px" } },
extend: {
colors: {
border: "var(--border)",
input: "var(--input)",
ring: "var(--ring)",
background: "var(--background)",
foreground: "var(--foreground)",
primary: {
DEFAULT: "var(--primary)",
foreground: "var(--primary-foreground)",
},
secondary: {
DEFAULT: "var(--secondary)",
foreground: "var(--secondary-foreground)",
},
destructive: {
DEFAULT: "var(--destructive)",
foreground: "var(--destructive-foreground)",
},
success: {
DEFAULT: "var(--success)",
foreground: "var(--success-foreground)",
},
warning: {
DEFAULT: "var(--warning)",
foreground: "var(--warning-foreground)",
},
muted: {
DEFAULT: "var(--muted)",
foreground: "var(--muted-foreground)",
},
accent: {
DEFAULT: "var(--accent)",
foreground: "var(--accent-foreground)",
},
popover: {
DEFAULT: "var(--popover)",
foreground: "var(--popover-foreground)",
},
card: {
DEFAULT: "var(--card)",
foreground: "var(--card-foreground)",
},
sidebar: {
DEFAULT: "var(--sidebar)",
foreground: "var(--sidebar-foreground)",
primary: "var(--sidebar-primary)",
"primary-foreground": "var(--sidebar-primary-foreground)",
accent: "var(--sidebar-accent)",
"accent-foreground": "var(--sidebar-accent-foreground)",
border: "var(--sidebar-border)",
ring: "var(--sidebar-ring)",
},
chart: {
"1": "var(--chart-1)",
"2": "var(--chart-2)",
"3": "var(--chart-3)",
"4": "var(--chart-4)",
"5": "var(--chart-5)",
},
},
borderRadius: {
lg: "var(--radius)",
md: "calc(var(--radius) - 2px)",
sm: "calc(var(--radius) - 4px)",
},
fontFamily: {
sans: ["var(--font-geist-sans)", "ui-sans-serif", "system-ui", "sans-serif"],
mono: ["var(--font-geist-mono)", "ui-monospace", "monospace"],
},
keyframes: {
"accordion-down": {
from: { height: "0" },
to: { height: "var(--radix-accordion-content-height)" },
},
"accordion-up": {
from: { height: "var(--radix-accordion-content-height)" },
to: { height: "0" },
},
},
animation: {
"accordion-down": "accordion-down 0.2s ease-out",
"accordion-up": "accordion-up 0.2s ease-out",
},
},
},
plugins: [animate],
} satisfies Config;
export default config;
-27
View File
@@ -1,27 +0,0 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}
-55
View File
@@ -1,55 +0,0 @@
networks:
DOCKER_NETWORK_DATA: #LIBREPORTAL|DOCKER_NETWORK_TAG|DOCKER_NETWORK_DATA
external: true
services:
moneyapp-service: #LIBREPORTAL|SERVICE_TAG_1|moneyapp-service
container_name: moneyapp-service
build: .
image: moneyapp:latest #LIBREPORTAL|MONEYAPP_VERSION_TAG|latest
restart: unless-stopped
hostname: moneyapp
# GLUETUN_OFF_BEGIN
ports:
- "PORTS_DATA_1" #LIBREPORTAL|PORTS_TAG_1|PORTS_DATA_1
# GLUETUN_OFF_END
environment:
- TZ=TIMEZONE_DATA #LIBREPORTAL|TIMEZONE_TAG|TIMEZONE_DATA
# AUTH_SECRET is auto-generated (CFG_MONEYAPP_AUTH_SECRET_1=RANDOMIZEDHEX1).
- AUTH_SECRET=MONEYAPP_AUTH_SECRET_1_DATA #LIBREPORTAL|MONEYAPP_AUTH_SECRET_1_TAG|MONEYAPP_AUTH_SECRET_1_DATA
- AUTH_URL=MONEYAPP_AUTH_URL_DATA #LIBREPORTAL|MONEYAPP_AUTH_URL_TAG|MONEYAPP_AUTH_URL_DATA
- DATABASE_FILE=/data/moneyapp.db
volumes:
- ./data:/data
healthcheck:
test: ["CMD-SHELL", "wget -q --spider http://127.0.0.1:3000/ || exit 1"]
interval: 30s
timeout: 5s
retries: 3
disable: HEALTHCHECK_DATA #LIBREPORTAL|HEALTHCHECK_TAG|HEALTHCHECK_DATA
labels:
libreportal.category: "CATEGORY_DATA" #LIBREPORTAL|CATEGORY_TAG|CATEGORY_DATA
libreportal.title: "TITLE_DATA" #LIBREPORTAL|TITLE_TAG|TITLE_DATA
traefik.enable: TRAEFIK_ENABLE_DATA #LIBREPORTAL|TRAEFIK_ENABLE_TAG|TRAEFIK_ENABLE_DATA
# TRAEFIK_PORT_1_BEGIN
traefik.http.routers.moneyapp-service.entrypoints: web,websecure
traefik.http.routers.moneyapp-service.rule: Host(`DOMAINSUBNAME_DATA_1`) #LIBREPORTAL|DOMAINSUBNAME_TAG_1|DOMAINSUBNAME_DATA_1
traefik.http.routers.moneyapp-service.tls: true
traefik.http.routers.moneyapp-service.tls.certresolver: production
traefik.http.services.moneyapp-service.loadbalancer.server.port: PORT_INTERNAL_DATA_1 #LIBREPORTAL|PORT_INTERNAL_TAG_1|PORT_INTERNAL_DATA_1
traefik.http.routers.moneyapp-service.middlewares: MIDDLEWARE_DATA_1 #LIBREPORTAL|MIDDLEWARE_TAG_1|MIDDLEWARE_DATA_1
# TRAEFIK_PORT_1_END
traefik.docker.network: DOCKER_NETWORK_DATA #LIBREPORTAL|DOCKER_NETWORK_TAG|DOCKER_NETWORK_DATA
# GLUETUN_OFF_BEGIN
networks:
DOCKER_NETWORK_DATA: #LIBREPORTAL|DOCKER_NETWORK_TAG|DOCKER_NETWORK_DATA
ipv4_address: IP_DATA_1 #LIBREPORTAL|IP_TAG_1|IP_DATA_1
# GLUETUN_OFF_END
# GLUETUN_ON_BEGIN
# network_mode: "container:gluetun-service"
# GLUETUN_ON_END
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
-65
View File
@@ -1,65 +0,0 @@
#
# =============================================================================
# GENERAL CONFIGURATION
# =============================================================================
# APP_NAME = name of application for use in scripts
# COMPOSE_FILE = default for no app_name in docker-compose file name, app if there is
# BACKUP = if true, include this application in backup operations
# UPDATE_TYPE = auto: new image builds are applied automatically (a recovery snapshot is taken first), manual: only when you press Update
# HEALTHCHECK = if true, default docker health checks for that container will be enabled
# AUTHELIA = if true, use Authelia authentication, if false turned off.
# AUTH_SECRET = NextAuth/Auth.js session secret (random base64; auto-generated)
#
CFG_MONEYAPP_APP_NAME=moneyapp
# MULTI_INSTANCE = if true, this app can run as multiple isolated instances
# (own data/DB/subdomain/backups) via `libreportal instance create`. Only set on
# apps whose compose identity (container_name, Traefik routers, backup labels)
# is instance-safe — see scripts/instance/instance_create.sh.
CFG_MONEYAPP_MULTI_INSTANCE=true
CFG_MONEYAPP_REQUIRES=""
CFG_MONEYAPP_BACKUP=true
# STORAGE = which storage location holds this app's data. Change it with
# `libreportal app move moneyapp <location>` — editing here records intent only.
CFG_MONEYAPP_STORAGE=default # Storage Location - Which disk holds this app's data [default:Primary]
CFG_MONEYAPP_BACKUP_STRATEGY=auto
CFG_MONEYAPP_UPDATE_TYPE=auto
CFG_MONEYAPP_COMPOSE_FILE=default
CFG_MONEYAPP_HEALTHCHECK=true
CFG_MONEYAPP_AUTHELIA=false
CFG_MONEYAPP_AUTH_SECRET_1=RANDOMIZEDHEX1
#
# =============================================================================
# METADATA
# =============================================================================
# CATEGORY = application category for grouping
# TITLE = display name for the application
# DESCRIPTION = short description of the application
# LONG_DESCRIPTION = detailed description of the application
# URL = source repository or documentation URL
# ACTIONS = available actions for this application
#
CFG_MONEYAPP_CATEGORY="productivity"
CFG_MONEYAPP_TITLE="MoneyApp"
CFG_MONEYAPP_DESCRIPTION="Household Money Management"
CFG_MONEYAPP_LONG_DESCRIPTION="Personal budget tracker with recurring rules, projected ledger, savings goals, compound-interest calculator and 12-month what-if forecast."
CFG_MONEYAPP_URL=""
CFG_MONEYAPP_ACTIONS="configure|install|restart|shutdown|uninstall"
#
# =============================================================================
# NETWORK CONFIGURATION
# =============================================================================
# DOMAIN = number of domain from the general config, useful when using multiple domains
# WHITELIST = if true only allow whitelisted ips (see general config), if false allow all
# NETWORK = default | gluetun (route outbound traffic through the VPN gateway)
#
CFG_MONEYAPP_DOMAIN=1
CFG_MONEYAPP_WHITELIST=false
CFG_MONEYAPP_NETWORK=default
#
# =============================================================================
# PORTS
# =============================================================================
# Schema (10 cols): service|name|external:internal|access|protocol|login|traefik|button_enabled|button_text|url_path
# button_text + url_path may carry comma-separated parallel arrays for multi-button entries.
#
CFG_MONEYAPP_PORT_1="moneyapp-service|webui|random:3000|public|tcp|false|true|true|MoneyApp|/|moneyapp"
-7
View File
@@ -1,7 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<circle cx="256" cy="256" r="256" fill="#2e9e6b"/>
<rect x="116" y="176" width="280" height="160" rx="20" fill="#ffffff"/>
<circle cx="256" cy="256" r="42" fill="none" stroke="#2e9e6b" stroke-width="16"/>
<circle cx="168" cy="256" r="11" fill="#2e9e6b"/>
<circle cx="344" cy="256" r="11" fill="#2e9e6b"/>
</svg>

Before

Width:  |  Height:  |  Size: 385 B

@@ -1,54 +0,0 @@
#!/bin/bash
# MoneyApp install hooks — pre-flight requirements + Auth.js callback URL.
moneyapp_install_pre()
{
local app_name="$1"
# Bail out before any compose/config work if global prerequisites are
# missing (currently "domain,traefik"). The helper prints what's
# missing.
if ! appInstallCheckRequirements "$app_name" "$CFG_MONEYAPP_REQUIRES"; then
moneyapp=n
return 1
fi
}
moneyapp_install_post_compose()
{
local app_name="$1"
((menu_number++))
echo ""
echo "---- $menu_number. Resolving Auth.js callback URL (AUTH_URL)."
echo ""
# Auth.js requires a fixed AUTH_URL matching what the browser actually
# hits — otherwise OAuth-style callbacks bounce. Pick:
# * https://<host>.<domain> when traefik is on AND a real domain
# is configured (CFG_DOMAIN_<n>)
# * http://<server-ip>:<port> otherwise (raw port exposure)
local moneyapp_compose_file="$(appDir "$app_name")/docker-compose.yml"
local moneyapp_auth_url=""
# host_setup is built as `<app>.<domain>` — when CFG_DOMAIN_<n> is
# empty it ends up as `<app>.`, a valid-but-broken hostname. Reject
# that explicitly: we want a real TLD before going https.
local _has_real_domain=0
if [[ -n "$host_setup" && "$host_setup" != *. ]] && [[ "$host_setup" == *.* ]]; then
_has_real_domain=1
fi
if [[ "$public" == "true" && $_has_real_domain -eq 1 ]]; then
moneyapp_auth_url="https://$host_setup"
else
local moneyapp_port_pair
moneyapp_port_pair=$(tagsManagerGetTagContent "$moneyapp_compose_file" "PORTS_TAG_1")
local moneyapp_external_port="${moneyapp_port_pair%%:*}"
local moneyapp_host="${public_ip_v4:-localhost}"
moneyapp_auth_url="http://${moneyapp_host}:${moneyapp_external_port}"
if [[ "$public" == "true" && $_has_real_domain -eq 0 ]]; then
isNotice "PORT_1 has traefik=true but no domain configured — falling back to direct port URL."
fi
fi
tagsManagerUpdateUniversalTag "$moneyapp_compose_file" "MONEYAPP_AUTH_URL_TAG" "$moneyapp_auth_url"
isSuccessful "AUTH_URL set to $moneyapp_auth_url"
}
+22 -1
View File
@@ -7,6 +7,27 @@ running app on its next `libreportal app install <app>` (also the path taken by
restore, peer pull, and migrate), not on update. Anything below that needs a manual
step says so.
## Next release — MoneyApp is retired; LibreLedger replaces it
**Affects:** anyone who installed MoneyApp. Nothing happens to other apps.
MoneyApp is gone from the catalog. Its replacement is **LibreLedger**
(`libreportal app install libreledger`), an encrypted personal money ledger:
your data is encrypted in your browser, and the server only ever stores the
ciphertext. It installs private (trusted places only), is included in backups,
and is published from [git.libreportal.org/LibrePortal/LibreLedger](https://git.libreportal.org/LibrePortal/LibreLedger).
- **An existing MoneyApp keeps running.** Its container, its data in
`<containers-dir>/moneyapp/data/` and its backups are left alone, but it gets no
more updates, and once the install tree is updated LibrePortal no longer
ships the files it was built from. So reinstalling it, restoring it or
pulling it from a peer stops working.
- **Nothing is migrated.** The two apps store different things (MoneyApp used
a plain SQLite database, and LibreLedger stores one encrypted file), so
there is no automatic conversion. Enter what you want to keep into
LibreLedger, then remove MoneyApp when you're done with
`libreportal app uninstall moneyapp`.
## 0.2.0 — Mastodon shipped with unsubstituted database credentials
**Affects:** anyone who installed Mastodon on 0.1.0. No other app is affected.
@@ -171,7 +192,7 @@ value as generated.
This reaches apps that had nothing to do with the compose-side generator problem
above, including **adguard, authelia, bookstack, gitea, gluetun, headscale,
invidious, moneyapp, nextcloud, pihole, speedtest, stalwart, traefik, vaultwarden**
invidious, moneyapp (since retired, see above), nextcloud, pihole, speedtest, stalwart, traefik, vaultwarden**
and **wireguard**.
A rename is a delete plus an add as far as config reconciliation is concerned: the
@@ -68,6 +68,10 @@ updaterSetAnchorVersion() {
# every 0, 4 and 7 in the ref -- v0.15.1 became v.15.1. awk does honour the
# escape, which is why the same idiom is fine below.
local aref; aref="$(printf '%s' "$aline" | sed -E 's/^[[:space:]]*image:[[:space:]]*//; s/[[:space:]]*#.*$//' | tr -d "\"' ")"
# A digest-pinned ref (repo:tag@sha256:…) splits on the tag, not inside the
# digest. The rewritten line drops the digest: it named the OLD build, and
# keeping it would hold the app on the old image under the new tag.
aref="${aref%%@*}"
local atag="${aref##*:}"; local arepo="${aref%:*}"
case "$aref" in */*:*|*:*) : ;; *) return 1 ;; esac
local ans=""; case "$arepo" in */*) ans="${arepo%/*}" ;; esac
@@ -81,6 +85,7 @@ updaterSetAnchorVersion() {
k=$0; sub(/^.*#LIBREPORTAL\|/,"",k); sub(/\|.*$/,"",k)
ref=$0; sub(/^[[:space:]]*image:[[:space:]]*/,"",ref)
sub(/[[:space:]]*#.*$/,"",ref); gsub(/["\047]/,"",ref)
sub(/@.*$/,"",ref)
if (ref ~ /:/) {
tag=ref; sub(/^.*:/,"",tag)
repo=ref; sub(/:[^:\/]*$/,"",repo)
@@ -879,8 +879,6 @@ declare -gA LP_FN_MAP=(
[migrateUrlRewriteEnabled]="migrate/migrate_url_rewrite.sh"
[_mmctl]="mattermost/scripts/mattermost_auth.sh"
[_mmctlFailed]="mattermost/scripts/mattermost_auth.sh"
[moneyapp_install_post_compose]="moneyapp/scripts/moneyapp_install_hooks.sh"
[moneyapp_install_pre]="moneyapp/scripts/moneyapp_install_hooks.sh"
[monitoringAppEnabled]="network/monitoring/monitoring.sh"
[monitoringInstalledApps]="network/monitoring/monitoring.sh"
[monitoringIsInstalled]="network/monitoring/monitoring.sh"
@@ -2307,8 +2305,6 @@ declare -gA LP_FN_ROOT=(
[migrateUrlRewriteEnabled]="scripts"
[_mmctl]="containers"
[_mmctlFailed]="containers"
[moneyapp_install_post_compose]="containers"
[moneyapp_install_pre]="containers"
[monitoringAppEnabled]="scripts"
[monitoringInstalledApps]="scripts"
[monitoringIsInstalled]="scripts"
@@ -3775,8 +3771,6 @@ migrateSystem() { unset -f migrateSystem; __lpAutoload "${install_scripts_dir}mi
migrateUrlRewriteEnabled() { unset -f migrateUrlRewriteEnabled; __lpAutoload "${install_scripts_dir}migrate/migrate_url_rewrite.sh"; migrateUrlRewriteEnabled "$@"; }
_mmctl() { unset -f _mmctl; __lpAutoload "${install_containers_dir}mattermost/scripts/mattermost_auth.sh"; _mmctl "$@"; }
_mmctlFailed() { unset -f _mmctlFailed; __lpAutoload "${install_containers_dir}mattermost/scripts/mattermost_auth.sh"; _mmctlFailed "$@"; }
moneyapp_install_post_compose() { unset -f moneyapp_install_post_compose; __lpAutoload "${install_containers_dir}moneyapp/scripts/moneyapp_install_hooks.sh"; moneyapp_install_post_compose "$@"; }
moneyapp_install_pre() { unset -f moneyapp_install_pre; __lpAutoload "${install_containers_dir}moneyapp/scripts/moneyapp_install_hooks.sh"; moneyapp_install_pre "$@"; }
monitoringAppEnabled() { unset -f monitoringAppEnabled; __lpAutoload "${install_scripts_dir}network/monitoring/monitoring.sh"; monitoringAppEnabled "$@"; }
monitoringInstalledApps() { unset -f monitoringInstalledApps; __lpAutoload "${install_scripts_dir}network/monitoring/monitoring.sh"; monitoringInstalledApps "$@"; }
monitoringIsInstalled() { unset -f monitoringIsInstalled; __lpAutoload "${install_scripts_dir}network/monitoring/monitoring.sh"; monitoringIsInstalled "$@"; }
@@ -549,13 +549,6 @@ PORTEOF
"type": "password",
"tooltip": "Password for the first Synapse admin account"
},
"MONEYAPP_AUTH_SECRET_1": {
"category": "advanced",
"label": "Auth Secret",
"type": "password",
"tooltip": "Signs session tokens. Changing it logs users out.",
"advanced": true
},
"STALWART_ADMIN_PASSWORD_1": {
"category": "general",
"label": "Stalwart Admin Password",