added api logic, vitest, minimal testing ui

This commit is contained in:
2025-11-15 23:26:57 -06:00
parent f4160b91db
commit 4eae966f96
95 changed files with 14155 additions and 469 deletions

5
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,5 @@
{
"css.lint.unknownAtRules": "ignore",
"scss.lint.unknownAtRules": "ignore",
"less.lint.unknownAtRules": "ignore"
}

28
Caddyfile.dev Normal file
View File

@@ -0,0 +1,28 @@
# Caddyfile.dev — local development (HTTP only)
{
auto_https off
admin off
}
:80 {
# Logs to stdout (handy in `docker compose logs caddy`)
log {
output stdout
format console
}
# Health-check / sanity page for the proxy itself
respond / "caddy ok" 200
# Proxy API: strip /api prefix and forward to the api service on 8080
handle_path /api/* {
reverse_proxy api:8080
}
# Helpful headers, even in dev
header {
X-Content-Type-Options "nosniff"
Referrer-Policy "strict-origin-when-cross-origin"
}
}
}

View File

@@ -1,27 +1,38 @@
FROM node:20-alpine AS deps FROM node:20-bookworm-slim AS deps
WORKDIR /app WORKDIR /app
COPY package.json package-lock.json* ./ COPY package.json package-lock.json* ./
RUN npm ci --omit=dev RUN npm ci --omit=dev
FROM node:20-alpine AS build FROM node:20-bookworm-slim AS build
WORKDIR /app WORKDIR /app
COPY package.json package-lock.json* ./ COPY package.json package-lock.json* ./
RUN npm ci RUN npm ci
COPY tsconfig.json ./ COPY tsconfig.json ./
COPY src ./src
COPY prisma ./prisma COPY prisma ./prisma
COPY src ./src
RUN npx prisma generate RUN npx prisma generate
RUN npm run build RUN npm run build
FROM node:20-alpine AS runner FROM node:20-bookworm-slim AS runner
WORKDIR /app WORKDIR /app
ENV NODE_ENV=production ENV NODE_ENV=production
# runtime files
# optional but nice
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates && rm -rf /var/lib/apt/lists/*
# 1) deps: prod node_modules
COPY --from=deps /app/node_modules ./node_modules COPY --from=deps /app/node_modules ./node_modules
# 2) app build output
COPY --from=build /app/dist ./dist COPY --from=build /app/dist ./dist
COPY --from=build /app/prisma ./prisma COPY --from=build /app/prisma ./prisma
# entrypoint does migrate deploy + start
# 3) 🔑 copy the generated Prisma client/artifacts from build stage
COPY --from=build /app/node_modules/@prisma/client ./node_modules/@prisma/client
COPY --from=build /app/node_modules/.prisma ./node_modules/.prisma
COPY entrypoint.sh ./entrypoint.sh COPY entrypoint.sh ./entrypoint.sh
RUN chmod +x /app/entrypoint.sh RUN sed -i 's/\r$//' /app/entrypoint.sh && chmod +x /app/entrypoint.sh
EXPOSE 8080 EXPOSE 8080
CMD ["/app/entrypoint.sh"] CMD ["/app/entrypoint.sh"]

0
api/GET Normal file
View File

0
api/allocator Normal file
View File

244
api/clients/ts/sdk.ts Normal file
View File

@@ -0,0 +1,244 @@
/* SkyMoney SDK: zero-dep, Fetch-based, TypeScript-first.
Usage:
import { SkyMoney } from "./sdk";
const api = new SkyMoney({ baseUrl: import.meta.env.VITE_API_URL });
const dash = await api.dashboard.get();
*/
export type TransactionKind = "variable_spend" | "fixed_payment";
export interface OkResponse { ok: true }
export interface ErrorResponse {
ok: false; code: string; message: string; requestId: string;
}
export interface VariableCategory {
id: string;
userId?: string;
name: string;
percent: number; // 0..100
isSavings: boolean;
priority: number;
balanceCents?: number;
}
export interface FixedPlan {
id: string;
userId?: string;
name: string;
totalCents?: number;
fundedCents?: number;
priority: number;
dueOn: string; // ISO
cycleStart?: string;// ISO
}
export interface Transaction {
id: string;
userId?: string;
kind: TransactionKind;
amountCents: number;
occurredAt: string; // ISO
categoryId?: string | null;
planId?: string | null;
}
export interface TransactionList {
items: Transaction[];
page: number;
limit: number;
total: number;
}
export interface DashboardResponse {
totals: {
incomeCents: number;
variableBalanceCents: number;
fixedRemainingCents: number;
};
percentTotal: number;
variableCategories: VariableCategory[];
fixedPlans: FixedPlan[];
recentTransactions: Transaction[];
}
export interface IncomeRequest { amountCents: number; }
export interface AllocationItem {
id: string; name: string; amountCents: number;
}
export interface IncomePreviewResponse {
fixed: AllocationItem[];
variable: AllocationItem[];
unallocatedCents: number;
}
// allocateIncome returns a richer object; tests expect these fields:
export interface IncomeAllocationResponse {
fixedAllocations?: AllocationItem[];
variableAllocations?: AllocationItem[];
remainingUnallocatedCents?: number;
// allow any extra fields without type errors:
// eslint-disable-next-line @typescript-eslint/no-explicit-any
[k: string]: any;
}
export type FetchLike = typeof fetch;
export type SDKOptions = {
baseUrl?: string;
userId?: string;
fetch?: FetchLike;
requestIdFactory?: () => string; // to set x-request-id if desired
};
function makeQuery(params: Record<string, unknown | undefined>): string {
const sp = new URLSearchParams();
for (const [k, v] of Object.entries(params)) {
if (v === undefined || v === null || v === "") continue;
sp.set(k, String(v));
}
const s = sp.toString();
return s ? `?${s}` : "";
}
export class SkyMoney {
readonly baseUrl: string;
private readonly f: FetchLike;
private readonly reqId?: () => string;
userId?: string;
constructor(opts: SDKOptions = {}) {
this.baseUrl = (opts.baseUrl || "http://localhost:8080").replace(/\/+$/, "");
this.userId = opts.userId ?? (
// Try localStorage if present (browser)
typeof localStorage !== "undefined" ? localStorage.getItem("x-user-id") || undefined : undefined
);
this.f = opts.fetch || fetch;
this.reqId = opts.requestIdFactory;
}
private async request<T>(
method: "GET" | "POST" | "PATCH" | "DELETE",
path: string,
body?: unknown,
query?: Record<string, unknown>,
headers?: Record<string, string>
): Promise<T> {
const url = `${this.baseUrl}${path}${query ? makeQuery(query) : ""}`;
const h: Record<string, string> = { ...(headers || {}) };
if (this.userId) h["x-user-id"] = this.userId;
if (this.reqId) h["x-request-id"] = this.reqId();
const hasBody = body !== undefined && body !== null;
const res = await this.f(url, {
method,
headers: {
...(hasBody ? { "content-type": "application/json" } : {}),
...h,
},
body: hasBody ? JSON.stringify(body) : undefined,
});
// Attempt to parse JSON; fall back to text
const text = await res.text();
const data = text ? safeJson(text) : undefined;
if (!res.ok) {
const err = new Error((data as any)?.message || `HTTP ${res.status}`);
(err as any).status = res.status;
(err as any).body = data ?? text;
throw err;
}
return data as T;
}
// ---- Health
health = {
get: () => this.request<{ ok: true }>("GET", "/health"),
db: () => this.request<{ ok: true; nowISO: string; latencyMs: number }>("GET", "/health/db"),
};
// ---- Dashboard
dashboard = {
get: () => this.request<DashboardResponse>("GET", "/dashboard"),
};
// ---- Income
income = {
preview: (amountCents: number) =>
this.request<IncomePreviewResponse>("POST", "/income/preview", { amountCents }),
create: (amountCents: number) =>
this.request<IncomeAllocationResponse>("POST", "/income", { amountCents }),
};
// ---- Transactions
transactions = {
list: (args: {
from?: string; // YYYY-MM-DD
to?: string; // YYYY-MM-DD
kind?: TransactionKind;
q?: string;
page?: number;
limit?: number;
}) =>
this.request<TransactionList>("GET", "/transactions", undefined, args),
create: (payload: {
kind: TransactionKind;
amountCents: number;
occurredAtISO: string;
categoryId?: string;
planId?: string;
}) => this.request<Transaction>("POST", "/transactions", payload),
};
// ---- Variable Categories
variableCategories = {
create: (payload: {
name: string;
percent: number;
isSavings: boolean;
priority: number;
}) => this.request<OkResponse>("POST", "/variable-categories", payload),
update: (id: string, patch: Partial<{
name: string;
percent: number;
isSavings: boolean;
priority: number;
}>) => this.request<OkResponse>("PATCH", `/variable-categories/${encodeURIComponent(id)}`, patch),
delete: (id: string) =>
this.request<OkResponse>("DELETE", `/variable-categories/${encodeURIComponent(id)}`),
};
// ---- Fixed Plans
fixedPlans = {
create: (payload: {
name: string;
totalCents: number;
fundedCents?: number;
priority: number;
dueOn: string; // ISO
cycleStart?: string; // ISO
}) => this.request<OkResponse>("POST", "/fixed-plans", payload),
update: (id: string, patch: Partial<{
name: string;
totalCents: number;
fundedCents: number;
priority: number;
dueOn: string;
cycleStart: string;
}>) => this.request<OkResponse>("PATCH", `/fixed-plans/${encodeURIComponent(id)}`, patch),
delete: (id: string) =>
this.request<OkResponse>("DELETE", `/fixed-plans/${encodeURIComponent(id)}`),
};
}
// ---------- helpers ----------
function safeJson(s: string) {
try { return JSON.parse(s) } catch { return s }
}

0
api/distributes Normal file
View File

0
api/handles Normal file
View File

548
api/openapi.yaml Normal file
View File

@@ -0,0 +1,548 @@
openapi: 3.0.3
info:
title: SkyMoney API
version: 0.1.0
description: |
Fastify backend for budgeting/allocations.
Most endpoints accept an optional `x-user-id` header; when omitted, the server
defaults to `demo-user-1`.
servers:
- url: http://localhost:8080
tags:
- name: Health
- name: Dashboard
- name: Income
- name: Transactions
- name: VariableCategories
- name: FixedPlans
paths:
/health:
get:
tags: [Health]
summary: Liveness check
parameters:
- $ref: '#/components/parameters/UserId'
- $ref: '#/components/parameters/RequestId'
responses:
'200':
description: OK
content:
application/json:
schema: { $ref: '#/components/schemas/HealthOk' }
/health/db:
get:
tags: [Health]
summary: DB health + latency
parameters:
- $ref: '#/components/parameters/UserId'
- $ref: '#/components/parameters/RequestId'
responses:
'200':
description: DB OK
content:
application/json:
schema: { $ref: '#/components/schemas/DbHealth' }
/dashboard:
get:
tags: [Dashboard]
summary: Aggregated dashboard data
parameters:
- $ref: '#/components/parameters/UserId'
- $ref: '#/components/parameters/RequestId'
responses:
'200':
description: Dashboard payload
content:
application/json:
schema: { $ref: '#/components/schemas/DashboardResponse' }
'400':
$ref: '#/components/responses/BadRequest'
'500':
$ref: '#/components/responses/InternalError'
/income:
post:
tags: [Income]
summary: Create income event and allocate funds
parameters:
- $ref: '#/components/parameters/UserId'
- $ref: '#/components/parameters/RequestId'
requestBody:
required: true
content:
application/json:
schema: { $ref: '#/components/schemas/IncomeRequest' }
responses:
'200':
description: Allocation result
content:
application/json:
schema: { $ref: '#/components/schemas/IncomeAllocationResponse' }
'400':
$ref: '#/components/responses/BadRequest'
'500':
$ref: '#/components/responses/InternalError'
/income/preview:
post:
tags: [Income]
summary: Preview allocation of a hypothetical income amount
parameters:
- $ref: '#/components/parameters/UserId'
- $ref: '#/components/parameters/RequestId'
requestBody:
required: true
content:
application/json:
schema: { $ref: '#/components/schemas/IncomeRequest' }
responses:
'200':
description: Preview
content:
application/json:
schema: { $ref: '#/components/schemas/IncomePreviewResponse' }
'400':
$ref: '#/components/responses/BadRequest'
'500':
$ref: '#/components/responses/InternalError'
/transactions:
get:
tags: [Transactions]
summary: List transactions with filters and pagination
parameters:
- $ref: '#/components/parameters/UserId'
- $ref: '#/components/parameters/RequestId'
- in: query
name: from
schema: { type: string, pattern: '^\d{4}-\d{2}-\d{2}$' }
description: Inclusive start date (YYYY-MM-DD)
- in: query
name: to
schema: { type: string, pattern: '^\d{4}-\d{2}-\d{2}$' }
description: Inclusive end date (YYYY-MM-DD)
- in: query
name: kind
schema: { $ref: '#/components/schemas/TransactionKind' }
- in: query
name: q
schema: { type: string }
description: Simple search (currently numeric amount match)
- in: query
name: page
schema: { type: integer, minimum: 1, default: 1 }
- in: query
name: limit
schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
responses:
'200':
description: List
content:
application/json:
schema: { $ref: '#/components/schemas/TransactionList' }
'400':
$ref: '#/components/responses/BadRequest'
'500':
$ref: '#/components/responses/InternalError'
post:
tags: [Transactions]
summary: Create a transaction
parameters:
- $ref: '#/components/parameters/UserId'
- $ref: '#/components/parameters/RequestId'
requestBody:
required: true
content:
application/json:
schema: { $ref: '#/components/schemas/TransactionCreate' }
responses:
'200':
description: Created
content:
application/json:
schema: { $ref: '#/components/schemas/Transaction' }
'400':
$ref: '#/components/responses/BadRequest'
'404':
$ref: '#/components/responses/NotFound'
'500':
$ref: '#/components/responses/InternalError'
/variable-categories:
post:
tags: [VariableCategories]
summary: Create a variable category (sum of percents must be 100)
parameters:
- $ref: '#/components/parameters/UserId'
- $ref: '#/components/parameters/RequestId'
requestBody:
required: true
content:
application/json:
schema: { $ref: '#/components/schemas/VariableCategoryCreate' }
responses:
'200':
description: OK
content:
application/json:
schema: { $ref: '#/components/schemas/OkResponse' }
'400':
$ref: '#/components/responses/BadRequest'
'500':
$ref: '#/components/responses/InternalError'
/variable-categories/{id}:
patch:
tags: [VariableCategories]
summary: Update a variable category (sum of percents must be 100)
parameters:
- $ref: '#/components/parameters/UserId'
- $ref: '#/components/parameters/RequestId'
- in: path
name: id
required: true
schema: { type: string }
requestBody:
required: true
content:
application/json:
schema: { $ref: '#/components/schemas/VariableCategoryPatch' }
responses:
'200':
description: OK
content:
application/json:
schema: { $ref: '#/components/schemas/OkResponse' }
'400':
$ref: '#/components/responses/BadRequest'
'404':
$ref: '#/components/responses/NotFound'
'500':
$ref: '#/components/responses/InternalError'
delete:
tags: [VariableCategories]
summary: Delete a variable category (sum of percents must remain 100)
parameters:
- $ref: '#/components/parameters/UserId'
- $ref: '#/components/parameters/RequestId'
- in: path
name: id
required: true
schema: { type: string }
responses:
'200':
description: OK
content:
application/json:
schema: { $ref: '#/components/schemas/OkResponse' }
'404':
$ref: '#/components/responses/NotFound'
'500':
$ref: '#/components/responses/InternalError'
/fixed-plans:
post:
tags: [FixedPlans]
summary: Create a fixed plan
parameters:
- $ref: '#/components/parameters/UserId'
- $ref: '#/components/parameters/RequestId'
requestBody:
required: true
content:
application/json:
schema: { $ref: '#/components/schemas/FixedPlanCreate' }
responses:
'200':
description: OK
content:
application/json:
schema: { $ref: '#/components/schemas/OkResponse' }
'400':
$ref: '#/components/responses/BadRequest'
'500':
$ref: '#/components/responses/InternalError'
/fixed-plans/{id}:
patch:
tags: [FixedPlans]
summary: Update a fixed plan (fundedCents cannot exceed totalCents)
parameters:
- $ref: '#/components/parameters/UserId'
- $ref: '#/components/parameters/RequestId'
- in: path
name: id
required: true
schema: { type: string }
requestBody:
required: true
content:
application/json:
schema: { $ref: '#/components/schemas/FixedPlanPatch' }
responses:
'200':
description: OK
content:
application/json:
schema: { $ref: '#/components/schemas/OkResponse' }
'400':
$ref: '#/components/responses/BadRequest'
'404':
$ref: '#/components/responses/NotFound'
'500':
$ref: '#/components/responses/InternalError'
delete:
tags: [FixedPlans]
summary: Delete a fixed plan
parameters:
- $ref: '#/components/parameters/UserId'
- $ref: '#/components/parameters/RequestId'
- in: path
name: id
required: true
schema: { type: string }
responses:
'200':
description: OK
content:
application/json:
schema: { $ref: '#/components/schemas/OkResponse' }
'404':
$ref: '#/components/responses/NotFound'
'500':
$ref: '#/components/responses/InternalError'
components:
parameters:
UserId:
in: header
name: x-user-id
required: false
schema: { type: string }
description: Override the stubbed user id for the request.
RequestId:
in: header
name: x-request-id
required: false
schema: { type: string, maxLength: 64 }
description: Custom request id (echoed back by server).
responses:
BadRequest:
description: Validation or guard failed
content:
application/json:
schema: { $ref: '#/components/schemas/ErrorResponse' }
NotFound:
description: Resource not found
content:
application/json:
schema: { $ref: '#/components/schemas/ErrorResponse' }
InternalError:
description: Unexpected server error
content:
application/json:
schema: { $ref: '#/components/schemas/ErrorResponse' }
schemas:
HealthOk:
type: object
properties:
ok: { type: boolean, const: true }
required: [ok]
DbHealth:
type: object
properties:
ok: { type: boolean, const: true }
nowISO: { type: string, format: date-time }
latencyMs: { type: integer, minimum: 0 }
required: [ok, nowISO, latencyMs]
OkResponse:
type: object
properties:
ok: { type: boolean, const: true }
required: [ok]
ErrorResponse:
type: object
properties:
ok: { type: boolean, const: false }
code: { type: string }
message: { type: string }
requestId: { type: string }
required: [ok, code, message, requestId]
VariableCategory:
type: object
properties:
id: { type: string }
userId: { type: string }
name: { type: string }
percent: { type: integer, minimum: 0, maximum: 100 }
isSavings: { type: boolean }
priority: { type: integer, minimum: 0 }
balanceCents:
type: integer
description: Current balance; may be omitted or 0 when not loaded.
required: [id, userId, name, percent, isSavings, priority]
FixedPlan:
type: object
properties:
id: { type: string }
userId: { type: string }
name: { type: string }
totalCents: { type: integer, minimum: 0 }
fundedCents: { type: integer, minimum: 0 }
priority: { type: integer, minimum: 0 }
dueOn: { type: string, format: date-time }
cycleStart: { type: string, format: date-time }
required: [id, userId, name, priority, dueOn]
TransactionKind:
type: string
enum: [variable_spend, fixed_payment]
Transaction:
type: object
properties:
id: { type: string }
userId: { type: string }
kind: { $ref: '#/components/schemas/TransactionKind' }
amountCents: { type: integer, minimum: 0 }
occurredAt: { type: string, format: date-time }
categoryId: { type: string, nullable: true }
planId: { type: string, nullable: true }
required: [id, userId, kind, amountCents, occurredAt]
TransactionList:
type: object
properties:
items:
type: array
items: { $ref: '#/components/schemas/Transaction' }
page: { type: integer, minimum: 1 }
limit: { type: integer, minimum: 1, maximum: 100 }
total: { type: integer, minimum: 0 }
required: [items, page, limit, total]
TransactionCreate:
type: object
properties:
kind: { $ref: '#/components/schemas/TransactionKind' }
amountCents: { type: integer, minimum: 1 }
occurredAtISO: { type: string, format: date-time }
categoryId: { type: string, nullable: true }
planId: { type: string, nullable: true }
required: [kind, amountCents, occurredAtISO]
DashboardResponse:
type: object
properties:
totals:
type: object
properties:
incomeCents: { type: integer, minimum: 0 }
variableBalanceCents: { type: integer, minimum: 0 }
fixedRemainingCents: { type: integer, minimum: 0 }
required: [incomeCents, variableBalanceCents, fixedRemainingCents]
percentTotal: { type: integer, minimum: 0, maximum: 100 }
variableCategories:
type: array
items: { $ref: '#/components/schemas/VariableCategory' }
fixedPlans:
type: array
items: { $ref: '#/components/schemas/FixedPlan' }
recentTransactions:
type: array
items: { $ref: '#/components/schemas/Transaction' }
required: [totals, percentTotal, variableCategories, fixedPlans, recentTransactions]
IncomeRequest:
type: object
properties:
amountCents: { type: integer, minimum: 0 }
required: [amountCents]
AllocationItem:
type: object
properties:
id: { type: string }
name: { type: string }
amountCents: { type: integer, minimum: 0 }
required: [id, name, amountCents]
IncomePreviewResponse:
type: object
properties:
fixed:
type: array
items: { $ref: '#/components/schemas/AllocationItem' }
variable:
type: array
items: { $ref: '#/components/schemas/AllocationItem' }
unallocatedCents: { type: integer, minimum: 0 }
required: [fixed, variable, unallocatedCents]
IncomeAllocationResponse:
type: object
description: >
Shape returned by allocateIncome. Tests expect:
fixedAllocations, variableAllocations, remainingUnallocatedCents.
Additional fields may be present.
properties:
fixedAllocations:
type: array
items: { $ref: '#/components/schemas/AllocationItem' }
variableAllocations:
type: array
items: { $ref: '#/components/schemas/AllocationItem' }
remainingUnallocatedCents: { type: integer, minimum: 0 }
additionalProperties: true
VariableCategoryCreate:
type: object
properties:
name: { type: string }
percent: { type: integer, minimum: 0, maximum: 100 }
isSavings: { type: boolean }
priority: { type: integer, minimum: 0 }
required: [name, percent, isSavings, priority]
VariableCategoryPatch:
type: object
properties:
name: { type: string }
percent: { type: integer, minimum: 0, maximum: 100 }
isSavings: { type: boolean }
priority: { type: integer, minimum: 0 }
additionalProperties: false
FixedPlanCreate:
type: object
properties:
name: { type: string }
totalCents: { type: integer, minimum: 0 }
fundedCents: { type: integer, minimum: 0 }
priority: { type: integer, minimum: 0 }
dueOn: { type: string, format: date-time }
cycleStart: { type: string, format: date-time }
required: [name, totalCents, priority, dueOn]
FixedPlanPatch:
type: object
properties:
name: { type: string }
totalCents: { type: integer, minimum: 0 }
fundedCents: { type: integer, minimum: 0 }
priority: { type: integer, minimum: 0 }
dueOn: { type: string, format: date-time }
cycleStart: { type: string, format: date-time }
additionalProperties: false

2309
api/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,26 +1,34 @@
{ {
"name": "skymoney-api", "name": "skymoney-api",
"version": "0.1.0", "version": "0.1.0",
"private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "tsx watch src/server.ts", "build": "tsc",
"build": "tsc -p tsconfig.json",
"start": "node dist/server.js", "start": "node dist/server.js",
"dev": "tsx src/server.ts",
"generate": "prisma generate", "generate": "prisma generate",
"migrate": "prisma migrate dev", "migrate": "prisma migrate dev",
"seed": "tsx src/scripts/seed.ts" "seed": "prisma db seed",
"test": "vitest --run",
"test:watch": "vitest"
}, },
"dependencies": { "prisma": {
"@fastify/cors": "^10.0.0", "seed": "tsx --tsconfig prisma/tsconfig.seed.json prisma/seed.ts"
"@prisma/client": "^5.20.0",
"fastify": "^4.26.2",
"zod": "^3.23.8"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^20.11.30", "@types/node": "^20.19.25",
"prisma": "^5.20.0", "@types/supertest": "^6.0.3",
"tsx": "^4.19.0", "prisma": "^5.22.0",
"typescript": "^5.6.3" "supertest": "^6.3.4",
"tsx": "^4.20.6",
"typescript": "^5.6.3",
"vitest": "^2.1.3"
},
"dependencies": {
"@fastify/cors": "^10.1.0",
"@fastify/rate-limit": "^10.3.0",
"@prisma/client": "^5.22.0",
"fastify": "^5.6.2",
"zod": "^3.23.8"
} }
} }

0
api/paginates Normal file
View File

802
api/pnpm-lock.yaml generated Normal file
View File

