added api logic, vitest, minimal testing ui

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

View File

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

View File

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

View File

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

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

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

View File

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