Auth guard: the Kronos-fired routes (
execute, status_check) require the
callback-service actor (actor.require_callback_service()). The two dashboard
read routes (executions list + get) use require_self_or_trusted_backend —
the user themselves or a trusted backend / admin.Overview
Mandate execution is the runtime side of the mandate lifecycle — where a registered mandate (from the Mandate Module) is actually charged. The mandate must be in theLIVE state. Two
Kronos-fired routes drive it:
POST /mandates/{mandate_id}/execute— Kronos fires this once per autopay cycle. Each firing claims amandate_executionsrow (idempotent on theX-Mandate-Execution-Idheader), creates a linked funding order, calls Juspay’s/txnsto debit, and stamps the txn status.POST /mandates/{mandate_id}/execution/{execution_id}/status_check— Kronos fires this +27h after a non-terminal debit to reconcile the txn status, then reschedules every 15min up tostatus_check.max_attempts(default 6). If still non-terminal when the Kronos window (ends_at) expires, the row sits in its last non-terminal status for operator inspection — no forced terminal state.
X-Mandate-Execution-Id header (templated by Kronos via
{{execution.execution_id}}), and the attempt cadence is implicit in Kronos’s
cron schedule — no DB column tracks attempts.
Two dashboard read routes (bearer auth, not Kronos) surface those rows:
GET …/executions lists a mandate’s attempts (each joined to its order’s
Juspay external_order_status), and GET …/executions/{execution_id} returns
one by id (carrying the linked order_id). Both fail closed — the mandate must
belong to user_id, and the execution to that mandate — so a guessed id
returns 404, not another user’s row. A cross-user admin list also exists at
GET /admin/mandate_executions.
Endpoints
POST /mandates/{mandate_id}/execute
Run a single autopay debit. Returns 201 on first claim, 200 on idempotent replay. Callback-service auth.
POST /mandates/{mandate_id}/execution/{execution_id}/status_check
Reconcile a single execution row against Juspay. Callback-service auth.
GET /users/{user_id}/mandates/{mandate_id}/executions
Dashboard list of a mandate’s execution attempts, each joined to its order’s Juspay
external_order_status. Bearer auth.GET /users/{user_id}/mandates/{mandate_id}/executions/{execution_id}
Single execution by id, carrying the linked
order_id. Fails closed on cross-ownership. Bearer auth.GET /admin/mandate_executions
Cross-user, paginated list for the admin dashboard. Admin / readonly-admin auth.
Lifecycle
If Juspay reports a still-non-terminal status on a reconciliation firing, the next Kronos firing (15min later) re-checks, up tostatus_check.max_attempts firings bounded by the cron window.
Two-layer idempotency
Kronos retries on transient failures, so duplicate firings are expected. Two layers prevent double-debits:- DB claim —
INSERT ... ON CONFLICT (job_execution_id) DO NOTHING RETURNING *. Exactly one concurrent caller wins; the loser reads the winner’s row and returns it. The conflict key isjob_execution_id(the value from theX-Mandate-Execution-Idheader). - Juspay-side dedup — Juspay deduplicates on
order_id. We passorder_id =the linked orders-ledger row id (orders.id), stable across retries, so a firing-retry that somehow slips past the DB claim still doesn’t double-charge. A resumedInitiatedfiring reuses its existing linked order (no duplicate order).
Success | Failed), the handler returns the row as-is without
hitting Juspay or rescheduling.
Status vocabulary
The execution row’sstatus is the internal firing lifecycle
(common_enums::MandateExecutionStatus) — repo-owned vocabulary, not
the Juspay status. The Juspay-side status lives on the linked funding order
(orders.external_status, reached via mandate_executions.order_id), so the
execution row never stores provider strings.
The mapping from the provider’s
ExternalOrderStatus to this enum is
From<&ExternalOrderStatus>: Charged → Success; Failed and the various
failure states (AuthenticationFailed, AuthorizationFailed, JuspayDeclined,
AutoRefunded) → Failed; and any non-terminal Juspay state (PendingVbv,
Authorized, Authorizing, Other(_), …) → Pending. The wire value is
SCREAMING_SNAKE_CASE (INITIATED / PENDING / SUCCESS / FAILED).
Debit amount
The amount Aarokya charges per firing is the user’s share of the daily premium on their singleIssued insurance policy:
sponsor_contribution is resolved from Superposition (insurance.sponsor_contribution,
keyed by plan_code / benefit_id / purchase date; default 0). The
subtraction is exact MinorUnit (paise) math, floored at zero. If the computed
user share is zero (sponsor covers the full premium), execute_mandate fails
with MXE_1608 (ZeroDebitAmount).
If the user has zero or multiple Issued policies, execute_mandate
fails with MXE_1609 (NoIssuedInsurancePolicyForUser) or MXE_1610
(AmbiguousIssuedInsurancePolicies) respectively — there’s no policy linkage
on the mandate row today.
Error responses
Configuration
The[mandate_execution] TOML block has been retired — every runtime knob is
served by Superposition (static fallback in
backend/config/fallback.superposition.toml), so values change from the admin
UI without a redeploy.
The in-repo
fallback.superposition.toml carries smaller placeholder values
for the delay/interval keys; the production defaults are the Superposition
code defaults listed above. Kronos tenant identity (kronos.org_id,
kronos.workspace_id) is also Superposition-served; the Kronos base_url
and secret api_key stay in TOML.