@@ -0,0 +1,802 @@
lockfileVersion: '9.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
importers:
.:
dependencies:
'@fastify/cors':
specifier: ^10.0.0
version: 10.1.0
'@prisma/client':
specifier: ^5.20.0
version: 5.22.0(prisma@5.22.0)
fastify:
specifier: ^4.26.2
version: 4.29.1
zod:
specifier: ^3.23.8
version: 3.25.76
devDependencies:
'@types/node':
specifier: ^20.11.30
version: 20.19.24
prisma:
specifier: ^5.20.0
version: 5.22.0
tsx:
specifier: ^4.19.0
version: 4.20.6
typescript:
specifier: ^5.6.3
version: 5.9.3
packages:
'@esbuild/aix-ppc64@0.25.12':
resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==}
engines: {node: '>=18'}
cpu: [ppc64]
os: [aix]
'@esbuild/android-arm64@0.25.12':
resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==}
engines: {node: '>=18'}
cpu: [arm64]
os: [android]
'@esbuild/android-arm@0.25.12':
resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==}
engines: {node: '>=18'}
cpu: [arm]
os: [android]
'@esbuild/android-x64@0.25.12':
resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==}
engines: {node: '>=18'}
cpu: [x64]
os: [android]
'@esbuild/darwin-arm64@0.25.12':
resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==}
engines: {node: '>=18'}
cpu: [arm64]
os: [darwin]
'@esbuild/darwin-x64@0.25.12':
resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==}
engines: {node: '>=18'}
cpu: [x64]
os: [darwin]
'@esbuild/freebsd-arm64@0.25.12':
resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==}
engines: {node: '>=18'}
cpu: [arm64]
os: [freebsd]
'@esbuild/freebsd-x64@0.25.12':
resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==}
engines: {node: '>=18'}
cpu: [x64]
os: [freebsd]
'@esbuild/linux-arm64@0.25.12':
resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==}
engines: {node: '>=18'}
cpu: [arm64]
os: [linux]
'@esbuild/linux-arm@0.25.12':
resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==}
engines: {node: '>=18'}
cpu: [arm]
os: [linux]
'@esbuild/linux-ia32@0.25.12':
resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==}
engines: {node: '>=18'}
cpu: [ia32]
os: [linux]
'@esbuild/linux-loong64@0.25.12':
resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==}
engines: {node: '>=18'}
cpu: [loong64]
os: [linux]
'@esbuild/linux-mips64el@0.25.12':
resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==}
engines: {node: '>=18'}
cpu: [mips64el]
os: [linux]
'@esbuild/linux-ppc64@0.25.12':
resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==}
engines: {node: '>=18'}
cpu: [ppc64]
os: [linux]
'@esbuild/linux-riscv64@0.25.12':
resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==}
engines: {node: '>=18'}
cpu: [riscv64]
os: [linux]
'@esbuild/linux-s390x@0.25.12':
resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==}
engines: {node: '>=18'}
cpu: [s390x]
os: [linux]
'@esbuild/linux-x64@0.25.12':
resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==}
engines: {node: '>=18'}
cpu: [x64]
os: [linux]
'@esbuild/netbsd-arm64@0.25.12':
resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==}
engines: {node: '>=18'}
cpu: [arm64]
os: [netbsd]
'@esbuild/netbsd-x64@0.25.12':
resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==}
engines: {node: '>=18'}
cpu: [x64]
os: [netbsd]
'@esbuild/openbsd-arm64@0.25.12':
resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==}
engines: {node: '>=18'}
cpu: [arm64]
os: [openbsd]
'@esbuild/openbsd-x64@0.25.12':
resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==}
engines: {node: '>=18'}
cpu: [x64]
os: [openbsd]
'@esbuild/openharmony-arm64@0.25.12':
resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==}
engines: {node: '>=18'}
cpu: [arm64]
os: [openharmony]
'@esbuild/sunos-x64@0.25.12':
resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==}
engines: {node: '>=18'}
cpu: [x64]
os: [sunos]
'@esbuild/win32-arm64@0.25.12':
resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==}
engines: {node: '>=18'}
cpu: [arm64]
os: [win32]
'@esbuild/win32-ia32@0.25.12':
resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==}
engines: {node: '>=18'}
cpu: [ia32]
os: [win32]
'@esbuild/win32-x64@0.25.12':
resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==}
engines: {node: '>=18'}
cpu: [x64]
os: [win32]
'@fastify/ajv-compiler@3.6.0':
resolution: {integrity: sha512-LwdXQJjmMD+GwLOkP7TVC68qa+pSSogeWWmznRJ/coyTcfe9qA05AHFSe1eZFwK6q+xVRpChnvFUkf1iYaSZsQ==}
'@fastify/cors@10.1.0':
resolution: {integrity: sha512-MZyBCBJtII60CU9Xme/iE4aEy8G7QpzGR8zkdXZkDFt7ElEMachbE61tfhAG/bvSaULlqlf0huMT12T7iqEmdQ==}
'@fastify/error@3.4.1':
resolution: {integrity: sha512-wWSvph+29GR783IhmvdwWnN4bUxTD01Vm5Xad4i7i1VuAOItLvbPAb69sb0IQ2N57yprvhNIwAP5B6xfKTmjmQ==}
'@fastify/fast-json-stringify-compiler@4.3.0':
resolution: {integrity: sha512-aZAXGYo6m22Fk1zZzEUKBvut/CIIQe/BapEORnxiD5Qr0kPHqqI69NtEMCme74h+at72sPhbkb4ZrLd1W3KRLA==}
'@fastify/merge-json-schemas@0.1.1':
resolution: {integrity: sha512-fERDVz7topgNjtXsJTTW1JKLy0rhuLRcquYqNR9rF7OcVpCa2OVW49ZPDIhaRRCaUuvVxI+N416xUoF76HNSXA==}
'@pinojs/redact@0.4.0':
resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==}
'@prisma/client@5.22.0':
resolution: {integrity: sha512-M0SVXfyHnQREBKxCgyo7sffrKttwE6R8PMq330MIUF0pTwjUhLbW84pFDlf06B27XyCR++VtjugEnIHdr07SVA==}
engines: {node: '>=16.13'}
peerDependencies:
prisma: '*'
peerDependenciesMeta:
prisma:
optional: true
'@prisma/debug@5.22.0':
resolution: {integrity: sha512-AUt44v3YJeggO2ZU5BkXI7M4hu9BF2zzH2iF2V5pyXT/lRTyWiElZ7It+bRH1EshoMRxHgpYg4VB6rCM+mG5jQ==}
'@prisma/engines-version@5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2':
resolution: {integrity: sha512-2PTmxFR2yHW/eB3uqWtcgRcgAbG1rwG9ZriSvQw+nnb7c4uCr3RAcGMb6/zfE88SKlC1Nj2ziUvc96Z379mHgQ==}
'@prisma/engines@5.22.0':
resolution: {integrity: sha512-UNjfslWhAt06kVL3CjkuYpHAWSO6L4kDCVPegV6itt7nD1kSJavd3vhgAEhjglLJJKEdJ7oIqDJ+yHk6qO8gPA==}
'@prisma/fetch-engine@5.22.0':
resolution: {integrity: sha512-bkrD/Mc2fSvkQBV5EpoFcZ87AvOgDxbG99488a5cexp5Ccny+UM6MAe/UFkUC0wLYD9+9befNOqGiIJhhq+HbA==}
'@prisma/get-platform@5.22.0':
resolution: {integrity: sha512-pHhpQdr1UPFpt+zFfnPazhulaZYCUqeIcPpJViYoq9R+D/yw4fjE+CtnsnKzPYm0ddUbeXUzjGVGIRVgPDCk4Q==}
'@types/node@20.19.24':
resolution: {integrity: sha512-FE5u0ezmi6y9OZEzlJfg37mqqf6ZDSF2V/NLjUyGrR9uTZ7Sb9F7bLNZ03S4XVUNRWGA7Ck4c1kK+YnuWjl+DA==}
abstract-logging@2.0.1:
resolution: {integrity: sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==}
ajv-formats@2.1.1:
resolution: {integrity: sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==}
peerDependencies:
ajv: ^8.0.0
peerDependenciesMeta:
ajv:
optional: true
ajv-formats@3.0.1:
resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==}
peerDependencies:
ajv: ^8.0.0
peerDependenciesMeta:
ajv:
optional: true
ajv@8.17.1:
resolution: {integrity: sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==}
atomic-sleep@1.0.0:
resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==}
engines: {node: '>=8.0.0'}
avvio@8.4.0:
resolution: {integrity: sha512-CDSwaxINFy59iNwhYnkvALBwZiTydGkOecZyPkqBpABYR1KqGEsET0VOOYDwtleZSUIdeY36DC2bSZ24CO1igA==}
cookie@0.7.2:
resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==}
engines: {node: '>= 0.6'}
esbuild@0.25.12:
resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==}
engines: {node: '>=18'}
hasBin: true
fast-content-type-parse@1.1.0:
resolution: {integrity: sha512-fBHHqSTFLVnR61C+gltJuE5GkVQMV0S2nqUO8TJ+5Z3qAKG8vAx4FKai1s5jq/inV1+sREynIWSuQ6HgoSXpDQ==}
fast-decode-uri-component@1.0.1:
resolution: {integrity: sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==}
fast-deep-equal@3.1.3:
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
fast-json-stringify@5.16.1:
resolution: {integrity: sha512-KAdnLvy1yu/XrRtP+LJnxbBGrhN+xXu+gt3EUvZhYGKCr3lFHq/7UFJHHFgmJKoqlh6B40bZLEv7w46B0mqn1g==}
fast-querystring@1.1.2:
resolution: {integrity: sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==}
fast-uri@2.4.0:
resolution: {integrity: sha512-ypuAmmMKInk5q7XcepxlnUWDLWv4GFtaJqAzWKqn62IpQ3pejtr5dTVbt3vwqVaMKmkNR55sTT+CqUKIaT21BA==}
fast-uri@3.1.0:
resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==}
fastify-plugin@5.1.0:
resolution: {integrity: sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw==}
fastify@4.29.1:
resolution: {integrity: sha512-m2kMNHIG92tSNWv+Z3UeTR9AWLLuo7KctC7mlFPtMEVrfjIhmQhkQnT9v15qA/BfVq3vvj134Y0jl9SBje3jXQ==}
fastq@1.19.1:
resolution: {integrity: sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==}
find-my-way@8.2.2:
resolution: {integrity: sha512-Dobi7gcTEq8yszimcfp/R7+owiT4WncAJ7VTTgFH1jYJ5GaG1FbhjwDG820hptN0QDFvzVY3RfCzdInvGPGzjA==}
engines: {node: '>=14'}
forwarded@0.2.0:
resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==}
engines: {node: '>= 0.6'}
fsevents@2.3.3:
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
os: [darwin]
get-tsconfig@4.13.0:
resolution: {integrity: sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==}
ipaddr.js@1.9.1:
resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==}
engines: {node: '>= 0.10'}
json-schema-ref-resolver@1.0.1:
resolution: {integrity: sha512-EJAj1pgHc1hxF6vo2Z3s69fMjO1INq6eGHXZ8Z6wCQeldCuwxGK9Sxf4/cScGn3FZubCVUehfWtcDM/PLteCQw==}
json-schema-traverse@1.0.0:
resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==}
light-my-request@5.14.0:
resolution: {integrity: sha512-aORPWntbpH5esaYpGOOmri0OHDOe3wC5M2MQxZ9dvMLZm6DnaAn0kJlcbU9hwsQgLzmZyReKwFwwPkR+nHu5kA==}
mnemonist@0.40.0:
resolution: {integrity: sha512-kdd8AFNig2AD5Rkih7EPCXhu/iMvwevQFX/uEiGhZyPZi7fHqOoF4V4kHLpCfysxXMgQ4B52kdPMCwARshKvEg==}
obliterator@2.0.5:
resolution: {integrity: sha512-42CPE9AhahZRsMNslczq0ctAEtqk8Eka26QofnqC346BZdHDySk3LWka23LI7ULIw11NmltpiLagIq8gBozxTw==}
on-exit-leak-free@2.1.2:
resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==}
engines: {node: '>=14.0.0'}
pino-abstract-transport@2.0.0:
resolution: {integrity: sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw==}
pino-std-serializers@7.0.0:
resolution: {integrity: sha512-e906FRY0+tV27iq4juKzSYPbUj2do2X2JX4EzSca1631EB2QJQUqGbDuERal7LCtOpxl6x3+nvo9NPZcmjkiFA==}
pino@9.14.0:
resolution: {integrity: sha512-8OEwKp5juEvb/MjpIc4hjqfgCNysrS94RIOMXYvpYCdm/jglrKEiAYmiumbmGhCvs+IcInsphYDFwqrjr7398w==}
hasBin: true
prisma@5.22.0:
resolution: {integrity: sha512-vtpjW3XuYCSnMsNVBjLMNkTj6OZbudcPPTPYHqX0CJfpcdWciI1dM8uHETwmDxxiqEwCIE6WvXucWUetJgfu/A==}
engines: {node: '>=16.13'}
hasBin: true
process-warning@3.0.0:
resolution: {integrity: sha512-mqn0kFRl0EoqhnL0GQ0veqFHyIN1yig9RHh/InzORTUiZHFRAur+aMtRkELNwGs9aNwKS6tg/An4NYBPGwvtzQ==}
process-warning@5.0.0:
resolution: {integrity: sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==}
proxy-addr@2.0.7:
resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==}
engines: {node: '>= 0.10'}
quick-format-unescaped@4.0.4:
resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==}
real-require@0.2.0:
resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==}
engines: {node: '>= 12.13.0'}
require-from-string@2.0.2:
resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
engines: {node: '>=0.10.0'}
resolve-pkg-maps@1.0.0:
resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==}
ret@0.4.3:
resolution: {integrity: sha512-0f4Memo5QP7WQyUEAYUO3esD/XjOc3Zjjg5CPsAq1p8sIu0XPeMbHJemKA0BO7tV0X7+A0FoEpbmHXWxPyD3wQ==}
engines: {node: '>=10'}
reusify@1.1.0:
resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==}
engines: {iojs: '>=1.0.0', node: '>=0.10.0'}
rfdc@1.4.1:
resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==}
safe-regex2@3.1.0:
resolution: {integrity: sha512-RAAZAGbap2kBfbVhvmnTFv73NWLMvDGOITFYTZBAaY8eR+Ir4ef7Up/e7amo+y1+AH+3PtLkrt9mvcTsG9LXug==}
safe-stable-stringify@2.5.0:
resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==}
engines: {node: '>=10'}
secure-json-parse@2.7.0:
resolution: {integrity: sha512-6aU+Rwsezw7VR8/nyvKTx8QpWH9FrcYiXXlqC4z5d5XQBDRqtbfsRjnwGyqbi3gddNtWHuEk9OANUotL26qKUw==}
semver@7.7.3:
resolution: {integrity: sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==}
engines: {node: '>=10'}
hasBin: true
set-cookie-parser@2.7.2:
resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==}
sonic-boom@4.2.0:
resolution: {integrity: sha512-INb7TM37/mAcsGmc9hyyI6+QR3rR1zVRu36B0NeGXKnOOLiZOfER5SA+N7X7k3yUYRzLWafduTDvJAfDswwEww==}
split2@4.2.0:
resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==}
engines: {node: '>= 10.x'}
thread-stream@3.1.0:
resolution: {integrity: sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A==}
toad-cache@3.7.0:
resolution: {integrity: sha512-/m8M+2BJUpoJdgAHoG+baCwBT+tf2VraSfkBgl0Y00qIWt41DJ8R5B8nsEw0I58YwF5IZH6z24/2TobDKnqSWw==}
engines: {node: '>=12'}
tsx@4.20.6:
resolution: {integrity: sha512-ytQKuwgmrrkDTFP4LjR0ToE2nqgy886GpvRSpU0JAnrdBYppuY5rLkRUYPU1yCryb24SsKBTL/hlDQAEFVwtZg==}
engines: {node: '>=18.0.0'}
hasBin: true
typescript@5.9.3:
resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
engines: {node: '>=14.17'}
hasBin: true
undici-types@6.21.0:
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
zod@3.25.76:
resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==}
snapshots:
'@esbuild/aix-ppc64@0.25.12':
optional: true
'@esbuild/android-arm64@0.25.12':
optional: true
'@esbuild/android-arm@0.25.12':
optional: true
'@esbuild/android-x64@0.25.12':
optional: true
'@esbuild/darwin-arm64@0.25.12':
optional: true
'@esbuild/darwin-x64@0.25.12':
optional: true
'@esbuild/freebsd-arm64@0.25.12':
optional: true
'@esbuild/freebsd-x64@0.25.12':
optional: true
'@esbuild/linux-arm64@0.25.12':
optional: true
'@esbuild/linux-arm@0.25.12':
optional: true
'@esbuild/linux-ia32@0.25.12':
optional: true
'@esbuild/linux-loong64@0.25.12':
optional: true
'@esbuild/linux-mips64el@0.25.12':
optional: true
'@esbuild/linux-ppc64@0.25.12':
optional: true
'@esbuild/linux-riscv64@0.25.12':
optional: true
'@esbuild/linux-s390x@0.25.12':
optional: true
'@esbuild/linux-x64@0.25.12':
optional: true
'@esbuild/netbsd-arm64@0.25.12':
optional: true
'@esbuild/netbsd-x64@0.25.12':
optional: true
'@esbuild/openbsd-arm64@0.25.12':
optional: true
'@esbuild/openbsd-x64@0.25.12':
optional: true
'@esbuild/openharmony-arm64@0.25.12':
optional: true
'@esbuild/sunos-x64@0.25.12':
optional: true
'@esbuild/win32-arm64@0.25.12':
optional: true
'@esbuild/win32-ia32@0.25.12':
optional: true
'@esbuild/win32-x64@0.25.12':
optional: true
'@fastify/ajv-compiler@3.6.0':
dependencies:
ajv: 8.17.1
ajv-formats: 2.1.1(ajv@8.17.1)
fast-uri: 2.4.0
'@fastify/cors@10.1.0':
dependencies:
fastify-plugin: 5.1.0
mnemonist: 0.40.0
'@fastify/error@3.4.1': {}
'@fastify/fast-json-stringify-compiler@4.3.0':
dependencies:
fast-json-stringify: 5.16.1
'@fastify/merge-json-schemas@0.1.1':
dependencies:
fast-deep-equal: 3.1.3
'@pinojs/redact@0.4.0': {}
'@prisma/client@5.22.0(prisma@5.22.0)':
optionalDependencies:
prisma: 5.22.0
'@prisma/debug@5.22.0': {}
'@prisma/engines-version@5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2': {}
'@prisma/engines@5.22.0':
dependencies:
'@prisma/debug': 5.22.0
'@prisma/engines-version': 5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2
'@prisma/fetch-engine': 5.22.0
'@prisma/get-platform': 5.22.0
'@prisma/fetch-engine@5.22.0':
dependencies:
'@prisma/debug': 5.22.0
'@prisma/engines-version': 5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2
'@prisma/get-platform': 5.22.0
'@prisma/get-platform@5.22.0':
dependencies:
'@prisma/debug': 5.22.0
'@types/node@20.19.24':
dependencies:
undici-types: 6.21.0
abstract-logging@2.0.1: {}
ajv-formats@2.1.1(ajv@8.17.1):
optionalDependencies:
ajv: 8.17.1
ajv-formats@3.0.1(ajv@8.17.1):
optionalDependencies:
ajv: 8.17.1
ajv@8.17.1:
dependencies:
fast-deep-equal: 3.1.3
fast-uri: 3.1.0
json-schema-traverse: 1.0.0
require-from-string: 2.0.2
atomic-sleep@1.0.0: {}
avvio@8.4.0:
dependencies:
'@fastify/error': 3.4.1
fastq: 1.19.1
cookie@0.7.2: {}
esbuild@0.25.12:
optionalDependencies:
'@esbuild/aix-ppc64': 0.25.12
'@esbuild/android-arm': 0.25.12
'@esbuild/android-arm64': 0.25.12
'@esbuild/android-x64': 0.25.12
'@esbuild/darwin-arm64': 0.25.12
'@esbuild/darwin-x64': 0.25.12
'@esbuild/freebsd-arm64': 0.25.12
'@esbuild/freebsd-x64': 0.25.12
'@esbuild/linux-arm': 0.25.12
'@esbuild/linux-arm64': 0.25.12
'@esbuild/linux-ia32': 0.25.12
'@esbuild/linux-loong64': 0.25.12
'@esbuild/linux-mips64el': 0.25.12
'@esbuild/linux-ppc64': 0.25.12
'@esbuild/linux-riscv64': 0.25.12
'@esbuild/linux-s390x': 0.25.12
'@esbuild/linux-x64': 0.25.12
'@esbuild/netbsd-arm64': 0.25.12
'@esbuild/netbsd-x64': 0.25.12
'@esbuild/openbsd-arm64': 0.25.12
'@esbuild/openbsd-x64': 0.25.12
'@esbuild/openharmony-arm64': 0.25.12
'@esbuild/sunos-x64': 0.25.12
'@esbuild/win32-arm64': 0.25.12
'@esbuild/win32-ia32': 0.25.12
'@esbuild/win32-x64': 0.25.12
fast-content-type-parse@1.1.0: {}
fast-decode-uri-component@1.0.1: {}
fast-deep-equal@3.1.3: {}
fast-json-stringify@5.16.1:
dependencies:
'@fastify/merge-json-schemas': 0.1.1
ajv: 8.17.1
ajv-formats: 3.0.1(ajv@8.17.1)
fast-deep-equal: 3.1.3
fast-uri: 2.4.0
json-schema-ref-resolver: 1.0.1
rfdc: 1.4.1
fast-querystring@1.1.2:
dependencies:
fast-decode-uri-component: 1.0.1
fast-uri@2.4.0: {}
fast-uri@3.1.0: {}
fastify-plugin@5.1.0: {}
fastify@4.29.1:
dependencies:
'@fastify/ajv-compiler': 3.6.0
'@fastify/error': 3.4.1
'@fastify/fast-json-stringify-compiler': 4.3.0
abstract-logging: 2.0.1
avvio: 8.4.0
fast-content-type-parse: 1.1.0
fast-json-stringify: 5.16.1
find-my-way: 8.2.2
light-my-request: 5.14.0
pino: 9.14.0
process-warning: 3.0.0
proxy-addr: 2.0.7
rfdc: 1.4.1
secure-json-parse: 2.7.0
semver: 7.7.3
toad-cache: 3.7.0
fastq@1.19.1:
dependencies:
reusify: 1.1.0
find-my-way@8.2.2:
dependencies:
fast-deep-equal: 3.1.3
fast-querystring: 1.1.2
safe-regex2: 3.1.0
forwarded@0.2.0: {}
fsevents@2.3.3:
optional: true
get-tsconfig@4.13.0:
dependencies:
resolve-pkg-maps: 1.0.0
ipaddr.js@1.9.1: {}
json-schema-ref-resolver@1.0.1:
dependencies:
fast-deep-equal: 3.1.3
json-schema-traverse@1.0.0: {}
light-my-request@5.14.0:
dependencies:
cookie: 0.7.2
process-warning: 3.0.0
set-cookie-parser: 2.7.2
mnemonist@0.40.0:
dependencies:
obliterator: 2.0.5
obliterator@2.0.5: {}
on-exit-leak-free@2.1.2: {}
pino-abstract-transport@2.0.0:
dependencies:
split2: 4.2.0
pino-std-serializers@7.0.0: {}
pino@9.14.0:
dependencies:
'@pinojs/redact': 0.4.0
atomic-sleep: 1.0.0
on-exit-leak-free: 2.1.2
pino-abstract-transport: 2.0.0
pino-std-serializers: 7.0.0
process-warning: 5.0.0
quick-format-unescaped: 4.0.4
real-require: 0.2.0
safe-stable-stringify: 2.5.0
sonic-boom: 4.2.0
thread-stream: 3.1.0
prisma@5.22.0:
dependencies:
'@prisma/engines': 5.22.0
optionalDependencies:
fsevents: 2.3.3
process-warning@3.0.0: {}
process-warning@5.0.0: {}
proxy-addr@2.0.7:
dependencies:
forwarded: 0.2.0
ipaddr.js: 1.9.1
quick-format-unescaped@4.0.4: {}
real-require@0.2.0: {}
require-from-string@2.0.2: {}
resolve-pkg-maps@1.0.0: {}
ret@0.4.3: {}
reusify@1.1.0: {}
rfdc@1.4.1: {}
safe-regex2@3.1.0:
dependencies:
ret: 0.4.3
safe-stable-stringify@2.5.0: {}
secure-json-parse@2.7.0: {}
semver@7.7.3: {}
set-cookie-parser@2.7.2: {}
sonic-boom@4.2.0:
dependencies:
atomic-sleep: 1.0.0
split2@4.2.0: {}
thread-stream@3.1.0:
dependencies:
real-require: 0.2.0
toad-cache@3.7.0: {}
tsx@4.20.6:
dependencies:
esbuild: 0.25.12
get-tsconfig: 4.13.0
optionalDependencies:
fsevents: 2.3.3
typescript@5.9.3: {}
undici-types@6.21.0: {}
zod@3.25.76: {}

View File

@@ -1,13 +0,0 @@
import { defineConfig, env } from "prisma/config";
import "dotenv/config";
export default defineConfig({
schema: "prisma/schema.prisma",
migrations: {
path: "prisma/migrations",
},
engine: "classic",
datasource: {
url: env("DATABASE_URL"),
},
});

View File

