Pazhakadai · Technical design · Version 1

Pazhakadai Technical Design

How to build and run Pazhakadai: architecture, schema, the stock-hold transaction, payment handling, security and deployment. Product rules live in SPEC.md; decision numbers like D1 refer to its decision log. The plain-language version is flows.html.

.NET 10 · ASP.NET CoreMySQL 8.4 LTS · InnoDBNext.js · App Router · TypeScriptFlutter · AndroidRazorpayFirebase Cloud MessagingUbuntu 24.04 LTS VPS
01

Architecture

One API, one database, one server. Single-instance by design (D15): images live on local disk and background workers run in-process.

Customer appFlutter · Android Delivery appFlutter · Android Customer websitebrowser Admin websitebrowser · desktop + phone Linux VPS · India region nginxTLS · /api → API · /hubs → API (WebSocket) · /uploads → disk Next.js serversshop :3000admin :3001 ASP.NET Core API :5000REST · SignalR hub · webhooksbackground workers (holds, alerts) uploads/product images (WebP) MySQL 8.4127.0.0.1:3306 · binlog on Razorpayorders · refunds · webhooks Firebase (FCM)push to apps + browsers REST webhook send
Clients only ever talk to nginx. Razorpay calls back into the same nginx for webhooks.

Why one server is fine

  • Traffic for a few branches is small; a 4 vCPU / 8 GB VPS has wide headroom.
  • Single instance makes the in-memory session cache and in-process workers correct without Redis or a distributed lock.
  • Scaling later means moving images to object storage and workers to a queue. Keep file access behind an IFileStore interface so that change is local.

Hostnames (placeholders)

  • pazhakadai.in: customer website, with /api and /uploads proxied (same origin, no CORS).
  • admin.pazhakadai.in: admin website, with /api, /hubs, /uploads proxied.
  • api.pazhakadai.in: mobile apps and the Razorpay webhook.
02

Repository layout

One monorepo, so API contract changes land with their client changes.

pazhakadai/
├─ docs/                      SPEC.md, flows.html, technical.html
├─ api/
│  ├─ src/Pazhakadai.Api/          ASP.NET Core host: endpoints, auth, SignalR, workers
│  ├─ src/Pazhakadai.Domain/       entities, state machine, money/weight value types
│  ├─ src/Pazhakadai.Infrastructure/  EF Core + Dapper, Razorpay client, FCM, files, PDF
│  └─ tests/                       unit + integration (Testcontainers MySQL)
├─ web/
│  ├─ shop/                   Next.js customer website
│  ├─ admin/                  Next.js admin + supervisor + counter billing
│  └─ packages/api-client/    TypeScript client generated from the API's OpenAPI document
├─ mobile/
│  ├─ customer_app/           Flutter
│  ├─ delivery_app/           Flutter
│  └─ packages/pz_core/       shared Dart: API client, auth, models, formatting
├─ db/migrations/             EF Core migrations (checked in, applied on deploy)
└─ deploy/                    nginx, systemd units, backup scripts, CI workflows
03

Backend structure

Modules (folders, not separate services)

  • Identity: customers, staff, partners, tokens, lockout
  • Branches: branches, PIN codes, settings, pause
  • Catalogue: categories, products, images
  • Inventory: branch products, movements, holds
  • Cart and Checkout: cart, re-check, hold, Razorpay order
  • Payments: webhook, confirm, review queue, refunds
  • Orders: state machine, packing, assignment
  • Delivery: QR tokens, OTP, partner endpoints
  • Billing: numbering, counter bills, PDF
  • Notifications: FCM, SignalR, alert rules
  • Reports, Audit

Libraries

  • Minimal APIs grouped per module, OpenAPI via Microsoft.AspNetCore.OpenApi
  • EF Core with the Pomelo MySQL provider for CRUD (pin EF Core to the version Pomelo supports)
  • Dapper over MySqlConnector for the hold, confirm and numbering SQL, so the exact statements are visible and reviewed
  • FluentValidation for request validation
  • Razorpay .NET SDK or a thin typed HttpClient
  • FirebaseAdmin for FCM
  • QuestPDF for bills (community licence, free under US$1M revenue)
  • SkiaSharp for image resizing (MIT)
  • Serilog for logs
Rule

Every change to stock, order status or money goes through one domain service method, inside one database transaction, which also writes the movement, the status history row and the audit entry. Endpoints never update these tables directly.

04

Data model

InnoDB, utf8mb4. Primary keys are BIGINT auto-increment; anything exposed in a URL also has a random public_id (ULID). Timestamps are DATETIME(6) in UTC. Grams and paise are INT/BIGINT.

Core ordering, stock and billing tables. Not drawn: categories, carts, addresses, PIN codes, partner profiles, tokens, devices, audit, settings (listed below).
All tables and columns
Branches and catalogue

branches

  • id, public_id
  • code · "A", unique, used in bill numbers
  • name, address, phone
  • gstin · nullable (D11)
  • delivery_fee_paise, free_delivery_above_paise
  • min_order_paise
  • ordering_paused, is_active

