Skip to content

Day 5 — Supply & Money: Procurement, Assets, Finance, Leads

Version 1.0 | written against code 2026-09-16

Days 3 and 4 covered how medicine reaches a patient and how stock is tracked. Today covers what sits behind that: how the warehouse buys stock (procurement), the places everything belongs to (assets: hospitals, warehouses, pincodes), how the company pays its partners and records spend (finance), and how a sales lead from the marketing site is captured (leads).

These modules carry the most money logic in the backend. Most of the rules here exist because someone got burned once. Read the Traps section slowly.


Goals

By the end of today you can:

  1. Walk a medicine from a hospital's procurement request, through a purchase order, into warehouse inventory, and name every status on the way.
  2. Explain how a hospital is linked to a warehouse, and how a patient's pincode picks a warehouse.
  3. Explain the fixed monthly partnership fee: how it is stored, how it becomes a weekly payout, and why the math is monthly × 12 / 52, not monthly / 4.33.
  4. Trace the payout state machine (draft → pending → approved → paid / voided), including the two-person rule and the expense row written on payment.
  5. Say what internal/pkg/revenueshare actually computes today (hint: it is not a partner share any more) and where it is used.
  6. Write a CSV export that cannot be turned into a spreadsheet formula attack.
  7. Spot a money mutation that is not safe to retry.

Reading order

Open these in order. Line numbers are from 2026-09-16 and will drift, so search for the symbol if a number is off.

#FileWhat to look for
1medyzen-backend/internal/procurement/routes/routes.goThe whole procurement API on one screen: requests, vendors, purchase orders, purchase returns, GST reversals, sales returns, reports
2medyzen-backend/internal/procurement/services/statemachine.go and po_statemachine.goTwo small transition maps. Everything else checks against these
3medyzen-backend/internal/procurement/services/procurement.goSubmit (L37), Review (L136), Cancel (L151), commitTransition (L215)
4medyzen-backend/internal/procurement/services/purchase_order.gorecomputePOBalance (L51), Create (L182), Send (L409), Confirm (L490), Cancel (L643), RecordPayment (L715)
5medyzen-backend/internal/procurement/services/purchase_return.goComplete (L279): stock goes down, debit note reduces the PO balance, GST reversal rows written
6medyzen-backend/internal/procurement/services/sales_return.goWriteSalesReturn (L32), called from the order module when a patient order is returned
7medyzen-backend/internal/assets/models/hospital.go, hospital_warehouse.go, warehouse_service_area.go, hospital_partnership_fee.goThe four tables that shape everything else
8medyzen-backend/internal/assets/services/hospital.goCreate (L81) writes the first fee, CreatePartnershipFee (L252), PrimaryWarehouseID (L837)
9medyzen-backend/internal/assets/services/warehouse_service_area.go and internal/database/migrations/084_create_warehouse_service_areas.up.sqlPincode → warehouse, and why it fails closed
10medyzen-backend/internal/finance/services/payout.goFeeInstallment (L28), Generate (L142), Transition (L278), createPayoutExpense (L455)
11medyzen-backend/internal/finance/repositories/payout.goactiveFeeQuery, ExistsForPeriod (L174), payoutHospitalPeriodConflict (L199)
12medyzen-backend/internal/finance/services/payout_scheduler.goThe finance.payout_scheduler worker: Saturday 18:00 IST, weekly run marker
13medyzen-backend/internal/finance/dtos/hospital_payout.goHow the 5 internal statuses collapse into 3 for the hospital
14medyzen-backend/internal/pkg/revenueshare/revenueshare.go and internal/dashboard/repositories/dashboard.go (L232–290)The "net delivered value" SQL expression
15medyzen-backend/internal/finance/repositories/analytics.go and services/analytics.go (Dashboard, L47)How the super-admin finance dashboard computes gross and net
16medyzen-backend/internal/pkg/csvsafe/csvsafe.goThe whole CSV-injection defence in 63 lines
17medyzen-backend/internal/leads/routes/lead.go, services/lead.goThe one module with an unauthenticated write

Explanation

1. Where these modules sit

All four follow the module pattern from Day 1: module.go wires repositories → services → handlers, and routes/ registers paths under /<module>/api/v1/.... Every route except POST /leads/api/v1/leads goes through middleware.Auth. The endpoint-level RBAC allow-list from Day 2 (internal/middleware/whitelist.go) also runs on these routes when RBACWhitelistMode is on. The services then check scope: which warehouse or hospital this caller may touch.

