# /zc-cron — Zync Cron Knowledge Base

Complete reference for all scheduled jobs in zync.is. Read before touching any cron code.

---

## Architecture

Two dispatch paths feed the same handlers:

```
CF Workers scheduled() ──→ runCronGroup(event.cron, env, appFetch)
                                        │
external cron-job.org ──→ POST /api/cron/dispatch-group ──→ runCronGroup(cronExpr, env)
                              (no appFetch = real HTTP self-fetch)
```

`runCronGroup` lives in `apps/zync-api/src/cron/runner.ts`.
- With `appFetch` (from `scheduled()`): dispatches via Hono in-memory (`http://localhost` host irrelevant)
- Without `appFetch` (from dispatch-group endpoint): dispatches via real HTTP to `WORKER_SELF_URL`

**Why two paths?** CF Workers free plan allows only 5 cron triggers total (shared across all workers on account). cron-job.org is the external fallback.

---

## cron-job.org Jobs (LIVE)

API key: `HooYh3OwVavQEAqXULYaxvC47PRVfRgHylGHEdRew3c=` (stored in `secrets/cronjobs.api`)
Endpoint: `POST https://zync-api.dry-salad-ffa1.workers.dev/api/cron/dispatch-group`
Auth: header `x-cron-secret: <CRON_SECRET>`

| jobId | Title | Schedule (UTC) | Group |
|-------|-------|----------------|-------|
| 7782993 | zync-cron-5min | `*/5 * * * *` | `5min` |
| 7782994 | zync-cron-hourly | `0 * * * *` | `hourly` |
| 7782997 | zync-cron-daily-0130 | `30 1 * * *` | `daily-0130` |
| 7782995 | zync-cron-daily-0500 | `0 5 * * *` | `daily-0500` |
| 7782996 | zync-cron-daily-0900 | `0 9 * * *` | `daily-0900` |

To modify: `PUT https://api.cron-job.org/jobs/{jobId}` with same auth header.
To list: `GET https://api.cron-job.org/jobs` — Authorization: Bearer <api-key>

---

## Environment Variables

| Var | Purpose |
|-----|---------|
| `CRON_SECRET` | Timing-safe auth on all cron endpoints (min 32 chars). Value in `Docs/worker-secrets.json` |
| `INTEGRATION_ENCRYPTION_KEY` | Decrypt OAuth tokens for calendar-sync and tasks-sync |

---

## Dispatch Group → Cron Expression Map

Defined in `apps/zync-api/src/routes/cron/dispatch-group.ts`:

```
5min       → */5 * * * *
hourly     → 0 * * * *
daily-0130 → 30 1 * * *
daily-0500 → 0 5 * * *
daily-0900 → 0 9 * * *
```

---

## CRON_ROUTE_MAP (runner.ts)

All HTTP-dispatched routes per cron expression. Routes registered in `apps/zync-api/src/routes/index.ts` and `apps/zync-api/src/index.ts`.

### `*/5 * * * *` — Every 5 minutes
| Route | Purpose |
|-------|---------|
| `/api/cron/ticket-sla-check` | Scan open tickets for SLA violations; fire alerts |

**Plus direct in-process calls (no HTTP):**
- `runSessionExpiredSweep(env)` — DELETE from `user_sessions` where `expires_at < now()`
- `runScheduledReports(env)` — Fire any report schedules due in this window

### `0 * * * *` — Every hour
| Route | Purpose |
|-------|---------|
| `/api/cron/invoice-adapter-reconcile` | Sync invoice status with Morning/payment adapters |
| `/api/cron/tasks-sync` | Push/pull tasks with Slack, Asana, Monday.com integrations |

### `30 1 * * *` — Daily 01:30 UTC (retention + cleanup)
| Route | Purpose |
|-------|---------|
| `/api/cron/webhook-log-retention` | Delete webhook_logs older than 30 days |
| `/api/cron/audit-log-retention` | Purge audit_log: business=90d, enterprise=365d |
| `/api/cron/data-retention-purge` | Hard-delete archived/soft-deleted records past retention window |
| `/api/cron/audit-partition-create` | Pre-create next month's audit_log partition (prevents insert failures) |

**Plus direct:**
- `runSessionIdleCleanup(env)` — Revoke sessions idle beyond tenant-configured timeout

### `0 5 * * *` — Daily 05:00 UTC (billing + contracts)
| Route | Purpose |
|-------|---------|
| `/api/cron/subscription-trial-check` | Grace period check; downgrade to freelancer plan after 7d |
| `/api/cron/recurring-invoice-generator` | Materialize invoices from recurring templates |
| `/api/cron/proposal-expiry` | Mark proposals past expiry_date as expired |
| `/api/cron/proposal-expiry-reminder` | Notify 3 days before proposal expires |
| `/api/cron/calendar-sync` | Fetch Google/Outlook changes, upsert calendar_events |
| `/api/cron/ticket-close-stale` | Auto-close resolved tickets idle 7+ days |
| `/api/cron/contract-expiry-reminder` | Notify tenant/counterparty before contract expires |
| `/api/cron/contract-signing-reminders` | Remind pending signatories on open signature requests |
| `/api/cron/withholding-expiry-check` | Alert on contractor withholding tax cert expiry |

