CampusBay
A verified peer-to-peer student marketplace, built and self-hosted solo. Real-time chat, Stripe payments, escrow, flash sales, AI support, and a wallet system. Live at campusbay.store.
CampusBay is a production-grade peer-to-peer student marketplace I'm building solo as a self-development project. Originally built for University of Malaya, now expanding Malaysia-wide, a trusted, verified space for students to buy and sell textbooks, electronics, hostel furniture, and more.
Registration is gated to institutional email addresses, verified via 6-digit OTP: every user is a real verified student, eliminating the scammers that are rampant on Carousell and Facebook Marketplace.
Screens from the running build. Every page here is live on campusbay.store, not a mockup: the same React frontend talking to the containerized backend described below.
The order lifecycle uses a state machine to move funds safely through an escrow-style flow:
order.totalAmount = listingPrice × quantity
- discountAmount
+ (SST if applicable)
buyer.wallet_balance -= order.totalAmount -- atomic SQL UPDATE
seller.wallet_balance += order.totalAmount -- on order Completed
escrow: funds held in system until Completed
released to seller only on meetup confirmationDeduction runs as a single atomic SQL statement, UPDATE users SET wallet_balance = wallet_balance - $amount WHERE id = $id AND wallet_balance >= $amount, so two concurrent checkouts can never both succeed against a balance that covers only one. Buyer funds move to escrow immediately; the seller sees the credit only once both parties confirm the meetup — safe without a third-party payment processor.
# Buyer wallet: RM 120.00, buying item priced RM 45, qty 1 # Voucher applied: RM 5 off, SST not applicable (digital good) totalAmount = 45 × 1 - 5 + 0 = RM 40.00 UPDATE users SET wallet_balance = wallet_balance - 40.00 WHERE id = 'buyer_id' AND wallet_balance >= 40.00 -- rows affected: 1 → success, new balance RM 80.00 -- funds sit in escrow until meetup confirmed by both sides -- only then: seller.wallet_balance += 40.00
basePrice = listing.price × quantity discount = voucher.discountAmount (flat or % calculated) sst = (basePrice - discount) × 0.08 (if SST enabled) totalAmount = basePrice - discount + sst -- Stored on Orders table: orders.discountAmount = discount orders.voucherCode = voucher.code orders.totalAmount = totalAmount
Voucher definitions can change or expire. If the order stored only a voucher reference, a later edit would silently rewrite historical totals. Storing discountAmount directly keeps every past order honest. Discounts are resolved once, at checkout, and the resulting amount is frozen onto the order row rather than recomputed later.
# Listing: RM 80, qty 2 → basePrice = 160 # Voucher "WELCOME10": 10% off, SST enabled at 8% discount = 160 × 0.10 = RM 16.00 sst = (160 - 16) × 0.08 = RM 11.52 total = 160 - 16 + 11.52 = RM 155.52 orders.discountAmount = 16.00 orders.voucherCode = "WELCOME10" orders.totalAmount = 155.52
boost.expiresAt = NOW() + INTERVAL '${days} days'
boost.active = (expiresAt > NOW())
-- Featured query: boosted products rank first
SELECT * FROM products
WHERE boost_expires_at > NOW()
ORDER BY boost_expires_at DESC, created_at DESCBoost is time-based, not score-based — no decaying relevance algorithm, just a hard expiry written once at purchase. That keeps /products/featured a single indexed range query rather than a recomputed ranking job, which matters on one mini-PC where cron jobs compete with the live API.
# Seller buys a 3-day boost on 2026-06-19 14:00:00
boost.expiresAt = '2026-06-19 14:00:00' + INTERVAL '3 days'
= '2026-06-22 14:00:00'
# Featured query at 2026-06-20 09:00:00:
WHERE boost_expires_at > '2026-06-20 09:00:00'
→ '2026-06-22 14:00:00' > '2026-06-20 09:00:00' → True
→ product appears in /products/featuredAccess Token: HS256, expires in 15min Refresh Token: HS256, expires in 7d, stored httpOnly cookie Rate limits (express-rate-limit): /api/auth/* → 10 req / 15min /api/wallet/* → 20 req / 15min /api/support/* → 3 requests / day (AI chat)
Short-lived access tokens limit the damage window: 15 minutes is enough for normal browsing, short enough that a stolen token is near-worthless. The refresh token never touches client-side JavaScript — httpOnly cookies are invisible to XSS — which matters most on a self-hosted stack with no CDN-level WAF.
# Login flow:
POST /api/auth/login → 200 OK
Set-Cookie: refreshToken=...; HttpOnly; Secure; SameSite=Strict
Body: { accessToken: "eyJhbGci...", expiresIn: 900 }
# 16th request to /api/auth/* within 15 minutes:
→ 429 Too Many Requests
{ "error": "Too many auth attempts, try again in 4 minutes" }