Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 11 additions & 5 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -50,11 +50,17 @@ DISABLE_EMAIL_SIGNUP=false
# Uses each browser's time zone. Disabled by default to preserve existing behavior.
EXCLUDE_FUTURE_EXPENSES=false

# Clean up cron job.
# Expense recurrences use this 24-hour HH:mm time (00:00-23:59) in PostgreSQL's cron.timezone.
# Changing RECURRENCE_TIME and recreating the app updates existing expense jobs at startup.
RECURRENCE_TIME="00:00"
# Used by Docker Compose to configure cron.timezone (IANA zone); recreate PostgreSQL and app after changes.
RECURRENCE_TIME_ZONE="UTC"

# Clean up cron job.
# Deletes old cached bank data, currency rates etc. based on the interval below.
# Note that the timezone for cron jobs is set to UTC timezone.
# You can use https://crontab.guru/ if you are not familiar with cron syntax.
# Be mindful that pg_cron does not support ranges, lists or steps: https://github.com/citusdata/pg_cron
# Cleanup follows PostgreSQL's cron.timezone too (UTC by default).
# You can use https://crontab.guru/ if you are not familiar with cron syntax.
# pg_cron supports ranges, lists and steps: https://github.com/citusdata/pg_cron
# EXAMPLES:
# Daily at 2am: "0 2 * * *"
# Weekly on Sunday at 2am: "0 2 * * 0"
Expand Down Expand Up @@ -113,7 +119,7 @@ KEYCLOAK_ID=
KEYCLOAK_SECRET=
KEYCLOAK_ISSUER=

# OIDC Provider
# OIDC Provider
# The (lowercase) name will be used to generate an id and possibly display an icon if it is added in https://github.com/oss-apps/split-pro/blob/main/src/pages/auth/signin.tsx#L25
# If your provider is not added, simpleicon probably has it and you may submit a PR
OIDC_NAME=
Expand Down
10 changes: 4 additions & 6 deletions docker/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -180,9 +180,7 @@ See [docs/CONFIGURATION.md](../docs/CONFIGURATION.md) for the `connect_timeout`/

## Recurring transactions (pg_cron)

Recurring expenses require PostgreSQL with `pg_cron`. The example compose file already enables it. If you use another database image, you must enable the extension yourself.

See [docs/RECURRING_TRANSACTIONS.md](../docs/RECURRING_TRANSACTIONS.md).
See [Recurring transactions](../docs/RECURRING_TRANSACTIONS.md) for requirements, configuration and deployment instructions.

### External / managed PostgreSQL (non-superuser)

Expand All @@ -193,7 +191,7 @@ SplitPro does not require superuser access at runtime. A regular database role i
```sql
CREATE EXTENSION IF NOT EXISTS pg_cron;
GRANT USAGE ON SCHEMA cron TO <app_user>;
GRANT SELECT, REFERENCES ON cron.job TO <app_user>;
GRANT SELECT, UPDATE, REFERENCES ON cron.job TO <app_user>;
GRANT SELECT ON cron.job_run_details TO <app_user>;
```

