Recurring transactions are the backbone of personal finance. Rent. Subscriptions. Paychecks. But scheduling in a local-first system has no server, no cron, no daemon. So we made time just another input to a deterministic function.
The Problem with Server-Based Schedules
Traditional apps run cron jobs on servers. "Every Monday at 9am, create this transaction." Simple, until:
- The server is down on Monday morning
- The user opens the app twiceβdoes it run twice?
- Two devices syncβdid both create the same transaction?
- The user undoes itβdoes the schedule run again?
We have no server. The schedule must work entirely in the browser, on-demand, and produce the same result no matter how many times you check.
The Schedule Model
Every rule can have an optional schedule:
{
"type": "monthly", // daily | weekly | monthly | once
"dayOfMonth": 1, // For monthly: which day (1-31)
"daysOfWeek": ["monday"], // For weekly: which days
"intervalDays": 1, // For daily: every N days
"startAt": 1704067200000, // When schedule starts
"endAt": null, // When schedule ends (null = never)
"timezone": "America/New_York"
}The timezone is explicit. No guessing. No "server time" vs "user time" bugs.
Period Keys: The Core Innovation
Every scheduled execution has a period keyβa string that uniquely identifies "when" the schedule ran:
Daily
YYYY-MM-DD
Example: 2024-01-15
Weekly
YYYY-WNN (ISO week)
Example: 2024-W03
Monthly
YYYY-MM
Example: 2024-01
Once
once
Always the literal string "once"
Why Period Keys Matter
The period key is stored with a compound primary key: [ruleId+periodKey]. This guarantees:
// Execution record ScheduledRuleExecution { ruleId: "rule_abc123", periodKey: "2024-01", // January 2024 executedAt: 1704153600000, operationId: "op_xyz789" } // Uniqueness constraint: [ruleId+periodKey] // β Can only have ONE execution per rule per period // β Open app twice? Second check finds existing record // β Sync from another device? Same period key, same record
How Schedule Evaluation Works
When the app opens (or on manual trigger), we evaluate all scheduled rules:
isScheduleDue(rule, currentTime) βββ Is rule enabled? (isEnabled AND isScheduleEnabled) βββ Is currentTime >= schedule.startAt? βββ Is currentTime <= schedule.endAt? (if set) βββ Is today a scheduled day? (type-specific) β βββ daily: Check interval from startAt β βββ weekly: Is today in daysOfWeek? β βββ monthly: Is today dayOfMonth? (or last day if shorter month) β βββ once: Is today the same day as startAt? βββ Has it already run for this period? βββ Check ScheduledRuleExecution[ruleId+periodKey] Returns: { isDue: boolean, periodKey: string, reason: string }
The reason is always returned. "Rule disabled." "Already executed for 2024-01." "Not a scheduled day." Full explainability.
Recurring Transactions as Rule Actions
A scheduled rule can have a createTransaction action:
{
"type": "createTransaction",
"params": {
"accountId": "acc_checking",
"amount": -150000, // -$1,500.00 (cents)
"payee": "Landlord",
"categoryId": "cat_rent",
"memo": "Monthly rent"
}
}When the schedule runs, the transaction is created with the date set to the period startβnot the current time. A monthly rent due on the 1st creates a transaction dated the 1st, even if you open the app on the 5th.
The Operation Payload
Every scheduled execution emits a rule.scheduled.run operation:
{
"opType": "rule.scheduled.run",
"payload": {
"ruleId": "rule_abc123",
"ruleName": "Monthly Rent",
"periodKey": "2024-01",
"scheduleType": "monthly",
"scheduledFor": 1704067200000, // Period start
"actualRunAt": 1704498000000, // When it actually ran
"createdTransactionIds": ["tx_rent_jan"],
"changesApplied": [] // If rule also modified transactions
}
}This payload has everything needed for undo: which transactions were created, which were modified, and the exact period.
How Replay Avoids Re-Triggering
This is critical. When you replay operations (during sync, or when reconstructing state), you don't want schedules to run again:
// During replay: // 1. Replaying rule.scheduled.run operation // 2. Creates ScheduledRuleExecution record // 3. Record has [ruleId+periodKey] compound key // When checking if schedule is due: const existingExecution = await db.scheduledRuleExecutions .where('[ruleId+periodKey]') .equals([ruleId, periodKey]) .first(); if (existingExecution) { return { isDue: false, reason: 'Already executed for period' }; } // Result: Schedule does NOT run again // The execution record from replay prevents double-execution
Undoing Scheduled Runs
Undo for scheduled executions follows the compensating operations pattern:
- Delete created transactions β Soft-delete any transactions the rule created
- Revert modifications β Restore previous values of any changed fields
- Remove execution record β Delete the
ScheduledRuleExecutionentry - Create undo operation β
rule.scheduled.revertfor the audit trail
Removing the execution record means the schedule can run again for that period if you want. This is intentionalβyou might undo, fix something, and want it to re-run.
What We Built
scheduled-rule-engine.ts
Schedule evaluation, period key generation, due checking, execution orchestration.
recurring-transaction.ts
Processes createTransaction actions, sets correct dates.
undo-service.ts
Extended with undoScheduledRuleRun() method.
ScheduledRuleExecution
New Dexie table with [ruleId+periodKey] compound key.
The Determinism Guarantee
Given the same rule and the same current time, the schedule evaluation always produces the same result:
- Same period key calculation
- Same due check logic
- Same transaction creation
- Same operation emission
No randomness. No server state. No race conditions. Time is just another input to a pure function.
Scheduled rules used to require servers and cron jobs. Now they require a timestamp and a database query. The period key ensures exactly-once semantics. The operation log makes it all undoable.
Your recurring transactions. Your schedule. No server required.
β The Accelerate Finance Team