branch_pincodes

  • branch_id, pincode · PK (both)
  • index on pincode

categories

  • id, name, sort_order, is_active

products

  • id, public_id, category_id
  • name, description
  • image_path, thumb_path
  • hsn_code · nullable
  • gst_rate_bp · basis points, default 0
  • is_active · soft delete

branch_products

  • id, branch_id, product_id · unique pair
  • price_per_kg_paise
  • step_g, min_g
  • stock_g · on-hand, unsold
  • held_g · sum of active holds
  • low_stock_g · nullable
  • is_sold, is_hidden, updated_at
  • CHECK (held_g >= 0 AND held_g <= stock_g)

stock_movements

  • id, branch_product_id
  • type · RECEIVED, SOLD_ONLINE, SOLD_COUNTER, WASTAGE, ADJUSTMENT, CANCELLED_RESTOCK, CANCELLED_WRITEOFF
  • qty_g · signed
  • stock_after_g
  • unit_cost_paise · nullable
  • reason, order_id, bill_id, staff_user_id, created_at
People and sessions

customers

  • id, public_id, name
  • mobile · unique, 10 digits
  • password_hash, must_change_password
  • failed_logins, locked_until
  • session_version
  • status · ACTIVE, ARCHIVED

customer_addresses

  • id, customer_id, label
  • name, phone, line1, line2, landmark
  • pincode, lat, lng · nullable
  • is_deleted

staff_users

  • id, public_id, name, mobile · unique
  • role · SUPER_ADMIN, ADMIN, SUPERVISOR, DELIVERY_PARTNER
  • password_hash, must_change_password
  • failed_logins, locked_until, session_version
  • is_active

staff_branches

  • staff_user_id, branch_id · PK

partner_profiles

  • staff_user_id · PK
  • home_branch_id, photo_path
  • vehicle_no, id_type, id_last4
  • on_duty

refresh_tokens

  • id, principal_type, principal_id
  • token_hash · SHA-256, unique
  • family_id, replaced_by_id
  • expires_at, absolute_expires_at
  • revoked_at, created_ip, user_agent

device_tokens

  • id, principal_type, principal_id
  • fcm_token · unique
  • platform · ANDROID, WEB
  • updated_at
Cart, orders, payments

carts / cart_items

  • carts: id, customer_id · unique, branch_id, fulfilment, address_id, updated_at
  • cart_items: cart_id, branch_product_id · PK, qty_g

orders

  • id, public_id, order_no · "A-124"
  • branch_id, customer_id
  • fulfilment · DELIVERY, PICKUP
  • status, address_snapshot · JSON
  • items_total_paise, delivery_fee_paise, total_paise
  • hold_expires_at, razorpay_order_id · unique
  • delivery_otp, otp_failed_attempts, otp_locked
  • estimated_date, partner_id
  • cancel_reason, created_at, confirmed_at, delivered_at

order_items

  • id, order_id, branch_product_id
  • product_name · snapshot
  • qty_g, price_per_kg_paise, line_total_paise
  • writeoff_on_cancel

stock_holds

  • id, order_id, branch_product_id
  • qty_g
  • status · ACTIVE, CONVERTED, RELEASED
  • created_at, resolved_at

order_status_history

  • id, order_id, from_status, to_status
  • actor_type, actor_id
  • reason, is_override, created_at

payments

  • id, order_id
  • razorpay_payment_id · unique
  • amount_paise, method, status
  • captured_at, is_late, raw · JSON

refunds

  • id, order_id, payment_id
  • razorpay_refund_id · unique
  • amount_paise, reason, staff_user_id
  • status · PENDING, PROCESSED, FAILED

webhook_events

  • event_id · PK (x-razorpay-event-id)
  • event_type, payload · JSON
  • received_at, processed_at, error
Bills, numbering, audit

bills

  • id, public_id, bill_no · unique
  • branch_id, fy, seq
  • channel · ONLINE, COUNTER
  • order_id, customer_id · nullable
  • customer_name, customer_mobile
  • branch_snapshot · JSON: name, address, phone, gstin
  • title · Bill / Bill of Supply / Tax Invoice
  • items_total_paise, delivery_fee_paise, total_paise
  • payment_method · RAZORPAY, CASH, UPI, CARD
  • status, cancel_reason, created_by, created_at

bill_lines

  • id, bill_id
  • product_name, hsn_code, gst_rate_bp
  • qty_g, price_per_kg_paise, line_total_paise
  • writeoff_on_cancel

bill_sequences

  • branch_id, fy · PK
  • next_no

order_sequences

  • branch_id · PK
  • next_no

audit_log

  • id, actor_type, actor_id
  • action, entity, entity_id
  • before_json, after_json
  • ip, user_agent, created_at

app_settings

  • key, value
  • hold_minutes=10, stale_order_minutes=120, review_reminder_minutes=120, cart_ttl_days=7
05

Money, weight and time

