Files
finance-app/prisma/schema.prisma
T
siddharthd dbc6fd1352
ci / lint-test (push) Successful in 51s
Add source identity to transactions so a feed can be re-imported safely
The CSV importer has no idempotency and structurally cannot have any:
batchInsertCSVTransactions assigns row_index = MAX(row_index) + 1, and
row_index is the fifth column of uq_transaction_identity, so the constraint
is guaranteed a fresh value on every run. The same file imported twice
produces two full sets of rows and nothing objects.

Tolerable for the hand-driven bank CSV this was built for. Not tolerable for
any recurring feed, whose windows overlap by design — and not cleanable
afterwards, since every child of transactions is ON DELETE CASCADE, so a
duplicate must be superseded rather than deleted (0023). ~$42k of re-imported
statement rows already show how that goes.

source_ref carries the provider's own key verbatim, enforced by a partial
unique index rather than an application-side ON CONFLICT that a refactor can
quietly drop.

Found while evaluating Frollo (DECISIONS.md ING-11 in the smarthome repo).
That feed is not being ingested — 88% of it duplicates existing statement
coverage — but this gap is real independently of it.
2026-08-01 22:13:07 +10:00

266 lines
10 KiB
Plaintext

generator client {
provider = "prisma-client"
output = "../src/generated/prisma"
}
datasource db {
provider = "postgresql"
}
model trips {
id Int @id @default(autoincrement())
owner_id Int
name String
description String?
start_date DateTime? @db.Date
end_date DateTime? @db.Date
color String @default("#6366f1")
archived Boolean @default(false)
created_at DateTime @default(now())
overrides transaction_overrides[]
payments split_payments[]
}
model transaction_overrides {
id Int @id @default(autoincrement())
transaction_id Int @unique
merchant_normalized String?
category_override String?
notes String?
my_share_percent Decimal? @db.Decimal(5, 2)
updated_at DateTime @default(now()) @updatedAt
trip_id Int?
trip trips? @relation(fields: [trip_id], references: [id], onDelete: SetNull)
}
model participants {
id Int @id @default(autoincrement())
name String @unique
email String? @unique
created_at DateTime @default(now())
splits transaction_splits[]
account_owner_mappings account_owner_mappings[]
payments_sent split_payments[] @relation("payments_from")
payments_received split_payments[] @relation("payments_to")
order_reviews order_reviews[]
}
model account_owner_mappings {
id Int @id @default(autoincrement())
bank_name String
account_number String
owner_id Int
created_at DateTime @default(now())
owner participants @relation(fields: [owner_id], references: [id])
@@unique([bank_name, account_number])
}
model transaction_splits {
id Int @id @default(autoincrement())
transaction_id Int
participant_id Int
share_percent Decimal @db.Decimal(5, 2)
settled Boolean @default(false)
settled_at DateTime?
created_at DateTime @default(now())
participant participants @relation(fields: [participant_id], references: [id])
@@unique([transaction_id, participant_id])
}
model split_payments {
id Int @id @default(autoincrement())
from_participant_id Int
to_participant_id Int
amount Decimal @db.Decimal(10, 2)
payment_date DateTime @db.Date
notes String?
linked_transaction_id Int?
trip_id Int?
created_at DateTime @default(now())
from_participant participants @relation("payments_from", fields: [from_participant_id], references: [id])
to_participant participants @relation("payments_to", fields: [to_participant_id], references: [id])
trip trips? @relation(fields: [trip_id], references: [id], onDelete: SetNull)
@@index([trip_id])
}
model tags {
id Int @id @default(autoincrement())
name String @unique
color String @default("#6366f1")
created_at DateTime @default(now())
transaction_tags transaction_tags[]
}
model transaction_tags {
transaction_id Int
tag_id Int
created_at DateTime @default(now())
tag tags @relation(fields: [tag_id], references: [id], onDelete: Cascade)
@@id([transaction_id, tag_id])
}
model rules {
id Int @id @default(autoincrement())
owner_id Int
name String
conditions Json @default("[]")
actions Json @default("{}")
enabled Boolean @default(true)
manual_only Boolean @default(false)
priority Int @default(0)
created_at DateTime @default(now())
updated_at DateTime @default(now()) @updatedAt
}
model budgets {
id Int @id @default(autoincrement())
owner_id Int
category String
month DateTime @db.Date
amount_limit Decimal @db.Decimal(10, 2)
created_at DateTime @default(now())
updated_at DateTime @default(now()) @updatedAt
@@unique([owner_id, category, month])
}
model statements {
id Int @id @default(autoincrement())
filename String
bank_name String?
card_name String?
account_type String?
account_number String
billing_start_date DateTime? @db.Date
billing_end_date DateTime? @db.Date
total_amount_due Decimal? @db.Decimal(12, 2)
minimum_amount_due Decimal? @db.Decimal(12, 2)
payment_due_date DateTime? @db.Date
event_created Boolean? @default(false)
tier_used String?
created_at DateTime? @default(now())
statement_type String @default("other")
currency String? @default("AUD")
opening_balance Decimal? @db.Decimal(12, 2)
closing_balance Decimal? @db.Decimal(12, 2)
total_credits Decimal? @db.Decimal(12, 2)
total_debits Decimal? @db.Decimal(12, 2)
interest_charged Decimal? @db.Decimal(12, 2)
fees_charged Decimal? @db.Decimal(12, 2)
credit_limit Decimal? @db.Decimal(12, 2)
available_credit Decimal? @db.Decimal(12, 2)
owner_id Int @default(1)
account_holder_name String?
exchange_rate_to_aud Decimal? @db.Decimal(10, 6)
paperless_doc_id Int? @unique
interest_rate Decimal? @db.Decimal(6, 3)
scheduled_repayment Decimal? @db.Decimal(12, 2)
repayment_frequency String?
redraw_available Decimal? @db.Decimal(12, 2)
loan_term_months Int?
transactions transactions[]
}
model transactions {
id Int @id @default(autoincrement())
statement_id Int?
transaction_date DateTime @db.Date
description String?
amount Decimal @db.Decimal(12, 2)
created_at DateTime? @default(now())
transaction_type String? @default("debit")
merchant_name String?
location String?
foreign_currency_amount Decimal? @db.Decimal(12, 2)
foreign_currency_code String?
category String?
row_index Int?
merchant_normalized String?
amount_aud Decimal? @db.Decimal(12, 2)
payment_method String? // card | cash | bank_transfer | other; NULL = unknown (migration 0016)
owner_id Int?
reconciled_with_id Int?
superseded_by_id Int?
principal_amount Decimal? @db.Decimal(12, 2)
interest_amount Decimal? @db.Decimal(12, 2)
source String? // feed this row came from, e.g. "frollo"; NULL = statement/manual (migration 0028)
source_ref String? // the provider's own transaction id — idempotency key for re-imports
source_account String? // account label as the source names it
statement statements? @relation(fields: [statement_id], references: [id], onDelete: Cascade)
reconciled_with transactions? @relation("reconciled", fields: [reconciled_with_id], references: [id], onDelete: SetNull)
reconciled_by transactions[] @relation("reconciled")
superseded_by transactions? @relation("superseded", fields: [superseded_by_id], references: [id], onDelete: SetNull)
supersedes transactions[] @relation("superseded")
expense_metadata expense_metadata?
order_reviews order_reviews[]
}
model expense_metadata {
id Int @id @default(autoincrement())
transaction_id Int? @unique
source String @default("email")
paperless_doc_id Int? @unique
source_email_subject String?
source_email_from String?
source_message_id String?
payment_method String?
payment_method_detail String?
order_reference String?
line_items Json @default("[]")
tax_amount Decimal? @db.Decimal(12, 2)
subtotal Decimal? @db.Decimal(12, 2)
merchant_normalized String?
amount Decimal? @db.Decimal(12, 2)
transaction_date DateTime? @db.Date
extraction_model String? @default("gemini-2.5-flash")
created_at DateTime? @default(now())
// In the database since migrations 0019/0020 but absent from this model until 0027.
// Regenerating the client from the stale definition would have dropped columns the order
// lane writes on every ingest.
card_last4 String?
currency String?
flags Json @default("[]")
reconciled_at DateTime? @db.Timestamptz(6)
matched_transaction_id Int?
platform String?
route Json?
// 0027 — receipt scans as a second producer on this lane.
tender_raw String?
receipt_sha256 String?
receipt_group String?
transaction transactions? @relation(fields: [transaction_id], references: [id], onDelete: Cascade)
@@unique([source, order_reference], name: "uq_expense_source_order")
}
model order_reviews {
id Int @id @default(autoincrement())
transaction_id Int
participant_id Int
rating String?
order_again Boolean?
note String?
item_verdicts Json @default("[]")
created_at DateTime @default(now())
updated_at DateTime @updatedAt
transaction transactions @relation(fields: [transaction_id], references: [id], onDelete: Cascade)
participant participants @relation(fields: [participant_id], references: [id], onDelete: Cascade)
@@unique([transaction_id, participant_id], name: "order_reviews_transaction_participant_key")
}
model rule_apply_runs {
id Int @id @default(autoincrement())
owner_id Int
applied_at DateTime @default(now())
split_from DateTime? @db.Date
matched Int @default(0)
transactions_affected Int @default(0)
reverted_at DateTime?
snapshot Json @default("[]")
}