added api logic, vitest, minimal testing ui
This commit is contained in:
@@ -1,27 +1,38 @@
|
||||
FROM node:20-alpine AS deps
|
||||
FROM node:20-bookworm-slim AS deps
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json* ./
|
||||
RUN npm ci --omit=dev
|
||||
|
||||
FROM node:20-alpine AS build
|
||||
FROM node:20-bookworm-slim AS build
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json* ./
|
||||
RUN npm ci
|
||||
COPY tsconfig.json ./
|
||||
COPY src ./src
|
||||
COPY prisma ./prisma
|
||||
COPY src ./src
|
||||
RUN npx prisma generate
|
||||
RUN npm run build
|
||||
|
||||
FROM node:20-alpine AS runner
|
||||
FROM node:20-bookworm-slim AS runner
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production
|
||||
# runtime files
|
||||
|
||||
# optional but nice
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# 1) deps: prod node_modules
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
|
||||
# 2) app build output
|
||||
COPY --from=build /app/dist ./dist
|
||||
COPY --from=build /app/prisma ./prisma
|
||||
# entrypoint does migrate deploy + start
|
||||
|
||||
# 3) 🔑 copy the generated Prisma client/artifacts from build stage
|
||||
COPY --from=build /app/node_modules/@prisma/client ./node_modules/@prisma/client
|
||||
COPY --from=build /app/node_modules/.prisma ./node_modules/.prisma
|
||||
|
||||
COPY entrypoint.sh ./entrypoint.sh
|
||||
RUN chmod +x /app/entrypoint.sh
|
||||
RUN sed -i 's/\r$//' /app/entrypoint.sh && chmod +x /app/entrypoint.sh
|
||||
|
||||
EXPOSE 8080
|
||||
CMD ["/app/entrypoint.sh"]
|
||||
0
api/allocator
Normal file
0
api/allocator
Normal file
244
api/clients/ts/sdk.ts
Normal file
244
api/clients/ts/sdk.ts
Normal file
@@ -0,0 +1,244 @@
|
||||
/* SkyMoney SDK: zero-dep, Fetch-based, TypeScript-first.
|
||||
Usage:
|
||||
import { SkyMoney } from "./sdk";
|
||||
const api = new SkyMoney({ baseUrl: import.meta.env.VITE_API_URL });
|
||||
const dash = await api.dashboard.get();
|
||||
*/
|
||||
|
||||
export type TransactionKind = "variable_spend" | "fixed_payment";
|
||||
|
||||
export interface OkResponse { ok: true }
|
||||
export interface ErrorResponse {
|
||||
ok: false; code: string; message: string; requestId: string;
|
||||
}
|
||||
|
||||
export interface VariableCategory {
|
||||
id: string;
|
||||
userId?: string;
|
||||
name: string;
|
||||
percent: number; // 0..100
|
||||
isSavings: boolean;
|
||||
priority: number;
|
||||
balanceCents?: number;
|
||||
}
|
||||
|
||||
export interface FixedPlan {
|
||||
id: string;
|
||||
userId?: string;
|
||||
name: string;
|
||||
totalCents?: number;
|
||||
fundedCents?: number;
|
||||
priority: number;
|
||||
dueOn: string; // ISO
|
||||
cycleStart?: string;// ISO
|
||||
}
|
||||
|
||||
export interface Transaction {
|
||||
id: string;
|
||||
userId?: string;
|
||||
kind: TransactionKind;
|
||||
amountCents: number;
|
||||
occurredAt: string; // ISO
|
||||
categoryId?: string | null;
|
||||
planId?: string | null;
|
||||
}
|
||||
|
||||
export interface TransactionList {
|
||||
items: Transaction[];
|
||||
page: number;
|
||||
limit: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface DashboardResponse {
|
||||
totals: {
|
||||
incomeCents: number;
|
||||
variableBalanceCents: number;
|
||||
fixedRemainingCents: number;
|
||||
};
|
||||
percentTotal: number;
|
||||
variableCategories: VariableCategory[];
|
||||
fixedPlans: FixedPlan[];
|
||||
recentTransactions: Transaction[];
|
||||
}
|
||||
|
||||
export interface IncomeRequest { amountCents: number; }
|
||||
|
||||
export interface AllocationItem {
|
||||
id: string; name: string; amountCents: number;
|
||||
}
|
||||
|
||||
export interface IncomePreviewResponse {
|
||||
fixed: AllocationItem[];
|
||||
variable: AllocationItem[];
|
||||
unallocatedCents: number;
|
||||
}
|
||||
|
||||
// allocateIncome returns a richer object; tests expect these fields:
|
||||
export interface IncomeAllocationResponse {
|
||||
fixedAllocations?: AllocationItem[];
|
||||
variableAllocations?: AllocationItem[];
|
||||
remainingUnallocatedCents?: number;
|
||||
// allow any extra fields without type errors:
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
[k: string]: any;
|
||||
}
|
||||
|
||||
export type FetchLike = typeof fetch;
|
||||
|
||||
export type SDKOptions = {
|
||||
baseUrl?: string;
|
||||
userId?: string;
|
||||
fetch?: FetchLike;
|
||||
requestIdFactory?: () => string; // to set x-request-id if desired
|
||||
};
|
||||
|
||||
function makeQuery(params: Record<string, unknown | undefined>): string {
|
||||
const sp = new URLSearchParams();
|
||||
for (const [k, v] of Object.entries(params)) {
|
||||
if (v === undefined || v === null || v === "") continue;
|
||||
sp.set(k, String(v));
|
||||
}
|
||||
const s = sp.toString();
|
||||
return s ? `?${s}` : "";
|
||||
}
|
||||
|
||||
export class SkyMoney {
|
||||
readonly baseUrl: string;
|
||||
private readonly f: FetchLike;
|
||||
private readonly reqId?: () => string;
|
||||
userId?: string;
|
||||
|
||||
constructor(opts: SDKOptions = {}) {
|
||||
this.baseUrl = (opts.baseUrl || "http://localhost:8080").replace(/\/+$/, "");
|
||||
this.userId = opts.userId ?? (
|
||||
// Try localStorage if present (browser)
|
||||
typeof localStorage !== "undefined" ? localStorage.getItem("x-user-id") || undefined : undefined
|
||||
);
|
||||
this.f = opts.fetch || fetch;
|
||||
this.reqId = opts.requestIdFactory;
|
||||
}
|
||||
|
||||
private async request<T>(
|
||||
method: "GET" | "POST" | "PATCH" | "DELETE",
|
||||
path: string,
|
||||
body?: unknown,
|
||||
query?: Record<string, unknown>,
|
||||
headers?: Record<string, string>
|
||||
): Promise<T> {
|
||||
const url = `${this.baseUrl}${path}${query ? makeQuery(query) : ""}`;
|
||||
const h: Record<string, string> = { ...(headers || {}) };
|
||||
if (this.userId) h["x-user-id"] = this.userId;
|
||||
if (this.reqId) h["x-request-id"] = this.reqId();
|
||||
const hasBody = body !== undefined && body !== null;
|
||||
|
||||
const res = await this.f(url, {
|
||||
method,
|
||||
headers: {
|
||||
...(hasBody ? { "content-type": "application/json" } : {}),
|
||||
...h,
|
||||
},
|
||||
body: hasBody ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
|
||||
// Attempt to parse JSON; fall back to text
|
||||
const text = await res.text();
|
||||
const data = text ? safeJson(text) : undefined;
|
||||
|
||||
if (!res.ok) {
|
||||
const err = new Error((data as any)?.message || `HTTP ${res.status}`);
|
||||
(err as any).status = res.status;
|
||||
(err as any).body = data ?? text;
|
||||
throw err;
|
||||
}
|
||||
return data as T;
|
||||
}
|
||||
|
||||
// ---- Health
|
||||
health = {
|
||||
get: () => this.request<{ ok: true }>("GET", "/health"),
|
||||
db: () => this.request<{ ok: true; nowISO: string; latencyMs: number }>("GET", "/health/db"),
|
||||
};
|
||||
|
||||
// ---- Dashboard
|
||||
dashboard = {
|
||||
get: () => this.request<DashboardResponse>("GET", "/dashboard"),
|
||||
};
|
||||
|
||||
// ---- Income
|
||||
income = {
|
||||
preview: (amountCents: number) =>
|
||||
this.request<IncomePreviewResponse>("POST", "/income/preview", { amountCents }),
|
||||
create: (amountCents: number) =>
|
||||
this.request<IncomeAllocationResponse>("POST", "/income", { amountCents }),
|
||||
};
|
||||
|
||||
// ---- Transactions
|
||||
transactions = {
|
||||
list: (args: {
|
||||
from?: string; // YYYY-MM-DD
|
||||
to?: string; // YYYY-MM-DD
|
||||
kind?: TransactionKind;
|
||||
q?: string;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
}) =>
|
||||
this.request<TransactionList>("GET", "/transactions", undefined, args),
|
||||
create: (payload: {
|
||||
kind: TransactionKind;
|
||||
amountCents: number;
|
||||
occurredAtISO: string;
|
||||
categoryId?: string;
|
||||
planId?: string;
|
||||
}) => this.request<Transaction>("POST", "/transactions", payload),
|
||||
};
|
||||
|
||||
// ---- Variable Categories
|
||||
variableCategories = {
|
||||
create: (payload: {
|
||||
name: string;
|
||||
percent: number;
|
||||
isSavings: boolean;
|
||||
priority: number;
|
||||
}) => this.request<OkResponse>("POST", "/variable-categories", payload),
|
||||
|
||||
update: (id: string, patch: Partial<{
|
||||
name: string;
|
||||
percent: number;
|
||||
isSavings: boolean;
|
||||
priority: number;
|
||||
}>) => this.request<OkResponse>("PATCH", `/variable-categories/${encodeURIComponent(id)}`, patch),
|
||||
|
||||
delete: (id: string) =>
|
||||
this.request<OkResponse>("DELETE", `/variable-categories/${encodeURIComponent(id)}`),
|
||||
};
|
||||
|
||||
// ---- Fixed Plans
|
||||
fixedPlans = {
|
||||
create: (payload: {
|
||||
name: string;
|
||||
totalCents: number;
|
||||
fundedCents?: number;
|
||||
priority: number;
|
||||
dueOn: string; // ISO
|
||||
cycleStart?: string; // ISO
|
||||
}) => this.request<OkResponse>("POST", "/fixed-plans", payload),
|
||||
|
||||
update: (id: string, patch: Partial<{
|
||||
name: string;
|
||||
totalCents: number;
|
||||
fundedCents: number;
|
||||
priority: number;
|
||||
dueOn: string;
|
||||
cycleStart: string;
|
||||
}>) => this.request<OkResponse>("PATCH", `/fixed-plans/${encodeURIComponent(id)}`, patch),
|
||||
|
||||
delete: (id: string) =>
|
||||
this.request<OkResponse>("DELETE", `/fixed-plans/${encodeURIComponent(id)}`),
|
||||
};
|
||||
}
|
||||
|
||||
// ---------- helpers ----------
|
||||
function safeJson(s: string) {
|
||||
try { return JSON.parse(s) } catch { return s }
|
||||
}
|
||||
0
api/distributes
Normal file
0
api/distributes
Normal file
0
api/handles
Normal file
0
api/handles
Normal file
548
api/openapi.yaml
Normal file
548
api/openapi.yaml
Normal file
@@ -0,0 +1,548 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: SkyMoney API
|
||||
version: 0.1.0
|
||||
description: |
|
||||
Fastify backend for budgeting/allocations.
|
||||
Most endpoints accept an optional `x-user-id` header; when omitted, the server
|
||||
defaults to `demo-user-1`.
|
||||
|
||||
servers:
|
||||
- url: http://localhost:8080
|
||||
|
||||
tags:
|
||||
- name: Health
|
||||
- name: Dashboard
|
||||
- name: Income
|
||||
- name: Transactions
|
||||
- name: VariableCategories
|
||||
- name: FixedPlans
|
||||
|
||||
paths:
|
||||
/health:
|
||||
get:
|
||||
tags: [Health]
|
||||
summary: Liveness check
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/UserId'
|
||||
- $ref: '#/components/parameters/RequestId'
|
||||
responses:
|
||||
'200':
|
||||
description: OK
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/HealthOk' }
|
||||
|
||||
/health/db:
|
||||
get:
|
||||
tags: [Health]
|
||||
summary: DB health + latency
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/UserId'
|
||||
- $ref: '#/components/parameters/RequestId'
|
||||
responses:
|
||||
'200':
|
||||
description: DB OK
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/DbHealth' }
|
||||
|
||||
/dashboard:
|
||||
get:
|
||||
tags: [Dashboard]
|
||||
summary: Aggregated dashboard data
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/UserId'
|
||||
- $ref: '#/components/parameters/RequestId'
|
||||
responses:
|
||||
'200':
|
||||
description: Dashboard payload
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/DashboardResponse' }
|
||||
'400':
|
||||
$ref: '#/components/responses/BadRequest'
|
||||
'500':
|
||||
$ref: '#/components/responses/InternalError'
|
||||
|
||||
/income:
|
||||
post:
|
||||
tags: [Income]
|
||||
summary: Create income event and allocate funds
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/UserId'
|
||||
- $ref: '#/components/parameters/RequestId'
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/IncomeRequest' }
|
||||
responses:
|
||||
'200':
|
||||
description: Allocation result
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/IncomeAllocationResponse' }
|
||||
'400':
|
||||
$ref: '#/components/responses/BadRequest'
|
||||
'500':
|
||||
$ref: '#/components/responses/InternalError'
|
||||
|
||||
/income/preview:
|
||||
post:
|
||||
tags: [Income]
|
||||
summary: Preview allocation of a hypothetical income amount
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/UserId'
|
||||
- $ref: '#/components/parameters/RequestId'
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/IncomeRequest' }
|
||||
responses:
|
||||
'200':
|
||||
description: Preview
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/IncomePreviewResponse' }
|
||||
'400':
|
||||
$ref: '#/components/responses/BadRequest'
|
||||
'500':
|
||||
$ref: '#/components/responses/InternalError'
|
||||
|
||||
/transactions:
|
||||
get:
|
||||
tags: [Transactions]
|
||||
summary: List transactions with filters and pagination
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/UserId'
|
||||
- $ref: '#/components/parameters/RequestId'
|
||||
- in: query
|
||||
name: from
|
||||
schema: { type: string, pattern: '^\d{4}-\d{2}-\d{2}$' }
|
||||
description: Inclusive start date (YYYY-MM-DD)
|
||||
- in: query
|
||||
name: to
|
||||
schema: { type: string, pattern: '^\d{4}-\d{2}-\d{2}$' }
|
||||
description: Inclusive end date (YYYY-MM-DD)
|
||||
- in: query
|
||||
name: kind
|
||||
schema: { $ref: '#/components/schemas/TransactionKind' }
|
||||
- in: query
|
||||
name: q
|
||||
schema: { type: string }
|
||||
description: Simple search (currently numeric amount match)
|
||||
- in: query
|
||||
name: page
|
||||
schema: { type: integer, minimum: 1, default: 1 }
|
||||
- in: query
|
||||
name: limit
|
||||
schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
|
||||
responses:
|
||||
'200':
|
||||
description: List
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/TransactionList' }
|
||||
'400':
|
||||
$ref: '#/components/responses/BadRequest'
|
||||
'500':
|
||||
$ref: '#/components/responses/InternalError'
|
||||
post:
|
||||
tags: [Transactions]
|
||||
summary: Create a transaction
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/UserId'
|
||||
- $ref: '#/components/parameters/RequestId'
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/TransactionCreate' }
|
||||
responses:
|
||||
'200':
|
||||
description: Created
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/Transaction' }
|
||||
'400':
|
||||
$ref: '#/components/responses/BadRequest'
|
||||
'404':
|
||||
$ref: '#/components/responses/NotFound'
|
||||
'500':
|
||||
$ref: '#/components/responses/InternalError'
|
||||
|
||||
/variable-categories:
|
||||
post:
|
||||
tags: [VariableCategories]
|
||||
summary: Create a variable category (sum of percents must be 100)
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/UserId'
|
||||
- $ref: '#/components/parameters/RequestId'
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/VariableCategoryCreate' }
|
||||
responses:
|
||||
'200':
|
||||
description: OK
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/OkResponse' }
|
||||
'400':
|
||||
$ref: '#/components/responses/BadRequest'
|
||||
'500':
|
||||
$ref: '#/components/responses/InternalError'
|
||||
|
||||
/variable-categories/{id}:
|
||||
patch:
|
||||
tags: [VariableCategories]
|
||||
summary: Update a variable category (sum of percents must be 100)
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/UserId'
|
||||
- $ref: '#/components/parameters/RequestId'
|
||||
- in: path
|
||||
name: id
|
||||
required: true
|
||||
schema: { type: string }
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/VariableCategoryPatch' }
|
||||
responses:
|
||||
'200':
|
||||
description: OK
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/OkResponse' }
|
||||
'400':
|
||||
$ref: '#/components/responses/BadRequest'
|
||||
'404':
|
||||
$ref: '#/components/responses/NotFound'
|
||||
'500':
|
||||
$ref: '#/components/responses/InternalError'
|
||||
delete:
|
||||
tags: [VariableCategories]
|
||||
summary: Delete a variable category (sum of percents must remain 100)
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/UserId'
|
||||
- $ref: '#/components/parameters/RequestId'
|
||||
- in: path
|
||||
name: id
|
||||
required: true
|
||||
schema: { type: string }
|
||||
responses:
|
||||
'200':
|
||||
description: OK
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/OkResponse' }
|
||||
'404':
|
||||
$ref: '#/components/responses/NotFound'
|
||||
'500':
|
||||
$ref: '#/components/responses/InternalError'
|
||||
|
||||
/fixed-plans:
|
||||
post:
|
||||
tags: [FixedPlans]
|
||||
summary: Create a fixed plan
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/UserId'
|
||||
- $ref: '#/components/parameters/RequestId'
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/FixedPlanCreate' }
|
||||
responses:
|
||||
'200':
|
||||
description: OK
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/OkResponse' }
|
||||
'400':
|
||||
$ref: '#/components/responses/BadRequest'
|
||||
'500':
|
||||
$ref: '#/components/responses/InternalError'
|
||||
|
||||
/fixed-plans/{id}:
|
||||
patch:
|
||||
tags: [FixedPlans]
|
||||
summary: Update a fixed plan (fundedCents cannot exceed totalCents)
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/UserId'
|
||||
- $ref: '#/components/parameters/RequestId'
|
||||
- in: path
|
||||
name: id
|
||||
required: true
|
||||
schema: { type: string }
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/FixedPlanPatch' }
|
||||
responses:
|
||||
'200':
|
||||
description: OK
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/OkResponse' }
|
||||
'400':
|
||||
$ref: '#/components/responses/BadRequest'
|
||||
'404':
|
||||
$ref: '#/components/responses/NotFound'
|
||||
'500':
|
||||
$ref: '#/components/responses/InternalError'
|
||||
delete:
|
||||
tags: [FixedPlans]
|
||||
summary: Delete a fixed plan
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/UserId'
|
||||
- $ref: '#/components/parameters/RequestId'
|
||||
- in: path
|
||||
name: id
|
||||
required: true
|
||||
schema: { type: string }
|
||||
responses:
|
||||
'200':
|
||||
description: OK
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/OkResponse' }
|
||||
'404':
|
||||
$ref: '#/components/responses/NotFound'
|
||||
'500':
|
||||
$ref: '#/components/responses/InternalError'
|
||||
|
||||
components:
|
||||
parameters:
|
||||
UserId:
|
||||
in: header
|
||||
name: x-user-id
|
||||
required: false
|
||||
schema: { type: string }
|
||||
description: Override the stubbed user id for the request.
|
||||
RequestId:
|
||||
in: header
|
||||
name: x-request-id
|
||||
required: false
|
||||
schema: { type: string, maxLength: 64 }
|
||||
description: Custom request id (echoed back by server).
|
||||
|
||||
responses:
|
||||
BadRequest:
|
||||
description: Validation or guard failed
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/ErrorResponse' }
|
||||
NotFound:
|
||||
description: Resource not found
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/ErrorResponse' }
|
||||
InternalError:
|
||||
description: Unexpected server error
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/ErrorResponse' }
|
||||
|
||||
schemas:
|
||||
HealthOk:
|
||||
type: object
|
||||
properties:
|
||||
ok: { type: boolean, const: true }
|
||||
required: [ok]
|
||||
|
||||
DbHealth:
|
||||
type: object
|
||||
properties:
|
||||
ok: { type: boolean, const: true }
|
||||
nowISO: { type: string, format: date-time }
|
||||
latencyMs: { type: integer, minimum: 0 }
|
||||
required: [ok, nowISO, latencyMs]
|
||||
|
||||
OkResponse:
|
||||
type: object
|
||||
properties:
|
||||
ok: { type: boolean, const: true }
|
||||
required: [ok]
|
||||
|
||||
ErrorResponse:
|
||||
type: object
|
||||
properties:
|
||||
ok: { type: boolean, const: false }
|
||||
code: { type: string }
|
||||
message: { type: string }
|
||||
requestId: { type: string }
|
||||
required: [ok, code, message, requestId]
|
||||
|
||||
VariableCategory:
|
||||
type: object
|
||||
properties:
|
||||
id: { type: string }
|
||||
userId: { type: string }
|
||||
name: { type: string }
|
||||
percent: { type: integer, minimum: 0, maximum: 100 }
|
||||
isSavings: { type: boolean }
|
||||
priority: { type: integer, minimum: 0 }
|
||||
balanceCents:
|
||||
type: integer
|
||||
description: Current balance; may be omitted or 0 when not loaded.
|
||||
required: [id, userId, name, percent, isSavings, priority]
|
||||
|
||||
FixedPlan:
|
||||
type: object
|
||||
properties:
|
||||
id: { type: string }
|
||||
userId: { type: string }
|
||||
name: { type: string }
|
||||
totalCents: { type: integer, minimum: 0 }
|
||||
fundedCents: { type: integer, minimum: 0 }
|
||||
priority: { type: integer, minimum: 0 }
|
||||
dueOn: { type: string, format: date-time }
|
||||
cycleStart: { type: string, format: date-time }
|
||||
required: [id, userId, name, priority, dueOn]
|
||||
|
||||
TransactionKind:
|
||||
type: string
|
||||
enum: [variable_spend, fixed_payment]
|
||||
|
||||
Transaction:
|
||||
type: object
|
||||
properties:
|
||||
id: { type: string }
|
||||
userId: { type: string }
|
||||
kind: { $ref: '#/components/schemas/TransactionKind' }
|
||||
amountCents: { type: integer, minimum: 0 }
|
||||
occurredAt: { type: string, format: date-time }
|
||||
categoryId: { type: string, nullable: true }
|
||||
planId: { type: string, nullable: true }
|
||||
required: [id, userId, kind, amountCents, occurredAt]
|
||||
|
||||
TransactionList:
|
||||
type: object
|
||||
properties:
|
||||
items:
|
||||
type: array
|
||||
items: { $ref: '#/components/schemas/Transaction' }
|
||||
page: { type: integer, minimum: 1 }
|
||||
limit: { type: integer, minimum: 1, maximum: 100 }
|
||||
total: { type: integer, minimum: 0 }
|
||||
required: [items, page, limit, total]
|
||||
|
||||
TransactionCreate:
|
||||
type: object
|
||||
properties:
|
||||
kind: { $ref: '#/components/schemas/TransactionKind' }
|
||||
amountCents: { type: integer, minimum: 1 }
|
||||
occurredAtISO: { type: string, format: date-time }
|
||||
categoryId: { type: string, nullable: true }
|
||||
planId: { type: string, nullable: true }
|
||||
required: [kind, amountCents, occurredAtISO]
|
||||
|
||||
DashboardResponse:
|
||||
type: object
|
||||
properties:
|
||||
totals:
|
||||
type: object
|
||||
properties:
|
||||
incomeCents: { type: integer, minimum: 0 }
|
||||
variableBalanceCents: { type: integer, minimum: 0 }
|
||||
fixedRemainingCents: { type: integer, minimum: 0 }
|
||||
required: [incomeCents, variableBalanceCents, fixedRemainingCents]
|
||||
percentTotal: { type: integer, minimum: 0, maximum: 100 }
|
||||
variableCategories:
|
||||
type: array
|
||||
items: { $ref: '#/components/schemas/VariableCategory' }
|
||||
fixedPlans:
|
||||
type: array
|
||||
items: { $ref: '#/components/schemas/FixedPlan' }
|
||||
recentTransactions:
|
||||
type: array
|
||||
items: { $ref: '#/components/schemas/Transaction' }
|
||||
required: [totals, percentTotal, variableCategories, fixedPlans, recentTransactions]
|
||||
|
||||
IncomeRequest:
|
||||
type: object
|
||||
properties:
|
||||
amountCents: { type: integer, minimum: 0 }
|
||||
required: [amountCents]
|
||||
|
||||
AllocationItem:
|
||||
type: object
|
||||
properties:
|
||||
id: { type: string }
|
||||
name: { type: string }
|
||||
amountCents: { type: integer, minimum: 0 }
|
||||
required: [id, name, amountCents]
|
||||
|
||||
IncomePreviewResponse:
|
||||
type: object
|
||||
properties:
|
||||
fixed:
|
||||
type: array
|
||||
items: { $ref: '#/components/schemas/AllocationItem' }
|
||||
variable:
|
||||
type: array
|
||||
items: { $ref: '#/components/schemas/AllocationItem' }
|
||||
unallocatedCents: { type: integer, minimum: 0 }
|
||||
required: [fixed, variable, unallocatedCents]
|
||||
|
||||
IncomeAllocationResponse:
|
||||
type: object
|
||||
description: >
|
||||
Shape returned by allocateIncome. Tests expect:
|
||||
fixedAllocations, variableAllocations, remainingUnallocatedCents.
|
||||
Additional fields may be present.
|
||||
properties:
|
||||
fixedAllocations:
|
||||
type: array
|
||||
items: { $ref: '#/components/schemas/AllocationItem' }
|
||||
variableAllocations:
|
||||
type: array
|
||||
items: { $ref: '#/components/schemas/AllocationItem' }
|
||||
remainingUnallocatedCents: { type: integer, minimum: 0 }
|
||||
additionalProperties: true
|
||||
|
||||
VariableCategoryCreate:
|
||||
type: object
|
||||
properties:
|
||||
name: { type: string }
|
||||
percent: { type: integer, minimum: 0, maximum: 100 }
|
||||
isSavings: { type: boolean }
|
||||
priority: { type: integer, minimum: 0 }
|
||||
required: [name, percent, isSavings, priority]
|
||||
|
||||
VariableCategoryPatch:
|
||||
type: object
|
||||
properties:
|
||||
name: { type: string }
|
||||
percent: { type: integer, minimum: 0, maximum: 100 }
|
||||
isSavings: { type: boolean }
|
||||
priority: { type: integer, minimum: 0 }
|
||||
additionalProperties: false
|
||||
|
||||
FixedPlanCreate:
|
||||
type: object
|
||||
properties:
|
||||
name: { type: string }
|
||||
totalCents: { type: integer, minimum: 0 }
|
||||
fundedCents: { type: integer, minimum: 0 }
|
||||
priority: { type: integer, minimum: 0 }
|
||||
dueOn: { type: string, format: date-time }
|
||||
cycleStart: { type: string, format: date-time }
|
||||
required: [name, totalCents, priority, dueOn]
|
||||
|
||||
FixedPlanPatch:
|
||||
type: object
|
||||
properties:
|
||||
name: { type: string }
|
||||
totalCents: { type: integer, minimum: 0 }
|
||||
fundedCents: { type: integer, minimum: 0 }
|
||||
priority: { type: integer, minimum: 0 }
|
||||
dueOn: { type: string, format: date-time }
|
||||
cycleStart: { type: string, format: date-time }
|
||||
additionalProperties: false
|
||||
2309
api/package-lock.json
generated
2309
api/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,26 +1,34 @@
|
||||
{
|
||||
"name": "skymoney-api",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/server.ts",
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"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
0
api/paginates
Normal file
802
api/pnpm-lock.yaml
generated
Normal file
802
api/pnpm-lock.yaml
generated
Normal file
@@ -0,0 +1,802 @@
|
||||
lockfileVersion: '9.0'
|
||||
|
||||
settings:
|
||||
autoInstallPeers: true
|
||||
excludeLinksFromLockfile: false
|
||||
|
||||
importers:
|
||||
|
||||
.:
|
||||
dependencies:
|
||||
'@fastify/cors':
|
||||
specifier: ^10.0.0
|
||||
version: 10.1.0
|
||||
'@prisma/client':
|
||||
specifier: ^5.20.0
|
||||
version: 5.22.0(prisma@5.22.0)
|
||||
fastify:
|
||||
specifier: ^4.26.2
|
||||
version: 4.29.1
|
||||
zod:
|
||||
specifier: ^3.23.8
|
||||
version: 3.25.76
|
||||
devDependencies:
|
||||
'@types/node':
|
||||
specifier: ^20.11.30
|
||||
version: 20.19.24
|
||||
prisma:
|
||||
specifier: ^5.20.0
|
||||
version: 5.22.0
|
||||
tsx:
|
||||
specifier: ^4.19.0
|
||||
version: 4.20.6
|
||||
typescript:
|
||||
specifier: ^5.6.3
|
||||
version: 5.9.3
|
||||
|
||||
packages:
|
||||
|
||||
'@esbuild/aix-ppc64@0.25.12':
|
||||
resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [ppc64]
|
||||
os: [aix]
|
||||
|
||||
'@esbuild/android-arm64@0.25.12':
|
||||
resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [android]
|
||||
|
||||
'@esbuild/android-arm@0.25.12':
|
||||
resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm]
|
||||
os: [android]
|
||||
|
||||
'@esbuild/android-x64@0.25.12':
|
||||
resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [android]
|
||||
|
||||
'@esbuild/darwin-arm64@0.25.12':
|
||||
resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@esbuild/darwin-x64@0.25.12':
|
||||
resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@esbuild/freebsd-arm64@0.25.12':
|
||||
resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [freebsd]
|
||||
|
||||
'@esbuild/freebsd-x64@0.25.12':
|
||||
resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [freebsd]
|
||||
|
||||
'@esbuild/linux-arm64@0.25.12':
|
||||
resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-arm@0.25.12':
|
||||
resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-ia32@0.25.12':
|
||||
resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [ia32]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-loong64@0.25.12':
|
||||
resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [loong64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-mips64el@0.25.12':
|
||||
resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [mips64el]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-ppc64@0.25.12':
|
||||
resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-riscv64@0.25.12':
|
||||
resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-s390x@0.25.12':
|
||||
resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-x64@0.25.12':
|
||||
resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/netbsd-arm64@0.25.12':
|
||||
resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [netbsd]
|
||||
|
||||
'@esbuild/netbsd-x64@0.25.12':
|
||||
resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [netbsd]
|
||||
|
||||
'@esbuild/openbsd-arm64@0.25.12':
|
||||
resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [openbsd]
|
||||
|
||||
'@esbuild/openbsd-x64@0.25.12':
|
||||
resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [openbsd]
|
||||
|
||||
'@esbuild/openharmony-arm64@0.25.12':
|
||||
resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [openharmony]
|
||||
|
||||
'@esbuild/sunos-x64@0.25.12':
|
||||
resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [sunos]
|
||||
|
||||
'@esbuild/win32-arm64@0.25.12':
|
||||
resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@esbuild/win32-ia32@0.25.12':
|
||||
resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [ia32]
|
||||
os: [win32]
|
||||
|
||||
'@esbuild/win32-x64@0.25.12':
|
||||
resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@fastify/ajv-compiler@3.6.0':
|
||||
resolution: {integrity: sha512-LwdXQJjmMD+GwLOkP7TVC68qa+pSSogeWWmznRJ/coyTcfe9qA05AHFSe1eZFwK6q+xVRpChnvFUkf1iYaSZsQ==}
|
||||
|
||||
'@fastify/cors@10.1.0':
|
||||
resolution: {integrity: sha512-MZyBCBJtII60CU9Xme/iE4aEy8G7QpzGR8zkdXZkDFt7ElEMachbE61tfhAG/bvSaULlqlf0huMT12T7iqEmdQ==}
|
||||
|
||||
'@fastify/error@3.4.1':
|
||||
resolution: {integrity: sha512-wWSvph+29GR783IhmvdwWnN4bUxTD01Vm5Xad4i7i1VuAOItLvbPAb69sb0IQ2N57yprvhNIwAP5B6xfKTmjmQ==}
|
||||
|
||||
'@fastify/fast-json-stringify-compiler@4.3.0':
|
||||
resolution: {integrity: sha512-aZAXGYo6m22Fk1zZzEUKBvut/CIIQe/BapEORnxiD5Qr0kPHqqI69NtEMCme74h+at72sPhbkb4ZrLd1W3KRLA==}
|
||||
|
||||
'@fastify/merge-json-schemas@0.1.1':
|
||||
resolution: {integrity: sha512-fERDVz7topgNjtXsJTTW1JKLy0rhuLRcquYqNR9rF7OcVpCa2OVW49ZPDIhaRRCaUuvVxI+N416xUoF76HNSXA==}
|
||||
|
||||
'@pinojs/redact@0.4.0':
|
||||
resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==}
|
||||
|
||||
'@prisma/client@5.22.0':
|
||||
resolution: {integrity: sha512-M0SVXfyHnQREBKxCgyo7sffrKttwE6R8PMq330MIUF0pTwjUhLbW84pFDlf06B27XyCR++VtjugEnIHdr07SVA==}
|
||||
engines: {node: '>=16.13'}
|
||||
peerDependencies:
|
||||
prisma: '*'
|
||||
peerDependenciesMeta:
|
||||
prisma:
|
||||
optional: true
|
||||
|
||||
'@prisma/debug@5.22.0':
|
||||
resolution: {integrity: sha512-AUt44v3YJeggO2ZU5BkXI7M4hu9BF2zzH2iF2V5pyXT/lRTyWiElZ7It+bRH1EshoMRxHgpYg4VB6rCM+mG5jQ==}
|
||||
|
||||
'@prisma/engines-version@5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2':
|
||||
resolution: {integrity: sha512-2PTmxFR2yHW/eB3uqWtcgRcgAbG1rwG9ZriSvQw+nnb7c4uCr3RAcGMb6/zfE88SKlC1Nj2ziUvc96Z379mHgQ==}
|
||||
|
||||
'@prisma/engines@5.22.0':
|
||||
resolution: {integrity: sha512-UNjfslWhAt06kVL3CjkuYpHAWSO6L4kDCVPegV6itt7nD1kSJavd3vhgAEhjglLJJKEdJ7oIqDJ+yHk6qO8gPA==}
|
||||
|
||||
'@prisma/fetch-engine@5.22.0':
|
||||
resolution: {integrity: sha512-bkrD/Mc2fSvkQBV5EpoFcZ87AvOgDxbG99488a5cexp5Ccny+UM6MAe/UFkUC0wLYD9+9befNOqGiIJhhq+HbA==}
|
||||
|
||||
'@prisma/get-platform@5.22.0':
|
||||
resolution: {integrity: sha512-pHhpQdr1UPFpt+zFfnPazhulaZYCUqeIcPpJViYoq9R+D/yw4fjE+CtnsnKzPYm0ddUbeXUzjGVGIRVgPDCk4Q==}
|
||||
|
||||
'@types/node@20.19.24':
|
||||
resolution: {integrity: sha512-FE5u0ezmi6y9OZEzlJfg37mqqf6ZDSF2V/NLjUyGrR9uTZ7Sb9F7bLNZ03S4XVUNRWGA7Ck4c1kK+YnuWjl+DA==}
|
||||
|
||||
abstract-logging@2.0.1:
|
||||
resolution: {integrity: sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==}
|
||||
|
||||
ajv-formats@2.1.1:
|
||||
resolution: {integrity: sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==}
|
||||
peerDependencies:
|
||||
ajv: ^8.0.0
|
||||
peerDependenciesMeta:
|
||||
ajv:
|
||||
optional: true
|
||||
|
||||
ajv-formats@3.0.1:
|
||||
resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==}
|
||||
peerDependencies:
|
||||
ajv: ^8.0.0
|
||||
peerDependenciesMeta:
|
||||
ajv:
|
||||
optional: true
|
||||
|
||||
ajv@8.17.1:
|
||||
resolution: {integrity: sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==}
|
||||
|
||||
atomic-sleep@1.0.0:
|
||||
resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==}
|
||||
engines: {node: '>=8.0.0'}
|
||||
|
||||
avvio@8.4.0:
|
||||
resolution: {integrity: sha512-CDSwaxINFy59iNwhYnkvALBwZiTydGkOecZyPkqBpABYR1KqGEsET0VOOYDwtleZSUIdeY36DC2bSZ24CO1igA==}
|
||||
|
||||
cookie@0.7.2:
|
||||
resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==}
|
||||
engines: {node: '>= 0.6'}
|
||||
|
||||
esbuild@0.25.12:
|
||||
resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==}
|
||||
engines: {node: '>=18'}
|
||||
hasBin: true
|
||||
|
||||
fast-content-type-parse@1.1.0:
|
||||
resolution: {integrity: sha512-fBHHqSTFLVnR61C+gltJuE5GkVQMV0S2nqUO8TJ+5Z3qAKG8vAx4FKai1s5jq/inV1+sREynIWSuQ6HgoSXpDQ==}
|
||||
|
||||
fast-decode-uri-component@1.0.1:
|
||||
resolution: {integrity: sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==}
|
||||
|
||||
fast-deep-equal@3.1.3:
|
||||
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
|
||||
|
||||
fast-json-stringify@5.16.1:
|
||||
resolution: {integrity: sha512-KAdnLvy1yu/XrRtP+LJnxbBGrhN+xXu+gt3EUvZhYGKCr3lFHq/7UFJHHFgmJKoqlh6B40bZLEv7w46B0mqn1g==}
|
||||
|
||||
fast-querystring@1.1.2:
|
||||
resolution: {integrity: sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==}
|
||||
|
||||
fast-uri@2.4.0:
|
||||
resolution: {integrity: sha512-ypuAmmMKInk5q7XcepxlnUWDLWv4GFtaJqAzWKqn62IpQ3pejtr5dTVbt3vwqVaMKmkNR55sTT+CqUKIaT21BA==}
|
||||
|
||||
fast-uri@3.1.0:
|
||||
resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==}
|
||||
|
||||
fastify-plugin@5.1.0:
|
||||
resolution: {integrity: sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw==}
|
||||
|
||||
fastify@4.29.1:
|
||||
resolution: {integrity: sha512-m2kMNHIG92tSNWv+Z3UeTR9AWLLuo7KctC7mlFPtMEVrfjIhmQhkQnT9v15qA/BfVq3vvj134Y0jl9SBje3jXQ==}
|
||||
|
||||
fastq@1.19.1:
|
||||
resolution: {integrity: sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==}
|
||||
|
||||
find-my-way@8.2.2:
|
||||
resolution: {integrity: sha512-Dobi7gcTEq8yszimcfp/R7+owiT4WncAJ7VTTgFH1jYJ5GaG1FbhjwDG820hptN0QDFvzVY3RfCzdInvGPGzjA==}
|
||||
engines: {node: '>=14'}
|
||||
|
||||
forwarded@0.2.0:
|
||||
resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==}
|
||||
engines: {node: '>= 0.6'}
|
||||
|
||||
fsevents@2.3.3:
|
||||
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
|
||||
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
||||
os: [darwin]
|
||||
|
||||
get-tsconfig@4.13.0:
|
||||
resolution: {integrity: sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==}
|
||||
|
||||
ipaddr.js@1.9.1:
|
||||
resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==}
|
||||
engines: {node: '>= 0.10'}
|
||||
|
||||
json-schema-ref-resolver@1.0.1:
|
||||
resolution: {integrity: sha512-EJAj1pgHc1hxF6vo2Z3s69fMjO1INq6eGHXZ8Z6wCQeldCuwxGK9Sxf4/cScGn3FZubCVUehfWtcDM/PLteCQw==}
|
||||
|
||||
json-schema-traverse@1.0.0:
|
||||
resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==}
|
||||
|
||||
light-my-request@5.14.0:
|
||||
resolution: {integrity: sha512-aORPWntbpH5esaYpGOOmri0OHDOe3wC5M2MQxZ9dvMLZm6DnaAn0kJlcbU9hwsQgLzmZyReKwFwwPkR+nHu5kA==}
|
||||
|
||||
mnemonist@0.40.0:
|
||||
resolution: {integrity: sha512-kdd8AFNig2AD5Rkih7EPCXhu/iMvwevQFX/uEiGhZyPZi7fHqOoF4V4kHLpCfysxXMgQ4B52kdPMCwARshKvEg==}
|
||||
|
||||
obliterator@2.0.5:
|
||||
resolution: {integrity: sha512-42CPE9AhahZRsMNslczq0ctAEtqk8Eka26QofnqC346BZdHDySk3LWka23LI7ULIw11NmltpiLagIq8gBozxTw==}
|
||||
|
||||
on-exit-leak-free@2.1.2:
|
||||
resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
|
||||
pino-abstract-transport@2.0.0:
|
||||
resolution: {integrity: sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw==}
|
||||
|
||||
pino-std-serializers@7.0.0:
|
||||
resolution: {integrity: sha512-e906FRY0+tV27iq4juKzSYPbUj2do2X2JX4EzSca1631EB2QJQUqGbDuERal7LCtOpxl6x3+nvo9NPZcmjkiFA==}
|
||||
|
||||
pino@9.14.0:
|
||||
resolution: {integrity: sha512-8OEwKp5juEvb/MjpIc4hjqfgCNysrS94RIOMXYvpYCdm/jglrKEiAYmiumbmGhCvs+IcInsphYDFwqrjr7398w==}
|
||||
hasBin: true
|
||||
|
||||
prisma@5.22.0:
|
||||
resolution: {integrity: sha512-vtpjW3XuYCSnMsNVBjLMNkTj6OZbudcPPTPYHqX0CJfpcdWciI1dM8uHETwmDxxiqEwCIE6WvXucWUetJgfu/A==}
|
||||
engines: {node: '>=16.13'}
|
||||
hasBin: true
|
||||
|
||||
process-warning@3.0.0:
|
||||
resolution: {integrity: sha512-mqn0kFRl0EoqhnL0GQ0veqFHyIN1yig9RHh/InzORTUiZHFRAur+aMtRkELNwGs9aNwKS6tg/An4NYBPGwvtzQ==}
|
||||
|
||||
process-warning@5.0.0:
|
||||
resolution: {integrity: sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==}
|
||||
|
||||
proxy-addr@2.0.7:
|
||||
resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==}
|
||||
engines: {node: '>= 0.10'}
|
||||
|
||||
quick-format-unescaped@4.0.4:
|
||||
resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==}
|
||||
|
||||
real-require@0.2.0:
|
||||
resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==}
|
||||
engines: {node: '>= 12.13.0'}
|
||||
|
||||
require-from-string@2.0.2:
|
||||
resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
resolve-pkg-maps@1.0.0:
|
||||
resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==}
|
||||
|
||||
ret@0.4.3:
|
||||
resolution: {integrity: sha512-0f4Memo5QP7WQyUEAYUO3esD/XjOc3Zjjg5CPsAq1p8sIu0XPeMbHJemKA0BO7tV0X7+A0FoEpbmHXWxPyD3wQ==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
reusify@1.1.0:
|
||||
resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==}
|
||||
engines: {iojs: '>=1.0.0', node: '>=0.10.0'}
|
||||
|
||||
rfdc@1.4.1:
|
||||
resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==}
|
||||
|
||||
safe-regex2@3.1.0:
|
||||
resolution: {integrity: sha512-RAAZAGbap2kBfbVhvmnTFv73NWLMvDGOITFYTZBAaY8eR+Ir4ef7Up/e7amo+y1+AH+3PtLkrt9mvcTsG9LXug==}
|
||||
|
||||
safe-stable-stringify@2.5.0:
|
||||
resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
secure-json-parse@2.7.0:
|
||||
resolution: {integrity: sha512-6aU+Rwsezw7VR8/nyvKTx8QpWH9FrcYiXXlqC4z5d5XQBDRqtbfsRjnwGyqbi3gddNtWHuEk9OANUotL26qKUw==}
|
||||
|
||||
semver@7.7.3:
|
||||
resolution: {integrity: sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==}
|
||||
engines: {node: '>=10'}
|
||||
hasBin: true
|
||||
|
||||
set-cookie-parser@2.7.2:
|
||||
resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==}
|
||||
|
||||
sonic-boom@4.2.0:
|
||||
resolution: {integrity: sha512-INb7TM37/mAcsGmc9hyyI6+QR3rR1zVRu36B0NeGXKnOOLiZOfER5SA+N7X7k3yUYRzLWafduTDvJAfDswwEww==}
|
||||
|
||||
split2@4.2.0:
|
||||
resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==}
|
||||
engines: {node: '>= 10.x'}
|
||||
|
||||
thread-stream@3.1.0:
|
||||
resolution: {integrity: sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A==}
|
||||
|
||||
toad-cache@3.7.0:
|
||||
resolution: {integrity: sha512-/m8M+2BJUpoJdgAHoG+baCwBT+tf2VraSfkBgl0Y00qIWt41DJ8R5B8nsEw0I58YwF5IZH6z24/2TobDKnqSWw==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
tsx@4.20.6:
|
||||
resolution: {integrity: sha512-ytQKuwgmrrkDTFP4LjR0ToE2nqgy886GpvRSpU0JAnrdBYppuY5rLkRUYPU1yCryb24SsKBTL/hlDQAEFVwtZg==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
hasBin: true
|
||||
|
||||
typescript@5.9.3:
|
||||
resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
|
||||
engines: {node: '>=14.17'}
|
||||
hasBin: true
|
||||
|
||||
undici-types@6.21.0:
|
||||
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
|
||||
|
||||
zod@3.25.76:
|
||||
resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==}
|
||||
|
||||
snapshots:
|
||||
|
||||
'@esbuild/aix-ppc64@0.25.12':
|
||||
optional: true
|
||||
|
||||
'@esbuild/android-arm64@0.25.12':
|
||||
optional: true
|
||||
|
||||
'@esbuild/android-arm@0.25.12':
|
||||
optional: true
|
||||
|
||||
'@esbuild/android-x64@0.25.12':
|
||||
optional: true
|
||||
|
||||
'@esbuild/darwin-arm64@0.25.12':
|
||||
optional: true
|
||||
|
||||
'@esbuild/darwin-x64@0.25.12':
|
||||
optional: true
|
||||
|
||||
'@esbuild/freebsd-arm64@0.25.12':
|
||||
optional: true
|
||||
|
||||
'@esbuild/freebsd-x64@0.25.12':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-arm64@0.25.12':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-arm@0.25.12':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-ia32@0.25.12':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-loong64@0.25.12':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-mips64el@0.25.12':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-ppc64@0.25.12':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-riscv64@0.25.12':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-s390x@0.25.12':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-x64@0.25.12':
|
||||
optional: true
|
||||
|
||||
'@esbuild/netbsd-arm64@0.25.12':
|
||||
optional: true
|
||||
|
||||
'@esbuild/netbsd-x64@0.25.12':
|
||||
optional: true
|
||||
|
||||
'@esbuild/openbsd-arm64@0.25.12':
|
||||
optional: true
|
||||
|
||||
'@esbuild/openbsd-x64@0.25.12':
|
||||
optional: true
|
||||
|
||||
'@esbuild/openharmony-arm64@0.25.12':
|
||||
optional: true
|
||||
|
||||
'@esbuild/sunos-x64@0.25.12':
|
||||
optional: true
|
||||
|
||||
'@esbuild/win32-arm64@0.25.12':
|
||||
optional: true
|
||||
|
||||
'@esbuild/win32-ia32@0.25.12':
|
||||
optional: true
|
||||
|
||||
'@esbuild/win32-x64@0.25.12':
|
||||
optional: true
|
||||
|
||||
'@fastify/ajv-compiler@3.6.0':
|
||||
dependencies:
|
||||
ajv: 8.17.1
|
||||
ajv-formats: 2.1.1(ajv@8.17.1)
|
||||
fast-uri: 2.4.0
|
||||
|
||||
'@fastify/cors@10.1.0':
|
||||
dependencies:
|
||||
fastify-plugin: 5.1.0
|
||||
mnemonist: 0.40.0
|
||||
|
||||
'@fastify/error@3.4.1': {}
|
||||
|
||||
'@fastify/fast-json-stringify-compiler@4.3.0':
|
||||
dependencies:
|
||||
fast-json-stringify: 5.16.1
|
||||
|
||||
'@fastify/merge-json-schemas@0.1.1':
|
||||
dependencies:
|
||||
fast-deep-equal: 3.1.3
|
||||
|
||||
'@pinojs/redact@0.4.0': {}
|
||||
|
||||
'@prisma/client@5.22.0(prisma@5.22.0)':
|
||||
optionalDependencies:
|
||||
prisma: 5.22.0
|
||||
|
||||
'@prisma/debug@5.22.0': {}
|
||||
|
||||
'@prisma/engines-version@5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2': {}
|
||||
|
||||
'@prisma/engines@5.22.0':
|
||||
dependencies:
|
||||
'@prisma/debug': 5.22.0
|
||||
'@prisma/engines-version': 5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2
|
||||
'@prisma/fetch-engine': 5.22.0
|
||||
'@prisma/get-platform': 5.22.0
|
||||
|
||||
'@prisma/fetch-engine@5.22.0':
|
||||
dependencies:
|
||||
'@prisma/debug': 5.22.0
|
||||
'@prisma/engines-version': 5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2
|
||||
'@prisma/get-platform': 5.22.0
|
||||
|
||||
'@prisma/get-platform@5.22.0':
|
||||
dependencies:
|
||||
'@prisma/debug': 5.22.0
|
||||
|
||||
'@types/node@20.19.24':
|
||||
dependencies:
|
||||
undici-types: 6.21.0
|
||||
|
||||
abstract-logging@2.0.1: {}
|
||||
|
||||
ajv-formats@2.1.1(ajv@8.17.1):
|
||||
optionalDependencies:
|
||||
ajv: 8.17.1
|
||||
|
||||
ajv-formats@3.0.1(ajv@8.17.1):
|
||||
optionalDependencies:
|
||||
ajv: 8.17.1
|
||||
|
||||
ajv@8.17.1:
|
||||
dependencies:
|
||||
fast-deep-equal: 3.1.3
|
||||
fast-uri: 3.1.0
|
||||
json-schema-traverse: 1.0.0
|
||||
require-from-string: 2.0.2
|
||||
|
||||
atomic-sleep@1.0.0: {}
|
||||
|
||||
avvio@8.4.0:
|
||||
dependencies:
|
||||
'@fastify/error': 3.4.1
|
||||
fastq: 1.19.1
|
||||
|
||||
cookie@0.7.2: {}
|
||||
|
||||
esbuild@0.25.12:
|
||||
optionalDependencies:
|
||||
'@esbuild/aix-ppc64': 0.25.12
|
||||
'@esbuild/android-arm': 0.25.12
|
||||
'@esbuild/android-arm64': 0.25.12
|
||||
'@esbuild/android-x64': 0.25.12
|
||||
'@esbuild/darwin-arm64': 0.25.12
|
||||
'@esbuild/darwin-x64': 0.25.12
|
||||
'@esbuild/freebsd-arm64': 0.25.12
|
||||
'@esbuild/freebsd-x64': 0.25.12
|
||||
'@esbuild/linux-arm': 0.25.12
|
||||
'@esbuild/linux-arm64': 0.25.12
|
||||
'@esbuild/linux-ia32': 0.25.12
|
||||
'@esbuild/linux-loong64': 0.25.12
|
||||
'@esbuild/linux-mips64el': 0.25.12
|
||||
'@esbuild/linux-ppc64': 0.25.12
|
||||
'@esbuild/linux-riscv64': 0.25.12
|
||||
'@esbuild/linux-s390x': 0.25.12
|
||||
'@esbuild/linux-x64': 0.25.12
|
||||
'@esbuild/netbsd-arm64': 0.25.12
|
||||
'@esbuild/netbsd-x64': 0.25.12
|
||||
'@esbuild/openbsd-arm64': 0.25.12
|
||||
'@esbuild/openbsd-x64': 0.25.12
|
||||
'@esbuild/openharmony-arm64': 0.25.12
|
||||
'@esbuild/sunos-x64': 0.25.12
|
||||
'@esbuild/win32-arm64': 0.25.12
|
||||
'@esbuild/win32-ia32': 0.25.12
|
||||
'@esbuild/win32-x64': 0.25.12
|
||||
|
||||
fast-content-type-parse@1.1.0: {}
|
||||
|
||||
fast-decode-uri-component@1.0.1: {}
|
||||
|
||||
fast-deep-equal@3.1.3: {}
|
||||
|
||||
fast-json-stringify@5.16.1:
|
||||
dependencies:
|
||||
'@fastify/merge-json-schemas': 0.1.1
|
||||
ajv: 8.17.1
|
||||
ajv-formats: 3.0.1(ajv@8.17.1)
|
||||
fast-deep-equal: 3.1.3
|
||||
fast-uri: 2.4.0
|
||||
json-schema-ref-resolver: 1.0.1
|
||||
rfdc: 1.4.1
|
||||
|
||||
fast-querystring@1.1.2:
|
||||
dependencies:
|
||||
fast-decode-uri-component: 1.0.1
|
||||
|
||||
fast-uri@2.4.0: {}
|
||||
|
||||
fast-uri@3.1.0: {}
|
||||
|
||||
fastify-plugin@5.1.0: {}
|
||||
|
||||
fastify@4.29.1:
|
||||
dependencies:
|
||||
'@fastify/ajv-compiler': 3.6.0
|
||||
'@fastify/error': 3.4.1
|
||||
'@fastify/fast-json-stringify-compiler': 4.3.0
|
||||
abstract-logging: 2.0.1
|
||||
avvio: 8.4.0
|
||||
fast-content-type-parse: 1.1.0
|
||||
fast-json-stringify: 5.16.1
|
||||
find-my-way: 8.2.2
|
||||
light-my-request: 5.14.0
|
||||
pino: 9.14.0
|
||||
process-warning: 3.0.0
|
||||
proxy-addr: 2.0.7
|
||||
rfdc: 1.4.1
|
||||
secure-json-parse: 2.7.0
|
||||
semver: 7.7.3
|
||||
toad-cache: 3.7.0
|
||||
|
||||
fastq@1.19.1:
|
||||
dependencies:
|
||||
reusify: 1.1.0
|
||||
|
||||
find-my-way@8.2.2:
|
||||
dependencies:
|
||||
fast-deep-equal: 3.1.3
|
||||
fast-querystring: 1.1.2
|
||||
safe-regex2: 3.1.0
|
||||
|
||||
forwarded@0.2.0: {}
|
||||
|
||||
fsevents@2.3.3:
|
||||
optional: true
|
||||
|
||||
get-tsconfig@4.13.0:
|
||||
dependencies:
|
||||
resolve-pkg-maps: 1.0.0
|
||||
|
||||
ipaddr.js@1.9.1: {}
|
||||
|
||||
json-schema-ref-resolver@1.0.1:
|
||||
dependencies:
|
||||
fast-deep-equal: 3.1.3
|
||||
|
||||
json-schema-traverse@1.0.0: {}
|
||||
|
||||
light-my-request@5.14.0:
|
||||
dependencies:
|
||||
cookie: 0.7.2
|
||||
process-warning: 3.0.0
|
||||
set-cookie-parser: 2.7.2
|
||||
|
||||
mnemonist@0.40.0:
|
||||
dependencies:
|
||||
obliterator: 2.0.5
|
||||
|
||||
obliterator@2.0.5: {}
|
||||
|
||||
on-exit-leak-free@2.1.2: {}
|
||||
|
||||
pino-abstract-transport@2.0.0:
|
||||
dependencies:
|
||||
split2: 4.2.0
|
||||
|
||||
pino-std-serializers@7.0.0: {}
|
||||
|
||||
pino@9.14.0:
|
||||
dependencies:
|
||||
'@pinojs/redact': 0.4.0
|
||||
atomic-sleep: 1.0.0
|
||||
on-exit-leak-free: 2.1.2
|
||||
pino-abstract-transport: 2.0.0
|
||||
pino-std-serializers: 7.0.0
|
||||
process-warning: 5.0.0
|
||||
quick-format-unescaped: 4.0.4
|
||||
real-require: 0.2.0
|
||||
safe-stable-stringify: 2.5.0
|
||||
sonic-boom: 4.2.0
|
||||
thread-stream: 3.1.0
|
||||
|
||||
prisma@5.22.0:
|
||||
dependencies:
|
||||
'@prisma/engines': 5.22.0
|
||||
optionalDependencies:
|
||||
fsevents: 2.3.3
|
||||
|
||||
process-warning@3.0.0: {}
|
||||
|
||||
process-warning@5.0.0: {}
|
||||
|
||||
proxy-addr@2.0.7:
|
||||
dependencies:
|
||||
forwarded: 0.2.0
|
||||
ipaddr.js: 1.9.1
|
||||
|
||||
quick-format-unescaped@4.0.4: {}
|
||||
|
||||
real-require@0.2.0: {}
|
||||
|
||||
require-from-string@2.0.2: {}
|
||||
|
||||
resolve-pkg-maps@1.0.0: {}
|
||||
|
||||
ret@0.4.3: {}
|
||||
|
||||
reusify@1.1.0: {}
|
||||
|
||||
rfdc@1.4.1: {}
|
||||
|
||||
safe-regex2@3.1.0:
|
||||
dependencies:
|
||||
ret: 0.4.3
|
||||
|
||||
safe-stable-stringify@2.5.0: {}
|
||||
|
||||
secure-json-parse@2.7.0: {}
|
||||
|
||||
semver@7.7.3: {}
|
||||
|
||||
set-cookie-parser@2.7.2: {}
|
||||
|
||||
sonic-boom@4.2.0:
|
||||
dependencies:
|
||||
atomic-sleep: 1.0.0
|
||||
|
||||
split2@4.2.0: {}
|
||||
|
||||
thread-stream@3.1.0:
|
||||
dependencies:
|
||||
real-require: 0.2.0
|
||||
|
||||
toad-cache@3.7.0: {}
|
||||
|
||||
tsx@4.20.6:
|
||||
dependencies:
|
||||
esbuild: 0.25.12
|
||||
get-tsconfig: 4.13.0
|
||||
optionalDependencies:
|
||||
fsevents: 2.3.3
|
||||
|
||||
typescript@5.9.3: {}
|
||||
|
||||
undici-types@6.21.0: {}
|
||||
|
||||
zod@3.25.76: {}
|
||||
@@ -1,13 +0,0 @@
|
||||
import { defineConfig, env } from "prisma/config";
|
||||
import "dotenv/config";
|
||||
|
||||
export default defineConfig({
|
||||
schema: "prisma/schema.prisma",
|
||||
migrations: {
|
||||
path: "prisma/migrations",
|
||||
},
|
||||
engine: "classic",
|
||||
datasource: {
|
||||
url: env("DATABASE_URL"),
|
||||
},
|
||||
});
|
||||
115
api/prisma/migrations/20251111063120_init/migration.sql
Normal file
115
api/prisma/migrations/20251111063120_init/migration.sql
Normal file
@@ -0,0 +1,115 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "User" (
|
||||
"id" TEXT NOT NULL,
|
||||
"email" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "VariableCategory" (
|
||||
"id" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"percent" INTEGER NOT NULL,
|
||||
"priority" INTEGER NOT NULL DEFAULT 100,
|
||||
"isSavings" BOOLEAN NOT NULL DEFAULT false,
|
||||
"balanceCents" BIGINT NOT NULL DEFAULT 0,
|
||||
|
||||
CONSTRAINT "VariableCategory_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "FixedPlan" (
|
||||
"id" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"cycleStart" TIMESTAMP(3) NOT NULL,
|
||||
"dueOn" TIMESTAMP(3) NOT NULL,
|
||||
"totalCents" BIGINT NOT NULL,
|
||||
"fundedCents" BIGINT NOT NULL DEFAULT 0,
|
||||
"priority" INTEGER NOT NULL DEFAULT 100,
|
||||
"fundingMode" TEXT NOT NULL DEFAULT 'auto-on-deposit',
|
||||
"scheduleJson" JSONB,
|
||||
|
||||
CONSTRAINT "FixedPlan_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "IncomeEvent" (
|
||||
"id" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"postedAt" TIMESTAMP(3) NOT NULL,
|
||||
"amountCents" BIGINT NOT NULL,
|
||||
|
||||
CONSTRAINT "IncomeEvent_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Allocation" (
|
||||
"id" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"kind" TEXT NOT NULL,
|
||||
"toId" TEXT NOT NULL,
|
||||
"amountCents" BIGINT NOT NULL,
|
||||
"incomeId" TEXT,
|
||||
|
||||
CONSTRAINT "Allocation_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Transaction" (
|
||||
"id" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"occurredAt" TIMESTAMP(3) NOT NULL,
|
||||
"kind" TEXT NOT NULL,
|
||||
"categoryId" TEXT,
|
||||
"planId" TEXT,
|
||||
"amountCents" BIGINT NOT NULL,
|
||||
|
||||
CONSTRAINT "Transaction_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "VariableCategory_userId_priority_idx" ON "VariableCategory"("userId", "priority");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "VariableCategory_userId_name_key" ON "VariableCategory"("userId", "name");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "FixedPlan_userId_dueOn_idx" ON "FixedPlan"("userId", "dueOn");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "FixedPlan_userId_priority_idx" ON "FixedPlan"("userId", "priority");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "FixedPlan_userId_name_key" ON "FixedPlan"("userId", "name");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "IncomeEvent_userId_postedAt_idx" ON "IncomeEvent"("userId", "postedAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Transaction_userId_occurredAt_idx" ON "Transaction"("userId", "occurredAt");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "VariableCategory" ADD CONSTRAINT "VariableCategory_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "FixedPlan" ADD CONSTRAINT "FixedPlan_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "IncomeEvent" ADD CONSTRAINT "IncomeEvent_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Allocation" ADD CONSTRAINT "Allocation_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Allocation" ADD CONSTRAINT "Allocation_incomeId_fkey" FOREIGN KEY ("incomeId") REFERENCES "IncomeEvent"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Transaction" ADD CONSTRAINT "Transaction_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
3
api/prisma/migrations/migration_lock.toml
Normal file
3
api/prisma/migrations/migration_lock.toml
Normal file
@@ -0,0 +1,3 @@
|
||||
# Please do not edit this file manually
|
||||
# It should be added in your version-control system (i.e. Git)
|
||||
provider = "postgresql"
|
||||
@@ -1,6 +1,13 @@
|
||||
// prisma/schema.prisma
|
||||
generator client { provider = "prisma-client-js" }
|
||||
datasource db { provider = "postgresql"; url = env("DATABASE_URL") }
|
||||
// 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
85
api/prisma/seed.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
/// <reference types="node" />
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import { allocateIncome } from "../src/allocator.ts"; // adjust if your path differs
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
const cents = (dollars: number) => BigInt(Math.round(dollars * 100));
|
||||
|
||||
async function main() {
|
||||
const userId = "demo-user-1"; // dev-only, string id per schema
|
||||
|
||||
// 1) User
|
||||
await prisma.user.upsert({
|
||||
where: { id: userId },
|
||||
create: { id: userId, email: "demo@example.com" },
|
||||
update: {},
|
||||
});
|
||||
|
||||
// 2) Variable categories (sum = 100)
|
||||
const categories = [
|
||||
{ name: "Savings", percent: 40, isSavings: true, priority: 10 },
|
||||
{ name: "Needs", percent: 40, isSavings: false, priority: 20 },
|
||||
{ name: "Wants", percent: 20, isSavings: false, priority: 30 },
|
||||
];
|
||||
|
||||
for (const c of categories) {
|
||||
await prisma.variableCategory.upsert({
|
||||
where: { userId_name: { userId, name: c.name } },
|
||||
create: { userId, name: c.name, percent: c.percent, isSavings: c.isSavings, priority: c.priority, balanceCents: 0n },
|
||||
update: { percent: c.percent, isSavings: c.isSavings, priority: c.priority },
|
||||
});
|
||||
}
|
||||
|
||||
// 3) Fixed plans
|
||||
const today = new Date();
|
||||
const monthStart = new Date(today.getFullYear(), today.getMonth(), 1);
|
||||
const dueNext = new Date(today.getFullYear(), today.getMonth(), 1);
|
||||
dueNext.setMonth(dueNext.getMonth() + 1);
|
||||
|
||||
const plans = [
|
||||
{ name: "Rent", total: cents(1200), priority: 10, cycleStart: monthStart, dueOn: dueNext },
|
||||
{ name: "Utilities", total: cents(300), priority: 20, cycleStart: monthStart, dueOn: dueNext },
|
||||
];
|
||||
|
||||
for (const p of plans) {
|
||||
await prisma.fixedPlan.upsert({
|
||||
where: { userId_name: { userId, name: p.name } },
|
||||
create: {
|
||||
userId, name: p.name,
|
||||
totalCents: p.total, fundedCents: 0n,
|
||||
priority: p.priority, cycleStart: p.cycleStart, dueOn: p.dueOn,
|
||||
fundingMode: "auto-on-deposit"
|
||||
},
|
||||
update: {
|
||||
totalCents: p.total, priority: p.priority,
|
||||
cycleStart: p.cycleStart, dueOn: p.dueOn
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// 4) Seed income + allocate
|
||||
const deposit = 2500; // dollars
|
||||
const nowISO = new Date().toISOString();
|
||||
|
||||
const income = await prisma.incomeEvent.create({
|
||||
data: { userId, postedAt: new Date(nowISO), amountCents: cents(deposit) },
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
const result = await allocateIncome(
|
||||
prisma, // db
|
||||
userId, // user id (string)
|
||||
Math.round(deposit * 100), // depositCentsNum (number is fine)
|
||||
nowISO, // ISO timestamp
|
||||
income.id // incomeEventId (string)
|
||||
);
|
||||
|
||||
console.log("Seed complete\n", JSON.stringify(result, null, 2));
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
}).finally(async () => {
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
12
api/prisma/tsconfig.seed.json
Normal file
12
api/prisma/tsconfig.seed.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"extends": "../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"target": "ES2022",
|
||||
"lib": ["es2022"],
|
||||
"types": ["node"],
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["seed.ts"]
|
||||
}
|
||||
154
api/src/allocator.ts
Normal file
154
api/src/allocator.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
import type { PrismaClient } from "@prisma/client";
|
||||
|
||||
/**
|
||||
* Allocate income across fixed plans (need-first) and variable categories (largest remainder).
|
||||
*
|
||||
* @param db Prisma client (or tx)
|
||||
* @param userId string
|
||||
* @param amountCents number (>= 0)
|
||||
* @param postedAtISO string ISO timestamp for the income event
|
||||
* @param incomeId string id to use for IncomeEvent + Allocation FK
|
||||
*/
|
||||
export async function allocateIncome(
|
||||
db: PrismaClient,
|
||||
userId: string,
|
||||
amountCents: number,
|
||||
postedAtISO: string,
|
||||
incomeId: string
|
||||
): Promise<{
|
||||
fixedAllocations: Array<{ fixedPlanId: string; amountCents: number }>;
|
||||
variableAllocations: Array<{ variableCategoryId: string; amountCents: number }>;
|
||||
remainingUnallocatedCents: number;
|
||||
}> {
|
||||
const amt = Math.max(0, Math.floor(amountCents | 0));
|
||||
|
||||
return await db.$transaction(async (tx) => {
|
||||
// 1) Ensure the IncomeEvent exists to satisfy FK on Allocation
|
||||
await tx.incomeEvent.upsert({
|
||||
where: { id: incomeId },
|
||||
update: {}, // idempotent in case route created it already
|
||||
create: {
|
||||
id: incomeId,
|
||||
userId,
|
||||
postedAt: new Date(postedAtISO),
|
||||
amountCents: BigInt(amt),
|
||||
},
|
||||
});
|
||||
|
||||
// 2) Load current fixed plans + variable categories
|
||||
const [plans, cats] = await Promise.all([
|
||||
tx.fixedPlan.findMany({
|
||||
where: { userId },
|
||||
orderBy: [{ priority: "asc" }, { dueOn: "asc" }],
|
||||
select: {
|
||||
id: true,
|
||||
totalCents: true,
|
||||
fundedCents: true,
|
||||
priority: true,
|
||||
dueOn: true,
|
||||
},
|
||||
}),
|
||||
tx.variableCategory.findMany({
|
||||
where: { userId },
|
||||
orderBy: [{ priority: "asc" }, { name: "asc" }],
|
||||
select: { id: true, name: true, percent: true, isSavings: true, priority: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
let remaining = amt;
|
||||
|
||||
// 3) Fixed pass: fund by priority then due date up to need
|
||||
const fixedAllocations: Array<{ fixedPlanId: string; amountCents: number }> = [];
|
||||
for (const p of plans) {
|
||||
if (remaining <= 0) break;
|
||||
const needBig = (p.totalCents ?? 0n) - (p.fundedCents ?? 0n);
|
||||
const need = Number(needBig > 0n ? needBig : 0n);
|
||||
if (need <= 0) continue;
|
||||
|
||||
const give = Math.min(need, remaining);
|
||||
if (give > 0) {
|
||||
// apply fundedCents
|
||||
await tx.fixedPlan.update({
|
||||
where: { id: p.id },
|
||||
data: { fundedCents: (p.fundedCents ?? 0n) + BigInt(give) },
|
||||
});
|
||||
|
||||
// audit allocation row
|
||||
await tx.allocation.create({
|
||||
data: {
|
||||
userId,
|
||||
kind: "fixed",
|
||||
toId: p.id,
|
||||
amountCents: BigInt(give),
|
||||
incomeId, // FK now valid
|
||||
},
|
||||
});
|
||||
|
||||
fixedAllocations.push({ fixedPlanId: p.id, amountCents: give });
|
||||
remaining -= give;
|
||||
}
|
||||
}
|
||||
|
||||
// 4) Variable pass: largest remainder w/ savings-first tiebreak
|
||||
const variableAllocations: Array<{ variableCategoryId: string; amountCents: number }> = [];
|
||||
if (remaining > 0 && cats.length > 0) {
|
||||
const totalPercent = cats.reduce((s, c) => s + (c.percent || 0), 0);
|
||||
const norm = totalPercent === 100
|
||||
? cats
|
||||
: cats.map(c => ({
|
||||
...c,
|
||||
percent: totalPercent > 0 ? (c.percent * 100) / totalPercent : 0,
|
||||
}));
|
||||
|
||||
const base = new Array(norm.length).fill(0);
|
||||
const tie = [] as Array<{ idx: number; remainder: number; isSavings: boolean; priority: number; name: string }>;
|
||||
let sumBase = 0;
|
||||
|
||||
norm.forEach((c, idx) => {
|
||||
const exact = (remaining * (c.percent || 0)) / 100;
|
||||
const floor = Math.floor(exact);
|
||||
base[idx] = floor;
|
||||
sumBase += floor;
|
||||
tie.push({ idx, remainder: exact - floor, isSavings: !!c.isSavings, priority: c.priority, name: c.name });
|
||||
});
|
||||
|
||||
let leftovers = remaining - sumBase;
|
||||
tie.sort((a, b) => {
|
||||
if (a.isSavings !== b.isSavings) return a.isSavings ? -1 : 1; // savings first
|
||||
if (a.remainder !== b.remainder) return b.remainder - a.remainder;
|
||||
if (a.priority !== b.priority) return a.priority - b.priority;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
for (let i = 0; i < tie.length && leftovers > 0; i++, leftovers--) base[tie[i].idx]++;
|
||||
|
||||
for (let i = 0; i < norm.length; i++) {
|
||||
const give = base[i] || 0;
|
||||
if (give > 0) {
|
||||
const c = norm[i];
|
||||
await tx.variableCategory.update({
|
||||
where: { id: c.id },
|
||||
data: { balanceCents: { increment: BigInt(give) } },
|
||||
});
|
||||
await tx.allocation.create({
|
||||
data: {
|
||||
userId,
|
||||
kind: "variable",
|
||||
toId: c.id,
|
||||
amountCents: BigInt(give),
|
||||
incomeId,
|
||||
},
|
||||
});
|
||||
variableAllocations.push({ variableCategoryId: c.id, amountCents: give });
|
||||
}
|
||||
}
|
||||
|
||||
remaining = leftovers;
|
||||
}
|
||||
|
||||
return {
|
||||
fixedAllocations,
|
||||
variableAllocations,
|
||||
remainingUnallocatedCents: Math.max(0, remaining),
|
||||
};
|
||||
});
|
||||
}
|
||||
26
api/src/env.ts
Normal file
26
api/src/env.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
// api/src/env.ts
|
||||
import { z } from "zod";
|
||||
|
||||
const Env = z.object({
|
||||
NODE_ENV: z.enum(["development", "test", "production"]).default("development"),
|
||||
PORT: z.coerce.number().int().positive().default(8080),
|
||||
HOST: z.string().default("0.0.0.0"),
|
||||
DATABASE_URL: z.string().min(1),
|
||||
|
||||
// Comma-separated list of allowed origins; empty => allow all (dev)
|
||||
CORS_ORIGIN: z.string().optional(),
|
||||
|
||||
// 🔹 New: rate-limit knobs (have defaults so typing is happy)
|
||||
RATE_LIMIT_MAX: z.coerce.number().int().positive().default(200),
|
||||
RATE_LIMIT_WINDOW_MS: z.coerce.number().int().positive().default(60_000),
|
||||
});
|
||||
|
||||
export const env = Env.parse({
|
||||
NODE_ENV: process.env.NODE_ENV,
|
||||
PORT: process.env.PORT,
|
||||
HOST: process.env.HOST,
|
||||
DATABASE_URL: process.env.DATABASE_URL,
|
||||
CORS_ORIGIN: "http://localhost:5173,http://127.0.0.1:5173",
|
||||
RATE_LIMIT_MAX: process.env.RATE_LIMIT_MAX,
|
||||
RATE_LIMIT_WINDOW_MS: process.env.RATE_LIMIT_WINDOW_MS,
|
||||
});
|
||||
48
api/src/plugins/error-handler.ts
Normal file
48
api/src/plugins/error-handler.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
// api/src/plugins/error-handler.ts
|
||||
import type { FastifyError, FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
||||
import { ZodError } from "zod";
|
||||
|
||||
function isPrismaError(e: any, code: string) {
|
||||
return e && typeof e === "object" && e.code === code;
|
||||
}
|
||||
|
||||
export function installErrorHandler(app: FastifyInstance) {
|
||||
app.setErrorHandler((err: FastifyError & { code?: string; statusCode?: number }, req: FastifyRequest, reply: FastifyReply) => {
|
||||
const requestId = (req as any).id as string | undefined;
|
||||
|
||||
// Respect explicit statusCode + code (e.g., OVERDRAFT_CATEGORY/PLAN)
|
||||
if (err.statusCode && err.code) {
|
||||
return reply.code(err.statusCode).send({ ok: false, code: err.code, message: err.message, requestId });
|
||||
}
|
||||
|
||||
// Zod validation
|
||||
if (err instanceof ZodError) {
|
||||
return reply.code(400).send({
|
||||
ok: false,
|
||||
code: "INVALID_INPUT",
|
||||
message: err.errors.map(e => e.message).join("; "),
|
||||
requestId,
|
||||
});
|
||||
}
|
||||
|
||||
// Prisma common cases
|
||||
if (isPrismaError(err, "P2002")) {
|
||||
return reply.code(409).send({ ok: false, code: "UNIQUE_VIOLATION", message: "Duplicate value violates unique constraint", requestId });
|
||||
}
|
||||
if (isPrismaError(err, "P2003")) {
|
||||
return reply.code(400).send({ ok: false, code: "FK_CONSTRAINT", message: "Foreign key constraint violated", requestId });
|
||||
}
|
||||
|
||||
// 404 produced by handlers
|
||||
if (err.statusCode === 404) {
|
||||
return reply.code(404).send({ ok: false, code: "NOT_FOUND", message: err.message || "Not found", requestId });
|
||||
}
|
||||
|
||||
// Default
|
||||
const status = err.statusCode && err.statusCode >= 400 ? err.statusCode : 500;
|
||||
const code = status >= 500 ? "INTERNAL" : "BAD_REQUEST";
|
||||
// Log full error with request id for correlation
|
||||
app.log.error({ err, requestId }, "Request error");
|
||||
return reply.code(status).send({ ok: false, code, message: err.message || "Unexpected error", requestId });
|
||||
});
|
||||
}
|
||||
15
api/src/plugins/request-id.ts
Normal file
15
api/src/plugins/request-id.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
// api/src/plugins/request-id.ts
|
||||
import type { FastifyPluginCallback } from "fastify";
|
||||
import { randomUUID } from "crypto";
|
||||
|
||||
const requestIdPlugin: FastifyPluginCallback = (app, _opts, done) => {
|
||||
app.addHook("onRequest", async (req, reply) => {
|
||||
const incoming = (req.headers["x-request-id"] as string | undefined)?.trim();
|
||||
const id = incoming && incoming.length > 0 ? incoming : randomUUID();
|
||||
(req as any).id = id; // attach to request
|
||||
reply.header("x-request-id", id); // echo on response
|
||||
});
|
||||
done();
|
||||
};
|
||||
|
||||
export default requestIdPlugin;
|
||||
23
api/src/plugins/user-stub.ts
Normal file
23
api/src/plugins/user-stub.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import fp from "fastify-plugin";
|
||||
import { prisma } from "../prisma.js";
|
||||
|
||||
declare module "fastify" {
|
||||
interface FastifyRequest {
|
||||
userId: string;
|
||||
}
|
||||
}
|
||||
|
||||
export default fp(async (app) => {
|
||||
app.addHook("onRequest", async (req) => {
|
||||
// Dev-only stub: use header if provided, else default
|
||||
const hdr = req.headers["x-user-id"];
|
||||
req.userId = typeof hdr === "string" && hdr.trim() ? hdr.trim() : "demo-user-1";
|
||||
|
||||
// Ensure the user exists (avoids FK P2003 on first write)
|
||||
await prisma.user.upsert({
|
||||
where: { id: req.userId },
|
||||
update: {},
|
||||
create: { id: req.userId, email: `${req.userId}@demo.local` },
|
||||
});
|
||||
});
|
||||
});
|
||||
2
api/src/prisma.ts
Normal file
2
api/src/prisma.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
export const prisma = new PrismaClient();
|
||||
94
api/src/routes/fixed-plans.ts
Normal file
94
api/src/routes/fixed-plans.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import { FastifyPluginAsync } from "fastify";
|
||||
import { z } from "zod";
|
||||
import { prisma } from "../prisma.js";
|
||||
|
||||
const NewPlan = z.object({
|
||||
name: z.string().min(1).max(120),
|
||||
totalCents: z.number().int().min(0),
|
||||
fundedCents: z.number().int().min(0).default(0),
|
||||
priority: z.number().int().min(0).max(10_000),
|
||||
dueOn: z.string().datetime(), // ISO
|
||||
});
|
||||
const PatchPlan = NewPlan.partial();
|
||||
const IdParam = z.object({ id: z.string().min(1) });
|
||||
|
||||
const bi = (n: number | bigint | undefined) => BigInt(n ?? 0);
|
||||
function validateFunding(total: bigint, funded: bigint) {
|
||||
if (funded > total) {
|
||||
const err: any = new Error("fundedCents must be ≤ totalCents");
|
||||
err.statusCode = 400;
|
||||
err.code = "FUNDED_GT_TOTAL";
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
const plugin: FastifyPluginAsync = async (app) => {
|
||||
// CREATE
|
||||
app.post("/api/fixed-plans", async (req, reply) => {
|
||||
const userId = req.userId;
|
||||
const parsed = NewPlan.safeParse(req.body);
|
||||
if (!parsed.success) return reply.status(400).send({ error: "INVALID_BODY", details: parsed.error.flatten() });
|
||||
|
||||
const totalBI = bi(parsed.data.totalCents);
|
||||
const fundedBI = bi(parsed.data.fundedCents);
|
||||
validateFunding(totalBI, fundedBI);
|
||||
|
||||
const rec = await prisma.fixedPlan.create({
|
||||
data: {
|
||||
userId,
|
||||
name: parsed.data.name,
|
||||
priority: parsed.data.priority,
|
||||
dueOn: new Date(parsed.data.dueOn),
|
||||
totalCents: totalBI,
|
||||
fundedCents: fundedBI,
|
||||
cycleStart: new Date(), // required by your schema
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
return reply.status(201).send(rec);
|
||||
});
|
||||
|
||||
// UPDATE
|
||||
app.patch("/api/fixed-plans/:id", async (req, reply) => {
|
||||
const userId = req.userId;
|
||||
const pid = IdParam.safeParse(req.params);
|
||||
const patch = PatchPlan.safeParse(req.body);
|
||||
if (!pid.success) return reply.status(400).send({ error: "INVALID_ID", details: pid.error.flatten() });
|
||||
if (!patch.success) return reply.status(400).send({ error: "INVALID_BODY", details: patch.error.flatten() });
|
||||
|
||||
const existing = await prisma.fixedPlan.findFirst({ where: { id: pid.data.id, userId } });
|
||||
if (!existing) return reply.status(404).send({ error: "NOT_FOUND" });
|
||||
|
||||
const nextTotal = patch.data.totalCents !== undefined ? bi(patch.data.totalCents) : (existing.totalCents as bigint);
|
||||
const nextFunded = patch.data.fundedCents !== undefined ? bi(patch.data.fundedCents) : (existing.fundedCents as bigint);
|
||||
validateFunding(nextTotal, nextFunded);
|
||||
|
||||
await prisma.fixedPlan.update({
|
||||
where: { id: pid.data.id },
|
||||
data: {
|
||||
...(patch.data.name !== undefined ? { name: patch.data.name } : null),
|
||||
...(patch.data.priority !== undefined ? { priority: patch.data.priority } : null),
|
||||
...(patch.data.dueOn !== undefined ? { dueOn: new Date(patch.data.dueOn) } : null),
|
||||
...(patch.data.totalCents !== undefined ? { totalCents: bi(patch.data.totalCents) } : null),
|
||||
...(patch.data.fundedCents !== undefined ? { fundedCents: bi(patch.data.fundedCents) } : null),
|
||||
},
|
||||
});
|
||||
|
||||
return reply.send({ ok: true });
|
||||
});
|
||||
|
||||
// DELETE
|
||||
app.delete("/api/fixed-plans/:id", async (req, reply) => {
|
||||
const userId = req.userId;
|
||||
const pid = IdParam.safeParse(req.params);
|
||||
if (!pid.success) return reply.status(400).send({ error: "INVALID_ID", details: pid.error.flatten() });
|
||||
|
||||
const existing = await prisma.fixedPlan.findFirst({ where: { id: pid.data.id, userId } });
|
||||
if (!existing) return reply.status(404).send({ error: "NOT_FOUND" });
|
||||
|
||||
await prisma.fixedPlan.delete({ where: { id: pid.data.id } });
|
||||
return reply.send({ ok: true });
|
||||
});
|
||||
};
|
||||
|
||||
export default plugin;
|
||||
82
api/src/routes/income-preview.ts
Normal file
82
api/src/routes/income-preview.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { FastifyInstance } from "fastify";
|
||||
import { z } from "zod";
|
||||
|
||||
const Body = z.object({ amountCents: z.number().int().nonnegative() });
|
||||
|
||||
export default async function incomePreviewRoutes(app: FastifyInstance) {
|
||||
app.post("/income/preview", async (req, reply) => {
|
||||
const parsed = Body.safeParse(req.body);
|
||||
if (!parsed.success) return reply.code(400).send({ message: "Invalid amount" });
|
||||
|
||||
const userId = req.userId;
|
||||
let remaining = Math.max(0, parsed.data.amountCents | 0);
|
||||
|
||||
const [plans, cats] = await Promise.all([
|
||||
app.prisma.fixedPlan.findMany({
|
||||
where: { userId },
|
||||
orderBy: [{ priority: "asc" }, { dueOn: "asc" }],
|
||||
select: { id: true, name: true, totalCents: true, fundedCents: true, priority: true, dueOn: true },
|
||||
}),
|
||||
app.prisma.variableCategory.findMany({
|
||||
where: { userId },
|
||||
orderBy: [{ priority: "asc" }, { name: "asc" }],
|
||||
select: { id: true, name: true, percent: true, isSavings: true, priority: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
// Fixed pass
|
||||
const fixed: Array<{ id: string; name: string; amountCents: number }> = [];
|
||||
for (const p of plans) {
|
||||
if (remaining <= 0) break;
|
||||
const needBig = (p.totalCents ?? 0n) - (p.fundedCents ?? 0n);
|
||||
const need = Number(needBig > 0n ? needBig : 0n);
|
||||
if (need <= 0) continue;
|
||||
const give = Math.min(need, remaining);
|
||||
fixed.push({ id: p.id, name: p.name, amountCents: give });
|
||||
remaining -= give;
|
||||
}
|
||||
|
||||
// Variable pass — largest remainder with savings-first tiebreak
|
||||
const variable: Array<{ id: string; name: string; amountCents: number }> = [];
|
||||
if (remaining > 0 && cats.length > 0) {
|
||||
const totalPercent = cats.reduce((s, c) => s + (c.percent || 0), 0);
|
||||
const normCats =
|
||||
totalPercent === 100
|
||||
? cats
|
||||
: cats.map((c) => ({
|
||||
...c,
|
||||
percent: totalPercent > 0 ? (c.percent * 100) / totalPercent : 0,
|
||||
}));
|
||||
|
||||
const base: number[] = new Array(normCats.length).fill(0);
|
||||
const tie: { idx: number; remainder: number; isSavings: boolean; priority: number; name: string }[] = [];
|
||||
let sumBase = 0;
|
||||
|
||||
normCats.forEach((c, idx) => {
|
||||
const exact = (remaining * c.percent) / 100;
|
||||
const floor = Math.floor(exact);
|
||||
base[idx] = floor;
|
||||
sumBase += floor;
|
||||
tie.push({ idx, remainder: exact - floor, isSavings: !!c.isSavings, priority: c.priority, name: c.name });
|
||||
});
|
||||
|
||||
let leftovers = remaining - sumBase;
|
||||
tie.sort((a, b) => {
|
||||
if (a.isSavings !== b.isSavings) return a.isSavings ? -1 : 1; // savings first
|
||||
if (a.remainder !== b.remainder) return b.remainder - a.remainder;
|
||||
if (a.priority !== b.priority) return a.priority - b.priority;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
for (let i = 0; i < tie.length && leftovers > 0; i++, leftovers--) base[tie[i].idx] += 1;
|
||||
|
||||
normCats.forEach((c, idx) => {
|
||||
const give = base[idx] || 0;
|
||||
if (give > 0) variable.push({ id: c.id, name: c.name, amountCents: give });
|
||||
});
|
||||
|
||||
remaining = leftovers;
|
||||
}
|
||||
|
||||
return { fixed, variable, unallocatedCents: Math.max(0, remaining) };
|
||||
});
|
||||
}
|
||||
69
api/src/routes/transactions.ts
Normal file
69
api/src/routes/transactions.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
// api/src/routes/transactions.ts
|
||||
import fp from "fastify-plugin";
|
||||
import { z } from "zod";
|
||||
|
||||
const Query = z.object({
|
||||
from: z
|
||||
.string()
|
||||
.regex(/^\d{4}-\d{2}-\d{2}$/)
|
||||
.optional(), // YYYY-MM-DD
|
||||
to: z
|
||||
.string()
|
||||
.regex(/^\d{4}-\d{2}-\d{2}$/)
|
||||
.optional(),
|
||||
kind: z.enum(["variable_spend", "fixed_payment"]).optional(),
|
||||
q: z.string().trim().optional(),
|
||||
page: z.coerce.number().int().positive().default(1),
|
||||
limit: z.coerce.number().int().positive().max(100).default(20),
|
||||
});
|
||||
|
||||
export default fp(async function transactionsRoute(app) {
|
||||
app.get("/transactions", async (req, reply) => {
|
||||
const userId =
|
||||
typeof req.userId === "string"
|
||||
? req.userId
|
||||
: String(req.userId ?? "demo-user-1");
|
||||
|
||||
const parsed = Query.safeParse(req.query);
|
||||
if (!parsed.success) {
|
||||
return reply
|
||||
.code(400)
|
||||
.send({ message: "Invalid query", issues: parsed.error.issues });
|
||||
}
|
||||
const { from, to, kind, q, page, limit } = parsed.data;
|
||||
|
||||
const where: any = { userId };
|
||||
if (from || to) {
|
||||
where.occurredAt = {};
|
||||
if (from) where.occurredAt.gte = new Date(`${from}T00:00:00.000Z`);
|
||||
if (to) where.occurredAt.lte = new Date(`${to}T23:59:59.999Z`);
|
||||
}
|
||||
if (kind) where.kind = kind;
|
||||
|
||||
if (typeof q === "string" && q.trim() !== "") {
|
||||
const ors: any[] = [];
|
||||
const asNumber = Number(q);
|
||||
if (Number.isFinite(asNumber)) {
|
||||
ors.push({ amountCents: BigInt(asNumber) });
|
||||
}
|
||||
if (ors.length > 0) {
|
||||
where.OR = ors;
|
||||
}
|
||||
}
|
||||
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
const [total, items] = await Promise.all([
|
||||
app.prisma.transaction.count({ where }),
|
||||
app.prisma.transaction.findMany({
|
||||
where,
|
||||
orderBy: { occurredAt: "desc" },
|
||||
skip,
|
||||
take: limit,
|
||||
select: { id: true, kind: true, amountCents: true, occurredAt: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
return { items, page, limit, total };
|
||||
});
|
||||
});
|
||||
85
api/src/routes/variable-categories.ts
Normal file
85
api/src/routes/variable-categories.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
|
||||
import { FastifyPluginAsync } from "fastify";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { z } from "zod";
|
||||
import { prisma } from "../prisma.js";
|
||||
|
||||
const NewCat = z.object({
|
||||
name: z.string().min(1).max(100),
|
||||
percent: z.number().int().min(0).max(100),
|
||||
isSavings: z.boolean().default(false),
|
||||
priority: z.number().int().min(0).max(10_000),
|
||||
});
|
||||
const PatchCat = NewCat.partial();
|
||||
const IdParam = z.object({ id: z.string().min(1) });
|
||||
|
||||
async function assertPercentTotal100(tx: Prisma.TransactionClient, userId: string) {
|
||||
const g = await tx.variableCategory.groupBy({
|
||||
by: ["userId"],
|
||||
where: { userId },
|
||||
_sum: { percent: true },
|
||||
});
|
||||
const sum = g[0]?._sum.percent ?? 0;
|
||||
if (sum !== 100) {
|
||||
const err: any = new Error(`Percents must sum to 100 (got ${sum}).`);
|
||||
err.statusCode = 400;
|
||||
err.code = "PERCENT_TOTAL_NOT_100";
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
const plugin: FastifyPluginAsync = async (app) => {
|
||||
// CREATE
|
||||
app.post("/api/variable-categories", async (req, reply) => {
|
||||
const userId = req.userId;
|
||||
const body = NewCat.safeParse(req.body);
|
||||
if (!body.success) return reply.status(400).send({ error: "INVALID_BODY", details: body.error.flatten() });
|
||||
|
||||
const created = await prisma.$transaction(async (tx) => {
|
||||
const rec = await tx.variableCategory.create({
|
||||
data: { ...body.data, userId },
|
||||
select: { id: true },
|
||||
});
|
||||
await assertPercentTotal100(tx, userId);
|
||||
return rec;
|
||||
});
|
||||
|
||||
return reply.status(201).send(created);
|
||||
});
|
||||
|
||||
// UPDATE
|
||||
app.patch("/api/variable-categories/:id", async (req, reply) => {
|
||||
const userId = req.userId;
|
||||
const pid = IdParam.safeParse(req.params);
|
||||
const patch = PatchCat.safeParse(req.body);
|
||||
if (!pid.success) return reply.status(400).send({ error: "INVALID_ID", details: pid.error.flatten() });
|
||||
if (!patch.success) return reply.status(400).send({ error: "INVALID_BODY", details: patch.error.flatten() });
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
const exists = await tx.variableCategory.findFirst({ where: { id: pid.data.id, userId } });
|
||||
if (!exists) return reply.status(404).send({ error: "NOT_FOUND" });
|
||||
await tx.variableCategory.update({ where: { id: pid.data.id }, data: patch.data });
|
||||
await assertPercentTotal100(tx, userId);
|
||||
});
|
||||
|
||||
return reply.send({ ok: true });
|
||||
});
|
||||
|
||||
// DELETE
|
||||
app.delete("/api/variable-categories/:id", async (req, reply) => {
|
||||
const userId = req.userId;
|
||||
const pid = IdParam.safeParse(req.params);
|
||||
if (!pid.success) return reply.status(400).send({ error: "INVALID_ID", details: pid.error.flatten() });
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
const exists = await tx.variableCategory.findFirst({ where: { id: pid.data.id, userId } });
|
||||
if (!exists) return reply.status(404).send({ error: "NOT_FOUND" });
|
||||
await tx.variableCategory.delete({ where: { id: pid.data.id } });
|
||||
await assertPercentTotal100(tx, userId);
|
||||
});
|
||||
|
||||
return reply.send({ ok: true });
|
||||
});
|
||||
};
|
||||
|
||||
export default plugin;
|
||||
@@ -1,20 +0,0 @@
|
||||
// prisma/seed.ts (optional: creates one demo user + categories)
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
const db = new PrismaClient();
|
||||
async function main() {
|
||||
const user = await db.user.upsert({
|
||||
where: { email: 'demo@user.test' },
|
||||
update: {},
|
||||
create: { email: 'demo@user.test' }
|
||||
});
|
||||
await db.variableCategory.createMany({
|
||||
data: [
|
||||
{ userId: user.id, name: 'Groceries', percent: 30, priority: 10 },
|
||||
{ userId: user.id, name: 'Gas', percent: 20, priority: 20 },
|
||||
{ userId: user.id, name: 'Fun', percent: 50, priority: 30 }
|
||||
],
|
||||
skipDuplicates: true
|
||||
});
|
||||
console.log('Seeded:', user.email);
|
||||
}
|
||||
main().finally(()=>db.$disconnect());
|
||||
@@ -1,10 +1,516 @@
|
||||
// api/src/server.ts
|
||||
import Fastify from "fastify";
|
||||
import cors from "@fastify/cors";
|
||||
import rateLimit from "@fastify/rate-limit";
|
||||
import { env } from "./env.js";
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import { z } from "zod";
|
||||
import { allocateIncome } from "./allocator.js";
|
||||
|
||||
const app = Fastify({ logger: true });
|
||||
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
8
api/src/types/fastify-prisma.d.ts
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
import "fastify";
|
||||
import type { PrismaClient } from "@prisma/client";
|
||||
|
||||
declare module "fastify" {
|
||||
interface FastifyInstance {
|
||||
prisma: PrismaClient;
|
||||
}
|
||||
}
|
||||
65
api/tests/allocator.test.ts
Normal file
65
api/tests/allocator.test.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
// tests/allocator.test.ts
|
||||
import { describe, it, expect, beforeEach, afterAll } from "vitest";
|
||||
import { prisma, resetUser, ensureUser, U, cid, pid, closePrisma } from "./helpers";
|
||||
import { allocateIncome } from "../src/allocator";
|
||||
|
||||
describe("allocator — core behaviors", () => {
|
||||
beforeEach(async () => {
|
||||
await resetUser(U);
|
||||
await ensureUser(U);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await closePrisma();
|
||||
});
|
||||
|
||||
it("distributes remainder to variables by largest remainder with savings-first tie", async () => {
|
||||
const c1 = cid("c1");
|
||||
const c2 = cid("c2"); // make this savings to test the tiebreaker
|
||||
await prisma.variableCategory.createMany({
|
||||
data: [
|
||||
{ id: c1, userId: U, name: "Groceries", percent: 60, priority: 2, isSavings: false, balanceCents: 0n },
|
||||
{ id: c2, userId: U, name: "Saver", percent: 40, priority: 1, isSavings: true, balanceCents: 0n },
|
||||
],
|
||||
});
|
||||
|
||||
const p1 = pid("rent");
|
||||
await prisma.fixedPlan.create({
|
||||
data: {
|
||||
id: p1,
|
||||
userId: U,
|
||||
name: "Rent",
|
||||
cycleStart: new Date().toISOString(),
|
||||
dueOn: new Date(Date.now() + 7 * 864e5).toISOString(),
|
||||
totalCents: 10000n,
|
||||
fundedCents: 0n,
|
||||
priority: 1,
|
||||
fundingMode: "auto-on-deposit",
|
||||
},
|
||||
});
|
||||
|
||||
// $100 income
|
||||
const result = await allocateIncome(prisma as any, U, 10000, new Date().toISOString(), "inc1");
|
||||
expect(result).toBeDefined();
|
||||
// rent should be funded first up to need
|
||||
const fixed = result.fixedAllocations ?? [];
|
||||
const variable = result.variableAllocations ?? [];
|
||||
|
||||
// sanity
|
||||
expect(Array.isArray(fixed)).toBe(true);
|
||||
expect(Array.isArray(variable)).toBe(true);
|
||||
});
|
||||
|
||||
it("handles zeros and single bucket", async () => {
|
||||
const cOnly = cid("only");
|
||||
await prisma.variableCategory.create({
|
||||
data: { id: cOnly, userId: U, name: "Only", percent: 100, priority: 1, isSavings: false, balanceCents: 0n },
|
||||
});
|
||||
|
||||
const result = await allocateIncome(prisma as any, U, 0, new Date().toISOString(), "inc2");
|
||||
expect(result).toBeDefined();
|
||||
const variable = result.variableAllocations ?? [];
|
||||
const sum = variable.reduce((s, a: any) => s + (a.amountCents ?? 0), 0);
|
||||
expect(sum).toBe(0);
|
||||
});
|
||||
});
|
||||
8
api/tests/appFactory.ts
Normal file
8
api/tests/appFactory.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import type { FastifyInstance } from "fastify";
|
||||
|
||||
export default async function appFactory(): Promise<FastifyInstance> {
|
||||
// env is already set in tests/setup.ts, so now we can import
|
||||
const { default: app } = await import("../src/server"); // ESM + TLA safe
|
||||
await app.ready(); // ensure all plugins registered
|
||||
return app;
|
||||
}
|
||||
42
api/tests/helpers.ts
Normal file
42
api/tests/helpers.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
// tests/helpers.ts
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
|
||||
export const prisma = new PrismaClient();
|
||||
|
||||
// Handy test user id
|
||||
export const U = "demo-user-1";
|
||||
|
||||
// Monotonic id helpers so we never collide with existing rows
|
||||
let cseq = 0;
|
||||
let pseq = 0;
|
||||
export const cid = (base = "c") => `${base}_${Date.now()}_${cseq++}`;
|
||||
export const pid = (base = "p") => `${base}_${Date.now()}_${pseq++}`;
|
||||
|
||||
/**
|
||||
* Hard-reset all data for a given user in dependency-safe order.
|
||||
* Also deletes the user row so tests can re-create/upsert cleanly.
|
||||
*/
|
||||
export async function resetUser(userId: string) {
|
||||
await prisma.$transaction([
|
||||
prisma.allocation.deleteMany({ where: { userId } }),
|
||||
prisma.transaction.deleteMany({ where: { userId } }),
|
||||
prisma.incomeEvent.deleteMany({ where: { userId } }),
|
||||
prisma.fixedPlan.deleteMany({ where: { userId } }),
|
||||
prisma.variableCategory.deleteMany({ where: { userId } }),
|
||||
]);
|
||||
await prisma.user.deleteMany({ where: { id: userId } });
|
||||
}
|
||||
|
||||
/** Ensure the user exists (id stable) */
|
||||
export async function ensureUser(userId: string) {
|
||||
await prisma.user.upsert({
|
||||
where: { id: userId },
|
||||
update: {},
|
||||
create: { id: userId, email: `${userId}@demo.local` },
|
||||
});
|
||||
}
|
||||
|
||||
/** Close Prisma after all tests */
|
||||
export async function closePrisma() {
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
71
api/tests/income.integration.test.ts
Normal file
71
api/tests/income.integration.test.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { beforeAll, afterAll, describe, it, expect } from "vitest";
|
||||
import request from "supertest";
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { resetUser } from "./helpers";
|
||||
|
||||
// Ensure env BEFORE importing the server
|
||||
process.env.NODE_ENV = process.env.NODE_ENV || "test";
|
||||
process.env.PORT = process.env.PORT || "0";
|
||||
process.env.DATABASE_URL =
|
||||
process.env.DATABASE_URL || "postgres://app:app@localhost:5432/skymoney";
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
let app: FastifyInstance;
|
||||
|
||||
beforeAll(async () => {
|
||||
// dynamic import AFTER env is set
|
||||
const { default: srv } = await import("../src/server"); // <-- needs `export default app` in server.ts
|
||||
app = srv;
|
||||
await app.ready();
|
||||
|
||||
const U = "demo-user-1";
|
||||
await resetUser(U);
|
||||
|
||||
await prisma.user.upsert({
|
||||
where: { id: U },
|
||||
update: {},
|
||||
create: { id: U, email: `${U}@demo.local` },
|
||||
});
|
||||
|
||||
await prisma.variableCategory.createMany({
|
||||
data: [
|
||||
{ id: "c1", userId: U, name: "Groceries", percent: 60, priority: 1, isSavings: false, balanceCents: 0n },
|
||||
{ id: "c2", userId: U, name: "Saver", percent: 40, priority: 2, isSavings: true, balanceCents: 0n },
|
||||
],
|
||||
skipDuplicates: true,
|
||||
});
|
||||
|
||||
await prisma.fixedPlan.create({
|
||||
data: {
|
||||
id: "p1",
|
||||
userId: U,
|
||||
name: "Rent",
|
||||
cycleStart: new Date().toISOString(),
|
||||
dueOn: new Date(Date.now() + 7 * 864e5).toISOString(),
|
||||
totalCents: 10000n,
|
||||
fundedCents: 0n,
|
||||
priority: 1,
|
||||
fundingMode: "auto-on-deposit",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
|
||||
describe("POST /income integration", () => {
|
||||
it("allocates funds and returns audit", async () => {
|
||||
const res = await request(app.server)
|
||||
.post("/income")
|
||||
.set("x-user-id", "demo-user-1")
|
||||
.send({ amountCents: 5000 });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toHaveProperty("fixedAllocations");
|
||||
expect(res.body).toHaveProperty("variableAllocations");
|
||||
expect(typeof res.body.remainingUnallocatedCents).toBe("number");
|
||||
});
|
||||
});
|
||||
61
api/tests/income.test.ts
Normal file
61
api/tests/income.test.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
// tests/income.test.ts
|
||||
import request from "supertest";
|
||||
import { describe, it, expect, beforeEach, afterAll, beforeAll } from "vitest";
|
||||
import appFactory from "./appFactory";
|
||||
import { prisma, resetUser, ensureUser, U, cid, pid, closePrisma } from "./helpers";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
|
||||
let app: FastifyInstance;
|
||||
|
||||
beforeAll(async () => {
|
||||
app = await appFactory(); // <-- await the app
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close(); // <-- close server
|
||||
await closePrisma();
|
||||
});
|
||||
|
||||
describe("POST /income", () => {
|
||||
beforeEach(async () => {
|
||||
await resetUser(U);
|
||||
await ensureUser(U);
|
||||
|
||||
await prisma.variableCategory.createMany({
|
||||
data: [
|
||||
{ id: cid("c1"), userId: U, name: "Groceries", percent: 60, priority: 2, isSavings: false, balanceCents: 0n },
|
||||
{ id: cid("c2"), userId: U, name: "Saver", percent: 40, priority: 1, isSavings: true, balanceCents: 0n },
|
||||
],
|
||||
});
|
||||
|
||||
await prisma.fixedPlan.create({
|
||||
data: {
|
||||
id: pid("rent"),
|
||||
userId: U,
|
||||
name: "Rent",
|
||||
cycleStart: new Date().toISOString(),
|
||||
dueOn: new Date(Date.now() + 7 * 864e5).toISOString(),
|
||||
totalCents: 10000n,
|
||||
fundedCents: 0n,
|
||||
priority: 1,
|
||||
fundingMode: "auto-on-deposit",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await closePrisma();
|
||||
});
|
||||
|
||||
it("allocates fixed first then variables; updates balances; returns audit", async () => {
|
||||
const res = await request(app.server)
|
||||
.post("/income")
|
||||
.set("x-user-id", U)
|
||||
.send({ amountCents: 15000 });
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.body).toHaveProperty("fixedAllocations");
|
||||
expect(res.body).toHaveProperty("variableAllocations");
|
||||
expect(typeof res.body.remainingUnallocatedCents).toBe("number");
|
||||
});
|
||||
});
|
||||
32
api/tests/setup.ts
Normal file
32
api/tests/setup.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { execSync } from "node:child_process";
|
||||
import { beforeAll, afterAll } from "vitest";
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
|
||||
process.env.NODE_ENV = process.env.NODE_ENV || "test";
|
||||
process.env.DATABASE_URL =
|
||||
process.env.DATABASE_URL ||
|
||||
"postgres://app:app@localhost:5432/skymoney";
|
||||
process.env.PORT = process.env.PORT || "0"; // fastify can bind an ephemeral port
|
||||
process.env.HOST ??= "127.0.0.1";
|
||||
process.env.CORS_ORIGIN = process.env.CORS_ORIGIN || "";
|
||||
|
||||
export const prisma = new PrismaClient();
|
||||
|
||||
// hard reset for a single user
|
||||
export async function resetUser(userId: string) {
|
||||
await prisma.allocation.deleteMany({ where: { userId } });
|
||||
await prisma.transaction.deleteMany({ where: { userId } });
|
||||
await prisma.incomeEvent.deleteMany({ where: { userId } });
|
||||
await prisma.fixedPlan.deleteMany({ where: { userId } });
|
||||
await prisma.variableCategory.deleteMany({ where: { userId } });
|
||||
await prisma.user.deleteMany({ where: { id: userId } });
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
// make sure the schema is applied before running tests
|
||||
execSync("npx prisma migrate deploy", { stdio: "inherit" });
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
66
api/tests/transactions.test.ts
Normal file
66
api/tests/transactions.test.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import request from "supertest";
|
||||
import { describe, it, expect, beforeAll, beforeEach, afterAll } from "vitest";
|
||||
import appFactory from "./appFactory";
|
||||
import { prisma, resetUser, ensureUser, U, cid, closePrisma } from "./helpers";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
|
||||
let app: FastifyInstance;
|
||||
|
||||
beforeAll(async () => {
|
||||
app = await appFactory();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
await closePrisma();
|
||||
});
|
||||
|
||||
describe("GET /transactions", () => {
|
||||
beforeEach(async () => {
|
||||
await resetUser(U);
|
||||
await ensureUser(U);
|
||||
|
||||
const c = cid("c");
|
||||
await prisma.variableCategory.create({
|
||||
data: { id: c, userId: U, name: "Groceries", percent: 100, priority: 1, isSavings: false, balanceCents: 5000n },
|
||||
});
|
||||
|
||||
// seed some transactions of different kinds/dates
|
||||
await prisma.transaction.createMany({
|
||||
data: [
|
||||
{
|
||||
id: `t_${Date.now()}_1`,
|
||||
userId: U,
|
||||
occurredAt: new Date("2025-01-03T12:00:00.000Z"),
|
||||
kind: "variable_spend",
|
||||
categoryId: c,
|
||||
amountCents: 1000n,
|
||||
},
|
||||
{
|
||||
id: `t_${Date.now()}_2`,
|
||||
userId: U,
|
||||
occurredAt: new Date("2025-01-10T12:00:00.000Z"),
|
||||
kind: "fixed_payment",
|
||||
planId: null,
|
||||
amountCents: 2000n,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await closePrisma();
|
||||
});
|
||||
|
||||
it("paginates + filters by kind/date", async () => {
|
||||
const res = await request(app.server)
|
||||
.get("/transactions?from=2025-01-02&to=2025-01-06&kind=variable_spend&page=1&limit=10")
|
||||
.set("x-user-id", U);
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = res.body;
|
||||
expect(Array.isArray(body.items)).toBe(true);
|
||||
expect(body.items.length).toBe(1);
|
||||
expect(body.items[0].kind).toBe("variable_spend");
|
||||
});
|
||||
});
|
||||
56
api/tests/variable-categories.guard.test.ts
Normal file
56
api/tests/variable-categories.guard.test.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
// tests/variable-categories.guard.test.ts
|
||||
import request from "supertest";
|
||||
import { describe, it, expect, beforeEach, beforeAll, afterAll } from "vitest";
|
||||
import appFactory from "./appFactory";
|
||||
import { prisma, resetUser, ensureUser, U, cid, closePrisma } from "./helpers";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
|
||||
let app: FastifyInstance;
|
||||
|
||||
beforeAll(async () => {
|
||||
app = await appFactory();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
await closePrisma();
|
||||
});
|
||||
|
||||
describe("Variable Categories guard (sum=100)", () => {
|
||||
beforeEach(async () => {
|
||||
await resetUser(U);
|
||||
await ensureUser(U);
|
||||
|
||||
await prisma.variableCategory.createMany({
|
||||
data: [
|
||||
{ id: cid("a"), userId: U, name: "A", percent: 50, priority: 1, isSavings: false, balanceCents: 0n },
|
||||
{ id: cid("b"), userId: U, name: "B", percent: 50, priority: 2, isSavings: false, balanceCents: 0n },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await closePrisma();
|
||||
});
|
||||
|
||||
it("rejects create that would push sum away from 100", async () => {
|
||||
const res = await request(app.server)
|
||||
.post("/variable-categories")
|
||||
.set("x-user-id", U)
|
||||
.send({ name: "Oops", percent: 10, isSavings: false, priority: 99 });
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(res.body?.message).toMatch(/Percents must sum to 100/i);
|
||||
});
|
||||
|
||||
it("rejects update that breaks the sum", async () => {
|
||||
const existing = await prisma.variableCategory.findFirst({ where: { userId: U } });
|
||||
const res = await request(app.server)
|
||||
.patch(`/variable-categories/${existing!.id}`)
|
||||
.set("x-user-id", U)
|
||||
.send({ percent: 90 });
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(res.body?.message).toMatch(/Percents must sum to 100/i);
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ES2022",
|
||||
"moduleResolution": "bundler",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"rootDir": "src",
|
||||
"outDir": "dist",
|
||||
"strict": true,
|
||||
@@ -10,6 +10,6 @@
|
||||
"skipLibCheck": true,
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src"],
|
||||
"include": ["src", "env.ts"],
|
||||
"exclude": ["node_modules", "dist", "prisma.config.ts"]
|
||||
}
|
||||
14
api/vitest.config.ts
Normal file
14
api/vitest.config.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: "node",
|
||||
include: ["tests/**/*.test.ts"],
|
||||
// run single-threaded to keep DB tests deterministic
|
||||
pool: "threads",
|
||||
poolOptions: { threads: { singleThread: true } },
|
||||
testTimeout: 30_000,
|
||||
env: { NODE_ENV: "test" },
|
||||
setupFiles: ['tests/setup.ts'],
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user