Expand All @@ -205,11 +203,11 @@ cron.database_name = '<your_splitpro_db>'
cron.timezone = 'UTC'
```

Restart PostgreSQL after changing these settings.
UTC is the default; choose another IANA zone in `cron.timezone` if needed. Restart PostgreSQL after changing these settings.

The migration that sets up recurring expenses uses `CREATE EXTENSION IF NOT EXISTS pg_cron`, which is a no-op when the extension is already installed — no superuser privileges are needed in that case. If the extension is missing and the app user is not a superuser, PostgreSQL will reject the `CREATE EXTENSION` call; ask your DB administrator to install it beforehand using the commands above.

Jobs created by SplitPro run as the app user and are scoped to that user via `pg_cron`'s row-level security, so the app can only manage its own jobs.
Jobs created by SplitPro run as the app user and are scoped to that user via `pg_cron`'s row-level security, so the app can only manage its own jobs. The `UPDATE` grant on `cron.job` is required for schedule migration and the row locks used by startup reconciliation.

## Receipt storage

Expand Down
2 changes: 1 addition & 1 deletion docker/dev/compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ services:
postgres
-c shared_preload_libraries=pg_cron
-c cron.database_name=${POSTGRES_DB:-splitpro}
-c cron.timezone=UTC
-c cron.timezone=${RECURRENCE_TIME_ZONE:-UTC}
healthcheck:
test: ['CMD-SHELL', 'pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}']
interval: 10s
Expand Down
4 changes: 3 additions & 1 deletion docker/prod/compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ services:
postgres
-c shared_preload_libraries=pg_cron
-c cron.database_name=${POSTGRES_DB:-splitpro}
-c cron.timezone=UTC
-c cron.timezone=${RECURRENCE_TIME_ZONE:-UTC}
# ports:
# - "5432:5432"
env_file: .env
Expand All @@ -35,6 +35,8 @@ services:
environment:
- PORT=${PORT:-3000}
- HOSTNAME=0.0.0.0
# Execution time in PostgreSQL's cron.timezone; applied to expense jobs at startup.
- RECURRENCE_TIME=${RECURRENCE_TIME:-00:00}
env_file: .env
healthcheck:
test: ['CMD', 'wget', '--spider', '-q', 'http://127.0.0.1:${PORT:-3000}/api/healthz']
Expand Down
16 changes: 12 additions & 4 deletions docs/CONFIGURATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ If SplitPro connects to an external/managed PostgreSQL instance, make sure your
```sql
CREATE EXTENSION IF NOT EXISTS pg_cron;
GRANT USAGE ON SCHEMA cron TO <app_user>;
GRANT SELECT, REFERENCES ON cron.job TO <app_user>;
GRANT SELECT, UPDATE, REFERENCES ON cron.job TO <app_user>;
GRANT SELECT ON cron.job_run_details TO <app_user>;
```

Expand All @@ -47,7 +47,9 @@ If SplitPro connects to an external/managed PostgreSQL instance, make sure your
SELECT extname FROM pg_extension WHERE extname = 'pg_cron';
```

SplitPro can then run with a regular (non-superuser) `POSTGRES_USER`.
SplitPro can then run with a regular (non-superuser) `POSTGRES_USER`. The `UPDATE`
permission on `cron.job` is needed for schedule migration and the row locks used by
startup reconciliation. pg_cron's row-level security scopes jobs to their owner.