@@ -0,0 +1,115 @@
-- CreateTable
CREATE TABLE "User" (
"id" TEXT NOT NULL,
"email" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "VariableCategory" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"percent" INTEGER NOT NULL,
"priority" INTEGER NOT NULL DEFAULT 100,
"isSavings" BOOLEAN NOT NULL DEFAULT false,
"balanceCents" BIGINT NOT NULL DEFAULT 0,
CONSTRAINT "VariableCategory_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "FixedPlan" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"cycleStart" TIMESTAMP(3) NOT NULL,
"dueOn" TIMESTAMP(3) NOT NULL,
"totalCents" BIGINT NOT NULL,
"fundedCents" BIGINT NOT NULL DEFAULT 0,
"priority" INTEGER NOT NULL DEFAULT 100,
"fundingMode" TEXT NOT NULL DEFAULT 'auto-on-deposit',
"scheduleJson" JSONB,
CONSTRAINT "FixedPlan_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "IncomeEvent" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"postedAt" TIMESTAMP(3) NOT NULL,
"amountCents" BIGINT NOT NULL,
CONSTRAINT "IncomeEvent_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Allocation" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"kind" TEXT NOT NULL,
"toId" TEXT NOT NULL,
"amountCents" BIGINT NOT NULL,
"incomeId" TEXT,
CONSTRAINT "Allocation_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Transaction" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"occurredAt" TIMESTAMP(3) NOT NULL,
"kind" TEXT NOT NULL,
"categoryId" TEXT,
"planId" TEXT,
"amountCents" BIGINT NOT NULL,
CONSTRAINT "Transaction_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
-- CreateIndex
CREATE INDEX "VariableCategory_userId_priority_idx" ON "VariableCategory"("userId", "priority");
-- CreateIndex
CREATE UNIQUE INDEX "VariableCategory_userId_name_key" ON "VariableCategory"("userId", "name");
-- CreateIndex
CREATE INDEX "FixedPlan_userId_dueOn_idx" ON "FixedPlan"("userId", "dueOn");
-- CreateIndex
CREATE INDEX "FixedPlan_userId_priority_idx" ON "FixedPlan"("userId", "priority");
-- CreateIndex
CREATE UNIQUE INDEX "FixedPlan_userId_name_key" ON "FixedPlan"("userId", "name");
-- CreateIndex
CREATE INDEX "IncomeEvent_userId_postedAt_idx" ON "IncomeEvent"("userId", "postedAt");
-- CreateIndex
CREATE INDEX "Transaction_userId_occurredAt_idx" ON "Transaction"("userId", "occurredAt");
-- AddForeignKey
ALTER TABLE "VariableCategory" ADD CONSTRAINT "VariableCategory_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "FixedPlan" ADD CONSTRAINT "FixedPlan_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "IncomeEvent" ADD CONSTRAINT "IncomeEvent_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Allocation" ADD CONSTRAINT "Allocation_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Allocation" ADD CONSTRAINT "Allocation_incomeId_fkey" FOREIGN KEY ("incomeId") REFERENCES "IncomeEvent"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Transaction" ADD CONSTRAINT "Transaction_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (i.e. Git)
provider = "postgresql"

View File

@@ -1,6 +1,13 @@
// prisma/schema.prisma // prisma/schema.prisma
generator client { provider = "prisma-client-js" } generator client {
datasource db { provider = "postgresql"; url = env("DATABASE_URL") } provider = "prisma-client-js"
binaryTargets = ["native", "debian-openssl-3.0.x"]
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model User { model User {
id String @id @default(uuid()) id String @id @default(uuid())
@@ -25,63 +32,59 @@ model VariableCategory {
balanceCents BigInt @default(0) balanceCents BigInt @default(0)
@@unique([userId, name]) @@unique([userId, name])
@@check(percent_gte_0, "percent >= 0") @@index([userId, priority])
@@check(percent_lte_100,"percent <= 100")
} }
model FixedPlan { model FixedPlan {
id String @id @default(uuid()) id String @id @default(uuid())
userId String userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade)
name String name String
cycleStart DateTime cycleStart DateTime
dueOn DateTime dueOn DateTime
totalCents BigInt totalCents BigInt
fundedCents BigInt @default(0) fundedCents BigInt @default(0)
priority Int @default(100) priority Int @default(100)
fundingMode String @default("auto-on-deposit") // or 'by-schedule' fundingMode String @default("auto-on-deposit")
scheduleJson Json? scheduleJson Json?
@@unique([userId, name]) @@unique([userId, name])
@@check(total_nonneg, "totalCents >= 0") @@index([userId, dueOn])
@@check(funded_nonneg, "fundedCents >= 0") @@index([userId, priority])
} }
model IncomeEvent { model IncomeEvent {
id String @id @default(uuid()) id String @id @default(uuid())
userId String userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade)
postedAt DateTime postedAt DateTime
amountCents BigInt amountCents BigInt
allocations Allocation[] allocations Allocation[]
@@check(pos_amount, "amountCents > 0") @@index([userId, postedAt])
} }
model Allocation { model Allocation {
id String @id @default(uuid()) id String @id @default(uuid())
userId String userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
kind String // 'savings' | 'variable' | 'fixed' kind String
toId String toId String
amountCents BigInt amountCents BigInt
incomeId String? incomeId String?
income IncomeEvent? @relation(fields: [incomeId], references: [id]) income IncomeEvent? @relation(fields: [incomeId], references: [id])
@@check(pos_amount, "amountCents > 0")
} }
model Transaction { model Transaction {
id String @id @default(uuid()) id String @id @default(uuid())
userId String userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade)
occurredAt DateTime occurredAt DateTime
kind String // 'variable-spend' | 'fixed-payment' kind String
categoryId String? categoryId String?
planId String? planId String?
amountCents BigInt amountCents BigInt
@@check(pos_amount, "amountCents > 0") @@index([userId, occurredAt])
} }

85
api/prisma/seed.ts Normal file
View File

@@ -0,0 +1,85 @@
/// <reference types="node" />
import { PrismaClient } from "@prisma/client";
import { allocateIncome } from "../src/allocator.ts"; // adjust if your path differs
const prisma = new PrismaClient();
const cents = (dollars: number) => BigInt(Math.round(dollars * 100));
async function main() {
const userId = "demo-user-1"; // dev-only, string id per schema
// 1) User
await prisma.user.upsert({
where: { id: userId },
create: { id: userId, email: "demo@example.com" },
update: {},
});
// 2) Variable categories (sum = 100)
const categories = [
{ name: "Savings", percent: 40, isSavings: true, priority: 10 },
{ name: "Needs", percent: 40, isSavings: false, priority: 20 },
{ name: "Wants", percent: 20, isSavings: false, priority: 30 },
];
for (const c of categories) {
await prisma.variableCategory.upsert({
where: { userId_name: { userId, name: c.name } },
create: { userId, name: c.name, percent: c.percent, isSavings: c.isSavings, priority: c.priority, balanceCents: 0n },
update: { percent: c.percent, isSavings: c.isSavings, priority: c.priority },
});
}
// 3) Fixed plans
const today = new Date();
const monthStart = new Date(today.getFullYear(), today.getMonth(), 1);
const dueNext = new Date(today.getFullYear(), today.getMonth(), 1);
dueNext.setMonth(dueNext.getMonth() + 1);
const plans = [
{ name: "Rent", total: cents(1200), priority: 10, cycleStart: monthStart, dueOn: dueNext },
{ name: "Utilities", total: cents(300), priority: 20, cycleStart: monthStart, dueOn: dueNext },
];
for (const p of plans) {
await prisma.fixedPlan.upsert({
where: { userId_name: { userId, name: p.name } },
create: {
userId, name: p.name,
totalCents: p.total, fundedCents: 0n,
priority: p.priority, cycleStart: p.cycleStart, dueOn: p.dueOn,
fundingMode: "auto-on-deposit"
},
update: {
totalCents: p.total, priority: p.priority,
cycleStart: p.cycleStart, dueOn: p.dueOn
},
});
}
// 4) Seed income + allocate
const deposit = 2500; // dollars
const nowISO = new Date().toISOString();
const income = await prisma.incomeEvent.create({
data: { userId, postedAt: new Date(nowISO), amountCents: cents(deposit) },
select: { id: true },
});
const result = await allocateIncome(
prisma, // db
userId, // user id (string)
Math.round(deposit * 100), // depositCentsNum (number is fine)
nowISO, // ISO timestamp
income.id // incomeEventId (string)
);
console.log("Seed complete\n", JSON.stringify(result, null, 2));
}
main().catch((e) => {
console.error(e);
process.exit(1);
}).finally(async () => {
await prisma.$disconnect();
});

View File

@@ -0,0 +1,12 @@
{
"extends": "../tsconfig.json",
"compilerOptions": {
"module": "esnext",
"moduleResolution": "bundler",
"target": "ES2022",
"lib": ["es2022"],
"types": ["node"],
"noEmit": true
},
"include": ["seed.ts"]
}

154
api/src/allocator.ts Normal file
View File

@@ -0,0 +1,154 @@
import type { PrismaClient } from "@prisma/client";
/**
* Allocate income across fixed plans (need-first) and variable categories (largest remainder).
*
* @param db Prisma client (or tx)
* @param userId string
* @param amountCents number (>= 0)
* @param postedAtISO string ISO timestamp for the income event
* @param incomeId string id to use for IncomeEvent + Allocation FK
*/
export async function allocateIncome(
db: PrismaClient,
userId: string,
amountCents: number,
postedAtISO: string,
incomeId: string
): Promise<{
fixedAllocations: Array<{ fixedPlanId: string; amountCents: number }>;
variableAllocations: Array<{ variableCategoryId: string; amountCents: number }>;
remainingUnallocatedCents: number;
}> {
const amt = Math.max(0, Math.floor(amountCents | 0));
return await db.$transaction(async (tx) => {
// 1) Ensure the IncomeEvent exists to satisfy FK on Allocation
await tx.incomeEvent.upsert({
where: { id: incomeId },
update: {}, // idempotent in case route created it already
create: {
id: incomeId,
userId,
postedAt: new Date(postedAtISO),
amountCents: BigInt(amt),
},
});
// 2) Load current fixed plans + variable categories
const [plans, cats] = await Promise.all([
tx.fixedPlan.findMany({
where: { userId },
orderBy: [{ priority: "asc" }, { dueOn: "asc" }],
select: {
id: true,
totalCents: true,
fundedCents: true,
priority: true,
dueOn: true,
},
}),
tx.variableCategory.findMany({
where: { userId },
orderBy: [{ priority: "asc" }, { name: "asc" }],
select: { id: true, name: true, percent: true, isSavings: true, priority: true },
}),
]);
let remaining = amt;
// 3) Fixed pass: fund by priority then due date up to need
const fixedAllocations: Array<{ fixedPlanId: string; amountCents: number }> = [];
for (const p of plans) {
if (remaining <= 0) break;
const needBig = (p.totalCents ?? 0n) - (p.fundedCents ?? 0n);
const need = Number(needBig > 0n ? needBig : 0n);
if (need <= 0) continue;
const give = Math.min(need, remaining);
if (give > 0) {
// apply fundedCents
await tx.fixedPlan.update({
where: { id: p.id },
data: { fundedCents: (p.fundedCents ?? 0n) + BigInt(give) },
});
// audit allocation row
await tx.allocation.create({
data: {
userId,
kind: "fixed",
toId: p.id,
amountCents: BigInt(give),
incomeId, // FK now valid
},
});
fixedAllocations.push({ fixedPlanId: p.id, amountCents: give });
remaining -= give;
}
}
// 4) Variable pass: largest remainder w/ savings-first tiebreak
const variableAllocations: Array<{ variableCategoryId: string; amountCents: number }> = [];
if (remaining > 0 && cats.length > 0) {
const totalPercent = cats.reduce((s, c) => s + (c.percent || 0), 0);
const norm = totalPercent === 100
? cats
: cats.map(c => ({
...c,
percent: totalPercent > 0 ? (c.percent * 100) / totalPercent : 0,
}));
const base = new Array(norm.length).fill(0);
const tie = [] as Array<{ idx: number; remainder: number; isSavings: boolean; priority: number; name: string }>;
let sumBase = 0;
norm.forEach((c, idx) => {
const exact = (remaining * (c.percent || 0)) / 100;
const floor = Math.floor(exact);
base[idx] = floor;
sumBase += floor;
tie.push({ idx, remainder: exact - floor, isSavings: !!c.isSavings, priority: c.priority, name: c.name });
});
let leftovers = remaining - sumBase;
tie.sort((a, b) => {
if (a.isSavings !== b.isSavings) return a.isSavings ? -1 : 1; // savings first
if (a.remainder !== b.remainder) return b.remainder - a.remainder;
if (a.priority !== b.priority) return a.priority - b.priority;
return a.name.localeCompare(b.name);
});
for (let i = 0; i < tie.length && leftovers > 0; i++, leftovers--) base[tie[i].idx]++;
for (let i = 0; i < norm.length; i++) {
const give = base[i] || 0;
if (give > 0) {
const c = norm[i];
await tx.variableCategory.update({
where: { id: c.id },
data: { balanceCents: { increment: BigInt(give) } },
});
await tx.allocation.create({
data: {
userId,
kind: "variable",
toId: c.id,
amountCents: BigInt(give),
incomeId,
},
});
variableAllocations.push({ variableCategoryId: c.id, amountCents: give });
}
}
remaining = leftovers;
}
return {
fixedAllocations,
variableAllocations,
remainingUnallocatedCents: Math.max(0, remaining),
};
});
}

26
api/src/env.ts Normal file
View File

@@ -0,0 +1,26 @@
// api/src/env.ts
import { z } from "zod";
const Env = z.object({
NODE_ENV: z.enum(["development", "test", "production"]).default("development"),
PORT: z.coerce.number().int().positive().default(8080),
HOST: z.string().default("0.0.0.0"),
DATABASE_URL: z.string().min(1),
// Comma-separated list of allowed origins; empty => allow all (dev)
CORS_ORIGIN: z.string().optional(),
// 🔹 New: rate-limit knobs (have defaults so typing is happy)
RATE_LIMIT_MAX: z.coerce.number().int().positive().default(200),
RATE_LIMIT_WINDOW_MS: z.coerce.number().int().positive().default(60_000),
});
export const env = Env.parse({
NODE_ENV: process.env.NODE_ENV,
PORT: process.env.PORT,
HOST: process.env.HOST,
DATABASE_URL: process.env.DATABASE_URL,
CORS_ORIGIN: "http://localhost:5173,http://127.0.0.1:5173",
RATE_LIMIT_MAX: process.env.RATE_LIMIT_MAX,
RATE_LIMIT_WINDOW_MS: process.env.RATE_LIMIT_WINDOW_MS,
});

View File

