CCS Merch Store
Live
Society · Web

CCS Merch Store

Contributor — payments & CI/CD · Creative Computing Society · 2024

Merch storefront and fulfilment portal for the society: a React/Vite catalogue and cart over a Django REST backend on PostgreSQL, with separate products, orders, discounts and dashboard apps and QR codes generated for order pickup. The contribution here was the payments path and the deploy pipeline, landed in a single day of work: a Cashfree integration with server-side order creation and a pull-based webhook that re-fetches the order rather than trusting the callback, plus a Jenkins build. That gateway carried the August 2024 drop; the team moved the store onto another one in December, which is what runs today.

Built with
ReactReact
ViteVite
TailwindTailwind
daisyUIdaisyUI
DjangoDjango
Django REST FrameworkDjango REST Framework
PostgreSQLPostgreSQL
CashfreeCashfree
Jenkins
DockerDocker
Project Details
Live Store
RESOURCES

STATUS
Live
ROLE

Contributor — payments & CI/CD

ORGANISATION

Creative Computing Society

YEAR

2024

TYPE

Society · Web

TAGS
E-commerce
Payments

Selling merch to a student society is less an e-commerce problem than a fulfilment one. Anyone can take the money; the hard part is knowing, at a counter on handout day, that the person in front of you paid and has not already collected. My contribution was the payments path and the deploy pipeline, landed in a single day of work in August 2024.

What was there before

The checkout it replaced was a hidden-form gateway POST. The server assembled a dozen fields - key, transaction id, amount, name, email, phone, product info, success and failure URLs, five spare user-defined slots - computed a SHA hash over them in a fixed order, and shipped the whole thing back to the browser, which rendered every one as an <input type="hidden"> inside a form pointed at the gateway's own domain. Clicking Pay submitted it.

That works, and it is a bad contract. The browser is the transport, so every field the gateway needs has to exist in the DOM and be correct; the hash has to be recomputed server-side against a field order that lives in a comment somewhere; and any field the gateway adds is a new hidden input and a new hash. Worse, the page can be edited before submission, so the integrity of the whole thing rests on that hash and nothing else.

The payments slice

I integrated Cashfree, replacing that hidden-form gateway POST with the JS SDK. Server side, order creation runs through the Python SDK with a 5-minute expiry window, a return URL to the storefront and a notify URL at the API. The endpoint hands the browser one thing, a payment session id.

One string instead of fourteen fields and a hash. Everything the gateway needs to know was told to it server-to-server when the session was created, and the browser is handed an opaque handle it cannot usefully tamper with. The store's own order id doubles as the gateway's order id, which is a small decision that pays for itself twice: reconciliation is a string equality rather than a join table, and every later lookup against the gateway is keyed on something the store already knows.

The webhook is the part I would defend. It has no signature check on the body and does not need one, because it does not trust the body at all: the callback is only a trigger, and the handler re-fetches the order from Cashfree server-to-server before deciding success or failure. Pull, do not trust. On success it marks the order verified, records which instrument was used, increments the discount code's usage counter, empties the buyer's cart, generates the pickup QR and fires the confirmation mail.

That left the race every redirect gateway has. The browser returns from the payment page before the server-to-server call has necessarily landed, so the status screen shows nothing. The fix was to have the return page poke the webhook itself and then poll verification, which is safe only because that webhook re-fetches from the gateway. Being pull-based makes the handler idempotent: calling it twice, once from Cashfree and once from the returning browser, re-derives the same state from the same authority. The verification endpoint it then polls is authenticated and refuses to describe a payment that does not belong to the requesting user.

I also reordered the URL patterns, since two catch-alls were shadowing the more specific place-order, apply-discount and webhook paths. Django resolves in declaration order, so order/<str:order_id>/ declared above order/place/ means "place" is matched as an order id and the checkout endpoint is unreachable. Moving the two parameterised patterns to the bottom of the list was the entire fix.

The pipeline slice

Jenkins in three stages - stash, pull, docker compose up --build -d - with mail on success and failure. The stash stage is the interesting one. The deploy target is a live working tree on the VM that drifts, with an env file, collected static and uploaded media, so a plain git pull conflicts and fails the build. Stashing first makes the pipeline idempotent against a dirty server. The backend image also installs a gateway SDK from a private package index rather than PyPI, which breaks every naive Dockerfile.

The drift is structural rather than sloppiness: the compose file bind-mounts logs, media and static from inside the repository directory, and the app writes to all three on every request. The container itself runs collectstatic, makemigrations, migrate and then gunicorn as one shell command, so a deploy is a rebuild rather than a restart, with Postgres 15 alongside it on a named volume.

What the store around it does

  • Login delegated entirely to the society SSO, so no passwords live here. Users are auto-provisioned on roll number and rank is recomputed from SSO roles on every login.
  • The SSO token is doubly wrapped: an HS256 JWT whose payload carries an AES-256-CBC ciphertext, decrypted with the leading bytes of the shared key and an IV taken from the head of the payload, then unpadded and parsed to yield roll number, email, name, phone and role list.
  • The user primary key is the roll number, not an autoincrement.
  • Products gate on society rank through a Postgres array column, so core-only merch is invisible to a general member.
  • Products carry their own form, with per-product flags for whether a printing name, a size or an uploaded image is required, so one model covers plain stock and custom-printed items.
  • A global ordering kill switch behind two staff endpoints, because a drop that sells out needs to stop taking money in one click rather than by unpublishing every product.
  • Discount codes with usage caps, expiry and rank restrictions. A 100% discount is charged as one rupee, because gateways reject zero-amount orders. Codes are either typed by an admin or generated as a random ten-character string, decided by a flag on the model.
  • QR codes for pickup. On payment success the order id and transaction id are joined with a pipe and stored as base64 in a text column, so the same bytes serve the API response and the inline email attachment.
  • Counter redemption behind a staff-only view checking the payment belongs to that order, is successful, and the order is verified and not already completed. A second scan is rejected.
  • CSV export of successful orders per drop, for the people packing the boxes.

The gate

The redemption check is worth spelling out because it is the whole reason the QR exists. The scanner splits the payload on the pipe, looks up the order and the payment independently, and then requires four things at once: that the payment actually belongs to that order, that the gateway reported success, that the order was verified, and that it has not already been completed. Only then does it flip the completion flag, mail the buyer, and return the buyer's name and amount for the volunteer to read off the screen. Any failure is a 400 with a plain string, because the person reading it is holding a phone in a queue.

Two honest notes. Celery and Redis are in the requirements but there is no worker and no broker, so the files named tasks.py spawn raw threads and confirmation mail is fire-and-forget inside the web process. And the QR payload is unsigned; what protects it is staff-only, single-use redemption.

Afterwards

The Cashfree path carried the August drop. In December the team reverted it and moved the store onto a third gateway for a later one, which is what main runs today. The archaeology is still visible in the image: requirements.txt carries two payment SDKs it no longer uses, and the Dockerfile still reaches out to a private package index for a third.

Project Details
Live Store
RESOURCES

STATUS
Live
ROLE

Contributor — payments & CI/CD

ORGANISATION

Creative Computing Society

YEAR

2024

TYPE

Society · Web

TAGS
E-commerce
Payments