Functional account summaries
This commit is contained in:
parent
15ff9609c4
commit
d485c2adcf
9
.vscode/launch.json
vendored
Normal file
9
.vscode/launch.json
vendored
Normal 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": [
|
||||
|
||||
]
|
||||
}
|
28
drizzle/0001_old_leopardon.sql
Normal file
28
drizzle/0001_old_leopardon.sql
Normal 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;
|
338
drizzle/meta/0001_snapshot.json
Normal file
338
drizzle/meta/0001_snapshot.json
Normal 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": {}
|
||||
}
|
||||
}
|
@ -8,6 +8,13 @@
|
||||
"when": 1738197417069,
|
||||
"tag": "0000_users-and-sessions",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 1,
|
||||
"version": "7",
|
||||
"when": 1738208377219,
|
||||
"tag": "0001_old_leopardon",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
31
src/lib/server/accounting.ts
Normal file
31
src/lib/server/accounting.ts
Normal 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))
|
||||
}
|
@ -1,10 +1,12 @@
|
||||
import { relations } from "drizzle-orm";
|
||||
import { ne, relations } from "drizzle-orm";
|
||||
import {
|
||||
check,
|
||||
integer,
|
||||
numeric,
|
||||
pgTable,
|
||||
varchar,
|
||||
text,
|
||||
timestamp,
|
||||
varchar,
|
||||
} from "drizzle-orm/pg-core";
|
||||
|
||||
export const users = pgTable("users", {
|
||||
@ -14,6 +16,10 @@ export const users = pgTable("users", {
|
||||
displayName: text(),
|
||||
});
|
||||
|
||||
export const userRelations = relations(users, ({ many }) => ({
|
||||
accounts: many(accounts),
|
||||
}));
|
||||
|
||||
export const sessions = pgTable("sessions", {
|
||||
id: text().primaryKey(),
|
||||
userId: integer()
|
||||
@ -24,7 +30,74 @@ export const sessions = pgTable("sessions", {
|
||||
|
||||
export const sessionRelations = relations(sessions, ({ one }) => ({
|
||||
user: one(users, {
|
||||
fields: [ sessions.userId ],
|
||||
fields: [sessions.userId],
|
||||
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],
|
||||
}),
|
||||
}));
|
||||
|
@ -1,45 +1,66 @@
|
||||
import { eq } from "drizzle-orm";
|
||||
import { db } from "./db";
|
||||
import { users } from "./schema";
|
||||
import type { RequestEvent } from "../../routes/$types";
|
||||
import { redirect } from "@sveltejs/kit";
|
||||
|
||||
export interface User {
|
||||
id: number;
|
||||
username: string;
|
||||
displayName?: string;
|
||||
id: number;
|
||||
username: string;
|
||||
displayName?: string;
|
||||
}
|
||||
|
||||
|
||||
export async function getUserFromUsername(username: string): Promise<User | null> {
|
||||
const user = await db.query.users.findFirst({
|
||||
columns: {
|
||||
id: true,
|
||||
username: true,
|
||||
displayName: true
|
||||
},
|
||||
where: eq(users.username, username)
|
||||
});
|
||||
export async function getUserFromUsername(
|
||||
username: string,
|
||||
): Promise<User | null> {
|
||||
const user = await db.query.users.findFirst({
|
||||
columns: {
|
||||
id: true,
|
||||
username: true,
|
||||
displayName: true,
|
||||
},
|
||||
where: eq(users.username, username),
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
...user,
|
||||
displayName: user.displayName || undefined,
|
||||
};
|
||||
...user,
|
||||
displayName: user.displayName || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getUserPasswordHash(userId: number): Promise<string|null> {
|
||||
const user = (await db.query.users.findFirst({
|
||||
columns: {
|
||||
passwordHash: true,
|
||||
},
|
||||
where: eq(users.id, userId)
|
||||
}));
|
||||
export async function getUserPasswordHash(
|
||||
userId: number,
|
||||
): Promise<string | null> {
|
||||
const user = await db.query.users.findFirst({
|
||||
columns: {
|
||||
passwordHash: true,
|
||||
},
|
||||
where: eq(users.id, userId),
|
||||
});
|
||||
|
||||
if (user === undefined) {
|
||||
throw new Error("Invalid user ID");
|
||||
}
|
||||
if (user === undefined) {
|
||||
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();
|
||||
}
|
12
src/routes/+page.server.ts
Normal file
12
src/routes/+page.server.ts
Normal 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),
|
||||
}
|
||||
};
|
@ -6,3 +6,11 @@
|
||||
|
||||
<h1>Welcome to SvelteKit</h1>
|
||||
<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>
|
Loading…
x
Reference in New Issue
Block a user