mirror of
https://github.com/whekin/household-bot.git
synced 2026-03-31 10:24:02 +00:00
feat(WHE-29): add v1 accounting schema migration and seed fixtures
This commit is contained in:
@@ -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
|
||||
```
|
||||
|
||||
@@ -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.
|
||||
|
||||
83
docs/specs/HOUSEBOT-012-accounting-schema.md
Normal file
83
docs/specs/HOUSEBOT-012-accounting-schema.md
Normal file
@@ -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.
|
||||
@@ -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",
|
||||
|
||||
123
packages/db/drizzle/0001_spicy_sersi.sql
Normal file
123
packages/db/drizzle/0001_spicy_sersi.sql
Normal file
@@ -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");
|
||||
1152
packages/db/drizzle/meta/0001_snapshot.json
Normal file
1152
packages/db/drizzle/meta/0001_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -8,6 +8,13 @@
|
||||
"when": 1772663250726,
|
||||
"tag": "0000_modern_centennial",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 1,
|
||||
"version": "7",
|
||||
"when": 1772669239939,
|
||||
"tag": "0001_spicy_sersi",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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:*",
|
||||
|
||||
@@ -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
|
||||
|
||||
226
packages/db/src/seed.ts
Normal file
226
packages/db/src/seed.ts
Normal file
@@ -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<void> {
|
||||
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 })
|
||||
}
|
||||
Reference in New Issue
Block a user