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/.
Data model
Section titled “Data model”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 polymorphicsubject()relation. Immutable once it leavespending, resubmission always creates a new row.ApprovalDecision: one row per individual approve/reject,step,decided_by,decision,comment,decided_at.belongsToApprovalRequest.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).
Submitting and listing
Section titled “Submitting and listing”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.
Deciding
Section titled “Deciding”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.
Policy configuration
Section titled “Policy configuration”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.
Route surface
Section titled “Route surface”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.
Related
Section titled “Related”- Sales:
sales.voidandsales.discountare approvable actions - Inventory:
inventory.adjustandinventory.price-overrideare approvable actions debt.slipwrapsApp\Services\CreditService::make_debt_slip()(customer debt write-down), labelled “Invoicing” inconfig/approvals.phppurely for settings-UI grouping; there is no Invoicing module on disk