@@ -0,0 +1,48 @@
// api/src/plugins/error-handler.ts
import type { FastifyError, FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import { ZodError } from "zod";
function isPrismaError(e: any, code: string) {
return e && typeof e === "object" && e.code === code;
}
export function installErrorHandler(app: FastifyInstance) {
app.setErrorHandler((err: FastifyError & { code?: string; statusCode?: number }, req: FastifyRequest, reply: FastifyReply) => {
const requestId = (req as any).id as string | undefined;
// Respect explicit statusCode + code (e.g., OVERDRAFT_CATEGORY/PLAN)
if (err.statusCode && err.code) {
return reply.code(err.statusCode).send({ ok: false, code: err.code, message: err.message, requestId });
}
// Zod validation
if (err instanceof ZodError) {
return reply.code(400).send({
ok: false,
code: "INVALID_INPUT",
message: err.errors.map(e => e.message).join("; "),
requestId,
});
}
// Prisma common cases
if (isPrismaError(err, "P2002")) {
return reply.code(409).send({ ok: false, code: "UNIQUE_VIOLATION", message: "Duplicate value violates unique constraint", requestId });
}
if (isPrismaError(err, "P2003")) {
return reply.code(400).send({ ok: false, code: "FK_CONSTRAINT", message: "Foreign key constraint violated", requestId });
}
// 404 produced by handlers
if (err.statusCode === 404) {
return reply.code(404).send({ ok: false, code: "NOT_FOUND", message: err.message || "Not found", requestId });
}
// Default
const status = err.statusCode && err.statusCode >= 400 ? err.statusCode : 500;
const code = status >= 500 ? "INTERNAL" : "BAD_REQUEST";
// Log full error with request id for correlation
app.log.error({ err, requestId }, "Request error");
return reply.code(status).send({ ok: false, code, message: err.message || "Unexpected error", requestId });
});
}

View File

@@ -0,0 +1,15 @@
// api/src/plugins/request-id.ts
import type { FastifyPluginCallback } from "fastify";
import { randomUUID } from "crypto";
const requestIdPlugin: FastifyPluginCallback = (app, _opts, done) => {
app.addHook("onRequest", async (req, reply) => {
const incoming = (req.headers["x-request-id"] as string | undefined)?.trim();
const id = incoming && incoming.length > 0 ? incoming : randomUUID();
(req as any).id = id; // attach to request
reply.header("x-request-id", id); // echo on response
});
done();
};
export default requestIdPlugin;

View File

@@ -0,0 +1,23 @@
import fp from "fastify-plugin";
import { prisma } from "../prisma.js";
declare module "fastify" {
interface FastifyRequest {
userId: string;
}
}
export default fp(async (app) => {
app.addHook("onRequest", async (req) => {
// Dev-only stub: use header if provided, else default
const hdr = req.headers["x-user-id"];
req.userId = typeof hdr === "string" && hdr.trim() ? hdr.trim() : "demo-user-1";
// Ensure the user exists (avoids FK P2003 on first write)
await prisma.user.upsert({
where: { id: req.userId },
update: {},
create: { id: req.userId, email: `${req.userId}@demo.local` },
});
});
});

2
api/src/prisma.ts Normal file
View File

@@ -0,0 +1,2 @@
import { PrismaClient } from "@prisma/client";
export const prisma = new PrismaClient();

View File

@@ -0,0 +1,94 @@
import { FastifyPluginAsync } from "fastify";
import { z } from "zod";
import { prisma } from "../prisma.js";
const NewPlan = z.object({
name: z.string().min(1).max(120),
totalCents: z.number().int().min(0),
fundedCents: z.number().int().min(0).default(0),
priority: z.number().int().min(0).max(10_000),
dueOn: z.string().datetime(), // ISO
});
const PatchPlan = NewPlan.partial();
const IdParam = z.object({ id: z.string().min(1) });
const bi = (n: number | bigint | undefined) => BigInt(n ?? 0);
function validateFunding(total: bigint, funded: bigint) {
if (funded > total) {
const err: any = new Error("fundedCents must be ≤ totalCents");
err.statusCode = 400;
err.code = "FUNDED_GT_TOTAL";
throw err;
}
}
const plugin: FastifyPluginAsync = async (app) => {
// CREATE
app.post("/api/fixed-plans", async (req, reply) => {
const userId = req.userId;
const parsed = NewPlan.safeParse(req.body);
if (!parsed.success) return reply.status(400).send({ error: "INVALID_BODY", details: parsed.error.flatten() });
const totalBI = bi(parsed.data.totalCents);
const fundedBI = bi(parsed.data.fundedCents);
validateFunding(totalBI, fundedBI);
const rec = await prisma.fixedPlan.create({
data: {
userId,
name: parsed.data.name,
priority: parsed.data.priority,
dueOn: new Date(parsed.data.dueOn),
totalCents: totalBI,
fundedCents: fundedBI,
cycleStart: new Date(), // required by your schema
},
select: { id: true },
});
return reply.status(201).send(rec);
});
// UPDATE
app.patch("/api/fixed-plans/:id", async (req, reply) => {
const userId = req.userId;
const pid = IdParam.safeParse(req.params);
const patch = PatchPlan.safeParse(req.body);
if (!pid.success) return reply.status(400).send({ error: "INVALID_ID", details: pid.error.flatten() });
if (!patch.success) return reply.status(400).send({ error: "INVALID_BODY", details: patch.error.flatten() });
const existing = await prisma.fixedPlan.findFirst({ where: { id: pid.data.id, userId } });
if (!existing) return reply.status(404).send({ error: "NOT_FOUND" });
const nextTotal = patch.data.totalCents !== undefined ? bi(patch.data.totalCents) : (existing.totalCents as bigint);
const nextFunded = patch.data.fundedCents !== undefined ? bi(patch.data.fundedCents) : (existing.fundedCents as bigint);
validateFunding(nextTotal, nextFunded);
await prisma.fixedPlan.update({
where: { id: pid.data.id },
data: {
...(patch.data.name !== undefined ? { name: patch.data.name } : null),
...(patch.data.priority !== undefined ? { priority: patch.data.priority } : null),
...(patch.data.dueOn !== undefined ? { dueOn: new Date(patch.data.dueOn) } : null),
...(patch.data.totalCents !== undefined ? { totalCents: bi(patch.data.totalCents) } : null),
...(patch.data.fundedCents !== undefined ? { fundedCents: bi(patch.data.fundedCents) } : null),
},
});
return reply.send({ ok: true });
});
// DELETE
app.delete("/api/fixed-plans/:id", async (req, reply) => {
const userId = req.userId;
const pid = IdParam.safeParse(req.params);
if (!pid.success) return reply.status(400).send({ error: "INVALID_ID", details: pid.error.flatten() });
const existing = await prisma.fixedPlan.findFirst({ where: { id: pid.data.id, userId } });
if (!existing) return reply.status(404).send({ error: "NOT_FOUND" });
await prisma.fixedPlan.delete({ where: { id: pid.data.id } });
return reply.send({ ok: true });
});
};
export default plugin;

View File

@@ -0,0 +1,82 @@
import { FastifyInstance } from "fastify";
import { z } from "zod";
const Body = z.object({ amountCents: z.number().int().nonnegative() });
export default async function incomePreviewRoutes(app: FastifyInstance) {
app.post("/income/preview", async (req, reply) => {
const parsed = Body.safeParse(req.body);
if (!parsed.success) return reply.code(400).send({ message: "Invalid amount" });
const userId = req.userId;
let remaining = Math.max(0, parsed.data.amountCents | 0);
const [plans, cats] = await Promise.all([
app.prisma.fixedPlan.findMany({
where: { userId },
orderBy: [{ priority: "asc" }, { dueOn: "asc" }],
select: { id: true, name: true, totalCents: true, fundedCents: true, priority: true, dueOn: true },
}),
app.prisma.variableCategory.findMany({
where: { userId },
orderBy: [{ priority: "asc" }, { name: "asc" }],
select: { id: true, name: true, percent: true, isSavings: true, priority: true },
}),
]);
// Fixed pass
const fixed: Array<{ id: string; name: string; amountCents: number }> = [];
for (const p of plans) {
if (remaining <= 0) break;
const needBig = (p.totalCents ?? 0n) - (p.fundedCents ?? 0n);
const need = Number(needBig > 0n ? needBig : 0n);
if (need <= 0) continue;
const give = Math.min(need, remaining);
fixed.push({ id: p.id, name: p.name, amountCents: give });
remaining -= give;
}
// Variable pass — largest remainder with savings-first tiebreak
const variable: Array<{ id: string; name: string; amountCents: number }> = [];
if (remaining > 0 && cats.length > 0) {
const totalPercent = cats.reduce((s, c) => s + (c.percent || 0), 0);
const normCats =
totalPercent === 100
? cats
: cats.map((c) => ({
...c,
percent: totalPercent > 0 ? (c.percent * 100) / totalPercent : 0,
}));
const base: number[] = new Array(normCats.length).fill(0);
const tie: { idx: number; remainder: number; isSavings: boolean; priority: number; name: string }[] = [];
let sumBase = 0;
normCats.forEach((c, idx) => {
const exact = (remaining * c.percent) / 100;
const floor = Math.floor(exact);
base[idx] = floor;
sumBase += floor;
tie.push({ idx, remainder: exact - floor, isSavings: !!c.isSavings, priority: c.priority, name: c.name });
});
let leftovers = remaining - sumBase;
tie.sort((a, b) => {
if (a.isSavings !== b.isSavings) return a.isSavings ? -1 : 1; // savings first
if (a.remainder !== b.remainder) return b.remainder - a.remainder;
if (a.priority !== b.priority) return a.priority - b.priority;
return a.name.localeCompare(b.name);
});
for (let i = 0; i < tie.length && leftovers > 0; i++, leftovers--) base[tie[i].idx] += 1;
normCats.forEach((c, idx) => {
const give = base[idx] || 0;
if (give > 0) variable.push({ id: c.id, name: c.name, amountCents: give });
});
remaining = leftovers;
}
return { fixed, variable, unallocatedCents: Math.max(0, remaining) };
});
}

View File

@@ -0,0 +1,69 @@
// api/src/routes/transactions.ts
import fp from "fastify-plugin";
import { z } from "zod";
const Query = z.object({
from: z
.string()
.regex(/^\d{4}-\d{2}-\d{2}$/)
.optional(), // YYYY-MM-DD
to: z
.string()
.regex(/^\d{4}-\d{2}-\d{2}$/)
.optional(),
kind: z.enum(["variable_spend", "fixed_payment"]).optional(),
q: z.string().trim().optional(),
page: z.coerce.number().int().positive().default(1),
limit: z.coerce.number().int().positive().max(100).default(20),
});
export default fp(async function transactionsRoute(app) {
app.get("/transactions", async (req, reply) => {
const userId =
typeof req.userId === "string"
? req.userId
: String(req.userId ?? "demo-user-1");
const parsed = Query.safeParse(req.query);
if (!parsed.success) {
return reply
.code(400)
.send({ message: "Invalid query", issues: parsed.error.issues });
}
const { from, to, kind, q, page, limit } = parsed.data;
const where: any = { userId };
if (from || to) {
where.occurredAt = {};
if (from) where.occurredAt.gte = new Date(`${from}T00:00:00.000Z`);
if (to) where.occurredAt.lte = new Date(`${to}T23:59:59.999Z`);
}
if (kind) where.kind = kind;
if (typeof q === "string" && q.trim() !== "") {
const ors: any[] = [];
const asNumber = Number(q);
if (Number.isFinite(asNumber)) {
ors.push({ amountCents: BigInt(asNumber) });
}
if (ors.length > 0) {
where.OR = ors;
}
}
const skip = (page - 1) * limit;
const [total, items] = await Promise.all([
app.prisma.transaction.count({ where }),
app.prisma.transaction.findMany({
where,
orderBy: { occurredAt: "desc" },
skip,
take: limit,
select: { id: true, kind: true, amountCents: true, occurredAt: true },
}),
]);
return { items, page, limit, total };
});
});

View File

@@ -0,0 +1,85 @@
import { FastifyPluginAsync } from "fastify";
import { Prisma } from "@prisma/client";
import { z } from "zod";
import { prisma } from "../prisma.js";
const NewCat = z.object({
name: z.string().min(1).max(100),
percent: z.number().int().min(0).max(100),
isSavings: z.boolean().default(false),
priority: z.number().int().min(0).max(10_000),
});
const PatchCat = NewCat.partial();
const IdParam = z.object({ id: z.string().min(1) });
async function assertPercentTotal100(tx: Prisma.TransactionClient, userId: string) {
const g = await tx.variableCategory.groupBy({
by: ["userId"],
where: { userId },
_sum: { percent: true },
});
const sum = g[0]?._sum.percent ?? 0;
if (sum !== 100) {
const err: any = new Error(`Percents must sum to 100 (got ${sum}).`);
err.statusCode = 400;
err.code = "PERCENT_TOTAL_NOT_100";
throw err;
}
}
const plugin: FastifyPluginAsync = async (app) => {
// CREATE
app.post("/api/variable-categories", async (req, reply) => {
const userId = req.userId;
const body = NewCat.safeParse(req.body);
if (!body.success) return reply.status(400).send({ error: "INVALID_BODY", details: body.error.flatten() });
const created = await prisma.$transaction(async (tx) => {
const rec = await tx.variableCategory.create({
data: { ...body.data, userId },
select: { id: true },
});
await assertPercentTotal100(tx, userId);
return rec;
});
return reply.status(201).send(created);
});
// UPDATE
app.patch("/api/variable-categories/:id", async (req, reply) => {
const userId = req.userId;
const pid = IdParam.safeParse(req.params);
const patch = PatchCat.safeParse(req.body);
if (!pid.success) return reply.status(400).send({ error: "INVALID_ID", details: pid.error.flatten() });
if (!patch.success) return reply.status(400).send({ error: "INVALID_BODY", details: patch.error.flatten() });
await prisma.$transaction(async (tx) => {
const exists = await tx.variableCategory.findFirst({ where: { id: pid.data.id, userId } });
if (!exists) return reply.status(404).send({ error: "NOT_FOUND" });
await tx.variableCategory.update({ where: { id: pid.data.id }, data: patch.data });
await assertPercentTotal100(tx, userId);
});
return reply.send({ ok: true });
});
// DELETE
app.delete("/api/variable-categories/:id", async (req, reply) => {
const userId = req.userId;
const pid = IdParam.safeParse(req.params);
if (!pid.success) return reply.status(400).send({ error: "INVALID_ID", details: pid.error.flatten() });
await prisma.$transaction(async (tx) => {
const exists = await tx.variableCategory.findFirst({ where: { id: pid.data.id, userId } });
if (!exists) return reply.status(404).send({ error: "NOT_FOUND" });
await tx.variableCategory.delete({ where: { id: pid.data.id } });
await assertPercentTotal100(tx, userId);
});
return reply.send({ ok: true });
});
};
export default plugin;

View File

@@ -1,20 +0,0 @@
// prisma/seed.ts (optional: creates one demo user + categories)
import { PrismaClient } from '@prisma/client';
const db = new PrismaClient();
async function main() {
const user = await db.user.upsert({
where: { email: 'demo@user.test' },
update: {},
create: { email: 'demo@user.test' }
});
await db.variableCategory.createMany({
data: [
{ userId: user.id, name: 'Groceries', percent: 30, priority: 10 },
{ userId: user.id, name: 'Gas', percent: 20, priority: 20 },
{ userId: user.id, name: 'Fun', percent: 50, priority: 30 }
],
skipDuplicates: true
});
console.log('Seeded:', user.email);
}
main().finally(()=>db.$disconnect());

View File

@@ -1,10 +1,516 @@
// api/src/server.ts
import Fastify from "fastify"; import Fastify from "fastify";
import cors from "@fastify/cors";
import rateLimit from "@fastify/rate-limit";
import { env } from "./env.js";
import { PrismaClient } from "@prisma/client";
import { z } from "zod";
import { allocateIncome } from "./allocator.js";
const app = Fastify({ logger: true });
app.get("/health", async () => ({ ok: true }));
const port = Number(process.env.PORT ?? 8080);
app.listen({ port, host: "0.0.0.0" }).catch((err) => { declare module "fastify" {
app.log.error(err); interface FastifyInstance { prisma: PrismaClient }
process.exit(1); interface FastifyRequest { userId: string }
}
const toBig = (n: number | string | bigint) => BigInt(n);
const isDate = (s?: string) => !!s && /^\d{4}-\d{2}-\d{2}$/.test(s);
const isoStart = (d: string) => new Date(`${d}T00:00:00.000Z`);
const isoEnd = (d: string) => new Date(`${d}T23:59:59.999Z`);
function jsonBigIntSafe(obj: unknown) {
return JSON.parse(JSON.stringify(obj, (_k, v) => (typeof v === "bigint" ? Number(v) : v)));
}
const app = Fastify({
logger: true,
requestIdHeader: "x-request-id",
genReqId: (req) => {
const hdr = req.headers["x-request-id"];
if (typeof hdr === "string" && hdr.length <= 64) return hdr;
return Math.random().toString(36).slice(2) + Date.now().toString(36);
},
}); });
// CORS
await app.register(cors, {
origin: (() => {
if (!env.CORS_ORIGIN) return true; // dev: allow all
const allow = env.CORS_ORIGIN.split(",").map(s => s.trim()).filter(Boolean);
return (origin, cb) => {
if (!origin) return cb(null, true); // curl/health
cb(null, allow.includes(origin));
};
})(),
credentials: true,
});
// Rate limit (light)
await app.register(rateLimit, {
max: env.RATE_LIMIT_MAX,
timeWindow: env.RATE_LIMIT_WINDOW_MS,
hook: "onRequest",
allowList: (req) => {
const ip = (req.ip || "").replace("::ffff:", "");
return ip === "127.0.0.1" || ip === "::1";
},
});
// Prisma
{
const prisma = new PrismaClient();
app.decorate("prisma", prisma);
app.addHook("onClose", async () => prisma.$disconnect());
}
// Auth stub + ensure user exists + set x-request-id header ONCE
app.addHook("onRequest", async (req, reply) => {
const headerId = req.headers["x-user-id"];
if (typeof headerId === "string" && headerId.trim()) req.userId = headerId.trim();
else req.userId = "demo-user-1";
// echo the request id (no per-request hook registration)
if (req.id) reply.header("x-request-id", String(req.id));
await app.prisma.user.upsert({
where: { id: req.userId },
update: {},
create: { id: req.userId, email: `${req.userId}@demo.local` },
});
});
// BigInt-safe JSON (single onSend)
app.addHook("preSerialization", (_req, _reply, payload, done) => {
try {
if (payload && typeof payload === "object") {
const safe = JSON.parse(
JSON.stringify(payload, (_k, v) => (typeof v === "bigint" ? Number(v) : v))
);
return done(null, safe);
}
return done(null, payload);
} catch {
// If anything goes sideways, keep the original payload
return done(null, payload);
}
});
app.setErrorHandler((err, req, reply) => {
// Map prisma/validation-ish errors to 400 by default
const status =
(typeof (err as any).statusCode === "number" && (err as any).statusCode) ||
(typeof (err as any).status === "number" && (err as any).status) ||
(typeof (err as any).code === "string" && (err as any).code.startsWith("P2") ? 400 : 500);
// Never leak stacks to client
const body = {
ok: false,
code: (err as any).code ?? "INTERNAL",
message:
status >= 500
? "Something went wrong"
: (err as any).message ?? "Bad request",
requestId: String(req.id ?? ""),
};
// Log full error with request context
req.log.error({ err, requestId: req.id }, "request failed");
reply.code(status).send(body);
});
// 404 JSON
app.setNotFoundHandler((req, reply) => {
reply.code(404).send({
ok: false,
code: "NOT_FOUND",
message: `No route: ${req.method} ${req.url}`,
requestId: String(req.id ?? ""),
});
});
// ───────────── Health ─────────────
app.get("/health", async () => ({ ok: true }));
app.get("/health/db", async () => {
const start = Date.now();
const [{ now }] = await app.prisma.$queryRawUnsafe<{ now: Date }[]>("SELECT now() as now");
const latencyMs = Date.now() - start;
return { ok: true, nowISO: now.toISOString(), latencyMs };
});
// ───────────── Dashboard ─────────────
app.get("/dashboard", async (req) => {
const userId = req.userId;
const [cats, plans, txs, agg] = await Promise.all([
app.prisma.variableCategory.findMany({
where: { userId }, orderBy: [{ priority: "asc" }, { name: "asc" }]
}),
app.prisma.fixedPlan.findMany({
where: { userId }, orderBy: [{ priority: "asc" }, { dueOn: "asc" }]
}),
app.prisma.transaction.findMany({
where: { userId }, orderBy: { occurredAt: "desc" }, take: 50,
select: { id: true, kind: true, amountCents: true, occurredAt: true }
}),
app.prisma.incomeEvent.aggregate({
where: { userId }, _sum: { amountCents: true }
}),
]);
const totals = {
incomeCents: Number(agg._sum?.amountCents ?? 0n),
variableBalanceCents: Number(cats.reduce((s, c) => s + (c.balanceCents ?? 0n), 0n)),
fixedRemainingCents: Number(plans.reduce((s, p) => {
const rem = (p.totalCents ?? 0n) - (p.fundedCents ?? 0n);
return s + (rem > 0n ? rem : 0n);
}, 0n)),
};
const percentTotal = cats.reduce((s, c) => s + c.percent, 0);
return { totals, variableCategories: cats, fixedPlans: plans, recentTransactions: txs, percentTotal };
});
// ───────────── Income (allocate) ─────────────
app.post("/income", async (req, reply) => {
const Body = z.object({ amountCents: z.number().int().nonnegative() });
const parsed = Body.safeParse(req.body);
if (!parsed.success) return reply.code(400).send({ message: "Invalid amount" });
const userId = req.userId;
const nowISO = new Date().toISOString();
const amountCentsNum = parsed.data.amountCents;
const income = await app.prisma.incomeEvent.create({
data: { userId, postedAt: new Date(nowISO), amountCents: toBig(amountCentsNum) },
select: { id: true },
});
const result = await allocateIncome(app.prisma, userId, amountCentsNum, nowISO, income.id);
return result;
});
// ───────────── Transactions: create (strict overdraft) ─────────────
app.post("/transactions", async (req, reply) => {
const Body = z.object({
kind: z.enum(["variable_spend", "fixed_payment"]),
amountCents: z.number().int().positive(),
occurredAtISO: z.string().datetime(),
categoryId: z.string().optional(),
planId: z.string().optional(),
});
const parsed = Body.safeParse(req.body);
if (!parsed.success) return reply.code(400).send({ message: "Invalid payload" });
const { kind, amountCents, occurredAtISO, categoryId, planId } = parsed.data;
const userId = req.userId;
const amt = toBig(amountCents);
return await app.prisma.$transaction(async (tx) => {
if (kind === "variable_spend") {
if (!categoryId) return reply.code(400).send({ message: "categoryId required" });
const cat = await tx.variableCategory.findFirst({ where: { id: categoryId, userId } });
if (!cat) return reply.code(404).send({ message: "Category not found" });
const bal = cat.balanceCents ?? 0n;
if (amt > bal) {
const err: any = new Error("Insufficient category balance");
err.statusCode = 400; err.code = "OVERDRAFT_CATEGORY";
throw err;
}
await tx.variableCategory.update({ where: { id: cat.id }, data: { balanceCents: bal - amt } });
} else {
if (!planId) return reply.code(400).send({ message: "planId required" });
const plan = await tx.fixedPlan.findFirst({ where: { id: planId, userId } });
if (!plan) return reply.code(404).send({ message: "Plan not found" });
const funded = plan.fundedCents ?? 0n;
if (amt > funded) {
const err: any = new Error("Insufficient plan funds");
err.statusCode = 400; err.code = "OVERDRAFT_PLAN";
throw err;
}
await tx.fixedPlan.update({ where: { id: plan.id }, data: { fundedCents: funded - amt } });
}
const row = await tx.transaction.create({
data: {
userId,
occurredAt: new Date(occurredAtISO),
kind,
amountCents: amt,
categoryId: kind === "variable_spend" ? categoryId ?? null : null,
planId: kind === "fixed_payment" ? planId ?? null : null,
},
select: { id: true, kind: true, amountCents: true, occurredAt: true },
});
return row;
});
});
// ───────────── Transactions: list ─────────────
app.get("/transactions", async (req, reply) => {
const Query = z.object({
from: z.string().refine(isDate, "YYYY-MM-DD").optional(),
to: z.string().refine(isDate, "YYYY-MM-DD").optional(),
kind: z.enum(["variable_spend", "fixed_payment"]).optional(),
q: z.string().trim().optional(),
page: z.coerce.number().int().positive().default(1),
limit: z.coerce.number().int().positive().max(100).default(20),
});
const parsed = Query.safeParse(req.query);
if (!parsed.success) {
return reply.code(400).send({ message: "Invalid query", issues: parsed.error.issues });
}
const { from, to, kind, q, page, limit } = parsed.data;
const userId = req.userId;
const where: any = { userId };
if (from || to) {
where.occurredAt = {};
if (from) where.occurredAt.gte = isoStart(from);
if (to) where.occurredAt.lte = isoEnd(to);
}
if (kind) where.kind = kind;
// 💡 Only add OR if we actually have predicates
if (typeof q === "string" && q.trim() !== "") {
const ors: any[] = [];
const asNumber = Number(q);
if (Number.isFinite(asNumber)) {
ors.push({ amountCents: toBig(asNumber) });
}
// (When you add text fields later, push them here too)
if (ors.length > 0) {
where.OR = ors;
}
}
const skip = (page - 1) * limit;
const [total, items] = await Promise.all([
app.prisma.transaction.count({ where }),
app.prisma.transaction.findMany({
where,
orderBy: { occurredAt: "desc" },
skip,
take: limit,
select: { id: true, kind: true, amountCents: true, occurredAt: true },
}),
]);
return { items, page, limit, total };
});
// ───────────── Variable Categories CRUD (sum=100 guard) ─────────────
const CatBody = z.object({
name: z.string().trim().min(1),
percent: z.number().int().min(0).max(100),
isSavings: z.boolean(),
priority: z.number().int().min(0),
});
app.post("/variable-categories", async (req, reply) => {
const parsed = CatBody.safeParse(req.body);
if (!parsed.success) return reply.code(400).send({ message: "Invalid payload" });
const userId = req.userId;
return await app.prisma.$transaction(async (tx) => {
await tx.variableCategory.create({ data: { userId, balanceCents: 0n, ...parsed.data } });
const total = await tx.variableCategory.aggregate({ where: { userId }, _sum: { percent: true } });
if ((total._sum?.percent ?? 0) !== 100) throw reply.code(400).send({ message: "Percents must sum to 100" });
return { ok: true };
});
});
app.patch("/variable-categories/:id", async (req, reply) => {
const patch = CatBody.partial().safeParse(req.body);
if (!patch.success) return reply.code(400).send({ message: "Invalid payload" });
const id = String((req.params as any).id);
const userId = req.userId;
return await app.prisma.$transaction(async (tx) => {
const exists = await tx.variableCategory.findFirst({ where: { id, userId } });
if (!exists) return reply.code(404).send({ message: "Not found" });
await tx.variableCategory.update({ where: { id }, data: patch.data });
const total = await tx.variableCategory.aggregate({ where: { userId }, _sum: { percent: true } });
if ((total._sum?.percent ?? 0) !== 100) throw reply.code(400).send({ message: "Percents must sum to 100" });
return { ok: true };
});
});
app.delete("/variable-categories/:id", async (req, reply) => {
const id = String((req.params as any).id);
const userId = req.userId;
return await app.prisma.$transaction(async (tx) => {
const exists = await tx.variableCategory.findFirst({ where: { id, userId } });
if (!exists) return reply.code(404).send({ message: "Not found" });
await tx.variableCategory.delete({ where: { id } });
const total = await tx.variableCategory.aggregate({ where: { userId }, _sum: { percent: true } });
if ((total._sum?.percent ?? 0) !== 100) throw reply.code(400).send({ message: "Percents must sum to 100" });
return { ok: true };
});
});
// ───────────── Fixed Plans CRUD (funded ≤ total) ─────────────
const PlanBody = z.object({
name: z.string().trim().min(1),
totalCents: z.number().int().min(0),
fundedCents: z.number().int().min(0).optional(),
priority: z.number().int().min(0),
dueOn: z.string().datetime(),
cycleStart: z.string().datetime().optional(),
});
app.post("/fixed-plans", async (req, reply) => {
const parsed = PlanBody.safeParse(req.body);
if (!parsed.success) return reply.code(400).send({ message: "Invalid payload" });
const userId = req.userId;
const totalBig = toBig(parsed.data.totalCents);
const fundedBig = toBig(parsed.data.fundedCents ?? 0);
if (fundedBig > totalBig) return reply.code(400).send({ message: "fundedCents cannot exceed totalCents" });
await app.prisma.fixedPlan.create({
data: {
userId,
name: parsed.data.name,
totalCents: totalBig,
fundedCents: fundedBig,
priority: parsed.data.priority,
dueOn: new Date(parsed.data.dueOn),
cycleStart: new Date(parsed.data.cycleStart ?? parsed.data.dueOn),
fundingMode: "auto-on-deposit",
},
});
return { ok: true };
});
app.patch("/fixed-plans/:id", async (req, reply) => {
const patch = PlanBody.partial().safeParse(req.body);
if (!patch.success) return reply.code(400).send({ message: "Invalid payload" });
const id = String((req.params as any).id);
const userId = req.userId;
const plan = await app.prisma.fixedPlan.findFirst({ where: { id, userId } });
if (!plan) return reply.code(404).send({ message: "Not found" });
const total = "totalCents" in patch.data ? toBig(patch.data.totalCents as number) : (plan.totalCents ?? 0n);
const funded = "fundedCents" in patch.data ? toBig(patch.data.fundedCents as number) : (plan.fundedCents ?? 0n);
if (funded > total) return reply.code(400).send({ message: "fundedCents cannot exceed totalCents" });
await app.prisma.fixedPlan.update({
where: { id },
data: {
...patch.data,
...(patch.data.totalCents !== undefined ? { totalCents: total } : {}),
...(patch.data.fundedCents !== undefined ? { fundedCents: funded } : {}),
...(patch.data.dueOn ? { dueOn: new Date(patch.data.dueOn) } : {}),
...(patch.data.cycleStart ? { cycleStart: new Date(patch.data.cycleStart) } : {}),
},
});
return { ok: true };
});
app.delete("/fixed-plans/:id", async (req, reply) => {
const id = String((req.params as any).id);
const userId = req.userId;
const plan = await app.prisma.fixedPlan.findFirst({ where: { id, userId } });
if (!plan) return reply.code(404).send({ message: "Not found" });
await app.prisma.fixedPlan.delete({ where: { id } });
return { ok: true };
});
// ───────────── Income Preview (server-side; mirrors FE preview) ─────────────
app.post("/income/preview", async (req, reply) => {
const Body = z.object({ amountCents: z.number().int().nonnegative() });
const parsed = Body.safeParse(req.body);
if (!parsed.success) return reply.code(400).send({ message: "Invalid amount" });
const userId = req.userId;
let remaining = Math.max(0, parsed.data.amountCents | 0);
const [plans, cats] = await Promise.all([
app.prisma.fixedPlan.findMany({
where: { userId }, orderBy: [{ priority: "asc" }, { dueOn: "asc" }],
select: { id: true, name: true, totalCents: true, fundedCents: true, priority: true, dueOn: true },
}),
app.prisma.variableCategory.findMany({
where: { userId }, orderBy: [{ priority: "asc" }, { name: "asc" }],
select: { id: true, name: true, percent: true, isSavings: true, priority: true },
}),
]);
// Fixed pass
const fixed: Array<{ id: string; name: string; amountCents: number }> = [];
for (const p of plans) {
if (remaining <= 0) break;
const needBig = (p.totalCents ?? 0n) - (p.fundedCents ?? 0n);
const need = Number(needBig > 0n ? needBig : 0n);
if (need <= 0) continue;
const give = Math.min(need, remaining);
fixed.push({ id: p.id, name: p.name, amountCents: give });
remaining -= give;
}
// Variable pass (largest remainder w/ savings-first tie)
const variable: Array<{ id: string; name: string; amountCents: number }> = [];
if (remaining > 0 && cats.length > 0) {
const totalPercent = cats.reduce((s, c) => s + (c.percent || 0), 0);
const normCats = totalPercent === 100
? cats
: cats.map(c => ({ ...c, percent: totalPercent > 0 ? (c.percent * 100) / totalPercent : 0 }));
const base: number[] = new Array(normCats.length).fill(0);
const tie: { idx: number; remainder: number; isSavings: boolean; priority: number; name: string }[] = [];
let sumBase = 0;
normCats.forEach((c, idx) => {
const exact = (remaining * c.percent) / 100;
const floor = Math.floor(exact);
base[idx] = floor; sumBase += floor;
tie.push({ idx, remainder: exact - floor, isSavings: !!c.isSavings, priority: c.priority, name: c.name });
});
let leftovers = remaining - sumBase;
tie.sort((a, b) => {
if (a.isSavings !== b.isSavings) return a.isSavings ? -1 : 1;
if (a.remainder !== b.remainder) return b.remainder - a.remainder;
if (a.priority !== b.priority) return a.priority - b.priority;
return a.name.localeCompare(b.name);
});
for (let i = 0; i < tie.length && leftovers > 0; i++, leftovers--) base[tie[i].idx] += 1;
normCats.forEach((c, idx) => {
const give = base[idx] || 0;
if (give > 0) variable.push({ id: c.id, name: c.name, amountCents: give });
});
remaining = leftovers;
}
return { fixed, variable, unallocatedCents: Math.max(0, remaining) };
});
// ───────────── Start ─────────────
const PORT = env.PORT;
const HOST = process.env.HOST || "0.0.0.0";
export default app; // <-- add this
if (process.env.NODE_ENV !== "test") {
app.listen({ port: PORT, host: HOST }).catch((err) => {
app.log.error(err);
process.exit(1);
});
}

8
api/src/types/fastify-prisma.d.ts vendored Normal file
View File

@@ -0,0 +1,8 @@
import "fastify";
import type { PrismaClient } from "@prisma/client";
declare module "fastify" {
interface FastifyInstance {
prisma: PrismaClient;
}
}

View File

@@ -0,0 +1,65 @@
// tests/allocator.test.ts
import { describe, it, expect, beforeEach, afterAll } from "vitest";
import { prisma, resetUser, ensureUser, U, cid, pid, closePrisma } from "./helpers";
import { allocateIncome } from "../src/allocator";
describe("allocator — core behaviors", () => {
beforeEach(async () => {
await resetUser(U);
await ensureUser(U);
});
afterAll(async () => {
await closePrisma();
});
it("distributes remainder to variables by largest remainder with savings-first tie", async () => {
const c1 = cid("c1");
const c2 = cid("c2"); // make this savings to test the tiebreaker
await prisma.variableCategory.createMany({
data: [
{ id: c1, userId: U, name: "Groceries", percent: 60, priority: 2, isSavings: false, balanceCents: 0n },
{ id: c2, userId: U, name: "Saver", percent: 40, priority: 1, isSavings: true, balanceCents: 0n },
],
});
const p1 = pid("rent");
await prisma.fixedPlan.create({
data: {
id: p1,
userId: U,
name: "Rent",
cycleStart: new Date().toISOString(),
dueOn: new Date(Date.now() + 7 * 864e5).toISOString(),
totalCents: 10000n,
fundedCents: 0n,
priority: 1,
fundingMode: "auto-on-deposit",
},
});
// $100 income
const result = await allocateIncome(prisma as any, U, 10000, new Date().toISOString(), "inc1");
expect(result).toBeDefined();
// rent should be funded first up to need
const fixed = result.fixedAllocations ?? [];
const variable = result.variableAllocations ?? [];
// sanity
expect(Array.isArray(fixed)).toBe(true);
expect(Array.isArray(variable)).toBe(true);
});
it("handles zeros and single bucket", async () => {
const cOnly = cid("only");
await prisma.variableCategory.create({
data: { id: cOnly, userId: U, name: "Only", percent: 100, priority: 1, isSavings: false, balanceCents: 0n },
});
const result = await allocateIncome(prisma as any, U, 0, new Date().toISOString(), "inc2");
expect(result).toBeDefined();
const variable = result.variableAllocations ?? [];
const sum = variable.reduce((s, a: any) => s + (a.amountCents ?? 0), 0);
expect(sum).toBe(0);
});
});

8
api/tests/appFactory.ts Normal file
View File

@@ -0,0 +1,8 @@
import type { FastifyInstance } from "fastify";
export default async function appFactory(): Promise<FastifyInstance> {
// env is already set in tests/setup.ts, so now we can import
const { default: app } = await import("../src/server"); // ESM + TLA safe
await app.ready(); // ensure all plugins registered
return app;
}

42
api/tests/helpers.ts Normal file
View File

@@ -0,0 +1,42 @@
// tests/helpers.ts
import { PrismaClient } from "@prisma/client";
export const prisma = new PrismaClient();
// Handy test user id
export const U = "demo-user-1";
// Monotonic id helpers so we never collide with existing rows
let cseq = 0;
let pseq = 0;
export const cid = (base = "c") => `${base}_${Date.now()}_${cseq++}`;
export const pid = (base = "p") => `${base}_${Date.now()}_${pseq++}`;
/**
* Hard-reset all data for a given user in dependency-safe order.
* Also deletes the user row so tests can re-create/upsert cleanly.
*/
export async function resetUser(userId: string) {
await prisma.$transaction([
prisma.allocation.deleteMany({ where: { userId } }),
prisma.transaction.deleteMany({ where: { userId } }),
prisma.incomeEvent.deleteMany({ where: { userId } }),
prisma.fixedPlan.deleteMany({ where: { userId } }),
prisma.variableCategory.deleteMany({ where: { userId } }),
]);
await prisma.user.deleteMany({ where: { id: userId } });
}
/** Ensure the user exists (id stable) */
export async function ensureUser(userId: string) {
await prisma.user.upsert({
where: { id: userId },
update: {},
create: { id: userId, email: `${userId}@demo.local` },
});
}
/** Close Prisma after all tests */
export async function closePrisma() {
await prisma.$disconnect();
}

View File

@@ -0,0 +1,71 @@
import { beforeAll, afterAll, describe, it, expect } from "vitest";
import request from "supertest";
import { PrismaClient } from "@prisma/client";
import type { FastifyInstance } from "fastify";
import { resetUser } from "./helpers";
// Ensure env BEFORE importing the server
process.env.NODE_ENV = process.env.NODE_ENV || "test";
process.env.PORT = process.env.PORT || "0";
process.env.DATABASE_URL =
process.env.DATABASE_URL || "postgres://app:app@localhost:5432/skymoney";
const prisma = new PrismaClient();
let app: FastifyInstance;
beforeAll(async () => {
// dynamic import AFTER env is set
const { default: srv } = await import("../src/server"); // <-- needs `export default app` in server.ts
app = srv;
await app.ready();
const U = "demo-user-1";
await resetUser(U);
await prisma.user.upsert({
where: { id: U },
update: {},
create: { id: U, email: `${U}@demo.local` },
});
await prisma.variableCategory.createMany({
data: [
{ id: "c1", userId: U, name: "Groceries", percent: 60, priority: 1, isSavings: false, balanceCents: 0n },
{ id: "c2", userId: U, name: "Saver", percent: 40, priority: 2, isSavings: true, balanceCents: 0n },
],
skipDuplicates: true,
});
await prisma.fixedPlan.create({
data: {
id: "p1",
userId: U,
name: "Rent",
cycleStart: new Date().toISOString(),
dueOn: new Date(Date.now() + 7 * 864e5).toISOString(),
totalCents: 10000n,
fundedCents: 0n,
priority: 1,
fundingMode: "auto-on-deposit",
},
});
});
afterAll(async () => {
await app.close();
await prisma.$disconnect();
});
describe("POST /income integration", () => {
it("allocates funds and returns audit", async () => {
const res = await request(app.server)
.post("/income")
.set("x-user-id", "demo-user-1")
.send({ amountCents: 5000 });
expect(res.status).toBe(200);
expect(res.body).toHaveProperty("fixedAllocations");
expect(res.body).toHaveProperty("variableAllocations");
expect(typeof res.body.remainingUnallocatedCents).toBe("number");
});
});

61
api/tests/income.test.ts Normal file
View File

@@ -0,0 +1,61 @@
// tests/income.test.ts
import request from "supertest";
import { describe, it, expect, beforeEach, afterAll, beforeAll } from "vitest";
import appFactory from "./appFactory";
import { prisma, resetUser, ensureUser, U, cid, pid, closePrisma } from "./helpers";
import type { FastifyInstance } from "fastify";
let app: FastifyInstance;
beforeAll(async () => {
app = await appFactory(); // <-- await the app
});
afterAll(async () => {
await app.close(); // <-- close server
await closePrisma();
});
describe("POST /income", () => {
beforeEach(async () => {
await resetUser(U);
await ensureUser(U);
await prisma.variableCategory.createMany({
data: [
{ id: cid("c1"), userId: U, name: "Groceries", percent: 60, priority: 2, isSavings: false, balanceCents: 0n },
{ id: cid("c2"), userId: U, name: "Saver", percent: 40, priority: 1, isSavings: true, balanceCents: 0n },
],
});
await prisma.fixedPlan.create({
data: {
id: pid("rent"),
userId: U,
name: "Rent",
cycleStart: new Date().toISOString(),
dueOn: new Date(Date.now() + 7 * 864e5).toISOString(),
totalCents: 10000n,
fundedCents: 0n,
priority: 1,
fundingMode: "auto-on-deposit",
},
});
});
afterAll(async () => {
await closePrisma();
});
it("allocates fixed first then variables; updates balances; returns audit", async () => {
const res = await request(app.server)
.post("/income")
.set("x-user-id", U)
.send({ amountCents: 15000 });
expect(res.statusCode).toBe(200);
expect(res.body).toHaveProperty("fixedAllocations");
expect(res.body).toHaveProperty("variableAllocations");
expect(typeof res.body.remainingUnallocatedCents).toBe("number");
});
});

32
api/tests/setup.ts Normal file
View File

@@ -0,0 +1,32 @@
import { execSync } from "node:child_process";
import { beforeAll, afterAll } from "vitest";
import { PrismaClient } from "@prisma/client";
process.env.NODE_ENV = process.env.NODE_ENV || "test";
process.env.DATABASE_URL =
process.env.DATABASE_URL ||
"postgres://app:app@localhost:5432/skymoney";
process.env.PORT = process.env.PORT || "0"; // fastify can bind an ephemeral port
process.env.HOST ??= "127.0.0.1";
process.env.CORS_ORIGIN = process.env.CORS_ORIGIN || "";
export const prisma = new PrismaClient();
// hard reset for a single user
export async function resetUser(userId: string) {
await prisma.allocation.deleteMany({ where: { userId } });
await prisma.transaction.deleteMany({ where: { userId } });
await prisma.incomeEvent.deleteMany({ where: { userId } });
await prisma.fixedPlan.deleteMany({ where: { userId } });
await prisma.variableCategory.deleteMany({ where: { userId } });
await prisma.user.deleteMany({ where: { id: userId } });
}
beforeAll(async () => {
// make sure the schema is applied before running tests
execSync("npx prisma migrate deploy", { stdio: "inherit" });
});
afterAll(async () => {
await prisma.$disconnect();
});

View File

@@ -0,0 +1,66 @@
import request from "supertest";
import { describe, it, expect, beforeAll, beforeEach, afterAll } from "vitest";
import appFactory from "./appFactory";
import { prisma, resetUser, ensureUser, U, cid, closePrisma } from "./helpers";
import type { FastifyInstance } from "fastify";
let app: FastifyInstance;
beforeAll(async () => {
app = await appFactory();
});
afterAll(async () => {
await app.close();
await closePrisma();
});
describe("GET /transactions", () => {
beforeEach(async () => {
await resetUser(U);
await ensureUser(U);
const c = cid("c");
await prisma.variableCategory.create({
data: { id: c, userId: U, name: "Groceries", percent: 100, priority: 1, isSavings: false, balanceCents: 5000n },
});
// seed some transactions of different kinds/dates
await prisma.transaction.createMany({
data: [
{
id: `t_${Date.now()}_1`,
userId: U,
occurredAt: new Date("2025-01-03T12:00:00.000Z"),
kind: "variable_spend",
categoryId: c,
amountCents: 1000n,
},
{
id: `t_${Date.now()}_2`,
userId: U,
occurredAt: new Date("2025-01-10T12:00:00.000Z"),
kind: "fixed_payment",
planId: null,
amountCents: 2000n,
},
],
});
});
afterAll(async () => {
await closePrisma();
});
it("paginates + filters by kind/date", async () => {
const res = await request(app.server)
.get("/transactions?from=2025-01-02&to=2025-01-06&kind=variable_spend&page=1&limit=10")
.set("x-user-id", U);
expect(res.statusCode).toBe(200);
const body = res.body;
expect(Array.isArray(body.items)).toBe(true);
expect(body.items.length).toBe(1);
expect(body.items[0].kind).toBe("variable_spend");
});
});

View File

@@ -0,0 +1,56 @@
// tests/variable-categories.guard.test.ts
import request from "supertest";
import { describe, it, expect, beforeEach, beforeAll, afterAll } from "vitest";
import appFactory from "./appFactory";
import { prisma, resetUser, ensureUser, U, cid, closePrisma } from "./helpers";
import type { FastifyInstance } from "fastify";
let app: FastifyInstance;
beforeAll(async () => {
app = await appFactory();
});
afterAll(async () => {
await app.close();
await closePrisma();
});
describe("Variable Categories guard (sum=100)", () => {
beforeEach(async () => {
await resetUser(U);
await ensureUser(U);
await prisma.variableCategory.createMany({
data: [
{ id: cid("a"), userId: U, name: "A", percent: 50, priority: 1, isSavings: false, balanceCents: 0n },
{ id: cid("b"), userId: U, name: "B", percent: 50, priority: 2, isSavings: false, balanceCents: 0n },
],
});
});
afterAll(async () => {
await closePrisma();
});
it("rejects create that would push sum away from 100", async () => {
const res = await request(app.server)
.post("/variable-categories")
.set("x-user-id", U)
.send({ name: "Oops", percent: 10, isSavings: false, priority: 99 });
expect(res.statusCode).toBe(400);
expect(res.body?.message).toMatch(/Percents must sum to 100/i);
});
it("rejects update that breaks the sum", async () => {
const existing = await prisma.variableCategory.findFirst({ where: { userId: U } });
const res = await request(app.server)
.patch(`/variable-categories/${existing!.id}`)
.set("x-user-id", U)
.send({ percent: 90 });
expect(res.statusCode).toBe(400);
expect(res.body?.message).toMatch(/Percents must sum to 100/i);
});
});

View File

@@ -1,8 +1,8 @@
{ {
"compilerOptions": { "compilerOptions": {
"target": "ES2022", "target": "ES2022",
"module": "ES2022", "module": "NodeNext",
"moduleResolution": "bundler", "moduleResolution": "NodeNext",
"rootDir": "src", "rootDir": "src",
"outDir": "dist", "outDir": "dist",
"strict": true, "strict": true,
@@ -10,6 +10,6 @@
"skipLibCheck": true, "skipLibCheck": true,
"types": ["node"] "types": ["node"]
}, },
"include": ["src"], "include": ["src", "env.ts"],
"exclude": ["node_modules", "dist", "prisma.config.ts"] "exclude": ["node_modules", "dist", "prisma.config.ts"]
} }

14
api/vitest.config.ts Normal file
View File

@@ -0,0 +1,14 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
environment: "node",
include: ["tests/**/*.test.ts"],
// run single-threaded to keep DB tests deterministic
pool: "threads",
poolOptions: { threads: { singleThread: true } },
testTimeout: 30_000,
env: { NODE_ENV: "test" },
setupFiles: ['tests/setup.ts'],
},
});

