Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aba12eb250 | ||
|
|
0aede3e75b |
@@ -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": [] }
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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 |
@@ -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
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
@@ -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.
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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`)
|
||||
);
|
||||
@@ -1,13 +0,0 @@
|
||||
{
|
||||
"version": "7",
|
||||
"dialect": "sqlite",
|
||||
"entries": [
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "6",
|
||||
"when": 1778161520872,
|
||||
"tag": "0000_fresh_silverclaw",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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;
|
||||
@@ -1,8 +0,0 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
output: "standalone",
|
||||
serverExternalPackages: ["better-sqlite3"],
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
@@ -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 +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 +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 +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 May 9th,Netflix,,10.00 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;
|
||||
|
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's add your bank accounts. You can edit these later — just enter what'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;
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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();
|
||||
@@ -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" },
|
||||
];
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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).*)"],
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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",
|
||||
|
||||