Skip to content

Day 3 — Patient Commerce: Cart, Orders, Payments

Version: 1.0 | Status: written against code 2026-09-16

Day 3 follows the money. A patient builds a cart, checks out, pays through Razorpay, and a warehouse fulfils and delivers the order. Along the way you will meet the order state machine, the payment ledger, the webhook and reconciler that keep that ledger honest, the realtime stream that keeps warehouse tablets up to date, and the SMS, email and push clients the backend uses to reach people.

Production runs REAL Razorpay. Anything you change in internal/order/services/payment_*.go, order.go or reconciler.go moves real patients' money once it deploys. Every exercise below uses a local stack and Razorpay test keys (rzp_test_...) or the mock gateway. Never point a local build at live keys.


Goals

By the end of today you can:

  1. Explain how a cart is tied to a warehouse, and why an empty warehouse_service_areas table makes the whole app unable to sell anything.
  2. Draw the order status state machine from memory and name the extra guards that sit on top of it.
  3. Trace one online order from PUT /cart to confirmed, naming every table row written and every lock taken.
  4. Explain the three independent paths that can settle a payment (client verify, webhook, reconciler retry) and why they cannot double-settle or double-charge.
  5. Explain how a kiosk order is placed on behalf of a patient (X-Kiosk-Id + X-Patient-Id), and why it belongs to the patient, not the operator.
  6. Describe how delivery assignment, handover and the delivery OTP work, and what DELIVERY_OTP_ENFORCE=false really means.
  7. Explain why realtime events carry no order data, and which order changes do not publish one.
  8. Describe the SMS (2Factor), email (ZeptoMail) and push (Expo) clients, including the SMS daily budget and the DLT template trap.

Reading order

#FileWhat to look for
1medyzen-backend/internal/cart/models/cart.goCart statuses (active, pending_payment, checked_out), channels (app, kiosk), decimal.Decimal totals
2medyzen-backend/internal/cart/routes/routes.goOnly three routes; ForbidKioskHeaderForEntityType(EntityUser)
3medyzen-backend/internal/cart/handlers/cart.go (lines 28-60)How channel, kiosk ID and acting patient are derived from the actor
4medyzen-backend/internal/pkg/acting/acting.goResolve: when X-Patient-Id is allowed and what the binder checks
5medyzen-backend/internal/cart/services/cart.goupdateAppCart (pincode → warehouse), updateKioskCart, enforceQuantityCap, applyTotals
6medyzen-backend/internal/cart/services/app_warehouse_resolver.go + internal/assets/repositories/warehouse_service_area.go (line 28)The pincode lookup that everything depends on
7medyzen-backend/internal/order/models/order.goSources, payment modes, 16 statuses, 6 payment statuses
8medyzen-backend/internal/order/services/statemachine.goallowedTransitions, the three must* guards, effectFor
9medyzen-backend/internal/order/services/order.go (151-276)PlaceFromCart: one transaction, stock reservation, cart attach
10medyzen-backend/internal/order/services/order.go (473-655)InitiatePayment, ConfirmPayment, HandlePaymentWebhook, markPaidAndAdvance
11medyzen-backend/internal/order/services/payment_attempt.goensureOpenPaymentAttempt — the double-charge guard
12medyzen-backend/internal/order/services/payment_razorpay.goThe only file that talks to Razorpay; signature verification
13medyzen-backend/internal/order/module.go + payment_gateway_dev.go / payment_gateway_prod.goWhy a production binary cannot run the mock gateway
14medyzen-backend/internal/order/services/payment_processing.goprocessWebhookEvent, applyPaymentEvent, refunds, recomputeRefundState
15medyzen-backend/internal/order/services/reconciler.go + reconciliation.goThe order.reconciler worker loop and the daily gateway-vs-ledger diff
16medyzen-backend/internal/order/services/order.go (696-773, 1016-1219)Transition, exitWithRelease, commitTransition
17medyzen-backend/internal/order/services/patient_order.go (69-106) + kiosk_cancel.goPatient and kiosk cancellation rules
18medyzen-backend/internal/order/services/delivery.go (173-630)Assign, HandoverConfirm, MarkDelivered, verifyOTP, ResendOTP
19medyzen-backend/internal/realtime/broker.go + handler.goRedis pub/sub fan-out, SSE stream, invalidation-only events
20medyzen-backend/internal/pkg/sms/twofactor.go + budget.go; cmd/server/main.go (1177-1207)2Factor client, DLT template names, daily cap
21medyzen-backend/internal/pkg/mailer/mailer.go, internal/pkg/push/push.goZeptoMail and Expo clients
22medyzen-backend/internal/notification/The in-app hospital notification feed (not SMS/push)

A note on paths: the course brief mentions pkg/mailer and pkg/push. There is no top-level pkg/ directory. Both live under internal/pkg/.


1. The cart

1.1 One cart per patient, per channel

