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
14 changes: 14 additions & 0 deletions src/payments/providers/stripe/stripe.provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -281,6 +281,20 @@
metadata = session.metadata || {};
break;

case 'checkout.session.expired': {
const expiredSession = event.data.object as Stripe.Checkout.Session;
sessionId = expiredSession.id;
paymentId = (expiredSession.payment_intent as string) || '';
status = 'failed';

Check warning on line 288 in src/payments/providers/stripe/stripe.provider.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Review this redundant assignment: "status" already holds the assigned value along all execution paths.

See more on https://sonarcloud.io/project/issues?id=tekdi_user-microservice&issues=AZ0-WTAgsbZkP7zZxyBT&open=AZ0-WTAgsbZkP7zZxyBT&pullRequest=703
currency = expiredSession.currency || 'inr';
amount = this.convertFromUnitAmount(
expiredSession.amount_total || 0,
currency,
);
metadata = expiredSession.metadata || {};
break;
}

case 'payment_intent.succeeded':
const paymentIntent = event.data.object as Stripe.PaymentIntent;
paymentId = paymentIntent.id;
Expand Down
194 changes: 142 additions & 52 deletions src/payments/services/coupon.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,14 +44,46 @@
/**
* Create a new coupon
*/
async createCoupon(dto: CreateCouponDto): Promise<DiscountCoupon> {

Check failure on line 47 in src/payments/services/coupon.service.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this function to reduce its Cognitive Complexity from 21 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=tekdi_user-microservice&issues=AZ0pYfGo9pdpHZejvYoD&open=AZ0pYfGo9pdpHZejvYoD&pullRequest=703
// Check if coupon code already exists
const existing = await this.couponRepository.findOne({
where: { couponCode: dto.couponCode },
});

if (existing) {
throw new BadRequestException(`Coupon code ${dto.couponCode} already exists`);
if (existing.isActive) {
throw new BadRequestException(`Coupon code ${dto.couponCode} already exists`);
}
// Same code is reserved by an inactive row (unique constraint). Reuse the row:
// stable couponId for reporting/redemptions, new terms from this payload.
existing.contextType = dto.contextType;
existing.contextId = dto.contextId;
existing.discountType = dto.discountType;
existing.discountValue = dto.discountValue;
existing.currency = dto.currency || 'USD';
existing.countryId = dto.countryId ?? null;
existing.isActive = dto.isActive ?? true;
existing.validFrom = dto.validFrom ? new Date(dto.validFrom) : null;
existing.validTill = dto.validTill ? new Date(dto.validTill) : null;
existing.maxRedemptions = dto.maxRedemptions ?? null;
existing.maxRedemptionsPerUser = dto.maxRedemptionsPerUser ?? null;
Comment on lines +56 to +68
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Reusing this row also reuses the old redemption history.

This branch rewrites the coupon definition but keeps the same coupon.id, currentRedemptions, and existing CouponRedemption rows. validateCoupon() later checks both total and per-user usage by that ID, so a reactivated coupon can come back already partially or fully exhausted.

🧰 Tools
🪛 ESLint

[error] 62-62: Replace 'USD' with "USD"

(prettier/prettier)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/payments/services/coupon.service.ts` around lines 56 - 68, Reusing the
inactive coupon row overwrites terms but keeps coupon.id, currentRedemptions and
existing CouponRedemption rows, which allows a reactivated coupon to appear
already consumed; when updating an inactive existing (the variable existing in
this block) you must reset usage state or clear past redemptions: either create
a fresh coupon row instead of reusing the same id, or if you must reuse the row,
set existing.currentRedemptions = 0 and remove or mark associated
CouponRedemption records as stale/archived so validateCoupon() checks reflect
the new terms; update related logic that references coupon.id and
CouponRedemption to ensure per-user counts and total redemptions start from zero
on reactivation.

if (dto.stripePromoCodeId !== undefined) {
existing.stripePromoCodeId = dto.stripePromoCodeId ?? null;
}

const savedCoupon = await this.couponRepository.save(existing);
if (this.stripe && !dto.stripePromoCodeId) {
try {
await this.syncCouponToStripe(savedCoupon);
} catch (error) {
this.logger.warn(
`Failed to sync reactivated coupon ${savedCoupon.id} to Stripe: ${error.message}`,
);
}
}
this.logger.log(
`Reactivated inactive coupon: ${savedCoupon.couponCode} (${savedCoupon.id})`,
);
return savedCoupon;
Comment on lines +69 to +86
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Don't return a reactivated coupon with the previous Stripe promo ID still attached.

Line 73 saves the new terms before Stripe sync, and the catch block only logs. If this inactive row already had stripePromoCodeId, the method returns that old value unchanged when sync fails. In src/payments/services/payment.service.ts:48-147, checkout only re-syncs when the field is missing, so the previous Stripe promotion code can be reused for the new coupon terms.

💡 Safer fallback
+      const previousStripePromoCodeId = existing.stripePromoCodeId;
+      if (dto.stripePromoCodeId === undefined) {
+        existing.stripePromoCodeId = null;
+      }
       const savedCoupon = await this.couponRepository.save(existing);
       if (this.stripe && !dto.stripePromoCodeId) {
         try {
+          savedCoupon.stripePromoCodeId = previousStripePromoCodeId;
           await this.syncCouponToStripe(savedCoupon);
         } catch (error) {
+          await this.couponRepository.update(savedCoupon.id, {
+            stripePromoCodeId: null,
+          });
           this.logger.warn(
             `Failed to sync reactivated coupon ${savedCoupon.id} to Stripe: ${error.message}`,
           );
         }
       }
🧰 Tools
🪛 ESLint

[error] 79-79: Delete ,

(prettier/prettier)


[error] 84-84: Delete ,

(prettier/prettier)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/payments/services/coupon.service.ts` around lines 69 - 86, When
reactivating a coupon the old stripePromoCodeId can remain on the returned
object if Stripe sync fails; ensure the coupon's stripePromoCodeId is cleared
before saving so we never return a stale promo id. In the reactivation flow in
coupon.service.ts (references: stripePromoCodeId, couponRepository.save,
syncCouponToStripe, savedCoupon) set existing.stripePromoCodeId = null when
dto.stripePromoCodeId is undefined (or explicitly override
savedCoupon.stripePromoCodeId = null before returning) and persist that state so
a failed sync won't leave a previous Stripe promo id on the coupon.

}

// Create coupon in database
Expand Down Expand Up @@ -90,18 +122,65 @@
return savedCoupon;
}

