
# Service Library - `enrollmentManagement`

This document provides a complete reference of the custom code library for the `enrollmentManagement` service. It includes all library functions, edge functions with their REST endpoints, templates, and assets.


## Library Functions

Library functions are reusable modules available to all business APIs and other custom code within the service via `require("lib/<moduleName>")`.


### `getTutorProfileIdsByTutorId.js`

```js
module.exports = async function getTutorProfileIdsByTutorId(userId) {
  // Fetch all tutorProfiles for this user from tutorCatalog
  const { fetchRemoteListByMQuery } = require("serviceCommon");
  const profiles = await fetchRemoteListByMQuery("tutorProfile", { tutorId: userId });
  return profiles.map(p => p.id);
}
```


### `validateEnrollmentConstraints.js`

```js
module.exports = function validateEnrollmentConstraints(coursePack, lessonSlotIds, screeningMeetings, studentId, tutorUser) {
  if (!coursePack) return { valid: false, error: 'Course pack not found.' };

  // 1. Course moderation check
  if (coursePack.moderationStatus === 'removed') {
    return { valid: false, error: 'This course has been removed and is no longer available for enrollment.' };
  }
  if (coursePack.moderationStatus === 'flagged') {
    return { valid: false, error: 'This course is currently under review and cannot accept new enrollments.' };
  }
  if (coursePack.isPublished === false) {
    return { valid: false, error: 'This course is not currently published.' };
  }

  // 2. Tutor status check
  if (tutorUser) {
    if (tutorUser.accountStatus === 'banned') {
      return { valid: false, error: 'This tutor\'s account has been suspended. Enrollment is not available.' };
    }
    if (tutorUser.accountStatus === 'suspended') {
      return { valid: false, error: 'This tutor\'s account is temporarily suspended. Please try again later.' };
    }
  }

  // 3. Screening check
  if (coursePack.preliminaryMeetingRequired) {
    const approved = (screeningMeetings || []).find(
      m => m.studentId === studentId && m.tutorDecision === 'approved'
    );
    if (!approved) return { valid: false, error: 'Screening approval is required before enrollment.' };
  }

  // lessonSlotIds is the array of IDs from the request
  const slots = Array.isArray(lessonSlotIds) ? lessonSlotIds : [];
  const slotCount = slots.length;
  if (slotCount === 0) return { valid: false, error: 'At least one lesson slot is required.' };

  // 4. Required classes count
  if (coursePack.requiredClassesCount && slotCount !== coursePack.requiredClassesCount) {
    return { valid: false, error: `This course requires exactly ${coursePack.requiredClassesCount} classes. Got ${slotCount}.` };
  }

  return { valid: true, error: null };
}
```


### `validateRefundEligibility.js`

```js
module.exports = function validateRefundEligibility(enrollment, lessonSlots, paymentRecord) {
  if (!enrollment) return { eligible: false, error: 'Enrollment not found.' };

  // Must be an active enrollment
  if (enrollment.enrollmentStatus !== 'active') {
    return { eligible: false, error: 'Only active enrollments can be refunded.' };
  }

  // Must have paid status
  if (enrollment.paymentStatus !== 'paid') {
    return { eligible: false, error: 'Only paid enrollments can be refunded.' };
  }

  // Must not already be refunded
  if (enrollment.refundStatus === 'processed') {
    return { eligible: false, error: 'This enrollment has already been refunded.' };
  }

  // Must have a valid payment record with paymentId for Stripe refund
  if (!paymentRecord || !paymentRecord.paymentId) {
    return { eligible: false, error: 'No payment record found for this enrollment. Cannot process refund.' };
  }

  // Check lesson completion status
  const slots = lessonSlots || [];
  const completedSlots = slots.filter(s => s.status === 'completed');

  // --- PATH 1: Within 1 hour of enrollment → auto-approved instant refund ---
  const enrolledAt = enrollment.enrolledAt || enrollment.createdAt;
  const enrollmentTime = enrolledAt ? new Date(enrolledAt).getTime() : 0;
  const now = Date.now();
  const oneHourMs = 60 * 60 * 1000;
  const withinOneHour = enrollmentTime > 0 && (now - enrollmentTime) <= oneHourMs;

  if (withinOneHour) {
    return {
      eligible: true,
      error: null,
      refundType: 'autoApproved',
      firstLessonCompleted: completedSlots.length >= 1,
      stripePaymentIntentId: paymentRecord.paymentId,
      refundAmount: enrollment.totalAmount,
      currency: enrollment.currency
    };
  }

  // --- PATH 2: After first lesson completed (exactly 1) → pending admin review ---
  if (completedSlots.length === 1) {
    return {
      eligible: true,
      error: null,
      refundType: 'pendingReview',
      firstLessonCompleted: true,
      stripePaymentIntentId: paymentRecord.paymentId,
      refundAmount: enrollment.totalAmount,
      currency: enrollment.currency
    };
  }

  // --- DENIED: No lessons completed (past 1 hour) ---
  if (completedSlots.length === 0) {
    return { eligible: false, error: 'The 1-hour instant refund window has passed. Refund is available after your first lesson is completed.' };
  }

  // --- DENIED: More than 1 lesson completed ---
  return { eligible: false, error: 'Refund is no longer available after more than one lesson has been completed.' };
}
```