Modules talk to each other through small interfaces declared by the caller (ports.go), not by importing each other's services. Procurement, for example, declares:

  • HospitalWarehouseReader.PrimaryWarehouseID, implemented by assets
  • StockReceiver.ReceiveForPurchaseOrder, implemented by inventory
  • StockDecrementer.DecrementForReturn, implemented by inventory

(medyzen-backend/internal/procurement/services/ports.go). cmd/server/main.go connects them. For example, L347 hands the procurement sales-return writer to the order service.


2. Procurement

Procurement has two separate state machines that are tied together:

  • A procurement request (procurement_requests) means "a hospital wants this medicine". A hospital operator creates it.
  • A purchase order (purchase_orders) means "the warehouse is buying from a vendor". Warehouse staff create it. One PO can carry lines for several requests, through purchase_order_items.request_id.

2.1 Request statuses

From medyzen-backend/internal/procurement/services/statemachine.go:

FromAllowed to
pendingapproved, rejected, cancelled, ordered
approvedprocured, rejected, ordered
orderedprocured, cancelled
rejected, procured, cancellednothing (terminal)

Who does what (procurement.go):

  • Submit (L37): the caller must have a hospital scope. The request's warehouse_id is not sent by the client. It is filled from PrimaryWarehouseID(hospitalID), so a hospital cannot send work to a warehouse it isn't linked to.
  • Review (L136): the warehouse decides approved or rejected. Rejecting requires a note (ErrRejectNeedsNote).
  • MarkProcured (L147): done by the warehouse.
  • Cancel (L151): done by the hospital, and only while pending. The inline comment explains why: once the warehouse has approved or raised a PO, a hospital-side cancel would strand that work.

Every transition goes through commitTransition (L215). It checks CanTransition, saves, and appends a row to procurement_request_events. It runs inside database.RunInTx after a GetByIDForUpdate (SELECT ... FOR UPDATE), so two reviewers clicking at once are handled one after the other, not both applied.

Notifications (notifier.NotifyProcurement) fire after the transaction commits. A rolled-back change never sends a message.

2.2 Purchase order statuses

From po_statemachine.go:

FromAllowed to
draftsent, cancelled
sentcompleted, cancelled
completed, cancellednothing

A PO also has a separate payment_status: unpaidpartialpaid.

Key points in purchase_order.go:

  • Create (L182) needs an account scoped to exactly one warehouse (ErrPONoWarehouseScope). Line GST always comes from the product master (GSTPercentByID). A GST value sent by the client is logged and ignored (L130–141). PO numbers are PO-%06d from a sequence, with a retry on unique violation (L229–249).
  • Update (L330) works only on draft (ErrPONotEditable).
  • Send (L409) moves every linked request to ordered and stamps po_id on it. If a request is already tagged to a different PO, it fails with ErrPORequestAlreadyTagged.
  • Confirm (L490) is the "goods arrived" step and does the most work in one transaction:
    1. delivery_date cannot be in the future.
    2. The request must list exactly the PO's current items, each once (ErrPOItemsMismatch).
    3. qty_received ≤ qty_ordered.
    4. Each line gets line_actual = actual_rate × qty_received and line_gst = line_actual × gst% / 100, all in decimal.Decimal.
    5. Lines with qty_received > 0 need a batch number, expiry, MRP and selling price. They are passed to inventory.ReceiveForPurchaseOrder, which refuses to run without an open transaction (internal/inventory/services/batch.go L184). Stock and the PO commit or fail together.
    6. due_date = delivery_date + credit_days. If the PO has no credit days, the vendor's value is used.
  • Cancel (L643) is allowed from draft or sent. Every linked request still in ordered goes back to pending with po_id = NULL, so the hospital's need is not lost.
  • RecordPayment (L715) works only on completed POs. The amount cannot exceed the live balance. A reference number is required unless the mode is cash.

The PO balance formula (recomputePOBalance, L51):

balance = max(0, total_actual + total_gst − total_paid − total_debit_notes)

Note the last dotted edge. Confirm does not move requests to procured. Tagged requests stay ordered until someone calls MarkProcured. See Traps.

