From b3ae1a51e4cfe90251d187a02567026dbde2b28c Mon Sep 17 00:00:00 2001 From: whekin Date: Thu, 5 Mar 2026 04:13:00 +0400 Subject: [PATCH] feat(WHE-29): add v1 accounting schema migration and seed fixtures --- docs/runbooks/dev-setup.md | 1 + docs/runbooks/migrations.md | 9 + docs/specs/HOUSEBOT-012-accounting-schema.md | 83 ++ package.json | 1 + packages/db/drizzle/0001_spicy_sersi.sql | 123 ++ packages/db/drizzle/meta/0001_snapshot.json | 1152 ++++++++++++++++++ packages/db/drizzle/meta/_journal.json | 7 + packages/db/package.json | 3 +- packages/db/src/schema.ts | 238 +++- packages/db/src/seed.ts | 226 ++++ 10 files changed, 1841 insertions(+), 2 deletions(-) create mode 100644 docs/specs/HOUSEBOT-012-accounting-schema.md create mode 100644 packages/db/drizzle/0001_spicy_sersi.sql create mode 100644 packages/db/drizzle/meta/0001_snapshot.json create mode 100644 packages/db/src/seed.ts diff --git a/docs/runbooks/dev-setup.md b/docs/runbooks/dev-setup.md index 7c3c787..8858161 100644 --- a/docs/runbooks/dev-setup.md +++ b/docs/runbooks/dev-setup.md @@ -25,6 +25,7 @@ bun run build bun run db:generate bun run db:check bun run db:migrate +bun run db:seed bun run infra:fmt:check bun run infra:validate ``` diff --git a/docs/runbooks/migrations.md b/docs/runbooks/migrations.md index cdbcdad..7a2efc7 100644 --- a/docs/runbooks/migrations.md +++ b/docs/runbooks/migrations.md @@ -58,3 +58,12 @@ bun run build 2. Data backfill/cutover 3. Cleanup migration - Never run `db:push` in production pipelines. + +## Rollback notes + +- If a migration fails mid-run, stop deploy and inspect `drizzle.__drizzle_migrations` state first. +- For additive migrations in v1, rollback by: + 1. Reverting application code to previous release. + 2. Leaving additive schema in place (safe default). +- For destructive migrations, require explicit rollback SQL script in the same PR before deploy approval. +- Keep one database backup/snapshot before production migration windows. diff --git a/docs/specs/HOUSEBOT-012-accounting-schema.md b/docs/specs/HOUSEBOT-012-accounting-schema.md new file mode 100644 index 0000000..b4b1573 --- /dev/null +++ b/docs/specs/HOUSEBOT-012-accounting-schema.md @@ -0,0 +1,83 @@ +# HOUSEBOT-012: V1 Accounting Schema + +## Summary + +Define and migrate a production-ready accounting schema for household rent/utilities/purchases and settlement snapshots. + +## Goals + +- Support all v1 finance flows in database form. +- Enforce idempotency constraints for Telegram message ingestion. +- Store all monetary values in minor integer units. +- Provide local seed fixtures for fast end-to-end development. + +## Non-goals + +- Bot command implementation. +- Settlement algorithm implementation. + +## Scope + +- In: Drizzle schema + generated SQL migration + seed script + runbook update. +- Out: adapter repositories and service handlers. + +## Interfaces and Contracts + +Tables covered: + +- `households` +- `members` +- `billing_cycles` +- `rent_rules` +- `utility_bills` +- `presence_overrides` +- `purchase_entries` +- `processed_bot_messages` +- `settlements` +- `settlement_lines` + +## Domain Rules + +- Minor-unit integer money only (`bigint` columns) +- Deterministic one-cycle settlement snapshot (`settlements.cycle_id` unique) +- Message idempotency enforced via unique source message keys + +## Data Model Changes + +- Add new finance tables and indexes for read/write paths. +- Add unique indexes for idempotency and period uniqueness. +- Preserve existing household/member table shape. + +## Security and Privacy + +- Store internal member IDs instead of personal attributes in finance tables. +- Keep raw purchase text for audit/parser debugging only. + +## Observability + +- `processed_bot_messages` and settlement metadata provide traceability. + +## Edge Cases and Failure Modes + +- Duplicate message ingestion attempts. +- Missing active cycle when writing purchases/bills. +- Partial fixture seed runs. + +## Test Plan + +- Generate and check migration metadata. +- Typecheck db package. +- Execute seed script on migrated database. + +## Acceptance Criteria + +- [ ] `bun run db:generate` produces migration SQL for schema updates. +- [ ] `bun run db:check` passes. +- [ ] Schema supports v1 rent/utilities/purchase/settlement workflows. +- [ ] Idempotency indexes exist for Telegram ingestion. +- [ ] Migration runbook includes rollback notes. + +## Rollout Plan + +- Apply migration in dev, run seed, validate queryability. +- Promote migration through CI/CD pipeline before production cutover. diff --git a/package.json b/package.json index d532061..9b60553 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "db:migrate": "bunx drizzle-kit migrate --config packages/db/drizzle.config.ts", "db:push": "bunx drizzle-kit push --config packages/db/drizzle.config.ts", "db:studio": "bunx drizzle-kit studio --config packages/db/drizzle.config.ts", + "db:seed": "set -a; [ -f .env ] && . ./.env; set +a; bun run --filter @household/db seed", "review:coderabbit": "coderabbit --prompt-only --base main || ~/.local/bin/coderabbit --prompt-only --base main", "infra:fmt": "terraform -chdir=infra/terraform fmt -recursive", "infra:fmt:check": "terraform -chdir=infra/terraform fmt -check -recursive", diff --git a/packages/db/drizzle/0001_spicy_sersi.sql b/packages/db/drizzle/0001_spicy_sersi.sql new file mode 100644 index 0000000..35cc673 --- /dev/null +++ b/packages/db/drizzle/0001_spicy_sersi.sql @@ -0,0 +1,123 @@ +CREATE TABLE "billing_cycles" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "household_id" uuid NOT NULL, + "period" text NOT NULL, + "currency" text NOT NULL, + "started_at" timestamp with time zone DEFAULT now() NOT NULL, + "closed_at" timestamp with time zone, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "presence_overrides" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "cycle_id" uuid NOT NULL, + "member_id" uuid NOT NULL, + "utility_days" integer NOT NULL, + "reason" text, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "processed_bot_messages" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "household_id" uuid NOT NULL, + "source" text NOT NULL, + "source_message_key" text NOT NULL, + "payload_hash" text, + "processed_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "purchase_entries" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "household_id" uuid NOT NULL, + "cycle_id" uuid, + "payer_member_id" uuid NOT NULL, + "amount_minor" bigint NOT NULL, + "currency" text NOT NULL, + "raw_text" text NOT NULL, + "normalized_text" text, + "parser_mode" text NOT NULL, + "parser_confidence" integer NOT NULL, + "telegram_chat_id" text, + "telegram_message_id" text, + "telegram_thread_id" text, + "message_sent_at" timestamp with time zone, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "rent_rules" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "household_id" uuid NOT NULL, + "amount_minor" bigint NOT NULL, + "currency" text NOT NULL, + "effective_from_period" text NOT NULL, + "effective_to_period" text, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "settlement_lines" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "settlement_id" uuid NOT NULL, + "member_id" uuid NOT NULL, + "rent_share_minor" bigint NOT NULL, + "utility_share_minor" bigint NOT NULL, + "purchase_offset_minor" bigint NOT NULL, + "net_due_minor" bigint NOT NULL, + "explanations" jsonb DEFAULT '[]'::jsonb NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "settlements" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "household_id" uuid NOT NULL, + "cycle_id" uuid NOT NULL, + "input_hash" text NOT NULL, + "total_due_minor" bigint NOT NULL, + "currency" text NOT NULL, + "computed_at" timestamp with time zone DEFAULT now() NOT NULL, + "metadata" jsonb DEFAULT '{}'::jsonb NOT NULL +); +--> statement-breakpoint +CREATE TABLE "utility_bills" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "household_id" uuid NOT NULL, + "cycle_id" uuid NOT NULL, + "bill_name" text NOT NULL, + "amount_minor" bigint NOT NULL, + "currency" text NOT NULL, + "due_date" date, + "source" text DEFAULT 'manual' NOT NULL, + "created_by_member_id" uuid, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "billing_cycles" ADD CONSTRAINT "billing_cycles_household_id_households_id_fk" FOREIGN KEY ("household_id") REFERENCES "public"."households"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "presence_overrides" ADD CONSTRAINT "presence_overrides_cycle_id_billing_cycles_id_fk" FOREIGN KEY ("cycle_id") REFERENCES "public"."billing_cycles"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "presence_overrides" ADD CONSTRAINT "presence_overrides_member_id_members_id_fk" FOREIGN KEY ("member_id") REFERENCES "public"."members"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "processed_bot_messages" ADD CONSTRAINT "processed_bot_messages_household_id_households_id_fk" FOREIGN KEY ("household_id") REFERENCES "public"."households"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "purchase_entries" ADD CONSTRAINT "purchase_entries_household_id_households_id_fk" FOREIGN KEY ("household_id") REFERENCES "public"."households"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "purchase_entries" ADD CONSTRAINT "purchase_entries_cycle_id_billing_cycles_id_fk" FOREIGN KEY ("cycle_id") REFERENCES "public"."billing_cycles"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "purchase_entries" ADD CONSTRAINT "purchase_entries_payer_member_id_members_id_fk" FOREIGN KEY ("payer_member_id") REFERENCES "public"."members"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "rent_rules" ADD CONSTRAINT "rent_rules_household_id_households_id_fk" FOREIGN KEY ("household_id") REFERENCES "public"."households"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "settlement_lines" ADD CONSTRAINT "settlement_lines_settlement_id_settlements_id_fk" FOREIGN KEY ("settlement_id") REFERENCES "public"."settlements"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "settlement_lines" ADD CONSTRAINT "settlement_lines_member_id_members_id_fk" FOREIGN KEY ("member_id") REFERENCES "public"."members"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "settlements" ADD CONSTRAINT "settlements_household_id_households_id_fk" FOREIGN KEY ("household_id") REFERENCES "public"."households"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "settlements" ADD CONSTRAINT "settlements_cycle_id_billing_cycles_id_fk" FOREIGN KEY ("cycle_id") REFERENCES "public"."billing_cycles"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "utility_bills" ADD CONSTRAINT "utility_bills_household_id_households_id_fk" FOREIGN KEY ("household_id") REFERENCES "public"."households"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "utility_bills" ADD CONSTRAINT "utility_bills_cycle_id_billing_cycles_id_fk" FOREIGN KEY ("cycle_id") REFERENCES "public"."billing_cycles"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "utility_bills" ADD CONSTRAINT "utility_bills_created_by_member_id_members_id_fk" FOREIGN KEY ("created_by_member_id") REFERENCES "public"."members"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +CREATE UNIQUE INDEX "billing_cycles_household_period_unique" ON "billing_cycles" USING btree ("household_id","period");--> statement-breakpoint +CREATE INDEX "billing_cycles_household_period_idx" ON "billing_cycles" USING btree ("household_id","period");--> statement-breakpoint +CREATE UNIQUE INDEX "presence_overrides_cycle_member_unique" ON "presence_overrides" USING btree ("cycle_id","member_id");--> statement-breakpoint +CREATE INDEX "presence_overrides_cycle_idx" ON "presence_overrides" USING btree ("cycle_id");--> statement-breakpoint +CREATE UNIQUE INDEX "processed_bot_messages_source_message_unique" ON "processed_bot_messages" USING btree ("household_id","source","source_message_key");--> statement-breakpoint +CREATE INDEX "purchase_entries_household_cycle_idx" ON "purchase_entries" USING btree ("household_id","cycle_id");--> statement-breakpoint +CREATE INDEX "purchase_entries_payer_idx" ON "purchase_entries" USING btree ("payer_member_id");--> statement-breakpoint +CREATE UNIQUE INDEX "purchase_entries_household_tg_message_unique" ON "purchase_entries" USING btree ("household_id","telegram_chat_id","telegram_message_id");--> statement-breakpoint +CREATE UNIQUE INDEX "rent_rules_household_from_period_unique" ON "rent_rules" USING btree ("household_id","effective_from_period");--> statement-breakpoint +CREATE INDEX "rent_rules_household_from_period_idx" ON "rent_rules" USING btree ("household_id","effective_from_period");--> statement-breakpoint +CREATE UNIQUE INDEX "settlement_lines_settlement_member_unique" ON "settlement_lines" USING btree ("settlement_id","member_id");--> statement-breakpoint +CREATE INDEX "settlement_lines_settlement_idx" ON "settlement_lines" USING btree ("settlement_id");--> statement-breakpoint +CREATE UNIQUE INDEX "settlements_cycle_unique" ON "settlements" USING btree ("cycle_id");--> statement-breakpoint +CREATE INDEX "settlements_household_computed_idx" ON "settlements" USING btree ("household_id","computed_at");--> statement-breakpoint +CREATE INDEX "utility_bills_cycle_idx" ON "utility_bills" USING btree ("cycle_id");--> statement-breakpoint +CREATE INDEX "utility_bills_household_cycle_idx" ON "utility_bills" USING btree ("household_id","cycle_id"); \ No newline at end of file diff --git a/packages/db/drizzle/meta/0001_snapshot.json b/packages/db/drizzle/meta/0001_snapshot.json new file mode 100644 index 0000000..850258b --- /dev/null +++ b/packages/db/drizzle/meta/0001_snapshot.json @@ -0,0 +1,1152 @@ +{ + "id": "fbec9197-8029-45f9-8f7d-36416d39d0b8", + "prevId": "41ded009-68a0-4c58-87ba-e4ff51d87211", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.billing_cycles": { + "name": "billing_cycles", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "household_id": { + "name": "household_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "period": { + "name": "period", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "closed_at": { + "name": "closed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "billing_cycles_household_period_unique": { + "name": "billing_cycles_household_period_unique", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "period", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "billing_cycles_household_period_idx": { + "name": "billing_cycles_household_period_idx", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "period", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "billing_cycles_household_id_households_id_fk": { + "name": "billing_cycles_household_id_households_id_fk", + "tableFrom": "billing_cycles", + "tableTo": "households", + "columnsFrom": ["household_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.households": { + "name": "households", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.members": { + "name": "members", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "household_id": { + "name": "household_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "telegram_user_id": { + "name": "telegram_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_admin": { + "name": "is_admin", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "joined_at": { + "name": "joined_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "members_household_idx": { + "name": "members_household_idx", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "members_household_tg_user_unique": { + "name": "members_household_tg_user_unique", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "telegram_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "members_household_id_households_id_fk": { + "name": "members_household_id_households_id_fk", + "tableFrom": "members", + "tableTo": "households", + "columnsFrom": ["household_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.presence_overrides": { + "name": "presence_overrides", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "cycle_id": { + "name": "cycle_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "member_id": { + "name": "member_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "utility_days": { + "name": "utility_days", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "presence_overrides_cycle_member_unique": { + "name": "presence_overrides_cycle_member_unique", + "columns": [ + { + "expression": "cycle_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "member_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "presence_overrides_cycle_idx": { + "name": "presence_overrides_cycle_idx", + "columns": [ + { + "expression": "cycle_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "presence_overrides_cycle_id_billing_cycles_id_fk": { + "name": "presence_overrides_cycle_id_billing_cycles_id_fk", + "tableFrom": "presence_overrides", + "tableTo": "billing_cycles", + "columnsFrom": ["cycle_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "presence_overrides_member_id_members_id_fk": { + "name": "presence_overrides_member_id_members_id_fk", + "tableFrom": "presence_overrides", + "tableTo": "members", + "columnsFrom": ["member_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.processed_bot_messages": { + "name": "processed_bot_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "household_id": { + "name": "household_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_message_key": { + "name": "source_message_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload_hash": { + "name": "payload_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "processed_at": { + "name": "processed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "processed_bot_messages_source_message_unique": { + "name": "processed_bot_messages_source_message_unique", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_message_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "processed_bot_messages_household_id_households_id_fk": { + "name": "processed_bot_messages_household_id_households_id_fk", + "tableFrom": "processed_bot_messages", + "tableTo": "households", + "columnsFrom": ["household_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.purchase_entries": { + "name": "purchase_entries", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "household_id": { + "name": "household_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "cycle_id": { + "name": "cycle_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "payer_member_id": { + "name": "payer_member_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "amount_minor": { + "name": "amount_minor", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "raw_text": { + "name": "raw_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "normalized_text": { + "name": "normalized_text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parser_mode": { + "name": "parser_mode", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parser_confidence": { + "name": "parser_confidence", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "telegram_chat_id": { + "name": "telegram_chat_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "telegram_message_id": { + "name": "telegram_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "telegram_thread_id": { + "name": "telegram_thread_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "message_sent_at": { + "name": "message_sent_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "purchase_entries_household_cycle_idx": { + "name": "purchase_entries_household_cycle_idx", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cycle_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "purchase_entries_payer_idx": { + "name": "purchase_entries_payer_idx", + "columns": [ + { + "expression": "payer_member_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "purchase_entries_household_tg_message_unique": { + "name": "purchase_entries_household_tg_message_unique", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "telegram_chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "telegram_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "purchase_entries_household_id_households_id_fk": { + "name": "purchase_entries_household_id_households_id_fk", + "tableFrom": "purchase_entries", + "tableTo": "households", + "columnsFrom": ["household_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "purchase_entries_cycle_id_billing_cycles_id_fk": { + "name": "purchase_entries_cycle_id_billing_cycles_id_fk", + "tableFrom": "purchase_entries", + "tableTo": "billing_cycles", + "columnsFrom": ["cycle_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "purchase_entries_payer_member_id_members_id_fk": { + "name": "purchase_entries_payer_member_id_members_id_fk", + "tableFrom": "purchase_entries", + "tableTo": "members", + "columnsFrom": ["payer_member_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.rent_rules": { + "name": "rent_rules", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "household_id": { + "name": "household_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "amount_minor": { + "name": "amount_minor", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "effective_from_period": { + "name": "effective_from_period", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "effective_to_period": { + "name": "effective_to_period", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "rent_rules_household_from_period_unique": { + "name": "rent_rules_household_from_period_unique", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "effective_from_period", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "rent_rules_household_from_period_idx": { + "name": "rent_rules_household_from_period_idx", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "effective_from_period", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "rent_rules_household_id_households_id_fk": { + "name": "rent_rules_household_id_households_id_fk", + "tableFrom": "rent_rules", + "tableTo": "households", + "columnsFrom": ["household_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.settlement_lines": { + "name": "settlement_lines", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "settlement_id": { + "name": "settlement_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "member_id": { + "name": "member_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "rent_share_minor": { + "name": "rent_share_minor", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "utility_share_minor": { + "name": "utility_share_minor", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "purchase_offset_minor": { + "name": "purchase_offset_minor", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "net_due_minor": { + "name": "net_due_minor", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "explanations": { + "name": "explanations", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "settlement_lines_settlement_member_unique": { + "name": "settlement_lines_settlement_member_unique", + "columns": [ + { + "expression": "settlement_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "member_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "settlement_lines_settlement_idx": { + "name": "settlement_lines_settlement_idx", + "columns": [ + { + "expression": "settlement_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "settlement_lines_settlement_id_settlements_id_fk": { + "name": "settlement_lines_settlement_id_settlements_id_fk", + "tableFrom": "settlement_lines", + "tableTo": "settlements", + "columnsFrom": ["settlement_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "settlement_lines_member_id_members_id_fk": { + "name": "settlement_lines_member_id_members_id_fk", + "tableFrom": "settlement_lines", + "tableTo": "members", + "columnsFrom": ["member_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.settlements": { + "name": "settlements", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "household_id": { + "name": "household_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "cycle_id": { + "name": "cycle_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "input_hash": { + "name": "input_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "total_due_minor": { + "name": "total_due_minor", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "computed_at": { + "name": "computed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + } + }, + "indexes": { + "settlements_cycle_unique": { + "name": "settlements_cycle_unique", + "columns": [ + { + "expression": "cycle_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "settlements_household_computed_idx": { + "name": "settlements_household_computed_idx", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "computed_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "settlements_household_id_households_id_fk": { + "name": "settlements_household_id_households_id_fk", + "tableFrom": "settlements", + "tableTo": "households", + "columnsFrom": ["household_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "settlements_cycle_id_billing_cycles_id_fk": { + "name": "settlements_cycle_id_billing_cycles_id_fk", + "tableFrom": "settlements", + "tableTo": "billing_cycles", + "columnsFrom": ["cycle_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.utility_bills": { + "name": "utility_bills", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "household_id": { + "name": "household_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "cycle_id": { + "name": "cycle_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "bill_name": { + "name": "bill_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "amount_minor": { + "name": "amount_minor", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "due_date": { + "name": "due_date", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'manual'" + }, + "created_by_member_id": { + "name": "created_by_member_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "utility_bills_cycle_idx": { + "name": "utility_bills_cycle_idx", + "columns": [ + { + "expression": "cycle_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "utility_bills_household_cycle_idx": { + "name": "utility_bills_household_cycle_idx", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cycle_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "utility_bills_household_id_households_id_fk": { + "name": "utility_bills_household_id_households_id_fk", + "tableFrom": "utility_bills", + "tableTo": "households", + "columnsFrom": ["household_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "utility_bills_cycle_id_billing_cycles_id_fk": { + "name": "utility_bills_cycle_id_billing_cycles_id_fk", + "tableFrom": "utility_bills", + "tableTo": "billing_cycles", + "columnsFrom": ["cycle_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "utility_bills_created_by_member_id_members_id_fk": { + "name": "utility_bills_created_by_member_id_members_id_fk", + "tableFrom": "utility_bills", + "tableTo": "members", + "columnsFrom": ["created_by_member_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/packages/db/drizzle/meta/_journal.json b/packages/db/drizzle/meta/_journal.json index 190a6d4..82ea0a1 100644 --- a/packages/db/drizzle/meta/_journal.json +++ b/packages/db/drizzle/meta/_journal.json @@ -8,6 +8,13 @@ "when": 1772663250726, "tag": "0000_modern_centennial", "breakpoints": true + }, + { + "idx": 1, + "version": "7", + "when": 1772669239939, + "tag": "0001_spicy_sersi", + "breakpoints": true } ] } diff --git a/packages/db/package.json b/packages/db/package.json index f40515d..a8a6ed8 100644 --- a/packages/db/package.json +++ b/packages/db/package.json @@ -6,7 +6,8 @@ "build": "bun build src/index.ts --outdir dist --target bun", "typecheck": "tsgo --project tsconfig.json --noEmit", "test": "bun test --pass-with-no-tests", - "lint": "oxlint \"src\"" + "lint": "oxlint \"src\"", + "seed": "bun run src/seed.ts" }, "dependencies": { "@household/config": "workspace:*", diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index ba67a83..bfd028a 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -1,4 +1,16 @@ -import { index, integer, pgTable, text, timestamp, uniqueIndex, uuid } from 'drizzle-orm/pg-core' +import { sql } from 'drizzle-orm' +import { + bigint, + date, + index, + integer, + jsonb, + pgTable, + text, + timestamp, + uniqueIndex, + uuid +} from 'drizzle-orm/pg-core' export const households = pgTable('households', { id: uuid('id').defaultRandom().primaryKey(), @@ -26,3 +38,227 @@ export const members = pgTable( ) }) ) + +export const billingCycles = pgTable( + 'billing_cycles', + { + id: uuid('id').defaultRandom().primaryKey(), + householdId: uuid('household_id') + .notNull() + .references(() => households.id, { onDelete: 'cascade' }), + period: text('period').notNull(), + currency: text('currency').notNull(), + startedAt: timestamp('started_at', { withTimezone: true }).defaultNow().notNull(), + closedAt: timestamp('closed_at', { withTimezone: true }), + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull() + }, + (table) => ({ + householdPeriodUnique: uniqueIndex('billing_cycles_household_period_unique').on( + table.householdId, + table.period + ), + householdPeriodIdx: index('billing_cycles_household_period_idx').on( + table.householdId, + table.period + ) + }) +) + +export const rentRules = pgTable( + 'rent_rules', + { + id: uuid('id').defaultRandom().primaryKey(), + householdId: uuid('household_id') + .notNull() + .references(() => households.id, { onDelete: 'cascade' }), + amountMinor: bigint('amount_minor', { mode: 'bigint' }).notNull(), + currency: text('currency').notNull(), + effectiveFromPeriod: text('effective_from_period').notNull(), + effectiveToPeriod: text('effective_to_period'), + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull() + }, + (table) => ({ + householdFromPeriodUnique: uniqueIndex('rent_rules_household_from_period_unique').on( + table.householdId, + table.effectiveFromPeriod + ), + householdFromPeriodIdx: index('rent_rules_household_from_period_idx').on( + table.householdId, + table.effectiveFromPeriod + ) + }) +) + +export const utilityBills = pgTable( + 'utility_bills', + { + id: uuid('id').defaultRandom().primaryKey(), + householdId: uuid('household_id') + .notNull() + .references(() => households.id, { onDelete: 'cascade' }), + cycleId: uuid('cycle_id') + .notNull() + .references(() => billingCycles.id, { onDelete: 'cascade' }), + billName: text('bill_name').notNull(), + amountMinor: bigint('amount_minor', { mode: 'bigint' }).notNull(), + currency: text('currency').notNull(), + dueDate: date('due_date'), + source: text('source').default('manual').notNull(), + createdByMemberId: uuid('created_by_member_id').references(() => members.id, { + onDelete: 'set null' + }), + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull() + }, + (table) => ({ + cycleIdx: index('utility_bills_cycle_idx').on(table.cycleId), + householdCycleIdx: index('utility_bills_household_cycle_idx').on( + table.householdId, + table.cycleId + ) + }) +) + +export const presenceOverrides = pgTable( + 'presence_overrides', + { + id: uuid('id').defaultRandom().primaryKey(), + cycleId: uuid('cycle_id') + .notNull() + .references(() => billingCycles.id, { onDelete: 'cascade' }), + memberId: uuid('member_id') + .notNull() + .references(() => members.id, { onDelete: 'cascade' }), + utilityDays: integer('utility_days').notNull(), + reason: text('reason'), + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull() + }, + (table) => ({ + cycleMemberUnique: uniqueIndex('presence_overrides_cycle_member_unique').on( + table.cycleId, + table.memberId + ), + cycleIdx: index('presence_overrides_cycle_idx').on(table.cycleId) + }) +) + +export const purchaseEntries = pgTable( + 'purchase_entries', + { + id: uuid('id').defaultRandom().primaryKey(), + householdId: uuid('household_id') + .notNull() + .references(() => households.id, { onDelete: 'cascade' }), + cycleId: uuid('cycle_id').references(() => billingCycles.id, { + onDelete: 'set null' + }), + payerMemberId: uuid('payer_member_id') + .notNull() + .references(() => members.id, { onDelete: 'restrict' }), + amountMinor: bigint('amount_minor', { mode: 'bigint' }).notNull(), + currency: text('currency').notNull(), + rawText: text('raw_text').notNull(), + normalizedText: text('normalized_text'), + parserMode: text('parser_mode').notNull(), + parserConfidence: integer('parser_confidence').notNull(), + telegramChatId: text('telegram_chat_id'), + telegramMessageId: text('telegram_message_id'), + telegramThreadId: text('telegram_thread_id'), + messageSentAt: timestamp('message_sent_at', { withTimezone: true }), + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull() + }, + (table) => ({ + householdCycleIdx: index('purchase_entries_household_cycle_idx').on( + table.householdId, + table.cycleId + ), + payerIdx: index('purchase_entries_payer_idx').on(table.payerMemberId), + tgMessageUnique: uniqueIndex('purchase_entries_household_tg_message_unique').on( + table.householdId, + table.telegramChatId, + table.telegramMessageId + ) + }) +) + +export const processedBotMessages = pgTable( + 'processed_bot_messages', + { + id: uuid('id').defaultRandom().primaryKey(), + householdId: uuid('household_id') + .notNull() + .references(() => households.id, { onDelete: 'cascade' }), + source: text('source').notNull(), + sourceMessageKey: text('source_message_key').notNull(), + payloadHash: text('payload_hash'), + processedAt: timestamp('processed_at', { withTimezone: true }).defaultNow().notNull() + }, + (table) => ({ + sourceMessageUnique: uniqueIndex('processed_bot_messages_source_message_unique').on( + table.householdId, + table.source, + table.sourceMessageKey + ) + }) +) + +export const settlements = pgTable( + 'settlements', + { + id: uuid('id').defaultRandom().primaryKey(), + householdId: uuid('household_id') + .notNull() + .references(() => households.id, { onDelete: 'cascade' }), + cycleId: uuid('cycle_id') + .notNull() + .references(() => billingCycles.id, { onDelete: 'cascade' }), + inputHash: text('input_hash').notNull(), + totalDueMinor: bigint('total_due_minor', { mode: 'bigint' }).notNull(), + currency: text('currency').notNull(), + computedAt: timestamp('computed_at', { withTimezone: true }).defaultNow().notNull(), + metadata: jsonb('metadata') + .default(sql`'{}'::jsonb`) + .notNull() + }, + (table) => ({ + cycleUnique: uniqueIndex('settlements_cycle_unique').on(table.cycleId), + householdComputedIdx: index('settlements_household_computed_idx').on( + table.householdId, + table.computedAt + ) + }) +) + +export const settlementLines = pgTable( + 'settlement_lines', + { + id: uuid('id').defaultRandom().primaryKey(), + settlementId: uuid('settlement_id') + .notNull() + .references(() => settlements.id, { onDelete: 'cascade' }), + memberId: uuid('member_id') + .notNull() + .references(() => members.id, { onDelete: 'restrict' }), + rentShareMinor: bigint('rent_share_minor', { mode: 'bigint' }).notNull(), + utilityShareMinor: bigint('utility_share_minor', { mode: 'bigint' }).notNull(), + purchaseOffsetMinor: bigint('purchase_offset_minor', { mode: 'bigint' }).notNull(), + netDueMinor: bigint('net_due_minor', { mode: 'bigint' }).notNull(), + explanations: jsonb('explanations') + .default(sql`'[]'::jsonb`) + .notNull(), + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull() + }, + (table) => ({ + settlementMemberUnique: uniqueIndex('settlement_lines_settlement_member_unique').on( + table.settlementId, + table.memberId + ), + settlementIdx: index('settlement_lines_settlement_idx').on(table.settlementId) + }) +) + +export type Household = typeof households.$inferSelect +export type Member = typeof members.$inferSelect +export type BillingCycle = typeof billingCycles.$inferSelect +export type UtilityBill = typeof utilityBills.$inferSelect +export type PurchaseEntry = typeof purchaseEntries.$inferSelect +export type Settlement = typeof settlements.$inferSelect diff --git a/packages/db/src/seed.ts b/packages/db/src/seed.ts new file mode 100644 index 0000000..dfdde04 --- /dev/null +++ b/packages/db/src/seed.ts @@ -0,0 +1,226 @@ +import { and, eq } from 'drizzle-orm' +import { drizzle } from 'drizzle-orm/postgres-js' +import postgres from 'postgres' + +import { + billingCycles, + households, + members, + presenceOverrides, + processedBotMessages, + purchaseEntries, + rentRules, + settlementLines, + settlements, + utilityBills +} from './schema' + +const databaseUrl = process.env.DATABASE_URL +if (!databaseUrl) { + throw new Error('DATABASE_URL is required for db seed') +} + +const queryClient = postgres(databaseUrl, { + prepare: false, + max: 2 +}) + +const db = drizzle(queryClient) + +const FIXTURE_IDS = { + household: '11111111-1111-4111-8111-111111111111', + cycle: '22222222-2222-4222-8222-222222222222', + memberAlice: '33333333-3333-4333-8333-333333333331', + memberBob: '33333333-3333-4333-8333-333333333332', + memberCarol: '33333333-3333-4333-8333-333333333333', + settlement: '44444444-4444-4444-8444-444444444444' +} as const + +async function seed(): Promise { + await db + .insert(households) + .values({ + id: FIXTURE_IDS.household, + name: 'Kojori Demo Household' + }) + .onConflictDoNothing() + + await db + .insert(members) + .values([ + { + id: FIXTURE_IDS.memberAlice, + householdId: FIXTURE_IDS.household, + telegramUserId: '10001', + displayName: 'Alice', + isAdmin: 1 + }, + { + id: FIXTURE_IDS.memberBob, + householdId: FIXTURE_IDS.household, + telegramUserId: '10002', + displayName: 'Bob', + isAdmin: 0 + }, + { + id: FIXTURE_IDS.memberCarol, + householdId: FIXTURE_IDS.household, + telegramUserId: '10003', + displayName: 'Carol', + isAdmin: 0 + } + ]) + .onConflictDoNothing() + + await db + .insert(billingCycles) + .values({ + id: FIXTURE_IDS.cycle, + householdId: FIXTURE_IDS.household, + period: '2026-03', + currency: 'USD' + }) + .onConflictDoNothing() + + await db + .insert(rentRules) + .values({ + householdId: FIXTURE_IDS.household, + amountMinor: 70000n, + currency: 'USD', + effectiveFromPeriod: '2026-03' + }) + .onConflictDoNothing() + + await db + .insert(utilityBills) + .values({ + householdId: FIXTURE_IDS.household, + cycleId: FIXTURE_IDS.cycle, + billName: 'Electricity', + amountMinor: 12000n, + currency: 'USD', + source: 'manual', + createdByMemberId: FIXTURE_IDS.memberAlice + }) + .onConflictDoNothing() + + await db + .insert(presenceOverrides) + .values([ + { + cycleId: FIXTURE_IDS.cycle, + memberId: FIXTURE_IDS.memberAlice, + utilityDays: 31, + reason: 'full month' + }, + { + cycleId: FIXTURE_IDS.cycle, + memberId: FIXTURE_IDS.memberBob, + utilityDays: 31, + reason: 'full month' + }, + { + cycleId: FIXTURE_IDS.cycle, + memberId: FIXTURE_IDS.memberCarol, + utilityDays: 20, + reason: 'partial month' + } + ]) + .onConflictDoNothing() + + await db + .insert(purchaseEntries) + .values({ + householdId: FIXTURE_IDS.household, + cycleId: FIXTURE_IDS.cycle, + payerMemberId: FIXTURE_IDS.memberAlice, + amountMinor: 3000n, + currency: 'USD', + rawText: 'Bought toilet paper 30 gel', + normalizedText: 'bought toilet paper 30 gel', + parserMode: 'rules', + parserConfidence: 93, + telegramChatId: '-100householdchat', + telegramMessageId: '501', + telegramThreadId: 'general-buys' + }) + .onConflictDoNothing() + + await db + .insert(processedBotMessages) + .values({ + householdId: FIXTURE_IDS.household, + source: 'telegram', + sourceMessageKey: 'chat:-100householdchat:message:501', + payloadHash: 'demo-hash' + }) + .onConflictDoNothing() + + await db + .insert(settlements) + .values({ + id: FIXTURE_IDS.settlement, + householdId: FIXTURE_IDS.household, + cycleId: FIXTURE_IDS.cycle, + inputHash: 'demo-settlement-hash', + totalDueMinor: 82000n, + currency: 'USD' + }) + .onConflictDoNothing() + + await db + .insert(settlementLines) + .values([ + { + settlementId: FIXTURE_IDS.settlement, + memberId: FIXTURE_IDS.memberAlice, + rentShareMinor: 23334n, + utilityShareMinor: 4000n, + purchaseOffsetMinor: -2000n, + netDueMinor: 25334n, + explanations: ['rent_share_minor=23334', 'utility_share_minor=4000'] + }, + { + settlementId: FIXTURE_IDS.settlement, + memberId: FIXTURE_IDS.memberBob, + rentShareMinor: 23333n, + utilityShareMinor: 4000n, + purchaseOffsetMinor: 1000n, + netDueMinor: 28333n, + explanations: ['rent_share_minor=23333', 'utility_share_minor=4000'] + }, + { + settlementId: FIXTURE_IDS.settlement, + memberId: FIXTURE_IDS.memberCarol, + rentShareMinor: 23333n, + utilityShareMinor: 4000n, + purchaseOffsetMinor: 1000n, + netDueMinor: 28333n, + explanations: ['rent_share_minor=23333', 'utility_share_minor=4000'] + } + ]) + .onConflictDoNothing() + + const seededCycle = await db + .select({ period: billingCycles.period, currency: billingCycles.currency }) + .from(billingCycles) + .where( + and( + eq(billingCycles.id, FIXTURE_IDS.cycle), + eq(billingCycles.householdId, FIXTURE_IDS.household) + ) + ) + .limit(1) + + if (seededCycle.length === 0) { + throw new Error('Seed verification failed: billing cycle not found') + } +} + +try { + await seed() + console.log('Seed completed') +} finally { + await queryClient.end({ timeout: 5 }) +}