Functional account summaries

This commit is contained in:
Sandra Modzelewska 2025-01-30 05:11:45 +00:00
parent 15ff9609c4
commit d485c2adcf
No known key found for this signature in database
9 changed files with 559 additions and 32 deletions

9
.vscode/launch.json vendored Normal file
View File

@ -0,0 +1,9 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
]
}

View File

@ -0,0 +1,28 @@
CREATE TABLE "accounts" (
"id" integer PRIMARY KEY GENERATED ALWAYS AS IDENTITY (sequence name "accounts_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 2147483647 START WITH 1 CACHE 1),
"owner_id" integer NOT NULL,
"name" text NOT NULL,
"currency" text NOT NULL,
"starting_date" timestamp with time zone NOT NULL,
"starting_balance" numeric DEFAULT '0' NOT NULL
);
--> statement-breakpoint
CREATE TABLE "receipt_items" (
"id" integer PRIMARY KEY GENERATED ALWAYS AS IDENTITY (sequence name "receipt_items_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 2147483647 START WITH 1 CACHE 1),
"receipt_id" integer NOT NULL,
"amount" numeric DEFAULT '0' NOT NULL,
"category" text NOT NULL
);
--> statement-breakpoint
CREATE TABLE "receipts" (
"id" integer PRIMARY KEY GENERATED ALWAYS AS IDENTITY (sequence name "receipts_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 2147483647 START WITH 1 CACHE 1),
"date" timestamp with time zone NOT NULL,
"account_from_id" integer NOT NULL,
"account_to_id" integer,
CONSTRAINT "no_account_loops" CHECK ("receipts"."account_from_id" <> "receipts"."account_to_id")
);
--> statement-breakpoint
ALTER TABLE "accounts" ADD CONSTRAINT "accounts_owner_id_users_id_fk" FOREIGN KEY ("owner_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "receipt_items" ADD CONSTRAINT "receipt_items_receipt_id_receipts_id_fk" FOREIGN KEY ("receipt_id") REFERENCES "public"."receipts"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "receipts" ADD CONSTRAINT "receipts_account_from_id_accounts_id_fk" FOREIGN KEY ("account_from_id") REFERENCES "public"."accounts"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "receipts" ADD CONSTRAINT "receipts_account_to_id_accounts_id_fk" FOREIGN KEY ("account_to_id") REFERENCES "public"."accounts"("id") ON DELETE no action ON UPDATE no action;

View File

@ -0,0 +1,338 @@
{
"id": "812f446c-4ff5-43a4-bad1-9e4cbb821b35",
"prevId": "9f31c19a-9e3c-41db-a08b-27d9916265cb",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.accounts": {
"name": "accounts",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"identity": {
"type": "always",
"name": "accounts_id_seq",
"schema": "public",
"increment": "1",
"startWith": "1",
"minValue": "1",
"maxValue": "2147483647",
"cache": "1",
"cycle": false
}
},
"owner_id": {
"name": "owner_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"currency": {
"name": "currency",
"type": "text",
"primaryKey": false,
"notNull": true
},
"starting_date": {
"name": "starting_date",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true
},
"starting_balance": {
"name": "starting_balance",
"type": "numeric",
"primaryKey": false,
"notNull": true,
"default": "'0'"
}
},
"indexes": {},
"foreignKeys": {
"accounts_owner_id_users_id_fk": {
"name": "accounts_owner_id_users_id_fk",
"tableFrom": "accounts",
"tableTo": "users",
"columnsFrom": [
"owner_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.receipt_items": {
"name": "receipt_items",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"identity": {
"type": "always",
"name": "receipt_items_id_seq",
"schema": "public",
"increment": "1",
"startWith": "1",
"minValue": "1",
"maxValue": "2147483647",
"cache": "1",
"cycle": false
}
},
"receipt_id": {
"name": "receipt_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"amount": {
"name": "amount",
"type": "numeric",
"primaryKey": false,
"notNull": true,
"default": "'0'"
},
"category": {
"name": "category",
"type": "text",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"receipt_items_receipt_id_receipts_id_fk": {
"name": "receipt_items_receipt_id_receipts_id_fk",
"tableFrom": "receipt_items",
"tableTo": "receipts",
"columnsFrom": [
"receipt_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.receipts": {
"name": "receipts",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"identity": {
"type": "always",
"name": "receipts_id_seq",
"schema": "public",
"increment": "1",
"startWith": "1",
"minValue": "1",
"maxValue": "2147483647",
"cache": "1",
"cycle": false
}
},
"date": {
"name": "date",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true
},
"account_from_id": {
"name": "account_from_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"account_to_id": {
"name": "account_to_id",
"type": "integer",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {
"receipts_account_from_id_accounts_id_fk": {
"name": "receipts_account_from_id_accounts_id_fk",
"tableFrom": "receipts",
"tableTo": "accounts",
"columnsFrom": [
"account_from_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"receipts_account_to_id_accounts_id_fk": {
"name": "receipts_account_to_id_accounts_id_fk",
"tableFrom": "receipts",
"tableTo": "accounts",
"columnsFrom": [
"account_to_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {
"no_account_loops": {
"name": "no_account_loops",
"value": "\"receipts\".\"account_from_id\" <> \"receipts\".\"account_to_id\""
}
},
"isRLSEnabled": false
},
"public.sessions": {
"name": "sessions",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"expires_at": {
"name": "expires_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"sessions_user_id_users_id_fk": {
"name": "sessions_user_id_users_id_fk",
"tableFrom": "sessions",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.users": {
"name": "users",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"identity": {
"type": "always",
"name": "users_id_seq",
"schema": "public",
"increment": "1",
"startWith": "1",
"minValue": "1",
"maxValue": "2147483647",
"cache": "1",
"cycle": false
}
},
"username": {
"name": "username",
"type": "varchar(128)",
"primaryKey": false,
"notNull": true
},
"password_hash": {
"name": "password_hash",
"type": "text",
"primaryKey": false,
"notNull": false
},
"display_name": {
"name": "display_name",
"type": "text",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"users_username_unique": {
"name": "users_username_unique",
"nullsNotDistinct": false,
"columns": [
"username"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}

View File

@ -8,6 +8,13 @@
"when": 1738197417069, "when": 1738197417069,
"tag": "0000_users-and-sessions", "tag": "0000_users-and-sessions",
"breakpoints": true "breakpoints": true
},
{
"idx": 1,
"version": "7",
"when": 1738208377219,
"tag": "0001_old_leopardon",
"breakpoints": true
} }
] ]
} }

View File

@ -0,0 +1,31 @@
import { and, eq, gt, sql } from "drizzle-orm";
import { db } from "./db";
import { accounts, receiptItems, receipts } from "./schema";
export interface AccountSummary {
id: number,
name: string,
balance: bigint,
currency: string,
lastReceipt?: Date,
}
export async function getUserAccountSummary(userId: number) {
return (await db
.select({
id: accounts.id,
name: accounts.name,
currency: accounts.currency,
balance: sql<string>`${accounts.startingBalance} + COALESCE(sum(${receiptItems.amount}), 0)`,
lastReceipt: sql<Date | null>`max(${receipts.date})`,
})
.from(accounts)
.where(eq(accounts.ownerId, userId))
.leftJoin(receipts, and(eq(receipts.accountFromId, accounts.id), gt(receipts.date, accounts.startingDate)))
.leftJoin(receiptItems, eq(receiptItems.receiptId, receipts.id))
.groupBy(accounts.id)).map((row) => ({
...row,
balance: BigInt(row.balance),
lastReceipt: row.lastReceipt ?? undefined,
} satisfies AccountSummary))
}

View File

@ -1,10 +1,12 @@
import { relations } from "drizzle-orm"; import { ne, relations } from "drizzle-orm";
import { import {
check,
integer, integer,
numeric,
pgTable, pgTable,
varchar,
text, text,
timestamp, timestamp,
varchar,
} from "drizzle-orm/pg-core"; } from "drizzle-orm/pg-core";
export const users = pgTable("users", { export const users = pgTable("users", {
@ -14,6 +16,10 @@ export const users = pgTable("users", {
displayName: text(), displayName: text(),
}); });
export const userRelations = relations(users, ({ many }) => ({
accounts: many(accounts),
}));
export const sessions = pgTable("sessions", { export const sessions = pgTable("sessions", {
id: text().primaryKey(), id: text().primaryKey(),
userId: integer() userId: integer()
@ -24,7 +30,74 @@ export const sessions = pgTable("sessions", {
export const sessionRelations = relations(sessions, ({ one }) => ({ export const sessionRelations = relations(sessions, ({ one }) => ({
user: one(users, { user: one(users, {
fields: [ sessions.userId ], fields: [sessions.userId],
references: [users.id], references: [users.id],
}), }),
})); }));
export const accounts = pgTable("accounts", {
id: integer().primaryKey().generatedAlwaysAsIdentity(),
ownerId: integer()
.notNull()
.references(() => users.id),
name: text().notNull(),
currency: text().notNull(),
startingDate: timestamp({ withTimezone: true }).notNull(),
startingBalance: numeric().notNull().default("0"),
});
export const accountRelations = relations(accounts, ({ one, many }) => ({
owner: one(users, {
fields: [accounts.ownerId],
references: [users.id],
}),
credits: many(receipts, { relationName: "account_from" }),
debits: many(receipts, { relationName: "account_to" }),
}));
export const receipts = pgTable(
"receipts",
{
id: integer().primaryKey().generatedAlwaysAsIdentity(),
date: timestamp({ withTimezone: true }).notNull(),
accountFromId: integer()
.notNull()
.references(() => accounts.id),
accountToId: integer().references(() => accounts.id),
},
(table) => [
check("no_account_loops", ne(table.accountFromId, table.accountToId)),
],
);
export const receiptRelations = relations(receipts, ({ one, many }) => ({
accountFrom: one(accounts, {
fields: [receipts.accountFromId],
references: [accounts.id],
relationName: "account_from",
}),
accountTo: one(accounts, {
fields: [receipts.accountToId],
references: [accounts.id],
relationName: "account_to",
}),
receipts: many(receipts),
}));
export const receiptItems = pgTable("receipt_items", {
id: integer().primaryKey().generatedAlwaysAsIdentity(),
receiptId: integer()
.notNull()
.references(() => receipts.id),
amount: numeric().notNull().default("0"),
category: text().notNull(),
});
export const receiptItemRelations = relations(receiptItems, ({ one }) => ({
receipt: one(receipts, {
fields: [receiptItems.receiptId],
references: [receipts.id],
}),
}));

View File

@ -1,45 +1,66 @@
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { db } from "./db"; import { db } from "./db";
import { users } from "./schema"; import { users } from "./schema";
import type { RequestEvent } from "../../routes/$types";
import { redirect } from "@sveltejs/kit";
export interface User { export interface User {
id: number; id: number;
username: string; username: string;
displayName?: string; displayName?: string;
} }
export async function getUserFromUsername(
export async function getUserFromUsername(username: string): Promise<User | null> { username: string,
const user = await db.query.users.findFirst({ ): Promise<User | null> {
columns: { const user = await db.query.users.findFirst({
id: true, columns: {
username: true, id: true,
displayName: true username: true,
}, displayName: true,
where: eq(users.username, username) },
}); where: eq(users.username, username),
});
if (!user) { if (!user) {
return null; return null;
} }
return { return {
...user, ...user,
displayName: user.displayName || undefined, displayName: user.displayName || undefined,
}; };
} }
export async function getUserPasswordHash(userId: number): Promise<string|null> { export async function getUserPasswordHash(
const user = (await db.query.users.findFirst({ userId: number,
columns: { ): Promise<string | null> {
passwordHash: true, const user = await db.query.users.findFirst({
}, columns: {
where: eq(users.id, userId) passwordHash: true,
})); },
where: eq(users.id, userId),
});
if (user === undefined) { if (user === undefined) {
throw new Error("Invalid user ID"); throw new Error("Invalid user ID");
} }
return user.passwordHash; return user.passwordHash;
}
export function ensureUser(
event: RequestEvent,
defaultAction: (() => never) | undefined = undefined,
): User {
const user = event.locals.user;
if (user !== null) {
return user;
}
if (defaultAction === undefined) {
redirect(303, "/login");
}
return defaultAction();
} }

View File

@ -0,0 +1,12 @@
import { getUserAccountSummary } from "$lib/server/accounting";
import { ensureUser } from "$lib/server/user";
import type { PageServerLoad } from "./$types";
export const load: PageServerLoad = async (event) => {
const user = ensureUser(event);
return {
accountSummaries: await getUserAccountSummary(user.id),
}
};

View File

@ -6,3 +6,11 @@
<h1>Welcome to SvelteKit</h1> <h1>Welcome to SvelteKit</h1>
<p>Visit <a href="https://svelte.dev/docs/kit">svelte.dev/docs/kit</a> to read the documentation</p> <p>Visit <a href="https://svelte.dev/docs/kit">svelte.dev/docs/kit</a> to read the documentation</p>
Your accounts:
<ul>
{#each data.accountSummaries as summary }
<li>{summary.name} - {summary.lastReceipt ?? "never"} - {summary.balance}{summary.currency}</li>
{/each}
</ul>