feat(WHE-29): add v1 accounting schema migration and seed fixtures

This commit is contained in:
2026-03-05 04:13:00 +04:00
parent 27205bc90b
commit b3ae1a51e4
10 changed files with 1841 additions and 2 deletions

View File

@@ -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
```

View File

@@ -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.

View 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.

View File

@@ -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",

View 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");

File diff suppressed because it is too large Load Diff

View File

@@ -8,6 +8,13 @@
"when": 1772663250726,
"tag": "0000_modern_centennial",
"breakpoints": true
},
{
"idx": 1,
"version": "7",
"when": 1772669239939,
"tag": "0001_spicy_sersi",
"breakpoints": true
}
]
}

View File

@@ -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:*",

View File

@@ -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
View 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 })
}