Payments and Accrual¶
How a worker's form submission becomes money: the models involved, how accrual is calculated, when it is recalculated, where the results are stored, and how accrued work is invoiced and paid.
See README.md for the wider opportunity app.
Overview¶
flowchart LR
PU[PaymentUnit<br/>amount, org_amount, limits] --> CL[OpportunityClaimLimit<br/>max_visits per worker]
F[Deliver form from HQ] --> UV[UserVisit<br/>one per deliver block]
UV --> CW[CompletedWork<br/>one per worker + entity + PaymentUnit]
CW -->|CompletedWorkUpdater| ACC[saved_* accrual fields]
ACC --> OA[OpportunityAccess.payment_accrued]
ACC -->|bill_invoice| CWI[CompletedWorkInvoice<br/>frozen line items]
CWI --> INV[PaymentInvoice]
INV -->|invoice_pay| PO[Record payment to organization]
OA -.->|CSV upload| PW[Record payment to worker]
There are two payees. The money itself moves outside Connect; Connect only records that a payment was made:
- The worker (FLW) earns
PaymentUnit.amountper approved unit. Their payments are recorded by uploading a payment CSV, which createsPaymentrows linked to anOpportunityAccess. - The organization earns
PaymentUnit.org_amountper approved unit, billed together with the FLW pay on aPaymentInvoice. Its payment is recorded when a PM marks the invoice paid, which creates aPaymentrow linked to anOrganizationand the invoice.
Model Relationships¶
erDiagram
Opportunity ||--o{ PaymentUnit : "has"
PaymentUnit ||--o{ PaymentUnit : "parent of"
PaymentUnit ||--o{ DeliverUnit : "paid by"
CommCareApp ||--o{ DeliverUnit : "defines"
Opportunity ||--o{ OpportunityAccess : "has"
OpportunityAccess ||--o| OpportunityClaim : "claims"
OpportunityClaim ||--o{ OpportunityClaimLimit : "limits"
PaymentUnit ||--o{ OpportunityClaimLimit : "limited by"
OpportunityAccess ||--o{ UserVisit : "submits"
DeliverUnit ||--o{ UserVisit : "type of"
OpportunityAccess ||--o{ CompletedWork : "earns"
PaymentUnit ||--o{ CompletedWork : "priced by"
CompletedWork ||--o{ UserVisit : "aggregates"
CompletedWork ||--o{ CompletedWorkInvoice : "billed in"
PaymentInvoice ||--o{ CompletedWorkInvoice : "line items"
ExchangeRate ||--o{ CompletedWorkInvoice : "priced at"
Opportunity ||--o{ PaymentInvoice : "has"
PaymentInvoice ||--o| Payment : "paid by"
OpportunityAccess ||--o{ Payment : "worker payments"
1. Payment Units and Deliver Units¶
PaymentUnit¶
What a worker is paid for, and how much. Belongs to one
Opportunity.
| Field | Meaning |
|---|---|
amount |
Worker pay per completed unit, in the opportunity's currency (integer) |
org_amount |
Organization pay per completed unit (default 0). Applies to every opportunity |
max_total |
Maximum units per worker. Required for setup to be complete |
max_daily |
Maximum visits per worker per day. Optional; blank means no daily limit |
parent_payment_unit |
Optional parent. A parent unit can only complete as many times as its children completed |
start_date / end_date |
Optional window. Visits beforestart_date are trial; end_date caps the claim limit |
DeliverUnit¶
A deliver block in the deliver app's forms (identified by slug). It belongs to a CommCareApp, not an opportunity, and points at the PaymentUnit it pays toward. A PaymentUnit can have several deliver units:
- Required (
optional=False): every required unit must be submitted for the payment unit to count as completed. - Optional (
optional=True): at least one optional unit must also be submitted, if any exist.
Claim limits and budget¶
When a worker claims an opportunity, OpportunityClaimLimit.create_claim_limits() gives them one OpportunityClaimLimit per payment unit:
max_visits = min(max_total, remaining_budget // (amount + org_amount))- Units are funded in
pkorder, drawing down the remaining budget, so the newest units go short first. - A unit the budget cannot fund gets no claim limit row, and deliver forms for it are rejected with a 400.
The opportunity budget counts both worker and organization pay: claimed_budget = Σ max_visits × (amount + org_amount).
2. UserVisits and CompletedWork¶
UserVisit¶
One row per deliver block in a form submission. A form with several deliver blocks creates several visits that share an xform_id.
Two independent statuses decide whether a visit counts toward payment:
| Field | Values | Set by |
|---|---|---|
status |
pending, approved, rejected, over_limit, duplicate, trial |
Form processing, staff visit approval/rejection |
review_status |
pending, agree, disagree |
Auto-approval, PM review |
A visit counts toward payment only when status=approved and review_status=agree.
Visit processing¶
process_deliver_unit() in form_receiver/processor.py runs for every deliver block:
flowchart TD
A[Deliver block received] --> B{Opportunity or<br/>PaymentUnit not started?}
B -->|yes| T[status = trial<br/>no CompletedWork]
B -->|no| C[get_or_create CompletedWork<br/>access + entity_id + PaymentUnit]
C --> D{Over a limit?<br/>claim ended, max_visits, max_daily}
D -->|yes| OL[visit: over_limit<br/>work: over_limit]
D -->|no| E{Entity already<br/>visited for this unit?}
E -->|yes| DUP[status = duplicate]
E -->|no| P[status = pending]
T --> FL
OL --> FL
DUP --> FL
P --> FL[Run verification flags]
FL --> S{Worker suspended or<br/>blocking assigned task?}
S -->|yes| R[visit + work: rejected]
S -->|no| AV{pending and flagged and<br/>automatic_visit_verification?}
AV -->|yes| R2[status = rejected]
AV -->|no| AA{pending, not flagged and<br/>auto_approve_visits?}
AA -->|yes| AP[status = approved<br/>review_status = agree]
AA -->|no| SV
R --> SV
R2 --> SV
AP --> SV[Save visit<br/>work incomplete → pending]
SV --> ACC[update_payment_accrued_for_user<br/>incremental]
The claim limit row is locked with select_for_update() while this runs, so concurrent submissions for the same worker and payment unit cannot both pass the limit checks.
Limits are counted per deliver unit and exclude over_limit and trial visits. Only auto-approved visits get review_status=agree here. Every other visit needs a PM to agree before it can be paid.
CompletedWork¶
One row per (opportunity_access, entity_id, payment_unit): everything a worker delivered for one beneficiary under one payment unit. It is the unit that gets approved, accrues payment, and is invoiced.
| Status | Meaning |
|---|---|
incomplete |
Default on creation. Moved topending once a visit is saved |
pending |
Has visits, not yet approved |
approved |
Accrues payment |
rejected |
A visit was rejected, or the worker was suspended or blocked by a pending task |
over_limit |
A visit hit a claim limit |
status_modified_date is set automatically whenever status changes. It is the approval date used for USD conversion and invoice windows, and it does not move when more visits arrive for an already-approved work.
Counting completions¶
A payment unit is completed once for each full set of its deliver units. For a single CompletedWork:
completed = min(count of each required deliver unit)
→ capped by sum(count of all optional deliver units) if any optional units
→ capped by sum(completed of child CompletedWorks) if any child payment units
approved uses the same rule, counting only visits with status=approved and review_status=agree.
Example: a payment unit with required units A and B and optional unit C. Visits A, A, B, C give completed = min(2, 1) = 1, capped by C = 1, so 1. A second visit to the same entity (a duplicate) that completes the set again raises the count to 2 and doubles the accrual.
Approving CompletedWork¶
Status changes are made in CompletedWorkUpdater._update_status() (utils/completed_work.py), and only when opportunity.auto_approve_payments is on:
flowchart TD
A[CompletedWork with at least one visit] --> B{auto_approve_payments?}
B -->|no| K[Status unchanged<br/>set manually via work status import]
B -->|yes| C{Any visit rejected?}
C -->|yes| RJ[rejected<br/>reason = visit reasons]
C -->|no| D{Every required unit, and one optional unit,<br/>has a visit approved + agree?}
D -->|yes| AP[approved]
D -->|no| E{incomplete?}
E -->|yes| PE[pending]
E -->|no| K2[Status unchanged]
With auto_approve_payments off, CompletedWork status is only changed by the completed-work status import (visit_import.bulk_update_completed_work_status).
Accrual calculation¶
CompletedWorkUpdater._update_payment() writes the cached accrual fields on every work that has at least one completion:
if status == approved and approved_count > 0:
saved_payment_accrued = approved_count × payment_unit.amount
saved_org_payment_accrued = approved_count × payment_unit.org_amount
saved_payment_accrued_usd = saved_payment_accrued / rate(status_modified_date)
saved_org_payment_accrued_usd = saved_org_payment_accrued / rate(status_modified_date)
else:
all four = 0
saved_completed_count = completed
saved_approved_count = approved
The rate comes from ExchangeRate.latest_exchange_rate(): the newest stored rate on or before the approval date. If none is stored, it fetches one from OpenExchangeRates synchronously. USD opportunities use a rate of 1. Because the rate is tied to the approval date, later rate changes do not alter accrued USD.
update_status() then totals the worker accrual onto the access:
OpportunityAccess.payment_accrued = Σ CompletedWork.saved_payment_accrued (all of the access's works)
Organization accrual has no per-access total. Reports sum saved_org_payment_accrued directly.
When accrual is recalculated¶
Accrual is cached, not calculated on read, so every action that can change a visit or work status must trigger a recalculation. Each trigger recalculates a different set of works:
flowchart LR
subgraph Triggers
T1[Form received]
T2[Bulk approve visits]
T3[Reject visits]
T4[PM review agree/disagree]
T5[Visit status / review status /<br/>work status imports]
T6[Nightly bulk_approve_completed_work<br/>23:00]
end
T1 --> I1[update_payment_accrued_for_user<br/>incremental + current work]
T2 --> I2[update_payment_accrued<br/>incremental]
T3 --> F1[update_payment_accrued<br/>full]
T4 --> F1
T5 --> B1[bulk_update_payment_accrued task<br/>all except rejected]
T6 --> B2[update_status<br/>all except rejected]
I1 --> U[update_status → CompletedWorkUpdater]
I2 --> U
F1 --> U
B1 --> U
B2 --> U
| Trigger | Entry point | Works recalculated |
|---|---|---|
| Form received | process_deliver_unit → update_payment_accrued_for_user(incremental=True) |
Works not yet approved withsaved_approved_count=0, plus the work just touched |
| Bulk approve visits | views.approve_visits → update_payment_accrued(incremental=True) |
Works not yet approved withsaved_approved_count=0 |
| Reject visits | views.reject_visits → update_payment_accrued() |
All of the worker's works |
| PM review | views.user_visit_review → update_payment_accrued() |
All of the worker's works |
| Visit, review or work status imports | visit_import.* → tasks.bulk_update_payment_accrued (Celery) |
All works exceptrejected |
Nightly,auto_approve_payments only |
tasks.bulk_approve_completed_work (active, not-ended opportunities) |
All works exceptrejected |
Incremental skips works that are already processed (approved, or with saved_approved_count > 0), so only works still waiting on approval are recalculated. Full recalculates every one of the worker's works.
All paths except the nightly task run under a Redis lock update_payment_accrued_lock_<access_id>. update_payment_accrued and bulk_update_payment_accrued skip suspended workers.
Cached vs live values¶
CompletedWork has both cached fields and live properties for the same numbers:
| Cached (stored) | Live (queried on access) | Used by |
|---|---|---|
saved_completed_count |
completed_count |
Cached: reports,OpportunityAccess.visit_count |
saved_approved_count |
approved_count |
Cached: invoicing (invoiced_approved_count watermark), reports |
saved_payment_accrued |
payment_accrued |
Cached:OpportunityAccess.payment_accrued, payment reports. Live: update_work_payment_date |
The live properties run several queries per work. Prefer the cached fields in anything that loops over many works.
3. Payments and Invoices¶
Where the money values are stored¶
| Value | Location | Currency |
|---|---|---|
| Worker accrual per work | CompletedWork.saved_payment_accrued / _usd |
Local / USD |
| Org accrual per work | CompletedWork.saved_org_payment_accrued / _usd |
Local / USD |
| Worker accrual per worker | OpportunityAccess.payment_accrued |
Local |
| Units already billed | CompletedWork.invoiced_approved_count |
— |
| Billed line item | CompletedWorkInvoice (flw_amount_*, org_amount_*) |
Local + USD |
| Invoice total | PaymentInvoice.amount / amount_usd |
Local / USD |
| Worker payment | Payment with opportunity_access |
Local + USD |
| Organization payment | Payment with organization and invoice |
Local + USD |
| Date a work was fully paid | CompletedWork.payment_date |
— |
Worker payments¶
Worker payments are recorded by uploading a CSV (visit_import.bulk_update_payments, via tasks.bulk_update_payments_task):
- The whole file is validated first. Nothing is written unless every row is payable.
- Each row creates a
Paymentwithopportunity_accessset.amount_usduses the rate on the row's payment date, or today's rate if no date is given. - Afterwards,
update_work_payment_date()matches the worker's payments to their works, oldest first:
flowchart LR
A[Payments ordered by date_paid] --> B[Add next payment to running total]
B --> C{Running total ≥ next work's<br/>payment_accrued?}
C -->|yes| D[Set work.payment_date = payment.date_paid<br/>subtract from running total]
D --> C
C -->|no| B
Works are ordered by status_modified_date. A partial payment leaves payment_date empty on that work and on every later one.
The amount still owed to a worker is payment_accrued − Σ Payment.amount. Payment.payment_unit exists on the model but neither payment path sets it.
Service delivery invoices¶
Service delivery invoices bill approved work, both worker and organization pay, to the program. They are created:
- Automatically, by
tasks.generate_automated_service_delivery_invoiceon the 2nd of each month at 01:00 UTC, for active, non-test opportunities starting on or afterOPPORTUNITY_AUTO_INVOICE_START_DATE. It covers the period up to the end of the previous month. - Manually, through
AutomatedPaymentInvoiceFormfromInvoiceCreateView.
Both call bill_invoice() (utils/invoice_line_items.py). Billing uses a per-work watermark instead of a link to a single invoice, so work that gains more approved units after it was billed can be billed again for the difference:
flowchart TD
A[Approved CompletedWork where<br/>saved_approved_count > invoiced_approved_count] --> B{Billed before?}
B -->|no: first billing| C[Must have been approved<br/>within the invoice window]
B -->|yes: late delta| D[Approved on or before end_date<br/>start_date ignored]
C --> E[billed_count = saved_approved_count − invoiced_approved_count]
D --> E
E --> F[Price at the billed month's exchange rate<br/>FLW: billed_count × amount<br/>Org: billed_count × org_amount]
F --> G[Create CompletedWorkInvoice row]
G --> H[invoiced_approved_count = saved_approved_count]
H --> I[Invoice amount = Σ FLW + org pay]
- Billed month: a first billing uses the work's approval month. A late delta uses the month of the invoice
end_date, becausestatus_modified_dateis fixed at the original approval. - Exchange rate: invoices use the billed month's rate, but accrual uses the approval-date rate. Invoiced USD can therefore differ from accrued USD for the same work.
- Window start:
get_start_date_for_invoice()uses the earliest approval awaiting first billing. If only late deltas remain, it uses the previous month. - Works are locked with
select_for_update()while billing, so two invoices cannot bill the same units. CompletedWorkInvoicerows are frozen at creation. Invoice views read them instead of recalculating fromCompletedWork.
Custom invoices (service_delivery=False) are free-form expenses with no line items. They do not touch the watermark.
Invoice lifecycle¶
stateDiagram-v2
[*] --> pending_nm_review: created
pending_nm_review --> pending_pm_review: NM submits
pending_nm_review --> cancelled_by_nm: NM cancels
pending_pm_review --> ready_to_pay: PM approves
pending_pm_review --> rejected_by_pm: PM rejects
ready_to_pay --> rejected_by_pm: PM rejects
ready_to_pay --> paid: PM marks paid
cancelled_by_nm --> [*]
rejected_by_pm --> [*]
paid --> [*]
- Transitions and the roles allowed to make them are defined in
InvoiceWorkflow(utils/invoice.py). - Cancel or reject (service delivery only):
rollback_invoice_line_items()lowers each work'sinvoiced_approved_countby itsbilled_countand deletes the invoice'sCompletedWorkInvoicerows, so the units are billable again. - Mark paid (
views.invoices.invoice_pay): only fromready_to_pay. Creates onePaymentper invoice withorganizationandinvoiceset, copying the invoice'samountandamount_usd. - NM and PM certification (who moved the invoice and when) is read from the
pghistorystatus events onPaymentInvoice.