A cart row (carts table, internal/cart/models/cart.go) has:

  • user_id — always the patient, never the operator.
  • channelapp (patient's phone) or kiosk (hospital counter).
  • kiosk_id, hospital_id — only set for kiosk carts.
  • warehouse_idnot null. A cart cannot exist without a warehouse, because prices and stock are per warehouse.
  • statusactivepending_payment (an order is attached) → checked_out.
  • total_mrp, total_sellingdecimal.Decimal stored as decimal(12,2).

There are only three HTTP routes (internal/cart/routes/routes.go):

PUT    /cart/api/v1/cart
GET    /cart/api/v1/cart
DELETE /cart/api/v1/cart/unavailable-items/{document_id}

PUT is a full replace: the client sends the whole item list and the server rebuilds the cart. There is no "add one item" endpoint. That keeps the server as the only place prices and stock are decided.

1.2 Who is the patient? Channel and acting identity

CartHandler.reqCtx (internal/cart/handlers/cart.go:28-60) and the order module's checkoutContext (internal/order/handlers/order.go:36-72) derive three things from the JWT actor:

Actor entity typeChannelKiosk ID
EntityUser (patient app)appnone; sending X-Kiosk-Id is rejected by ForbidKioskHeaderForEntityType middleware
EntityHospitalOperator (kiosk)kioskrequired from X-Kiosk-Id, else ErrMissingKioskID
anything elseErrForbidden

Then acting.Resolve (internal/pkg/acting/acting.go:19-42) decides whose cart it is:

  • No X-Patient-Id header (or it equals the caller): the caller is the patient. A kiosk operator doing this is still checked with AssertOperatorAtKiosk.
  • X-Patient-Id set to someone else: only allowed with a kiosk ID, and binder.AssertActingPatient must pass. The binder is KioskPatientService (internal/assets/services/kiosk_patient.go:72), which checks that the operator is assigned to that kiosk and has a live patient session there.

That session is created by POST /assets/api/v1/kiosk/patients/resolve. ResolvePatient normalises the phone to E.164 and calls PatientDirectory.FindOrCreateByPhone. So a kiosk order is keyed to the patient's user account, found by phone. The operator is only recorded in orders.placed_by_operator_id.

Why: a patient who walks up to a kiosk and later installs the app should see that order in their history. If kiosk orders belonged to the operator, every patient served at a hospital would be merged into one account.

1.3 App cart: pincode decides the warehouse

updateAppCart (internal/cart/services/cart.go:194-265):

  1. Lock the active cart FOR UPDATE (GetActiveForUpdate, internal/cart/repositories/cart.go:45).

  2. If none exists, the request must carry a pincode, else ErrAddressNotServiceable.

  3. appRes.ResolveForPincode(pincode) → a warehouse ID. This is WarehouseServiceAreaService (internal/assets/services/warehouse_service_area.go:23), which runs:

    sql
    WHERE pincode = ? AND is_active = true   -- warehouse_service_areas

    No row means ErrAddressNotServiceable.

  4. Allocate stock at that warehouse (StockAllocator.Allocate, owned by inventory — Day 4).

  5. Check the prescription quantity cap, replace the items, recompute totals, save.

If appRes is nil, NewCartService puts in NotServiceableAppWarehouseResolver, which refuses every pincode. Failing closed is deliberate: a cart with no warehouse would have no prices.

Checkout checks the pincode again. PlaceFromCart resolves the chosen address's pincode and fails with ErrAddressWarehouseMismatch if it maps to a different warehouse than the cart (internal/order/services/order.go:207-215). The patient cannot price a cart in one city and ship it to another.

1.4 Kiosk cart: kiosk decides the warehouse

updateKioskCart calls KioskResolver.ResolveContext(kioskID) (internal/assets/services/kiosk.go:89). It loads the kiosk, rejects disabled kiosks, and picks the hospital's primary warehouse mapping (ErrKioskNoWarehouse if there is none). No pincode is involved.

1.5 Prescription quantity cap

enforceQuantityCap (cart.go:304-340): for any product that requires a prescription, the patient cannot raise the quantity above what is already in the cart (ErrPrescriptionQuantityIncreaseNotAllowed). Quantities for Rx products come from a pharmacist's review via PushReviewItems, which exempts those products from the cap. This is a compliance rule, not a UX rule: the patient must not be able to order more of a prescription drug than was reviewed.

1.6 Money in the cart

Line totals are UnitMRP.Mul(decimal.NewFromInt(qty)) (buildItems, cart.go:643-683). Cart totals are summed with Add (applyTotals). No float64 touches a price anywhere in internal/cart or internal/order (checked with grep). The only floats in internal/order are delivery GPS lat/lng from the rider app (dtos/delivery.go:149, converted with decimal.NewFromFloat), and Razorpay's JSON amount, which razorpayAmount turns straight into int64 paise.


2. The order model

internal/order/models/order.go:

ConceptValues
Sourcekiosk, whatsapp, walk_in, mobile_app
PaymentModecod, online, instant_cash
Status16 values (see diagram)
PaymentStatuspending, paid, failed, refund_initiated, refunded, partially_refunded

Things to notice:

  • Totals (total_mrp, total_discount, total_selling, total_gst) are all decimal.Decimal.
  • Patient name, phone, address and landmark are gorm:"-". The real columns are *_ciphertext plus blind indexes (patient_phone_bidx10 etc.). This is the PII encryption from Day 2. Only city, state and pincode stay as plain text.
  • Source is derived from the actor, never taken from the request: deriveSource (order.go:140-149) maps operator → kiosk and user → mobile_app. walk_in is set only by CreateWalkIn. The whatsapp source exists in the model and state machine, but no code path in internal/order sets it (not verified elsewhere).

Related tables you will see today:

TableModelPurpose
order_itemsOrderItemSnapshot of cart lines at checkout, with ordered/accepted/returned qty
order_status_eventsOrderStatusEventAppend-only audit of every status change
order_paymentsOrderPaymentThe money ledger: payments and refunds, in paise
payment_webhook_eventsPaymentWebhookEventRaw Razorpay webhooks, deduped, retried
payment_reconciliation_runs / _discrepanciespayment_reconciliation.goDaily gateway-vs-ledger diff
order_delivery_assignmentsOrderDeliveryAssignmentRider assignment history
order_delivery_otpsOrderDeliveryOTPHashed handover OTPs

3. The order state machine

3.1 The table

allowedTransitions in internal/order/services/statemachine.go:5-49 is the single source of truth. CanTransition(from, to) just looks up that table. IsTerminal(status) is "has no outgoing edges", which gives cancelled, rejected and returned (tested in statemachine_matrix_test.go).

completed is not terminal. A completed order can still be returned.

3.2 Guards on top of the table

The generic Transition endpoint (POST /order/api/v1/orders/{id}/transition, order.go:696-773) adds rules the table cannot express:

GuardRuleWhy
IsTerminalTerminal orders cannot move (ErrOrderFinalized)Money and stock are already settled
mustDispatchViaHandoverready_for_pickup → out_for_delivery is refused here (ErrDispatchViaHandoverRequired)Dispatch must go through HandoverConfirm, which mints the delivery OTP
mustNotCancelInFlightout_for_delivery → cancelled refused (ErrOrderNotCancellable)Goods are on a bike; staff cannot cancel them from a desk
mustBePaidBeforeFulfilmentAn online order in awaiting_payment cannot move forward unless payment is settled (ErrOnlineOrderNotPaid). Cancel and reject stay open. Cash modes are exempt.Stops a warehouse from shipping an online order that was never paid
Settlement pendingA paid confirmed order whose item edits left a non-zero paise delta cannot move to a non-refund status (ErrOrderSettlementPending)An edited order must be settled before it is worked
Reserved reasonsisReservedTransitionReason refuses reasons like otp_verified or cash_collected from clientsThose strings are audit markers the server writes itself

Note: out_for_delivery → cancelled is in the table because KioskCancel needs it. KioskCancellable (dtos/kiosk_order.go) allows that edge only for a kiosk-source COD order, and KioskCancel calls exitWithRelease directly, so it never hits mustNotCancelInFlight.

3.3 Side effects: effectFor

effectFor(to) (statemachine.go:93-107) says what a status change does to stock and money:

TargetRelease stockRestock (sellable again)Refund
cancelled, rejectedyesyesyes
partially_acceptedyes (rejected lines)yesyes
returned, partially_returnedyesno (written off)yes
anything else

When a transition has an effect, Transition calls exitWithRelease (order.go:1016-1104):

  1. applyFull or applyPartial works out stock changes and the refund amount (as decimal).
  2. stock.Release(..., restock, ...).
  3. recomputeTotals. A partially_accepted order that has no quantity left becomes rejected.
  4. If the order was paid and the refund is positive, call recordRefundIntent (settlement.go:78). For an online order this writes a type=refund, status=pending ledger row, capped at captured minus already refunded, and sets payment_status=refund_initiated.
  5. commitTransition: check CanTransition again, save the order and items, append an order_status_events row.
  6. For returns, write a sales-return record through salesReturnWriter. This must run inside a transaction or it errors.

After the transaction commits, Transition does three best-effort things: it voids any open Razorpay payment link on an unpaid terminal order, starts ProcessPendingRefunds straight away if a refund was queued, and publishes a realtime event.


4. Placing an order

4.1 PlaceFromCart (patient app and kiosk)

POST /order/api/v1/orders/checkoutOrderService.PlaceFromCart (order.go:151-276).

Checks before the transaction:

  • payment_mode must be valid.
  • An operator cannot be their own patient (ErrOperatorCannotBePatient).
  • App channel cannot use instant_cash.
  • App channel must send address_id, and the address must belong to the patient (addrReader.GetOwned).

The initial status:

Channel + modeInitial status
kiosk + codfirstOperationalStatus(kiosk) = confirmed; cart marked checked_out at once
everything elseawaiting_payment

Inside one DB transaction:

  1. cartRead.LoadForCheckout locks the active cart FOR UPDATE and requires items.
    • Idempotent retry: if there is no active cart but a pending_payment cart exists, it returns the already-created order instead of failing. A double-tapped "Place order" gets the same order back.
  2. App: re-resolve the address pincode and compare it with the cart's warehouse.
  3. Build the order: ORD- plus 8 hex characters, items snapshotted from cart lines (snapshotItems), GST per line computed as lineSelling × gst% ÷ (100 + gst%), rounded to 2 places (computeLineGST). Prices are GST-inclusive.
  4. repo.Create, then stock.Reserve at the warehouse.
  5. cartFinal.AttachOrder sets the cart to pending_payment with order_id.
  6. Append the first order_status_events row.

After commit: publishOrderEvent(order.created).

4.2 Other channels

PathCodeNotes
Walk-in counter saleCreateWalkIn, order.go:278-370Staff only. Refuses cod. Allocates stock directly (no cart), status completed, payment_status=paid at once. Any shortfall → ErrOutOfStock.
Kiosk cashSettlePayment, order.go:430-471For instant_cash/cod orders in awaiting_payment. success=truemarkPaidAndAdvance writes a provider=cash captured ledger row. success=falsefailPaymentAndReopen cancels, releases stock, and reopens the cart so the patient can retry. Patients cannot settle their own cash orders; riders cannot call it at all.
Reorderservices/reorder.goNot covered in depth today.

5. Payments

5.1 Gateway selection: mock can never ship

newPaymentGateway (internal/order/module.go:104-116):

  • PAYMENT_PROVIDER=razorpay needs RAZORPAY_KEY_ID, RAZORPAY_KEY_SECRET and RAZORPAY_WEBHOOK_SECRET. If any is missing the process refuses to start. An empty webhook secret would make HMAC signatures forgeable.
  • Any other value uses the mock gateway, only if mockPaymentGatewayAllowed() is true. That function has two build-tagged versions: payment_gateway_dev.go (//go:build dev || integration) returns true, and payment_gateway_prod.go returns false.

The mock's VerifyPaymentSignature and VerifyWebhookSignature always return true (payment_mock.go:51-57). The build tag is the only thing stopping a production binary from accepting any "payment". There is deliberately no env var for it. The Dockerfile builds with no tags. Note that config.go:366 defaults PAYMENT_PROVIDER to mock, so a plain go run ./cmd/server without -tags dev will refuse to start.

5.2 Paise, not rupees

The order stores rupees as decimal(12,2). Razorpay works in paise as integers. The one conversion is paiseFromDecimal (order.go:684-686): amount × 100, round to 0 places, IntPart(). Every ledger row (order_payments.amount_paise) is int64 paise. Amounts are compared as integers, never as floats.

5.3 The checkout → payment → confirmation sequence

Note: the confirmation to confirmed (from verify or webhook) does not publish a realtime event. Only PlaceFromCart, CreateWalkIn and Transition do. See Traps.

5.4 Initiate: the double-charge guard

InitiatePayment (order.go:478-525) runs under GetByIDForUpdate, a Postgres row lock on the order. Inside the lock, ensureOpenPaymentAttempt (payment_attempt.go:11-48) reuses the latest attempt if it is still created, has a Razorpay order ID, and has the same amount. Only otherwise does it call CreateOrder.

Why the lock matters, from the code's own explanation: without it, two parallel taps both read "no open attempt", both open a Razorpay order for the full amount, and both are payable. The patient can then be charged twice.

For kiosk orders with kioskPaymentLinksEnabled, a Razorpay payment link (15-minute TTL, PaymentLinkTTL) is also created or reused, keyed by reference_id = order_no. That link can be shown as a QR code on the kiosk.

5.5 Verify: amount first, then signature

ConfirmPayment (order.go:527-591), all under the order row lock:

  1. The attempt must belong to this order (p.OrderID != o.ID → not found).
  2. Already paid → return the order unchanged (idempotent).
  3. Order must still be awaiting_payment.
  4. A flagged attempt → ErrPaymentUnderReview.
  5. Amount check: p.AmountPaise must equal paiseFromDecimal(o.TotalSelling). A Razorpay signature proves only that payment P belongs to gateway order G. It says nothing about whether G is still worth what the order is worth now, for example after a pharmacist edited the items.
  6. VerifyPaymentSignature (Razorpay SDK utils.VerifyPaymentSignature with the key secret). If it fails, the attempt is marked failed, the transaction commits that, and only then does the caller get ErrInvalidSignature. Returning the error inside the transaction would roll back the evidence.
  7. Success: attempt → captured with signature_verified=true, then markPaidAndAdvancefirstOperationalStatus(source) → cart checked_out.

5.6 Webhook: signature, dedupe, process, never lose it

POST /order/api/v1/payments/webhook is the only unauthenticated route in the module. It has a per-IP rate limiter (default 20/s, burst 60, module.go:85-88) and a 1 MB body cap (maxWebhookBody, handlers/order.go:25).

HandlePaymentWebhook (order.go:593-618):

  1. VerifyWebhookSignature(body, X-Razorpay-Signature) using the webhook secret. That is a different secret from the key secret. If it fails, the medyzen_order_payment_webhook_signature_invalid_total counter goes up and a loud error is logged. A sudden rise usually means RAZORPAY_WEBHOOK_SECRET no longer matches the Razorpay dashboard.
  2. Dedupe key = SHA-256 of the raw body. The X-Razorpay-Event-Id header is read by the handler but replaced by the body hash (line 603-604). Insert uses ON CONFLICT (event_id) DO NOTHING (repositories/webhook_event.go). A replayed identical body is a no-op.
  3. Process inline. If processing fails, log it and still return 200. The event row stays processed_at IS NULL, and the reconciler retries it.

processWebhookEvent (payment_processing.go:54-89) runs in one transaction:

  • refund.*applyRefundEvent.
  • Payment events: find the ledger row by razorpay_order_id. If that fails, fall back to notes.order_no (reconcileByOrderNo). Then lock the order and call applyPaymentEvent.
  • If an event cannot be matched to a row, unreconciled raises a money alert and increments attempts. After 5 attempts (maxReconcileAttempts) it is marked processed with an error, so it stops blocking the queue. The alert text says plainly that this does NOT mean the money is accounted for.

applyPaymentEvent for payment.captured / order.paid (payment_processing.go:126-246). Both events fire for one capture, so every branch must be replay-safe:

SituationOutcome
Order already paid, same row, same payment IDno-op (replay)
Order already paid, same row captured, different payment IDinsert a new flagged row; alert double_capture_settled (patient very likely paid twice)
Order already paid by another rowthis row → flagged; alert double_capture_other_row
Order terminal (cancelled etc.) and not paidrecord capture, insert a pending refund row for the full amount, payment_status=refund_initiated; alert capture_on_cancelled_order
Order past awaiting_payment and not doorstep-collectablerow → flagged; alert capture_wrong_order_state
Amount ≠ attempt amountrow → flagged; alert amount_mismatch
Doorstep online collection on an order already in fulfilmentmark paid, add an online_collected status event, leave the status alone
Normalrow → captured, markPaidAndAdvance

payment.failed only marks a created attempt as failed. The order stays awaiting_payment so the patient can retry.

Design rule: the webhook never silently drops money. Anything odd becomes a flagged ledger row plus a medyzen_order_money_alert_total{reason=...} metric (money_alert.go). Humans resolve it from the payment-integrity console routes (/order/api/v1/payments/flagged, /reconciliation/...).

5.7 Why three settlers cannot double-settle

Client verify, the inline webhook and the reconciler's webhook retry can all try to settle the same payment. They stay correct because:

  1. All of them take SELECT ... FOR UPDATE on the order row first, so they run one at a time.
  2. Whichever runs second sees payment_status=paid and either returns (verify) or hits the replay no-op (webhook).
  3. Webhook rows are unique on the body hash.
  4. Refund rows are claimed with FOR UPDATE SKIP LOCKED and moved to refunding (ClaimPendingRefunds, repositories/order_payment.go:193-223). A second worker skips rows that are already claimed. A claim expires after 5 minutes (refundClaimTTL), which is longer than the 20 s Razorpay timeout.
  5. Each refund call sends notes.idempotency_key = ledger row document_id. Before calling Refund, processRefund asks FindRefundByKey, so a crash between "Razorpay accepted" and "we saved" does not refund twice.

5.8 Refunds are asynchronous

processRefund (payment_processing.go:394-456) only moves a row to refunded or failed when Razorpay reports that final status. A pending refund stays claimed until a refund.processed / refund.failed webhook arrives or the claim goes stale. recomputeRefundState works out payment_status again from sums in the ledger (captured, refunded, in flight), so a failed refund puts the order back to owing money. Refunds issued by hand from the Razorpay dashboard arrive as webhooks. resolveRefundRow creates a ledger row for them so the books match the gateway.

5.9 The order.reconciler worker

Registered in cmd/server/main.go:730 as startWorker("order.reconciler", orderMod.Reconciler.Start), under worker.Supervise, which restarts the loop if it panics. PaymentReconciler.Start (reconciler.go:58-97) runs three tickers:

TickerIntervalWork
mainPAYMENT_RECONCILE_SECONDS, default 30 sProcessPendingRefunds then ProcessPendingWebhookEvents (batches of 50)
purge1 hRedact raw webhook payloads older than retention (default 90 days) to {"redacted":true}. The payloads contain payer name, email, phone and card metadata.
reconchecks every 15 minOnce per IST day, at least 2 h after midnight: RunDailyReconciliation for yesterday

ReconcileWindow (reconciliation.go:69+) lists Razorpay captures and refunds for the window and diffs them against the ledger in both directions. The six discrepancy kinds are missing_capture, phantom_capture, capture_amount_mismatch, missing_refund, phantom_refund and refund_amount_mismatch. It is read-only. The code explains why: a pass that fixes itself would destroy the evidence. Results go to payment_reconciliation_runs / _discrepancies.

lastRecon lives in memory, per process. Each ECS task runs its own reconciler, so in a multi-task deployment the daily report may run once per task. Whether prod runs more than one task at a time is not verified here (see Day 7). The refund and webhook passes are safe to run in parallel because of the locks above.


6. Cancellation and returns

WhoEntry pointAllowed when
Patient (app)POST /orders/patient-view/{docId}/cancelPatientCancelmobile_app source; status awaiting_payment, pending or confirmed; not online-mode in awaiting_payment (dtos.PatientCancellable)
Kiosk operatorPOST /orders/{id}/kiosk-cancelKioskCancelkiosk source; any status up to ready_for_pickup, or out_for_delivery if COD
Warehouse staffPOST /orders/{id}/transitionTransitionAnything the state machine and guards allow

Why a patient cannot cancel an unpaid online order: the Razorpay checkout may already be open on their phone. Cancelling now would set up the "capture on a cancelled order" path. That path is handled (automatic refund), but it is better not to start it.

All three call exitWithRelease and then cartFinal.ReleaseIfPending, which moves the cart from pending_payment to checked_out only if it still points at this order. After commit, a queued refund is started inline, and the reconciler catches anything that start misses.

Returns (returned, partially_returned) use ItemAdjustment lists. Returned stock is written off, not restocked, and a sales-return document is written for GST.


7. Delivery: assignment, handover, OTP

internal/order/services/delivery.go.

Assign (delivery.go:173-236): the order must be ready_for_pickup. The rider must be a delivery partner, active, available, attached to the order's warehouse, and either KYC-approved or still inside the re-KYC grace period. The previous assignment is closed with a reason, a new row is inserted, and orders.delivery_partner_id is set. The push notification goes out after commit.

Handover (238-296) is the only way into out_for_delivery. In one transaction it changes the status, appends the event and stores an order_delivery_otps row. The row holds code_hash (salted, peppered with DELIVERY_OTP_PEPPER), expires_at = now + 10 min, and last_sent_at. The SMS is sent after commit. If the send fails, it is logged and not returned as an error, because rolling back a physical handover over a vendor blip would leave the rider stuck.

MarkDelivered (403-490):

  • Only the assigned rider can call it. Other riders get "not found", not "forbidden", so order IDs are not leaked.
  • No OTP row at all → legacy_bypass reason (orders from before OTPs existed).
  • DELIVERY_OTP_ENFORCE=false → only the break-glass EMERGENCY_OTP value is accepted (common.IsEmergencyOTP), recorded as otp_bypass. So "not enforced" does not mean "any code works".
  • Enforced → verifyOTP. A wrong code increments attempt_count. At DeliveryOTPMaxAttempts (5) the row locks for 15 minutes. The failed-attempt write must survive, so the wrong-code error is carried out of the transaction and returned after commit. Returning it inside would roll the counter back and make brute force free.

Resend (rider): 30 s cooldown, at most 5 resends. Reset (warehouse staff): mints a fresh row with no limit and logs an otp_reset event.

main.go:1193-1207 explains the rollout state. The delivery OTP needs its own DLT template (TWOFACTOR_DELIVERY_TEMPLATE). Until that is set, the provider is sms.Noop, the code is stored but never texted, and DELIVERY_OTP_ENFORCE must stay false.


8. Realtime: invalidation signals over Redis

internal/realtime/.

Route: GET /order/api/v1/orders/stream (authenticated). Handler.Stream resolves the warehouse from the actor (access.Scope), so a client cannot subscribe to another warehouse. super_admin has no scope and is refused with ErrWarehouseRequired.

Transport: Server-Sent Events, not WebSockets. The code gives the reason: the panel already authenticates with a cookie, EventSource sends that cookie on a plain GET, and nothing needs to travel upstream. The handler sends a ready frame, then events, with a : keep-alive comment every 20 s. It sets X-Accel-Buffering: no so proxies do not buffer, and closes the stream after 30 min, after which the client reconnects.

Broker (broker.go):

  • Redis channel warehouse.orders.{warehouseID}.
  • Why Redis: the API runs as more than one ECS task. An in-process broker would only tell tablets connected to the task that handled the order.
  • With no Redis (REDIS_URL empty, e.g. local docker-compose.yml, which has no redis service), it falls back to an in-process map of channels.
  • Publish never returns an error to the caller. An order must not fail because a notification failed.
  • Subscriber buffer is 16. A slow subscriber's events are dropped (select ... default), so it cannot block others.

The payload is deliberately thin:

go
type Event struct {
    Type        string // order.created | order.transitioned
    WarehouseID int64
    OrderID     int64
    OrderNo     string
    Status      string
}

No patient name, phone, address or items. The event means "something changed, refetch". The client then calls its normal authorised, scoped endpoint. Why: a stream held open on a shared warehouse tablet is the wrong place to push PHI. Because events can be dropped, clients must also poll. The code expects this ("clients fall back to polling").

OrderService depends only on the small OrderEventPublisher interface (order.go:61-63), so internal/order does not import the transport's types. realtime is wired in through SetEventPublisher(broker) in order/module.go:67.


9. Notifications: SMS, email, push, in-app

9.1 SMS: 2Factor (internal/pkg/sms)

  • Sender interface: IsConfigured(), Send(ctx, e164, code). Noop is used when SMS is not configured.
  • TwoFactor.SendTemplate validates E.164, a 4-8 digit numeric code, and a template name matching ^[A-Za-z0-9 _.-]{1,64}$, then GETs https://2factor.in/API/V1/{key}/SMS/{phone}/{code}/{template}.
  • The API key is in the URL path. So no error from this package ever wraps a net/http or net/url error, because those include the full URL and would log the key (twofactor.go:28-29).
  • 2Factor returns HTTP 200 with {"Status":"Error"} for most business failures, so interpret reads the body, not the status code. Errors are grouped as ErrTransport, ErrNoCredit or ErrRejected.
  • Two clients share one API key but use different DLT templates (main.go:1177-1207): TWOFACTOR_OTP_TEMPLATE for login and TWOFACTOR_DELIVERY_TEMPLATE for delivery handover. SMS_PROVIDER=twofactor without a login template is a fatal startup error. A missing delivery template only logs a warning.

Daily budget (budget.go): SMS_DAILY_SEND_CAP (default 1000) is one global cap per UTC day. With Redis, a Lua INCR + EXPIRE script on sms:budget:YYYY-MM-DD keeps it shared across tasks. Without Redis it is an in-process counter, which in a multi-task deployment means the cap applies per task. If Redis errors, the send is allowed (fail open), so a Redis outage does not break login. The cap exists because a number-rotating attacker could otherwise drain the vendor balance and take phone login down (main.go:1186). A failed vendor attempt still counts against the cap (TestDailyBudget_ChargesAnAttemptThatFailsAtTheVendor).

9.2 Email: ZeptoMail (internal/pkg/mailer)

  • POST https://api.zeptomail.in/v1.1/email with an Authorization header set to the API key.
  • SendWithResult uses context.WithoutCancel(ctx). A client disconnecting must not abort a send the caller has already committed to (e.g. an OTP already stored and budget already spent). The real limit is the 10 s http.Client timeout.
  • It returns ProviderMessageID plus a SHA-256 of the HTML body as proof of send. A 2xx reply with no message ID is logged but not treated as a failure.
  • ics.go builds calendar invites (used by hiring, Day 6).

9.3 Push: Expo (internal/pkg/push)

  • POST https://exp.host/--/api/v2/push/send, with an optional bearer EXPO_PUSH_ACCESS_TOKEN. There are no FCM credentials in the backend.
  • IsConfigured() is only the PUSH_NOTIFICATIONS_ENABLED flag (main.go:318).
  • A DeviceNotRegistered ticket is returned as ErrDeviceNotRegistered, so callers can prune dead tokens.
  • Callers today: order delivery (rider pushes) and prescription/services/review_transport_expo.go. There is no patient order-status push in internal/order.

9.4 In-app feed: internal/notification

This is not a delivery channel for patients. It is the Hospital Panel's bell icon: table hospital_notifications plus hospital_notification_reads (per operator). Types are settlement paid and procurement request approved/procured/rejected/cancelled, plus system announcement. Three routes under /notification/api/v1/my/notifications. The hospital comes from HospitalScope. emit logs and swallows insert failures, because "the underlying event still stands".


Traps

  1. Production runs REAL Razorpay. PAYMENT_PROVIDER=razorpay in prod. A bug in applyPaymentEvent, ensureOpenPaymentAttempt or processRefund double-charges, strands or wrongly refunds real money. Pushing to main deploys. Get a money-path change reviewed (security-auditor) and tested with make test-integration before it merges.

  2. The mock gateway accepts every signature. It is only compiled with -tags dev or -tags integration. Never add a runtime switch for it, and never add those tags to the Dockerfile.

  3. Two different Razorpay secrets. Client verify uses RAZORPAY_KEY_SECRET. The webhook uses RAZORPAY_WEBHOOK_SECRET. If the webhook secret is rotated in the dashboard but not in Secrets Manager, every webhook fails signature checks. Online orders then settle only through client verify. A patient who closes the app after paying stays awaiting_payment until someone investigates. Watch the medyzen_order_payment_webhook_signature_invalid_total counter.

  4. Webhook dedupe is by body hash, not by Razorpay event ID. Two different deliveries with byte-identical bodies are treated as one. That is fine for Razorpay retries. If you ever "fix" this to use X-Razorpay-Event-Id, keep the replay no-ops in applyPaymentEvent, because payment.captured and order.paid both arrive for one capture.

  5. Returning an error inside RunInTx rolls back your evidence. ConfirmPayment (invalid signature) and MarkDelivered (wrong OTP) both commit the failure record first and return the error afterwards. Copy this pattern whenever a failed attempt must be recorded.

  6. Empty warehouse_service_areas = no pincode is serviceable. ResolveByPincode finds no row → ErrAddressNotServiceable in cart, search, checkout and prescription review. Nothing else in the app can work around it. When "no patient can order", check that table (active rows) before debugging code.

  7. Kiosk orders belong to the patient, found by phone. orders.user_id is the patient resolved through FindOrCreateByPhone. The operator is in placed_by_operator_id. The kiosk identifies itself with X-Kiosk-Id, the patient with X-Patient-Id, and the header only works while a live kiosk patient session exists. Do not "simplify" this to the operator's ID, or every hospital's patients merge into one account. A patient app sending X-Kiosk-Id is rejected on purpose.

  8. Wrong DLT template name sends silently. TwoFactor only checks that the template name is well formed (a regex). It cannot tell whether 2Factor has a DLT-approved template with that exact name. 2Factor has been seen to accept an unknown name and send on its generic header, and the code sees Status: Success. After changing TWOFACTOR_*_TEMPLATE, check a real handset receives the message with the Medyzen sender and wording. Logs alone prove nothing.

  9. Money is decimal, and should stay that way. Every price and total in cart and order is shopspring/decimal and decimal(12,2). Gateway amounts are int64 paise. Never use InexactFloat64() or float64 for arithmetic in a new money field. Compare amounts in paise (integers) or with decimal.Equal, never with == on decimal.Decimal structs.

  10. Not every status change publishes a realtime event. Only PlaceFromCart, CreateWalkIn and Transition publish. Payment confirmation (awaiting_payment → confirmed via verify or webhook), handover, mark-delivered, patient cancel and kiosk cancel do not. Warehouse boards learn about those from polling. If you add a status-changing path, decide on purpose whether it should publish.

  11. Never put order data in a realtime event. Events are invalidation signals. Adding a patient name "to save a refetch" pushes PHI to every tablet on that warehouse channel.

  12. DELIVERY_OTP_ENFORCE=false is not "OTP off". It means only the emergency OTP works. Do not flip it to true until the delivery DLT template is live, or no rider can complete a delivery, because patients never receive the code.

  13. completed is not terminal. Code that treats completed as final (e.g. to skip refund logic) is wrong. Returns can still arrive.

  14. Reconciliation never repairs. A discrepancy row is only an observation. Resolving it records a human note and does not write order_payments. Money fixes are deliberate, reviewed actions.


Exercises

Local stack only. Use the Postgres from docker-compose.yml (docker compose up -d db) and run the API on your machine. Before running, blank every real secret in .env (config.Load() reads it). Use Razorpay test keys only, or the mock gateway. Getting a JWT for a patient or staff user locally is covered in Day 2.

E1 — State machine by hand (no DB). Run go test ./internal/order/services -run 'TestCanTransition|TestIsTerminal|TestEffectFor|TestAllowedTransitions|TestMust' -v. Then, on paper, list every status from which cancelled is reachable in one step. Check your list against statemachine.go. Which of those does Transition still refuse, and why?

E2 — Break a guard, watch a test fail. In a scratch branch that you will throw away, change mustBePaidBeforeFulfilment to always return false. Run go test ./internal/order/services/.... Which tests fail? Read one and explain in two sentences what real-world loss it prevents. Then revert with git checkout -- internal/order/services/statemachine.go.

E3 — Serviceability. Start the server with the dev tag so the mock gateway is allowed: go run -tags dev ./cmd/server (after -migrate up). As a patient, PUT /cart/api/v1/cart with any pincode. Confirm the error. Insert one active warehouse_service_areas row for that pincode in your local DB only (look at the model in internal/assets/models for the columns). Retry. Write down which other endpoints would have failed with the same error.

E4 — Webhook signature and dedupe (mock or test secret). With a local RAZORPAY_WEBHOOK_SECRET=local-test-secret and PAYMENT_PROVIDER=razorpay using your test key ID/secret, build a JSON body for payment.failed with a made-up order_id. Sign it: printf '%s' "$BODY" | openssl dgst -sha256 -hmac local-test-secret -hex. POST it to /order/api/v1/payments/webhook with X-Razorpay-Signature. Then:

  • send it once with a wrong signature and check the response and log line;
  • send the valid one twice and count rows in payment_webhook_events;
  • check attempts and error on the row, and explain why it is not processed yet (hint: unreconciled).

E5 — Full online payment in Razorpay test mode. With test keys, check out an online order, call /payments/initiate twice in quick succession, and check that order_payments has one created row. Complete the payment with a Razorpay test card through the Checkout SDK or a small HTML page, then call /payments/verify. Confirm the order is confirmed and the cart is checked_out. If your local server is reachable by Razorpay test webhooks (e.g. a tunnel), confirm the webhook becomes a no-op. Otherwise skip that part.

E6 — Amount drift. After E5's initiate but before paying, change the order total in your local DB by 1 rupee. Call /payments/verify with valid test values. Which error do you get, and which log line is written? Why is the amount checked before the signature?

E7 — Realtime stream. With no REDIS_URL (in-process broker), open curl -N -H "Authorization: Bearer $STAFF_TOKEN" localhost:8080/order/api/v1/orders/stream as a warehouse-scoped staff user. In another terminal, place a kiosk COD order or run a Transition on an order in that warehouse. Record the exact frames. Then confirm a payment and note that no frame arrives.

E8 — Delivery OTP lockout (unit level). Read delivery_test.go / delivery_integration_test.go and find the test that proves a wrong OTP's attempt count survives the transaction. If none exists, write down the steps such a test would need (do not commit it).

E9 — SMS budget. Run go test ./internal/pkg/sms -run TestDailyBudget -v. Explain why the Redis path fails open while the cap exists to protect spend, and name the trade-off.


Self-check

  1. What three conditions must hold for an app cart to be created, and which table decides the warehouse?
  2. Name the three terminal order statuses. Is completed one of them?
  3. Which transition is in allowedTransitions but refused by Transition, and which endpoint is the only way to make it?
  4. Why does InitiatePayment take a row lock on the order, and what does ensureOpenPaymentAttempt reuse?
  5. In ConfirmPayment, why is the amount compared before the signature is verified, and why is an invalid signature committed before the error is returned?
  6. What is the dedupe key for payment_webhook_events, and why must applyPaymentEvent still be replay-safe?
  7. A payment is captured on an order that was already cancelled. What does the system do?
  8. Which two things prevent the reconciler from issuing the same refund twice?
  9. For a kiosk order, which headers identify the kiosk and the patient, and whose user_id goes on the order?
  10. What does DELIVERY_OTP_ENFORCE=false actually allow at mark-delivered?
  11. Why do realtime events contain no patient data, and why must panels still poll?
  12. Why can a wrong 2Factor DLT template name go unnoticed in logs?

Answers

  1. The request is from a patient on the app channel, there is no existing active cart (or the request reuses one), and a pincode is sent that resolves to an active row in warehouse_service_areas. Stock allocation must also not error. The table is warehouse_service_areas (ResolveByPincode).
  2. cancelled, rejected, returned (no outgoing edges). completed is not terminal. It can go to returned or partially_returned.
  3. ready_for_pickup → out_for_delivery. mustDispatchViaHandover refuses it in Transition. Only POST /delivery/orders/{id}/handover (HandoverConfirm) makes it, and that also mints the delivery OTP. (out_for_delivery → cancelled is also blocked in Transition, and is reachable only through KioskCancel for kiosk COD orders.)
  4. Without the lock, two parallel initiates could each see no open attempt and open two payable Razorpay orders, which can charge the patient twice. It reuses the latest ledger row if its status is created, it has a razorpay_order_id, and its amount_paise equals the current order total.
  5. The signature only ties payment ID to gateway order ID. It does not prove the attempt amount still matches the order after edits, so a valid signature for a stale amount must be refused. The failed attempt is saved (status failed) and the transaction commits before ErrInvalidSignature is returned, because returning the error inside RunInTx would roll back that record.
  6. The SHA-256 of the raw body (the header event ID is replaced). Razorpay sends both payment.captured and order.paid for one capture, and reconciler retries run processing again, so each branch must treat "already paid by this payment ID" as a no-op.
  7. applyPaymentEvent records the capture on the ledger row, inserts a pending refund row for the full amount, sets payment_status=refund_initiated, and raises the capture_on_cancelled_order money alert. The reconciler (or an inline start) then issues the refund.
  8. ClaimPendingRefunds uses FOR UPDATE SKIP LOCKED and moves rows to refunding (with a 5-minute stale takeover). processRefund also sends and first looks up notes.idempotency_key = the row's document ID with FindRefundByKey before calling Refund.
  9. X-Kiosk-Id identifies the kiosk, and X-Patient-Id names the patient, valid only with a live kiosk patient session for that operator and kiosk. The order's user_id is the patient (found or created by phone). The operator goes in placed_by_operator_id.
  10. Only the break-glass emergency OTP (EMERGENCY_OTP, checked by common.IsEmergencyOTP). It is recorded with the otp_bypass reason. Orders with no OTP row at all use legacy_bypass.
  11. The stream is held open on shared warehouse devices, which is the wrong place for PHI. The client refetches from authorised, scoped endpoints instead. Events can be dropped (slow subscriber buffer of 16, Redis publish failure), and several status changes (payment confirmation, handover, delivery, patient/kiosk cancel) do not publish at all.
  12. The code only checks the template name's format. 2Factor can accept an unknown template and still return Status: Success, sending on a generic header, so the backend logs a successful send. Only checking on a real handset catches it.

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