2.3 Returns and GST reversal

  • Purchase return (warehouse sends stock back to a vendor). Create builds lines from specific inventory variants, which must belong to the caller's warehouse. Complete (L279) runs in one transaction. It decrements stock per line, writes one gst_reversal_entries row per line (doc_type debit note, CGST/SGST split), and, if the return is linked to a PO, adds the debit value to total_debit_notes. That debit is capped at the outstanding balance, and the cap is logged. A duplicate debit note number returns ErrPRDebitNoteNoTaken from the unique index.
  • Sales return (a patient order returned). This is not an API write. The order service calls WriteSalesReturn (sales_return.go L32) through the SalesReturnWriter port (internal/order/services/order.go L1088). GST is backed out of a GST-inclusive selling price: gst = gross × pct / (100 + pct), rounded to 2 decimals. Compare this with a purchase return, where GST is added on top of a taxable rate. Mixing up the two formulas is an easy bug to write.

2.4 Vendor-wise report

GET /procurement/api/v1/reports/vendor-wise/export.csv streams through VendorService.VendorWiseReportExport(ctx, actor, w *csvsafe.Writer) (services/vendor.go L191). The function's parameter type forces the safe writer on the caller. More in §6.


3. Assets: hospitals, warehouses, pincodes

3.1 Hospitals

medyzen-backend/internal/assets/models/hospital.go: a hospital has a code (unique), a type (hospital, clinic, nursing_home, diagnostic_center) and a sales-pipeline status with its own transition map:

lead -> negotiation -> active <-> suspended ; active/suspended -> closed

HospitalService.Create (services/hospital.go L81) inserts the hospital and its first hospital_partnership_fees row in the same transaction. A hospital never exists without a fee record. If the contact email is set, it also creates the hospital operator login (syncPrimaryOperatorLogin).

Bank details live in hospital_bank_details. AddBankDetail, UpdateBankDetail and DeleteBankDetail call requireNonHospitalOperator, so a hospital cannot change where its own money is sent. Finance reads these rows at payout time (§4.4).

3.2 Hospital ↔ warehouse

hospital_warehouses (models/hospital_warehouse.go) is a many-to-many join with distance_km and is_primary, unique on (hospital_id, warehouse_id).

  • Super-admin manages it through POST/PATCH/DELETE /assets/api/v1/warehouses/{id}/hospitals[/{hospitalId}] (WarehouseService.AssignHospital, L281).
  • HospitalService.PrimaryWarehouseID (L837) returns the row with is_primary = true. If none is primary, it returns the first row. If there are no rows, it returns ErrNoWarehouseForHospital, and a procurement submit fails.
  • HospitalRevenueReader.IsLinkedToWarehouse (services/revenue_reader.go) is how other modules check "may this warehouse see this hospital".

3.3 Pincode → warehouse (serviceability)

Patients are not linked to hospitals for delivery. They are routed by pincode.

warehouse_service_areas (migrations/084_...up.sql) has one row per (warehouse_id, pincode). A partial unique index (pincode WHERE deleted_at IS NULL AND is_active) guarantees at most one active warehouse per pincode, so the resolver never has to choose between two.

WarehouseServiceAreaService.ResolveForPincode returns ErrAddressNotServiceable for an unmapped pincode. The migration header puts it plainly: "every resolver reading this table must fail closed for an unmapped pincode, never guess a default warehouse." Callers are cart, order placement, inventory search and prescription review (grep ResolveForPincode).

GET /assets/api/v1/serviceability?pincode= uses CheckServiceability. For an uncovered pincode it returns serviceable: false and logs the pincode as a demand signal. It does not return an error.

There is no API route that writes warehouse_service_areas (the assets routes only read it). Rows get there through ops or data entry. If the table is empty, no patient can order anywhere.

Patient addresses themselves live in human (internal/human/models/patient_address_pii.go). Free-text fields are encrypted. City, state and pincode stay in plain text because serviceability and finance reports need them.


4. Finance: the fixed partnership fee and payouts

4.1 The fee model: fixed monthly, not a percentage

