diff --git a/.github/workflows/postgres.yaml b/.github/workflows/postgres.yaml
index 2995059c..de8ee1fd 100644
--- a/.github/workflows/postgres.yaml
+++ b/.github/workflows/postgres.yaml
@@ -156,11 +156,11 @@ jobs:
--amend ghcr.io/oss-apps/postgres-amd64:$GIT_SHA \
--amend ghcr.io/oss-apps/postgres-arm64:$GIT_SHA \
- docker manifest push ghcr.io/oss-apps/postgres:$CHANNEL
+ docker manifest push ghcr.io/oss-apps/postgres:$DB_VERSION
docker manifest push ghcr.io/oss-apps/postgres:$GIT_SHA
- name: Create and push "latest" tag if applicable
- if: ${{ github.event.inputs.is_latest == true }}
+ if: github.event.inputs.is_latest == 'true'
run: |
docker manifest create \
ossapps/postgres:latest \
diff --git a/.lintstagedrc.js b/.lintstagedrc.js
index d916607d..6181ec62 100644
--- a/.lintstagedrc.js
+++ b/.lintstagedrc.js
@@ -5,6 +5,9 @@ const config = {
// Run oxlint on JavaScript/TypeScript files
'*.{js,jsx,ts,tsx}': ['oxlint --type-aware --fix'],
+ // Format prisma.schema files
+ '*.prisma': ['pnpm prisma format'],
+
// Type check TypeScript files (optional - can be slow)
// '*.{ts,tsx}': () => 'tsc --noEmit',
};
diff --git a/components.json b/components.json
index 5e65b79c..034e70dc 100644
--- a/components.json
+++ b/components.json
@@ -1,10 +1,10 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
- "style": "default",
+ "style": "new-york",
"rsc": false,
"tsx": true,
"tailwind": {
- "config": "tailwind.config.ts",
+ "config": "",
"css": "src/styles/globals.css",
"baseColor": "gray",
"cssVariables": true,
diff --git a/docker/dev/compose.yml b/docker/dev/compose.yml
index dda1b9a2..19ba3326 100644
--- a/docker/dev/compose.yml
+++ b/docker/dev/compose.yml
@@ -12,13 +12,13 @@ services:
- POSTGRES_PORT=${POSTGRES_PORT:-5432}
volumes:
- database:/var/lib/postgresql/data
- ports:
- - '${POSTGRES_PORT:-5432}:${POSTGRES_PORT:-5432}'
command: >
postgres
-c shared_preload_libraries=pg_cron
-c cron.database_name=${POSTGRES_DB:-splitpro}
- -c cron.timezone=${TZ:-UTC}
+ -c cron.timezone=UTC
+ ports:
+ - '${POSTGRES_PORT:-5432}:${POSTGRES_PORT:-5432}'
minio:
image: minio/minio:RELEASE.2025-04-22T22-12-26Z
diff --git a/docker/postgres/Dockerfile b/docker/postgres/Dockerfile
index ef77aed1..019fbf17 100644
--- a/docker/postgres/Dockerfile
+++ b/docker/postgres/Dockerfile
@@ -1,9 +1,11 @@
ARG POSTGRES_MAJOR=16
-ARG POSTGRES_MINOR=16.10
+ARG POSTGRES_MINOR=10
ARG POSTGRES_BASE=trixie
FROM postgres:${POSTGRES_MAJOR}.${POSTGRES_MINOR}-${POSTGRES_BASE}
+ARG POSTGRES_MAJOR
+
# Install pg_cron (from PGDG packages)
RUN apt-get update && apt-get install -y \
postgresql-${POSTGRES_MAJOR}-cron \
diff --git a/docker/prod/compose.yml b/docker/prod/compose.yml
index c68c9f67..5323f3d6 100644
--- a/docker/prod/compose.yml
+++ b/docker/prod/compose.yml
@@ -15,16 +15,16 @@ services:
interval: 10s
timeout: 5s
retries: 5
+ command: >
+ postgres
+ -c shared_preload_libraries=pg_cron
+ -c cron.database_name=${POSTGRES_DB:-splitpro}
+ -c cron.timezone=UTC
# ports:
# - "5432:5432"
env_file: .env
volumes:
- database:/var/lib/postgresql/data
- command: >
- postgres
- -c shared_preload_libraries=pg_cron
- -c cron.database_name=${POSTGRES_DB:-splitpro}
- -c cron.timezone=${TZ:-UTC}
splitpro:
image: ossapps/splitpro:latest
diff --git a/package.json b/package.json
index 0cc069f3..3f67c883 100644
--- a/package.json
+++ b/package.json
@@ -44,6 +44,8 @@
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.0",
"cmdk": "^1.1.1",
+ "cron-parser": "^4.9.0",
+ "cronstrue": "^3.3.0",
"date-fns": "^3.3.1",
"i18next": "^25.2.1",
"i18next-browser-languagedetector": "^8.2.0",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 608124a6..db8b500e 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -66,6 +66,12 @@ importers:
cmdk:
specifier: ^1.1.1
version: 1.1.1(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
+ cron-parser:
+ specifier: ^4.9.0
+ version: 4.9.0
+ cronstrue:
+ specifier: ^3.3.0
+ version: 3.3.0
date-fns:
specifier: ^3.3.1
version: 3.6.0
@@ -3511,6 +3517,14 @@ packages:
create-require@1.1.1:
resolution: {integrity: sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==}
+ cron-parser@4.9.0:
+ resolution: {integrity: sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q==}
+ engines: {node: '>=12.0.0'}
+
+ cronstrue@3.3.0:
+ resolution: {integrity: sha512-iwJytzJph1hosXC09zY8F5ACDJKerr0h3/2mOxg9+5uuFObYlgK0m35uUPk4GCvhHc2abK7NfnR9oMqY0qZFAg==}
+ hasBin: true
+
cross-fetch@4.0.0:
resolution: {integrity: sha512-e4a5N8lVvuLgAWgnCrLr2PP0YyDOTHa9H/Rj54dirp61qXnNq46m82bRhNqIA5VccJtWBvPTFRV3TtvHUKPB1g==}
@@ -4571,6 +4585,10 @@ packages:
peerDependencies:
react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0
+ luxon@3.7.2:
+ resolution: {integrity: sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==}
+ engines: {node: '>=12'}
+
magic-string@0.25.9:
resolution: {integrity: sha512-RmF0AsMzgt25qzqqLc1+MbHmhdx0ojF2Fvs4XnOqz2ZOBXzzkEwc/dJQZCYHAn7v1jbVOjAZfK8msRn4BxO4VQ==}
@@ -9850,6 +9868,12 @@ snapshots:
create-require@1.1.1: {}
+ cron-parser@4.9.0:
+ dependencies:
+ luxon: 3.7.2
+
+ cronstrue@3.3.0: {}
+
cross-fetch@4.0.0:
dependencies:
node-fetch: 2.7.0
@@ -11202,6 +11226,8 @@ snapshots:
dependencies:
react: 19.1.1
+ luxon@3.7.2: {}
+
magic-string@0.25.9:
dependencies:
sourcemap-codec: 1.4.8
diff --git a/prisma.config.ts b/prisma.config.ts
index 06d19cf2..7f89f308 100644
--- a/prisma.config.ts
+++ b/prisma.config.ts
@@ -8,4 +8,10 @@ export default {
path: path.join('prisma', 'migrations'),
seed: 'tsx prisma/seed.ts',
},
+ experimental: {
+ externalTables: true,
+ },
+ tables: {
+ external: ['cron.job', 'cron.job_run_details'],
+ },
} satisfies PrismaConfig;
diff --git a/prisma/migrations/20241026095834_add_gocardless_bank_transaction_integration/migration.sql b/prisma/migrations/20250907160149_add_gocardless_bank_transaction_integration/migration.sql
similarity index 100%
rename from prisma/migrations/20241026095834_add_gocardless_bank_transaction_integration/migration.sql
rename to prisma/migrations/20250907160149_add_gocardless_bank_transaction_integration/migration.sql
diff --git a/prisma/migrations/20250920192654_recurrence/migration.sql b/prisma/migrations/20250920192654_recurrence/migration.sql
new file mode 100644
index 00000000..205161db
--- /dev/null
+++ b/prisma/migrations/20250920192654_recurrence/migration.sql
@@ -0,0 +1,76 @@
+-- AlterTable
+ALTER TABLE "public"."Expense" ADD COLUMN "recurrenceId" INTEGER;
+
+-- CreateTable
+CREATE TABLE "public"."ExpenseRecurrence" (
+ "id" SERIAL NOT NULL,
+ "jobId" BIGINT NOT NULL,
+ "notified" BOOLEAN NOT NULL DEFAULT true,
+
+ CONSTRAINT "ExpenseRecurrence_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateIndex
+CREATE UNIQUE INDEX "ExpenseRecurrence_jobId_key" ON "public"."ExpenseRecurrence"("jobId");
+
+-- AddForeignKey
+ALTER TABLE "public"."Expense" ADD CONSTRAINT "Expense_recurrenceId_fkey" FOREIGN KEY ("recurrenceId") REFERENCES "public"."ExpenseRecurrence"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+CREATE OR REPLACE FUNCTION duplicate_expense_with_participants(original_expense_id UUID)
+RETURNS UUID AS $$
+DECLARE
+ new_expense_id UUID;
+BEGIN
+ -- STEP 1: Insert the new expense and get its new ID
+ INSERT INTO "Expense" (
+ "paidBy", "addedBy", name, category, amount, "splitType", "expenseDate", "updatedAt",
+ currency, "groupId", "updatedBy", "recurrenceId"
+ )
+ SELECT
+ "paidBy", "addedBy", name, category, amount, "splitType", now(), now(),
+ currency, "groupId", "updatedBy", "recurrenceId"
+ FROM "Expense"
+ WHERE id = original_expense_id
+ RETURNING id INTO new_expense_id;
+
+ -- STEP 2: Insert the new expense participants
+ INSERT INTO "ExpenseParticipant" (
+ "expenseId", "userId", amount
+ )
+ SELECT
+ new_expense_id, "userId", amount
+ FROM "ExpenseParticipant"
+ WHERE "expenseId" = original_expense_id;
+
+ -- STEP 3: Set notified to false in the ExpenseRecurrence table
+ UPDATE "ExpenseRecurrence"
+ SET notified = false
+ WHERE id = (SELECT "recurrenceId" FROM "Expense" WHERE id = original_expense_id);
+
+ -- STEP 4: Return the new expense ID
+ RETURN new_expense_id;
+END;
+$$ LANGUAGE plpgsql;
+
+DO $$
+BEGIN
+IF current_database() NOT LIKE 'prisma_migrate_shadow_db%' THEN
+ CREATE EXTENSION IF NOT EXISTS pg_cron;
+ELSE
+ CREATE SCHEMA IF NOT EXISTS cron;
+ CREATE TABLE IF NOT EXISTS cron.job (
+ jobid BIGINT PRIMARY KEY,
+ schedule TEXT,
+ command TEXT,
+ nodename TEXT,
+ nodeport INTEGER,
+ database TEXT,
+ username TEXT,
+ active BOOLEAN,
+ jobname TEXT
+ );
+END IF;
+END $$;
+
+-- AddForeignKey
+ALTER TABLE "public"."ExpenseRecurrence" ADD CONSTRAINT "ExpenseRecurrence_jobId_fkey" FOREIGN KEY ("jobId") REFERENCES "cron"."job"("jobid") ON DELETE CASCADE ON UPDATE CASCADE;
\ No newline at end of file
diff --git a/prisma/migrations/20250921172223_add_expense_uuid/migration.sql b/prisma/migrations/20250921172223_add_expense_uuid/migration.sql
new file mode 100644
index 00000000..c64c2e3b
--- /dev/null
+++ b/prisma/migrations/20250921172223_add_expense_uuid/migration.sql
@@ -0,0 +1,46 @@
+/*
+ Warnings:
+
+ - The `otherConversion` column on the `Expense` table would be dropped and recreated. This will lead to data loss if there is data in the column.
+ - A unique constraint covering the columns `[uuidId]` on the table `Expense` will be added. If there are existing duplicate values, this will fail.
+
+*/
+-- DropForeignKey
+ALTER TABLE "public"."Expense" DROP CONSTRAINT "Expense_otherConversion_fkey";
+
+-- DropForeignKey
+ALTER TABLE "public"."ExpenseNote" DROP CONSTRAINT "ExpenseNote_expenseId_fkey";
+
+-- DropForeignKey
+ALTER TABLE "public"."ExpenseParticipant" DROP CONSTRAINT "ExpenseParticipant_expenseId_fkey";
+
+-- AlterTable
+ALTER TABLE "public"."Expense" ADD COLUMN "uuidId" UUID DEFAULT gen_random_uuid(),
+DROP COLUMN "otherConversion",
+ADD COLUMN "otherConversion" UUID;
+
+-- AlterTable
+ALTER TABLE "public"."ExpenseNote" ADD COLUMN "expenseUuid" UUID;
+
+-- AlterTable
+ALTER TABLE "public"."ExpenseParticipant" ADD COLUMN "expenseUuid" UUID;
+
+-- CreateIndex
+CREATE UNIQUE INDEX "Expense_uuidId_key" ON "public"."Expense"("uuidId");
+
+-- CreateIndex
+CREATE UNIQUE INDEX "Expense_otherConversion_key" ON "public"."Expense"("otherConversion");
+
+-- AddForeignKey
+ALTER TABLE "public"."Expense" ADD CONSTRAINT "Expense_otherConversion_fkey" FOREIGN KEY ("otherConversion") REFERENCES "public"."Expense"("uuidId") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "public"."ExpenseParticipant" ADD CONSTRAINT "ExpenseParticipant_expenseUuid_fkey" FOREIGN KEY ("expenseUuid") REFERENCES "public"."Expense"("uuidId") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "public"."ExpenseNote" ADD CONSTRAINT "ExpenseNote_expenseUuid_fkey" FOREIGN KEY ("expenseUuid") REFERENCES "public"."Expense"("uuidId") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- Migrate data
+UPDATE "public"."Expense" SET "uuidId" = gen_random_uuid() WHERE "uuidId" IS NULL;
+UPDATE "public"."ExpenseParticipant" SET "expenseUuid" = (SELECT "uuidId" FROM "public"."Expense" WHERE "id" = "expenseId") WHERE "expenseUuid" IS NULL;
+UPDATE "public"."ExpenseNote" SET "expenseUuid" = (SELECT "uuidId" FROM "public"."Expense" WHERE "id" = "expenseId") WHERE "expenseUuid" IS NULL;
diff --git a/prisma/migrations/20250921174004_replace_expense_cuid_with_uuid/migration.sql b/prisma/migrations/20250921174004_replace_expense_cuid_with_uuid/migration.sql
new file mode 100644
index 00000000..dc001d07
--- /dev/null
+++ b/prisma/migrations/20250921174004_replace_expense_cuid_with_uuid/migration.sql
@@ -0,0 +1,66 @@
+/*
+ Warnings:
+
+ - The primary key for the `Expense` table will be changed. If it partially fails, the table could be left without primary key constraint.
+ - You are about to drop the column `uuidId` on the `Expense` table. All the data in the column will be lost.
+ - The `id` column on the `Expense` table would be dropped and recreated. This will lead to data loss if there is data in the column.
+ - You are about to drop the column `expenseUuid` on the `ExpenseNote` table. All the data in the column will be lost.
+ - The primary key for the `ExpenseParticipant` table will be changed. If it partially fails, the table could be left without primary key constraint.
+ - You are about to drop the column `expenseUuid` on the `ExpenseParticipant` table. All the data in the column will be lost.
+ - Changed the type of `expenseId` on the `ExpenseNote` table. No cast exists, the column would be dropped and recreated, which cannot be done if there is data, since the column is required.
+ - Changed the type of `expenseId` on the `ExpenseParticipant` table. No cast exists, the column would be dropped and recreated, which cannot be done if there is data, since the column is required.
+
+*/
+-- DropForeignKey
+ALTER TABLE "public"."Expense" DROP CONSTRAINT "Expense_otherConversion_fkey";
+
+-- DropForeignKey
+ALTER TABLE "public"."ExpenseNote" DROP CONSTRAINT "ExpenseNote_expenseUuid_fkey";
+
+-- DropForeignKey
+ALTER TABLE "public"."ExpenseParticipant" DROP CONSTRAINT "ExpenseParticipant_expenseUuid_fkey";
+
+-- DropIndex
+DROP INDEX "public"."Expense_uuidId_key";
+
+-- AlterTable: Expense - Drop primary key constraint first
+ALTER TABLE "public"."Expense" DROP CONSTRAINT "Expense_pkey";
+
+-- AlterTable: Expense - Drop the old id column
+ALTER TABLE "public"."Expense" DROP COLUMN "id";
+
+-- AlterTable: Expense - Rename uuidId to id
+ALTER TABLE "public"."Expense" RENAME COLUMN "uuidId" TO "id";
+
+-- AlterTable: Expense - Add primary key constraint
+ALTER TABLE "public"."Expense" ADD CONSTRAINT "Expense_pkey" PRIMARY KEY ("id");
+
+-- AlterTable: ExpenseNote - Drop old expenseId column
+ALTER TABLE "public"."ExpenseNote" DROP COLUMN "expenseId";
+
+-- AlterTable: ExpenseNote - Rename expenseUuid to expenseId
+ALTER TABLE "public"."ExpenseNote" RENAME COLUMN "expenseUuid" TO "expenseId";
+
+-- AlterTable: ExpenseParticipant - Drop primary key constraint first
+ALTER TABLE "public"."ExpenseParticipant" DROP CONSTRAINT "ExpenseParticipant_pkey";
+
+-- AlterTable: ExpenseParticipant - Drop old expenseId column
+ALTER TABLE "public"."ExpenseParticipant" DROP COLUMN "expenseId";
+
+-- AlterTable: ExpenseParticipant - Rename expenseUuid to expenseId
+ALTER TABLE "public"."ExpenseParticipant" RENAME COLUMN "expenseUuid" TO "expenseId";
+
+-- AlterTable: ExpenseParticipant - Add primary key constraint
+ALTER TABLE "public"."ExpenseParticipant" ADD CONSTRAINT "ExpenseParticipant_pkey" PRIMARY KEY ("expenseId", "userId");
+
+-- AlterTable
+ALTER TABLE "public"."ExpenseNote" ALTER COLUMN "expenseId" SET NOT NULL;
+
+-- AddForeignKey
+ALTER TABLE "public"."Expense" ADD CONSTRAINT "Expense_otherConversion_fkey" FOREIGN KEY ("otherConversion") REFERENCES "public"."Expense"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "public"."ExpenseParticipant" ADD CONSTRAINT "ExpenseParticipant_expenseId_fkey" FOREIGN KEY ("expenseId") REFERENCES "public"."Expense"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "public"."ExpenseNote" ADD CONSTRAINT "ExpenseNote_expenseId_fkey" FOREIGN KEY ("expenseId") REFERENCES "public"."Expense"("id") ON DELETE CASCADE ON UPDATE CASCADE;
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index d3e94769..a5e68d40 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -1,221 +1,289 @@
-// This is your Prisma schema file,
-// learn more about it in the docs: https://pris.ly/d/prisma-schema
-
generator client {
- provider = "prisma-client-js"
- previewFeatures = ["relationJoins"]
+ provider = "prisma-client-js"
+ previewFeatures = ["relationJoins"]
}
datasource db {
- provider = "postgresql"
- // NOTE: When using mysql or sqlserver, uncomment the @db.Text annotations in model Account below
- // Further reading:
- // https://next-auth.js.org/adapters/prisma#create-the-prisma-schema
- // https://www.prisma.io/docs/reference/api-reference/prisma-schema-reference#string
- url = env("DATABASE_URL")
+ provider = "postgresql"
+ // NOTE: When using mysql or sqlserver, uncomment the @db.Text annotations in model Account below
+ // Further reading:
+ // https://next-auth.js.org/adapters/prisma#create-the-prisma-schema
+ // https://www.prisma.io/docs/reference/api-reference/prisma-schema-reference#string
+ url = env("DATABASE_URL")
+ schemas = ["cron", "public"]
}
-// Necessary for Next auth
model Account {
- id String @id @default(cuid())
- userId Int
- type String
- provider String
- providerAccountId String
- refresh_token String? // @db.Text
- access_token String? // @db.Text
- expires_at Int?
- token_type String?
- scope String?
- id_token String? // @db.Text
- session_state String?
- user User @relation(fields: [userId], references: [id], onDelete: Cascade)
-
- @@unique([provider, providerAccountId])
+ id String @id @default(cuid())
+ userId Int
+ type String
+ provider String
+ providerAccountId String
+ refresh_token String? // @db.Text
+ access_token String? // @db.Text
+ expires_at Int?
+ token_type String?
+ scope String?
+ id_token String? // @db.Text
+ session_state String?
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
+
+ @@unique([provider, providerAccountId])
+ @@schema("public")
}
model Session {
- id String @id @default(cuid())
- sessionToken String @unique
- userId Int
- expires DateTime
- user User @relation(fields: [userId], references: [id], onDelete: Cascade)
+ id String @id @default(cuid())
+ sessionToken String @unique
+ userId Int
+ expires DateTime
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
+
+ @@schema("public")
}
model User {
- id Int @id @default(autoincrement())
- name String?
- email String? @unique
- obapiProviderId String?
- bankingId String?
- emailVerified DateTime?
- image String?
- currency String @default("USD")
- preferredLanguage String @default("")
- accounts Account[]
- sessions Session[]
- groups Group[]
- associatedGroups GroupUser[]
- expenseParticipants ExpenseParticipant[]
- expenseNotes ExpenseNote[]
- userBalances Balance[] @relation("UserBalance")
- cachedBankData CachedBankData[] @relation("UserCachedBankData")
- friendBalances Balance[] @relation("FriendBalance")
- groupUserBalances GroupBalance[] @relation("GroupUserBalance")
- groupFriendBalances GroupBalance[] @relation("GroupFriendBalance")
- paidExpenses Expense[] @relation("PaidByUser")
- addedExpenses Expense[] @relation("AddedByUser")
- deletedExpenses Expense[] @relation("DeletedByUser")
- updatedExpenses Expense[] @relation("UpdatedByUser")
+ id Int @id @default(autoincrement())
+ name String?
+ email String? @unique
+ obapiProviderId String?
+ bankingId String?
+ emailVerified DateTime?
+ image String?
+ currency String @default("USD")
+ preferredLanguage String @default("")
+ accounts Account[]
+ sessions Session[]
+ groups Group[]
+ associatedGroups GroupUser[]
+ expenseParticipants ExpenseParticipant[]
+ expenseNotes ExpenseNote[]
+ userBalances Balance[] @relation(name: "UserBalance")
+ cachedBankData CachedBankData[] @relation(name: "UserCachedBankData")
+ friendBalances Balance[] @relation(name: "FriendBalance")
+ groupUserBalances GroupBalance[] @relation(name: "GroupUserBalance")
+ groupFriendBalances GroupBalance[] @relation(name: "GroupFriendBalance")
+ paidExpenses Expense[] @relation(name: "PaidByUser")
+ addedExpenses Expense[] @relation(name: "AddedByUser")
+ deletedExpenses Expense[] @relation(name: "DeletedByUser")
+ updatedExpenses Expense[] @relation(name: "UpdatedByUser")
+
+ @@schema("public")
}
model CachedBankData {
- id Int @id @default(autoincrement())
- obapiProviderId String @unique
- data String
- userId Int
- user User @relation(name: "UserCachedBankData", fields: [userId], references: [id], onDelete: Cascade)
- lastFetched DateTime
- createdAt DateTime @default(now())
- updatedAt DateTime @updatedAt
+ id Int @id @default(autoincrement())
+ obapiProviderId String @unique
+ data String
+ userId Int
+ user User @relation(name: "UserCachedBankData", fields: [userId], references: [id], onDelete: Cascade)
+ lastFetched DateTime
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+
+ @@schema("public")
}
model VerificationToken {
- identifier String
- token String @unique
- expires DateTime
+ identifier String
+ token String @unique
+ expires DateTime
- @@unique([identifier, token])
+ @@unique([identifier, token])
+ @@schema("public")
}
model Balance {
- userId Int
- currency String
- friendId Int
- amount BigInt
- createdAt DateTime @default(now())
- updatedAt DateTime @updatedAt
- importedFromSplitwise Boolean @default(false)
- user User @relation(name: "UserBalance", fields: [userId], references: [id], onDelete: Cascade)
- friend User @relation(name: "FriendBalance", fields: [friendId], references: [id], onDelete: Cascade)
+ userId Int
+ currency String
+ friendId Int
+ amount BigInt
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+ importedFromSplitwise Boolean @default(false)
+ user User @relation(name: "UserBalance", fields: [userId], references: [id], onDelete: Cascade)
+ friend User @relation(name: "FriendBalance", fields: [friendId], references: [id], onDelete: Cascade)
- @@id([userId, currency, friendId])
+ @@id([userId, currency, friendId])
+ @@schema("public")
}
model Group {
- id Int @id @default(autoincrement())
- publicId String @unique
- name String
- userId Int
- defaultCurrency String @default("USD")
- createdAt DateTime @default(now())
- updatedAt DateTime @updatedAt
- archivedAt DateTime?
- splitwiseGroupId String? @unique
- createdBy User @relation(fields: [userId], references: [id], onDelete: Cascade)
- groupUsers GroupUser[]
- expenses Expense[]
- groupBalances GroupBalance[]
- simplifyDebts Boolean @default(false)
+ id Int @id @default(autoincrement())
+ publicId String @unique
+ name String
+ userId Int
+ defaultCurrency String @default("USD")
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+ archivedAt DateTime?
+ splitwiseGroupId String? @unique
+ createdBy User @relation(fields: [userId], references: [id], onDelete: Cascade)
+ groupUsers GroupUser[]
+ expenses Expense[]
+ groupBalances GroupBalance[]
+ simplifyDebts Boolean @default(false)
+
+ @@schema("public")
}
model GroupUser {
- groupId Int
- userId Int
- user User @relation(fields: [userId], references: [id], onDelete: Cascade)
- group Group @relation(fields: [groupId], references: [id], onDelete: Cascade)
+ groupId Int
+ userId Int
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
+ group Group @relation(fields: [groupId], references: [id], onDelete: Cascade)
- @@id([groupId, userId])
+ @@id([groupId, userId])
+ @@schema("public")
}
model GroupBalance {
- groupId Int
- currency String
- userId Int
- firendId Int
- amount BigInt
- updatedAt DateTime @updatedAt
- group Group @relation(fields: [groupId], references: [id], onDelete: Cascade)
- user User @relation(name: "GroupUserBalance", fields: [userId], references: [id], onDelete: Cascade)
- friend User @relation(name: "GroupFriendBalance", fields: [firendId], references: [id], onDelete: Cascade)
+ groupId Int
+ currency String
+ userId Int
+ firendId Int
+ amount BigInt
+ updatedAt DateTime @updatedAt
+ group Group @relation(fields: [groupId], references: [id], onDelete: Cascade)
+ user User @relation(name: "GroupUserBalance", fields: [userId], references: [id], onDelete: Cascade)
+ friend User @relation(name: "GroupFriendBalance", fields: [firendId], references: [id], onDelete: Cascade)
- @@id([groupId, currency, firendId, userId])
+ @@id([groupId, currency, firendId, userId])
+ @@schema("public")
}
enum SplitType {
- EQUAL
- PERCENTAGE
- EXACT
- SHARE
- ADJUSTMENT
- SETTLEMENT
- CURRENCY_CONVERSION
+ EQUAL
+ PERCENTAGE
+ EXACT
+ SHARE
+ ADJUSTMENT
+ SETTLEMENT
+ CURRENCY_CONVERSION
+
+ @@schema("public")
}
model Expense {
- id String @id @default(cuid())
- paidBy Int
- addedBy Int
- name String
- category String
- amount BigInt
- splitType SplitType @default(EQUAL)
- expenseDate DateTime @default(now())
- createdAt DateTime @default(now())
- updatedAt DateTime @updatedAt
- currency String
- fileKey String?
- groupId Int?
- deletedAt DateTime?
- deletedBy Int?
- updatedBy Int?
- otherConversion String? @unique
- group Group? @relation(fields: [groupId], references: [id], onDelete: Cascade)
- paidByUser User @relation(name: "PaidByUser", fields: [paidBy], references: [id], onDelete: Cascade)
- addedByUser User @relation(name: "AddedByUser", fields: [addedBy], references: [id], onDelete: Cascade)
- deletedByUser User? @relation(name: "DeletedByUser", fields: [deletedBy], references: [id], onDelete: Cascade)
- updatedByUser User? @relation(name: "UpdatedByUser", fields: [updatedBy], references: [id], onDelete: SetNull)
- conversionTo Expense? @relation(name: "CurrencyConversion", fields: [otherConversion], references: [id], onDelete: Cascade)
- conversionFrom Expense? @relation(name: "CurrencyConversion")
- expenseParticipants ExpenseParticipant[]
- expenseNotes ExpenseNote[]
- transactionId String?
-
- @@index([groupId])
- @@index([paidBy])
+ id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
+ paidBy Int
+ addedBy Int
+ name String
+ category String
+ amount BigInt
+ splitType SplitType @default(EQUAL)
+ expenseDate DateTime @default(now())
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+ currency String
+ fileKey String?
+ groupId Int?
+ deletedAt DateTime?
+ deletedBy Int?
+ updatedBy Int?
+ otherConversion String? @unique @db.Uuid
+ recurrenceId Int?
+ group Group? @relation(fields: [groupId], references: [id], onDelete: Cascade)
+ paidByUser User @relation(name: "PaidByUser", fields: [paidBy], references: [id], onDelete: Cascade)
+ addedByUser User @relation(name: "AddedByUser", fields: [addedBy], references: [id], onDelete: Cascade)
+ deletedByUser User? @relation(name: "DeletedByUser", fields: [deletedBy], references: [id], onDelete: Cascade)
+ updatedByUser User? @relation(name: "UpdatedByUser", fields: [updatedBy], references: [id], onDelete: SetNull)
+ conversionTo Expense? @relation(name: "CurrencyConversion", fields: [otherConversion], references: [id], onDelete: Cascade)
+ conversionFrom Expense? @relation(name: "CurrencyConversion")
+ recurrence ExpenseRecurrence? @relation(fields: [recurrenceId], references: [id], onDelete: Cascade)
+ expenseParticipants ExpenseParticipant[]
+ expenseNotes ExpenseNote[]
+ transactionId String?
+
+ @@index([groupId])
+ @@index([paidBy])
+ @@schema("public")
}
model ExpenseParticipant {
- expenseId String
- userId Int
- amount BigInt
- user User @relation(fields: [userId], references: [id], onDelete: Cascade)
- expense Expense @relation(fields: [expenseId], references: [id], onDelete: Cascade)
+ expenseId String @db.Uuid
+ userId Int
+ amount BigInt
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
+ expense Expense @relation(fields: [expenseId], references: [id], onDelete: Cascade)
- @@id([expenseId, userId])
+ @@id([expenseId, userId])
+ @@schema("public")
}
model ExpenseNote {
- id String @id @default(cuid())
- expenseId String
- note String
- createdById Int
- createdAt DateTime @default(now())
- createdBy User @relation(fields: [createdById], references: [id], onDelete: Cascade)
- expense Expense @relation(fields: [expenseId], references: [id], onDelete: Cascade)
+ id String @id @default(cuid())
+ expenseId String @db.Uuid
+ note String
+ createdById Int
+ createdAt DateTime @default(now())
+ createdBy User @relation(fields: [createdById], references: [id], onDelete: Cascade)
+ expense Expense @relation(fields: [expenseId], references: [id], onDelete: Cascade)
+
+ @@schema("public")
+}
+
+model ExpenseRecurrence {
+ id Int @id @default(autoincrement())
+ jobId BigInt @unique
+ notified Boolean @default(true)
+ expense Expense[]
+ job job @relation(fields: [jobId], references: [jobid], onDelete: Cascade)
+
+ @@schema("public")
}
model CurrencyRateCache {
- from String
- to String
- date DateTime
- rate Float
- insertedAt DateTime @default(now())
+ from String
+ to String
+ date DateTime
+ rate Float
+ insertedAt DateTime @default(now())
- @@id([from, to, date])
+ @@id([from, to, date])
+ @@schema("public")
}
model PushNotification {
- userId Int @id
- subscription String
+ userId Int @id
+ subscription String
+
+ @@schema("public")
+}
+
+/// This model contains row level security and requires additional setup for migrations. Visit https://pris.ly/d/row-level-security for more info.
+model job {
+ jobid BigInt @id @default(autoincrement())
+ schedule String
+ command String
+ nodename String @default("localhost")
+ nodeport Int @default(dbgenerated("inet_server_port()"))
+ database String @default(dbgenerated("current_database()"))
+ username String @default(dbgenerated("CURRENT_USER"))
+ active Boolean @default(true)
+ jobname String?
+
+ recurrence ExpenseRecurrence? @relation()
+ runDetails job_run_details[] @relation("job_run_details")
+
+ @@unique([jobname, username], map: "jobname_username_uniq")
+ @@schema("cron")
+}
+
+/// This model contains row level security and requires additional setup for migrations. Visit https://pris.ly/d/row-level-security for more info.
+model job_run_details {
+ jobid BigInt?
+ runid BigInt @id @default(autoincrement())
+ job_pid Int?
+ database String?
+ username String?
+ command String?
+ status String?
+ return_message String?
+ start_time DateTime? @db.Timestamptz(6)
+ end_time DateTime? @db.Timestamptz(6)
+
+ job job? @relation("job_run_details", fields: [jobid], references: [jobid], onDelete: Cascade)
+
+ @@schema("cron")
}
diff --git a/public/locales/en/common.json b/public/locales/en/common.json
index 58fc167c..ca64e2db 100644
--- a/public/locales/en/common.json
+++ b/public/locales/en/common.json
@@ -129,7 +129,8 @@
"unsubscribe_error": "Error unsubscribing notification",
"upload_failed": "Failed to upload file",
"uploading_error": "Error uploading file",
- "valid_email": "Enter valid email"
+ "valid_email": "Enter valid email",
+ "invalid_cron_expression": "Invalid cron expression"
},
"bank_transactions": {
"choose_bank_provider": "Choose bank provider",
@@ -301,9 +302,31 @@
"activity": "Activity",
"add": "Add",
"add_expense": "Add Expense",
+ "recurring": "Recurring",
"balances": "Balances",
"groups": "Groups"
},
+ "recurrence": {
+ "title": "Recurrence",
+ "description": "Set up automatic recurrence for this expense.",
+ "cron_expression": "Cron Expression",
+ "time_of_day": "Time of Day",
+ "days_of_week": "Days of Week",
+ "days_of_month": "Days of Month",
+ "months": "Months",
+ "never": "No schedule configured",
+ "empty": "No recurring expenses yet",
+ "recurring": "Recurring",
+ "expense_for_the_amount_of": "Expense {{name}} for the amount of {{amount}} {{currency}}",
+ "schedule_type": {
+ "never": "Never",
+ "custom": "Custom",
+ "day": "Daily",
+ "month": "Monthly",
+ "week": "Weekly",
+ "year": "Yearly"
+ }
+ },
"ui": {
"added_by": "Added by",
"and": "and",
diff --git a/src/components/AddExpense/AddExpensePage.tsx b/src/components/AddExpense/AddExpensePage.tsx
index a8c6afa4..cfc08a30 100644
--- a/src/components/AddExpense/AddExpensePage.tsx
+++ b/src/components/AddExpense/AddExpensePage.tsx
@@ -1,4 +1,4 @@
-import { HeartHandshakeIcon, Landmark, X } from 'lucide-react';
+import { HeartHandshakeIcon, Landmark, RefreshCcwDot, X } from 'lucide-react';
import { useTranslation } from 'next-i18next';
import Link from 'next/link';
import { useRouter } from 'next/router';
@@ -11,6 +11,7 @@ import { currencyConversion, toSafeBigInt, toUIString } from '~/utils/numbers';
import { toast } from 'sonner';
import { useTranslationWithUtils } from '~/hooks/useTranslationWithUtils';
+import { cronToBackend } from '~/lib/cron';
import { cn } from '~/lib/utils';
import { CurrencyConversion } from '../Friend/CurrencyConversion';
import { Button } from '../ui/button';
@@ -20,6 +21,7 @@ import AddBankTransactions from './AddBankTransactions';
import { CategoryPicker } from './CategoryPicker';
import { CurrencyPicker } from './CurrencyPicker';
import { DateSelector } from './DateSelector';
+import { RecurrenceInput } from './RecurrenceInput';
import { SelectUserOrGroup } from './SelectUserOrGroup';
import { SplitTypeSection } from './SplitTypeSection';
import { UploadFile } from './UploadFile';
@@ -48,6 +50,7 @@ export const AddOrEditExpensePage: React.FC<{
const splitType = useAddExpenseStore((s) => s.splitType);
const fileKey = useAddExpenseStore((s) => s.fileKey);
const transactionId = useAddExpenseStore((s) => s.transactionId);
+ const cronExpression = useAddExpenseStore((s) => s.cronExpression);
const {
setCurrency,
@@ -120,6 +123,7 @@ export const AddOrEditExpensePage: React.FC<{
expenseDate,
expenseId,
transactionId,
+ cronExpression: cronExpression ? cronToBackend(cronExpression) : undefined,
},
{
onSuccess: (d) => {
@@ -162,6 +166,7 @@ export const AddOrEditExpensePage: React.FC<{
setMultipleTransactions,
transactionId,
setIsTransactionLoading,
+ cronExpression,
]);
const handleDescriptionChange = useCallback(
@@ -275,7 +280,7 @@ export const AddOrEditExpensePage: React.FC<{
<>
+ {cronString.value}{' '} + + cron({cronExpression}) + +
+ ); + else + return ( ++ {t('errors.invalid_cron_expression')} +
+ ); + })()} +{toUIDate(e.expense.expenseDate)}
{t('navigation.recurring')}
++ {t('recurrence.expense_for_the_amount_of', { + name: e.expense.name, + amount: toUIString(e.expense.amount), + currency: e.expense.currency, + })} +
++ {t('recurrence.recurring')} + {i18nReady + ? `: + + ${cronParser(cronFromBackend(e.job.schedule))}` + : ''} +
+{toUIDate(e.expense.expenseDate)}
+