Skip to content

Preparation Plans: Actions, Verifications, and Payouts

A "preparation plan" is the set of concrete tasks a user is asked to do after a weather alert fires — buy sandbags, move livestock, secure documents — and the Action/UserAction model group is how this repo represents that plan, its per-user progress, the evidence that proves a task was really done, and the cash payout that can follow. Users receive alerts, complete preparation tasks, upload verification evidence, and receive payouts via M-Pesa [@project]; this concept is the data model behind that sentence, and it is the concept most of the operational surface of Yaya Manager (reviewing evidence, approving payouts) is built around.

Catalog versus instance

Action is a catalog entry: a reusable task template with a name, description, behavior_statement, alert_level_fit, feasibility, time_effort_minutes, and a payout_value, plus an incentive_appropriate flag that decides whether completing it should ever produce a payout [@action-model]. An Action hasMany ActionStep records, ordered steps within the task, each with a requires_evidence boolean that marks whether that particular step needs photo/document proof rather than a plain completion tap [@action-model] [@action-step-model].

UserAction and UserActionStep are the per-user instances created from that catalog when an alert is assigned to a user: UserAction belongsTo User, UserAlert, and Action, hasMany UserActionStep records, and hasOne RequestedPayout [@user-action-model]. This split exists so the catalog (what a "move livestock" task looks like) can be edited centrally — see Bulk-edit the action catalog — while each user's copy of it tracks its own status, evidence, and expiry independently. AlertAssignmentService::assignToUser is where a UserAlert and its UserActions get created together: it deactivates the user's previous active alert, creates the new UserAlert, asks an action-selection service which Actions fit that alert, and for each selected action creates a UserAction (status Assigned, expires_at set from the action's expires_after_hours, defaulting to 72) and one UserActionStep per ActionStep, all inside a single transaction [@alert-assignment-service].

Status lifecycles

UserAction.status (a UserActionStatusEnum) moves through assignedactivecompleted, or expired if the user never acts before expires_at passes [@user-action-model]. UserActionStep.status (a UserActionStepStatusEnum) is more granular because it has to represent the evidence-review round trip: pending → (if the step requires evidence) evidence_uploadedapproved or rejectedcompleted, or straight pendingcompleted for steps that do not require evidence [@user-action-step-model]. UserActionService enforces these transitions in code, not just as documentation: completeStep refuses to mark an evidence-requiring step complete unless it is already Approved, and refuses a non-evidence step complete unless it is still Pending [@user-action-service]. Rejected evidence can be resubmitted up to UserActionService::MAX_RESUBMISSIONS (3) times; a rejection also carries a RejectionReasonCategoryEnum (image_unclear, wrong_evidence, fraud) so the user gets a tailored notification rather than a generic "rejected" message [@user-action-service] [@rejection-enum].

Evidence review is a human step, not an automated check: ops staff approve or reject uploaded evidence from the "Verification Queue," the navigation label Yaya Manager gives to the UserActionStepResource Filament resource built on UserActionStep [@user-action-step-resource]. UserActionStep carries approved_by/rejected_by foreign keys to AdminUser, recording exactly which admin made the call [@user-action-step-model].

Completion triggers a payout, not the other way around

A UserAction only becomes Completed once every one of its UserActionSteps reaches CompletedUserActionService::completeStep calls a private completeActionIfReady after every step completion, which checks $userAction->steps->every(...) before flipping the parent's status [@user-action-service]. At that moment, if the action's incentive_appropriate flag is true, the service does a RequestedPayout::firstOrCreate keyed on user_action_id, seeding amount from the action's payout_value and currency fixed to KES, with status Pending [@user-action-service]. This is why RequestedPayout belongsTo UserAction as a one-to-one (UserAction::requestedPayout is a hasOne): a payout is not requested by the user, it is a side effect of completing an action whose catalog entry was marked incentive-appropriate [@user-action-model] [@payout-model].

RequestedPayout.status (PayoutStatusEnum) then moves through its own, separate lifecycle, driven entirely from Yaya Manager rather than from user action: pendingapproved (ops exports pending payouts to XLSX via PaymentsController::processPending, which flips their status in the same transaction as the export) → paid (PaymentsController::markAsPaid, after the M-Pesa transfer has actually been sent outside the system) — or pending/approvedcancelled via PaymentsController::cancel [@payments-controller]. M-Pesa payouts are manual for the pilot phase [@project] [@db-arch] — Yaya Engine tracks the payout record and its status, but the actual mobile-money transfer is a manual step ops does after exporting the batch, which is why RequestedPayout has a processed_by (AdminUser) column recording who approved, paid, or cancelled each one [@payout-model] [@payments-controller].

Where this fits in the wider data model

This whole chain — alert assigns a UserAlert, which is linked to UserActions with UserActionSteps, some steps needing verification, a completed action triggering a RequestedPayout — is the "prepare, verify, get paid" journey that the rest of the product exists to support [@db-arch]. It sits inside the broader Domain model, alongside the User/FSP and messaging model groups, and it is the model group that Yaya Manager spends the most screen space on: the Verification Queue and the Payments page are both direct views over this chain.