### `cancelEnrollmentLessonSlots.js`

```js
module.exports = async function cancelEnrollmentLessonSlots(lessonSlotIds, session) {
  if (!lessonSlotIds || !Array.isArray(lessonSlotIds) || lessonSlotIds.length === 0) {
    return { canceled: 0, errors: [] };
  }

  const axios = require('axios');
  const { getServiceSecret } = require('common');

  // Build the courseScheduling M2M URL
  // In preview, services are on the same host with different routes
  const baseUrl = process.env.COURSESCHEDULING_API_URL || 'http://localhost:3002';
  const m2mUrl = `${baseUrl}/m2m/updateLessonSlotById`;

  const serviceSecret = getServiceSecret ? getServiceSecret() : process.env.SERVICE_SECRET_KEY;

  let canceled = 0;
  const errors = [];

  for (const slotId of lessonSlotIds) {
    try {
      await axios.patch(m2mUrl, {
        id: slotId,
        dataClause: { status: 'canceled' }
      }, {
        headers: {
          'Content-Type': 'application/json',
          ...(serviceSecret ? { 'x-service-secret': serviceSecret } : {}),
          ...(session?.token ? { 'Authorization': `Bearer ${session.token}` } : {})
        },
        timeout: 5000
      });
      canceled++;
    } catch (err) {
      errors.push({ slotId, error: err.message });
    }
  }

  return { canceled, total: lessonSlotIds.length, errors };
}
```


### `cancelAllTutorEnrollments.js`

```js
module.exports = async function cancelAllTutorEnrollments(tutorProfileId, reason, session) {
  const { updateEnrollmentById } = require('dbLayer');
  const { ElasticIndexer } = require('serviceCommon');
  const { convertUserQueryToElasticQuery } = require('common');

  const userQuery = {
    $and: [
      { tutorProfileId: tutorProfileId },
      { enrollmentStatus: 'active' },
      { isActive: true }
    ]
  };
  const elasticQuery = convertUserQueryToElasticQuery(userQuery);
  const elasticIndex = new ElasticIndexer('enrollment');
  const enrollments = (await elasticIndex.getDataByPage(0, 500, elasticQuery)) || [];

  if (enrollments.length === 0) {
    return { canceled: 0, total: 0, errors: [], affectedStudentIds: [] };
  }

  const context = { session, requestId: 'cascade-tutor-ban' };
  let canceled = 0;
  const errors = [];
  const affectedStudentIds = [];

  for (const enrollment of enrollments) {
    try {
      await updateEnrollmentById(enrollment.id, {
        enrollmentStatus: 'canceled',
        paymentStatus: 'refunded',
        refundStatus: 'processed'
      }, context);

      if (enrollment.studentId) affectedStudentIds.push(enrollment.studentId);

      if (enrollment.lessonSlotIds && Array.isArray(enrollment.lessonSlotIds)) {
        try {
          const cancelSlots = require('libFunctions/cancelEnrollmentLessonSlots');
          await cancelSlots(enrollment.lessonSlotIds, session);
        } catch (slotErr) {
          console.log('Slot cleanup error for enrollment ' + enrollment.id + ':', slotErr.message);
        }
      }

      canceled++;
    } catch (err) {
      errors.push({ enrollmentId: enrollment.id, error: err.message });
    }
  }

  return { canceled, total: enrollments.length, errors, affectedStudentIds: [...new Set(affectedStudentIds)] };
}
```


