diff --git a/src/pathways/common/services/lms-client.service.ts b/src/pathways/common/services/lms-client.service.ts index 63e59bdf..ddd05179 100644 --- a/src/pathways/common/services/lms-client.service.ts +++ b/src/pathways/common/services/lms-client.service.ts @@ -535,4 +535,75 @@ export class LmsClientService { }; } } + + /** + * 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 = { + 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) { + 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), + }; + } catch (error) { + const msg = error instanceof Error ? error.message : 'Unknown error'; + this.logger.error( + `LMS getCoursePricing failed for course ${courseId}: ${msg}`, + ); + return null; + } + } } diff --git a/src/pathways/pathways.module.ts b/src/pathways/pathways.module.ts index 98c4e253..afe4ebf9 100644 --- a/src/pathways/pathways.module.ts +++ b/src/pathways/pathways.module.ts @@ -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 { } diff --git a/src/payments/payments.module.ts b/src/payments/payments.module.ts index 1da0fd2c..a5b20030 100644 --- a/src/payments/payments.module.ts +++ b/src/payments/payments.module.ts @@ -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 @@ -50,6 +51,7 @@ import { StripeProvider } from './providers/stripe/stripe.provider'; ]), ConfigModule, PostgresModule, + PathwaysModule, ], controllers: [ PaymentsController, diff --git a/src/payments/services/payment.service.ts b/src/payments/services/payment.service.ts index 62ce418b..8ff6db3f 100644 --- a/src/payments/services/payment.service.ts +++ b/src/payments/services/payment.service.ts @@ -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'; @@ -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, @@ -46,6 +52,89 @@ export class PaymentService { private readonly userRepository: Repository, ) {} + /** 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 { + if (dto.purpose !== PaymentPurpose.CERTIFICATE_BUNDLE) { + return dto.amount; + } + + const tenantId = this.configService.get('DEFAULT_TENANT_ID'); + const organisationId = this.configService.get( + 'DEFAULT_ORGANISATION_ID', + ); + const lmsUrl = this.configService.get('LMS_SERVICE_URL'); + const academicYearId = + this.configService.get('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." + ); + } + 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; + } + + total = Math.round(total * 100) / 100; + + const requestedCurrency = (dto.currency || 'USD').toUpperCase().slice(0, 3); + const canonicalCurrency = (pricingCurrency || 'USD').toUpperCase().slice(0, 3); + 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 @@ -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) { @@ -79,7 +171,7 @@ export class PaymentService { userId: dto.userId, contextType: target.contextType, contextId: target.contextId, - originalAmount: dto.amount, + originalAmount: canonicalOriginalAmount, }); if (!validationResult.isValid) { @@ -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); @@ -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}`, ); } @@ -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, }, @@ -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 }, @@ -188,7 +281,7 @@ export class PaymentService { ...(validatedCoupon && { coupon: { code: validatedCoupon.couponCode, - discountAmount: dto.amount - finalAmount, + discountAmount: canonicalOriginalAmount - finalAmount, finalAmount, }, }), @@ -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( @@ -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 }, ); }