Integers only

  • Weight in grams (int), money in paise (long). No decimal or double in storage.
  • Line total: (qty_g × price_per_kg_paise + 500) / 1000 with integer division. That rounds half up and is exact.
  • Online qty must satisfy qty_g ≥ min_g and (qty_g − min_g) % step_g == 0.
  • Format only at the edge: 1137 g → "1.137 kg", 24488 → "₹244.88".

Time

  • Store UTC. Display and business dates in Asia/Kolkata.
  • Estimated delivery date is a DATE, not a timestamp.
  • Financial year from the IST date: month ≥ 4 → 2026-27, else 2025-26.
  • Hold expiry uses database time (UTC_TIMESTAMP(6)) so app-server clock drift can't matter.
public static long LineTotalPaise(int qtyG, long pricePerKgPaise) =>
    (qtyG * pricePerKgPaise + 500) / 1000;

public static string FinancialYear(DateOnly istDate) =>
    istDate.Month >= 4
        ? $"{istDate.Year}-{(istDate.Year + 1) % 100:00}"
        : $"{istDate.Year - 1}-{istDate.Year % 100:00}";
06

Stock hold algorithm

The core of D1, D2 and D4. Available stock is stock_g − held_g. Every change is a conditional UPDATE that only succeeds when enough is available, so two buyers can never both get the last kilogram.

Checkout (tap Pay): hold first, then create the Razorpay order.

6.1 Checkout steps

  1. Load the cart and the branch settings. Reject if the branch is paused or the PIN isn't served.
  2. Start a transaction (READ COMMITTED).
  3. For each line, sorted by branch_product_id (consistent lock order prevents deadlocks), run the conditional update below.
  4. If any update affects 0 rows, or any current price differs from what the client showed (expectedTotalPaise), roll back and return 409 with a list of changes.
  5. Insert orders (PAYMENT_PENDING, hold_expires_at = now + 10 min), order_items (snapshots) and stock_holds (ACTIVE). Commit.
  6. After commit, create the Razorpay order. If that call fails, release the hold immediately and return an error.
-- Reserve. Succeeds only if enough is available right now.
UPDATE branch_products
   SET held_g = held_g + @qty
 WHERE id = @branch_product_id
   AND is_sold = 1 AND is_hidden = 0
   AND price_per_kg_paise = @price_seen_by_customer
   AND stock_g - held_g >= @qty;
-- affected rows = 1 → held; 0 → out of stock, hidden, or price changed (re-read to tell which)
Connector setting

MySqlConnector returns found rows by default (UseAffectedRows=false). That equals changed rows here because @qty > 0 always changes held_g. Validate qty > 0 before the update.

6.2 Confirm (payment captured)

Called from the webhook, from the signed checkout callback, and from the expiry worker's reconciliation. It must be idempotent.

BEGIN;
SELECT status FROM orders WHERE id = @order_id FOR UPDATE;    -- serialises confirm vs expiry vs duplicate webhook
INSERT INTO payments (...) VALUES (...)                          -- unique razorpay_payment_id: duplicate → stop, already handled

-- If status = PAYMENT_PENDING (holds still ACTIVE, even if hold_expires_at just passed):
UPDATE branch_products bp JOIN stock_holds h ON h.branch_product_id = bp.id
   SET bp.stock_g = bp.stock_g - h.qty_g,
       bp.held_g  = bp.held_g  - h.qty_g
 WHERE h.order_id = @order_id AND h.status = 'ACTIVE';
UPDATE stock_holds SET status = 'CONVERTED', resolved_at = UTC_TIMESTAMP(6) WHERE order_id = @order_id AND status = 'ACTIVE';
INSERT INTO stock_movements (type = 'SOLD_ONLINE', ...);         -- one per line
-- allocate bill number (section 10), insert bill + lines, generate OTP
UPDATE orders SET status = 'CONFIRMED', delivery_otp = @otp, confirmed_at = UTC_TIMESTAMP(6) WHERE id = @order_id;
INSERT INTO order_status_history (...);
COMMIT;
-- after commit: FCM to customer, SignalR + web push to branch staff

-- If status = PAYMENT_FAILED (hold already released) → status = PAYMENT_REVIEW, is_late = 1. No stock change.
-- If status = CONFIRMED and this is a different payment id → record it, flag for review (customer paid twice).

6.3 Release (failed, dismissed checkout, or expired)

BEGIN;
SELECT status FROM orders WHERE id = @order_id FOR UPDATE;
-- only if status = PAYMENT_PENDING
UPDATE branch_products bp JOIN stock_holds h ON h.branch_product_id = bp.id
   SET bp.held_g = bp.held_g - h.qty_g
 WHERE h.order_id = @order_id AND h.status = 'ACTIVE';
UPDATE stock_holds SET status = 'RELEASED', resolved_at = UTC_TIMESTAMP(6) WHERE order_id = @order_id AND status = 'ACTIVE';
UPDATE orders SET status = 'PAYMENT_FAILED' WHERE id = @order_id;
COMMIT;

