added api logic, vitest, minimal testing ui
This commit is contained in:
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"]
|
||||
}
|
||||
Reference in New Issue
Block a user