The business decision (2026-08-17) was that a hospital partner is paid a fixed monthly amount, not a share of order revenue. The code matches that:

  • hospital_partnership_fees (assets/models/hospital_partnership_fee.go): hospital_id, monthly_amount DECIMAL(12,2), effective_from, effective_to (NULL = current), created_by.
  • Migration 192_hospital_partnership_fee_destructive_cutover.up.sql dropped hospitals.revenue_share_pct, orders.revenue_share_pct, and the per-order hospital_payout_lines table. Migration 190 archived that data first. A grep for revenue_share_pct in non-migration Go code returns nothing.
  • CreatePartnershipFee (hospital.go L252) never edits a fee. It locks the current row, requires new effective_from > current effective_from, closes the old row at new_from − 1 day, and inserts a new row. The fee history is append-only, so every old payout can still be explained.
  • When a hospital operator reads the fee history, redactFeesForPartner hides internal actor names.

Open business question, not a code fact: a later internal note (2026-08-30) records a claim that partners get "fixed monthly plus revenue share". The backend implements only the fixed fee. Do not add a share component without a product decision.

4.2 From monthly fee to weekly installment

Payouts are weekly. FeeInstallment (finance/services/payout.go L28):

go
func FeeInstallment(monthly decimal.Decimal) decimal.Decimal {
	return monthly.Mul(decimal.NewFromInt(12)).Div(decimal.NewFromInt(52)).Round(2)
}

Worked example for ₹4,00,000/month:

MethodResult
400000 × 12 / 52 (what the code does)₹92,307.69
400000 / 4.33333 (the stored divisor)₹92,307.83

The payout row stores weekly_divisor = 4.33333 for display only. Migration 191 says so, and the test TestFeeInstallment_IsAuthoritativeAndTheAuditDivisorOnlyApproximatesIt (payout_money_test.go) checks it. Never recompute a payout from that column.

The payout row also stores monthly_amount and fee_id at generation time. Changing the fee later does not rewrite old payouts.

4.3 Where the order money goes

Order money and partner money are separate flows. The hospital's payout does not depend on any order.

About internal/pkg/revenueshare: despite the name, it no longer computes a partner share. It is one SQL fragment for net delivered value of an order line:

go
const OrderNetLineExpr = `oi.line_selling * (oi.accepted_qty - oi.returned_qty)::numeric / NULLIF(oi.ordered_qty, 0)`

It is used in internal/dashboard/repositories/dashboard.go (hospital top medicines and payment-mode revenue), warehouse_summary.go, and internal/order/repositories/order.go (L327). It exists as a shared constant so the hospital dashboard and other readers cannot disagree about what "revenue" means. That disagreement once caused a bug (see Traps).

4.4 Payout state machine

models/payout.go CanTransitionTo:

What Transition (L278) checks for each target status:

  • Everything runs in one transaction after GetByIDForUpdate. paid is terminal (errPayoutIsTerminal).
  • hasLiveCapability (services/analytics.go L37) checks the capability on the token and, for super_admin, re-checks against the database (access.IsSuperAdmin). A super-admin whose access was revoked but whose token hasn't expired is still refused (TestPayoutWrites_RevokedSuperAdminIsRefusedDespiteTheClaim).
  • → pending: fee_amount must be non-zero, and the hospital must have complete bank details.
  • → approved: actor.DocID == p.CreatedBy returns errSelfApproval. For scheduler-generated payouts, CreatedBy is SystemActorID (00000000-...-0001), so any real approver passes this check.
  • → paid: needs a valid payout_mode (bank_transfer, upi, other) and a non-blank reference_no. It then:
    1. Looks up the hospital's primary bank detail and copies the bank name and last 4 digits onto the payout row (bank_name_snapshot, bank_account_last4_snapshot). A later edit to the bank record cannot change the record of where money already went. A missing bank record does not block payment, because "other" mode exists.
    2. Calls createPayoutExpense (L455), which inserts an expenses row with payout_id set, in the same transaction. If the hospital_revenue_share cost center or hospital_payout category is missing, the whole transition fails (errPayoutTaxonomyMissing). An expense linked to a payout cannot be voided (ExpenseService.VoiderrPayoutGenerated).
    3. After commit, NotifySettlementPaid fires with an INR-formatted amount (formatINR, lakh grouping).

4.5 Generation and the scheduler worker