/**
* Stripe coupons are immutable: percent_off, amount_off, max_redemptions, redeem_by cannot be
* changed after creation. Compare DB state to the Stripe coupon we would create.
*/
private stripeCouponMatchesEntity(
stripeCoupon: Stripe.Coupon,
entity: DiscountCoupon,
): boolean {
if (entity.discountType === DiscountType.PERCENT) {
if (stripeCoupon.percent_off == null) {
return false;
}
if (Number(stripeCoupon.percent_off) !== Number(entity.discountValue)) {
return false;
}
} else {
const expectedCents = Math.round(Number(entity.discountValue) * 100);
if (
stripeCoupon.amount_off !== expectedCents ||
stripeCoupon.currency !== entity.currency.toLowerCase()
) {
return false;
}
}

const entityMax = entity.maxRedemptions ?? null;
const stripeMax = stripeCoupon.max_redemptions ?? null;
if (entityMax !== stripeMax) {
return false;
}

const expectedRedeemBy = entity.validTill
? Math.floor(new Date(entity.validTill).getTime() / 1000)
: null;
const stripeRedeemBy = stripeCoupon.redeem_by ?? null;
if (expectedRedeemBy !== stripeRedeemBy) {
return false;
}

return true;
}

private promotionCodeCouponId(promo: Stripe.PromotionCode): string {
return typeof promo.coupon === 'string' ? promo.coupon : promo.coupon.id;
}

