Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 71 additions & 0 deletions src/pathways/common/services/lms-client.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -535,4 +535,75 @@
};
}
}

/**
* GET /lms-service/v1/courses/:courseId — returns authoritative list price for checkout validation.
*/
async getCoursePricing(
courseId: string,
tenantId: string,
organisationId: string,
academicYearId?: string,
): Promise<{ courseId: string; amount: number; currency: string } | null> {
if (!this.lmsServiceUrl) {
this.logger.warn('LMS_SERVICE_URL not configured');
return null;
}

const url = `${this.lmsServiceUrl}/lms-service/v1/courses/${courseId}`;
const headers: Record<string, string> = {
tenantid: tenantId,
organisationid: organisationId,
'Content-Type': 'application/json',
};
if (academicYearId) {
headers.academicyearid = academicYearId;
}

try {
const res = await axios.get(url, {
headers,
timeout: 15000,
validateStatus: (status) => status < 500,
});

if (res.status !== 200) {
this.logger.warn(
`LMS get course ${courseId} returned HTTP ${res.status}`,
);
return null;
}

const result = res.data?.result;
if (!result) {
return null;
}

const pricing = result.pricing;
const resolvedCourseId = result.courseId || courseId;
if (!pricing || pricing.amount === undefined || pricing.amount === null) {

Check warning on line 584 in src/pathways/common/services/lms-client.service.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer using an optional chain expression instead, as it's more concise and easier to read.

See more on https://sonarcloud.io/project/issues?id=tekdi_user-microservice&issues=AZ33a2epdMPnwoeOq0yZ&open=AZ33a2epdMPnwoeOq0yZ&pullRequest=735
return {
courseId: resolvedCourseId,
amount: 0,
currency: String(pricing?.currency || 'USD')
.toUpperCase()
.slice(0, 3),
};
}

return {
courseId: resolvedCourseId,
amount: Number(pricing.amount),
currency: String(pricing.currency || 'USD')
.toUpperCase()
.slice(0, 3),
};
Comment thread
mahajanmahesh935 marked this conversation as resolved.
Comment thread
mahajanmahesh935 marked this conversation as resolved.
} catch (error) {
const msg = error instanceof Error ? error.message : 'Unknown error';
this.logger.error(
`LMS getCoursePricing failed for course ${courseId}: ${msg}`,
);
return null;
}
}
}
2 changes: 1 addition & 1 deletion src/pathways/pathways.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,6 @@ import { CacheModule } from '../cache/cache.module';
],
controllers: [PathwaysController, TagsController],
providers: [PathwaysService, TagsService, LmsClientService],
exports: [PathwaysService, TagsService],
exports: [PathwaysService, TagsService, LmsClientService],
})
export class PathwaysModule { }
2 changes: 2 additions & 0 deletions src/payments/payments.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import { UserAdapter } from '../user/useradapter';
import { PostgresModule } from '../adapters/postgres/postgres-module';
import { HttpService } from '../common/utils/http-service';
import { StripeProvider } from './providers/stripe/stripe.provider';
import { PathwaysModule } from '../pathways/pathways.module';