PayoutService.Generate (L142):

  1. ActiveFeeHospitals(periodStart, periodEnd, ids) joins hospitals to hospital_partnership_fees where the fee overlaps the period (effective_from <= end AND (effective_to IS NULL OR effective_to >= start)).
  2. For each row, ExistsForPeriod(hospital, period_start) skips it if a payout already exists.
  3. It inserts with CreatePayoutIfAbsent: INSERT ... ON CONFLICT (hospital_id, period_start) WHERE status <> 'voided' DO NOTHING. The repository comment explains why this is not a plain insert. The loop runs in one transaction for all hospitals, so a unique violation from one concurrent insert would abort the whole batch. The TargetWhere must match the partial index predicate exactly, or Postgres rejects the statement.
  4. due_at = period_end + 7 days (hospitalPayoutSLADays).
  5. payout_no = PO-yymmdd-<hospitalID>-<6 hex from crypto/rand>. A clock-based suffix used to collide under concurrency (see the comment above nextPayoutNo).

PayoutScheduler (payout_scheduler.go) is started in cmd/server/main.go L731 as startWorker("finance.payout_scheduler", ...) and ticks every 5 minutes (finance/module.go):

Why is this safe with several ECS tasks running? Idempotency has two layers. The run marker stops repeated passes after one succeeds. The partial unique index stops duplicate payouts even when two instances run at the same moment. The run marker is an optimisation. The index is the real guarantee.

Each hospital is generated separately (HospitalIDs: []int64{hospitalID}), so one bad hospital does not roll back the others. Failures go into failure_detail on the run row and into the logs, with the manual retry call spelled out.

4.6 What the hospital sees

GET /finance/api/v1/my/payouts[/history|/summary|/{id}|/{id}/export] (services/hospital_payout.go) is scoped by HospitalScope. Statuses are collapsed (dtos/hospital_payout.go):

InternalHospital sees
draft, pending, approvedpending
paidapproved
voidedcancelled

History shows only approved, paid and voided (VisibleInternalStatuses), so draft and pending payouts are invisible to the partner. nextPayoutDates shows the next 3 Saturdays at 18:00 IST.

4.7 Finance analytics and expenses (brief)

  • AnalyticsService.Dashboard computes gross as SUM(orders.total_selling) over revenueCountableStatuses, and net as gross − accrued fees (payouts whose period_start is in the window, not voided) − refunds settled in the window. Refunds are stored in paise and divided by 100 in SQL.
  • Expenses follow the same pattern: CapExpenseCreate/Approve/Void, a lock-then-check transaction, self-approval refused, void requires a reason, and an expense_events audit row on every change.

4.8 Invoices and PDFs

No PDF generation exists in finance or procurement. The only PDF writer in the repo is internal/hr/services/payslip_pdf.go (Day 6). Finance exports are CSV only.


5. Money types: decimal everywhere

Every money column in these modules is shopspring/decimal.Decimal in Go and DECIMAL(12,2) (or wider) in Postgres: PO totals, payout amounts, fee amounts, GST lines. Payment-gateway amounts from Day 3 are int64 paise, converted with decimalFromPaise / paiseFromDecimal.

Why it matters: 0.1 + 0.2 != 0.3 in float64. A 12/52 installment is a repeating decimal. Rounding it once, explicitly, to paise (.Round(2)) is how a payout can match to the paisa. The export row type PayoutExportRow repeats the rule in its comment: "decimal.Decimal fields, never float64".


6. CSV exports: pkg/csvsafe

Excel, Numbers and Google Sheets run a cell as a formula if it starts with =, +, -, @, tab or CR. POST /leads/api/v1/leads is unauthenticated, so anyone can submit a lead named =HYPERLINK("http://evil","click"). Without protection, "export leads" becomes code execution on a founder's laptop.

csvsafe.Writer (internal/pkg/csvsafe/csvsafe.go):

  • It wraps encoding/csv in an unexported field, so no caller can reach the unsafe writer.
  • Sanitize prefixes ' to any field starting with a formula character, unless the field parses as a number. Without that exception, -42.50 in a finance export would become text and the spreadsheet totals would break.

Every export in this area uses it: leads, payouts (list and single), hospital payouts, expenses, analytics revenue breakdown, procurement vendor-wise. Services take *csvsafe.Writer as the parameter type, so the compiler enforces it.

The payout list export also counts first and refuses above 50,000 rows (maxPayoutExportRows, payout_export.go) before writing any header. The client gets a clean 400, never half a file.


7. Leads

