@@ -82,18 +94,20 @@ const MyApp: AppType<{ session: Session | null }> = ({
-
-
-
-
- {(Component as NextPageWithUser).auth ? (
-
- ) : (
-
- )}{' '}
-
-
-
+
+
+
+
+
+ {(Component as NextPageWithUser).auth ? (
+
+ ) : (
+
+ )}{' '}
+
+
+
+
);
};
diff --git a/src/pages/account.tsx b/src/pages/account.tsx
index 3f37ad261..f63c204da 100644
--- a/src/pages/account.tsx
+++ b/src/pages/account.tsx
@@ -10,7 +10,7 @@ import {
Star,
} from 'lucide-react';
import type { GetServerSideProps } from 'next';
-import { signOut } from 'next-auth/react';
+import { signOut } from '~/lib/auth-client';
import { useTranslation } from 'next-i18next';
import Head from 'next/head';
import { useRouter } from 'next/router';
diff --git a/src/pages/activity.tsx b/src/pages/activity.tsx
index 62e66c648..56ddaa99b 100644
--- a/src/pages/activity.tsx
+++ b/src/pages/activity.tsx
@@ -1,5 +1,5 @@
-import { SplitType } from '@prisma/client';
-import { type User } from 'next-auth';
+import { SplitType } from '~/server/db/types';
+import type { SessionUser as User } from '~/server/auth';
import Head from 'next/head';
import Link from 'next/link';
import MainLayout from '~/components/Layout/MainLayout';
diff --git a/src/pages/add.tsx b/src/pages/add.tsx
index d26d049e7..96319d40f 100644
--- a/src/pages/add.tsx
+++ b/src/pages/add.tsx
@@ -65,7 +65,7 @@ const AddPage: NextPageWithUser<{
setCurrentUser({
...user,
defaultCurrency: user.defaultCurrency ?? null,
- emailVerified: null,
+ emailVerified: true,
name: user.name ?? null,
email: user.email ?? null,
image: user.image ?? null,
diff --git a/src/pages/api/auth/[...all].ts b/src/pages/api/auth/[...all].ts
new file mode 100644
index 000000000..7a5fab6ef
--- /dev/null
+++ b/src/pages/api/auth/[...all].ts
@@ -0,0 +1,7 @@
+import { auth } from '~/server/auth';
+
+export const config = { runtime: 'edge' };
+
+export default function handler(request: Request) {
+ return auth.handler(request);
+}
diff --git a/src/pages/api/auth/[...nextauth].ts b/src/pages/api/auth/[...nextauth].ts
deleted file mode 100644
index 11410e3ab..000000000
--- a/src/pages/api/auth/[...nextauth].ts
+++ /dev/null
@@ -1,5 +0,0 @@
-import NextAuth from 'next-auth';
-
-import { authOptions } from '~/server/auth';
-
-export default NextAuth(authOptions);
diff --git a/src/pages/api/client-errors.ts b/src/pages/api/client-errors.ts
new file mode 100644
index 000000000..3edd4654a
--- /dev/null
+++ b/src/pages/api/client-errors.ts
@@ -0,0 +1,55 @@
+import { getSessionFromHeaders } from '~/server/auth';
+import { logError } from '~/server/observability/errorLog';
+
+export const config = { runtime: 'edge' };
+
+const MAX_BODY_BYTES = 16 * 1024;
+const MAX_FIELD = 4000;
+
+const clip = (value: unknown, max = MAX_FIELD) =>
+ 'string' === typeof value ? value.slice(0, max) : undefined;
+
+/**
+ * Receives browser errors (window.onerror, unhandled rejections, React error boundary) and
+ * writes them to Workers Logs. Same-origin only, size-capped, no auth required so crashes on
+ * the sign-in page are captured too.
+ */
+export default async function handler(request: Request) {
+ if ('POST' !== request.method) {
+ return new Response(null, { status: 405 });
+ }
+
+ const origin = request.headers.get('origin');
+ if (origin && new URL(origin).host !== new URL(request.url).host) {
+ return new Response(null, { status: 403 });
+ }
+
+ const raw = await request.text();
+ if (raw.length > MAX_BODY_BYTES) {
+ return new Response(null, { status: 413 });
+ }
+
+ let body: Record
;
+ try {
+ body = JSON.parse(raw) as Record;
+ } catch {
+ return new Response(null, { status: 400 });
+ }
+
+ const session = await getSessionFromHeaders(request.headers).catch(() => null);
+
+ const error = new Error(clip(body.message) ?? 'Unknown browser error');
+ error.name = clip(body.name, 200) ?? 'BrowserError';
+ error.stack = clip(body.stack);
+
+ logError('browser', error, {
+ kind: clip(body.kind, 50),
+ url: clip(body.url, 1000),
+ componentStack: clip(body.componentStack),
+ userAgent: clip(request.headers.get('user-agent'), 300),
+ userId: session?.user.id,
+ rayId: request.headers.get('cf-ray') ?? undefined,
+ });
+
+ return new Response(null, { status: 204 });
+}
diff --git a/src/pages/api/files/[...path].ts b/src/pages/api/files/[...path].ts
index 7269ea3f2..dcd045924 100644
--- a/src/pages/api/files/[...path].ts
+++ b/src/pages/api/files/[...path].ts
@@ -1,45 +1,36 @@
-import fs from 'node:fs/promises';
-import path from 'node:path';
-import type { NextApiRequest, NextApiResponse } from 'next';
-import { getServerSession } from 'next-auth/next';
+import { getSessionFromHeaders } from '~/server/auth';
+import { bindings } from '~/server/db';
-import { authOptions } from '~/server/auth';
-import { fileExists } from '~/utils/file';
+export const config = { runtime: 'edge' };
-const UPLOAD_DIR = path.join(process.cwd(), 'uploads');
+const PREFIX = '/api/files/';
-export default async function handler(req: NextApiRequest, res: NextApiResponse) {
- if ('GET' !== req.method) {
- return res.status(405).end();
+/** Serves receipts from R2. Any signed-in user can read, as before. */
+export default async function handler(request: Request) {
+ if ('GET' !== request.method) {
+ return new Response(null, { status: 405 });
}
- const session = await getServerSession(req, res, authOptions);
+ const session = await getSessionFromHeaders(request.headers);
if (!session) {
- return res.status(403).send('Unauthorized');
+ return new Response('Unauthorized', { status: 403 });
}
- const { path: pathParts } = req.query;
-
- if (!pathParts || !Array.isArray(pathParts)) {
- return res.status(400).send('Invalid path');
+ const { pathname } = new URL(request.url);
+ const key = decodeURIComponent(pathname.slice(pathname.indexOf(PREFIX) + PREFIX.length));
+ // Keys look like "/" (legacy: "/.webp").
+ if (!/^\d+\/[0-9a-f-]{36}(-thumb)?(\.webp)?$/i.test(key)) {
+ return new Response('Invalid path', { status: 400 });
}
- const filePath = path.join(UPLOAD_DIR, ...pathParts);
- const resolvedPath = path.resolve(filePath);
-
- if (!resolvedPath.startsWith(path.resolve(UPLOAD_DIR))) {
- return res.status(403).send('Forbidden');
+ const object = await bindings.UPLOADS.get(key);
+ if (!object) {
+ return new Response('File not found', { status: 404 });
}
- if (!(await fileExists(filePath))) {
- return res.status(404).send('File not found');
- }
-
- res.setHeader('Content-Type', 'image/webp');
- res.setHeader('Cache-Control', 'private, max-age=31536000, immutable');
-
- const fileStream = await fs
- .open(filePath, 'r')
- .then((fileHandle) => fileHandle.createReadStream());
- fileStream.pipe(res);
+ const headers = new Headers();
+ object.writeHttpMetadata(headers);
+ headers.set('etag', object.httpEtag);
+ headers.set('Cache-Control', 'private, max-age=31536000, immutable');
+ return new Response(object.body, { headers });
}
diff --git a/src/pages/api/readyz.ts b/src/pages/api/readyz.ts
index 37b6d0a53..9ea0270c2 100644
--- a/src/pages/api/readyz.ts
+++ b/src/pages/api/readyz.ts
@@ -1,39 +1,28 @@
-import { Prisma } from '@prisma/client';
-import type { NextApiRequest, NextApiResponse } from 'next';
+import { bindings } from '~/server/db';
-import { db } from '~/server/db';
+export const config = { runtime: 'edge' };
const DB_CHECK_TIMEOUT_MS = 3000;
-/**
- * Readiness check. Verifies the database is reachable, bounded by a short timeout so a
- * hung connection fails the check quickly instead of leaving the request pending. The
- * timeout is enforced by Postgres itself (`statement_timeout`, scoped to this transaction
- * via `SET LOCAL`) rather than raced client-side, so a slow query is actually cancelled
- * instead of left running against the connection pool. Meant for load balancers/
- * orchestrators to stop routing traffic here without restarting the process - see
- * `/api/healthz` for the dependency-free liveness check.
- *
- * This only bounds queries that reach Postgres - it can't help if the socket itself is
- * dead (see the `connect_timeout`/`socket_timeout` note in docs/CONFIGURATION.md), so it's
- * a complement to those connection-string options rather than a replacement for them.
- */
-export default async function handler(req: NextApiRequest, res: NextApiResponse) {
- if ('GET' !== req.method) {
- res.setHeader('Allow', 'GET');
- return res.status(405).json({ status: 'error', message: 'Method not allowed' });
+/** Readiness check: verifies D1 answers `SELECT 1` within a short timeout. */
+export default async function handler(request: Request) {
+ if ('GET' !== request.method) {
+ return Response.json(
+ { status: 'error', message: 'Method not allowed' },
+ { status: 405, headers: { Allow: 'GET' } },
+ );
}
try {
- // DB_CHECK_TIMEOUT_MS is a hardcoded constant, not user input, so inlining it via
- // Prisma.raw is safe here - `SET` does not accept bound query parameters.
- await db.$transaction([
- db.$executeRaw`SET LOCAL statement_timeout = ${Prisma.raw(String(DB_CHECK_TIMEOUT_MS))}`,
- db.$queryRaw`SELECT 1`,
+ await Promise.race([
+ bindings.DB.prepare('SELECT 1').first(),
+ new Promise((_, reject) =>
+ setTimeout(() => reject(new Error('timeout')), DB_CHECK_TIMEOUT_MS),
+ ),
]);
- return res.status(200).json({ status: 'ok' });
+ return Response.json({ status: 'ok' });
} catch (error) {
console.error('Readiness check failed:', error);
- return res.status(503).json({ status: 'error', message: 'Database not reachable' });
+ return Response.json({ status: 'error', message: 'Database not reachable' }, { status: 503 });
}
}
diff --git a/src/pages/api/session.ts b/src/pages/api/session.ts
new file mode 100644
index 000000000..7ccaec64c
--- /dev/null
+++ b/src/pages/api/session.ts
@@ -0,0 +1,9 @@
+import { getSessionFromHeaders } from '~/server/auth';
+
+export const config = { runtime: 'edge' };
+
+/** Returns the app session (with SplitPro user fields) for the client SessionProvider. */
+export default async function handler(request: Request) {
+ const session = await getSessionFromHeaders(request.headers);
+ return Response.json(session, { headers: { 'Cache-Control': 'private, no-store' } });
+}
diff --git a/src/pages/api/trpc/[trpc].ts b/src/pages/api/trpc/[trpc].ts
index 62bca418b..5af221491 100644
--- a/src/pages/api/trpc/[trpc].ts
+++ b/src/pages/api/trpc/[trpc].ts
@@ -1,17 +1,54 @@
-import { createNextApiHandler } from '@trpc/server/adapters/next';
+import { fetchRequestHandler } from '@trpc/server/adapters/fetch';
-import { env } from '~/env';
import { appRouter } from '~/server/api/root';
import { createTRPCContext } from '~/server/api/trpc';
+import { logError } from '~/server/observability/errorLog';
-// export API handler
-export default createNextApiHandler({
- router: appRouter,
- createContext: createTRPCContext,
- onError:
- 'development' === env.NODE_ENV
- ? ({ path, error }) => {
- console.error(`❌ tRPC failed on ${path ?? ''}: ${error.message}`);
- }
- : undefined,
-});
+export const config = { runtime: 'edge' };
+
+/** Client mistakes (bad input, auth) are expected; log them as warnings, not errors. */
+const EXPECTED_CODES = new Set([
+ 'BAD_REQUEST',
+ 'UNAUTHORIZED',
+ 'FORBIDDEN',
+ 'NOT_FOUND',
+ 'PARSE_ERROR',
+ 'METHOD_NOT_SUPPORTED',
+ 'TOO_MANY_REQUESTS',
+]);
+
+export default function handler(request: Request) {
+ let userId: number | undefined;
+
+ return fetchRequestHandler({
+ endpoint: '/api/trpc',
+ req: request,
+ router: appRouter,
+ createContext: async () => {
+ const ctx = await createTRPCContext({ req: { headers: request.headers } });
+ userId = ctx.session?.user.id;
+ return ctx;
+ },
+ onError: ({ path, type, error, input }) => {
+ const context = {
+ path,
+ type,
+ code: error.code,
+ userId,
+ input,
+ rayId: request.headers.get('cf-ray') ?? undefined,
+ };
+ if (EXPECTED_CODES.has(error.code)) {
+ console.warn(
+ JSON.stringify(
+ { level: 'warn', source: 'trpc', message: error.message, ...context },
+ (_k, v: unknown) => ('bigint' === typeof v ? v.toString() : v),
+ ),
+ );
+ return;
+ }
+ // Log the original error (with its stack), not the tRPC wrapper.
+ logError('trpc', error.cause ?? error, context);
+ },
+ });
+}
diff --git a/src/pages/api/upload.ts b/src/pages/api/upload.ts
index ff4d6b0b3..11cc098ba 100644
--- a/src/pages/api/upload.ts
+++ b/src/pages/api/upload.ts
@@ -1,88 +1,54 @@
-import { randomUUID } from 'node:crypto';
-import fs from 'node:fs/promises';
-import path from 'node:path';
-import type { NextApiRequest, NextApiResponse } from 'next';
-import { getServerSession } from 'next-auth/next';
-import sharp from 'sharp';
-
-import { authOptions } from '~/server/auth';
import { env } from '~/env';
+import { getSessionFromHeaders } from '~/server/auth';
+import { bindings } from '~/server/db';
-import { type File, formidable } from 'formidable';
-import { fileExists } from '~/utils/file';
-
-export const config = {
- api: {
- bodyParser: false,
- },
-};
+export const config = { runtime: 'edge' };
-const UPLOAD_DIR = path.join(process.cwd(), 'uploads');
+const ALLOWED_TYPES = new Set(['image/webp', 'image/jpeg', 'image/png', 'image/gif', 'image/avif']);
-export default async function handler(req: NextApiRequest, res: NextApiResponse) {
- if ('POST' !== req.method) {
- return res.status(405).json({ error: 'Method not allowed' });
+/**
+ * Stores a receipt in R2 under `/`. The browser already compresses images
+ * (browser-image-compression), so the server stores them as-is (no sharp on Workers).
+ */
+export default async function handler(request: Request) {
+ if ('POST' !== request.method) {
+ return Response.json({ error: 'Method not allowed' }, { status: 405 });
}
- const session = await getServerSession(req, res, authOptions);
+ const session = await getSessionFromHeaders(request.headers);
if (!session?.user) {
- return res.status(401).json({ error: 'Unauthorized' });
+ return Response.json({ error: 'Unauthorized' }, { status: 401 });
}
- if (!(await fileExists(UPLOAD_DIR))) {
- await fs.mkdir(UPLOAD_DIR, { recursive: true });
+ const maxBytes = env.UPLOAD_MAX_FILE_SIZE_MB * 1024 * 1024;
+ const declared = Number(request.headers.get('content-length') ?? 0);
+ if (declared > maxBytes + 64 * 1024) {
+ return Response.json({ error: 'File too large' }, { status: 413 });
}
- const form = formidable({
- keepExtensions: true,
- maxFileSize: env.UPLOAD_MAX_FILE_SIZE_MB * 1024 * 1024,
- });
-
- let uploadedFile: File | undefined;
-
try {
- const [, files] = await form.parse(req);
- uploadedFile = files.file?.[0];
-
- if (!uploadedFile) {
- return res.status(400).json({ error: 'No file provided' });
+ const form = await request.formData();
+ const file = form.get('file');
+ if (!(file instanceof File)) {
+ return Response.json({ error: 'No file provided' }, { status: 400 });
}
-
- const userId = String(session.user.id);
- const userDir = path.join(UPLOAD_DIR, userId);
-
- if (!(await fileExists(userDir))) {
- await fs.mkdir(userDir, { recursive: true });
+ if (file.size > maxBytes) {
+ return Response.json({ error: 'File too large' }, { status: 413 });
+ }
+ const contentType = file.type || 'application/octet-stream';
+ if (!ALLOWED_TYPES.has(contentType)) {
+ return Response.json({ error: 'Unsupported file type' }, { status: 415 });
}
- const fileUUID = randomUUID();
- const fileName = `${fileUUID}.webp`;
- const thumbName = `${fileUUID}-thumb.webp`;
- const finalPath = path.join(userDir, fileName);
- const thumbPath = path.join(userDir, thumbName);
-
- const fileBuffer = await fs.readFile(uploadedFile.filepath);
-
- await sharp(fileBuffer)
- .resize({ width: 1200, withoutEnlargement: true })
- .webp({ quality: 80 })
- .toFile(finalPath);
-
- await sharp(fileBuffer).resize(200).webp({ quality: 60 }).toFile(thumbPath);
-
- return res.status(200).json({
- key: `${userId}/${fileName}`,
+ const key = `${session.user.id}/${crypto.randomUUID()}`;
+ await bindings.UPLOADS.put(key, file.stream(), {
+ httpMetadata: { contentType },
+ customMetadata: { uploadedBy: String(session.user.id) },
});
+
+ return Response.json({ key });
} catch (error) {
console.error('Upload error:', error);
- return res.status(500).json({ error: 'Internal Server Error' });
- } finally {
- try {
- if (uploadedFile) {
- await fs.unlink(uploadedFile.filepath);
- }
- } catch {
- // Ignore errors
- }
+ return Response.json({ error: 'Internal Server Error' }, { status: 500 });
}
}
diff --git a/src/pages/auth/signin.tsx b/src/pages/auth/signin.tsx
index 2e2cfe3d7..367d1fabc 100644
--- a/src/pages/auth/signin.tsx
+++ b/src/pages/auth/signin.tsx
@@ -9,7 +9,7 @@ import {
SiKeycloak,
} from '@icons-pack/react-simple-icons';
import { type GetServerSideProps, type NextPage } from 'next';
-import { type ClientSafeProvider, getProviders, signIn } from 'next-auth/react';
+import { signIn } from '~/lib/auth-client';
import { type TFunction, useTranslation } from 'next-i18next';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useForm } from 'react-hook-form';
@@ -28,7 +28,11 @@ import {
import { Input } from '~/components/ui/input';
import { LoadingSpinner } from '~/components/ui/spinner';
import { env } from '~/env';
-import { getServerAuthSession } from '~/server/auth';
+import {
+ type AuthProvider as ClientSafeProvider,
+ getAuthProviders,
+ getServerAuthSession,
+} from '~/server/auth';
import { customServerSideTranslations } from '~/utils/i18n/server';
import VerificationStep from './VerificationStep';
@@ -74,29 +78,14 @@ const Home: NextPage<{
},
});
- // Client-side fallback for getProviders when server-side call fails
+ // Providers come from getServerSideProps; better-auth has no client provider listing.
useEffect(() => {
- if (serverProviders.length > 0) {
- return;
+ if (0 === serverProviders.length) {
+ toast.error(t('errors.no_providers'), { duration: 8000 });
}
-
- void (async () => {
- setIsLoadingProviders(true);
- try {
- const clientProviders = await getProviders();
- if (clientProviders && Object.keys(clientProviders).length > 0) {
- setProviders(Object.values(clientProviders));
- } else {
- throw new Error('No providers returned from getProviders()');
- }
- } catch (error) {
- console.error('Error fetching providers client-side:', error);
- toast.error(t('errors.no_providers'), { duration: 8000 });
- } finally {
- setIsLoadingProviders(false);
- }
- })();
- }, [serverProviders.length, t]);
+ setProviders(serverProviders);
+ setIsLoadingProviders(false);
+ }, [serverProviders, t]);
useEffect(() => {
if (error) {
@@ -138,9 +127,10 @@ const Home: NextPage<{
setEmailStatus('sending');
const email = emailForm.getValues().email.toLowerCase();
setShowVerificationStep(true);
- await signIn('email', { email, callbackUrl, redirect: false });
+ // Email sign-in is disabled on this deployment (Google only).
+ void email;
setEmailStatus('success');
- }, [emailForm, setShowVerificationStep, callbackUrl]);
+ }, [emailForm, setShowVerificationStep]);
useEffect(() => {
const onPop = (e: PopStateEvent) => {
@@ -290,12 +280,7 @@ export default Home;
export const getServerSideProps: GetServerSideProps = async (context) => {
const session = await getServerAuthSession(context);
- let providers: Record | null = null;
- try {
- providers = await getProviders();
- } catch (error) {
- console.error(error);
- }
+ const providers = getAuthProviders();
const { callbackUrl, error } = context.query;
if (session) {
@@ -315,7 +300,7 @@ export const getServerSideProps: GetServerSideProps = async (context) => {
...(await customServerSideTranslations(context.locale, ['common'])),
error: typeof error === 'string' ? error : '',
feedbackEmail: env.FEEDBACK_EMAIL ?? '',
- providers: Object.values(providers ?? {}),
+ providers,
callbackUrl: callbackUrl && !Array.isArray(callbackUrl) ? callbackUrl : '',
oauthAutoRedirect: env.OAUTH_AUTO_REDIRECT,
},
diff --git a/src/pages/balances/[friendId].tsx b/src/pages/balances/[friendId].tsx
index a5f87a0d0..0e9136e5a 100644
--- a/src/pages/balances/[friendId].tsx
+++ b/src/pages/balances/[friendId].tsx
@@ -96,7 +96,7 @@ const FriendPage: NextPageWithUser = ({ user }) => {
participants={[
{
...user,
- emailVerified: null,
+ emailVerified: true,
name: user.name ?? null,
email: user.email ?? null,
image: user.image ?? null,
diff --git a/src/pages/balances/[friendId]/expenses/[expenseId].tsx b/src/pages/balances/[friendId]/expenses/[expenseId].tsx
index a1b7beaff..4e6f841bb 100644
--- a/src/pages/balances/[friendId]/expenses/[expenseId].tsx
+++ b/src/pages/balances/[friendId]/expenses/[expenseId].tsx
@@ -1,4 +1,4 @@
-import { SplitType } from '@prisma/client';
+import { SplitType } from '~/server/db/types';
import { ChevronLeftIcon, PencilIcon } from 'lucide-react';
import { type GetServerSideProps } from 'next';
import { useTranslation } from 'next-i18next';
diff --git a/src/pages/expenses/[expenseId].tsx b/src/pages/expenses/[expenseId].tsx
index 4239c47a6..d4ea38559 100644
--- a/src/pages/expenses/[expenseId].tsx
+++ b/src/pages/expenses/[expenseId].tsx
@@ -1,4 +1,4 @@
-import { SplitType } from '@prisma/client';
+import { SplitType } from '~/server/db/types';
import { ChevronLeftIcon, PencilIcon } from 'lucide-react';
import { type GetServerSideProps } from 'next';
import { useTranslation } from 'next-i18next';
diff --git a/src/pages/groups/[groupId].tsx b/src/pages/groups/[groupId].tsx
index e4b824711..3f489fd4e 100644
--- a/src/pages/groups/[groupId].tsx
+++ b/src/pages/groups/[groupId].tsx
@@ -39,7 +39,9 @@ import { env } from '~/env';
import { useTranslationWithUtils } from '~/hooks/useTranslationWithUtils';
import { deserializeDefaultSplit } from '~/lib/defaultSplit';
import { isCurrencyCode, parseCurrencyCode } from '~/lib/currency';
+import { eq } from 'drizzle-orm';
import { db } from '~/server/db';
+import { groups } from '~/server/db/schema';
import { type NextPageWithUser } from '~/types';
import { api } from '~/utils/api';
import { customServerSideTranslations } from '~/utils/i18n/server';
@@ -579,10 +581,9 @@ BalancePage.auth = true;
export default BalancePage;
export const getServerSideProps: GetServerSideProps = async (context) => {
- const group = await db.group.findFirst({
- where: {
- id: Number(context.query.groupId),
- },
+ const group = await db.query.groups.findFirst({
+ where: eq(groups.id, Number(context.query.groupId)),
+ columns: { id: true },
});
if (!group) {
diff --git a/src/pages/groups/[groupId]/expenses/[expenseId].tsx b/src/pages/groups/[groupId]/expenses/[expenseId].tsx
index 2465290b3..a929ac199 100644
--- a/src/pages/groups/[groupId]/expenses/[expenseId].tsx
+++ b/src/pages/groups/[groupId]/expenses/[expenseId].tsx
@@ -1,4 +1,4 @@
-import { SplitType } from '@prisma/client';
+import { SplitType } from '~/server/db/types';
import { ChevronLeftIcon, PencilIcon } from 'lucide-react';
import { type GetServerSideProps } from 'next';
import { useTranslation } from 'next-i18next';
diff --git a/src/server/api/routers/bankTransactions.ts b/src/server/api/routers/bankTransactions.ts
index 62915ba77..cf9ca2d37 100644
--- a/src/server/api/routers/bankTransactions.ts
+++ b/src/server/api/routers/bankTransactions.ts
@@ -1,3 +1,6 @@
+import { eq } from 'drizzle-orm';
+
+import { users } from '~/server/db/schema';
import { createTRPCRouter, protectedProcedure } from '../trpc';
import { z } from 'zod';
import { whichBankConnectionConfigured } from '~/server/bankTransactionHelper';
@@ -35,14 +38,10 @@ export const bankTransactionsRouter = createTRPCRouter({
throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Failed to link to bank' });
}
- await ctx.db.user.update({
- where: {
- id: ctx.session.user.id,
- },
- data: {
- obapiProviderId: res.institutionId,
- },
- });
+ await ctx.db
+ .update(users)
+ .set({ obapiProviderId: res.institutionId })
+ .where(eq(users.id, ctx.session.user.id));
return res;
}
@@ -77,14 +76,10 @@ export const bankTransactionsRouter = createTRPCRouter({
});
}
- await ctx.db.user.update({
- where: {
- id: ctx.session.user.id,
- },
- data: {
- obapiProviderId: res.accessToken,
- },
- });
+ await ctx.db
+ .update(users)
+ .set({ obapiProviderId: res.accessToken })
+ .where(eq(users.id, ctx.session.user.id));
return res;
}
diff --git a/src/server/api/routers/expense.ts b/src/server/api/routers/expense.ts
index afc6f354a..6af5eab77 100644
--- a/src/server/api/routers/expense.ts
+++ b/src/server/api/routers/expense.ts
@@ -3,7 +3,28 @@ import { z } from 'zod';
import { simplifyDebts } from '~/lib/simplify';
import { createTRPCRouter, groupProcedure, protectedProcedure } from '~/server/api/trpc';
+import {
+ and,
+ desc,
+ eq,
+ exists,
+ inArray,
+ isNotNull,
+ isNull,
+ ne,
+ notInArray,
+ or,
+ sql,
+} from 'drizzle-orm';
+
import { db } from '~/server/db';
+import {
+ balanceView,
+ expenseParticipants,
+ expenses as expensesTable,
+ groupUsers,
+ groups,
+} from '~/server/db/schema';
import { BigMath, currencyConversion } from '~/utils/numbers';
import {
@@ -16,46 +37,73 @@ import {
import { createExpense, deleteExpense, editExpense } from '../services/splitService';
import { currencyRateProvider } from '../services/currencyRateService';
import { type CurrencyCode, isCurrencyCode } from '~/lib/currency';
-import { SplitType } from '@prisma/client';
+import { SplitType } from '~/server/db/types';
import { DEFAULT_CATEGORY } from '~/lib/category';
import { getUserMap } from './user';
-import { FriendBalance } from '~/components/Friend/FriendBalance';
+import { deleteJob, updateJobSchedule } from '../services/scheduleService';
+
+/** Correlated EXISTS: the expense identified by `expenseIdCol` has participant `userId`. */
+const hasParticipant = (expenseIdCol: typeof expensesTable.id, userId: number, nonZero = false) =>
+ exists(
+ db
+ .select({ one: sql`1` })
+ .from(expenseParticipants)
+ .where(
+ and(
+ eq(expenseParticipants.expenseId, expenseIdCol),
+ eq(expenseParticipants.userId, userId),
+ nonZero ? ne(expenseParticipants.amount, 0n) : undefined,
+ ),
+ ),
+ );
+
+/** Hide the "from" half of a currency conversion; the "to" half carries conversionToId. */
+const notConversionFromHalf = (e: typeof expensesTable._.columns) =>
+ or(ne(e.splitType, SplitType.CURRENCY_CONVERSION), isNotNull(e.conversionToId));
+
+const userSummaryColumns = { name: true, email: true, image: true, id: true } as const;
export const expenseRouter = createTRPCRouter({
getCumulatedBalances: protectedProcedure.query(async ({ ctx }) => {
- const cumulatedBalances = await db.balanceView.groupBy({
- by: ['currency'],
- _sum: { amount: true },
- where: { userId: ctx.session.user.id, amount: { not: 0 } },
- orderBy: { _sum: { amount: 'desc' } },
- });
+ const total = sql`SUM(${balanceView.amount})`.mapWith((v) => BigInt(v ?? 0));
+ const cumulatedBalances = (
+ await db
+ .select({ currency: balanceView.currency, amount: total })
+ .from(balanceView)
+ .where(and(eq(balanceView.userId, ctx.session.user.id), ne(balanceView.amount, 0n)))
+ .groupBy(balanceView.currency)
+ .orderBy(desc(total))
+ ).map((b) => ({ currency: b.currency, _sum: { amount: b.amount } }));
const youOwe = cumulatedBalances
.filter((b) => b._sum.amount && 0 > b._sum.amount)
- .map((b) => ({ currency: b.currency, amount: b._sum.amount! }))
+ .map((b) => ({ currency: b.currency, amount: b._sum.amount }))
.reverse();
const youGet = cumulatedBalances
.filter((b) => b._sum.amount && 0 < b._sum.amount)
- .map((b) => ({ currency: b.currency, amount: b._sum.amount! }));
+ .map((b) => ({ currency: b.currency, amount: b._sum.amount }));
return { youOwe, youGet };
}),
getBalances: protectedProcedure.query(async ({ ctx }) => {
- const rawBalances = await db.balanceView.findMany({
- where: {
- userId: ctx.session.user.id,
- friendId: { notIn: ctx.session.user.hiddenFriendIds },
- },
- include: {
- group: {
- select: {
- simplifyDebts: true,
- },
- },
- },
- });
+ const rawBalances = await db
+ .select({
+ friendId: balanceView.friendId,
+ currency: balanceView.currency,
+ amount: balanceView.amount,
+ groupId: balanceView.groupId,
+ group: { simplifyDebts: groups.simplifyDebts },
+ })
+ .from(balanceView)
+ .leftJoin(groups, eq(groups.id, balanceView.groupId))
+ .where(
+ and(
+ eq(balanceView.userId, ctx.session.user.id),
+ notInArray(balanceView.friendId, ctx.session.user.hiddenFriendIds),
+ ),
+ );
const processedBalances = await Promise.all(
rawBalances.map(async ({ friendId, currency, amount, groupId, group }) => {
@@ -63,9 +111,10 @@ export const expenseRouter = createTRPCRouter({
return { friendId, currency, amount };
}
- const allGroupBalances = await db.balanceView.findMany({
- where: { groupId, currency },
- });
+ const allGroupBalances = await db
+ .select()
+ .from(balanceView)
+ .where(and(eq(balanceView.groupId, groupId), eq(balanceView.currency, currency)));
const simplified = simplifyDebts(allGroupBalances);
const simplifiedBalance = simplified.find(
@@ -142,16 +191,7 @@ export const expenseRouter = createTRPCRouter({
}
if (input.groupId !== null) {
- const group = await db.group.findUnique({
- where: { id: input.groupId },
- select: { archivedAt: true },
- });
- if (!group) {
- throw new TRPCError({ code: 'BAD_REQUEST', message: 'Group not found' });
- }
- if (group.archivedAt) {
- throw new TRPCError({ code: 'BAD_REQUEST', message: 'Group is archived' });
- }
+ await assertCanWriteToGroup(input.groupId, ctx.session.user.id);
}
try {
@@ -177,6 +217,13 @@ export const expenseRouter = createTRPCRouter({
.mutation(async ({ input, ctx }) => {
const { amount, rate, from, to, senderId, receiverId, groupId, expenseId } = input;
+ if (expenseId) {
+ await validateEditExpensePermission(expenseId, ctx.session.user.id);
+ }
+ if (groupId !== null && groupId !== undefined) {
+ await assertCanWriteToGroup(groupId, ctx.session.user.id);
+ }
+
if (!isCurrencyCode(from) || !isCurrencyCode(to)) {
throw new TRPCError({ code: 'BAD_REQUEST', message: 'Invalid currency code' });
}
@@ -231,83 +278,24 @@ export const expenseRouter = createTRPCRouter({
getExpensesWithFriend: protectedProcedure
.input(z.object({ friendId: z.number() }))
.query(async ({ input, ctx }) => {
- const expenses = await db.expense.findMany({
- where: {
- AND: [
- {
- expenseParticipants: {
- some: {
- userId: input.friendId,
- amount: {
- not: 0n,
- },
- },
- },
- },
- {
- expenseParticipants: {
- some: {
- userId: ctx.session.user.id,
- amount: {
- not: 0n,
- },
- },
- },
- },
- {
- OR: [
- {
- paidBy: ctx.session.user.id,
- },
- {
- paidBy: input.friendId,
- },
- ],
- },
- {
- deletedBy: null,
- },
- {
- OR: [
- {
- NOT: {
- splitType: SplitType.CURRENCY_CONVERSION,
- },
- },
- {
- NOT: {
- conversionToId: null,
- },
- },
- ],
- },
- ],
- },
- orderBy: {
- expenseDate: 'desc',
- },
- include: {
+ const me = ctx.session.user.id;
+ const expenses = await db.query.expenses.findMany({
+ where: (e) =>
+ and(
+ hasParticipant(e.id, input.friendId, true),
+ hasParticipant(e.id, me, true),
+ or(eq(e.paidBy, me), eq(e.paidBy, input.friendId)),
+ isNull(e.deletedBy),
+ notConversionFromHalf(e),
+ ),
+ orderBy: (e) => [desc(e.expenseDate)],
+ with: {
expenseParticipants: {
- where: {
- OR: [
- {
- userId: ctx.session.user.id,
- },
- {
- userId: input.friendId,
- },
- ],
- },
+ where: (ep) => inArray(ep.userId, [me, input.friendId]),
},
paidByUser: true,
conversionTo: true,
- group: {
- select: {
- id: true,
- name: true,
- simplifyDebts: true,
- },
- },
+ group: { columns: { id: true, name: true, simplifyDebts: true } },
},
});
@@ -317,27 +305,11 @@ export const expenseRouter = createTRPCRouter({
getGroupExpenses: groupProcedure
.input(z.object({ groupId: z.number() }))
.query(async ({ input, ctx }) => {
- const expenses = await ctx.db.expense.findMany({
- where: {
- groupId: input.groupId,
- deletedBy: null,
- OR: [
- {
- NOT: {
- splitType: SplitType.CURRENCY_CONVERSION,
- },
- },
- {
- NOT: {
- conversionToId: null,
- },
- },
- ],
- },
- orderBy: {
- expenseDate: 'desc',
- },
- include: {
+ const expenses = await ctx.db.query.expenses.findMany({
+ where: (e) =>
+ and(eq(e.groupId, input.groupId), isNull(e.deletedBy), notConversionFromHalf(e)),
+ orderBy: (e) => [desc(e.expenseDate)],
+ with: {
expenseParticipants: true,
paidByUser: true,
deletedByUser: true,
@@ -350,60 +322,34 @@ export const expenseRouter = createTRPCRouter({
getExpenseDetails: protectedProcedure
.input(z.object({ expenseId: z.string() }))
- .query(async ({ input }) => {
- const expense = await db.expense.findUnique({
- where: {
- id: input.expenseId,
- },
- include: {
- expenseParticipants: {
- include: {
- user: true,
- },
- },
+ .query(async ({ input, ctx }) => {
+ const expense = await db.query.expenses.findFirst({
+ where: eq(expensesTable.id, input.expenseId),
+ with: {
+ expenseParticipants: { with: { user: true } },
expenseNotes: true,
addedByUser: true,
paidByUser: true,
deletedByUser: true,
updatedByUser: true,
group: true,
- recurrence: {
- include: {
- job: {
- select: {
- schedule: true,
- command: true,
- },
- },
- },
- },
- conversionTo: {
- include: {
- expenseParticipants: {
- include: {
- user: true,
- },
- },
- },
- },
+ recurrence: { with: { job: { columns: { schedule: true, command: true } } } },
+ conversionTo: { with: { expenseParticipants: { with: { user: true } } } },
},
});
+ if (expense && !(await canAccessExpense(expense.id, ctx.session.user.id))) {
+ throw new TRPCError({ code: 'NOT_FOUND', message: 'Expense not found' });
+ }
+
if (expense && expense.groupId !== null) {
- const missingGroupMembers = await db.group.findUnique({
- where: {
- id: expense.groupId,
- },
- include: {
+ const participantIds = expense.expenseParticipants.map((ep) => ep.userId);
+ const missingGroupMembers = await db.query.groups.findFirst({
+ where: eq(groups.id, expense.groupId),
+ with: {
groupUsers: {
- include: {
- user: true,
- },
- where: {
- userId: {
- notIn: expense.expenseParticipants.map((ep) => ep.userId),
- },
- },
+ where: (gu) => notInArray(gu.userId, participantIds),
+ with: { user: true },
},
},
});
@@ -425,68 +371,34 @@ export const expenseRouter = createTRPCRouter({
}),
getAllExpenses: protectedProcedure.query(async ({ ctx }) => {
- const expenses = await db.expenseParticipant.findMany({
- where: {
- userId: ctx.session.user.id,
- },
- orderBy: {
- expense: {
- createdAt: 'desc',
- },
- },
- include: {
- expense: {
- include: {
- paidByUser: {
- select: {
- name: true,
- email: true,
- image: true,
- id: true,
- },
- },
- deletedByUser: {
- select: {
- name: true,
- email: true,
- image: true,
- id: true,
- },
+ const expenses = (
+ await db.query.expenseParticipants.findMany({
+ where: eq(expenseParticipants.userId, ctx.session.user.id),
+ with: {
+ expense: {
+ with: {
+ paidByUser: { columns: userSummaryColumns },
+ deletedByUser: { columns: userSummaryColumns },
},
},
},
- },
- });
+ })
+ ).sort((a, b) => b.expense.createdAt.getTime() - a.expense.createdAt.getTime());
return expenses;
}),
getRecurringExpenses: protectedProcedure.query(async ({ ctx }) => {
- const recurrences = await db.expenseRecurrence.findMany({
- include: {
+ const me = ctx.session.user.id;
+ const recurrences = await db.query.expenseRecurrences.findMany({
+ with: {
job: true,
expense: {
- take: 1,
- orderBy: { createdAt: 'desc' },
- where: {
- deletedBy: null,
- expenseParticipants: {
- some: {
- userId: ctx.session.user.id,
- },
- },
- recurrenceId: { not: null },
- },
- include: {
- addedByUser: {
- select: {
- name: true,
- email: true,
- image: true,
- id: true,
- },
- },
- },
+ limit: 1,
+ orderBy: (e) => [desc(e.createdAt)],
+ where: (e) =>
+ and(isNull(e.deletedBy), hasParticipant(e.id, me), isNotNull(e.recurrenceId)),
+ with: { addedByUser: { columns: userSummaryColumns } },
},
},
});
@@ -499,56 +411,26 @@ export const expenseRouter = createTRPCRouter({
deleteRecurrence: protectedProcedure
.input(z.object({ recurrenceId: z.number() }))
.mutation(async ({ input, ctx }) => {
- const recurrence = await db.expenseRecurrence.findUnique({
- where: { id: input.recurrenceId },
- include: {
- job: true,
- expense: {
- where: {
- expenseParticipants: {
- some: { userId: ctx.session.user.id },
- },
- },
- take: 1,
- },
- },
- });
+ const recurrence = await findUserRecurrence(input.recurrenceId, ctx.session.user.id);
if (!recurrence || 0 === recurrence.expense.length) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'Recurrence not found' });
}
- // Unschedule the cron job
- await db.$executeRaw`SELECT cron.unschedule(${recurrence.job.jobname})`;
-
- // Delete the recurrence (cascade nulls recurrenceId on expenses)
- await db.expenseRecurrence.delete({ where: { id: input.recurrenceId } });
+ // Deleting the job cascades to the recurrence, which nulls recurrenceId on expenses.
+ await deleteJob(recurrence.job.jobid);
}),
updateRecurrence: protectedProcedure
.input(z.object({ recurrenceId: z.number(), cronExpression: z.string() }))
.mutation(async ({ input, ctx }) => {
- const recurrence = await db.expenseRecurrence.findUnique({
- where: { id: input.recurrenceId },
- include: {
- job: true,
- expense: {
- where: {
- expenseParticipants: {
- some: { userId: ctx.session.user.id },
- },
- },
- take: 1,
- },
- },
- });
+ const recurrence = await findUserRecurrence(input.recurrenceId, ctx.session.user.id);
if (!recurrence || 0 === recurrence.expense.length) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'Recurrence not found' });
}
- // Use cron.alter_job to update the schedule
- await db.$executeRaw`SELECT cron.alter_job(${recurrence.job.jobid}, schedule := ${input.cronExpression})`;
+ await updateJobSchedule(recurrence.job.jobid, input.cronExpression);
}),
deleteExpense: protectedProcedure
@@ -556,9 +438,9 @@ export const expenseRouter = createTRPCRouter({
.mutation(async ({ input, ctx }) => {
await validateEditExpensePermission(input.expenseId, ctx.session.user.id);
- const expense = await db.expense.findUnique({
- where: { id: input.expenseId },
- select: { groupId: true },
+ const expense = await db.query.expenses.findFirst({
+ where: eq(expensesTable.id, input.expenseId),
+ columns: { groupId: true },
});
if (!expense) {
@@ -566,16 +448,7 @@ export const expenseRouter = createTRPCRouter({
}
if (expense.groupId !== null) {
- const group = await db.group.findUnique({
- where: { id: expense.groupId },
- select: { archivedAt: true },
- });
- if (!group) {
- throw new TRPCError({ code: 'BAD_REQUEST', message: 'Group not found' });
- }
- if (group.archivedAt) {
- throw new TRPCError({ code: 'BAD_REQUEST', message: 'Group is archived' });
- }
+ await assertCanWriteToGroup(expense.groupId, ctx.session.user.id);
}
await deleteExpense(input.expenseId, ctx.session.user.id);
@@ -626,20 +499,40 @@ export const expenseRouter = createTRPCRouter({
}),
});
-const validateEditExpensePermission = async (expenseId: string, userId: number): Promise => {
- const [expenseParticipant, addedBy] = await Promise.all([
- db.expenseParticipant.findUnique({
- where: {
- expenseId_userId: {
- expenseId: expenseId,
- userId: userId,
- },
- },
+/**
+ * Who may see or change an expense: its payer, the user who added it, a participant, or (for
+ * group expenses) any member of the group.
+ */
+const canAccessExpense = async (expenseId: string, userId: number): Promise => {
+ const expense = await db.query.expenses.findFirst({
+ where: eq(expensesTable.id, expenseId),
+ columns: { paidBy: true, addedBy: true, groupId: true },
+ });
+ if (!expense) {
+ return false;
+ }
+ if (expense.paidBy === userId || expense.addedBy === userId) {
+ return true;
+ }
+
+ const [participant, member] = await Promise.all([
+ db.query.expenseParticipants.findFirst({
+ where: and(
+ eq(expenseParticipants.expenseId, expenseId),
+ eq(expenseParticipants.userId, userId),
+ ),
}),
- db.expense.findUnique({ where: { id: expenseId }, select: { addedBy: true } }),
+ expense.groupId === null
+ ? undefined
+ : db.query.groupUsers.findFirst({
+ where: and(eq(groupUsers.groupId, expense.groupId), eq(groupUsers.userId, userId)),
+ }),
]);
+ return Boolean(participant ?? member);
+};
- if (!expenseParticipant && !addedBy?.addedBy) {
+const validateEditExpensePermission = async (expenseId: string, userId: number): Promise => {
+ if (!(await canAccessExpense(expenseId, userId))) {
throw new TRPCError({
code: 'UNAUTHORIZED',
message: 'You are not the participant of the expense',
@@ -647,4 +540,33 @@ const validateEditExpensePermission = async (expenseId: string, userId: number):
}
};
+/** The group must exist, be active, and the user must be a member. */
+const assertCanWriteToGroup = async (groupId: number, userId: number): Promise => {
+ const group = await db.query.groups.findFirst({
+ where: eq(groups.id, groupId),
+ columns: { archivedAt: true },
+ });
+ if (!group) {
+ throw new TRPCError({ code: 'BAD_REQUEST', message: 'Group not found' });
+ }
+ const membership = await db.query.groupUsers.findFirst({
+ where: and(eq(groupUsers.groupId, groupId), eq(groupUsers.userId, userId)),
+ });
+ if (!membership) {
+ throw new TRPCError({ code: 'FORBIDDEN', message: 'Not a group member' });
+ }
+ if (group.archivedAt) {
+ throw new TRPCError({ code: 'BAD_REQUEST', message: 'Group is archived' });
+ }
+};
+
+const findUserRecurrence = (recurrenceId: number, userId: number) =>
+ db.query.expenseRecurrences.findFirst({
+ where: (r) => eq(r.id, recurrenceId),
+ with: {
+ job: true,
+ expense: { where: (e) => hasParticipant(e.id, userId), limit: 1 },
+ },
+ });
+
export type ExpenseRouter = typeof expenseRouter;
diff --git a/src/server/api/routers/group.ts b/src/server/api/routers/group.ts
index 99f01455b..0806c7ad5 100644
--- a/src/server/api/routers/group.ts
+++ b/src/server/api/routers/group.ts
@@ -1,11 +1,16 @@
import { TRPCError } from '@trpc/server';
+import { and, desc, eq, inArray, isNull, ne, sql } from 'drizzle-orm';
import { nanoid } from 'nanoid';
import { z } from 'zod';
import { simplifyDebts } from '~/lib/simplify';
import { createTRPCRouter, groupProcedure, protectedProcedure } from '~/server/api/trpc';
import { sendGroupSimplifyDebtsToggleNotification } from '~/server/api/services/notificationService';
-import { SplitType } from '@prisma/client';
+import { SplitType } from '~/server/db/types';
+import { runBatch } from '~/server/db/batch';
+import { balanceView, expenses, groupDefaultSplits, groupUsers, groups } from '~/server/db/schema';
+import type { DB } from '~/server/db';
+import { runInBackground } from '~/server/observability/background';
import {
defaultSplitInputSchema,
deserializeDefaultSplit,
@@ -17,42 +22,32 @@ export const groupRouter = createTRPCRouter({
create: protectedProcedure
.input(z.object({ name: z.string().min(1) }))
.mutation(async ({ ctx, input }) => {
- const group = await ctx.db.group.create({
- data: {
- name: input.name,
- publicId: nanoid(),
+ const publicId = nanoid();
+ const [[group]] = (await runBatch([
+ ctx.db
+ .insert(groups)
+ .values({ name: input.name, publicId, userId: ctx.session.user.id })
+ .returning(),
+ ctx.db.insert(groupUsers).values({
+ groupId: sql`(SELECT ${groups.id} FROM ${groups} WHERE ${groups.publicId} = ${publicId})`,
userId: ctx.session.user.id,
- groupUsers: {
- create: {
- userId: ctx.session.user.id,
- },
- },
- },
- });
+ }),
+ ])) as [(typeof groups.$inferSelect)[]];
- return group;
+ return group!;
}),
getAllGroups: protectedProcedure.query(async ({ ctx }) => {
- const groups = await ctx.db.groupUser.findMany({
- where: {
- userId: ctx.session.user.id,
- },
- include: {
+ const userGroups = await ctx.db.query.groupUsers.findMany({
+ where: eq(groupUsers.userId, ctx.session.user.id),
+ with: {
group: {
- include: {
- groupUsers: {
- include: {
- user: true,
- },
- },
- groupDefaultSplit: true,
- },
+ with: { groupUsers: { with: { user: true } }, groupDefaultSplit: true },
},
},
});
- return groups.map((groupUser) => ({
+ return userGroups.map((groupUser) => ({
...groupUser,
group: {
...groupUser.group,
@@ -69,30 +64,37 @@ export const groupRouter = createTRPCRouter({
getAllGroupsWithBalances: protectedProcedure
.input(z.object({ getArchived: z.boolean() }).optional())
.query(async ({ ctx, input }) => {
- const groups = await ctx.db.groupUser.findMany({
- where: {
- userId: ctx.session.user.id,
- group: {
- archivedAt: input?.getArchived ? { not: null } : null,
- },
- },
- include: {
+ const me = ctx.session.user.id;
+ const memberships = await ctx.db.query.groupUsers.findMany({
+ where: eq(groupUsers.userId, me),
+ with: {
group: {
- include: {
- groupBalances: {
- where: { userId: ctx.session.user.id },
- },
- // We can sort by group balance view instead
- expenses: {
- orderBy: {
- createdAt: 'desc',
- },
- take: 1,
- },
- },
+ with: { expenses: { orderBy: (e) => [desc(e.createdAt)], limit: 1 } },
},
},
});
+ const filtered = memberships.filter((m) =>
+ input?.getArchived ? null !== m.group.archivedAt : null === m.group.archivedAt,
+ );
+ const myBalances =
+ 0 === filtered.length
+ ? []
+ : await ctx.db
+ .select()
+ .from(balanceView)
+ .where(
+ and(
+ eq(balanceView.userId, me),
+ inArray(
+ balanceView.groupId,
+ filtered.map((m) => m.groupId),
+ ),
+ ),
+ );
+ const groups = filtered.map((m) => ({
+ ...m,
+ group: { ...m.group, groupBalances: myBalances.filter((b) => b.groupId === m.groupId) },
+ }));
const sortedGroupsByLatestExpense = groups.sort((a, b) => {
const aDate = a.group.expenses[0]?.createdAt ?? new Date(0);
@@ -119,43 +121,28 @@ export const groupRouter = createTRPCRouter({
joinGroup: protectedProcedure
.input(z.object({ groupId: z.string() }))
.mutation(async ({ ctx, input }) => {
- const group = await ctx.db.group.findFirst({
- where: {
- publicId: input.groupId,
- },
+ const group = await ctx.db.query.groups.findFirst({
+ where: eq(groups.publicId, input.groupId),
});
if (!group) {
throw new Error('Group not found');
}
- await ctx.db.groupUser.create({
- data: {
- groupId: group.id,
- userId: ctx.session.user.id,
- },
- });
-
- await ctx.db.groupDefaultSplit.deleteMany({ where: { groupId: group.id } });
+ await runBatch([
+ ctx.db.insert(groupUsers).values({ groupId: group.id, userId: ctx.session.user.id }),
+ ctx.db.delete(groupDefaultSplits).where(eq(groupDefaultSplits.groupId, group.id)),
+ ]);
return group;
}),
getGroupDetails: groupProcedure.query(async ({ input, ctx }) => {
- const group = await ctx.db.group.findUnique({
- where: {
- id: input.groupId,
- },
- include: {
- groupUsers: {
- include: {
- user: true,
- },
- },
- groupBalances: true,
- groupDefaultSplit: true,
- },
+ const found = await ctx.db.query.groups.findFirst({
+ where: eq(groups.id, input.groupId),
+ with: { groupUsers: { with: { user: true } }, groupDefaultSplit: true },
});
+ const group = found && { ...found, groupBalances: await groupBalancesOf(ctx.db, found.id) };
if (!group) {
return group;
@@ -179,19 +166,22 @@ export const groupRouter = createTRPCRouter({
}),
getGroupTotals: groupProcedure.query(async ({ input, ctx }) => {
- const totals = await ctx.db.expense.groupBy({
- by: 'currency',
- _sum: {
- amount: true,
- },
- where: {
- groupId: input.groupId,
- deletedAt: null,
- splitType: {
- not: SplitType.SETTLEMENT,
- },
- },
- });
+ const totals = (
+ await ctx.db
+ .select({
+ currency: expenses.currency,
+ amount: sql`SUM(${expenses.amount})`.mapWith((v) => BigInt(v ?? 0)),
+ })
+ .from(expenses)
+ .where(
+ and(
+ eq(expenses.groupId, input.groupId),
+ isNull(expenses.deletedAt),
+ ne(expenses.splitType, SplitType.SETTLEMENT),
+ ),
+ )
+ .groupBy(expenses.currency)
+ ).map((t) => ({ currency: t.currency, _sum: { amount: t.amount } }));
return totals;
}),
@@ -199,26 +189,25 @@ export const groupRouter = createTRPCRouter({
addMembers: groupProcedure
.input(z.object({ userIds: z.array(z.number()) }))
.mutation(async ({ input, ctx }) => {
- const groupUsers = await ctx.db.groupUser.createMany({
- data: input.userIds.map((userId) => ({
- groupId: input.groupId,
- userId,
- })),
- });
-
- await ctx.db.groupDefaultSplit.deleteMany({ where: { groupId: input.groupId } });
-
- return groupUsers;
+ await runBatch([
+ ...(input.userIds.length
+ ? [
+ ctx.db
+ .insert(groupUsers)
+ .values(input.userIds.map((userId) => ({ groupId: input.groupId, userId }))),
+ ]
+ : []),
+ ctx.db.delete(groupDefaultSplits).where(eq(groupDefaultSplits.groupId, input.groupId)),
+ ]);
+ const created = { count: input.userIds.length };
+
+ return created;
}),
toggleSimplifyDebts: groupProcedure
.input(z.object({ groupId: z.number() }))
.mutation(async ({ input, ctx }) => {
- const group = await ctx.db.group.findUnique({
- where: {
- id: input.groupId,
- },
- });
+ const group = await ctx.db.query.groups.findFirst({ where: eq(groups.id, input.groupId) });
if (!group) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'Group not found' });
@@ -231,11 +220,11 @@ export const groupRouter = createTRPCRouter({
});
}
- const isInGroup = await ctx.db.groupUser.findFirst({
- where: {
- groupId: input.groupId,
- userId: ctx.session.user.id,
- },
+ const isInGroup = await ctx.db.query.groupUsers.findFirst({
+ where: and(
+ eq(groupUsers.groupId, input.groupId),
+ eq(groupUsers.userId, ctx.session.user.id),
+ ),
});
if (!isInGroup) {
@@ -247,20 +236,19 @@ export const groupRouter = createTRPCRouter({
const simplifyDebtsInv = !group.simplifyDebts;
- await ctx.db.group.update({
- where: {
- id: input.groupId,
- },
- data: {
- simplifyDebts: simplifyDebtsInv,
- },
- });
+ await ctx.db
+ .update(groups)
+ .set({ simplifyDebts: simplifyDebtsInv })
+ .where(eq(groups.id, input.groupId));
// Send notifications asynchronously
- void sendGroupSimplifyDebtsToggleNotification(
- input.groupId,
- ctx.session.user.id,
- simplifyDebtsInv,
+ runInBackground(
+ 'push:simplify-debts',
+ sendGroupSimplifyDebtsToggleNotification(
+ input.groupId,
+ ctx.session.user.id,
+ simplifyDebtsInv,
+ ),
);
return simplifyDebts;
@@ -271,11 +259,7 @@ export const groupRouter = createTRPCRouter({
.mutation(async ({ input, ctx }) => {
const userId = input.userId ?? ctx.session.user.id;
- const group = await ctx.db.group.findUnique({
- where: {
- id: input.groupId,
- },
- });
+ const group = await ctx.db.query.groups.findFirst({ where: eq(groups.id, input.groupId) });
if (!group) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'Group not found' });
}
@@ -287,9 +271,7 @@ export const groupRouter = createTRPCRouter({
});
}
- const groupBalances = await ctx.db.balanceView.findMany({
- where: { groupId: input.groupId },
- });
+ const groupBalances = await groupBalancesOf(ctx.db, input.groupId);
const finalGroupBalances = group.simplifyDebts ? simplifyDebts(groupBalances) : groupBalances;
@@ -300,16 +282,13 @@ export const groupRouter = createTRPCRouter({
});
}
- const groupUser = await ctx.db.groupUser.delete({
- where: {
- groupId_userId: {
- groupId: input.groupId,
- userId,
- },
- },
- });
-
- await ctx.db.groupDefaultSplit.deleteMany({ where: { groupId: input.groupId } });
+ const [[groupUser]] = (await runBatch([
+ ctx.db
+ .delete(groupUsers)
+ .where(and(eq(groupUsers.groupId, input.groupId), eq(groupUsers.userId, userId)))
+ .returning(),
+ ctx.db.delete(groupDefaultSplits).where(eq(groupDefaultSplits.groupId, input.groupId)),
+ ])) as [(typeof groupUsers.$inferSelect)[]];
return groupUser;
}),
@@ -329,21 +308,25 @@ export const groupRouter = createTRPCRouter({
const serialized = serializeDefaultSplit(parsed);
- const groupDefaultSplit = await ctx.db.groupDefaultSplit.upsert({
- where: { groupId: input.groupId },
- create: {
+ const [groupDefaultSplit] = await ctx.db
+ .insert(groupDefaultSplits)
+ .values({
groupId: input.groupId,
splitType: serialized.splitType,
shares: serialized.shares,
- },
- update: {
- splitType: serialized.splitType,
- shares: serialized.shares,
- },
- });
+ })
+ .onConflictDoUpdate({
+ target: groupDefaultSplits.groupId,
+ set: {
+ splitType: serialized.splitType,
+ shares: serialized.shares,
+ updatedAt: new Date(),
+ },
+ })
+ .returning();
return {
- splitType: groupDefaultSplit.splitType,
+ splitType: groupDefaultSplit!.splitType,
shares: serialized.shares,
};
}),
@@ -351,7 +334,7 @@ export const groupRouter = createTRPCRouter({
clearDefaultSplit: groupProcedure
.input(z.object({ groupId: z.number() }))
.mutation(async ({ input, ctx }) => {
- await ctx.db.groupDefaultSplit.deleteMany({ where: { groupId: input.groupId } });
+ await ctx.db.delete(groupDefaultSplits).where(eq(groupDefaultSplits.groupId, input.groupId));
return true;
}),
@@ -365,26 +348,17 @@ export const groupRouter = createTRPCRouter({
}),
)
.mutation(async ({ input, ctx }) => {
- const group = await ctx.db.group.findUnique({
- where: {
- id: input.groupId,
- },
- });
+ const group = await ctx.db.query.groups.findFirst({ where: eq(groups.id, input.groupId) });
if (!group) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'Group not found' });
}
- const updatedGroup = await ctx.db.group.update({
- where: {
- id: input.groupId,
- },
- data: {
- name: input.name,
- image: input.image,
- defaultCurrency: input.defaultCurrency,
- },
- });
+ const [updatedGroup] = await ctx.db
+ .update(groups)
+ .set({ name: input.name, image: input.image, defaultCurrency: input.defaultCurrency })
+ .where(eq(groups.id, input.groupId))
+ .returning();
return updatedGroup;
}),
@@ -392,25 +366,19 @@ export const groupRouter = createTRPCRouter({
toggleArchive: groupProcedure
.input(z.object({ groupId: z.number() }))
.mutation(async ({ input, ctx }) => {
- const group = await ctx.db.group.findUnique({
- where: {
- id: input.groupId,
- },
- include: {
- groupBalances: true,
- },
- });
+ const found = await ctx.db.query.groups.findFirst({ where: eq(groups.id, input.groupId) });
+ const group = found && { ...found, groupBalances: await groupBalancesOf(ctx.db, found.id) };
if (!group) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'Group not found' });
}
// Check if user is a member of the group
- const isInGroup = await ctx.db.groupUser.findFirst({
- where: {
- groupId: input.groupId,
- userId: ctx.session.user.id,
- },
+ const isInGroup = await ctx.db.query.groupUsers.findFirst({
+ where: and(
+ eq(groupUsers.groupId, input.groupId),
+ eq(groupUsers.userId, ctx.session.user.id),
+ ),
});
if (!isInGroup) {
@@ -439,14 +407,11 @@ export const groupRouter = createTRPCRouter({
}
}
- const updatedGroup = await ctx.db.group.update({
- where: {
- id: input.groupId,
- },
- data: {
- archivedAt: isArchiving ? new Date() : null,
- },
- });
+ const [updatedGroup] = await ctx.db
+ .update(groups)
+ .set({ archivedAt: isArchiving ? new Date() : null })
+ .where(eq(groups.id, input.groupId))
+ .returning();
return updatedGroup;
}),
@@ -454,14 +419,8 @@ export const groupRouter = createTRPCRouter({
delete: groupProcedure
.input(z.object({ groupId: z.number() }))
.mutation(async ({ input, ctx }) => {
- const group = await ctx.db.group.findUnique({
- where: {
- id: input.groupId,
- },
- include: {
- groupBalances: true,
- },
- });
+ const found = await ctx.db.query.groups.findFirst({ where: eq(groups.id, input.groupId) });
+ const group = found && { ...found, groupBalances: await groupBalancesOf(ctx.db, found.id) };
if (group?.userId !== ctx.session.user.id) {
throw new TRPCError({ code: 'UNAUTHORIZED', message: 'Only creator can delete the group' });
@@ -480,14 +439,13 @@ export const groupRouter = createTRPCRouter({
});
}
- await ctx.db.group.delete({
- where: {
- id: input.groupId,
- },
- });
+ await ctx.db.delete(groups).where(eq(groups.id, input.groupId));
return group;
}),
});
+const groupBalancesOf = (database: DB, groupId: number) =>
+ database.select().from(balanceView).where(eq(balanceView.groupId, groupId));
+
export type GroupRouter = typeof groupRouter;
diff --git a/src/server/api/routers/user.ts b/src/server/api/routers/user.ts
index 1b7e9a87f..eff17feec 100644
--- a/src/server/api/routers/user.ts
+++ b/src/server/api/routers/user.ts
@@ -1,5 +1,5 @@
import { TRPCError } from '@trpc/server';
-import { type User } from 'next-auth';
+import type { SessionUser as User } from '~/server/auth';
import { z } from 'zod';
import { env } from '~/env';
@@ -10,7 +10,18 @@ import {
} from '~/lib/defaultSplit';
import { simplifyDebts } from '~/lib/simplify';
import { createTRPCRouter, protectedProcedure } from '~/server/api/trpc';
+import { and, desc, eq, inArray, isNull, ne, notInArray, sql } from 'drizzle-orm';
+
import { db } from '~/server/db';
+import { runInBackground } from '~/server/observability/background';
+import {
+ balanceView,
+ expenses,
+ friendDefaultSplits,
+ groups,
+ pushNotifications,
+ users,
+} from '~/server/db/schema';
import { sendFeedbackEmail, sendInviteEmail } from '~/server/mailer';
import { SplitwiseGroupSchema, SplitwiseUserSchema } from '~/types';
@@ -29,56 +40,53 @@ export const userRouter = createTRPCRouter({
me: protectedProcedure.query(({ ctx }) => ctx.session.user),
getFriends: protectedProcedure.query(async ({ ctx }) => {
- const friends = await db.balanceView.findMany({
- where: { userId: ctx.session.user.id, friendId: { notIn: ctx.session.user.hiddenFriendIds } },
- include: { friend: true },
- distinct: ['friendId'],
- });
+ const friends = await db
+ .selectDistinct({ friend: users })
+ .from(balanceView)
+ .innerJoin(users, eq(users.id, balanceView.friendId))
+ .where(
+ and(
+ eq(balanceView.userId, ctx.session.user.id),
+ notInArray(balanceView.friendId, ctx.session.user.hiddenFriendIds),
+ ),
+ );
return friends.map((f) => f.friend);
}),
getOwnExpenses: protectedProcedure.query(async ({ ctx }) => {
- const expenses = await db.expense.findMany({
- where: {
- paidBy: ctx.session.user.id,
- deletedBy: null,
- },
- orderBy: {
- expenseDate: 'desc',
- },
- include: {
- group: true,
- },
+ const ownExpenses = await db.query.expenses.findMany({
+ where: and(eq(expenses.paidBy, ctx.session.user.id), isNull(expenses.deletedBy)),
+ orderBy: [desc(expenses.expenseDate)],
+ with: { group: true },
});
- return expenses;
+ return ownExpenses;
}),
inviteFriend: protectedProcedure
.input(z.object({ email: z.string(), sendInviteEmail: z.boolean().optional() }))
.mutation(async ({ input, ctx: { session } }) => {
- const friend = await db.user.findUnique({
- where: {
- email: input.email,
- },
- });
+ const email = input.email.toLowerCase();
+ const friend = await db.query.users.findFirst({ where: eq(users.email, email) });
if (friend) {
return friend;
}
- const user = await db.user.create({
- data: {
- email: input.email,
- name: input.email.split('@')[0],
- },
- });
+ const [user] = await db
+ .insert(users)
+ .values({ email, name: email.split('@')[0] })
+ .returning();
+ if (!user) {
+ throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Failed to create user' });
+ }
if (input.sendInviteEmail) {
- sendInviteEmail(input.email, session.user.name ?? session.user.email ?? '').catch((err) => {
- console.error('Error sending invite email', err);
- });
+ runInBackground(
+ 'invite-email',
+ sendInviteEmail(input.email, session.user.name ?? session.user.email ?? ''),
+ );
}
return user;
@@ -87,21 +95,22 @@ export const userRouter = createTRPCRouter({
getBalancesWithFriend: protectedProcedure
.input(z.object({ friendId: z.number() }))
.query(async ({ input, ctx }) => {
- const rawBalances = await db.balanceView.findMany({
- where: {
- userId: ctx.session.user.id,
- friendId: input.friendId,
- amount: { not: 0 },
- },
- include: {
- group: {
- select: {
- name: true,
- simplifyDebts: true,
- },
- },
- },
- });
+ const rawBalances = await db
+ .select({
+ groupId: balanceView.groupId,
+ currency: balanceView.currency,
+ amount: balanceView.amount,
+ group: { name: groups.name, simplifyDebts: groups.simplifyDebts },
+ })
+ .from(balanceView)
+ .leftJoin(groups, eq(groups.id, balanceView.groupId))
+ .where(
+ and(
+ eq(balanceView.userId, ctx.session.user.id),
+ eq(balanceView.friendId, input.friendId),
+ ne(balanceView.amount, 0n),
+ ),
+ );
const processedBalances = await Promise.all(
rawBalances.map(async ({ groupId, currency, amount, group }) => {
@@ -117,9 +126,10 @@ export const userRouter = createTRPCRouter({
}
// For simplifyDebts groups, fetch all group balances and simplify
- const allGroupBalances = await db.balanceView.findMany({
- where: { groupId, currency },
- });
+ const allGroupBalances = await db
+ .select()
+ .from(balanceView)
+ .where(and(eq(balanceView.groupId, groupId), eq(balanceView.currency, currency)));
const simplified = simplifyDebts(allGroupBalances);
@@ -156,14 +166,11 @@ export const userRouter = createTRPCRouter({
}),
)
.mutation(async ({ input, ctx }) => {
- const user = await db.user.update({
- where: {
- id: ctx.session.user.id,
- },
- data: {
- ...input,
- },
- });
+ const [user] = await db
+ .update(users)
+ .set(input)
+ .where(eq(users.id, ctx.session.user.id))
+ .returning();
return user;
}),
@@ -171,11 +178,7 @@ export const userRouter = createTRPCRouter({
getUserDetails: protectedProcedure
.input(z.object({ userId: z.number() }))
.query(async ({ input }) => {
- const user = await db.user.findUnique({
- where: {
- id: input.userId,
- },
- });
+ const user = await db.query.users.findFirst({ where: eq(users.id, input.userId) });
return user;
}),
@@ -183,21 +186,25 @@ export const userRouter = createTRPCRouter({
submitFeedback: protectedProcedure
.input(z.object({ feedback: z.string().min(10) }))
.mutation(async ({ input, ctx }) => {
- await sendFeedbackEmail(input.feedback, ctx.session.user as User);
+ await sendFeedbackEmail(input.feedback, ctx.session.user);
}),
getFriend: protectedProcedure
.input(z.object({ friendId: z.number() }))
.query(async ({ input, ctx }) => {
- const friend = await db.user.findUnique({
- where: {
- id: input.friendId,
- userBalances: {
- some: {
- friendId: ctx.session.user.id,
- },
- },
- },
+ const friend = await db.query.users.findFirst({
+ // Uncorrelated IN subquery: relational queries alias the outer table, so a correlated
+ // Reference to `users.id` would render as "User"."id" and fail on D1.
+ where: and(
+ eq(users.id, input.friendId),
+ inArray(
+ users.id,
+ db
+ .select({ userId: balanceView.userId })
+ .from(balanceView)
+ .where(eq(balanceView.friendId, ctx.session.user.id)),
+ ),
+ ),
});
if (!friend) {
@@ -206,13 +213,11 @@ export const userRouter = createTRPCRouter({
const [userAId, userBId] = toSortedFriendPair(ctx.session.user.id, input.friendId);
- const friendDefaultSplit = await db.friendDefaultSplit.findUnique({
- where: {
- userAId_userBId: {
- userAId,
- userBId,
- },
- },
+ const friendDefaultSplit = await db.query.friendDefaultSplits.findFirst({
+ where: and(
+ eq(friendDefaultSplits.userAId, userAId),
+ eq(friendDefaultSplits.userBId, userBId),
+ ),
});
const defaultSplit =
@@ -248,15 +253,19 @@ export const userRouter = createTRPCRouter({
}),
)
.mutation(async ({ input, ctx }) => {
- const friend = await db.user.findUnique({
- where: {
- id: input.friendId,
- userBalances: {
- some: {
- friendId: ctx.session.user.id,
- },
- },
- },
+ const friend = await db.query.users.findFirst({
+ // Uncorrelated IN subquery: relational queries alias the outer table, so a correlated
+ // Reference to `users.id` would render as "User"."id" and fail on D1.
+ where: and(
+ eq(users.id, input.friendId),
+ inArray(
+ users.id,
+ db
+ .select({ userId: balanceView.userId })
+ .from(balanceView)
+ .where(eq(balanceView.friendId, ctx.session.user.id)),
+ ),
+ ),
});
if (!friend) {
@@ -271,19 +280,17 @@ export const userRouter = createTRPCRouter({
const [userAId, userBId] = toSortedFriendPair(ctx.session.user.id, input.friendId);
const serialized = serializeDefaultSplit(parsed);
- await db.friendDefaultSplit.upsert({
- where: { userAId_userBId: { userAId, userBId } },
- create: {
- userAId,
- userBId,
- splitType: serialized.splitType,
- shares: serialized.shares,
- },
- update: {
- splitType: serialized.splitType,
- shares: serialized.shares,
- },
- });
+ await db
+ .insert(friendDefaultSplits)
+ .values({ userAId, userBId, splitType: serialized.splitType, shares: serialized.shares })
+ .onConflictDoUpdate({
+ target: [friendDefaultSplits.userAId, friendDefaultSplits.userBId],
+ set: {
+ splitType: serialized.splitType,
+ shares: serialized.shares,
+ updatedAt: new Date(),
+ },
+ });
return serialized;
}),
@@ -292,7 +299,11 @@ export const userRouter = createTRPCRouter({
.input(z.object({ friendId: z.number() }))
.mutation(async ({ input, ctx }) => {
const [userAId, userBId] = toSortedFriendPair(ctx.session.user.id, input.friendId);
- await db.friendDefaultSplit.deleteMany({ where: { userAId, userBId } });
+ await db
+ .delete(friendDefaultSplits)
+ .where(
+ and(eq(friendDefaultSplits.userAId, userAId), eq(friendDefaultSplits.userBId, userBId)),
+ );
return true;
}),
@@ -308,22 +319,13 @@ export const userRouter = createTRPCRouter({
});
}
- await db.pushNotification.upsert({
- where: {
- userId_endpoint: {
- userId: ctx.session.user.id,
- endpoint,
- },
- },
- create: {
- userId: ctx.session.user.id,
- endpoint,
- subscription: input.subscription,
- },
- update: {
- subscription: input.subscription,
- },
- });
+ await db
+ .insert(pushNotifications)
+ .values({ userId: ctx.session.user.id, endpoint, subscription: input.subscription })
+ .onConflictDoUpdate({
+ target: [pushNotifications.userId, pushNotifications.endpoint],
+ set: { subscription: input.subscription },
+ });
}),
deletePushNotification: protectedProcedure
@@ -334,15 +336,14 @@ export const userRouter = createTRPCRouter({
return;
}
- await db.pushNotification
- .delete({
- where: {
- userId_endpoint: {
- userId: ctx.session.user.id,
- endpoint,
- },
- },
- })
+ await db
+ .delete(pushNotifications)
+ .where(
+ and(
+ eq(pushNotifications.userId, ctx.session.user.id),
+ eq(pushNotifications.endpoint, endpoint),
+ ),
+ )
.catch(() => null);
}),
@@ -361,22 +362,19 @@ export const userRouter = createTRPCRouter({
deleteFriend: protectedProcedure
.input(z.object({ friendId: z.number() }))
.mutation(async ({ input, ctx }) => {
- const friendBalances = await db.balanceView.groupBy({
- by: ['currency'],
- _sum: { amount: true },
- where: {
- userId: ctx.session.user.id,
- friendId: input.friendId,
- amount: { not: 0 },
- },
- having: {
- amount: {
- _sum: {
- not: 0,
- },
- },
- },
- });
+ const total = sql`SUM(${balanceView.amount})`;
+ const friendBalances = await db
+ .select({ currency: balanceView.currency, amount: total })
+ .from(balanceView)
+ .where(
+ and(
+ eq(balanceView.userId, ctx.session.user.id),
+ eq(balanceView.friendId, input.friendId),
+ ne(balanceView.amount, 0n),
+ ),
+ )
+ .groupBy(balanceView.currency)
+ .having(sql`${total} != 0`);
if (0 < friendBalances.length) {
throw new TRPCError({
@@ -385,16 +383,18 @@ export const userRouter = createTRPCRouter({
});
}
- await db.user.update({
- where: {
- id: ctx.session.user.id,
- },
- data: {
- hiddenFriendIds: {
- push: input.friendId,
- },
- },
- });
+ // Atomic JSON array append (no read-modify-write race).
+ await db
+ .update(users)
+ .set({
+ hiddenFriendIds: sql`(
+ SELECT json_group_array(value) FROM (
+ SELECT value FROM json_each(${users.hiddenFriendIds})
+ UNION SELECT ${input.friendId}
+ )
+ )`,
+ })
+ .where(eq(users.id, ctx.session.user.id));
}),
downloadData: protectedProcedure.mutation(async ({ ctx }) => {
@@ -422,13 +422,10 @@ export const userRouter = createTRPCRouter({
});
export const getUserMap = async (userIds: number[]) => {
- const users = await db.user.findMany({
- where: {
- id: { in: userIds },
- },
- });
+ const found =
+ 0 === userIds.length ? [] : await db.select().from(users).where(inArray(users.id, userIds));
- return Object.fromEntries(users.map((u) => [u.id, u]));
+ return Object.fromEntries(found.map((u) => [u.id, u]));
};
export type UserRouter = typeof userRouter;
diff --git a/src/server/api/services/bankTransactions/gocardless.ts b/src/server/api/services/bankTransactions/gocardless.ts
index e4be40eaa..6fa0a7bdf 100644
--- a/src/server/api/services/bankTransactions/gocardless.ts
+++ b/src/server/api/services/bankTransactions/gocardless.ts
@@ -4,7 +4,10 @@ import { TRPCError } from '@trpc/server';
import { format, subDays } from 'date-fns';
import NordigenClient, { type Transaction } from 'nordigen-node';
import { env } from '~/env';
+import { and, eq } from 'drizzle-orm';
+
import { db } from '~/server/db';
+import { cachedBankData } from '~/server/db/schema';
import type { TransactionOutput, TransactionOutputItem } from '~/types/bank.types';
abstract class AbstractBankProvider {
@@ -64,8 +67,11 @@ export class GoCardlessService extends AbstractBankProvider {
const requisitionData = await this.client.requisition.getRequisitionById(requisitionId);
- const cachedData = await db.cachedBankData.findUnique({
- where: { obapiProviderId: requisitionId, userId },
+ const cachedData = await db.query.cachedBankData.findFirst({
+ where: and(
+ eq(cachedBankData.obapiProviderId, requisitionId),
+ eq(cachedBankData.userId, userId),
+ ),
});
if (cachedData) {
@@ -112,24 +118,16 @@ export class GoCardlessService extends AbstractBankProvider {
const formattedTransactions: TransactionOutput = this.mergeTransactions(allTransactions);
- const data = {
- obapiProviderId: requisitionId ?? '',
- data: JSON.stringify(formattedTransactions),
- lastFetched: new Date(),
- user: {
- connect: {
- id: userId,
- },
- },
- };
-
- await db.cachedBankData.upsert({
- where: { obapiProviderId: requisitionId ?? '', userId },
- create: data,
- update: {
- lastFetched: new Date(),
- },
- });
+ const serialized = JSON.stringify(formattedTransactions);
+ const now = new Date();
+ await db
+ .insert(cachedBankData)
+ .values({ obapiProviderId: requisitionId ?? '', data: serialized, lastFetched: now, userId })
+ .onConflictDoUpdate({
+ target: cachedBankData.obapiProviderId,
+ set: { data: serialized, lastFetched: now, updatedAt: now },
+ where: eq(cachedBankData.userId, userId),
+ });
return formattedTransactions;
}
diff --git a/src/server/api/services/bankTransactions/plaid.ts b/src/server/api/services/bankTransactions/plaid.ts
index c6df75dc1..36db08cb9 100644
--- a/src/server/api/services/bankTransactions/plaid.ts
+++ b/src/server/api/services/bankTransactions/plaid.ts
@@ -9,7 +9,10 @@ import {
type Transaction,
} from 'plaid';
import { env } from '~/env';
+import { and, eq } from 'drizzle-orm';
+
import { db } from '~/server/db';
+import { cachedBankData } from '~/server/db/schema';
import type { TransactionOutput, TransactionOutputItem } from '~/types/bank.types';
abstract class AbstractBankProvider {
@@ -77,8 +80,11 @@ export class PlaidService extends AbstractBankProvider {
return;
}
- const cachedData = await db.cachedBankData.findUnique({
- where: { obapiProviderId: accessToken, userId },
+ const cachedData = await db.query.cachedBankData.findFirst({
+ where: and(
+ eq(cachedBankData.obapiProviderId, accessToken),
+ eq(cachedBankData.userId, userId),
+ ),
});
if (cachedData) {
@@ -111,24 +117,16 @@ export class PlaidService extends AbstractBankProvider {
response.data.transactions,
);
- const data = {
- obapiProviderId: accessToken,
- data: JSON.stringify(formattedTransactions),
- lastFetched: new Date(),
- user: {
- connect: {
- id: userId,
- },
- },
- };
-
- await db.cachedBankData.upsert({
- where: { obapiProviderId: accessToken, userId },
- create: data,
- update: {
- lastFetched: new Date(),
- },
- });
+ const serialized = JSON.stringify(formattedTransactions);
+ const now = new Date();
+ await db
+ .insert(cachedBankData)
+ .values({ obapiProviderId: accessToken, data: serialized, lastFetched: now, userId })
+ .onConflictDoUpdate({
+ target: cachedBankData.obapiProviderId,
+ set: { data: serialized, lastFetched: now, updatedAt: now },
+ where: eq(cachedBankData.userId, userId),
+ });
return formattedTransactions;
}
@@ -213,8 +211,8 @@ export class PlaidService extends AbstractBankProvider {
}
private formatTransactions(transactions: Transaction[]): TransactionOutput {
- const bookedTransactions = transactions.filter((t) => t.pending === false);
- const pendingTransactions = transactions.filter((t) => t.pending === true);
+ const bookedTransactions = transactions.filter((t) => !t.pending);
+ const pendingTransactions = transactions.filter((t) => t.pending);
return {
transactions: {
diff --git a/src/server/api/services/currencyRateService.ts b/src/server/api/services/currencyRateService.ts
index 0b119742b..e08f48ce4 100644
--- a/src/server/api/services/currencyRateService.ts
+++ b/src/server/api/services/currencyRateService.ts
@@ -1,7 +1,10 @@
import { format, getISODay, isToday, subDays } from 'date-fns';
import { env } from '~/env';
import type { CurrencyCode } from '~/lib/currency';
+import { and, eq } from 'drizzle-orm';
+
import { db } from '~/server/db';
+import { cachedCurrencyRates } from '~/server/db/schema';
export interface RateResponse {
base: string;
@@ -80,39 +83,25 @@ abstract class CurrencyRateProvider {
}
private upsertCache(from: CurrencyCode, to: CurrencyCode, date: Date, rate: number) {
- return db.cachedCurrencyRate.upsert({
- where: {
- from_to_date: { from, to, date },
- },
- create: {
- from,
- to,
- date,
- rate,
- lastFetched: new Date(),
- },
- update: {
- rate,
- lastFetched: new Date(),
- },
- });
+ const now = new Date();
+ return db
+ .insert(cachedCurrencyRates)
+ .values({ from, to, date, rate, lastFetched: now })
+ .onConflictDoUpdate({
+ target: [cachedCurrencyRates.from, cachedCurrencyRates.to, cachedCurrencyRates.date],
+ set: { rate, lastFetched: now, updatedAt: now },
+ });
}
private async getCache(from: CurrencyCode, to: CurrencyCode, date: Date) {
- const result = await db.cachedCurrencyRate.findUnique({
- where: {
- from_to_date: { from, to, date },
- },
- });
+ const key = and(
+ eq(cachedCurrencyRates.from, from),
+ eq(cachedCurrencyRates.to, to),
+ eq(cachedCurrencyRates.date, date),
+ );
+ const result = await db.query.cachedCurrencyRates.findFirst({ where: key });
if (result) {
- void db.cachedCurrencyRate.update({
- where: {
- from_to_date: { from, to, date },
- },
- data: {
- lastFetched: new Date(),
- },
- });
+ void db.update(cachedCurrencyRates).set({ lastFetched: new Date() }).where(key);
}
return result;
}
diff --git a/src/server/api/services/notificationService.ts b/src/server/api/services/notificationService.ts
index b7a2f2d4a..f01ece806 100644
--- a/src/server/api/services/notificationService.ts
+++ b/src/server/api/services/notificationService.ts
@@ -1,8 +1,11 @@
-import { SplitType } from '@prisma/client';
+import { SplitType } from '~/server/db/types';
import { isCurrencyCode } from '~/lib/currency';
import { type PushMessage } from '~/types';
+import { and, desc, eq, inArray, or } from 'drizzle-orm';
+
import { db } from '~/server/db';
+import { expenseRecurrences, expenses, groups, pushNotifications, users } from '~/server/db/schema';
import { pushNotification } from '~/server/notification';
import { getCurrencyHelpers } from '~/utils/numbers';
@@ -26,14 +29,15 @@ const removeStalePushSubscriptions = async (
return;
}
- await db.pushNotification.deleteMany({
- where: {
- OR: subscriptions.map((subscription) => ({
- userId: subscription.userId,
- endpoint: subscription.endpoint,
- })),
- },
- });
+ await db
+ .delete(pushNotifications)
+ .where(
+ or(
+ ...subscriptions.map((s) =>
+ and(eq(pushNotifications.userId, s.userId), eq(pushNotifications.endpoint, s.endpoint)),
+ ),
+ ),
+ );
};
const isPermanentPushFailure = (statusCode: number | undefined) =>
@@ -44,13 +48,10 @@ export const sendPushNotificationToUsers = async (userIds: number[], pushData: P
return { sentCount: 0 };
}
- const subscriptions = await db.pushNotification.findMany({
- where: {
- userId: {
- in: userIds,
- },
- },
- });
+ const subscriptions = await db
+ .select()
+ .from(pushNotifications)
+ .where(inArray(pushNotifications.userId, userIds));
const pushResults = await Promise.all(
subscriptions.map(async (s) => {
@@ -69,11 +70,10 @@ export const sendPushNotificationToUsers = async (userIds: number[], pushData: P
};
export async function sendExpensePushNotification(expenseId: string) {
- const expense = await db.expense.findUnique({
- where: {
- id: expenseId,
- },
- select: {
+ const nameEmail = { columns: { name: true, email: true } } as const;
+ const expense = await db.query.expenses.findFirst({
+ where: eq(expenses.id, expenseId),
+ columns: {
paidBy: true,
amount: true,
currency: true,
@@ -81,42 +81,14 @@ export async function sendExpensePushNotification(expenseId: string) {
name: true,
deletedBy: true,
splitType: true,
- deletedByUser: {
- select: {
- name: true,
- email: true,
- },
- },
- expenseParticipants: {
- select: {
- userId: true,
- amount: true,
- },
- },
- paidByUser: {
- select: {
- name: true,
- email: true,
- },
- },
- addedByUser: {
- select: {
- name: true,
- email: true,
- },
- },
- updatedByUser: {
- select: {
- name: true,
- email: true,
- },
- },
- conversionTo: {
- select: {
- currency: true,
- amount: true,
- },
- },
+ },
+ with: {
+ deletedByUser: nameEmail,
+ expenseParticipants: { columns: { userId: true, amount: true } },
+ paidByUser: nameEmail,
+ addedByUser: nameEmail,
+ updatedByUser: nameEmail,
+ conversionTo: { columns: { currency: true, amount: true } },
},
});
@@ -207,22 +179,13 @@ export async function sendGroupSimplifyDebtsToggleNotification(
newState: boolean,
) {
try {
- const group = await db.group.findUnique({
- where: {
- id: groupId,
- },
- select: {
- name: true,
+ const group = await db.query.groups.findFirst({
+ where: eq(groups.id, groupId),
+ columns: { name: true },
+ with: {
groupUsers: {
- select: {
- userId: true,
- user: {
- select: {
- name: true,
- email: true,
- },
- },
- },
+ columns: { userId: true },
+ with: { user: { columns: { name: true, email: true } } },
},
},
});
@@ -231,14 +194,9 @@ export async function sendGroupSimplifyDebtsToggleNotification(
return;
}
- const togglerUser = await db.user.findUnique({
- where: {
- id: togglerUserId,
- },
- select: {
- name: true,
- email: true,
- },
+ const togglerUser = await db.query.users.findFirst({
+ where: eq(users.id, togglerUserId),
+ columns: { name: true, email: true },
});
if (!togglerUser) {
@@ -277,17 +235,13 @@ export async function sendGroupSimplifyDebtsToggleNotification(
export async function checkRecurrenceNotifications() {
try {
- const recurrences = await db.expenseRecurrence.findMany({
- where: {
- NOT: {
- notified: true,
- },
- },
- include: {
+ const recurrences = await db.query.expenseRecurrences.findMany({
+ where: eq(expenseRecurrences.notified, false),
+ with: {
expense: {
- select: { id: true },
- orderBy: { createdAt: 'desc' },
- take: 1,
+ columns: { id: true },
+ orderBy: (e) => [desc(e.createdAt)],
+ limit: 1,
},
},
});
@@ -297,19 +251,14 @@ export async function checkRecurrenceNotifications() {
.filter((r) => r.expense[0])
.map(async (r) => {
await sendExpensePushNotification(r.expense[0]!.id);
- await db.expenseRecurrence.update({
- where: {
- id: r.id,
- },
- data: {
- notified: true,
- },
- });
+ await db
+ .update(expenseRecurrences)
+ .set({ notified: true })
+ .where(eq(expenseRecurrences.id, r.id));
}),
);
} catch (e) {
console.error('Error sending recurrence notifications', e);
- } finally {
- setTimeout(checkRecurrenceNotifications, 1000 * 60); // Check every minute
+ throw e;
}
}
diff --git a/src/server/api/services/scheduleService.ts b/src/server/api/services/scheduleService.ts
index b6a3f1f9f..2f4645d4c 100644
--- a/src/server/api/services/scheduleService.ts
+++ b/src/server/api/services/scheduleService.ts
@@ -1,24 +1,76 @@
+/**
+ * Recurring-expense scheduling. Replaces pg_cron.
+ *
+ * Each recurring expense owns one row in the `job` table. The scheduler Workflow
+ * (see worker-entry.ts) runs every minute and executes jobs whose `nextRunAt` is due.
+ * The `command` column keeps the old pg_cron format so `extractTemplateExpenseId`
+ * and the UI keep working.
+ */
+import cronParser from 'cron-parser';
+import { and, eq, lt, lte, sql } from 'drizzle-orm';
+
import { db } from '~/server/db';
+import { runBatch } from '~/server/db/batch';
+import { cachedBankData, cachedCurrencyRates, jobs } from '~/server/db/schema';
+
+export const recurringJobName = (expenseId: string) => `expense_recurring_${expenseId}`;
+
+export const recurringJobCommand = (expenseId: string) =>
+ `SELECT duplicate_expense_with_participants('${expenseId}'::UUID);`;
+
+/** Pg_cron used `$` for "last day of month"; cron-parser uses `L`. Schedules are UTC. */
+export const nextRunAt = (schedule: string, from: Date = new Date()): Date =>
+ cronParser
+ .parseExpression(schedule.replaceAll('$', 'L'), { currentDate: from, tz: 'UTC' })
+ .next()
+ .toDate();
+
+/** True when `schedule` has an occurrence inside the minute that starts at `minute`. */
+export const firesInMinute = (schedule: string, minute: Date): boolean => {
+ const start = new Date(Math.floor(minute.getTime() / 60_000) * 60_000);
+ return nextRunAt(schedule, new Date(start.getTime() - 1)).getTime() === start.getTime();
+};
-export const createRecurringDeleteBankCacheJob = async (
- cronExpression: string,
- interval: string,
-) => {
- // Implementation for creating a recurring delete bank cache using pg_cron
-
- await db.$executeRawUnsafe(`
- SELECT cron.schedule('cleanup_cached_bank_data', '${cronExpression}', $$
- DELETE FROM "CachedBankData"
- WHERE "lastFetched" < NOW() - INTERVAL '${interval}';
- DELETE FROM "CachedCurrencyRate"
- WHERE "lastFetched" < NOW() - INTERVAL '${interval}';
- $$);
- `);
+/** Parses a Postgres-style interval such as "2 days" or "12 hours" into milliseconds. */
+export const parseInterval = (interval: string): number => {
+ const match = /^\s*(\d+)\s*(minute|hour|day|week|month)s?\s*$/i.exec(interval);
+ if (!match) {
+ throw new Error(`Unsupported interval: ${interval}`);
+ }
+ const units: Record = {
+ minute: 60_000,
+ hour: 3_600_000,
+ day: 86_400_000,
+ week: 7 * 86_400_000,
+ month: 30 * 86_400_000,
+ };
+ return Number(match[1]) * units[match[2]!.toLowerCase()]!;
};
-export const createRecurringExpenseJob = (expenseId: string, cronExpression: string) =>
- db.$queryRawUnsafe<[{ schedule: bigint }]>(
- `SELECT cron.schedule($1, $2, $$ SELECT duplicate_expense_with_participants('${expenseId}'::UUID); $$);`,
- expenseId,
- cronExpression,
- );
+export const getDueJobs = (now: Date = new Date()) =>
+ db
+ .select({ jobid: jobs.jobid })
+ .from(jobs)
+ .where(and(eq(jobs.active, true), lte(jobs.nextRunAt, now)))
+ .limit(100);
+
+/** Deletes cached bank data and currency rates that were not fetched within `interval`. */
+export const deleteStaleCache = async (interval: string, now: Date = new Date()) => {
+ const cutoff = new Date(now.getTime() - parseInterval(interval));
+ await runBatch([
+ db.delete(cachedBankData).where(lt(cachedBankData.lastFetched, cutoff)),
+ db.delete(cachedCurrencyRates).where(lt(cachedCurrencyRates.lastFetched, cutoff)),
+ ]);
+};
+
+export const updateJobSchedule = (jobid: number, schedule: string) =>
+ db
+ .update(jobs)
+ .set({ schedule, nextRunAt: nextRunAt(schedule) })
+ .where(eq(jobs.jobid, jobid));
+
+/** Deleting the job cascades to ExpenseRecurrence, which sets Expense.recurrenceId to NULL. */
+export const deleteJob = (jobid: number) => db.delete(jobs).where(eq(jobs.jobid, jobid));
+
+export const jobIdSubquery = (jobname: string) =>
+ sql`(SELECT ${jobs.jobid} FROM ${jobs} WHERE ${jobs.jobname} = ${jobname})`;
diff --git a/src/server/api/services/splitService.ts b/src/server/api/services/splitService.ts
index 8c81a47ea..525f23a0b 100644
--- a/src/server/api/services/splitService.ts
+++ b/src/server/api/services/splitService.ts
@@ -1,355 +1,338 @@
-import { SplitType, type User } from '@prisma/client';
+import { and, eq, inArray, sql } from 'drizzle-orm';
+import type { BatchItem } from 'drizzle-orm/batch';
import { nanoid } from 'nanoid';
-import { db } from '~/server/db';
+import { bindings, db } from '~/server/db';
+import { chunk, runBatch } from '~/server/db/batch';
+import {
+ SplitType,
+ type User,
+ balanceView,
+ expenseParticipants,
+ expenseRecurrences,
+ expenses,
+ groupUsers,
+ groups,
+ jobs,
+ users,
+} from '~/server/db/schema';
import { type SplitwiseGroup, type SplitwiseUser } from '~/types';
import type { CreateExpense } from '~/types/expense.types';
import { sendExpensePushNotification } from './notificationService';
-import { createRecurringExpenseJob } from './scheduleService';
+import {
+ deleteJob,
+ jobIdSubquery,
+ nextRunAt,
+ recurringJobCommand,
+ recurringJobName,
+ updateJobSchedule,
+} from './scheduleService';
+import { runInBackground } from '~/server/observability/background';
import { getCurrencyHelpers } from '~/utils/numbers';
import { isCurrencyCode } from '~/lib/currency';
import { DEFAULT_CATEGORY } from '~/lib/category';
import { extractTemplateExpenseId } from '~/lib/cron';
+interface Participant {
+ userId: number;
+ amount: bigint;
+}
+
+/** 3 bound parameters per participant row; D1 allows 100 per statement. */
+const PARTICIPANT_CHUNK = 33;
+
+const insertParticipants = (
+ expenseId: string,
+ participants: Participant[],
+): BatchItem<'sqlite'>[] =>
+ chunk(participants, PARTICIPANT_CHUNK).map((rows) =>
+ db
+ .insert(expenseParticipants)
+ .values(rows.map((p) => ({ expenseId, userId: p.userId, amount: p.amount }))),
+ );
+
+/** Columns of an expense that a create/edit request may set. */
+const expenseColumns = (e: CreateExpense) => ({
+ groupId: e.groupId,
+ paidBy: e.paidBy,
+ name: e.name,
+ category: e.category,
+ amount: e.amount,
+ splitType: e.splitType,
+ currency: e.currency,
+});
+
export async function joinGroup(userId: number, publicGroupId: string) {
- const group = await db.group.findUnique({
- where: {
- publicId: publicGroupId,
- },
- });
+ const group = await db.query.groups.findFirst({ where: eq(groups.publicId, publicGroupId) });
if (!group) {
throw new Error('Group not found');
}
- await db.groupUser.create({
- data: {
- groupId: group.id,
- userId,
- },
- });
+ await db.insert(groupUsers).values({ groupId: group.id, userId });
return group;
}
export async function createExpense(
- {
- groupId,
- paidBy,
- name,
- category,
- amount,
- splitType,
- currency,
- participants,
- expenseDate,
- fileKey,
- transactionId,
- cronExpression,
- }: CreateExpense & { cronExpression?: string },
+ input: CreateExpense & { cronExpression?: string },
currentUserId: number,
conversionFromParams?: CreateExpense,
) {
- const nonZeroParticipants = getNonZeroParticipants(participants);
-
- const conversionFrom = conversionFromParams
- ? {
- create: {
- ...conversionFromParams,
- addedBy: currentUserId,
- expenseParticipants: {
- create: getNonZeroParticipants(conversionFromParams.participants),
- },
- },
- }
- : undefined;
- if (conversionFrom) {
- // @ts-ignore
- delete conversionFrom.create.participants;
- }
-
- // Pre-generate UUID and create cron job if recurring (before transaction)
- let expenseId: string | undefined = undefined;
- let jobId: bigint | undefined = undefined;
+ const { participants, expenseDate, fileKey, transactionId, cronExpression } = input;
+ const expenseId = crypto.randomUUID();
+ const now = new Date();
+ const items: BatchItem<'sqlite'>[] = [];
+ // Recurrence: job -> ExpenseRecurrence -> Expense.recurrenceId, all resolved inside the batch.
+ let recurrenceIdSql: ReturnType | null = null;
if (cronExpression) {
- const [{ gen_random_uuid }] = await db.$queryRaw<[{ gen_random_uuid: string }]>`
- SELECT gen_random_uuid()::text as gen_random_uuid
- `;
- expenseId = gen_random_uuid;
-
- const [{ schedule }] = await createRecurringExpenseJob(expenseId, cronExpression);
- jobId = schedule;
+ const jobname = recurringJobName(expenseId);
+ items.push(
+ db.insert(jobs).values({
+ schedule: cronExpression,
+ command: recurringJobCommand(expenseId),
+ jobname,
+ nextRunAt: nextRunAt(cronExpression, now),
+ }),
+ db.insert(expenseRecurrences).values({ jobId: jobIdSubquery(jobname) }),
+ );
+ recurrenceIdSql = sql`(SELECT ${expenseRecurrences.id} FROM ${expenseRecurrences} WHERE ${expenseRecurrences.jobId} = ${jobIdSubquery(jobname)})`;
}
- const operations = [];
-
- // Create expense operation
- operations.push(
- db.expense.create({
- data: {
- ...(expenseId && { id: expenseId }),
- groupId,
- paidBy,
- name,
- category,
- amount,
- splitType,
- currency,
- expenseParticipants: {
- create: nonZeroParticipants,
- },
+ const expenseIndex = items.length;
+ items.push(
+ db
+ .insert(expenses)
+ .values({
+ id: expenseId,
+ ...expenseColumns(input),
fileKey,
addedBy: currentUserId,
- expenseDate,
+ expenseDate: expenseDate ?? now,
transactionId,
- conversionFrom,
- },
- }),
+ recurrenceId: recurrenceIdSql,
+ })
+ .returning(),
);
-
- // Link recurrence to expense if we created a cron job
- if (expenseId && jobId) {
- operations.push(
- db.expenseRecurrence.create({
- data: {
- expense: {
- connect: { id: expenseId },
- },
- job: {
- connect: { jobid: jobId },
- },
- },
+ items.push(...insertParticipants(expenseId, getNonZeroParticipants(participants)));
+
+ // Currency conversion: the "from" expense points at this one via conversionToId.
+ if (conversionFromParams) {
+ const fromId = crypto.randomUUID();
+ items.push(
+ db.insert(expenses).values({
+ id: fromId,
+ ...expenseColumns(conversionFromParams),
+ addedBy: currentUserId,
+ expenseDate: conversionFromParams.expenseDate ?? now,
+ conversionToId: expenseId,
}),
+ ...insertParticipants(fromId, getNonZeroParticipants(conversionFromParams.participants)),
);
}
- try {
- const results = await db.$transaction(operations);
- const createdExpense = results[0] as Awaited>;
- if (!createdExpense) {
- throw new Error('Expense creation failed');
- }
-
- sendExpensePushNotification(createdExpense.id).catch(console.error);
- return createdExpense;
- } catch (error) {
- // If we created a cron job but transaction failed, clean up the cron job
- if (expenseId) {
- await db.$executeRaw`SELECT cron.unschedule(${`expense_recurring_${expenseId}`})`;
- }
- throw error;
+ const results = await runBatch(items);
+ const [createdExpense] = results[expenseIndex] as (typeof expenses.$inferSelect)[];
+ if (!createdExpense) {
+ throw new Error('Expense creation failed');
}
+
+ runInBackground('push:create', sendExpensePushNotification(createdExpense.id));
+ return createdExpense;
}
-export async function deleteExpense(expenseId: string, deletedBy: number) {
- const expense = await db.expense.findUnique({
- where: {
- id: expenseId,
- },
- include: {
- recurrence: {
- include: {
- job: true,
- },
- },
- },
+const getExpenseWithJob = (expenseId: string) =>
+ db.query.expenses.findFirst({
+ where: eq(expenses.id, expenseId),
+ with: { recurrence: { with: { job: true } } },
});
- const operations = [];
-
- if (!expense) {
- throw new Error('Expense not found');
- }
-
- if (expense.conversionToId) {
- await deleteExpense(expense.conversionToId, deletedBy);
- }
-
- operations.push(
- db.expense.update({
- where: { id: expenseId },
- data: {
- deletedBy,
- deletedAt: new Date(),
- },
- }),
- );
+export async function deleteExpense(expenseId: string, deletedBy: number) {
+ const items: BatchItem<'sqlite'>[] = [];
+ const touched: string[] = [];
+
+ // Collect the expense and its conversion chain, then soft-delete all of them atomically.
+ let currentId: string | null = expenseId;
+ while (currentId) {
+ const expense = await getExpenseWithJob(currentId);
+ if (!expense) {
+ throw new Error('Expense not found');
+ }
+ touched.push(expense.id);
- if (expense.recurrence?.job) {
- const templateId = extractTemplateExpenseId(expense.recurrence.job.command);
- const isTemplate = templateId === expense.id;
+ items.push(
+ db
+ .update(expenses)
+ .set({ deletedBy, deletedAt: new Date() })
+ .where(eq(expenses.id, expense.id)),
+ );
- if (isTemplate) {
- // Template deletion stops the recurrence
- operations.push(db.$executeRaw`SELECT cron.unschedule(${expense.recurrence.job.jobname})`);
- operations.push(
- db.expenseRecurrence.delete({
- where: { id: expense.recurrence.id },
- }),
- );
+ const job = expense.recurrence?.job;
+ // Template deletion stops the recurrence; deleting a derived expense keeps it running.
+ if (job && extractTemplateExpenseId(job.command) === expense.id) {
+ items.push(deleteJob(job.jobid));
}
- // Derived expense deletion: just soft-delete, recurrence continues
+
+ currentId = expense.conversionToId;
}
- await db.$transaction(operations);
- sendExpensePushNotification(expenseId).catch(console.error);
+ await runBatch(items);
+ runInBackground('push:expense', sendExpensePushNotification(expenseId));
}
export async function editExpense(
- {
- expenseId,
- paidBy,
- name,
- category,
- amount,
- splitType,
- currency,
- participants,
- expenseDate,
- fileKey,
- transactionId,
- cronExpression,
- }: CreateExpense & { cronExpression?: string },
+ input: CreateExpense & { cronExpression?: string },
currentUserId: number,
conversionToParams?: CreateExpense,
) {
+ const { expenseId, participants, expenseDate, fileKey, transactionId, cronExpression } = input;
if (!expenseId) {
throw new Error('Expense ID is required for editing');
}
- const expense = await db.expense.findUnique({
- where: { id: expenseId },
- include: {
- expenseParticipants: true,
- recurrence: {
- include: {
- job: true,
- },
- },
- },
- });
-
+ const expense = await getExpenseWithJob(expenseId);
if (!expense) {
throw new Error('Expense not found');
}
- // Determine if this is a template or derived expense
- const templateId = expense.recurrence?.job?.command
- ? extractTemplateExpenseId(expense.recurrence.job.command)
- : null;
- const isTemplate = templateId === expenseId;
+ const job = expense.recurrence?.job;
+ const isTemplate = job ? extractTemplateExpenseId(job.command) === expenseId : false;
- const operations = [];
+ if (conversionToParams && !expense.conversionToId) {
+ throw new Error('Conversion to expense not found for editing');
+ }
- // Delete existing participants
- operations.push(
- db.expenseParticipant.deleteMany({
- where: {
- expenseId: expense.conversionToId ? { in: [expenseId, expense.conversionToId] } : expenseId,
- },
- }),
- );
+ const affectedIds = expense.conversionToId ? [expenseId, expense.conversionToId] : [expenseId];
- // Update expense with new details and create new participants
- operations.push(
- db.expense.update({
- where: { id: expenseId },
- data: {
- paidBy,
- name,
- category,
- amount,
- splitType,
- currency,
- expenseParticipants: {
- create: participants,
- },
+ const items: BatchItem<'sqlite'>[] = [
+ db.delete(expenseParticipants).where(inArray(expenseParticipants.expenseId, affectedIds)),
+ db
+ .update(expenses)
+ .set({
+ ...expenseColumns(input),
fileKey,
transactionId,
- expenseDate,
+ ...(expenseDate && { expenseDate }),
updatedBy: currentUserId,
- },
- }),
- );
- if (conversionToParams) {
- if (!expense.conversionToId) {
- throw new Error('Conversion to expense not found for editing');
- }
- const { participants: toParticipants, ...toExpenseData } = conversionToParams;
-
- operations.push(
- db.expense.update({
- where: { id: expense.conversionToId },
- data: {
- ...toExpenseData,
- expenseParticipants: {
- create: toParticipants,
- },
+ })
+ .where(eq(expenses.id, expenseId)),
+ ...insertParticipants(expenseId, participants),
+ ];
+
+ if (conversionToParams && expense.conversionToId) {
+ items.push(
+ db
+ .update(expenses)
+ .set({
+ ...expenseColumns(conversionToParams),
+ ...(conversionToParams.expenseDate && { expenseDate: conversionToParams.expenseDate }),
updatedBy: currentUserId,
- },
- }),
+ })
+ .where(eq(expenses.id, expense.conversionToId)),
+ ...insertParticipants(expense.conversionToId, conversionToParams.participants),
);
}
- // Handle recurrence changes only for template expenses
- if (isTemplate && expense.recurrence?.job) {
- const currentSchedule = expense.recurrence.job.schedule;
-
- if (cronExpression && cronExpression !== currentSchedule) {
- // Schedule changed - update the cron job
- operations.push(
- db.$executeRaw`SELECT cron.alter_job(${expense.recurrence.job.jobid}, schedule := ${cronExpression})`,
- );
+ // Recurrence changes apply only to the template expense.
+ if (isTemplate && job) {
+ if (cronExpression && cronExpression !== job.schedule) {
+ items.push(updateJobSchedule(job.jobid, cronExpression));
} else if (!cronExpression) {
- // Recurrence removed - unschedule and delete recurrence record
- operations.push(db.$executeRaw`SELECT cron.unschedule(${expense.recurrence.job.jobname})`);
- operations.push(
- db.expenseRecurrence.delete({
- where: { id: expense.recurrence.id },
- }),
- );
+ items.push(deleteJob(job.jobid));
}
- // If cronExpression === currentSchedule, no action needed
}
- // For derived expenses, cronExpression is ignored entirely
- await db.$transaction(operations);
- sendExpensePushNotification(expenseId).catch(console.error);
- return { id: expenseId }; // Return the updated expense
+ await runBatch(items);
+ runInBackground('push:expense', sendExpensePushNotification(expenseId));
+ return { id: expenseId };
}
-export async function getCompleteFriendsDetails(userId: number) {
- const viewBalances = await db.balanceView.findMany({
- where: {
- userId,
- },
- include: {
- friend: true,
- },
- });
+/**
+ * Executes one recurring-expense job: copies the template expense and its participants.
+ *
+ * Idempotent per run slot: the copy's id is derived from (jobid, scheduled time), and the
+ * inserts use INSERT OR IGNORE, so a retried Workflow step cannot create duplicates.
+ */
+export async function runRecurringJob(jobid: number, now: Date = new Date()) {
+ const job = await db.query.jobs.findFirst({ where: eq(jobs.jobid, jobid) });
+ if (!job?.active || !job.nextRunAt || job.nextRunAt > now) {
+ return { skipped: true };
+ }
- const friends = viewBalances.reduce(
- (acc, balance) => {
- const { friendId } = balance;
- acc[friendId] ??= {
- balances: [],
- id: friendId,
- email: balance.friend.email,
- name: balance.friend.name,
- };
-
- if (0n !== balance.amount) {
- acc[friendId]?.balances.push({
- currency: balance.currency,
- amount: balance.amount,
- });
- }
+ const templateId = extractTemplateExpenseId(job.command);
+ const slot = job.nextRunAt;
+ const newId = await deterministicUuid(`${jobid}:${slot.getTime()}`);
+ const ts = now.getTime();
+
+ // Drizzle's `db.run()` cannot be part of a D1 batch, so the whole batch is built from prepared
+ // D1 statements. Drizzle builders contribute their SQL via `toSQL()`.
+ const prepare = (statement: { sql: string; params: unknown[] }) =>
+ bindings.DB.prepare(statement.sql).bind(...statement.params);
+
+ await bindings.DB.batch([
+ bindings.DB.prepare(
+ `INSERT OR IGNORE INTO "Expense" (
+ "id", "paidBy", "addedBy", "name", "category", "amount", "splitType", "expenseDate",
+ "createdAt", "updatedAt", "currency", "groupId", "updatedBy", "recurrenceId"
+ )
+ SELECT ?, "paidBy", "addedBy", "name", "category", "amount", "splitType", ?,
+ ?, ?, "currency", "groupId", "updatedBy", "recurrenceId"
+ FROM "Expense" WHERE "id" = ?`,
+ ).bind(newId, ts, ts, ts, templateId),
+ bindings.DB.prepare(
+ `INSERT OR IGNORE INTO "ExpenseParticipant" ("expenseId", "userId", "amount")
+ SELECT ?, "userId", "amount" FROM "ExpenseParticipant"
+ WHERE "expenseId" = ?
+ AND EXISTS (SELECT 1 FROM "Expense" WHERE "id" = ?)`,
+ ).bind(newId, templateId, newId),
+ prepare(
+ db
+ .update(expenseRecurrences)
+ .set({ notified: false })
+ .where(eq(expenseRecurrences.jobId, jobid))
+ .toSQL(),
+ ),
+ prepare(
+ db
+ .update(jobs)
+ .set({ lastRunAt: now, nextRunAt: nextRunAt(job.schedule, now) })
+ .where(eq(jobs.jobid, jobid))
+ .toSQL(),
+ ),
+ ]);
+
+ return { skipped: false, expenseId: newId };
+}
+
+const deterministicUuid = async (seed: string) => {
+ const hash = new Uint8Array(
+ await crypto.subtle.digest('SHA-256', new TextEncoder().encode(seed)),
+ );
+ const b = hash.slice(0, 16);
+ b[6] = (b[6]! & 0x0f) | 0x50; // Version 5-style
+ b[8] = (b[8]! & 0x3f) | 0x80; // RFC 4122 variant
+ const hex = [...b].map((x) => x.toString(16).padStart(2, '0')).join('');
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
+};
- return acc;
- },
- {} as Record<
+export async function getCompleteFriendsDetails(userId: number) {
+ const viewBalances = await db
+ .select({
+ balance: {
+ friendId: balanceView.friendId,
+ currency: balanceView.currency,
+ amount: balanceView.amount,
+ },
+ friend: { email: users.email, name: users.name },
+ })
+ .from(balanceView)
+ .innerJoin(users, eq(users.id, balanceView.friendId))
+ .where(eq(balanceView.userId, userId));
+
+ const friends = viewBalances.reduce<
+ Record<
number,
{
id: number;
@@ -357,48 +340,76 @@ export async function getCompleteFriendsDetails(userId: number) {
name?: string | null;
balances: { currency: string; amount: bigint }[];
}
- >,
- );
+ >
+ >((acc, { balance, friend }) => {
+ const { friendId } = balance;
+ acc[friendId] ??= {
+ balances: [],
+ id: friendId,
+ email: friend.email,
+ name: friend.name,
+ };
+
+ if (0n !== balance.amount) {
+ acc[friendId]?.balances.push({
+ currency: balance.currency,
+ amount: balance.amount,
+ });
+ }
+
+ return acc;
+ }, {});
return friends;
}
export async function getCompleteGroupDetails(userId: number) {
- const groups = await db.group.findMany({
- where: {
- groupUsers: {
- some: {
- userId,
- },
- },
- },
- include: {
- groupUsers: true,
- groupBalances: true,
- },
+ const userGroups = await db.query.groups.findMany({
+ where: inArray(
+ groups.id,
+ db
+ .select({ groupId: groupUsers.groupId })
+ .from(groupUsers)
+ .where(eq(groupUsers.userId, userId)),
+ ),
+ with: { groupUsers: true },
});
- return groups;
+ if (0 === userGroups.length) {
+ return [];
+ }
+
+ const balances = await db
+ .select()
+ .from(balanceView)
+ .where(
+ inArray(
+ balanceView.groupId,
+ userGroups.map((g) => g.id),
+ ),
+ );
+
+ return userGroups.map((g) => ({
+ ...g,
+ groupBalances: balances.filter((b) => b.groupId === g.id),
+ }));
}
export async function importUserBalanceFromSplitWise(
currentUserId: number,
splitWiseUsers: SplitwiseUser[],
) {
- const operations = [];
+ const items: BatchItem<'sqlite'>[] = [];
- const users = await createUsersFromSplitwise(splitWiseUsers);
+ const importedUsers = await createUsersFromSplitwise(splitWiseUsers);
- const userMap = users.reduce(
- (acc, user) => {
- if (user.email) {
- acc[user.email] = user;
- }
+ const userMap = importedUsers.reduce>((acc, user) => {
+ if (user.email) {
+ acc[user.email] = user;
+ }
- return acc;
- },
- {} as Record,
- );
+ return acc;
+ }, {});
const currencyHelperCache: Record['toSafeBigInt']> =
{};
@@ -413,80 +424,69 @@ export async function importUserBalanceFromSplitWise(
for (const balance of user.balance) {
const currency = balance.currency_code;
- if (!currencyHelperCache[currency]) {
- currencyHelperCache[currency] = getCurrencyHelpers({
- currency: isCurrencyCode(currency) ? currency : 'USD',
- }).toSafeBigInt;
- }
+ currencyHelperCache[currency] ??= getCurrencyHelpers({
+ currency: isCurrencyCode(currency) ? currency : 'USD',
+ }).toSafeBigInt;
const amount = currencyHelperCache[currency](balance.amount);
-
- operations.push(
- db.expense.create({
- data: {
- name: 'Splitwise Balance Import',
- amount,
- currency,
- paidBy: currentUserId,
- splitType: SplitType.EQUAL,
- expenseParticipants: {
- create: [
- {
- userId: currentUserId,
- amount: amount,
- },
- {
- userId: dbUser.id,
- amount: -amount,
- },
- ],
- },
- addedBy: currentUserId,
- category: DEFAULT_CATEGORY,
- },
+ const id = crypto.randomUUID();
+
+ items.push(
+ db.insert(expenses).values({
+ id,
+ name: 'Splitwise Balance Import',
+ amount,
+ currency,
+ paidBy: currentUserId,
+ splitType: SplitType.EQUAL,
+ addedBy: currentUserId,
+ category: DEFAULT_CATEGORY,
}),
+ ...insertParticipants(id, [
+ { userId: currentUserId, amount },
+ { userId: dbUser.id, amount: -amount },
+ ]),
);
}
}
- await db.$transaction(operations);
+ await runBatch(items);
}
-async function createUsersFromSplitwise(users: SplitwiseUser[]) {
- const userEmails = users.map((u) => u.email);
-
- const existingUsers = await db.user.findMany({
- where: {
- email: {
- in: userEmails,
- },
- },
- });
-
- const existingUserMap: Record = {};
-
- for (const user of existingUsers) {
- if (user.email) {
- existingUserMap[user.email] = true;
- }
+async function createUsersFromSplitwise(splitwiseUsers: SplitwiseUser[]) {
+ const userEmails = splitwiseUsers.map((u) => u.email);
+ if (0 === userEmails.length) {
+ return [];
}
- const newUsers = users.filter((u) => !existingUserMap[u.email]);
-
- await db.user.createMany({
- data: newUsers.map((u) => ({
- email: u.email,
- name: `${u.first_name}${u.last_name ? ` ${u.last_name}` : ''}`,
- })),
- });
+ const findExisting = async () =>
+ (
+ await Promise.all(
+ chunk(userEmails, 90).map((emails) =>
+ db.select().from(users).where(inArray(users.email, emails)),
+ ),
+ )
+ ).flat();
+
+ const existingEmails = new Set((await findExisting()).map((u) => u.email));
+ const newUsers = splitwiseUsers.filter((u) => !existingEmails.has(u.email));
+
+ // 2 bound parameters per row.
+ await runBatch(
+ chunk(newUsers, 45).map((rows) =>
+ db
+ .insert(users)
+ .values(
+ rows.map((u) => ({
+ email: u.email,
+ name: `${u.first_name}${u.last_name ? ` ${u.last_name}` : ''}`,
+ })),
+ )
+ .onConflictDoNothing(),
+ ),
+ );
- return db.user.findMany({
- where: {
- email: {
- in: userEmails,
- },
- },
- });
+ return findExisting();
}
export async function importGroupFromSplitwise(
@@ -501,76 +501,56 @@ export async function importGroupFromSplitwise(
}
}
- const users = await createUsersFromSplitwise(Object.values(splitwiseUserMap));
+ const importedUsers = await createUsersFromSplitwise(Object.values(splitwiseUserMap));
- const userMap = users.reduce(
- (acc, user) => {
- if (user.email) {
- acc[user.email] = user;
- }
+ const userMap = importedUsers.reduce>((acc, user) => {
+ if (user.email) {
+ acc[user.email] = user;
+ }
- return acc;
- },
- {} as Record,
- );
+ return acc;
+ }, {});
const missingGroups = await Promise.all(
splitWiseGroups.map(async (group) => {
- const dbGroup = await db.group.findUnique({
- where: {
- splitwiseGroupId: group.id.toString(),
- },
+ const dbGroup = await db.query.groups.findFirst({
+ where: eq(groups.splitwiseGroupId, group.id.toString()),
});
return dbGroup ? null : group;
}),
);
- const operations = missingGroups
- .filter((g) => null !== g)
- .map((group) => {
- const groupmembers = group.members.map((member) => ({
- userId: userMap[member.email.toString()]!.id,
- }));
-
- return db.group.create({
- data: {
- name: group.name,
- splitwiseGroupId: group.id.toString(),
- publicId: nanoid(),
- userId: currentUserId,
- groupUsers: {
- create: groupmembers,
- },
- },
- });
- });
+ const items: BatchItem<'sqlite'>[] = [];
+ for (const group of missingGroups) {
+ if (!group) {
+ // oxlint-disable-next-line no-continue
+ continue;
+ }
+ const splitwiseGroupId = group.id.toString();
+ const groupIdSql = sql`(SELECT ${groups.id} FROM ${groups} WHERE ${groups.splitwiseGroupId} = ${splitwiseGroupId})`;
+
+ items.push(
+ db.insert(groups).values({
+ name: group.name,
+ splitwiseGroupId,
+ publicId: nanoid(),
+ userId: currentUserId,
+ }),
+ // 2 bound parameters per row, plus the subquery parameter.
+ ...chunk(group.members, 45).map((members) =>
+ db.insert(groupUsers).values(
+ members.map((member) => ({
+ groupId: groupIdSql,
+ userId: userMap[member.email.toString()]!.id,
+ })),
+ ),
+ ),
+ );
+ }
- await db.$transaction(operations);
+ await runBatch(items);
}
-const getNonZeroParticipants = (participants: { userId: number; amount: bigint }[]) =>
+const getNonZeroParticipants = (participants: Participant[]) =>
participants.length > 1 ? participants.filter((p) => 0n !== p.amount) : participants;
-
-interface HistoricalBalance {
- userId: number;
- friendId: number;
- groupId: number | null;
- currency: string;
- amount: bigint;
-}
-
-export const getHistoricalBalances = async (
- userId: number,
- friendId: number,
- currency: string,
- beforeDate: Date,
-) =>
- db.$queryRaw`
- SELECT "userId", "friendId", "groupId", currency, amount
- FROM get_balance_at_date(${beforeDate})
- WHERE "userId" = ${userId}
- AND "friendId" = ${friendId}
- AND currency = ${currency}
- AND amount != 0
- `;
diff --git a/src/server/api/trpc.ts b/src/server/api/trpc.ts
index fa6fdbc1a..0346ceb7f 100644
--- a/src/server/api/trpc.ts
+++ b/src/server/api/trpc.ts
@@ -8,13 +8,15 @@
*/
import { TRPCError, initTRPC } from '@trpc/server';
-import { type CreateNextContextOptions } from '@trpc/server/adapters/next';
-import { type Session } from 'next-auth';
+import type { Session } from '~/server/auth';
import superjson from 'superjson';
import { ZodError, z } from 'zod';
import { getServerAuthSession } from '~/server/auth';
+import { and, eq } from 'drizzle-orm';
+
import { db } from '~/server/db';
+import { groupUsers } from '~/server/db/schema';
/**
* 1. CONTEXT
@@ -49,11 +51,9 @@ const createInnerTRPCContext = (opts: CreateContextOptions) => ({
*
* @see https://trpc.io/docs/context
*/
-export const createTRPCContext = async (opts: CreateNextContextOptions) => {
- const { req, res } = opts;
-
- // Get the session from the server using the getServerSession wrapper function
- const session = await getServerAuthSession({ req, res });
+export const createTRPCContext = async (opts: { req: { headers: Headers } }) => {
+ const { req } = opts;
+ const session = await getServerAuthSession({ req });
return createInnerTRPCContext({
session,
@@ -119,7 +119,7 @@ export const protectedProcedure = t.procedure.use(({ ctx, next }) => {
return next({
ctx: {
- // infers the `session` as non-nullable
+ // Infers the `session` as non-nullable
session: { ...ctx.session, user: ctx.session.user },
},
});
@@ -128,11 +128,8 @@ export const protectedProcedure = t.procedure.use(({ ctx, next }) => {
export const groupProcedure = protectedProcedure
.input(z.object({ groupId: z.number() }))
.use(async ({ ctx, input: { groupId }, next }) => {
- const groupUser = await ctx.db.groupUser.findFirst({
- where: {
- groupId,
- userId: ctx.session.user.id,
- },
+ const groupUser = await ctx.db.query.groupUsers.findFirst({
+ where: and(eq(groupUsers.groupId, groupId), eq(groupUsers.userId, ctx.session.user.id)),
});
if (!groupUser) {
diff --git a/src/server/auth.ts b/src/server/auth.ts
index d301b4a67..cc6a9fc1e 100644
--- a/src/server/auth.ts
+++ b/src/server/auth.ts
@@ -1,301 +1,172 @@
-import { PrismaAdapter } from '@next-auth/prisma-adapter';
-import { Prisma } from '@prisma/client';
+import { betterAuth } from 'better-auth';
+import { drizzleAdapter } from 'better-auth/adapters/drizzle';
+import { eq } from 'drizzle-orm';
import { type GetServerSidePropsContext } from 'next';
-import { type DefaultSession, type NextAuthOptions, type User, getServerSession } from 'next-auth';
-import { type Adapter, type AdapterAccount, type AdapterUser } from 'next-auth/adapters';
-import AuthentikProvider from 'next-auth/providers/authentik';
-import EmailProvider from 'next-auth/providers/email';
-import GoogleProvider from 'next-auth/providers/google';
-import KeycloakProvider from 'next-auth/providers/keycloak';
import { env } from '~/env';
import { db } from '~/server/db';
-import { parseEnvBoolean } from '~/utils/env';
-
-import { mailServerConfig, sendSignUpEmail } from './mailer';
-import { getBaseUrl } from '~/utils/api';
-import type { OAuthConfig } from 'next-auth/providers/oauth';
-
-/**
- * Module augmentation for `next-auth` types. Allows us to add custom properties to the `session`
- * object and keep type safety.
- *
- * @see https://next-auth.js.org/getting-started/typescript#module-augmentation
- */
-declare module 'next-auth' {
- interface Session extends DefaultSession {
- user: DefaultSession['user'] & {
- id: number;
- currency: string;
- defaultCurrency?: string | null;
- obapiProviderId?: string;
- bankingId?: string;
- preferredLanguage: string;
- hiddenFriendIds: number[];
- // ...other properties
- // Role: UserRole;
- };
- }
-
- interface User {
- id: number;
- name: string;
- email: string;
- image: string;
- currency: string;
- defaultCurrency?: string | null;
- obapiProviderId?: string;
- bankingId?: string;
- preferredLanguage: string;
- hiddenFriendIds: number[];
- }
+import { accounts, sessions, users, verifications } from '~/server/db/schema';
+import { PREVIEW_ACCESS_EMAIL_HEADER, PREVIEW_ACCESS_NAME_HEADER } from '~/server/preview-access';
+
+/** Session user shape the app expects (kept from the former next-auth session). */
+export interface SessionUser {
+ id: number;
+ name: string | null;
+ email: string | null;
+ image: string | null;
+ currency: string;
+ defaultCurrency?: string | null;
+ obapiProviderId?: string | null;
+ bankingId?: string | null;
+ preferredLanguage: string;
+ hiddenFriendIds: number[];
}
-const SplitProPrismaAdapter = (...args: Parameters): Adapter => {
- const prismaAdapter = PrismaAdapter(...args);
-
- return {
- ...prismaAdapter,
- createUser: (user: Omit): Promise => {
- // oxlint-disable-next-line typescript/no-unsafe-assignment
- const prismaCreateUser = prismaAdapter.createUser;
-
- if (env.INVITE_ONLY) {
- throw new Error('This instance is Invite Only');
- }
-
- if (!prismaCreateUser) {
- // This should never happen but typing says it's possible.
- throw new Error('Prisma Adapter lacks User Creation');
- }
-
- // oxlint-disable-next-line typescript/no-unsafe-return, typescript/no-unsafe-call
- return prismaCreateUser(user);
- },
- linkAccount: async (account: AdapterAccount) => {
- // oxlint-disable-next-line typescript/no-unsafe-assignment
- const originalLinkAccount = prismaAdapter.linkAccount;
-
- if (!originalLinkAccount) {
- throw new Error('Adapter is missing the linkAccount method.');
- }
-
- // OIDC providers can provide non-standard fields that do not exist in the prisma schema.
- // We strip them out before passing them on to the original adapter.
- const knownAccountFields = new Set(Object.values(Prisma.AccountScalarFieldEnum));
-
- const sanitised = Object.fromEntries(
- Object.entries(account as Record).filter(([k]) =>
- knownAccountFields.has(k),
- ),
- ) as AdapterAccount;
-
- return originalLinkAccount(sanitised);
- },
- } as Adapter;
-};
+export interface Session {
+ user: SessionUser;
+ expires: string;
+}
-/**
- * Options for NextAuth.js used to configure adapters, providers, callbacks, etc.
- *
- * @see https://next-auth.js.org/configuration/options
- */
-export const authOptions: NextAuthOptions = {
- pages: {
- signIn: '/auth/signin',
+export const auth = betterAuth({
+ appName: 'SplitPro',
+ baseURL: env.NEXTAUTH_URL,
+ secret: env.NEXTAUTH_SECRET,
+ database: drizzleAdapter(db, {
+ provider: 'sqlite',
+ schema: { user: users, session: sessions, account: accounts, verification: verifications },
+ }),
+ advanced: {
+ // Keep integer user ids: the whole app (balances, groups, expenses) uses them.
+ database: { generateId: 'serial' },
},
- callbacks: {
- session: ({ session, user }) => ({
- ...session,
- user: {
- ...session.user,
- id: user.id,
- currency: user.currency,
- defaultCurrency: user.defaultCurrency,
- obapiProviderId: user.obapiProviderId,
- bankingId: user.bankingId,
- preferredLanguage: user.preferredLanguage,
- hiddenFriendIds: user.hiddenFriendIds,
- },
- }),
- async signIn({ user, email }) {
- if (email?.verificationRequest && env.DISABLE_EMAIL_SIGNUP) {
- const existingUser = await db.user.findUnique({
- where: { email: user.email },
- });
-
- if (!existingUser) {
- return `${getBaseUrl()}/auth/signin?error=SignupDisabled`;
+ session: {
+ expiresIn: 60 * 60 * 24 * 30,
+ updateAge: 60 * 60 * 24,
+ },
+ account: {
+ accountLinking: { enabled: true, trustedProviders: ['google'] },
+ },
+ socialProviders:
+ env.GOOGLE_CLIENT_ID && env.GOOGLE_CLIENT_SECRET
+ ? {
+ google: {
+ clientId: env.GOOGLE_CLIENT_ID,
+ clientSecret: env.GOOGLE_CLIENT_SECRET,
+ prompt: 'select_account',
+ },
}
- }
-
- return true;
+ : {},
+ databaseHooks: {
+ user: {
+ create: {
+ before: async (user) => {
+ if (!user.email) {
+ return { data: user };
+ }
+ // Users created by invites or Splitwise imports already exist without an account.
+ // Better-auth links by email, so only brand-new emails reach this hook.
+ if (env.INVITE_ONLY) {
+ throw new Error('This instance is Invite Only');
+ }
+ const name = user.name?.trim() ? user.name : user.email.split('@')[0];
+ return { data: { ...user, email: user.email.toLowerCase(), name } };
+ },
+ },
},
},
- adapter: SplitProPrismaAdapter(db),
- providers: getProviders(),
- events: {
- createUser: async ({ user }) => {
- // Check if the user's name is empty
- if ((!user.name || '' === user.name.trim()) && user.email) {
- // Define the logic to update the user's name here
- const [updatedName] = user.email.split('@');
+});
- // Use your database client to update the user's name
- await db.user.update({
- where: { id: user.id },
- data: { name: updatedName },
- });
- }
- },
- },
-};
+/** Enabled sign-in providers, for the sign-in page. */
+export const getAuthProviders = () =>
+ env.GOOGLE_CLIENT_ID && env.GOOGLE_CLIENT_SECRET
+ ? [{ id: 'google', name: 'Google', type: 'oauth' as const }]
+ : [];
-/**
- * Wrapper for `getServerSession` so that you don't need to import the `authOptions` in every file.
- *
- * @see https://next-auth.js.org/configuration/nextjs
- */
-export const getServerAuthSession = (ctx: {
- req: GetServerSidePropsContext['req'];
- res: GetServerSidePropsContext['res'];
-}) => getServerSession(ctx.req, ctx.res, authOptions);
+export type AuthProvider = ReturnType[number];
-export const getServerAuthSessionForSSG = async (context: GetServerSidePropsContext) => {
- console.log('Before getting session');
- const session = await getServerAuthSession(context);
- console.log('After getting session');
-
- if (!session?.user?.email) {
- return {
- redirect: {
- destination: '/',
- permanent: false,
- },
- };
+const toHeaders = (raw: Headers | Record): Headers => {
+ if (raw instanceof Headers) {
+ return raw;
}
+ const headers = new Headers();
+ for (const [key, value] of Object.entries(raw)) {
+ if (Array.isArray(value)) {
+ value.forEach((v) => headers.append(key, v));
+ } else if (undefined !== value) {
+ headers.set(key, value);
+ }
+ }
+ return headers;
+};
+const loadSessionUser = async (userId: number): Promise => {
+ const user = await db.query.users.findFirst({ where: eq(users.id, userId) });
+ if (!user) {
+ return null;
+ }
return {
- props: {
- user: session.user,
- },
+ id: user.id,
+ name: user.name,
+ email: user.email,
+ image: user.image,
+ currency: user.currency,
+ defaultCurrency: user.defaultCurrency,
+ obapiProviderId: user.obapiProviderId,
+ bankingId: user.bankingId,
+ preferredLanguage: user.preferredLanguage,
+ hiddenFriendIds: user.hiddenFriendIds ?? [],
};
};
-/**
- * Get providers to enable
- */
-function getProviders() {
- const providersList = [];
-
- if (env.GOOGLE_CLIENT_ID && env.GOOGLE_CLIENT_SECRET) {
- providersList.push(
- GoogleProvider({
- clientId: env.GOOGLE_CLIENT_ID,
- clientSecret: env.GOOGLE_CLIENT_SECRET,
- allowDangerousEmailAccountLinking: true,
- }),
- );
- }
-
- if (env.EMAIL_SERVER_HOST) {
- providersList.push(
- EmailProvider({
- from: env.FROM_EMAIL,
- server: mailServerConfig,
- async sendVerificationRequest({ identifier: email, url, token }) {
- const result = await sendSignUpEmail(email, url, token);
- if (!result) {
- throw new Error('Failed to send email');
- }
- },
- generateVerificationToken() {
- return Math.random().toString(36).substring(2, 7).toLowerCase();
- },
- }),
- );
+/** Session from request headers (cookie). Returns the app's full session user. */
+export const getSessionFromHeaders = async (
+ raw: Headers | Record,
+): Promise => {
+ const headers = toHeaders(raw);
+ if (env.PREVIEW_ACCESS_AUTH) {
+ const email = headers.get(PREVIEW_ACCESS_EMAIL_HEADER)?.trim().toLowerCase();
+ if (!email) return null;
+
+ await db
+ .insert(users)
+ .values({
+ email,
+ name: headers.get(PREVIEW_ACCESS_NAME_HEADER)?.trim() || email.split('@')[0],
+ emailVerified: true,
+ })
+ .onConflictDoNothing();
+
+ const accessUser = await db.query.users.findFirst({ where: eq(users.email, email) });
+ if (!accessUser) return null;
+ const user = await loadSessionUser(Number(accessUser.id));
+ if (!user) return null;
+ return { user, expires: new Date(Date.now() + 8 * 60 * 60 * 1000).toISOString() };
}
- if (env.AUTHENTIK_ID && env.AUTHENTIK_SECRET && env.AUTHENTIK_ISSUER) {
- providersList.push(
- AuthentikProvider({
- clientId: env.AUTHENTIK_ID,
- clientSecret: env.AUTHENTIK_SECRET,
- issuer: env.AUTHENTIK_ISSUER,
- allowDangerousEmailAccountLinking: true,
- }),
- );
+ const result = await auth.api.getSession({ headers });
+ if (!result) {
+ return null;
}
-
- if (env.KEYCLOAK_ID && env.KEYCLOAK_SECRET && env.KEYCLOAK_ISSUER) {
- providersList.push(
- KeycloakProvider({
- clientId: env.KEYCLOAK_ID,
- clientSecret: env.KEYCLOAK_SECRET,
- issuer: env.KEYCLOAK_ISSUER,
- allowDangerousEmailAccountLinking: true,
- }),
- );
+ const user = await loadSessionUser(Number(result.user.id));
+ if (!user) {
+ return null;
}
+ return { user, expires: new Date(result.session.expiresAt).toISOString() };
+};
- if (env.OIDC_CLIENT_ID && env.OIDC_CLIENT_SECRET && env.OIDC_WELL_KNOWN_URL) {
- providersList.push({
- id: env.OIDC_NAME?.toLowerCase() ?? 'oidc',
- name: env.OIDC_NAME ?? 'OIDC',
- clientId: env.OIDC_CLIENT_ID,
- clientSecret: env.OIDC_CLIENT_SECRET,
- type: 'oauth',
- wellKnown: env.OIDC_WELL_KNOWN_URL,
- authorization: { params: { scope: 'openid email profile' } },
- allowDangerousEmailAccountLinking: env.OIDC_ALLOW_DANGEROUS_EMAIL_LINKING,
- idToken: true,
- profile(profile) {
- // This function expects a "standard" next-auth user but we override
- // What a next-auth user is above. The expected next-auth user must be
- // A record that has an id, a name, an email, and an image.
- //
- // To work around this, we case to unknown and then `User`.
- return {
- id: profile.sub,
- name: profile.name,
- email: profile.email,
- image: profile.picture,
- } as unknown as User;
- },
- } satisfies OAuthConfig<{
- sub: string;
- name: string;
- email: string;
- picture: string;
- preferred_username: string;
- }>);
- }
-
- return providersList;
-}
-
-/**
- * Validates the environment variables that are related to authentication.
- * this will check if atleat one provider is set properly.
- *
- * this function should be updated if new providers are added.
- */
-export function validateAuthEnv() {
- console.log('Validating auth env');
- if (!parseEnvBoolean(process.env.SKIP_ENV_VALIDATION)) {
- const providers = getProviders();
- const oauthProviders = providers.filter((provider) => 'oauth' === provider.type);
+/** Drop-in for the former next-auth wrapper used by getServerSideProps and tRPC. */
+export const getServerAuthSession = (ctx: {
+ req:
+ | GetServerSidePropsContext['req']
+ | { headers: Headers | Record };
+}) => getSessionFromHeaders(ctx.req.headers as Record);
- if (env.OAUTH_AUTO_REDIRECT && (1 !== oauthProviders.length || 1 !== providers.length)) {
- console.warn(
- 'OAUTH_AUTO_REDIRECT is enabled, but automatic redirection will not happen until exactly one OAuth provider and no other authentication providers are configured.',
- );
- }
+export const getServerAuthSessionForSSG = async (context: GetServerSidePropsContext) => {
+ const session = await getServerAuthSession(context);
- if (0 === providers.length) {
- throw new Error(
- 'No authentication providers are configured, at least one is required. Learn more here: https://github.com/oss-apps/split-pro?tab=readme-ov-file#setting-up-the-environment',
- );
- }
+ if (!session?.user?.email) {
+ return { redirect: { destination: '/', permanent: false } };
}
-}
+
+ return { props: { user: session.user } };
+};
diff --git a/src/server/db.ts b/src/server/db.ts
deleted file mode 100644
index 86818a8f8..000000000
--- a/src/server/db.ts
+++ /dev/null
@@ -1,22 +0,0 @@
-import { PrismaClient } from '@prisma/client';
-
-import { env } from '~/env';
-
-// oxlint-disable-next-line no-shadow-restricted-names
-declare namespace globalThis {
- // oxlint-disable-next-line no-unused-vars
- let prisma: PrismaClient | undefined;
-}
-
-export const db =
- globalThis.prisma ??
- (await (async () => {
- const prisma = new PrismaClient({
- log: 'development' === env.NODE_ENV ? ['error', 'warn'] : ['error'],
- });
- return prisma;
- })());
-
-if ('production' !== env.NODE_ENV) {
- globalThis.prisma = db;
-}
diff --git a/src/server/db/batch.ts b/src/server/db/batch.ts
new file mode 100644
index 000000000..fb5858d3e
--- /dev/null
+++ b/src/server/db/batch.ts
@@ -0,0 +1,26 @@
+import type { BatchItem } from 'drizzle-orm/batch';
+
+import { getDb } from './index';
+
+/**
+ * Runs statements in one D1 batch. D1 executes a batch as a single SQL transaction:
+ * if any statement fails, every statement in the batch is rolled back.
+ */
+export const runBatch = async (items: BatchItem<'sqlite'>[]): Promise => {
+ if (0 === items.length) {
+ return [];
+ }
+ return getDb().batch(items as [BatchItem<'sqlite'>, ...BatchItem<'sqlite'>[]]);
+};
+
+/**
+ * D1 allows at most 100 bound parameters per statement. Use this to split multi-row
+ * inserts: `chunk(rows, Math.floor(100 / columnsPerRow))`.
+ */
+export const chunk = (items: T[], size: number): T[][] => {
+ const out: T[][] = [];
+ for (let i = 0; i < items.length; i += size) {
+ out.push(items.slice(i, i + size));
+ }
+ return out;
+};
diff --git a/src/server/db/enums.ts b/src/server/db/enums.ts
new file mode 100644
index 000000000..325e3fdb3
--- /dev/null
+++ b/src/server/db/enums.ts
@@ -0,0 +1,11 @@
+// Dependency-free so client components can import it without pulling in drizzle-orm.
+export const SplitType = {
+ EQUAL: 'EQUAL',
+ PERCENTAGE: 'PERCENTAGE',
+ EXACT: 'EXACT',
+ SHARE: 'SHARE',
+ ADJUSTMENT: 'ADJUSTMENT',
+ SETTLEMENT: 'SETTLEMENT',
+ CURRENCY_CONVERSION: 'CURRENCY_CONVERSION',
+} as const;
+export type SplitType = (typeof SplitType)[keyof typeof SplitType];
diff --git a/src/server/db/index.ts b/src/server/db/index.ts
new file mode 100644
index 000000000..5454b7f1f
--- /dev/null
+++ b/src/server/db/index.ts
@@ -0,0 +1,22 @@
+import { env } from 'cloudflare:workers';
+import { type DrizzleD1Database, drizzle } from 'drizzle-orm/d1';
+
+import * as schema from './schema';
+
+export type DB = DrizzleD1Database;
+
+/** Cloudflare bindings for this Worker. Keep in sync with wrangler.jsonc. */
+export interface AppBindings {
+ DB: D1Database;
+ UPLOADS: R2Bucket;
+ EMAIL?: SendEmail;
+ SCHEDULER?: Workflow;
+ [key: string]: unknown;
+}
+
+export const bindings = env as unknown as AppBindings;
+
+/** Drizzle over the D1 binding. `cloudflare:workers` env is available at module scope. */
+export const db: DB = drizzle(bindings.DB, { schema });
+
+export const getDb = (): DB => db;
diff --git a/src/server/db/schema.ts b/src/server/db/schema.ts
new file mode 100644
index 000000000..74401a9cc
--- /dev/null
+++ b/src/server/db/schema.ts
@@ -0,0 +1,456 @@
+/**
+ * Drizzle schema for Cloudflare D1 (SQLite). Port of the former Prisma/Postgres schema.
+ *
+ * Conventions:
+ * - Table and column names match the old Prisma names, so SQL stays familiar.
+ * - Money amounts are stored as INTEGER (minor units) and surfaced as `bigint` in JS.
+ * - Timestamps are stored as INTEGER milliseconds and surfaced as `Date`.
+ */
+import { relations, sql } from 'drizzle-orm';
+import {
+ customType,
+ index,
+ integer,
+ primaryKey,
+ real,
+ sqliteTable,
+ sqliteView,
+ text,
+ uniqueIndex,
+} from 'drizzle-orm/sqlite-core';
+
+import { SplitType } from './enums';
+
+export { SplitType };
+const splitTypeValues = Object.values(SplitType) as [SplitType, ...SplitType[]];
+
+/** INTEGER column surfaced as bigint. D1 returns numbers; amounts stay well below 2^53. */
+export const bigintInt = customType<{ data: bigint; driverData: number | bigint }>({
+ dataType: () => 'integer',
+ toDriver: (value) => Number(value),
+ fromDriver: (value) => BigInt(value),
+});
+
+const timestamp = (name: string) => integer(name, { mode: 'timestamp_ms' });
+const createdAt = () =>
+ timestamp('createdAt')
+ .notNull()
+ .$defaultFn(() => new Date());
+const updatedAt = () =>
+ timestamp('updatedAt')
+ .notNull()
+ .$defaultFn(() => new Date())
+ .$onUpdateFn(() => new Date());
+const randomId = () => crypto.randomUUID();
+
+export const users = sqliteTable('User', {
+ id: integer('id').primaryKey({ autoIncrement: true }),
+ name: text('name'),
+ email: text('email').unique(),
+ emailVerified: integer('emailVerified', { mode: 'boolean' }).notNull().default(false),
+ image: text('image'),
+ currency: text('currency').notNull().default('USD'),
+ defaultCurrency: text('defaultCurrency'),
+ preferredLanguage: text('preferredLanguage').notNull().default(''),
+ bankingId: text('bankingId'),
+ obapiProviderId: text('obapiProviderId'),
+ hiddenFriendIds: text('hiddenFriendIds', { mode: 'json' })
+ .$type()
+ .notNull()
+ .default(sql`'[]'`),
+ createdAt: createdAt(),
+ updatedAt: updatedAt(),
+});
+
+// Better-auth tables (serial ids; see src/server/auth.ts).
+export const accounts = sqliteTable(
+ 'Account',
+ {
+ id: integer('id').primaryKey({ autoIncrement: true }),
+ userId: integer('userId')
+ .notNull()
+ .references(() => users.id, { onDelete: 'cascade' }),
+ accountId: text('accountId').notNull(),
+ providerId: text('providerId').notNull(),
+ accessToken: text('accessToken'),
+ refreshToken: text('refreshToken'),
+ idToken: text('idToken'),
+ accessTokenExpiresAt: timestamp('accessTokenExpiresAt'),
+ refreshTokenExpiresAt: timestamp('refreshTokenExpiresAt'),
+ scope: text('scope'),
+ password: text('password'),
+ createdAt: createdAt(),
+ updatedAt: updatedAt(),
+ },
+ (t) => [
+ uniqueIndex('Account_providerId_accountId_key').on(t.providerId, t.accountId),
+ index('Account_userId_idx').on(t.userId),
+ ],
+);
+
+export const sessions = sqliteTable(
+ 'Session',
+ {
+ id: integer('id').primaryKey({ autoIncrement: true }),
+ token: text('token').notNull().unique(),
+ userId: integer('userId')
+ .notNull()
+ .references(() => users.id, { onDelete: 'cascade' }),
+ expiresAt: timestamp('expiresAt').notNull(),
+ ipAddress: text('ipAddress'),
+ userAgent: text('userAgent'),
+ createdAt: createdAt(),
+ updatedAt: updatedAt(),
+ },
+ (t) => [index('Session_userId_idx').on(t.userId)],
+);
+
+export const verifications = sqliteTable(
+ 'Verification',
+ {
+ id: integer('id').primaryKey({ autoIncrement: true }),
+ identifier: text('identifier').notNull(),
+ value: text('value').notNull(),
+ expiresAt: timestamp('expiresAt').notNull(),
+ createdAt: createdAt(),
+ updatedAt: updatedAt(),
+ },
+ (t) => [index('Verification_identifier_idx').on(t.identifier)],
+);
+
+export const groups = sqliteTable('Group', {
+ id: integer('id').primaryKey({ autoIncrement: true }),
+ publicId: text('publicId').notNull().unique(),
+ name: text('name').notNull(),
+ image: text('image'),
+ userId: integer('userId')
+ .notNull()
+ .references(() => users.id, { onDelete: 'cascade' }),
+ defaultCurrency: text('defaultCurrency'),
+ createdAt: createdAt(),
+ updatedAt: updatedAt(),
+ splitwiseGroupId: text('splitwiseGroupId').unique(),
+ simplifyDebts: integer('simplifyDebts', { mode: 'boolean' }).notNull().default(false),
+ archivedAt: timestamp('archivedAt'),
+});
+
+export const groupDefaultSplits = sqliteTable('GroupDefaultSplit', {
+ groupId: integer('groupId')
+ .primaryKey()
+ .references(() => groups.id, { onDelete: 'cascade' }),
+ splitType: text('splitType', { enum: splitTypeValues }).notNull(),
+ shares: text('shares', { mode: 'json' }).notNull(),
+ createdAt: createdAt(),
+ updatedAt: updatedAt(),
+});
+
+export const friendDefaultSplits = sqliteTable(
+ 'FriendDefaultSplit',
+ {
+ userAId: integer('userAId')
+ .notNull()
+ .references(() => users.id, { onDelete: 'cascade' }),
+ userBId: integer('userBId')
+ .notNull()
+ .references(() => users.id, { onDelete: 'cascade' }),
+ splitType: text('splitType', { enum: splitTypeValues }).notNull(),
+ shares: text('shares', { mode: 'json' }).notNull(),
+ createdAt: createdAt(),
+ updatedAt: updatedAt(),
+ },
+ (t) => [primaryKey({ columns: [t.userAId, t.userBId] })],
+);
+
+export const groupUsers = sqliteTable(
+ 'GroupUser',
+ {
+ groupId: integer('groupId')
+ .notNull()
+ .references(() => groups.id, { onDelete: 'cascade' }),
+ userId: integer('userId')
+ .notNull()
+ .references(() => users.id, { onDelete: 'cascade' }),
+ },
+ (t) => [
+ primaryKey({ columns: [t.groupId, t.userId] }),
+ index('GroupUser_userId_idx').on(t.userId),
+ ],
+);
+
+/** Replaces pg_cron's `cron.job`. Executed by the scheduler Workflow. */
+export const jobs = sqliteTable(
+ 'job',
+ {
+ jobid: integer('jobid').primaryKey({ autoIncrement: true }),
+ schedule: text('schedule').notNull(),
+ command: text('command').notNull(),
+ jobname: text('jobname').notNull().unique(),
+ active: integer('active', { mode: 'boolean' }).notNull().default(true),
+ nextRunAt: timestamp('nextRunAt'),
+ lastRunAt: timestamp('lastRunAt'),
+ },
+ (t) => [index('job_active_nextRunAt_idx').on(t.active, t.nextRunAt)],
+);
+
+export const expenseRecurrences = sqliteTable('ExpenseRecurrence', {
+ id: integer('id').primaryKey({ autoIncrement: true }),
+ jobId: integer('jobId')
+ .notNull()
+ .unique()
+ .references(() => jobs.jobid, { onDelete: 'cascade' }),
+ notified: integer('notified', { mode: 'boolean' }).notNull().default(true),
+});
+
+export const expenses = sqliteTable(
+ 'Expense',
+ {
+ id: text('id').primaryKey().$defaultFn(randomId),
+ paidBy: integer('paidBy')
+ .notNull()
+ .references(() => users.id, { onDelete: 'cascade' }),
+ addedBy: integer('addedBy')
+ .notNull()
+ .references(() => users.id, { onDelete: 'cascade' }),
+ name: text('name').notNull(),
+ category: text('category').notNull(),
+ amount: bigintInt('amount').notNull(),
+ splitType: text('splitType', { enum: splitTypeValues }).notNull().default('EQUAL'),
+ expenseDate: timestamp('expenseDate')
+ .notNull()
+ .$defaultFn(() => new Date()),
+ createdAt: createdAt(),
+ updatedAt: updatedAt(),
+ currency: text('currency').notNull(),
+ fileKey: text('fileKey'),
+ groupId: integer('groupId').references(() => groups.id, { onDelete: 'cascade' }),
+ deletedAt: timestamp('deletedAt'),
+ deletedBy: integer('deletedBy').references(() => users.id, { onDelete: 'cascade' }),
+ updatedBy: integer('updatedBy').references(() => users.id, { onDelete: 'set null' }),
+ transactionId: text('transactionId'),
+ recurrenceId: integer('recurrenceId').references(() => expenseRecurrences.id, {
+ onDelete: 'set null',
+ }),
+ // oxlint-disable-next-line typescript/no-explicit-any
+ conversionToId: text('conversionToId')
+ .unique()
+ .references((): any => expenses.id, { onDelete: 'cascade' }),
+ },
+ (t) => [
+ index('Expense_groupId_idx').on(t.groupId),
+ index('Expense_paidBy_idx').on(t.paidBy),
+ index('Expense_recurrenceId_idx').on(t.recurrenceId),
+ ],
+);
+
+export const expenseParticipants = sqliteTable(
+ 'ExpenseParticipant',
+ {
+ expenseId: text('expenseId')
+ .notNull()
+ .references(() => expenses.id, { onDelete: 'cascade' }),
+ userId: integer('userId')
+ .notNull()
+ .references(() => users.id, { onDelete: 'cascade' }),
+ amount: bigintInt('amount').notNull(),
+ },
+ (t) => [
+ primaryKey({ columns: [t.expenseId, t.userId] }),
+ index('ExpenseParticipant_userId_idx').on(t.userId),
+ ],
+);
+
+export const expenseNotes = sqliteTable('ExpenseNote', {
+ id: text('id').primaryKey().$defaultFn(randomId),
+ note: text('note').notNull(),
+ createdById: integer('createdById')
+ .notNull()
+ .references(() => users.id, { onDelete: 'cascade' }),
+ createdAt: createdAt(),
+ expenseId: text('expenseId')
+ .notNull()
+ .references(() => expenses.id, { onDelete: 'cascade' }),
+});
+
+export const cachedCurrencyRates = sqliteTable(
+ 'CachedCurrencyRate',
+ {
+ from: text('from').notNull(),
+ to: text('to').notNull(),
+ date: timestamp('date').notNull(),
+ rate: real('rate').notNull(),
+ lastFetched: timestamp('lastFetched').notNull(),
+ createdAt: createdAt(),
+ updatedAt: updatedAt(),
+ },
+ (t) => [primaryKey({ columns: [t.from, t.to, t.date] })],
+);
+
+export const cachedBankData = sqliteTable('CachedBankData', {
+ id: integer('id').primaryKey({ autoIncrement: true }),
+ obapiProviderId: text('obapiProviderId').notNull().unique(),
+ data: text('data').notNull(),
+ userId: integer('userId')
+ .notNull()
+ .references(() => users.id, { onDelete: 'cascade' }),
+ lastFetched: timestamp('lastFetched').notNull(),
+ createdAt: createdAt(),
+ updatedAt: updatedAt(),
+});
+
+export const pushNotifications = sqliteTable(
+ 'PushNotification',
+ {
+ userId: integer('userId').notNull(),
+ endpoint: text('endpoint').notNull(),
+ subscription: text('subscription').notNull(),
+ },
+ (t) => [primaryKey({ columns: [t.userId, t.endpoint] })],
+);
+
+export const appMetadata = sqliteTable('AppMetadata', {
+ key: text('key').primaryKey(),
+ value: text('value').notNull(),
+ createdAt: createdAt(),
+ updatedAt: updatedAt(),
+});
+
+/**
+ * Balances derived from non-deleted expenses. Defined in SQL by the migration
+ * `0001_balance_view_and_triggers.sql`; `.existing()` stops drizzle-kit from managing it.
+ */
+export const balanceView = sqliteView('BalanceView', {
+ userId: integer('userId').notNull(),
+ friendId: integer('friendId').notNull(),
+ groupId: integer('groupId'),
+ currency: text('currency').notNull(),
+ amount: bigintInt('amount').notNull(),
+ createdAt: timestamp('createdAt').notNull(),
+ updatedAt: timestamp('updatedAt').notNull(),
+}).existing();
+
+// ---------------------------------------------------------------------------
+// Relations (used by db.query.*.findMany({ with }))
+// ---------------------------------------------------------------------------
+
+export const usersRelations = relations(users, ({ many }) => ({
+ accounts: many(accounts),
+ sessions: many(sessions),
+ groups: many(groups),
+ associatedGroups: many(groupUsers),
+ expenseParticipants: many(expenseParticipants),
+ expenseNotes: many(expenseNotes),
+ paidExpenses: many(expenses, { relationName: 'PaidByUser' }),
+ addedExpenses: many(expenses, { relationName: 'AddedByUser' }),
+ deletedExpenses: many(expenses, { relationName: 'DeletedByUser' }),
+ updatedExpenses: many(expenses, { relationName: 'UpdatedByUser' }),
+ cachedBankData: many(cachedBankData),
+}));
+
+export const accountsRelations = relations(accounts, ({ one }) => ({
+ user: one(users, { fields: [accounts.userId], references: [users.id] }),
+}));
+
+export const sessionsRelations = relations(sessions, ({ one }) => ({
+ user: one(users, { fields: [sessions.userId], references: [users.id] }),
+}));
+
+export const groupsRelations = relations(groups, ({ one, many }) => ({
+ createdBy: one(users, { fields: [groups.userId], references: [users.id] }),
+ groupUsers: many(groupUsers),
+ expenses: many(expenses),
+ groupDefaultSplit: one(groupDefaultSplits, {
+ fields: [groups.id],
+ references: [groupDefaultSplits.groupId],
+ }),
+}));
+
+export const groupDefaultSplitsRelations = relations(groupDefaultSplits, ({ one }) => ({
+ group: one(groups, { fields: [groupDefaultSplits.groupId], references: [groups.id] }),
+}));
+
+export const friendDefaultSplitsRelations = relations(friendDefaultSplits, ({ one }) => ({
+ userA: one(users, { fields: [friendDefaultSplits.userAId], references: [users.id] }),
+ userB: one(users, { fields: [friendDefaultSplits.userBId], references: [users.id] }),
+}));
+
+export const groupUsersRelations = relations(groupUsers, ({ one }) => ({
+ group: one(groups, { fields: [groupUsers.groupId], references: [groups.id] }),
+ user: one(users, { fields: [groupUsers.userId], references: [users.id] }),
+}));
+
+export const jobsRelations = relations(jobs, ({ one }) => ({
+ recurrence: one(expenseRecurrences, {
+ fields: [jobs.jobid],
+ references: [expenseRecurrences.jobId],
+ }),
+}));
+
+export const expenseRecurrencesRelations = relations(expenseRecurrences, ({ one, many }) => ({
+ job: one(jobs, { fields: [expenseRecurrences.jobId], references: [jobs.jobid] }),
+ expense: many(expenses),
+}));
+
+export const expensesRelations = relations(expenses, ({ one, many }) => ({
+ paidByUser: one(users, {
+ fields: [expenses.paidBy],
+ references: [users.id],
+ relationName: 'PaidByUser',
+ }),
+ addedByUser: one(users, {
+ fields: [expenses.addedBy],
+ references: [users.id],
+ relationName: 'AddedByUser',
+ }),
+ deletedByUser: one(users, {
+ fields: [expenses.deletedBy],
+ references: [users.id],
+ relationName: 'DeletedByUser',
+ }),
+ updatedByUser: one(users, {
+ fields: [expenses.updatedBy],
+ references: [users.id],
+ relationName: 'UpdatedByUser',
+ }),
+ group: one(groups, { fields: [expenses.groupId], references: [groups.id] }),
+ recurrence: one(expenseRecurrences, {
+ fields: [expenses.recurrenceId],
+ references: [expenseRecurrences.id],
+ }),
+ conversionTo: one(expenses, {
+ fields: [expenses.conversionToId],
+ references: [expenses.id],
+ relationName: 'CurrencyConversion',
+ }),
+ conversionFrom: many(expenses, { relationName: 'CurrencyConversion' }),
+ expenseParticipants: many(expenseParticipants),
+ expenseNotes: many(expenseNotes),
+}));
+
+export const expenseParticipantsRelations = relations(expenseParticipants, ({ one }) => ({
+ expense: one(expenses, { fields: [expenseParticipants.expenseId], references: [expenses.id] }),
+ user: one(users, { fields: [expenseParticipants.userId], references: [users.id] }),
+}));
+
+export const expenseNotesRelations = relations(expenseNotes, ({ one }) => ({
+ expense: one(expenses, { fields: [expenseNotes.expenseId], references: [expenses.id] }),
+ createdBy: one(users, { fields: [expenseNotes.createdById], references: [users.id] }),
+}));
+
+export const cachedBankDataRelations = relations(cachedBankData, ({ one }) => ({
+ user: one(users, { fields: [cachedBankData.userId], references: [users.id] }),
+}));
+
+// ---------------------------------------------------------------------------
+// Row types (replacements for the former @prisma/client model types)
+// ---------------------------------------------------------------------------
+
+export type User = Omit;
+export type Group = typeof groups.$inferSelect;
+export type GroupUser = typeof groupUsers.$inferSelect;
+export type Expense = typeof expenses.$inferSelect;
+export type ExpenseParticipant = typeof expenseParticipants.$inferSelect;
+export type ExpenseNote = typeof expenseNotes.$inferSelect;
+export type ExpenseRecurrence = typeof expenseRecurrences.$inferSelect;
+export type Job = typeof jobs.$inferSelect;
+export type BalanceView = typeof balanceView.$inferSelect;
+export type Account = typeof accounts.$inferSelect;
diff --git a/src/server/db/types.ts b/src/server/db/types.ts
new file mode 100644
index 000000000..7d5f5c826
--- /dev/null
+++ b/src/server/db/types.ts
@@ -0,0 +1,14 @@
+// Client-safe entry point: the enum value plus type-only re-exports of row types.
+export { SplitType } from './enums';
+export type {
+ Account,
+ BalanceView,
+ Expense,
+ ExpenseNote,
+ ExpenseParticipant,
+ ExpenseRecurrence,
+ Group,
+ GroupUser,
+ Job,
+ User,
+} from './schema';
diff --git a/src/server/mailer.ts b/src/server/mailer.ts
index 15c601cfc..bf60b487d 100644
--- a/src/server/mailer.ts
+++ b/src/server/mailer.ts
@@ -1,72 +1,27 @@
-import { type User } from 'next-auth';
-import nodemailer, { type Transporter } from 'nodemailer';
-
import { env } from '~/env';
+import type { SessionUser as User } from '~/server/auth';
+import { bindings } from '~/server/db';
import { sendToDiscord } from './service-notification';
-// oxlint-disable-next-line init-declarations
-let transporter: Transporter;
-
-export const mailServerConfig = {
- host: env.EMAIL_SERVER_HOST,
- port: parseInt(env.EMAIL_SERVER_PORT ?? ''),
- auth: { user: env.EMAIL_SERVER_USER, pass: env.EMAIL_SERVER_PASSWORD },
- tls: {
- rejectUnauthorized: env.NODE_ENV !== 'development' ? env.EMAIL_TLS_REJECT_UNAUTHORIZED : false,
- },
-};
-
-const getTransporter = () => {
- if (transporter) {
- return transporter;
- }
-
- if (!mailServerConfig.host) {
- return;
- }
-
- const transport = {
- ...mailServerConfig,
- secure: 465 === mailServerConfig.port,
- };
-
- transporter = nodemailer.createTransport(transport);
- return transporter;
-};
-
-export async function sendSignUpEmail(email: string, url: string, token: string) {
- const { host } = new URL(url);
-
- console.log(env.NODE_ENV);
-
- if ('development' === env.NODE_ENV) {
- console.log('Sign in link : ', email, url, token);
- return true;
- }
-
- const subject = 'Sign in to SplitPro';
- const text = `Hey,\n\nYou can sign in to SplitPro by clicking the below URL:\n${url}\n\nYou can also use this OTP: ${token}\n\nThanks,\nSplitPro Team`;
- const html = `Hey,
You can sign in to SplitPro by clicking the below URL:
Sign in to ${host}
You can also use this OTP: ${token}
Thanks,
SplitPro Team`;
-
- return await sendMail(email, subject, text, html);
-}
+const escapeHtml = (value: string) =>
+ value.replace(
+ /[&<>"']/g,
+ (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]!,
+ );
export async function sendInviteEmail(email: string, name: string) {
if (!env.ENABLE_SENDING_INVITES) {
throw new Error('Sending invites is not enabled');
}
- const { host } = new URL(env.NEXTAUTH_URL);
-
- if ('development' === env.NODE_ENV) {
- console.log('Sending invite email', email, name);
- return;
- }
+ const url = env.NEXTAUTH_URL;
+ const { host } = new URL(url);
+ const safeName = escapeHtml(name);
const subject = 'Invitation to SplitPro';
- const text = `Hey,\n\nYou have been invited to SplitPro by ${name}. It's a completely open source free alternative to splitwise. You can sign in to SplitPro by clicking the below URL:\n${env.NEXTAUTH_URL}\n\nThanks,\nSplitPro Team`;
- const html = `Hey,
You have been invited to SplitPro by ${name}. It's a completely open source free alternative to splitwise. You can sign in to SplitPro by clicking the below URL:
Sign in to ${host}
Thanks,
SplitPro Team
`;
+ const text = `Hey,\n\nYou have been invited to SplitPro by ${name}. It's a completely open source free alternative to splitwise. You can sign in to SplitPro by clicking the below URL:\n${url}\n\nThanks,\nSplitPro Team`;
+ const html = `Hey,
You have been invited to SplitPro by ${safeName}. It's a completely open source free alternative to splitwise. You can sign in to SplitPro by clicking the below URL:
Sign in to ${host}
Thanks,
SplitPro Team
`;
await sendMail(email, subject, text, html);
}
@@ -81,33 +36,28 @@ export async function sendFeedbackEmail(feedback: string, user: User) {
const subject = `Feedback received on SplitPro from ${user.name}`;
const text = `Feedback created by ${user.name} :\n\nFeedback: ${feedback}\n\nemail: ${user.email}`;
- await sendMail(env.FEEDBACK_EMAIL, subject, text, text, user.email ?? undefined);
+ await sendMail(env.FEEDBACK_EMAIL, subject, text, escapeHtml(text), user.email ?? undefined);
}
-async function sendMail(
- email: string,
- subject: string,
- text: string,
- html: string,
- replyTo?: string,
-) {
- const transporter = getTransporter();
- try {
- if (transporter) {
- await transporter.sendMail({
- to: email,
- from: env.FROM_EMAIL,
- subject,
- text,
- html,
- replyTo,
- });
+/** Sends through the Cloudflare Email Sending binding (`send_email` → EMAIL). */
+async function sendMail(to: string, subject: string, text: string, html: string, replyTo?: string) {
+ const mailer = bindings.EMAIL;
+ if (!mailer || !env.FROM_EMAIL) {
+ console.log('Email binding or FROM_EMAIL not configured, so skipping');
+ return false;
+ }
- console.log('Email sent');
- return true;
- } else {
- console.log('SMTP server not configured, so skipping');
- }
+ try {
+ await mailer.send({
+ to,
+ from: { email: env.FROM_EMAIL, name: 'SplitPro' },
+ subject,
+ text,
+ html,
+ ...(replyTo && { replyTo }),
+ });
+ console.log('Email sent');
+ return true;
} catch (error) {
console.log('Error sending email', error);
await sendToDiscord(
diff --git a/src/server/observability/background.ts b/src/server/observability/background.ts
new file mode 100644
index 000000000..c5a90e9bd
--- /dev/null
+++ b/src/server/observability/background.ts
@@ -0,0 +1,18 @@
+import { getRequestExecutionContext } from 'vinext/shims/request-context';
+
+import { logError } from './errorLog';
+
+/**
+ * Runs fire-and-forget work so it survives the response (via `ctx.waitUntil`) and any
+ * failure is logged to Workers Observability instead of being lost.
+ */
+export const runInBackground = (name: string, work: Promise) => {
+ const guarded = work.catch((error: unknown) => {
+ logError('worker', error, { phase: 'background', task: name });
+ });
+ try {
+ getRequestExecutionContext()?.waitUntil(guarded);
+ } catch {
+ // No request context (e.g. inside a Workflow step): the promise still runs.
+ }
+};
diff --git a/src/server/observability/errorLog.ts b/src/server/observability/errorLog.ts
new file mode 100644
index 000000000..6231fbc8d
--- /dev/null
+++ b/src/server/observability/errorLog.ts
@@ -0,0 +1,45 @@
+/**
+ * Structured error logging for Workers Observability.
+ *
+ * Every entry is one `console.error` call with a JSON object, so Workers Logs can filter on
+ * fields such as `source`, `path`, or `errorName`. Nothing is sampled
+ * (see `observability` in wrangler.jsonc).
+ */
+export type ErrorSource = 'worker' | 'unhandledrejection' | 'trpc' | 'ssr' | 'workflow' | 'browser';
+
+export interface ErrorContext {
+ [key: string]: unknown;
+}
+
+const MAX_STRING = 4000;
+
+const truncate = (value: string) =>
+ value.length > MAX_STRING ? `${value.slice(0, MAX_STRING)}…[truncated]` : value;
+
+export const serializeError = (error: unknown) => {
+ if (error instanceof Error) {
+ const cause = (error as Error & { cause?: unknown }).cause;
+ return {
+ errorName: error.name,
+ errorMessage: truncate(error.message),
+ stack: error.stack ? truncate(error.stack) : undefined,
+ cause: cause instanceof Error ? `${cause.name}: ${truncate(cause.message)}` : undefined,
+ };
+ }
+ return { errorName: 'NonError', errorMessage: truncate(String(error)) };
+};
+
+export const logError = (source: ErrorSource, error: unknown, context: ErrorContext = {}) => {
+ console.error(
+ JSON.stringify(
+ {
+ level: 'error',
+ source,
+ ...serializeError(error),
+ ...context,
+ timestamp: new Date().toISOString(),
+ },
+ (_key, value: unknown) => ('bigint' === typeof value ? value.toString() : value),
+ ),
+ );
+};
diff --git a/src/server/preview-access.ts b/src/server/preview-access.ts
new file mode 100644
index 000000000..36168205c
--- /dev/null
+++ b/src/server/preview-access.ts
@@ -0,0 +1,2 @@
+export const PREVIEW_ACCESS_EMAIL_HEADER = 'x-splitpro-preview-access-email';
+export const PREVIEW_ACCESS_NAME_HEADER = 'x-splitpro-preview-access-name';
diff --git a/src/server/workflows/scheduler.ts b/src/server/workflows/scheduler.ts
new file mode 100644
index 000000000..58caa3919
--- /dev/null
+++ b/src/server/workflows/scheduler.ts
@@ -0,0 +1,69 @@
+/**
+ * Scheduler Workflow. Replaces pg_cron and the old in-process setTimeout loops.
+ * Triggered every minute by the `schedules` on the SCHEDULER Workflow binding (wrangler.jsonc).
+ */
+import { WorkflowEntrypoint, type WorkflowEvent, type WorkflowStep } from 'cloudflare:workers';
+
+import { env as appEnv } from '~/env';
+import { checkRecurrenceNotifications } from '~/server/api/services/notificationService';
+import { deleteStaleCache, firesInMinute, getDueJobs } from '~/server/api/services/scheduleService';
+import { runRecurringJob } from '~/server/api/services/splitService';
+import { logError } from '~/server/observability/errorLog';
+
+const retry = {
+ retries: { limit: 3, delay: '10 seconds' as const, backoff: 'exponential' as const },
+};
+
+export class SchedulerWorkflow extends WorkflowEntrypoint {
+ async run(event: WorkflowEvent, step: WorkflowStep) {
+ const now = new Date(event.schedule?.scheduledTime ?? event.timestamp.getTime());
+
+ const due = await step.do('load due jobs', async () =>
+ (await getDueJobs(now)).map((j) => j.jobid),
+ );
+
+ // One step per job: retried independently; runRecurringJob is idempotent per run slot.
+ for (const jobid of due) {
+ await step.do(`run job ${jobid}`, retry, async () => {
+ try {
+ const result = await runRecurringJob(jobid, now);
+ return { jobid, skipped: result.skipped };
+ } catch (error) {
+ logError('workflow', error, { step: 'run job', jobid, instanceId: event.instanceId });
+ throw error;
+ }
+ });
+ }
+
+ if (due.length > 0) {
+ await step.do('recurrence notifications', retry, async () => {
+ try {
+ await checkRecurrenceNotifications();
+ return true;
+ } catch (error) {
+ logError('workflow', error, {
+ step: 'recurrence notifications',
+ instanceId: event.instanceId,
+ });
+ throw error;
+ }
+ });
+ }
+
+ const cleanupRule = appEnv.CLEAR_CACHE_CRON_RULE;
+ const retention = appEnv.CACHE_RETENTION_INTERVAL;
+ if (cleanupRule && retention && firesInMinute(cleanupRule, now)) {
+ await step.do('cache cleanup', retry, async () => {
+ try {
+ await deleteStaleCache(retention, now);
+ return true;
+ } catch (error) {
+ logError('workflow', error, { step: 'cache cleanup', instanceId: event.instanceId });
+ throw error;
+ }
+ });
+ }
+
+ return { ranJobs: due.length };
+ }
+}
diff --git a/src/store/addStore.ts b/src/store/addStore.ts
index 0dbf1d40b..4da93d999 100644
--- a/src/store/addStore.ts
+++ b/src/store/addStore.ts
@@ -1,4 +1,4 @@
-import { type Group, SplitType, type User } from '@prisma/client';
+import { type Group, SplitType, type User } from '~/server/db/types';
import Router from 'next/router';
import { create } from 'zustand';
diff --git a/src/sw.ts b/src/sw.ts
new file mode 100644
index 000000000..7955258a8
--- /dev/null
+++ b/src/sw.ts
@@ -0,0 +1,58 @@
+import { type PushMessage } from '~/types';
+import { defaultCache } from '@serwist/vite/worker';
+import type { PrecacheEntry, SerwistGlobalConfig } from 'serwist';
+import { Serwist } from 'serwist';
+
+// This declares the value of `injectionPoint` to TypeScript.
+// `injectionPoint` is the string that will be replaced by the
+// Actual precache manifest. By default, this string is set to
+// `"self.__SW_MANIFEST"`.
+declare global {
+ interface WorkerGlobalScope extends SerwistGlobalConfig {
+ __SW_MANIFEST: (PrecacheEntry | string)[] | undefined;
+ }
+}
+
+declare const self: ServiceWorkerGlobalScope;
+
+const serwist = new Serwist({
+ precacheEntries: self.__SW_MANIFEST,
+ skipWaiting: true,
+ clientsClaim: true,
+ navigationPreload: true,
+ runtimeCaching: defaultCache,
+});
+
+self.addEventListener('push', function (event) {
+ const { title, message, data } = JSON.parse(event?.data?.text() ?? '{}') as PushMessage;
+ event.waitUntil(
+ self.registration.showNotification(title, {
+ body: message,
+ icon: '/icons/android-chrome-192x192.png',
+ data,
+ }),
+ );
+});
+
+self.addEventListener('notificationclick', function (event) {
+ event.notification.close();
+ event.waitUntil(
+ (async () => {
+ const clientList = await self.clients.matchAll({ type: 'window' });
+
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
+ const url = (event.notification.data?.url as string) ?? '/';
+
+ const matchingClient = clientList.find((client) => client.focused) ?? clientList[0];
+
+ if (matchingClient) {
+ const client = await matchingClient.focus();
+ await client.navigate(url);
+ } else {
+ await self.clients.openWindow(url);
+ }
+ })(),
+ );
+});
+
+serwist.addEventListeners();
diff --git a/src/tests/addStore.test.ts b/src/tests/addStore.test.ts
deleted file mode 100644
index 026f654d0..000000000
--- a/src/tests/addStore.test.ts
+++ /dev/null
@@ -1,1190 +0,0 @@
-import { SplitType, type User } from '@prisma/client';
-
-import {
- type AddExpenseState,
- type Participant,
- calculateParticipantSplit,
- calculateSplitShareBasedOnAmount,
- initSplitShares,
- useAddExpenseStore,
-} from '~/store/addStore';
-import { getCurrencyHelpers } from '~/utils/numbers';
-
-// Mock dependencies
-jest.mock('~/utils/array', () => ({
- shuffleArray: jest.fn((arr: T[]): T[] => arr), // No shuffling for predictable tests
-}));
-
-// Create mock users for testing
-const createMockUser = (id: number, name: string, email: string): User => ({
- id,
- name,
- email,
- currency: 'USD',
- defaultCurrency: null,
- emailVerified: null,
- image: null,
- preferredLanguage: 'en',
- obapiProviderId: null,
- bankingId: null,
- hiddenFriendIds: [],
-});
-
-const user1: User = createMockUser(1, 'Alice', 'alice@example.com');
-const user2: User = createMockUser(2, 'Bob', 'bob@example.com');
-const user3: User = createMockUser(3, 'Charlie', 'charlie@example.com');
-
-// Create participants with initial amounts
-const createParticipants = (users: User[], amounts: bigint[] = []): Participant[] =>
- users.map((user, index) => ({
- ...user,
- amount: amounts[index] ?? 0n,
- }));
-
-// Helper to create split shares structure
-const createSplitShares = (participants: Participant[], splitType: SplitType, shares: bigint[]) => {
- const splitShares: Record> = {};
-
- participants.forEach((participant, index) => {
- splitShares[participant.id] = initSplitShares();
- splitShares[participant.id]![splitType] = shares[index] ?? 0n;
- });
-
- return splitShares;
-};
-
-describe('calculateParticipantSplit', () => {
- describe('Edge cases', () => {
- it('should return participants unchanged when amount is 0', () => {
- const participants = createParticipants([user1, user2, user3]);
- const splitShares = createSplitShares(participants, SplitType.EQUAL, [1n, 1n, 1n]);
-
- const state: Partial = {
- amount: 0n,
- participants,
- splitType: SplitType.EQUAL,
- splitShares,
- paidBy: user1,
- expenseDate: new Date('2024-01-01'),
- };
-
- const result = calculateParticipantSplit(state as AddExpenseState);
-
- expect(result.participants).toEqual(participants);
- expect(result.canSplitScreenClosed).toBe(true);
- });
- it('should handle empty participants array', () => {
- const state: Partial = {
- amount: 10000n,
- participants: [],
- splitType: SplitType.EQUAL,
- splitShares: {},
- paidBy: undefined,
- expenseDate: new Date('2024-01-01'),
- };
-
- const result = calculateParticipantSplit(state as AddExpenseState);
-
- expect(result.participants).toEqual([]);
- expect(result.canSplitScreenClosed).toBe(false);
- });
- });
-
- describe('SplitType.EQUAL', () => {
- it('should split amount equally among all participants', () => {
- const participants = createParticipants([user1, user2, user3]);
- const splitShares = createSplitShares(participants, SplitType.EQUAL, [1n, 1n, 1n]);
-
- const state: Partial = {
- amount: 30000n,
- participants,
- splitType: SplitType.EQUAL,
- splitShares,
- paidBy: user1,
- expenseDate: new Date('2024-01-01'),
- };
-
- const result = calculateParticipantSplit(state as AddExpenseState);
-
- // Each person should owe $100 (10000n), but payer gets the difference
- expect(result.participants[0]?.amount).toBe(20000n); // Payer: -10000n + 30000n = 20000n
- expect(result.participants[1]?.amount).toBe(-10000n); // Owes $100
- expect(result.participants[2]?.amount).toBe(-10000n); // Owes $100
- expect(result.canSplitScreenClosed).toBe(true);
- });
-
- it('should exclude participants with 0 share from equal split', () => {
- const participants = createParticipants([user1, user2, user3]);
- const splitShares = createSplitShares(participants, SplitType.EQUAL, [1n, 1n, 0n]);
-
- const state: Partial = {
- amount: 20000n,
- participants,
- splitType: SplitType.EQUAL,
- splitShares,
- paidBy: user1,
- expenseDate: new Date('2024-01-01'),
- };
-
- const result = calculateParticipantSplit(state as AddExpenseState);
-
- // Only first two participants split the cost
- expect(result.participants[0]?.amount).toBe(10000n); // Payer: -10000n + 20000n = 10000n
- expect(result.participants[1]?.amount).toBe(-10000n); // Owes $100
- expect(result.participants[2]?.amount).toBe(0n); // Excluded from split
- });
-
- it('should allow one non-payer to owe the full amount', () => {
- const participants = createParticipants([user1, user2]);
- const splitShares = createSplitShares(participants, SplitType.EQUAL, [0n, 1n]);
-
- const state: Partial = {
- amount: 10000n,
- participants,
- splitType: SplitType.EQUAL,
- splitShares,
- paidBy: user1,
- expenseDate: new Date('2024-01-01'),
- };
-
- const result = calculateParticipantSplit(state as AddExpenseState);
-
- expect(result.participants[0]?.amount).toBe(10000n);
- expect(result.participants[1]?.amount).toBe(-10000n);
- expect(result.canSplitScreenClosed).toBe(true);
- });
-
- it('should mark a multi-person self-only split as incomplete', () => {
- const participants = createParticipants([user1, user2]);
- const splitShares = createSplitShares(participants, SplitType.EQUAL, [1n, 0n]);
-
- const state: Partial = {
- amount: 10000n,
- participants,
- splitType: SplitType.EQUAL,
- splitShares,
- paidBy: user1,
- expenseDate: new Date('2024-01-01'),
- };
-
- const result = calculateParticipantSplit(state as AddExpenseState);
-
- expect(result.participants[0]?.amount).toBe(0n);
- expect(result.participants[1]?.amount).toBe(0n);
- expect(result.canSplitScreenClosed).toBe(false);
- });
-
- it('should mark equal split incomplete when every share is disabled', () => {
- const participants = createParticipants([user1, user2]);
- const splitShares = createSplitShares(participants, SplitType.EQUAL, [0n, 0n]);
-
- const state: Partial = {
- amount: 10000n,
- participants,
- splitType: SplitType.EQUAL,
- splitShares,
- paidBy: user1,
- expenseDate: new Date('2024-01-01'),
- };
-
- const result = calculateParticipantSplit(state as AddExpenseState);
-
- expect(result.canSplitScreenClosed).toBe(false);
- });
-
- it('should handle uneven division with penny adjustment', () => {
- const participants = createParticipants([user1, user2, user3]);
- const splitShares = createSplitShares(participants, SplitType.EQUAL, [1n, 1n, 1n]);
-
- const state: Partial = {
- amount: 10001n,
- participants,
- splitType: SplitType.EQUAL,
- splitShares,
- paidBy: user1,
- expenseDate: new Date('2024-01-01'),
- };
-
- const result = calculateParticipantSplit(state as AddExpenseState);
-
- // Should distribute the extra penny
- const totalOwed = result.participants.reduce((sum, p) => sum + (p.amount ?? 0n), 0n);
- expect(totalOwed).toBe(0n); // Total should always balance // Payer should get the remainder
- expect(result.participants[0]?.amount).toBeGreaterThanOrEqual(6667n);
- });
-
- it('should handle single participant as payer', () => {
- const participants = createParticipants([user1]);
- const splitShares = createSplitShares(participants, SplitType.EQUAL, [1n]);
-
- const state: Partial = {
- amount: 10000n,
- participants,
- splitType: SplitType.EQUAL,
- splitShares,
- paidBy: user1,
- expenseDate: new Date('2024-01-01'),
- };
-
- const result = calculateParticipantSplit(state as AddExpenseState);
-
- expect(result.participants[0]?.amount).toBe(0n); // Payer pays and owes nothing
- expect(result.canSplitScreenClosed).toBe(true);
- });
- });
-
- describe('SplitType.PERCENTAGE', () => {
- it('should split amount by percentage', () => {
- const participants = createParticipants([user1, user2, user3]);
- const splitShares = createSplitShares(participants, SplitType.PERCENTAGE, [
- 5000n /* Lines 153-154 omitted */,
- 3000n /* Lines 154-155 omitted */,
- 2000n /* Lines 155-156 omitted */,
- ]);
-
- const state: Partial = {
- amount: 10000n,
- participants,
- splitType: SplitType.PERCENTAGE,
- splitShares,
- paidBy: user1,
- expenseDate: new Date('2024-01-01'),
- };
-
- const result = calculateParticipantSplit(state as AddExpenseState);
-
- expect(result.participants[0]?.amount).toBe(5000n); // Payer: -5000n + 10000n = 5000n
- expect(result.participants[1]?.amount).toBe(-3000n); // Owes 30%
- expect(result.participants[2]?.amount).toBe(-2000n); // Owes 20%
- expect(result.canSplitScreenClosed).toBe(true);
- });
-
- it('should mark incomplete when percentages do not sum to 100%', () => {
- const participants = createParticipants([user1, user2, user3]);
- const splitShares = createSplitShares(participants, SplitType.PERCENTAGE, [
- 4000n /* Lines 175-176 omitted */,
- 3000n /* Lines 176-177 omitted */,
- 2000n /* Lines 177-178 omitted */,
- ]);
-
- const state: Partial = {
- amount: 10000n,
- participants,
- splitType: SplitType.PERCENTAGE,
- splitShares,
- paidBy: user1,
- expenseDate: new Date('2024-01-01'),
- };
-
- const result = calculateParticipantSplit(state as AddExpenseState);
-
- expect(result.canSplitScreenClosed).toBe(false);
- });
-
- it('should handle 0% share participants', () => {
- const participants = createParticipants([user1, user2, user3]);
- const splitShares = createSplitShares(participants, SplitType.PERCENTAGE, [
- 7000n /* Lines 194-195 omitted */,
- 3000n /* Lines 195-196 omitted */,
- 0n /* Lines 196-197 omitted */,
- ]);
-
- const state: Partial = {
- amount: 10000n,
- participants,
- splitType: SplitType.PERCENTAGE,
- splitShares,
- paidBy: user1,
- expenseDate: new Date('2024-01-01'),
- };
-
- const result = calculateParticipantSplit(state as AddExpenseState);
-
- expect(result.participants[0]?.amount).toBe(3000n); // Payer: -7000n + 10000n
- expect(result.participants[1]?.amount).toBe(-3000n);
- expect(result.participants[2]?.amount).toBe(0n); // No share
- });
- });
-
- describe('SplitType.SHARE', () => {
- it('should split amount by shares', () => {
- const participants = createParticipants([user1, user2, user3]);
- const splitShares = createSplitShares(participants, SplitType.SHARE, [
- 200n /* Lines 217-218 omitted */,
- 100n /* Lines 218-219 omitted */,
- 100n /* Lines 219-220 omitted */,
- ]);
-
- const state: Partial = {
- amount: 12000n,
- participants,
- splitType: SplitType.SHARE,
- splitShares,
- paidBy: user1,
- expenseDate: new Date('2024-01-01'),
- };
-
- const result = calculateParticipantSplit(state as AddExpenseState);
-
- expect(result.participants[0]?.amount).toBe(6000n); // Payer: -6000n + 12000n = 6000n (50%)
- expect(result.participants[1]?.amount).toBe(-3000n); // 25%
- expect(result.participants[2]?.amount).toBe(-3000n); // 25%
- expect(result.canSplitScreenClosed).toBe(true);
- });
-
- it('should handle participants with 0 shares', () => {
- const participants = createParticipants([user1, user2, user3]);
- const splitShares = createSplitShares(participants, SplitType.SHARE, [
- 300n /* Lines 239-240 omitted */,
- 100n /* Lines 240-241 omitted */,
- 0n /* Lines 241-242 omitted */,
- ]);
-
- const state: Partial = {
- amount: 8000n,
- participants,
- splitType: SplitType.SHARE,
- splitShares,
- paidBy: user1,
- expenseDate: new Date('2024-01-01'),
- };
-
- const result = calculateParticipantSplit(state as AddExpenseState);
-
- expect(result.participants[0]?.amount).toBe(2000n); // Payer: -6000n + 8000n (75%)
- expect(result.participants[1]?.amount).toBe(-2000n); // 25%
- expect(result.participants[2]?.amount).toBe(0n); // No shares
- });
-
- it('should mark incomplete when no shares are assigned', () => {
- const participants = createParticipants([user1, user2, user3]);
- const splitShares = createSplitShares(participants, SplitType.SHARE, [0n, 0n, 0n]);
-
- const state: Partial = {
- amount: 10000n,
- participants,
- splitType: SplitType.SHARE,
- splitShares,
- paidBy: user1,
- expenseDate: new Date('2024-01-01'),
- };
-
- const result = calculateParticipantSplit(state as AddExpenseState);
-
- expect(result.canSplitScreenClosed).toBe(false);
- });
- });
-
- describe('SplitType.EXACT', () => {
- it('should assign exact amounts to participants', () => {
- const participants = createParticipants([user1, user2, user3]);
- const splitShares = createSplitShares(participants, SplitType.EXACT, [
- 4000n /* Lines 277-278 omitted */,
- 3000n /* Lines 278-279 omitted */,
- 3000n /* Lines 279-280 omitted */,
- ]);
-
- const state: Partial = {
- amount: 10000n,
- participants,
- splitType: SplitType.EXACT,
- splitShares,
- paidBy: user1,
- expenseDate: new Date('2024-01-01'),
- };
-
- const result = calculateParticipantSplit(state as AddExpenseState);
-
- expect(result.participants[0]?.amount).toBe(6000n); // Payer: -4000n + 10000n
- expect(result.participants[1]?.amount).toBe(-3000n);
- expect(result.participants[2]?.amount).toBe(-3000n);
- expect(result.canSplitScreenClosed).toBe(true);
- });
-
- it('should mark incomplete when exact amounts do not sum to total', () => {
- const participants = createParticipants([user1, user2, user3]);
- const splitShares = createSplitShares(participants, SplitType.EXACT, [
- 4000n /* Lines 299-300 omitted */,
- 3000n /* Lines 300-301 omitted */,
- 2000n /* Lines 301-302 omitted */,
- ]);
-
- const state: Partial = {
- amount: 10000n,
- participants,
- splitType: SplitType.EXACT,
- splitShares,
- paidBy: user1,
- expenseDate: new Date('2024-01-01'),
- };
-
- const result = calculateParticipantSplit(state as AddExpenseState);
-
- expect(result.canSplitScreenClosed).toBe(false);
- });
-
- it('should handle undefined exact amounts as 0', () => {
- const participants = createParticipants([user1, user2, user3]);
- const splitShares = createSplitShares(participants, SplitType.EXACT, [
- 10000n /* Lines 318-319 omitted */,
- 0n /* Lines 319-320 omitted */,
- 0n /* Lines 320-321 omitted */,
- ]);
-
- const state: Partial = {
- amount: 10000n,
- participants,
- splitType: SplitType.EXACT,
- splitShares,
- paidBy: user1,
- expenseDate: new Date('2024-01-01'),
- };
-
- const result = calculateParticipantSplit(state as AddExpenseState);
-
- expect(result.participants[0]?.amount).toBe(0n); // Payer: -10000n + 10000n
- expect(result.participants[1]?.amount).toBe(0n);
- expect(result.participants[2]?.amount).toBe(0n);
- });
- });
-
- describe('SplitType.ADJUSTMENT', () => {
- it('should distribute remaining amount equally after adjustments', () => {
- const participants = createParticipants([user1, user2, user3]);
- const splitShares = createSplitShares(participants, SplitType.ADJUSTMENT, [
- 1000n /* Lines 341-342 omitted */,
- -500n /* Lines 342-343 omitted */,
- 0n /* Lines 343-344 omitted */,
- ]);
-
- const state: Partial = {
- amount: 15000n,
- participants,
- splitType: SplitType.ADJUSTMENT,
- splitShares,
- paidBy: user1,
- expenseDate: new Date('2024-01-01'),
- };
-
- const result = calculateParticipantSplit(state as AddExpenseState);
-
- // Remaining after adjustments: 15000n - 1000n - (-500n) - 0n = 14500n
- // Split equally: 14500n / 3 = ~4833n each
- const baseShare = (15000n - 1000n - -500n - 0n) / 3n; // 4833n
-
- expect(result.participants[0]?.amount).toBe(9166n); // Payer adjustment (adjusted for rounding)
- expect(result.participants[1]?.amount).toBe(-(baseShare - 500n));
- expect(result.participants[2]?.amount).toBe(-baseShare);
- });
-
- it('should mark incomplete when adjustments exceed total amount', () => {
- const participants = createParticipants([user1, user2, user3]);
- const splitShares = createSplitShares(participants, SplitType.ADJUSTMENT, [
- 8000n /* Lines 366-367 omitted */,
- 5000n /* Lines 367-368 omitted */,
- 0n,
- ]);
-
- const state: Partial = {
- amount: 10000n,
- participants,
- splitType: SplitType.ADJUSTMENT,
- splitShares,
- paidBy: user1,
- expenseDate: new Date('2024-01-01'),
- };
-
- const result = calculateParticipantSplit(state as AddExpenseState);
-
- expect(result.canSplitScreenClosed).toBe(false);
- });
- });
-
- describe('Payer scenarios', () => {
- it('should handle different payers correctly', () => {
- const participants = createParticipants([user1, user2, user3]);
- const splitShares = createSplitShares(participants, SplitType.EQUAL, [1n, 1n, 1n]);
-
- const state: Partial = {
- amount: 30000n,
- participants,
- splitType: SplitType.EQUAL,
- splitShares,
- paidBy: user2,
- expenseDate: new Date('2024-01-01'),
- };
-
- // Test with user2 as payer
- const result = calculateParticipantSplit(state as AddExpenseState);
-
- expect(result.participants[0]?.amount).toBe(-10000n); // Owes
- expect(result.participants[1]?.amount).toBe(20000n); // Payer: -10000n + 30000n
- expect(result.participants[2]?.amount).toBe(-10000n); // Owes
- });
-
- it('should handle when payer is not in participants list', () => {
- const participants = createParticipants([user1, user2, user3]);
- const splitShares = createSplitShares(participants, SplitType.EQUAL, [1n, 1n, 1n]);
-
- const externalPayer = createMockUser(4, 'David', 'david@example.com');
-
- const state: Partial = {
- amount: 30000n,
- participants,
- splitType: SplitType.EQUAL,
- splitShares,
- paidBy: externalPayer,
- expenseDate: new Date('2024-01-01'),
- };
-
- const result = calculateParticipantSplit(state as AddExpenseState);
-
- // All participants should owe their share but total balances to 0 due to penny adjustment
- expect(result.participants[0]?.amount).toBe(0n);
- expect(result.participants[1]?.amount).toBe(0n);
- expect(result.participants[2]?.amount).toBe(0n);
- });
- });
-
- describe('Balance verification', () => {
- it('should always maintain balance (total amounts sum to 0)', () => {
- const participants = createParticipants([user1, user2, user3]);
- const splitShares = createSplitShares(participants, SplitType.PERCENTAGE, [
- 4000n, // 40%
- 3500n, // 35%
- 2500n, // 25%
- ]);
-
- const state: Partial = {
- amount: 12345n,
- participants,
- splitType: SplitType.PERCENTAGE,
- splitShares,
- paidBy: user1,
- expenseDate: new Date('2024-01-01'),
- };
-
- const result = calculateParticipantSplit(state as AddExpenseState);
-
- const totalAmount = result.participants.reduce((sum, p) => sum + (p.amount ?? 0n), 0n);
-
- expect(totalAmount).toBe(0n);
- });
-
- it('should handle penny adjustments correctly', () => {
- const participants = createParticipants([user1, user2]);
- const splitShares = createSplitShares(participants, SplitType.EQUAL, [1n, 1n]);
-
- const state: Partial = {
- amount: 1n,
- participants,
- splitType: SplitType.EQUAL,
- splitShares,
- paidBy: user1,
- expenseDate: new Date('2024-01-01'),
- };
-
- const result = calculateParticipantSplit(state as AddExpenseState);
-
- // One participant should get the penny
- const totalAmount = result.participants.reduce((sum, p) => sum + (p.amount ?? 0n), 0n);
-
- expect(totalAmount).toBe(0n);
- expect(result.participants.some((p) => -1n === p.amount)).toBe(true);
- });
- });
-});
-
-describe('calculateSplitShareBasedOnAmount', () => {
- describe('SplitType.EQUAL', () => {
- it('should set equal shares for participants with non-zero amounts', () => {
- const participants = createParticipants([user1, user2, user3], [5000n, 5000n, 0n]);
- const splitShares: Record> = {};
- participants.forEach((p) => {
- splitShares[p.id] = initSplitShares();
- });
-
- calculateSplitShareBasedOnAmount(10000n, participants, SplitType.EQUAL, splitShares, user1);
-
- expect(splitShares[user1.id]![SplitType.EQUAL]).toBe(1n);
- expect(splitShares[user2.id]![SplitType.EQUAL]).toBe(1n);
- expect(splitShares[user3.id]![SplitType.EQUAL]).toBe(0n); // Zero amount
- });
- });
-
- describe('SplitType.PERCENTAGE', () => {
- it('should calculate percentage shares for regular participants', () => {
- const participants = createParticipants([user1, user2, user3], [-3000n, -2000n, -5000n]);
- const splitShares: Record> = {};
- participants.forEach((p) => {
- splitShares[p.id] = initSplitShares();
- });
-
- calculateSplitShareBasedOnAmount(10000n, participants, SplitType.PERCENTAGE, splitShares);
-
- expect(splitShares[user1.id]![SplitType.PERCENTAGE]).toBe(3000n); // 30%
- expect(splitShares[user2.id]![SplitType.PERCENTAGE]).toBe(2000n); // 20%
- expect(splitShares[user3.id]![SplitType.PERCENTAGE]).toBe(5000n); // 50%
- });
-
- it('should calculate percentage shares when payer is specified', () => {
- const participants = createParticipants([user1, user2, user3], [8000n, -3000n, -5000n]);
- const splitShares: Record> = {};
- participants.forEach((p) => {
- splitShares[p.id] = initSplitShares();
- });
-
- calculateSplitShareBasedOnAmount(
- 10000n,
- participants,
- SplitType.PERCENTAGE,
- splitShares,
- user1,
- );
-
- // User1 is payer: paid 10000n but their amount is 8000n, so they owe 2000n (20%)
- expect(splitShares[user1.id]![SplitType.PERCENTAGE]).toBe(2000n); // 20%
- expect(splitShares[user2.id]![SplitType.PERCENTAGE]).toBe(3000n); // 30%
- expect(splitShares[user3.id]![SplitType.PERCENTAGE]).toBe(5000n); // 50%
- });
-
- it('should handle zero amount', () => {
- const participants = createParticipants([user1, user2, user3], [-3000n, -2000n, -5000n]);
- const splitShares: Record> = {};
- participants.forEach((p) => {
- splitShares[p.id] = initSplitShares();
- });
-
- calculateSplitShareBasedOnAmount(0n, participants, SplitType.PERCENTAGE, splitShares);
-
- expect(splitShares[user1.id]![SplitType.PERCENTAGE]).toBe(0n);
- expect(splitShares[user2.id]![SplitType.PERCENTAGE]).toBe(0n);
- expect(splitShares[user3.id]![SplitType.PERCENTAGE]).toBe(0n);
- });
- });
-
- describe('SplitType.SHARE', () => {
- it('should calculate share values based on participant amounts', () => {
- const participants = createParticipants([user1, user2, user3], [-6000n, -3000n, -3000n]);
- const splitShares: Record> = {};
- participants.forEach((p) => {
- splitShares[p.id] = initSplitShares();
- });
-
- calculateSplitShareBasedOnAmount(12000n, participants, SplitType.SHARE, splitShares);
-
- // Shares should be proportional: 6000:3000:3000 = 2:1:1
- // Multiplied by 100 and normalized
- expect(splitShares[user1.id]![SplitType.SHARE]).toBe(200n); // 2 shares * 100
- expect(splitShares[user2.id]![SplitType.SHARE]).toBe(100n); // 1 share * 100
- expect(splitShares[user3.id]![SplitType.SHARE]).toBe(100n); // 1 share * 100
- });
-
- it('should handle payer in share calculation', () => {
- const participants = createParticipants([user1, user2, user3], [6000n, -3000n, -3000n]);
- const splitShares: Record> = {};
- participants.forEach((p) => {
- splitShares[p.id] = initSplitShares();
- });
-
- calculateSplitShareBasedOnAmount(12000n, participants, SplitType.SHARE, splitShares, user1);
-
- // User1 is payer: paid 12000n but their amount is 6000n, so they owe 6000n
- // Shares: 6000:3000:3000 = 2:1:1
- expect(splitShares[user1.id]![SplitType.SHARE]).toBe(200n);
- expect(splitShares[user2.id]![SplitType.SHARE]).toBe(100n);
- expect(splitShares[user3.id]![SplitType.SHARE]).toBe(100n);
- });
-
- it('should handle zero amount', () => {
- const participants = createParticipants([user1, user2, user3], [-6000n, -3000n, -3000n]);
- const splitShares: Record> = {};
- participants.forEach((p) => {
- splitShares[p.id] = initSplitShares();
- });
-
- calculateSplitShareBasedOnAmount(0n, participants, SplitType.SHARE, splitShares);
-
- expect(splitShares[user1.id]![SplitType.SHARE]).toBe(0n);
- expect(splitShares[user2.id]![SplitType.SHARE]).toBe(0n);
- expect(splitShares[user3.id]![SplitType.SHARE]).toBe(0n);
- });
- });
-
- describe('SplitType.EXACT', () => {
- it('should set exact amounts for regular participants', () => {
- const participants = createParticipants([user1, user2, user3], [-4000n, -3000n, -3000n]);
- const splitShares: Record> = {};
- participants.forEach((p) => {
- splitShares[p.id] = initSplitShares();
- });
-
- calculateSplitShareBasedOnAmount(10000n, participants, SplitType.EXACT, splitShares);
-
- expect(splitShares[user1.id]![SplitType.EXACT]).toBe(4000n);
- expect(splitShares[user2.id]![SplitType.EXACT]).toBe(3000n);
- expect(splitShares[user3.id]![SplitType.EXACT]).toBe(3000n);
- });
-
- it('should handle payer in exact calculation', () => {
- const participants = createParticipants([user1, user2, user3], [6000n, -3000n, -3000n]);
- const splitShares: Record> = {};
- participants.forEach((p) => {
- splitShares[p.id] = initSplitShares();
- });
-
- calculateSplitShareBasedOnAmount(10000n, participants, SplitType.EXACT, splitShares, user1);
-
- // User1 is payer: paid 10000n but their amount is 6000n, so they owe 4000n
- expect(splitShares[user1.id]![SplitType.EXACT]).toBe(4000n);
- expect(splitShares[user2.id]![SplitType.EXACT]).toBe(3000n);
- expect(splitShares[user3.id]![SplitType.EXACT]).toBe(3000n);
- });
-
- it('should handle zero amounts', () => {
- const participants = createParticipants([user1, user2, user3], [0n, 0n, 0n]);
- const splitShares: Record> = {};
- participants.forEach((p) => {
- splitShares[p.id] = initSplitShares();
- });
-
- calculateSplitShareBasedOnAmount(10000n, participants, SplitType.EXACT, splitShares);
-
- expect(splitShares[user1.id]![SplitType.EXACT]).toBe(0n);
- expect(splitShares[user2.id]![SplitType.EXACT]).toBe(0n);
- expect(splitShares[user3.id]![SplitType.EXACT]).toBe(0n);
- });
- });
-
- describe('SplitType.ADJUSTMENT', () => {
- it('should handle payer in adjustment calculation', () => {
- const participants = createParticipants([user1, user2, user3], [-9500n, -4500n, -5000n]);
- const splitShares: Record> = {};
- participants.forEach((p) => {
- splitShares[p.id] = initSplitShares();
- });
-
- calculateSplitShareBasedOnAmount(
- 15000n,
- participants,
- SplitType.ADJUSTMENT,
- splitShares,
- user1,
- );
-
- expect(splitShares[user1.id]![SplitType.ADJUSTMENT]).toBe(1000n);
- expect(splitShares[user2.id]![SplitType.ADJUSTMENT]).toBe(0n);
- expect(splitShares[user3.id]![SplitType.ADJUSTMENT]).toBe(500n);
- });
-
- it('should handle participants with zero amounts', () => {
- const participants = createParticipants([user1, user2, user3], [0n, -5000n, 0n]);
- const splitShares: Record> = {};
- participants.forEach((p) => {
- splitShares[p.id] = initSplitShares();
- });
-
- calculateSplitShareBasedOnAmount(5000n, participants, SplitType.ADJUSTMENT, splitShares);
-
- // Only user2 has non-zero amount, so equal share = 5000n / 1 = 5000n
- // User2 owes 5000n vs 5000n = 0n adjustment
- expect(splitShares[user1.id]![SplitType.ADJUSTMENT]).toBe(-5000n); // 0 - 5000n
- expect(splitShares[user2.id]![SplitType.ADJUSTMENT]).toBe(0n); // 5000n - 5000n
- expect(splitShares[user3.id]![SplitType.ADJUSTMENT]).toBe(-5000n); // 0 - 5000n
- });
- });
-
- describe('Edge cases', () => {
- it('should handle empty participants array', () => {
- const participants: Participant[] = [];
- const splitShares: Record> = {};
-
- calculateSplitShareBasedOnAmount(10000n, participants, SplitType.EQUAL, splitShares);
-
- // Should not throw an error and splitShares should remain empty
- expect(Object.keys(splitShares)).toHaveLength(0);
- });
-
- it('should handle when one participant owes entire amount', () => {
- const participants = createParticipants([user1, user2], [10000n, -10000n]);
- const splitShares: Record> = {};
- participants.forEach((p) => {
- splitShares[p.id] = initSplitShares();
- });
-
- calculateSplitShareBasedOnAmount(10000n, participants, SplitType.EQUAL, splitShares, user1);
-
- expect(splitShares[user1.id]![SplitType.EQUAL]).toBe(0n);
- expect(splitShares[user2.id]![SplitType.EQUAL]).toBe(1n);
- });
-
- it('should handle self-payment (no money flow) scenario', () => {
- const participants = createParticipants([user1, user2, user3], [0n, 0n, 0n]);
- const splitShares: Record> = {};
- participants.forEach((p) => {
- splitShares[p.id] = initSplitShares();
- });
-
- calculateSplitShareBasedOnAmount(10000n, participants, SplitType.EQUAL, splitShares, user1);
-
- expect(splitShares[user1.id]![SplitType.EQUAL]).toBe(1n);
- expect(splitShares[user2.id]![SplitType.EQUAL]).toBe(0n);
- expect(splitShares[user3.id]![SplitType.EQUAL]).toBe(0n);
- });
-
- it('should handle undefined paidBy', () => {
- const participants = createParticipants([user1, user2], [-5000n, -5000n]);
- const splitShares: Record> = {};
- participants.forEach((p) => {
- splitShares[p.id] = initSplitShares();
- });
-
- calculateSplitShareBasedOnAmount(
- 10000n,
- participants,
- SplitType.PERCENTAGE,
- splitShares,
- undefined,
- );
-
- expect(splitShares[user1.id]![SplitType.PERCENTAGE]).toBe(5000n);
- expect(splitShares[user2.id]![SplitType.PERCENTAGE]).toBe(5000n);
- });
-
- it('should handle all split types with same data', () => {
- const participants = createParticipants([user1, user2], [-6000n, -4000n]);
-
- Object.values(SplitType).forEach((splitType) => {
- if (splitType === SplitType.SETTLEMENT) {
- return;
- } // Skip settlement as it's not implemented
-
- const splitShares: Record> = {};
- participants.forEach((p) => {
- splitShares[p.id] = initSplitShares();
- });
-
- // Should not throw for any split type
- expect(() => {
- calculateSplitShareBasedOnAmount(10000n, participants, splitType, splitShares);
- }).not.toThrow();
- });
- });
- });
-});
-
-// Integration tests for function reversibility
-describe('Function Reversibility Tests', () => {
- describe('calculateParticipantSplit -> calculateSplitShareBasedOnAmount', () => {
- it('should properly reverse EQUAL split', () => {
- const participants = createParticipants([user1, user2, user3]);
- const originalShares = [1n, 1n, 1n];
- const splitShares = createSplitShares(participants, SplitType.EQUAL, originalShares);
-
- const state: Partial = {
- amount: 15000n,
- participants,
- splitType: SplitType.EQUAL,
- splitShares,
- paidBy: user1,
- expenseDate: new Date('2024-01-01'),
- };
-
- // Apply split calculation
- const splitResult = calculateParticipantSplit(state as AddExpenseState);
-
- // Reverse the calculation
- const reversedShares: Record> = {};
- participants.forEach((p) => {
- reversedShares[p.id] = initSplitShares();
- });
-
- calculateSplitShareBasedOnAmount(
- 15000n,
- splitResult.participants,
- SplitType.EQUAL,
- reversedShares,
- user1,
- );
-
- // Check if we got back the original shares
- expect(reversedShares[user1.id]![SplitType.EQUAL]).toBe(originalShares[0]);
- expect(reversedShares[user2.id]![SplitType.EQUAL]).toBe(originalShares[1]);
- expect(reversedShares[user3.id]![SplitType.EQUAL]).toBe(originalShares[2]);
- });
-
- it('should properly reverse PERCENTAGE split', () => {
- const participants = createParticipants([user1, user2, user3]);
- const originalShares = [5000n, 3000n, 2000n]; // 50%, 30%, 20%
- const splitShares = createSplitShares(participants, SplitType.PERCENTAGE, originalShares);
-
- const state: Partial = {
- amount: 20000n,
- participants,
- splitType: SplitType.PERCENTAGE,
- splitShares,
- paidBy: user1,
- expenseDate: new Date('2024-01-01'),
- };
-
- // Apply split calculation
- const splitResult = calculateParticipantSplit(state as AddExpenseState);
-
- // Reverse the calculation
- const reversedShares: Record> = {};
- participants.forEach((p) => {
- reversedShares[p.id] = initSplitShares();
- });
-
- calculateSplitShareBasedOnAmount(
- 20000n,
- splitResult.participants,
- SplitType.PERCENTAGE,
- reversedShares,
- user1,
- );
-
- // Check if we got back the original percentage shares
- expect(reversedShares[user1.id]![SplitType.PERCENTAGE]).toBe(originalShares[0]);
- expect(reversedShares[user2.id]![SplitType.PERCENTAGE]).toBe(originalShares[1]);
- expect(reversedShares[user3.id]![SplitType.PERCENTAGE]).toBe(originalShares[2]);
- });
-
- it('should properly reverse SHARE split', () => {
- const participants = createParticipants([user1, user2, user3]);
- const originalShares = [400n, 200n, 200n]; // 2:1:1 ratio
- const splitShares = createSplitShares(participants, SplitType.SHARE, originalShares);
-
- const state: Partial = {
- amount: 16000n,
- participants,
- splitType: SplitType.SHARE,
- splitShares,
- paidBy: user1,
- expenseDate: new Date('2024-01-01'),
- };
-
- // Apply split calculation
- const splitResult = calculateParticipantSplit(state as AddExpenseState);
-
- // Reverse the calculation
- const reversedShares: Record> = {};
- participants.forEach((p) => {
- reversedShares[p.id] = initSplitShares();
- });
-
- calculateSplitShareBasedOnAmount(
- 16000n,
- splitResult.participants,
- SplitType.SHARE,
- reversedShares,
- user1,
- );
-
- // Check if we got back proportional shares (should maintain the 2:1:1 ratio)
- const ratio1 = Number(reversedShares[user1.id]![SplitType.SHARE]) / Number(originalShares[0]);
- const ratio2 = Number(reversedShares[user2.id]![SplitType.SHARE]) / Number(originalShares[1]);
- const ratio3 = Number(reversedShares[user3.id]![SplitType.SHARE]) / Number(originalShares[2]);
-
- // All ratios should be approximately equal (accounting for rounding)
- expect(Math.abs(ratio1 - ratio2)).toBeLessThan(0.1);
- expect(Math.abs(ratio2 - ratio3)).toBeLessThan(0.1);
- });
-
- it('should properly reverse EXACT split', () => {
- const participants = createParticipants([user1, user2, user3]);
- const originalShares = [6000n, 4000n, 2000n];
- const splitShares = createSplitShares(participants, SplitType.EXACT, originalShares);
-
- const state: Partial = {
- amount: 12000n,
- participants,
- splitType: SplitType.EXACT,
- splitShares,
- paidBy: user1,
- expenseDate: new Date('2024-01-01'),
- };
-
- // Apply split calculation
- const splitResult = calculateParticipantSplit(state as AddExpenseState);
-
- // Reverse the calculation
- const reversedShares: Record> = {};
- participants.forEach((p) => {
- reversedShares[p.id] = initSplitShares();
- });
-
- calculateSplitShareBasedOnAmount(
- 12000n,
- splitResult.participants,
- SplitType.EXACT,
- reversedShares,
- user1,
- );
-
- // Check if we got back the original exact amounts
- expect(reversedShares[user1.id]![SplitType.EXACT]).toBe(originalShares[0]);
- expect(reversedShares[user2.id]![SplitType.EXACT]).toBe(originalShares[1]);
- });
-
- it('should handle ADJUSTMENT split reversal (known to have bugs)', () => {
- const participants = createParticipants([user1, user2, user3]);
- const originalShares = [1000n, 0n, 500n]; // Various adjustments
- const splitShares = createSplitShares(participants, SplitType.ADJUSTMENT, originalShares);
-
- const state: Partial = {
- amount: 12300n,
- participants,
- splitType: SplitType.ADJUSTMENT,
- splitShares,
- paidBy: user1,
- expenseDate: new Date('2024-01-01'),
- };
-
- // Apply split calculation
- const splitResult = calculateParticipantSplit(state as AddExpenseState);
-
- // Reverse the calculation
- const reversedShares: Record> = {};
- participants.forEach((p) => {
- reversedShares[p.id] = initSplitShares();
- });
-
- calculateSplitShareBasedOnAmount(
- 12300n,
- splitResult.participants,
- SplitType.ADJUSTMENT,
- reversedShares,
- user1,
- );
-
- expect(reversedShares[user1.id]![SplitType.ADJUSTMENT]).toBe(originalShares[0]);
- expect(reversedShares[user2.id]![SplitType.ADJUSTMENT]).toBe(originalShares[1]);
- expect(reversedShares[user3.id]![SplitType.ADJUSTMENT]).toBe(originalShares[2]);
- });
-
- it('should handle edge case with zero amounts', () => {
- const participants = createParticipants([user1, user2, user3]);
- const originalShares = [1n, 1n, 0n]; // One participant excluded
- const splitShares = createSplitShares(participants, SplitType.EQUAL, originalShares);
-
- const state: Partial = {
- amount: 10000n,
- participants,
- splitType: SplitType.EQUAL,
- splitShares,
- paidBy: user1,
- expenseDate: new Date('2024-01-01'),
- };
-
- // Apply split calculation
- const splitResult = calculateParticipantSplit(state as AddExpenseState);
-
- // Reverse the calculation
- const reversedShares: Record> = {};
- participants.forEach((p) => {
- reversedShares[p.id] = initSplitShares();
- });
-
- calculateSplitShareBasedOnAmount(
- 10000n,
- splitResult.participants,
- SplitType.EQUAL,
- reversedShares,
- user1,
- );
-
- // Check if we preserved the zero share
- expect(reversedShares[user1.id]![SplitType.EQUAL]).toBe(originalShares[0]);
- expect(reversedShares[user2.id]![SplitType.EQUAL]).toBe(originalShares[1]);
- expect(reversedShares[user3.id]![SplitType.EQUAL]).toBe(originalShares[2]);
- });
-
- it('should handle external payer scenario', () => {
- const participants = createParticipants([user1, user2, user3]);
- const originalShares = [1n, 1n, 1n];
- const splitShares = createSplitShares(participants, SplitType.EQUAL, originalShares);
- const externalPayer = createMockUser(4, 'External', 'external@example.com');
-
- const state: Partial = {
- amount: 15000n,
- participants,
- splitType: SplitType.EQUAL,
- splitShares,
- paidBy: externalPayer,
- expenseDate: new Date('2024-01-01'),
- };
-
- // Apply split calculation with external payer
- const splitResult = calculateParticipantSplit(state as AddExpenseState);
-
- // Reverse the calculation
- const reversedShares: Record> = {};
- participants.forEach((p) => {
- reversedShares[p.id] = initSplitShares();
- });
-
- calculateSplitShareBasedOnAmount(
- 15000n,
- splitResult.participants,
- SplitType.EQUAL,
- reversedShares,
- externalPayer,
- );
-
- // With external payer, all participants have 0 amounts due to balance adjustment
- // So reversed shares should all be 0
- expect(reversedShares[user1.id]![SplitType.EQUAL]).toBe(0n);
- expect(reversedShares[user2.id]![SplitType.EQUAL]).toBe(0n);
- expect(reversedShares[user3.id]![SplitType.EQUAL]).toBe(0n);
- });
- });
-});
-
-// Regression test for #658: editing a negative expense must preserve the sign
-describe('useAddExpenseStore sign preservation on edit (#658)', () => {
- const { toUIString } = getCurrencyHelpers({ locale: 'en-US', currency: 'USD' });
- const { actions } = useAddExpenseStore.getState();
-
- beforeEach(() => {
- actions.resetState();
- });
-
- it('loads a negative expense with the minus sign visible in amountStr', () => {
- actions.setAmount(-20000n);
- actions.setAmountStr(toUIString(-20000n, true, true));
-
- const state = useAddExpenseStore.getState();
- expect(state.amountStr).toBe('-200');
- expect(state.isNegative).toBe(true);
- expect(state.amount).toBe(20000n);
- });
-
- it('preserves the sign when the user edits digits without removing the minus', () => {
- actions.setAmount(-20000n);
- actions.setAmountStr(toUIString(-20000n, true, true));
-
- // Simulate the user changing -200 to -200.01 via the input
- actions.setAmountStr('-200.01');
- actions.setAmount(-20001n);
-
- const state = useAddExpenseStore.getState();
- expect(state.amountStr).toBe('-200.01');
- expect(state.isNegative).toBe(true);
- expect(state.amount).toBe(20001n);
- });
-
- it('flips the sign when the user deletes the minus and edits the value', () => {
- actions.setAmount(-20000n);
- actions.setAmountStr(toUIString(-20000n, true, true));
-
- // Simulate the user deleting the minus and changing the value to 200.01
- actions.setAmountStr('200.01');
- actions.setAmount(20001n);
-
- const state = useAddExpenseStore.getState();
- expect(state.amountStr).toBe('200.01');
- expect(state.isNegative).toBe(false);
- expect(state.amount).toBe(20001n);
- });
-});
diff --git a/src/tests/currencyPreferenceStore.test.ts b/src/tests/currencyPreferenceStore.test.ts
deleted file mode 100644
index 7213a13ae..000000000
--- a/src/tests/currencyPreferenceStore.test.ts
+++ /dev/null
@@ -1,67 +0,0 @@
-import { SHOW_ALL_VALUE, useCurrencyPreferenceStore } from '~/store/currencyPreferenceStore';
-
-const resetStore = () =>
- useCurrencyPreferenceStore.setState({
- recentCurrencies: [],
- userDefaultCurrency: null,
- groupDefaultCurrencies: {},
- preferences: {},
- });
-
-beforeEach(() => {
- sessionStorage.clear();
- resetStore();
-});
-
-describe('getPreference – entity type isolation', () => {
- it('returns userDefaultCurrency for a friend entity even when a group with the same id has a different default currency', () => {
- const collidingId = 5;
-
- // Simulate visiting the Groups page: group id=5 has USD as its default
- useCurrencyPreferenceStore.getState().setGroupDefaultCurrency(collidingId, 'USD');
- // User's own currency is EUR
- useCurrencyPreferenceStore.getState().setUserDefaultCurrency('EUR');
-
- // When rendering a friend balance entry (no entityType), USD must not leak in
- const preference = useCurrencyPreferenceStore.getState().getPreference(collidingId);
-
- expect(preference).toBe('EUR');
- expect(preference).not.toBe('USD');
- });
-
- it('returns the group default currency when entityType is "group"', () => {
- const groupId = 5;
- useCurrencyPreferenceStore.getState().setGroupDefaultCurrency(groupId, 'USD');
- useCurrencyPreferenceStore.getState().setUserDefaultCurrency('EUR');
-
- const preference = useCurrencyPreferenceStore.getState().getPreference(groupId, 'group');
-
- expect(preference).toBe('USD');
- });
-
- it('respects an explicit preference over all defaults for both entity types', () => {
- const id = 7;
- useCurrencyPreferenceStore.getState().setGroupDefaultCurrency(id, 'USD');
- useCurrencyPreferenceStore.getState().setUserDefaultCurrency('EUR');
- useCurrencyPreferenceStore.getState().setPreference(id, 'GBP');
-
- expect(useCurrencyPreferenceStore.getState().getPreference(id)).toBe('GBP');
- expect(useCurrencyPreferenceStore.getState().getPreference(id, 'group')).toBe('GBP');
- });
-
- it('falls back to SHOW_ALL when no preference, no group default (for group entity), and no user default', () => {
- const preference = useCurrencyPreferenceStore.getState().getPreference(99, 'group');
- expect(preference).toBe(SHOW_ALL_VALUE);
- });
-
- it('falls back to SHOW_ALL for a friend entity with no preference and no user default', () => {
- useCurrencyPreferenceStore.getState().setGroupDefaultCurrency(3, 'USD');
- const preference = useCurrencyPreferenceStore.getState().getPreference(3);
- expect(preference).toBe(SHOW_ALL_VALUE);
- });
-
- it('uses the global (string) key when no entityId is provided', () => {
- useCurrencyPreferenceStore.getState().setUserDefaultCurrency('JPY');
- expect(useCurrencyPreferenceStore.getState().getPreference()).toBe('JPY');
- });
-});
diff --git a/src/tests/env.test.ts b/src/tests/env.test.ts
deleted file mode 100644
index cc3a6c915..000000000
--- a/src/tests/env.test.ts
+++ /dev/null
@@ -1,27 +0,0 @@
-import { parseEnvBoolean } from '../utils/env';
-
-describe('parseEnvBoolean', () => {
- it.each([
- ['1', true],
- ['true', true],
- ['TRUE', true],
- [' TrUe ', true],
- ['0', false],
- ['false', false],
- ['FALSE', false],
- [' FaLsE ', false],
- ])('parses %p as %p', (value, expected) => {
- expect(parseEnvBoolean(value)).toBe(expected);
- });
-
- it.each([undefined, '', 'on', 'off', 'yes', '2'])('uses false for invalid value %p', (value) => {
- expect(parseEnvBoolean(value)).toBe(false);
- });
-
- it.each([undefined, '', 'on', 'off', 'yes', '2'])(
- 'uses the supplied fallback for invalid value %p',
- (value) => {
- expect(parseEnvBoolean(value, true)).toBe(true);
- },
- );
-});
diff --git a/src/tests/healthz.test.ts b/src/tests/healthz.test.ts
deleted file mode 100644
index 3812bed72..000000000
--- a/src/tests/healthz.test.ts
+++ /dev/null
@@ -1,37 +0,0 @@
-import type { NextApiRequest, NextApiResponse } from 'next';
-
-import handler from '~/pages/api/healthz';
-
-const createMockRes = () => {
- const res = {} as NextApiResponse;
- res.status = jest.fn().mockReturnValue(res);
- res.json = jest.fn().mockReturnValue(res);
- res.setHeader = jest.fn().mockReturnValue(res);
- return res;
-};
-
-describe('/api/healthz', () => {
- describe('GET requests', () => {
- it('responds with 200 ok for GET requests', () => {
- const req = { method: 'GET' } as NextApiRequest;
- const res = createMockRes();
-
- handler(req, res);
-
- expect(res.status).toHaveBeenCalledWith(200);
- expect(res.json).toHaveBeenCalledWith({ status: 'ok' });
- });
- });
-
- describe('method validation', () => {
- it('rejects non-GET methods', () => {
- const req = { method: 'POST' } as NextApiRequest;
- const res = createMockRes();
-
- handler(req, res);
-
- expect(res.status).toHaveBeenCalledWith(405);
- expect((res.setHeader as jest.Mock).mock.calls).toContainEqual(['Allow', 'GET']);
- });
- });
-});
diff --git a/src/tests/integration/authorization.integration.test.ts b/src/tests/integration/authorization.integration.test.ts
new file mode 100644
index 000000000..013f0bc3f
--- /dev/null
+++ b/src/tests/integration/authorization.integration.test.ts
@@ -0,0 +1,251 @@
+import { TRPCError } from '@trpc/server';
+import { beforeEach, describe, expect, it } from 'vitest';
+
+import { db } from '~/server/db';
+import { expenseParticipants, expenses, groupUsers } from '~/server/db/schema';
+import { SplitType } from '~/server/db/types';
+import { eq } from 'drizzle-orm';
+
+import {
+ addExpense,
+ balanceBetween,
+ callerFor,
+ expenseRow,
+ makeGroup,
+ makeUser,
+ participantsOf,
+ resetDb,
+} from './helpers';
+
+const rejectsWith = async (promise: Promise, code?: TRPCError['code']) => {
+ const error = await promise.then(
+ () => null,
+ (e: unknown) => e,
+ );
+ expect(error, 'expected the call to be rejected').toBeInstanceOf(TRPCError);
+ if (code) {
+ expect((error as TRPCError).code).toBe(code);
+ }
+};
+
+describe('authorization', () => {
+ beforeEach(resetDb);
+
+ it('rejects unauthenticated queries and mutations', async () => {
+ const anon = callerFor(null);
+ await rejectsWith(anon.expense.getBalances(), 'UNAUTHORIZED');
+ await rejectsWith(anon.group.create({ name: 'x' }), 'UNAUTHORIZED');
+ await rejectsWith(anon.user.getFriends(), 'UNAUTHORIZED');
+ await rejectsWith(
+ anon.expense.addOrEditExpense({
+ paidBy: 1,
+ name: 'x',
+ category: 'general',
+ amount: 100n,
+ groupId: null,
+ splitType: SplitType.EQUAL,
+ currency: 'EUR',
+ participants: [],
+ }),
+ 'UNAUTHORIZED',
+ );
+ });
+
+ it('rejects outsider group reads and membership mutations', async () => {
+ const owner = await makeUser();
+ const member = await makeUser();
+ const outsider = await makeUser();
+ const group = await makeGroup(owner, [member]);
+ const out = callerFor(outsider);
+
+ await rejectsWith(out.group.getGroupDetails({ groupId: group.id }), 'FORBIDDEN');
+ await rejectsWith(out.group.getGroupTotals({ groupId: group.id }), 'FORBIDDEN');
+ await rejectsWith(
+ out.group.addMembers({ groupId: group.id, userIds: [outsider.id] }),
+ 'FORBIDDEN',
+ );
+ await rejectsWith(out.group.leaveGroup({ groupId: group.id, userId: member.id }), 'FORBIDDEN');
+ await rejectsWith(out.group.toggleArchive({ groupId: group.id }), 'FORBIDDEN');
+ await rejectsWith(out.group.delete({ groupId: group.id }), 'FORBIDDEN');
+
+ const members = await db.select().from(groupUsers).where(eq(groupUsers.groupId, group.id));
+ expect(members.map((m) => m.userId).sort((x, y) => x - y)).toEqual(
+ [owner.id, member.id].sort((x, y) => x - y),
+ );
+ });
+
+ it('only the creator can remove another member, and only the creator can delete a group', async () => {
+ const owner = await makeUser();
+ const member = await makeUser();
+ const other = await makeUser();
+ const group = await makeGroup(owner, [member, other]);
+
+ await rejectsWith(
+ callerFor(member).group.leaveGroup({ groupId: group.id, userId: other.id }),
+ 'UNAUTHORIZED',
+ );
+ await rejectsWith(callerFor(member).group.delete({ groupId: group.id }), 'UNAUTHORIZED');
+ });
+
+ it('rejects outsider expense reads, edits and deletes without changing accounting', async () => {
+ const alice = await makeUser();
+ const bob = await makeUser();
+ const mallory = await makeUser();
+ const group = await makeGroup(alice, [bob]);
+
+ const created = await addExpense(callerFor(alice), {
+ paidBy: alice,
+ amount: 1000n,
+ groupId: group.id,
+ shares: { [alice.id]: 500n, [bob.id]: -500n },
+ name: 'Dinner',
+ });
+ const mal = callerFor(mallory);
+
+ await rejectsWith(mal.expense.getExpenseDetails({ expenseId: created.id }));
+ await rejectsWith(
+ addExpense(mal, {
+ paidBy: mallory,
+ amount: 1n,
+ groupId: group.id,
+ shares: { [mallory.id]: 1n, [bob.id]: -1n },
+ }),
+ );
+ await rejectsWith(
+ mal.expense.addOrEditExpense({
+ expenseId: created.id,
+ paidBy: alice.id,
+ name: 'Hijacked',
+ category: 'general',
+ amount: 999_999n,
+ groupId: group.id,
+ splitType: SplitType.EQUAL,
+ currency: 'EUR',
+ participants: [
+ { userId: alice.id, amount: 999_999n },
+ { userId: bob.id, amount: -999_999n },
+ ],
+ }),
+ );
+ await rejectsWith(mal.expense.deleteExpense({ expenseId: created.id }));
+
+ const row = await expenseRow(created.id);
+ expect(row?.name).toBe('Dinner');
+ expect(row?.deletedAt).toBeNull();
+ expect(await participantsOf(created.id)).toEqual(
+ [
+ { userId: alice.id, amount: 500n },
+ { userId: bob.id, amount: -500n },
+ ].sort((a, b) => a.userId - b.userId),
+ );
+ expect(await balanceBetween(alice.id, bob.id)).toBe(500n);
+ const stray = await db
+ .select()
+ .from(expenseParticipants)
+ .where(eq(expenseParticipants.userId, mallory.id));
+ expect(stray).toHaveLength(0);
+ });
+
+ it('lets a group member who is not a participant read and delete a group expense', async () => {
+ const alice = await makeUser();
+ const bob = await makeUser();
+ const carol = await makeUser();
+ const group = await makeGroup(alice, [bob, carol]);
+ const created = await addExpense(callerFor(alice), {
+ paidBy: alice,
+ amount: 1000n,
+ groupId: group.id,
+ shares: { [alice.id]: 500n, [bob.id]: -500n },
+ });
+
+ const details = await callerFor(carol).expense.getExpenseDetails({ expenseId: created.id });
+ expect(details?.id).toBe(created.id);
+ });
+
+ it('rejects writes to archived groups', async () => {
+ const alice = await makeUser();
+ const bob = await makeUser();
+ const group = await makeGroup(alice, [bob]);
+ const a = callerFor(alice);
+
+ const created = await addExpense(a, {
+ paidBy: alice,
+ amount: 1000n,
+ groupId: group.id,
+ shares: { [alice.id]: 500n, [bob.id]: -500n },
+ });
+ // Settle fully so the group may be archived.
+ await addExpense(callerFor(bob), {
+ paidBy: bob,
+ amount: 500n,
+ groupId: group.id,
+ splitType: SplitType.SETTLEMENT,
+ shares: { [bob.id]: 500n, [alice.id]: -500n },
+ });
+ await a.group.toggleArchive({ groupId: group.id });
+
+ await rejectsWith(
+ addExpense(a, {
+ paidBy: alice,
+ amount: 100n,
+ groupId: group.id,
+ shares: { [alice.id]: 50n, [bob.id]: -50n },
+ }),
+ 'BAD_REQUEST',
+ );
+ await rejectsWith(a.expense.deleteExpense({ expenseId: created.id }), 'BAD_REQUEST');
+ await rejectsWith(
+ a.expense.addOrEditExpense({
+ expenseId: created.id,
+ paidBy: alice.id,
+ name: 'Edited',
+ category: 'general',
+ amount: 1000n,
+ groupId: group.id,
+ splitType: SplitType.EQUAL,
+ currency: 'EUR',
+ participants: [
+ { userId: alice.id, amount: 500n },
+ { userId: bob.id, amount: -500n },
+ ],
+ }),
+ 'BAD_REQUEST',
+ );
+ expect((await expenseRow(created.id))?.name).not.toBe('Edited');
+ });
+
+ it('applies the same membership, edit and archive guards to linked currency conversions', async () => {
+ const alice = await makeUser();
+ const bob = await makeUser();
+ const mallory = await makeUser();
+ const group = await makeGroup(alice, [bob]);
+ const conversion = {
+ amount: 1000n,
+ from: 'EUR',
+ to: 'USD',
+ rate: 1.1,
+ senderId: alice.id,
+ receiverId: bob.id,
+ groupId: group.id,
+ };
+
+ // An outsider cannot create a conversion in someone else's group.
+ await rejectsWith(callerFor(mallory).expense.addOrEditCurrencyConversion(conversion));
+ expect(await db.select().from(expenses)).toHaveLength(0);
+
+ // A member can.
+ await callerFor(alice).expense.addOrEditCurrencyConversion(conversion);
+ const [from] = await db.select().from(expenses).where(eq(expenses.currency, 'EUR'));
+ expect(from).toBeDefined();
+
+ // An outsider cannot edit it.
+ await rejectsWith(
+ callerFor(mallory).expense.addOrEditCurrencyConversion({
+ ...conversion,
+ expenseId: from!.id,
+ amount: 5n,
+ }),
+ );
+ expect((await expenseRow(from!.id))?.amount).toBe(1000n);
+ });
+});
diff --git a/src/tests/integration/balances.integration.test.ts b/src/tests/integration/balances.integration.test.ts
new file mode 100644
index 000000000..e502dddb3
--- /dev/null
+++ b/src/tests/integration/balances.integration.test.ts
@@ -0,0 +1,102 @@
+import { beforeEach, describe, expect, it } from 'vitest';
+
+import { SplitType } from '~/server/db/types';
+
+import { addExpense, balanceBetween, callerFor, makeGroup, makeUser, resetDb } from './helpers';
+
+describe('balance isolation and simplification', () => {
+ beforeEach(resetDb);
+
+ it('isolates groups and currencies and hides deleted expenses', async () => {
+ const alice = await makeUser();
+ const bob = await makeUser();
+ const g1 = await makeGroup(alice, [bob]);
+ const g2 = await makeGroup(alice, [bob]);
+ const a = callerFor(alice);
+ const split = (n: bigint) => ({ [alice.id]: n, [bob.id]: -n });
+
+ await addExpense(a, { paidBy: alice, amount: 2000n, groupId: g1.id, shares: split(1000n) });
+ await addExpense(a, { paidBy: alice, amount: 600n, groupId: g2.id, shares: split(300n) });
+ await addExpense(a, { paidBy: alice, amount: 400n, currency: 'USD', shares: split(200n) });
+ const doomed = await addExpense(a, {
+ paidBy: alice,
+ amount: 10_000n,
+ groupId: g1.id,
+ shares: split(5000n),
+ });
+
+ expect(await balanceBetween(alice.id, bob.id, { groupId: g1.id, currency: 'EUR' })).toBe(6000n);
+
+ await a.expense.deleteExpense({ expenseId: doomed.id });
+
+ expect(await balanceBetween(alice.id, bob.id, { groupId: g1.id, currency: 'EUR' })).toBe(1000n);
+ expect(await balanceBetween(alice.id, bob.id, { groupId: g2.id, currency: 'EUR' })).toBe(300n);
+ expect(await balanceBetween(alice.id, bob.id, { groupId: null, currency: 'USD' })).toBe(200n);
+ expect(await balanceBetween(bob.id, alice.id, { groupId: g1.id, currency: 'EUR' })).toBe(
+ -1000n,
+ );
+
+ // GetBalances aggregates per friend and currency across groups.
+ const { balances } = await a.expense.getBalances();
+ const withBob = balances.find((b) => b.friendId === bob.id)!;
+ const amountOf = (currency: string) =>
+ withBob.currencies.find((c) => c.currency === currency)?.amount;
+ expect(amountOf('EUR')).toBe(1300n);
+ expect(amountOf('USD')).toBe(200n);
+ });
+
+ it('simplifies a three-member chain while preserving every member net balance', async () => {
+ const alice = await makeUser();
+ const bob = await makeUser();
+ const carol = await makeUser();
+ const group = await makeGroup(alice, [bob, carol]);
+
+ // Alice paid for Bob (100); Bob paid for Carol (100).
+ await addExpense(callerFor(alice), {
+ paidBy: alice,
+ amount: 100n,
+ groupId: group.id,
+ shares: { [alice.id]: 100n, [bob.id]: -100n },
+ });
+ await addExpense(callerFor(bob), {
+ paidBy: bob,
+ amount: 100n,
+ groupId: group.id,
+ shares: { [bob.id]: 100n, [carol.id]: -100n },
+ });
+ await callerFor(alice).group.toggleSimplifyDebts({ groupId: group.id });
+
+ const details = await callerFor(alice).group.getGroupDetails({ groupId: group.id });
+ const balances = details?.groupBalances ?? [];
+
+ // The chain collapses to one edge: Carol owes Alice 100, Bob is out of it.
+ expect(balances.filter((b) => 0n !== b.amount)).toHaveLength(2);
+ expect(balances).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({ userId: alice.id, friendId: carol.id, amount: 100n }),
+ expect.objectContaining({ userId: carol.id, friendId: alice.id, amount: -100n }),
+ ]),
+ );
+
+ // Every member's net position is preserved (and the whole group nets to zero).
+ const netOf = (userId: number) =>
+ balances.filter((b) => b.userId === userId).reduce((sum, b) => sum + b.amount, 0n);
+ expect(netOf(alice.id)).toBe(100n);
+ expect(netOf(bob.id)).toBe(0n);
+ expect(netOf(carol.id)).toBe(-100n);
+ expect(balances.reduce((sum, b) => sum + b.amount, 0n)).toBe(0n);
+ });
+
+ it('keeps a plain two-person balance symmetric', async () => {
+ const alice = await makeUser();
+ const bob = await makeUser();
+ await addExpense(callerFor(alice), {
+ paidBy: alice,
+ amount: 1234n,
+ splitType: SplitType.EXACT,
+ shares: { [alice.id]: 700n, [bob.id]: -700n },
+ });
+ expect(await balanceBetween(alice.id, bob.id)).toBe(700n);
+ expect(await balanceBetween(bob.id, alice.id)).toBe(-700n);
+ });
+});
diff --git a/src/tests/integration/expense.integration.test.ts b/src/tests/integration/expense.integration.test.ts
new file mode 100644
index 000000000..b0e504504
--- /dev/null
+++ b/src/tests/integration/expense.integration.test.ts
@@ -0,0 +1,174 @@
+import { beforeEach, describe, expect, it } from 'vitest';
+
+import { SplitType } from '~/server/db/types';
+
+import {
+ addExpense,
+ balanceBetween,
+ callerFor,
+ expenseRow,
+ makeGroup,
+ makeUser,
+ participantsOf,
+ resetDb,
+} from './helpers';
+
+describe('expense lifecycle', () => {
+ beforeEach(resetDb);
+
+ it.each([false, true])(
+ 'creates, edits and soft-deletes an expense with exact bidirectional balances (group: %s)',
+ async (inGroup) => {
+ const alice = await makeUser();
+ const bob = await makeUser();
+ const groupId = inGroup ? (await makeGroup(alice, [bob])).id : null;
+ const a = callerFor(alice);
+
+ const created = await addExpense(a, {
+ paidBy: alice,
+ amount: 1000n,
+ groupId,
+ name: 'Dinner',
+ shares: { [alice.id]: 500n, [bob.id]: -500n },
+ });
+ expect(await balanceBetween(alice.id, bob.id)).toBe(500n);
+ expect(await balanceBetween(bob.id, alice.id)).toBe(-500n);
+
+ await a.expense.addOrEditExpense({
+ expenseId: created.id,
+ paidBy: alice.id,
+ name: 'Dinner + drinks',
+ category: 'food',
+ amount: 1200n,
+ groupId,
+ splitType: SplitType.EQUAL,
+ currency: 'EUR',
+ participants: [
+ { userId: alice.id, amount: 600n },
+ { userId: bob.id, amount: -600n },
+ ],
+ });
+ expect((await expenseRow(created.id))?.name).toBe('Dinner + drinks');
+ expect((await expenseRow(created.id))?.updatedBy).toBe(alice.id);
+ expect(await balanceBetween(alice.id, bob.id)).toBe(600n);
+ expect(await balanceBetween(bob.id, alice.id)).toBe(-600n);
+
+ await callerFor(bob).expense.deleteExpense({ expenseId: created.id });
+ const deleted = await expenseRow(created.id);
+ expect(deleted?.deletedAt).toBeInstanceOf(Date);
+ expect(deleted?.deletedBy).toBe(bob.id);
+ expect(await balanceBetween(alice.id, bob.id)).toBe(0n);
+ expect(await balanceBetween(bob.id, alice.id)).toBe(0n);
+ },
+ );
+
+ it('preserves the original expense if an edit transaction fails', async () => {
+ const alice = await makeUser();
+ const bob = await makeUser();
+ const a = callerFor(alice);
+ const created = await addExpense(a, {
+ paidBy: alice,
+ amount: 1000n,
+ name: 'Original',
+ shares: { [alice.id]: 500n, [bob.id]: -500n },
+ });
+
+ // The last statement of the edit batch references a user that does not exist, so the
+ // Foreign key fails. Atomicity means none of the earlier statements may stay applied.
+ const missingUserId = 999_999;
+ await expect(
+ a.expense.addOrEditExpense({
+ expenseId: created.id,
+ paidBy: alice.id,
+ name: 'Should not persist',
+ category: 'food',
+ amount: 5000n,
+ groupId: null,
+ splitType: SplitType.EQUAL,
+ currency: 'EUR',
+ participants: [
+ { userId: alice.id, amount: 2500n },
+ { userId: missingUserId, amount: -2500n },
+ ],
+ }),
+ ).rejects.toThrow();
+
+ const row = await expenseRow(created.id);
+ expect(row?.name).toBe('Original');
+ expect(row?.amount).toBe(1000n);
+ expect(await participantsOf(created.id)).toEqual(
+ [
+ { userId: alice.id, amount: 500n },
+ { userId: bob.id, amount: -500n },
+ ].sort((x, y) => x.userId - y.userId),
+ );
+ expect(await balanceBetween(alice.id, bob.id)).toBe(500n);
+ });
+
+ it('does not create a half-written expense when creation fails', async () => {
+ const alice = await makeUser();
+ await expect(
+ callerFor(alice).expense.addOrEditExpense({
+ paidBy: alice.id,
+ name: 'Broken',
+ category: 'food',
+ amount: 100n,
+ groupId: null,
+ splitType: SplitType.EQUAL,
+ currency: 'EUR',
+ participants: [
+ { userId: alice.id, amount: 50n },
+ { userId: 999_999, amount: -50n },
+ ],
+ }),
+ ).rejects.toThrow();
+
+ const { expenses } = await import('~/server/db/schema');
+ const { db } = await import('~/server/db');
+ expect(await db.select().from(expenses)).toHaveLength(0);
+ });
+});
+
+describe('settlement accounting', () => {
+ beforeEach(resetDb);
+
+ it.each([false, true])(
+ 'partially then fully settles a debt, editing and deleting the settlement (group: %s)',
+ async (inGroup) => {
+ const alice = await makeUser();
+ const bob = await makeUser();
+ const groupId = inGroup ? (await makeGroup(alice, [bob])).id : null;
+
+ // Bob owes Alice 500.
+ await addExpense(callerFor(alice), {
+ paidBy: alice,
+ amount: 1000n,
+ groupId,
+ shares: { [alice.id]: 500n, [bob.id]: -500n },
+ });
+ expect(await balanceBetween(bob.id, alice.id)).toBe(-500n);
+
+ const settle = (amount: bigint) =>
+ addExpense(callerFor(bob), {
+ paidBy: bob,
+ amount,
+ groupId,
+ splitType: SplitType.SETTLEMENT,
+ name: 'Settle up',
+ shares: { [bob.id]: amount, [alice.id]: -amount },
+ });
+
+ const partial = await settle(200n);
+ expect(await balanceBetween(bob.id, alice.id)).toBe(-300n);
+ expect(await balanceBetween(alice.id, bob.id)).toBe(300n);
+
+ await settle(300n);
+ expect(await balanceBetween(bob.id, alice.id)).toBe(0n);
+ expect(await balanceBetween(alice.id, bob.id)).toBe(0n);
+
+ // Undo the partial settlement: the debt comes back by exactly that amount.
+ await callerFor(alice).expense.deleteExpense({ expenseId: partial.id });
+ expect(await balanceBetween(alice.id, bob.id)).toBe(200n);
+ },
+ );
+});
diff --git a/src/tests/integration/helpers.ts b/src/tests/integration/helpers.ts
new file mode 100644
index 000000000..e1a4513be
--- /dev/null
+++ b/src/tests/integration/helpers.ts
@@ -0,0 +1,148 @@
+import { env } from 'cloudflare:workers';
+import { and, eq } from 'drizzle-orm';
+
+import { appRouter } from '~/server/api/root';
+import type { Session } from '~/server/auth';
+import { db } from '~/server/db';
+import {
+ balanceView,
+ expenseParticipants,
+ expenses,
+ groupUsers,
+ groups,
+ users,
+} from '~/server/db/schema';
+import { SplitType } from '~/server/db/types';
+
+export type TestUser = typeof users.$inferSelect;
+
+/** Delete every row from every app table (not the migration bookkeeping tables). */
+export const resetDb = async () => {
+ const { results } = await (env as unknown as { DB: D1Database }).DB.prepare(
+ `SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' AND name NOT LIKE '_cf_%' AND name != 'd1_migrations'`,
+ ).all<{ name: string }>();
+ await (env as unknown as { DB: D1Database }).DB.batch([
+ (env as unknown as { DB: D1Database }).DB.prepare('PRAGMA defer_foreign_keys = on'),
+ ...results.map(({ name }) =>
+ (env as unknown as { DB: D1Database }).DB.prepare(`DELETE FROM "${name}"`),
+ ),
+ ]);
+};
+
+let counter = 0;
+
+export const makeUser = async (overrides: Partial = {}) => {
+ counter += 1;
+ const [user] = await db
+ .insert(users)
+ .values({ name: `User ${counter}`, email: `user${counter}@example.test`, ...overrides })
+ .returning();
+ return user!;
+};
+
+export const makeGroup = async (owner: TestUser, members: TestUser[] = [], name = 'Trip') => {
+ counter += 1;
+ const [group] = await db
+ .insert(groups)
+ .values({
+ name: `${name} ${counter}`,
+ publicId: `pub-${counter}-${crypto.randomUUID()}`,
+ userId: owner.id,
+ })
+ .returning();
+ const everyone = [owner, ...members.filter((m) => m.id !== owner.id)];
+ await db.insert(groupUsers).values(everyone.map((u) => ({ groupId: group!.id, userId: u.id })));
+ return group!;
+};
+
+export const sessionFor = (user: TestUser): Session => ({
+ user: {
+ id: user.id,
+ name: user.name,
+ email: user.email,
+ image: user.image,
+ currency: user.currency,
+ defaultCurrency: user.defaultCurrency,
+ obapiProviderId: user.obapiProviderId,
+ bankingId: user.bankingId,
+ preferredLanguage: user.preferredLanguage,
+ hiddenFriendIds: user.hiddenFriendIds,
+ },
+ expires: new Date(Date.now() + 86_400_000).toISOString(),
+});
+
+/** TRPC caller bound to a user's session, or anonymous when `user` is null. */
+// oxlint-disable-next-line typescript/no-redundant-type-constituents
+export const callerFor = (user: TestUser | null) =>
+ appRouter.createCaller({ session: user ? sessionFor(user) : null, db });
+
+// ---------------------------------------------------------------------------------------------
+// Expense helpers
+// ---------------------------------------------------------------------------------------------
+type Caller = ReturnType;
+type ExpenseInput = Parameters[0];
+type SingleExpenseInput = Extract;
+
+/**
+ * Create an expense through the real tRPC procedure.
+ * `shares` maps userId -> signed amount. The payer is positive, everyone else negative, and the
+ * amounts sum to zero (the convention the app and BalanceView use).
+ */
+export const addExpense = async (
+ caller: Caller,
+ opts: {
+ paidBy: TestUser;
+ shares: Record;
+ amount: bigint;
+ groupId?: number | null;
+ currency?: string;
+ splitType?: SingleExpenseInput['splitType'];
+ name?: string;
+ cronExpression?: string;
+ },
+) => {
+ const [created] = await caller.expense.addOrEditExpense({
+ paidBy: opts.paidBy.id,
+ name: opts.name ?? 'Expense',
+ category: 'general',
+ amount: opts.amount,
+ groupId: opts.groupId ?? null,
+ splitType: opts.splitType ?? SplitType.EQUAL,
+ currency: opts.currency ?? 'EUR',
+ participants: Object.entries(opts.shares).map(([userId, amount]) => ({
+ userId: Number(userId),
+ amount,
+ })),
+ cronExpression: opts.cronExpression,
+ });
+ return created!;
+};
+
+/** Net amount `userId` is owed by `friendId` (positive = friend owes user), summed over rows. */
+export const balanceBetween = async (
+ userId: number,
+ friendId: number,
+ where: { groupId?: number | null; currency?: string } = {},
+) => {
+ const rows = await db
+ .select()
+ .from(balanceView)
+ .where(
+ and(
+ eq(balanceView.userId, userId),
+ eq(balanceView.friendId, friendId),
+ where.currency ? eq(balanceView.currency, where.currency) : undefined,
+ ),
+ );
+ return rows
+ .filter((r) => (where.groupId === undefined ? true : r.groupId === where.groupId))
+ .reduce((sum, r) => sum + r.amount, 0n);
+};
+
+export const participantsOf = async (expenseId: string) =>
+ (await db.select().from(expenseParticipants).where(eq(expenseParticipants.expenseId, expenseId)))
+ .map((p) => ({ userId: p.userId, amount: p.amount }))
+ .sort((a, b) => a.userId - b.userId);
+
+export const expenseRow = async (expenseId: string) =>
+ (await db.select().from(expenses).where(eq(expenses.id, expenseId)))[0];
diff --git a/src/tests/integration/queries.integration.test.ts b/src/tests/integration/queries.integration.test.ts
new file mode 100644
index 000000000..179abf182
--- /dev/null
+++ b/src/tests/integration/queries.integration.test.ts
@@ -0,0 +1,138 @@
+import { eq } from 'drizzle-orm';
+import { beforeEach, describe, expect, it } from 'vitest';
+
+import { getCompleteGroupDetails } from '~/server/api/services/splitService';
+import { db } from '~/server/db';
+import { users } from '~/server/db/schema';
+import { SplitType } from '~/server/db/types';
+
+import { addExpense, callerFor, makeGroup, makeUser, resetDb } from './helpers';
+
+/**
+ * Regression tests for the Drizzle/D1 query layer: every one of these ran real SQL against D1.
+ * `getFriend` and `getCompleteGroupDetails` once failed in production with
+ * "no such column: User.id" (correlated subquery against an aliased table).
+ */
+describe('query layer on D1', () => {
+ beforeEach(resetDb);
+
+ it('getFriend returns a friend only when a balance relationship exists', async () => {
+ const alice = await makeUser();
+ const bob = await makeUser();
+ const stranger = await makeUser();
+ await addExpense(callerFor(alice), {
+ paidBy: alice,
+ amount: 1000n,
+ shares: { [alice.id]: 500n, [bob.id]: -500n },
+ });
+
+ const friend = await callerFor(alice).user.getFriend({ friendId: bob.id });
+ expect(friend?.id).toBe(bob.id);
+ expect((await callerFor(bob).user.getFriend({ friendId: alice.id }))?.id).toBe(alice.id);
+ expect(await callerFor(alice).user.getFriend({ friendId: stranger.id })).toBeFalsy();
+ });
+
+ it('getCompleteGroupDetails only returns the groups the user belongs to', async () => {
+ const alice = await makeUser();
+ const bob = await makeUser();
+ const carol = await makeUser();
+ const shared = await makeGroup(alice, [bob], 'Shared');
+ await makeGroup(carol, [], 'Private');
+ await addExpense(callerFor(alice), {
+ paidBy: alice,
+ amount: 1000n,
+ groupId: shared.id,
+ shares: { [alice.id]: 500n, [bob.id]: -500n },
+ });
+
+ const aliceGroups = await getCompleteGroupDetails(alice.id);
+ expect(aliceGroups.map((g) => g.id)).toEqual([shared.id]);
+ expect(aliceGroups[0]!.groupBalances.some((b) => 0n !== b.amount)).toBe(true);
+ expect((await getCompleteGroupDetails(carol.id)).map((g) => g.name)[0]).toContain('Private');
+ expect(await getCompleteGroupDetails((await makeUser()).id)).toEqual([]);
+ });
+
+ it('deleteFriend refuses while a balance is outstanding, then hides the friend once', async () => {
+ const alice = await makeUser();
+ const bob = await makeUser();
+ await addExpense(callerFor(alice), {
+ paidBy: alice,
+ amount: 1000n,
+ shares: { [alice.id]: 500n, [bob.id]: -500n },
+ });
+ await expect(callerFor(alice).user.deleteFriend({ friendId: bob.id })).rejects.toThrow(
+ /outstanding balances/,
+ );
+
+ await addExpense(callerFor(bob), {
+ paidBy: bob,
+ amount: 500n,
+ splitType: SplitType.SETTLEMENT,
+ shares: { [bob.id]: 500n, [alice.id]: -500n },
+ });
+ await callerFor(alice).user.deleteFriend({ friendId: bob.id });
+ await callerFor(alice).user.deleteFriend({ friendId: bob.id });
+
+ const [row] = await db.select().from(users).where(eq(users.id, alice.id));
+ expect(row!.hiddenFriendIds).toEqual([bob.id]);
+ });
+
+ it('a new shared expense un-hides the friend on both sides (trigger)', async () => {
+ const alice = await makeUser();
+ const bob = await makeUser();
+ const carol = await makeUser();
+ await db
+ .update(users)
+ .set({ hiddenFriendIds: [bob.id, carol.id] })
+ .where(eq(users.id, alice.id));
+ await db
+ .update(users)
+ .set({ hiddenFriendIds: [alice.id] })
+ .where(eq(users.id, bob.id));
+
+ await addExpense(callerFor(bob), {
+ paidBy: bob,
+ amount: 1000n,
+ shares: { [bob.id]: 500n, [alice.id]: -500n },
+ });
+
+ const [a] = await db.select().from(users).where(eq(users.id, alice.id));
+ const [b] = await db.select().from(users).where(eq(users.id, bob.id));
+ expect(a!.hiddenFriendIds).toEqual([carol.id]);
+ expect(b!.hiddenFriendIds).toEqual([]);
+ });
+
+ it('every read procedure runs valid SQL against a populated database', async () => {
+ const alice = await makeUser();
+ const bob = await makeUser();
+ const group = await makeGroup(alice, [bob]);
+ const a = callerFor(alice);
+ const expense = await addExpense(a, {
+ paidBy: alice,
+ amount: 1000n,
+ groupId: group.id,
+ shares: { [alice.id]: 500n, [bob.id]: -500n },
+ cronExpression: '0 9 * * *',
+ });
+
+ await expect(a.user.me()).resolves.toBeDefined();
+ await expect(a.user.getFriends()).resolves.toBeDefined();
+ await expect(a.user.getOwnExpenses()).resolves.toHaveLength(1);
+ await expect(a.user.getBalancesWithFriend({ friendId: bob.id })).resolves.toBeDefined();
+ await expect(a.user.getUserDetails({ userId: bob.id })).resolves.toBeDefined();
+ await expect(a.user.downloadData()).resolves.toBeDefined();
+ await expect(a.expense.getBalances()).resolves.toBeDefined();
+ await expect(a.expense.getCumulatedBalances()).resolves.toBeDefined();
+ await expect(a.expense.getExpensesWithFriend({ friendId: bob.id })).resolves.toBeDefined();
+ await expect(a.expense.getGroupExpenses({ groupId: group.id })).resolves.toHaveLength(1);
+ await expect(a.expense.getExpenseDetails({ expenseId: expense.id })).resolves.toMatchObject({
+ id: expense.id,
+ });
+ await expect(a.expense.getAllExpenses()).resolves.toBeDefined();
+ await expect(a.expense.getRecurringExpenses()).resolves.toHaveLength(1);
+ await expect(a.group.getAllGroups()).resolves.toHaveLength(1);
+ await expect(a.group.getAllGroupsWithBalances()).resolves.toBeDefined();
+ await expect(a.group.getGroupDetails({ groupId: group.id })).resolves.toBeDefined();
+ await expect(a.group.getGroupTotals({ groupId: group.id })).resolves.toBeDefined();
+ });
+});
diff --git a/src/tests/integration/recurrence.integration.test.ts b/src/tests/integration/recurrence.integration.test.ts
new file mode 100644
index 000000000..28777df15
--- /dev/null
+++ b/src/tests/integration/recurrence.integration.test.ts
@@ -0,0 +1,135 @@
+import { eq } from 'drizzle-orm';
+import { beforeEach, describe, expect, it } from 'vitest';
+
+import { getDueJobs } from '~/server/api/services/scheduleService';
+import { runRecurringJob } from '~/server/api/services/splitService';
+import { db } from '~/server/db';
+import { expenseRecurrences, expenses, jobs } from '~/server/db/schema';
+
+import {
+ addExpense,
+ balanceBetween,
+ callerFor,
+ expenseRow,
+ makeUser,
+ participantsOf,
+ resetDb,
+} from './helpers';
+
+const copiesOf = async (recurrenceId: number, templateId: string) =>
+ (await db.select().from(expenses).where(eq(expenses.recurrenceId, recurrenceId))).filter(
+ (e) => e.id !== templateId,
+ );
+
+describe('recurrence lifecycle', () => {
+ beforeEach(resetDb);
+
+ const setup = async () => {
+ const alice = await makeUser();
+ const bob = await makeUser();
+ const a = callerFor(alice);
+ const template = await addExpense(a, {
+ paidBy: alice,
+ amount: 1000n,
+ name: 'Rent',
+ shares: { [alice.id]: 500n, [bob.id]: -500n },
+ cronExpression: '0 9 * * *',
+ });
+ const recurrenceId = (await expenseRow(template.id))!.recurrenceId!;
+ const [job] = await db.select().from(jobs);
+ return { alice, bob, a, template, recurrenceId, job: job! };
+ };
+
+ it('creates a job linked to the template expense', async () => {
+ const { template, job, recurrenceId } = await setup();
+ expect(recurrenceId).toBeGreaterThan(0);
+ expect(job.jobname).toBe(`expense_recurring_${template.id}`);
+ expect(job.command).toContain(template.id);
+ expect(job.active).toBe(true);
+ expect(job.nextRunAt!.getTime()).toBeGreaterThan(Date.now());
+ const [recurrence] = await db.select().from(expenseRecurrences);
+ expect(recurrence?.jobId).toBe(job.jobid);
+ });
+
+ it('duplicates participants and balances once per slot, even if the step is retried', async () => {
+ const { alice, bob, template, recurrenceId, job } = await setup();
+ const slot1 = job.nextRunAt!;
+ const now1 = new Date(slot1.getTime() + 60_000);
+
+ expect((await getDueJobs(new Date(slot1.getTime() - 1))).map((j) => j.jobid)).not.toContain(
+ job.jobid,
+ );
+ expect((await getDueJobs(now1)).map((j) => j.jobid)).toContain(job.jobid);
+
+ const first = await runRecurringJob(job.jobid, now1);
+ expect(first.skipped).toBe(false);
+ let copies = await copiesOf(recurrenceId, template.id);
+ expect(copies).toHaveLength(1);
+ expect(await participantsOf(copies[0]!.id)).toEqual(await participantsOf(template.id));
+ expect(await balanceBetween(alice.id, bob.id)).toBe(1000n); // Template + one copy
+
+ const [afterFirst] = await db.select().from(jobs);
+ expect(afterFirst!.nextRunAt!.getTime()).toBeGreaterThan(slot1.getTime());
+ expect(afterFirst!.lastRunAt).toBeInstanceOf(Date);
+ expect((await db.select().from(expenseRecurrences))[0]?.notified).toBe(false);
+
+ // Same slot again: nothing is due, nothing is duplicated.
+ expect((await runRecurringJob(job.jobid, now1)).skipped).toBe(true);
+
+ // A retry after the job row was rolled back to the old slot must not create a second copy.
+ await db.update(jobs).set({ nextRunAt: slot1 }).where(eq(jobs.jobid, job.jobid));
+ await runRecurringJob(job.jobid, now1);
+ copies = await copiesOf(recurrenceId, template.id);
+ expect(copies).toHaveLength(1);
+ expect(await balanceBetween(alice.id, bob.id)).toBe(1000n);
+
+ // The next slot produces a second, distinct copy.
+ const [afterRetry] = await db.select().from(jobs);
+ await runRecurringJob(job.jobid, new Date(afterRetry!.nextRunAt!.getTime() + 60_000));
+ copies = await copiesOf(recurrenceId, template.id);
+ expect(copies).toHaveLength(2);
+ expect(new Set(copies.map((c) => c.id)).size).toBe(2);
+ expect(await balanceBetween(alice.id, bob.id)).toBe(1500n);
+ });
+
+ it('edits the schedule and stops only when the template is deleted', async () => {
+ const { a, alice, template, recurrenceId, job } = await setup();
+
+ await a.expense.updateRecurrence({ recurrenceId, cronExpression: '30 18 * * *' });
+ const [edited] = await db.select().from(jobs);
+ expect(edited!.schedule).toBe('30 18 * * *');
+ expect(edited!.nextRunAt!.getUTCHours()).toBe(18);
+ expect(edited!.nextRunAt!.getUTCMinutes()).toBe(30);
+
+ // Deleting a generated copy keeps the recurrence running.
+ await runRecurringJob(job.jobid, new Date(edited!.nextRunAt!.getTime() + 60_000));
+ const [copy] = await copiesOf(recurrenceId, template.id);
+ await a.expense.deleteExpense({ expenseId: copy!.id });
+ expect(await db.select().from(jobs)).toHaveLength(1);
+
+ // Deleting the template stops it.
+ await a.expense.deleteExpense({ expenseId: template.id });
+ expect(await db.select().from(jobs)).toHaveLength(0);
+ expect(await db.select().from(expenseRecurrences)).toHaveLength(0);
+ expect((await expenseRow(template.id))?.recurrenceId).toBeNull();
+ void alice;
+ });
+
+ it('deleteRecurrence removes the job but keeps the expenses', async () => {
+ const { a, template, recurrenceId } = await setup();
+ await a.expense.deleteRecurrence({ recurrenceId });
+ expect(await db.select().from(jobs)).toHaveLength(0);
+ expect((await expenseRow(template.id))?.deletedAt).toBeNull();
+ expect((await expenseRow(template.id))?.recurrenceId).toBeNull();
+ });
+
+ it("does not let a stranger change someone else's recurrence", async () => {
+ const { recurrenceId } = await setup();
+ const stranger = await makeUser();
+ await expect(
+ callerFor(stranger).expense.updateRecurrence({ recurrenceId, cronExpression: '0 0 * * *' }),
+ ).rejects.toThrow();
+ await expect(callerFor(stranger).expense.deleteRecurrence({ recurrenceId })).rejects.toThrow();
+ expect(await db.select().from(jobs)).toHaveLength(1);
+ });
+});
diff --git a/src/tests/mock/example_group_balances.json b/src/tests/mock/example_group_balances.json
deleted file mode 100644
index f754551b9..000000000
--- a/src/tests/mock/example_group_balances.json
+++ /dev/null
@@ -1,1490 +0,0 @@
-[
- {
- "groupId": 2,
- "currency": "NZD",
- "userId": 14,
- "friendId": 3,
- "amount": "0",
- "updatedAt": "2025-10-19T06:44:46.676Z"
- },
- {
- "groupId": 2,
- "currency": "NZD",
- "userId": 3,
- "friendId": 14,
- "amount": "0",
- "updatedAt": "2025-10-19T06:44:46.676Z"
- },
- {
- "groupId": 2,
- "currency": "NZD",
- "userId": 14,
- "friendId": 1,
- "amount": "0",
- "updatedAt": "2025-10-19T06:44:46.676Z"
- },
- {
- "groupId": 2,
- "currency": "NZD",
- "userId": 1,
- "friendId": 14,
- "amount": "0",
- "updatedAt": "2025-10-19T06:44:46.676Z"
- },
- {
- "groupId": 2,
- "currency": "NZD",
- "userId": 14,
- "friendId": 4,
- "amount": "0",
- "updatedAt": "2025-10-19T06:44:46.676Z"
- },
- {
- "groupId": 2,
- "currency": "NZD",
- "userId": 4,
- "friendId": 14,
- "amount": "0",
- "updatedAt": "2025-10-19T06:44:46.676Z"
- },
- {
- "groupId": 2,
- "currency": "NZD",
- "userId": 14,
- "friendId": 7,
- "amount": "0",
- "updatedAt": "2025-10-19T06:44:46.676Z"
- },
- {
- "groupId": 2,
- "currency": "NZD",
- "userId": 7,
- "friendId": 14,
- "amount": "0",
- "updatedAt": "2025-10-19T06:44:46.676Z"
- },
- {
- "groupId": 2,
- "currency": "NZD",
- "userId": 14,
- "friendId": 9,
- "amount": "0",
- "updatedAt": "2025-10-19T06:44:46.676Z"
- },
- {
- "groupId": 2,
- "currency": "NZD",
- "userId": 9,
- "friendId": 14,
- "amount": "0",
- "updatedAt": "2025-10-19T06:44:46.676Z"
- },
- {
- "groupId": 2,
- "currency": "NZD",
- "userId": 14,
- "friendId": 12,
- "amount": "0",
- "updatedAt": "2025-10-19T06:44:46.676Z"
- },
- {
- "groupId": 2,
- "currency": "NZD",
- "userId": 12,
- "friendId": 14,
- "amount": "0",
- "updatedAt": "2025-10-19T06:44:46.676Z"
- },
- {
- "groupId": 2,
- "currency": "NZD",
- "userId": 14,
- "friendId": 11,
- "amount": "0",
- "updatedAt": "2025-10-19T06:44:46.676Z"
- },
- {
- "groupId": 2,
- "currency": "NZD",
- "userId": 11,
- "friendId": 14,
- "amount": "0",
- "updatedAt": "2025-10-19T06:44:46.676Z"
- },
- {
- "groupId": 2,
- "currency": "NZD",
- "userId": 14,
- "friendId": 13,
- "amount": "0",
- "updatedAt": "2025-10-19T06:44:46.676Z"
- },
- {
- "groupId": 2,
- "currency": "NZD",
- "userId": 13,
- "friendId": 14,
- "amount": "0",
- "updatedAt": "2025-10-19T06:44:46.676Z"
- },
- {
- "groupId": 2,
- "currency": "NZD",
- "userId": 14,
- "friendId": 10,
- "amount": "0",
- "updatedAt": "2025-10-19T06:44:46.676Z"
- },
- {
- "groupId": 2,
- "currency": "NZD",
- "userId": 10,
- "friendId": 14,
- "amount": "0",
- "updatedAt": "2025-10-19T06:44:46.676Z"
- },
- {
- "groupId": 2,
- "currency": "NZD",
- "userId": 14,
- "friendId": 8,
- "amount": "0",
- "updatedAt": "2025-10-19T06:44:46.676Z"
- },
- {
- "groupId": 2,
- "currency": "NZD",
- "userId": 8,
- "friendId": 14,
- "amount": "0",
- "updatedAt": "2025-10-19T06:44:46.676Z"
- },
- {
- "groupId": 2,
- "currency": "USD",
- "userId": 14,
- "friendId": 4,
- "amount": "0",
- "updatedAt": "2025-10-19T06:44:46.676Z"
- },
- {
- "groupId": 2,
- "currency": "USD",
- "userId": 4,
- "friendId": 14,
- "amount": "0",
- "updatedAt": "2025-10-19T06:44:46.676Z"
- },
- {
- "groupId": 2,
- "currency": "USD",
- "userId": 6,
- "friendId": 3,
- "amount": "0",
- "updatedAt": "2025-10-19T06:44:46.676Z"
- },
- {
- "groupId": 2,
- "currency": "USD",
- "userId": 3,
- "friendId": 6,
- "amount": "0",
- "updatedAt": "2025-10-19T06:44:46.676Z"
- },
- {
- "groupId": 2,
- "currency": "USD",
- "userId": 6,
- "friendId": 1,
- "amount": "0",
- "updatedAt": "2025-10-19T06:44:46.676Z"
- },
- {
- "groupId": 2,
- "currency": "USD",
- "userId": 14,
- "friendId": 7,
- "amount": "0",
- "updatedAt": "2025-10-19T06:44:46.676Z"
- },
- {
- "groupId": 2,
- "currency": "USD",
- "userId": 7,
- "friendId": 14,
- "amount": "0",
- "updatedAt": "2025-10-19T06:44:46.676Z"
- },
- {
- "groupId": 2,
- "currency": "USD",
- "userId": 14,
- "friendId": 9,
- "amount": "0",
- "updatedAt": "2025-10-19T06:44:46.676Z"
- },
- {
- "groupId": 2,
- "currency": "USD",
- "userId": 9,
- "friendId": 14,
- "amount": "0",
- "updatedAt": "2025-10-19T06:44:46.676Z"
- },
- {
- "groupId": 2,
- "currency": "USD",
- "userId": 14,
- "friendId": 12,
- "amount": "0",
- "updatedAt": "2025-10-19T06:44:46.676Z"
- },
- {
- "groupId": 2,
- "currency": "USD",
- "userId": 12,
- "friendId": 14,
- "amount": "0",
- "updatedAt": "2025-10-19T06:44:46.676Z"
- },
- {
- "groupId": 2,
- "currency": "USD",
- "userId": 14,
- "friendId": 3,
- "amount": "0",
- "updatedAt": "2025-10-19T06:44:46.676Z"
- },
- {
- "groupId": 2,
- "currency": "USD",
- "userId": 3,
- "friendId": 14,
- "amount": "0",
- "updatedAt": "2025-10-19T06:44:46.676Z"
- },
- {
- "groupId": 2,
- "currency": "USD",
- "userId": 14,
- "friendId": 1,
- "amount": "0",
- "updatedAt": "2025-10-19T06:44:46.676Z"
- },
- {
- "groupId": 2,
- "currency": "USD",
- "userId": 1,
- "friendId": 14,
- "amount": "0",
- "updatedAt": "2025-10-19T06:44:46.676Z"
- },
- {
- "groupId": 2,
- "currency": "USD",
- "userId": 14,
- "friendId": 8,
- "amount": "0",
- "updatedAt": "2025-10-19T06:44:46.676Z"
- },
- {
- "groupId": 2,
- "currency": "USD",
- "userId": 8,
- "friendId": 14,
- "amount": "0",
- "updatedAt": "2025-10-19T06:44:46.676Z"
- },
- {
- "groupId": 2,
- "currency": "USD",
- "userId": 14,
- "friendId": 11,
- "amount": "0",
- "updatedAt": "2025-10-19T06:44:46.676Z"
- },
- {
- "groupId": 2,
- "currency": "USD",
- "userId": 11,
- "friendId": 14,
- "amount": "0",
- "updatedAt": "2025-10-19T06:44:46.676Z"
- },
- {
- "groupId": 2,
- "currency": "USD",
- "userId": 14,
- "friendId": 13,
- "amount": "0",
- "updatedAt": "2025-10-19T06:44:46.676Z"
- },
- {
- "groupId": 2,
- "currency": "USD",
- "userId": 13,
- "friendId": 14,
- "amount": "0",
- "updatedAt": "2025-10-19T06:44:46.676Z"
- },
- {
- "groupId": 2,
- "currency": "USD",
- "userId": 14,
- "friendId": 10,
- "amount": "0",
- "updatedAt": "2025-10-19T06:44:46.676Z"
- },
- {
- "groupId": 2,
- "currency": "USD",
- "userId": 10,
- "friendId": 14,
- "amount": "0",
- "updatedAt": "2025-10-19T06:44:46.676Z"
- },
- {
- "groupId": 2,
- "currency": "USD",
- "userId": 1,
- "friendId": 6,
- "amount": "0",
- "updatedAt": "2025-10-19T06:44:46.676Z"
- },
- {
- "groupId": 2,
- "currency": "USD",
- "userId": 6,
- "friendId": 14,
- "amount": "0",
- "updatedAt": "2025-10-19T06:44:46.676Z"
- },
- {
- "groupId": 2,
- "currency": "USD",
- "userId": 14,
- "friendId": 6,
- "amount": "0",
- "updatedAt": "2025-10-19T06:44:46.676Z"
- },
- {
- "groupId": 2,
- "currency": "USD",
- "userId": 6,
- "friendId": 8,
- "amount": "0",
- "updatedAt": "2025-10-19T06:44:46.676Z"
- },
- {
- "groupId": 2,
- "currency": "USD",
- "userId": 8,
- "friendId": 6,
- "amount": "0",
- "updatedAt": "2025-10-19T06:44:46.676Z"
- },
- {
- "groupId": 2,
- "currency": "PLN",
- "userId": 7,
- "friendId": 9,
- "amount": "0",
- "updatedAt": "2025-10-19T06:44:46.676Z"
- },
- {
- "groupId": 2,
- "currency": "PLN",
- "userId": 9,
- "friendId": 7,
- "amount": "0",
- "updatedAt": "2025-10-19T06:44:46.676Z"
- },
- {
- "groupId": 2,
- "currency": "PLN",
- "userId": 4,
- "friendId": 6,
- "amount": "0",
- "updatedAt": "2025-10-19T06:44:46.676Z"
- },
- {
- "groupId": 2,
- "currency": "PLN",
- "userId": 6,
- "friendId": 4,
- "amount": "0",
- "updatedAt": "2025-10-19T06:44:46.676Z"
- },
- {
- "groupId": 2,
- "currency": "PLN",
- "userId": 4,
- "friendId": 7,
- "amount": "2700",
- "updatedAt": "2025-10-19T06:44:46.676Z"
- },
- {
- "groupId": 2,
- "currency": "PLN",
- "userId": 7,
- "friendId": 4,
- "amount": "-2700",
- "updatedAt": "2025-10-19T06:44:46.676Z"
- },
- {
- "groupId": 2,
- "currency": "PLN",
- "userId": 4,
- "friendId": 9,
- "amount": "0",
- "updatedAt": "2025-10-19T06:44:46.676Z"
- },
- {
- "groupId": 2,
- "currency": "PLN",
- "userId": 9,
- "friendId": 4,
- "amount": "0",
- "updatedAt": "2025-10-19T06:44:46.676Z"
- },
- {
- "groupId": 2,
- "currency": "PLN",
- "userId": 3,
- "friendId": 4,
- "amount": "5495",
- "updatedAt": "2025-10-19T06:44:46.676Z"
- },
- {
- "groupId": 2,
- "currency": "PLN",
- "userId": 4,
- "friendId": 3,
- "amount": "-5495",
- "updatedAt": "2025-10-19T06:44:46.676Z"
- },
- {
- "groupId": 2,
- "currency": "PLN",
- "userId": 3,
- "friendId": 6,
- "amount": "81537",
- "updatedAt": "2025-10-19T06:44:46.676Z"
- },
- {
- "groupId": 2,
- "currency": "PLN",
- "userId": 6,
- "friendId": 3,
- "amount": "-81537",
- "updatedAt": "2025-10-19T06:44:46.676Z"
- },
- {
- "groupId": 2,
- "currency": "PLN",
- "userId": 3,
- "friendId": 7,
- "amount": "0",
- "updatedAt": "2025-10-19T06:44:46.676Z"
- },
- {
- "groupId": 2,
- "currency": "PLN",
- "userId": 7,
- "friendId": 3,
- "amount": "0",
- "updatedAt": "2025-10-19T06:44:46.676Z"
- },
- {
- "groupId": 2,
- "currency": "PLN",
- "userId": 3,
- "friendId": 9,
- "amount": "0",
- "updatedAt": "2025-10-19T06:44:46.676Z"
- },
- {
- "groupId": 2,
- "currency": "PLN",
- "userId": 9,
- "friendId": 3,
- "amount": "0",
- "updatedAt": "2025-10-19T06:44:46.676Z"
- },
- {
- "groupId": 2,
- "currency": "PLN",
- "userId": 10,
- "friendId": 3,
- "amount": "-12227",
- "updatedAt": "2025-10-19T06:44:46.676Z"
- },
- {
- "groupId": 2,
- "currency": "PLN",
- "userId": 3,
- "friendId": 10,
- "amount": "12227",
- "updatedAt": "2025-10-19T06:44:46.676Z"
- },
- {
- "groupId": 2,
- "currency": "PLN",
- "userId": 10,
- "friendId": 6,
- "amount": "0",
- "updatedAt": "2025-10-19T06:44:46.676Z"
- },
- {
- "groupId": 2,
- "currency": "PLN",
- "userId": 6,
- "friendId": 10,
- "amount": "0",
- "updatedAt": "2025-10-19T06:44:46.676Z"
- },
- {
- "groupId": 2,
- "currency": "PLN",
- "userId": 10,
- "friendId": 7,
- "amount": "0",
- "updatedAt": "2025-10-19T06:44:46.676Z"
- },
- {
- "groupId": 2,
- "currency": "PLN",
- "userId": 7,
- "friendId": 10,
- "amount": "0",
- "updatedAt": "2025-10-19T06:44:46.676Z"
- },
- {
- "groupId": 2,
- "currency": "PLN",
- "userId": 10,
- "friendId": 9,
- "amount": "0",
- "updatedAt": "2025-10-19T06:44:46.676Z"
- },
- {
- "groupId": 2,
- "currency": "PLN",
- "userId": 9,
- "friendId": 10,
- "amount": "0",
- "updatedAt": "2025-10-19T06:44:46.676Z"
- },
- {
- "groupId": 2,
- "currency": "PLN",
- "userId": 14,
- "friendId": 3,
- "amount": "-78708",
- "updatedAt": "2025-10-19T06:44:46.676Z"
- },
- {
- "groupId": 2,
- "currency": "PLN",
- "userId": 3,
- "friendId": 14,
- "amount": "78708",
- "updatedAt": "2025-10-19T06:44:46.676Z"
- },
- {
- "groupId": 2,
- "currency": "PLN",
- "userId": 14,
- "friendId": 4,
- "amount": "2600",
- "updatedAt": "2025-10-19T06:44:46.676Z"
- },
- {
- "groupId": 2,
- "currency": "PLN",
- "userId": 4,
- "friendId": 14,
- "amount": "-2600",
- "updatedAt": "2025-10-19T06:44:46.676Z"
- },
- {
- "groupId": 2,
- "currency": "PLN",
- "userId": 14,
- "friendId": 6,
- "amount": "0",
- "updatedAt": "2025-10-19T06:44:46.676Z"
- },
- {
- "groupId": 2,
- "currency": "PLN",
- "userId": 6,
- "friendId": 14,
- "amount": "0",
- "updatedAt": "2025-10-19T06:44:46.676Z"
- },
- {
- "groupId": 2,
- "currency": "PLN",
- "userId": 14,
- "friendId": 7,
- "amount": "427",
- "updatedAt": "2025-10-19T06:44:46.676Z"
- },
- {
- "groupId": 2,
- "currency": "PLN",
- "userId": 7,
- "friendId": 14,
- "amount": "-427",
- "updatedAt": "2025-10-19T06:44:46.676Z"
- },
- {
- "groupId": 2,
- "currency": "PLN",
- "userId": 14,
- "friendId": 9,
- "amount": "0",
- "updatedAt": "2025-10-19T06:44:46.676Z"
- },
- {
- "groupId": 2,
- "currency": "PLN",
- "userId": 9,
- "friendId": 14,
- "amount": "0",
- "updatedAt": "2025-10-19T06:44:46.676Z"
- },
- {
- "groupId": 2,
- "currency": "PLN",
- "userId": 14,
- "friendId": 10,
- "amount": "227",
- "updatedAt": "2025-10-19T06:44:46.676Z"
- },
- {
- "groupId": 2,
- "currency": "PLN",
- "userId": 10,
- "friendId": 14,
- "amount": "-227",
- "updatedAt": "2025-10-19T06:44:46.676Z"
- },
- {
- "groupId": 2,
- "currency": "PLN",
- "userId": 1,
- "friendId": 4,
- "amount": "-2700",
- "updatedAt": "2025-10-19T06:44:46.676Z"
- },
- {
- "groupId": 2,
- "currency": "PLN",
- "userId": 4,
- "friendId": 1,
- "amount": "2700",
- "updatedAt": "2025-10-19T06:44:46.676Z"
- },
- {
- "groupId": 2,
- "currency": "PLN",
- "userId": 1,
- "friendId": 6,
- "amount": "32517",
- "updatedAt": "2025-10-19T06:44:46.676Z"
- },
- {
- "groupId": 2,
- "currency": "PLN",
- "userId": 6,
- "friendId": 1,
- "amount": "-32517",
- "updatedAt": "2025-10-19T06:44:46.676Z"
- },
- {
- "groupId": 2,
- "currency": "PLN",
- "userId": 1,
- "friendId": 7,
- "amount": "0",
- "updatedAt": "2025-10-19T06:44:46.676Z"
- },
- {
- "groupId": 2,
- "currency": "PLN",
- "userId": 7,
- "friendId": 1,
- "amount": "0",
- "updatedAt": "2025-10-19T06:44:46.676Z"
- },
- {
- "groupId": 2,
- "currency": "PLN",
- "userId": 1,
- "friendId": 9,
- "amount": "0",
- "updatedAt": "2025-10-19T06:44:46.676Z"
- },
- {
- "groupId": 2,
- "currency": "PLN",
- "userId": 9,
- "friendId": 1,
- "amount": "0",
- "updatedAt": "2025-10-19T06:44:46.676Z"
- },
- {
- "groupId": 2,
- "currency": "PLN",
- "userId": 1,
- "friendId": 14,
- "amount": "30417",
- "updatedAt": "2025-10-19T06:44:46.676Z"
- },
- {
- "groupId": 2,
- "currency": "PLN",
- "userId": 14,
- "friendId": 1,
- "amount": "-30417",
- "updatedAt": "2025-10-19T06:44:46.676Z"
- },
- {
- "groupId": 2,
- "currency": "PLN",
- "userId": 13,
- "friendId": 1,
- "amount": "1000",
- "updatedAt": "2025-10-19T06:44:46.676Z"
- },
- {
- "groupId": 2,
- "currency": "PLN",
- "userId": 1,
- "friendId": 13,
- "amount": "-1000",
- "updatedAt": "2025-10-19T06:44:46.676Z"
- },
- {
- "groupId": 2,
- "currency": "PLN",
- "userId": 13,
- "friendId": 3,
- "amount": "20738",
- "updatedAt": "2025-10-19T06:44:46.676Z"
- },
- {
- "groupId": 2,
- "currency": "PLN",
- "userId": 3,
- "friendId": 13,
- "amount": "-20738",
- "updatedAt": "2025-10-19T06:44:46.676Z"
- },
- {
- "groupId": 2,
- "currency": "PLN",
- "userId": 13,
- "friendId": 4,
- "amount": "2400",
- "updatedAt": "2025-10-19T06:44:46.676Z"
- },
- {
- "groupId": 2,
- "currency": "PLN",
- "userId": 4,
- "friendId": 13,
- "amount": "-2400",
- "updatedAt": "2025-10-19T06:44:46.676Z"
- },
- {
- "groupId": 2,
- "currency": "PLN",
- "userId": 13,
- "friendId": 6,
- "amount": "0",
- "updatedAt": "2025-10-19T06:44:46.676Z"
- },
- {
- "groupId": 2,
- "currency": "PLN",
- "userId": 6,
- "friendId": 13,
- "amount": "0",
- "updatedAt": "2025-10-19T06:44:46.676Z"
- },
- {
- "groupId": 2,
- "currency": "PLN",
- "userId": 13,
- "friendId": 7,
- "amount": "0",
- "updatedAt": "2025-10-19T06:44:46.676Z"
- },
- {
- "groupId": 2,
- "currency": "PLN",
- "userId": 7,
- "friendId": 13,
- "amount": "0",
- "updatedAt": "2025-10-19T06:44:46.676Z"
- },
- {
- "groupId": 2,
- "currency": "PLN",
- "userId": 13,
- "friendId": 9,
- "amount": "0",
- "updatedAt": "2025-10-19T06:44:46.676Z"
- },
- {
- "groupId": 2,
- "currency": "PLN",
- "userId": 9,
- "friendId": 13,
- "amount": "0",
- "updatedAt": "2025-10-19T06:44:46.676Z"
- },
- {
- "groupId": 2,
- "currency": "PLN",
- "userId": 13,
- "friendId": 14,
- "amount": "-13999",
- "updatedAt": "2025-10-19T06:44:46.676Z"
- },
- {
- "groupId": 2,
- "currency": "PLN",
- "userId": 14,
- "friendId": 13,
- "amount": "13999",
- "updatedAt": "2025-10-19T06:44:46.676Z"
- },
- {
- "groupId": 2,
- "currency": "PLN",
- "userId": 10,
- "friendId": 1,
- "amount": "9400",
- "updatedAt": "2025-10-19T06:44:46.676Z"
- },
- {
- "groupId": 2,
- "currency": "PLN",
- "userId": 1,
- "friendId": 10,
- "amount": "-9400",
- "updatedAt": "2025-10-19T06:44:46.676Z"
- },
- {
- "groupId": 2,
- "currency": "PLN",
- "userId": 10,
- "friendId": 4,
- "amount": "400",
- "updatedAt": "2025-10-19T06:44:46.676Z"
- },
- {
- "groupId": 2,
- "currency": "PLN",
- "userId": 4,
- "friendId": 10,
- "amount": "-400",
- "updatedAt": "2025-10-19T06:44:46.676Z"
- },
- {
- "groupId": 2,
- "currency": "PLN",
- "userId": 13,
- "friendId": 10,
- "amount": "-6400",
- "updatedAt": "2025-10-19T06:44:46.676Z"
- },
- {
- "groupId": 2,
- "currency": "PLN",
- "userId": 10,
- "friendId": 13,
- "amount": "6400",
- "updatedAt": "2025-10-19T06:44:46.676Z"
- },
- {
- "groupId": 2,
- "currency": "PLN",
- "userId": 8,
- "friendId": 1,
- "amount": "-29917",
- "updatedAt": "2025-10-19T06:44:46.676Z"
- },
- {
- "groupId": 2,
- "currency": "PLN",
- "userId": 1,
- "friendId": 8,
- "amount": "29917",
- "updatedAt": "2025-10-19T06:44:46.676Z"
- },
- {
- "groupId": 2,
- "currency": "PLN",
- "userId": 8,
- "friendId": 3,
- "amount": "46133",
- "updatedAt": "2025-10-19T06:44:46.676Z"
- },
- {
- "groupId": 2,
- "currency": "PLN",
- "userId": 3,
- "friendId": 8,
- "amount": "-46133",
- "updatedAt": "2025-10-19T06:44:46.676Z"
- },
- {
- "groupId": 2,
- "currency": "PLN",
- "userId": 8,
- "friendId": 4,
- "amount": "-1800",
- "updatedAt": "2025-10-19T06:44:46.676Z"
- },
- {
- "groupId": 2,
- "currency": "PLN",
- "userId": 4,
- "friendId": 8,
- "amount": "1800",
- "updatedAt": "2025-10-19T06:44:46.676Z"
- },
- {
- "groupId": 2,
- "currency": "PLN",
- "userId": 8,
- "friendId": 6,
- "amount": "0",
- "updatedAt": "2025-10-19T06:44:46.676Z"
- },
- {
- "groupId": 2,
- "currency": "PLN",
- "userId": 6,
- "friendId": 8,
- "amount": "0",
- "updatedAt": "2025-10-19T06:44:46.676Z"
- },
- {
- "groupId": 2,
- "currency": "PLN",
- "userId": 8,
- "friendId": 7,
- "amount": "0",
- "updatedAt": "2025-10-19T06:44:46.676Z"
- },
- {
- "groupId": 2,
- "currency": "PLN",
- "userId": 7,
- "friendId": 8,
- "amount": "0",
- "updatedAt": "2025-10-19T06:44:46.676Z"
- },
- {
- "groupId": 2,
- "currency": "PLN",
- "userId": 8,
- "friendId": 9,
- "amount": "0",
- "updatedAt": "2025-10-19T06:44:46.676Z"
- },
- {
- "groupId": 2,
- "currency": "PLN",
- "userId": 9,
- "friendId": 8,
- "amount": "0",
- "updatedAt": "2025-10-19T06:44:46.676Z"
- },
- {
- "groupId": 2,
- "currency": "PLN",
- "userId": 8,
- "friendId": 10,
- "amount": "-8500",
- "updatedAt": "2025-10-19T06:44:46.676Z"
- },
- {
- "groupId": 2,
- "currency": "PLN",
- "userId": 10,
- "friendId": 8,
- "amount": "8500",
- "updatedAt": "2025-10-19T06:44:46.676Z"
- },
- {
- "groupId": 2,
- "currency": "PLN",
- "userId": 8,
- "friendId": 13,
- "amount": "15500",
- "updatedAt": "2025-10-19T06:44:46.676Z"
- },
- {
- "groupId": 2,
- "currency": "PLN",
- "userId": 13,
- "friendId": 8,
- "amount": "-15500",
- "updatedAt": "2025-10-19T06:44:46.676Z"
- },
- {
- "groupId": 2,
- "currency": "PLN",
- "userId": 8,
- "friendId": 14,
- "amount": "-9672",
- "updatedAt": "2025-10-19T06:44:46.676Z"
- },
- {
- "groupId": 2,
- "currency": "PLN",
- "userId": 14,
- "friendId": 8,
- "amount": "9672",
- "updatedAt": "2025-10-19T06:44:46.676Z"
- },
- {
- "groupId": 2,
- "currency": "PLN",
- "userId": 11,
- "friendId": 1,
- "amount": "-960",
- "updatedAt": "2025-10-19T06:44:46.676Z"
- },
- {
- "groupId": 2,
- "currency": "PLN",
- "userId": 1,
- "friendId": 11,
- "amount": "960",
- "updatedAt": "2025-10-19T06:44:46.676Z"
- },
- {
- "groupId": 2,
- "currency": "PLN",
- "userId": 11,
- "friendId": 3,
- "amount": "44672",
- "updatedAt": "2025-10-19T06:44:46.676Z"
- },
- {
- "groupId": 2,
- "currency": "PLN",
- "userId": 3,
- "friendId": 11,
- "amount": "-44672",
- "updatedAt": "2025-10-19T06:44:46.676Z"
- },
- {
- "groupId": 2,
- "currency": "PLN",
- "userId": 11,
- "friendId": 4,
- "amount": "24985",
- "updatedAt": "2025-10-19T06:44:46.676Z"
- },
- {
- "groupId": 2,
- "currency": "PLN",
- "userId": 4,
- "friendId": 11,
- "amount": "-24985",
- "updatedAt": "2025-10-19T06:44:46.676Z"
- },
- {
- "groupId": 2,
- "currency": "PLN",
- "userId": 11,
- "friendId": 6,
- "amount": "6300",
- "updatedAt": "2025-10-19T06:44:46.676Z"
- },
- {
- "groupId": 2,
- "currency": "PLN",
- "userId": 6,
- "friendId": 11,
- "amount": "-6300",
- "updatedAt": "2025-10-19T06:44:46.676Z"
- },
- {
- "groupId": 2,
- "currency": "PLN",
- "userId": 11,
- "friendId": 7,
- "amount": "1100",
- "updatedAt": "2025-10-19T06:44:46.676Z"
- },
- {
- "groupId": 2,
- "currency": "PLN",
- "userId": 7,
- "friendId": 11,
- "amount": "-1100",
- "updatedAt": "2025-10-19T06:44:46.676Z"
- },
- {
- "groupId": 2,
- "currency": "PLN",
- "userId": 11,
- "friendId": 8,
- "amount": "18124",
- "updatedAt": "2025-10-19T06:44:46.676Z"
- },
- {
- "groupId": 2,
- "currency": "PLN",
- "userId": 8,
- "friendId": 11,
- "amount": "-18124",
- "updatedAt": "2025-10-19T06:44:46.676Z"
- },
- {
- "groupId": 2,
- "currency": "PLN",
- "userId": 11,
- "friendId": 9,
- "amount": "0",
- "updatedAt": "2025-10-19T06:44:46.676Z"
- },
- {
- "groupId": 2,
- "currency": "PLN",
- "userId": 9,
- "friendId": 11,
- "amount": "0",
- "updatedAt": "2025-10-19T06:44:46.676Z"
- },
- {
- "groupId": 2,
- "currency": "PLN",
- "userId": 11,
- "friendId": 10,
- "amount": "1140",
- "updatedAt": "2025-10-19T06:44:46.676Z"
- },
- {
- "groupId": 2,
- "currency": "PLN",
- "userId": 10,
- "friendId": 11,
- "amount": "-1140",
- "updatedAt": "2025-10-19T06:44:46.676Z"
- },
- {
- "groupId": 2,
- "currency": "PLN",
- "userId": 11,
- "friendId": 13,
- "amount": "-2176",
- "updatedAt": "2025-10-19T06:44:46.676Z"
- },
- {
- "groupId": 2,
- "currency": "PLN",
- "userId": 13,
- "friendId": 11,
- "amount": "2176",
- "updatedAt": "2025-10-19T06:44:46.676Z"
- },
- {
- "groupId": 2,
- "currency": "PLN",
- "userId": 11,
- "friendId": 14,
- "amount": "24385",
- "updatedAt": "2025-10-19T06:44:46.676Z"
- },
- {
- "groupId": 2,
- "currency": "PLN",
- "userId": 14,
- "friendId": 11,
- "amount": "-24385",
- "updatedAt": "2025-10-19T06:44:46.676Z"
- },
- {
- "groupId": 2,
- "currency": "PLN",
- "userId": 11,
- "friendId": 17,
- "amount": "9043",
- "updatedAt": "2025-10-19T06:44:46.676Z"
- },
- {
- "groupId": 2,
- "currency": "PLN",
- "userId": 17,
- "friendId": 11,
- "amount": "-9043",
- "updatedAt": "2025-10-19T06:44:46.676Z"
- },
- {
- "groupId": 2,
- "currency": "EUR",
- "userId": 3,
- "friendId": 12,
- "amount": "-4637",
- "updatedAt": "2025-10-19T06:44:46.676Z"
- },
- {
- "groupId": 2,
- "currency": "EUR",
- "userId": 12,
- "friendId": 3,
- "amount": "4637",
- "updatedAt": "2025-10-19T06:44:46.676Z"
- },
- {
- "groupId": 2,
- "currency": "EUR",
- "userId": 1,
- "friendId": 3,
- "amount": "-9251",
- "updatedAt": "2025-10-19T06:44:46.676Z"
- },
- {
- "groupId": 2,
- "currency": "EUR",
- "userId": 3,
- "friendId": 1,
- "amount": "9251",
- "updatedAt": "2025-10-19T06:44:46.676Z"
- },
- {
- "groupId": 2,
- "currency": "EUR",
- "userId": 1,
- "friendId": 12,
- "amount": "-12760",
- "updatedAt": "2025-10-19T06:44:46.676Z"
- },
- {
- "groupId": 2,
- "currency": "EUR",
- "userId": 12,
- "friendId": 1,
- "amount": "12760",
- "updatedAt": "2025-10-19T06:44:46.676Z"
- },
- {
- "groupId": 2,
- "currency": "PLN",
- "userId": 1,
- "friendId": 3,
- "amount": "5808",
- "updatedAt": "2025-10-19T06:44:46.676Z"
- },
- {
- "groupId": 2,
- "currency": "PLN",
- "userId": 3,
- "friendId": 1,
- "amount": "-5808",
- "updatedAt": "2025-10-19T06:44:46.676Z"
- },
- {
- "groupId": 2,
- "currency": "PLN",
- "userId": 12,
- "friendId": 1,
- "amount": "-26585",
- "updatedAt": "2025-10-19T06:44:46.676Z"
- },
- {
- "groupId": 2,
- "currency": "PLN",
- "userId": 1,
- "friendId": 12,
- "amount": "26585",
- "updatedAt": "2025-10-19T06:44:46.676Z"
- },
- {
- "groupId": 2,
- "currency": "PLN",
- "userId": 12,
- "friendId": 4,
- "amount": "2000",
- "updatedAt": "2025-10-19T06:44:46.676Z"
- },
- {
- "groupId": 2,
- "currency": "PLN",
- "userId": 4,
- "friendId": 12,
- "amount": "-2000",
- "updatedAt": "2025-10-19T06:44:46.676Z"
- },
- {
- "groupId": 2,
- "currency": "PLN",
- "userId": 12,
- "friendId": 6,
- "amount": "0",
- "updatedAt": "2025-10-19T06:44:46.676Z"
- },
- {
- "groupId": 2,
- "currency": "PLN",
- "userId": 6,
- "friendId": 12,
- "amount": "0",
- "updatedAt": "2025-10-19T06:44:46.676Z"
- },
- {
- "groupId": 2,
- "currency": "PLN",
- "userId": 12,
- "friendId": 7,
- "amount": "2500",
- "updatedAt": "2025-10-19T06:44:46.676Z"
- },
- {
- "groupId": 2,
- "currency": "PLN",
- "userId": 7,
- "friendId": 12,
- "amount": "-2500",
- "updatedAt": "2025-10-19T06:44:46.676Z"
- },
- {
- "groupId": 2,
- "currency": "PLN",
- "userId": 12,
- "friendId": 8,
- "amount": "-7888",
- "updatedAt": "2025-10-19T06:44:46.676Z"
- },
- {
- "groupId": 2,
- "currency": "PLN",
- "userId": 8,
- "friendId": 12,
- "amount": "7888",
- "updatedAt": "2025-10-19T06:44:46.676Z"
- },
- {
- "groupId": 2,
- "currency": "PLN",
- "userId": 12,
- "friendId": 9,
- "amount": "0",
- "updatedAt": "2025-10-19T06:44:46.676Z"
- },
- {
- "groupId": 2,
- "currency": "PLN",
- "userId": 9,
- "friendId": 12,
- "amount": "0",
- "updatedAt": "2025-10-19T06:44:46.676Z"
- },
- {
- "groupId": 2,
- "currency": "PLN",
- "userId": 12,
- "friendId": 10,
- "amount": "-4067",
- "updatedAt": "2025-10-19T06:44:46.676Z"
- },
- {
- "groupId": 2,
- "currency": "PLN",
- "userId": 10,
- "friendId": 12,
- "amount": "4067",
- "updatedAt": "2025-10-19T06:44:46.676Z"
- },
- {
- "groupId": 2,
- "currency": "PLN",
- "userId": 12,
- "friendId": 13,
- "amount": "1500",
- "updatedAt": "2025-10-19T06:44:46.676Z"
- },
- {
- "groupId": 2,
- "currency": "PLN",
- "userId": 13,
- "friendId": 12,
- "amount": "-1500",
- "updatedAt": "2025-10-19T06:44:46.676Z"
- },
- {
- "groupId": 2,
- "currency": "PLN",
- "userId": 12,
- "friendId": 14,
- "amount": "4800",
- "updatedAt": "2025-10-19T06:44:46.676Z"
- },
- {
- "groupId": 2,
- "currency": "PLN",
- "userId": 14,
- "friendId": 12,
- "amount": "-4800",
- "updatedAt": "2025-10-19T06:44:46.676Z"
- },
- {
- "groupId": 2,
- "currency": "PLN",
- "userId": 12,
- "friendId": 17,
- "amount": "0",
- "updatedAt": "2025-10-19T06:44:46.676Z"
- },
- {
- "groupId": 2,
- "currency": "PLN",
- "userId": 17,
- "friendId": 12,
- "amount": "0",
- "updatedAt": "2025-10-19T06:44:46.676Z"
- },
- {
- "groupId": 2,
- "currency": "PLN",
- "userId": 12,
- "friendId": 3,
- "amount": "-9922",
- "updatedAt": "2025-10-19T06:44:46.676Z"
- },
- {
- "groupId": 2,
- "currency": "PLN",
- "userId": 3,
- "friendId": 12,
- "amount": "9922",
- "updatedAt": "2025-10-19T06:44:46.676Z"
- },
- {
- "groupId": 2,
- "currency": "PLN",
- "userId": 12,
- "friendId": 11,
- "amount": "20277",
- "updatedAt": "2025-10-19T06:44:46.676Z"
- },
- {
- "groupId": 2,
- "currency": "PLN",
- "userId": 11,
- "friendId": 12,
- "amount": "-20277",
- "updatedAt": "2025-10-19T06:44:46.676Z"
- }
-]
diff --git a/src/tests/number.test.ts b/src/tests/number.test.ts
deleted file mode 100644
index 15c14456a..000000000
--- a/src/tests/number.test.ts
+++ /dev/null
@@ -1,254 +0,0 @@
-// Filepath: /home/wiktor/kod/split-pro/src/utils/number.test.ts
-import { currencyConversion, getCurrencyHelpers } from '../utils/numbers';
-
-describe('getCurrencyHelpers', () => {
- describe('toUIString', () => {
- describe('en-US locale', () => {
- const locale = 'en-US';
-
- describe('USD', () => {
- const currency = 'USD';
- const { toUIString } = getCurrencyHelpers({ locale, currency });
-
- it.each([
- [12345n, '$123.45'],
- [-12345n, '$123.45'],
- [-50n, '$0.5'],
- [-0n, '$0'],
- [99999999999999999999999999999n, '$999,999,999,999,999,999,999,999,999.99'],
- [-99999999999999999999999999999n, '$999,999,999,999,999,999,999,999,999.99'],
- ])('should format %p as %p ', (value, expected) => {
- expect(toUIString(value)).toBe(expected);
- });
-
- it('should format with thousands separators for large numbers', () => {
- const value = 12345678900n;
- expect(toUIString(value)).toBe('$123,456,789');
- });
-
- it('should insert thousands separators for single ~1k values', () => {
- const value = 123400n;
- expect(toUIString(value)).toBe('$1,234');
- });
-
- it('Should correctly format single digit cent amounts', () => {
- const value = 7n;
- expect(toUIString(value)).toBe('$0.07');
- });
-
- it.each([
- [12345n, '$123.45'],
- [-12345n, '-$123.45'],
- [-50n, '-$0.5'],
- [-0n, '$0'],
- [99999999999999999999999999999n, '$999,999,999,999,999,999,999,999,999.99'],
- [-99999999999999999999999999999n, '-$999,999,999,999,999,999,999,999,999.99'],
- ])('should format %p as %p with signed flag', (value, expected) => {
- expect(toUIString(value, true)).toBe(expected);
- });
-
- it.each([
- [12345n, '123.45'],
- [-12345n, '-123.45'],
- [-50n, '-0.5'],
- [-0n, '0'],
- ])('should format %p as %p with signed flag and hideSymbol', (value, expected) => {
- expect(toUIString(value, true, true)).toBe(expected);
- });
- });
- describe('JPY (no decimals)', () => {
- const currency = 'JPY';
-
- const { toUIString } = getCurrencyHelpers({ locale, currency });
-
- it.each([
- [12345n, '¥12,345'],
- [-12345n, '¥12,345'],
- [-50n, '¥50'],
- [-0n, '¥0'],
- ])('should format %p as %p ', (value, expected) => {
- expect(toUIString(value)).toBe(expected);
- });
- });
-
- describe('NZD', () => {
- const { toSafeBigInt, toUIString } = getCurrencyHelpers({
- locale: 'en-NZ',
- currency: 'NZD',
- });
-
- it('preserves one cent when parsing and displaying an amount', () => {
- expect(toSafeBigInt('0.01')).toBe(1n);
- expect(toUIString(1n, false, true)).toBe('0.01');
- });
- });
- });
-
- describe('bg-BG locale', () => {
- const locale = 'bg-BG';
-
- describe('EUR', () => {
- const currency = 'EUR';
-
- const { toUIString } = getCurrencyHelpers({ locale, currency });
-
- it.each([
- [12345n, '123,45 €'],
- [-12345n, '123,45 €'],
- [-50n, '0,5 €'],
- [-0n, '0 €'],
- ])('should format %p as %p ', (value, expected) => {
- expect(toUIString(value)).toBe(expected);
- });
-
- it('should format with thousands separators for large numbers', () => {
- expect(toUIString(12345678900n)).toBe('123 456 789 €');
- expect(toUIString(5678900n)).toBe('56 789 €');
- });
-
- it('should not insert thousands separators for single ~1k values', () => {
- const value = 123400n;
- expect(toUIString(value)).toBe('1234 €');
- });
- });
- });
- });
-
- describe('toSafeBigInt', () => {
- const { toSafeBigInt } = getCurrencyHelpers({
- locale: 'en-US',
- currency: 'USD',
- });
-
- it.each([
- ['123.45', 12345n],
- ['0.99', 99n],
- ['1000', 100000n],
- ['1000.5', 100050n],
- ['0', 0n],
- ['', 0n],
- ['invalid', 0n],
- [123.45, 12345n],
- [0.99, 99n],
- [1000, 100000n],
- [1000.5, 100050n],
- [NaN, 0n],
- [0, 0n],
- [12345n, 12345n],
- [0n, 0n],
- ])('should parse %p to %p ', (input, expected) => {
- expect(toSafeBigInt(input)).toBe(expected);
- });
-
- it.each([
- ['-123.45', -12345n],
- ['-0.99', -99n],
- ['-100.01', -10001n],
- ['-1000', -100000n],
- ['-1000.5', -100050n],
-
- [-123.45, -12345n],
- [-0.99, -99n],
- [-1000, -100000n],
- [-1000.5, -100050n],
- ])('should parse %p to %p with signed flag', (input, expected) => {
- expect(toSafeBigInt(input, true)).toBe(expected);
- });
-
- it.each([
- ['123456789012345678901234567890.12', 12345678901234567890123456789012n],
- ['0.000000000000000000000000000001', 0n],
- ['-123456789012345678901234567890.12', -12345678901234567890123456789012n],
- ])('should parse big numbers %p to %p ', (input, expected) => {
- expect(toSafeBigInt(input, true)).toBe(expected);
- });
- });
-
- describe('sanitizeInput', () => {
- const { sanitizeInput } = getCurrencyHelpers({
- locale: 'en-US',
- currency: 'USD',
- });
-
- it.each([
- ['123.45', '123.45'],
- ['1,234.56', '1234.56'],
- ['1,234.56789', '1234.56'],
- ['$1,234.56', '1234.56'],
- ['$1,234.56789', '1234.56'],
- ['-123.45', '123.45'],
- ['abc123.45xyz', '123.45'],
- ['..123..45..', '.12'],
- ['', ''],
- ])('should sanitize %p to %p', (input, expected) => {
- expect(sanitizeInput(input, false)).toBe(expected);
- });
-
- it.each([
- ['-123.45', '-123.45'],
- ['(-123.45)', '-123.45'],
- ['-$1,234.56', '-1234.56'],
- ['abc-123.45xyz', '-123.45'],
- ['--123.45', '-123.45'],
- ])('should sanitize %p to %p with signed flag', (input, expected) => {
- expect(sanitizeInput(input, true)).toBe(expected);
- });
- });
-
- describe('parseToCleanString', () => {
- const { parseToCleanString } = getCurrencyHelpers({
- locale: 'en-US',
- currency: 'USD',
- });
-
- it.each([
- ['-200', '-200'],
- ['-200.01', '-200.01'],
- ['--200', '-200'],
- ['-$200.00', '-200.00'],
- ])('should keep the minus sign for %p with signed flag', (input, expected) => {
- expect(parseToCleanString(input, true)).toBe(expected);
- });
-
- it.each([
- [-20000n, '-200.00'],
- [-12345n, '-123.45'],
- [-50n, '-0.50'],
- [-0n, '0.00'],
- ])('should keep the minus sign for bigint %p with signed flag', (value, expected) => {
- expect(parseToCleanString(value, true)).toBe(expected);
- });
-
- it.each([
- ['-200', '200'],
- ['-123.45', '123.45'],
- ])('should drop the minus sign for %p without signed flag', (input, expected) => {
- expect(parseToCleanString(input)).toBe(expected);
- });
- });
-});
-
-describe('currencyConversion', () => {
- it('handles increasing decimal digit conversions', () => {
- const from = 'JPY'; // 0 decimal digits
- const to = 'USD';
- const amount = 12345n; // 12345 JPY
- const rate = 0.0073; // 1 JPY = 0.0073 USD
-
- const res = currencyConversion({ from, to, amount, rate });
-
- // 12345 JPY * 0.0073 = 90.1185 USD -> rounded to 90.12 USD -> 9012 in bigint
- expect(res).toBe(9012n);
- });
-
- it('handles decreasing decimal digit conversions', () => {
- const from = 'USD'; // 2 decimal digits
- const to = 'JPY';
- const amount = 9012n;
- const rate = 1 / 0.0073;
-
- const res = currencyConversion({ from, to, amount, rate });
-
- expect(res).toBe(12345n);
- });
-});
diff --git a/src/tests/readyz.test.ts b/src/tests/readyz.test.ts
deleted file mode 100644
index e6ae32234..000000000
--- a/src/tests/readyz.test.ts
+++ /dev/null
@@ -1,118 +0,0 @@
-import type { Prisma } from '@prisma/client';
-import type { NextApiRequest, NextApiResponse } from 'next';
-
-jest.mock('~/server/db', () => ({
- db: {
- $executeRaw: jest.fn(),
- $queryRaw: jest.fn(),
- $transaction: jest.fn(),
- },
-}));
-
-import { db } from '~/server/db';
-import handler from '~/pages/api/readyz';
-
-const createMockRes = () => {
- const res = {} as NextApiResponse;
- res.status = jest.fn().mockReturnValue(res);
- res.json = jest.fn().mockReturnValue(res);
- res.setHeader = jest.fn().mockReturnValue(res);
- return res;
-};
-
-describe('/api/readyz', () => {
- afterEach(() => {
- jest.clearAllMocks();
- });
-
- describe('successful checks', () => {
- it('responds with 200 ok when the database is reachable', async () => {
- (db.$transaction as jest.Mock).mockResolvedValue([undefined, [{ '?column?': 1 }]]);
- const req = { method: 'GET' } as NextApiRequest;
- const res = createMockRes();
-
- await handler(req, res);
-
- expect(res.status).toHaveBeenCalledWith(200);
- expect(res.json).toHaveBeenCalledWith({ status: 'ok' });
- });
-
- it('scopes a 3s statement_timeout to the check via SET LOCAL', async () => {
- (db.$executeRaw as jest.Mock).mockReturnValue('SET_STATEMENT_TIMEOUT');
- (db.$queryRaw as jest.Mock).mockReturnValue('SELECT_1');
- (db.$transaction as jest.Mock).mockResolvedValue([undefined, [{ '?column?': 1 }]]);
- const req = { method: 'GET' } as NextApiRequest;
- const res = createMockRes();
-
- await handler(req, res);
-
- const executeRawCall = (db.$executeRaw as jest.Mock).mock.calls[0] as
- | [TemplateStringsArray, Prisma.Sql]
- | undefined;
- if (!executeRawCall) {
- throw new Error('Expected db.$executeRaw to have been called');
- }
- const [setTimeoutStrings, rawTimeoutValue] = executeRawCall;
- expect(setTimeoutStrings.join('')).toContain('SET LOCAL statement_timeout = ');
- expect(rawTimeoutValue.strings.join('')).toBe('3000');
-
- const queryRawCall = (db.$queryRaw as jest.Mock).mock.calls[0] as
- | [TemplateStringsArray]
- | undefined;
- if (!queryRawCall) {
- throw new Error('Expected db.$queryRaw to have been called');
- }
- const [selectStrings] = queryRawCall;
- expect(selectStrings.join('')).toBe('SELECT 1');
-
- // Order matters: the timeout must be set before SELECT 1 runs in the same transaction.
- expect((db.$transaction as jest.Mock).mock.calls[0]?.[0]).toEqual([
- 'SET_STATEMENT_TIMEOUT',
- 'SELECT_1',
- ]);
- });
- });
-
- describe('database failures', () => {
- it('responds with 503 when the database is unreachable', async () => {
- (db.$transaction as jest.Mock).mockRejectedValue(new Error('connection refused'));
- const req = { method: 'GET' } as NextApiRequest;
- const res = createMockRes();
-
- await handler(req, res);
-
- expect(res.status).toHaveBeenCalledWith(503);
- expect(res.json).toHaveBeenCalledWith({
- status: 'error',
- message: 'Database not reachable',
- });
- });
-
- it('responds with 503 when the database check times out', async () => {
- (db.$transaction as jest.Mock).mockRejectedValue(
- new Error('canceling statement due to statement timeout'),
- );
- const req = { method: 'GET' } as NextApiRequest;
- const res = createMockRes();
-
- await handler(req, res);
-
- expect(res.status).toHaveBeenCalledWith(503);
- });
- });
-
- describe('method validation', () => {
- it('rejects non-GET methods', async () => {
- const req = { method: 'POST' } as NextApiRequest;
- const res = createMockRes();
-
- await handler(req, res);
-
- expect(res.status).toHaveBeenCalledWith(405);
- expect((res.setHeader as jest.Mock).mock.calls).toContainEqual(['Allow', 'GET']);
- expect((db.$executeRaw as jest.Mock).mock.calls).toHaveLength(0);
- expect((db.$queryRaw as jest.Mock).mock.calls).toHaveLength(0);
- expect((db.$transaction as jest.Mock).mock.calls).toHaveLength(0);
- });
- });
-});
diff --git a/src/tests/simplify.test.ts b/src/tests/simplify.test.ts
deleted file mode 100644
index f3768a2bc..000000000
--- a/src/tests/simplify.test.ts
+++ /dev/null
@@ -1,210 +0,0 @@
-import { type BalanceView as GroupBalance } from '@prisma/client';
-import { addHours } from 'date-fns';
-import rawRealGraph from './mock/example_group_balances.json';
-import { simplifyDebts } from '~/lib/simplify';
-
-const realGraph = rawRealGraph.map(({ amount, updatedAt, ...rest }) => ({
- ...rest,
- amount: BigInt(amount),
- updatedAt: new Date(updatedAt),
- createdAt: new Date(updatedAt),
-}));
-
-interface MinimalEdge {
- userOne: number;
- userTwo: number;
- amount: bigint;
-}
-
-const sortByIds = (a: GroupBalance, b: GroupBalance) => {
- if (a.userId === b.userId) {
- return a.friendId - b.friendId;
- }
- return a.userId - b.userId;
-};
-
-let dateCounter = 0;
-
-const edgeToGroupBalance = (edge: MinimalEdge): [GroupBalance, GroupBalance] => {
- const createdAt = addHours(new Date(), dateCounter++);
- const base = {
- groupId: 0,
- currency: 'USD',
- updatedAt: createdAt,
- createdAt,
- };
- return [
- {
- userId: edge.userOne,
- friendId: edge.userTwo,
- amount: edge.amount,
- ...base,
- },
- {
- userId: edge.userTwo,
- friendId: edge.userOne,
- amount: 0n === edge.amount ? 0n : -edge.amount,
- ...base,
- },
- ];
-};
-
-const padWithZeroBalances: (balances: GroupBalance[], userCount: number) => GroupBalance[] = (
- balances,
- userCount,
-) => {
- const result = [...balances];
- for (let userId = 0; userId < userCount; userId++) {
- for (let friendId = userId + 1; friendId < userCount; friendId++) {
- const found = balances.find(
- (balance) => balance.userId === userId && balance.friendId === friendId,
- );
-
- if (!found) {
- result.push(...edgeToGroupBalance({ userOne: userId, userTwo: friendId, amount: 0n }));
- }
- }
- }
- return result;
-};
-
-const getFullBalanceGraph = (edges: MinimalEdge[], userCount: number): GroupBalance[] => {
- const arr = padWithZeroBalances(edges.flatMap(edgeToGroupBalance), userCount);
- arr.sort(sortByIds);
- return arr;
-};
-
-// taken from https://www.geeksforgeeks.org/minimize-cash-flow-among-given-set-friends-borrowed-money/
-const smallGraph: GroupBalance[] = getFullBalanceGraph(
- [
- { userOne: 0, userTwo: 1, amount: 1000n },
- { userOne: 1, userTwo: 2, amount: 5000n },
- { userOne: 2, userTwo: 0, amount: -2000n },
- ],
- 3,
-);
-
-const smallGraphResult: GroupBalance[] = getFullBalanceGraph(
- [
- { userOne: 1, userTwo: 2, amount: 4000n },
- { userOne: 2, userTwo: 0, amount: -3000n },
- ],
- 3,
-).map((resultBalance, idx) => ({
- ...resultBalance,
- updatedAt: smallGraph[idx]!.updatedAt,
- createdAt: smallGraph[idx]!.createdAt,
-}));
-
-// taken from https://medium.com/@mithunmk93/algorithm-behind-splitwises-debt-simplification-feature-8ac485e97688
-const largeGraph: GroupBalance[] = getFullBalanceGraph(
- [
- { userOne: 1, userTwo: 2, amount: 4000n },
- { userOne: 1, userTwo: 5, amount: -1000n },
- { userOne: 1, userTwo: 6, amount: -3000n },
-
- { userOne: 2, userTwo: 3, amount: 2000n },
- { userOne: 2, userTwo: 5, amount: -3000n },
-
- { userOne: 3, userTwo: 4, amount: 5000n },
- { userOne: 3, userTwo: 5, amount: -1000n },
- { userOne: 3, userTwo: 6, amount: -1000n },
-
- { userOne: 4, userTwo: 5, amount: -1000n },
- ],
- 7,
-);
-
-const denseGraph: GroupBalance[] = getFullBalanceGraph(
- [
- { userOne: 0, userTwo: 1, amount: 895795n },
- { userOne: 0, userTwo: 2, amount: 328043n },
- { userOne: 0, userTwo: 3, amount: -174624n },
- { userOne: 0, userTwo: 4, amount: 5035n },
- { userOne: 0, userTwo: 5, amount: -81416n },
-
- { userOne: 1, userTwo: 2, amount: -324560n },
- { userOne: 1, userTwo: 3, amount: -11502n },
- { userOne: 1, userTwo: 4, amount: 126115n },
- { userOne: 1, userTwo: 5, amount: 83333n },
-
- { userOne: 2, userTwo: 3, amount: -519167n },
- { userOne: 2, userTwo: 4, amount: -400050n },
- { userOne: 2, userTwo: 5, amount: -23334n },
-
- { userOne: 3, userTwo: 4, amount: 157284n },
- { userOne: 3, userTwo: 5, amount: 15833n },
-
- { userOne: 4, userTwo: 5, amount: 12916n },
- ],
- 6,
-);
-
-describe('simplifyDebts', () => {
- it('simplifies small graph', () => {
- const result = simplifyDebts(smallGraph);
- result.sort(sortByIds);
- expect(result).toEqual(smallGraphResult);
- });
-
- it.each([
- { graph: smallGraph, expected: 2 },
- { graph: largeGraph, expected: 3 },
- { graph: denseGraph, expected: 5 },
- ])('gets the optimal operation count', ({ graph, expected }) => {
- expect(simplifyDebts(graph).filter((balance) => 0 < balance.amount).length).toBe(expected);
- });
-
- it.each([
- ['small', smallGraph],
- ['large', largeGraph],
- ['dense', denseGraph],
- ['real', realGraph],
- ])('preserves metadata for %s graph', (name, graph) => {
- const result = simplifyDebts(graph);
- for (const balance of result) {
- expect(balance.groupId).toBeDefined();
- expect(balance.currency).toBeDefined();
- expect(balance.userId).toBeDefined();
- expect(balance.friendId).toBeDefined();
- expect(balance.updatedAt).toBeDefined();
- }
- });
-
- it.each([
- ['small', smallGraph],
- ['large', largeGraph],
- ['dense', denseGraph],
- ['real', realGraph],
- ])('preserves the total balance per user for %s graph', (name, graph) => {
- const startingBalances = graph.reduce(
- (acc, balance) => {
- acc[balance.userId] = (acc[balance.userId] ?? 0n) + balance.amount;
- return acc;
- },
- {} as Record,
- );
-
- const userBalances = simplifyDebts(graph).reduce(
- (acc, balance) => {
- acc[balance.userId] = (acc[balance.userId] ?? 0n) + balance.amount;
- return acc;
- },
- {} as Record,
- );
-
- expect(startingBalances).toEqual(userBalances);
- });
-
- it.each([
- ['small', smallGraph],
- ['large', largeGraph],
- ['dense', denseGraph],
- ['real', realGraph],
- ])('produces no self-loops for %s graph', (name, graph) => {
- const result = simplifyDebts(graph);
- for (const balance of result) {
- expect(balance.userId).not.toBe(balance.friendId);
- }
- });
-});
diff --git a/src/types.ts b/src/types.ts
index 88c2ac39c..7d28d5cbe 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -1,5 +1,5 @@
import { type NextPage } from 'next';
-import { type User } from 'next-auth';
+import type { SessionUser as User } from '~/server/auth';
import { z } from 'zod';
import { type CurrencyCode } from './lib/currency';
diff --git a/src/types/expense.types.ts b/src/types/expense.types.ts
index 8ead7d236..7ddbbc6b4 100644
--- a/src/types/expense.types.ts
+++ b/src/types/expense.types.ts
@@ -1,4 +1,4 @@
-import { type Expense, type ExpenseParticipant, SplitType } from '@prisma/client';
+import { type Expense, type ExpenseParticipant, SplitType } from '~/server/db/types';
import { type ZodTypeAny, z } from 'zod';
/**
diff --git a/src/utils/api.ts b/src/utils/api.ts
index af26dffe2..1ea1f90eb 100644
--- a/src/utils/api.ts
+++ b/src/utils/api.ts
@@ -14,11 +14,11 @@ import { type AppRouter } from '~/server/api/root';
export const getBaseUrl = () => {
if ('undefined' !== typeof window) {
return '';
- } // browser should use relative url
+ } // Browser should use relative url
if (process.env.VERCEL_URL) {
return `https://${process.env.VERCEL_URL}`;
} // SSR should use vercel url
- return `http://localhost:${process.env.PORT ?? 3000}`; // dev SSR should use localhost
+ return process.env.NEXTAUTH_URL ?? 'http://localhost:5173';
};
/** A set of type-safe react-query hooks for your tRPC API. */
diff --git a/src/utils/file.ts b/src/utils/file.ts
deleted file mode 100644
index 829ac7e82..000000000
--- a/src/utils/file.ts
+++ /dev/null
@@ -1,8 +0,0 @@
-import fs from 'node:fs/promises';
-
-export function fileExists(filePath: string): Promise {
- return fs
- .stat(filePath)
- .then(() => true)
- .catch(() => false);
-}
diff --git a/src/utils/i18n/server.ts b/src/utils/i18n/server.ts
index d34b7b74c..bc8d3b654 100644
--- a/src/utils/i18n/server.ts
+++ b/src/utils/i18n/server.ts
@@ -1,13 +1,54 @@
import { type SSRConfig } from 'next-i18next';
-import { serverSideTranslations } from 'next-i18next/serverSideTranslations';
-import i18nConfig from '@/next-i18next.config.js';
+
+/**
+ * Server-side translations without `fs`. next-i18next's `serverSideTranslations` reads
+ * `public/locales` from disk, which does not exist on Workers. The locale JSON files are
+ * bundled instead (one lazy chunk per file) and returned in the same `_nextI18Next` shape
+ * that `appWithTranslation` expects.
+ */
+const localeFiles = import.meta.glob<{ default: Record }>(
+ '../../../public/locales/*/*.json',
+);
+
+const FALLBACK_LOCALE = 'en';
+
+const loadNamespace = async (locale: string, ns: string) => {
+ const loader = localeFiles[`../../../public/locales/${locale}/${ns}.json`];
+ if (!loader) {
+ return undefined;
+ }
+ return (await loader()).default;
+};
export const customServerSideTranslations = async (
locale: string | undefined,
namespaces: string[],
): Promise => {
- locale ??= 'en';
- return await serverSideTranslations(locale, namespaces, i18nConfig);
+ const initialLocale = !locale || 'default' === locale ? FALLBACK_LOCALE : locale;
+ const locales = [...new Set([initialLocale, FALLBACK_LOCALE])];
+
+ const initialI18nStore: Record> = {};
+ await Promise.all(
+ locales.flatMap((lng) => {
+ initialI18nStore[lng] = {};
+ return namespaces.map(async (ns) => {
+ const data = await loadNamespace(lng, ns);
+ if (data) {
+ initialI18nStore[lng]![ns] = data;
+ }
+ });
+ }),
+ );
+
+ return {
+ _nextI18Next: {
+ initialI18nStore,
+ initialLocale,
+ ns: namespaces,
+ // `appWithTranslation(MyApp, i18nConfig)` in _app.tsx supplies the config.
+ userConfig: null,
+ },
+ };
};
export const withI18nStaticProps =
diff --git a/src/utils/strings.ts b/src/utils/strings.ts
index bd6adcc51..93978e888 100644
--- a/src/utils/strings.ts
+++ b/src/utils/strings.ts
@@ -1,4 +1,4 @@
-import { SplitType, type User } from '@prisma/client';
+import { SplitType, type User } from '~/server/db/types';
import { isToday } from 'date-fns';
import { type TFunction } from 'next-i18next';
import { type CurrencyCode } from '~/lib/currency';
diff --git a/test/setup.integration.ts b/test/setup.integration.ts
new file mode 100644
index 000000000..4f24de72a
--- /dev/null
+++ b/test/setup.integration.ts
@@ -0,0 +1,17 @@
+import { applyD1Migrations } from 'cloudflare:test';
+import { env } from 'cloudflare:workers';
+import { beforeAll } from 'vitest';
+
+// BigInt values appear in tRPC results and balance rows.
+// @ts-expect-error extending the BigInt prototype for tests only
+BigInt.prototype.toJSON = function toJSON() {
+ return this.toString();
+};
+
+beforeAll(async () => {
+ const { DB, TEST_MIGRATIONS } = env as unknown as {
+ DB: D1Database;
+ TEST_MIGRATIONS: Parameters[1];
+ };
+ await applyD1Migrations(DB, TEST_MIGRATIONS);
+});
diff --git a/test/wrangler.test.jsonc b/test/wrangler.test.jsonc
new file mode 100644
index 000000000..4cc58e1a3
--- /dev/null
+++ b/test/wrangler.test.jsonc
@@ -0,0 +1,20 @@
+{
+ // Used only by the Vitest integration project (@cloudflare/vitest-pool-workers).
+ "name": "splitpro-test",
+ "compatibility_date": "2026-10-01",
+ "compatibility_flags": ["nodejs_compat", "global_fetch_strictly_public"],
+ "d1_databases": [
+ {
+ "binding": "DB",
+ "database_name": "splitpro-test",
+ "database_id": "00000000-0000-0000-0000-000000000000",
+ "migrations_dir": "../migrations",
+ },
+ ],
+ "r2_buckets": [{ "binding": "UPLOADS", "bucket_name": "splitpro-test-uploads" }],
+ "vars": {
+ "SKIP_ENV_VALIDATION": "1",
+ "NEXTAUTH_URL": "http://localhost:8787",
+ "NEXTAUTH_SECRET": "test-secret-test-secret-test-secret-123456",
+ },
+}
diff --git a/tests/e2e/app.spec.ts b/tests/e2e/app.spec.ts
new file mode 100644
index 000000000..acb73de7b
--- /dev/null
+++ b/tests/e2e/app.spec.ts
@@ -0,0 +1,220 @@
+import { expect, login, test, trpc } from './fixtures';
+import { USERS } from './users.mjs';
+
+/** Superjson input with BigInt fields: `bigints` lists the dotted paths that hold bigints. */
+const sj = (json: unknown, bigints: string[] = []) => ({
+ json,
+ meta: bigints.length
+ ? { values: Object.fromEntries(bigints.map((p) => [p, ['bigint']])) }
+ : undefined,
+});
+
+const expenseInput = (opts: {
+ paidBy: number;
+ other: number;
+ amount: number;
+ groupId?: number | null;
+ name?: string;
+ splitType?: string;
+ cronExpression?: string;
+}) =>
+ sj(
+ {
+ paidBy: opts.paidBy,
+ name: opts.name ?? 'E2E dinner',
+ category: 'food',
+ amount: String(opts.amount),
+ groupId: opts.groupId ?? null,
+ splitType: opts.splitType ?? 'EQUAL',
+ currency: 'EUR',
+ participants: [
+ { userId: opts.paidBy, amount: String(opts.amount / 2) },
+ { userId: opts.other, amount: String(-opts.amount / 2) },
+ ],
+ cronExpression: opts.cronExpression,
+ },
+ ['amount', 'participants.0.amount', 'participants.1.amount'],
+ );
+
+// oxlint-disable-next-line typescript/no-explicit-any
+const data = (result: { body: unknown }): any =>
+ // oxlint-disable-next-line typescript/no-explicit-any
+ (result.body as { result: { data: { json: any } } }[])[0].result.data.json;
+
+// Tests share one database but never share rows: each one works on its own group or expense,
+// So they can run in parallel.
+
+test.describe('auth', () => {
+ test('redirects visitors to sign in, which offers Google only', async ({ page }) => {
+ await page.goto('/balances');
+ await expect(page).toHaveURL(/\/auth\/signin/);
+ await expect(page.getByText('Continue with Google')).toBeVisible();
+ });
+
+ test('a signed-in user sees the app instead of the sign-in page', async ({
+ page,
+ context,
+ baseURL,
+ }) => {
+ await login(context, 'alice', baseURL!);
+ await page.goto('/balances');
+ await expect(page).toHaveURL(/\/balances/);
+ await expect(page.getByText('Continue with Google')).toHaveCount(0);
+ });
+
+ test('tRPC rejects anonymous calls and serves signed-in ones', async ({ context, baseURL }) => {
+ expect((await trpc(context, baseURL!, 'query', 'user.me')).status).toBe(401);
+ await login(context, 'alice', baseURL!);
+ const me = await trpc(context, baseURL!, 'query', 'user.me');
+ expect(me.status).toBe(200);
+ expect(data(me)).toMatchObject({ id: USERS.alice.id, email: USERS.alice.email });
+ });
+});
+
+test.describe('groups, expenses and balances', () => {
+ test('creates a group and an expense, shows both in the UI, and settles up', async ({
+ page,
+ context,
+ baseURL,
+ }) => {
+ await login(context, 'alice', baseURL!);
+ const groupName = `Trip ${Date.now()}`;
+
+ const created = await trpc(
+ context,
+ baseURL!,
+ 'mutation',
+ 'group.create',
+ sj({ name: groupName }),
+ );
+ expect(created.status).toBe(200);
+ const group = data(created);
+ await trpc(
+ context,
+ baseURL!,
+ 'mutation',
+ 'group.addMembers',
+ sj({ groupId: group.id, userIds: [USERS.bob.id] }),
+ );
+
+ // BigInt + Date round trip through the real tRPC endpoint.
+ const add = await trpc(
+ context,
+ baseURL!,
+ 'mutation',
+ 'expense.addOrEditExpense',
+ expenseInput({
+ paidBy: USERS.alice.id,
+ other: USERS.bob.id,
+ amount: 1000,
+ groupId: group.id,
+ }),
+ );
+ expect(add.status).toBe(200);
+
+ await page.goto(`/groups/${group.id}`);
+ await expect(page.getByText(groupName).first()).toBeVisible();
+ await expect(page.getByText('E2E dinner').first()).toBeVisible();
+
+ const groupBalances = async (): Promise<
+ { userId: number; friendId: number; amount: string }[]
+ > =>
+ data(
+ await trpc(context, baseURL!, 'query', 'group.getGroupDetails', sj({ groupId: group.id })),
+ ).groupBalances;
+ const before = (await groupBalances()).find(
+ (b) => b.userId === USERS.alice.id && b.friendId === USERS.bob.id,
+ );
+ expect(before?.amount).toBe('500');
+
+ await page.goto('/balances');
+ await expect(page.getByText(USERS.bob.name).first()).toBeVisible();
+
+ // Bob settles up; the group balance goes back to zero.
+ await context.clearCookies();
+ await login(context, 'bob', baseURL!);
+ const settle = await trpc(
+ context,
+ baseURL!,
+ 'mutation',
+ 'expense.addOrEditExpense',
+ expenseInput({
+ paidBy: USERS.bob.id,
+ other: USERS.alice.id,
+ amount: 1000,
+ groupId: group.id,
+ splitType: 'SETTLEMENT',
+ name: 'Settle up',
+ }),
+ );
+ expect(settle.status).toBe(200);
+ expect((await groupBalances()).every((b) => '0' === b.amount)).toBe(true);
+ });
+
+ test('a stranger cannot read or delete someone else’s expense', async ({ context, baseURL }) => {
+ await login(context, 'alice', baseURL!);
+ const add = await trpc(
+ context,
+ baseURL!,
+ 'mutation',
+ 'expense.addOrEditExpense',
+ expenseInput({ paidBy: USERS.alice.id, other: USERS.bob.id, amount: 400, name: 'Private' }),
+ );
+ const expenseId = data(add)[0].id as string;
+
+ await context.clearCookies();
+ await login(context, 'mallory', baseURL!);
+ const read = await trpc(
+ context,
+ baseURL!,
+ 'query',
+ 'expense.getExpenseDetails',
+ sj({ expenseId }),
+ );
+ expect(read.status).toBe(404);
+ const del = await trpc(
+ context,
+ baseURL!,
+ 'mutation',
+ 'expense.deleteExpense',
+ sj({ expenseId }),
+ );
+ expect(del.status).toBeGreaterThanOrEqual(400);
+
+ await context.clearCookies();
+ await login(context, 'alice', baseURL!);
+ const still = await trpc(
+ context,
+ baseURL!,
+ 'query',
+ 'expense.getExpenseDetails',
+ sj({ expenseId }),
+ );
+ expect(still.status).toBe(200);
+ expect(data(still).deletedAt).toBeNull();
+ });
+
+ test('creates a recurring expense and lists it (cron parsing works in the production bundle)', async ({
+ context,
+ baseURL,
+ }) => {
+ await login(context, 'alice', baseURL!);
+ const name = `Monthly rent ${Date.now()}`;
+ const add = await trpc(
+ context,
+ baseURL!,
+ 'mutation',
+ 'expense.addOrEditExpense',
+ expenseInput({
+ paidBy: USERS.alice.id,
+ other: USERS.bob.id,
+ amount: 800,
+ name,
+ cronExpression: '0 9 1 * *',
+ }),
+ );
+ expect(add.status).toBe(200);
+ const recurring = data(await trpc(context, baseURL!, 'query', 'expense.getRecurringExpenses'));
+ expect(JSON.stringify(recurring)).toContain(name);
+ });
+});
diff --git a/tests/e2e/fixtures.ts b/tests/e2e/fixtures.ts
new file mode 100644
index 000000000..202dd3c4f
--- /dev/null
+++ b/tests/e2e/fixtures.ts
@@ -0,0 +1,40 @@
+import { type BrowserContext, test as base, expect } from '@playwright/test';
+
+import { USERS, sessionCookie } from './users.mjs';
+
+type Who = keyof typeof USERS;
+
+export const login = async (context: BrowserContext, who: Who, baseURL: string) => {
+ await context.addCookies([
+ {
+ name: 'better-auth.session_token',
+ value: sessionCookie(USERS[who].token),
+ url: baseURL,
+ },
+ ]);
+};
+
+/** Calls a tRPC procedure over HTTP the way the browser does (superjson, single call). */
+export const trpc = async (
+ context: BrowserContext,
+ baseURL: string,
+ kind: 'query' | 'mutation',
+ path: string,
+ input?: unknown,
+) => {
+ const body = input === undefined ? { json: null, meta: { values: ['undefined'], v: 1 } } : input;
+ const response =
+ kind === 'query'
+ ? await context.request.get(
+ `${baseURL}/api/trpc/${path}?batch=1&input=${encodeURIComponent(JSON.stringify({ 0: body }))}`,
+ )
+ : await context.request.post(`${baseURL}/api/trpc/${path}?batch=1`, { data: { 0: body } });
+ const parsed = (await response.json()) as unknown;
+ if (response.status() >= 400) {
+ console.log(`tRPC ${path} -> ${response.status()}`, JSON.stringify(parsed).slice(0, 600));
+ }
+ return { status: response.status(), body: parsed };
+};
+
+export { expect };
+export const test = base;
diff --git a/tests/e2e/prepare.mjs b/tests/e2e/prepare.mjs
new file mode 100644
index 000000000..6892dd244
--- /dev/null
+++ b/tests/e2e/prepare.mjs
@@ -0,0 +1,36 @@
+// Creates a throwaway local D1 under .e2e-state (migrations + three users with live sessions).
+// Runs as part of the Playwright webServer command, before the Worker starts.
+import { execFileSync } from 'node:child_process';
+import { rmSync, writeFileSync } from 'node:fs';
+
+import { USERS } from './users.mjs';
+
+const wrangler = (...args) =>
+ execFileSync(
+ 'npx',
+ [
+ 'wrangler',
+ ...args,
+ '-c',
+ 'dist/splitpro/wrangler.json',
+ '--local',
+ '--persist-to',
+ '.e2e-state',
+ ],
+ { stdio: 'pipe', env: { ...process.env, CI: '1' } },
+ );
+
+rmSync('.e2e-state', { recursive: true, force: true });
+wrangler('d1', 'migrations', 'apply', 'splitpro');
+
+const now = Date.now();
+const expires = now + 7 * 86_400_000;
+const sql = Object.values(USERS)
+ .flatMap((u) => [
+ `INSERT INTO "User" (id, name, email, emailVerified, currency, createdAt, updatedAt) VALUES (${u.id}, '${u.name}', '${u.email}', 1, 'EUR', ${now}, ${now});`,
+ `INSERT INTO "Session" (token, userId, expiresAt, createdAt, updatedAt) VALUES ('${u.token}', ${u.id}, ${expires}, ${now}, ${now});`,
+ ])
+ .join('\n');
+writeFileSync('.e2e-seed.sql', sql);
+wrangler('d1', 'execute', 'splitpro', '--file', '.e2e-seed.sql');
+rmSync('.e2e-seed.sql');
diff --git a/tests/e2e/users.mjs b/tests/e2e/users.mjs
new file mode 100644
index 000000000..f277c2fdc
--- /dev/null
+++ b/tests/e2e/users.mjs
@@ -0,0 +1,12 @@
+import { createHmac } from 'node:crypto';
+
+export const SECRET = 'e2e-secret-e2e-secret-e2e-secret-123456';
+export const USERS = {
+ alice: { id: 1, name: 'Alice E2E', email: 'alice@e2e.test', token: 'e2e-token-alice' },
+ bob: { id: 2, name: 'Bob E2E', email: 'bob@e2e.test', token: 'e2e-token-bob' },
+ mallory: { id: 3, name: 'Mallory E2E', email: 'mallory@e2e.test', token: 'e2e-token-mallory' },
+};
+
+/** better-auth cookie value: `.`, URL-encoded. */
+export const sessionCookie = (token) =>
+ encodeURIComponent(`${token}.${createHmac('sha256', SECRET).update(token).digest('base64')}`);
diff --git a/tsconfig.json b/tsconfig.json
index f11f7096c..1930d0536 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -20,7 +20,7 @@
"module": "ESNext",
"moduleResolution": "Bundler",
"jsx": "preserve",
- "plugins": [{ "name": "next" }],
+ "types": ["@cloudflare/workers-types", "vite/client", "vinext/types"],
"incremental": true,
/* Path Aliases */
@@ -31,13 +31,25 @@
},
"include": [
"eslint.config.mjs",
- "next-env.d.ts",
"**/*.ts",
"**/*.tsx",
"**/*.jsx",
"**/*.cjs",
"**/*.js",
- ".next/types/**/*.ts"
+ "worker/**/*.ts"
],
- "exclude": ["node_modules", "public"]
+ "exclude": [
+ "node_modules",
+ "public",
+ "dist",
+ ".wrangler",
+ "src/tests",
+ "src/**/*.test.ts",
+ "src/dummies",
+ "test",
+ "tests",
+ "playwright.config.ts",
+ "vitest.config.ts",
+ ".e2e-state"
+ ]
}
diff --git a/vite.config.ts b/vite.config.ts
new file mode 100644
index 000000000..e9bb53225
--- /dev/null
+++ b/vite.config.ts
@@ -0,0 +1,14 @@
+import { cloudflare } from '@cloudflare/vite-plugin';
+import vinext from 'vinext';
+import { defineConfig } from 'vite';
+
+export default defineConfig({
+ plugins: [vinext(), cloudflare()],
+ build: { sourcemap: true },
+ resolve: {
+ alias: {
+ '~': new URL('./src', import.meta.url).pathname,
+ '@': new URL('.', import.meta.url).pathname,
+ },
+ },
+});
diff --git a/vite.sw.config.ts b/vite.sw.config.ts
new file mode 100644
index 000000000..0098bc595
--- /dev/null
+++ b/vite.sw.config.ts
@@ -0,0 +1,15 @@
+import { defineConfig } from 'vite';
+
+// Builds the service worker (push notifications + runtime caching) to public/sw.js.
+// Runs before the app build so the file is served as a static asset.
+export default defineConfig({
+ publicDir: false,
+ resolve: { alias: { '~': new URL('./src', import.meta.url).pathname } },
+ define: { 'self.__SW_MANIFEST': 'undefined', 'process.env.NODE_ENV': '"production"' },
+ build: {
+ outDir: 'public',
+ emptyOutDir: false,
+ lib: { entry: 'src/sw.ts', formats: ['es'], fileName: () => 'sw.js' },
+ rolldownOptions: { output: { inlineDynamicImports: true } },
+ },
+});
diff --git a/vitest.config.ts b/vitest.config.ts
new file mode 100644
index 000000000..7571f652e
--- /dev/null
+++ b/vitest.config.ts
@@ -0,0 +1,34 @@
+import { resolve } from 'node:path';
+
+import { cloudflareTest, readD1Migrations } from '@cloudflare/vitest-plugin';
+import { defineConfig } from 'vitest/config';
+
+const alias = { '~': resolve(import.meta.dirname, 'src') };
+
+export default defineConfig({
+ test: {
+ projects: [
+ {
+ // Real D1 (local, via workerd) with the production migrations applied.
+ resolve: { alias },
+ plugins: [
+ cloudflareTest(async () => ({
+ wrangler: { configPath: './test/wrangler.test.jsonc' },
+ miniflare: {
+ bindings: {
+ TEST_MIGRATIONS: await readD1Migrations(resolve(import.meta.dirname, 'migrations')),
+ },
+ },
+ })),
+ ],
+ test: {
+ name: 'integration',
+ include: ['src/tests/integration/**/*.integration.test.ts'],
+ setupFiles: ['test/setup.integration.ts'],
+ // CommonJS-only dependencies must be pre-bundled to run inside workerd.
+ deps: { optimizer: { ssr: { enabled: true, include: ['cron-parser'] } } },
+ },
+ },
+ ],
+ },
+});
diff --git a/worker/index.ts b/worker/index.ts
index 1dbec632f..656d4ab14 100644
--- a/worker/index.ts
+++ b/worker/index.ts
@@ -1,58 +1,97 @@
-import { type PushMessage } from '~/types';
-import { defaultCache } from '@serwist/next/worker';
-import type { PrecacheEntry, SerwistGlobalConfig } from 'serwist';
-import { Serwist } from 'serwist';
-
-// This declares the value of `injectionPoint` to TypeScript.
-// `injectionPoint` is the string that will be replaced by the
-// actual precache manifest. By default, this string is set to
-// `"self.__SW_MANIFEST"`.
-declare global {
- interface WorkerGlobalScope extends SerwistGlobalConfig {
- __SW_MANIFEST: (PrecacheEntry | string)[] | undefined;
- }
-}
-
-declare const self: ServiceWorkerGlobalScope;
-
-const serwist = new Serwist({
- precacheEntries: self.__SW_MANIFEST,
- skipWaiting: true,
- clientsClaim: true,
- navigationPreload: true,
- runtimeCaching: defaultCache,
-});
+/**
+ * Worker entry: vinext Pages Router handler plus the scheduler Workflow class.
+ * vinext uses ./worker/index.ts as `main` when it exists.
+ *
+ * The wrapper logs every uncaught exception and every 5xx response to Workers Observability.
+ * Uncaught exceptions are re-thrown so they also appear in Workers Issues.
+ */
+import handler from 'vinext/server/pages-router-entry';
+import { createRemoteJWKSet, jwtVerify } from 'jose';
+
+import { logError } from '../src/server/observability/errorLog';
+import { PREVIEW_ACCESS_EMAIL_HEADER, PREVIEW_ACCESS_NAME_HEADER } from '../src/server/preview-access';
+
+export { SchedulerWorkflow } from '../src/server/workflows/scheduler';
-self.addEventListener('push', function (event) {
- const { title, message, data } = JSON.parse(event?.data?.text() ?? '{}') as PushMessage;
- event.waitUntil(
- self.registration.showNotification(title, {
- body: message,
- icon: '/icons/android-chrome-192x192.png',
- data,
- }),
- );
+addEventListener('unhandledrejection', (event: Event) => {
+ logError('unhandledrejection', (event as PromiseRejectionEvent).reason);
});
-self.addEventListener('notificationclick', function (event) {
- event.notification.close();
- event.waitUntil(
- (async () => {
- const clientList = await self.clients.matchAll({ type: 'window' });
+type FetchHandler = (request: Request, env: unknown, ctx: ExecutionContext) => Promise;
+const vinextFetch = (handler as unknown as { fetch: FetchHandler }).fetch;
+const accessIssuer = 'https://andrepascoa.cloudflareaccess.com';
+const accessJwks = createRemoteJWKSet(new URL(`${accessIssuer}/cdn-cgi/access/certs`));
- // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
- const url = (event.notification.data?.url as string) ?? '/';
+const requestContext = (request: Request) => {
+ const url = new URL(request.url);
+ return {
+ method: request.method,
+ path: url.pathname,
+ query: url.search,
+ rayId: request.headers.get('cf-ray') ?? undefined,
+ };
+};
- const matchingClient = clientList.find((client) => client.focused) ?? clientList[0];
+export default {
+ async fetch(request: Request, env: unknown, ctx: ExecutionContext): Promise {
+ const previewAccessEnabled =
+ 'object' === typeof env && null !== env && 'true' === Reflect.get(env, 'PREVIEW_ACCESS_AUTH');
- if (matchingClient) {
- const client = await matchingClient.focus();
- await client.navigate(url);
- } else {
- await self.clients.openWindow(url);
+ if (previewAccessEnabled) {
+ let identity = await ctx.access?.getIdentity();
+ if (!identity?.email) {
+ const assertion = request.headers.get('cf-access-jwt-assertion');
+ const audience = 'object' === typeof env && null !== env && Reflect.get(env, 'PREVIEW_ACCESS_AUD');
+ if (assertion && 'string' === typeof audience) {
+ try {
+ const verified = await jwtVerify(assertion, accessJwks, {
+ issuer: accessIssuer,
+ audience,
+ });
+ if ('string' === typeof verified.payload.email) {
+ identity = {
+ email: verified.payload.email,
+ name: 'string' === typeof verified.payload.name ? verified.payload.name : undefined,
+ };
+ }
+ } catch (error) {
+ logError('worker', error, { ...requestContext(request), phase: 'access-identity-fallback' });
+ }
+ }
}
- })(),
- );
-});
-serwist.addEventListeners();
+ const email = identity?.email?.trim().toLowerCase();
+ if (!email) {
+ return new Response('Cloudflare Access authentication was not verified.', { status: 403 });
+ }
+
+ // Replace, never trust, identity headers supplied by the incoming client.
+ const headers = new Headers(request.headers);
+ headers.delete(PREVIEW_ACCESS_EMAIL_HEADER);
+ headers.delete(PREVIEW_ACCESS_NAME_HEADER);
+ headers.set(PREVIEW_ACCESS_EMAIL_HEADER, email);
+ if (identity?.name) headers.set(PREVIEW_ACCESS_NAME_HEADER, identity.name);
+ request = new Request(request, { headers });
+ }
+
+ let response: Response;
+ try {
+ response = await vinextFetch(request, env, ctx);
+ } catch (error) {
+ logError('worker', error, { ...requestContext(request), phase: 'uncaught' });
+ throw error;
+ }
+
+ // Vinext turns errors in getServerSideProps / API routes into 5xx responses without
+ // Throwing, so log those too (the handler's own console.error output, if any, is
+ // Attached to the same invocation in Workers Logs).
+ if (500 <= response.status) {
+ logError('ssr', new Error(`HTTP ${response.status} response`), {
+ ...requestContext(request),
+ status: response.status,
+ });
+ }
+
+ return response;
+ },
+} satisfies ExportedHandler;
diff --git a/wrangler.example.jsonc b/wrangler.example.jsonc
new file mode 100644
index 000000000..6f6952bd6
--- /dev/null
+++ b/wrangler.example.jsonc
@@ -0,0 +1,87 @@
+{
+ "$schema": "node_modules/wrangler/config-schema.json",
+ "name": "splitpro",
+ "main": "./worker/index.ts",
+ "compatibility_date": "2026-10-01",
+ "compatibility_flags": ["nodejs_compat", "global_fetch_strictly_public"],
+ "assets": {
+ "not_found_handling": "none",
+ "binding": "ASSETS",
+ },
+ "routes": [{ "pattern": "split.example.com", "custom_domain": true }],
+ "workers_dev": false,
+ "upload_source_maps": true,
+ "observability": {
+ "enabled": true,
+ "head_sampling_rate": 1,
+ "redact_query_string": false,
+ "issues": { "enabled": true },
+ "logs": { "enabled": true, "invocation_logs": true, "persist": true, "head_sampling_rate": 1 },
+ "traces": { "enabled": true, "persist": true, "head_sampling_rate": 1 },
+ },
+ "d1_databases": [
+ {
+ "binding": "DB",
+ "database_name": "splitpro",
+ "database_id": "00000000-0000-0000-0000-000000000000",
+ "migrations_dir": "migrations",
+ },
+ ],
+ "r2_buckets": [{ "binding": "UPLOADS", "bucket_name": "splitpro-uploads" }],
+ "send_email": [{ "name": "EMAIL" }],
+ "workflows": [
+ {
+ "name": "splitpro-scheduler",
+ "binding": "SCHEDULER",
+ "class_name": "SchedulerWorkflow",
+ // Every minute: run due recurring expenses, send recurrence notifications,
+ // and (on CLEAR_CACHE_CRON_RULE) clean up cached bank data and currency rates.
+ "schedules": ["* * * * *"],
+ },
+ ],
+ "vars": {
+ "NEXTAUTH_URL": "https://split.example.com",
+ "DEFAULT_HOMEPAGE": "/balances",
+ "ENABLE_SENDING_INVITES": "true",
+ "DISABLE_EMAIL_SIGNUP": "true",
+ "INVITE_ONLY": "false",
+ "PREVIEW_ACCESS_AUTH": "false",
+ "FROM_EMAIL": "splitpro@example.com",
+ "CLEAR_CACHE_CRON_RULE": "0 2 * * 0",
+ "CACHE_RETENTION_INTERVAL": "2 days",
+ "CURRENCY_RATE_PROVIDER": "frankfurter",
+ "UPLOAD_MAX_FILE_SIZE_MB": "10",
+ },
+ "previews": {
+ "vars": {
+ "NEXTAUTH_URL": "https://replace-with-this-preview-url",
+ "NEXTAUTH_SECRET": "preview-only-secret-change-at-runtime",
+ "NODE_ENV": "production",
+ "DEFAULT_HOMEPAGE": "/balances",
+ "ENABLE_SENDING_INVITES": "false",
+ "DISABLE_EMAIL_SIGNUP": "true",
+ "INVITE_ONLY": "true",
+ "PREVIEW_ACCESS_AUTH": "true",
+ "PREVIEW_ACCESS_AUD": "replace-with-your-access-audience-tag",
+ "CLEAR_CACHE_CRON_RULE": "0 2 * * 0",
+ "CACHE_RETENTION_INTERVAL": "2 days",
+ "CURRENCY_RATE_PROVIDER": "frankfurter",
+ "UPLOAD_MAX_FILE_SIZE_MB": "10",
+ },
+ "d1_databases": [
+ {
+ "binding": "DB",
+ "database_name": "splitpro-preview",
+ "database_id": "00000000-0000-0000-0000-000000000000",
+ "migrations_dir": "migrations",
+ },
+ ],
+ "r2_buckets": [{ "binding": "UPLOADS", "bucket_name": "splitpro-preview-uploads" }],
+ "observability": {
+ "enabled": true,
+ "head_sampling_rate": 1,
+ "logs": { "enabled": true, "invocation_logs": true, "persist": true, "head_sampling_rate": 1 },
+ "traces": { "enabled": true, "persist": true, "head_sampling_rate": 1 },
+ },
+ },
+}