diff --git a/apps/bot/package.json b/apps/bot/package.json index 4befa2a..ace3b5d 100644 --- a/apps/bot/package.json +++ b/apps/bot/package.json @@ -10,6 +10,7 @@ "lint": "oxlint \"src\"" }, "dependencies": { + "@household/application": "workspace:*", "@household/db": "workspace:*", "drizzle-orm": "^0.44.7", "grammy": "1.41.1" diff --git a/apps/bot/src/config.ts b/apps/bot/src/config.ts index 2c88bda..f87ac64 100644 --- a/apps/bot/src/config.ts +++ b/apps/bot/src/config.ts @@ -8,6 +8,8 @@ export interface BotRuntimeConfig { telegramHouseholdChatId?: string telegramPurchaseTopicId?: number purchaseTopicIngestionEnabled: boolean + openaiApiKey?: string + parserModel: string } function parsePort(raw: string | undefined): number { @@ -66,7 +68,8 @@ export function getBotRuntimeConfig(env: NodeJS.ProcessEnv = process.env): BotRu telegramBotToken: requireValue(env.TELEGRAM_BOT_TOKEN, 'TELEGRAM_BOT_TOKEN'), telegramWebhookSecret: requireValue(env.TELEGRAM_WEBHOOK_SECRET, 'TELEGRAM_WEBHOOK_SECRET'), telegramWebhookPath: env.TELEGRAM_WEBHOOK_PATH ?? '/webhook/telegram', - purchaseTopicIngestionEnabled + purchaseTopicIngestionEnabled, + parserModel: env.PARSER_MODEL?.trim() || 'gpt-4.1-mini' } if (databaseUrl !== undefined) { @@ -81,6 +84,10 @@ export function getBotRuntimeConfig(env: NodeJS.ProcessEnv = process.env): BotRu if (telegramPurchaseTopicId !== undefined) { runtime.telegramPurchaseTopicId = telegramPurchaseTopicId } + const openaiApiKey = parseOptionalValue(env.OPENAI_API_KEY) + if (openaiApiKey !== undefined) { + runtime.openaiApiKey = openaiApiKey + } return runtime } diff --git a/apps/bot/src/index.ts b/apps/bot/src/index.ts index 39a8a33..bc7c1ab 100644 --- a/apps/bot/src/index.ts +++ b/apps/bot/src/index.ts @@ -2,6 +2,7 @@ import { webhookCallback } from 'grammy' import { createTelegramBot } from './bot' import { getBotRuntimeConfig } from './config' +import { createOpenAiParserFallback } from './openai-parser-fallback' import { createPurchaseMessageRepository, registerPurchaseTopicIngestion @@ -17,6 +18,7 @@ let closePurchaseRepository: (() => Promise) | undefined if (runtime.purchaseTopicIngestionEnabled) { const purchaseRepositoryClient = createPurchaseMessageRepository(runtime.databaseUrl!) closePurchaseRepository = purchaseRepositoryClient.close + const llmFallback = createOpenAiParserFallback(runtime.openaiApiKey, runtime.parserModel) registerPurchaseTopicIngestion( bot, @@ -25,7 +27,12 @@ if (runtime.purchaseTopicIngestionEnabled) { householdChatId: runtime.telegramHouseholdChatId!, purchaseTopicId: runtime.telegramPurchaseTopicId! }, - purchaseRepositoryClient.repository + purchaseRepositoryClient.repository, + llmFallback + ? { + llmFallback + } + : {} ) } else { console.warn( diff --git a/apps/bot/src/openai-parser-fallback.ts b/apps/bot/src/openai-parser-fallback.ts new file mode 100644 index 0000000..6cf2678 --- /dev/null +++ b/apps/bot/src/openai-parser-fallback.ts @@ -0,0 +1,119 @@ +import type { PurchaseParserLlmFallback } from '@household/application' + +interface OpenAiStructuredResult { + amountMinor: string + currency: 'GEL' | 'USD' + itemDescription: string + confidence: number + needsReview: boolean +} + +function asBigInt(value: string): bigint | null { + if (!/^[0-9]+$/.test(value)) { + return null + } + + const parsed = BigInt(value) + return parsed > 0n ? parsed : null +} + +export function createOpenAiParserFallback( + apiKey: string | undefined, + model: string +): PurchaseParserLlmFallback | undefined { + if (!apiKey) { + return undefined + } + + return async (rawText: string) => { + const response = await fetch('https://api.openai.com/v1/responses', { + method: 'POST', + headers: { + authorization: `Bearer ${apiKey}`, + 'content-type': 'application/json' + }, + body: JSON.stringify({ + model, + input: [ + { + role: 'system', + content: + 'Extract a shared household purchase from text. Return only valid JSON with amountMinor, currency, itemDescription, confidence, needsReview.' + }, + { + role: 'user', + content: rawText + } + ], + text: { + format: { + type: 'json_schema', + name: 'purchase_parse', + schema: { + type: 'object', + additionalProperties: false, + properties: { + amountMinor: { + type: 'string' + }, + currency: { + type: 'string', + enum: ['GEL', 'USD'] + }, + itemDescription: { + type: 'string' + }, + confidence: { + type: 'number', + minimum: 0, + maximum: 100 + }, + needsReview: { + type: 'boolean' + } + }, + required: ['amountMinor', 'currency', 'itemDescription', 'confidence', 'needsReview'] + } + } + } + }) + }) + + if (!response.ok) { + return null + } + + const payload = (await response.json()) as { + output_text?: string + } + + if (!payload.output_text) { + return null + } + + let parsedJson: OpenAiStructuredResult + try { + parsedJson = JSON.parse(payload.output_text) as OpenAiStructuredResult + } catch { + return null + } + + const amountMinor = asBigInt(parsedJson.amountMinor) + if (!amountMinor) { + return null + } + + if (parsedJson.itemDescription.trim().length === 0) { + return null + } + + return { + amountMinor, + currency: parsedJson.currency, + itemDescription: parsedJson.itemDescription, + confidence: Math.max(0, Math.min(100, Math.round(parsedJson.confidence))), + parserMode: 'llm', + needsReview: parsedJson.needsReview + } + } +} diff --git a/apps/bot/src/purchase-topic-ingestion.ts b/apps/bot/src/purchase-topic-ingestion.ts index 7f0beb4..0c5bada 100644 --- a/apps/bot/src/purchase-topic-ingestion.ts +++ b/apps/bot/src/purchase-topic-ingestion.ts @@ -1,3 +1,4 @@ +import { parsePurchaseMessage, type PurchaseParserLlmFallback } from '@household/application' import { and, eq } from 'drizzle-orm' import type { Bot, Context } from 'grammy' @@ -25,7 +26,10 @@ export interface PurchaseTopicRecord extends PurchaseTopicCandidate { } export interface PurchaseMessageIngestionRepository { - save(record: PurchaseTopicRecord): Promise<'created' | 'duplicate'> + save( + record: PurchaseTopicRecord, + llmFallback?: PurchaseParserLlmFallback + ): Promise<'created' | 'duplicate'> } export function extractPurchaseTopicCandidate( @@ -52,6 +56,10 @@ export function extractPurchaseTopicCandidate( } } +function needsReviewAsInt(value: boolean): number { + return value ? 1 : 0 +} + export function createPurchaseMessageRepository(databaseUrl: string): { repository: PurchaseMessageIngestionRepository close: () => Promise @@ -62,7 +70,7 @@ export function createPurchaseMessageRepository(databaseUrl: string): { }) const repository: PurchaseMessageIngestionRepository = { - async save(record) { + async save(record, llmFallback) { const matchedMember = await db .select({ id: schema.members.id }) .from(schema.members) @@ -75,6 +83,30 @@ export function createPurchaseMessageRepository(databaseUrl: string): { .limit(1) const senderMemberId = matchedMember[0]?.id ?? null + let parserError: string | null = null + + const parsed = await parsePurchaseMessage( + { + rawText: record.rawText + }, + llmFallback + ? { + llmFallback + } + : {} + ).catch((error) => { + parserError = error instanceof Error ? error.message : 'Unknown parser error' + return null + }) + + const processingStatus = + parserError !== null + ? 'parse_failed' + : parsed === null + ? 'needs_review' + : parsed.needsReview + ? 'needs_review' + : 'parsed' const inserted = await db .insert(schema.purchaseMessages) @@ -89,7 +121,14 @@ export function createPurchaseMessageRepository(databaseUrl: string): { telegramThreadId: record.threadId, telegramUpdateId: String(record.updateId), messageSentAt: record.messageSentAt, - processingStatus: 'pending' + parsedAmountMinor: parsed?.amountMinor, + parsedCurrency: parsed?.currency, + parsedItemDescription: parsed?.itemDescription, + parserMode: parsed?.parserMode, + parserConfidence: parsed?.confidence, + needsReview: needsReviewAsInt(parsed?.needsReview ?? true), + parserError, + processingStatus }) .onConflictDoNothing({ target: [ @@ -151,7 +190,10 @@ function toCandidateFromContext(ctx: Context): PurchaseTopicCandidate | null { export function registerPurchaseTopicIngestion( bot: Bot, config: PurchaseTopicIngestionConfig, - repository: PurchaseMessageIngestionRepository + repository: PurchaseMessageIngestionRepository, + options: { + llmFallback?: PurchaseParserLlmFallback + } = {} ): void { bot.on('message:text', async (ctx) => { const candidate = toCandidateFromContext(ctx) @@ -165,7 +207,7 @@ export function registerPurchaseTopicIngestion( } try { - const status = await repository.save(record) + const status = await repository.save(record, options.llmFallback) if (status === 'created') { console.log( diff --git a/bun.lock b/bun.lock index 098f645..fe6d84e 100644 --- a/bun.lock +++ b/bun.lock @@ -15,6 +15,7 @@ "apps/bot": { "name": "@household/bot", "dependencies": { + "@household/application": "workspace:*", "@household/db": "workspace:*", "drizzle-orm": "^0.44.7", "grammy": "1.41.1", diff --git a/docs/specs/HOUSEBOT-022-hybrid-purchase-parser.md b/docs/specs/HOUSEBOT-022-hybrid-purchase-parser.md new file mode 100644 index 0000000..6790aa9 --- /dev/null +++ b/docs/specs/HOUSEBOT-022-hybrid-purchase-parser.md @@ -0,0 +1,80 @@ +# HOUSEBOT-022: Hybrid Purchase Parser + +## Summary + +Implement a rules-first purchase parser with optional LLM fallback for ambiguous Telegram purchase messages. + +## Goals + +- Parse common RU/EN purchase text with deterministic regex rules first. +- Call LLM fallback only when rules cannot safely resolve a single amount. +- Persist raw + parsed fields + confidence + parser mode. + +## Non-goals + +- Receipt OCR. +- Complex multi-item itemization. + +## Scope + +- In: parser core logic, fallback interface, bot ingestion integration, DB fields for parser output. +- Out: settlement posting and command UIs. + +## Interfaces and Contracts + +- `parsePurchaseMessage({ rawText }, { llmFallback? })` +- Parser result fields: + - `amountMinor` + - `currency` + - `itemDescription` + - `confidence` + - `parserMode` (`rules` | `llm`) + - `needsReview` + +## Domain Rules + +- Rules parser attempts single-amount extraction first. +- Missing currency defaults to GEL and marks `needsReview=true`. +- Ambiguous text (multiple amounts) triggers LLM fallback if configured. + +## Data Model Changes + +- `purchase_messages` stores parsed fields: + - `parsed_amount_minor` + - `parsed_currency` + - `parsed_item_description` + - `parser_mode` + - `parser_confidence` + - `needs_review` + - `parser_error` + +## Security and Privacy + +- LLM fallback sends only minimal raw text needed for parsing. +- API key required for fallback path. + +## Observability + +- `processing_status` and `parser_error` capture parse outcomes. + +## Edge Cases and Failure Modes + +- Empty message text. +- Multiple numeric amounts. +- Invalid LLM output payload. +- Missing API key disables LLM fallback. + +## Test Plan + +- Unit tests for rules parser and fallback behavior. +- Ingestion tests for topic filter remain valid. + +## Acceptance Criteria + +- [ ] Rules parser handles common message patterns. +- [ ] LLM fallback is invoked only when rules are insufficient. +- [ ] Parsed result + confidence + parser mode persisted. + +## Rollout Plan + +- Enable in dev group and monitor `needs_review` rate before stricter auto-accept rules. diff --git a/packages/application/src/index.ts b/packages/application/src/index.ts index 944e7de..8973460 100644 --- a/packages/application/src/index.ts +++ b/packages/application/src/index.ts @@ -1 +1,9 @@ export { calculateMonthlySettlement } from './settlement-engine' +export { + parsePurchaseMessage, + type ParsedPurchaseResult, + type ParsePurchaseInput, + type ParsePurchaseOptions, + type PurchaseParserLlmFallback, + type PurchaseParserMode +} from './purchase-parser' diff --git a/packages/application/src/purchase-parser.test.ts b/packages/application/src/purchase-parser.test.ts new file mode 100644 index 0000000..1b2db1d --- /dev/null +++ b/packages/application/src/purchase-parser.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, test } from 'bun:test' + +import { parsePurchaseMessage } from './purchase-parser' + +describe('parsePurchaseMessage', () => { + test('parses explicit currency with rules', async () => { + const result = await parsePurchaseMessage({ + rawText: 'Купил туалетную бумагу 30 gel' + }) + + expect(result).not.toBeNull() + expect(result?.amountMinor).toBe(3000n) + expect(result?.currency).toBe('GEL') + expect(result?.parserMode).toBe('rules') + expect(result?.needsReview).toBe(false) + }) + + test('defaults to GEL when currency is omitted and marks review', async () => { + const result = await parsePurchaseMessage({ + rawText: 'Bought soap 12.5' + }) + + expect(result).not.toBeNull() + expect(result?.amountMinor).toBe(1250n) + expect(result?.currency).toBe('GEL') + expect(result?.needsReview).toBe(true) + }) + + test('uses llm fallback for ambiguous message with multiple amounts', async () => { + const result = await parsePurchaseMessage( + { + rawText: 'Купил пасту 10 и мыло 5' + }, + { + llmFallback: async () => ({ + amountMinor: 1500n, + currency: 'GEL', + itemDescription: 'паста и мыло', + confidence: 67, + parserMode: 'llm', + needsReview: true + }) + } + ) + + expect(result).not.toBeNull() + expect(result?.parserMode).toBe('llm') + expect(result?.amountMinor).toBe(1500n) + }) + + test('returns null when both rules and llm fail', async () => { + const result = await parsePurchaseMessage( + { + rawText: 'без суммы вообще' + }, + { + llmFallback: async () => null + } + ) + + expect(result).toBeNull() + }) +}) diff --git a/packages/application/src/purchase-parser.ts b/packages/application/src/purchase-parser.ts new file mode 100644 index 0000000..fc8e99f --- /dev/null +++ b/packages/application/src/purchase-parser.ts @@ -0,0 +1,132 @@ +export type PurchaseParserMode = 'rules' | 'llm' + +export interface ParsedPurchaseResult { + amountMinor: bigint + currency: 'GEL' | 'USD' + itemDescription: string + confidence: number + parserMode: PurchaseParserMode + needsReview: boolean +} + +export type PurchaseParserLlmFallback = (rawText: string) => Promise + +export interface ParsePurchaseInput { + rawText: string +} + +export interface ParsePurchaseOptions { + llmFallback?: PurchaseParserLlmFallback +} + +const CURRENCY_PATTERN = '(?:₾|gel|lari|лари|usd|\\$|доллар(?:а|ов)?)' +const AMOUNT_WITH_OPTIONAL_CURRENCY = new RegExp( + `(?\\d+(?:[.,]\\d{1,2})?)\\s*(?${CURRENCY_PATTERN})?`, + 'giu' +) + +function normalizeCurrency(raw: string | undefined): 'GEL' | 'USD' | null { + if (!raw) { + return null + } + + const value = raw.trim().toLowerCase() + if (value === '₾' || value === 'gel' || value === 'lari' || value === 'лари') { + return 'GEL' + } + + if (value === 'usd' || value === '$' || value.startsWith('доллар')) { + return 'USD' + } + + return null +} + +function toMinorUnits(rawAmount: string): bigint { + const normalized = rawAmount.replace(',', '.') + const [wholePart, fractionalPart = ''] = normalized.split('.') + const cents = fractionalPart.padEnd(2, '0').slice(0, 2) + + return BigInt(`${wholePart}${cents}`) +} + +function normalizeDescription(rawText: string, matchedFragment: string): string { + const cleaned = rawText.replace(matchedFragment, ' ').replace(/\s+/g, ' ').trim() + + if (cleaned.length === 0) { + return 'shared purchase' + } + + return cleaned +} + +function parseWithRules(rawText: string): ParsedPurchaseResult | null { + const matches = Array.from(rawText.matchAll(AMOUNT_WITH_OPTIONAL_CURRENCY)) + + if (matches.length !== 1) { + return null + } + + const [match] = matches + if (!match?.groups?.amount) { + return null + } + + const currency = normalizeCurrency(match.groups.currency) + const amountMinor = toMinorUnits(match.groups.amount) + + const explicitCurrency = currency !== null + const resolvedCurrency = currency ?? 'GEL' + const confidence = explicitCurrency ? 92 : 78 + + return { + amountMinor, + currency: resolvedCurrency, + itemDescription: normalizeDescription(rawText, match[0] ?? ''), + confidence, + parserMode: 'rules', + needsReview: !explicitCurrency + } +} + +function validateLlmResult(result: ParsedPurchaseResult | null): ParsedPurchaseResult | null { + if (!result) { + return null + } + + if (result.amountMinor <= 0n) { + return null + } + + if (result.confidence < 0 || result.confidence > 100) { + return null + } + + if (result.itemDescription.trim().length === 0) { + return null + } + + return result +} + +export async function parsePurchaseMessage( + input: ParsePurchaseInput, + options: ParsePurchaseOptions = {} +): Promise { + const rawText = input.rawText.trim() + if (rawText.length === 0) { + return null + } + + const rulesResult = parseWithRules(rawText) + if (rulesResult) { + return rulesResult + } + + if (!options.llmFallback) { + return null + } + + const llmResult = await options.llmFallback(rawText) + return validateLlmResult(llmResult) +} diff --git a/packages/db/drizzle/0003_mature_roulette.sql b/packages/db/drizzle/0003_mature_roulette.sql new file mode 100644 index 0000000..02493de --- /dev/null +++ b/packages/db/drizzle/0003_mature_roulette.sql @@ -0,0 +1,7 @@ +ALTER TABLE "purchase_messages" ADD COLUMN "parsed_amount_minor" bigint;--> statement-breakpoint +ALTER TABLE "purchase_messages" ADD COLUMN "parsed_currency" text;--> statement-breakpoint +ALTER TABLE "purchase_messages" ADD COLUMN "parsed_item_description" text;--> statement-breakpoint +ALTER TABLE "purchase_messages" ADD COLUMN "parser_mode" text;--> statement-breakpoint +ALTER TABLE "purchase_messages" ADD COLUMN "parser_confidence" integer;--> statement-breakpoint +ALTER TABLE "purchase_messages" ADD COLUMN "needs_review" integer DEFAULT 1 NOT NULL;--> statement-breakpoint +ALTER TABLE "purchase_messages" ADD COLUMN "parser_error" text; \ No newline at end of file diff --git a/packages/db/drizzle/meta/0003_snapshot.json b/packages/db/drizzle/meta/0003_snapshot.json new file mode 100644 index 0000000..f0e0419 --- /dev/null +++ b/packages/db/drizzle/meta/0003_snapshot.json @@ -0,0 +1,1393 @@ +{ + "id": "67e9eddc-9734-443e-a731-8a63cbf49145", + "prevId": "5ddb4be4-d1fb-4cef-b010-62371bbd13c9", + "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.purchase_messages": { + "name": "purchase_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 + }, + "sender_member_id": { + "name": "sender_member_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "sender_telegram_user_id": { + "name": "sender_telegram_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sender_display_name": { + "name": "sender_display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "raw_text": { + "name": "raw_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "telegram_chat_id": { + "name": "telegram_chat_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "telegram_message_id": { + "name": "telegram_message_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "telegram_thread_id": { + "name": "telegram_thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "telegram_update_id": { + "name": "telegram_update_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "message_sent_at": { + "name": "message_sent_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "parsed_amount_minor": { + "name": "parsed_amount_minor", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "parsed_currency": { + "name": "parsed_currency", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parsed_item_description": { + "name": "parsed_item_description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parser_mode": { + "name": "parser_mode", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parser_confidence": { + "name": "parser_confidence", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "needs_review": { + "name": "needs_review", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "parser_error": { + "name": "parser_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "processing_status": { + "name": "processing_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "ingested_at": { + "name": "ingested_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "purchase_messages_household_thread_idx": { + "name": "purchase_messages_household_thread_idx", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "telegram_thread_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "purchase_messages_sender_idx": { + "name": "purchase_messages_sender_idx", + "columns": [ + { + "expression": "sender_telegram_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "purchase_messages_household_tg_message_unique": { + "name": "purchase_messages_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": {} + }, + "purchase_messages_household_tg_update_unique": { + "name": "purchase_messages_household_tg_update_unique", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "telegram_update_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "purchase_messages_household_id_households_id_fk": { + "name": "purchase_messages_household_id_households_id_fk", + "tableFrom": "purchase_messages", + "tableTo": "households", + "columnsFrom": ["household_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "purchase_messages_sender_member_id_members_id_fk": { + "name": "purchase_messages_sender_member_id_members_id_fk", + "tableFrom": "purchase_messages", + "tableTo": "members", + "columnsFrom": ["sender_member_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "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 babccc5..7bbefc1 100644 --- a/packages/db/drizzle/meta/_journal.json +++ b/packages/db/drizzle/meta/_journal.json @@ -22,6 +22,13 @@ "when": 1772670548136, "tag": "0002_tough_sandman", "breakpoints": true + }, + { + "idx": 3, + "version": "7", + "when": 1772671128084, + "tag": "0003_mature_roulette", + "breakpoints": true } ] } diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index b67e618..9439eb6 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -198,6 +198,13 @@ export const purchaseMessages = pgTable( telegramThreadId: text('telegram_thread_id').notNull(), telegramUpdateId: text('telegram_update_id').notNull(), messageSentAt: timestamp('message_sent_at', { withTimezone: true }), + parsedAmountMinor: bigint('parsed_amount_minor', { mode: 'bigint' }), + parsedCurrency: text('parsed_currency'), + parsedItemDescription: text('parsed_item_description'), + parserMode: text('parser_mode'), + parserConfidence: integer('parser_confidence'), + needsReview: integer('needs_review').default(1).notNull(), + parserError: text('parser_error'), processingStatus: text('processing_status').default('pending').notNull(), ingestedAt: timestamp('ingested_at', { withTimezone: true }).defaultNow().notNull() },