Skip to content

Commit bb1ee93

Browse files
authored
Merge pull request #713 from mahajanmahesh935/StripeFeedbacks
TASK #00000 : Add validation for creating intent for already paid contextId
2 parents 096e355 + b9571be commit bb1ee93

5 files changed

Lines changed: 161 additions & 5 deletions

File tree

src/common/utils/api-id.config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,7 @@ export const APIID = {
129129
// Payment APIs
130130
PAYMENT_INITIATE: "api.payment.initiate",
131131
PAYMENT_STATUS: "api.payment.status",
132+
PAYMENT_STATUS_BY_USER_CONTEXT: "api.payment.status.byUserContext",
132133
PAYMENT_STATUS_OVERRIDE: "api.payment.status.override",
133134
PAYMENT_WEBHOOK: "api.payment.webhook",
134135
CERTIFICATE_GENERATE: "api.certificate.generate",

src/payments/dtos/payment-status.dto.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,3 +89,8 @@ export class PaymentStatusResponseDto {
8989
updatedAt: Date;
9090
}
9191

92+
export class PaymentStatusesByUserContextResponseDto {
93+
@ApiProperty({ type: [PaymentStatusResponseDto] })
94+
data: PaymentStatusResponseDto[];
95+
}
96+

src/payments/payments.controller.ts

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import {
2424
ApiCreatedResponse,
2525
ApiOkResponse,
2626
ApiBadRequestResponse,
27+
ApiConflictResponse,
2728
ApiNotFoundResponse,
2829
ApiQuery,
2930
} from '@nestjs/swagger';
@@ -32,7 +33,10 @@ import { APIID } from '../common/utils/api-id.config';
3233
import { PaymentService } from './services/payment.service';
3334
import { InitiatePaymentDto } from './dtos/initiate-payment.dto';
3435
import { OverridePaymentStatusDto } from './dtos/override-payment-status.dto';
35-
import { PaymentStatusResponseDto } from './dtos/payment-status.dto';
36+
import {
37+
PaymentStatusResponseDto,
38+
PaymentStatusesByUserContextResponseDto,
39+
} from './dtos/payment-status.dto';
3640
import { PaymentReportResponseDto } from './dtos/payment-report.dto';
3741

3842
@ApiTags('Payments')
@@ -58,6 +62,10 @@ export class PaymentsController {
5862
},
5963
})
6064
@ApiBadRequestResponse({ description: 'Invalid payment data' })
65+
@ApiConflictResponse({
66+
description:
67+
'A completed (PAID) payment already exists for this user and context; includes alreadyPaid and paymentIntentId',
68+
})
6169
async initiatePayment(@Body() dto: InitiatePaymentDto) {
6270
return await this.paymentService.initiatePayment(dto);
6371
}
@@ -111,6 +119,42 @@ export class PaymentsController {
111119
return await this.paymentService.getPaymentStatusBySessionId(sessionId);
112120
}
113121

122+
@Get('by-user-context')
123+
@UseFilters(new AllExceptionsFilter(APIID.PAYMENT_STATUS_BY_USER_CONTEXT))
124+
@ApiOperation({
125+
summary: 'Get payment status by userId and contextId',
126+
description:
127+
'Returns every payment intent for this user that includes a target with the given contextId (e.g. cohort/course id). Each element matches GET :id/status. Ordered by intent updatedAt descending (newest first).',
128+
})
129+
@ApiQuery({
130+
name: 'userId',
131+
required: true,
132+
type: String,
133+
description: 'User UUID',
134+
})
135+
@ApiQuery({
136+
name: 'contextId',
137+
required: true,
138+
type: String,
139+
description: 'Context UUID from payment target (e.g. cohort id)',
140+
})
141+
@ApiOkResponse({
142+
description: 'Payment statuses retrieved successfully',
143+
type: PaymentStatusesByUserContextResponseDto,
144+
})
145+
@ApiNotFoundResponse({
146+
description: 'No payment intent found for this user and context',
147+
})
148+
async getPaymentStatusByUserAndContext(
149+
@Query('userId', ParseUUIDPipe) userId: string,
150+
@Query('contextId', ParseUUIDPipe) contextId: string,
151+
) {
152+
return await this.paymentService.getPaymentStatusByUserIdAndContextId(
153+
userId,
154+
contextId,
155+
);
156+
}
157+
114158
@Get(':id/status')
115159
@UseFilters(new AllExceptionsFilter(APIID.PAYMENT_STATUS))
116160
@ApiOperation({ summary: 'Get payment status' })

src/payments/services/payment-intent.service.ts

Lines changed: 60 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { Injectable, NotFoundException, ConflictException } from '@nestjs/common';
22
import { InjectRepository } from '@nestjs/typeorm';
3-
import { Repository } from 'typeorm';
3+
import { In, Repository } from 'typeorm';
44
import { PaymentIntent } from '../entities/payment-intent.entity';
55
import { PaymentIntentStatus } from '../enums/payment.enums';
66

@@ -97,6 +97,65 @@ export class PaymentIntentService {
9797
return intent || null;
9898
}
9999

