diff --git a/prisma/migrations/0004_tags/migration.sql b/prisma/migrations/0004_tags/migration.sql
new file mode 100644
index 0000000..c30866c
--- /dev/null
+++ b/prisma/migrations/0004_tags/migration.sql
@@ -0,0 +1,13 @@
+CREATE TABLE IF NOT EXISTS tags (
+ id SERIAL PRIMARY KEY,
+ name TEXT NOT NULL UNIQUE,
+ color TEXT NOT NULL DEFAULT '#6366f1',
+ created_at TIMESTAMPTZ DEFAULT NOW()
+);
+
+CREATE TABLE IF NOT EXISTS transaction_tags (
+ transaction_id INTEGER NOT NULL REFERENCES transactions(id) ON DELETE CASCADE,
+ tag_id INTEGER NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
+ created_at TIMESTAMPTZ DEFAULT NOW(),
+ PRIMARY KEY (transaction_id, tag_id)
+);
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index 0c99912..fc06f66 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -15,3 +15,53 @@ model transaction_overrides {
notes String?
updated_at DateTime @default(now()) @updatedAt
}
+
+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[]
+}
+
+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 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])
+}
diff --git a/src/app/api/tags/[id]/route.ts b/src/app/api/tags/[id]/route.ts
new file mode 100644
index 0000000..2cc9e78
--- /dev/null
+++ b/src/app/api/tags/[id]/route.ts
@@ -0,0 +1,8 @@
+import { NextRequest, NextResponse } from "next/server";
+import { queryRaw } from "@/lib/db";
+
+export async function DELETE(_req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
+ const { id } = await params;
+ await queryRaw(`DELETE FROM tags WHERE id = $1`, [Number(id)]);
+ return NextResponse.json({ ok: true });
+}
diff --git a/src/app/api/tags/route.ts b/src/app/api/tags/route.ts
new file mode 100644
index 0000000..d3d8b7a
--- /dev/null
+++ b/src/app/api/tags/route.ts
@@ -0,0 +1,20 @@
+import { NextRequest, NextResponse } from "next/server";
+import { getTags } from "@/lib/queries";
+import { queryRaw } from "@/lib/db";
+
+export async function GET() {
+ const tags = await getTags();
+ return NextResponse.json(tags);
+}
+
+export async function POST(req: NextRequest) {
+ const { name, color } = await req.json();
+ if (!name?.trim()) {
+ return NextResponse.json({ error: "name required" }, { status: 400 });
+ }
+ const rows = await queryRaw<{ id: number; name: string; color: string }>(
+ `INSERT INTO tags (name, color) VALUES ($1, $2) RETURNING id, name, color`,
+ [name.trim(), color || "#6366f1"]
+ );
+ return NextResponse.json(rows[0], { status: 201 });
+}
diff --git a/src/app/api/transactions/[id]/tags/route.ts b/src/app/api/transactions/[id]/tags/route.ts
new file mode 100644
index 0000000..88c70e3
--- /dev/null
+++ b/src/app/api/transactions/[id]/tags/route.ts
@@ -0,0 +1,24 @@
+import { NextRequest, NextResponse } from "next/server";
+import { queryRaw } from "@/lib/db";
+
+export async function POST(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
+ const { id } = await params;
+ const { tag_id } = await req.json();
+ if (!tag_id) return NextResponse.json({ error: "tag_id required" }, { status: 400 });
+ await queryRaw(
+ `INSERT INTO transaction_tags (transaction_id, tag_id) VALUES ($1, $2) ON CONFLICT DO NOTHING`,
+ [Number(id), Number(tag_id)]
+ );
+ return NextResponse.json({ ok: true });
+}
+
+export async function DELETE(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
+ const { id } = await params;
+ const { tag_id } = await req.json();
+ if (!tag_id) return NextResponse.json({ error: "tag_id required" }, { status: 400 });
+ await queryRaw(
+ `DELETE FROM transaction_tags WHERE transaction_id = $1 AND tag_id = $2`,
+ [Number(id), Number(tag_id)]
+ );
+ return NextResponse.json({ ok: true });
+}
diff --git a/src/app/api/transactions/bulk/route.ts b/src/app/api/transactions/bulk/route.ts
index 2c5605b..cef9cd3 100644
--- a/src/app/api/transactions/bulk/route.ts
+++ b/src/app/api/transactions/bulk/route.ts
@@ -1,13 +1,15 @@
import { NextRequest, NextResponse } from "next/server";
-import { prisma } from "@/lib/db";
+import { prisma, queryRaw } from "@/lib/db";
export async function POST(req: NextRequest) {
const body = await req.json();
- const { action, ids, category, merchant_normalized } = body as {
+ const { action, ids, category, merchant_normalized, splits, tag_id } = body as {
action: string;
ids: number[];
category?: string;
merchant_normalized?: string;
+ splits?: { participant_id: number; share_percent: number }[];
+ tag_id?: number;
};
if (!ids || !Array.isArray(ids) || ids.length === 0) {
@@ -38,5 +40,42 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ updated: ids.length });
}
+ if (action === "split" && Array.isArray(splits) && splits.length > 0) {
+ const total = splits.reduce((s, x) => s + x.share_percent, 0);
+ if (Math.abs(total - 100) > 0.01) {
+ return NextResponse.json({ error: "Shares must sum to 100%" }, { status: 400 });
+ }
+ await prisma.$transaction(
+ ids.flatMap((id) => [
+ prisma.transaction_splits.deleteMany({ where: { transaction_id: id } }),
+ prisma.transaction_splits.createMany({
+ data: splits.map((s) => ({
+ transaction_id: id,
+ participant_id: s.participant_id,
+ share_percent: s.share_percent,
+ })),
+ }),
+ ])
+ );
+ return NextResponse.json({ updated: ids.length });
+ }
+
+ if ((action === "tag" || action === "untag") && tag_id) {
+ if (action === "tag") {
+ for (const id of ids) {
+ await queryRaw(
+ `INSERT INTO transaction_tags (transaction_id, tag_id) VALUES ($1, $2) ON CONFLICT DO NOTHING`,
+ [id, tag_id]
+ );
+ }
+ } else {
+ await queryRaw(
+ `DELETE FROM transaction_tags WHERE transaction_id = ANY($1::int[]) AND tag_id = $2`,
+ [ids, tag_id]
+ );
+ }
+ return NextResponse.json({ updated: ids.length });
+ }
+
return NextResponse.json({ error: "Invalid action" }, { status: 400 });
}
diff --git a/src/app/tags/page.tsx b/src/app/tags/page.tsx
index 7ab85a2..24aca14 100644
--- a/src/app/tags/page.tsx
+++ b/src/app/tags/page.tsx
@@ -1,8 +1,110 @@
+"use client";
+
+import { useState } from "react";
+import { useTags, useCreateTag, useDeleteTag } from "@/lib/hooks";
+
+const PRESET_COLORS = [
+ "#6366f1", // indigo
+ "#8b5cf6", // violet
+ "#ec4899", // pink
+ "#ef4444", // red
+ "#f97316", // orange
+ "#eab308", // yellow
+ "#22c55e", // green
+ "#14b8a6", // teal
+ "#3b82f6", // blue
+ "#6b7280", // gray
+];
+
export default function TagsPage() {
+ const { data: tags, isLoading } = useTags();
+ const createTag = useCreateTag();
+ const deleteTag = useDeleteTag();
+
+ const [name, setName] = useState("");
+ const [color, setColor] = useState(PRESET_COLORS[0]);
+ const [error, setError] = useState("");
+
+ const handleCreate = async () => {
+ if (!name.trim()) return;
+ setError("");
+ try {
+ await createTag.mutateAsync({ name: name.trim(), color });
+ setName("");
+ } catch (e: unknown) {
+ setError(e instanceof Error ? e.message : "Failed to create tag");
+ }
+ };
+
return (
Tags
-
Coming soon - tag transactions for trips, projects, and more.
+
+ {/* Create form */}
+
+
New Tag
+
+
setName(e.target.value)}
+ onKeyDown={(e) => e.key === "Enter" && handleCreate()}
+ className="bg-zinc-800 border border-zinc-700 rounded px-3 py-1.5 text-sm w-48"
+ />
+
+ {PRESET_COLORS.map((c) => (
+
+
+
+ {error &&
{error}
}
+
+
+ {/* Tags list */}
+ {isLoading ? (
+
Loading...
+ ) : !tags?.length ? (
+
No tags yet. Create one above.
+ ) : (
+
+ {tags.map((tag) => (
+
+
+
+ {tag.name}
+
+ {tag.transaction_count} transaction{tag.transaction_count !== 1 ? "s" : ""}
+
+
+
+
+ ))}
+
+ )}
);
}
diff --git a/src/app/transactions/page.tsx b/src/app/transactions/page.tsx
index a86554a..b56cb09 100644
--- a/src/app/transactions/page.tsx
+++ b/src/app/transactions/page.tsx
@@ -1,8 +1,10 @@
"use client";
import { useState, useCallback } from "react";
-import { useTransactions, useBanks, useUpdateTransaction, useBulkAction } from "@/lib/hooks";
+import { useTransactions, useBanks, useUpdateTransaction, useBulkAction, useTags } from "@/lib/hooks";
import { CATEGORIES, formatCategory } from "@/lib/categories";
+import { SplitModal } from "@/components/split-modal";
+import { TagPicker } from "@/components/tag-picker";
function formatDate(d: string) {
return new Date(d).toLocaleDateString("en-AU", {
@@ -102,6 +104,7 @@ export default function TransactionsPage() {
category: "",
bank_name: "",
search: "",
+ tag_id: "",
sort_by: "transaction_date",
sort_dir: "desc",
limit: 50,
@@ -109,9 +112,12 @@ export default function TransactionsPage() {
});
const [selected, setSelected] = useState>(new Set());
const [bulkCategory, setBulkCategory] = useState("");
+ const [bulkTagId, setBulkTagId] = useState("");
+ const [splitModal, setSplitModal] = useState<{ transactionId?: number; transactionIds?: number[]; amount?: number; description: string } | null>(null);
const { data, isLoading } = useTransactions(filters);
const { data: banks } = useBanks();
+ const { data: tags } = useTags();
const updateTxn = useUpdateTransaction();
const bulkAction = useBulkAction();
@@ -196,6 +202,16 @@ export default function TransactionsPage() {
))}
+
{/* Bulk action bar */}
@@ -224,6 +240,39 @@ export default function TransactionsPage() {
>
Apply
+
+
+