### `cancelAllCourseEnrollments.js`

```js
module.exports = async function cancelAllCourseEnrollments(coursePackId, reason, session) {
  const { updateEnrollmentById } = require('dbLayer');
  const { ElasticIndexer } = require('serviceCommon');
  const { convertUserQueryToElasticQuery } = require('common');

  const userQuery = {
    $and: [
      { coursePackId: coursePackId },
      { enrollmentStatus: 'active' },
      { isActive: true }
    ]
  };
  const elasticQuery = convertUserQueryToElasticQuery(userQuery);
  const elasticIndex = new ElasticIndexer('enrollment');
  const enrollments = (await elasticIndex.getDataByPage(0, 500, elasticQuery)) || [];

  if (enrollments.length === 0) {
    return { canceled: 0, total: 0, errors: [], affectedStudentIds: [] };
  }

  const context = { session, requestId: 'cascade-course-removal' };
  let canceled = 0;
  const errors = [];
  const affectedStudentIds = [];

  for (const enrollment of enrollments) {
    try {
      await updateEnrollmentById(enrollment.id, {
        enrollmentStatus: 'canceled',
        paymentStatus: 'refunded',
        refundStatus: 'processed'
      }, context);

      if (enrollment.studentId) affectedStudentIds.push(enrollment.studentId);

      if (enrollment.lessonSlotIds && Array.isArray(enrollment.lessonSlotIds)) {
        try {
          const cancelSlots = require('libFunctions/cancelEnrollmentLessonSlots');
          await cancelSlots(enrollment.lessonSlotIds, session);
        } catch (slotErr) {
          console.log('Slot cleanup error for enrollment ' + enrollment.id + ':', slotErr.message);
        }
      }

      canceled++;
    } catch (err) {
      errors.push({ enrollmentId: enrollment.id, error: err.message });
    }
  }

  return { canceled, total: enrollments.length, errors, affectedStudentIds: [...new Set(affectedStudentIds)] };
}
```


### `cleanupPendingEnrollments.js`

```js
module.exports = async function cleanupPendingEnrollments(maxAgeMinutes, session) {
  const { updateEnrollmentById } = require('dbLayer');
  const { ElasticIndexer } = require('serviceCommon');
  const { convertUserQueryToElasticQuery } = require('common');

  const cutoff = new Date(Date.now() - (maxAgeMinutes || 60) * 60 * 1000).toISOString();

  // Find pending enrollments older than cutoff
  const userQuery = {
    $and: [
      { enrollmentStatus: 'pending' },
      { paymentStatus: 'pending' },
      { createdAt: { $lt: cutoff } },
      { isActive: true }
    ]
  };
  const elasticQuery = convertUserQueryToElasticQuery(userQuery);
  const elasticIndex = new ElasticIndexer('enrollment');
  const enrollments = (await elasticIndex.getDataByPage(0, 500, elasticQuery)) || [];

  if (enrollments.length === 0) {
    return { cleaned: 0, total: 0, errors: [] };
  }

  const context = { session: session || {}, requestId: 'cleanup-pending-enrollments' };
  let cleaned = 0;
  const errors = [];

  for (const enrollment of enrollments) {
    try {
      // Cancel the enrollment
      await updateEnrollmentById(enrollment.id, {
        enrollmentStatus: 'canceled',
        refundStatus: 'ineligible'
      }, context);

      // Free lesson slots
      if (enrollment.lessonSlotIds && Array.isArray(enrollment.lessonSlotIds)) {
        try {
          const cancelSlots = require('libFunctions/cancelEnrollmentLessonSlots');
          await cancelSlots(enrollment.lessonSlotIds, session);
        } catch (slotErr) {
          console.log('Slot cleanup error for enrollment ' + enrollment.id + ':', slotErr.message);
        }
      }

      cleaned++;
    } catch (err) {
      errors.push({ enrollmentId: enrollment.id, error: err.message });
    }
  }

  return { cleaned, total: enrollments.length, errors };
}
```