6.4 Other stock writers use the same guard

OperationStatement
Counter bill lineSET stock_g = stock_g - @q WHERE id = @id AND stock_g - held_g >= @q
Wastage / negative adjustmentSame guard. Staff cannot write off stock that is held for a paying customer; they wait for the hold to resolve.
Received / positive adjustment / restockSET stock_g = stock_g + @q (no guard needed)
Admin confirms a late paymentCounter-style guarded decrement per line, all in one transaction; any line short → roll back, show which lines

6.5 Timing details

  • Razorpay Checkout gets timeout = seconds left on hold − 60, so the payment window closes before the hold does.
  • The hold is formally ended by the expiry worker, not by the clock. A capture arriving after hold_expires_at but before the worker ran still converts, because the stock is still held.
  • Before releasing an expired order, the worker asks Razorpay for the order's payments (GET /v1/orders/{id}/payments). If one is captured, it confirms instead. This covers missed or delayed webhooks.
  • A new checkout by the same customer: if an ACTIVE hold exists with identical lines, return the same Razorpay order; otherwise release the old one first, in the same transaction.
07

Razorpay integration

Three ways a payment result reaches the API. All go through the same idempotent Confirm.

Account setup

  • Enable automatic capture in the dashboard (payment settings).
  • Webhook URL https://api.pazhakadai.in/api/webhooks/razorpay with a dedicated secret.
  • Events: payment.captured, payment.failed, order.paid, refund.processed, refund.failed.
  • Separate test-mode keys and webhook for staging.

Flow

  1. API: POST /v1/orders with amount (paise), currency: "INR", receipt: order_no, notes.order_public_id.
  2. Client opens Checkout (checkout.js on web, razorpay_flutter in the app) with order_id, key, timeout, prefilled mobile.
  3. Client success handler posts razorpay_payment_id, razorpay_order_id, razorpay_signature to /api/payments/verify.
  4. Webhook arrives independently. Both paths call the same idempotent Confirm.
// Checkout callback: signature = HMAC_SHA256(order_id + "|" + payment_id, key_secret)
bool VerifyCheckout(string orderId, string paymentId, string signature) =>
    FixedTimeEquals(HexHmacSha256(keySecret, $"{orderId}|{paymentId}"), signature);

// Webhook: signature = HMAC_SHA256(raw request body, webhook_secret), header X-Razorpay-Signature
app.MapPost("/api/webhooks/razorpay", async (HttpRequest req, PaymentService svc) => {
    var body = await new StreamReader(req.Body).ReadToEndAsync();      // raw bytes, before any JSON parsing
    if (!FixedTimeEquals(HexHmacSha256(webhookSecret, body), req.Headers["X-Razorpay-Signature"]))
        return Results.Unauthorized();
    var eventId = req.Headers["x-razorpay-event-id"].ToString();
    if (!await svc.TryRecordEvent(eventId, body)) return Results.Ok();   // duplicate delivery
    await svc.Handle(body);                                              // fast; heavy work after commit
    return Results.Ok();
});
Trust rule

An order is confirmed only on a verified signature: the webhook's, or the checkout callback's checked on the server. A client saying "paid" without a valid signature changes nothing. The amount always comes from the server's order row, never from the client.

Refunds (D23: full only, admin only)

POST /v1/payments/{payment_id}/refund
{ "amount": <payment amount in paise>, "speed": "normal",
  "receipt": "<order_no>-R", "notes": { "reason": "...", "staff": "<staff public_id>" } }

Insert refunds (PENDING) and set the order to CANCELLED in the same transaction as the restock, then call Razorpay after commit. refund.processed marks the refund PROCESSED and the order REFUNDED. refund.failed raises an admin alert. A worker re-fetches refunds still PENDING after 24 hours.

08

Order state machine

Enforced in Domain/OrderStateMachine.cs. Any transition not listed is rejected, except an admin override, which needs a reason and is flagged in history.

Payment In progress Completed Needs an admin Failed or reversed
FromToActorGuard / side effects
PAYMENT_PENDINGCONFIRMEDSystemVerified capture. Convert holds, bill, OTP.
PAYMENT_PENDINGPAYMENT_FAILEDSystempayment.failed, checkout dismissed, or expiry. Release holds.
PAYMENT_FAILEDPAYMENT_REVIEWSystemLate capture. Alert admins.
PAYMENT_REVIEWCONFIRMEDAdminGuarded stock decrement succeeds for every line.
PAYMENT_REVIEWREFUNDEDAdminReason. Full refund. No stock change.
CONFIRMEDPACKEDAdmin, supervisorDelivery orders. estimated_date required (≥ today).
CONFIRMEDREADY_FOR_PICKUPAdmin, supervisorPickup orders. Pickup date required.
PACKEDASSIGNEDAdmin, supervisorActive partner whose home branch is the order's branch. Reassign allowed while ASSIGNED.
ASSIGNEDOUT_FOR_DELIVERYPartnerValid QR token, assigned to this partner.
OUT_FOR_DELIVERYDELIVEREDPartnerValid QR token + correct OTP, not locked.
OUT_FOR_DELIVERYDELIVERY_ATTEMPT_FAILEDPartnerReason code required.
DELIVERY_ATTEMPT_FAILEDPACKEDAdmin, supervisorPackage back at shop. New date required. Clears partner.
READY_FOR_PICKUPCOLLECTEDAdmin, supervisorCorrect OTP typed by staff.
Any of CONFIRMED … DELIVERY_ATTEMPT_FAILED, READY_FOR_PICKUPCANCELLEDAdminReason. Restock or write off per line. Refund started.
CANCELLEDREFUNDEDSystemrefund.processed.
09