A small module (internal/leads):

  • POST /leads/api/v1/leads has no auth, only limiter.Limit. It stores the lead with status new, then emails founders@medyzen.in. Every field is passed through html.EscapeString in the email body, because this is attacker-controlled input going into HTML. A mailer failure is logged and does not fail the request.
  • GET /leads and GET /leads/export call requireLiveSuperAdmin: the token role and a live database check.
  • PATCH /leads/{id}/status (new, contacted, converted) does not call requireLiveSuperAdmin in the service. It relies on middleware.Auth plus the endpoint RBAC allow-list. That is fine when the allow-list is in enforce mode, and weaker than its sibling endpoints when it isn't. Keep this in mind when reviewing.

Traps

1. The fee is fixed monthly, so don't bring percentages back. The share-percentage columns were dropped in migration 192. Old docs, old memory notes and marketing copy may still say "revenue share". The authoritative code is hospital_partnership_fees + FeeInstallment. If someone asks for "partner share of this order", that is a product decision, not a bug fix.

2. weekly_divisor is decoration.fee_amount = monthly × 12 / 52, rounded once to paise. monthly / 4.33333 differs by paise (₹92,307.69 vs ₹92,307.83 on ₹4 lakh). Any report that recomputes from the stored divisor will not reconcile.

3. "Revenue" has more than one definition, and one of them overstated returns. The hospital dashboard used to compute revenue from the gross bill (total_selling). total_selling is frozen at invoice time and does not go down on a partial return, so hospitals with returns saw inflated "Total Revenue Earned". The fix (2026-08-14) moved hospital dashboards to revenueshare.OrderNetLineExpr (delivered value: accepted − returned). Two things follow:

  • Any new hospital- or warehouse-facing revenue number must use revenueshare.OrderNetLineExpr / OrderNetSumExpr. Don't write your own SUM(total_selling).
  • The super-admin finance dashboard still starts from SUM(o.total_selling) (finance/repositories/analytics.go L70) and subtracts settled refunds separately. That is a different, cash-oriented view. Don't "fix" one to match the other without deciding which question each one answers.

4. CSV exports must use csvsafe. Never encoding/csv directly for anything a human opens in a spreadsheet. Lead names and messages come from the internet. Vendor names, expense titles and hospital names are typed by staff. Take *csvsafe.Writer in your service signature so the compiler enforces it.

5. Money is decimal.Decimal, never float. Watch for three things: strconv.ParseFloat on an amount, float64 in a DTO, and SUM(...)/100.0 scanned into a float (analytics scans it into a string, then mustDecimal, on purpose). mustDecimalStr in payout.go returns zero on a parse error, so a bad scan shows as ₹0 instead of an error. Don't copy it for anything that decides a payment.

6. Irreversible finance mutations need idempotency. Know which ones have it.

MutationProtectionRetry-safe?
Payout generateExistsForPeriod + partial unique index ON CONFLICT DO NOTHING + run markerYes
Payout → paidRow lock + paid is terminal; expense created in the same txYes (second call returns errPayoutIsTerminal)
Purchase return completeRow lock + must be pending + unique debit note numberYes
PO confirmRow lock + sent → completed transitionYes
PO RecordPaymentRow lock + overpayment cap only. No idempotency key, no unique reference.No. A double-submitted ₹10,000 partial payment on a ₹50,000 balance records twice.
Sales return writeNone of its own; relies on the order transition calling it onceOnly as safe as the caller

For new money endpoints, follow the payout paid pattern: lock, check a terminal state, write, all in one transaction. On the frontend, isPending is not a guard (see the Day 3 notes). The backend must refuse the second call.

7. A voided payout may not be regenerable through Generate, despite what the migration says. Migration 191 made idx_payout_hospital_period partial (WHERE status <> 'voided') so that voiding a payout would allow regenerating it. But ExistsForPeriod (finance/repositories/payout.go L174) counts rows regardless of status, and both Preview and Generate skip on it. As written, a voided week is never regenerated for that hospital. No test covers void-then-regenerate. Not verified in a running system. Confirm with Exercise 4 before relying on either behaviour.

8. A fee change inside a payout week gives two candidate rows.activeFeeQuery returns every fee row overlapping the week, ordered only by h.id. If a fee changes mid-week, both the old and new rows match. Generate inserts from whichever comes first and skips the second via ExistsForPeriod. Which fee wins is not deterministic from the SQL. Not verified in practice. Setting effective_from on a Sunday (the period start) avoids it.