If your deployment sits behind a NAT/firewall/proxy that can silently drop idle TCP connections (leaving the pool with dead sockets that hang instead of erroring), add `connect_timeout` and `socket_timeout` (seconds) to `DATABASE_URL` so Prisma gives up on a stale connection instead of hanging indefinitely - e.g. `?connect_timeout=5&socket_timeout=5`. `/api/readyz`'s own query timeout only bounds queries that reach Postgres; it can't help if the socket itself is dead. See [docker/README.md#health-checks](../docker/README.md#health-checks) for `/api/healthz`/`/api/readyz` details and example probe configuration.

Expand All @@ -68,12 +70,18 @@ At least one provider must be configured. SplitPro does not support username/pas

## Optional variables

### Recurring expenses (pg_cron)

See [Recurring transactions](RECURRING_TRANSACTIONS.md) for configuration and deployment instructions.

### Cache cleanup (pg_cron)

- `CLEAR_CACHE_CRON_RULE`: Cron rule for cache cleanup jobs (UTC).
- `CLEAR_CACHE_CRON_RULE`: Cron rule for cache cleanup jobs in database `cron.timezone` (UTC by default).
- `CACHE_RETENTION_INTERVAL`: Postgres interval string that defines how long cached data can remain unused.

`pg_cron` does not support cron ranges or lists. See https://github.com/citusdata/pg_cron#what-is-pg_cron for details.
For time-zone behavior, see [Recurring transactions](RECURRING_TRANSACTIONS.md).
Standard cron ranges, lists and steps are supported; see the
[pg_cron documentation](https://github.com/citusdata/pg_cron#cron-syntax).

### SMTP (email provider)

Expand Down
181 changes: 155 additions & 26 deletions docs/RECURRING_TRANSACTIONS.md
Original file line number Diff line number Diff line change
@@ -1,51 +1,180 @@
# RECURRING TRANSACTIONS
# Recurring transactions

SplitPro supports recurring expenses using PostgreSQL cron jobs. This requires the `pg_cron` extension.
SplitPro uses PostgreSQL's `pg_cron` extension to generate recurring expenses.
Users select calendar days; the instance operator configures one execution time
and time zone for all expense recurrences.

## Requirements

- PostgreSQL with `pg_cron` enabled.
- Use the provided Docker image (`ossapps/postgres`) or a compatible Postgres build with the extension installed.
- See [docker/prod/compose.yml](../docker/prod/compose.yml) for a working configuration example.

## How it works
- Use the provided `ossapps/postgres` image or a compatible PostgreSQL installation.
- The [production Compose file](../docker/prod/compose.yml) enables the extension
and configures its time zone. For an external database, see
[PostgreSQL prerequisites](CONFIGURATION.md#external-postgresql-prerequisites-pg_cron).

## Creating and managing recurrences

Choose daily, weekly, monthly or yearly repetition in the recurrence editor.
Select weekdays, days of the month or a month as appropriate. There is no time
picker: all rules use the instance execution time. Recurrence management is
available from the Activity tab.

A recurring expense starts with a template expense. Editing the template changes
future occurrences and leaves existing derived expenses unchanged. Deleting the
template stops the rule and preserves generated expenses. Editing or deleting a
derived expense does not change the template's schedule.

The initial expense already covers its date. A scheduled run on or before that
date creates nothing. Subsequent runs can generate at most one expense per
recurrence per instance-calendar day, including after retries or deletion of a
generated expense. Existing expense timestamps are interpreted in the instance
time zone for this check.

## Execution time and calendar

| Setting | Default | Purpose |
| ---------------------- | ------- | -------------------------------------------------------------------------------------- |
| `RECURRENCE_TIME` | `00:00` | App setting: execution time in 24-hour `HH:mm` format, from `00:00` through `23:59`. |
| `RECURRENCE_TIME_ZONE` | `UTC` | Compose setting: configures PostgreSQL's `cron.timezone`, for example `Europe/Warsaw`. |

`cron.timezone` is the source of truth for weekdays, month boundaries and execution
time. Browser and application-server OS time zones do not change saved calendar
fields. For example, a monthly last-day rule remains a last-day rule in the instance
calendar regardless of who creates or edits it.

All recurrence rules on an instance share these settings. `RECURRENCE_TIME` is a
runtime app setting; no rebuild is required. Invalid time values fail app environment
validation. For external/managed PostgreSQL, set `cron.timezone` on the database
server: setting `RECURRENCE_TIME_ZONE` on the application alone does not configure
an external database. The app reads the actual database setting for previews.

The configured time is when a job is scheduled to run. Actual execution can be
delayed by database load or availability. Missed days during database downtime are
not automatically backfilled.

## What users see in their browser

The editor, recurring-expenses list and expense details show the **next scheduled
run as a full local date and time in the browser's time zone**, with that zone's
name. Underneath, they show the configured schedule and its instance time zone.
The browser preview refreshes every minute.

For example, with `RECURRENCE_TIME=02:35` and `RECURRENCE_TIME_ZONE=UTC`, a monthly
rule for January 1 runs at January 1, 2027, 02:35 UTC. A browser in
`America/Los_Angeles` shows December 31, 2026, 18:35. The saved rule still means
January 1 in UTC.

The preview converts a concrete upcoming occurrence, using the daylight-saving
offsets for that date. A fixed instance time may therefore appear at different
local times in summer and winter. It does not promise the same browser-local day
to users around the world. Changing the browser zone changes the display, not the
saved rule. The preview describes a scheduled job run; the initial-expense and
already-generated-date checks can still make that run a no-op.

## Production Docker Compose configuration

In the directory containing the production `compose.yml`, add these values to
`.env`:

```dotenv
RECURRENCE_TIME="06:35"
RECURRENCE_TIME_ZONE="Europe/Warsaw"
```

- A recurring expense is created from a template expense.
- The schedule uses a cron rule and generates derived expenses.
- Editing the template updates future occurrences only.
- Deleting the template removes the recurrence rule but preserves generated expenses.
- Recurrence management is available from the Activity tab.
The PostgreSQL service receives the zone through `-c cron.timezone=...`; the app
receives `RECURRENCE_TIME` through its environment. Both have defaults, so existing
`.env` files continue to use midnight UTC.

## Editing behavior
After changing only the execution time, recreate the app container without its dependencies:

- Template edits update the schedule and future instances.
- Existing derived expenses are not modified when you edit the template.
- Deleting the template removes the recurrence rule while keeping derived expenses.
```bash
docker compose up -d --no-deps --force-recreate splitpro
```

## Cron limitations
`--no-deps` keeps PostgreSQL running during a time-only update. PostgreSQL also loads
`.env`, so otherwise its changed environment can cause an unnecessary recreation.

`pg_cron` supports a subset of standard cron syntax. Ranges and lists are not supported.
Refer to https://github.com/citusdata/pg_cron#what-is-pg_cron for details.
After changing the time zone, recreate PostgreSQL and the app so the database uses
the new zone and the app reconciles schedules against it:

## Example schedules
```bash
docker compose up -d --force-recreate postgres splitpro
```

- Daily at 02:00 UTC: `0 2 * * *`
- Weekly on Sunday at 02:00 UTC: `0 2 * * 0`
- Monthly on the 1st at 02:00 UTC: `0 2 1 * *`
These commands assume the production Compose file and `.env` are in the current
directory. A plain `docker compose restart` does not apply changed environment
variables or container command arguments. Recreating the containers keeps their
named data volumes; do not remove those volumes when changing recurrence settings.

On every normal Node.js server startup, the instrumentation hook runs data
migrations, then checks existing expense jobs against `RECURRENCE_TIME`. It changes
only schedules that differ, preserving calendar fields, job IDs, commands and
active status. New and edited recurrences use the same setting. Reconciliation is
skipped in `TEST_MODE`; unchanged schedules are left alone on repeated startups.
Deploy the same execution time to every application replica sharing the database.

`cron.timezone` applies to **all pg_cron jobs**, including cache cleanup. Changing
`RECURRENCE_TIME` only updates expense jobs; changing the zone also changes the
wall-clock interpretation of `CLEAR_CACHE_CRON_RULE`. Review maintenance schedules
when changing the zone. Existing historical expense dates are not rewritten.

## Calendar rules and examples

With `RECURRENCE_TIME=06:35`, the resulting database schedules are:

| Selection | pg_cron schedule in the instance time zone |
| ----------------------- | ------------------------------------------ |
| Daily | `35 6 * * *` |
| Every Sunday | `35 6 * * 0` |
| First day of each month | `35 6 1 * *` |
| Last day of each month | `35 6 $ * *` |
| January 1 each year | `35 6 1 1 *` |

The UI uses `L` for last day; PostgreSQL uses `$`. This marker conversion does not
shift dates or months. Last day follows the actual month length, including leap
years. A rule for the 31st skips months without a 31st, and February 29 skips
non-leap years. Legacy custom rules retain cron semantics: restricting both the
day of month and weekday matches either field.

`pg_cron` supports standard five-field cron expressions, including ranges, lists
and steps, plus its `$` last-day marker. The recurrence editor offers calendar
choices rather than arbitrary execution times. See the
[pg_cron documentation](https://github.com/citusdata/pg_cron#cron-syntax) for syntax.
Choose an execution time away from local daylight-saving clock transitions if you
want to avoid skipped or repeated wall-clock times in that zone.

## Upgrading existing recurrences

The date-only migration removes saved hour/minute choices while retaining stored
calendar fields. Startup reconciliation then applies `RECURRENCE_TIME`. It also
records existing occurrence dates to prevent duplicate generation during the
upgrade. Existing expenses and participants are preserved.

The old browser-offset conversion did not store the original zone or calendar
intent. Rules already shifted incorrectly cannot be repaired automatically. After
upgrading, review existing rules, especially the first/last day of a month and
rules near year boundaries. Correct those schedules in the editor; historical
expenses are not automatically moved or deleted.

## Checking pg_cron

You can verify the extension inside Postgres:
Run these queries in the SplitPro database:

```sql
SELECT * FROM pg_extension WHERE extname = 'pg_cron';
```
SHOW cron.timezone;

## Configuration
SELECT j.jobid, j.schedule, j.active
FROM cron.job j
JOIN "ExpenseRecurrence" r ON r."jobId" = j.jobid;
```

Recurring transactions rely on `pg_cron` and also use cache cleanup jobs configured via `CLEAR_CACHE_CRON_RULE` and `CACHE_RETENTION_INTERVAL`. See [CONFIGURATION](CONFIGURATION.md).
The startup log reports how many recurrence schedules were updated and the target
execution time. For other environment settings, see [Configuration](CONFIGURATION.md).

## UI walkthrough videos

These videos demonstrate the general workflow; older recordings may show the removed time picker.

- Creating a recurrence: https://github.com/user-attachments/assets/39799e04-030d-42da-8feb-7805f17ecb5e
- Managing recurrences: https://github.com/user-attachments/assets/da291cdf-7db6-4fb5-8a33-806216dfca21
2 changes: 2 additions & 0 deletions jest.integration.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import { loadTestEnvironment } from './src/tests/helpers/environment.ts';
loadTestEnvironment();
process.env.DATABASE_URL = getTestDatabaseUrl();
process.env.SKIP_ENV_VALIDATION = '1';
// Keep schedules deterministic because skipped env validation also skips Zod defaults.
process.env.RECURRENCE_TIME = '00:00';

const integrationJestConfig = () =>
configureJest({
Expand Down
Loading
Loading