
WorkFifty Villagers
Fifty Villagers
A platform for an education NGO: applications, admit cards, results and donations, with an admin panel the team runs day to day.
- Role
- Solo full stack
- Stack
- React, Node.js, Postgres, Docker
- Status
- Live
- Years
- 2025 to now
- Updates shipped automatically
- 46
- Schema migrations
- 17

The problem
One place for the whole entrance test
Fifty Villagers runs an entrance test for students. The team needed one place for the whole cycle: applications, admit cards with exam centres, results and donations, with an admin side they could run themselves.
The brief: admin and student accounts, Razorpay payments that are verified before they count, and a record of every change an admin makes.
Key decisions
Four decisions that keep money and data straight
Refresh tokens stored hashed
Refresh tokens are hashed before they are saved, like passwords, so a copy of the database cannot be used to sign in. Logging out revokes them.
Signature-verified Razorpay webhooks
Every payment event is verified server-side before it touches the records. The handler is idempotent, so a payment that is already recorded is never recorded twice.
A 17-migration Postgres schema with an activity log
An activity log of every admin action: who did it, what changed and when. No silent edits.
A small VPS instead of managed hosting
Docker, Nginx and HTTPS on a small VPS, deployed by GitHub Actions with a health check after every release.
How payments get accepted
The check every payment passes
import crypto from 'crypto';
// Razorpay signs every event with our shared secret.
// Verify before touching the ledger; replays are dropped.
export function verifyWebhook(rawBody, signature, secret) {
const expected = crypto
.createHmac('sha256', secret)
.update(rawBody)
.digest('hex');
return crypto.timingSafeEqual(
Buffer.from(expected),
Buffer.from(signature)
);
}Looking back
What I'd keep, and what I'd change
Note to self
Keep: treating every payment as a small state machine with tests around it. A payment can only take the steps it's allowed to, and once it's paid it never goes back to unpaid, so a retried webhook or a double tap can't leave it in a strange state.
Change: the activity log only arrived at the sixteenth migration. I'd build it in from the first, and turn on Postgres row-level security from the start instead of relying only on application-level RBAC. Two layers are cheap; one is a foot-gun.