9. payout_no and po_no both start with PO-. A purchase order is PO-000123. A hospital payout is PO-260919-7-A1B2C3. They are different tables and different meanings. Say which one you mean in tickets and logs.

10. Confirming a PO does not complete the hospital's request.Confirm leaves tagged requests in ordered. The hospital sees "ordered" until the warehouse calls POST /requests/{id}/procured. If partners ask "why does my request still say ordered?", this is why.

11. Scheduler-generated payouts are "created by" the system actor. The self-approval check compares the approver with SystemActorID for auto-generated payouts, so a single super-admin can move a scheduler payout through pending → approved → paid alone. The two-person rule only has force on payouts a human generated. Note that approved → paid does not check that payer ≠ approver.

12. No primary warehouse means an arbitrary warehouse.PrimaryWarehouseID falls back to the first hospital_warehouses row when none is primary. AssignHospital and UpdateAssignment do not clear other primaries, so a hospital can also end up with two primaries (first one found wins). Procurement requests route by this value.

13. An empty warehouse_service_areas table means nothing is serviceable. It fails closed on purpose, and no API writes to it. A "patients can't order" report is often a data problem, not a code problem.

14. GST: inclusive vs exclusive. Purchase side: gst = taxable × pct / 100 (added on top). Sales-return side: gst = gross × pct / (100 + pct) (extracted from a GST-inclusive price). Also, GSTPercent on PO and purchase-return lines always comes from the product master. A product with no master GST gets a nil line GST, and the header total becomes nil ("unclassified"), not zero.

15. Payout timezone. The scheduler works in Asia/Kolkata (reportTimezone). weekBounds builds dates in that location. Tests that use time.Now() in UTC near midnight on Saturday will flake. Use tickAt with a fixed clock, as the existing tests do.


Exercises

All exercises run locally. Never point anything at production. Before starting, blank every real secret in .env: config.Load() reads it, and a local run with real keys can send real SMS or email or touch real storage.

Setup:

bash
cd medyzen-backend
make docker-up                       # Postgres exposed on localhost:5434, API on :8080
createdb -h localhost -p 5434 -U postgres medyzen_test
export TEST_DATABASE_URL="postgres://postgres:postgres@localhost:5434/medyzen_test?sslmode=disable"

Exercise 1: fee math (no database)

bash
go test ./internal/finance/services -run 'TestFeeInstallment|TestNextPayoutDates|TestPayoutStateMachine' -v