### `checkEnrollmentCompletion.js`

```js
module.exports = async function checkEnrollmentCompletion(enrollmentId, session) {
  const { getEnrollmentById, updateEnrollmentById } = require('dbLayer');
  const { ElasticIndexer } = require('serviceCommon');
  const { convertUserQueryToElasticQuery } = require('common');

  if (!enrollmentId) return { updated: false, reason: 'No enrollmentId' };

  // Fetch the enrollment
  const enrollment = await getEnrollmentById(enrollmentId);
  if (!enrollment) return { updated: false, reason: 'Enrollment not found' };
  if (enrollment.enrollmentStatus !== 'active') return { updated: false, reason: 'Not active' };

  const slotIds = enrollment.lessonSlotIds;
  if (!slotIds || !Array.isArray(slotIds) || slotIds.length === 0) {
    return { updated: false, reason: 'No lesson slots' };
  }

  // Fetch all lesson slots from Elasticsearch
  const userQuery = { id: { $in: slotIds } };
  const elasticQuery = convertUserQueryToElasticQuery(userQuery);
  const elasticIndex = new ElasticIndexer('lessonSlot');
  const slots = (await elasticIndex.getDataByPage(0, 500, elasticQuery)) || [];

  // Check if ALL slots are completed
  const allCompleted = slots.length > 0 && slots.length === slotIds.length && slots.every(s => s.status === 'completed');

  if (!allCompleted) {
    return { updated: false, reason: `${slots.filter(s => s.status === 'completed').length}/${slotIds.length} completed` };
  }

  // Mark enrollment as completed
  const context = { session: session || {}, requestId: 'auto-complete-enrollment' };
  await updateEnrollmentById(enrollmentId, { enrollmentStatus: 'completed' }, context);

  return { updated: true, enrollmentId };
}
```


### `publishEnrichedRefundEvent.js`

```js
module.exports = async function publishEnrichedRefundEvent(refundRequest, enrollment) {
  const { elasticClient, sendMessageToKafka } = require('common');

  let student = null;
  let tutorUser = null;
  let coursePack = null;

  try {
    const studentRes = await elasticClient.search({ index: 'tutorhub_user', body: { query: { term: { id: enrollment.studentId } }, _source: ['fullname', 'email'], size: 1 } });
    if (studentRes.hits.hits.length > 0) student = studentRes.hits.hits[0]._source;

    const profileRes = await elasticClient.search({ index: 'tutorhub_tutorprofile', body: { query: { term: { id: enrollment.tutorProfileId } }, _source: ['tutorId'], size: 1 } });
    const profile = profileRes.hits.hits.length > 0 ? profileRes.hits.hits[0]._source : null;

    if (profile && profile.tutorId) {
      const tutorRes = await elasticClient.search({ index: 'tutorhub_user', body: { query: { term: { id: profile.tutorId } }, _source: ['fullname', 'email'], size: 1 } });
      if (tutorRes.hits.hits.length > 0) tutorUser = tutorRes.hits.hits[0]._source;
    }

    const packRes = await elasticClient.search({ index: 'tutorhub_coursepack', body: { query: { term: { id: enrollment.coursePackId } }, _source: ['title', 'category'], size: 1 } });
    if (packRes.hits.hits.length > 0) coursePack = packRes.hits.hits[0]._source;
  } catch (err) {
    console.log('Warning: failed to fetch refund notification data from ES:', err.message);
  }

  const enrichedPayload = {
    id: refundRequest.id,
    enrollmentId: refundRequest.enrollmentId,
    status: refundRequest.status,
    reason: refundRequest.reason,
    requestedAt: refundRequest.requestedAt,
    processedAt: refundRequest.processedAt,
    totalAmount: enrollment.totalAmount,
    currency: enrollment.currency,
    student: student,
    tutorUser: tutorUser,
    coursePack: coursePack
  };

  try {
    const topic = refundRequest.status === 'autoApproved'
      ? 'tutorhub-enrollmentmanagement-service-refund-processed'
      : 'tutorhub-enrollmentmanagement-service-refund-requested';
    await sendMessageToKafka(topic, enrichedPayload);
  } catch (err) {
    console.log('Warning: failed to publish enriched refund event:', err.message);
  }
}
```