View File

@@ -1,11 +1,5 @@
# docker-compose.yml (root)
version: "3.9" version: "3.9"
x-env: &api_env
NODE_ENV: production
PORT: "8080"
DATABASE_URL: postgres://app:app@postgres:5432/skymoney
services: services:
postgres: postgres:
image: postgres:15 image: postgres:15
@@ -15,55 +9,53 @@ services:
POSTGRES_PASSWORD: app POSTGRES_PASSWORD: app
volumes: volumes:
- pgdata:/var/lib/postgresql/data - pgdata:/var/lib/postgresql/data
ports:
- "5432:5432"
restart: unless-stopped restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "pg_isready -U app -d skymoney"]
interval: 5s
timeout: 3s
retries: 10
api: api:
build: build:
context: ./api context: ./api
dockerfile: Dockerfile dockerfile: Dockerfile
environment: environment:
<<: *api_env NODE_ENV: production
PORT: "8080"
DATABASE_URL: postgres://app:app@postgres:5432/skymoney
CORS_ORIGIN: http://localhost:5173
depends_on: depends_on:
- postgres - postgres
expose: ports:
- "8080" - "8081:8080"
restart: unless-stopped restart: unless-stopped
healthcheck:
# OPTIONAL: build web and serve with Caddy # runs *inside* the container; port 8080 is the app's internal port
web: test: ["CMD-SHELL", "wget -qO- http://localhost:8080/health || exit 1"]
# If you want Compose to build your Vite app automatically: interval: 5s
image: node:20-alpine timeout: 3s
working_dir: /app retries: 10
volumes: start_period: 10s
- ./web:/app
- webdist:/build-out
command: >
sh -c "npm ci && npm run build && rm -rf /build-out/* && cp -r dist/* /build-out/"
environment:
# If your web build needs an API base url:
- VITE_API_BASE=/api
depends_on:
- api
# This runs once at up; to rebuild, `docker compose up -d --build web`
restart: "no"
caddy: caddy:
image: caddy:2 image: caddy:2
ports: ports:
- "80:80" - "8080:80"
- "443:443"
volumes: volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro - ./Caddyfile.dev:/etc/caddy/Caddyfile:ro
- caddydata:/data - ./web/dist:/srv/site:ro
- caddyconfig:/config
# Serve built web files (if using web service above)
- webdist:/srv/site
depends_on: depends_on:
- api - api
restart: unless-stopped restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost/ || exit 1"]
interval: 10s
timeout: 3s
retries: 10
volumes: volumes:
pgdata: pgdata:
caddydata:
caddyconfig:
webdist:

6
package-lock.json generated Normal file
View File

@@ -0,0 +1,6 @@
{
"name": "SkyMoney",
"lockfileVersion": 3,
"requires": true,
"packages": {}
}

2
web/.env.development Normal file
View File

@@ -0,0 +1,2 @@
VITE_API_URL=http://localhost:8080/api
VITE_APP_NAME=SkyMoney

24
web/.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

73
web/README.md Normal file
View File

@@ -0,0 +1,73 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Remove tseslint.configs.recommended and replace with this
tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
tseslint.configs.stylisticTypeChecked,
// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```

23
web/eslint.config.js Normal file
View File

@@ -0,0 +1,23 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
},
])

13
web/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>web</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

5859
web/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

40
web/package.json Normal file
View File

@@ -0,0 +1,40 @@
{
"name": "web",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview",
"test": "vitest --run",
"test:watch": "vitest"
},
"dependencies": {
"@tailwindcss/vite": "^4.1.17",
"@tanstack/react-query": "^5.90.9",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-router-dom": "^7.9.6",
"recharts": "^3.4.1",
"tailwindcss": "^4.1.17",
"zod": "^4.1.12"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@tanstack/react-query-devtools": "^5.90.2",
"@types/node": "^20.12.12",
"@types/react": "^19.2.2",
"@types/react-dom": "^19.2.2",
"@vitejs/plugin-react": "^5.1.0",
"eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"typescript": "~5.9.3",
"typescript-eslint": "^8.46.3",
"vite": "^7.2.2",
"vitest": "^2.1.3"
}
}

42
web/src/App.css Normal file
View File

