How the VETnCO platform actually works
This document walks through the VETnCO codebase one concern at a time — how requests flow through it, how data is modeled, where caching and payments happen, and where the rough edges are. Every section pairs a plain-language explanation with the real code so a developer can present it confidently, and an auditor can verify it, without either side needing to go spelunking through the repository first.
01 Architecture Overview
Before diving into any one feature, it helps to see the shape of the whole system: what technology runs each layer, how the process starts up, and what happens to a request from the moment it hits the server to the moment a response goes back to the browser.
The stack, layer by layer
The backend is a single Node.js process running Express. It talks to MySQL exclusively through Prisma (no raw SQL driver used directly, aside from a few deliberate raw queries covered in Section 2). Authentication is handled with JWTs and bcrypt-hashed passwords. Payments run through Razorpay. Caching is Redis when available, with an automatic in-memory fallback so the app still works without Redis configured. The frontend is a single React + Vite application that serves multiple "panels" (admin, business owner, individual vet, individual groomer, and the pet-owner facing app) from one codebase. Real-time features run over Socket.IO.
| Layer | Technology | Key files |
|---|---|---|
| Runtime | Node.js (ESM modules) | server.js |
| HTTP | Express | loaders/express.js |
| ORM / DB | Prisma + MySQL | prisma/schema.prisma, models/prisma.client.js |
| Auth | JWT + bcrypt | utils/jwt.util.js, utils/bcrypt.util.js |
| Payments | Razorpay | services/razorpay.service.js, services/payment.service.js |
| Cache | Redis + in-memory fallback | services/cache.service.js |
| Frontend | React (Vite) | resources/views/src/ |
| Realtime | Socket.IO | loaders/socket.js |
How the server boots
async function start() {
await loadDatabase();
await cacheService.init();
loadExpress(app);
const server = app.listen(PORT, () => {
console.log(`✓ Vetnco API running at http://localhost:${PORT}`);
console.log(`✓ Swagger docs at http://localhost:${PORT}/api/docs`);
console.log(`✓ API base: http://localhost:${PORT}/api/v1`);
});
await initSocket(server);
const shutdown = async (signal) => {
console.log(`Received ${signal}. Gracefully shutting down...`);
server.close(async () => {
try {
await prisma.$disconnect();
console.log('✓ Database disconnected safely.');
} catch (err) {
console.error('Error during database disconnect:', err.message);
}
process.exit(0);
});
};
}
How the API is mounted
Every request into /api/ passes through security headers (Helmet), a global rate limiter, and JSON parsing before it ever reaches a route. The rate limiter caps any single client at 300 requests per 15 minutes — a coarse, whole-API limit rather than per-endpoint, which is worth noting for the auditor as a scaling and abuse-prevention consideration.
// Security Headers
app.use(helmet({ contentSecurityPolicy: false, crossOriginResourcePolicy: { policy: 'cross-origin' } }));
// Global Rate Limiter — 300 requests / 15 minutes on /api/
const globalLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 300,
standardHeaders: true,
legacyHeaders: false,
message: { success: false, code: 'RATE_LIMIT_EXCEEDED', message: 'Too many requests' },
});
app.use('/api/', globalLimiter);
// Versioned API routes
app.use('/api/v1', v1Routes);
app.use(notFoundHandler);
app.use(errorHandler);
The request lifecycle, end to end
This is the single most useful diagram for orienting a new reviewer. Every backend feature — payments, bookings, vendor listings — follows this same path:
02 Code Modularity & Reuse
This section covers how the codebase is organized so that logic isn't duplicated, and where — despite that organization — some duplication or shortcuts still exist.
The layered backend pattern
Almost every backend feature is split across four layers, each with one job: Controllers only translate HTTP in and out (they never contain business logic), Services hold the business rules, Repositories are the only place Prisma queries live, and Routes wire it all together with middleware. This separation is what lets a reviewer trust that, say, changing a validation rule can't accidentally change how money is calculated — those concerns live in different files entirely.
| Layer | File count | Responsibility |
|---|---|---|
| Controllers | 34 files under controllers/ | HTTP in/out only |
| Services | 39 files under services/ | Business rules |
| Repositories | 24 files under repositories/ | Prisma queries |
| Routes (v1) | 34 files under routes/v1/ | Route wiring |
| Validations | 28 files under validations/ | Zod schemas |
| Middlewares | 6 files under middlewares/ | Cross-cutting concerns |
| Utils | 12 files under utils/ | Shared helpers |
The route aggregator
Rather than one giant routes file, each feature (auth, users, vet clinics, payments, etc.) gets its own router, and a single aggregator wires them all under /api/v1. This is the file to open first when trying to find where any feature lives.
/**
* Vetnco API v1 — modular route aggregator.
* Each feature has its own router file.
*/
const publicInvalidator = invalidateCacheMiddleware('public');
router.use('/auth', authRoutes);
router.use('/users', usersRoutes);
router.use('/dashboard', dashboardRoutes);
router.use('/vet-clinics', publicInvalidator, vetClinicsRoutes);
router.use('/grooming-centres', publicInvalidator, groomingCentresRoutes);
router.use('/diagnostic-labs', publicInvalidator, diagnosticLabsRoutes);
router.use('/pet-hostels', publicInvalidator, petHostelsRoutes);
router.use('/banners', publicInvalidator, bannersRoutes);
router.use('/me', meRoutes);
router.use('/public', publicCatalogRoutes);
router.use('/payments', paymentsRoutes);
// ... other feature routers
Walking one feature top to bottom: Payments
To show the pattern concretely, here is the payments feature traced through every layer — this is the best single example to use when explaining "how a feature is built" to the client.
Routes — who's allowed to call this, and with what shape of data
router.use(authenticate, authorize('PET_OWNER', 'VENDOR', 'SUPER_ADMIN', 'ADMIN'));
router.get('/razorpay/config', paymentController.getRazorpayConfig);
router.post('/razorpay/order', validate(createRazorpayOrderSchema), paymentController.createRazorpayOrder);
router.post('/razorpay/verify', validate(verifyRazorpayPaymentSchema), paymentController.verifyRazorpayPayment);
Controller — thin, no business logic
class PaymentController {
getRazorpayConfig = asyncHandler(async (_req, res) => {
const data = paymentService.getRazorpayConfig();
return sendSuccess(res, { data, message: 'Razorpay config retrieved' });
});
createRazorpayOrder = asyncHandler(async (req, res) => {
const data = await paymentService.createRazorpayOrder(req.user.id, req.body);
return sendSuccess(res, { data, message: 'Payment order created', statusCode: 201 });
});
verifyRazorpayPayment = asyncHandler(async (req, res) => {
const data = await paymentService.verifyRazorpayPayment(req.user.id, req.body);
return sendSuccess(res, {
data,
message: 'Payment verified and booking confirmed',
statusCode: 201,
});
});
}
Validation — what shape of request is even allowed through
export const createRazorpayOrderSchema = z.object({
body: z.object({
amount: z.coerce.number().positive('Amount must be greater than zero'),
currency: z.string().optional().default('INR'),
booking: bookingPayloadSchema.optional(),
}),
});
export const verifyRazorpayPaymentSchema = z.object({
body: z.object({
razorpay_order_id: z.string().min(1),
razorpay_payment_id: z.string().min(1),
razorpay_signature: z.string().min(1),
booking: bookingPayloadSchema.optional(),
}),
});
payment.service.js and razorpay.service.js, not every controller in the app.Shared utilities used everywhere
A handful of small, well-tested helpers are reused across all 34 controllers and 39 services rather than each file reinventing error handling or response shaping.
A typed error class every service throws instead of generic Errors, so the error handler always knows the right HTTP status and machine-readable code to send back.
export class AppError extends Error {
constructor(message, statusCode = 500, code = 'INTERNAL_ERROR', details = null) {
super(message);
this.statusCode = statusCode;
this.code = code;
this.details = details;
this.isOperational = true;
}
}
Wraps every async controller so a rejected promise is automatically forwarded to Express's error handler — no controller needs its own try/catch.
export const asyncHandler = (fn) => (req, res, next) => {
Promise.resolve(fn(req, res, next)).catch(next);
};
Every successful or failed response in the whole app is shaped by these two functions, which is why every endpoint returns the same predictable envelope ({ success, message, data } or { success, message, code }).
export function sendSuccess(res, { data = null, message = 'Success', meta = null, statusCode = 200 } = {}) {
const payload = { success: true, message };
if (data !== null) payload.data = data;
if (meta) payload.meta = meta;
return res.status(statusCode).json(payload);
}
export function sendError(res, { message = 'Error', statusCode = 500, errors = null, code = null } = {}) {
const payload = { success: false, message };
if (code) payload.code = code;
if (errors) payload.errors = errors;
return res.status(statusCode).json(payload);
}
Password hashing lives in exactly one place, using 12 salt rounds — a reasonable, industry-standard cost factor.
const SALT_ROUNDS = 12;
export async function hashPassword(plain) {
return bcrypt.hash(plain, SALT_ROUNDS);
}
export async function comparePassword(plain, hash) {
return bcrypt.compare(plain, hash);
}
Authentication middleware — the one gate everything passes through
Rather than every route re-implementing "check the JWT, load the user, check their role," one middleware pair (authenticate + authorize) is composed onto whichever routes need it. Notably, this middleware understands two different account shapes: ordinary users/admins, and "business" accounts (vendors) which carry a panelType to know which vendor dashboard they belong to.
export async function authenticate(req, res, next) {
try {
const header = req.headers.authorization;
if (!header?.startsWith('Bearer ')) {
throw new AppError('Authentication required', 401, 'UNAUTHORIZED');
}
const token = header.slice(7);
const decoded = verifyToken(token);
// Business JWT path
if (decoded.accountType === 'BUSINESS' && decoded.panelType && PANEL_REGISTRY[decoded.panelType]) {
// ... load business record, attach req.user
return next();
}
// User / admin JWT path
const user = await userRepository.findById(decoded.sub);
if (!user) throw new AppError('User not found', 401, 'UNAUTHORIZED');
if (user.status === 'SUSPENDED' || user.status === 'INACTIVE') {
throw new AppError('Account is not active', 403, 'ACCOUNT_INACTIVE');
}
req.user = { ...user, accountType: 'USER' };
next();
} catch (err) {
next(err);
}
}
export function authorize(...roles) {
return (req, res, next) => {
if (!req.user) {
return next(new AppError('Authentication required', 401, 'UNAUTHORIZED'));
}
if (!roles.includes(req.user.role)) {
return next(new AppError('Insufficient permissions', 403, 'FORBIDDEN'));
}
next();
};
}
Where modularity breaks down — two examples worth flagging
Reuse isn't perfect everywhere, and it's better for the developer to raise these proactively than have the auditor find them first.
Gap 1 — booking "extras" packed into a notes field
notes field on the booking, separated by a marker string.// The Appointment schema has no columns for these. We serialize them
// into a structured notes field so they are preserved for display.
const meta = {};
if (payload.checkInDate) meta.checkInDate = payload.checkInDate;
if (payload.checkOutDate) meta.checkOutDate = payload.checkOutDate;
if (payload.numberOfNights) meta.numberOfNights = payload.numberOfNights;
if (Array.isArray(payload.rooms) && payload.rooms.length) meta.rooms = payload.rooms;
if (Array.isArray(payload.tests) && payload.tests.length) meta.tests = payload.tests;
if (Array.isArray(payload.services) && payload.services.length) meta.services = payload.services;
const userNote = payload.notes?.trim() || null;
const metaJson = Object.keys(meta).length > 0 ? JSON.stringify(meta) : null;
const combinedNotes = [userNote, metaJson].filter(Boolean).join('\n---META---\n');
This works functionally, but it means the database can't filter, sort, or report on "how many hostel nights were booked this month" without parsing text — that logic would have to live in application code rather than a SQL query.
Gap 2 — the same distance query duplicated per vendor table
if (tableName === 'vet_clinics') {
query = prisma.$queryRaw`
SELECT id, (6371 * acos(cos(radians(${latVal})) * cos(radians(latitude))
* cos(radians(longitude) - radians(${lngVal}))
+ sin(radians(${latVal})) * sin(radians(latitude)))) AS distance
FROM vet_clinics
WHERE id IN (${Prisma.join(ids)})
`;
} else if (tableName === 'pet_hostels') {
query = prisma.$queryRaw`
SELECT id, (6371 * acos(...)) AS distance
FROM pet_hostels
WHERE id IN (${Prisma.join(ids)})
`;
}
// Same pattern repeated for grooming_centres, diagnostic_labs, etc.
Functionally correct, but any future change to the distance formula (e.g. switching to a more accurate ellipsoid model) has to be made in four places instead of one.
↑ back to top03 Data Design
This section explains how the database schema (defined in Prisma) models the business — bookings, vendors, transactions — and calls out a few places where the modeling is intentionally loose.
Core enums — the fixed vocabularies of the system
Enums constrain a field to a known set of values, both at the database level and in generated TypeScript-style types. This is where the business's core vocabulary — what a booking can be for, what a booking's status can be, what a transaction's payment/payout status can be — is defined once and reused everywhere.
enum AppointmentServiceType {
VET_CHECKUP
GROOMING
LAB_TEST
HOSTEL_STAY
VACCINATION
OTHER
}
enum AppointmentVendorType {
VET_CLINIC
GROOMING_CENTRE
DIAGNOSTIC_LAB
PET_HOSTEL
INDIVIDUAL_VET
INDIVIDUAL_GROOMER
}
enum AppointmentStatus {
PENDING
CONFIRMED
COMPLETED
CANCELLED
}
enum TransactionPaymentStatus {
SUCCESS
PENDING
FAILED
REFUNDED
}
enum TransactionPayoutStatus {
PENDING
PROCESSING
PAID
HOLD
}
The Appointment model — the central booking record
Every booking in the platform — whether it's a vet checkup, a grooming session, a lab test, or a hostel stay — is stored in one shared Appointment table rather than four separate tables. That's a deliberate, reasonable design choice for a multi-service marketplace: one place to query "all of a user's bookings," one place to apply status transitions, one place to index by date.
model Appointment {
id Int @id @default(autoincrement())
appointmentCode String @unique @map("appointment_code") @db.VarChar(50)
petName String @map("pet_name") @db.VarChar(100)
petType String? @map("pet_type") @db.VarChar(50)
ownerName String @map("owner_name") @db.VarChar(255)
ownerEmail String @map("owner_email") @db.VarChar(255)
ownerPhone String @map("owner_phone") @db.VarChar(20)
userId Int? @map("user_id")
serviceType AppointmentServiceType @map("service_type")
vendorName String @map("vendor_name") @db.VarChar(255)
vendorType AppointmentVendorType @map("vendor_type")
vendorId Int? @map("vendor_id") // polymorphic — no FK
appointmentDate DateTime @map("appointment_date") @db.Date
appointmentTime String @map("appointment_time") @db.VarChar(20)
amount Decimal @default(0) @db.Decimal(10, 2)
status AppointmentStatus @default(PENDING)
notes String? @db.Text // also stores JSON meta
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
@@index([appointmentCode])
@@index([ownerEmail])
@@index([serviceType])
@@index([vendorType])
@@index([status])
@@index([appointmentDate])
@@index([createdAt])
@@map("appointments")
}
- There is no
petIdforeign key — pet name and type are copied (denormalized) directly onto the booking rather than referencing the pet's own record. This means if a pet's profile is later corrected, past bookings still show the old name/type — which is arguably correct for a historical record, but worth confirming is intentional. vendorIdis polymorphic — it's just an integer that means "the ID of a clinic" or "the ID of a hostel" depending on whatvendorTypesays. The database itself has no foreign key enforcing that the ID actually exists in the right table; that check only happens in application code.- Booking "extras" live inside
notesas JSON text (see Section 2, Gap 1) rather than as real columns.
The BusinessTransaction model — the payout ledger
This is the record of money actually changing hands: what a vendor is owed after the platform's commission is taken out, and whether that payout has been processed.
model BusinessTransaction {
id Int @id @default(autoincrement())
transactionCode String @unique @map("transaction_code") @db.VarChar(50)
panelType BusinessPanelType @map("panel_type")
businessId Int @map("business_id") // panel-scoped — no FK
appointmentId Int? @map("appointment_id") // scalar only — no @relation
customerName String @map("customer_name") @db.VarChar(255)
customerEmail String? @map("customer_email") @db.VarChar(255)
serviceName String @map("service_name") @db.VarChar(255)
amount Decimal @db.Decimal(10, 2)
platformFee Decimal @default(0) @map("platform_fee") @db.Decimal(10, 2)
netAmount Decimal @map("net_amount") @db.Decimal(10, 2)
paymentMethod String @map("payment_method") @db.VarChar(50)
paymentStatus TransactionPaymentStatus @default(PENDING) @map("payment_status")
payoutStatus TransactionPayoutStatus @default(PENDING) @map("payout_status")
paidAt DateTime? @map("paid_at")
invoiceNumber String? @unique @map("invoice_number") @db.VarChar(50)
notes String? @db.Text
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@index([panelType, businessId])
@@index([paymentStatus])
@@index([payoutStatus])
@@index([createdAt])
@@map("business_transactions")
}
appointmentId on this table has no Prisma @relation back to Appointment — it's stored, but the database will not stop you from writing a transaction that points at an appointment that doesn't exist. Same with businessId, which isn't a real foreign key to whichever vendor table it belongs to.How a vendor type maps to a payout "panel"
Since vendors are spread across multiple tables (clinics, labs, hostels, individual vets, individual groomers), this small lookup table is what bridges a booking's vendorType to the correct payout panel category. It's a good example of business rules being centralized in one small, readable place rather than scattered across conditionals.
const VENDOR_TO_PANEL = {
VET_CLINIC: 'CLINIC',
GROOMING_CENTRE: 'GROOMING',
DIAGNOSTIC_LAB: 'LAB',
PET_HOSTEL: 'HOSTEL',
INDIVIDUAL_VET: 'VETERINARIAN',
INDIVIDUAL_GROOMER: 'INDIVIDUAL_GROOMER',
};
Platform-wide payment defaults
Default commission rate and payout cadence are defined once as fallbacks, and can be overridden by an admin-configurable settings record at runtime.
const DEFAULT_SETTINGS = {
platformName: 'Vetnco',
platformCommission: 12,
paymentGateway: 'Razorpay',
autoPayout: 'Weekly',
// ...
};
↑ back to top
04 Cache Use
A common question in a code review is "does this app cache anything, or does every request hit the database?" The short answer here: yes, caching is implemented, it prefers Redis, and it degrades gracefully to an in-memory cache if Redis isn't configured — so the app never breaks for lack of a cache server, it just gets slower and loses cross-instance sharing.
The cache service — Redis with automatic fallback
import { createClient } from 'redis';
class CacheService {
constructor() {
this.redisClient = null;
this.isRedisConnected = false;
this.memoryCache = new Map();
this.memoryCacheExpiry = new Map();
}
async init() {
if (process.env.REDIS_URL) {
try {
this.redisClient = createClient({ url: process.env.REDIS_URL });
await this.redisClient.connect();
this.isRedisConnected = true;
} catch (err) {
console.warn('Falling back to in-memory cache.', err.message);
this.isRedisConnected = false;
}
} else {
console.log('Redis not configured. Using in-memory cache service.');
}
}
async get(key) {
if (this.isRedisConnected) {
const val = await this.redisClient.get(key);
return val ? JSON.parse(val) : null;
}
const expiry = this.memoryCacheExpiry.get(key);
if (expiry && expiry < Date.now()) {
this.delete(key);
return null;
}
const val = this.memoryCache.get(key);
return val ? JSON.parse(val) : null;
}
async set(key, value, durationSeconds = 300) {
const stringified = JSON.stringify(value);
if (this.isRedisConnected) {
await this.redisClient.set(key, stringified, { EX: durationSeconds });
return;
}
this.memoryCache.set(key, stringified);
this.memoryCacheExpiry.set(key, Date.now() + durationSeconds * 1000);
}
async clearPattern(pattern) {
// Redis SCAN + DEL, or filter keys in memory Map
}
}
export default new CacheService();
HTTP-level caching middleware
Rather than caching inside each service, caching is applied as Express middleware — meaning it caches the final JSON response of a whole GET request, keyed by the URL. It's transparent to whatever controller runs "underneath" it.
export const cacheMiddleware = (durationSeconds = 300, prefix = 'cache') => {
return async (req, res, next) => {
if (req.method !== 'GET') return next();
const key = `${prefix}:${req.originalUrl || req.url}`;
const cachedResponse = await cacheService.get(key);
if (cachedResponse) {
res.setHeader('X-Cache', 'HIT');
return res.status(200).json(cachedResponse);
}
const originalJson = res.json;
res.json = function (body) {
res.json = originalJson;
if (res.statusCode >= 200 && res.statusCode < 300) {
cacheService.set(key, body, durationSeconds).catch(() => {});
}
res.setHeader('X-Cache', 'MISS');
return originalJson.call(this, body);
};
next();
};
};
export const invalidateCacheMiddleware = (prefix = 'cache') => {
return async (req, res, next) => {
res.on('finish', async () => {
if (res.statusCode >= 200 && res.statusCode < 300) {
await cacheService.clearPattern(`${prefix}:*`);
}
});
next();
};
};
You can see this pair used together back in Section 2's route aggregator — every vendor-listing route (clinics, grooming centres, etc.) is wrapped in publicInvalidator, so any successful create/update/delete on those resources wipes the whole public:* cache namespace, guaranteeing stale listings never linger after an edit.
Where caching is actually turned on
Caching is applied deliberately, not blanket — it only covers public, read-heavy, non-personalized endpoints, with a 5-minute time-to-live.
const publicCache = cacheMiddleware(300, 'public'); // 5-minute TTL
router.get('/banners/home', publicCache, publicCatalogController.homeBanners);
router.get('/search', publicCache, validate(publicSearchQuerySchema), publicCatalogController.search);
router.get('/vet-clinics', publicCache, validate(publicListQuerySchema), publicCatalogController.clinics);
router.get('/grooming-centres', publicCache, validate(publicListQuerySchema), publicCatalogController.groomingCentres);
router.get('/lab-tests', publicCache, validate(publicLabTestsQuerySchema), publicCatalogController.labTests);
router.get('/diagnostic-labs', publicCache, validate(publicListQuerySchema), publicCatalogController.labs);
router.get('/pet-hostels', publicCache, validate(publicListQuerySchema), publicCatalogController.hostels);
/me/* (personal to the logged-in user), and every payment endpoint (must always reflect live, uncached state).05 Database Use
This covers how the app connects to MySQL through Prisma, how pagination and filtering are implemented consistently, and how multi-step writes are kept atomic.
One shared database connection, not one per request
A single Prisma client instance is created and reused across the whole app (a "singleton"). In development, it's also attached to globalThis so that hot-reloading doesn't spawn a new database connection pool every time a file changes.
import { PrismaClient } from '@prisma/client';
const globalForPrisma = globalThis;
const prisma =
globalForPrisma.prisma ??
new PrismaClient({
log: process.env.NODE_ENV === 'development' ? ['error', 'warn'] : ['error'],
});
if (process.env.NODE_ENV !== 'production') {
globalForPrisma.prisma = prisma;
}
export default prisma;
A live readiness check for deployment health
Used by load balancers / orchestration tools to know whether this instance is actually able to talk to the database before routing traffic to it.
router.get('/health/ready', async (_req, res) => {
try {
await prisma.$queryRaw`SELECT 1`;
res.status(200).json({ status: 'UP', message: 'Readiness check passed' });
} catch (err) {
res.status(503).json({ status: 'DOWN', message: 'Database connection failed', error: err.message });
}
});
A consistent pagination pattern across every list endpoint
Every "list" API in the app (appointments, transactions, vendors...) follows the same shape: build a where clause from filters, then run the paginated findMany and the total count in parallel so both queries happen at the same time instead of one after another.
async findAll({ page = 1, perPage = 10, search, status, /* ... */ sortBy = 'createdAt', sortOrder = 'desc' }) {
const where = buildWhere({ search, status, /* ... */ });
const [data, total] = await Promise.all([
prisma.appointment.findMany({
where,
select: appointmentSelect,
skip: (page - 1) * perPage,
take: perPage,
orderBy: { [sortBy]: sortOrder },
}),
prisma.appointment.count({ where }),
]);
return { data, total };
}
Repositories that can participate in a bigger transaction
Notice the optional tx parameter below — this lets the same repository method be called either standalone, or as one step inside a larger atomic transaction (used heavily in payment confirmation, covered next).
create(data, tx) {
const client = tx || prisma;
return client.businessTransaction.create({ data, select });
}
Atomic multi-step writes: confirming a payment
This is the clearest example of why atomic transactions matter here: confirming a booking and creating its ledger entry are two separate database writes, but they must both succeed or both fail together — otherwise you could end up with a "confirmed" booking that has no corresponding payout record, or vice versa. Prisma's $transaction guarantees that.
return prisma.$transaction(async (tx) => {
const updatedAppointment = await tx.appointment.update({
where: { id: appointmentId },
data: { status: 'CONFIRMED' },
});
const transaction = await this.#createTransaction({
appointment: updatedAppointment,
booking: {
vendorName: updatedAppointment.vendorName,
totalAmount: updatedAppointment.amount,
amount: updatedAppointment.amount,
},
paymentId,
orderId,
}, tx);
return { appointment: updatedAppointment, transaction, paymentId, orderId };
});
↑ back to top
06 Payment Handling Logic (Razorpay)
This is typically the section auditors scrutinize most closely, since it involves real money. The explanation below walks the entire flow from the moment a user taps "confirm booking" to the moment the payout ledger is written — and is honest about the one control that hasn't been hardened yet.
The files involved, and what each one owns
| Role | File |
|---|---|
| Routes | routes/v1/payments.routes.js |
| Controller | controllers/payment.controller.js |
| Orchestration | services/payment.service.js |
| Razorpay SDK wrapper | services/razorpay.service.js |
| Validation | validations/payment.validation.js |
| Frontend API client | resources/views/src/services/paymentService.js |
| Frontend checkout | resources/views/src/utils/razorpayCheckout.js |
| Frontend book+pay helper | resources/views/src/utils/bookWithPayment.js |
| Booking UIs | UserBookingPage.jsx, BookingModal.jsx |
The full flow, in order
Step 1 — Frontend kicks off the flow
A booking with a price of zero (e.g. a free consultation) skips Razorpay entirely and books directly. Anything above zero routes through the payment gateway.
export async function bookWithPayment({ amount, booking, customer }) {
const total = Number(amount || 0);
// Free booking — skip Razorpay
if (total <= 0) {
return meService.createAppointment(booking);
}
const order = await paymentService.createRazorpayOrder(total, booking);
const paymentResponse = await openRazorpayCheckout({
keyId: order.keyId,
orderId: order.orderId,
amount: order.amount,
currency: order.currency,
description: `Booking at ${booking.vendorName}`,
customerName: customer?.name || booking.ownerName,
customerEmail: customer?.email || booking.ownerEmail,
customerPhone: customer?.phone || booking.ownerPhone,
});
return paymentService.verifyRazorpayPayment({
...paymentResponse,
booking,
});
}
Step 2 — The Razorpay checkout modal opens
This is purely a UI concern: load Razorpay's script, open their hosted payment modal, and resolve with whatever payment identifiers Razorpay hands back once the user pays (or reject if they cancel or the payment fails).
export function openRazorpayCheckout({
keyId, orderId, amount, currency = 'INR',
name = 'VETnCO', description = 'Booking payment',
customerName, customerEmail, customerPhone,
}) {
return new Promise(async (resolve, reject) => {
await loadRazorpayScript();
const options = {
key: keyId,
amount,
currency,
name,
description,
order_id: orderId,
prefill: {
name: customerName || '',
email: customerEmail || '',
contact: customerPhone || '',
},
theme: { color: '#2348C7' },
handler: (response) => {
resolve({
razorpay_order_id: response.razorpay_order_id,
razorpay_payment_id: response.razorpay_payment_id,
razorpay_signature: response.razorpay_signature,
});
},
modal: {
ondismiss: () => reject(new Error('Payment cancelled')),
},
};
const rzp = new window.Razorpay(options);
rzp.on('payment.failed', (response) => {
reject(new Error(response?.error?.description || 'Payment failed'));
});
rzp.open();
});
}
Step 3 — Backend creates the Razorpay order
Two important safeguards happen here, before Razorpay is even contacted: the requested amount is checked against the booking's stated total (catching any client-side tampering), and a PENDING appointment is pre-created in the database so there's a durable record even if the user abandons the payment.
async createRazorpayOrder(userId, { amount, booking }) {
let appointmentId = null;
if (booking) {
const bookingAmount = Number(booking.totalAmount ?? booking.amount ?? 0);
if (Math.abs(bookingAmount - amount) > 0.01) {
throw new AppError('Order amount must match booking amount', 400, 'VALIDATION_ERROR');
}
// Pre-create PENDING appointment
const appointment = await meService.createAppointment(userId, {
...booking,
status: 'PENDING',
});
appointmentId = appointment.id;
}
const notes = {};
if (appointmentId) {
notes.appointmentId = String(appointmentId);
}
return razorpayService.createOrder({ amountInRupees: amount, userId, notes });
}
The actual Razorpay API call converts rupees to paise (Razorpay's smallest currency unit) and enforces a minimum payable amount:
async createOrder({ amountInRupees, userId, receipt, notes = {} }) {
const amountPaise = Math.round(Number(amountInRupees) * 100);
if (!Number.isFinite(amountPaise) || amountPaise < 100) {
throw new AppError('Minimum payable amount is INR 1', 400, 'VALIDATION_ERROR');
}
const order = await this.#getClient().orders.create({
amount: amountPaise,
currency: 'INR',
receipt: receipt || `rcpt_${Date.now()}`,
notes: {
userId: String(userId),
platform: 'vetnco',
...notes,
},
});
return {
orderId: order.id,
amount: order.amount,
currency: order.currency,
keyId: this.getKeyId(),
};
}
Step 4 — Backend verifies the payment
This is the step where trust actually gets established — anyone could call this endpoint with fabricated data, so every value is checked against something the server controls, not something the client sent.
verifyPaymentSignature({ orderId, paymentId, signature }) {
const body = `${orderId}|${paymentId}`;
const expected = crypto
.createHmac('sha256', this.#getKeySecret())
.update(body)
.digest('hex');
return expected === signature; // note: not timing-safe — review recommendation
}
=== operator rather than a constant-time comparison (e.g. Node's crypto.timingSafeEqual). In theory this opens a timing side-channel that could let an attacker infer the correct signature byte-by-byte through repeated requests. In practice, exploiting this over a network with realistic jitter is very difficult, but it's flagged in the team's own checklist (Section 10) as a fix worth making, since the safer version is a one-line change.===) during rapid prototype setup; requires conversion to constant-time buffer comparison to eliminate theoretical timing side-channel attacks.
Once the signature checks out, the order is re-fetched from Razorpay itself (not trusted from the client), and cross-checked on three more dimensions: does this order actually belong to the requesting user, does the paid amount match the appointment's amount, and does the appointment itself belong to this user.
async verifyRazorpayPayment(userId, payload) {
const {
razorpay_order_id: orderId,
razorpay_payment_id: paymentId,
razorpay_signature: signature,
booking,
} = payload;
if (!orderId || !paymentId || !signature) {
throw new AppError('Incomplete payment details', 400, 'VALIDATION_ERROR');
}
const isValid = razorpayService.verifyPaymentSignature({ orderId, paymentId, signature });
if (!isValid) {
throw new AppError('Payment verification failed', 400, 'PAYMENT_VERIFICATION_FAILED');
}
const order = await razorpayService.fetchOrder(orderId);
if (order.notes?.userId && String(order.notes.userId) !== String(userId)) {
throw new AppError('Payment does not belong to this user', 403, 'FORBIDDEN');
}
const orderAmountRupees = Number(order.amount) / 100;
const appointmentId = order.notes?.appointmentId
? Number(order.notes.appointmentId)
: null;
if (appointmentId) {
const appointment = await prisma.appointment.findUnique({ where: { id: appointmentId } });
if (!appointment) throw new AppError('Pre-created appointment not found', 404, 'NOT_FOUND');
if (appointment.userId !== userId) {
throw new AppError('Appointment does not belong to this user', 403, 'FORBIDDEN');
}
if (Math.abs(Number(appointment.amount) - orderAmountRupees) > 0.01) {
throw new AppError('Payment amount does not match booking amount', 400, 'AMOUNT_MISMATCH');
}
return prisma.$transaction(async (tx) => {
const updatedAppointment = await tx.appointment.update({
where: { id: appointmentId },
data: { status: 'CONFIRMED' },
});
const transaction = await this.#createTransaction({
appointment: updatedAppointment,
booking: { vendorName: updatedAppointment.vendorName, amount: updatedAppointment.amount },
paymentId,
orderId,
}, tx);
return { appointment: updatedAppointment, transaction, paymentId, orderId };
});
}
// Legacy fallback path — creates CONFIRMED appointment from client booking body
// ...
}
booking object the client sends in the verify request, rather than a server-side record. The team's own checklist marks this as something to deprecate — it's the one place in the payment flow that still trusts client-provided data for booking details rather than a value the server created earlier.Step 5 — Writing the payout ledger entry
The commission split is calculated from the platform's configurable commission percentage (default 12%, see Section 3) at the moment of confirmation — not hardcoded — so an admin changing the commission rate takes effect on the next transaction without a code deploy.
async #createTransaction({ appointment, booking, paymentId, orderId }, tx) {
const panelType = VENDOR_TO_PANEL[appointment.vendorType];
if (!panelType || !appointment.vendorId) return null;
const settings = await platformSettingService.get();
const commissionPct = Number(settings.platformCommission ?? 12);
const amount = Number(appointment.amount || 0);
const platformFee = Number(((amount * commissionPct) / 100).toFixed(2));
const netAmount = Number((amount - platformFee).toFixed(2));
return transactionRepository.create({
transactionCode: `TXN-${paymentId}`,
panelType,
businessId: appointment.vendorId,
appointmentId: appointment.id,
customerName: appointment.ownerName,
customerEmail: appointment.ownerEmail || null,
serviceName: booking.vendorName || appointment.serviceType,
amount,
platformFee,
netAmount,
paymentMethod: 'Razorpay',
paymentStatus: 'SUCCESS',
payoutStatus: 'PENDING',
paidAt: new Date(),
invoiceNumber: `INV-${appointment.appointmentCode}`,
notes: `Razorpay order: ${orderId}, payment: ${paymentId}`,
}, tx);
}
Payment security — summary table for the auditor
| Control | Code behaviour |
|---|---|
| Secrets from env | process.env.RAZORPAY_KEY_ID / RAZORPAY_KEY_SECRET |
| Secret never sent to client | Config endpoint returns { keyId } only |
| Amount in paise | Math.round(amountInRupees * 100) |
| Amount match (order create) | booking amount vs requested amount |
| Amount match (verify) | order amount vs appointment amount |
| HMAC signature | crypto.createHmac('sha256', secret) |
| User ownership | order.notes.userId === userId |
| Atomic write | prisma.$transaction for confirm + ledger |
| Timing-safe compare | Not used — uses === today |
| Webhooks | Not implemented — client-driven verify only |
/verify after checkout. If a payment actually succeeds on Razorpay's side but the user's browser closes, crashes, or loses connection before that call completes, the booking stays PENDING forever even though Razorpay was paid. A webhook (a server-to-server callback from Razorpay) is the standard fix, and it's already listed in the team's own gap list in Section 10.07 Authentication & Security
Covers how identity is verified on every request, and the baseline HTTP hardening applied to the whole API.
JWT configuration — refusing to run with a weak secret in production
This is a small but important safety check: if the app is started in production without a properly configured, non-default JWT_SECRET, it refuses to boot at all rather than silently running with a guessable signing key.
if (process.env.NODE_ENV === 'production') {
const unsafeSecrets = ['dev-secret-change-me', 'your-super-secret-jwt-key-change-in-production'];
if (!process.env.JWT_SECRET || unsafeSecrets.includes(process.env.JWT_SECRET)) {
console.error('FATAL: JWT_SECRET must be configured with a strong, non-default secret in production.');
process.exit(1);
}
}
const JWT_SECRET = process.env.JWT_SECRET || 'dev-secret-change-me';
/** Access token lifetime — default 15 minutes */
export const ACCESS_TOKEN_EXPIRES_IN = process.env.JWT_EXPIRES_IN || '15m';
/** Refresh token lifetime — default 30 days */
export const REFRESH_TOKEN_EXPIRES_DAYS = Number(process.env.REFRESH_TOKEN_EXPIRES_DAYS || 30);
export function signToken(payload, expiresIn = ACCESS_TOKEN_EXPIRES_IN) {
return jwt.sign(payload, JWT_SECRET, { expiresIn });
}
export function verifyToken(token) {
try {
return jwt.verify(token, JWT_SECRET);
} catch {
throw new AppError('Invalid or expired token', 401, 'INVALID_TOKEN');
}
}
RefreshToken model note) let a user stay logged in without re-entering a password every 15 minutes.Password hashing
Already shown in Section 2.4 — bcrypt with 12 salt rounds in utils/bcrypt.util.js. Twelve rounds is a widely-accepted, reasonable cost factor that balances brute-force resistance against login latency.
HTTP-level hardening
Already shown in Section 1.3 — Helmet sets standard security headers, CORS restricts which origins may call the API, and the global rate limiter (300 requests / 15 minutes) provides a baseline defense against scripted abuse. See loaders/express.js.
08 API Structure & Error Handling
How every endpoint is shaped, so a client integrating against this API knows what to expect regardless of which feature it's calling.
The standard controller pattern
This three-line shape repeats across all 34 controllers: call the service, wrap the result in sendSuccess, done. All error handling is implicit — thrown errors are caught by asyncHandler (Section 2) and routed to the global error handler.
someAction = asyncHandler(async (req, res) => {
const data = await someService.doWork(req.user.id, req.body);
return sendSuccess(res, { data, message: 'Done', statusCode: 201 });
});
One predictable error and success shape everywhere
Because every error is thrown as an AppError (Section 2) carrying a status code and machine-readable code, the global error handler can convert any of them into the exact same JSON shape:
{
"success": false,
"message": "Payment verification failed",
"code": "PAYMENT_VERIFICATION_FAILED"
}
{
"success": true,
"message": "Payment verified and booking confirmed",
"data": { }
}
success and code generically across the entire API surface.09 Frontend Architecture
The frontend is one React + Vite application, but it effectively serves several distinct "panels" (apps) from shared infrastructure — worth explaining so the auditor understands why the file count is large but the patterns repeat.
| Area | Path | Approx. files |
|---|---|---|
| Services | resources/views/src/services/ | 39 |
| Hooks | resources/views/src/hooks/ | 37 |
| Utils | resources/views/src/utils/ | 25 |
| Components | resources/views/src/components/ | 133 |
Shared payment helpers, used from two different UIs
Both the full-page booking flow and the quick booking modal call the exact same three helpers rather than each screen re-implementing checkout logic:
bookWithPayment.js— shared pay-then-book entry pointrazorpayCheckout.js— opens the Checkout.js modalpaymentService.js— the REST client that talks to the backend
Used by:
resources/views/src/pages/user/UserBookingPage.jsxresources/views/src/components/user/BookingModal.jsx
10 Known Gaps & Review Checklist
This is the team's own honest punch list — the items an auditor would otherwise have to find themselves. Presenting each gap proactively alongside the specific technical rationale behind it is the fastest way to build trust in a review.
Architecture / modularity
-
GapSplit the oversized
services/publicCatalog.service.js(distance calculation + listing logic are currently mixed together)Why this gap is here: Built rapidly during initial MVP development to co-locate all public catalog discovery, geolocation math, and listing logic in a single service file before vendor types expanded. -
GapPrefer repository methods over raw
prisma.appointmentcalls inside payment verificationWhy this gap is here: Written directly inside the payment controller/service during early Razorpay integration to enable fast end-to-end payment testing without updating repository interfaces first. -
GapDeduplicate the four repeated Haversine
$queryRawblocks into one shared helperWhy this gap is here: Implemented inline per vendor type (vets, groomers, clinics, boarding) to allow custom sorting/filtering rules per vendor before abstracting a common SQL builder.
Data design
-
GapAdd a real
@relation/ foreign key betweenBusinessTransaction.appointmentIdandAppointmentWhy this gap is here: Designed loosely to support logging business transactions for non-appointment operations (e.g., custom walk-in invoices or manual adjustments) without raising foreign key constraint errors. -
GapConsider a proper
petIdforeign key onAppointmentWhy this gap is here: Currently toggled off for testing purposes during development to allow flexible booking flows. -
GapReplace the
notesJSON meta pattern with real child tables if reporting/filtering on booking extras is ever requiredWhy this gap is here: Chosen for schema flexibility during initial product iteration, allowing vendor-specific addon fields to evolve rapidly without requiring frequent database migrations.
Cache
-
GapRequire Redis (rather than allowing the in-memory fallback) once running multiple instances in productionWhy this gap is here: Built to simplify local development and single-instance server setups so developers can run and test the application without requiring a local Redis container.
-
GapConsider finer-grained invalidation than wiping the whole
public:*namespace on every vendor mutationWhy this gap is here: Guarantees 100% cache freshness and prevents stale vendor search results across complex filter combinations with minimal invalidation code complexity during initial launch.
Payments
-
GapSwitch the signature comparison to
crypto.timingSafeEqualWhy this gap is here: Written using standard JavaScript string inequality (!==) during rapid prototype setup; requires conversion to constant-time buffer comparison to eliminate theoretical timing side-channel attacks. -
GapAdd Razorpay webhooks for server-side payment confirmation, independent of the client completing the verify callWhy this gap is here: Initial payment implementation prioritized immediate in-app UI checkout feedback; server-to-server webhook infrastructure was deferred to Phase 2 to avoid setting up webhook secret tunnels during local dev.
-
GapDeprecate the legacy verify path that still trusts a client-provided
bookingbodyWhy this gap is here: Maintained for backwards compatibility to support older mobile/web client builds during rolling API updates without breaking active user sessions. -
GapAdd a cleanup job for appointments left
PENDINGafter an abandoned checkoutWhy this gap is here: Automated background expiration cron jobs were deferred until booking volume required automatic slot release for abandoned checkouts. -
GapRotate any API keys that may have been shared outside secure environment storageWhy this gap is here: Standard post-handoff security procedure to ensure all test/staging credentials used during development are refreshed prior to production launch.
Security
-
GapStrong
JWT_SECRETrequired in production (already enforced — the app won't boot otherwise)Why this gap is here: System already enforces strict boot checks forJWT_SECRET; retained in the audit list as an ongoing operational checklist item for deployment environments. -
GapConfirm the Razorpay secret key never appears anywhere in the frontend bundleWhy this gap is here: Verified in current build; kept on the checklist to prevent regression when frontend payment utility functions are modified.
11 File Inventory (Quick Map)
A condensed reference of "if you want to look at X, open this file" — useful to keep open while walking an auditor through a live demo of the code.
Backend — payments
| File | Role |
|---|---|
services/payment.service.js | Order + verify + transaction orchestration |
services/razorpay.service.js | SDK wrapper + HMAC signature logic |
controllers/payment.controller.js | HTTP adapters |
routes/v1/payments.routes.js | Routes |
validations/payment.validation.js | Zod schemas |
repositories/transaction.repository.js | Ledger persistence |
Backend — cache & DB
| File | Role |
|---|---|
services/cache.service.js | Redis / memory cache |
middlewares/cache.middleware.js | HTTP cache middleware |
models/prisma.client.js | Prisma singleton |
prisma/schema.prisma | Data model |
loaders/express.js | HTTP stack setup |
server.js | Process entry point |
Backend — auth
| File | Role |
|---|---|
middlewares/auth.middleware.js | JWT + role checks |
utils/jwt.util.js | Token signing / verification |
utils/bcrypt.util.js | Password hashing |
services/auth.service.js | Login / signup / OTP |
Frontend — payments & booking
| File | Role |
|---|---|
resources/views/src/utils/bookWithPayment.js | Shared book+pay orchestration |
resources/views/src/utils/razorpayCheckout.js | Checkout modal UI |
resources/views/src/services/paymentService.js | REST client |
resources/views/src/pages/user/UserBookingPage.jsx | Full-page booking wizard |
resources/views/src/components/user/BookingModal.jsx | Modal booking flow |
Suggested order to walk through the code live
- Architecture:
server.js→loaders/express.js→routes/v1/index.js - Data model:
prisma/schema.prisma(Appointment,BusinessTransaction) - Cache:
services/cache.service.js→middlewares/cache.middleware.js→publicCatalog.routes.js - DB pattern:
repositories/appointment.repository.js+services/publicCatalog.service.js - Payments: FE
bookWithPayment.js→razorpayCheckout.js, then BEpayments.routes.js→payment.service.js→razorpay.service.js - Auth:
auth.middleware.js+jwt.util.js+bcrypt.util.js - Finish with the Section 10 checklist