Production deployment setup + feature complete

- Dockerfile + deploy.sh for Hetzner server
- Email verification via Better Auth + Resend
- Invite code flow (6-digit OTP, generate/join)
- Settlement share percent fix (payer vs debtor)
- OCR scanner fixes (date display, retry, viewfinder)
- app.json icon/splash/adaptive-icon configured
- iOS deployment target 15.5 (ML Kit requirement)
- DB migration 0014: household_invitations table

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
René Schober
2026-03-20 11:54:22 +01:00
parent 4e34270786
commit 9ddc7c6d7a
194 changed files with 55961 additions and 305 deletions

View File

@@ -14,8 +14,10 @@
"@better-auth/expo": "catalog:",
"@haushaltsApp/db": "workspace:*",
"@haushaltsApp/env": "workspace:*",
"@types/nodemailer": "^7.0.11",
"better-auth": "catalog:",
"dotenv": "catalog:",
"nodemailer": "^8.0.2",
"zod": "catalog:"
},
"devDependencies": {

View File

@@ -4,11 +4,23 @@ import * as schema from "@haushaltsApp/db/schema/auth";
import { env } from "@haushaltsApp/env/server";
import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { bearer, organization } from "better-auth/plugins";
import nodemailer from "nodemailer";
function createTransport() {
return nodemailer.createTransport({
host: env.SMTP_HOST,
port: env.SMTP_PORT,
secure: env.SMTP_PORT === 465,
...(env.SMTP_USER && env.SMTP_PASSWORD
? { auth: { user: env.SMTP_USER, pass: env.SMTP_PASSWORD } }
: {}),
});
}
export const auth = betterAuth({
database: drizzleAdapter(db, {
provider: "pg",
schema: schema,
}),
trustedOrigins: [
@@ -20,7 +32,71 @@ export const auth = betterAuth({
],
emailAndPassword: {
enabled: true,
requireEmailVerification: true,
async sendResetPassword(data) {
await createTransport().sendMail({
from: env.SMTP_FROM,
to: data.user.email,
subject: "Passwort zurücksetzen HausApp",
html: `
<p>Hallo ${data.user.name ?? ""},</p>
<p>Klicke auf den Link, um dein Passwort zurückzusetzen:</p>
<p><a href="${data.url}" style="background:#2563EB;color:#fff;padding:12px 24px;border-radius:8px;text-decoration:none;display:inline-block;">Passwort zurücksetzen</a></p>
<p style="color:#9ca3af;font-size:12px;">Oder kopiere diesen Link: ${data.url}</p>
<p style="color:#9ca3af;font-size:12px;">Der Link ist 1 Stunde gültig.</p>
`,
});
},
},
emailVerification: {
sendVerificationEmail: async (data) => {
await createTransport().sendMail({
from: env.SMTP_FROM,
to: data.user.email,
subject: "E-Mail bestätigen HausApp",
html: `
<p>Hallo ${data.user.name ?? ""},</p>
<p>Bitte bestätige deine E-Mail-Adresse:</p>
<p><a href="${data.url}" style="background:#2563EB;color:#fff;padding:12px 24px;border-radius:8px;text-decoration:none;display:inline-block;">E-Mail bestätigen</a></p>
<p style="color:#9ca3af;font-size:12px;">Oder kopiere diesen Link: ${data.url}</p>
<p style="color:#9ca3af;font-size:12px;">Der Link ist 1 Stunde gültig.</p>
`,
});
},
},
socialProviders: {
...(env.APPLE_CLIENT_ID && env.APPLE_PRIVATE_KEY
? {
apple: {
clientId: env.APPLE_CLIENT_ID,
clientSecret: env.APPLE_PRIVATE_KEY,
appBundleIdentifier: env.APPLE_CLIENT_ID,
},
}
: {}),
},
plugins: [
expo(),
bearer(),
organization({
allowUserToCreateOrganization: true,
async sendInvitationEmail(data) {
const inviteUrl = `${env.MOBILE_APP_SCHEME}invite?invitationId=${data.invitation.id}`;
await createTransport().sendMail({
from: env.SMTP_FROM,
to: data.email,
subject: `Einladung zu ${data.organization.name} HausApp`,
html: `
<p>Hallo,</p>
<p><strong>${data.inviter.user.name}</strong> hat dich eingeladen, dem Haushalt <strong>${data.organization.name}</strong> beizutreten.</p>
<p>Klicke auf den Link, um die Einladung anzunehmen:</p>
<p><a href="${inviteUrl}" style="background:#2563EB;color:#fff;padding:12px 24px;border-radius:8px;text-decoration:none;display:inline-block;">Einladung annehmen</a></p>
<p style="color:#9ca3af;font-size:12px;">Oder kopiere diesen Link: ${inviteUrl}</p>
`,
});
},
}),
],
secret: env.BETTER_AUTH_SECRET,
baseURL: env.BETTER_AUTH_URL,
advanced: {
@@ -30,5 +106,4 @@ export const auth = betterAuth({
httpOnly: true,
},
},
plugins: [expo()],
});

View File

@@ -5,6 +5,9 @@
".": {
"default": "./src/index.ts"
},
"./schema": {
"default": "./src/schema/index.ts"
},
"./*": {
"default": "./src/*.ts"
}

View File

@@ -4,3 +4,6 @@ import { drizzle } from "drizzle-orm/node-postgres";
import * as schema from "./schema";
export const db = drizzle(env.DATABASE_URL, { schema });
// Re-export commonly used Drizzle utilities so consumers don't need a separate drizzle-orm dep
export { eq, and, or, not, gt, gte, lt, lte, isNull, isNotNull, inArray, asc, desc, sql } from "drizzle-orm";

View File

@@ -0,0 +1,174 @@
CREATE TYPE "public"."budget_context_type" AS ENUM('vacation', 'project', 'event');--> statement-breakpoint
CREATE TYPE "public"."category_type" AS ENUM('income', 'expense');--> statement-breakpoint
CREATE TYPE "public"."subscription_plan" AS ENUM('free', 'pro', 'family');--> statement-breakpoint
CREATE TYPE "public"."subscription_status" AS ENUM('active', 'canceled', 'past_due');--> statement-breakpoint
CREATE TYPE "public"."transaction_type" AS ENUM('income', 'expense');--> statement-breakpoint
CREATE TABLE "budget_contexts" (
"id" text PRIMARY KEY NOT NULL,
"household_id" text NOT NULL,
"name" text NOT NULL,
"type" "budget_context_type" NOT NULL,
"total_budget" numeric(12, 2) NOT NULL,
"currency" text DEFAULT 'EUR' NOT NULL,
"start_date" date,
"end_date" date,
"is_active" boolean DEFAULT true NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "categories" (
"id" text PRIMARY KEY NOT NULL,
"household_id" text NOT NULL,
"name" text NOT NULL,
"icon" text,
"color" text,
"type" "category_type" NOT NULL,
"is_default" boolean DEFAULT false NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "households" (
"id" text PRIMARY KEY NOT NULL,
"name" text NOT NULL,
"owner_id" text NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "savings_goals" (
"id" text PRIMARY KEY NOT NULL,
"household_id" text NOT NULL,
"name" text NOT NULL,
"target_amount" numeric(12, 2) NOT NULL,
"current_amount" numeric(12, 2) DEFAULT '0' NOT NULL,
"target_date" date,
"allocation_percent" numeric(5, 2),
"created_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "shopping_list_items" (
"id" text PRIMARY KEY NOT NULL,
"list_id" text NOT NULL,
"added_by_user_id" text NOT NULL,
"name" text NOT NULL,
"quantity" numeric(10, 2),
"unit" text,
"is_checked" boolean DEFAULT false NOT NULL,
"checked_by_user_id" text,
"checked_at" timestamp,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "shopping_lists" (
"id" text PRIMARY KEY NOT NULL,
"household_id" text NOT NULL,
"name" text NOT NULL,
"is_active" boolean DEFAULT true NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "subscription_plans" (
"id" text PRIMARY KEY NOT NULL,
"household_id" text NOT NULL,
"plan" "subscription_plan" DEFAULT 'free' NOT NULL,
"status" "subscription_status" DEFAULT 'active' NOT NULL,
"stripe_customer_id" text,
"stripe_subscription_id" text,
"current_period_start" timestamp,
"current_period_end" timestamp,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "transactions" (
"id" text PRIMARY KEY NOT NULL,
"household_id" text NOT NULL,
"user_id" text NOT NULL,
"category_id" text,
"amount" numeric(12, 2) NOT NULL,
"currency" text DEFAULT 'EUR' NOT NULL,
"type" "transaction_type" NOT NULL,
"description" text,
"merchant" text,
"date" date NOT NULL,
"receipt_image_url" text,
"budget_context_id" text,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "account" (
"id" text PRIMARY KEY NOT NULL,
"account_id" text NOT NULL,
"provider_id" text NOT NULL,
"user_id" text NOT NULL,
"access_token" text,
"refresh_token" text,
"id_token" text,
"access_token_expires_at" timestamp,
"refresh_token_expires_at" timestamp,
"scope" text,
"password" text,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp NOT NULL
);
--> statement-breakpoint
CREATE TABLE "session" (
"id" text PRIMARY KEY NOT NULL,
"expires_at" timestamp NOT NULL,
"token" text NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp NOT NULL,
"ip_address" text,
"user_agent" text,
"user_id" text NOT NULL,
CONSTRAINT "session_token_unique" UNIQUE("token")
);
--> statement-breakpoint
CREATE TABLE "user" (
"id" text PRIMARY KEY NOT NULL,
"name" text NOT NULL,
"email" text NOT NULL,
"email_verified" boolean DEFAULT false NOT NULL,
"image" text,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL,
CONSTRAINT "user_email_unique" UNIQUE("email")
);
--> statement-breakpoint
CREATE TABLE "verification" (
"id" text PRIMARY KEY NOT NULL,
"identifier" text NOT NULL,
"value" text NOT NULL,
"expires_at" timestamp NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "budget_contexts" ADD CONSTRAINT "budget_contexts_household_id_households_id_fk" FOREIGN KEY ("household_id") REFERENCES "public"."households"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "categories" ADD CONSTRAINT "categories_household_id_households_id_fk" FOREIGN KEY ("household_id") REFERENCES "public"."households"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "households" ADD CONSTRAINT "households_owner_id_user_id_fk" FOREIGN KEY ("owner_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "savings_goals" ADD CONSTRAINT "savings_goals_household_id_households_id_fk" FOREIGN KEY ("household_id") REFERENCES "public"."households"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "shopping_list_items" ADD CONSTRAINT "shopping_list_items_list_id_shopping_lists_id_fk" FOREIGN KEY ("list_id") REFERENCES "public"."shopping_lists"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "shopping_list_items" ADD CONSTRAINT "shopping_list_items_added_by_user_id_user_id_fk" FOREIGN KEY ("added_by_user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "shopping_list_items" ADD CONSTRAINT "shopping_list_items_checked_by_user_id_user_id_fk" FOREIGN KEY ("checked_by_user_id") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "shopping_lists" ADD CONSTRAINT "shopping_lists_household_id_households_id_fk" FOREIGN KEY ("household_id") REFERENCES "public"."households"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "subscription_plans" ADD CONSTRAINT "subscription_plans_household_id_households_id_fk" FOREIGN KEY ("household_id") REFERENCES "public"."households"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "transactions" ADD CONSTRAINT "transactions_household_id_households_id_fk" FOREIGN KEY ("household_id") REFERENCES "public"."households"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "transactions" ADD CONSTRAINT "transactions_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "transactions" ADD CONSTRAINT "transactions_category_id_categories_id_fk" FOREIGN KEY ("category_id") REFERENCES "public"."categories"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "transactions" ADD CONSTRAINT "transactions_budget_context_id_budget_contexts_id_fk" FOREIGN KEY ("budget_context_id") REFERENCES "public"."budget_contexts"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "account" ADD CONSTRAINT "account_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "session" ADD CONSTRAINT "session_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "budget_contexts_household_id_idx" ON "budget_contexts" USING btree ("household_id");--> statement-breakpoint
CREATE INDEX "categories_household_id_idx" ON "categories" USING btree ("household_id");--> statement-breakpoint
CREATE INDEX "savings_goals_household_id_idx" ON "savings_goals" USING btree ("household_id");--> statement-breakpoint
CREATE INDEX "shopping_list_items_list_id_idx" ON "shopping_list_items" USING btree ("list_id");--> statement-breakpoint
CREATE INDEX "shopping_lists_household_id_idx" ON "shopping_lists" USING btree ("household_id");--> statement-breakpoint
CREATE INDEX "subscription_plans_household_id_idx" ON "subscription_plans" USING btree ("household_id");--> statement-breakpoint
CREATE INDEX "transactions_household_id_idx" ON "transactions" USING btree ("household_id");--> statement-breakpoint
CREATE INDEX "transactions_user_id_idx" ON "transactions" USING btree ("user_id");--> statement-breakpoint
CREATE INDEX "transactions_date_idx" ON "transactions" USING btree ("date");--> statement-breakpoint
CREATE INDEX "account_userId_idx" ON "account" USING btree ("user_id");--> statement-breakpoint
CREATE INDEX "session_userId_idx" ON "session" USING btree ("user_id");--> statement-breakpoint
CREATE INDEX "verification_identifier_idx" ON "verification" USING btree ("identifier");

View File

@@ -0,0 +1,39 @@
CREATE TABLE "invitation" (
"id" text PRIMARY KEY NOT NULL,
"organization_id" text NOT NULL,
"email" text NOT NULL,
"role" text,
"status" text DEFAULT 'pending' NOT NULL,
"expires_at" timestamp NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
"inviter_id" text NOT NULL
);
--> statement-breakpoint
CREATE TABLE "member" (
"id" text PRIMARY KEY NOT NULL,
"organization_id" text NOT NULL,
"user_id" text NOT NULL,
"role" text DEFAULT 'member' NOT NULL,
"created_at" timestamp NOT NULL
);
--> statement-breakpoint
CREATE TABLE "organization" (
"id" text PRIMARY KEY NOT NULL,
"name" text NOT NULL,
"slug" text NOT NULL,
"logo" text,
"created_at" timestamp NOT NULL,
"metadata" text,
CONSTRAINT "organization_slug_unique" UNIQUE("slug")
);
--> statement-breakpoint
ALTER TABLE "session" ADD COLUMN "active_organization_id" text;--> statement-breakpoint
ALTER TABLE "invitation" ADD CONSTRAINT "invitation_organization_id_organization_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "invitation" ADD CONSTRAINT "invitation_inviter_id_user_id_fk" FOREIGN KEY ("inviter_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "member" ADD CONSTRAINT "member_organization_id_organization_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "member" ADD CONSTRAINT "member_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "invitation_organizationId_idx" ON "invitation" USING btree ("organization_id");--> statement-breakpoint
CREATE INDEX "invitation_email_idx" ON "invitation" USING btree ("email");--> statement-breakpoint
CREATE INDEX "member_organizationId_idx" ON "member" USING btree ("organization_id");--> statement-breakpoint
CREATE INDEX "member_userId_idx" ON "member" USING btree ("user_id");--> statement-breakpoint
CREATE UNIQUE INDEX "organization_slug_uidx" ON "organization" USING btree ("slug");

View File

@@ -0,0 +1,70 @@
DO $$ BEGIN CREATE TYPE "public"."sync_operation" AS ENUM('create', 'update', 'delete'); EXCEPTION WHEN duplicate_object THEN null; END $$;--> statement-breakpoint
DO $$ BEGIN CREATE TYPE "public"."transaction_scope" AS ENUM('household', 'private', 'child'); EXCEPTION WHEN duplicate_object THEN null; END $$;--> statement-breakpoint
CREATE TABLE "children" (
"id" text PRIMARY KEY NOT NULL,
"household_id" text NOT NULL,
"name" text NOT NULL,
"color" text DEFAULT '#378ADD' NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "sync_queue" (
"id" text PRIMARY KEY NOT NULL,
"household_id" text NOT NULL,
"user_id" text NOT NULL,
"operation" "sync_operation" NOT NULL,
"table_name" text NOT NULL,
"payload" jsonb NOT NULL,
"attempts" numeric DEFAULT '0' NOT NULL,
"last_error" text,
"created_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "vacation_entries" (
"id" text PRIMARY KEY NOT NULL,
"vacation_id" text NOT NULL,
"created_by" text NOT NULL,
"category_id" text,
"amount" numeric(12, 2) NOT NULL,
"currency" text DEFAULT 'EUR' NOT NULL,
"description" text,
"date" date NOT NULL,
"synced_at" timestamp,
"created_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "vacations" (
"id" text PRIMARY KEY NOT NULL,
"household_id" text NOT NULL,
"name" text NOT NULL,
"budget" numeric(12, 2),
"currency" text DEFAULT 'EUR' NOT NULL,
"starts_on" date,
"ends_on" date,
"created_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "budget_contexts" DISABLE ROW LEVEL SECURITY;--> statement-breakpoint
DROP TABLE "budget_contexts" CASCADE;--> statement-breakpoint
ALTER TABLE "transactions" DROP CONSTRAINT IF EXISTS "transactions_budget_context_id_budget_contexts_id_fk";
--> statement-breakpoint
ALTER TABLE "transactions" ADD COLUMN "child_id" text;--> statement-breakpoint
ALTER TABLE "transactions" ADD COLUMN "scope" "transaction_scope" DEFAULT 'household' NOT NULL;--> statement-breakpoint
ALTER TABLE "transactions" ADD COLUMN "is_fixed" boolean DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE "transactions" ADD COLUMN "is_carry_over" boolean DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE "transactions" ADD COLUMN "synced_at" timestamp;--> statement-breakpoint
ALTER TABLE "children" ADD CONSTRAINT "children_household_id_households_id_fk" FOREIGN KEY ("household_id") REFERENCES "public"."households"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "sync_queue" ADD CONSTRAINT "sync_queue_household_id_households_id_fk" FOREIGN KEY ("household_id") REFERENCES "public"."households"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "sync_queue" ADD CONSTRAINT "sync_queue_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "vacation_entries" ADD CONSTRAINT "vacation_entries_vacation_id_vacations_id_fk" FOREIGN KEY ("vacation_id") REFERENCES "public"."vacations"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "vacation_entries" ADD CONSTRAINT "vacation_entries_created_by_user_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "vacation_entries" ADD CONSTRAINT "vacation_entries_category_id_categories_id_fk" FOREIGN KEY ("category_id") REFERENCES "public"."categories"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "vacations" ADD CONSTRAINT "vacations_household_id_households_id_fk" FOREIGN KEY ("household_id") REFERENCES "public"."households"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "children_household_id_idx" ON "children" USING btree ("household_id");--> statement-breakpoint
CREATE INDEX "sync_queue_household_id_idx" ON "sync_queue" USING btree ("household_id");--> statement-breakpoint
CREATE INDEX "vacation_entries_vacation_id_idx" ON "vacation_entries" USING btree ("vacation_id");--> statement-breakpoint
CREATE INDEX "vacations_household_id_idx" ON "vacations" USING btree ("household_id");--> statement-breakpoint
ALTER TABLE "transactions" ADD CONSTRAINT "transactions_child_id_children_id_fk" FOREIGN KEY ("child_id") REFERENCES "public"."children"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "transactions_scope_idx" ON "transactions" USING btree ("scope");--> statement-breakpoint
ALTER TABLE "transactions" DROP COLUMN "budget_context_id";--> statement-breakpoint
DROP TYPE "public"."budget_context_type";

View File

@@ -0,0 +1,29 @@
CREATE TABLE "debt_payments" (
"id" text PRIMARY KEY NOT NULL,
"debt_id" text NOT NULL,
"amount" numeric(12, 2) NOT NULL,
"date" date NOT NULL,
"note" text,
"linked_transaction_id" text,
"created_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "debts" (
"id" text PRIMARY KEY NOT NULL,
"household_id" text NOT NULL,
"user_id" text NOT NULL,
"label" text NOT NULL,
"creditor" text,
"total_amount" numeric(12, 2) NOT NULL,
"notes" text,
"created_at" timestamp DEFAULT now() NOT NULL,
"closed_at" timestamp
);
--> statement-breakpoint
ALTER TABLE "debt_payments" ADD CONSTRAINT "debt_payments_debt_id_debts_id_fk" FOREIGN KEY ("debt_id") REFERENCES "public"."debts"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "debt_payments" ADD CONSTRAINT "debt_payments_linked_transaction_id_transactions_id_fk" FOREIGN KEY ("linked_transaction_id") REFERENCES "public"."transactions"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "debts" ADD CONSTRAINT "debts_household_id_households_id_fk" FOREIGN KEY ("household_id") REFERENCES "public"."households"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "debts" ADD CONSTRAINT "debts_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "debt_payments_debt_id_idx" ON "debt_payments" USING btree ("debt_id");--> statement-breakpoint
CREATE INDEX "debts_household_id_idx" ON "debts" USING btree ("household_id");--> statement-breakpoint
CREATE INDEX "debts_user_id_idx" ON "debts" USING btree ("user_id");

View File

@@ -0,0 +1,2 @@
ALTER TABLE "debts" ADD COLUMN "creditor_user_id" text;--> statement-breakpoint
ALTER TABLE "debts" ADD CONSTRAINT "debts_creditor_user_id_user_id_fk" FOREIGN KEY ("creditor_user_id") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;

View File

@@ -0,0 +1,45 @@
CREATE TABLE "fixed_costs" (
"id" text PRIMARY KEY NOT NULL,
"household_id" text NOT NULL,
"scope" "transaction_scope" DEFAULT 'household' NOT NULL,
"child_id" text,
"category_id" text,
"label" text NOT NULL,
"amount" numeric(12, 2) NOT NULL,
"type" "transaction_type" DEFAULT 'expense' NOT NULL,
"is_active" boolean DEFAULT true NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "monthly_transfers" (
"id" text PRIMARY KEY NOT NULL,
"household_id" text NOT NULL,
"month" text NOT NULL,
"from_user_id" text NOT NULL,
"to_user_id" text NOT NULL,
"amount" numeric(12, 2) NOT NULL,
"note" text,
"created_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "transfer_line_items" (
"id" text PRIMARY KEY NOT NULL,
"household_id" text NOT NULL,
"label" text NOT NULL,
"amount" numeric(12, 2) NOT NULL,
"is_active" boolean DEFAULT true NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "fixed_costs" ADD CONSTRAINT "fixed_costs_household_id_households_id_fk" FOREIGN KEY ("household_id") REFERENCES "public"."households"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "fixed_costs" ADD CONSTRAINT "fixed_costs_child_id_children_id_fk" FOREIGN KEY ("child_id") REFERENCES "public"."children"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "fixed_costs" ADD CONSTRAINT "fixed_costs_category_id_categories_id_fk" FOREIGN KEY ("category_id") REFERENCES "public"."categories"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "monthly_transfers" ADD CONSTRAINT "monthly_transfers_household_id_households_id_fk" FOREIGN KEY ("household_id") REFERENCES "public"."households"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "monthly_transfers" ADD CONSTRAINT "monthly_transfers_from_user_id_user_id_fk" FOREIGN KEY ("from_user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "monthly_transfers" ADD CONSTRAINT "monthly_transfers_to_user_id_user_id_fk" FOREIGN KEY ("to_user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "transfer_line_items" ADD CONSTRAINT "transfer_line_items_household_id_households_id_fk" FOREIGN KEY ("household_id") REFERENCES "public"."households"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "fixed_costs_household_id_idx" ON "fixed_costs" USING btree ("household_id");--> statement-breakpoint
CREATE INDEX "fixed_costs_scope_idx" ON "fixed_costs" USING btree ("scope");--> statement-breakpoint
CREATE INDEX "monthly_transfers_household_id_idx" ON "monthly_transfers" USING btree ("household_id");--> statement-breakpoint
CREATE INDEX "monthly_transfers_month_idx" ON "monthly_transfers" USING btree ("month");--> statement-breakpoint
CREATE INDEX "transfer_line_items_household_id_idx" ON "transfer_line_items" USING btree ("household_id");

View File

@@ -0,0 +1,16 @@
CREATE TABLE "household_settings" (
"id" text PRIMARY KEY NOT NULL,
"household_id" text NOT NULL,
"owner_name" text DEFAULT 'Ich' NOT NULL,
"partner_name" text DEFAULT 'Partner' NOT NULL,
"user_share_percent" numeric(5, 2) DEFAULT '50' NOT NULL,
"monthly_budget" numeric(12, 2) DEFAULT '400' NOT NULL,
"currency" text DEFAULT 'EUR' NOT NULL,
"split_child_costs" boolean DEFAULT true NOT NULL,
"onboarding_complete" boolean DEFAULT false NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL,
CONSTRAINT "household_settings_household_id_unique" UNIQUE("household_id")
);
--> statement-breakpoint
ALTER TABLE "household_settings" ADD CONSTRAINT "household_settings_household_id_households_id_fk" FOREIGN KEY ("household_id") REFERENCES "public"."households"("id") ON DELETE cascade ON UPDATE no action;

View File

@@ -0,0 +1,17 @@
CREATE TABLE "month_status" (
"id" text PRIMARY KEY NOT NULL,
"household_id" text NOT NULL,
"month" text NOT NULL,
"status" text DEFAULT 'open' NOT NULL,
"closed_at" timestamp,
"closed_by" text,
"final_amount" numeric(12, 2),
"notes" text,
"final_transfer_id" text,
"created_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "month_status" ADD CONSTRAINT "month_status_household_id_households_id_fk" FOREIGN KEY ("household_id") REFERENCES "public"."households"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "month_status" ADD CONSTRAINT "month_status_closed_by_user_id_fk" FOREIGN KEY ("closed_by") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "month_status_household_id_idx" ON "month_status" USING btree ("household_id");--> statement-breakpoint
CREATE INDEX "month_status_month_idx" ON "month_status" USING btree ("month");

View File

@@ -0,0 +1 @@
CREATE UNIQUE INDEX "month_status_household_month_unique" ON "month_status" USING btree ("household_id","month");

View File

@@ -0,0 +1 @@
ALTER TABLE "household_settings" ADD COLUMN "language" text DEFAULT 'auto' NOT NULL;

View File

@@ -0,0 +1,14 @@
CREATE TABLE "shopping_items" (
"id" text PRIMARY KEY NOT NULL,
"household_id" text NOT NULL,
"label" text NOT NULL,
"quantity" text,
"added_by" text NOT NULL,
"checked_by" text,
"checked_at" text,
"sort_order" integer DEFAULT 0 NOT NULL,
"created_at" text NOT NULL
);
--> statement-breakpoint
ALTER TABLE "shopping_items" ADD CONSTRAINT "shopping_items_household_id_households_id_fk" FOREIGN KEY ("household_id") REFERENCES "public"."households"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "shopping_items_household_id_idx" ON "shopping_items" USING btree ("household_id");

View File

@@ -0,0 +1 @@
ALTER TABLE "household_settings" ADD COLUMN "payer_user_id" text;

View File

@@ -0,0 +1,31 @@
CREATE TABLE "trip_expenses" (
"id" text PRIMARY KEY NOT NULL,
"trip_id" text NOT NULL,
"household_id" text NOT NULL,
"label" text NOT NULL,
"amount" numeric(12, 2) NOT NULL,
"category" text DEFAULT 'sonstiges' NOT NULL,
"paid_by" text NOT NULL,
"date" text NOT NULL,
"note" text,
"created_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "trips" (
"id" text PRIMARY KEY NOT NULL,
"household_id" text NOT NULL,
"name" text NOT NULL,
"destination" text,
"budget" numeric(12, 2) NOT NULL,
"start_date" text NOT NULL,
"end_date" text NOT NULL,
"status" text DEFAULT 'active' NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "trip_expenses" ADD CONSTRAINT "trip_expenses_trip_id_trips_id_fk" FOREIGN KEY ("trip_id") REFERENCES "public"."trips"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "trips" ADD CONSTRAINT "trips_household_id_households_id_fk" FOREIGN KEY ("household_id") REFERENCES "public"."households"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "trip_expenses_trip_id_idx" ON "trip_expenses" USING btree ("trip_id");--> statement-breakpoint
CREATE INDEX "trip_expenses_household_id_idx" ON "trip_expenses" USING btree ("household_id");--> statement-breakpoint
CREATE INDEX "trips_household_id_idx" ON "trips" USING btree ("household_id");

View File

@@ -0,0 +1,4 @@
ALTER TABLE "trips" ADD COLUMN "settlement_from_user_id" text;--> statement-breakpoint
ALTER TABLE "trips" ADD COLUMN "settlement_to_user_id" text;--> statement-breakpoint
ALTER TABLE "trips" ADD COLUMN "settlement_amount" numeric(12, 2);--> statement-breakpoint
ALTER TABLE "trips" ADD COLUMN "settled_at" text;

View File

@@ -0,0 +1,11 @@
CREATE TABLE "household_invitations" (
"id" text PRIMARY KEY NOT NULL,
"household_id" text NOT NULL,
"code" text NOT NULL,
"created_by" text NOT NULL,
"expires_at" text NOT NULL,
"used_at" text,
"used_by" text,
"created_at" text NOT NULL,
CONSTRAINT "household_invitations_code_unique" UNIQUE("code")
);

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,111 @@
{
"version": "7",
"dialect": "postgresql",
"entries": [
{
"idx": 0,
"version": "7",
"when": 1773381800099,
"tag": "0000_overjoyed_stingray",
"breakpoints": true
},
{
"idx": 1,
"version": "7",
"when": 1773383654638,
"tag": "0001_tiresome_vector",
"breakpoints": true
},
{
"idx": 2,
"version": "7",
"when": 1773416364202,
"tag": "0002_flawless_sasquatch",
"breakpoints": true
},
{
"idx": 3,
"version": "7",
"when": 1773419350413,
"tag": "0003_chilly_the_order",
"breakpoints": true
},
{
"idx": 4,
"version": "7",
"when": 1773420670722,
"tag": "0004_silly_wiccan",
"breakpoints": true
},
{
"idx": 5,
"version": "7",
"when": 1773421166761,
"tag": "0005_absurd_hulk",
"breakpoints": true
},
{
"idx": 6,
"version": "7",
"when": 1773665770861,
"tag": "0006_smooth_shiver_man",
"breakpoints": true
},
{
"idx": 7,
"version": "7",
"when": 1773666811100,
"tag": "0007_tense_earthquake",
"breakpoints": true
},
{
"idx": 8,
"version": "7",
"when": 1773666865784,
"tag": "0008_public_rachel_grey",
"breakpoints": true
},
{
"idx": 9,
"version": "7",
"when": 1773676918621,
"tag": "0009_skinny_thing",
"breakpoints": true
},
{
"idx": 10,
"version": "7",
"when": 1773730950919,
"tag": "0010_redundant_mongu",
"breakpoints": true
},
{
"idx": 11,
"version": "7",
"when": 1773903012939,
"tag": "0011_luxuriant_selene",
"breakpoints": true
},
{
"idx": 12,
"version": "7",
"when": 1773903947726,
"tag": "0012_busy_vulture",
"breakpoints": true
},
{
"idx": 13,
"version": "7",
"when": 1773906551276,
"tag": "0013_dizzy_lionheart",
"breakpoints": true
},
{
"idx": 14,
"version": "7",
"when": 1773996772794,
"tag": "0014_nostalgic_baron_strucker",
"breakpoints": true
}
]
}

View File

@@ -0,0 +1,529 @@
import { relations } from "drizzle-orm";
import {
boolean,
date,
index,
integer,
jsonb,
numeric,
pgEnum,
pgTable,
text,
timestamp,
uniqueIndex,
} from "drizzle-orm/pg-core";
import { user } from "./auth";
// Enums
export const subscriptionPlanEnum = pgEnum("subscription_plan", ["free", "pro", "family"]);
export const subscriptionStatusEnum = pgEnum("subscription_status", ["active", "canceled", "past_due"]);
export const categoryTypeEnum = pgEnum("category_type", ["income", "expense"]);
export const transactionTypeEnum = pgEnum("transaction_type", ["income", "expense"]);
export const transactionScopeEnum = pgEnum("transaction_scope", ["household", "private", "child"]);
export const syncOperationEnum = pgEnum("sync_operation", ["create", "update", "delete"]);
// households table (tenant)
export const households = pgTable("households", {
id: text("id").primaryKey().$defaultFn(() => crypto.randomUUID()),
name: text("name").notNull(),
ownerId: text("owner_id").notNull().references(() => user.id, { onDelete: "cascade" }),
createdAt: timestamp("created_at").defaultNow().notNull(),
});
// subscription_plans table
export const subscriptionPlans = pgTable(
"subscription_plans",
{
id: text("id").primaryKey().$defaultFn(() => crypto.randomUUID()),
householdId: text("household_id").notNull().references(() => households.id, { onDelete: "cascade" }),
plan: subscriptionPlanEnum("plan").notNull().default("free"),
status: subscriptionStatusEnum("status").notNull().default("active"),
stripeCustomerId: text("stripe_customer_id"),
stripeSubscriptionId: text("stripe_subscription_id"),
currentPeriodStart: timestamp("current_period_start"),
currentPeriodEnd: timestamp("current_period_end"),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at").defaultNow().$onUpdate(() => new Date()).notNull(),
},
(table) => [index("subscription_plans_household_id_idx").on(table.householdId)],
);
// categories table
export const categories = pgTable(
"categories",
{
id: text("id").primaryKey().$defaultFn(() => crypto.randomUUID()),
householdId: text("household_id").notNull().references(() => households.id, { onDelete: "cascade" }),
name: text("name").notNull(),
icon: text("icon"),
color: text("color"),
type: categoryTypeEnum("type").notNull(),
isDefault: boolean("is_default").notNull().default(false),
createdAt: timestamp("created_at").defaultNow().notNull(),
},
(table) => [index("categories_household_id_idx").on(table.householdId)],
);
// children table
export const children = pgTable(
"children",
{
id: text("id").primaryKey().$defaultFn(() => crypto.randomUUID()),
householdId: text("household_id").notNull().references(() => households.id, { onDelete: "cascade" }),
name: text("name").notNull(),
color: text("color").notNull().default("#378ADD"),
createdAt: timestamp("created_at").defaultNow().notNull(),
},
(table) => [index("children_household_id_idx").on(table.householdId)],
);
// transactions table
export const transactions = pgTable(
"transactions",
{
id: text("id").primaryKey().$defaultFn(() => crypto.randomUUID()),
householdId: text("household_id").notNull().references(() => households.id, { onDelete: "cascade" }),
userId: text("user_id").notNull().references(() => user.id, { onDelete: "cascade" }),
categoryId: text("category_id").references(() => categories.id, { onDelete: "set null" }),
childId: text("child_id").references(() => children.id, { onDelete: "set null" }),
scope: transactionScopeEnum("scope").notNull().default("household"),
amount: numeric("amount", { precision: 12, scale: 2 }).notNull(),
currency: text("currency").notNull().default("EUR"),
type: transactionTypeEnum("type").notNull(),
isFixed: boolean("is_fixed").notNull().default(false),
isCarryOver: boolean("is_carry_over").notNull().default(false),
description: text("description"),
merchant: text("merchant"),
date: date("date").notNull(),
receiptImageUrl: text("receipt_image_url"),
syncedAt: timestamp("synced_at"),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at").defaultNow().$onUpdate(() => new Date()).notNull(),
},
(table) => [
index("transactions_household_id_idx").on(table.householdId),
index("transactions_user_id_idx").on(table.userId),
index("transactions_date_idx").on(table.date),
index("transactions_scope_idx").on(table.scope),
],
);
// vacations table (replaces budget_contexts)
export const vacations = pgTable(
"vacations",
{
id: text("id").primaryKey().$defaultFn(() => crypto.randomUUID()),
householdId: text("household_id").notNull().references(() => households.id, { onDelete: "cascade" }),
name: text("name").notNull(),
budget: numeric("budget", { precision: 12, scale: 2 }),
currency: text("currency").notNull().default("EUR"),
startsOn: date("starts_on"),
endsOn: date("ends_on"),
createdAt: timestamp("created_at").defaultNow().notNull(),
},
(table) => [index("vacations_household_id_idx").on(table.householdId)],
);
// vacation_entries table
export const vacationEntries = pgTable(
"vacation_entries",
{
id: text("id").primaryKey().$defaultFn(() => crypto.randomUUID()),
vacationId: text("vacation_id").notNull().references(() => vacations.id, { onDelete: "cascade" }),
createdBy: text("created_by").notNull().references(() => user.id, { onDelete: "cascade" }),
categoryId: text("category_id").references(() => categories.id, { onDelete: "set null" }),
amount: numeric("amount", { precision: 12, scale: 2 }).notNull(),
currency: text("currency").notNull().default("EUR"),
description: text("description"),
date: date("date").notNull(),
syncedAt: timestamp("synced_at"),
createdAt: timestamp("created_at").defaultNow().notNull(),
},
(table) => [index("vacation_entries_vacation_id_idx").on(table.vacationId)],
);
// shopping_lists table
export const shoppingLists = pgTable(
"shopping_lists",
{
id: text("id").primaryKey().$defaultFn(() => crypto.randomUUID()),
householdId: text("household_id").notNull().references(() => households.id, { onDelete: "cascade" }),
name: text("name").notNull(),
isActive: boolean("is_active").notNull().default(true),
createdAt: timestamp("created_at").defaultNow().notNull(),
},
(table) => [index("shopping_lists_household_id_idx").on(table.householdId)],
);
// shopping_list_items table
export const shoppingListItems = pgTable(
"shopping_list_items",
{
id: text("id").primaryKey().$defaultFn(() => crypto.randomUUID()),
listId: text("list_id").notNull().references(() => shoppingLists.id, { onDelete: "cascade" }),
addedByUserId: text("added_by_user_id").notNull().references(() => user.id, { onDelete: "cascade" }),
name: text("name").notNull(),
quantity: numeric("quantity", { precision: 10, scale: 2 }),
unit: text("unit"),
isChecked: boolean("is_checked").notNull().default(false),
checkedByUserId: text("checked_by_user_id").references(() => user.id, { onDelete: "set null" }),
checkedAt: timestamp("checked_at"),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at").defaultNow().$onUpdate(() => new Date()).notNull(),
},
(table) => [index("shopping_list_items_list_id_idx").on(table.listId)],
);
// shopping_items table — flat, household-scoped real-time shopping list
export const shoppingItems = pgTable(
"shopping_items",
{
id: text("id").primaryKey().$defaultFn(() => crypto.randomUUID()),
householdId: text("household_id").notNull().references(() => households.id, { onDelete: "cascade" }),
label: text("label").notNull(),
quantity: text("quantity"),
addedBy: text("added_by").notNull(),
checkedBy: text("checked_by"),
checkedAt: text("checked_at"),
sortOrder: integer("sort_order").notNull().default(0),
createdAt: text("created_at").notNull().$defaultFn(() => new Date().toISOString()),
},
(table) => [index("shopping_items_household_id_idx").on(table.householdId)],
);
// savings_goals table
export const savingsGoals = pgTable(
"savings_goals",
{
id: text("id").primaryKey().$defaultFn(() => crypto.randomUUID()),
householdId: text("household_id").notNull().references(() => households.id, { onDelete: "cascade" }),
name: text("name").notNull(),
targetAmount: numeric("target_amount", { precision: 12, scale: 2 }).notNull(),
currentAmount: numeric("current_amount", { precision: 12, scale: 2 }).notNull().default("0"),
targetDate: date("target_date"),
allocationPercent: numeric("allocation_percent", { precision: 5, scale: 2 }),
createdAt: timestamp("created_at").defaultNow().notNull(),
},
(table) => [index("savings_goals_household_id_idx").on(table.householdId)],
);
// fixed_costs table — recurring transaction templates
export const fixedCosts = pgTable(
"fixed_costs",
{
id: text("id").primaryKey().$defaultFn(() => crypto.randomUUID()),
householdId: text("household_id").notNull().references(() => households.id, { onDelete: "cascade" }),
scope: transactionScopeEnum("scope").notNull().default("household"),
childId: text("child_id").references(() => children.id, { onDelete: "set null" }),
categoryId: text("category_id").references(() => categories.id, { onDelete: "set null" }),
label: text("label").notNull(),
amount: numeric("amount", { precision: 12, scale: 2 }).notNull(),
type: transactionTypeEnum("type").notNull().default("expense"),
isActive: boolean("is_active").notNull().default(true),
createdAt: timestamp("created_at").defaultNow().notNull(),
},
(table) => [
index("fixed_costs_household_id_idx").on(table.householdId),
index("fixed_costs_scope_idx").on(table.scope),
],
);
// monthly_transfers table — recorded money transfers between members
export const monthlyTransfers = pgTable(
"monthly_transfers",
{
id: text("id").primaryKey().$defaultFn(() => crypto.randomUUID()),
householdId: text("household_id").notNull().references(() => households.id, { onDelete: "cascade" }),
month: text("month").notNull(), // "YYYY-MM"
fromUserId: text("from_user_id").notNull().references(() => user.id, { onDelete: "cascade" }),
toUserId: text("to_user_id").notNull().references(() => user.id, { onDelete: "cascade" }),
amount: numeric("amount", { precision: 12, scale: 2 }).notNull(),
note: text("note"),
createdAt: timestamp("created_at").defaultNow().notNull(),
},
(table) => [
index("monthly_transfers_household_id_idx").on(table.householdId),
index("monthly_transfers_month_idx").on(table.month),
],
);
// transfer_line_items table — fixed additions to settlement calculation
export const transferLineItems = pgTable(
"transfer_line_items",
{
id: text("id").primaryKey().$defaultFn(() => crypto.randomUUID()),
householdId: text("household_id").notNull().references(() => households.id, { onDelete: "cascade" }),
label: text("label").notNull(),
amount: numeric("amount", { precision: 12, scale: 2 }).notNull(),
isActive: boolean("is_active").notNull().default(true),
createdAt: timestamp("created_at").defaultNow().notNull(),
},
(table) => [index("transfer_line_items_household_id_idx").on(table.householdId)],
);
// month_status table
export const monthStatus = pgTable(
"month_status",
{
id: text("id").primaryKey().$defaultFn(() => crypto.randomUUID()),
householdId: text("household_id").notNull().references(() => households.id, { onDelete: "cascade" }),
month: text("month").notNull(), // 'YYYY-MM'
status: text("status", { enum: ["open", "closed"] }).notNull().default("open"),
closedAt: timestamp("closed_at"),
closedBy: text("closed_by").references(() => user.id, { onDelete: "set null" }),
finalAmount: numeric("final_amount", { precision: 12, scale: 2 }),
notes: text("notes"),
finalTransferId: text("final_transfer_id"), // references monthly_transfers.id (no FK to avoid circular)
createdAt: timestamp("created_at").defaultNow().notNull(),
},
(table) => [
index("month_status_household_id_idx").on(table.householdId),
index("month_status_month_idx").on(table.month),
uniqueIndex("month_status_household_month_unique").on(table.householdId, table.month),
],
);
// household_settings table
export const householdSettings = pgTable("household_settings", {
id: text("id").primaryKey().$defaultFn(() => crypto.randomUUID()),
householdId: text("household_id").notNull().unique().references(() => households.id, { onDelete: "cascade" }),
ownerName: text("owner_name").notNull().default("Ich"),
partnerName: text("partner_name").notNull().default("Partner"),
userSharePercent: numeric("user_share_percent", { precision: 5, scale: 2 }).notNull().default("50"),
monthlyBudget: numeric("monthly_budget", { precision: 12, scale: 2 }).notNull().default("400"),
currency: text("currency").notNull().default("EUR"),
splitChildCosts: boolean("split_child_costs").notNull().default(true),
payerUserId: text("payer_user_id"),
onboardingComplete: boolean("onboarding_complete").notNull().default(false),
language: text("language").notNull().default("auto"),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at").defaultNow().$onUpdate(() => new Date()).notNull(),
});
// debts table
export const debts = pgTable(
"debts",
{
id: text("id").primaryKey().$defaultFn(() => crypto.randomUUID()),
householdId: text("household_id").notNull().references(() => households.id, { onDelete: "cascade" }),
userId: text("user_id").notNull().references(() => user.id, { onDelete: "cascade" }),
creditorUserId: text("creditor_user_id").references(() => user.id, { onDelete: "set null" }),
label: text("label").notNull(),
creditor: text("creditor"),
totalAmount: numeric("total_amount", { precision: 12, scale: 2 }).notNull(),
notes: text("notes"),
createdAt: timestamp("created_at").defaultNow().notNull(),
closedAt: timestamp("closed_at"),
},
(table) => [
index("debts_household_id_idx").on(table.householdId),
index("debts_user_id_idx").on(table.userId),
],
);
// debt_payments table
export const debtPayments = pgTable(
"debt_payments",
{
id: text("id").primaryKey().$defaultFn(() => crypto.randomUUID()),
debtId: text("debt_id").notNull().references(() => debts.id, { onDelete: "cascade" }),
amount: numeric("amount", { precision: 12, scale: 2 }).notNull(),
date: date("date").notNull(),
note: text("note"),
linkedTransactionId: text("linked_transaction_id").references(() => transactions.id, { onDelete: "set null" }),
createdAt: timestamp("created_at").defaultNow().notNull(),
},
(table) => [index("debt_payments_debt_id_idx").on(table.debtId)],
);
// sync_queue table (Phase 2 — offline support)
export const syncQueue = pgTable(
"sync_queue",
{
id: text("id").primaryKey().$defaultFn(() => crypto.randomUUID()),
householdId: text("household_id").notNull().references(() => households.id, { onDelete: "cascade" }),
userId: text("user_id").notNull().references(() => user.id, { onDelete: "cascade" }),
operation: syncOperationEnum("operation").notNull(),
tableName: text("table_name").notNull(),
payload: jsonb("payload").notNull(),
attempts: numeric("attempts").notNull().default("0"),
lastError: text("last_error"),
createdAt: timestamp("created_at").defaultNow().notNull(),
},
(table) => [index("sync_queue_household_id_idx").on(table.householdId)],
);
// trips table
export const trips = pgTable(
"trips",
{
id: text("id").primaryKey().$defaultFn(() => crypto.randomUUID()),
householdId: text("household_id").notNull().references(() => households.id, { onDelete: "cascade" }),
name: text("name").notNull(),
destination: text("destination"),
budget: numeric("budget", { precision: 12, scale: 2 }).notNull(),
startDate: text("start_date").notNull(),
endDate: text("end_date").notNull(),
status: text("status").notNull().default("active"),
settlementFromUserId: text("settlement_from_user_id"),
settlementToUserId: text("settlement_to_user_id"),
settlementAmount: numeric("settlement_amount", { precision: 12, scale: 2 }),
settledAt: text("settled_at"),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at").defaultNow().$onUpdate(() => new Date()).notNull(),
},
(table) => [index("trips_household_id_idx").on(table.householdId)],
);
// trip_expenses table
export const tripExpenses = pgTable(
"trip_expenses",
{
id: text("id").primaryKey().$defaultFn(() => crypto.randomUUID()),
tripId: text("trip_id").notNull().references(() => trips.id, { onDelete: "cascade" }),
householdId: text("household_id").notNull(),
label: text("label").notNull(),
amount: numeric("amount", { precision: 12, scale: 2 }).notNull(),
category: text("category").notNull().default("sonstiges"),
paidBy: text("paid_by").notNull(),
date: text("date").notNull(),
note: text("note"),
createdAt: timestamp("created_at").defaultNow().notNull(),
},
(table) => [
index("trip_expenses_trip_id_idx").on(table.tripId),
index("trip_expenses_household_id_idx").on(table.householdId),
],
);
// household_invitations table — short-lived join codes
export const householdInvitations = pgTable("household_invitations", {
id: text("id").primaryKey(),
householdId: text("household_id").notNull(),
code: text("code").notNull().unique(),
createdBy: text("created_by").notNull(),
expiresAt: text("expires_at").notNull(),
usedAt: text("used_at"),
usedBy: text("used_by"),
createdAt: text("created_at").notNull(),
});
// Relations
export const householdsRelations = relations(households, ({ one, many }) => ({
owner: one(user, { fields: [households.ownerId], references: [user.id] }),
subscriptionPlan: one(subscriptionPlans, { fields: [households.id], references: [subscriptionPlans.householdId] }),
categories: many(categories),
children: many(children),
transactions: many(transactions),
vacations: many(vacations),
shoppingLists: many(shoppingLists),
savingsGoals: many(savingsGoals),
debts: many(debts),
fixedCosts: many(fixedCosts),
settings: one(householdSettings, { fields: [households.id], references: [householdSettings.householdId] }),
monthlyTransfers: many(monthlyTransfers),
transferLineItems: many(transferLineItems),
trips: many(trips),
}));
export const subscriptionPlansRelations = relations(subscriptionPlans, ({ one }) => ({
household: one(households, { fields: [subscriptionPlans.householdId], references: [households.id] }),
}));
export const householdSettingsRelations = relations(householdSettings, ({ one }) => ({
household: one(households, { fields: [householdSettings.householdId], references: [households.id] }),
}));
export const monthStatusRelations = relations(monthStatus, ({ one }) => ({
household: one(households, { fields: [monthStatus.householdId], references: [households.id] }),
closedByUser: one(user, { fields: [monthStatus.closedBy], references: [user.id] }),
}));
export const categoriesRelations = relations(categories, ({ one, many }) => ({
household: one(households, { fields: [categories.householdId], references: [households.id] }),
transactions: many(transactions),
vacationEntries: many(vacationEntries),
}));
export const childrenRelations = relations(children, ({ one, many }) => ({
household: one(households, { fields: [children.householdId], references: [households.id] }),
transactions: many(transactions),
}));
export const transactionsRelations = relations(transactions, ({ one }) => ({
household: one(households, { fields: [transactions.householdId], references: [households.id] }),
user: one(user, { fields: [transactions.userId], references: [user.id] }),
category: one(categories, { fields: [transactions.categoryId], references: [categories.id] }),
child: one(children, { fields: [transactions.childId], references: [children.id] }),
}));
export const vacationsRelations = relations(vacations, ({ one, many }) => ({
household: one(households, { fields: [vacations.householdId], references: [households.id] }),
entries: many(vacationEntries),
}));
export const vacationEntriesRelations = relations(vacationEntries, ({ one }) => ({
vacation: one(vacations, { fields: [vacationEntries.vacationId], references: [vacations.id] }),
createdByUser: one(user, { fields: [vacationEntries.createdBy], references: [user.id] }),
category: one(categories, { fields: [vacationEntries.categoryId], references: [categories.id] }),
}));
export const shoppingListsRelations = relations(shoppingLists, ({ one, many }) => ({
household: one(households, { fields: [shoppingLists.householdId], references: [households.id] }),
items: many(shoppingListItems),
}));
export const shoppingListItemsRelations = relations(shoppingListItems, ({ one }) => ({
list: one(shoppingLists, { fields: [shoppingListItems.listId], references: [shoppingLists.id] }),
addedByUser: one(user, { fields: [shoppingListItems.addedByUserId], references: [user.id] }),
checkedByUser: one(user, { fields: [shoppingListItems.checkedByUserId], references: [user.id] }),
}));
export const savingsGoalsRelations = relations(savingsGoals, ({ one }) => ({
household: one(households, { fields: [savingsGoals.householdId], references: [households.id] }),
}));
export const shoppingItemsRelations = relations(shoppingItems, ({ one }) => ({
household: one(households, { fields: [shoppingItems.householdId], references: [households.id] }),
}));
export const fixedCostsRelations = relations(fixedCosts, ({ one }) => ({
household: one(households, { fields: [fixedCosts.householdId], references: [households.id] }),
category: one(categories, { fields: [fixedCosts.categoryId], references: [categories.id] }),
child: one(children, { fields: [fixedCosts.childId], references: [children.id] }),
}));
export const monthlyTransfersRelations = relations(monthlyTransfers, ({ one }) => ({
household: one(households, { fields: [monthlyTransfers.householdId], references: [households.id] }),
fromUser: one(user, { fields: [monthlyTransfers.fromUserId], references: [user.id] }),
toUser: one(user, { fields: [monthlyTransfers.toUserId], references: [user.id] }),
}));
export const transferLineItemsRelations = relations(transferLineItems, ({ one }) => ({
household: one(households, { fields: [transferLineItems.householdId], references: [households.id] }),
}));
export const debtsRelations = relations(debts, ({ one, many }) => ({
household: one(households, { fields: [debts.householdId], references: [households.id] }),
user: one(user, { fields: [debts.userId], references: [user.id] }),
payments: many(debtPayments),
}));
export const debtPaymentsRelations = relations(debtPayments, ({ one }) => ({
debt: one(debts, { fields: [debtPayments.debtId], references: [debts.id] }),
linkedTransaction: one(transactions, { fields: [debtPayments.linkedTransactionId], references: [transactions.id] }),
}));
export const syncQueueRelations = relations(syncQueue, ({ one }) => ({
household: one(households, { fields: [syncQueue.householdId], references: [households.id] }),
user: one(user, { fields: [syncQueue.userId], references: [user.id] }),
}));
export const tripsRelations = relations(trips, ({ one, many }) => ({
household: one(households, { fields: [trips.householdId], references: [households.id] }),
expenses: many(tripExpenses),
}));
export const tripExpensesRelations = relations(tripExpenses, ({ one }) => ({
trip: one(trips, { fields: [tripExpenses.tripId], references: [trips.id] }),
}));

View File

@@ -1,5 +1,12 @@
import { relations } from "drizzle-orm";
import { pgTable, text, timestamp, boolean, index } from "drizzle-orm/pg-core";
import {
boolean,
index,
pgTable,
text,
timestamp,
uniqueIndex,
} from "drizzle-orm/pg-core";
export const user = pgTable("user", {
id: text("id").primaryKey(),
@@ -10,7 +17,7 @@ export const user = pgTable("user", {
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at")
.defaultNow()
.$onUpdate(() => /* @__PURE__ */ new Date())
.$onUpdate(() => new Date())
.notNull(),
});
@@ -22,13 +29,15 @@ export const session = pgTable(
token: text("token").notNull().unique(),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at")
.$onUpdate(() => /* @__PURE__ */ new Date())
.$onUpdate(() => new Date())
.notNull(),
ipAddress: text("ip_address"),
userAgent: text("user_agent"),
userId: text("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
// Added by organization plugin — tracks active household
activeOrganizationId: text("active_organization_id"),
},
(table) => [index("session_userId_idx").on(table.userId)],
);
@@ -51,7 +60,7 @@ export const account = pgTable(
password: text("password"),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at")
.$onUpdate(() => /* @__PURE__ */ new Date())
.$onUpdate(() => new Date())
.notNull(),
},
(table) => [index("account_userId_idx").on(table.userId)],
@@ -67,15 +76,73 @@ export const verification = pgTable(
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at")
.defaultNow()
.$onUpdate(() => /* @__PURE__ */ new Date())
.$onUpdate(() => new Date())
.notNull(),
},
(table) => [index("verification_identifier_idx").on(table.identifier)],
);
// Organization plugin tables — Household = Organization in Better Auth
export const organization = pgTable(
"organization",
{
id: text("id").primaryKey(),
name: text("name").notNull(),
slug: text("slug").notNull().unique(),
logo: text("logo"),
createdAt: timestamp("created_at").notNull(),
metadata: text("metadata"),
},
(table) => [uniqueIndex("organization_slug_uidx").on(table.slug)],
);
export const member = pgTable(
"member",
{
id: text("id").primaryKey(),
organizationId: text("organization_id")
.notNull()
.references(() => organization.id, { onDelete: "cascade" }),
userId: text("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
role: text("role").default("member").notNull(),
createdAt: timestamp("created_at").notNull(),
},
(table) => [
index("member_organizationId_idx").on(table.organizationId),
index("member_userId_idx").on(table.userId),
],
);
export const invitation = pgTable(
"invitation",
{
id: text("id").primaryKey(),
organizationId: text("organization_id")
.notNull()
.references(() => organization.id, { onDelete: "cascade" }),
email: text("email").notNull(),
role: text("role"),
status: text("status").default("pending").notNull(),
expiresAt: timestamp("expires_at").notNull(),
createdAt: timestamp("created_at").defaultNow().notNull(),
inviterId: text("inviter_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
},
(table) => [
index("invitation_organizationId_idx").on(table.organizationId),
index("invitation_email_idx").on(table.email),
],
);
// Relations
export const userRelations = relations(user, ({ many }) => ({
sessions: many(session),
accounts: many(account),
members: many(member),
invitations: many(invitation),
}));
export const sessionRelations = relations(session, ({ one }) => ({
@@ -91,3 +158,30 @@ export const accountRelations = relations(account, ({ one }) => ({
references: [user.id],
}),
}));
export const organizationRelations = relations(organization, ({ many }) => ({
members: many(member),
invitations: many(invitation),
}));
export const memberRelations = relations(member, ({ one }) => ({
organization: one(organization, {
fields: [member.organizationId],
references: [organization.id],
}),
user: one(user, {
fields: [member.userId],
references: [user.id],
}),
}));
export const invitationRelations = relations(invitation, ({ one }) => ({
organization: one(organization, {
fields: [invitation.organizationId],
references: [organization.id],
}),
user: one(user, {
fields: [invitation.inviterId],
references: [user.id],
}),
}));

View File

@@ -1,2 +1,2 @@
export * from "./auth";
export {};
export * from "./app";

View File

@@ -9,6 +9,16 @@ export const env = createEnv({
BETTER_AUTH_URL: z.url(),
CORS_ORIGIN: z.url(),
NODE_ENV: z.enum(["development", "production", "test"]).default("development"),
APPLE_CLIENT_ID: z.string().optional(),
APPLE_TEAM_ID: z.string().optional(),
APPLE_KEY_ID: z.string().optional(),
APPLE_PRIVATE_KEY: z.string().optional(),
MOBILE_APP_SCHEME: z.string().default("haushaltsApp://"),
SMTP_HOST: z.string().default("localhost"),
SMTP_PORT: z.coerce.number().default(1025),
SMTP_USER: z.string().optional(),
SMTP_PASSWORD: z.string().optional(),
SMTP_FROM: z.string().default("noreply@haushaltsapp.local"),
},
runtimeEnv: process.env,
emptyStringAsUndefined: true,

View File

@@ -0,0 +1,24 @@
{
"name": "@haushaltsApp/shared",
"version": "0.0.0",
"private": true,
"type": "module",
"exports": {
"./schemas/transaction": {
"default": "./src/schemas/transaction.schema.ts"
},
"./schemas/trips": {
"default": "./src/schemas/trips.schema.ts"
},
"./schemas/*": "./src/schemas/*.ts",
"./types": "./src/types/index.ts",
"./constants/*": "./src/constants/*.ts"
},
"dependencies": {
"zod": "catalog:"
},
"devDependencies": {
"@haushaltsApp/config": "workspace:*",
"typescript": "^5"
}
}

View File

@@ -0,0 +1,23 @@
export const PLAN_FEATURES = {
free: {
maxHouseholdMembers: 2,
ocr: false,
vacationBudgets: 1,
realtimeSync: false,
},
pro: {
maxHouseholdMembers: 5,
ocr: true,
vacationBudgets: 999,
realtimeSync: true,
},
family: {
maxHouseholdMembers: 999,
ocr: true,
vacationBudgets: 999,
realtimeSync: true,
},
} as const;
export type PlanType = keyof typeof PLAN_FEATURES;
export type PlanFeatures = (typeof PLAN_FEATURES)[PlanType];

View File

@@ -0,0 +1,15 @@
import { z } from "zod";
export const signUpSchema = z.object({
name: z.string().min(2).max(50),
email: z.string().email(),
password: z.string().min(8).max(100),
});
export const signInSchema = z.object({
email: z.string().email(),
password: z.string().min(1),
});
export type SignUpInput = z.infer<typeof signUpSchema>;
export type SignInInput = z.infer<typeof signInSchema>;

View File

@@ -0,0 +1,11 @@
import { z } from "zod";
export const CreateChildSchema = z.object({
name: z.string().min(1).max(100),
color: z.string().regex(/^#[0-9a-fA-F]{6}$/).default("#378ADD"),
});
export const UpdateChildSchema = CreateChildSchema.partial();
export type CreateChildInput = z.infer<typeof CreateChildSchema>;
export type UpdateChildInput = z.infer<typeof UpdateChildSchema>;

View File

@@ -0,0 +1,19 @@
import { z } from "zod";
export const CreateDebtSchema = z.object({
label: z.string().min(1).max(255),
creditorUserId: z.string().min(1).optional(), // internal household member
creditor: z.string().max(255).optional(), // free-text fallback
totalAmount: z.number().positive("Betrag muss positiv sein"),
notes: z.string().max(1000).optional(),
});
export const CreateDebtPaymentSchema = z.object({
debtId: z.string().min(1),
amount: z.number().positive("Betrag muss positiv sein"),
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
note: z.string().max(255).optional(),
});
export type CreateDebtInput = z.infer<typeof CreateDebtSchema>;
export type CreateDebtPaymentInput = z.infer<typeof CreateDebtPaymentSchema>;

View File

@@ -0,0 +1,41 @@
import { z } from "zod";
export const CreateFixedCostSchema = z.object({
scope: z.enum(["household", "private", "child"]).default("household"),
childId: z.string().min(1).optional(),
categoryId: z.string().min(1).optional(),
label: z.string().min(1).max(255),
amount: z.number().positive(),
type: z.enum(["income", "expense"]).default("expense"),
});
export const UpdateFixedCostSchema = z.object({
label: z.string().min(1).max(255).optional(),
amount: z.number().positive().optional(),
categoryId: z.string().min(1).nullable().optional(),
isActive: z.boolean().optional(),
});
export const CreateTransferLineItemSchema = z.object({
label: z.string().min(1).max(255),
amount: z.number().positive(),
});
export const UpdateTransferLineItemSchema = z.object({
label: z.string().min(1).max(255).optional(),
amount: z.number().positive().optional(),
isActive: z.boolean().optional(),
});
export const CreateMonthlyTransferSchema = z.object({
month: z.string().regex(/^\d{4}-\d{2}$/),
toUserId: z.string().min(1),
amount: z.number().positive(),
note: z.string().max(255).optional(),
});
export type CreateFixedCostInput = z.infer<typeof CreateFixedCostSchema>;
export type UpdateFixedCostInput = z.infer<typeof UpdateFixedCostSchema>;
export type CreateTransferLineItemInput = z.infer<typeof CreateTransferLineItemSchema>;
export type UpdateTransferLineItemInput = z.infer<typeof UpdateTransferLineItemSchema>;
export type CreateMonthlyTransferInput = z.infer<typeof CreateMonthlyTransferSchema>;

View File

@@ -0,0 +1,15 @@
import { z } from "zod";
export const UpdateHouseholdSettingsSchema = z.object({
ownerName: z.string().min(1).max(50).optional(),
partnerName: z.string().min(1).max(50).optional(),
userSharePercent: z.number().min(10).max(100).optional(),
monthlyBudget: z.number().min(0).optional(),
currency: z.string().length(3).optional(),
splitChildCosts: z.boolean().optional(),
payerUserId: z.string().nullable().optional(),
onboardingComplete: z.boolean().optional(),
language: z.string().optional(),
});
export type UpdateHouseholdSettingsInput = z.infer<typeof UpdateHouseholdSettingsSchema>;

View File

@@ -0,0 +1,10 @@
import { z } from "zod";
export const createHouseholdSchema = z.object({
name: z.string().min(1).max(100),
});
export const updateHouseholdSchema = createHouseholdSchema.partial();
export type CreateHouseholdInput = z.infer<typeof createHouseholdSchema>;
export type UpdateHouseholdInput = z.infer<typeof updateHouseholdSchema>;

View File

@@ -0,0 +1,7 @@
import { z } from "zod";
export const joinWithCodeSchema = z.object({
code: z.string().length(6),
});
export type JoinWithCodeInput = z.infer<typeof joinWithCodeSchema>;

View File

@@ -0,0 +1,8 @@
import { z } from "zod";
export const scanReceiptSchema = z.object({
imageBase64: z.string().min(1),
mimeType: z.enum(["image/jpeg", "image/png"]).default("image/jpeg"),
});
export type ScanReceiptInput = z.infer<typeof scanReceiptSchema>;

View File

@@ -0,0 +1,24 @@
import { z } from "zod";
export const createShoppingListSchema = z.object({
householdId: z.string().min(1),
name: z.string().min(1).max(100),
});
export const createShoppingListItemSchema = z.object({
listId: z.string().min(1),
name: z.string().min(1).max(200),
quantity: z.string().optional(),
unit: z.string().max(20).optional(),
});
export const updateShoppingListItemSchema = z.object({
name: z.string().min(1).max(200).optional(),
quantity: z.string().optional(),
unit: z.string().max(20).optional(),
isChecked: z.boolean().optional(),
});
export type CreateShoppingListInput = z.infer<typeof createShoppingListSchema>;
export type CreateShoppingListItemInput = z.infer<typeof createShoppingListItemSchema>;
export type UpdateShoppingListItemInput = z.infer<typeof updateShoppingListItemSchema>;

View File

@@ -0,0 +1,39 @@
import { z } from "zod";
export const shoppingItemSchema = z.object({
id: z.string(),
householdId: z.string(),
label: z.string(),
quantity: z.string().nullable(),
addedBy: z.string(),
checkedBy: z.string().nullable(),
checkedAt: z.string().nullable(),
sortOrder: z.number(),
createdAt: z.string(),
});
export type ShoppingItem = z.infer<typeof shoppingItemSchema>;
export const addShoppingItemSchema = z.object({
label: z.string().min(1),
quantity: z.string().optional(),
});
export type AddShoppingItemInput = z.infer<typeof addShoppingItemSchema>;
// WebSocket event types sent from server → client
export type ShoppingServerEvent =
| { type: "item:added"; item: ShoppingItem }
| { type: "item:checked"; itemId: string; checkedBy: string; checkedAt: string }
| { type: "item:unchecked"; itemId: string }
| { type: "item:deleted"; itemId: string }
| { type: "item:cleared" }
| { type: "sync"; items: ShoppingItem[] };
// WebSocket command types sent from client → server
export type ShoppingClientCommand =
| { type: "item:add"; label: string; quantity?: string }
| { type: "item:check"; itemId: string }
| { type: "item:uncheck"; itemId: string }
| { type: "item:delete"; itemId: string }
| { type: "item:clear" };

View File

@@ -0,0 +1,32 @@
import { z } from "zod";
export const TransactionScopeSchema = z.enum(["household", "private", "child"]);
export const CreateTransactionSchema = z.object({
amount: z.number().positive("Betrag muss positiv sein"),
type: z.enum(["income", "expense"]),
scope: TransactionScopeSchema.default("household"),
childId: z.string().min(1).optional(),
categoryId: z.string().min(1).optional(),
description: z.string().max(255).optional(),
merchant: z.string().max(255).optional(),
date: z.string().datetime({ offset: true }),
isFixed: z.boolean().default(false),
});
export const UpdateTransactionSchema = CreateTransactionSchema.partial();
export const TransactionFiltersSchema = z.object({
categoryId: z.string().optional(),
type: z.enum(["income", "expense"]).optional(),
scope: TransactionScopeSchema.optional(),
childId: z.string().optional(),
from: z.string().datetime({ offset: true }).optional(),
to: z.string().datetime({ offset: true }).optional(),
limit: z.coerce.number().min(1).max(100).default(50),
offset: z.coerce.number().min(0).default(0),
});
export type CreateTransactionInput = z.infer<typeof CreateTransactionSchema>;
export type UpdateTransactionInput = z.infer<typeof UpdateTransactionSchema>;
export type TransactionFilters = z.infer<typeof TransactionFiltersSchema>;

View File

@@ -0,0 +1,30 @@
import { z } from "zod";
export const TRIP_CATEGORIES = ["unterkunft", "essen", "transport", "aktivitaeten", "sonstiges"] as const;
export type TripCategory = (typeof TRIP_CATEGORIES)[number];
export const createTripSchema = z.object({
name: z.string().min(1).max(100),
destination: z.string().max(200).optional(),
budget: z.number().positive(),
startDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
endDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
});
export const updateTripSchema = createTripSchema.partial();
export const createTripExpenseSchema = z.object({
label: z.string().min(1).max(200),
amount: z.number().positive(),
category: z.enum(TRIP_CATEGORIES).default("sonstiges"),
paidBy: z.string().min(1),
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
note: z.string().max(500).optional(),
});
export const updateTripExpenseSchema = createTripExpenseSchema.partial();
export type CreateTripInput = z.infer<typeof createTripSchema>;
export type UpdateTripInput = z.infer<typeof updateTripSchema>;
export type CreateTripExpenseInput = z.infer<typeof createTripExpenseSchema>;
export type UpdateTripExpenseInput = z.infer<typeof updateTripExpenseSchema>;

View File

@@ -0,0 +1,15 @@
export type { SignUpInput, SignInInput } from "../schemas/auth.schema";
export type { CreateHouseholdInput, UpdateHouseholdInput } from "../schemas/household.schema";
export type { CreateTransactionInput, UpdateTransactionInput, TransactionFilters } from "../schemas/transaction.schema";
export type { CreateChildInput, UpdateChildInput } from "../schemas/children.schema";
export type {
CreateShoppingListInput,
CreateShoppingListItemInput,
UpdateShoppingListItemInput,
} from "../schemas/shopping-list.schema";
export type {
ShoppingItem,
AddShoppingItemInput,
ShoppingServerEvent,
ShoppingClientCommand,
} from "../schemas/shopping.schema";

View File

@@ -0,0 +1,11 @@
{
"extends": "@haushaltsApp/config/tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src",
"declaration": true,
"declarationMap": true,
"sourceMap": true
},
"include": ["src"]
}