Conduct — Activity to Completion Report — Blueprint
Conduct — Activity to Completion Report
Module slug: conduct · Kind: ERP · Migrations: 0029_conduct, 0030_embed_origins, 0031_reimbursement
Code: src/aayojana/conduct/ · Models: src/aayojana/models/conduct.py
1 · What the module is for
1.1 Work an organisation carries out has to end in a report, and the report has to be built from the record rather than remembered a year later.
1.2 Conduct runs two tracks through one register, because both end in the same place: a sum of money spent, evidenced, and posted to an account head.
1.3 The grant track — money going out to somebody outside the organisation. Applied for, put to the board, authorised against a named financial instrument, worked, and closed only by an Action Taken Report.
1.4 The operational track — the organisation's own recurring spending. A school's month, a shop's month, a department's month. No applicant, no board authorisation, no ATR.
1.5 Ruled by VKG, 2026-09-07: "Apart from external grant applications, monthly expenses of schools, business, organisations shall be provided for. Operators could start the process. (Regular expense need not have a grantee and report); but expenses will be accounted for; and ultimately categorised under Vitta Module."
1.6 Ruled by VKG, 2026-09-07: "External applications need Google Auth; while operators are expected to be logged in."
2 · The lifecycle
2.1 Grant track
| # | Stage | Who | Result |
|---|---|---|---|
| 1 | Application | Applicant (Google sign-in) or operator at the counter | GR-YYYY-nnn, status submitted |
| 2 | Admin review | Admin | assigned — or declined with a reason |
| 3 | Board authorisation | Admin, on the board's behalf | authorised, and an activity AC-YYYY-nnn opens |
| 4 | Work and ATR | Operator | description + ≥3 photographs → atr_filed, closed |
| 5 | Expenses | Operator | EX-YYYY-nnn against the activity |
| 6 | Vitta posting | Admin | balanced journal entry; the expense is stamped with it |
| 7 | Activity page / Annual report | Anyone with access | compiled from 1–6 |
2.2 Operational track
| # | Stage | Who | Result |
|---|---|---|---|
| 1 | Open the month | Operator | AC-YYYY-nnn, kind operational, status open |
| 2 | Expenses | Operator | EX-YYYY-nnn against the month |
| 3 | Vitta posting | Admin | as above |
| 4 | Close | Operator | status closed — no photographs, no ATR |
2.3 The two terminal states are deliberately different words. atr_filed belongs to
the grant track and closed to the operational one, enforced by check constraints, so
a monthly electricity bill can never appear in a donor report as work reported on.
3 · Rules the database enforces
3.1 ck_conduct_activity_grant_has_application — a grant activity always has an
application behind it.
3.2 ck_conduct_activity_operational_has_no_application — and an operational one
never does.
3.3 ck_conduct_activity_atr_is_grant_only / ..._close_is_operational_only — see 2.3.
3.4 uq_conduct_auth_one_per_application — one board decision per application. A page
submitted twice cannot authorise the same grant twice and open two activities.
3.5 uq_conduct_expense_serial — one voucher serial per tenant, so a bill cannot be
claimed twice in two months.
3.6 ck_conduct_expense_posted_has_heads — a posted expense always names both the
expense head it debited and the account it credited.
3.7 ck_conduct_photo_exactly_one_subject — a photograph hangs off exactly one of
application, activity or expense. One attached to two things, or to none, cannot be
counted towards an ATR minimum honestly.
3.8 ck_conduct_appl_self_service_is_verified — a self-service application always
carries the Google subject id that submitted it.
3.9 ck_conduct_appl_declined_has_reason — a declined application always says why.
4 · Rules the service enforces
4.1 A stage can only be reached from the stage before it — submitted → assigned →
authorised; nothing skips.
4.2 An ATR needs a description and MIN_ATR_PHOTOS (3) photographs. Checked in
service.file_atr, not only in the browser, because the browser is not where an
import or an API caller arrives.
4.3 A grant expense names its beneficiary; an operational expense does not have to. Where a grant expense leaves it blank, the applicant's name is copied in.
4.4 An amount is a Decimal, is greater than zero, and keeps its paise.
4.5 Overspend against an authorisation is reported, never refused — the variance appears on the activity page. Refusing the entry would only move the overspend off the books.
4.6 Posting refuses two accounts in different funds. Money does not cross a fund boundary without a transfer entry — this is what keeps FCRA money separate.
5 · The hand-off to Vitta
5.1 Aayojana records what was spent and on whose authority. Vitta holds the books. There is no second ledger here.
5.2 service.post_expense_to_vitta creates a vitta_vouchers row
(source_type="conduct_expense") and calls vitta.service.post_journal_entry — the
only thing in the suite allowed to declare a posting balanced.
5.3 The entry is two lines: debit the expense head, credit the bank or cash
account, carrying the activity's cost_centre and branch_id.
5.4 Recording and posting are separate steps on purpose. A clerk with a voucher in hand must not wait on an accountant's chart-of-accounts setup.
5.5 What has not been posted is a queue, not a loss: /admin/conduct/expenses/pending
and the dashboard tile. Clearing that queue is what a monthly close means.
6 · Who may do what
| Action | Floor | Guard |
|---|---|---|
| Take an application at the counter | Operator | require_operator_html |
| Open an operational month | Operator | require_operator_html |
| Attach evidence, save a description | Operator | require_operator_html |
| Record an expense | Operator | require_operator_html |
| File an ATR / close a month | Operator | require_operator_html |
| Assign to board / decline | Admin | require_admin_html |
| Record the board's authorisation | Admin | require_admin_html |
| Post an expense to Vitta | Admin | require_admin_html |
| Apply for a grant | Public, Google-verified | applicant_auth |
6.1 ROLE_ADMIN inherits ROLE_OPERATOR through the role hierarchy, so an admin can
do everything an operator can.
6.2 Board members hold no logins. A dormant credential with write access to a
grant register, created so that a quarterly signature can be captured, is a worse trade
than it looks. The admin records the decision and names the approving trustee in
authorised_by; recorded_by_user_id says who typed it. The audit trail can then
answer both questions.
7 · The public door
7.1 GET /apply/{org_code} is the only unauthenticated write surface in Aayojana.
7.2 An applicant signs in with Google Identity Services. The browser posts a signed ID
token; applicant_auth.verify_google_credential checks its signature against Google's
published keys, its audience against GOOGLE_CLIENT_ID, and its issuer. Google's
g_csrf_token double-submit cookie is checked as well.
7.3 An applicant gets no user account. No users row, no Membership, no role, no
__session cookie. Minting a user per applicant would put thousands of dormant
credentials inside the tenant's identity system to record a fact that fits in three
columns.
7.4 Instead the session is a separate JWT with type: "applicant" in a separate
__applicant cookie, bound to one tenant_id and lasting 8 hours.
auth_service.get_current_user requires type == "access", so an applicant token
presented to a staff route is rejected before anything looks up a user id.
7.5 Identity is matched on Google's sub, never the email address: a Workspace address
can be reassigned to a new person, and that person must not inherit the previous
holder's grant history.
7.6 Every refusal on the public page — no such organisation, suspended, or not entitled to the module — returns 404. Distinguishing them would tell a stranger which organisations exist and what they have paid for.
7.7 Rate limits: 10 applications per Google account per hour; 30 sign-in attempts per IP per hour.
7.8 Where GOOGLE_CLIENT_ID is empty the page says online applications are not open
and gives the organisation's contact details, rather than rendering a button that fails
silently. The counter route is unaffected.
8 · Evidence
8.1 Photographs go to the private store (services/document_storage), the same one
that holds title deeds — never the public member-photo path, which ends in
blob.make_public().
8.2 A grant register's photographs are of identifiable people at the moment they needed help. A public bucket path is a published photograph the moment anyone guesses or leaks it.
8.3 Bytes are sniffed, not trusted: the declared content type is checked against the file's magic bytes. An ATR photograph must be a JPEG or PNG; an expense may also carry a PDF bill.
8.4 Each row keeps a SHA-256, so the photograph served with next year's report is provably the one filed with this year's ATR.
8.5 Reads go through GET /admin/conduct/photo/{id}, which resolves the tenant from
the caller's membership and then either redirects to a 15-minute signed URL (GCS) or
streams the bytes (development).
9 · Reports
9.1 Activity page — application, board authorisation, ATR with photographs,
expenses with vouchers, and the authorised-versus-spent variance, on one page.
GET /admin/conduct/activity/{id}, and as JSON at
GET /api/conduct/activity-pages/{id}.
9.2 Annual report — brief or detailed, by year, at GET /admin/conduct/report.
The two tracks are reported separately: a donor should not have to subtract the
school's electricity from the total, and an auditor should not have to add it back.
9.3 Export is browser print-to-PDF for v1, with print CSS that drops the chrome. Server-side PDF via WeasyPrint (already used for letters) is the obvious v2 and is not built.
9.4 The module does not yet register itself with the Reports Engine
(reports_engine.register_report), which is per-tenant and needs a provisioning hook.
10 · Numbering
10.1 GR-YYYY-nnn, AC-YYYY-nnn, EX-YYYY-nnn — carried unchanged from the register
this module replaces.
10.2 The series is per tenant per year. Two tenants both begin at 001.
10.3 The next number comes from max(ref_seq), not a row count: counting renumbers
everything the first time a row is deleted.
10.4 Uniqueness is enforced in the database (uq_conduct_*_ref), not only in the code
that generates it. Two clerks pressing Save together is ordinary; a duplicated grant
number in an annual report is not recoverable by apology.
12 · The embeddable card
12.1 Ruled by VKG, 2026-09-10: "I want this module to be appended to any website. So therefore, it has to be attachable HTML component >> May be NPM or JS."
12.2 One line on the organisation's own site:
<script src="https://app.aayojana.com/static/js/aayojana-conduct.js"
data-org="sgsdatta" async></script>
12.3 Or the custom element, placed by hand and configured:
<aayojana-conduct org="sgsdatta" heading="…" blurb="…" label="…">.
12.4 The card renders in a shadow root, so the host site's CSS cannot reshape it and its own CSS cannot leak out. This lands on websites nobody here has seen.
12.5 The card is not the form. Clicking it opens the application form in a
first-party popup on app.aayojana.com. Two reasons, both getting worse rather
than better:
12.5.1 In a cross-site iframe the applicant's session cookie is a
third-party cookie — SameSite=Lax withholds it, and browsers are removing
third-party cookies outright.
12.5.2 Google Identity Services will not sign somebody in inside a cross-origin frame, and registering every customer's domain against one OAuth client does not scale.
12.6 Same trade Stripe Checkout and Razorpay's hosted page make. The host page stays open behind the popup.
12.7 On completion the popup postMessages
{type: "aayojana:conduct:submitted", org, ref} back to the page that opened
it, so the site can say "received — GR-2026-004". The card also fires a DOM
aayojana:submitted CustomEvent for the host page to hook.
12.8 The target origin is never *. It is matched exactly against
organisations.embed_origins (migration 0030) on the server before the popup
will send anything. A wildcard would hand an applicant's reference number to
whatever page happened to open the window.
12.9 Default-closed. An empty allow-list is safe and is what a tenant starts with: the card still opens the form and applications still arrive; the host page simply is not told the reference number.
12.10 _normalise_origin refuses anything that could never match — no scheme,
a path, a query, or plain http for a non-localhost host. Saving a near-miss
silently would leave the tenant with an embed that reports nothing and no
reason why.
12.11 The origin survives the Google round trip in a first-party
__conduct_embed cookie scoped to /apply/<code>. A query parameter cannot:
the applicant leaves for Google and comes back on a POST.
12.12 applicant_auth.token_from_request now also accepts
Authorization: Bearer <applicant token>, so a future inline-frame or
single-page client has a carrier that does not depend on cookies. The cookie
wins where both are present.
12.13 Tenant-facing settings page: /admin/conduct/embed — the snippet, the
allow-list editor, and what the visitor sees. Admin only; changing who may
embed is not an operator's call.
12.14 The npm package is at packages/aayojana-conduct/ for hosts that
build — the Next.js properties in the suite. It is a loader, not a copy: it
injects the hosted script and re-exports loadConduct / mountConduct.
Vendoring a second copy would let a site pinned to an old version keep a card
that no longer matches the form it opens. It is marked private and
unpublished — publishing is an outward release, not a code change.
13 · The reimbursement sub-type
13.1 VKG's memo, 2026-09-10. A reimbursement is the Foundation paying back somebody who has already spent their own money — a school fee, a hospital bill, a course fee — rather than paying an institution or a vendor directly.
13.2 conduct_applications.kind is grant or reimbursement. The
lifecycle is unchanged: application → admin → board → ATR → expense → annual
report. What changes is what has to be proved.
13.3 The rule the sub-type exists for. In the base lifecycle the only evidence is the ATR photographs, taken by the Foundation at closure. Those prove the Foundation handed money to a person. They prove nothing about whether the fee was ever charged. A reimbursement therefore carries the original receipt — the institution's own document — as evidence in its own right.
13.4 conduct_photos.role is what keeps the two apart:
applicant · institution · receipt · atr · voucher. The subject column
says where evidence hangs; the role says what it proves.
13.5 Two check constraints make the separation structural rather than conventional:
13.5.1 ck_conduct_photo_receipt_is_on_the_application — a receipt hangs
off the application, never an activity.
13.5.2 ck_conduct_photo_atr_is_on_the_activity — and an ATR photograph
hangs off the activity, never an application.
13.6 service.photo_count counts role = 'atr' explicitly. Three receipts
are not three ATR photographs and cannot close anything.
13.7 service.file_atr refuses to close a reimbursement activity until
has_original_receipt is true. Both proofs, or it stays open.
13.8 The receipt is required at submission too, refused in the route rather than by a constraint — a photograph is a second row, and the database cannot see at INSERT time whether one is coming. Closure enforces it again, so no other way in can skip it.
13.9 A receipt or a voucher may be a PDF; a portrait, an institution photograph or an ATR photograph may not. An institution's bill is usually a PDF; a portrait that is a PDF is somebody working around the requirement.
13.10 Prior approval — that approval was sought before the money was
spent. Mode oral carries the name of the person who gave it; mode email
carries date, sender and recipient. Each is enforced by check constraint,
because a mode recorded without its evidence is a claim of approval with
nothing behind it.
13.11 Open ruling — memo §3.1. Whether a missing prior approval should
auto-reject or go to the board was left open. Shipped as board discretion:
prior_approval_mode is nullable, a blank enters the ordinary queue, and the
review and board pages show "not recorded — the board decides at its
discretion" in amber. Rejecting on a blank field would destroy an application
a trustee may well have approved by telephone. One condition in
service.submit_application flips it if VKG rules the other way.
13.12 category (Education · Medical · Skill Training · …) and moa_clause
are free text. The memo calls the category list "not exhaustive", and a trust's
MoA clauses belong to its own deed — the clause vocabulary is VKG's to give,
not ours to invent.
13.13 amount_claimed sits on the application and is distinct from the
board's authorised amount: the board may approve less than was asked.
13.14 The annual report separates reimbursements out, totals them, and names any that have no receipt on file in red — the finding a trustee needs before signing.
13.15 Migration 0031_reimbursement. Backfills role from each existing
photograph's subject before adding the constraints.
11 · Not built
11.1 Server-side PDF generation (see 9.3).
11.2 Reports Engine registration (see 9.4).
11.3 Outbound notification to the applicant when their status changes — the Comms module is the right home for it and the templates are not written.
11.4 Editing or reversing a posted expense. By design: a correction is made in Vitta with a reversing entry, never by editing the Aayojana row.
11.5 Bulk import of a historical paper register.