Skip to content

Commit

Permalink
feat(payment-stripe): add disputes (chargebacks) and evidences (#123)
Browse files Browse the repository at this point in the history
* feat(payment-stripe): dispute entity and parser helper class

* feat(payment-stripe): clean up

* feat(payment-stripe): add evidence details

* feat(payment-stripe): update parser

* feat(payment-stripe): add dispute creation webhook handler

* docs(payment-stripe): update stripe testing docs

* feat(payment-stripe): clean up

* feat(payment-stripe): update jsdocs

* feat(payment-stripe): clean up

* feat(payment-stripe): clean up

* feat(payment-stripe): update disputes entity composition

* feat(payment-stripe): add dispute internal events

* feat(payment-stripe): clean up

* feat(payment-stripe): clean up

* feat(payment-stripe): clean up

* feat(payment-stripe): add dispute events and update configs (#124)

* feat(payment-stripe): add dispute fee in configs

* feat(payment-stripe): update dispute calculations

* feat(payment-stripe): clean up

* feat(payment-stripe): clean up

* feat(payment-stripe): clean up

* feat(payment-stripe): clean up

* docs(payment-stripe): update envs

* feat(payment-stripe): add webhook handlers service (#125)

* feat(payment-stripe): add webhook handlers service

* feat(payment-stripe): update webhook handlers infra

* feat(payment-stripe): update jsdocs

* feat(payment-stripe): add charge webhook handlers

* feat(payment-stripe): clean up

* feat(payment-stripe): update migration namings

* feat(payment-stripe): dispute transaction (#126)

* feat(payment-stripe): update method permissions

* feat(payment-stripe): update models permissions

* feat(payment-stripe): clean up

* feat(payment-stripe): clean up
  • Loading branch information
OlegDO authored Dec 6, 2023
1 parent f58640c commit 1b38cf1
Show file tree
Hide file tree
Showing 44 changed files with 2,376 additions and 932 deletions.
2 changes: 1 addition & 1 deletion .env
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
MS_INIT_CONFIGS='[{"microservice":"*","type":"db","params":{"host":"db","port":5432,"username":"postgres","password":"example"}},{"microservice":"authentication","type":"config","params":{"jwtOptions":{"secretKey":"DemoSecretKey"}}},{"microservice":"authorization","type":"config","params":{}},{"microservice":"content","type":"config","params":{}},{"microservice":"gateway","type":"config","params":{"corsOptions":{},"webhookUrl":"/webhook/"}},{"microservice":"users","type":"config","params":{"removedAccountRestoreTime":0}},{"microservice":"notification","type":"config","params":{"defaultEmailFrom":"change@me.com","transportOptions":{"host":"smtp.ethereal.email","port":587,"secure":false,"auth":{"user":"generated ethereal user","pass":"generated ethereal password"}}}},{"microservice":"files","type":"config","params":{"imageProcessingConfig":{"thumbnails":[{"name":"thumbnail","options":{"width":75}},{"name":"small","options":{"width":150}},{"name":"medium","options":{"width":300}},{"name":"large","options":{"width":600}},{"name":"extra-large","options":{"width":1200}}],"outputOptions":{"jpeg":{"quality":80,"mozjpeg":true},"png":{"quality":80},"webp":{"quality":80}},"isWebp":true}}},{"microservice":"payment-stripe","type":"config","params":{"paymentMethods":["bancontact","card"],"apiKey":"your test key from stripe or other service","config":{"apiVersion":"2022-11-15"},"payoutCoeff":0.3,"webhookKeys":{"connect":"your test webhook key from stripe or other service for connect account"},"fees":{"stableUnit":30,"paymentPercent":2.9},"duplicatedCardsUsage":"reject"}}]'
MS_INIT_CONFIGS='[{"microservice":"*","type":"db","params":{"host":"db","port":5432,"username":"postgres","password":"example"}},{"microservice":"authentication","type":"config","params":{"jwtOptions":{"secretKey":"DemoSecretKey"}}},{"microservice":"authorization","type":"config","params":{}},{"microservice":"content","type":"config","params":{}},{"microservice":"gateway","type":"config","params":{"corsOptions":{},"webhookUrl":"/webhook/"}},{"microservice":"users","type":"config","params":{"removedAccountRestoreTime":0}},{"microservice":"notification","type":"config","params":{"defaultEmailFrom":"change@me.com","transportOptions":{"host":"smtp.ethereal.email","port":587,"secure":false,"auth":{"user":"generated ethereal user","pass":"generated ethereal password"}}}},{"microservice":"files","type":"config","params":{"imageProcessingConfig":{"thumbnails":[{"name":"thumbnail","options":{"width":75}},{"name":"small","options":{"width":150}},{"name":"medium","options":{"width":300}},{"name":"large","options":{"width":600}},{"name":"extra-large","options":{"width":1200}}],"outputOptions":{"jpeg":{"quality":80,"mozjpeg":true},"png":{"quality":80},"webp":{"quality":80}},"isWebp":true}}},{"microservice":"payment-stripe","type":"config","params":{"paymentMethods":["bancontact","card"],"apiKey":"your test key from stripe or other service","config":{"apiVersion":"2022-11-15"},"payoutCoeff":0.3,"webhookKeys":{"connect":"your test webhook key from stripe or other service for connect account"},"fees":{"stablePaymentUnit":30,"paymentPercent":2.9},"duplicatedCardsUsage":"reject"}}]'
MS_INIT_MIDDLEWARES='[{"target":"gateway","targetMethod":"*","sender":"authentication","senderMethod":"token.identify","type":"request","order":10,"description":"Validate JWT token and add payload with token info.","params":{"type":"request","isRequired":true,"maxValueSize":100,"exclude":["users.user.sign-in","users.identity-provider.sign-in","authentication.cookies.remove","authentication.token.renew"],"strategy":"transform","convertResult":{"payload.authentication.tokenId":"$middleware.tokenId","payload.authentication.userId":"$middleware.userId","payload.authentication.isAuth":"$middleware.isAuth","payload.authentication.provider":"$middleware.provider"}}},{"target":"gateway","targetMethod":"*","sender":"authorization","senderMethod":"endpoint.enforce","type":"request","order":20,"description":"Whether the user is allowed to call the method. Filter method input params.","params":{"type":"request","isRequired":true,"isCleanResult":true,"strategy":"transform","reqParams":{"isInternal":true},"convertParams":{"userId":"$task.params.payload.authentication.userId","method":"$task.method","filterInput":"$task.params"},"convertResult":{".":"$middleware.filteredInput","payload":"$task.params.payload","payload.authorization.isAllow":"$middleware.isAllow","payload.authorization.roles":"$middleware.roles","payload.authorization.filter":"$middleware.filters"}}},{"target":"gateway","targetMethod":"*","sender":"authorization","senderMethod":"endpoint.filter","type":"response","order":20,"description":"Filter microservice response fields.","params":{"type":"response","isRequired":true,"isCleanResult":true,"strategy":"transform","reqParams":{"isInternal":true},"convertParams":{"type":"out","userId":"$task.params.payload.authentication.userId","method":"$task.method","filterInput":"$result"},"convertResult":{".":"$middleware.filtered"}}},{"target":"users","targetMethod":"identity-provider.sign-in","sender":"authentication","senderMethod":"token.create","type":"response","order":10,"description":"Create JWT auth tokens and attach to response after successful sign in.","params":{"type":"response","isRequired":true,"strategy":"transform","extraRequests":[{"key":"rolesResp","method":"authorization.user-role.view","params":{"userId":"<%= result.user.id %>"}}],"convertParams":{"type":"jwt","userId":"$result.user.id","params":"$task.params.payload.headers.user-info","jwtPayload.roles":"$rolesResp.roles","returnType":"<%= _.get(task, \"params.payload.headers.user-info.authType\", \"cookies\") %>"},"convertResult":{"payload.cookies":"$middleware.payload.cookies","tokens.access":"$middleware.access","tokens.refresh":"$middleware.refresh"}}},{"target":"users","targetMethod":"user.sign-in","sender":"authentication","senderMethod":"token.create","type":"response","order":10,"description":"Create JWT auth tokens and attach to response after successful sign in.","params":{"type":"response","isRequired":true,"strategy":"transform","extraRequests":[{"key":"rolesResp","method":"authorization.user-role.view","params":{"userId":"<%= result.user.id %>"}}],"convertParams":{"type":"jwt","userId":"$result.user.id","params":"$task.params.payload.headers.user-info","jwtPayload.roles":"$rolesResp.roles","returnType":"<%= _.get(task, \"params.payload.headers.user-info.authType\", \"cookies\") %>"},"convertResult":{"payload.cookies":"$middleware.payload.cookies","tokens.access":"$middleware.access","tokens.refresh":"$middleware.refresh"}}},{"target":"users","targetMethod":"user.sign-up","sender":"authentication","senderMethod":"token.create","type":"response","order":10,"description":"Create JWT auth tokens and attach to response after successful sign up.","params":{"type":"response","isRequired":true,"strategy":"transform","extraRequests":[{"key":"rolesResp","method":"authorization.user-role.view","params":{"userId":"<%= result.user.id %>"}}],"convertParams":{"type":"jwt","userId":"$result.user.id","params":"$task.params.payload.headers.user-info","jwtPayload.roles":"$rolesResp.roles","returnType":"<%= _.get(task, \"params.payload.headers.user-info.authType\", \"cookies\") %>"},"convertResult":{"payload.cookies":"$middleware.payload.cookies","tokens.access":"$middleware.access","tokens.refresh":"$middleware.refresh"}}},{"target":"users","targetMethod":"user.sign-out","sender":"authentication","senderMethod":"token.remove","type":"response","order":10,"description":"Remove JWT auth token after successful sign out.","params":{"type":"response","isRequired":true,"strategy":"transform","extraRequests":[{"key":"rolesResp","method":"authentication.cookies.remove","condition":"<%= _.get(task, \"params.payload.headers.authType\", \"cookies\") === \"cookies\" %>"}],"convertParams":{"query.where.id":"$task.params.payload.authentication.tokenId"},"convertResult":{"payload.cookies":"$rolesResp.payload.cookies"}}},{"target":"users","targetMethod":"user.remove","sender":"authentication","senderMethod":"token.remove","type":"response","order":10,"description":"Remove JWT auth tokens after successful sign out.","params":{"type":"response","isRequired":true,"strategy":"transform","extraRequests":[{"key":"rolesResp","method":"authentication.cookies.remove","condition":"<%= _.get(task, \"params.payload.headers.authType\", \"cookies\") === \"cookies\" %>"}],"convertParams":{"query.where.userId":"$task.params.payload.authentication.userId"},"convertResult":{"payload.cookies":"$rolesResp.payload.cookies"}}}]'
MS_INIT_TASKS='[{"rule":"0 1 * * *","method":"users.confirm-code.remove","description":"Cleanup old confirmation codes","payload":{"params":{"query":{"where":{"expirationAt":{"<":"<%= Math.floor(Date.now() / 1000) %>"}}},"payload":{"authorization":{"filter":{"methodOptions":{"isAllowMultiple":true}}}}},"allowErrorCodes":[-33485],"responseTemplate":"<%= `deleted: ${deleted.length}` %>"}},{"rule":"0 1 * * *","method":"authentication.token.remove","description":"Cleanup old auth tokens","payload":{"params":{"query":{"where":{"expirationAt":{"<":"<%= Math.floor(Date.now() / 1000) %>"}}},"payload":{"authorization":{"filter":{"methodOptions":{"isAllowMultiple":true}}}}},"allowErrorCodes":[-33485],"responseTemplate":"<%= `deleted: ${deleted.length}` %>"}},{"rule":"* * * * *","method":"notification.job.task.process","description":"Process tasks","payload":{"responseTemplate":"<%= `Process tasks counts: total is ${total}, completed is ${completed} and failed is ${failed}` %>"}}]'
MS_IMPORT_PERMISSION=2
Expand Down
7 changes: 6 additions & 1 deletion configs/config.local.json
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,12 @@
"webhookKeys": {
"connect": "your test webhook key from stripe or other service for connect account"
},
"fees": { "stableUnit": 30, "paymentPercent": 2.9, "instantPayoutPercent": 1 },
"fees": {
"stablePaymentUnit": 30,
"stableDisputeFee": 1500,
"paymentPercent": 2.9,
"instantPayoutPercent": 1
},
"duplicatedCardsUsage": "reject"
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,13 @@ Content-Type: application/json
"params": {
"userId": "68827b31-33e9-45b5-bf9f-8823b993d0ef",
"receiverId": "0b0be2e8-1013-4be8-8e7f-a27c4b0a5e45",
"entityId": "entity08-ecd6-483a-9117-2af2e104b5f8",
"entityId": "9bea9620-b8fb-4675-a8cc-593a98b8ffe5",
"cardId": "6da843ff-01da-4363-872f-c415f2976d66",
"entityCost": 100,
"feesPayer": "sender",
"title": "Test 5: campaign ticket with tax",
"withTax": true
"title": "7 fraudulent dispute card",
"applicationPaymentPercent": 3,
"withTax": false
}
}

Expand All @@ -26,8 +28,8 @@ Content-Type: application/json
"id": "1",
"method": "stripe.refund",
"params": {
"transactionId": "pi_3NkNQYAmQ4asS8PS13Dz4Z0x",
"amount": 100
"transactionId": "pi_3OCWa6FpQjUWTpHe1cPPDiCC",
"amount": 200
}
}

Expand All @@ -40,7 +42,7 @@ Content-Type: application/json
"id": "1",
"method": "stripe.refund",
"params": {
"transactionId": "pi_3NiFSQAmQ4asS8PS0oUkim0t"
"transactionId": "pi_3OCT0JFpQjUWTpHe01ks0b1T"
}
}

Expand All @@ -53,8 +55,8 @@ Content-Type: application/json
"id": "1",
"method": "stripe.refund",
"params": {
"transactionId": "pi_3NkNTgAmQ4asS8PS1b9pWug7",
"refundAmountType": "entityCost"
"transactionId": "pi_3OCTWzFpQjUWTpHe1Fon6ebZ",
"refundAmountType": "revenue"
}
}