/**
* Sync coupon to Stripe
*/
async syncCouponToStripe(coupon: DiscountCoupon): Promise<void> {

Check failure on line 174 in src/payments/services/coupon.service.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this function to reduce its Cognitive Complexity from 47 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=tekdi_user-microservice&issues=AZ0pYfGo9pdpHZejvYoE&open=AZ0pYfGo9pdpHZejvYoE&pullRequest=703
if (!this.stripe) {
throw new Error('Stripe is not configured');
}

try {
// Create or update Stripe coupon
const desiredCouponId = coupon.couponCode.toLowerCase().replace(/[^a-z0-9]/g, '_');

Check warning on line 180 in src/payments/services/coupon.service.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer `String#replaceAll()` over `String#replace()`.

See more on https://sonarcloud.io/project/issues?id=tekdi_user-microservice&issues=AZ0pYfGo9pdpHZejvYoF&open=AZ0pYfGo9pdpHZejvYoF&pullRequest=703

const stripeCouponParams: Stripe.CouponCreateParams = {
id: coupon.couponCode.toLowerCase().replace(/[^a-z0-9]/g, '_'),
id: desiredCouponId,
name: coupon.couponCode,
};

Expand All @@ -110,93 +189,105 @@
if (percentOff < 0 || percentOff > 100) {
throw new BadRequestException('Percentage discount must be between 0 and 100');
}
// For percentage discounts, Stripe doesn't accept currency parameter
stripeCouponParams.percent_off = percentOff;
} else {
// For fixed amount discounts, currency is required
stripeCouponParams.amount_off = Math.round(
Number(coupon.discountValue) * 100, // Convert to cents
Number(coupon.discountValue) * 100,
);
stripeCouponParams.currency = coupon.currency.toLowerCase();
}

// Set redemption limits
if (coupon.maxRedemptions) {
stripeCouponParams.max_redemptions = coupon.maxRedemptions;
}

// Set validity period
if (coupon.validFrom || coupon.validTill) {
stripeCouponParams.redeem_by = coupon.validTill
? Math.floor(new Date(coupon.validTill).getTime() / 1000)
: undefined;
}

// Try to create the coupon
const paramsForNewCoupon: Stripe.CouponCreateParams = { ...stripeCouponParams };
delete paramsForNewCoupon.id;

let stripeCoupon: Stripe.Coupon;
try {
stripeCoupon = await this.stripe.coupons.create(stripeCouponParams);
} catch (error) {
// If coupon already exists, retrieve it
if (error.code === 'resource_already_exists') {
stripeCoupon = await this.stripe.coupons.retrieve(
stripeCouponParams.id as string,
);
const existing = await this.stripe.coupons.retrieve(desiredCouponId);
if (this.stripeCouponMatchesEntity(existing, coupon)) {
stripeCoupon = existing;
} else {
this.logger.log(
`Stripe coupon ${desiredCouponId} exists but does not match DB; creating new Stripe coupon (immutable fields changed)`,
);
if (coupon.stripePromoCodeId) {
try {
await this.stripe.promotionCodes.update(coupon.stripePromoCodeId, {
active: false,
});
} catch (deactErr) {
if (deactErr.code !== 'resource_missing') {
throw deactErr;
}
}
}
stripeCoupon = await this.stripe.coupons.create(paramsForNewCoupon);
}
Comment on lines 217 to +237
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

This replacement flow will keep rotating Stripe coupons after the first immutable change.

When desiredCouponId is already taken, Line 236 creates a replacement coupon with a new Stripe-generated ID, but the next sync starts from Line 218 again instead of reusing the coupon behind the stored stripePromoCodeId. That means every later sync can rediscover the old immutable coupon, deactivate the current promo, and create yet another replacement even if the current promo already points to a matching coupon. Since updateCoupon() now syncs on every save, even a later context/country change can churn promo IDs indefinitely; any failure after deactivation also leaves the database pointing at an inactive promo. Persist the active Stripe coupon ID, or derive/reuse it from coupon.stripePromoCodeId before falling back to desiredCouponId.

Also applies to: 243-287

🧰 Tools
🪛 ESLint

[error] 217-217: Replace 'resource_already_exists' with "resource_already_exists"

(prettier/prettier)


[error] 223-223: Delete ,

(prettier/prettier)


[error] 227-227: Replace coupon.stripePromoCodeId, with ⏎··················coupon.stripePromoCodeId,⏎·················

(prettier/prettier)


[error] 228-228: Insert ··

(prettier/prettier)


[error] 229-229: Replace } with ··}⏎················

(prettier/prettier)


[error] 231-231: Replace 'resource_missing' with "resource_missing"

(prettier/prettier)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/payments/services/coupon.service.ts` around lines 217 - 237, When
desiredCouponId exists but its immutable fields differ, avoid always creating a
new Stripe coupon; first try to derive/reuse the active coupon referenced by
coupon.stripePromoCodeId (retrieve the promotion code via
this.stripe.promotionCodes.retrieve or retrieve the promo code's coupon and
confirm it matches paramsForNewCoupon) and persist that Stripe coupon ID to the
DB (e.g., coupon.stripeCouponId) before deactivating or creating replacements.
Update the flow in the branch that uses
this.stripe.coupons.retrieve(desiredCouponId) /
this.stripe.promotionCodes.update(...) so it: 1) if mismatch, attempt to fetch
the coupon linked to coupon.stripePromoCodeId and use it when it matches, 2)
only deactivate the promo and create paramsForNewCoupon when no active matching
coupon can be found, and 3) persist the chosen active Stripe coupon ID back to
the entity so subsequent updateCoupon() runs reuse it instead of cycling
replacements; apply the same change to the similar block later in the file.

} else {
throw error;
}
}

// Create or update promotion code
let promoCode: Stripe.PromotionCode;
if (coupon.stripePromoCodeId) {
// Update existing promotion code
// Note: max_redemptions is a coupon property, not a promotion code property
try {
promoCode = await this.stripe.promotionCodes.update(
const existingPromo = await this.stripe.promotionCodes.retrieve(
coupon.stripePromoCodeId,
{
active: coupon.isActive,
},
);
this.logger.log(
`Updated existing Stripe promotion code ${promoCode.id} for coupon ${coupon.couponCode}`,
);
if (this.promotionCodeCouponId(existingPromo) === stripeCoupon.id) {
await this.stripe.promotionCodes.update(coupon.stripePromoCodeId, {
active: coupon.isActive,
});
this.logger.log(
`Updated existing Stripe promotion code ${coupon.stripePromoCodeId} for coupon ${coupon.couponCode}`,
);
return;
}
try {
await this.stripe.promotionCodes.update(coupon.stripePromoCodeId, {
active: false,
});
this.logger.log(
`Deactivated Stripe promotion code ${coupon.stripePromoCodeId} (replaced after coupon terms change)`,
);
} catch (deactErr) {
if (deactErr.code !== 'resource_missing') {
throw deactErr;
}
}
} catch (error) {
// If promotion code doesn't exist in Stripe, create a new one
if (error.code === 'resource_missing') {
this.logger.warn(
`Promotion code ${coupon.stripePromoCodeId} not found in Stripe, creating new one`,
);
promoCode = await this.stripe.promotionCodes.create({
coupon: stripeCoupon.id,
code: coupon.couponCode,
active: coupon.isActive,
max_redemptions: coupon.maxRedemptions || undefined,
});
coupon.stripePromoCodeId = promoCode.id;
await this.couponRepository.save(coupon);
this.logger.log(
`Created new Stripe promotion code ${promoCode.id} for coupon ${coupon.couponCode}`,
);
} else {
throw error;
}
}
} else {
// Create new promotion code
promoCode = await this.stripe.promotionCodes.create({
coupon: stripeCoupon.id,
code: coupon.couponCode,
active: coupon.isActive,
max_redemptions: coupon.maxRedemptions || undefined,
});
coupon.stripePromoCodeId = promoCode.id;
await this.couponRepository.save(coupon);
this.logger.log(
`Created new Stripe promotion code ${promoCode.id} for coupon ${coupon.couponCode}`,
);
}

const promoCode = await this.stripe.promotionCodes.create({
coupon: stripeCoupon.id,
code: coupon.couponCode,
active: coupon.isActive,
max_redemptions: coupon.maxRedemptions || undefined,
});
coupon.stripePromoCodeId = promoCode.id;
await this.couponRepository.save(coupon);
this.logger.log(
`Created Stripe promotion code ${promoCode.id} for coupon ${coupon.couponCode}`,
);
} catch (error) {
this.logger.error(
`Failed to sync coupon ${coupon.couponCode} to Stripe: ${error.message}`,
Expand Down Expand Up @@ -489,7 +580,7 @@
/**
* Update coupon
*/
async updateCoupon(

Check failure on line 583 in src/payments/services/coupon.service.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this function to reduce its Cognitive Complexity from 26 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=tekdi_user-microservice&issues=AZ0pYfGo9pdpHZejvYoG&open=AZ0pYfGo9pdpHZejvYoG&pullRequest=703
id: string,
updates: UpdateCouponDto,
): Promise<DiscountCoupon> {
Expand Down Expand Up @@ -553,8 +644,7 @@

const updated = await this.couponRepository.save(coupon);

// Sync to Stripe if needed
if (this.stripe && coupon.stripePromoCodeId) {
if (this.stripe) {
try {
await this.syncCouponToStripe(updated);
} catch (error) {
Expand Down
17 changes: 11 additions & 6 deletions src/payments/services/payment.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -173,7 +173,7 @@
* Handle webhook event
* Updates payment status and unlocks targets on success
*/
async handleWebhook(

Check failure on line 176 in src/payments/services/payment.service.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this function to reduce its Cognitive Complexity from 18 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=tekdi_user-microservice&issues=AZ0-WS-OsbZkP7zZxyBS&open=AZ0-WS-OsbZkP7zZxyBS&pullRequest=703
provider: string,
event: any,
rawPayload: string | Buffer,
Expand Down Expand Up @@ -258,6 +258,14 @@
);
}

let failedWebhookReason: string | null = null;
if (webhookEvent.status === 'failed') {
failedWebhookReason =
eventType === 'checkout.session.expired'
? 'Checkout session expired'
: 'Payment failed via webhook';
}

// Wrap all database operations in a transaction to ensure atomicity
const result = await this.dataSource.transaction(
async (manager: EntityManager) => {
Expand Down Expand Up @@ -298,18 +306,15 @@
providerPaymentId: webhookEvent.paymentId,
providerSessionId: webhookEvent.sessionId,
status: transactionStatus,
failureReason:
webhookEvent.status === 'failed'
? 'Payment failed via webhook'
: null,
failureReason: failedWebhookReason,
rawResponse: webhookEvent.rawEvent,
});
transaction = await manager.save(PaymentTransaction, transactionEntity);
} else {
// Update existing transaction
transactionToUpdate.status = transactionStatus;
if (webhookEvent.status === 'failed') {
transactionToUpdate.failureReason = 'Payment failed via webhook';
if (failedWebhookReason) {
transactionToUpdate.failureReason = failedWebhookReason;
}
// Populate providerPaymentId if it was null (e.g. transaction was found by sessionId)
if (webhookEvent.paymentId && !transactionToUpdate.providerPaymentId) {
Expand Down