Skip to content

feat(payment-stripe): dispute transaction #126

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { MigrationInterface, QueryRunner } from 'typeorm';

export default class transactionDisputeRelation1701787694561 implements MigrationInterface {
name = 'transactionDisputeRelation1701787694561';

public async up(queryRunner: QueryRunner): Promise<void> {
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<void> {
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"`,
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { MigrationInterface, QueryRunner } from 'typeorm';

export default class transactionChargeAndDispute1701791863603 implements MigrationInterface {
name = 'transactionChargeAndDispute1701791863603';

public async up(queryRunner: QueryRunner): Promise<void> {
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<void> {
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"`);
}
}
Original file line number Diff line number Diff line change
@@ -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;
10 changes: 10 additions & 0 deletions microservices/payment-stripe/src/constants/refund-status.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
enum RefundStatus {
INITIAL = 'initial',
IN_PROCESS = 'inProcess',
REQUIRES_ACTION = 'requiresAction',
SUCCESS = 'success',
ERROR = 'error',
CANCELED = 'canceled',
}

export default RefundStatus;
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
enum StripeRefundStatus {
PENDING = 'pending',
REQUIRES_ACTION = 'requires_action',
SUCCEEDED = 'succeeded',
FAILED = 'failed',
CANCELED = 'canceled',
}

export default StripeRefundStatus;
2 changes: 1 addition & 1 deletion microservices/payment-stripe/src/entities/dispute.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 })
Expand Down
8 changes: 3 additions & 5 deletions microservices/payment-stripe/src/entities/refund.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down
25 changes: 22 additions & 3 deletions microservices/payment-stripe/src/entities/transaction.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -10,6 +10,7 @@ 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 TransactionRole from '@constants/transaction-role';
Expand Down Expand Up @@ -112,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' },
Expand Down Expand Up @@ -257,13 +259,30 @@ 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)
@IsUndefinable()
status: TransactionStatus;

@JSONSchema({
description:
'If transaction was fail refund on second partial refund - this status indicate real refund state.',
})
@Column({ type: 'enum', enum: ChargeRefundStatus, default: ChargeRefundStatus.NO_REFUND })
@IsEnum(ChargeRefundStatus)
@IsUndefinable()
chargeRefundStatus: ChargeRefundStatus;

@JSONSchema({
description: 'Does transaction was disputed. Chargeback or injury occur.',
})
@Column({ type: 'boolean', default: false })
@IsBoolean()
@IsUndefinable()
isDisputed: boolean;

@JSONSchema({
description: 'Store data about payment connected account and etc.',
})
Expand Down
32 changes: 32 additions & 0 deletions microservices/payment-stripe/src/repositories/customer.ts
Original file line number Diff line number Diff line change
@@ -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<CustomerEntity> {
/**
* Returns customer by account id
*/
public static async getCustomerByAccountId(
accountId: string,
manager: EntityManager,
): Promise<CustomerEntity> {
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;
2 changes: 1 addition & 1 deletion microservices/payment-stripe/src/services/calculation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down
67 changes: 63 additions & 4 deletions microservices/payment-stripe/src/services/dispute.ts
Original file line number Diff line number Diff line change
@@ -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';

/**
Expand All @@ -28,14 +31,20 @@ class Dispute {
manager: EntityManager,
): Promise<void> {
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);
Expand All @@ -48,6 +57,7 @@ class Dispute {
disputeEntity.netWorth = netWorth;

await manager.getRepository(DisputeEntity).save(disputeEntity);
await Dispute.updateTransactionsDisputeStatus(manager, disputeEntity.transactionId);
}

/**
Expand All @@ -71,8 +81,14 @@ class Dispute {
/**
* Handle after create
*/
public static async handleAfterCreate(entity: DisputeEntity): Promise<void> {
await Microservice.eventPublish(Event.DisputeCreated, entity);
public static async handleAfterCreate(
entity: DisputeEntity,
manager: EntityManager,
): Promise<void> {
await Promise.all([
Dispute.updateTransactionsDisputeStatus(manager, entity.transactionId),
Microservice.eventPublish(Event.DisputeCreated, entity),
]);
}

/**
Expand All @@ -81,6 +97,49 @@ class Dispute {
public static async handleAfterUpdate(entity: DisputeEntity): Promise<void> {
await Microservice.eventPublish(Event.DisputeUpdated, entity);
}

/**
* Update transactions dispute status
*/
private static async updateTransactionsDisputeStatus(
manager: EntityManager,
transactionId?: string | null,
): Promise<void> {
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;

transactions.forEach((transaction) => {
if (transaction.isDisputed) {
return;
}

transaction.isDisputed = true;
isUpdated = true;
});

if (!isUpdated) {
return;
}

await transactionRepository.save(transactions);
}
}

export default Dispute;
Loading