## Edge Functions

Edge functions are custom HTTP endpoint handlers that run outside the standard Business API pipeline. Each edge function is paired with an Edge Controller that defines its REST endpoint.


### `deletePaymentMethodEdge.js`


**Edge Controller:**
- **Path:** `/payment-methods/delete/:paymentMethodId`
- **Method:** `GET`
- **Login Required:** Yes


```js
const { getSys_paymentMethodByPaymentMethodId, deleteSys_paymentMethodById } = require('dbLayer');
const { stripeGateway } = require('common');

module.exports = async (request) => {
  const session = request.session;
  const paymentMethodId = request.params.paymentMethodId;
  if (!paymentMethodId) {
    return { status: 400, result: 'ERR', message: 'paymentMethodId is required' };
  }

  const paymentMethodData = await getSys_paymentMethodByPaymentMethodId(paymentMethodId);
  if (!paymentMethodData) {
    return { status: 404, result: 'ERR', message: 'Payment method not found' };
  }
  if (paymentMethodData.userId !== session.userId) {
    return { status: 403, result: 'ERR', message: 'Not authorized to delete this payment method' };
  }

  try {
    if (Object.keys(stripeGateway).length > 0) {
      await stripeGateway.deletePaymentMethod(paymentMethodId);
    }
  } catch (stripeErr) {
    console.log('Stripe detach warning (proceeding with local delete):', stripeErr.message);
  }

  const deleted = await deleteSys_paymentMethodById(paymentMethodData.id);
  if (!deleted) {
    return { status: 500, result: 'ERR', message: 'Failed to delete payment method record' };
  }

  return { status: 200, result: 'OK', message: 'Payment method deleted', data: deleted };
};
```


### `onEnrollmentPaymentDone.js`


**Edge Controller:**
- **Path:** ``
- **Method:** `GET`
- **Login Required:** No


```js
const { updateEnrollmentById } = require('dbLayer');
const { elasticClient, sendMessageToKafka } = require('common');

module.exports = async (request) => {
  const enrollment = request.enrollment;
  if (!enrollment || !enrollment.id) return { status: 200, message: 'No enrollment to update' };

  await updateEnrollmentById(enrollment.id, {
    enrollmentStatus: 'active',
    enrolledAt: new Date().toISOString()
  });

  let student = null;
  let tutorUser = null;
  let coursePack = null;

  try {
    const studentRes = await elasticClient.search({ index: 'tutorhub_user', body: { query: { term: { id: enrollment.studentId } }, _source: ['fullname', 'email'], size: 1 } });
    if (studentRes.hits.hits.length > 0) student = studentRes.hits.hits[0]._source;

    const profileRes = await elasticClient.search({ index: 'tutorhub_tutorprofile', body: { query: { term: { id: enrollment.tutorProfileId } }, _source: ['tutorId'], size: 1 } });
    const profile = profileRes.hits.hits.length > 0 ? profileRes.hits.hits[0]._source : null;

    if (profile && profile.tutorId) {
      const tutorRes = await elasticClient.search({ index: 'tutorhub_user', body: { query: { term: { id: profile.tutorId } }, _source: ['fullname', 'email'], size: 1 } });
      if (tutorRes.hits.hits.length > 0) tutorUser = tutorRes.hits.hits[0]._source;
    }

    const packRes = await elasticClient.search({ index: 'tutorhub_coursepack', body: { query: { term: { id: enrollment.coursePackId } }, _source: ['title', 'category', 'schedulingType'], size: 1 } });
    if (packRes.hits.hits.length > 0) coursePack = packRes.hits.hits[0]._source;
  } catch (err) {
    console.log('Warning: failed to fetch notification data from ES:', err.message);
  }

  try {
    await sendMessageToKafka('tutorhub-enrollmentmanagement-service-enrollment-payment-confirmed', {
      id: enrollment.id,
      studentId: enrollment.studentId,
      coursePackId: enrollment.coursePackId,
      tutorProfileId: enrollment.tutorProfileId,
      totalAmount: enrollment.totalAmount,
      currency: enrollment.currency,
      student: student,
      tutorUser: tutorUser,
      coursePack: coursePack
    });
  } catch (err) {
    console.log('Warning: failed to publish enriched enrollment event:', err.message);
  }

  return { status: 200, message: 'Enrollment activated' };
};
```