Then add a scratch table test (don't commit it) asserting FeeInstallment(400000) == 92307.69 and FeeInstallment(1) == 0.23. Explain in one sentence why decimal.NewFromFloat(400000.0/4.33333) is the wrong way to compute it.

Exercise 2: csvsafe

bash
go test ./internal/pkg/csvsafe -v

Write three inputs you expect to be prefixed and three you expect to be left alone. Include -12.5, -cmd, @SUM(A1), +91 98xxxxxxxx (a phone number!), and an empty string. Predict each result first, then run Sanitize in a scratch test. What happens to a phone number stored with a leading +, and is that acceptable in a leads export?

Exercise 3: procurement end to end (integration)

bash
go test -tags integration -race -count=1 ./internal/procurement/... -run 'PurchaseOrder|Procurement' -v

Read internal/procurement/services/purchase_order_test.go and find the test that covers Confirm receiving stock. Then write down the exact sequence of HTTP calls (paths from routes.go) a warehouse user makes to: tag two pending requests onto one PO, send it, confirm with one line short-delivered, record a partial payment, then complete a purchase return linked to that PO. For each step, write the request and PO statuses you expect.

Exercise 4: verify Trap 7 (void, then regenerate)

In a scratch integration test under internal/finance/services (build tag integration, using the same helpers as payout_integration_test.go):

  1. Seed a hospital with a fee.
  2. Generate for a week → one draft payout.
  3. Transition to voided with a reason.
  4. Generate again for the same week.

Does a new draft appear? Report the result and which line of code decides it. Delete the scratch test afterwards.

Exercise 5: find the idempotency gap

Against the local API (seed a super-admin with make seed-admin EMAIL=you@example.com, local only), or by reading RecordPayment: show that posting the same partial PO payment twice records two po_payments rows. Then write a short design (no code) for making it idempotent. Options include a unique (purchase_order_id, reference_no) for non-cash modes, or an Idempotency-Key header. Explain which migration safety concerns from Day 7 apply.

Exercise 6: revenue definitions

Using psql on your local database only, create one order with ordered_qty = 10, accepted_qty = 10, returned_qty = 4, line_selling = 1000. Compute by hand:

  • (a) what revenueshare.OrderNetLineExpr yields
  • (b) what SUM(total_selling) yields

Which one should a hospital partner see? Which one does /finance/api/v1/analytics/dashboard start from?


Self-check

  1. A hospital operator calls POST /procurement/api/v1/requests/{id}/cancel on a request in approved. What happens, and why is it designed that way?
  2. Which field on a procurement request does the client not get to choose, and where does its value come from?
  3. What must be true about the request body of POST /purchase-orders/{id}/confirm, beyond valid numbers?
  4. When a PO in sent is cancelled, what happens to its linked requests?
  5. Write the PO balance formula. What caps a purchase-return debit note?
  6. A hospital's fee is ₹1,30,000/month. What is the weekly fee_amount? What value is stored in weekly_divisor, and what is it used for?
  7. List the conditions to move a payout draft → pending, pending → approved, and approved → paid.
  8. Why does Generate use ON CONFLICT ... DO NOTHING rather than letting a unique violation surface?
  9. What two mechanisms stop the Saturday scheduler from paying a hospital twice when two server instances run it at once?
  10. What does internal/pkg/revenueshare compute today, and why must hospital-facing revenue use it instead of orders.total_selling?
  11. A founder opens a leads CSV and a cell runs a formula. Which package should have prevented it, and what are its two rules?
  12. Name one money mutation in today's modules that is not safe to retry, and say what makes it unsafe.

Answers

  1. It is refused with ErrInvalidTransition. Hospital-side cancel is allowed only while pending (procurement.go L170). Once the warehouse has approved or raised a PO, cancelling would strand warehouse work that has already started.
  2. warehouse_id. Submit resolves it with hwReader.PrimaryWarehouseID(hospitalID) from hospital_warehouses (primary row, otherwise the first row).
  3. It must list exactly the PO's current items, each once, with no extras and no omissions (ErrPOItemsMismatch). qty_received ≤ qty_ordered. delivery_date not in the future. Batch number, expiry, MRP and selling price for every line with qty_received > 0. The PO must be sent.
  4. Each linked request still in ordered goes back to pending, po_id is cleared, and an event row with the cancellation reason is appended.
  5. balance = max(0, total_actual + total_gst − total_paid − total_debit_notes). The debit applied is capped at the PO's current outstanding balance, and the cap is logged.
  6. 130000 × 12 / 52 = 30000.00. weekly_divisor stores 4.33333 for audit and display only. The amount is never computed from it.
  7. → pending: CapPayoutCreate (live check), fee_amount ≠ 0, complete bank details on file. → approved: CapPayoutApprove, approver ≠ created_by. → paid: CapPayoutPay, valid payout_mode, non-blank reference_no. On paid, it also snapshots the bank details and creates an expense row in the same transaction. In every case the payout must not already be paid.
  8. The whole batch runs in one transaction. A unique violation from a concurrent insert for one hospital would abort the transaction and lose every other hospital's payout. DO NOTHING turns losing the race into a skip. The TargetWhere must match the partial index predicate.
  9. The payout_generation_runs marker (unique period_start, checked with HasRun), and the partial unique index idx_payout_hospital_period on (hospital_id, period_start) WHERE status <> 'voided'. The index is the real guarantee.
  10. A SQL fragment for net delivered line value: line_selling × (accepted_qty − returned_qty) / ordered_qty. It is no longer a partner share. total_selling is frozen at invoice time and is not reduced by partial returns, so using it overstated hospital revenue.
  11. internal/pkg/csvsafe. (a) Prefix ' to any field starting with = + - @ \t \r. (b) Leave the field alone if it parses as a number, so negative amounts stay numeric. The raw writer is unexported, so the check can't be bypassed.
  12. PurchaseOrderService.RecordPayment. It locks the row and caps overpayment, but has no idempotency key or unique reference, so a duplicate partial payment within the balance is recorded twice. (Also acceptable: WriteSalesReturn, which has no guard of its own.)

Internal — written against the code, not the other way around.