/**
* Payments Module
Expand Down Expand Up @@ -50,6 +51,7 @@ import { StripeProvider } from './providers/stripe/stripe.provider';
]),
ConfigModule,
PostgresModule,
PathwaysModule,
],
controllers: [
PaymentsController,
Expand Down
111 changes: 102 additions & 9 deletions src/payments/services/payment.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,13 @@ import {
Injectable,
Logger,
BadRequestException,
InternalServerErrorException,
ServiceUnavailableException,
NotFoundException,
ConflictException,
Inject,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { InjectDataSource, InjectRepository } from '@nestjs/typeorm';
import { DataSource, EntityManager, Repository, In, SelectQueryBuilder } from 'typeorm';
import { PaymentProvider } from '../interfaces/payment-provider.interface';
Expand All @@ -28,12 +31,15 @@ import { PaymentIntent } from '../entities/payment-intent.entity';
import { PaymentTarget } from '../entities/payment-target.entity';
import { User } from '../../user/entities/user-entity';
import { PaymentReportItemDto } from '../dtos/payment-report.dto';
import { LmsClientService } from '../../pathways/common/services/lms-client.service';

@Injectable()
export class PaymentService {
private readonly logger = new Logger(PaymentService.name);

constructor(
private readonly configService: ConfigService,
private readonly lmsClientService: LmsClientService,
private readonly paymentIntentService: PaymentIntentService,
private readonly paymentTransactionService: PaymentTransactionService,
private readonly paymentTargetService: PaymentTargetService,
Expand All @@ -46,6 +52,89 @@ export class PaymentService {
private readonly userRepository: Repository<User>,
) {}

/** Compare monetary amounts to the cent (avoids float drift). */
private amountsMatchClientTotal(a: number, b: number): boolean {
return Math.round(a * 100) === Math.round(b * 100);
}