100+
/**
101+
* All payment intents for this user that have a target with the given contextId.
102+
* Ordered by intent updatedAt descending (newest first).
103+
*/
104+
/**
105+
* Latest PAID intent for this user with a target for the given contextId, if any.
106+
*/
107+
async findPaidByUserIdAndContextId(
108+
userId: string,
109+
contextId: string,
110+
): Promise<Pick<PaymentIntent, 'id'> | null> {
111+
const row = await this.paymentIntentRepository
112+
.createQueryBuilder('intent')
113+
.select('intent.id', 'id')
114+
.innerJoin('intent.targets', 't')
115+
.where('intent.userId = :userId', { userId })
116+
.andWhere('t.contextId = :contextId', { contextId })
117+
.andWhere('intent.status = :status', { status: PaymentIntentStatus.PAID })
118+
.groupBy('intent.id')
119+
.orderBy('MAX(intent.updatedAt)', 'DESC')
120+
.addOrderBy('intent.id', 'DESC')
121+
.limit(1)
122+
.getRawOne();
123+
124+
const id = row?.id as string | undefined;
125+
return id ? { id } : null;
126+
}
127+
128+
async findAllByUserIdAndContextId(
129+
userId: string,
130+
contextId: string,
131+
): Promise<PaymentIntent[]> {
132+
const rows = await this.paymentIntentRepository
133+
.createQueryBuilder('intent')
134+
.select('intent.id', 'id')
135+
.innerJoin('intent.targets', 't')
136+
.where('intent.userId = :userId', { userId })
137+
.andWhere('t.contextId = :contextId', { contextId })
138+
.groupBy('intent.id')
139+
.orderBy('MAX(intent.updatedAt)', 'DESC')
140+
.addOrderBy('intent.id', 'DESC')
141+
.getRawMany();
142+
143+
const ids = rows.map((r) => r.id as string).filter(Boolean);
144+
if (ids.length === 0) {
145+
return [];
146+
}
147+
148+
const intents = await this.paymentIntentRepository.find({
149+
where: { id: In(ids) },
150+
relations: ['transactions', 'targets'],
151+
});
152+
const order = new Map(ids.map((id, i) => [id, i]));
153+
intents.sort(
154+
(a, b) => (order.get(a.id) ?? 0) - (order.get(b.id) ?? 0),
155+
);
156+
return intents;
157+
}
158+
100159
/**
101160
* Update payment intent status
102161
*/

src/payments/services/payment.service.ts

Lines changed: 50 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,11 @@
1-
import { Injectable, Logger, BadRequestException, NotFoundException, Inject } from '@nestjs/common';
1+
import {
2+
Injectable,
3+
Logger,
4+
BadRequestException,
5+
NotFoundException,
6+
ConflictException,
7+
Inject,
8+
} from '@nestjs/common';
29
import { InjectDataSource, InjectRepository } from '@nestjs/typeorm';
310
import { DataSource, EntityManager, Repository, In } from 'typeorm';
411
import { PaymentProvider } from '../interfaces/payment-provider.interface';
@@ -44,6 +51,20 @@ export class PaymentService {
4451
async initiatePayment(dto: InitiatePaymentDto) {
4552
this.logger.log(`Initiating payment for user ${dto.userId}`);
4653

54+
const primaryContextId = dto.targets[0].contextId;
55+
const existingPaid =
56+
await this.paymentIntentService.findPaidByUserIdAndContextId(
57+
dto.userId,
58+
primaryContextId,
59+
);
60+
if (existingPaid) {
61+
throw new ConflictException({
62+
message: 'Already paid for this context.',
63+
alreadyPaid: true,
64+
paymentIntentId: existingPaid.id,
65+
});
66+
}
67+
4768
// Validate coupon if provided
4869
let validatedCoupon = null;
4970
let finalAmount = dto.amount;
@@ -440,7 +461,10 @@ export class PaymentService {
440461
*/
441462
async getPaymentStatus(paymentIntentId: string) {
442463
const intent = await this.paymentIntentService.findById(paymentIntentId);
443-
464+
return this.formatIntentToStatusResponse(intent);
465+
}
466+
467+
private formatIntentToStatusResponse(intent: PaymentIntent) {
444468
return {
445469
id: intent.id,
446470
userId: intent.userId,
@@ -487,7 +511,30 @@ export class PaymentService {
487511
`No payment found for session_id ${sessionId}`,
488512
);
489513
}
490-
return this.getPaymentStatus(intent.id);
514+
return this.formatIntentToStatusResponse(intent);
515+
}
516+
517+
/**
518+
* All payment intents for userId with a target for contextId; same item shape as getPaymentStatus.
519+
* Newest intent first (by updatedAt).
520+
*/
521+
async getPaymentStatusByUserIdAndContextId(
522+
userId: string,
523+
contextId: string,
524+
) {
525+
const intents =
526+
await this.paymentIntentService.findAllByUserIdAndContextId(
527+
userId,
528+
contextId,
529+
);
530+
if (intents.length === 0) {
531+
throw new NotFoundException(
532+
`No payment found for userId ${userId} and contextId ${contextId}`,
533+
);
534+
}
535+
return {
536+
data: intents.map((intent) => this.formatIntentToStatusResponse(intent)),
537+
};
491538
}
492539

493540
/**

0 commit comments

Comments
 (0)