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 assigned →
active → completed, 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_uploaded → approved or rejected → completed, or straight
pending → completed 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 Completed — UserActionService::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: pending → approved (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/approved → cancelled 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.