/**
* For certificate bundle checkout, total must match LMS course list prices (sum per distinct course).
* Coupon discounts are applied afterward against this canonical original total.
*/
private async resolveCanonicalOriginalAmount(
dto: InitiatePaymentDto,
): Promise<number> {
if (dto.purpose !== PaymentPurpose.CERTIFICATE_BUNDLE) {
return dto.amount;
}

const tenantId = this.configService.get<string>('DEFAULT_TENANT_ID');
const organisationId = this.configService.get<string>(
'DEFAULT_ORGANISATION_ID',
);
const lmsUrl = this.configService.get<string>('LMS_SERVICE_URL');
const academicYearId =
this.configService.get<string>('DEFAULT_ACADEMIC_YEAR_ID') || undefined;

if (!lmsUrl || !tenantId || !organisationId) {
throw new InternalServerErrorException(
"Course pricing service is not configured correctly."
);
}

const uniqueCourseIds = [...new Set(dto.targets.map((t) => t.contextId))];
if (uniqueCourseIds.length === 0) {
throw new BadRequestException(
'Payment targets must include at least one course context.',
);
}

let total = 0;
let pricingCurrency: string | undefined;

for (const courseId of uniqueCourseIds) {
const pricing = await this.lmsClientService.getCoursePricing(
courseId,
tenantId,
organisationId,
academicYearId,
);
if (!pricing) {
throw new ServiceUnavailableException(
"Course pricing service is temporarily unavailable. Please try again later."
);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
if (pricingCurrency === undefined) {
pricingCurrency = pricing.currency;
} else if (pricing.currency !== pricingCurrency) {
throw new BadRequestException(
'Cannot combine courses with different pricing currencies in a single payment.',
);
}
total += pricing.amount;
}
Comment thread
mahajanmahesh935 marked this conversation as resolved.
Comment thread
mahajanmahesh935 marked this conversation as resolved.

total = Math.round(total * 100) / 100;

const requestedCurrency = (dto.currency || 'USD').toUpperCase().slice(0, 3);
const canonicalCurrency = (pricingCurrency || 'USD').toUpperCase().slice(0, 3);
Comment thread
mahajanmahesh935 marked this conversation as resolved.
if (requestedCurrency !== canonicalCurrency) {
throw new BadRequestException(
`Currency ${requestedCurrency} does not match course pricing (${canonicalCurrency}).`,
);
}

if (!this.amountsMatchClientTotal(dto.amount, total)) {
throw new BadRequestException({
message: 'Payment amount does not match current course pricing.',
expectedAmount: total,
currency: canonicalCurrency,
});
}

return total;
}

/**
* Initiate a payment
* Creates payment intent, targets, and checkout session
Expand All @@ -67,9 +156,12 @@ export class PaymentService {
});
}

const canonicalOriginalAmount =
await this.resolveCanonicalOriginalAmount(dto);

// Validate coupon if provided
let validatedCoupon = null;
let finalAmount = dto.amount;
let finalAmount = canonicalOriginalAmount;
let stripePromoCodeId: string | undefined = undefined;

if (dto.promoCode) {
Expand All @@ -79,7 +171,7 @@ export class PaymentService {
userId: dto.userId,
contextType: target.contextType,
contextId: target.contextId,
originalAmount: dto.amount,
originalAmount: canonicalOriginalAmount,
});

if (!validationResult.isValid) {
Expand All @@ -91,7 +183,8 @@ export class PaymentService {
validatedCoupon = validationResult.coupon;
// Use nullish coalescing to handle 100% discounts (discountedAmount = 0)
// Only fall back to original amount if discountedAmount is undefined/null
finalAmount = validationResult.discountedAmount ?? dto.amount;
finalAmount =
validationResult.discountedAmount ?? canonicalOriginalAmount;

// Get the full coupon to access stripePromoCodeId
const fullCoupon = await this.couponService.getCouponById(validatedCoupon.id);
Expand Down Expand Up @@ -138,7 +231,7 @@ export class PaymentService {
}

this.logger.log(
`Coupon ${dto.promoCode} validated. Original: ${dto.amount}, Discounted: ${finalAmount}, Stripe Promo Code ID: ${stripePromoCodeId}`,
`Coupon ${dto.promoCode} validated. Original: ${canonicalOriginalAmount}, Discounted: ${finalAmount}, Stripe Promo Code ID: ${stripePromoCodeId}`,
);
}

Expand All @@ -151,7 +244,7 @@ export class PaymentService {
provider: PaymentProviderEnum.STRIPE, // Default to Stripe for now
metadata: {
...dto.metadata,
originalAmount: dto.amount,
originalAmount: canonicalOriginalAmount,
couponCode: validatedCoupon?.couponCode,
couponId: validatedCoupon?.id,
},
Expand All @@ -165,7 +258,7 @@ export class PaymentService {
const providerResult = await this.paymentProvider.initiatePayment(
{
...dto,
amount: dto.amount, // Pass original amount, Stripe will apply discount via promo code
amount: canonicalOriginalAmount, // LMS-verified list total; Stripe applies promo on top
promoCode: stripePromoCodeId, // Must be a Stripe promotion code ID (e.g., "promo_xxx")
},
{ appPaymentIntentId: intent.id },
Expand All @@ -188,7 +281,7 @@ export class PaymentService {
...(validatedCoupon && {
coupon: {
code: validatedCoupon.couponCode,
discountAmount: dto.amount - finalAmount,
discountAmount: canonicalOriginalAmount - finalAmount,
finalAmount,
},
}),
Expand Down Expand Up @@ -717,7 +810,7 @@ export class PaymentService {
paidAmount: paidAmount,
discountApplied: discountCode,
discountAmount: discountAmount,
transactionId: transaction.id,
transactionId: transaction.providerPaymentId,
transactionTime: transaction.createdAt,
status: this.mapTransactionStatusToReportStatus(transaction.status),
targetUnlocked: this.computeTargetUnlockedForContext(
Expand Down Expand Up @@ -783,7 +876,7 @@ export class PaymentService {
qb
.innerJoin(User, 'user', 'user.userId = intent.userId')
.andWhere(
'(LOWER(COALESCE(user.firstName, \'\')) LIKE :searchPattern OR LOWER(COALESCE(user.lastName, \'\')) LIKE :searchPattern OR LOWER(COALESCE(user.email, \'\')) LIKE :searchPattern)',
"(LOWER(COALESCE(user.firstName, '')) LIKE :searchPattern OR LOWER(COALESCE(user.lastName, '')) LIKE :searchPattern OR LOWER(COALESCE(user.email, '')) LIKE :searchPattern OR LOWER(COALESCE(transaction.providerPaymentId, '')) LIKE :searchPattern)",
{ searchPattern },
);
}
Expand Down