---
title: Scheduled reports
description: The report delivery service - report types, cadences, formats, subscriptions, and send-now.
---

# Scheduled reports

The report service replaces Looker's ~140 scheduled plans. It runs inside the data-plane Worker: a cron wakes every 5 minutes, finds due subscriptions, renders each report from the same data the dashboards use, and emails it via Resend as `reports@joe.coffee`.

Design properties worth knowing:

- **Idempotent.** Each send is claimed against a period key (the business date or month), so a retry after a failure cannot double-send.
- **No-data suppression.** A report with nothing to say (no payouts on a Sunday, no orders yesterday) sends nothing rather than an empty email.
- **Branded and linked.** Every email uses the shared Joe layout; merchant reports carry a "View live dashboard" button backed by a 90-day, shop-locked `/e/` link. Clicking it mints a fresh short-lived Metabase token, so the button no longer inherits the old seven-day embed-token window. Internal reports still link directly into Metabase.
- **Formats:** inline HTML, CSV, XLSX, rendered dashboard PDF (via the joe-render service), and webhooks. Every format Looker used is supported.

## Report types

| Type | Cadence | Contents |
| --- | --- | --- |
| `weekly_sales` | Fridays | Branded email: net sales, orders, average ticket, tips, week-over-week, top items |
| `weekly_sales_csv` | Fridays | Same week as CSV + XLSX attachments |
| `shop_sales_pdf` | Fridays | The shop's live dashboard rendered to a branded PDF |
| `daily_snapshot` | Daily | Yesterday vs same weekday prior week, top items |
| `daily_orders_csv` | Daily at each shop's close | Order-level CSV of today (live path) |
| `monthly_orders_csv` | 1st of month | Last complete month, order counts by store/day/source |
| `payout_arrivals` | Daily (banking days) | Payouts arriving tomorrow |
| `ladro_iif_daily_sales_csv` | Daily | Caffe Ladro QuickBooks IIF daily sales journal |
| `ladro_iif_transfers_csv` | Daily | Caffe Ladro QuickBooks bank-transfer entries (byte-parity with Looker verified) |

New types are added as small code packages (see [Requesting a new report](./requesting-reports.md)).

## Cadence model

Three scheduling shapes cover the whole Looker inventory:

1. **Fixed-hour** (default 13:00 UTC) - weekly and daily reports without a specific hour.
2. **Per-shop send hour** (`send_hour_utc` on the subscription) - the 5-minute cron gates on the current UTC hour; used for "at store close" reports.
3. **Monthly** (`monthly_1st`) - fires on the 1st, keyed by month.

## Managing subscriptions

Admin page: **https://data-plane.joe.coffee/v1/reports/admin** (admin token required).

A subscription = shop + report type + cadence + recipients (+ optional send hour). Create, deactivate, and inspect from the admin page, or via the API:

```
GET  /v1/reports/subscriptions            list
POST /v1/reports/subscriptions            create {shopId, reportType, recipients, sendHourUtc?}
POST /v1/reports/subscriptions/:id/deactivate
```

Send-log entries record every delivery (or suppression) per subscription per period, which is what the dual-send soak reads to compare against Looker.

## Send-now (test a report immediately)

```
POST /v1/reports/send-now
{"shopId": "...", "reportType": "weekly_sales", "recipients": ["you@joe.coffee"]}
```

Renders and sends immediately with current data; repeatable (send-now runs are not idempotency-logged). This is the fastest way to check a report against Looker's version of the same day.

Two operational notes: fire send-nows one at a time (a rapid burst can contend on the Worker's DB pool), and the first PDF render after idle can take ~20 extra seconds while the render machine wakes.

## Failure handling

Failed sends surface in the send log with the error; the claim design means fixing the cause and re-running never double-delivers. If Resend is misconfigured (missing key), the service refuses to claim periods at all, so nothing is silently marked sent.