### `0 9 * * *` — Daily 09:00 UTC (invoicing + leads)
| Route | Purpose |
|-------|---------|
| `/api/cron/invoice-reminders` | Send overdue payment reminder emails |
| `/api/cron/lead-reengagement` | Email inactive leads based on reengagement rules |
| `/api/cron/lead-score-refresh` | Enqueue `lead.score_recalc` jobs (async queue, not blocking) |
| `/api/cron/recurring-task-generator` | Materialize tasks from recurring task templates |
| `/api/cron/generate-recurring-expenses` | Materialize expenses from recurring expense templates |

---

## Key Files

```
apps/zync-api/src/
  cron/
    runner.ts                     ← CRON_ROUTE_MAP + dispatchCronRoutes() + runCronGroup()
    lead-score-refresh.ts         ← runLeadScoreRefresh() (direct handler)
    scheduled-reports.ts          ← runScheduledReports() (direct handler)
  routes/cron/
    dispatch-group.ts             ← POST /api/cron/dispatch-group (external trigger)
    ticket-sla-check.ts
    invoice-adapter-reconcile.ts
    tasks-sync.ts
    webhook-log-retention.ts
    audit-log-retention.ts
    data-retention-purge.ts
    audit-partition-create.ts
    session-idle-cleanup.ts
    subscription-trial-check.ts
    recurring-invoice-generator.ts
    proposal-expiry.ts
    proposal-expiry-reminder.ts
    calendar-sync.ts
    ticket-close-stale.ts
    contract-expiry-reminder.ts
    contract-signing-reminders.ts
    withholding-expiry-check.ts
    invoice-reminders.ts
    lead-reengagement.ts
    recurring-task-generator.ts
    recurring-expense-generator.ts
  index.ts                        ← scheduled() handler + some app.route() registrations
  routes/index.ts                 ← remaining cron route registrations + dispatch-group
```

---

## Route Registration Rules (CRITICAL)

`routes/index.ts` ends with `portalFilesRoute` catch-all:
```ts
routes.route('', portalFilesRoute)  // WARNING: MUST be last
```

Any cron route registered AFTER this gets intercepted → 403 "Forbidden: invalid Origin".

**Rule: all cron routes register BEFORE `portalFilesRoute`.**

Some cron routes are registered in `index.ts` (top-level `app.route`) instead of `routes/index.ts` — both work as long as they're on `app` before requests arrive.

---

## Adding a New Cron Route

1. Create `apps/zync-api/src/routes/cron/<name>.ts` — export a `Hono<AppEnv>()` instance
2. Register in `routes/index.ts` **before** `portalFilesRoute` line
3. Add path to `CRON_ROUTE_MAP` in `cron/runner.ts` under the appropriate cron expression
4. If new schedule needed: add to GROUP_MAP in `dispatch-group.ts` + create new cron-job.org job
5. Authenticate inbound via `x-cron-secret` header using `timingSafeEqual` from `@zync/auth`

---

## Security Model

All cron HTTP endpoints check `x-cron-secret` header with timing-safe comparison before executing.
Missing/wrong secret → 401. Secret too short (<32 chars) → 500 (server misconfigured).
`dispatch-group` endpoint fires handler via `waitUntil` — returns 200 immediately, work runs async.

---

## Cloudflare Workers Triggers (DISABLED)

`wrangler.toml` cron triggers are commented out — free plan limit hit (account shares quota across workers).
External cron-job.org jobs are the active trigger mechanism.
Do NOT uncomment CF cron triggers without upgrading the Cloudflare plan.

---

## Learned Rules

### cf-workers-no-localhost-self-fetch | fired:1 | 2026-06-10
Using bare `fetch('http://localhost/api/cron/...')` in `scheduled()` handler → wrong; `http://localhost` does not resolve to self in CF Workers production — requests silently fail.
Prevent: pass `appFetch = (req) => Promise.resolve(app.fetch(req, env))` from `scheduled()` into `runCronGroup`; use real `WORKER_SELF_URL` only when no `appFetch` provided (external HTTP callers). Architecture already documented in runner.ts.

### hono-app-fetch-promise-resolve | fired:1 | 2026-06-10
`(req) => app.fetch(req, env)` as `appFetch` callback → TypeScript error; `app.fetch()` returns `Response | Promise<Response>`, not `Promise<Response>`.
Prevent: always wrap: `(req) => Promise.resolve(app.fetch(req, env))` when passing Hono fetch as a Promise-returning callback.

### cron-route-hono-appenv-type | fired:1 | 2026-06-10
`new Hono<{ Bindings: Env }>()` in a cron route → wrong; causes type mismatch with `routes/index.ts` which uses `AppEnv`.
Prevent: all cron routes use `new Hono<AppEnv>()` (import from `../../types`). Check existing cron routes as reference before creating a new one.
