added api logic, vitest, minimal testing ui
This commit is contained in:
5
.vscode/settings.json
vendored
Normal file
5
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"css.lint.unknownAtRules": "ignore",
|
||||
"scss.lint.unknownAtRules": "ignore",
|
||||
"less.lint.unknownAtRules": "ignore"
|
||||
}
|
||||
28
Caddyfile.dev
Normal file
28
Caddyfile.dev
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,27 +1,38 @@
|
||||
FROM node:20-alpine AS deps
|
||||
FROM node:20-bookworm-slim AS deps
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json* ./
|
||||
RUN npm ci --omit=dev
|
||||
|
||||
FROM node:20-alpine AS build
|
||||
FROM node:20-bookworm-slim AS build
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json* ./
|
||||
RUN npm ci
|
||||
COPY tsconfig.json ./
|
||||
COPY src ./src
|
||||
COPY prisma ./prisma
|
||||
COPY src ./src
|
||||
RUN npx prisma generate
|
||||
RUN npm run build
|
||||
|
||||
FROM node:20-alpine AS runner
|
||||
FROM node:20-bookworm-slim AS runner
|
||||
WORKDIR /app
|
||||
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
|
||||
|
||||
# 2) app build output
|
||||
COPY --from=build /app/dist ./dist
|
||||
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
|
||||
RUN chmod +x /app/entrypoint.sh
|
||||
RUN sed -i 's/\r$//' /app/entrypoint.sh && chmod +x /app/entrypoint.sh
|
||||
|
||||
EXPOSE 8080
|
||||
CMD ["/app/entrypoint.sh"]
|
||||
0
api/allocator
Normal file
0
api/allocator
Normal file
244
api/clients/ts/sdk.ts
Normal file
244
api/clients/ts/sdk.ts
Normal 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
0
api/distributes
Normal file
0
api/handles
Normal file
0
api/handles
Normal file
548
api/openapi.yaml
Normal file
548
api/openapi.yaml
Normal 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
|
||||
2303
api/package-lock.json
generated
2303
api/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,26 +1,34 @@
|
||||
{
|
||||
"name": "skymoney-api",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/server.ts",
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"build": "tsc",
|
||||
"start": "node dist/server.js",
|
||||
"dev": "tsx src/server.ts",
|
||||
"generate": "prisma generate",
|
||||
"migrate": "prisma migrate dev",
|
||||
"seed": "tsx src/scripts/seed.ts"
|
||||
"seed": "prisma db seed",
|
||||
"test": "vitest --run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fastify/cors": "^10.0.0",
|
||||
"@prisma/client": "^5.20.0",
|
||||
"fastify": "^4.26.2",
|
||||
"zod": "^3.23.8"
|
||||
"prisma": {
|
||||
"seed": "tsx --tsconfig prisma/tsconfig.seed.json prisma/seed.ts"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.11.30",
|
||||
"prisma": "^5.20.0",
|
||||
"tsx": "^4.19.0",
|
||||
"typescript": "^5.6.3"
|
||||
"@types/node": "^20.19.25",
|
||||
"@types/supertest": "^6.0.3",
|
||||
"prisma": "^5.22.0",
|
||||
"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
0
api/paginates
Normal file
802
api/pnpm-lock.yaml
generated
Normal file
802
api/pnpm-lock.yaml
generated
Normal 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: {}
|
||||
@@ -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"),
|
||||
},
|
||||
});
|
||||
115
api/prisma/migrations/20251111063120_init/migration.sql
Normal file
115
api/prisma/migrations/20251111063120_init/migration.sql
Normal 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;
|
||||
3
api/prisma/migrations/migration_lock.toml
Normal file
3
api/prisma/migrations/migration_lock.toml
Normal 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"
|
||||
@@ -1,6 +1,13 @@
|
||||
// prisma/schema.prisma
|
||||
generator client { provider = "prisma-client-js" }
|
||||
datasource db { provider = "postgresql"; url = env("DATABASE_URL") }
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
binaryTargets = ["native", "debian-openssl-3.0.x"]
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "postgresql"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
model User {
|
||||
id String @id @default(uuid())
|
||||
@@ -25,8 +32,7 @@ model VariableCategory {
|
||||
balanceCents BigInt @default(0)
|
||||
|
||||
@@unique([userId, name])
|
||||
@@check(percent_gte_0, "percent >= 0")
|
||||
@@check(percent_lte_100,"percent <= 100")
|
||||
@@index([userId, priority])
|
||||
}
|
||||
|
||||
model FixedPlan {
|
||||
@@ -39,12 +45,12 @@ model FixedPlan {
|
||||
totalCents BigInt
|
||||
fundedCents BigInt @default(0)
|
||||
priority Int @default(100)
|
||||
fundingMode String @default("auto-on-deposit") // or 'by-schedule'
|
||||
fundingMode String @default("auto-on-deposit")
|
||||
scheduleJson Json?
|
||||
|
||||
@@unique([userId, name])
|
||||
@@check(total_nonneg, "totalCents >= 0")
|
||||
@@check(funded_nonneg, "fundedCents >= 0")
|
||||
@@index([userId, dueOn])
|
||||
@@index([userId, priority])
|
||||
}
|
||||
|
||||
model IncomeEvent {
|
||||
@@ -53,10 +59,9 @@ model IncomeEvent {
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
postedAt DateTime
|
||||
amountCents BigInt
|
||||
|
||||
allocations Allocation[]
|
||||
|
||||
@@check(pos_amount, "amountCents > 0")
|
||||
@@index([userId, postedAt])
|
||||
}
|
||||
|
||||
model Allocation {
|
||||
@@ -64,13 +69,11 @@ model Allocation {
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
createdAt DateTime @default(now())
|
||||
kind String // 'savings' | 'variable' | 'fixed'
|
||||
kind String
|
||||
toId String
|
||||
amountCents BigInt
|
||||
incomeId String?
|
||||
income IncomeEvent? @relation(fields: [incomeId], references: [id])
|
||||
|
||||
@@check(pos_amount, "amountCents > 0")
|
||||
}
|
||||
|
||||
model Transaction {
|
||||
@@ -78,10 +81,10 @@ model Transaction {
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
occurredAt DateTime
|
||||
kind String // 'variable-spend' | 'fixed-payment'
|
||||
kind String
|
||||
categoryId String?
|
||||
planId String?
|
||||
amountCents BigInt
|
||||
|
||||
@@check(pos_amount, "amountCents > 0")
|
||||
@@index([userId, occurredAt])
|
||||
}
|
||||
|
||||
85
api/prisma/seed.ts
Normal file
85
api/prisma/seed.ts
Normal 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();
|
||||
});
|
||||
12
api/prisma/tsconfig.seed.json
Normal file
12
api/prisma/tsconfig.seed.json
Normal 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
154
api/src/allocator.ts
Normal 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
26
api/src/env.ts
Normal 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,
|
||||
});
|
||||
48
api/src/plugins/error-handler.ts
Normal file
48
api/src/plugins/error-handler.ts
Normal 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 });
|
||||
});
|
||||
}
|
||||
15
api/src/plugins/request-id.ts
Normal file
15
api/src/plugins/request-id.ts
Normal 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;
|
||||
23
api/src/plugins/user-stub.ts
Normal file
23
api/src/plugins/user-stub.ts
Normal 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
2
api/src/prisma.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
export const prisma = new PrismaClient();
|
||||
94
api/src/routes/fixed-plans.ts
Normal file
94
api/src/routes/fixed-plans.ts
Normal 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;
|
||||
82
api/src/routes/income-preview.ts
Normal file
82
api/src/routes/income-preview.ts
Normal 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) };
|
||||
});
|
||||
}
|
||||
69
api/src/routes/transactions.ts
Normal file
69
api/src/routes/transactions.ts
Normal 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 };
|
||||
});
|
||||
});
|
||||
85
api/src/routes/variable-categories.ts
Normal file
85
api/src/routes/variable-categories.ts
Normal 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;
|
||||
@@ -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());
|
||||
@@ -1,10 +1,516 @@
|
||||
// api/src/server.ts
|
||||
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 });
|
||||
|
||||
|
||||
declare module "fastify" {
|
||||
interface FastifyInstance { prisma: PrismaClient }
|
||||
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 };
|
||||
});
|
||||
|
||||
const port = Number(process.env.PORT ?? 8080);
|
||||
app.listen({ port, host: "0.0.0.0" }).catch((err) => {
|
||||
// ───────────── 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
8
api/src/types/fastify-prisma.d.ts
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
import "fastify";
|
||||
import type { PrismaClient } from "@prisma/client";
|
||||
|
||||
declare module "fastify" {
|
||||
interface FastifyInstance {
|
||||
prisma: PrismaClient;
|
||||
}
|
||||
}
|
||||
65
api/tests/allocator.test.ts
Normal file
65
api/tests/allocator.test.ts
Normal 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
8
api/tests/appFactory.ts
Normal 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
42
api/tests/helpers.ts
Normal 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();
|
||||
}
|
||||
71
api/tests/income.integration.test.ts
Normal file
71
api/tests/income.integration.test.ts
Normal 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
61
api/tests/income.test.ts
Normal 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
32
api/tests/setup.ts
Normal 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();
|
||||
});
|
||||
66
api/tests/transactions.test.ts
Normal file
66
api/tests/transactions.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
56
api/tests/variable-categories.guard.test.ts
Normal file
56
api/tests/variable-categories.guard.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ES2022",
|
||||
"moduleResolution": "bundler",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"rootDir": "src",
|
||||
"outDir": "dist",
|
||||
"strict": true,
|
||||
@@ -10,6 +10,6 @@
|
||||
"skipLibCheck": true,
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src"],
|
||||
"include": ["src", "env.ts"],
|
||||
"exclude": ["node_modules", "dist", "prisma.config.ts"]
|
||||
}
|
||||
14
api/vitest.config.ts
Normal file
14
api/vitest.config.ts
Normal 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'],
|
||||
},
|
||||
});
|
||||
@@ -1,11 +1,5 @@
|
||||
# docker-compose.yml (root)
|
||||
version: "3.9"
|
||||
|
||||
x-env: &api_env
|
||||
NODE_ENV: production
|
||||
PORT: "8080"
|
||||
DATABASE_URL: postgres://app:app@postgres:5432/skymoney
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:15
|
||||
@@ -15,55 +9,53 @@ services:
|
||||
POSTGRES_PASSWORD: app
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
ports:
|
||||
- "5432:5432"
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U app -d skymoney"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 10
|
||||
|
||||
api:
|
||||
build:
|
||||
context: ./api
|
||||
dockerfile: Dockerfile
|
||||
environment:
|
||||
<<: *api_env
|
||||
NODE_ENV: production
|
||||
PORT: "8080"
|
||||
DATABASE_URL: postgres://app:app@postgres:5432/skymoney
|
||||
CORS_ORIGIN: http://localhost:5173
|
||||
|
||||
depends_on:
|
||||
- postgres
|
||||
expose:
|
||||
- "8080"
|
||||
ports:
|
||||
- "8081:8080"
|
||||
restart: unless-stopped
|
||||
|
||||
# OPTIONAL: build web and serve with Caddy
|
||||
web:
|
||||
# If you want Compose to build your Vite app automatically:
|
||||
image: node:20-alpine
|
||||
working_dir: /app
|
||||
volumes:
|
||||
- ./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"
|
||||
healthcheck:
|
||||
# runs *inside* the container; port 8080 is the app's internal port
|
||||
test: ["CMD-SHELL", "wget -qO- http://localhost:8080/health || exit 1"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 10
|
||||
start_period: 10s
|
||||
|
||||
caddy:
|
||||
image: caddy:2
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
- "8080:80"
|
||||
volumes:
|
||||
- ./Caddyfile:/etc/caddy/Caddyfile:ro
|
||||
- caddydata:/data
|
||||
- caddyconfig:/config
|
||||
# Serve built web files (if using web service above)
|
||||
- webdist:/srv/site
|
||||
- ./Caddyfile.dev:/etc/caddy/Caddyfile:ro
|
||||
- ./web/dist:/srv/site:ro
|
||||
depends_on:
|
||||
- api
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -qO- http://localhost/ || exit 1"]
|
||||
interval: 10s
|
||||
timeout: 3s
|
||||
retries: 10
|
||||
|
||||
volumes:
|
||||
pgdata:
|
||||
caddydata:
|
||||
caddyconfig:
|
||||
webdist:
|
||||
|
||||
6
package-lock.json
generated
Normal file
6
package-lock.json
generated
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"name": "SkyMoney",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {}
|
||||
}
|
||||
2
web/.env.development
Normal file
2
web/.env.development
Normal file
@@ -0,0 +1,2 @@
|
||||
VITE_API_URL=http://localhost:8080/api
|
||||
VITE_APP_NAME=SkyMoney
|
||||
24
web/.gitignore
vendored
Normal file
24
web/.gitignore
vendored
Normal 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
73
web/README.md
Normal 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
23
web/eslint.config.js
Normal 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
13
web/index.html
Normal 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
5859
web/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
40
web/package.json
Normal file
40
web/package.json
Normal 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
42
web/src/App.css
Normal 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
63
web/src/App.tsx
Normal 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
30
web/src/api/categories.ts
Normal 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
30
web/src/api/client.ts
Normal 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
27
web/src/api/fixedPlans.ts
Normal 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
65
web/src/api/http.ts
Normal 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
67
web/src/api/schemas.ts
Normal 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>;
|
||||
20
web/src/api/transactions.ts
Normal file
20
web/src/api/transactions.ts
Normal 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);
|
||||
}
|
||||
31
web/src/components/CurrencyInput.tsx
Normal file
31
web/src/components/CurrencyInput.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
31
web/src/components/Pagination.tsx
Normal file
31
web/src/components/Pagination.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
17
web/src/components/PercentGuard.tsx
Normal file
17
web/src/components/PercentGuard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
19
web/src/components/Skeleton.tsx
Normal file
19
web/src/components/Skeleton.tsx
Normal 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>;
|
||||
}
|
||||
39
web/src/components/Toast.tsx
Normal file
39
web/src/components/Toast.tsx
Normal 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);
|
||||
}
|
||||
25
web/src/components/UserSwitcher.tsx
Normal file
25
web/src/components/UserSwitcher.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
32
web/src/components/charts/FixedFundingBars.tsx
Normal file
32
web/src/components/charts/FixedFundingBars.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
35
web/src/components/charts/VariableAllocationDonut.tsx
Normal file
35
web/src/components/charts/VariableAllocationDonut.tsx
Normal 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
36
web/src/components/ui.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
78
web/src/hooks/useCategories.ts
Normal file
78
web/src/hooks/useCategories.ts
Normal 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 })
|
||||
});
|
||||
}
|
||||
47
web/src/hooks/useDashboard.ts
Normal file
47
web/src/hooks/useDashboard.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
86
web/src/hooks/useFixedPlans.ts
Normal file
86
web/src/hooks/useFixedPlans.ts
Normal 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 }),
|
||||
});
|
||||
}
|
||||
15
web/src/hooks/useIncome.ts
Normal file
15
web/src/hooks/useIncome.ts
Normal 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),
|
||||
});
|
||||
}
|
||||
18
web/src/hooks/useIncomePreview.ts
Normal file
18
web/src/hooks/useIncomePreview.ts
Normal 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 }),
|
||||
});
|
||||
}
|
||||
28
web/src/hooks/useTransactions.ts
Normal file
28
web/src/hooks/useTransactions.ts
Normal 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
|
||||
}
|
||||
});
|
||||
}
|
||||
33
web/src/hooks/useTransactionsQuery.tsx
Normal file
33
web/src/hooks/useTransactionsQuery.tsx
Normal 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
128
web/src/index.css
Normal 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
30
web/src/main.tsx
Normal 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>
|
||||
);
|
||||
148
web/src/pages/DashboardPage.tsx
Normal file
148
web/src/pages/DashboardPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
18
web/src/pages/HealthPage.tsx
Normal file
18
web/src/pages/HealthPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
223
web/src/pages/IncomePage.tsx
Normal file
223
web/src/pages/IncomePage.tsx
Normal 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
168
web/src/pages/SpendPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
183
web/src/pages/TransactionsPage.tsx
Normal file
183
web/src/pages/TransactionsPage.tsx
Normal 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">
|
||||
Couldn’t 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>
|
||||
);
|
||||
}
|
||||
179
web/src/pages/settings/CategoriesPage.tsx
Normal file
179
web/src/pages/settings/CategoriesPage.tsx
Normal 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">Couldn’t 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>
|
||||
);
|
||||
}
|
||||
218
web/src/pages/settings/PlansPage.tsx
Normal file
218
web/src/pages/settings/PlansPage.tsx
Normal 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">Couldn’t 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>;
|
||||
}
|
||||
14
web/src/pages/settings/_SettingsNav.tsx
Normal file
14
web/src/pages/settings/_SettingsNav.tsx
Normal 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
132
web/src/styles.css
Normal 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; }
|
||||
77
web/src/utils/allocatorPreview.ts
Normal file
77
web/src/utils/allocatorPreview.ts
Normal 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
6
web/src/utils/format.ts
Normal 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
9
web/src/utils/money.ts
Normal 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);
|
||||
};
|
||||
120
web/tests/allocatorPreview.test.ts
Normal file
120
web/tests/allocatorPreview.test.ts
Normal 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
28
web/tsconfig.app.json
Normal 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
7
web/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
26
web/tsconfig.node.json
Normal file
26
web/tsconfig.node.json
Normal 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
14
web/vite.config.ts
Normal 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
9
web/vitest.config.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: "node",
|
||||
include: ["tests/**/*.test.ts"],
|
||||
reporters: "default",
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user