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.
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 only covers one of them. The buyer's funds move into an escrow state immediately, but the seller only sees the credit once both parties confirm the meetup, which is what makes the marketplace safe without a third-party payment processor in the loop.
# 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
Discounts are resolved once, at checkout, and the resulting amount is frozen onto the order row rather than recomputed later. That matters because voucher definitions can change or expire — if the order only stored a reference to the voucher code, a later edit to that voucher would silently rewrite historical order totals. Storing discountAmount directly keeps every past order mathematically honest no matter what happens to the voucher afterward.
# 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 intentionally time-based rather than score-based — there's no decaying relevance algorithm, just a hard expiry timestamp written once at purchase time. This keeps the /products/featured endpoint a single indexed range query instead of a recomputed ranking job, which matters on a single mini-PC host where background cron jobs compete for the same CPU as 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 if one ever leaks — 15 minutes is long enough for normal browsing but short enough that a stolen token is nearly worthless by the time it could be misused. The refresh token never touches client-side JavaScript at all, since httpOnly cookies are invisible to XSS payloads; that's the one line of defence that matters most against token theft on a self-hosted stack without a CDN-level WAF in front of it.
# 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" }