QR stickers and delivery OTP

Pickup scan at the shop, then QR plus OTP at the door.

QR token

  • Content: PZ1.{order_public_id}.{sig}, where sig = first 16 bytes of HMAC-SHA256(qr_key, order_public_id), base64url.
  • No personal data, no OTP. A photo of a sticker reveals nothing and can't be forged for another order.
  • Sticker rendered as an HTML page sized for the label (e.g. 50 × 75 mm) and printed from the browser; QR drawn client-side with a QR library.
  • POST /api/partner/scan checks signature, assignment to the caller, and expected status (pickup vs handover).

OTP

  • RandomNumberGenerator.GetInt32(0, 10000).ToString("D4") at confirm.
  • Returned only by customer order endpoints. Never by partner endpoints, never in the QR, never in logs.
  • Compared with a constant-time comparison. Each miss increments otp_failed_attempts; at 5, otp_locked = 1 and branch admins are alerted.
  • Admin override to DELIVERED (reason required) clears the lock.
10

Bills, PDF, printing and WhatsApp

Counter bill, then print or the two-tap WhatsApp share (D10).

Gap-free numbering, per branch per financial year

-- inside the same transaction that inserts the bill; a rollback leaves no gap
INSERT INTO bill_sequences (branch_id, fy, next_no) VALUES (@b, @fy, 1)
  ON DUPLICATE KEY UPDATE next_no = next_no;
SELECT next_no FROM bill_sequences WHERE branch_id = @b AND fy = @fy FOR UPDATE;
UPDATE bill_sequences SET next_no = next_no + 1 WHERE branch_id = @b AND fy = @fy;
-- bill_no = $"{branch.Code}/{fy}/{seq:D6}"  →  A/2026-27/000123

Snapshots

Bills copy names, weights, prices, totals and the branch's name, address, phone and GSTIN at issue time. PDF and print read only from bills and bill_lines, never from live product or branch rows. Title: no GSTIN → "Bill"; GSTIN and all gst_rate_bp = 0 → "Bill of Supply"; otherwise "Tax Invoice".

PDF on request

QuestPDF, one 80 mm receipt layout: page.ContinuousSize(80, Unit.Millimetre). Embed a font that has the ₹ glyph (e.g. Noto Sans) via FontManager.RegisterFont; server fonts may lack it. Endpoints: GET /api/orders/{id}/bill.pdf (owner) and GET /api/admin/bills/{id}/pdf (staff, branch-scoped). Nothing is stored.

Browser printing

Admin route /print/bill/[id] renders the receipt as HTML and calls window.print() on load.

@page { size: 80mm auto; margin: 0 }
body  { width: 72mm; margin: 4mm; font: 11px/1.4 monospace }

WhatsApp, two taps

Prefetch the PDF blob as soon as the bill is saved, so tap 2 calls navigator.share inside the click's user activation (Chrome expires it after a few seconds; a slow fetch inside the handler would lose it).

// tap 1: open the chat
const msg = `Thank you for shopping at Pazhakadai, ${branchName}. Bill ${billNo}, ₹${total}.`;
window.open(`https://wa.me/91${mobile}?text=${encodeURIComponent(msg)}`, "_blank");

// on save: prefetch
const pdf = new File([await (await api.get(`/api/admin/bills/${id}/pdf`)).blob()],
                     `${billNo.replaceAll("/", "-")}.pdf`, { type: "application/pdf" });

// tap 2: share the file
if (navigator.canShare?.({ files: [pdf] })) await navigator.share({ files: [pdf], title: billNo });
else downloadFallback(pdf);   // desktop browsers without file sharing: download, then drag into WhatsApp Desktop
11

Auth and security

