From 4653cd69229394bd51c2a4a7f3dff6d5bc3a72b7 Mon Sep 17 00:00:00 2001 From: Oleg Drozdovich Date: Tue, 5 Dec 2023 12:39:43 +0400 Subject: [PATCH 01/14] feat(payment-stripe): add application fee webhook handlers --- .../src/services/payment-gateway/stripe.ts | 163 +----------------- .../webhook-handlers/application-fee.ts | 136 +++++++++++++++ .../src/services/webhook-handlers/charge.ts | 65 +++++++ .../src/services/webhook-handlers/customer.ts | 12 ++ .../src/services/webhook-handlers/index.ts | 19 +- 5 files changed, 233 insertions(+), 162 deletions(-) create mode 100644 microservices/payment-stripe/src/services/webhook-handlers/application-fee.ts diff --git a/microservices/payment-stripe/src/services/payment-gateway/stripe.ts b/microservices/payment-stripe/src/services/payment-gateway/stripe.ts index 81481e14..151379ab 100644 --- a/microservices/payment-stripe/src/services/payment-gateway/stripe.ts +++ b/microservices/payment-stripe/src/services/payment-gateway/stripe.ts @@ -593,7 +593,7 @@ class Stripe extends Abstract { webhookType: string, ): Promise { const event = this.sdk.webhooks.constructEvent(payload, signature, webhookKey); - const webhookHandlers = WebhookHandlers.init(); + const webhookHandlers = WebhookHandlers.init(this.manager); switch (event.type) { /** @@ -703,7 +703,10 @@ class Stripe extends Abstract { */ case 'application_fee.refund.updated': const applicationFeeRefundUpdatedHandlers = { - account: this.handleApplicationFeeRefundUpdated(event), + account: webhookHandlers.applicationFee.handleApplicationFeeRefundUpdated( + event, + this.sdk, + ), }; await applicationFeeRefundUpdatedHandlers?.[webhookType]; @@ -711,7 +714,7 @@ class Stripe extends Abstract { case 'application_fee.refunded': const applicationFeeRefundedHandlers = { - account: this.handleApplicationFeeRefunded(event), + account: webhookHandlers.applicationFee.handleApplicationFeeRefunded(event), }; await applicationFeeRefundedHandlers?.[webhookType]; @@ -722,7 +725,7 @@ class Stripe extends Abstract { */ case 'charge.refund.updated': const chargeRefundUpdatedHandlers = { - account: this.handleRefundUpdated(event), + account: webhookHandlers.charge.handleRefundUpdated(event, this.manager), }; await chargeRefundUpdatedHandlers?.[webhookType]; @@ -798,108 +801,6 @@ class Stripe extends Abstract { await this.transactionRepository.save(transactions); } - /** - * Handles application fee refund updated - */ - public async handleApplicationFeeRefundUpdated(event: StripeSdk.Event): Promise { - const { fee } = event.data.object as StripeSdk.FeeRefund; - - const applicationFeeId = this.extractId(fee); - const transactions = await this.transactionRepository.find({ - applicationFeeId, - }); - - if (!transactions.length) { - throw new BaseException({ - status: 500, - message: messages.getNotFoundMessage( - 'Failed to update refunded application fees. Debit or credit transaction', - ), - payload: { eventName: event.type, applicationFeeId }, - }); - } - - const { amount, amount_refunded: refundedApplicationFeeAmount } = - await this.sdk.applicationFees.retrieve(applicationFeeId); - - /** - * @TODO: create helper for updates check - */ - let isUpdated = false; - - transactions.forEach((transaction) => { - if (transaction.fee !== amount) { - throw new BaseException({ - status: 500, - message: `Handle webhook event "${event.type}" occur. Transaction fee is not equal to Stripe application fee`, - payload: { eventName: event.type, transactionFee: transaction.fee, feeAmount: amount }, - }); - } - - if (transaction.params.refundedApplicationFeeAmount !== refundedApplicationFeeAmount) { - transaction.params.refundedApplicationFeeAmount = refundedApplicationFeeAmount; - isUpdated = true; - } - }); - - if (!isUpdated) { - return; - } - - await this.transactionRepository.save(transactions); - } - - /** - * Handles application fee refunded - */ - public async handleApplicationFeeRefunded(event: StripeSdk.Event): Promise { - const { - id: applicationFeeId, - amount, - amount_refunded: refundedApplicationFeeAmount, - } = event.data.object as StripeSdk.ApplicationFee; - - const transactions = await this.transactionRepository.find({ - applicationFeeId, - }); - - if (!transactions.length) { - throw new BaseException({ - status: 500, - message: messages.getNotFoundMessage( - 'Failed to update refunded application fees. Debit or credit transaction', - ), - payload: { eventName: event.type, applicationFeeId }, - }); - } - - let isUpdated = false; - - /** - * @TODO: move out to separate helper (e.g. class calculation) - */ - transactions.forEach((transaction) => { - if (transaction.fee !== amount) { - throw new BaseException({ - status: 500, - message: `Handle webhook event "${event.type}" occur. Transaction fee is not equal to Stripe application fee`, - payload: { eventName: event.type, transactionFee: transaction.fee, feeAmount: amount }, - }); - } - - if (transaction.params.refundedApplicationFeeAmount !== refundedApplicationFeeAmount) { - transaction.params.refundedApplicationFeeAmount = refundedApplicationFeeAmount; - isUpdated = true; - } - }); - - if (!isUpdated) { - return; - } - - await this.transactionRepository.save(transactions); - } - /** * Handles payment method detach * @description Card and other payment methods should be removed in according subscribers @@ -1002,56 +903,6 @@ class Stripe extends Abstract { }); } - /** - * Handles refund updated - */ - public async handleRefundUpdated(event: StripeSdk.Event): Promise { - const { - id, - status, - reason, - failure_reason: failedReason, - payment_intent: paymentIntent, - } = event.data.object as StripeSdk.Refund; - - if (!paymentIntent || !status) { - throw new BaseException({ - status: 500, - message: "Payment intent id or refund status wasn't provided.", - }); - } - - const refund = await this.refundRepository - .createQueryBuilder('r') - .where("r.params ->> 'refundId' = :refundId", { refundId: id }) - .getOne(); - - if (!refund) { - throw new BaseException({ - status: 500, - message: messages.getNotFoundMessage('Failed to update refund. Refund'), - }); - } - - const refundStatus = Parser.parseStripeTransactionStatus(status as StripeTransactionStatus); - - if (!refundStatus) { - throw new BaseException({ - status: 500, - message: 'Failed to get transaction status for refund.', - }); - } - - refund.status = refundStatus; - refund.params.errorReason = failedReason; - - if (reason && refund.params.reason !== reason) { - refund.params.reason = reason; - } - - await this.refundRepository.save(refund); - } - /** * Handles payment intent failure creation * @description Payment intent will be created with the failed status: card was declined - diff --git a/microservices/payment-stripe/src/services/webhook-handlers/application-fee.ts b/microservices/payment-stripe/src/services/webhook-handlers/application-fee.ts new file mode 100644 index 00000000..c02fb50e --- /dev/null +++ b/microservices/payment-stripe/src/services/webhook-handlers/application-fee.ts @@ -0,0 +1,136 @@ +import { BaseException } from '@lomray/microservice-nodejs-lib'; +import StripeSdk from 'stripe'; +import { EntityManager, Repository } from 'typeorm'; +import TransactionEntity from '@entities/transaction'; +import extractIdFromStripeInstance from '@helpers/extract-id-from-stripe-instance'; +import messages from '@helpers/validators/messages'; + +/** + * Application fee webhook handlers + */ +class ApplicationFee { + /** + * @private + */ + private readonly manager: EntityManager; + + /** + * @private + */ + private readonly transactionRepository: Repository; + + /** + * @constructor + */ + public constructor(manager: EntityManager) { + this.manager = manager; + this.transactionRepository = manager.getRepository(TransactionEntity); + } + + /** + * Handles application fee refund updated + */ + public async handleApplicationFeeRefundUpdated( + event: StripeSdk.Event, + sdk: StripeSdk, + ): Promise { + const { fee } = event.data.object as StripeSdk.FeeRefund; + + const applicationFeeId = extractIdFromStripeInstance(fee); + const transactions = await this.transactionRepository.find({ + applicationFeeId, + }); + + if (!transactions.length) { + throw new BaseException({ + status: 500, + message: messages.getNotFoundMessage( + 'Failed to update refunded application fees. Debit or credit transaction', + ), + payload: { eventName: event.type, applicationFeeId }, + }); + } + + const { amount, amount_refunded: refundedApplicationFeeAmount } = + await sdk.applicationFees.retrieve(applicationFeeId); + + /** + * @TODO: create helper for updates check + */ + let isUpdated = false; + + transactions.forEach((transaction) => { + if (transaction.fee !== amount) { + throw new BaseException({ + status: 500, + message: `Handle webhook event "${event.type}" occur. Transaction fee is not equal to Stripe application fee`, + payload: { eventName: event.type, transactionFee: transaction.fee, feeAmount: amount }, + }); + } + + if (transaction.params.refundedApplicationFeeAmount !== refundedApplicationFeeAmount) { + transaction.params.refundedApplicationFeeAmount = refundedApplicationFeeAmount; + isUpdated = true; + } + }); + + if (!isUpdated) { + return; + } + + await this.transactionRepository.save(transactions); + } + + /** + * Handles application fee refunded + */ + public async handleApplicationFeeRefunded(event: StripeSdk.Event): Promise { + const { + id: applicationFeeId, + amount, + amount_refunded: refundedApplicationFeeAmount, + } = event.data.object as StripeSdk.ApplicationFee; + + const transactions = await this.transactionRepository.find({ + applicationFeeId, + }); + + if (!transactions.length) { + throw new BaseException({ + status: 500, + message: messages.getNotFoundMessage( + 'Failed to update refunded application fees. Debit or credit transaction', + ), + payload: { eventName: event.type, applicationFeeId }, + }); + } + + let isUpdated = false; + + /** + * @TODO: move out to separate helper (e.g. class calculation) + */ + transactions.forEach((transaction) => { + if (transaction.fee !== amount) { + throw new BaseException({ + status: 500, + message: `Handle webhook event "${event.type}" occur. Transaction fee is not equal to Stripe application fee`, + payload: { eventName: event.type, transactionFee: transaction.fee, feeAmount: amount }, + }); + } + + if (transaction.params.refundedApplicationFeeAmount !== refundedApplicationFeeAmount) { + transaction.params.refundedApplicationFeeAmount = refundedApplicationFeeAmount; + isUpdated = true; + } + }); + + if (!isUpdated) { + return; + } + + await this.transactionRepository.save(transactions); + } +} + +export default ApplicationFee; diff --git a/microservices/payment-stripe/src/services/webhook-handlers/charge.ts b/microservices/payment-stripe/src/services/webhook-handlers/charge.ts index e43c5b88..b8ac1084 100644 --- a/microservices/payment-stripe/src/services/webhook-handlers/charge.ts +++ b/microservices/payment-stripe/src/services/webhook-handlers/charge.ts @@ -3,9 +3,11 @@ import StripeSdk from 'stripe'; import { EntityManager } from 'typeorm'; import StripeDisputeReason from '@constants/stripe-dispute-reason'; import StripeDisputeStatus from '@constants/stripe-dispute-status'; +import StripeTransactionStatus from '@constants/stripe-transaction-status'; import TransactionStatus from '@constants/transaction-status'; import DisputeEntity from '@entities/dispute'; import EvidenceDetails from '@entities/evidence-details'; +import RefundEntity from '@entities/refund'; import TransactionEntity from '@entities/transaction'; import extractIdFromStripeInstance from '@helpers/extract-id-from-stripe-instance'; import messages from '@helpers/validators/messages'; @@ -17,6 +19,18 @@ import Parser from '@services/parser'; * Charge webhook handler */ class Charge { + /** + * @private + */ + private readonly manager: EntityManager; + + /** + * @constructor + */ + public constructor(manager: EntityManager) { + this.manager = manager; + } + /** * Handles charge refunded */ @@ -176,6 +190,57 @@ class Charge { await DisputeService.update(disputeEntity, dispute, entityManager); }); } + + /** + * Handles refund updated + */ + public async handleRefundUpdated(event: StripeSdk.Event, manager: EntityManager): Promise { + const refundRepository = manager.getRepository(RefundEntity); + const { + id, + status, + reason, + failure_reason: failedReason, + payment_intent: paymentIntent, + } = event.data.object as StripeSdk.Refund; + + if (!paymentIntent || !status) { + throw new BaseException({ + status: 500, + message: "Payment intent id or refund status wasn't provided.", + }); + } + + const refund = await refundRepository + .createQueryBuilder('r') + .where("r.params ->> 'refundId' = :refundId", { refundId: id }) + .getOne(); + + if (!refund) { + throw new BaseException({ + status: 500, + message: messages.getNotFoundMessage('Failed to update refund. Refund'), + }); + } + + const refundStatus = Parser.parseStripeTransactionStatus(status as StripeTransactionStatus); + + if (!refundStatus) { + throw new BaseException({ + status: 500, + message: 'Failed to get transaction status for refund.', + }); + } + + refund.status = refundStatus; + refund.params.errorReason = failedReason; + + if (reason && refund.params.reason !== reason) { + refund.params.reason = reason; + } + + await refundRepository.save(refund); + } } export default Charge; diff --git a/microservices/payment-stripe/src/services/webhook-handlers/customer.ts b/microservices/payment-stripe/src/services/webhook-handlers/customer.ts index cad6d857..4547bffb 100644 --- a/microservices/payment-stripe/src/services/webhook-handlers/customer.ts +++ b/microservices/payment-stripe/src/services/webhook-handlers/customer.ts @@ -9,6 +9,18 @@ import CardRepository from '@repositories/card'; * Customer webhook handlers */ class Customer { + /** + * @private + */ + private readonly manager: EntityManager; + + /** + * @constructor + */ + public constructor(manager: EntityManager) { + this.manager = manager; + } + /** * Handles customer update */ diff --git a/microservices/payment-stripe/src/services/webhook-handlers/index.ts b/microservices/payment-stripe/src/services/webhook-handlers/index.ts index 8dd8e03a..3a8266dc 100644 --- a/microservices/payment-stripe/src/services/webhook-handlers/index.ts +++ b/microservices/payment-stripe/src/services/webhook-handlers/index.ts @@ -1,3 +1,5 @@ +import { EntityManager } from 'typeorm'; +import ApplicationFee from './application-fee'; import Charge from './charge'; import Customer from './customer'; @@ -17,20 +19,25 @@ class WebhookHandlers { */ public readonly charge: Charge; + /** + * @public + */ + public readonly applicationFee: ApplicationFee; + /** * @constructor - * @TODO: provide manager */ - private constructor() { - this.customer = new Customer(); - this.charge = new Charge(); + private constructor(manager: EntityManager) { + this.customer = new Customer(manager); + this.charge = new Charge(manager); + this.applicationFee = new ApplicationFee(manager); } /** * Init service */ - public static init(): WebhookHandlers { - return new WebhookHandlers(); + public static init(manager: EntityManager): WebhookHandlers { + return new WebhookHandlers(manager); } } From 7e161cb2fa5193da366242e027f78f6bc894791f Mon Sep 17 00:00:00 2001 From: Oleg Drozdovich Date: Tue, 5 Dec 2023 14:09:50 +0400 Subject: [PATCH 02/14] feat(payment-stripe): add transaction dispute status --- ...701769135839-transaction-dispute-status.ts | 19 +++++ .../constants/transaction-dispute-status.ts | 7 ++ .../payment-stripe/src/entities/dispute.ts | 2 +- .../src/entities/transaction.ts | 14 ++++ .../payment-stripe/src/services/dispute.ts | 74 ++++++++++++++++++- .../payment-stripe/src/services/parser.ts | 21 ++++++ .../payment-stripe/src/subscribers/dispute.ts | 4 +- 7 files changed, 134 insertions(+), 7 deletions(-) create mode 100644 microservices/payment-stripe/migrations/1701769135839-transaction-dispute-status.ts create mode 100644 microservices/payment-stripe/src/constants/transaction-dispute-status.ts diff --git a/microservices/payment-stripe/migrations/1701769135839-transaction-dispute-status.ts b/microservices/payment-stripe/migrations/1701769135839-transaction-dispute-status.ts new file mode 100644 index 00000000..e65a027d --- /dev/null +++ b/microservices/payment-stripe/migrations/1701769135839-transaction-dispute-status.ts @@ -0,0 +1,19 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export default class transactionDisputeStatus1701769135839 implements MigrationInterface { + name = 'transactionDisputeStatus1701769135839'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TYPE "public"."transaction_disputestatus_enum" AS ENUM('notDisputed', 'disputed', 'disputeClosed')`, + ); + await queryRunner.query( + `ALTER TABLE "transaction" ADD "disputeStatus" "public"."transaction_disputestatus_enum" NOT NULL DEFAULT 'notDisputed'`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "transaction" DROP COLUMN "disputeStatus"`); + await queryRunner.query(`DROP TYPE "public"."transaction_disputestatus_enum"`); + } +} diff --git a/microservices/payment-stripe/src/constants/transaction-dispute-status.ts b/microservices/payment-stripe/src/constants/transaction-dispute-status.ts new file mode 100644 index 00000000..ab8456e3 --- /dev/null +++ b/microservices/payment-stripe/src/constants/transaction-dispute-status.ts @@ -0,0 +1,7 @@ +enum TransactionDisputeStatus { + NOT_DISPUTED = 'notDisputed', + DISPUTED = 'disputed', + DISPUTE_CLOSED = 'disputeClosed', +} + +export default TransactionDisputeStatus; diff --git a/microservices/payment-stripe/src/entities/dispute.ts b/microservices/payment-stripe/src/entities/dispute.ts index c6e60d89..bef8a356 100644 --- a/microservices/payment-stripe/src/entities/dispute.ts +++ b/microservices/payment-stripe/src/entities/dispute.ts @@ -101,7 +101,7 @@ class Dispute { @JSONSchema({ description: - 'New worth of the account (platform or connected account) after dispute related to this transaction', + 'Dispute new worth related to the account (platform or connected account) after dispute related to this transaction', example: -12139, }) @Column({ type: 'int', default: 0 }) diff --git a/microservices/payment-stripe/src/entities/transaction.ts b/microservices/payment-stripe/src/entities/transaction.ts index 452a0b7a..5feef66f 100644 --- a/microservices/payment-stripe/src/entities/transaction.ts +++ b/microservices/payment-stripe/src/entities/transaction.ts @@ -12,6 +12,7 @@ import { } from 'typeorm'; import StripeCheckoutStatus from '@constants/stripe-checkout-status'; import StripeTransactionStatus from '@constants/stripe-transaction-status'; +import TransactionDisputeStatus from '@constants/transaction-dispute-status'; import TransactionRole from '@constants/transaction-role'; import TransactionStatus from '@constants/transaction-status'; import TransactionType from '@constants/transaction-type'; @@ -264,6 +265,19 @@ class Transaction { @IsUndefinable() status: TransactionStatus; + @JSONSchema({ + description: + 'Transaction can not be refunded during dispute, but if dispute closed - can be refunded', + }) + @Column({ + type: 'enum', + enum: TransactionDisputeStatus, + default: TransactionDisputeStatus.NOT_DISPUTED, + }) + @IsEnum(TransactionDisputeStatus) + @IsUndefinable() + disputeStatus: TransactionDisputeStatus; + @JSONSchema({ description: 'Store data about payment connected account and etc.', }) diff --git a/microservices/payment-stripe/src/services/dispute.ts b/microservices/payment-stripe/src/services/dispute.ts index 96c1a8ce..715b8683 100644 --- a/microservices/payment-stripe/src/services/dispute.ts +++ b/microservices/payment-stripe/src/services/dispute.ts @@ -1,10 +1,13 @@ +import { Log } from '@lomray/microservice-helpers'; import { Microservice } from '@lomray/microservice-nodejs-lib'; import Event from '@lomray/microservices-client-api/constants/events/payment-stripe'; import StripeSdk from 'stripe'; import { EntityManager } from 'typeorm'; +import DisputeStatus from '@constants/dispute-status'; import StripeDisputeReason from '@constants/stripe-dispute-reason'; import StripeDisputeStatus from '@constants/stripe-dispute-status'; import DisputeEntity from '@entities/dispute'; +import TransactionEntity from '@entities/transaction'; import Parser from '@services/parser'; /** @@ -28,14 +31,20 @@ class Dispute { manager: EntityManager, ): Promise { disputeEntity.amount = amount; - disputeEntity.status = Parser.parseStripeDisputeStatus(status as StripeDisputeStatus); - disputeEntity.reason = Parser.parseStripeDisputeReason(reason as StripeDisputeReason); disputeEntity.metadata = metadata; disputeEntity.params.balanceTransactionId = balanceTransactions?.[0]?.id; disputeEntity.params.isChargeRefundable = isChargeRefundable; disputeEntity.evidenceDetails.submissionCount = evidenceDetails.submission_count; disputeEntity.evidenceDetails.isPastBy = evidenceDetails.past_due; disputeEntity.evidenceDetails.hasEvidence = evidenceDetails.has_evidence; + disputeEntity.reason = Parser.parseStripeDisputeReason(reason as StripeDisputeReason); + + const disputeStatus = Parser.parseStripeDisputeStatus(status as StripeDisputeStatus); + + // Stripe can send under review status after won or lost dispute + if (![DisputeStatus.LOST, DisputeStatus.WON].includes(disputeEntity.status)) { + disputeEntity.status = disputeStatus; + } if (evidenceDetails.due_by) { disputeEntity.evidenceDetails.dueBy = new Date(evidenceDetails.due_by * 1000); @@ -48,6 +57,11 @@ class Dispute { disputeEntity.netWorth = netWorth; await manager.getRepository(DisputeEntity).save(disputeEntity); + await Dispute.updateTransactionsDisputeStatus( + manager, + disputeEntity.transactionId, + disputeStatus, + ); } /** @@ -71,8 +85,14 @@ class Dispute { /** * Handle after create */ - public static async handleAfterCreate(entity: DisputeEntity): Promise { - await Microservice.eventPublish(Event.DisputeCreated, entity); + public static async handleAfterCreate( + entity: DisputeEntity, + manager: EntityManager, + ): Promise { + await Promise.all([ + Dispute.updateTransactionsDisputeStatus(manager, entity.transactionId, entity.status), + Microservice.eventPublish(Event.DisputeCreated, entity), + ]); } /** @@ -81,6 +101,52 @@ class Dispute { public static async handleAfterUpdate(entity: DisputeEntity): Promise { await Microservice.eventPublish(Event.DisputeUpdated, entity); } + + /** + * Update transactions dispute status + */ + private static async updateTransactionsDisputeStatus( + manager: EntityManager, + transactionId?: string | null, + disputeStatus?: DisputeStatus | null, + ): Promise { + if (!transactionId) { + return; + } + + const transactionRepository = manager.getRepository(TransactionEntity); + + const transactions = await transactionRepository.find({ + where: { + transactionId, + }, + }); + + if (!transactions.length) { + Log.error('Failed to update transaction dispute status. Transactions were not found.'); + + return; + } + + let isUpdated = false; + const transactionDisputeStatus = + Parser.parseStripeDisputeStatusToTransactionDisputeStatus(disputeStatus); + + transactions.forEach((transaction) => { + if (transaction.disputeStatus === transactionDisputeStatus) { + return; + } + + transaction.disputeStatus = transactionDisputeStatus; + isUpdated = true; + }); + + if (!isUpdated) { + return; + } + + await transactionRepository.save(transactions); + } } export default Dispute; diff --git a/microservices/payment-stripe/src/services/parser.ts b/microservices/payment-stripe/src/services/parser.ts index 91184e12..adae7fbd 100644 --- a/microservices/payment-stripe/src/services/parser.ts +++ b/microservices/payment-stripe/src/services/parser.ts @@ -4,6 +4,7 @@ import DisputeStatus from '@constants/dispute-status'; import StripeDisputeReason from '@constants/stripe-dispute-reason'; import StripeDisputeStatus from '@constants/stripe-dispute-status'; import StripeTransactionStatus from '@constants/stripe-transaction-status'; +import TransactionDisputeStatus from '@constants/transaction-dispute-status'; import TransactionStatus from '@constants/transaction-status'; declare function assert(status: never): never; @@ -14,6 +15,26 @@ declare function assert(status: never): never; * Decomposed payment stripe logic */ class Parser { + /** + * Parse Stripe dispute status + */ + public static parseStripeDisputeStatusToTransactionDisputeStatus( + stripeStatus?: DisputeStatus | null, + ): TransactionDisputeStatus { + if (!stripeStatus) { + return TransactionDisputeStatus.NOT_DISPUTED; + } + + switch (stripeStatus) { + case DisputeStatus.LOST: + case DisputeStatus.WON: + return TransactionDisputeStatus.DISPUTE_CLOSED; + + default: + return TransactionDisputeStatus.DISPUTED; + } + } + /** * Parse Stripe dispute status */ diff --git a/microservices/payment-stripe/src/subscribers/dispute.ts b/microservices/payment-stripe/src/subscribers/dispute.ts index d57261be..541baac9 100644 --- a/microservices/payment-stripe/src/subscribers/dispute.ts +++ b/microservices/payment-stripe/src/subscribers/dispute.ts @@ -17,8 +17,8 @@ class Dispute implements EntitySubscriberInterface { /** * Handle Refund event: after insert */ - public async afterInsert({ entity }: InsertEvent): Promise { - await DisputeService.handleAfterCreate(entity); + public async afterInsert({ entity, manager }: InsertEvent): Promise { + await DisputeService.handleAfterCreate(entity, manager); } /** From 49a388e923b16886b0244ba4e16c1cefa2870b9b Mon Sep 17 00:00:00 2001 From: Oleg Drozdovich Date: Tue, 5 Dec 2023 14:17:35 +0400 Subject: [PATCH 03/14] feat(payment-stripe): add payment intent webhook handlers --- .../src/services/payment-gateway/stripe.ts | 225 +-------------- .../src/services/webhook-handlers/index.ts | 7 + .../webhook-handlers/payment-intent.ts | 262 ++++++++++++++++++ 3 files changed, 271 insertions(+), 223 deletions(-) create mode 100644 microservices/payment-stripe/src/services/webhook-handlers/payment-intent.ts diff --git a/microservices/payment-stripe/src/services/payment-gateway/stripe.ts b/microservices/payment-stripe/src/services/payment-gateway/stripe.ts index 151379ab..b3523142 100644 --- a/microservices/payment-stripe/src/services/payment-gateway/stripe.ts +++ b/microservices/payment-stripe/src/services/payment-gateway/stripe.ts @@ -32,7 +32,6 @@ import messages from '@helpers/validators/messages'; import TBalance from '@interfaces/balance'; import TCurrency from '@interfaces/currency'; import IFees from '@interfaces/fees'; -import type { IPaymentIntentMetadata } from '@interfaces/payment-intent-metadata'; import type ITax from '@interfaces/tax'; import type { ICardDataByFingerprintResult } from '@repositories/card'; import CardRepository from '@repositories/card'; @@ -684,7 +683,7 @@ class Stripe extends Abstract { case 'payment_intent.succeeded': case 'payment_intent.canceled': const paymentIntentProcessingSucceededCanceledHandlers = { - account: this.handlePaymentIntent(event), + account: webhookHandlers.paymentIntent.handlePaymentIntent(event, this.sdk), }; await paymentIntentProcessingSucceededCanceledHandlers?.[webhookType]; @@ -692,7 +691,7 @@ class Stripe extends Abstract { case 'payment_intent.payment_failed': const paymentIntentPaymentFailedHandlers = { - account: this.handlePaymentIntentPaymentFailed(event), + account: webhookHandlers.paymentIntent.handlePaymentIntentPaymentFailed(event, this.sdk), }; await paymentIntentPaymentFailedHandlers?.[webhookType]; @@ -903,226 +902,6 @@ class Stripe extends Abstract { }); } - /** - * Handles payment intent failure creation - * @description Payment intent will be created with the failed status: card was declined - - * high fraud risk but stripe will throw error on creation and send webhook event with the creation - */ - public async handlePaymentIntentPaymentFailed(event: StripeSdk.Event): Promise { - const { - id, - status, - metadata, - amount, - latest_charge: latestCharge, - last_payment_error: lastPaymentError, - } = event.data.object as StripeSdk.PaymentIntent; - - await this.manager.transaction(async (entityManager) => { - const transactionRepository = entityManager.getRepository(Transaction); - - const transactions = await transactionRepository.find({ transactionId: id }); - - /** - * If transactions weren't created cause payment intent failed on create - */ - if (transactions.length) { - return; - } - - const { - entityId, - title, - feesPayer, - cardId, - entityCost, - senderId, - receiverId, - taxExpiresAt, - taxCreatedAt, - taxBehaviour, - receiverRevenue, - taxAutoCalculateFee, - taxFee, - taxTransactionId, - taxCalculationId, - } = metadata as unknown as IPaymentIntentMetadata; - - const card = await entityManager - .getRepository(Card) - .createQueryBuilder('card') - .where('card.userId = :userId AND card.id = :cardId', { userId: senderId, cardId }) - .getOne(); - - if (!card) { - throw new BaseException({ - status: 500, - message: messages.getNotFoundMessage('Failed to create transaction. Card'), - }); - } - - /* eslint-enable camelcase */ - const transactionData = { - entityId, - title, - paymentMethodId: CardRepository.extractPaymentMethodId(card), - cardId, - transactionId: id, - status: Parser.parseStripeTransactionStatus(status as StripeTransactionStatus), - ...(latestCharge ? { chargeId: this.extractId(latestCharge) } : {}), - ...(taxTransactionId ? { taxTransactionId } : {}), - ...(taxCalculationId ? { taxCalculationId } : {}), - // eslint-disable-next-line camelcase - params: { - feesPayer, - entityCost: this.toSmallestCurrencyUnit(entityCost), - errorCode: lastPaymentError?.code, - errorMessage: lastPaymentError?.message, - declineCode: lastPaymentError?.decline_code, - taxExpiresAt, - taxCreatedAt, - taxBehaviour, - // Only if calculation was created provide tax calculation fee - ...(taxCalculationId && taxAutoCalculateFee - ? { taxAutoCalculateFee: this.toSmallestCurrencyUnit(taxAutoCalculateFee) } - : {}), - // Only if tax transaction was created provide tax fee - ...(taxTransactionId && taxFee ? { taxFee: this.toSmallestCurrencyUnit(taxFee) } : {}), - }, - }; - - await Promise.all([ - transactionRepository.save( - transactionRepository.create({ - ...transactionData, - userId: senderId, - type: TransactionType.CREDIT, - amount, - params: transactionData.params, - }), - ), - transactionRepository.save( - transactionRepository.create({ - ...transactionData, - userId: receiverId, - type: TransactionType.DEBIT, - amount: this.toSmallestCurrencyUnit(receiverRevenue), - params: transactionData.params, - }), - ), - ]); - - // Do not support update payment intent, only recharge via creating new payment - if (status !== StripeTransactionStatus.REQUIRES_PAYMENT_METHOD) { - return; - } - - await this.sdk.paymentIntents.cancel(id); - }); - } - - /** - * Handles payment intent statuses - */ - public async handlePaymentIntent(event: StripeSdk.Event): Promise { - const { - id, - status, - latest_charge: latestCharge, - last_payment_error: lastPaymentError, - transfer_data: transferData, - } = event.data.object as StripeSdk.PaymentIntent; - - await this.manager.transaction(async (entityManager) => { - const transactionRepository = entityManager.getRepository(Transaction); - - const transactions = await transactionRepository.find({ transactionId: id }); - - if (!transactions.length) { - const errorMessage = messages.getNotFoundMessage( - `Failed to handle payment intent "${event.type}". Debit or credit transaction`, - ); - - Log.error(errorMessage); - - throw new BaseException({ - status: 500, - message: errorMessage, - payload: { eventName: event.type }, - }); - } - - // Sonar warning - const { transactionId, taxCalculationId } = transactions?.[0] || {}; - - let stripeTaxTransaction: StripeSdk.Tax.Transaction | null = null; - - /** - * If tax collecting and payment intent succeeded - create tax transaction - * @description Create tax transaction cost is $0.5. Create only if payment intent succeeded - */ - if (taxCalculationId && status === StripeTransactionStatus.SUCCEEDED) { - // Create tax transaction (for Stripe Tax reports) - stripeTaxTransaction = await this.sdk.tax.transactions.createFromCalculation({ - calculation: taxCalculationId, - // Stripe payment intent id - reference: transactionId, - }); - } - - transactions.forEach((transaction) => { - transaction.status = Parser.parseStripeTransactionStatus(status as StripeTransactionStatus); - - /** - * Attach related charge - */ - if (!transaction.chargeId && latestCharge) { - transaction.chargeId = this.extractId(latestCharge); - } - - /** - * Attach destination funds transfer connect account - */ - if (!transaction.params.transferDestinationConnectAccountId && transferData?.destination) { - transaction.params.transferDestinationConnectAccountId = this.extractId( - transferData.destination, - ); - } - - /** - * Attach tax transaction if reference - */ - if (stripeTaxTransaction) { - transaction.taxTransactionId = stripeTaxTransaction.id; - } - - if (!lastPaymentError) { - return; - } - - /** - * Attach error data if it occurs - */ - transaction.params.errorMessage = lastPaymentError.message; - transaction.params.errorCode = lastPaymentError.code; - transaction.params.declineCode = lastPaymentError.decline_code; - }); - - if (stripeTaxTransaction) { - /** - * Sync payment intent with the microservice transactions - */ - await this.sdk.paymentIntents.update(transactionId, { - metadata: { - taxTransactionId: stripeTaxTransaction?.id, - }, - }); - } - - await transactionRepository.save(transactions); - }); - } - /** * Create instant payout * @description Should be called from the API diff --git a/microservices/payment-stripe/src/services/webhook-handlers/index.ts b/microservices/payment-stripe/src/services/webhook-handlers/index.ts index 3a8266dc..4904f3bd 100644 --- a/microservices/payment-stripe/src/services/webhook-handlers/index.ts +++ b/microservices/payment-stripe/src/services/webhook-handlers/index.ts @@ -2,6 +2,7 @@ import { EntityManager } from 'typeorm'; import ApplicationFee from './application-fee'; import Charge from './charge'; import Customer from './customer'; +import PaymentIntent from './payment-intent'; /** * Webhook handlers @@ -24,6 +25,11 @@ class WebhookHandlers { */ public readonly applicationFee: ApplicationFee; + /** + * @public + */ + public readonly paymentIntent: PaymentIntent; + /** * @constructor */ @@ -31,6 +37,7 @@ class WebhookHandlers { this.customer = new Customer(manager); this.charge = new Charge(manager); this.applicationFee = new ApplicationFee(manager); + this.paymentIntent = new PaymentIntent(manager); } /** diff --git a/microservices/payment-stripe/src/services/webhook-handlers/payment-intent.ts b/microservices/payment-stripe/src/services/webhook-handlers/payment-intent.ts new file mode 100644 index 00000000..0e49ad81 --- /dev/null +++ b/microservices/payment-stripe/src/services/webhook-handlers/payment-intent.ts @@ -0,0 +1,262 @@ +import { Log } from '@lomray/microservice-helpers'; +import { BaseException } from '@lomray/microservice-nodejs-lib'; +import toSmallestUnit from '@lomray/microservices-client-api/helpers/parsers/to-smallest-unit'; +import StripeSdk from 'stripe'; +import { EntityManager, Repository } from 'typeorm'; +import StripeTransactionStatus from '@constants/stripe-transaction-status'; +import TransactionType from '@constants/transaction-type'; +import Card from '@entities/card'; +import TransactionEntity from '@entities/transaction'; +import extractIdFromStripeInstance from '@helpers/extract-id-from-stripe-instance'; +import messages from '@helpers/validators/messages'; +import { IPaymentIntentMetadata } from '@interfaces/payment-intent-metadata'; +import CardRepository from '@repositories/card'; +import Parser from '@services/parser'; + +/** + * Payment intent webhook handlers + */ +class PaymentIntent { + /** + * @private + */ + private readonly manager: EntityManager; + + /** + * @private + */ + private readonly transactionRepository: Repository; + + /** + * @constructor + */ + public constructor(manager: EntityManager) { + this.manager = manager; + this.transactionRepository = manager.getRepository(TransactionEntity); + } + + /** + * Handles payment intent statuses + */ + public async handlePaymentIntent(event: StripeSdk.Event, sdk: StripeSdk): Promise { + const { + id, + status, + latest_charge: latestCharge, + last_payment_error: lastPaymentError, + transfer_data: transferData, + } = event.data.object as StripeSdk.PaymentIntent; + + await this.manager.transaction(async (entityManager) => { + const transactionRepository = entityManager.getRepository(TransactionEntity); + + const transactions = await transactionRepository.find({ transactionId: id }); + + if (!transactions.length) { + const errorMessage = messages.getNotFoundMessage( + `Failed to handle payment intent "${event.type}". Debit or credit transaction`, + ); + + Log.error(errorMessage); + + throw new BaseException({ + status: 500, + message: errorMessage, + payload: { eventName: event.type }, + }); + } + + // Sonar warning + const { transactionId, taxCalculationId } = transactions?.[0] || {}; + + let stripeTaxTransaction: StripeSdk.Tax.Transaction | null = null; + + /** + * If tax collecting and payment intent succeeded - create tax transaction + * @description Create tax transaction cost is $0.5. Create only if payment intent succeeded + */ + if (taxCalculationId && status === StripeTransactionStatus.SUCCEEDED) { + // Create tax transaction (for Stripe Tax reports) + stripeTaxTransaction = await sdk.tax.transactions.createFromCalculation({ + calculation: taxCalculationId, + // Stripe payment intent id + reference: transactionId, + }); + } + + transactions.forEach((transaction) => { + transaction.status = Parser.parseStripeTransactionStatus(status as StripeTransactionStatus); + + /** + * Attach related charge + */ + if (!transaction.chargeId && latestCharge) { + transaction.chargeId = extractIdFromStripeInstance(latestCharge); + } + + /** + * Attach destination funds transfer connect account + */ + if (!transaction.params.transferDestinationConnectAccountId && transferData?.destination) { + transaction.params.transferDestinationConnectAccountId = extractIdFromStripeInstance( + transferData.destination, + ); + } + + /** + * Attach tax transaction if reference + */ + if (stripeTaxTransaction) { + transaction.taxTransactionId = stripeTaxTransaction.id; + } + + if (!lastPaymentError) { + return; + } + + /** + * Attach error data if it occurs + */ + transaction.params.errorMessage = lastPaymentError.message; + transaction.params.errorCode = lastPaymentError.code; + transaction.params.declineCode = lastPaymentError.decline_code; + }); + + if (stripeTaxTransaction) { + /** + * Sync payment intent with the microservice transactions + */ + await sdk.paymentIntents.update(transactionId, { + metadata: { + taxTransactionId: stripeTaxTransaction?.id, + }, + }); + } + + await transactionRepository.save(transactions); + }); + } + + /** + * Handles payment intent failure creation + * @description Payment intent will be created with the failed status: card was declined - + * high fraud risk but stripe will throw error on creation and send webhook event with the creation + */ + public async handlePaymentIntentPaymentFailed( + event: StripeSdk.Event, + sdk: StripeSdk, + ): Promise { + const { + id, + status, + metadata, + amount, + latest_charge: latestCharge, + last_payment_error: lastPaymentError, + } = event.data.object as StripeSdk.PaymentIntent; + + await this.manager.transaction(async (entityManager) => { + const transactionRepository = entityManager.getRepository(TransactionEntity); + + const transactions = await transactionRepository.find({ transactionId: id }); + + /** + * If transactions weren't created cause payment intent failed on create + */ + if (transactions.length) { + return; + } + + const { + entityId, + title, + feesPayer, + cardId, + entityCost, + senderId, + receiverId, + taxExpiresAt, + taxCreatedAt, + taxBehaviour, + receiverRevenue, + taxAutoCalculateFee, + taxFee, + taxTransactionId, + taxCalculationId, + } = metadata as unknown as IPaymentIntentMetadata; + + const card = await entityManager + .getRepository(Card) + .createQueryBuilder('card') + .where('card.userId = :userId AND card.id = :cardId', { userId: senderId, cardId }) + .getOne(); + + if (!card) { + throw new BaseException({ + status: 500, + message: messages.getNotFoundMessage('Failed to create transaction. Card'), + }); + } + + /* eslint-enable camelcase */ + const transactionData = { + entityId, + title, + paymentMethodId: CardRepository.extractPaymentMethodId(card), + cardId, + transactionId: id, + status: Parser.parseStripeTransactionStatus(status as StripeTransactionStatus), + ...(latestCharge ? { chargeId: extractIdFromStripeInstance(latestCharge) } : {}), + ...(taxTransactionId ? { taxTransactionId } : {}), + ...(taxCalculationId ? { taxCalculationId } : {}), + // eslint-disable-next-line camelcase + params: { + feesPayer, + entityCost: toSmallestUnit(entityCost), + errorCode: lastPaymentError?.code, + errorMessage: lastPaymentError?.message, + declineCode: lastPaymentError?.decline_code, + taxExpiresAt, + taxCreatedAt, + taxBehaviour, + // Only if calculation was created provide tax calculation fee + ...(taxCalculationId && taxAutoCalculateFee + ? { taxAutoCalculateFee: toSmallestUnit(taxAutoCalculateFee) } + : {}), + // Only if tax transaction was created provide tax fee + ...(taxTransactionId && taxFee ? { taxFee: toSmallestUnit(taxFee) } : {}), + }, + }; + + await Promise.all([ + transactionRepository.save( + transactionRepository.create({ + ...transactionData, + userId: senderId, + type: TransactionType.CREDIT, + amount, + params: transactionData.params, + }), + ), + transactionRepository.save( + transactionRepository.create({ + ...transactionData, + userId: receiverId, + type: TransactionType.DEBIT, + amount: toSmallestUnit(receiverRevenue), + params: transactionData.params, + }), + ), + ]); + + // Do not support update payment intent, only recharge via creating new payment + if (status !== StripeTransactionStatus.REQUIRES_PAYMENT_METHOD) { + return; + } + + await sdk.paymentIntents.cancel(id); + }); + } +} + +export default PaymentIntent; From b87cb869c6805799b6f137198077ab705dfa79ea Mon Sep 17 00:00:00 2001 From: Oleg Drozdovich Date: Tue, 5 Dec 2023 14:18:50 +0400 Subject: [PATCH 04/14] feat(payment-stripe): clean up --- .../payment-stripe/src/services/payment-gateway/stripe.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/microservices/payment-stripe/src/services/payment-gateway/stripe.ts b/microservices/payment-stripe/src/services/payment-gateway/stripe.ts index b3523142..f32d9e6d 100644 --- a/microservices/payment-stripe/src/services/payment-gateway/stripe.ts +++ b/microservices/payment-stripe/src/services/payment-gateway/stripe.ts @@ -50,6 +50,7 @@ import Abstract from './abstract'; export interface IStripeProductParams extends IProductParams { name: string; description?: string; + // @TODO: Expected: Images urls? images?: string[]; } From 0fbe07b34030cb40d7d44d03b895b1b3a864f5dc Mon Sep 17 00:00:00 2001 From: Oleg Drozdovich Date: Tue, 5 Dec 2023 14:19:27 +0400 Subject: [PATCH 05/14] feat(payment-stripe): clean up --- .../payment-stripe/src/services/payment-gateway/stripe.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/microservices/payment-stripe/src/services/payment-gateway/stripe.ts b/microservices/payment-stripe/src/services/payment-gateway/stripe.ts index f32d9e6d..fbd4bba6 100644 --- a/microservices/payment-stripe/src/services/payment-gateway/stripe.ts +++ b/microservices/payment-stripe/src/services/payment-gateway/stripe.ts @@ -50,7 +50,7 @@ import Abstract from './abstract'; export interface IStripeProductParams extends IProductParams { name: string; description?: string; - // @TODO: Expected: Images urls? + // @TODO: Expected: ImagesUrl? images?: string[]; } From 564f95aafc09802bba8ef3af726702479cdd66cc Mon Sep 17 00:00:00 2001 From: Oleg Drozdovich Date: Tue, 5 Dec 2023 15:33:07 +0400 Subject: [PATCH 06/14] feat(payment-stripe): update webhook handlers --- .../src/services/payment-gateway/stripe.ts | 34 +--------- .../src/services/webhook-handlers/index.ts | 7 +++ .../webhook-handlers/payment-intent.ts | 2 +- .../src/services/webhook-handlers/transfer.ts | 63 +++++++++++++++++++ 4 files changed, 72 insertions(+), 34 deletions(-) create mode 100644 microservices/payment-stripe/src/services/webhook-handlers/transfer.ts diff --git a/microservices/payment-stripe/src/services/payment-gateway/stripe.ts b/microservices/payment-stripe/src/services/payment-gateway/stripe.ts index fbd4bba6..7669fb69 100644 --- a/microservices/payment-stripe/src/services/payment-gateway/stripe.ts +++ b/microservices/payment-stripe/src/services/payment-gateway/stripe.ts @@ -671,7 +671,7 @@ class Stripe extends Abstract { */ case 'transfer.reversed': const transferReversedHandlers = { - account: this.transferReversed(event), + account: webhookHandlers.transfer.transferReversed(event), }; await transferReversedHandlers?.[webhookType]; @@ -769,38 +769,6 @@ class Stripe extends Abstract { } } - /** - * Transfer reversed - */ - public async transferReversed(event: StripeSdk.Event): Promise { - const { amount_reversed: reversedAmount, source_transaction: chargeId } = event.data - .object as StripeSdk.Transfer; - - const transactions = await this.transactionRepository - .createQueryBuilder('t') - .where('t.chargeId = :chargeId', { chargeId }) - .getMany(); - - if (!transactions.length) { - const errorMessage = messages.getNotFoundMessage( - 'Failed to hande transfer reversed. Debit or credit transaction', - ); - - Log.error(errorMessage); - - throw new BaseException({ - status: 500, - message: errorMessage, - }); - } - - transactions.forEach((transaction) => { - transaction.params.transferReversedAmount = reversedAmount; - }); - - await this.transactionRepository.save(transactions); - } - /** * Handles payment method detach * @description Card and other payment methods should be removed in according subscribers diff --git a/microservices/payment-stripe/src/services/webhook-handlers/index.ts b/microservices/payment-stripe/src/services/webhook-handlers/index.ts index 4904f3bd..cac58ec3 100644 --- a/microservices/payment-stripe/src/services/webhook-handlers/index.ts +++ b/microservices/payment-stripe/src/services/webhook-handlers/index.ts @@ -3,6 +3,7 @@ import ApplicationFee from './application-fee'; import Charge from './charge'; import Customer from './customer'; import PaymentIntent from './payment-intent'; +import Transfer from './transfer'; /** * Webhook handlers @@ -30,6 +31,11 @@ class WebhookHandlers { */ public readonly paymentIntent: PaymentIntent; + /** + * @public + */ + public readonly transfer: Transfer; + /** * @constructor */ @@ -38,6 +44,7 @@ class WebhookHandlers { this.charge = new Charge(manager); this.applicationFee = new ApplicationFee(manager); this.paymentIntent = new PaymentIntent(manager); + this.transfer = new Transfer(manager); } /** diff --git a/microservices/payment-stripe/src/services/webhook-handlers/payment-intent.ts b/microservices/payment-stripe/src/services/webhook-handlers/payment-intent.ts index 0e49ad81..493b8066 100644 --- a/microservices/payment-stripe/src/services/webhook-handlers/payment-intent.ts +++ b/microservices/payment-stripe/src/services/webhook-handlers/payment-intent.ts @@ -9,7 +9,7 @@ import Card from '@entities/card'; import TransactionEntity from '@entities/transaction'; import extractIdFromStripeInstance from '@helpers/extract-id-from-stripe-instance'; import messages from '@helpers/validators/messages'; -import { IPaymentIntentMetadata } from '@interfaces/payment-intent-metadata'; +import type { IPaymentIntentMetadata } from '@interfaces/payment-intent-metadata'; import CardRepository from '@repositories/card'; import Parser from '@services/parser'; diff --git a/microservices/payment-stripe/src/services/webhook-handlers/transfer.ts b/microservices/payment-stripe/src/services/webhook-handlers/transfer.ts new file mode 100644 index 00000000..0e0880e0 --- /dev/null +++ b/microservices/payment-stripe/src/services/webhook-handlers/transfer.ts @@ -0,0 +1,63 @@ +import { Log } from '@lomray/microservice-helpers'; +import { BaseException } from '@lomray/microservice-nodejs-lib'; +import StripeSdk from 'stripe'; +import { EntityManager, Repository } from 'typeorm'; +import TransactionEntity from '@entities/transaction'; +import messages from '@helpers/validators/messages'; + +/** + * Transfer webhook handlers + */ +class Transfer { + /** + * @private + */ + private readonly manager: EntityManager; + + /** + * @private + */ + private readonly transactionRepository: Repository; + + /** + * @constructor + */ + public constructor(manager: EntityManager) { + this.manager = manager; + this.transactionRepository = manager.getRepository(TransactionEntity); + } + + /** + * Transfer reversed + */ + public async transferReversed(event: StripeSdk.Event): Promise { + const { amount_reversed: reversedAmount, source_transaction: chargeId } = event.data + .object as StripeSdk.Transfer; + + const transactions = await this.transactionRepository + .createQueryBuilder('t') + .where('t.chargeId = :chargeId', { chargeId }) + .getMany(); + + if (!transactions.length) { + const errorMessage = messages.getNotFoundMessage( + 'Failed to hande transfer reversed. Debit or credit transaction', + ); + + Log.error(errorMessage); + + throw new BaseException({ + status: 500, + message: errorMessage, + }); + } + + transactions.forEach((transaction) => { + transaction.params.transferReversedAmount = reversedAmount; + }); + + await this.transactionRepository.save(transactions); + } +} + +export default Transfer; From 5a612f58dd11f3606385188556f13779ab22da75 Mon Sep 17 00:00:00 2001 From: Oleg Drozdovich Date: Tue, 5 Dec 2023 15:42:00 +0400 Subject: [PATCH 07/14] feat(payment-stripe): update webhook handlers --- .../src/services/payment-gateway/stripe.ts | 106 +------------- .../src/services/webhook-handlers/index.ts | 7 + .../webhook-handlers/payment-method.ts | 134 ++++++++++++++++++ 3 files changed, 143 insertions(+), 104 deletions(-) create mode 100644 microservices/payment-stripe/src/services/webhook-handlers/payment-method.ts diff --git a/microservices/payment-stripe/src/services/payment-gateway/stripe.ts b/microservices/payment-stripe/src/services/payment-gateway/stripe.ts index 7669fb69..c724f512 100644 --- a/microservices/payment-stripe/src/services/payment-gateway/stripe.ts +++ b/microservices/payment-stripe/src/services/payment-gateway/stripe.ts @@ -652,7 +652,7 @@ class Stripe extends Abstract { case 'payment_method.updated': case 'payment_method.automatically_updated': const paymentMethodUpdatedAutomaticallyUpdatedHandlers = { - account: this.handlePaymentMethodUpdated(event), + account: webhookHandlers.paymentMethod.handlePaymentMethodUpdated(event), }; await paymentMethodUpdatedAutomaticallyUpdatedHandlers?.[webhookType]; @@ -660,7 +660,7 @@ class Stripe extends Abstract { case 'payment_method.detached': const paymentMethodDetachedHandlers = { - account: this.handlePaymentMethodDetached(event), + account: webhookHandlers.paymentMethod.handlePaymentMethodDetached(event), }; await paymentMethodDetachedHandlers?.[webhookType]; @@ -769,108 +769,6 @@ class Stripe extends Abstract { } } - /** - * Handles payment method detach - * @description Card and other payment methods should be removed in according subscribers - * @TODO: Handle other payment methods if needed - */ - public async handlePaymentMethodDetached(event: StripeSdk.Event): Promise { - const { id: paymentMethodId, card: cardPaymentMethod } = event.data - .object as StripeSdk.PaymentMethod; - - if (!cardPaymentMethod) { - throw new BaseException({ - status: 500, - message: "Payment method card wasn't provided", - }); - } - - const card = await this.cardRepository - .createQueryBuilder('card') - .where(`card.params->>'paymentMethodId' = :value OR card."paymentMethodId" = :value`, { - value: paymentMethodId, - }) - .getOne(); - - if (!card) { - throw new BaseException({ - status: 500, - message: messages.getNotFoundMessage('Payment method'), - }); - } - - await this.cardRepository.remove(card, { data: { isFromWebhook: true } }); - - void Microservice.eventPublish(Event.PaymentMethodRemoved, { - paymentMethodId, - }); - } - - /** - * Handles payment method update - * @description Expected card that can be setup via setupIntent - */ - public async handlePaymentMethodUpdated(event: StripeSdk.Event): Promise { - const { - id, - card: cardPaymentMethod, - billing_details: billing, - } = event.data.object as StripeSdk.PaymentMethod; - - if (!cardPaymentMethod) { - throw new BaseException({ - status: 500, - message: "Payment method card wasn't provided", - }); - } - - const card = await this.cardRepository - .createQueryBuilder('card') - .where(`card.params->>'paymentMethodId' = :value OR card."paymentMethodId" = :value`, { - value: id, - }) - .getOne(); - - if (!card) { - throw new BaseException({ - status: 500, - message: messages.getNotFoundMessage('Payment method'), - }); - } - - const { - exp_month: expMonth, - exp_year: expYear, - last4: lastDigits, - brand, - funding, - issuer, - country, - } = cardPaymentMethod; - - const expired = toExpirationDate(expMonth, expYear); - - card.lastDigits = lastDigits; - card.expired = expired; - card.brand = brand; - card.funding = funding; - card.origin = country; - // If billing was updated to null - SHOULD set null - card.country = billing.address?.country || null; - card.postalCode = billing.address?.postal_code || null; - card.params.issuer = issuer; - - await this.cardRepository.save(card); - - void Microservice.eventPublish(Event.PaymentMethodUpdated, { - funding, - brand, - expired, - lastDigits, - cardId: card.id, - }); - } - /** * Create instant payout * @description Should be called from the API diff --git a/microservices/payment-stripe/src/services/webhook-handlers/index.ts b/microservices/payment-stripe/src/services/webhook-handlers/index.ts index cac58ec3..f83aa9f4 100644 --- a/microservices/payment-stripe/src/services/webhook-handlers/index.ts +++ b/microservices/payment-stripe/src/services/webhook-handlers/index.ts @@ -3,6 +3,7 @@ import ApplicationFee from './application-fee'; import Charge from './charge'; import Customer from './customer'; import PaymentIntent from './payment-intent'; +import PaymentMethod from './payment-method'; import Transfer from './transfer'; /** @@ -36,6 +37,11 @@ class WebhookHandlers { */ public readonly transfer: Transfer; + /** + * @public + */ + public readonly paymentMethod: PaymentMethod; + /** * @constructor */ @@ -45,6 +51,7 @@ class WebhookHandlers { this.applicationFee = new ApplicationFee(manager); this.paymentIntent = new PaymentIntent(manager); this.transfer = new Transfer(manager); + this.paymentMethod = new PaymentMethod(manager); } /** diff --git a/microservices/payment-stripe/src/services/webhook-handlers/payment-method.ts b/microservices/payment-stripe/src/services/webhook-handlers/payment-method.ts new file mode 100644 index 00000000..772f8556 --- /dev/null +++ b/microservices/payment-stripe/src/services/webhook-handlers/payment-method.ts @@ -0,0 +1,134 @@ +import { BaseException, Microservice } from '@lomray/microservice-nodejs-lib'; +import Event from '@lomray/microservices-client-api/constants/events/payment-stripe'; +import StripeSdk from 'stripe'; +import { EntityManager } from 'typeorm'; +import toExpirationDate from '@helpers/formatters/to-expiration-date'; +import messages from '@helpers/validators/messages'; +import CardRepository from '@repositories/card'; + +/** + * Payment method webhook handlers + */ +class PaymentMethod { + /** + * @private + */ + private readonly manager: EntityManager; + + /** + * @private + */ + private readonly cardRepository: CardRepository; + + /** + * @constructor + */ + public constructor(manager: EntityManager) { + this.manager = manager; + this.cardRepository = manager.getCustomRepository(CardRepository); + } + + /** + * Handles payment method update + * @description Expected card that can be setup via setupIntent + */ + public async handlePaymentMethodUpdated(event: StripeSdk.Event): Promise { + const { + id, + card: cardPaymentMethod, + billing_details: billing, + } = event.data.object as StripeSdk.PaymentMethod; + + if (!cardPaymentMethod) { + throw new BaseException({ + status: 500, + message: "Payment method card wasn't provided", + }); + } + + const card = await this.cardRepository + .createQueryBuilder('card') + .where(`card.params->>'paymentMethodId' = :value OR card."paymentMethodId" = :value`, { + value: id, + }) + .getOne(); + + if (!card) { + throw new BaseException({ + status: 500, + message: messages.getNotFoundMessage('Payment method'), + }); + } + + const { + exp_month: expMonth, + exp_year: expYear, + last4: lastDigits, + brand, + funding, + issuer, + country, + } = cardPaymentMethod; + + const expired = toExpirationDate(expMonth, expYear); + + card.lastDigits = lastDigits; + card.expired = expired; + card.brand = brand; + card.funding = funding; + card.origin = country; + // If billing was updated to null - SHOULD set null + card.country = billing.address?.country || null; + card.postalCode = billing.address?.postal_code || null; + card.params.issuer = issuer; + + await this.cardRepository.save(card); + + void Microservice.eventPublish(Event.PaymentMethodUpdated, { + funding, + brand, + expired, + lastDigits, + cardId: card.id, + }); + } + + /** + * Handles payment method detach + * @description Card and other payment methods should be removed in according subscribers + * @TODO: Handle other payment methods if needed + */ + public async handlePaymentMethodDetached(event: StripeSdk.Event): Promise { + const { id: paymentMethodId, card: cardPaymentMethod } = event.data + .object as StripeSdk.PaymentMethod; + + if (!cardPaymentMethod) { + throw new BaseException({ + status: 500, + message: "Payment method card wasn't provided", + }); + } + + const card = await this.cardRepository + .createQueryBuilder('card') + .where(`card.params->>'paymentMethodId' = :value OR card."paymentMethodId" = :value`, { + value: paymentMethodId, + }) + .getOne(); + + if (!card) { + throw new BaseException({ + status: 500, + message: messages.getNotFoundMessage('Payment method'), + }); + } + + await this.cardRepository.remove(card, { data: { isFromWebhook: true } }); + + void Microservice.eventPublish(Event.PaymentMethodRemoved, { + paymentMethodId, + }); + } +} + +export default PaymentMethod; From 7577067f03a724a83106a123c21eba5dc3c4ea2e Mon Sep 17 00:00:00 2001 From: Oleg Drozdovich Date: Tue, 5 Dec 2023 15:53:06 +0400 Subject: [PATCH 08/14] feat(payment-stripe): add setup intent webhooks handlers --- .../src/services/payment-gateway/stripe.ts | 171 +------------- .../src/services/webhook-handlers/index.ts | 7 + .../services/webhook-handlers/setup-intent.ts | 221 ++++++++++++++++++ 3 files changed, 229 insertions(+), 170 deletions(-) create mode 100644 microservices/payment-stripe/src/services/webhook-handlers/setup-intent.ts diff --git a/microservices/payment-stripe/src/services/payment-gateway/stripe.ts b/microservices/payment-stripe/src/services/payment-gateway/stripe.ts index c724f512..986f33e0 100644 --- a/microservices/payment-stripe/src/services/payment-gateway/stripe.ts +++ b/microservices/payment-stripe/src/services/payment-gateway/stripe.ts @@ -2,7 +2,6 @@ import { Log } from '@lomray/microservice-helpers'; import { BaseException, Microservice } from '@lomray/microservice-nodejs-lib'; import Event from '@lomray/microservices-client-api/constants/events/payment-stripe'; import { validate } from 'class-validator'; -import _ from 'lodash'; import StripeSdk from 'stripe'; import remoteConfig from '@config/remote'; import BalanceType from '@constants/balance-type'; @@ -33,7 +32,6 @@ import TBalance from '@interfaces/balance'; import TCurrency from '@interfaces/currency'; import IFees from '@interfaces/fees'; import type ITax from '@interfaces/tax'; -import type { ICardDataByFingerprintResult } from '@repositories/card'; import CardRepository from '@repositories/card'; import Calculation from '@services/calculation'; import Parser from '@services/parser'; @@ -643,7 +641,7 @@ class Stripe extends Abstract { */ case 'setup_intent.succeeded': const setupIntentSucceededHandlers = { - account: this.handleSetupIntentSucceed(event), + account: webhookHandlers.setupIntent.handleSetupIntentSucceed(event, this.sdk), }; await setupIntentSucceededHandlers?.[webhookType]; @@ -895,173 +893,6 @@ class Stripe extends Abstract { }; } - /** - * Handles setup intent succeed - * @description Support cards. Should be called when webhook triggers - */ - public async handleSetupIntentSucceed(event: StripeSdk.Event): Promise { - const { duplicatedCardsUsage } = await remoteConfig(); - - /* eslint-disable camelcase */ - const { id, payment_method } = event.data.object as StripeSdk.SetupIntent; - - if (!payment_method) { - throw new BaseException({ - status: 500, - message: messages.getNotFoundMessage('The SetupIntent payment method'), - }); - } - - /** - * Get payment method data - */ - const paymentMethod = await this.sdk.paymentMethods.retrieve(this.extractId(payment_method), { - expand: [StripePaymentMethods.CARD], - }); - - if (!paymentMethod?.card || !paymentMethod?.customer) { - throw new BaseException({ - status: 500, - message: 'The payment method card or customer data is invalid.', - }); - } - - const customer = await this.customerRepository.findOne({ - customerId: this.extractId(paymentMethod.customer), - }); - - if (!customer) { - throw new BaseException({ - status: 500, - message: messages.getNotFoundMessage('Customer'), - }); - } - - const { - id: paymentMethodId, - billing_details: billing, - card: { - brand, - last4: lastDigits, - exp_month: expMonth, - exp_year: expYear, - funding, - country, - issuer, - fingerprint, - }, - } = paymentMethod; - - const { userId } = customer; - - const cardParams = { - lastDigits, - brand, - userId, - funding, - fingerprint, - paymentMethodId, - origin: country, - ...(billing.address?.country ? { country: billing.address.country } : {}), - ...(billing.address?.postal_code ? { postalCode: billing.address.postal_code } : {}), - expired: toExpirationDate(expMonth, expYear), - }; - - const cardEntity = this.cardRepository.create({ - ...cardParams, - params: { - isApproved: true, - setupIntentId: id, - issuer, - }, - }); - - /** - * If we should reject duplicated cards - check - */ - if (duplicatedCardsUsage === 'reject') { - const cardData = await CardRepository.getCardDataByFingerprint({ - userId, - fingerprint, - shouldExpandCard: true, - }); - - /** - * Cancel set up card if this card already exist as the payment method - */ - if (cardData.isExist && cardData.type === 'paymentMethod') { - await this.detachOrRenewWithDetachDuplicatedCard(paymentMethodId, cardEntity, cardData); - - return; - } - } - - const savedCard = await this.cardRepository.save(cardEntity); - - void Microservice.eventPublish(Event.SetupIntentSucceeded, savedCard); - } - - /** - * Detach duplicated card - * @description Will detach duplicated card from Stripe customer - */ - private async detachOrRenewWithDetachDuplicatedCard( - paymentMethodId: string, - cardEntity: Card, - { entity }: ICardDataByFingerprintResult, - ): Promise { - if (!entity) { - throw new BaseException({ - status: 500, - message: messages.getNotFoundMessage('Failed to validate duplicated card. Card'), - }); - } - - /** - * Card properties for renewal card that must be identical - */ - const cardProperties = ['lastDigits', 'brand', 'origin', 'fingerprint', 'funding', 'userId']; - const existingCardPaymentMethodId = CardRepository.extractPaymentMethodId(entity); - - const { year: existingYear, month: existingMonth } = fromExpirationDate(entity.expired); - const { year: updatedYear, month: updatedMonth } = fromExpirationDate(cardEntity.expired); - - /** - * Update renewal card details - * @description Stripe does not create new fingerprint if card was renewal with new expiration date - */ - if ( - entity.expired !== cardEntity.expired && - // All other card details MUST be equal - _.isEqual(_.pick(entity, cardProperties), _.pick(cardEntity, cardProperties)) && - // Check expiration dates - updatedYear >= existingYear && - updatedMonth >= existingMonth - ) { - entity.expired = cardEntity.expired; - - /** - * Update card details and next() detach new duplicated card - */ - await this.sdk.paymentMethods.update(existingCardPaymentMethodId as string, { - card: { - exp_month: updatedMonth, - exp_year: updatedYear, - }, - }); - - await this.cardRepository.save(entity); - } - - /** - * If customer trying to add identical, not renewal card - * @description Detach duplicated card from Stripe customer - */ - await this.sdk.paymentMethods.detach(paymentMethodId); - - await Microservice.eventPublish(Event.CardNotCreatedDuplicated, cardEntity); - } - /** * Handles connect account update * @description Connect account event diff --git a/microservices/payment-stripe/src/services/webhook-handlers/index.ts b/microservices/payment-stripe/src/services/webhook-handlers/index.ts index f83aa9f4..a400a7da 100644 --- a/microservices/payment-stripe/src/services/webhook-handlers/index.ts +++ b/microservices/payment-stripe/src/services/webhook-handlers/index.ts @@ -4,6 +4,7 @@ import Charge from './charge'; import Customer from './customer'; import PaymentIntent from './payment-intent'; import PaymentMethod from './payment-method'; +import SetupIntent from './setup-intent'; import Transfer from './transfer'; /** @@ -42,6 +43,11 @@ class WebhookHandlers { */ public readonly paymentMethod: PaymentMethod; + /** + * @public + */ + public readonly setupIntent: SetupIntent; + /** * @constructor */ @@ -52,6 +58,7 @@ class WebhookHandlers { this.paymentIntent = new PaymentIntent(manager); this.transfer = new Transfer(manager); this.paymentMethod = new PaymentMethod(manager); + this.setupIntent = new SetupIntent(manager); } /** diff --git a/microservices/payment-stripe/src/services/webhook-handlers/setup-intent.ts b/microservices/payment-stripe/src/services/webhook-handlers/setup-intent.ts new file mode 100644 index 00000000..50726e3b --- /dev/null +++ b/microservices/payment-stripe/src/services/webhook-handlers/setup-intent.ts @@ -0,0 +1,221 @@ +import { BaseException, Microservice } from '@lomray/microservice-nodejs-lib'; +import Event from '@lomray/microservices-client-api/constants/events/payment-stripe'; +import _ from 'lodash'; +import StripeSdk from 'stripe'; +import { EntityManager, Repository } from 'typeorm'; +import remoteConfig from '@config/remote'; +import StripePaymentMethods from '@constants/stripe-payment-methods'; +import Card from '@entities/card'; +import CustomerEntity from '@entities/customer'; +import extractIdFromStripeInstance from '@helpers/extract-id-from-stripe-instance'; +import fromExpirationDate from '@helpers/formatters/from-expiration-date'; +import toExpirationDate from '@helpers/formatters/to-expiration-date'; +import messages from '@helpers/validators/messages'; +import CardRepository, { ICardDataByFingerprintResult } from '@repositories/card'; + +/** + * Setup intent Webhook Handler + */ +class SetupIntent { + /** + * @private + */ + private readonly manager: EntityManager; + + /** + * @private + */ + private readonly cardRepository: CardRepository; + + /** + * @private + */ + private readonly customerRepository: Repository; + + /** + * @constructor + */ + public constructor(manager: EntityManager) { + this.manager = manager; + this.cardRepository = manager.getCustomRepository(CardRepository); + this.customerRepository = manager.getRepository(CustomerEntity); + } + + /** + * Handles setup intent succeed + * @description Support cards. Should be called when webhook triggers + */ + public async handleSetupIntentSucceed(event: StripeSdk.Event, sdk: StripeSdk): Promise { + const { duplicatedCardsUsage } = await remoteConfig(); + + /* eslint-disable camelcase */ + const { id, payment_method } = event.data.object as StripeSdk.SetupIntent; + + if (!payment_method) { + throw new BaseException({ + status: 500, + message: messages.getNotFoundMessage('The SetupIntent payment method'), + }); + } + + /** + * Get payment method data + */ + const paymentMethod = await sdk.paymentMethods.retrieve( + extractIdFromStripeInstance(payment_method), + { + expand: [StripePaymentMethods.CARD], + }, + ); + + if (!paymentMethod?.card || !paymentMethod?.customer) { + throw new BaseException({ + status: 500, + message: 'The payment method card or customer data is invalid.', + }); + } + + const customer = await this.customerRepository.findOne({ + customerId: extractIdFromStripeInstance(paymentMethod.customer), + }); + + if (!customer) { + throw new BaseException({ + status: 500, + message: messages.getNotFoundMessage('Customer'), + }); + } + + const { + id: paymentMethodId, + billing_details: billing, + card: { + brand, + last4: lastDigits, + exp_month: expMonth, + exp_year: expYear, + funding, + country, + issuer, + fingerprint, + }, + } = paymentMethod; + + const { userId } = customer; + + const cardParams = { + lastDigits, + brand, + userId, + funding, + fingerprint, + paymentMethodId, + origin: country, + ...(billing.address?.country ? { country: billing.address.country } : {}), + ...(billing.address?.postal_code ? { postalCode: billing.address.postal_code } : {}), + expired: toExpirationDate(expMonth, expYear), + }; + + const cardEntity = this.cardRepository.create({ + ...cardParams, + params: { + isApproved: true, + setupIntentId: id, + issuer, + }, + }); + + /** + * If we should reject duplicated cards - check + */ + if (duplicatedCardsUsage === 'reject') { + const cardData = await CardRepository.getCardDataByFingerprint({ + userId, + fingerprint, + shouldExpandCard: true, + }); + + /** + * Cancel set up card if this card already exist as the payment method + */ + if (cardData.isExist && cardData.type === 'paymentMethod') { + await this.detachOrRenewWithDetachDuplicatedCard( + paymentMethodId, + cardEntity, + cardData, + sdk, + ); + + return; + } + } + + const savedCard = await this.cardRepository.save(cardEntity); + + void Microservice.eventPublish(Event.SetupIntentSucceeded, savedCard); + } + + /** + * Detach duplicated card + * @description Will detach duplicated card from Stripe customer + */ + private async detachOrRenewWithDetachDuplicatedCard( + paymentMethodId: string, + cardEntity: Card, + { entity }: ICardDataByFingerprintResult, + sdk: StripeSdk, + ): Promise { + if (!entity) { + throw new BaseException({ + status: 500, + message: messages.getNotFoundMessage('Failed to validate duplicated card. Card'), + }); + } + + /** + * Card properties for renewal card that must be identical + */ + const cardProperties = ['lastDigits', 'brand', 'origin', 'fingerprint', 'funding', 'userId']; + const existingCardPaymentMethodId = CardRepository.extractPaymentMethodId(entity); + + const { year: existingYear, month: existingMonth } = fromExpirationDate(entity.expired); + const { year: updatedYear, month: updatedMonth } = fromExpirationDate(cardEntity.expired); + + /** + * Update renewal card details + * @description Stripe does not create new fingerprint if card was renewal with new expiration date + */ + if ( + entity.expired !== cardEntity.expired && + // All other card details MUST be equal + _.isEqual(_.pick(entity, cardProperties), _.pick(cardEntity, cardProperties)) && + // Check expiration dates + updatedYear >= existingYear && + updatedMonth >= existingMonth + ) { + entity.expired = cardEntity.expired; + + /** + * Update card details and next() detach new duplicated card + */ + await sdk.paymentMethods.update(existingCardPaymentMethodId as string, { + card: { + exp_month: updatedMonth, + exp_year: updatedYear, + }, + }); + + await this.cardRepository.save(entity); + } + + /** + * If customer trying to add identical, not renewal card + * @description Detach duplicated card from Stripe customer + */ + await sdk.paymentMethods.detach(paymentMethodId); + + await Microservice.eventPublish(Event.CardNotCreatedDuplicated, cardEntity); + } +} + +export default SetupIntent; From 34ccbaad7758e0d34ed30db12e227f991342363c Mon Sep 17 00:00:00 2001 From: Oleg Drozdovich Date: Tue, 5 Dec 2023 16:12:09 +0400 Subject: [PATCH 09/14] feat(payment-stripe): clean up --- .../payment-stripe/src/services/webhook-handlers/charge.ts | 4 ++-- .../src/services/webhook-handlers/payment-intent.ts | 4 ++-- .../src/services/webhook-handlers/setup-intent.ts | 7 ++++--- 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/microservices/payment-stripe/src/services/webhook-handlers/charge.ts b/microservices/payment-stripe/src/services/webhook-handlers/charge.ts index b8ac1084..5af7ad38 100644 --- a/microservices/payment-stripe/src/services/webhook-handlers/charge.ts +++ b/microservices/payment-stripe/src/services/webhook-handlers/charge.ts @@ -6,7 +6,7 @@ import StripeDisputeStatus from '@constants/stripe-dispute-status'; import StripeTransactionStatus from '@constants/stripe-transaction-status'; import TransactionStatus from '@constants/transaction-status'; import DisputeEntity from '@entities/dispute'; -import EvidenceDetails from '@entities/evidence-details'; +import EvidenceDetailsEntity from '@entities/evidence-details'; import RefundEntity from '@entities/refund'; import TransactionEntity from '@entities/transaction'; import extractIdFromStripeInstance from '@helpers/extract-id-from-stripe-instance'; @@ -105,7 +105,7 @@ class Charge { await manager.transaction(async (entityManager) => { const disputeRepository = entityManager.getRepository(DisputeEntity); - const evidenceDetailsRepository = entityManager.getRepository(EvidenceDetails); + const evidenceDetailsRepository = entityManager.getRepository(EvidenceDetailsEntity); /** * Get transactions by payment intent id diff --git a/microservices/payment-stripe/src/services/webhook-handlers/payment-intent.ts b/microservices/payment-stripe/src/services/webhook-handlers/payment-intent.ts index 493b8066..137aa2ff 100644 --- a/microservices/payment-stripe/src/services/webhook-handlers/payment-intent.ts +++ b/microservices/payment-stripe/src/services/webhook-handlers/payment-intent.ts @@ -5,7 +5,7 @@ import StripeSdk from 'stripe'; import { EntityManager, Repository } from 'typeorm'; import StripeTransactionStatus from '@constants/stripe-transaction-status'; import TransactionType from '@constants/transaction-type'; -import Card from '@entities/card'; +import CardEntity from '@entities/card'; import TransactionEntity from '@entities/transaction'; import extractIdFromStripeInstance from '@helpers/extract-id-from-stripe-instance'; import messages from '@helpers/validators/messages'; @@ -186,7 +186,7 @@ class PaymentIntent { } = metadata as unknown as IPaymentIntentMetadata; const card = await entityManager - .getRepository(Card) + .getRepository(CardEntity) .createQueryBuilder('card') .where('card.userId = :userId AND card.id = :cardId', { userId: senderId, cardId }) .getOne(); diff --git a/microservices/payment-stripe/src/services/webhook-handlers/setup-intent.ts b/microservices/payment-stripe/src/services/webhook-handlers/setup-intent.ts index 50726e3b..1fbab856 100644 --- a/microservices/payment-stripe/src/services/webhook-handlers/setup-intent.ts +++ b/microservices/payment-stripe/src/services/webhook-handlers/setup-intent.ts @@ -5,13 +5,14 @@ import StripeSdk from 'stripe'; import { EntityManager, Repository } from 'typeorm'; import remoteConfig from '@config/remote'; import StripePaymentMethods from '@constants/stripe-payment-methods'; -import Card from '@entities/card'; +import CardEntity from '@entities/card'; import CustomerEntity from '@entities/customer'; import extractIdFromStripeInstance from '@helpers/extract-id-from-stripe-instance'; import fromExpirationDate from '@helpers/formatters/from-expiration-date'; import toExpirationDate from '@helpers/formatters/to-expiration-date'; import messages from '@helpers/validators/messages'; -import CardRepository, { ICardDataByFingerprintResult } from '@repositories/card'; +import CardRepository from '@repositories/card'; +import type { ICardDataByFingerprintResult } from '@repositories/card'; /** * Setup intent Webhook Handler @@ -161,7 +162,7 @@ class SetupIntent { */ private async detachOrRenewWithDetachDuplicatedCard( paymentMethodId: string, - cardEntity: Card, + cardEntity: CardEntity, { entity }: ICardDataByFingerprintResult, sdk: StripeSdk, ): Promise { From 8c6b062666da8a0a71ad9494d68006faddb37f98 Mon Sep 17 00:00:00 2001 From: Oleg Drozdovich Date: Tue, 5 Dec 2023 16:21:26 +0400 Subject: [PATCH 10/14] feat(payment-stripe): update webhook handlers and repositories --- .../src/repositories/customer.ts | 32 +++++++++ .../src/services/payment-gateway/stripe.ts | 65 ++---------------- .../src/services/webhook-handlers/account.ts | 66 +++++++++++++++++++ .../src/services/webhook-handlers/index.ts | 7 ++ 4 files changed, 111 insertions(+), 59 deletions(-) create mode 100644 microservices/payment-stripe/src/repositories/customer.ts create mode 100644 microservices/payment-stripe/src/services/webhook-handlers/account.ts diff --git a/microservices/payment-stripe/src/repositories/customer.ts b/microservices/payment-stripe/src/repositories/customer.ts new file mode 100644 index 00000000..5f722a4a --- /dev/null +++ b/microservices/payment-stripe/src/repositories/customer.ts @@ -0,0 +1,32 @@ +import { BaseException } from '@lomray/microservice-nodejs-lib'; +import { EntityManager, EntityRepository, Repository } from 'typeorm'; +import CustomerEntity from '@entities/customer'; +import messages from '@helpers/validators/messages'; + +@EntityRepository(CustomerEntity) +class Customer extends Repository { + /** + * Returns customer by account id + */ + public static async getCustomerByAccountId( + accountId: string, + manager: EntityManager, + ): Promise { + const customer = await manager + .getRepository(CustomerEntity) + .createQueryBuilder('customer') + .where("customer.params ->> 'accountId' = :accountId", { accountId }) + .getOne(); + + if (!customer) { + throw new BaseException({ + status: 500, + message: messages.getNotFoundMessage('Customer'), + }); + } + + return customer; + } +} + +export default Customer; diff --git a/microservices/payment-stripe/src/services/payment-gateway/stripe.ts b/microservices/payment-stripe/src/services/payment-gateway/stripe.ts index 986f33e0..2b835b38 100644 --- a/microservices/payment-stripe/src/services/payment-gateway/stripe.ts +++ b/microservices/payment-stripe/src/services/payment-gateway/stripe.ts @@ -33,6 +33,7 @@ import TCurrency from '@interfaces/currency'; import IFees from '@interfaces/fees'; import type ITax from '@interfaces/tax'; import CardRepository from '@repositories/card'; +import CustomerRepository from '@repositories/customer'; import Calculation from '@services/calculation'; import Parser from '@services/parser'; import WebhookHandlers from '@services/webhook-handlers'; @@ -606,7 +607,7 @@ class Stripe extends Abstract { */ case 'account.updated': const accountUpdatedHandlers = { - connect: this.handleAccountUpdated(event), + connect: webhookHandlers.account.handleAccountUpdated(event), }; await accountUpdatedHandlers?.[webhookType]; @@ -987,8 +988,9 @@ class Stripe extends Abstract { }); } - const { userId, params } = await this.getCustomerByAccountId( + const { userId, params } = await CustomerRepository.getCustomerByAccountId( this.extractId(externalAccount.account), + this.manager, ); if (!this.isExternalAccountIsBankAccount(externalAccount)) { @@ -1113,42 +1115,6 @@ class Stripe extends Abstract { await this.bankAccountRepository.remove(bankAccount); } - /** - * Handles customer update - * @description Connect account event - */ - public async handleAccountUpdated(event: StripeSdk.Event) { - /* eslint-disable camelcase */ - const { - id, - payouts_enabled: isPayoutEnabled, - charges_enabled: isChargesEnabled, - capabilities, - business_profile, - } = event.data.object as StripeSdk.Account; - - const customer = await this.getCustomerByAccountId(id); - - if (!customer) { - throw new BaseException({ - status: 404, - message: messages.getNotFoundMessage('Customer'), - }); - } - - /** - * Check if customer can accept payment - * @description Check if user correctly and verify setup connect account - */ - customer.params.isPayoutEnabled = isPayoutEnabled; - customer.params.transferCapabilityStatus = capabilities?.transfers || 'inactive'; - customer.params.isVerified = isChargesEnabled && capabilities?.transfers === 'active'; - customer.params.accountSupportPhoneNumber = business_profile?.support_phone; - - await this.customerRepository.save(customer); - /* eslint-enable camelcase */ - } - /** * Handles completing of transaction inside stripe payment process */ @@ -2146,28 +2112,9 @@ class Stripe extends Abstract { return card; } - /** - * Returns customer by account id - */ - private async getCustomerByAccountId(accountId: string): Promise { - const customer = await this.customerRepository - .createQueryBuilder('customer') - .where("customer.params ->> 'accountId' = :accountId", { accountId }) - .getOne(); - - if (!customer) { - throw new BaseException({ - status: 500, - message: messages.getNotFoundMessage('Customer'), - }); - } - - return customer; - } - /** * Returns card by card id - * NOTE: Uses to search related connect account (external account) data + * @description Uses to search related connect account (external account) data */ private getCardById(cardId: string): Promise { return this.cardRepository @@ -2178,7 +2125,7 @@ class Stripe extends Abstract { /** * Returns bank account by bank account id - * NOTE: Uses to search related connect account (external account) data + * @description Uses to search related connect account (external account) data */ private getBankAccountById(bankAccountId: string): Promise { return this.bankAccountRepository diff --git a/microservices/payment-stripe/src/services/webhook-handlers/account.ts b/microservices/payment-stripe/src/services/webhook-handlers/account.ts new file mode 100644 index 00000000..388515be --- /dev/null +++ b/microservices/payment-stripe/src/services/webhook-handlers/account.ts @@ -0,0 +1,66 @@ +import { BaseException } from '@lomray/microservice-nodejs-lib'; +import StripeSdk from 'stripe'; +import { EntityManager } from 'typeorm'; +import messages from '@helpers/validators/messages'; +import CustomerRepository from '@repositories/customer'; + +/** + * Account webhook handlers + */ +class Account { + /** + * @private + */ + private readonly manager: EntityManager; + + /** + * @private + */ + private readonly customerRepository: CustomerRepository; + + /** + * @constructor + */ + public constructor(manager: EntityManager) { + this.manager = manager; + this.customerRepository = manager.getCustomRepository(CustomerRepository); + } + + /** + * Handles customer update + * @description Connect account event + */ + public async handleAccountUpdated(event: StripeSdk.Event) { + /* eslint-disable camelcase */ + const { + id, + payouts_enabled: isPayoutEnabled, + charges_enabled: isChargesEnabled, + capabilities, + business_profile, + } = event.data.object as StripeSdk.Account; + + const customer = await CustomerRepository.getCustomerByAccountId(id, this.manager); + + if (!customer) { + throw new BaseException({ + status: 404, + message: messages.getNotFoundMessage('Customer'), + }); + } + + /** + * Check if customer can accept payment + * @description Check if user correctly and verify setup connect account + */ + customer.params.isPayoutEnabled = isPayoutEnabled; + customer.params.transferCapabilityStatus = capabilities?.transfers || 'inactive'; + customer.params.isVerified = isChargesEnabled && capabilities?.transfers === 'active'; + customer.params.accountSupportPhoneNumber = business_profile?.support_phone; + + await this.customerRepository.save(customer); + /* eslint-enable camelcase */ + } +} + +export default Account; diff --git a/microservices/payment-stripe/src/services/webhook-handlers/index.ts b/microservices/payment-stripe/src/services/webhook-handlers/index.ts index a400a7da..20dc3a8b 100644 --- a/microservices/payment-stripe/src/services/webhook-handlers/index.ts +++ b/microservices/payment-stripe/src/services/webhook-handlers/index.ts @@ -1,4 +1,5 @@ import { EntityManager } from 'typeorm'; +import Account from './account'; import ApplicationFee from './application-fee'; import Charge from './charge'; import Customer from './customer'; @@ -48,6 +49,11 @@ class WebhookHandlers { */ public readonly setupIntent: SetupIntent; + /** + * @public + */ + public readonly account: Account; + /** * @constructor */ @@ -59,6 +65,7 @@ class WebhookHandlers { this.transfer = new Transfer(manager); this.paymentMethod = new PaymentMethod(manager); this.setupIntent = new SetupIntent(manager); + this.account = new Account(manager); } /** From ac7e584c2b36648066bff2107f63c054e30be163 Mon Sep 17 00:00:00 2001 From: Oleg Drozdovich Date: Tue, 5 Dec 2023 16:26:32 +0400 Subject: [PATCH 11/14] fix(payment-stripe): sonar warnings --- .../src/services/payment-gateway/stripe.ts | 56 ++++++++++++------- 1 file changed, 37 insertions(+), 19 deletions(-) diff --git a/microservices/payment-stripe/src/services/payment-gateway/stripe.ts b/microservices/payment-stripe/src/services/payment-gateway/stripe.ts index 2b835b38..17e4065e 100644 --- a/microservices/payment-stripe/src/services/payment-gateway/stripe.ts +++ b/microservices/payment-stripe/src/services/payment-gateway/stripe.ts @@ -184,7 +184,7 @@ class Stripe extends Abstract { * Add new card * @description NOTES: * 1. Usage example - only in integration tests - * 2. Use setup intent for livemode + * 2. Use setup intent for live-mode * 3. For creating card manually with the sensitive data such as digits, cvc. Platform * account must be eligible for PCI (Payment Card Industry Data Security Standards) */ @@ -598,109 +598,120 @@ class Stripe extends Abstract { /** * Checkout session events */ - case 'checkout.session.completed': + case 'checkout.session.completed': { await this.handleTransactionCompleted(event); break; + } /** * Account events */ - case 'account.updated': + case 'account.updated': { const accountUpdatedHandlers = { connect: webhookHandlers.account.handleAccountUpdated(event), }; await accountUpdatedHandlers?.[webhookType]; break; + } - case 'account.external_account.created': + case 'account.external_account.created': { const accountExternalAccountCreatedHandlers = { connect: this.handleExternalAccountCreated(event), }; await accountExternalAccountCreatedHandlers?.[webhookType]; break; + } - case 'account.external_account.updated': + case 'account.external_account.updated': { const accountExternalAccountUpdatedHandlers = { connect: this.handleExternalAccountUpdated(event), }; await accountExternalAccountUpdatedHandlers?.[webhookType]; break; + } - case 'account.external_account.deleted': + case 'account.external_account.deleted': { const accountExternalAccountDeletedHandlers = { connect: this.handleExternalAccountDeleted(event), }; await accountExternalAccountDeletedHandlers?.[webhookType]; break; + } /** * Payment method events */ - case 'setup_intent.succeeded': + case 'setup_intent.succeeded': { const setupIntentSucceededHandlers = { account: webhookHandlers.setupIntent.handleSetupIntentSucceed(event, this.sdk), }; await setupIntentSucceededHandlers?.[webhookType]; break; + } case 'payment_method.updated': - case 'payment_method.automatically_updated': + case 'payment_method.automatically_updated': { const paymentMethodUpdatedAutomaticallyUpdatedHandlers = { account: webhookHandlers.paymentMethod.handlePaymentMethodUpdated(event), }; await paymentMethodUpdatedAutomaticallyUpdatedHandlers?.[webhookType]; break; + } - case 'payment_method.detached': + case 'payment_method.detached': { const paymentMethodDetachedHandlers = { account: webhookHandlers.paymentMethod.handlePaymentMethodDetached(event), }; await paymentMethodDetachedHandlers?.[webhookType]; break; + } /** * Transfer events */ - case 'transfer.reversed': + case 'transfer.reversed': { const transferReversedHandlers = { account: webhookHandlers.transfer.transferReversed(event), }; await transferReversedHandlers?.[webhookType]; break; + } /** * Payment intent events */ case 'payment_intent.processing': case 'payment_intent.succeeded': - case 'payment_intent.canceled': + case 'payment_intent.canceled': { const paymentIntentProcessingSucceededCanceledHandlers = { account: webhookHandlers.paymentIntent.handlePaymentIntent(event, this.sdk), }; await paymentIntentProcessingSucceededCanceledHandlers?.[webhookType]; break; + } - case 'payment_intent.payment_failed': + case 'payment_intent.payment_failed': { const paymentIntentPaymentFailedHandlers = { account: webhookHandlers.paymentIntent.handlePaymentIntentPaymentFailed(event, this.sdk), }; await paymentIntentPaymentFailedHandlers?.[webhookType]; break; + } /** * Application fee events */ - case 'application_fee.refund.updated': + case 'application_fee.refund.updated': { const applicationFeeRefundUpdatedHandlers = { account: webhookHandlers.applicationFee.handleApplicationFeeRefundUpdated( event, @@ -710,61 +721,68 @@ class Stripe extends Abstract { await applicationFeeRefundUpdatedHandlers?.[webhookType]; break; + } - case 'application_fee.refunded': + case 'application_fee.refunded': { const applicationFeeRefundedHandlers = { account: webhookHandlers.applicationFee.handleApplicationFeeRefunded(event), }; await applicationFeeRefundedHandlers?.[webhookType]; break; + } /** * Refund events */ - case 'charge.refund.updated': + case 'charge.refund.updated': { const chargeRefundUpdatedHandlers = { account: webhookHandlers.charge.handleRefundUpdated(event, this.manager), }; await chargeRefundUpdatedHandlers?.[webhookType]; break; + } /** * Charge events */ - case 'charge.refunded': + case 'charge.refunded': { const chargeRefundedHandlers = { account: webhookHandlers.charge.handleChargeRefunded(event, this.manager), }; await chargeRefundedHandlers?.[webhookType]; break; + } - case 'charge.dispute.created': + case 'charge.dispute.created': { const chargeDisputeCreatedHandlers = { account: webhookHandlers.charge.handleChargeDisputeCreated(event, this.manager), }; await chargeDisputeCreatedHandlers?.[webhookType]; break; + } case 'charge.dispute.updated': case 'charge.dispute.closed': - case 'charge.dispute.funds_reinstated': + case 'charge.dispute.funds_reinstated': { const chargeDisputeUpdatedClosedFundsReinstatedHandlers = { account: webhookHandlers.charge.handleChargeDisputeUpdated(event, this.manager), }; await chargeDisputeUpdatedClosedFundsReinstatedHandlers?.[webhookType]; break; + } /** * Customer events */ - case 'customer.updated': + case 'customer.updated': { await webhookHandlers.customer.handleCustomerUpdated(event, this.manager); break; + } } } From cfd127c72c3abb379f12b058c379c39db525a49f Mon Sep 17 00:00:00 2001 From: Oleg Drozdovich Date: Tue, 5 Dec 2023 18:43:04 +0400 Subject: [PATCH 12/14] feat(payment-stripe): update refund and transaction statuses --- ...701769135839-transaction-dispute-status.ts | 19 ----------- .../src/constants/refund-status.ts | 10 ++++++ .../src/constants/stripe-refund-status.ts | 9 +++++ .../constants/transaction-dispute-status.ts | 4 +-- .../src/constants/transaction-status.ts | 1 + .../payment-stripe/src/entities/refund.ts | 8 ++--- .../payment-stripe/src/services/dispute.ts | 6 +++- .../payment-stripe/src/services/parser.ts | 33 +++++++++++++++++-- .../src/services/payment-gateway/stripe.ts | 8 +++-- .../src/services/webhook-handlers/charge.ts | 4 +-- 10 files changed, 68 insertions(+), 34 deletions(-) delete mode 100644 microservices/payment-stripe/migrations/1701769135839-transaction-dispute-status.ts create mode 100644 microservices/payment-stripe/src/constants/refund-status.ts create mode 100644 microservices/payment-stripe/src/constants/stripe-refund-status.ts diff --git a/microservices/payment-stripe/migrations/1701769135839-transaction-dispute-status.ts b/microservices/payment-stripe/migrations/1701769135839-transaction-dispute-status.ts deleted file mode 100644 index e65a027d..00000000 --- a/microservices/payment-stripe/migrations/1701769135839-transaction-dispute-status.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { MigrationInterface, QueryRunner } from 'typeorm'; - -export default class transactionDisputeStatus1701769135839 implements MigrationInterface { - name = 'transactionDisputeStatus1701769135839'; - - public async up(queryRunner: QueryRunner): Promise { - await queryRunner.query( - `CREATE TYPE "public"."transaction_disputestatus_enum" AS ENUM('notDisputed', 'disputed', 'disputeClosed')`, - ); - await queryRunner.query( - `ALTER TABLE "transaction" ADD "disputeStatus" "public"."transaction_disputestatus_enum" NOT NULL DEFAULT 'notDisputed'`, - ); - } - - public async down(queryRunner: QueryRunner): Promise { - await queryRunner.query(`ALTER TABLE "transaction" DROP COLUMN "disputeStatus"`); - await queryRunner.query(`DROP TYPE "public"."transaction_disputestatus_enum"`); - } -} diff --git a/microservices/payment-stripe/src/constants/refund-status.ts b/microservices/payment-stripe/src/constants/refund-status.ts new file mode 100644 index 00000000..a9167af2 --- /dev/null +++ b/microservices/payment-stripe/src/constants/refund-status.ts @@ -0,0 +1,10 @@ +enum RefundStatus { + INITIAL = 'initial', + IN_PROCESS = 'inProcess', + REQUIRES_ACTION = 'requiresAction', + SUCCESS = 'success', + ERROR = 'error', + CANCELED = 'canceled', +} + +export default RefundStatus; diff --git a/microservices/payment-stripe/src/constants/stripe-refund-status.ts b/microservices/payment-stripe/src/constants/stripe-refund-status.ts new file mode 100644 index 00000000..0cd22336 --- /dev/null +++ b/microservices/payment-stripe/src/constants/stripe-refund-status.ts @@ -0,0 +1,9 @@ +enum StripeRefundStatus { + PENDING = 'pending', + REQUIRES_ACTION = 'requires_action', + SUCCEEDED = 'succeeded', + FAILED = 'failed', + CANCELED = 'canceled', +} + +export default StripeRefundStatus; diff --git a/microservices/payment-stripe/src/constants/transaction-dispute-status.ts b/microservices/payment-stripe/src/constants/transaction-dispute-status.ts index ab8456e3..68c1acbe 100644 --- a/microservices/payment-stripe/src/constants/transaction-dispute-status.ts +++ b/microservices/payment-stripe/src/constants/transaction-dispute-status.ts @@ -1,7 +1,7 @@ enum TransactionDisputeStatus { NOT_DISPUTED = 'notDisputed', - DISPUTED = 'disputed', - DISPUTE_CLOSED = 'disputeClosed', + OPEN = 'open', + CLOSED = 'closed', } export default TransactionDisputeStatus; diff --git a/microservices/payment-stripe/src/constants/transaction-status.ts b/microservices/payment-stripe/src/constants/transaction-status.ts index c794b269..2e69fcd6 100644 --- a/microservices/payment-stripe/src/constants/transaction-status.ts +++ b/microservices/payment-stripe/src/constants/transaction-status.ts @@ -16,6 +16,7 @@ enum TransactionStatus { REFUND_CANCELED = 'refundCanceled', REFUND_IN_PROCESS = 'refundInProcess', ERROR = 'error', + DISPUTED = 'disputed', } export default TransactionStatus; diff --git a/microservices/payment-stripe/src/entities/refund.ts b/microservices/payment-stripe/src/entities/refund.ts index 32b465e6..a40a5dc5 100644 --- a/microservices/payment-stripe/src/entities/refund.ts +++ b/microservices/payment-stripe/src/entities/refund.ts @@ -9,6 +9,7 @@ import { UpdateDateColumn, } from 'typeorm'; import type RefundAmountType from '@constants/refund-amount-type'; +import RefundStatus from '@constants/refund-status'; import TransactionStatus from '@constants/transaction-status'; // eslint-disable-next-line @typescript-eslint/no-empty-interface @@ -72,13 +73,10 @@ class Refund { @IsUndefinable() params: IParams; - @JSONSchema({ - description: 'Status should be started with the refund prefix', - }) - @Column({ type: 'enum', enum: TransactionStatus, default: TransactionStatus.INITIAL }) + @Column({ type: 'enum', enum: RefundStatus, default: RefundStatus.INITIAL }) @IsEnum(TransactionStatus) @IsUndefinable() - status: TransactionStatus; + status: RefundStatus; @IsTypeormDate() @CreateDateColumn() diff --git a/microservices/payment-stripe/src/services/dispute.ts b/microservices/payment-stripe/src/services/dispute.ts index 715b8683..2d064350 100644 --- a/microservices/payment-stripe/src/services/dispute.ts +++ b/microservices/payment-stripe/src/services/dispute.ts @@ -6,6 +6,7 @@ import { EntityManager } from 'typeorm'; import DisputeStatus from '@constants/dispute-status'; import StripeDisputeReason from '@constants/stripe-dispute-reason'; import StripeDisputeStatus from '@constants/stripe-dispute-status'; +import TransactionStatus from '@constants/transaction-status'; import DisputeEntity from '@entities/dispute'; import TransactionEntity from '@entities/transaction'; import Parser from '@services/parser'; @@ -133,10 +134,13 @@ class Dispute { Parser.parseStripeDisputeStatusToTransactionDisputeStatus(disputeStatus); transactions.forEach((transaction) => { - if (transaction.disputeStatus === transactionDisputeStatus) { + const isDisputeStatusNotChanged = transaction.disputeStatus === transactionDisputeStatus; + + if (isDisputeStatusNotChanged && transaction.status === TransactionStatus.DISPUTED) { return; } + transaction.status = TransactionStatus.DISPUTED; transaction.disputeStatus = transactionDisputeStatus; isUpdated = true; }); diff --git a/microservices/payment-stripe/src/services/parser.ts b/microservices/payment-stripe/src/services/parser.ts index adae7fbd..a47b4fe3 100644 --- a/microservices/payment-stripe/src/services/parser.ts +++ b/microservices/payment-stripe/src/services/parser.ts @@ -1,8 +1,10 @@ import { Log } from '@lomray/microservice-helpers'; import DisputeReason from '@constants/dispute-reason'; import DisputeStatus from '@constants/dispute-status'; +import RefundStatus from '@constants/refund-status'; import StripeDisputeReason from '@constants/stripe-dispute-reason'; import StripeDisputeStatus from '@constants/stripe-dispute-status'; +import StripeRefundStatus from '@constants/stripe-refund-status'; import StripeTransactionStatus from '@constants/stripe-transaction-status'; import TransactionDisputeStatus from '@constants/transaction-dispute-status'; import TransactionStatus from '@constants/transaction-status'; @@ -15,6 +17,33 @@ declare function assert(status: never): never; * Decomposed payment stripe logic */ class Parser { + /** + * Parse Stripe refund status + */ + public static parseStripeRefundStatus(stripeStatus: StripeRefundStatus): RefundStatus { + switch (stripeStatus) { + case StripeRefundStatus.SUCCEEDED: + return RefundStatus.SUCCESS; + + case StripeRefundStatus.PENDING: + return RefundStatus.IN_PROCESS; + + case StripeRefundStatus.FAILED: + return RefundStatus.ERROR; + + case StripeRefundStatus.CANCELED: + return RefundStatus.CANCELED; + + case StripeRefundStatus.REQUIRES_ACTION: + return RefundStatus.REQUIRES_ACTION; + + default: + Log.error(`Unknown Stripe refund status: ${stripeStatus as string}`); + + assert(stripeStatus); + } + } + /** * Parse Stripe dispute status */ @@ -28,10 +57,10 @@ class Parser { switch (stripeStatus) { case DisputeStatus.LOST: case DisputeStatus.WON: - return TransactionDisputeStatus.DISPUTE_CLOSED; + return TransactionDisputeStatus.CLOSED; default: - return TransactionDisputeStatus.DISPUTED; + return TransactionDisputeStatus.OPEN; } } diff --git a/microservices/payment-stripe/src/services/payment-gateway/stripe.ts b/microservices/payment-stripe/src/services/payment-gateway/stripe.ts index 17e4065e..c305f021 100644 --- a/microservices/payment-stripe/src/services/payment-gateway/stripe.ts +++ b/microservices/payment-stripe/src/services/payment-gateway/stripe.ts @@ -8,9 +8,11 @@ import BalanceType from '@constants/balance-type'; import BusinessType from '@constants/business-type'; import CouponDuration from '@constants/coupon-duration'; import RefundAmountType from '@constants/refund-amount-type'; +import RefundStatus from '@constants/refund-status'; import StripeAccountTypes from '@constants/stripe-account-types'; import StripeCheckoutStatus from '@constants/stripe-checkout-status'; import StripePaymentMethods from '@constants/stripe-payment-methods'; +import StripeRefundStatus from '@constants/stripe-refund-status'; import StripeTransactionStatus from '@constants/stripe-transaction-status'; import TransactionRole from '@constants/transaction-role'; import TransactionStatus from '@constants/transaction-status'; @@ -182,7 +184,7 @@ interface ICreateMultipleProductCheckoutParams { class Stripe extends Abstract { /** * Add new card - * @description NOTES: + * @description Definitions: * 1. Usage example - only in integration tests * 2. Use setup intent for live-mode * 3. For creating card manually with the sensitive data such as digits, cvc. Platform @@ -1673,8 +1675,8 @@ class Stripe extends Abstract { transactionId, amount: stripeRefund.amount, status: stripeRefund.status - ? Parser.parseStripeTransactionStatus(stripeRefund.status as StripeTransactionStatus) - : TransactionStatus.INITIAL, + ? Parser.parseStripeRefundStatus(stripeRefund.status as StripeRefundStatus) + : RefundStatus.INITIAL, ...(entityId ? { entityId } : {}), params: { refundId: stripeRefund.id, diff --git a/microservices/payment-stripe/src/services/webhook-handlers/charge.ts b/microservices/payment-stripe/src/services/webhook-handlers/charge.ts index 5af7ad38..85333dac 100644 --- a/microservices/payment-stripe/src/services/webhook-handlers/charge.ts +++ b/microservices/payment-stripe/src/services/webhook-handlers/charge.ts @@ -3,7 +3,7 @@ import StripeSdk from 'stripe'; import { EntityManager } from 'typeorm'; import StripeDisputeReason from '@constants/stripe-dispute-reason'; import StripeDisputeStatus from '@constants/stripe-dispute-status'; -import StripeTransactionStatus from '@constants/stripe-transaction-status'; +import StripeRefundStatus from '@constants/stripe-refund-status'; import TransactionStatus from '@constants/transaction-status'; import DisputeEntity from '@entities/dispute'; import EvidenceDetailsEntity from '@entities/evidence-details'; @@ -223,7 +223,7 @@ class Charge { }); } - const refundStatus = Parser.parseStripeTransactionStatus(status as StripeTransactionStatus); + const refundStatus = Parser.parseStripeRefundStatus(status as StripeRefundStatus); if (!refundStatus) { throw new BaseException({ From 868012a2b36acd1ba397bec0b09b9c9c017c879c Mon Sep 17 00:00:00 2001 From: Oleg Drozdovich Date: Tue, 5 Dec 2023 19:22:44 +0400 Subject: [PATCH 13/14] feat(payment-stripe): update migration --- ...1787694561-transaction-dispute-relation.ts | 59 +++++++++++++++++++ .../constants/transaction-dispute-status.ts | 7 --- .../src/services/calculation.ts | 2 +- .../payment-stripe/src/services/dispute.ts | 16 +---- .../payment-stripe/src/services/parser.ts | 21 ------- 5 files changed, 63 insertions(+), 42 deletions(-) create mode 100644 microservices/payment-stripe/migrations/1701787694561-transaction-dispute-relation.ts delete mode 100644 microservices/payment-stripe/src/constants/transaction-dispute-status.ts diff --git a/microservices/payment-stripe/migrations/1701787694561-transaction-dispute-relation.ts b/microservices/payment-stripe/migrations/1701787694561-transaction-dispute-relation.ts new file mode 100644 index 00000000..835ab10c --- /dev/null +++ b/microservices/payment-stripe/migrations/1701787694561-transaction-dispute-relation.ts @@ -0,0 +1,59 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export default class transactionDisputeRelation1701787694561 implements MigrationInterface { + name = 'transactionDisputeRelation1701787694561'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TYPE "public"."transaction_status_enum" RENAME TO "transaction_status_enum_old"`, + ); + await queryRunner.query( + `CREATE TYPE "public"."transaction_status_enum" AS ENUM('success', 'inProcess', 'requiredPayment', 'initial', 'expired', 'refunded', 'partialRefunded', 'refundFailed', 'refundCanceled', 'refundInProcess', 'error', 'disputed')`, + ); + await queryRunner.query(`ALTER TABLE "transaction" ALTER COLUMN "status" DROP DEFAULT`); + await queryRunner.query( + `ALTER TABLE "transaction" ALTER COLUMN "status" TYPE "public"."transaction_status_enum" USING "status"::"text"::"public"."transaction_status_enum"`, + ); + await queryRunner.query( + `ALTER TABLE "transaction" ALTER COLUMN "status" SET DEFAULT 'initial'`, + ); + await queryRunner.query( + `CREATE TYPE "public"."refund_status_enum" AS ENUM('initial', 'inProcess', 'requiresAction', 'success', 'error', 'canceled')`, + ); + await queryRunner.query(`ALTER TABLE "refund" ALTER COLUMN "status" DROP DEFAULT`); + await queryRunner.query( + `ALTER TABLE "refund" ALTER COLUMN "status" TYPE "public"."refund_status_enum" USING "status"::"text"::"public"."refund_status_enum"`, + ); + await queryRunner.query(`ALTER TABLE "refund" ALTER COLUMN "status" SET DEFAULT 'initial'`); + await queryRunner.query(`DROP TYPE "public"."transaction_status_enum_old"`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TYPE "public"."transaction_status_enum_old" AS ENUM('success', 'inProcess', 'requiredPayment', 'initial', 'expired', 'error', 'refunded', 'refundFailed', 'refundCanceled', 'refundInProcess', 'partialRefunded')`, + ); + await queryRunner.query(`ALTER TABLE "refund" ALTER COLUMN "status" DROP DEFAULT`); + await queryRunner.query( + `ALTER TABLE "refund" ALTER COLUMN "status" TYPE "public"."transaction_status_enum_old" USING "status"::"text"::"public"."transaction_status_enum_old"`, + ); + await queryRunner.query(`ALTER TABLE "refund" ALTER COLUMN "status" SET DEFAULT 'initial'`); + await queryRunner.query(`DROP TYPE "public"."refund_status_enum"`); + await queryRunner.query( + `ALTER TYPE "public"."transaction_status_enum_old" RENAME TO "transaction_status_enum"`, + ); + await queryRunner.query( + `CREATE TYPE "public"."transaction_status_enum_old" AS ENUM('success', 'inProcess', 'requiredPayment', 'initial', 'expired', 'error', 'refunded', 'refundFailed', 'refundCanceled', 'refundInProcess', 'partialRefunded')`, + ); + await queryRunner.query(`ALTER TABLE "transaction" ALTER COLUMN "status" DROP DEFAULT`); + await queryRunner.query( + `ALTER TABLE "transaction" ALTER COLUMN "status" TYPE "public"."transaction_status_enum_old" USING "status"::"text"::"public"."transaction_status_enum_old"`, + ); + await queryRunner.query( + `ALTER TABLE "transaction" ALTER COLUMN "status" SET DEFAULT 'initial'`, + ); + await queryRunner.query(`DROP TYPE "public"."transaction_status_enum"`); + await queryRunner.query( + `ALTER TYPE "public"."transaction_status_enum_old" RENAME TO "transaction_status_enum"`, + ); + } +} diff --git a/microservices/payment-stripe/src/constants/transaction-dispute-status.ts b/microservices/payment-stripe/src/constants/transaction-dispute-status.ts deleted file mode 100644 index 68c1acbe..00000000 --- a/microservices/payment-stripe/src/constants/transaction-dispute-status.ts +++ /dev/null @@ -1,7 +0,0 @@ -enum TransactionDisputeStatus { - NOT_DISPUTED = 'notDisputed', - OPEN = 'open', - CLOSED = 'closed', -} - -export default TransactionDisputeStatus; diff --git a/microservices/payment-stripe/src/services/calculation.ts b/microservices/payment-stripe/src/services/calculation.ts index 998658a2..429fe2a7 100644 --- a/microservices/payment-stripe/src/services/calculation.ts +++ b/microservices/payment-stripe/src/services/calculation.ts @@ -5,7 +5,7 @@ import StripePaymentMethods from '@constants/stripe-payment-methods'; import TaxBehaviour from '@constants/tax-behaviour'; import TransactionRole from '@constants/transaction-role'; import getPercentFromAmount from '@helpers/get-percent-from-amount'; -import IFees from '@interfaces/fees'; +import type IFees from '@interfaces/fees'; import type ITax from '@interfaces/tax'; import type ITaxes from '@interfaces/taxes'; diff --git a/microservices/payment-stripe/src/services/dispute.ts b/microservices/payment-stripe/src/services/dispute.ts index 2d064350..8326a2ba 100644 --- a/microservices/payment-stripe/src/services/dispute.ts +++ b/microservices/payment-stripe/src/services/dispute.ts @@ -58,11 +58,7 @@ class Dispute { disputeEntity.netWorth = netWorth; await manager.getRepository(DisputeEntity).save(disputeEntity); - await Dispute.updateTransactionsDisputeStatus( - manager, - disputeEntity.transactionId, - disputeStatus, - ); + await Dispute.updateTransactionsDisputeStatus(manager, disputeEntity.transactionId); } /** @@ -91,7 +87,7 @@ class Dispute { manager: EntityManager, ): Promise { await Promise.all([ - Dispute.updateTransactionsDisputeStatus(manager, entity.transactionId, entity.status), + Dispute.updateTransactionsDisputeStatus(manager, entity.transactionId), Microservice.eventPublish(Event.DisputeCreated, entity), ]); } @@ -109,7 +105,6 @@ class Dispute { private static async updateTransactionsDisputeStatus( manager: EntityManager, transactionId?: string | null, - disputeStatus?: DisputeStatus | null, ): Promise { if (!transactionId) { return; @@ -130,18 +125,13 @@ class Dispute { } let isUpdated = false; - const transactionDisputeStatus = - Parser.parseStripeDisputeStatusToTransactionDisputeStatus(disputeStatus); transactions.forEach((transaction) => { - const isDisputeStatusNotChanged = transaction.disputeStatus === transactionDisputeStatus; - - if (isDisputeStatusNotChanged && transaction.status === TransactionStatus.DISPUTED) { + if (transaction.status === TransactionStatus.DISPUTED) { return; } transaction.status = TransactionStatus.DISPUTED; - transaction.disputeStatus = transactionDisputeStatus; isUpdated = true; }); diff --git a/microservices/payment-stripe/src/services/parser.ts b/microservices/payment-stripe/src/services/parser.ts index a47b4fe3..0943724b 100644 --- a/microservices/payment-stripe/src/services/parser.ts +++ b/microservices/payment-stripe/src/services/parser.ts @@ -6,7 +6,6 @@ import StripeDisputeReason from '@constants/stripe-dispute-reason'; import StripeDisputeStatus from '@constants/stripe-dispute-status'; import StripeRefundStatus from '@constants/stripe-refund-status'; import StripeTransactionStatus from '@constants/stripe-transaction-status'; -import TransactionDisputeStatus from '@constants/transaction-dispute-status'; import TransactionStatus from '@constants/transaction-status'; declare function assert(status: never): never; @@ -44,26 +43,6 @@ class Parser { } } - /** - * Parse Stripe dispute status - */ - public static parseStripeDisputeStatusToTransactionDisputeStatus( - stripeStatus?: DisputeStatus | null, - ): TransactionDisputeStatus { - if (!stripeStatus) { - return TransactionDisputeStatus.NOT_DISPUTED; - } - - switch (stripeStatus) { - case DisputeStatus.LOST: - case DisputeStatus.WON: - return TransactionDisputeStatus.CLOSED; - - default: - return TransactionDisputeStatus.OPEN; - } - } - /** * Parse Stripe dispute status */ From 1fe1c65e73e4307ff799a1327ec71ab3c21d114c Mon Sep 17 00:00:00 2001 From: Oleg Drozdovich Date: Tue, 5 Dec 2023 20:01:34 +0400 Subject: [PATCH 14/14] feat(payment-stripe): update transaction dispute workflow --- ...91863603-transaction-charge-and-dispute.ts | 51 +++++++++++++++++++ .../src/constants/charge-refund-status.ts | 8 +++ .../src/constants/transaction-status.ts | 1 - .../src/entities/transaction.ts | 27 ++++++---- .../payment-stripe/src/services/dispute.ts | 5 +- .../src/services/transaction.ts | 49 ++++++++++++++++++ 6 files changed, 126 insertions(+), 15 deletions(-) create mode 100644 microservices/payment-stripe/migrations/1701791863603-transaction-charge-and-dispute.ts create mode 100644 microservices/payment-stripe/src/constants/charge-refund-status.ts diff --git a/microservices/payment-stripe/migrations/1701791863603-transaction-charge-and-dispute.ts b/microservices/payment-stripe/migrations/1701791863603-transaction-charge-and-dispute.ts new file mode 100644 index 00000000..ca77e18f --- /dev/null +++ b/microservices/payment-stripe/migrations/1701791863603-transaction-charge-and-dispute.ts @@ -0,0 +1,51 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export default class transactionChargeAndDispute1701791863603 implements MigrationInterface { + name = 'transactionChargeAndDispute1701791863603'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TYPE "public"."transaction_chargerefundstatus_enum" AS ENUM('noRefund', 'partialRefund', 'fullRefund')`, + ); + await queryRunner.query( + `ALTER TABLE "transaction" ADD "chargeRefundStatus" "public"."transaction_chargerefundstatus_enum" NOT NULL DEFAULT 'noRefund'`, + ); + await queryRunner.query( + `ALTER TABLE "transaction" ADD "isDisputed" boolean NOT NULL DEFAULT false`, + ); + await queryRunner.query( + `ALTER TYPE "public"."transaction_status_enum" RENAME TO "transaction_status_enum_old"`, + ); + await queryRunner.query( + `CREATE TYPE "public"."transaction_status_enum" AS ENUM('success', 'inProcess', 'requiredPayment', 'initial', 'expired', 'refunded', 'partialRefunded', 'refundFailed', 'refundCanceled', 'refundInProcess', 'error')`, + ); + await queryRunner.query(`ALTER TABLE "transaction" ALTER COLUMN "status" DROP DEFAULT`); + await queryRunner.query( + `ALTER TABLE "transaction" ALTER COLUMN "status" TYPE "public"."transaction_status_enum" USING "status"::"text"::"public"."transaction_status_enum"`, + ); + await queryRunner.query( + `ALTER TABLE "transaction" ALTER COLUMN "status" SET DEFAULT 'initial'`, + ); + await queryRunner.query(`DROP TYPE "public"."transaction_status_enum_old"`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TYPE "public"."transaction_status_enum_old" AS ENUM('success', 'inProcess', 'requiredPayment', 'initial', 'expired', 'refunded', 'partialRefunded', 'refundFailed', 'refundCanceled', 'refundInProcess', 'error', 'disputed')`, + ); + await queryRunner.query(`ALTER TABLE "transaction" ALTER COLUMN "status" DROP DEFAULT`); + await queryRunner.query( + `ALTER TABLE "transaction" ALTER COLUMN "status" TYPE "public"."transaction_status_enum_old" USING "status"::"text"::"public"."transaction_status_enum_old"`, + ); + await queryRunner.query( + `ALTER TABLE "transaction" ALTER COLUMN "status" SET DEFAULT 'initial'`, + ); + await queryRunner.query(`DROP TYPE "public"."transaction_status_enum"`); + await queryRunner.query( + `ALTER TYPE "public"."transaction_status_enum_old" RENAME TO "transaction_status_enum"`, + ); + await queryRunner.query(`ALTER TABLE "transaction" DROP COLUMN "isDisputed"`); + await queryRunner.query(`ALTER TABLE "transaction" DROP COLUMN "chargeRefundStatus"`); + await queryRunner.query(`DROP TYPE "public"."transaction_chargerefundstatus_enum"`); + } +} diff --git a/microservices/payment-stripe/src/constants/charge-refund-status.ts b/microservices/payment-stripe/src/constants/charge-refund-status.ts new file mode 100644 index 00000000..dab6c328 --- /dev/null +++ b/microservices/payment-stripe/src/constants/charge-refund-status.ts @@ -0,0 +1,8 @@ +enum ChargeRefundStatus { + NO_REFUND = 'noRefund', + PARTIAL_REFUND = 'partialRefund', + // Full transaction amount refunded to sender. No matter with transfer or not + FULL_REFUND = 'fullRefund', +} + +export default ChargeRefundStatus; diff --git a/microservices/payment-stripe/src/constants/transaction-status.ts b/microservices/payment-stripe/src/constants/transaction-status.ts index 2e69fcd6..c794b269 100644 --- a/microservices/payment-stripe/src/constants/transaction-status.ts +++ b/microservices/payment-stripe/src/constants/transaction-status.ts @@ -16,7 +16,6 @@ enum TransactionStatus { REFUND_CANCELED = 'refundCanceled', REFUND_IN_PROCESS = 'refundInProcess', ERROR = 'error', - DISPUTED = 'disputed', } export default TransactionStatus; diff --git a/microservices/payment-stripe/src/entities/transaction.ts b/microservices/payment-stripe/src/entities/transaction.ts index 5feef66f..f79142d4 100644 --- a/microservices/payment-stripe/src/entities/transaction.ts +++ b/microservices/payment-stripe/src/entities/transaction.ts @@ -1,5 +1,5 @@ import { IsNullable, IsTypeormDate, IsUndefinable, IsValidate } from '@lomray/microservice-helpers'; -import { Allow, IsEnum, IsNumber, IsObject, Length } from 'class-validator'; +import { Allow, IsBoolean, IsEnum, IsNumber, IsObject, Length } from 'class-validator'; import { JSONSchema } from 'class-validator-jsonschema'; import { Column, @@ -10,9 +10,9 @@ import { PrimaryGeneratedColumn, UpdateDateColumn, } from 'typeorm'; +import ChargeRefundStatus from '@constants/charge-refund-status'; import StripeCheckoutStatus from '@constants/stripe-checkout-status'; import StripeTransactionStatus from '@constants/stripe-transaction-status'; -import TransactionDisputeStatus from '@constants/transaction-dispute-status'; import TransactionRole from '@constants/transaction-role'; import TransactionStatus from '@constants/transaction-status'; import TransactionType from '@constants/transaction-type'; @@ -113,7 +113,8 @@ export const defaultParams: Pick< @JSONSchema({ // Check payment stripe docs fo detailed description - description: 'Stipe transaction presentation', + description: + 'Stipe transaction presentation. Disputed transaction can be refundable if disputed charge have is refundable to true', properties: { customer: { $ref: '#/definitions/Customer' }, product: { $ref: '#/definitions/Product' }, @@ -258,7 +259,7 @@ class Transaction { fee: number; @JSONSchema({ - description: 'Field for storing status of payment by the card or any other source', + description: 'Field for storing status of payment by the card or any other source.', }) @Column({ type: 'enum', enum: TransactionStatus, default: TransactionStatus.INITIAL }) @IsEnum(TransactionStatus) @@ -267,16 +268,20 @@ class Transaction { @JSONSchema({ description: - 'Transaction can not be refunded during dispute, but if dispute closed - can be refunded', + 'If transaction was fail refund on second partial refund - this status indicate real refund state.', }) - @Column({ - type: 'enum', - enum: TransactionDisputeStatus, - default: TransactionDisputeStatus.NOT_DISPUTED, + @Column({ type: 'enum', enum: ChargeRefundStatus, default: ChargeRefundStatus.NO_REFUND }) + @IsEnum(ChargeRefundStatus) + @IsUndefinable() + chargeRefundStatus: ChargeRefundStatus; + + @JSONSchema({ + description: 'Does transaction was disputed. Chargeback or injury occur.', }) - @IsEnum(TransactionDisputeStatus) + @Column({ type: 'boolean', default: false }) + @IsBoolean() @IsUndefinable() - disputeStatus: TransactionDisputeStatus; + isDisputed: boolean; @JSONSchema({ description: 'Store data about payment connected account and etc.', diff --git a/microservices/payment-stripe/src/services/dispute.ts b/microservices/payment-stripe/src/services/dispute.ts index 8326a2ba..f22da9fa 100644 --- a/microservices/payment-stripe/src/services/dispute.ts +++ b/microservices/payment-stripe/src/services/dispute.ts @@ -6,7 +6,6 @@ import { EntityManager } from 'typeorm'; import DisputeStatus from '@constants/dispute-status'; import StripeDisputeReason from '@constants/stripe-dispute-reason'; import StripeDisputeStatus from '@constants/stripe-dispute-status'; -import TransactionStatus from '@constants/transaction-status'; import DisputeEntity from '@entities/dispute'; import TransactionEntity from '@entities/transaction'; import Parser from '@services/parser'; @@ -127,11 +126,11 @@ class Dispute { let isUpdated = false; transactions.forEach((transaction) => { - if (transaction.status === TransactionStatus.DISPUTED) { + if (transaction.isDisputed) { return; } - transaction.status = TransactionStatus.DISPUTED; + transaction.isDisputed = true; isUpdated = true; }); diff --git a/microservices/payment-stripe/src/services/transaction.ts b/microservices/payment-stripe/src/services/transaction.ts index 6b592424..0534255d 100644 --- a/microservices/payment-stripe/src/services/transaction.ts +++ b/microservices/payment-stripe/src/services/transaction.ts @@ -2,7 +2,9 @@ import { Microservice } from '@lomray/microservice-nodejs-lib'; import Event from '@lomray/microservices-client-api/constants/events/payment-stripe'; import { EntityManager } from 'typeorm'; import { ColumnMetadata } from 'typeorm/metadata/ColumnMetadata'; +import ChargeRefundStatus from '@constants/charge-refund-status'; import TransactionStatus from '@constants/transaction-status'; +import TransactionType from '@constants/transaction-type'; import TransactionEntity from '@entities/transaction'; import Factory from '@services/payment-gateway/factory'; @@ -33,6 +35,8 @@ class Transaction { manager: EntityManager, updateColumns: ColumnMetadata[], ): Promise { + await this.updateChargeRefundStatus(entity, manager); + if (this.notSucceededTransactionStatuses.includes(entity.status)) { await Microservice.eventPublish(Event.TransactionUpdated, entity); @@ -50,6 +54,51 @@ class Transaction { await Microservice.eventPublish(Event.TransactionUpdated, entity); } + + /** + * Update charge refund status + */ + private static async updateChargeRefundStatus( + { + type, + amount, + transactionId, + chargeRefundStatus, + params: { refundedTransactionAmount }, + }: TransactionEntity, + manager: EntityManager, + ): Promise { + if (type !== TransactionType.DEBIT) { + return; + } + + const transactionRepository = manager.getRepository(TransactionEntity); + let status: ChargeRefundStatus | null = null; + + if (refundedTransactionAmount === amount) { + status = ChargeRefundStatus.FULL_REFUND; + } else if (refundedTransactionAmount > 0) { + status = ChargeRefundStatus.PARTIAL_REFUND; + } else { + status = ChargeRefundStatus.NO_REFUND; + } + + if (!status || status === chargeRefundStatus) { + return; + } + + const transactions = await transactionRepository.find({ + where: { + transactionId, + }, + }); + + transactions.forEach((transaction) => { + transaction.chargeRefundStatus = status as ChargeRefundStatus; + }); + + await transactionRepository.save(transactions); + } } export default Transaction;