@@ -0,0 +1,42 @@
#root {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
}
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
transition: filter 300ms;
}
.logo:hover {
filter: drop-shadow(0 0 2em #646cffaa);
}
.logo.react:hover {
filter: drop-shadow(0 0 2em #61dafbaa);
}
@keyframes logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: no-preference) {
a:nth-of-type(2) .logo {
animation: logo-spin infinite 20s linear;
}
}
.card {
padding: 2em;
}
.read-the-docs {
color: #888;
}

63
web/src/App.tsx Normal file
View File

@@ -0,0 +1,63 @@
import { Link, NavLink, Route, Routes } from "react-router-dom";
import DashboardPage from "./pages/DashboardPage";
import IncomePage from "./pages/IncomePage";
import TransactionsPage from "./pages/TransactionsPage";
export default function App() {
return (
<div className="min-h-screen bg-[--color-bg] text-[--color-fg]">
<TopNav />
<main className="container">
<Routes>
<Route path="/" element={<DashboardPage />} />
<Route path="/income" element={<IncomePage />} />
<Route path="/transactions" element={<TransactionsPage />} />
<Route path="*" element={<NotFound />} />
</Routes>
</main>
<footer className="container py-8 text-center text-sm muted">
SkyMoney {new Date().getFullYear()}
</footer>
</div>
);
}
function TopNav() {
return (
<header className="border-b border-[--color-ink] bg-[--color-panel]">
<div className="container h-14 flex items-center gap-4">
<Link to="/" className="font-bold">
SkyMoney
</Link>
<Nav to="/">Dashboard</Nav>
<Nav to="/income">Income</Nav>
<Nav to="/transactions">Transactions</Nav>
<div className="ml-auto text-xs muted">demo user</div>
</div>
</header>
);
}
function Nav({ to, children }: { to: string; children: React.ReactNode }) {
return (
<NavLink
to={to}
className={({ isActive }) =>
"px-3 py-1 rounded-xl hover:bg-[--color-ink]/60 " +
(isActive ? "bg-[--color-ink]" : "")
}
end
>
{children}
</NavLink>
);
}
function NotFound() {
return (
<div className="p-6">
<h1 className="text-xl font-bold mb-2">404</h1>
<p className="muted">This page got lost in the budget cuts.</p>
</div>
);
}

30
web/src/api/categories.ts Normal file
View File

@@ -0,0 +1,30 @@
import { request } from "./client";
export type NewCategory = {
name: string;
percent: number; // 0..100
isSavings: boolean;
priority: number;
};
export type UpdateCategory = Partial<NewCategory>;
export const categoriesApi = {
create: (body: NewCategory) =>
request<{ id: number }>("/variable-categories", {
method: "POST",
body: JSON.stringify(body),
headers: { "Content-Type": "application/json" }
}),
update: (id: number, body: UpdateCategory) =>
request(`/variable-categories/${id}`, {
method: "PATCH",
body: JSON.stringify(body),
headers: { "Content-Type": "application/json" }
}),
delete: (id: number) =>
request(`/variable-categories/${id}`, {
method: "DELETE"
})
};

30
web/src/api/client.ts Normal file
View File

@@ -0,0 +1,30 @@
export type ApiError = { status: number; message: string };
const base = "/api";
const KEY = "skymoney:userId";
export function getUserId(): string {
let id = localStorage.getItem(KEY);
if (!id) { id = "1"; localStorage.setItem(KEY, id); }
return id;
}
export function setUserId(id: string) {
localStorage.setItem(KEY, String(id || "1"));
}
export async function request<T>(path: string, init?: RequestInit): Promise<T> {
const res = await fetch(`${base}${path}`, {
headers: { "Content-Type": "application/json", "x-user-id": getUserId(), ...(init?.headers || {}) },
...init
});
const text = await res.text();
const data = text ? JSON.parse(text) : null;
if (!res.ok) throw { status: res.status, message: data?.message || res.statusText } as ApiError;
return data as T;
}
export const api = {
get: <T,>(path: string) => request<T>(path),
post: <T,>(path: string, body: unknown) =>
request<T>(path, { method: "POST", body: JSON.stringify(body) }),
};

27
web/src/api/fixedPlans.ts Normal file
View File

@@ -0,0 +1,27 @@
import { request } from "./client";
export type NewPlan = {
name: string;
totalCents: number; // >= 0
fundedCents?: number; // optional, default 0
priority: number; // int
dueOn: string; // ISO date
};
export type UpdatePlan = Partial<NewPlan>;
export const fixedPlansApi = {
create: (body: NewPlan) =>
request<{ id: number }>("/fixed-plans", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
}),
update: (id: number, body: UpdatePlan) =>
request(`/fixed-plans/${id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
}),
delete: (id: number) =>
request(`/fixed-plans/${id}`, { method: "DELETE" }),
};

65
web/src/api/http.ts Normal file
View File

@@ -0,0 +1,65 @@
// Lightweight fetch wrappers with sensible defaults
const BASE =
(typeof import.meta !== "undefined" && import.meta.env && import.meta.env.VITE_API_URL) ||
""; // e.g. "http://localhost:8080" or proxy
type FetchOpts = {
method?: "GET" | "POST" | "PATCH" | "DELETE";
headers?: Record<string, string>;
body?: any;
query?: Record<string, string | number | boolean | undefined>;
};
function toQS(q?: FetchOpts["query"]) {
if (!q) return "";
const sp = new URLSearchParams();
for (const [k, v] of Object.entries(q)) {
if (v === undefined || v === null || v === "") continue;
sp.set(k, String(v));
}
const s = sp.toString();
return s ? `?${s}` : "";
}
async function http<T>(path: string, opts: FetchOpts = {}): Promise<T> {
const { method = "GET", headers = {}, body, query } = opts;
const url = `${BASE}${path}${toQS(query)}`;
const res = await fetch(url, {
method,
headers: {
"Content-Type": "application/json",
// The API defaults x-user-id if missing; add your own if you want:
// "x-user-id": localStorage.getItem("userId") ?? "demo-user-1",
...headers,
},
body: body !== undefined ? JSON.stringify(body) : undefined,
});
// Try to parse JSON either way
const text = await res.text();
const json = text ? JSON.parse(text) : null;
if (!res.ok) {
const msg =
(json && (json.message || json.error)) ||
`${res.status} ${res.statusText}` ||
"Request failed";
throw new Error(msg);
}
return json as T;
}
export async function apiGet<T>(path: string, query?: FetchOpts["query"]) {
return http<T>(path, { method: "GET", query });
}
export async function apiPost<T>(path: string, body?: any) {
return http<T>(path, { method: "POST", body });
}
export async function apiPatch<T>(path: string, body?: any) {
return http<T>(path, { method: "PATCH", body });
}
export async function apiDelete<T>(path: string) {
return http<T>(path, { method: "DELETE" });
}

67
web/src/api/schemas.ts Normal file
View File

@@ -0,0 +1,67 @@
import { z } from "zod";
const money = z.number().int().nonnegative();
export const VariableCategory = z.object({
id: z.number().int(),
name: z.string(),
percent: z.number().int(),
isSavings: z.boolean(),
priority: z.number().int(),
balanceCents: money
}).passthrough();
export const FixedPlan = z.object({
id: z.number().int(),
name: z.string(),
totalCents: money,
fundedCents: money,
priority: z.number().int(),
dueOn: z.string() // ISO
}).passthrough();
export const Transaction = z.object({
id: z.number().int(),
kind: z.enum(["variable_spend", "fixed_payment"]).or(z.string()),
amountCents: money,
occurredAt: z.string()
}).passthrough();
export const Dashboard = z.object({
totals: z.object({
incomeCents: money,
variableBalanceCents: money,
fixedRemainingCents: money
}).passthrough(),
variableCategories: z.array(VariableCategory),
fixedPlans: z.array(FixedPlan),
recentTransactions: z.array(Transaction)
}).passthrough();
export const IncomeResult = z.object({
incomeEventId: z.number().int(),
fixedAllocations: z.array(z.object({
id: z.number().int(),
amountCents: money,
fixedPlanId: z.number().int().nullable()
}).passthrough()),
variableAllocations: z.array(z.object({
id: z.number().int(),
amountCents: money,
variableCategoryId: z.number().int().nullable()
}).passthrough()),
remainingUnallocatedCents: money
}).passthrough();
export const TransactionsList = z.object({
items: z.array(z.object({
id: z.number().int(),
kind: z.enum(["variable_spend", "fixed_payment"]).or(z.string()),
amountCents: z.number().int().nonnegative(),
occurredAt: z.string()
}).passthrough()),
page: z.number().int().nonnegative(),
limit: z.number().int().positive(),
total: z.number().int().nonnegative()
}).passthrough();
export type TransactionsListT = z.infer<typeof TransactionsList>;

View File

@@ -0,0 +1,20 @@
import { request } from "./client";
import { TransactionsList, type TransactionsListT } from "./schemas";
export type TxQuery = {
from?: string; // YYYY-MM-DD
to?: string; // YYYY-MM-DD
kind?: "variable_spend" | "fixed_payment";
q?: string;
page?: number; // 1-based
limit?: number; // default 20
};
export async function listTransactions(params: TxQuery): Promise<TransactionsListT> {
const u = new URL("/api/transactions", location.origin);
for (const [k, v] of Object.entries(params)) {
if (v !== undefined && v !== "") u.searchParams.set(k, String(v));
}
const data = await request<unknown>(u.pathname + "?" + u.searchParams.toString());
return TransactionsList.parse(data);
}

View File

@@ -0,0 +1,31 @@
import React from "react";
export default function CurrencyInput({
value,
onValue,
placeholder = "0.00",
}: {
value: string;
onValue: (v: string) => void;
placeholder?: string;
}) {
function onChange(e: React.ChangeEvent<HTMLInputElement>) {
const raw = e.target.value.replace(/[^0-9.]/g, "");
// Keep only first dot, max 2 decimals
const parts = raw.split(".");
const cleaned =
parts.length === 1
? parts[0]
: `${parts[0]}.${parts.slice(1).join("").slice(0, 2)}`;
onValue(cleaned);
}
return (
<input
className="input"
inputMode="decimal"
placeholder={placeholder}
value={value}
onChange={onChange}
/>
);
}

View File

@@ -0,0 +1,31 @@
import React from "react";
export default function Pagination({
page,
limit,
total,
onPage,
}: {
page: number;
limit: number;
total: number;
onPage: (p: number) => void;
}) {
const pages = Math.max(1, Math.ceil(total / Math.max(1, limit)));
const prev = () => onPage(Math.max(1, page - 1));
const next = () => onPage(Math.min(pages, page + 1));
return (
<div className="row items-center mt-3">
<button className="btn" onClick={prev} disabled={page <= 1}>
Prev
</button>
<div className="mx-3 text-sm">
Page <strong>{page}</strong> of <strong>{pages}</strong> {total} total
</div>
<button className="btn" onClick={next} disabled={page >= pages}>
Next
</button>
</div>
);
}

View File

@@ -0,0 +1,17 @@
import React from "react";
import { useDashboard } from "../hooks/useDashboard";
export default function PercentGuard() {
const dash = useDashboard();
const total = dash.data?.percentTotal ?? 0;
if (dash.isLoading) return null;
if (total === 100) return null;
return (
<div className="toast-err">
Variable category percents must sum to <strong>100%</strong> (currently {total}%).
Adjust them before recording income.
</div>
);
}

View File

@@ -0,0 +1,19 @@
export function Skeleton({ className = "" }: { className?: string }) {
return <div className={`animate-pulse rounded-[--radius-xl] bg-[color-mix(in_oklab,var(--color-ink) 70%,transparent)] ${className}`} />;
}
export function KPISkeleton() {
return <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<Skeleton className="h-24" />
<Skeleton className="h-24" />
<Skeleton className="h-24" />
</div>;
}
export function ChartSkeleton({ tall=false }: { tall?: boolean }) {
return <Skeleton className={tall ? "h-80" : "h-64"} />;
}
export function TableSkeleton() {
return <div className="card">
<div className="section-title">Loading</div>
<Skeleton className="h-40" />
</div>;
}

View File

@@ -0,0 +1,39 @@
import React, { createContext, useContext, useState, useCallback, type PropsWithChildren } from "react";
type Toast = { id: string; kind: "ok" | "err"; message: string };
type Ctx = { push: (kind: Toast["kind"], message: string) => void };
const ToastCtx = createContext<Ctx>({ push: () => {} });
export function ToastProvider({ children }: PropsWithChildren<{}>) {
const [toasts, setToasts] = useState<Toast[]>([]);
const push = useCallback((kind: Toast["kind"], message: string) => {
const id = Math.random().toString(36).slice(2);
setToasts((t) => [...t, { id, kind, message }]);
setTimeout(() => setToasts((t) => t.filter((x) => x.id !== id)), 3500);
}, []);
return (
<ToastCtx.Provider value={{ push }}>
{children}
<div className="fixed bottom-4 left-1/2 -translate-x-1/2 space-y-2 z-50">
{toasts.map((t) => (
<div
key={t.id}
className={
"px-4 py-2 rounded-xl shadow " +
(t.kind === "ok" ? "bg-green-600 text-white" : "bg-red-600 text-white")
}
>
{t.message}
</div>
))}
</div>
</ToastCtx.Provider>
);
}
export function useToast() {
return useContext(ToastCtx);
}

View File

@@ -0,0 +1,25 @@
import { setUserId, getUserId } from "../api/client";
import { useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
export default function UserSwitcher() {
const qc = useQueryClient();
const [val, setVal] = useState(getUserId());
const apply = () => {
setUserId(val);
qc.invalidateQueries(); // why: reload all data for new tenant
};
return (
<div className="row">
<input
className="input w-20"
type="number"
min={1}
value={val}
onChange={(e) => setVal(e.target.value)}
title="Dev User Id"
/>
<button className="btn" type="button" onClick={apply}>Use</button>
</div>
);
}

View File

@@ -0,0 +1,32 @@
import { ResponsiveContainer, BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend } from "recharts";
export type FixedItem = { name: string; funded: number; remaining: number };
export default function FixedFundingBars({ data }: { data: FixedItem[] }) {
if (!data.length) {
return (
<div className="card">
<h3 className="text-sm mb-2" style={{ color: "var(--color-sage)" }}>Fixed Plan Funding</h3>
<div className="muted text-sm">No fixed plans yet.</div>
</div>
);
}
return (
<div className="card">
<h3 className="text-sm mb-2" style={{ color: "var(--color-sage)" }}>Fixed Plan Funding</h3>
<div className="chart-lg">
<ResponsiveContainer>
<BarChart data={data} stackOffset="expand">
<CartesianGrid strokeDasharray="3 3" stroke="#1f2937" />
<XAxis dataKey="name" stroke="#94a3b8" />
<YAxis tickFormatter={(v) => `${Math.round(Number(v) * 100)}%`} stroke="#94a3b8" />
<Tooltip formatter={(v: number) => `${Math.round(Number(v) * 100)}%`} contentStyle={{ background: "#111827", border: "1px solid #111827", borderRadius: 12, color: "#E7E3D7" }} />
<Legend />
<Bar dataKey="funded" stackId="a" fill="#165F46" name="Funded" />
<Bar dataKey="remaining" stackId="a" fill="#374151" name="Remaining" />
</BarChart>
</ResponsiveContainer>
</div>
</div>
);
}

View File

@@ -0,0 +1,35 @@
// web/src/components/charts/VariableAllocationDonut.tsx
import { PieChart, Pie, Cell, ResponsiveContainer, Tooltip, Legend } from "recharts";
export type VariableSlice = { name: string; value: number; isSavings: boolean };
export default function VariableAllocationDonut({ data }: { data: VariableSlice[] }) {
const total = data.reduce((s, d) => s + d.value, 0);
if (!data.length || total === 0) {
return (
<div className="card">
<h3 className="text-sm mb-2" style={{ color: "var(--color-sage)" }}>Variable Allocation</h3>
<div className="muted text-sm">No categories configured yet.</div>
</div>
);
}
const fillFor = (s: boolean) => (s ? "#165F46" : "#374151");
return (
<div className="card">
<h3 className="text-sm mb-2" style={{ color: "var(--color-sage)" }}>Variable Allocation</h3>
<div className="chart-md">
<ResponsiveContainer>
<PieChart>
<Pie data={data} dataKey="value" nameKey="name" innerRadius={70} outerRadius={100} stroke="#0B1020" strokeWidth={2}>
{data.map((d, i) => <Cell key={i} fill={fillFor(d.isSavings)} />)}
</Pie>
<Tooltip formatter={(v: number) => `${v}%`} contentStyle={{ background: "#111827", border: "1px solid #111827", borderRadius: 12, color: "#E7E3D7" }} />
<Legend verticalAlign="bottom" height={24} />
</PieChart>
</ResponsiveContainer>
</div>
</div>
);
}

36
web/src/components/ui.tsx Normal file
View File

@@ -0,0 +1,36 @@
import React, { type PropsWithChildren } from "react";
import { fmtMoney } from "../utils/money";
export function Money({ cents }: { cents: number }) {
return <span className="font-mono">{fmtMoney(cents)}</span>;
}
export function Field({
label,
children,
}: PropsWithChildren<{ label: string }>) {
return (
<label className="stack">
<span className="text-sm muted">{label}</span>
{children}
</label>
);
}
export function Button({
children,
disabled,
onClick,
type = "submit",
}: PropsWithChildren<{ disabled?: boolean; onClick?: () => void; type?: "button" | "submit" }>) {
return (
<button
type={type}
onClick={onClick}
disabled={disabled}
className="btn"
>
{children}
</button>
);
}

View File

@@ -0,0 +1,78 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { categoriesApi, type NewCategory, type UpdateCategory } from "../api/categories";
const DASHBOARD_KEY = ["dashboard"];
export function useCategories() {
const qc = useQueryClient();
const snap = qc.getQueryData<any>(DASHBOARD_KEY);
return snap?.variableCategories ?? [];
}
export function useCreateCategory() {
const qc = useQueryClient();
return useMutation({
mutationFn: (body: NewCategory) => categoriesApi.create(body),
onMutate: async (vars) => {
await qc.cancelQueries({ queryKey: DASHBOARD_KEY });
const prev = qc.getQueryData<any>(DASHBOARD_KEY);
if (prev) {
const optimistic = {
...prev,
variableCategories: [
...prev.variableCategories,
{ id: -Math.floor(Math.random() * 1e9), balanceCents: 0, ...vars }
]
};
qc.setQueryData(DASHBOARD_KEY, optimistic);
}
return { prev };
},
onError: (_err, _vars, ctx) => ctx?.prev && qc.setQueryData(DASHBOARD_KEY, ctx.prev),
onSuccess: () => qc.invalidateQueries({ queryKey: DASHBOARD_KEY })
});
}
export function useUpdateCategory() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ id, body }: { id: number; body: UpdateCategory }) => categoriesApi.update(id, body),
onMutate: async ({ id, body }) => {
await qc.cancelQueries({ queryKey: DASHBOARD_KEY });
const prev = qc.getQueryData<any>(DASHBOARD_KEY);
if (prev) {
const optimistic = {
...prev,
variableCategories: prev.variableCategories.map((c: any) =>
c.id === id ? { ...c, ...body } : c
)
};
qc.setQueryData(DASHBOARD_KEY, optimistic);
}
return { prev };
},
onError: (_e, _v, ctx) => ctx?.prev && qc.setQueryData(DASHBOARD_KEY, ctx.prev),
onSuccess: () => qc.invalidateQueries({ queryKey: DASHBOARD_KEY })
});
}
export function useDeleteCategory() {
const qc = useQueryClient();
return useMutation({
mutationFn: (id: number) => categoriesApi.delete(id),
onMutate: async (id) => {
await qc.cancelQueries({ queryKey: DASHBOARD_KEY });
const prev = qc.getQueryData<any>(DASHBOARD_KEY);
if (prev) {
const optimistic = {
...prev,
variableCategories: prev.variableCategories.filter((c: any) => c.id !== id)
};
qc.setQueryData(DASHBOARD_KEY, optimistic);
}
return { prev };
},
onError: (_e, _v, ctx) => ctx?.prev && qc.setQueryData(DASHBOARD_KEY, ctx.prev),
onSuccess: () => qc.invalidateQueries({ queryKey: DASHBOARD_KEY })
});
}

View File

@@ -0,0 +1,47 @@
import { useQuery } from "@tanstack/react-query";
import { apiGet } from "../api/http";
export type VariableCategory = {
id: string;
name: string;
percent: number;
priority: number;
isSavings: boolean;
balanceCents?: number;
};
export type FixedPlan = {
id: string;
name: string;
priority: number;
totalCents?: number;
fundedCents?: number;
dueOn: string;
};
export type Tx = {
id: string;
kind: "variable_spend" | "fixed_payment";
amountCents: number;
occurredAt: string;
};
export type DashboardResponse = {
totals: {
incomeCents: number;
variableBalanceCents: number;
fixedRemainingCents: number;
};
percentTotal: number;
variableCategories: VariableCategory[];
fixedPlans: FixedPlan[];
recentTransactions: Tx[];
};
export function useDashboard() {
return useQuery({
queryKey: ["dashboard"],
queryFn: () => apiGet<DashboardResponse>("/dashboard"),
staleTime: 10_000,
});
}

View File

@@ -0,0 +1,86 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { fixedPlansApi, type NewPlan, type UpdatePlan } from "../api/fixedPlans";
const DASH = ["dashboard"];
export function useCreatePlan() {
const qc = useQueryClient();
return useMutation({
mutationFn: (body: NewPlan) => fixedPlansApi.create(body),
onMutate: async (vars) => {
await qc.cancelQueries({ queryKey: DASH });
const prev = qc.getQueryData<any>(DASH);
if (prev) {
const optimistic = {
...prev,
fixedPlans: [
...prev.fixedPlans,
{
id: -Math.floor(Math.random() * 1e9),
fundedCents: Math.min(vars.fundedCents ?? 0, vars.totalCents),
...vars,
},
],
};
qc.setQueryData(DASH, optimistic);
}
return { prev };
},
onError: (_e, _v, ctx) => ctx?.prev && qc.setQueryData(DASH, ctx.prev),
onSuccess: () => qc.invalidateQueries({ queryKey: DASH }),
});
}
export function useUpdatePlan() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ id, body }: { id: number; body: UpdatePlan }) =>
fixedPlansApi.update(id, body),
onMutate: async ({ id, body }) => {
await qc.cancelQueries({ queryKey: DASH });
const prev = qc.getQueryData<any>(DASH);
if (prev) {
const optimistic = {
...prev,
fixedPlans: prev.fixedPlans.map((p: any) =>
p.id === id
? {
...p,
...body,
fundedCents: Math.min(
"fundedCents" in (body || {}) ? (body as any).fundedCents ?? p.fundedCents : p.fundedCents,
"totalCents" in (body || {}) ? (body as any).totalCents ?? p.totalCents : p.totalCents
),
}
: p
),
};
qc.setQueryData(DASH, optimistic);
}
return { prev };
},
onError: (_e, _v, ctx) => ctx?.prev && qc.setQueryData(DASH, ctx.prev),
onSuccess: () => qc.invalidateQueries({ queryKey: DASH }),
});
}
export function useDeletePlan() {
const qc = useQueryClient();
return useMutation({
mutationFn: (id: number) => fixedPlansApi.delete(id),
onMutate: async (id) => {
await qc.cancelQueries({ queryKey: DASH });
const prev = qc.getQueryData<any>(DASH);
if (prev) {
const optimistic = {
...prev,
fixedPlans: prev.fixedPlans.filter((p: any) => p.id !== id),
};
qc.setQueryData(DASH, optimistic);
}
return { prev };
},
onError: (_e, _v, ctx) => ctx?.prev && qc.setQueryData(DASH, ctx.prev),
onSuccess: () => qc.invalidateQueries({ queryKey: DASH }),
});
}

View File

@@ -0,0 +1,15 @@
import { useMutation } from "@tanstack/react-query";
import { apiPost } from "../api/http";
export type IncomeResult = {
fixedAllocations: Array<{ fixedPlanId: string; amountCents: number }>;
variableAllocations: Array<{ variableCategoryId: string; amountCents: number }>;
remainingUnallocatedCents: number;
};
export function useCreateIncome() {
return useMutation({
mutationFn: (body: { amountCents: number }) =>
apiPost<IncomeResult>("/income", body),
});
}

View File

@@ -0,0 +1,18 @@
import { useQuery } from "@tanstack/react-query";
import { apiPost } from "../api/http";
export type PreviewAlloc = { id: string; name: string; amountCents: number };
export type IncomePreview = {
fixed: PreviewAlloc[];
variable: PreviewAlloc[];
unallocatedCents: number;
};
export function useIncomePreview(amountCents: number) {
return useQuery({
queryKey: ["income-preview", amountCents],
enabled: amountCents > 0,
queryFn: () =>
apiPost<IncomePreview>("/income/preview", { amountCents }),
});
}

View File

@@ -0,0 +1,28 @@
// web/src/hooks/useTransaction.ts
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { z } from "zod";
import { api } from "../api/client";
const Tx = z.object({
kind: z.enum(["variable_spend", "fixed_payment"]),
amountCents: z.number().int().positive(),
occurredAtISO: z.string().datetime(),
variableCategoryId: z.number().int().optional(),
fixedPlanId: z.number().int().optional()
}).superRefine((v, ctx) => {
const isVar = v.kind === "variable_spend";
if (isVar && !v.variableCategoryId) ctx.addIssue({ code: "custom", message: "variableCategoryId required" });
if (!isVar && !v.fixedPlanId) ctx.addIssue({ code: "custom", message: "fixedPlanId required" });
});
export type TxInput = z.infer<typeof Tx>;
export function useCreateTransaction() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (input: TxInput) => api.post("/transactions", Tx.parse(input)),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["dashboard"] });
qc.invalidateQueries({ queryKey: ["transactions"] }); // ensure list refreshes if open
}
});
}

View File

@@ -0,0 +1,33 @@
import { useQuery } from "@tanstack/react-query";
import { apiGet } from "../api/http";
export type TxItem = {
id: string;
kind: "variable_spend" | "fixed_payment";
amountCents: number;
occurredAt: string;
};
export type TxListResponse = {
items: TxItem[];
page: number;
limit: number;
total: number;
};
export type TxQueryParams = {
page: number;
limit: number;
q?: string;
from?: string;
to?: string;
kind?: "variable_spend" | "fixed_payment";
};
export function useTransactionsQuery(params: TxQueryParams) {
return useQuery({
queryKey: ["transactions", params],
queryFn: () => apiGet<TxListResponse>("/transactions", params),
placeholderData: (previousData) => previousData,
});
}

128
web/src/index.css Normal file
View File

@@ -0,0 +1,128 @@
@import "tailwindcss";
@theme {
/* Colors */
--color-midnight: #0B1020; /* app bg */
--color-ink: #111827; /* surfaces/lines */
--color-pine: #165F46; /* primary: discipline/growth */
--color-sage: #B7CAB6; /* muted text */
--color-sand: #E7E3D7; /* body text */
--color-amber: #E0B04E; /* positive */
--color-rose: #CC4B4B; /* alerts */
--radius-lg: 0.75rem;
--radius-xl: 0.9rem;
--radius-2xl: 1.2rem;
--shadow-soft: 0 6px 24px rgba(0,0,0,.25);
--container-w: 72rem;
}
html, body, #root { height: 100%; }
body {
background: var(--color-midnight);
color: var(--color-sand);
font-family: var(--font-sans);
}
/* Accessible focus ring */
:focus-visible { outline: 2px solid color-mix(in oklab, var(--color-pine) 80%, white 20%); outline-offset: 2px; }
/* Motion sensitivity */
@media (prefers-reduced-motion: reduce) {
* { animation-duration: 0.001ms !important; animation-iteration-count: 1 !important; transition-duration: 0.001ms !important; scroll-behavior: auto !important; }
}
/* Utility tweaks */
.mono { font-family: var(--font-mono); }
.muted { color: var(--color-sage); }
/* ==== App Shell ==== */
.app { @apply min-h-full grid grid-rows-[auto,1fr]; }
/* Top nav */
.nav {
@apply sticky top-0 z-10 backdrop-blur border-b;
background: color-mix(in oklab, var(--color-ink) 85%, transparent);
border-color: var(--color-ink);
}
.container { @apply mx-auto p-4 md:p-6; max-width: var(--container-w); }
/* Links in the header */
.link { @apply px-3 py-2 rounded-[--radius-xl] transition; }
.link:hover { background: var(--color-ink); }
.link-active { background: var(--color-ink); }
/* ==== Cards / Sections ==== */
.card {
@apply p-4 border rounded-[--radius-2xl];
background: var(--color-ink);
border-color: var(--color-ink);
box-shadow: var(--shadow-soft);
}
.section-title { @apply text-lg font-semibold mb-3; }
/* KPI tile */
.kpi { @apply flex flex-col gap-1; } /* no 'card' here */
.kpi h3 { @apply text-sm; color: var(--color-sage); }
.kpi .val { @apply text-2xl font-semibold; }
/* ==== Buttons ==== */
.btn {
@apply inline-flex items-center gap-2 px-4 py-2 font-semibold border rounded-[--radius-xl] active:scale-[.99];
background: var(--color-pine);
color: var(--color-sand);
border-color: var(--color-ink);
}
.btn:disabled { @apply opacity-60 cursor-not-allowed; }
/* ==== Inputs ==== */
.input, select, textarea {
@apply w-full px-3 py-2 rounded-[--radius-xl] border outline-none;
background: var(--color-midnight);
border-color: var(--color-ink);
}
label.field { @apply grid gap-1 mb-3; }
label.field > span { @apply text-sm; color: var(--color-sage); }
/* ==== Table ==== */
.table { @apply w-full border-separate; border-spacing: 0 0.5rem; }
.table thead th { @apply text-left text-sm pb-2; color: var(--color-sage); }
.table tbody tr { background: var(--color-ink); }
.table td, .table th { @apply px-3 py-2; }
.table tbody tr > td:first-child { @apply rounded-l-[--radius-xl]; }
.table tbody tr > td:last-child { @apply rounded-r-[--radius-xl]; }
/* ==== Badges / Chips ==== */
.badge {
@apply inline-flex items-center px-2 py-0.5 text-xs rounded-full border;
background: var(--color-ink);
border-color: var(--color-ink);
}
/* ==== Simple Toasts ==== */
.toast-ok {
@apply rounded-[--radius-xl] px-3 py-2 border;
background: #0a2917; /* dark green */
color: #bbf7d0;
border-color: var(--color-pine);
}
.toast-err {
@apply rounded-[--radius-xl] px-3 py-2 border;
background: #3f1515;
color: #fecaca;
border-color: #7f1d1d;
}
/* ==== Layout helpers ==== */
.grid-auto { @apply grid gap-4 sm:grid-cols-2 lg:grid-cols-3; }
.stack { @apply flex flex-col gap-3; }
.row { @apply flex items-center gap-3; }
/* ==== Chart wrappers (consistent sizing) ==== */
.chart-md { @apply h-64; }
.chart-lg { @apply h-72; }

30
web/src/main.tsx Normal file
View File

@@ -0,0 +1,30 @@
import React from "react";
import ReactDOM from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import { ToastProvider } from "./components/Toast";
import App from "./App";
import "./styles.css";
const client = new QueryClient({
defaultOptions: {
queries: {
retry: 1,
refetchOnWindowFocus: false,
},
},
});
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<QueryClientProvider client={client}>
<BrowserRouter>
<ToastProvider>
<App />
</ToastProvider>
</BrowserRouter>
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
</React.StrictMode>
);

View File

@@ -0,0 +1,148 @@
import { useEffect, useState } from "react";
import { apiGet } from "../api/http";
import { fmtMoney } from "../utils/money";
type VariableCategory = {
id: string; name: string; percent: number; priority: number;
isSavings: boolean; balanceCents?: number;
};
type FixedPlan = {
id: string; name: string; priority: number;
totalCents?: number; fundedCents?: number; dueOn: string;
};
type Tx = { id: string; kind: "variable_spend"|"fixed_payment"; amountCents: number; occurredAt: string };
type DashboardResponse = {
totals: {
incomeCents: number;
variableBalanceCents: number;
fixedRemainingCents: number;
};
percentTotal: number;
variableCategories: VariableCategory[];
fixedPlans: FixedPlan[];
recentTransactions: Tx[];
};
export default function DashboardPage() {
const [data, setData] = useState<DashboardResponse | null>(null);
const [err, setErr] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
async function load() {
setLoading(true);
setErr(null);
try {
const d = await apiGet<DashboardResponse>("/dashboard");
setData(d);
} catch (e: any) {
setErr(e.message || "Failed to load");
} finally {
setLoading(false);
}
}
useEffect(() => { load(); }, []);
if (loading) return <div className="p-6">Loading dashboard</div>;
if (err) return (
<div className="p-6 text-red-600">
{err} <button className="ml-2 underline" onClick={load}>retry</button>
</div>
);
if (!data) return null;
return (
<div className="p-6 space-y-8">
<h1 className="text-2xl font-bold">Dashboard</h1>
<section className="grid gap-4 md:grid-cols-3">
<Card label="Total Income" value={fmtMoney(data.totals.incomeCents)} />
<Card label="Variable Balance" value={fmtMoney(data.totals.variableBalanceCents)} />
<Card label="Fixed Remaining" value={fmtMoney(data.totals.fixedRemainingCents)} />
</section>
<section className="grid gap-6 md:grid-cols-2">
<div className="space-y-2">
<h2 className="font-semibold">Variable Categories (sum {data.percentTotal}%)</h2>
<div className="border rounded-md divide-y">
{data.variableCategories.map(c => (
<div key={c.id} className="p-3 flex items-center justify-between">
<div>
<div className="font-medium">{c.name}</div>
<div className="text-sm opacity-70">
{c.isSavings ? "Savings • " : ""}Priority {c.priority}
</div>
</div>
<div className="text-right">
<div className="font-mono">{fmtMoney(c.balanceCents ?? 0)}</div>
<div className="text-sm opacity-70">{c.percent}%</div>
</div>
</div>
))}
</div>
</div>
<div className="space-y-2">
<h2 className="font-semibold">Fixed Plans</h2>
<div className="border rounded-md divide-y">
{data.fixedPlans.map(p => {
const total = p.totalCents ?? 0;
const funded = p.fundedCents ?? 0;
const remaining = Math.max(total - funded, 0);
return (
<div key={p.id} className="p-3 flex items-center justify-between">
<div>
<div className="font-medium">{p.name}</div>
<div className="text-sm opacity-70">
Due {new Date(p.dueOn).toLocaleDateString()} Priority {p.priority}
</div>
</div>
<div className="text-right">
<div className="font-mono">{fmtMoney(remaining)}</div>
<div className="text-sm opacity-70">remaining</div>
</div>
</div>
);
})}
</div>
</div>
</section>
<section className="space-y-2">
<h2 className="font-semibold">Recent Transactions</h2>
<div className="border rounded-md overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-gray-50">
<tr>
<th className="text-left p-2">Date</th>
<th className="text-left p-2">Kind</th>
<th className="text-right p-2">Amount</th>
</tr>
</thead>
<tbody>
{data.recentTransactions.map(tx => (
<tr key={tx.id} className="border-t">
<td className="p-2">{new Date(tx.occurredAt).toLocaleString()}</td>
<td className="p-2">{tx.kind.replace("_", " ")}</td>
<td className="p-2 text-right font-mono">{fmtMoney(tx.amountCents)}</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
</div>
);
}
function Card({ label, value }: { label: string; value: string }) {
return (
<div className="rounded-xl border p-4 shadow-sm">
<div className="text-sm opacity-70">{label}</div>
<div className="text-xl font-bold mt-1">{value}</div>
</div>
);
}

View File

@@ -0,0 +1,18 @@
import { useQuery } from "@tanstack/react-query";
import { api } from "../api/client";
export default function HealthPage() {
const app = useQuery({ queryKey: ["health"], queryFn: () => api.get<{ok:true}>("/health") });
const db = useQuery({ queryKey: ["health","db"], queryFn: () => api.get<{ok:true; nowISO:string; latencyMs:number}>("/health/db") });
return (
<div className="card max-w-lg">
<h2 className="section-title">Health</h2>
<ul className="stack">
<li>API: {app.isLoading ? "…" : app.data?.ok ? "OK" : "Down"}</li>
<li>DB: {db.isLoading ? "…" : db.data?.ok ? `OK (${db.data.latencyMs} ms)` : "Down"}</li>
<li>Server Time: {db.data?.nowISO ? new Date(db.data.nowISO).toLocaleString() : "…"}</li>
</ul>
</div>
);
}

View File

@@ -0,0 +1,223 @@
import { useMemo, useState, type FormEvent } from "react";
import { useCreateIncome } from "../hooks/useIncome";
import { useDashboard } from "../hooks/useDashboard";
import { Money, Field, Button } from "../components/ui";
import CurrencyInput from "../components/CurrencyInput";
import { previewAllocation } from "../utils/allocatorPreview";
import PercentGuard from "../components/PercentGuard";
import { useToast } from "../components/Toast";
import { useIncomePreview } from "../hooks/useIncomePreview";
function dollarsToCents(input: string): number {
const n = Number.parseFloat(input || "0");
if (!Number.isFinite(n)) return 0;
return Math.round(n * 100);
}
type Alloc = { id: number | string; amountCents: number; name: string };
export default function IncomePage() {
const [amountStr, setAmountStr] = useState("");
const { push } = useToast();
const m = useCreateIncome();
const dash = useDashboard();
const cents = dollarsToCents(amountStr);
const canSubmit = (dash.data?.percentTotal ?? 0) === 100;
// Server preview (preferred) with client fallback
const srvPreview = useIncomePreview(cents);
const preview = useMemo(() => {
if (!dash.data || cents <= 0) return null;
if (srvPreview.data) return srvPreview.data;
// fallback: local simulation
return previewAllocation(cents, dash.data.fixedPlans, dash.data.variableCategories);
}, [cents, dash.data, srvPreview.data]);
const submit = (e: FormEvent) => {
e.preventDefault();
if (cents <= 0 || !canSubmit) return;
m.mutate(
{ amountCents: cents },
{
onSuccess: (res) => {
const fixed = (res.fixedAllocations ?? []).reduce(
(s: number, a: any) => s + (a.amountCents ?? 0),
0
);
const variable = (res.variableAllocations ?? []).reduce(
(s: number, a: any) => s + (a.amountCents ?? 0),
0
);
const unalloc = res.remainingUnallocatedCents ?? 0;
push(
"ok",
`Allocated: Fixed ${(fixed / 100).toFixed(2)} + Variable ${(variable / 100).toFixed(
2
)}. Unallocated ${(unalloc / 100).toFixed(2)}.`
);
setAmountStr("");
},
onError: (err: any) => push("err", err?.message ?? "Income failed"),
}
);
};
const variableAllocations: Alloc[] = useMemo(() => {
if (!m.data) return [];
const nameById = new Map<string | number, string>(
(dash.data?.variableCategories ?? []).map((c) => [c.id as string | number, c.name] as const)
);
const grouped = new Map<string | number, number>();
for (const a of m.data.variableAllocations ?? []) {
const id = (a as any).variableCategoryId ?? (a as any).id ?? -1;
grouped.set(id, (grouped.get(id) ?? 0) + (a as any).amountCents);
}
return [...grouped.entries()]
.map(([id, amountCents]) => ({ id, amountCents, name: nameById.get(id) ?? `Category #${id}` }))
.sort((a, b) => b.amountCents - a.amountCents);
}, [m.data, dash.data]);
const fixedAllocations: Alloc[] = useMemo(() => {
if (!m.data) return [];
const nameById = new Map<string | number, string>(
(dash.data?.fixedPlans ?? []).map((p) => [p.id as string | number, p.name] as const)
);
const grouped = new Map<string | number, number>();
for (const a of m.data.fixedAllocations ?? []) {
const id = (a as any).fixedPlanId ?? (a as any).id ?? -1;
grouped.set(id, (grouped.get(id) ?? 0) + (a as any).amountCents);
}
return [...grouped.entries()]
.map(([id, amountCents]) => ({ id, amountCents, name: nameById.get(id) ?? `Plan #${id}` }))
.sort((a, b) => b.amountCents - a.amountCents);
}, [m.data, dash.data]);
const hasResult = !!m.data;
return (
<div className="stack max-w-lg">
<PercentGuard />
<form onSubmit={submit} className="card">
<h2 className="section-title">Record Income</h2>
<Field label="Amount (USD)">
<CurrencyInput value={amountStr} onValue={setAmountStr} />
</Field>
<Button disabled={m.isPending || cents <= 0 || !canSubmit}>
{m.isPending ? "Allocating…" : canSubmit ? "Submit" : "Fix percents to 100%"}
</Button>
{/* Live Preview */}
{!hasResult && preview && (
<div className="mt-4 stack">
<div className="row">
<h3 className="text-sm muted">Preview (not yet applied)</h3>
<span className="ml-auto text-sm">
Unallocated: <Money cents={preview.unallocatedCents} />
</span>
</div>
<section className="border border-[--color-ink] rounded-[--radius-xl] p-3 bg-[--color-ink]">
<div className="row mb-2">
<h4 className="text-sm muted">Fixed Plans</h4>
<span className="ml-auto font-semibold">
<Money cents={preview.fixed.reduce((s, x) => s + x.amountCents, 0)} />
</span>
</div>
{preview.fixed.length === 0 ? (
<div className="muted text-sm">No fixed allocations.</div>
) : (
<ul className="list-disc pl-5 space-y-1">
{preview.fixed.map((a) => (
<li key={a.id} className="row">
<span>{a.name}</span>
<span className="ml-auto">
<Money cents={a.amountCents} />
</span>
</li>
))}
</ul>
)}
</section>
<section className="border border-[--color-ink] rounded-[--radius-xl] p-3 bg-[--color-ink]">
<div className="row mb-2">
<h4 className="text-sm muted">Variable Categories</h4>
<span className="ml-auto font-semibold">
<Money cents={preview.variable.reduce((s, x) => s + x.amountCents, 0)} />
</span>
</div>
{preview.variable.length === 0 ? (
<div className="muted text-sm">No variable allocations.</div>
) : (
<ul className="list-disc pl-5 space-y-1">
{preview.variable.map((a) => (
<li key={a.id} className="row">
<span>{a.name}</span>
<span className="ml-auto">
<Money cents={a.amountCents} />
</span>
</li>
))}
</ul>
)}
</section>
</div>
)}
{/* Actual Result */}
{m.error && <div className="toast-err mt-3"> {(m.error as any).message}</div>}
{hasResult && (
<div className="mt-4 stack">
<div className="row">
<span className="muted text-sm">Unallocated</span>
<span className="ml-auto">
<Money cents={m.data?.remainingUnallocatedCents ?? 0} />
</span>
</div>
<section className="border border-[--color-ink] rounded-[--radius-xl] p-3 bg-[--color-ink]">
<div className="row mb-2">
<h3 className="text-sm muted">Fixed Plans (Applied)</h3>
<span className="ml-auto font-semibold">
<Money cents={fixedAllocations.reduce((s, x) => s + x.amountCents, 0)} />
</span>
</div>
<ul className="list-disc pl-5 space-y-1">
{fixedAllocations.map((a) => (
<li key={a.id} className="row">
<span>{a.name}</span>
<span className="ml-auto">
<Money cents={a.amountCents} />
</span>
</li>
))}
</ul>
</section>
<section className="border border-[--color-ink] rounded-[--radius-xl] p-3 bg-[--color-ink]">
<div className="row mb-2">
<h3 className="text-sm muted">Variable Categories (Applied)</h3>
<span className="ml-auto font-semibold">
<Money cents={variableAllocations.reduce((s, x) => s + x.amountCents, 0)} />
</span>
</div>
<ul className="list-disc pl-5 space-y-1">
{variableAllocations.map((a) => (
<li key={a.id} className="row">
<span>{a.name}</span>
<span className="ml-auto">
<Money cents={a.amountCents} />
</span>
</li>
))}
</ul>
</section>
</div>
)}
</form>
</div>
);
}

