Skip to content

Doewe — Data Model

erDiagram
    User {
        String id PK
        String email
        String password
        String name "nullable"
        DateTime passwordChangedAt "nullable"
        DateTime createdAt
    }
    PasswordResetToken {
        String id PK
        String userId FK
        String tokenHash
        DateTime expiresAt
        DateTime usedAt "nullable"
        DateTime createdAt
    }
    Account {
        String id PK
        String userId FK
        String name
        DateTime createdAt
    }
    Category {
        String id PK
        String userId FK
        String name
        Boolean isIncome
        Boolean isTaxRelevant
        DateTime createdAt
    }
    Transaction {
        String id PK
        String accountId FK
        String categoryId FK
        String savingGoalId FK
        Int amountCents
        String description
        DateTime occurredAt
        Boolean taxRelevant
        DateTime createdAt
    }
    Attachment {
        String id PK
        String transactionId FK
        String fileName
        String mimeType
        Int sizeBytes
        Bytes data
        DateTime createdAt
    }
    RecurringTransaction {
        String id PK
        String accountId FK
        String categoryId FK
        Int amountCents
        String description
        String frequency
        Int intervalMonths
        Int dayOfMonth
        DateTime nextOccurrence
        DateTime createdAt
    }
    RecurringTransactionSkip {
        String id PK
        String recurringId FK
        Int year
        Int month
        DateTime createdAt
    }
    Budget {
        String id PK
        String accountId FK
        String categoryId FK "nullable, null = saving goal"
        String title
        Int month "nullable, null = undated goal"
        Int year "nullable, null = undated goal"
        Int amountCents "nullable, null = no amount yet"
        DateTime completedAt "nullable"
        Int spentCents "nullable"
        DateTime createdAt
    }

    User ||--o{ Account : "owns"
    User ||--o{ Category : "defines"
    User ||--o{ PasswordResetToken : "requests"
    Account ||--o{ Transaction : "contains"
    Account ||--o{ RecurringTransaction : "has"
    Account ||--o{ Budget : "has"
    Category ||--o{ Transaction : "classifies"
    Category ||--o{ RecurringTransaction : "classifies"
    Category ||--o{ Budget : "scopes"
    RecurringTransaction ||--o{ RecurringTransactionSkip : "skipped by"
    Budget ||--o{ Transaction : "funds (saving goal)"
    Transaction ||--o{ Attachment : "documented by"

There is no separate SavingGoal model — a saving goal is a Budget row with categoryId = null. See the Budget section below.


Represents an authenticated person using the application. Every piece of data in the system is owned by a user — either directly (categories) or transitively through accounts.

FieldTypeDescription
idString (CUID)Primary key, generated by Prisma
emailStringUnique login identifier
passwordStringbcrypt hash of the password; never exposed via API
nameString?Optional display name
passwordChangedAtDateTime?Bumped on every password change/reset. JWTs issued before this instant are treated as stale and rejected, so a reset evicts existing sessions
createdAtDateTimeAccount creation timestamp

Relationships: A user owns multiple Accounts, multiple Categorys, and multiple PasswordResetTokens.

Constraints: email is unique across all users.


One-time token for the “forgot password” flow. Only the SHA-256 hash of the token is stored — the plaintext lives solely in the emailed reset link.

FieldTypeDescription
idString (CUID)Primary key
userIdStringFK → User.id (onDelete: Cascade)
tokenHashStringSHA-256 hash of the token; unique
expiresAtDateTimeExpiry timestamp
usedAtDateTime?Set once the token has been redeemed
createdAtDateTimeCreation timestamp

A financial account owned by a user, analogous to a bank account or cash wallet. All transactions, recurring transactions, budgets, and saving goals belong to an account, which ties them back to the user.

FieldTypeDescription
idString (CUID)Primary key
userIdStringFK → User.id
nameStringHuman-readable label (e.g., “Girokonto”, “Bargeld”)
createdAtDateTimeCreation timestamp

Relationships: An account belongs to exactly one User (onDelete: Cascade — deleting the user also deletes their accounts). It has many Transactions, RecurringTransactions, and Budgets. (Saving goals are Budget rows with categoryId = null; there is no separate SavingGoal model.)


A user-defined label used to classify transactions and recurring transactions. Categories are also the unit of budgeting — a budget is tied to one category for one month.

FieldTypeDescription
idString (CUID)Primary key
userIdStringFK → User.id
nameStringLabel (e.g., “Lebensmittel”, “Miete”, “Gehalt”)
isIncomeBooleantrue for income categories, false for expense categories
isTaxRelevantBooleanDefault false. Enabling it retroactively earmarks all existing transactions of the category and becomes the default for new ones (form pre-enables the toggle; API inherits when the flag is omitted). Individual transactions can still be unmarked
createdAtDateTimeCreation timestamp

Relationships: A category belongs to one User. It can classify many Transactions, RecurringTransactions, and Budgets.

Constraints: (userId, name) is unique — a user cannot have two categories with the same name.

Special convention: Categories whose name matches "savings" or "sparen" (case-insensitive) are treated as savings categories by the analytics engine. See Domain Rules below.


A single, one-time financial event: money coming in or going out on a specific date.

FieldTypeDescription
idString (CUID)Primary key
accountIdStringFK → Account.id
categoryIdString?FK → Category.id (optional)
savingGoalIdString?FK → Budget.id (optional) — points to a saving goal (a Budget with categoryId = null); onDelete: SetNull
amountCentsIntMonetary value in euro cents; positive = income, negative = expense
descriptionStringFree-text note
occurredAtDateTimeWhen the money moved (user-specified date)
taxRelevantBooleanDefault false. Earmarks the transaction for the German tax return; the /tax page lists all flagged transactions per year
createdAtDateTimeRecord creation timestamp

Relationships: Belongs to one Account. Optionally classified by one Category. Optionally linked to one saving goal (Budget). Has many Attachments (receipts).

Key rule: amountCents carries the sign. A grocery purchase of €42.50 is stored as -4250. A salary of €3 200 is stored as 320000.


A receipt file (photo or PDF) attached to a transaction as evidence for the tax return (Belegvorhaltepflicht — receipts are archived, not submitted to the tax office). The file bytes are stored directly in PostgreSQL.

FieldTypeDescription
idString (CUID)Primary key
transactionIdStringFK → Transaction.id; onDelete: Cascade — deleting the transaction removes its receipts
fileNameStringSanitized original file name
mimeTypeStringOne of image/jpeg, image/png, image/webp, application/pdf (enforced in the API)
sizeBytesIntFile size; max. 5 MB (enforced in the API)
dataBytesRaw file content. Never selected in list queries — only GET /api/attachments/[id] loads it
createdAtDateTimeUpload timestamp

Constraints: Max. 5 attachments per transaction (enforced in the API). Indexed on transactionId.


A template for a transaction that repeats on a schedule (e.g., monthly rent, quarterly insurance premium). The system uses it to project future cash flow and to display “expected” entries in the monthly summary.

FieldTypeDescription
idString (CUID)Primary key
accountIdStringFK → Account.id
categoryIdString?FK → Category.id (optional)
amountCentsIntAmount per occurrence (same sign convention as Transaction)
descriptionStringLabel (e.g., “Miete”, “Netflix”)
frequencyStringUppercase frequency identifier — allowed values per schema comment: DAILY | WEEKLY | MONTHLY | YEARLY (validated in the API). The create endpoint currently always sets "MONTHLY"; the actual cadence is driven by intervalMonths
intervalMonthsIntNumber of months between occurrences (e.g., 1 for monthly, 3 for quarterly)
dayOfMonthIntWhich day of the month the payment falls on (1–31)
nextOccurrenceDateTimeAnchor date of the series = first occurrence. Computed from dayOfMonth, or set directly from an optional startDate (yyyy-mm-dd) supplied at create/update
createdAtDateTimeCreation timestamp

Relationships: Belongs to one Account. Optionally classified by one Category. Has many RecurringTransactionSkips for months the user chooses to skip.


Records a deliberate skip of a recurring transaction for a specific calendar month. If a skip record exists for a (recurringId, year, month) combination, that month’s occurrence is suppressed in the analytics.

FieldTypeDescription
idString (CUID)Primary key
recurringIdStringFK → RecurringTransaction.id (onDelete: Cascade — deleting the recurring transaction removes its skips)
yearIntCalendar year of the skipped occurrence (e.g., 2026)
monthIntCalendar month of the skipped occurrence (1–12)
createdAtDateTimeCreation timestamp

Constraints: (recurringId, year, month) is unique — you can only skip a given occurrence once. There is an index on recurringId.


A spending limit set by the user for a specific category in a specific calendar month — or, with categoryId = null, a saving goal.

FieldTypeDescription
idString (CUID)Primary key
accountIdStringFK → Account.id
categoryIdString?FK → Category.id; null = the row is a saving goal, not a category budget
titleStringDisplay label (defaults to ""); serves as the goal name for saving goals
monthInt?Calendar month (1–12). Category budgets always set it; null on a saving goal = undated goal (idea backlog)
yearInt?Calendar year; null on a saving goal = undated goal
amountCentsInt?Budget limit / goal target in euro cents (positive). Required for category budgets; null on a saving goal = no target amount set yet
completedAtDateTime?null = active; set = goal closed. Only meaningful for saving goals (categoryId = null)
spentCentsInt?Amount actually withdrawn from the savings pool on completion (snapshot); null while active
createdAtDateTimeCreation timestamp

Constraints: (accountId, categoryId, month, year) is unique — only one budget per category per month per account.

Relationships: Belongs to one Account. Optionally scoped to one Category. Has many Transactions linked via Transaction.savingGoalId (contributions to a saving goal).

Saving-goal lifecycle: A Budget with categoryId = null acts as a saving goal. Setting completedAt/spentCents (via POST /api/saving-plan/[id]/complete) marks the goal as completed; clearing them (via DELETE) reopens it. Completing a goal does not create a transaction — it only reserves spentCents out of the computed savings pool that funds the remaining active goals. See docs/calculations/07-sparziele.md.


A standing budget for one category: the same amount every month (MONTHLY) or one yearly amount distributed over the months (YEARLY). The plan takes precedence; a per-month Budget row only serves as a fallback for categories without a plan.

FieldTypeDescription
idString (CUID)Primary key
householdIdStringFK → Household.id (tenant scope); onDelete: Cascade
categoryIdStringFK → Category.id, unique — at most one plan per category; onDelete: Cascade
periodStringMONTHLY or YEARLY (validated in the API, not by a DB enum)
amountCentsIntPer-month amount (MONTHLY) or yearly total (YEARLY), integer cents, 1..1_000_000_000
createdAt / updatedAtDateTimeTimestamps
deletedAtDateTime?Soft-delete tombstone; hidden by the Prisma extension on findMany/findFirst/… (not on findUnique). Re-creating a plan for the category revives the row (same id)

Resolution: see docs/calculations/06-budgets.md (distribution formula, precedence order). Savings and income categories are not budgetable (API: 400).


There is no standalone SavingGoal model. A saving goal is a Budget record with categoryId = null:

  • title serves as the goal name (e.g., “Urlaub 2027”, “Neues Fahrrad”).
  • amountCents is the target amount (optional — null = no amount decided yet).
  • month/year form the target date (both null = undated goal / idea backlog, excluded from the forward-looking savings plan).
  • Transactions are linked to a goal via Transaction.savingGoalId → Budget.id.

See the Budget section above and docs/calculations/07-sparziele.md for the full lifecycle.


All monetary values are stored as integer euro cents in the database, and all arithmetic happens on integer cents. Note that the analytics endpoints divide by 100 at the final JSON output step — their responses contain euro values (e.g., income: 3200), not cents. The CRUD endpoints (transactions, budgets, …) accept and return integer cents.

The @doewe/shared package exports a branded Cents type to prevent accidentally mixing raw numbers with cent values:

packages/shared/src/money.ts
type Cents = number & { readonly __brand: "Cents" };
function parseCents(input: string): Cents // user input string ("12,50") → Cents
function fromCents(n: number): Cents // raw DB integer → branded Cents
function toDecimalString(c: Cents): string // display, e.g., "42.50"
function add(a: Cents, b: Cents): Cents
function sub(a: Cents, b: Cents): Cents
function multiply(a: Cents, factor: number): Cents

Rule: Never do arithmetic on raw number values that represent money. Always use the Cents helpers.

amountCents > 0 → income (Einnahme)
amountCents < 0 → expense (Ausgabe)
amountCents = 0 → not allowed in practice

The analytics endpoints exploit this: SUM(amountCents) WHERE amountCents > 0 gives total income; SUM(amountCents) WHERE amountCents < 0 gives total expenses. Account balance is SUM(amountCents) across all transactions.

The UI is responsible for negating user-entered amounts when the user specifies an expense. The API receives the correctly-signed cent value.

The analytics engine identifies savings transactions by checking whether the associated category name matches the pattern "savings" or "sparen" (case-insensitive). There is no dedicated boolean flag or separate model. This means:

  • A transaction categorized under “Sparen” or “savings” contributes to the savings total, not the expense total, in the monthly summary.
  • Renaming the category to anything else will exclude those transactions from the savings calculation.
  • The convention applies to both one-time transactions and recurring transactions.

The following illustrates a realistic snapshot of one user’s data.

idemail
usr_01anna@example.de
iduserIdname
acc_01usr_01Girokonto
acc_02usr_01Tagesgeldkonto
iduserIdnameisIncome
cat_01usr_01Gehalttrue
cat_02usr_01Lebensmittelfalse
cat_03usr_01Mietefalse
cat_04usr_01Sparenfalse
cat_05usr_01Freizeitfalse
idaccountIdcategoryIdamountCentsdescriptionoccurredAt
txn_01acc_01cat_01320000Gehalt April2026-04-01
txn_02acc_01cat_03-85000Miete April2026-04-02
txn_03acc_01cat_02-6340Rewe2026-04-05
txn_04acc_01cat_04-50000Monatliche Sparrate2026-04-10
txn_05acc_01cat_05-2800Kino2026-04-18
idaccountIdcategoryIdamountCentsdescriptionintervalMonthsdayOfMonthnextOccurrence
rec_01acc_01cat_03-85000Miete112026-05-01
rec_02acc_01cat_04-50000Sparrate1102026-05-10

(No skips in this example — Anna pays everything in May.)

idaccountIdcategoryIdtitlemonthyearamountCents
bud_01acc_01cat_02Lebensmittel4202620000
bud_02acc_01cat_05Freizeit420265000
MetricCalculationResult
IncomeSUM(amountCents > 0)€3 200.00
ExpensesABS(SUM(amountCents < 0, non-savings))€941.40
SavingsABS(SUM(amountCents, category=Sparen))€500.00
Lebensmittel actual vs budget€63.40 vs €200.0031.7% used
Freizeit actual vs budget€28.00 vs €50.0056.0% used