Technical Review · Explained Edition

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.

Audience: Client auditor + our dev team
Stack: Node.js / Express · Prisma / MySQL · React (Vite) · Redis · Razorpay

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.

LayerTechnologyKey files
RuntimeNode.js (ESM modules)server.js
HTTPExpressloaders/express.js
ORM / DBPrisma + MySQLprisma/schema.prisma, models/prisma.client.js
AuthJWT + bcryptutils/jwt.util.js, utils/bcrypt.util.js
PaymentsRazorpayservices/razorpay.service.js, services/payment.service.js
CacheRedis + in-memory fallbackservices/cache.service.js
FrontendReact (Vite)resources/views/src/
RealtimeSocket.IOloaders/socket.js

How the server boots

What to explain to the auditor: the app doesn't accept traffic until the database and cache layer are both ready, and it shuts down cleanly — draining connections and disconnecting Prisma — rather than dropping requests when the process is killed.
server.js
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.

loaders/express.js
// 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:

HTTP Request Helmet / CORS / Rate limit / JSON body [loaders/express.js] /api/v1/* [loaders/express.js] Feature router [routes/v1/index.js] authenticate / authorize / validate [middlewares/*] Controller [controllers/*.js] Service [services/*.js] Repository (Prisma) [repositories/*.js] sendSuccess / errorHandler [utils/apiResponse.js]
Talking point for developers: because every feature follows this exact chain, once an auditor understands one vertical slice (e.g. payments), they can navigate any other feature the same way — the shape never changes, only the specifics.
↑ back to top

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.

LayerFile countResponsibility
Controllers34 files under controllers/HTTP in/out only
Services39 files under services/Business rules
Repositories24 files under repositories/Prisma queries
Routes (v1)34 files under routes/v1/Route wiring
Validations28 files under validations/Zod schemas
Middlewares6 files under middlewares/Cross-cutting concerns
Utils12 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.

routes/v1/index.js
/**
 * 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

routes/v1/payments.routes.js
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

controllers/payment.controller.js
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

validations/payment.validation.js
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(),
  }),
});
Notice the controller never touches Razorpay, never touches the database, and never does math on money — it purely receives a validated request and hands it to the service. That's the modularity payoff: an auditor reviewing "is money handled safely" only needs to read 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.

utils/AppError.js

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;
  }
}
utils/asyncHandler.js

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);
};
utils/apiResponse.js

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);
}
utils/bcrypt.util.js

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.

middlewares/auth.middleware.js
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

What's happening: hostel stays, lab tests, and grooming add-ons don't have their own database columns. Instead, the service layer builds a small JSON object and appends it to the free-text notes field on the booking, separated by a marker string.
Why 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.
services/me.service.js
// 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

What's happening: "search near me" needs a Haversine (great-circle distance) calculation. Rather than one shared helper, the same raw SQL block is repeated once per vendor table (clinics, hostels, grooming centres, labs).
Why 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.
services/publicCatalog.service.js
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 top

03 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")
}
Three things worth explaining clearly to the auditor:
  • There is no petId foreign 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.
  • vendorId is polymorphic — it's just an integer that means "the ID of a clinic" or "the ID of a hostel" depending on what vendorType says. 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 notes as 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")
}
Flag for the auditor: 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.
Why 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.

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.

services/payment.service.js
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.

repositories/platformSetting.repository.js
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

services/cache.service.js
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();
Worth flagging: the in-memory fallback only lives inside a single Node.js process. If the app is ever scaled to run on more than one server/instance, each instance would have its own separate cache — and a write on one instance wouldn't invalidate the cache on another unless Redis is actually configured in that environment.
Why 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.

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.

middlewares/cache.middleware.js
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.

routes/v1/publicCatalog.routes.js
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);
Deliberately not cached: location geocode/autocomplete lookups (these call an external maps provider and change per user), anything under authenticated /me/* (personal to the logged-in user), and every payment endpoint (must always reflect live, uncached state).
↑ back to top

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.

models/prisma.client.js
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.

routes/v1/index.js
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.

repositories/appointment.repository.js
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).

repositories/transaction.repository.js
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.

services/payment.service.js
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.

HMAC signature verification User ownership check Amount cross-check (twice) Atomic confirm + ledger write Secret never sent to client

The files involved, and what each one owns

RoleFile
Routesroutes/v1/payments.routes.js
Controllercontrollers/payment.controller.js
Orchestrationservices/payment.service.js
Razorpay SDK wrapperservices/razorpay.service.js
Validationvalidations/payment.validation.js
Frontend API clientresources/views/src/services/paymentService.js
Frontend checkoutresources/views/src/utils/razorpayCheckout.js
Frontend book+pay helperresources/views/src/utils/bookWithPayment.js
Booking UIsUserBookingPage.jsx, BookingModal.jsx

The full flow, in order

User confirms booking (amount > 0) FE bookWithPayment() POST /payments/razorpay/order { amount, booking } amount must match booking create Appointment (status = PENDING) Razorpay orders.create (amount in paise) open Razorpay Checkout.js modal POST /payments/razorpay/verify HMAC signature check user / appointment ownership checks amount match $transaction: CONFIRMED + BusinessTransaction (SUCCESS)

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.

resources/views/src/utils/bookWithPayment.js
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).

resources/views/src/utils/razorpayCheckout.js
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.

services/payment.service.js
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:

services/razorpay.service.js
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.

services/razorpay.service.js
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
}
Security note for the auditor: the signature comparison above uses JavaScript's === 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.
Why this gap is here: Written using standard JavaScript string comparison (===) 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.

services/payment.service.js
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
  // ...
}
Worth flagging: there is a "legacy fallback" path (used when no pre-created appointment ID exists on the order) that confirms a booking based on the 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.
Why this gap is here: Maintained for backwards compatibility to support older mobile/web client builds during rolling API updates without breaking active user sessions.

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.

services/payment.service.js
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

ControlCode behaviour
Secrets from envprocess.env.RAZORPAY_KEY_ID / RAZORPAY_KEY_SECRET
Secret never sent to clientConfig endpoint returns { keyId } only
Amount in paiseMath.round(amountInRupees * 100)
Amount match (order create)booking amount vs requested amount
Amount match (verify)order amount vs appointment amount
HMAC signaturecrypto.createHmac('sha256', secret)
User ownershiporder.notes.userId === userId
Atomic writeprisma.$transaction for confirm + ledger
Timing-safe compareNot used — uses === today
WebhooksNot implemented — client-driven verify only
Biggest structural gap to be upfront about: there is no Razorpay webhook handler. Today, a payment is only ever confirmed because the user's browser called /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.
Why 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.
↑ back to top

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.

utils/jwt.util.js
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');
  }
}
Short-lived access tokens (15 minutes) paired with a much longer refresh token (30 days) is a standard, sound pattern — it limits how long a stolen access token stays useful, while refresh tokens (stored hashed, per Section 3's 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.

↑ back to top

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": { }
}
This consistency is genuinely useful to point out to an auditor: it means the frontend never has to guess the shape of an error response feature-by-feature, and a client integration (or automated test) can check success and code generically across the entire API surface.
↑ back to top

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.

AreaPathApprox. files
Servicesresources/views/src/services/39
Hooksresources/views/src/hooks/37
Utilsresources/views/src/utils/25
Componentsresources/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 point
  • razorpayCheckout.js — opens the Checkout.js modal
  • paymentService.js — the REST client that talks to the backend

Used by:

  • resources/views/src/pages/user/UserBookingPage.jsx
  • resources/views/src/components/user/BookingModal.jsx
This is a good, concrete talking point for "reuse on the frontend": if the payment flow ever needs to change (say, adding a new gateway), it changes in three files, and both booking surfaces pick up the change automatically.
↑ back to top

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.appointment calls inside payment verification
    Why 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 $queryRaw blocks into one shared helper
    Why 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 between BusinessTransaction.appointmentId and Appointment
    Why 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 petId foreign key on Appointment
    Why this gap is here: Currently toggled off for testing purposes during development to allow flexible booking flows.
  • GapReplace the notes JSON meta pattern with real child tables if reporting/filtering on booking extras is ever required
    Why 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 production
    Why 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 mutation
    Why 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.timingSafeEqual
    Why 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 call
    Why 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 booking body
    Why 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 PENDING after an abandoned checkout
    Why 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 storage
    Why 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_SECRET required in production (already enforced — the app won't boot otherwise)
    Why this gap is here: System already enforces strict boot checks for JWT_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 bundle
    Why this gap is here: Verified in current build; kept on the checklist to prevent regression when frontend payment utility functions are modified.
↑ back to top

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

FileRole
services/payment.service.jsOrder + verify + transaction orchestration
services/razorpay.service.jsSDK wrapper + HMAC signature logic
controllers/payment.controller.jsHTTP adapters
routes/v1/payments.routes.jsRoutes
validations/payment.validation.jsZod schemas
repositories/transaction.repository.jsLedger persistence

Backend — cache & DB

FileRole
services/cache.service.jsRedis / memory cache
middlewares/cache.middleware.jsHTTP cache middleware
models/prisma.client.jsPrisma singleton
prisma/schema.prismaData model
loaders/express.jsHTTP stack setup
server.jsProcess entry point

Backend — auth

FileRole
middlewares/auth.middleware.jsJWT + role checks
utils/jwt.util.jsToken signing / verification
utils/bcrypt.util.jsPassword hashing
services/auth.service.jsLogin / signup / OTP

Frontend — payments & booking

FileRole
resources/views/src/utils/bookWithPayment.jsShared book+pay orchestration
resources/views/src/utils/razorpayCheckout.jsCheckout modal UI
resources/views/src/services/paymentService.jsREST client
resources/views/src/pages/user/UserBookingPage.jsxFull-page booking wizard
resources/views/src/components/user/BookingModal.jsxModal booking flow

Suggested order to walk through the code live

  1. Architecture: server.jsloaders/express.jsroutes/v1/index.js
  2. Data model: prisma/schema.prisma (Appointment, BusinessTransaction)
  3. Cache: services/cache.service.jsmiddlewares/cache.middleware.jspublicCatalog.routes.js
  4. DB pattern: repositories/appointment.repository.js + services/publicCatalog.service.js
  5. Payments: FE bookWithPayment.jsrazorpayCheckout.js, then BE payments.routes.jspayment.service.jsrazorpay.service.js
  6. Auth: auth.middleware.js + jwt.util.js + bcrypt.util.js
  7. Finish with the Section 10 checklist
↑ back to top