Skip to content

Approvals

Location: dg_smart_pos_api/modules/Approvals. Owns the maker-checker (dual sign-off) framework: any sensitive action can be routed through a pending request that needs 0, 1, or 2 independent approvals before it actually executes. The module’s own Http/Controllers, Http/Requests, Http/Resources, Providers, and Routes are real, but the framework’s actual Eloquent models and services live under the core app/Approvals/ namespace, not under modules/Approvals/.

All in app/Approvals/Models/ (App\Approvals\Models\ namespace):

  • ApprovalRequest: one row per maker-checker instance: action_key, store_id, requested_by, payload (array cast, the parameters the eventual action needs), required_approvals (0/1/2), current_step, status (pending/approved/rejected/cancelled), execution_status/execution_error/result (outcome of actually running the action once approved), and a polymorphic subject() relation. Immutable once it leaves pending, resubmission always creates a new row.
  • ApprovalDecision: one row per individual approve/reject, step, decided_by, decision, comment, decided_at. belongsTo ApprovalRequest.
  • ApprovalPolicy: action_key, store_id (nullable = tenant-wide default), required_approvals, is_enabled, condition (array cast). A store row overrides the tenant default for that one action.

Five actions are currently registered in config/approvals.php, each mapped to a handler implementing App\Approvals\Contracts\ApprovableAction: sales.void (VoidSaleAction), sales.discount (CreateSaleWithDiscountAction), inventory.adjust (AdjustStockAction), inventory.price-override (PriceOverrideAction), debt.slip (CreateDebtSlipAction). All five ship with default_required_approvals => 0 (off by default; a store or tenant policy turns dual sign-off on).

ApprovalRequestController::index supports ?scope=inbox (default: pending requests the current user can act on right now, i.e. current_step matches a approvals:approve-{step} permission they hold and they aren’t the maker) and ?scope=mine (the caller’s own submissions, any status). pendingCount returns just the inbox count, meant for a sidebar badge.

approve/reject both route through the private decide() helper into App\Approvals\Services\ApprovalDecisionService::decide(). That service is written to be concurrency-safe: it locks the approval_requests row (lockForUpdate) so two simultaneous decisions on the same request can’t both execute the action, re-validates the approver actually holds approvals:approve-{current_step}, refuses self-approval and refuses a second decision by the same approver on the same step. A reject is terminal immediately. An approval on a non-final step just advances current_step; only the approval that satisfies the last required step actually runs the handler’s execute($payload), inside the same locked transaction, since ApprovableAction::execute() is contractually DB-local. A handler exception doesn’t roll back the approval decision itself: execution_status is set to failed with execution_error recorded, so an “approved but failed to execute” state is diagnosable rather than silently lost.

cancel is allowed for the original maker or anyone with approvals:manage-policies, only while still pending.

ApprovalPolicyController::index returns every configured action with its tenant default, this store’s override (if any), and the effective resolved value, in one call, so a settings page can render one row per action without N lookups. update sets either a store-specific override (store_id present) or the tenant-wide default (omitted). destroy removes a store’s override, reverting it to the tenant default.

All under auth:sanctum, prefix approvals/stores/{store}, registered via modules/Approvals/Routes/approvals.php (required directly from routes/tenant.php):

Method Path Controller method Extra middleware
GET requests/pending-count ApprovalRequestController::pendingCount permission:approvals:view
GET requests ApprovalRequestController::index permission:approvals:view
GET requests/{approvalRequest} ApprovalRequestController::show permission:approvals:view
POST requests/{approvalRequest}/approve ApprovalRequestController::approve permission:approvals:view
POST requests/{approvalRequest}/reject ApprovalRequestController::reject permission:approvals:view
POST requests/{approvalRequest}/cancel ApprovalRequestController::cancel permission:approvals:view
GET policies ApprovalPolicyController::index permission:approvals:manage-policies
PUT policies ApprovalPolicyController::update permission:approvals:manage-policies
DELETE policies ApprovalPolicyController::destroy permission:approvals:manage-policies

Note the route group’s permission:approvals:view middleware is a coarse gate for every request endpoint, including approve/reject; the finer-grained approvals:approve-{step} check happens inside ApprovalDecisionService, not at the route layer, because eligibility depends on which step a given request is currently on.

  • Sales: sales.void and sales.discount are approvable actions
  • Inventory: inventory.adjust and inventory.price-override are approvable actions
  • debt.slip wraps App\Services\CreditService::make_debt_slip() (customer debt write-down), labelled “Invoicing” in config/approvals.php purely for settings-UI grouping; there is no Invoicing module on disk