168
web/src/pages/SpendPage.tsx Normal file
View File

@@ -0,0 +1,168 @@
import { useMemo, useState, type FormEvent, type ChangeEvent } from "react";
import { useDashboard } from "../hooks/useDashboard";
import { useCreateTransaction } from "../hooks/useTransactions";
import { Money, Field, Button } from "../components/ui";
import CurrencyInput from "../components/CurrencyInput";
import { useToast } from "../components/Toast";
import { nowLocalISOStringMinute } from "../utils/format";
type Kind = "variable_spend" | "fixed_payment";
function dollarsToCents(input: string): number {
const n = Number.parseFloat(input || "0");
if (!Number.isFinite(n)) return 0;
return Math.round(n * 100);
}
export default function SpendPage() {
const dash = useDashboard();
const m = useCreateTransaction();
const { push } = useToast();
const [kind, setKind] = useState<Kind>("variable_spend");
const [amountStr, setAmountStr] = useState("");
const [occurredAt, setOccurredAt] = useState(nowLocalISOStringMinute());
const [variableCategoryId, setVariableCategoryId] = useState<string>("");
const [fixedPlanId, setFixedPlanId] = useState<string>("");
const amountCents = dollarsToCents(amountStr);
// Optional UX lock: block variable spend if chosen category has 0 balance.
const selectedCategory = useMemo(() => {
if (!dash.data) return null;
return dash.data.variableCategories.find(c => String(c.id) === variableCategoryId) ?? null;
}, [dash.data, variableCategoryId]);
const disableIfZeroBalance = true; // flip to false to allow negatives
const categoryBlocked =
kind === "variable_spend" &&
!!selectedCategory &&
disableIfZeroBalance &&
(selectedCategory.balanceCents ?? 0) <= 0;
const canSubmit = useMemo(() => {
if (!dash.data) return false;
if (amountCents <= 0) return false;
if (kind === "variable_spend") return !!variableCategoryId && !categoryBlocked;
return !!fixedPlanId; // fixed_payment
}, [dash.data, amountCents, kind, variableCategoryId, fixedPlanId, categoryBlocked]);
const onSubmit = (e: FormEvent) => {
e.preventDefault();
if (!canSubmit) return;
const payload = {
kind,
amountCents,
occurredAtISO: new Date(occurredAt).toISOString(),
variableCategoryId: kind === "variable_spend" ? Number(variableCategoryId) : undefined,
fixedPlanId: kind === "fixed_payment" ? Number(fixedPlanId) : undefined,
} as any;
m.mutate(payload, {
onSuccess: () => {
push("ok", kind === "variable_spend" ? "Recorded spend." : "Recorded payment.");
setAmountStr("");
// Keep date defaulting to “now” for quick entry
setOccurredAt(nowLocalISOStringMinute());
},
onError: (err: any) => push("err", err?.message ?? "Failed to record"),
});
};
const cats = dash.data?.variableCategories ?? [];
const plans = dash.data?.fixedPlans ?? [];
return (
<div className="grid gap-4 max-w-xl">
<form onSubmit={onSubmit} className="card stack">
<h2 className="section-title">Spend / Pay</h2>
{/* Kind toggle */}
<div className="row gap-2">
<label className="row">
<input
type="radio"
checked={kind === "variable_spend"}
onChange={() => setKind("variable_spend")}
/>
<span className="text-sm">Variable Spend</span>
</label>
<label className="row">
<input
type="radio"
checked={kind === "fixed_payment"}
onChange={() => setKind("fixed_payment")}
/>
<span className="text-sm">Fixed Payment</span>
</label>
</div>
{/* Pick target */}
{kind === "variable_spend" ? (
<Field label="Category">
<select
className="input"
value={variableCategoryId}
onChange={(e: ChangeEvent<HTMLSelectElement>) => setVariableCategoryId(e.target.value)}
>
<option value="">Select a category</option>
{cats
.slice()
.sort((a, b) => (a.priority - b.priority) || a.name.localeCompare(b.name))
.map(c => (
<option key={c.id} value={String(c.id)}>
{c.name} <Money cents={c.balanceCents} />
</option>
))}
</select>
</Field>
) : (
<Field label="Fixed Plan">
<select
className="input"
value={fixedPlanId}
onChange={(e: ChangeEvent<HTMLSelectElement>) => setFixedPlanId(e.target.value)}
>
<option value="">Select a plan</option>
{plans
.slice()
.sort((a, b) => (a.priority - b.priority) || (new Date(a.dueOn).getTime() - new Date(b.dueOn).getTime()))
.map(p => (
<option key={p.id} value={String(p.id)}>
{p.name} Funded <Money cents={p.fundedCents} /> / <Money cents={p.totalCents} />
</option>
))}
</select>
</Field>
)}
{/* Amount + Date */}
<Field label="Amount (USD)">
<CurrencyInput value={amountStr} onValue={setAmountStr} />
</Field>
<Field label="When">
<input
className="input"
type="datetime-local"
value={occurredAt}
onChange={(e) => setOccurredAt(e.target.value)}
/>
</Field>
{/* Guard + submit */}
{categoryBlocked && (
<div className="toast-err">
Selected category has no available balance. Add income or pick another category.
</div>
)}
<Button disabled={m.isPending || !canSubmit}>
{m.isPending ? "Saving…" : "Submit"}
</Button>
{m.error && <div className="toast-err"> {(m.error as any).message}</div>}
</form>
</div>
);
}

View File

@@ -0,0 +1,183 @@
import { useEffect, useMemo, useState, type ChangeEvent } from "react";
import { useSearchParams } from "react-router-dom";
import { useTransactionsQuery } from "../hooks/useTransactionsQuery";
import { Money } from "../components/ui";
import Pagination from "../components/Pagination";
type Kind = "all" | "variable_spend" | "fixed_payment";
function isoDateOnly(d: Date) {
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, "0");
const da = String(d.getDate()).padStart(2, "0");
return `${y}-${m}-${da}`;
}
export default function TransactionsPage() {
const today = isoDateOnly(new Date());
const [sp, setSp] = useSearchParams();
// init from URL
const initKind = (sp.get("kind") as Kind) || "all";
const initQ = sp.get("q") || "";
const initFrom = sp.get("from") || "";
const initTo = sp.get("to") || today;
const initPage = Math.max(1, Number(sp.get("page") || 1));
const [kind, setKind] = useState<Kind>(initKind);
const [qRaw, setQRaw] = useState(initQ);
const [q, setQ] = useState(initQ.trim());
const [from, setFrom] = useState(initFrom);
const [to, setTo] = useState(initTo);
const [page, setPage] = useState(initPage);
const limit = 20;
// debounce search
useEffect(() => {
const id = setTimeout(() => {
setPage(1);
setQ(qRaw.trim());
}, 250);
return () => clearTimeout(id);
}, [qRaw]);
// write to URL on change
useEffect(() => {
const next = new URLSearchParams();
if (kind !== "all") next.set("kind", kind);
if (q) next.set("q", q);
if (from) next.set("from", from);
if (to) next.set("to", to);
if (page !== 1) next.set("page", String(page));
setSp(next, { replace: true });
}, [kind, q, from, to, page, setSp]);
const params = useMemo(
() => ({
page,
limit,
q: q || undefined,
from: from || undefined,
to: to || undefined,
kind: kind === "all" ? undefined : kind,
}),
[page, limit, q, from, to, kind]
);
const { data, isLoading, error, refetch, isFetching } = useTransactionsQuery(params);
const clear = () => {
setKind("all");
setQRaw("");
setFrom("");
setTo(today);
setPage(1);
};
const rows = data?.items ?? [];
const totalAmount = rows.reduce((s, r) => s + (r.amountCents ?? 0), 0);
return (
<div className="grid gap-4">
<section className="card">
<h2 className="section-title">Transactions</h2>
{/* Filters */}
<div className="row gap-2 flex-wrap mb-3">
<select
className="input w-44"
value={kind}
onChange={(e: ChangeEvent<HTMLSelectElement>) => {
setKind(e.target.value as Kind);
setPage(1);
}}
>
<option value="all">All Types</option>
<option value="variable_spend">Variable Spend</option>
<option value="fixed_payment">Fixed Payment</option>
</select>
<input
className="input w-56"
placeholder="Search…"
value={qRaw}
onChange={(e) => setQRaw(e.target.value)}
/>
<input
className="input w-40"
type="date"
value={from}
onChange={(e) => {
setFrom(e.target.value);
setPage(1);
}}
/>
<input
className="input w-40"
type="date"
value={to}
onChange={(e) => {
setTo(e.target.value);
setPage(1);
}}
/>
<button type="button" className="btn" onClick={clear}>
Clear
</button>
<div className="badge ml-auto">
{isFetching ? "Refreshing…" : `Showing ${rows.length}`}
</div>
</div>
{/* States */}
{isLoading && <div className="muted text-sm">Loading</div>}
{error && !isLoading && (
<div className="toast-err mb-3">
Couldnt load transactions.{" "}
<button className="btn ml-2" onClick={() => refetch()} disabled={isFetching}>
{isFetching ? "Retrying…" : "Retry"}
</button>
</div>
)}
{/* Table */}
{!isLoading && rows.length === 0 ? (
<div className="muted text-sm">No transactions match your filters.</div>
) : (
<>
<table className="table">
<thead>
<tr>
<th>Type</th>
<th>Amount</th>
<th>Date</th>
</tr>
</thead>
<tbody>
{rows.map((t) => (
<tr key={t.id}>
<td className="rounded-l-[--radius-xl] px-3 py-2">{t.kind}</td>
<td className="px-3 py-2">
<Money cents={t.amountCents} />
</td>
<td className="rounded-r-[--radius-xl] px-3 py-2">
{new Date(t.occurredAt).toLocaleString()}
</td>
</tr>
))}
</tbody>
</table>
<div className="row mt-2">
<div className="muted text-sm">Page total</div>
<div className="ml-auto font-semibold">
<Money cents={totalAmount} />
</div>
</div>
{data && (
<Pagination page={data.page} limit={data.limit} total={data.total} onPage={setPage} />
)}
</>
)}
</section>
</div>
);
}

View File

