Approvable-action catalog
Approvable-action catalog
Each approvable action is a typed case of ApprovalActionType (a stable dotted
<resource>.<verb> slug) with a registered factory that builds the held action. The registry is
deny-by-default: an action type with no registered factory cannot be held or executed, so a
config entry that names an unknown action fails closed rather than letting a mutation through
unapproved.
Actions wired through the gate
| Type slug | What it holds | Amount used for the threshold | Underlying service |
|---|---|---|---|
invoice.refund |
Reverse an invoice as a credit note (money out) | Refund net (partial) or invoice gross (full) | RunsInvoiceOperations::refund() |
wallet.adjust |
Grant or debit organization wallet credit | The credit amount | AdjustsWallet |
customer.suspend |
Suspend a customer organization (access held, billing untouched) | none — always holds when enabled | app org model + engine AccountStanding |
plan.archive |
Archive a plan (close it to new signups) | none — always holds when enabled | PlanAuthoring::archive() |
An action with no money dimension (customer.suspend, plan.archive) ignores the numeric
threshold: enabling it means every invocation is held for a second person.
Credit-note issuance is not a separate approvable action — a credit note is only ever created as
the side effect of a refund reversal, so it is governed by invoice.refund.
What a held action captures
Every held action serializes a JSON-safe payload — only the ids/scalars needed to reconstruct
and run it later, never resolved models or secrets — plus a context (the org, the amount, the
currency, the target resource) captured on the ApprovalRequest row so the queue renders without
rebuilding the action. For example a held refund captures {invoice_id, net_minor, reason, idempotency_key}; a held wallet adjustment captures {organization_id, direction, pool, denomination, amount, reason, actor, expires_in_days} — where actor is the maker, so the
wallet row records who originated the adjustment even though a different operator approved it.
Registering a new approvable action
- Add a case to
App\Billing\Approvals\Enums\ApprovalActionType. - Implement
App\Billing\Approvals\Contracts\ApprovableAction—type(),context(),payload(),validate(),describe(),execute().execute()must call the same domain service the direct path uses and record its own audit event, so an approved run is identical to a direct one. - Implement
App\Billing\Approvals\Contracts\BuildsApprovableAction— a factory that rebuilds the action from its payload (re-loading target ids from the database). - Register the factory in
App\Providers\ApprovalServiceProvider. - Add a
config('billing.approvals.actions.<slug>')entry (see Policy & thresholds). - Route the controller through the gate: build the action via the registry and call
ApprovalGate::run().