added api logic, vitest, minimal testing ui

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

View File

@@ -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/GET Normal file
View File

0
api/allocator Normal file
View File

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

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

0
api/distributes Normal file
View File

0
api/handles Normal file
View File

548
api/openapi.yaml Normal file
View File

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

2309
api/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,26 +1,34 @@
{
"name": "skymoney-api",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "tsx watch src/server.ts",
"build": "tsc -p tsconfig.json",
"scripts": {
"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
View File

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

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,13 @@
// prisma/schema.prisma
generator client { provider = "prisma-client-js" }
datasource db { provider = "postgresql"; url = env("DATABASE_URL") }
// prisma/schema.prisma
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,63 +32,59 @@ 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 {
id String @id @default(uuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
name String
cycleStart DateTime
dueOn DateTime
totalCents BigInt
fundedCents BigInt @default(0)
priority Int @default(100)
fundingMode String @default("auto-on-deposit") // or 'by-schedule'
id String @id @default(uuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
name String
cycleStart DateTime
dueOn DateTime
totalCents BigInt
fundedCents BigInt @default(0)
priority Int @default(100)
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 {
id String @id @default(uuid())
id String @id @default(uuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
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 {
id String @id @default(uuid())
id String @id @default(uuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
kind String // 'savings' | 'variable' | 'fixed'
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
kind String
toId String
amountCents BigInt
incomeId String?
income IncomeEvent? @relation(fields: [incomeId], references: [id])
@@check(pos_amount, "amountCents > 0")
}
model Transaction {
id String @id @default(uuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
occurredAt DateTime
kind String // 'variable-spend' | 'fixed-payment'
categoryId String?
planId String?
amountCents BigInt
id String @id @default(uuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
occurredAt DateTime
kind String
categoryId String?
planId String?
amountCents BigInt
@@check(pos_amount, "amountCents > 0")
@@index([userId, occurredAt])
}

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

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

View File

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

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

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

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

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,10 +1,516 @@
// api/src/server.ts
import Fastify from "fastify";
import cors from "@fastify/cors";
import rateLimit from "@fastify/rate-limit";
import { env } from "./env.js";
import { PrismaClient } from "@prisma/client";
import { z } from "zod";
import { allocateIncome } from "./allocator.js";
const app = Fastify({ logger: true });
app.get("/health", async () => ({ ok: true }));
const port = Number(process.env.PORT ?? 8080);
app.listen({ port, host: "0.0.0.0" }).catch((err) => {
app.log.error(err);
process.exit(1);
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 };
});
// ───────────── Dashboard ─────────────
app.get("/dashboard", async (req) => {
const userId = req.userId;
const [cats, plans, txs, agg] = await Promise.all([
app.prisma.variableCategory.findMany({
where: { userId }, orderBy: [{ priority: "asc" }, { name: "asc" }]
}),
app.prisma.fixedPlan.findMany({
where: { userId }, orderBy: [{ priority: "asc" }, { dueOn: "asc" }]
}),
app.prisma.transaction.findMany({
where: { userId }, orderBy: { occurredAt: "desc" }, take: 50,
select: { id: true, kind: true, amountCents: true, occurredAt: true }
}),
app.prisma.incomeEvent.aggregate({
where: { userId }, _sum: { amountCents: true }
}),
]);
const totals = {
incomeCents: Number(agg._sum?.amountCents ?? 0n),
variableBalanceCents: Number(cats.reduce((s, c) => s + (c.balanceCents ?? 0n), 0n)),
fixedRemainingCents: Number(plans.reduce((s, p) => {
const rem = (p.totalCents ?? 0n) - (p.fundedCents ?? 0n);
return s + (rem > 0n ? rem : 0n);
}, 0n)),
};
const percentTotal = cats.reduce((s, c) => s + c.percent, 0);
return { totals, variableCategories: cats, fixedPlans: plans, recentTransactions: txs, percentTotal };
});
// ───────────── Income (allocate) ─────────────
app.post("/income", async (req, reply) => {
const Body = z.object({ amountCents: z.number().int().nonnegative() });
const parsed = Body.safeParse(req.body);
if (!parsed.success) return reply.code(400).send({ message: "Invalid amount" });
const userId = req.userId;
const nowISO = new Date().toISOString();
const amountCentsNum = parsed.data.amountCents;
const income = await app.prisma.incomeEvent.create({
data: { userId, postedAt: new Date(nowISO), amountCents: toBig(amountCentsNum) },
select: { id: true },
});
const result = await allocateIncome(app.prisma, userId, amountCentsNum, nowISO, income.id);
return result;
});
// ───────────── Transactions: create (strict overdraft) ─────────────
app.post("/transactions", async (req, reply) => {
const Body = z.object({
kind: z.enum(["variable_spend", "fixed_payment"]),
amountCents: z.number().int().positive(),
occurredAtISO: z.string().datetime(),
categoryId: z.string().optional(),
planId: z.string().optional(),
});
const parsed = Body.safeParse(req.body);
if (!parsed.success) return reply.code(400).send({ message: "Invalid payload" });
const { kind, amountCents, occurredAtISO, categoryId, planId } = parsed.data;
const userId = req.userId;
const amt = toBig(amountCents);
return await app.prisma.$transaction(async (tx) => {
if (kind === "variable_spend") {
if (!categoryId) return reply.code(400).send({ message: "categoryId required" });
const cat = await tx.variableCategory.findFirst({ where: { id: categoryId, userId } });
if (!cat) return reply.code(404).send({ message: "Category not found" });
const bal = cat.balanceCents ?? 0n;
if (amt > bal) {
const err: any = new Error("Insufficient category balance");
err.statusCode = 400; err.code = "OVERDRAFT_CATEGORY";
throw err;
}
await tx.variableCategory.update({ where: { id: cat.id }, data: { balanceCents: bal - amt } });
} else {
if (!planId) return reply.code(400).send({ message: "planId required" });
const plan = await tx.fixedPlan.findFirst({ where: { id: planId, userId } });
if (!plan) return reply.code(404).send({ message: "Plan not found" });
const funded = plan.fundedCents ?? 0n;
if (amt > funded) {
const err: any = new Error("Insufficient plan funds");
err.statusCode = 400; err.code = "OVERDRAFT_PLAN";
throw err;
}
await tx.fixedPlan.update({ where: { id: plan.id }, data: { fundedCents: funded - amt } });
}
const row = await tx.transaction.create({
data: {
userId,
occurredAt: new Date(occurredAtISO),
kind,
amountCents: amt,
categoryId: kind === "variable_spend" ? categoryId ?? null : null,
planId: kind === "fixed_payment" ? planId ?? null : null,
},
select: { id: true, kind: true, amountCents: true, occurredAt: true },
});
return row;
});
});
// ───────────── Transactions: list ─────────────
app.get("/transactions", async (req, reply) => {
const Query = z.object({
from: z.string().refine(isDate, "YYYY-MM-DD").optional(),
to: z.string().refine(isDate, "YYYY-MM-DD").optional(),
kind: z.enum(["variable_spend", "fixed_payment"]).optional(),
q: z.string().trim().optional(),
page: z.coerce.number().int().positive().default(1),
limit: z.coerce.number().int().positive().max(100).default(20),
});
const parsed = Query.safeParse(req.query);
if (!parsed.success) {
return reply.code(400).send({ message: "Invalid query", issues: parsed.error.issues });
}
const { from, to, kind, q, page, limit } = parsed.data;
const userId = req.userId;
const where: any = { userId };
if (from || to) {
where.occurredAt = {};
if (from) where.occurredAt.gte = isoStart(from);
if (to) where.occurredAt.lte = isoEnd(to);
}
if (kind) where.kind = kind;
// 💡 Only add OR if we actually have predicates
if (typeof q === "string" && q.trim() !== "") {
const ors: any[] = [];
const asNumber = Number(q);
if (Number.isFinite(asNumber)) {
ors.push({ amountCents: toBig(asNumber) });
}
// (When you add text fields later, push them here too)
if (ors.length > 0) {
where.OR = ors;
}
}
const skip = (page - 1) * limit;
const [total, items] = await Promise.all([
app.prisma.transaction.count({ where }),
app.prisma.transaction.findMany({
where,
orderBy: { occurredAt: "desc" },
skip,
take: limit,
select: { id: true, kind: true, amountCents: true, occurredAt: true },
}),
]);
return { items, page, limit, total };
});
// ───────────── Variable Categories CRUD (sum=100 guard) ─────────────
const CatBody = z.object({
name: z.string().trim().min(1),
percent: z.number().int().min(0).max(100),
isSavings: z.boolean(),
priority: z.number().int().min(0),
});
app.post("/variable-categories", async (req, reply) => {
const parsed = CatBody.safeParse(req.body);
if (!parsed.success) return reply.code(400).send({ message: "Invalid payload" });
const userId = req.userId;
return await app.prisma.$transaction(async (tx) => {
await tx.variableCategory.create({ data: { userId, balanceCents: 0n, ...parsed.data } });
const total = await tx.variableCategory.aggregate({ where: { userId }, _sum: { percent: true } });
if ((total._sum?.percent ?? 0) !== 100) throw reply.code(400).send({ message: "Percents must sum to 100" });
return { ok: true };
});
});
app.patch("/variable-categories/:id", async (req, reply) => {
const patch = CatBody.partial().safeParse(req.body);
if (!patch.success) return reply.code(400).send({ message: "Invalid payload" });
const id = String((req.params as any).id);
const userId = req.userId;
return await app.prisma.$transaction(async (tx) => {
const exists = await tx.variableCategory.findFirst({ where: { id, userId } });
if (!exists) return reply.code(404).send({ message: "Not found" });
await tx.variableCategory.update({ where: { id }, data: patch.data });
const total = await tx.variableCategory.aggregate({ where: { userId }, _sum: { percent: true } });
if ((total._sum?.percent ?? 0) !== 100) throw reply.code(400).send({ message: "Percents must sum to 100" });
return { ok: true };
});
});
app.delete("/variable-categories/:id", async (req, reply) => {
const id = String((req.params as any).id);
const userId = req.userId;
return await app.prisma.$transaction(async (tx) => {
const exists = await tx.variableCategory.findFirst({ where: { id, userId } });
if (!exists) return reply.code(404).send({ message: "Not found" });
await tx.variableCategory.delete({ where: { id } });
const total = await tx.variableCategory.aggregate({ where: { userId }, _sum: { percent: true } });
if ((total._sum?.percent ?? 0) !== 100) throw reply.code(400).send({ message: "Percents must sum to 100" });
return { ok: true };
});
});
// ───────────── Fixed Plans CRUD (funded ≤ total) ─────────────
const PlanBody = z.object({
name: z.string().trim().min(1),
totalCents: z.number().int().min(0),
fundedCents: z.number().int().min(0).optional(),
priority: z.number().int().min(0),
dueOn: z.string().datetime(),
cycleStart: z.string().datetime().optional(),
});
app.post("/fixed-plans", async (req, reply) => {
const parsed = PlanBody.safeParse(req.body);
if (!parsed.success) return reply.code(400).send({ message: "Invalid payload" });
const userId = req.userId;
const totalBig = toBig(parsed.data.totalCents);
const fundedBig = toBig(parsed.data.fundedCents ?? 0);
if (fundedBig > totalBig) return reply.code(400).send({ message: "fundedCents cannot exceed totalCents" });
await app.prisma.fixedPlan.create({
data: {
userId,
name: parsed.data.name,
totalCents: totalBig,
fundedCents: fundedBig,
priority: parsed.data.priority,
dueOn: new Date(parsed.data.dueOn),
cycleStart: new Date(parsed.data.cycleStart ?? parsed.data.dueOn),
fundingMode: "auto-on-deposit",
},
});
return { ok: true };
});
app.patch("/fixed-plans/:id", async (req, reply) => {
const patch = PlanBody.partial().safeParse(req.body);
if (!patch.success) return reply.code(400).send({ message: "Invalid payload" });
const id = String((req.params as any).id);
const userId = req.userId;
const plan = await app.prisma.fixedPlan.findFirst({ where: { id, userId } });
if (!plan) return reply.code(404).send({ message: "Not found" });
const total = "totalCents" in patch.data ? toBig(patch.data.totalCents as number) : (plan.totalCents ?? 0n);
const funded = "fundedCents" in patch.data ? toBig(patch.data.fundedCents as number) : (plan.fundedCents ?? 0n);
if (funded > total) return reply.code(400).send({ message: "fundedCents cannot exceed totalCents" });
await app.prisma.fixedPlan.update({
where: { id },
data: {
...patch.data,
...(patch.data.totalCents !== undefined ? { totalCents: total } : {}),
...(patch.data.fundedCents !== undefined ? { fundedCents: funded } : {}),
...(patch.data.dueOn ? { dueOn: new Date(patch.data.dueOn) } : {}),
...(patch.data.cycleStart ? { cycleStart: new Date(patch.data.cycleStart) } : {}),
},
});
return { ok: true };
});
app.delete("/fixed-plans/:id", async (req, reply) => {
const id = String((req.params as any).id);
const userId = req.userId;
const plan = await app.prisma.fixedPlan.findFirst({ where: { id, userId } });
if (!plan) return reply.code(404).send({ message: "Not found" });
await app.prisma.fixedPlan.delete({ where: { id } });
return { ok: true };
});
// ───────────── Income Preview (server-side; mirrors FE preview) ─────────────
app.post("/income/preview", async (req, reply) => {
const Body = z.object({ amountCents: z.number().int().nonnegative() });
const parsed = Body.safeParse(req.body);
if (!parsed.success) return reply.code(400).send({ message: "Invalid amount" });
const userId = req.userId;
let remaining = Math.max(0, parsed.data.amountCents | 0);
const [plans, cats] = await Promise.all([
app.prisma.fixedPlan.findMany({
where: { userId }, orderBy: [{ priority: "asc" }, { dueOn: "asc" }],
select: { id: true, name: true, totalCents: true, fundedCents: true, priority: true, dueOn: true },
}),
app.prisma.variableCategory.findMany({
where: { userId }, orderBy: [{ priority: "asc" }, { name: "asc" }],
select: { id: true, name: true, percent: true, isSavings: true, priority: true },
}),
]);
// Fixed pass
const fixed: Array<{ id: string; name: string; amountCents: number }> = [];
for (const p of plans) {
if (remaining <= 0) break;
const needBig = (p.totalCents ?? 0n) - (p.fundedCents ?? 0n);
const need = Number(needBig > 0n ? needBig : 0n);
if (need <= 0) continue;
const give = Math.min(need, remaining);
fixed.push({ id: p.id, name: p.name, amountCents: give });
remaining -= give;
}
// Variable pass (largest remainder w/ savings-first tie)
const variable: Array<{ id: string; name: string; amountCents: number }> = [];
if (remaining > 0 && cats.length > 0) {
const totalPercent = cats.reduce((s, c) => s + (c.percent || 0), 0);
const normCats = totalPercent === 100
? cats
: cats.map(c => ({ ...c, percent: totalPercent > 0 ? (c.percent * 100) / totalPercent : 0 }));
const base: number[] = new Array(normCats.length).fill(0);
const tie: { idx: number; remainder: number; isSavings: boolean; priority: number; name: string }[] = [];
let sumBase = 0;
normCats.forEach((c, idx) => {
const exact = (remaining * c.percent) / 100;
const floor = Math.floor(exact);
base[idx] = floor; sumBase += floor;
tie.push({ idx, remainder: exact - floor, isSavings: !!c.isSavings, priority: c.priority, name: c.name });
});
let leftovers = remaining - sumBase;
tie.sort((a, b) => {
if (a.isSavings !== b.isSavings) return a.isSavings ? -1 : 1;
if (a.remainder !== b.remainder) return b.remainder - a.remainder;
if (a.priority !== b.priority) return a.priority - b.priority;
return a.name.localeCompare(b.name);
});
for (let i = 0; i < tie.length && leftovers > 0; i++, leftovers--) base[tie[i].idx] += 1;
normCats.forEach((c, idx) => {
const give = base[idx] || 0;
if (give > 0) variable.push({ id: c.id, name: c.name, amountCents: give });
});
remaining = leftovers;
}
return { fixed, variable, unallocatedCents: Math.max(0, remaining) };
});
// ───────────── Start ─────────────
const PORT = env.PORT;
const HOST = process.env.HOST || "0.0.0.0";
export default app; // <-- add this
if (process.env.NODE_ENV !== "test") {
app.listen({ port: PORT, host: HOST }).catch((err) => {
app.log.error(err);
process.exit(1);
});
}

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

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

View File

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

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

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

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

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

View File

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

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

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

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

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

View File

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

View File

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

View File

@@ -1,8 +1,8 @@
{
"compilerOptions": {
"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
View File

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