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.
Architecture
One API, one database, one server. Single-instance by design (D15): images live on local disk and background workers run in-process.
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
IFileStoreinterface so that change is local.
Hostnames (placeholders)
pazhakadai.in: customer website, with/apiand/uploadsproxied (same origin, no CORS).admin.pazhakadai.in: admin website, with/api,/hubs,/uploadsproxied.api.pazhakadai.in: mobile apps and the Razorpay webhook.
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
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
MySqlConnectorfor the hold, confirm and numbering SQL, so the exact statements are visible and reviewed FluentValidationfor request validationRazorpay.NET SDK or a thin typedHttpClientFirebaseAdminfor FCMQuestPDFfor bills (community licence, free under US$1M revenue)SkiaSharpfor image resizing (MIT)Serilogfor logs
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.
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.
All tables and columns
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
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
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
- 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
Money, weight and time
Integers only
- Weight in grams (
int), money in paise (long). Nodecimalordoublein storage. - Line total:
(qty_g × price_per_kg_paise + 500) / 1000with integer division. That rounds half up and is exact. - Online qty must satisfy
qty_g ≥ min_gand(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, else2025-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}";
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.
6.1 Checkout steps
- Load the cart and the branch settings. Reject if the branch is paused or the PIN isn't served.
- Start a transaction (
READ COMMITTED). - For each line, sorted by
branch_product_id(consistent lock order prevents deadlocks), run the conditional update below. - If any update affects 0 rows, or any current price differs from what the client showed (
expectedTotalPaise), roll back and return409with a list of changes. - Insert
orders(PAYMENT_PENDING,hold_expires_at = now + 10 min),order_items(snapshots) andstock_holds(ACTIVE). Commit. - 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)
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
| Operation | Statement |
|---|---|
| Counter bill line | SET stock_g = stock_g - @q WHERE id = @id AND stock_g - held_g >= @q |
| Wastage / negative adjustment | Same guard. Staff cannot write off stock that is held for a paying customer; they wait for the hold to resolve. |
| Received / positive adjustment / restock | SET stock_g = stock_g + @q (no guard needed) |
| Admin confirms a late payment | Counter-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_atbut 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.
Razorpay integration
Account setup
- Enable automatic capture in the dashboard (payment settings).
- Webhook URL
https://api.pazhakadai.in/api/webhooks/razorpaywith a dedicated secret. - Events:
payment.captured,payment.failed,order.paid,refund.processed,refund.failed. - Separate test-mode keys and webhook for staging.
Flow
- API:
POST /v1/orderswithamount(paise),currency: "INR",receipt: order_no,notes.order_public_id. - Client opens Checkout (
checkout.json web,razorpay_flutterin the app) withorder_id,key,timeout, prefilled mobile. - Client success handler posts
razorpay_payment_id,razorpay_order_id,razorpay_signatureto/api/payments/verify. - 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();
});
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.
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.
| From | To | Actor | Guard / side effects |
|---|---|---|---|
PAYMENT_PENDING | CONFIRMED | System | Verified capture. Convert holds, bill, OTP. |
PAYMENT_PENDING | PAYMENT_FAILED | System | payment.failed, checkout dismissed, or expiry. Release holds. |
PAYMENT_FAILED | PAYMENT_REVIEW | System | Late capture. Alert admins. |
PAYMENT_REVIEW | CONFIRMED | Admin | Guarded stock decrement succeeds for every line. |
PAYMENT_REVIEW | REFUNDED | Admin | Reason. Full refund. No stock change. |
CONFIRMED | PACKED | Admin, supervisor | Delivery orders. estimated_date required (≥ today). |
CONFIRMED | READY_FOR_PICKUP | Admin, supervisor | Pickup orders. Pickup date required. |
PACKED | ASSIGNED | Admin, supervisor | Active partner whose home branch is the order's branch. Reassign allowed while ASSIGNED. |
ASSIGNED | OUT_FOR_DELIVERY | Partner | Valid QR token, assigned to this partner. |
OUT_FOR_DELIVERY | DELIVERED | Partner | Valid QR token + correct OTP, not locked. |
OUT_FOR_DELIVERY | DELIVERY_ATTEMPT_FAILED | Partner | Reason code required. |
DELIVERY_ATTEMPT_FAILED | PACKED | Admin, supervisor | Package back at shop. New date required. Clears partner. |
READY_FOR_PICKUP | COLLECTED | Admin, supervisor | Correct OTP typed by staff. |
| Any of CONFIRMED … DELIVERY_ATTEMPT_FAILED, READY_FOR_PICKUP | CANCELLED | Admin | Reason. Restock or write off per line. Refund started. |
CANCELLED | REFUNDED | System | refund.processed. |
QR stickers and delivery OTP
QR token
- Content:
PZ1.{order_public_id}.{sig}, wheresig= first 16 bytes ofHMAC-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/scanchecks 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 = 1and branch admins are alerted. - Admin override to DELIVERED (reason required) clears the lock.
Bills, PDF, printing and WhatsApp
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
Auth and security
| Concern | Implementation |
|---|---|
| Passwords | PasswordHasher<T> from ASP.NET Core Identity (PBKDF2). Min 8 chars, checked against a bundled common-password list. |
| Access token | JWT, 15 minutes. Claims: sub, typ (customer | staff), role, branches, sv (session version). |
| Refresh token | 32 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 storage | Web: 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 deactivation | Deactivate → 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. |
| Lockout | 5 failures → locked_until = now + 15 min. Same generic error for wrong number and wrong password. |
| Rate limits | ASP.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 scoping | Every 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 policies | CanRefund, CanEditPrices, CanWriteOffStock = super admin + admin. CanOperateOrders, CanBillCounter, CanReceiveStock add supervisor. Partner for /api/partner/*. |
| Audit | IAuditWriter 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 headers | HTTPS only, HSTS, X-Content-Type-Options, frame-ancestors none on admin. Uploads served with Content-Type from extension and no script execution. |
Realtime and push
SignalR (admin dashboard)
- Hub
/hubs/staff. On connect, add the connection tobranch:{id}groups from the caller's claims. - Events:
OrderConfirmed,OrderStatusChanged,PaymentReview,DeliveryFailed,StaleOrder,StockChanged. - WebSocket auth: access token via
access_tokenquery string, read inJwtBearerEvents.OnMessageReceivedfor 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 returnUnregistered.- Apps register their token with
POST /api/devicesafter login; logout deletes it. - Admin web push: Firebase JS SDK
getTokenwith a VAPID key andfirebase-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.
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}.webpand{ulid}_t.webp; the DB stores relative paths. New uploads get new names, so caches never serve stale images. - nginx serves
/uploads/directly withexpires 30d. The API is not involved in reads.
Background workers
BackgroundService + PeriodicTimer inside the API process. Each run is idempotent and processes a bounded batch.
| Worker | Every | Does |
|---|---|---|
| HoldExpiry | 30 s | PAYMENT_PENDING with hold_expires_at < now: ask Razorpay for captured payments → Confirm, else Release. |
| StaleOrderAlert | 10 min | CONFIRMED longer than stale_order_minutes → SignalR + web push to branch staff (once per order per interval). |
| ReviewReminder | 10 min | PAYMENT_REVIEW older than review_reminder_minutes → push to branch admins. |
| RefundSync | 1 h | Refunds PENDING over 24 h → fetch status from Razorpay. |
| CartCleanup | daily 03:00 IST | Delete carts untouched for cart_ttl_days. |
| TokenCleanup | daily | Delete expired refresh tokens and processed webhook events older than 90 days. |
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}
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.jsloaded 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_corepackage:dioclient with refresh interceptor, models, money/weight formatting. - State:
flutter_riverpod; routing:go_router. - Customer:
razorpay_flutter,firebase_messaging+flutter_local_notifications. - Delivery:
mobile_scannerfor QR,url_launcherfor Maps and calls. - Delivery app distributed via Play Console internal testing or a signed APK.
Deployment
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)
- On pull request: build and test API (including Testcontainers MySQL), lint and build both Next.js apps,
flutter analyzeand tests. - On merge to
main: build artefacts, rsync to staging over SSH, apply EF migrations (efbundle), restart services, smoke-test/health. - Production: same job, triggered manually after staging is checked. Take a database dump immediately before migrating.
A second, smaller VPS with Razorpay test keys and its own webhook, reachable over HTTPS so webhooks arrive. Never point staging at production data.
Backups and restore
Losing the disk must cost at most 15 minutes of orders.
| What | When | How |
|---|---|---|
| Full database | Nightly 02:00 IST | mysqldump --single-transaction --routines --triggers --source-data=2 | gzip |
| Binary logs | Every 15 min | FLUSH BINARY LOGS, then copy closed binlog files off-server. binlog_expire_logs_seconds = 7 days locally. |
| Images | Nightly | rclone sync /srv/pazhakadai/uploads remote:pz-uploads |
| Config | On change | nginx, 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.
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.
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.
/healthchecks 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.
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_galways equals the sum of ACTIVE holds, andstock_gequals 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.
Build order
Each milestone ends with something usable on staging. The risky part, the stock hold, comes early.
| # | Milestone | Includes |
|---|---|---|
| M1 | Foundation | Repo, CI, VPS + staging, schema, auth (staff + customer), branches, catalogue, branch products, stock movements, admin website shell. |
| M2 | Online ordering on the web | Customer website: addresses, PIN → branch, cart, checkout with holds, Razorpay, webhook, expiry worker, review queue, order page with OTP. Race tests green. |
| M3 | Fulfilment | Order board, pack + date, QR stickers, assignment, delivery app (scan, OTP, failed attempt), store pickup, cancel + full refund. |
| M4 | Counter | Counter billing, bill numbering, QuestPDF, browser printing, WhatsApp two-tap, daily sales report. |
| M5 | Notifications and reports | SignalR dashboard, FCM to apps and admin browsers, stale/review reminders, the six reports, CSV export, audit log viewer. |
| M6 | Customer app and launch | Flutter customer app on Play Store, backups + restore drill, load test, security review, go-live per branch. |