@@ -0,0 +1,179 @@
import { useMemo, useState, type ChangeEvent, type FormEvent } from "react";
import { useDashboard } from "../../hooks/useDashboard";
import { Money } from "../../components/ui";
import SettingsNav from "./_SettingsNav";
import { useCategories, useCreateCategory, useUpdateCategory, useDeleteCategory } from "../../hooks/useCategories";
import { useToast } from "../../components/Toast";
type Row = { id: number; name: string; percent: number; priority: number; isSavings: boolean; balanceCents: number };
function SumBadge({ total }: { total: number }) {
const ok = total === 100;
return (
<div className={`badge ${ok ? "" : ""}`}>
Total: {total}%
</div>
);
}
export default function SettingsCategoriesPage() {
const { data, isLoading, error, refetch, isFetching } = useDashboard();
const cats: Row[] = useCategories();
const createM = useCreateCategory();
const updateM = useUpdateCategory();
const deleteM = useDeleteCategory();
const { push } = useToast();
const total = useMemo(() => cats.reduce((s, c) => s + c.percent, 0), [cats]);
// Add form state
const [name, setName] = useState("");
const [percent, setPercent] = useState("");
const [priority, setPriority] = useState("");
const [isSavings, setIsSavings] = useState(false);
const addDisabled = !name || !percent || !priority || Number(percent) < 0 || Number(percent) > 100;
const onAdd = (e: FormEvent) => {
e.preventDefault();
const body = {
name: name.trim(),
percent: Math.max(0, Math.min(100, Math.floor(Number(percent) || 0))),
priority: Math.max(0, Math.floor(Number(priority) || 0)),
isSavings
};
createM.mutate(body, {
onSuccess: () => {
push("ok", "Category created");
setName(""); setPercent(""); setPriority(""); setIsSavings(false);
},
onError: (err: any) => push("err", err?.message ?? "Create failed")
});
};
const onEdit = (id: number, patch: Partial<Row>) => {
updateM.mutate({ id, body: patch }, {
onError: (err: any) => push("err", err?.message ?? "Update failed")
});
};
const onDelete = (id: number) => {
deleteM.mutate(id, {
onSuccess: () => push("ok", "Category deleted"),
onError: (err: any) => push("err", err?.message ?? "Delete failed")
});
};
if (isLoading) return <div className="card max-w-2xl"><SettingsNav/><div className="muted">Loading</div></div>;
if (error || !data) {
return (
<div className="card max-w-2xl">
<SettingsNav/>
<p className="mb-3">Couldnt load categories.</p>
<button className="btn" onClick={() => refetch()} disabled={isFetching}>{isFetching ? "Retrying…" : "Retry"}</button>
</div>
);
}
return (
<div className="grid gap-4 max-w-5xl">
<section className="card">
<SettingsNav/>
{/* Add form */}
<form onSubmit={onAdd} className="row gap-2 mb-4 flex-wrap">
<input className="input w-44" placeholder="Name" value={name} onChange={(e) => setName(e.target.value)} />
<input className="input w-28" placeholder="%"
type="number" min={0} max={100} value={percent} onChange={(e) => setPercent(e.target.value)} />
<input className="input w-28" placeholder="Priority"
type="number" min={0} value={priority} onChange={(e) => setPriority(e.target.value)} />
<label className="row">
<input type="checkbox" checked={isSavings} onChange={(e) => setIsSavings(e.target.checked)} />
<span className="muted text-sm">Savings</span>
</label>
<button className="btn" disabled={addDisabled || createM.isPending}>Add</button>
<div className="ml-auto"><SumBadge total={total} /></div>
</form>
{cats.length === 0 ? (
<div className="muted text-sm">No categories yet.</div>
) : (
<table className="table">
<thead><tr><th>Name</th><th>%</th><th>Priority</th><th>Savings</th><th>Balance</th><th></th></tr></thead>
<tbody>
{cats
.slice()
.sort((a, b) => (a.priority - b.priority) || a.name.localeCompare(b.name))
.map(c => (
<tr key={c.id}>
<td className="rounded-l-[--radius-xl] px-3 py-2">
<InlineEditText value={c.name} onChange={(v) => onEdit(c.id, { name: v })} />
</td>
<td className="px-3 py-2">
<InlineEditNumber value={c.percent} min={0} max={100} onChange={(v) => onEdit(c.id, { percent: v })} />
</td>
<td className="px-3 py-2">
<InlineEditNumber value={c.priority} min={0} onChange={(v) => onEdit(c.id, { priority: v })} />
</td>
<td className="px-3 py-2">
<InlineEditCheckbox checked={c.isSavings} onChange={(v) => onEdit(c.id, { isSavings: v })} />
</td>
<td className="px-3 py-2"><Money cents={c.balanceCents} /></td>
<td className="rounded-r-[--radius-xl] px-3 py-2">
<button className="btn" type="button" onClick={() => onDelete(c.id)} disabled={deleteM.isPending}>Delete</button>
</td>
</tr>
))}
</tbody>
</table>
)}
{/* Guard if total != 100 */}
{total !== 100 && (
<div className="toast-err mt-3">
Percents must sum to <strong>100%</strong> for allocations. Current total: {total}%.
</div>
)}
</section>
</div>
);
}
/* --- tiny inline editors --- */
function InlineEditText({ value, onChange }: { value: string; onChange: (v: string) => void }) {
const [v, setV] = useState(value);
const [editing, setEditing] = useState(false);
const commit = () => { if (v !== value) onChange(v.trim()); setEditing(false); };
return editing ? (
<input className="input" value={v} onChange={(e) => setV(e.target.value)}
onBlur={commit} onKeyDown={(e) => e.key === "Enter" && commit()} autoFocus />
) : (
<button type="button" className="link" onClick={() => setEditing(true)}>{value}</button>
);
}
function InlineEditNumber({ value, onChange, min=0, max=Number.MAX_SAFE_INTEGER }:
{ value: number; onChange: (v: number) => void; min?: number; max?: number; }) {
const [editing, setEditing] = useState(false);
const [v, setV] = useState(String(value));
const commit = () => {
const n = Math.max(min, Math.min(max, Math.floor(Number(v) || 0)));
if (n !== value) onChange(n);
setEditing(false);
};
return editing ? (
<input className="input w-24" value={v} onChange={(e) => setV(e.target.value)}
onBlur={commit} onKeyDown={(e) => e.key === "Enter" && commit()} autoFocus />
) : (
<button type="button" className="link" onClick={() => setEditing(true)}>{value}</button>
);
}
function InlineEditCheckbox({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) {
return (
<label className="row">
<input type="checkbox" checked={checked} onChange={(e) => onChange(e.target.checked)} />
<span className="muted text-sm">{checked ? "Yes" : "No"}</span>
</label>
);
}

View File

@@ -0,0 +1,218 @@
// web/src/pages/settings/PlansPage.tsx
import { useMemo, useState, type ChangeEvent, type FormEvent } from "react";
import { useDashboard } from "../../hooks/useDashboard";
import SettingsNav from "./_SettingsNav";
import { useCreatePlan, useUpdatePlan, useDeletePlan } from "../../hooks/useFixedPlans";
import { Money } from "../../components/ui";
import { useToast } from "../../components/Toast";
function isoDateLocal(d: Date = new Date()) {
const z = new Date(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate()));
return z.toISOString().slice(0, 10);
}
function daysUntil(iso: string) {
const today = new Date(isoDateLocal());
const due = new Date(isoDateLocal(new Date(iso)));
const diffMs = due.getTime() - today.getTime();
return Math.round(diffMs / (24 * 60 * 60 * 1000));
}
function DueBadge({ dueISO }: { dueISO: string }) {
const d = daysUntil(dueISO);
if (d < 0) return <span className="badge" style={{ borderColor: "#7f1d1d" }}>Overdue</span>;
if (d <= 7) return <span className="badge">Due in {d}d</span>;
return <span className="badge" aria-hidden="true">On track</span>;
}
export default function SettingsPlansPage() {
const { data, isLoading, error, refetch, isFetching } = useDashboard();
const createM = useCreatePlan();
const updateM = useUpdatePlan();
const deleteM = useDeletePlan();
const { push } = useToast();
// Add form state
const [name, setName] = useState("");
const [total, setTotal] = useState("");
const [funded, setFunded] = useState("");
const [priority, setPriority] = useState("");
const [due, setDue] = useState(isoDateLocal());
const totals = useMemo(() => {
if (!data) return { funded: 0, total: 0, remaining: 0 };
const funded = data.fixedPlans.reduce((s, p) => s + p.fundedCents, 0);
const total = data.fixedPlans.reduce((s, p) => s + p.totalCents, 0);
return { funded, total, remaining: Math.max(0, total - funded) };
}, [data]);
if (isLoading) return <div className="card max-w-3xl"><SettingsNav/><div className="muted">Loading</div></div>;
if (error || !data) {
return (
<div className="card max-w-3xl">
<SettingsNav/>
<p className="mb-3">Couldnt load fixed plans.</p>
<button className="btn" onClick={() => refetch()} disabled={isFetching}>{isFetching ? "Retrying…" : "Retry"}</button>
</div>
);
}
const onAdd = (e: FormEvent) => {
e.preventDefault();
const totalCents = Math.max(0, Math.round((parseFloat(total || "0")) * 100));
const fundedCents = Math.max(0, Math.round((parseFloat(funded || "0")) * 100));
const body = {
name: name.trim(),
totalCents,
fundedCents: Math.min(fundedCents, totalCents),
priority: Math.max(0, Math.floor(Number(priority) || 0)),
dueOn: new Date(due).toISOString(),
};
if (!body.name || totalCents <= 0) return;
createM.mutate(body, {
onSuccess: () => {
push("ok", "Plan created");
setName(""); setTotal(""); setFunded(""); setPriority(""); setDue(isoDateLocal());
},
onError: (err: any) => push("err", err?.message ?? "Create failed"),
});
};
const onEdit = (id: number, patch: Partial<{ name: string; totalCents: number; fundedCents: number; priority: number; dueOn: string }>) => {
if ("totalCents" in patch && "fundedCents" in patch && (patch.totalCents ?? 0) < (patch.fundedCents ?? 0)) {
patch.fundedCents = patch.totalCents;
}
updateM.mutate({ id, body: patch }, {
onSuccess: () => push("ok", "Plan updated"),
onError: (err: any) => push("err", err?.message ?? "Update failed"),
});
};
const onDelete = (id: number) => {
deleteM.mutate(id, {
onSuccess: () => push("ok", "Plan deleted"),
onError: (err: any) => push("err", err?.message ?? "Delete failed"),
});
};
return (
<div className="grid gap-4 max-w-5xl">
<section className="card">
<SettingsNav/>
{/* KPI strip */}
<div className="grid gap-2 sm:grid-cols-3 mb-4">
<div className="card kpi"><h3>Funded</h3><div className="val"><Money cents={totals.funded} /></div></div>
<div className="card kpi"><h3>Total</h3><div className="val"><Money cents={totals.total} /></div></div>
<div className="card kpi"><h3>Remaining</h3><div className="val"><Money cents={totals.remaining} /></div></div>
</div>
{/* Add form */}
<form onSubmit={onAdd} className="row gap-2 mb-4 flex-wrap">
<input className="input w-48" placeholder="Name" value={name} onChange={(e) => setName(e.target.value)} />
<input className="input w-28" placeholder="Total $" type="number" min={0} step="0.01" value={total} onChange={(e) => setTotal(e.target.value)} />
<input className="input w-28" placeholder="Funded $" type="number" min={0} step="0.01" value={funded} onChange={(e) => setFunded(e.target.value)} />
<input className="input w-24" placeholder="Priority" type="number" min={0} value={priority} onChange={(e) => setPriority(e.target.value)} />
<input className="input w-40" type="date" value={due} onChange={(e) => setDue(e.target.value)} />
<button className="btn" disabled={!name || !total || createM.isPending}>Add</button>
</form>
{/* Table */}
{data.fixedPlans.length === 0 ? (
<div className="muted text-sm">No fixed plans yet.</div>
) : (
<table className="table">
<thead>
<tr>
<th>Name</th><th>Due</th><th>Priority</th>
<th>Funded</th><th>Total</th><th>Remaining</th><th>Status</th><th></th>
</tr>
</thead>
<tbody>
{data.fixedPlans
.slice()
.sort((a, b) => (a.priority - b.priority) || (new Date(a.dueOn).getTime() - new Date(b.dueOn).getTime()))
.map(p => {
const remaining = Math.max(0, p.totalCents - p.fundedCents);
return (
<tr key={p.id}>
<td className="rounded-l-[--radius-xl] px-3 py-2">
<InlineEditText value={p.name} onChange={(v) => onEdit(p.id, { name: v })} />
</td>
<td className="px-3 py-2">
<InlineEditDate value={p.dueOn} onChange={(iso) => onEdit(p.id, { dueOn: iso })} />
</td>
<td className="px-3 py-2">
<InlineEditNumber value={p.priority} min={0} onChange={(n) => onEdit(p.id, { priority: n })} />
</td>
<td className="px-3 py-2">
<InlineEditMoney
valueCents={p.fundedCents}
onChange={(cents) => onEdit(p.id, { fundedCents: Math.max(0, Math.min(cents, p.totalCents)) })}
/>
</td>
<td className="px-3 py-2">
<InlineEditMoney
valueCents={p.totalCents}
onChange={(cents) => onEdit(p.id, { totalCents: Math.max(cents, 0), fundedCents: Math.min(p.fundedCents, cents) })}
/>
</td>
<td className="px-3 py-2"><Money cents={remaining} /></td>
<td className="px-3 py-2"><DueBadge dueISO={p.dueOn} /></td>
<td className="rounded-r-[--radius-xl] px-3 py-2">
<button className="btn" type="button" onClick={() => onDelete(p.id)} disabled={deleteM.isPending}>Delete</button>
</td>
</tr>
);
})}
</tbody>
</table>
)}
</section>
</div>
);
}
/* --- Inline editors (minimal) --- */
function InlineEditText({ value, onChange }: { value: string; onChange: (v: string) => void }) {
const [editing, setEditing] = useState(false);
const [v, setV] = useState(value);
const commit = () => { const t = v.trim(); if (t && t !== value) onChange(t); setEditing(false); };
return editing ? (
<input className="input" value={v} onChange={(e) => setV(e.target.value)} onBlur={commit} onKeyDown={(e) => e.key === "Enter" && commit()} autoFocus />
) : <button type="button" className="link" onClick={() => setEditing(true)}>{value}</button>;
}
function InlineEditNumber({ value, onChange, min=0, max=Number.MAX_SAFE_INTEGER }:
{ value: number; onChange: (v: number) => void; min?: number; max?: number }) {
const [editing, setEditing] = useState(false);
const [v, setV] = useState(String(value));
const commit = () => { const n = Math.max(min, Math.min(max, Math.floor(Number(v) || 0))); if (n !== value) onChange(n); setEditing(false); };
return editing ? (
<input className="input w-24" value={v} onChange={(e) => setV(e.target.value)} onBlur={commit} onKeyDown={(e) => e.key === "Enter" && commit()} autoFocus />
) : <button type="button" className="link" onClick={() => setEditing(true)}>{value}</button>;
}
function InlineEditMoney({ valueCents, onChange }: { valueCents: number; onChange: (cents: number) => void }) {
const [editing, setEditing] = useState(false);
const [v, setV] = useState((valueCents / 100).toFixed(2));
const commit = () => {
const cents = Math.max(0, Math.round((parseFloat(v || "0")) * 100));
if (cents !== valueCents) onChange(cents);
setEditing(false);
};
return editing ? (
<input className="input w-28" type="number" step="0.01" min={0} value={v} onChange={(e) => setV(e.target.value)}
onBlur={commit} onKeyDown={(e) => e.key === "Enter" && commit()} autoFocus />
) : <button type="button" className="link" onClick={() => setEditing(true)}>{(valueCents/100).toFixed(2)}</button>;
}
function InlineEditDate({ value, onChange }: { value: string; onChange: (iso: string) => void }) {
const [editing, setEditing] = useState(false);
const local = new Date(value);
const [v, setV] = useState(local.toISOString().slice(0, 10));
const commit = () => { const iso = new Date(v + "T00:00:00Z").toISOString(); if (iso !== value) onChange(iso); setEditing(false); };
return editing ? (
<input className="input w-40" type="date" value={v} onChange={(e) => setV(e.target.value)} onBlur={commit} onKeyDown={(e) => e.key === "Enter" && commit()} autoFocus />
) : <button type="button" className="link" onClick={() => setEditing(true)}>{new Date(value).toLocaleDateString()}</button>;
}

View File

@@ -0,0 +1,14 @@
import { NavLink } from "react-router-dom";
export default function SettingsNav() {
const link = (to: string, label: string) =>
<NavLink to={to} className={({isActive}) => `link ${isActive ? "link-active" : ""}`}>{label}</NavLink>;
return (
<div className="row mb-3">
<h2 className="section-title m-0">Settings</h2>
<div className="ml-auto flex gap-1">
{link("/settings/categories", "Categories")}
{link("/settings/plans", "Fixed Plans")}
</div>
</div>
);
}

132
web/src/styles.css Normal file
View File

@@ -0,0 +1,132 @@
:root {
--color-bg: #0b0c10;
--color-panel: #111318;
--color-fg: #e7e9ee;
--color-ink: #2a2e37;
--color-accent: #5dd6b2;
--radius-xl: 12px;
--radius-lg: 10px;
--radius-md: 8px;
--shadow-1: 0 6px 20px rgba(0,0,0,0.25);
}
* { box-sizing: border-box; }
html, body, #root { height: 100%; }
body { margin: 0; background: var(--color-bg); color: var(--color-fg); font: 14px/1.35 system-ui, -apple-system, Segoe UI, Roboto, sans-serif; }
a { color: inherit; text-decoration: none; }
.container { width: min(1100px, 100%); margin-inline: auto; padding: 0 16px; }
.muted { opacity: 0.7; }
.card {
background: var(--color-panel);
border: 1px solid var(--color-ink);
border-radius: var(--radius-xl);
padding: 16px;
box-shadow: var(--shadow-1);
}
.row { display: flex; align-items: center; }
.stack { display: grid; gap: 12px; }
.section-title { font-weight: 700; font-size: 16px; margin-bottom: 10px; }
.input {
background: #0f1116;
color: var(--color-fg);
border: 1px solid var(--color-ink);
border-radius: var(--radius-lg);
padding: 8px 10px;
outline: none;
}
.input:focus { border-color: var(--color-accent); }
.btn {
background: var(--color-accent);
color: #062016;
border: none;
border-radius: var(--radius-lg);
padding: 8px 12px;
font-weight: 700;
cursor: pointer;
}
.btn[disabled] { opacity: 0.5; cursor: default; }
.badge {
background: var(--color-ink);
border-radius: var(--radius-lg);
padding: 4px 8px;
font-size: 12px;
}
.table { width: 100%; border-collapse: separate; border-spacing: 0 8px; }
.table thead th {
text-align: left; font-size: 12px; opacity: 0.7; padding: 0 8px;
}
.table tbody tr {
background: var(--color-panel);
border: 1px solid var(--color-ink);
border-radius: var(--radius-xl);
}
.table td { padding: 8px; }
.toast-err {
background: #b21d2a;
color: white;
border-radius: var(--radius-lg);
padding: 10px 12px;
}
.border { border: 1px solid var(--color-ink); }
.rounded-xl { border-radius: var(--radius-xl); }
.divide-y > * + * { border-top: 1px solid var(--color-ink); }
/* utility-ish */
.w-44 { width: 11rem; }
.w-56 { width: 14rem; }
.w-40 { width: 10rem; }
.ml-auto { margin-left: auto; }
.mt-2 { margin-top: 0.5rem; }
.mt-3 { margin-top: 0.75rem; }
.mt-4 { margin-top: 1rem; }
.mb-3 { margin-bottom: 0.75rem; }
.gap-2 { gap: 0.5rem; }
.gap-4 { gap: 1rem; }
.flex-wrap { flex-wrap: wrap; }
.text-sm { font-size: 0.875rem; }
.text-xl { font-size: 1.25rem; }
.font-semibold { font-weight: 600; }
.font-bold { font-weight: 700; }
.font-mono { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace; }
.bg-\[--color-ink\] { background: var(--color-ink); }
.bg-\[--color-ink\]\/60 { background: color-mix(in oklab, var(--color-ink), transparent 40%); }
.bg-\[--color-panel\] { background: var(--color-panel); }
.text-\[--color-fg\] { color: var(--color-fg); }
.border-\[--color-ink\] { border-color: var(--color-ink); }
.rounded-\[--radius-xl\] { border-radius: var(--radius-xl); }
.p-2 { padding: 0.5rem; }
.p-3 { padding: 0.75rem; }
.p-4 { padding: 1rem; }
.p-6 { padding: 1.5rem; }
.py-8 { padding-block: 2rem; }
.h-14 { height: 3.5rem; }
.text-center { text-align: center; }
.text-right { text-align: right; }
.opacity-70 { opacity: .7; }
.grid { display: grid; }
.md\:grid-cols-2 { grid-template-columns: 1fr; }
.md\:grid-cols-3 { grid-template-columns: 1fr; }
@media (min-width: 768px) {
.md\:grid-cols-2 { grid-template-columns: 1fr 1fr; }
.md\:grid-cols-3 { grid-template-columns: 1fr 1fr 1fr; }
}
.shadow-sm { box-shadow: 0 2px 12px rgba(0,0,0,0.2); }
.underline { text-decoration: underline; }
.fixed { position: fixed; }
.bottom-4 { bottom: 1rem; }
.left-1\/2 { left: 50%; }
.-translate-x-1\/2 { transform: translateX(-50%); }
.z-50 { z-index: 50; }
.space-y-2 > * + * { margin-top: .5rem; }
.space-y-8 > * + * { margin-top: 2rem; }
.space-y-6 > * + * { margin-top: 1.5rem; }
.overflow-x-auto { overflow-x: auto; }

View File

@@ -0,0 +1,77 @@
import type { FixedPlan, VariableCategory } from "../hooks/useDashboard";
export function previewAllocation(
amountCents: number,
fixedPlans: FixedPlan[],
variableCategories: VariableCategory[]
) {
let remaining = Math.max(0, amountCents | 0);
// Fixed pass: fill by priority then due date order (assume arrays are already sorted on the server;
// this is just a local fallback).
const fixedSorted = [...fixedPlans].sort((a, b) => {
if (a.priority !== b.priority) return a.priority - b.priority;
return new Date(a.dueOn).getTime() - new Date(b.dueOn).getTime();
});
const fixed: Array<{ id: string; name: string; amountCents: number }> = [];
for (const p of fixedSorted) {
if (remaining <= 0) break;
const total = BigInt(p.totalCents ?? 0);
const funded = BigInt(p.fundedCents ?? 0);
const needBig = total - funded;
const need = Number(needBig > 0n ? needBig : 0n);
if (need <= 0) continue;
const give = Math.min(need, remaining);
fixed.push({ id: p.id, name: p.name, amountCents: give });
remaining -= give;
}
// Variable pass: largest remainder; savings-first on ties; then by priority then name.
const variable: Array<{ id: string; name: string; amountCents: number }> = [];
const cats = [...variableCategories].sort((a, b) => {
if (a.priority !== b.priority) return a.priority - b.priority;
return a.name.localeCompare(b.name);
});
if (remaining > 0 && cats.length > 0) {
const totalPercent = cats.reduce((s, c) => s + (c.percent || 0), 0);
const normCats =
totalPercent === 100
? cats
: cats.map((c) => ({
...c,
percent: totalPercent > 0 ? (c.percent * 100) / totalPercent : 0,
}));
const base: number[] = new Array(normCats.length).fill(0);
const tie: { idx: number; remainder: number; isSavings: boolean; priority: number; name: string }[] = [];
let sumBase = 0;
normCats.forEach((c, idx) => {
const exact = (remaining * c.percent) / 100;
const floor = Math.floor(exact);
base[idx] = floor;
sumBase += floor;
tie.push({ idx, remainder: exact - floor, isSavings: !!c.isSavings, priority: c.priority, name: c.name });
});
let leftovers = remaining - sumBase;
tie.sort((a, b) => {
if (a.isSavings !== b.isSavings) return a.isSavings ? -1 : 1;
if (a.remainder !== b.remainder) return b.remainder - a.remainder;
if (a.priority !== b.priority) return a.priority - b.priority;
return a.name.localeCompare(b.name);
});
for (let i = 0; i < tie.length && leftovers > 0; i++, leftovers--) base[tie[i].idx] += 1;
normCats.forEach((c, idx) => {
const give = base[idx] || 0;
if (give > 0) variable.push({ id: c.id, name: c.name, amountCents: give });
});
remaining = leftovers;
}
return { fixed, variable, unallocatedCents: Math.max(0, remaining) };
}

6
web/src/utils/format.ts Normal file
View File

@@ -0,0 +1,6 @@
export function toMoney(cents: number) {
return (cents / 100).toLocaleString(undefined, { style: "currency", currency: "USD" });
}
export function nowLocalISOStringMinute() {
return new Date().toISOString().slice(0, 16);
}

9
web/src/utils/money.ts Normal file
View File

@@ -0,0 +1,9 @@
export function fmtMoney(cents: number) {
const dollars = (Number(cents || 0) / 100).toFixed(2);
return `$${dollars}`;
}
export const toCents = (val: string | number): number => {
const n = typeof val === "string" ? Number(val) : val;
if (!Number.isFinite(n)) return 0;
return Math.round(n * 100);
};

View File

@@ -0,0 +1,120 @@
import { describe, it, expect } from "vitest";
import { previewAllocation, type FixedPlan, type VariableCategory } from "../src/utils/allocatorPreview";
const cats = (defs: Array<Partial<VariableCategory> & { name: string }>): VariableCategory[] =>
defs.map((d, i) => ({
id: i + 1,
name: d.name,
percent: d.percent ?? 0,
isSavings: d.isSavings ?? false,
priority: d.priority ?? 100,
}));
const plans = (defs: Array<Partial<FixedPlan> & { name: string; totalCents: number }>): FixedPlan[] =>
defs.map((d, i) => ({
id: i + 1,
name: d.name,
totalCents: d.totalCents,
fundedCents: d.fundedCents ?? 0,
priority: d.priority ?? 100,
dueOn: d.dueOn ?? new Date().toISOString(),
}));
describe("previewAllocation — basics", () => {
it("single bucket 100% gets all remaining after fixed", () => {
const fp = plans([]);
const vc = cats([{ name: "Only", percent: 100 }]);
const r = previewAllocation(1_000, fp, vc);
expect(r.fixed.length).toBe(0);
expect(r.variable).toEqual([{ id: 1, name: "Only", amountCents: 1_000 }]);
expect(r.unallocatedCents).toBe(0);
});
it("zero allocations when amount is 0", () => {
const r = previewAllocation(0, plans([]), cats([{ name: "A", percent: 100 }]));
expect(r.fixed.length).toBe(0);
expect(r.variable.length).toBe(0);
expect(r.unallocatedCents).toBe(0);
});
it("no variable split if no categories", () => {
const r = previewAllocation(500, plans([]), []);
expect(r.fixed.length).toBe(0);
expect(r.variable.length).toBe(0);
expect(r.unallocatedCents).toBe(500);
});
});
describe("previewAllocation — fixed first", () => {
it("funds plans in priority then due order up to need", () => {
const fp = plans([
{ name: "B", totalCents: 8000, fundedCents: 7000, priority: 2, dueOn: "2025-12-31T00:00:00Z" }, // need 1000
{ name: "A", totalCents: 5000, fundedCents: 0, priority: 1, dueOn: "2025-12-01T00:00:00Z" }, // need 5000 (but priority=1 => first)
]);
const vc = cats([{ name: "Var", percent: 100 }]);
const r = previewAllocation(4000, fp, vc);
// Fixed plan A (priority 1) gets as much as possible: 4000 (need 5000)
expect(r.fixed).toEqual([{ id: 2, name: "A", amountCents: 4000 }]);
expect(r.variable.length).toBe(0);
expect(r.unallocatedCents).toBe(0);
});
it("leftover goes to variables after satisfying plan needs", () => {
const fp = plans([{ name: "Rent", totalCents: 10000, fundedCents: 9000, priority: 1 }]); // need 1000
const vc = cats([{ name: "Groceries", percent: 100 }]);
const r = previewAllocation(2500, fp, vc);
expect(r.fixed).toEqual([{ id: 1, name: "Rent", amountCents: 1000 }]);
expect(r.variable).toEqual([{ id: 1, name: "Groceries", amountCents: 1500 }]);
expect(r.unallocatedCents).toBe(0);
});
});
describe("previewAllocation — largest remainder with savings-first tiebreak", () => {
it("splits by integer floor and then leftover by remainder", () => {
// 3 cats: 50%, 30%, 20%; amount 101 -> floors: 50, 30, 20 => sumBase=100, 1 leftover to largest remainder (all remainders 0, so go to savings-first if any)
const vc = cats([
{ name: "A", percent: 50, isSavings: false, priority: 10 },
{ name: "B", percent: 30, isSavings: true, priority: 10 },
{ name: "C", percent: 20, isSavings: false, priority: 10 },
]);
const r = previewAllocation(101, plans([]), vc);
// Base: A50, B30, C20 = 100; leftover=1 -> goes to B (savings-first)
expect(r.variable).toEqual([
{ id: 1, name: "A", amountCents: 50 },
{ id: 2, name: "B", amountCents: 31 },
{ id: 3, name: "C", amountCents: 20 },
]);
});
it("ties on remainder resolved by savings-first, then priority asc, then name asc", () => {
// Force equal remainders with 3 categories @ 33.333…%
const vc = cats([
{ name: "Zeta", percent: 33, isSavings: false, priority: 2 },
{ name: "Alpha", percent: 33, isSavings: false, priority: 1 },
{ name: "Saver", percent: 34, isSavings: true, priority: 5 },
]);
// Amount 4: exacts ~ 1.32, 1.32, 1.36 => floors 1,1,1 sumBase 3 leftover 1 -> goes to Saver (savings-first)
const r = previewAllocation(4, plans([]), vc);
expect(r.variable).toEqual([
{ id: 1, name: "Zeta", amountCents: 1 },
{ id: 2, name: "Alpha", amountCents: 1 },
{ id: 3, name: "Saver", amountCents: 2 },
]);
});
});
describe("previewAllocation — normalization safety", () => {
it("normalizes percents when sum != 100 to avoid crashing UI", () => {
const vc = cats([
{ name: "A", percent: 40 },
{ name: "B", percent: 40 },
{ name: "C", percent: 40 }, // sum = 120 -> normalize
]);
const r = previewAllocation(120, plans([]), vc);
// Expect all cents allocated (no unallocated) and proportions roughly 1/3 each.
const total = r.variable.reduce((s, v) => s + v.amountCents, 0);
expect(total).toBe(120);
expect(r.unallocatedCents).toBe(0);
});
});

28
web/tsconfig.app.json Normal file
View File

@@ -0,0 +1,28 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"types": ["vite/client"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}

7
web/tsconfig.json Normal file
View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

26
web/tsconfig.node.json Normal file
View File

@@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

14
web/vite.config.ts Normal file
View File

@@ -0,0 +1,14 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
export default defineConfig({
plugins: [
react(),
tailwindcss(),
],
})

9
web/vitest.config.ts Normal file
View File

@@ -0,0 +1,9 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
environment: "node",
include: ["tests/**/*.test.ts"],
reporters: "default",
},
});