### `onRefundRequestCreated.js`


**Edge Controller:**
- **Path:** ``
- **Method:** `GET`
- **Login Required:** No


```js
const { getEnrollmentById } = require('dbLayer');
const { elasticClient, sendMessageToKafka } = require('common');

module.exports = async (request) => {
  const payload = request.body || request.kafkaMessage || request;
  const dataSource = payload.dataSource || payload;

  if (!dataSource || !dataSource.enrollmentId) {
    console.log('onRefundRequestCreated: no enrollmentId in payload, skipping');
    return { status: 200, message: 'Skipped - no enrollmentId' };
  }

  const refundRequest = dataSource;
  let enrollment = null;
  try {
    enrollment = await getEnrollmentById(refundRequest.enrollmentId);
  } catch (err) {
    console.log('onRefundRequestCreated: failed to fetch enrollment:', err.message);
    return { status: 200, message: 'Skipped - enrollment not found' };
  }

  if (!enrollment) {
    console.log('onRefundRequestCreated: enrollment not found for', refundRequest.enrollmentId);
    return { status: 200, message: 'Skipped - enrollment not found' };
  }

  let student = null;
  let tutorUser = null;
  let coursePack = null;

  try {
    const studentRes = await elasticClient.search({ index: 'tutorhub_user', body: { query: { term: { id: enrollment.studentId } }, _source: ['fullname', 'email'], size: 1 } });
    if (studentRes.hits.hits.length > 0) student = studentRes.hits.hits[0]._source;

    const profileRes = await elasticClient.search({ index: 'tutorhub_tutorprofile', body: { query: { term: { id: enrollment.tutorProfileId } }, _source: ['tutorId'], size: 1 } });
    const profile = profileRes.hits.hits.length > 0 ? profileRes.hits.hits[0]._source : null;

    if (profile && profile.tutorId) {
      const tutorRes = await elasticClient.search({ index: 'tutorhub_user', body: { query: { term: { id: profile.tutorId } }, _source: ['fullname', 'email'], size: 1 } });
      if (tutorRes.hits.hits.length > 0) tutorUser = tutorRes.hits.hits[0]._source;
    }

    const packRes = await elasticClient.search({ index: 'tutorhub_coursepack', body: { query: { term: { id: enrollment.coursePackId } }, _source: ['title', 'category'], size: 1 } });
    if (packRes.hits.hits.length > 0) coursePack = packRes.hits.hits[0]._source;
  } catch (err) {
    console.log('onRefundRequestCreated: failed to fetch enrichment data from ES:', err.message);
  }

  const enrichedPayload = {
    id: refundRequest.id,
    enrollmentId: refundRequest.enrollmentId,
    status: refundRequest.status,
    reason: refundRequest.reason,
    requestedAt: refundRequest.requestedAt,
    processedAt: refundRequest.processedAt,
    totalAmount: enrollment.totalAmount,
    currency: enrollment.currency,
    student: student,
    tutorUser: tutorUser,
    coursePack: coursePack
  };

  try {
    const isAutoApproved = refundRequest.status === 'autoApproved' || refundRequest.status === 'autoapproved';
    const topic = isAutoApproved
      ? 'tutorhub-enrollmentmanagement-service-refund-processed'
      : 'tutorhub-enrollmentmanagement-service-refund-requested';
    await sendMessageToKafka(topic, enrichedPayload);
    console.log('onRefundRequestCreated: published to', topic);
  } catch (err) {
    console.log('onRefundRequestCreated: failed to publish enriched refund event:', err.message);
  }

  return { status: 200, message: 'Refund notification published' };
};
```





## Edge Controllers Summary

| Function Name | Method | Path | Login Required |
|--------------|--------|------|----------------|
| `deletePaymentMethodEdge` | `GET` | `/payment-methods/delete/:paymentMethodId` | Yes |
| `onRefundRequestCreated` | `GET` | `` | No |
| `onEnrollmentPaymentDone` | `GET` | `` | No |









---

*This document was generated from the service library configuration and should be kept in sync with design changes.*