Expand Down
30 changes: 25 additions & 5 deletions microservices/payment-stripe/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,8 @@ This microservice provides payments mechanism for stipe.
- `MS_PAYMENT_METHODS` - Payment methods allowed using in Stripe. Default: `'["bancontact", "card"]'`
- `MS_WEBHOOK_KEYS` - Stripe webhook keys to connect ms and work with stripe webhook service. Example: `{"myId":"whsec_c6332a6b339173127d2e6c813112e2f2323b4b2b10eea5ac17c5649a60cf335e"}`
- `MS_PAYOUT_COEFF` - Number for calculating amount of money for transferring to the product owners. Default: `0.3`
- `MS_FEES` - Fees that takes while creating payment intent. Default: `'{ "stableUnit": 30, "paymentPercent": 2.9 }'`
- `MS_FEES` - Fees that takes while creating payment intent. Default: `'{ "stablePaymentUnit": 30, "paymentPercent": 2.9, "stableDisputeFee": 1500, "instantPayoutPercent": 1 }'`
- `MS_TAX` - Tax that takes while creating payment intent. Default: `'{ "defaultPercent": 8, "stableUnit": 50, "autoCalculateFeeUnit": 5 }'`
- [See full list `COMMON ENVIRONMENTS`](https://github.com/Lomray-Software/microservice-helpers#common-environments)

### <a id="how-to-run"></a>HOW TO RUN:
Expand Down Expand Up @@ -89,8 +90,8 @@ stripe listen --forward-to 'http://localhost:3000/webhook/payment-stripe.stripe.
into your hosted endpoint listeners in the Stripe dashboard.
2. For local development: use stripe-cli

### <a id="webhooks"></a>DEFINITIONS:
#### <a id="webhooks"></a>Fees:
### <a id="definitions"></a>DEFINITIONS:
#### <a id="definitions-fees"></a>Fees:
1. Application fees - collected amount by Platform from transaction.
2. Tax - collected taxes (included in application fees).
3. Fee - Platform fee, Stripe fee (included in application fees).
Expand All @@ -101,8 +102,27 @@ into your hosted endpoint listeners in the Stripe dashboard.
7. Base fee - platform + stripe + create tax transaction fee
8. Personal fee - base fee + personal (debit or credit extra fee)

#### <a id="webhooks"></a>Tax:
#### <a id="definitions-tax"></a>Tax:
1. Tax calculation (Tax Calculation API) - calculated by Stripe tax for transaction (e.g. payment intent)
2. Tax transaction (Tax Transaction API) - Stripe tax transaction presented in Stripe Tax reports


### <a id="testing"></a> TESTING:
#### <a id="testing-stripe"></a> STRIPE:
Original reference: https://stripe.com/docs/testing

#### <a id="testing-stripe-cards"></a> CARDS:

Use these test cards to simulate successful payments from North and South America.
1. United States (US) - 4242424242424242 - Visa

To simulate a declined payment with a successfully attached card, use the next one.
1. Decline after attaching - 4000000000000341 - Attaching this card to a Customer object succeeds, but attempts to charge the customer fail.

To simulate a disputed transaction, use the test cards in this section. Then, to simulate winning or losing the dispute, provide winning or losing evidence.
1. Fraudulent - 4000000000000259 - With default account settings, charge succeeds, only to be disputed as fraudulent. This type of dispute is protected after 3D Secure authentication.
2. Not received - 4000000000002685 - With default account settings, charge succeeds, only to be disputed as product not received. This type of dispute isn’t protected after 3D Secure authentication.
3. Inquiry - 4000000000001976 - With default account settings, charge succeeds, only to be disputed as an inquiry.
4. Warning - 4000000000005423 - With default account settings, charge succeeds, only to receive an early fraud warning.
5. Multiple disputes - 4000000404000079 - With default account settings, charge succeeds, only to be disputed multiple times.

Rebuild: 1
3 changes: 2 additions & 1 deletion microservices/payment-stripe/__mocks__/config.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
const configMock = {
fees: {
paymentPercent: 2.9,
stableUnit: 30, // $0.3
stablePaymentUnit: 30, // $0.3
stableDisputeFee: 1500, // $15
},
taxes: {
defaultPercent: 8,
Expand Down
33 changes: 33 additions & 0 deletions microservices/payment-stripe/migrations/1701685975841-dispute.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { MigrationInterface, QueryRunner } from 'typeorm';

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

public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`CREATE TABLE "evidence_details" ("disputeId" uuid NOT NULL, "hasEvidence" boolean NOT NULL DEFAULT false, "submissionCount" integer NOT NULL DEFAULT '0', "dueBy" TIMESTAMP, "isPastBy" boolean NOT NULL, CONSTRAINT "evidence_details(uq):disputeId" UNIQUE ("disputeId"), CONSTRAINT "evidence_details(pk):disputeId" PRIMARY KEY ("disputeId"))`,
);
await queryRunner.query(
`CREATE TYPE "public"."dispute_reason_enum" AS ENUM('bankCannotProcess', 'checkReturned', 'creditNotProcessed', 'customerInitiated', 'debitNotAuthorized', 'duplicate', 'fraudulent', 'general', 'incorrectAccountDetails', 'insufficientFunds', 'productNotReceived', 'productUnacceptable', 'subscriptionCanceled', 'unrecognized')`,
);
await queryRunner.query(
`CREATE TYPE "public"."dispute_status_enum" AS ENUM('warningNeedsResponse', 'warningUnderReview', 'warningClosed', 'needsResponse', 'underReview', 'won', 'lost')`,
);
await queryRunner.query(
`CREATE TABLE "dispute" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "transactionId" character varying(66), "disputeId" character varying(66) NOT NULL, "amount" integer NOT NULL, "chargedAmount" integer NOT NULL DEFAULT '0', "chargedFees" integer NOT NULL DEFAULT '0', "netWorth" integer NOT NULL DEFAULT '0', "reason" "public"."dispute_reason_enum" NOT NULL, "status" "public"."dispute_status_enum" NOT NULL, "params" json NOT NULL DEFAULT '{}', "metadata" json NOT NULL DEFAULT '{}', "createdAt" TIMESTAMP NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP NOT NULL DEFAULT now(), CONSTRAINT "dispute(pk):id" PRIMARY KEY ("id"))`,
);
await queryRunner.query(
`ALTER TABLE "evidence_details" ADD CONSTRAINT "evidence_details(fk):disputeId" FOREIGN KEY ("disputeId") REFERENCES "dispute"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`,
);
}

public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "evidence_details" DROP CONSTRAINT "evidence_details(fk):disputeId"`,
);
await queryRunner.query(`DROP TABLE "dispute"`);
await queryRunner.query(`DROP TYPE "public"."dispute_status_enum"`);
await queryRunner.query(`DROP TYPE "public"."dispute_reason_enum"`);
await queryRunner.query(`DROP TABLE "evidence_details"`);
}
}
Loading

0 comments on commit 1b38cf1

Please sign in to comment.