Staff session lifecycle, including immediate deactivation (D21).
ConcernImplementation
PasswordsPasswordHasher<T> from ASP.NET Core Identity (PBKDF2). Min 8 chars, checked against a bundled common-password list.
Access tokenJWT, 15 minutes. Claims: sub, typ (customer | staff), role, branches, sv (session version).
Refresh token32 random bytes; only the SHA-256 is stored. Rotated on every use; reuse of an old token revokes the whole family. Customers: 90-day sliding. Staff and partners: absolute_expires_at = login + 12 h, never extended.
Token storageWeb: refresh token in an HttpOnly; Secure; SameSite=Strict cookie scoped to /api/auth (same origin through nginx); access token in memory. Flutter: flutter_secure_storage.
Immediate deactivationDeactivate → increment session_version, revoke refresh tokens, update the in-memory session-version cache. Middleware rejects any JWT whose sv is stale. Correct because there is one API instance.
Lockout5 failures → locked_until = now + 15 min. Same generic error for wrong number and wrong password.
Rate limitsASP.NET Core rate limiter, per IP: auth endpoints 10/min, checkout 20/min, partner OTP 10/min. ForwardedHeaders configured for nginx so the real IP is used.
Branch scopingEvery admin endpoint takes a branch id (route or derived from the entity) and checks it against the caller's staff_branches; super admin bypasses. One authorization handler, not per-endpoint code.
Role policiesCanRefund, CanEditPrices, CanWriteOffStock = super admin + admin. CanOperateOrders, CanBillCounter, CanReceiveStock add supervisor. Partner for /api/partner/*.
AuditIAuditWriter called by domain services in the same transaction. Records before/after JSON, IP, user agent.
Secrets/etc/pazhakadai/api.env (mode 600) loaded by systemd: DB password, JWT signing key, QR HMAC key, Razorpay key id/secret, webhook secret, Firebase service-account path.
Transport and headersHTTPS only, HSTS, X-Content-Type-Options, frame-ancestors none on admin. Uploads served with Content-Type from extension and no script execution.
12

Realtime and push

Fan-out after an order is confirmed. Always after commit, never inside a transaction.

SignalR (admin dashboard)

  • Hub /hubs/staff. On connect, add the connection to branch:{id} groups from the caller's claims.
  • Events: OrderConfirmed, OrderStatusChanged, PaymentReview, DeliveryFailed, StaleOrder, StockChanged.
  • WebSocket auth: access token via access_token query string, read in JwtBearerEvents.OnMessageReceived for the hub path only.
  • Admin UI plays a sound (after the user's first click, per browser autoplay rules) and flashes the tab title.

FCM

  • FirebaseAdmin → SendEachForMulticastAsync. Delete tokens that return Unregistered.
  • Apps register their token with POST /api/devices after login; logout deletes it.
  • Admin web push: Firebase JS SDK getToken with a VAPID key and firebase-messaging-sw.js. Works on Android Chrome and desktop; iOS only as a home-screen web app.
  • Sent from an in-process channel after commit, so a slow FCM call never holds a database transaction.
13

Images

  • Upload via POST /api/admin/products/{id}/image, multipart, max 5 MB. Accept JPEG, PNG and WebP by decoding them, not by trusting the extension.
  • SkiaSharp: resize to max 1200 px and a 400 px thumbnail, re-encode as WebP (quality 80). Re-encoding strips metadata and any embedded payload.
  • Saved as /srv/pazhakadai/uploads/products/{ulid}.webp and {ulid}_t.webp; the DB stores relative paths. New uploads get new names, so caches never serve stale images.
  • nginx serves /uploads/ directly with expires 30d. The API is not involved in reads.
14

Background workers

BackgroundService + PeriodicTimer inside the API process. Each run is idempotent and processes a bounded batch.

WorkerEveryDoes
HoldExpiry30 sPAYMENT_PENDING with hold_expires_at < now: ask Razorpay for captured payments → Confirm, else Release.
StaleOrderAlert10 minCONFIRMED longer than stale_order_minutes → SignalR + web push to branch staff (once per order per interval).
ReviewReminder10 minPAYMENT_REVIEW older than review_reminder_minutes → push to branch admins.
RefundSync1 hRefunds PENDING over 24 h → fetch status from Razorpay.
CartCleanupdaily 03:00 ISTDelete carts untouched for cart_ttl_days.
TokenCleanupdailyDelete expired refresh tokens and processed webhook events older than 90 days.
15

API outline

All under /api. JSON, camelCase. Errors as RFC 9457 problem details.

# Auth
POST /auth/customer/register        POST /auth/customer/login       POST /auth/staff/login
POST /auth/refresh                  POST /auth/logout               POST /auth/change-password

# Customer
GET  /branches?pincode=600010        GET  /branches/{id}/products?category=
GET  /addresses   POST /addresses   PUT /addresses/{id}   DELETE /addresses/{id}
GET  /cart        PUT /cart/context  {branchId, fulfilment, addressId}
PUT  /cart/items/{branchProductId}  {qtyG}         DELETE /cart/items/{branchProductId}
POST /checkout    {expectedTotalPaise}  → 200 {orderId, razorpayOrderId, amountPaise, keyId, timeoutSeconds}
                                        → 409 {changes:[{product, kind: PRICE|STOCK|UNAVAILABLE|MIN_ORDER|PAUSED, ...}]}
POST /payments/verify  {razorpayOrderId, razorpayPaymentId, razorpaySignature}
POST /payments/abandon {orderId}                   # checkout dismissed → release now
GET  /orders      GET /orders/{id}  (includes OTP)  GET /orders/{id}/bill.pdf
POST /devices     DELETE /devices/{token}

# Razorpay
POST /webhooks/razorpay

# Admin (branch-scoped)
GET|POST|PUT      /admin/branches  /admin/branches/{id}/pincodes  /admin/branches/{id}/pause
GET|POST|PUT      /admin/categories  /admin/products  POST /admin/products/{id}/image
GET|PUT           /admin/branches/{b}/products/{productId}      # price, step, min, low-stock, hidden
POST              /admin/branches/{b}/stock-movements           {branchProductId, type, qtyG, unitCostPaise?, reason}
GET               /admin/branches/{b}/stock-movements?product=&from=&to=
GET               /admin/orders?branch=&status=&date=
POST              /admin/orders/{id}/pack        {estimatedDate}
POST              /admin/orders/{id}/ready       {pickupDate}
POST              /admin/orders/{id}/assign      {partnerId}
POST              /admin/orders/{id}/return      {estimatedDate}  # after failed attempt
POST              /admin/orders/{id}/collect     {otp}
POST              /admin/orders/{id}/override    {status, reason}
POST              /admin/orders/{id}/cancel      {reason, writeOffItemIds[]}
GET               /admin/orders/{id}/sticker
GET               /admin/payment-reviews        POST /admin/payment-reviews/{orderId}/confirm|refund
POST|GET          /admin/counter-bills          POST /admin/counter-bills/{id}/cancel
GET               /admin/bills/{id}/pdf
GET|POST|PUT      /admin/staff  /admin/partners  POST /admin/staff/{id}/deactivate|reset-password
GET               /admin/customers?mobile=     POST /admin/customers/{id}/reset-password|release-mobile
GET               /admin/reports/{daily-sales|product-sales|stock-wastage|status-board|refunds|partners}?format=csv
GET               /admin/audit?actor=&entity=&from=&to=

# Delivery partner
GET  /partner/orders/today          GET /partner/orders/history?month=
PUT  /partner/duty {onDuty}
POST /partner/scan {token}          # ASSIGNED → OUT_FOR_DELIVERY, or opens handover when OUT_FOR_DELIVERY
POST /partner/orders/{id}/deliver {token, otp}
POST /partner/orders/{id}/attempt-failed {reasonCode, note}
16

Web and mobile clients

Next.js (shop, admin)

  • App Router, TypeScript, output: "standalone" for deployment.
  • API client generated from OpenAPI into web/packages/api-client; never hand-written fetch types.
  • Shop: product pages server-rendered for speed; cart and checkout client-side; Razorpay checkout.js loaded on the checkout page only.
  • Admin: client-rendered behind login; responsive layouts designed phone-first for orders and counter billing; SignalR via @microsoft/signalr.
  • All UI strings in one resource file each (English only, D22).

Flutter (customer, delivery)

  • Shared pz_core package: dio client with refresh interceptor, models, money/weight formatting.
  • State: flutter_riverpod; routing: go_router.
  • Customer: razorpay_flutter, firebase_messaging + flutter_local_notifications.
  • Delivery: mobile_scanner for QR, url_launcher for Maps and calls.
  • Delivery app distributed via Play Console internal testing or a signed APK.
17

Deployment

Browsers + appscustomers, staff,partners Ubuntu 24.04 LTS VPS · India region · ufw: 22, 80, 443 only nginx :443TLS (Let's Encrypt) · /api /hubs → API · /uploads → disk · / → Next.js pazhakadai-shopNext.js · 127.0.0.1:3000systemd pazhakadai-adminNext.js · 127.0.0.1:3001systemd pazhakadai-api.NET · 127.0.0.1:5000systemd · api.env uploads//srv/pazhakadai/uploads MySQL 8.4127.0.0.1:3306 · binlog backup scriptscron · nightly + /15 min Razorpayorders, refunds, webhooks Firebase (FCM)app + web push Off-site backup storagerclone crypt · 30 days GitHub Actionsbuild, test, deploy Uptime monitorpolls /health every minute HTTPS serves images writes images SQL nightly copy of images rclone webhook REST send deploy over SSH GET /health
Everything public goes through nginx on 443. All services bind to 127.0.0.1.

Server

  • Ubuntu 24.04 LTS, 4 vCPU / 8 GB / 100 GB SSD, India region.
  • ufw: allow 22, 80, 443 only. SSH keys only, fail2ban.
  • MySQL bound to 127.0.0.1; app user with rights on its schema only.
  • certbot (Let's Encrypt) for all hostnames, auto-renew.
  • Unattended security upgrades on.

Processes (systemd)

  • pazhakadai-api: dotnet Pazhakadai.Api.dll, ASPNETCORE_URLS=http://127.0.0.1:5000, EnvironmentFile=/etc/pazhakadai/api.env.
  • pazhakadai-shop: node server.js, port 3000.
  • pazhakadai-admin: node server.js, port 3001.
  • All Restart=always, run as a non-root user.
# deploy/nginx/admin.conf (excerpt)
server {
  server_name admin.pazhakadai.in;
  client_max_body_size 6m;
  location /api/     { proxy_pass http://127.0.0.1:5000; include proxy_params; }
  location /hubs/    { proxy_pass http://127.0.0.1:5000;
                       proxy_http_version 1.1;
                       proxy_set_header Upgrade $http_upgrade;
                       proxy_set_header Connection "upgrade";
                       proxy_read_timeout 1h; }
  location /uploads/ { alias /srv/pazhakadai/uploads/; expires 30d; add_header X-Content-Type-Options nosniff; }
  location /         { proxy_pass http://127.0.0.1:3001; include proxy_params; }
}

CI/CD (GitHub Actions)

  1. On pull request: build and test API (including Testcontainers MySQL), lint and build both Next.js apps, flutter analyze and tests.
  2. On merge to main: build artefacts, rsync to staging over SSH, apply EF migrations (efbundle), restart services, smoke-test /health.
  3. Production: same job, triggered manually after staging is checked. Take a database dump immediately before migrating.
Staging

A second, smaller VPS with Razorpay test keys and its own webhook, reachable over HTTPS so webhooks arrive. Never point staging at production data.

18

Backups and restore

Losing the disk must cost at most 15 minutes of orders.

WhatWhenHow
Full databaseNightly 02:00 ISTmysqldump --single-transaction --routines --triggers --source-data=2 | gzip
Binary logsEvery 15 minFLUSH BINARY LOGS, then copy closed binlog files off-server. binlog_expire_logs_seconds = 7 days locally.
ImagesNightlyrclone sync /srv/pazhakadai/uploads remote:pz-uploads
ConfigOn changenginx, systemd units in the repo; api.env kept in a password manager.

Off-server target: any S3-compatible bucket (e.g. Backblaze B2, Cloudflare R2) or Google Drive through rclone, encrypted with rclone crypt. Keep 30 days.

Before launch

Run a full restore on a fresh VPS: load the latest dump, replay binlogs to a chosen time with mysqlbinlog, sync images, start services, place a test order. Write the steps into deploy/RESTORE.md.

19

Logging and monitoring

  • Serilog to rolling JSON files (14 days) and journald. Every request logs a correlation id; webhook handling logs event id, order, outcome.
  • Never log passwords, tokens, OTPs or full card or UPI data. Mask mobile numbers to the last 4 digits.
  • /health checks MySQL connectivity and disk free space. An external uptime monitor polls it every minute and alerts by email or Telegram.
  • Alert on: webhook signature failures, HoldExpiry worker not running for 5 minutes, disk over 80%, backup job failure.
20

Testing

Must-have automated tests

  • Last-kilogram race: 1000 g stock, 50 parallel checkouts of 1000 g → exactly 1 succeeds; held_g = 1000; no deadlock errors.
  • Hold conservation: random mix of checkouts, confirms, releases, counter sales → held_g always equals the sum of ACTIVE holds, and stock_g equals the sum of movements.
  • Webhook replay: same event 3 times → one confirm, one bill, one movement.
  • Confirm vs expiry race on the same order → exactly one wins, state consistent.
  • Late capture → PAYMENT_REVIEW, no stock change.
  • State machine: every listed transition allowed, every other rejected.
  • Money: line totals, rounding, FY boundaries (31 Mar / 1 Apr IST).
  • Bill numbering under concurrent counter bills → no gaps, no duplicates.

Manual on staging (Razorpay test mode)

  • Successful UPI, card failure, closing the checkout, letting the timer run out.
  • Simulated late payment (pause the HoldExpiry worker's Razorpay check, capture after expiry).
  • Full delivery: pack, sticker print, assign, scan, OTP, wrong OTP × 5.
  • Counter billing on a phone: WhatsApp two-tap with a real device.
  • Thermal printer print from Chrome on the counter PC.
  • Deactivate a logged-in supervisor and confirm their next request fails.
21

Build order

Each milestone ends with something usable on staging. The risky part, the stock hold, comes early.

#MilestoneIncludes
M1FoundationRepo, CI, VPS + staging, schema, auth (staff + customer), branches, catalogue, branch products, stock movements, admin website shell.
M2Online ordering on the webCustomer website: addresses, PIN → branch, cart, checkout with holds, Razorpay, webhook, expiry worker, review queue, order page with OTP. Race tests green.
M3FulfilmentOrder board, pack + date, QR stickers, assignment, delivery app (scan, OTP, failed attempt), store pickup, cancel + full refund.
M4CounterCounter billing, bill numbering, QuestPDF, browser printing, WhatsApp two-tap, daily sales report.
M5Notifications and reportsSignalR dashboard, FCM to apps and admin browsers, stale/review reminders, the six reports, CSV export, audit log viewer.
M6Customer app and launchFlutter customer app on Play Store, backups + restore drill, load test, security review, go-live per branch.