9
🛠

Conduct — Activity to Completion Report — Blueprint

Grants and reimbursements · board authorisation · ATR · expenses · annual report

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.