Jobs & Providers
Everything that runs without a user: scheduled crons, external service drivers, and the token-authenticated HTTP feed endpoints.
Crons (convex/crons.ts)
Visit status sweep — every 15 minutes
crons.sweep (internalMutation) drives the time-based edges of the visit state machine using each org's configurable windows:
planned → due_inonce inside the org's early-check-in window (orgs.checkInEarlyMinutes).in_house → overduepast the late-check-out grace (orgs.checkOutLateMinutes).
All transitions still funnel through the same transition() helper the interactive mutations use, so the state machine has exactly one enforcement point.
Reminder dispatch — every 30 minutes
crons.dispatchReminders (internalMutation) walks each org's active reminderConfigs:
- For
upcoming_visitandvaccine_expirykinds, find matching clients withinoffsetHours. - Fill the config's
template. - Dedupe against
reminderLogs.by_dedupeso a reminder never sends twice. - Drop an in-app message on the client's thread (always).
- If the channel is email, schedule
providers.email.send.
Note: the
agreement_missingkind exists in the schema and settings UI but is not yet handled by the dispatch loop.
Payment providers
All money is integer cents; payments is an append-only charge/refund ledger with idempotency keys. The org's paymentProvider field selects the driver.
| Provider | Driver | Status |
|---|---|---|
dev | paymentMethods.charge, portal.payOrder | instant simulated settle; local/dev default |
manual | orders.recordPayment | cash/desk payments |
stripe | convex/providers/stripe.ts | production driver |
fluidpay | — | reserved in enums, no driver yet |
Stripe driver (providers/stripe.ts)
- Vaulting:
createSetupIntent(action) → Stripe Elements confirms client-side →finalizeSetuppersists brand/last4/exp +providerRefviasaveMethod. Only display data and provider refs are stored — no PANs. - Charging:
chargeSaved(staff; orders.manage) andportalChargeSaved(customer; ownership-checked) create off-session PaymentIntents, idempotent perorder_{id}_{cumulativePaid}, recorded throughrecordCharge. - Talks to
https://api.stripe.com/v1directly; requiresSTRIPE_SECRET_KEYor throwsPROVIDER_NOT_CONFIGURED. - Client selection logic in the web app: saved cards branch on
method.provider === "stripe"→ provider action, else the dev mutation.
Email driver (providers/email.ts)
send (internalAction) — Resend. POSTs https://api.resend.com/emails with a per-tenant from address (org display name + domain from EMAIL_FROM, default reminders@pawdentity.com). Without RESEND_API_KEY it logs and no-ops — the in-app thread message remains the delivery of record.
HTTP endpoints (convex/http.ts)
Calendar feeds for staff, authenticated by the per-user feedToken on the membership (?token=), no session required:
| Route | Returns |
|---|---|
GET /feeds/events.ics | ICS calendar of visits/events |
GET /feeds/events.json | same data as JSON |
GET /feeds/consumption.json | per-room daily occupancy |
Tokens are minted and rotated in Settings → General (settings.feedInfo / regenerateFeedToken); feedData (internalQuery) resolves the membership and builds the payload.
Environment variables
| Variable | Consumer | Behavior when unset |
|---|---|---|
STRIPE_SECRET_KEY | stripe driver | PROVIDER_NOT_CONFIGURED on Stripe paths; dev/manual still work |
RESEND_API_KEY | email driver | logs and no-ops; in-app delivery only |
EMAIL_FROM | email driver | defaults to reminders@pawdentity.com |
The platform is fully functional with none of them set — that's the local/dev posture.