Skip to content

Commit dfa4564

Browse files
authored
feat(payment-stripe): dispute transaction (#126)
1 parent 985340b commit dfa4564

24 files changed

+1410
-790
lines changed
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import { MigrationInterface, QueryRunner } from 'typeorm';
2+
3+
export default class transactionDisputeRelation1701787694561 implements MigrationInterface {
4+
name = 'transactionDisputeRelation1701787694561';
5+
6+
public async up(queryRunner: QueryRunner): Promise<void> {
7+
await queryRunner.query(
8+
`ALTER TYPE "public"."transaction_status_enum" RENAME TO "transaction_status_enum_old"`,
9+
);
10+
await queryRunner.query(
11+
`CREATE TYPE "public"."transaction_status_enum" AS ENUM('success', 'inProcess', 'requiredPayment', 'initial', 'expired', 'refunded', 'partialRefunded', 'refundFailed', 'refundCanceled', 'refundInProcess', 'error', 'disputed')`,
12+
);
13+
await queryRunner.query(`ALTER TABLE "transaction" ALTER COLUMN "status" DROP DEFAULT`);
14+
await queryRunner.query(
15+
`ALTER TABLE "transaction" ALTER COLUMN "status" TYPE "public"."transaction_status_enum" USING "status"::"text"::"public"."transaction_status_enum"`,
16+
);
17+
await queryRunner.query(
18+
`ALTER TABLE "transaction" ALTER COLUMN "status" SET DEFAULT 'initial'`,
19+
);
20+
await queryRunner.query(
21+
`CREATE TYPE "public"."refund_status_enum" AS ENUM('initial', 'inProcess', 'requiresAction', 'success', 'error', 'canceled')`,
22+
);
23+
await queryRunner.query(`ALTER TABLE "refund" ALTER COLUMN "status" DROP DEFAULT`);
24+
await queryRunner.query(
25+
`ALTER TABLE "refund" ALTER COLUMN "status" TYPE "public"."refund_status_enum" USING "status"::"text"::"public"."refund_status_enum"`,
26+
);
27+
await queryRunner.query(`ALTER TABLE "refund" ALTER COLUMN "status" SET DEFAULT 'initial'`);
28+
await queryRunner.query(`DROP TYPE "public"."transaction_status_enum_old"`);
29+
}
30+
31+
public async down(queryRunner: QueryRunner): Promise<void> {
32+
await queryRunner.query(
33+
`CREATE TYPE "public"."transaction_status_enum_old" AS ENUM('success', 'inProcess', 'requiredPayment', 'initial', 'expired', 'error', 'refunded', 'refundFailed', 'refundCanceled', 'refundInProcess', 'partialRefunded')`,
34+
);
35+
await queryRunner.query(`ALTER TABLE "refund" ALTER COLUMN "status" DROP DEFAULT`);
36+
await queryRunner.query(
37+
`ALTER TABLE "refund" ALTER COLUMN "status" TYPE "public"."transaction_status_enum_old" USING "status"::"text"::"public"."transaction_status_enum_old"`,
38+
);
39+
await queryRunner.query(`ALTER TABLE "refund" ALTER COLUMN "status" SET DEFAULT 'initial'`);
40+
await queryRunner.query(`DROP TYPE "public"."refund_status_enum"`);
41+
await queryRunner.query(
42+
`ALTER TYPE "public"."transaction_status_enum_old" RENAME TO "transaction_status_enum"`,
43+
);
44+
await queryRunner.query(
45+
`CREATE TYPE "public"."transaction_status_enum_old" AS ENUM('success', 'inProcess', 'requiredPayment', 'initial', 'expired', 'error', 'refunded', 'refundFailed', 'refundCanceled', 'refundInProcess', 'partialRefunded')`,
46+
);
47+
await queryRunner.query(`ALTER TABLE "transaction" ALTER COLUMN "status" DROP DEFAULT`);
48+
await queryRunner.query(
49+
`ALTER TABLE "transaction" ALTER COLUMN "status" TYPE "public"."transaction_status_enum_old" USING "status"::"text"::"public"."transaction_status_enum_old"`,
50+
);
51+
await queryRunner.query(
52+
`ALTER TABLE "transaction" ALTER COLUMN "status" SET DEFAULT 'initial'`,
53+
);
54+
await queryRunner.query(`DROP TYPE "public"."transaction_status_enum"`);
55+
await queryRunner.query(
56+
`ALTER TYPE "public"."transaction_status_enum_old" RENAME TO "transaction_status_enum"`,
57+
);
58+
}
59+
}
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import { MigrationInterface, QueryRunner } from 'typeorm';
2+
3+
export default class transactionChargeAndDispute1701791863603 implements MigrationInterface {
4+
name = 'transactionChargeAndDispute1701791863603';
5+
6+
public async up(queryRunner: QueryRunner): Promise<void> {
7+
await queryRunner.query(
8+
`CREATE TYPE "public"."transaction_chargerefundstatus_enum" AS ENUM('noRefund', 'partialRefund', 'fullRefund')`,
9+
);
10+
await queryRunner.query(
11+
`ALTER TABLE "transaction" ADD "chargeRefundStatus" "public"."transaction_chargerefundstatus_enum" NOT NULL DEFAULT 'noRefund'`,
12+
);
13+
await queryRunner.query(
14+
`ALTER TABLE "transaction" ADD "isDisputed" boolean NOT NULL DEFAULT false`,
15+
);
16+
await queryRunner.query(
17+
`ALTER TYPE "public"."transaction_status_enum" RENAME TO "transaction_status_enum_old"`,
18+
);
19+
await queryRunner.query(
20+
`CREATE TYPE "public"."transaction_status_enum" AS ENUM('success', 'inProcess', 'requiredPayment', 'initial', 'expired', 'refunded', 'partialRefunded', 'refundFailed', 'refundCanceled', 'refundInProcess', 'error')`,
21+
);
22+
await queryRunner.query(`ALTER TABLE "transaction" ALTER COLUMN "status" DROP DEFAULT`);
23+
await queryRunner.query(
24+
`ALTER TABLE "transaction" ALTER COLUMN "status" TYPE "public"."transaction_status_enum" USING "status"::"text"::"public"."transaction_status_enum"`,
25+
);
26+
await queryRunner.query(
27+
`ALTER TABLE "transaction" ALTER COLUMN "status" SET DEFAULT 'initial'`,
28+
);
29+
await queryRunner.query(`DROP TYPE "public"."transaction_status_enum_old"`);
30+
}
31+
32+
public async down(queryRunner: QueryRunner): Promise<void> {
33+
await queryRunner.query(
34+
`CREATE TYPE "public"."transaction_status_enum_old" AS ENUM('success', 'inProcess', 'requiredPayment', 'initial', 'expired', 'refunded', 'partialRefunded', 'refundFailed', 'refundCanceled', 'refundInProcess', 'error', 'disputed')`,
35+
);
36+
await queryRunner.query(`ALTER TABLE "transaction" ALTER COLUMN "status" DROP DEFAULT`);
37+
await queryRunner.query(
38+
`ALTER TABLE "transaction" ALTER COLUMN "status" TYPE "public"."transaction_status_enum_old" USING "status"::"text"::"public"."transaction_status_enum_old"`,
39+
);
40+
await queryRunner.query(
41+
`ALTER TABLE "transaction" ALTER COLUMN "status" SET DEFAULT 'initial'`,
42+
);
43+
await queryRunner.query(`DROP TYPE "public"."transaction_status_enum"`);
44+
await queryRunner.query(
45+
`ALTER TYPE "public"."transaction_status_enum_old" RENAME TO "transaction_status_enum"`,
46+
);
47+
await queryRunner.query(`ALTER TABLE "transaction" DROP COLUMN "isDisputed"`);
48+
await queryRunner.query(`ALTER TABLE "transaction" DROP COLUMN "chargeRefundStatus"`);
49+
await queryRunner.query(`DROP TYPE "public"."transaction_chargerefundstatus_enum"`);
50+
}
51+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
enum ChargeRefundStatus {
2+
NO_REFUND = 'noRefund',
3+
PARTIAL_REFUND = 'partialRefund',
4+
// Full transaction amount refunded to sender. No matter with transfer or not
5+
FULL_REFUND = 'fullRefund',
6+
}
7+
8+
export default ChargeRefundStatus;
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
enum RefundStatus {
2+
INITIAL = 'initial',
3+
IN_PROCESS = 'inProcess',
4+
REQUIRES_ACTION = 'requiresAction',
5+
SUCCESS = 'success',
6+
ERROR = 'error',
7+
CANCELED = 'canceled',
8+
}
9+
10+
export default RefundStatus;
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
enum StripeRefundStatus {
2+
PENDING = 'pending',
3+
REQUIRES_ACTION = 'requires_action',
4+
SUCCEEDED = 'succeeded',
5+
FAILED = 'failed',
6+
CANCELED = 'canceled',
7+
}
8+
9+
export default StripeRefundStatus;

microservices/payment-stripe/src/entities/dispute.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,7 @@ class Dispute {
101101

102102
@JSONSchema({
103103
description:
104-
'New worth of the account (platform or connected account) after dispute related to this transaction',
104+
'Dispute new worth related to the account (platform or connected account) after dispute related to this transaction',
105105
example: -12139,
106106
})
107107
@Column({ type: 'int', default: 0 })

microservices/payment-stripe/src/entities/refund.ts

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
UpdateDateColumn,
1010
} from 'typeorm';
1111
import type RefundAmountType from '@constants/refund-amount-type';
12+
import RefundStatus from '@constants/refund-status';
1213
import TransactionStatus from '@constants/transaction-status';
1314

1415
// eslint-disable-next-line @typescript-eslint/no-empty-interface
@@ -72,13 +73,10 @@ class Refund {
7273
@IsUndefinable()
7374
params: IParams;
7475

75-
@JSONSchema({
76-
description: 'Status should be started with the refund prefix',
77-
})
78-
@Column({ type: 'enum', enum: TransactionStatus, default: TransactionStatus.INITIAL })
76+
@Column({ type: 'enum', enum: RefundStatus, default: RefundStatus.INITIAL })
7977
@IsEnum(TransactionStatus)
8078
@IsUndefinable()
81-
status: TransactionStatus;
79+
status: RefundStatus;
8280

8381
@IsTypeormDate()
8482
@CreateDateColumn()

microservices/payment-stripe/src/entities/transaction.ts

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { IsNullable, IsTypeormDate, IsUndefinable, IsValidate } from '@lomray/microservice-helpers';
2-
import { Allow, IsEnum, IsNumber, IsObject, Length } from 'class-validator';
2+
import { Allow, IsBoolean, IsEnum, IsNumber, IsObject, Length } from 'class-validator';
33
import { JSONSchema } from 'class-validator-jsonschema';
44
import {
55
Column,
@@ -10,6 +10,7 @@ import {
1010
PrimaryGeneratedColumn,
1111
UpdateDateColumn,
1212
} from 'typeorm';
13+
import ChargeRefundStatus from '@constants/charge-refund-status';
1314
import StripeCheckoutStatus from '@constants/stripe-checkout-status';
1415
import StripeTransactionStatus from '@constants/stripe-transaction-status';
1516
import TransactionRole from '@constants/transaction-role';
@@ -112,7 +113,8 @@ export const defaultParams: Pick<
112113

113114
@JSONSchema({
114115
// Check payment stripe docs fo detailed description
115-
description: 'Stipe transaction presentation',
116+
description:
117+
'Stipe transaction presentation. Disputed transaction can be refundable if disputed charge have is refundable to true',
116118
properties: {
117119
customer: { $ref: '#/definitions/Customer' },
118120
product: { $ref: '#/definitions/Product' },
@@ -257,13 +259,30 @@ class Transaction {
257259
fee: number;
258260

259261
@JSONSchema({
260-
description: 'Field for storing status of payment by the card or any other source',
262+
description: 'Field for storing status of payment by the card or any other source.',
261263
})
262264
@Column({ type: 'enum', enum: TransactionStatus, default: TransactionStatus.INITIAL })
263265
@IsEnum(TransactionStatus)
264266
@IsUndefinable()
265267
status: TransactionStatus;
266268

269+
@JSONSchema({
270+
description:
271+
'If transaction was fail refund on second partial refund - this status indicate real refund state.',
272+
})
273+
@Column({ type: 'enum', enum: ChargeRefundStatus, default: ChargeRefundStatus.NO_REFUND })
274+
@IsEnum(ChargeRefundStatus)
275+
@IsUndefinable()
276+
chargeRefundStatus: ChargeRefundStatus;
277+
278+
@JSONSchema({
279+
description: 'Does transaction was disputed. Chargeback or injury occur.',
280+
})
281+
@Column({ type: 'boolean', default: false })
282+
@IsBoolean()
283+
@IsUndefinable()
284+
isDisputed: boolean;
285+
267286
@JSONSchema({
268287
description: 'Store data about payment connected account and etc.',
269288
})
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { BaseException } from '@lomray/microservice-nodejs-lib';
2+
import { EntityManager, EntityRepository, Repository } from 'typeorm';
3+
import CustomerEntity from '@entities/customer';
4+
import messages from '@helpers/validators/messages';
5+
6+
@EntityRepository(CustomerEntity)
7+
class Customer extends Repository<CustomerEntity> {
8+
/**
9+
* Returns customer by account id
10+
*/
11+
public static async getCustomerByAccountId(
12+
accountId: string,
13+
manager: EntityManager,
14+
): Promise<CustomerEntity> {
15+
const customer = await manager
16+
.getRepository(CustomerEntity)
17+
.createQueryBuilder('customer')
18+
.where("customer.params ->> 'accountId' = :accountId", { accountId })
19+
.getOne();
20+
21+
if (!customer) {
22+
throw new BaseException({
23+
status: 500,
24+
message: messages.getNotFoundMessage('Customer'),
25+
});
26+
}
27+
28+
return customer;
29+
}
30+
}
31+
32+
export default Customer;

microservices/payment-stripe/src/services/calculation.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import StripePaymentMethods from '@constants/stripe-payment-methods';
55
import TaxBehaviour from '@constants/tax-behaviour';
66
import TransactionRole from '@constants/transaction-role';
77
import getPercentFromAmount from '@helpers/get-percent-from-amount';
8-
import IFees from '@interfaces/fees';
8+
import type IFees from '@interfaces/fees';
99
import type ITax from '@interfaces/tax';
1010
import type ITaxes from '@interfaces/taxes';
1111

microservices/payment-stripe/src/services/dispute.ts

Lines changed: 63 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
1+
import { Log } from '@lomray/microservice-helpers';
12
import { Microservice } from '@lomray/microservice-nodejs-lib';
23
import Event from '@lomray/microservices-client-api/constants/events/payment-stripe';
34
import StripeSdk from 'stripe';
45
import { EntityManager } from 'typeorm';
6+
import DisputeStatus from '@constants/dispute-status';
57
import StripeDisputeReason from '@constants/stripe-dispute-reason';
68
import StripeDisputeStatus from '@constants/stripe-dispute-status';
79
import DisputeEntity from '@entities/dispute';
10+
import TransactionEntity from '@entities/transaction';
811
import Parser from '@services/parser';
912

1013
/**
@@ -28,14 +31,20 @@ class Dispute {
2831
manager: EntityManager,
2932
): Promise<void> {
3033
disputeEntity.amount = amount;
31-
disputeEntity.status = Parser.parseStripeDisputeStatus(status as StripeDisputeStatus);
32-
disputeEntity.reason = Parser.parseStripeDisputeReason(reason as StripeDisputeReason);
3334
disputeEntity.metadata = metadata;
3435
disputeEntity.params.balanceTransactionId = balanceTransactions?.[0]?.id;
3536
disputeEntity.params.isChargeRefundable = isChargeRefundable;
3637
disputeEntity.evidenceDetails.submissionCount = evidenceDetails.submission_count;
3738
disputeEntity.evidenceDetails.isPastBy = evidenceDetails.past_due;
3839
disputeEntity.evidenceDetails.hasEvidence = evidenceDetails.has_evidence;
40+
disputeEntity.reason = Parser.parseStripeDisputeReason(reason as StripeDisputeReason);
41+
42+
const disputeStatus = Parser.parseStripeDisputeStatus(status as StripeDisputeStatus);
43+
44+
// Stripe can send under review status after won or lost dispute
45+
if (![DisputeStatus.LOST, DisputeStatus.WON].includes(disputeEntity.status)) {
46+
disputeEntity.status = disputeStatus;
47+
}
3948

4049
if (evidenceDetails.due_by) {
4150
disputeEntity.evidenceDetails.dueBy = new Date(evidenceDetails.due_by * 1000);
@@ -48,6 +57,7 @@ class Dispute {
4857
disputeEntity.netWorth = netWorth;
4958

5059
await manager.getRepository(DisputeEntity).save(disputeEntity);
60+
await Dispute.updateTransactionsDisputeStatus(manager, disputeEntity.transactionId);
5161
}
5262

5363
/**
@@ -71,8 +81,14 @@ class Dispute {
7181
/**
7282
* Handle after create
7383
*/
74-
public static async handleAfterCreate(entity: DisputeEntity): Promise<void> {
75-
await Microservice.eventPublish(Event.DisputeCreated, entity);
84+
public static async handleAfterCreate(
85+
entity: DisputeEntity,
86+
manager: EntityManager,
87+
): Promise<void> {
88+
await Promise.all([
89+
Dispute.updateTransactionsDisputeStatus(manager, entity.transactionId),
90+
Microservice.eventPublish(Event.DisputeCreated, entity),
91+
]);
7692
}
7793

7894
/**
@@ -81,6 +97,49 @@ class Dispute {
8197
public static async handleAfterUpdate(entity: DisputeEntity): Promise<void> {
8298
await Microservice.eventPublish(Event.DisputeUpdated, entity);
8399
}
100+
101+
/**
102+
* Update transactions dispute status
103+
*/
104+
private static async updateTransactionsDisputeStatus(
105+
manager: EntityManager,
106+
transactionId?: string | null,
107+
): Promise<void> {
108+
if (!transactionId) {
109+
return;
110+
}
111+
112+
const transactionRepository = manager.getRepository(TransactionEntity);
113+
114+
const transactions = await transactionRepository.find({
115+
where: {
116+
transactionId,
117+
},
118+
});
119+
120+
if (!transactions.length) {
121+
Log.error('Failed to update transaction dispute status. Transactions were not found.');
122+
123+
return;
124+
}
125+
126+
let isUpdated = false;
127+
128+
transactions.forEach((transaction) => {
129+
if (transaction.isDisputed) {
130+
return;
131+
}
132+
133+
transaction.isDisputed = true;
134+
isUpdated = true;
135+
});
136+
137+
if (!isUpdated) {
138+
return;
139+
}
140+
141+
await transactionRepository.save(transactions);
142+
}
84143
}
85144

86145
export default Dispute;

0 commit comments

Comments
 (0)