diff --git a/.eslintignore b/.eslintignore
index 66509f73..0ca95845 100644
--- a/.eslintignore
+++ b/.eslintignore
@@ -7,3 +7,4 @@ dist
.ebextensions
.elasticbeanstalk
coverage
+docs
diff --git a/README.md b/README.md
index 152763b1..6eeb8a67 100644
--- a/README.md
+++ b/README.md
@@ -367,3 +367,4 @@ It's been signed with the secret 'secret'. This secret should match your entry i
- [Permissions Guide](https://github.com/topcoder-platform/tc-project-service/blob/develop/docs/guides/permissions-guide/permissions-guide.md) - what kind of permissions we have, how they work and how to use them.
- [Permissions](https://htmlpreview.github.io/?https://github.com/topcoder-platform/tc-project-service/blob/develop/docs/permissions.html) - the list of all permissions in Project Service.
- [Swagger API Definition](http://editor.swagger.io/?url=https://raw.githubusercontent.com/topcoder-platform/tc-project-service/develop/docs/swagger.yaml) - click to open it via Online Swagger Editor.
+- [Customer Payments](./docs/guides/customer-payments/README.md) - Customer Payments API details.
diff --git a/config/custom-environment-variables.json b/config/custom-environment-variables.json
index 8dd29cf4..5b40e56f 100644
--- a/config/custom-environment-variables.json
+++ b/config/custom-environment-variables.json
@@ -15,7 +15,9 @@
"timelineDocType": "TIMELINES_ES_DOC_TYPE",
"metadataIndexName": "METADATA_ES_INDEX_NAME",
"metadataDocType": "METADATA_ES_DOC_TYPE",
- "metadataDocDefaultId": "METADATA_ES_DOC_DEFAULT_ID"
+ "metadataDocDefaultId": "METADATA_ES_DOC_DEFAULT_ID",
+ "customerPaymentIndexName": "CUSTOMER_PAYMENTS_ES_INDEX_NAME",
+ "customerPaymentDocType": "CUSTOMER_PAYMENT_ES_DOC_TYPE"
},
"pubsubQueueName": "PUBSUB_QUEUE_NAME",
"pubsubExchangeName": "PUBSUB_EXCHANGE_NAME",
@@ -76,5 +78,6 @@
"CLIENT_KEY": "SALESFORCE_CLIENT_KEY",
"SUBJECT": "SALESFORCE_SUBJECT",
"CLIENT_ID": "SALESFORCE_CLIENT_ID"
- }
+ },
+ "STRIPE_SECRET_KEY": "STRIPE_SECRET_KEY"
}
diff --git a/config/default.json b/config/default.json
index 3fdbaa81..ebfb3fc6 100644
--- a/config/default.json
+++ b/config/default.json
@@ -25,7 +25,9 @@
"timelineDocType": "doc",
"metadataIndexName": "metadata",
"metadataDocType": "doc",
- "metadataDocDefaultId": 1
+ "metadataDocDefaultId": 1,
+ "customerPaymentIndexName": "customer_payments",
+ "customerPaymentDocType": "doc"
},
"connectProjectUrl": "",
"dbConfig": {
@@ -83,5 +85,6 @@
"SUBJECT": "",
"CLIENT_ID": ""
},
+ "STRIPE_SECRET_KEY": "",
"sfdcBillingAccountNameField": "Billing_Account_Name__c"
-}
\ No newline at end of file
+}
diff --git a/config/test.json b/config/test.json
index d226b208..8eada42c 100644
--- a/config/test.json
+++ b/config/test.json
@@ -13,7 +13,9 @@
"timelineIndexName": "timelines_test",
"timelineDocType": "doc",
"metadataIndexName": "metadata_test",
- "metadataDocType": "doc"
+ "metadataDocType": "doc",
+ "customerPaymentIndexName": "customer_payments_test",
+ "customerPaymentDocType": "doc"
},
"connectProjectsUrl": "https://local.topcoder-dev.com/projects/",
"dbConfig": {
diff --git a/docs/Project API.postman_collection.json b/docs/Project API.postman_collection.json
index d86f73b0..29675eb6 100644
--- a/docs/Project API.postman_collection.json
+++ b/docs/Project API.postman_collection.json
@@ -1,6 +1,6 @@
{
"info": {
- "_postman_id": "6418ac6e-a797-4e30-b4d3-a1dd0cdead22",
+ "_postman_id": "e80dcb5f-34f8-4b52-83cb-ce96082f9c31",
"name": "Project API",
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
},
@@ -11798,6 +11798,398 @@
"response": []
}
]
+ },
+ {
+ "name": "Customer Payment",
+ "item": [
+ {
+ "name": "Capture and refund",
+ "item": [
+ {
+ "name": "charge customer payment",
+ "event": [
+ {
+ "listen": "test",
+ "script": {
+ "exec": [
+ "pm.test(\"Status code is 200\", function () {",
+ " pm.response.to.have.status(200);",
+ " pm.environment.set(\"captureAndRefundId\", pm.response.json().id);",
+ "})"
+ ],
+ "type": "text/javascript"
+ }
+ }
+ ],
+ "request": {
+ "method": "PATCH",
+ "header": [
+ {
+ "key": "Content-Type",
+ "value": "application/json"
+ },
+ {
+ "key": "Authorization",
+ "value": "Bearer {{jwt-token-admin-40051333}}"
+ }
+ ],
+ "url": {
+ "raw": "{{api-url}}/customer-payments/:id/charge",
+ "host": [
+ "{{api-url}}"
+ ],
+ "path": [
+ "customer-payments",
+ ":id",
+ "charge"
+ ],
+ "variable": [
+ {
+ "key": "id",
+ "value": "8"
+ }
+ ]
+ }
+ },
+ "response": []
+ },
+ {
+ "name": "refund customer payment",
+ "event": [
+ {
+ "listen": "test",
+ "script": {
+ "exec": [
+ "pm.test(\"Status code is 200\", function () {",
+ " pm.response.to.have.status(200);",
+ "})"
+ ],
+ "type": "text/javascript"
+ }
+ }
+ ],
+ "request": {
+ "method": "PATCH",
+ "header": [
+ {
+ "key": "Content-Type",
+ "value": "application/json"
+ },
+ {
+ "key": "Authorization",
+ "value": "Bearer {{jwt-token-admin-40051333}}"
+ }
+ ],
+ "url": {
+ "raw": "{{api-url}}/customer-payments/{{captureAndRefundId}}/refund",
+ "host": [
+ "{{api-url}}"
+ ],
+ "path": [
+ "customer-payments",
+ "{{captureAndRefundId}}",
+ "refund"
+ ]
+ }
+ },
+ "response": []
+ }
+ ]
+ },
+ {
+ "name": "Confirm and capture",
+ "item": [
+ {
+ "name": "confirm customer payment",
+ "event": [
+ {
+ "listen": "test",
+ "script": {
+ "exec": [
+ "pm.test(\"Status code is 200\", function () {",
+ " pm.response.to.have.status(200);",
+ " pm.environment.set(\"confirmAndCaptureId\", pm.response.json().id);",
+ "})"
+ ],
+ "type": "text/javascript"
+ }
+ }
+ ],
+ "request": {
+ "method": "PATCH",
+ "header": [
+ {
+ "key": "Content-Type",
+ "value": "application/json"
+ },
+ {
+ "key": "Authorization",
+ "value": "Bearer {{jwt-token-admin-40051333}}"
+ }
+ ],
+ "url": {
+ "raw": "{{api-url}}/customer-payments/:id/confirm",
+ "host": [
+ "{{api-url}}"
+ ],
+ "path": [
+ "customer-payments",
+ ":id",
+ "confirm"
+ ],
+ "variable": [
+ {
+ "key": "id",
+ "value": "10"
+ }
+ ]
+ }
+ },
+ "response": []
+ },
+ {
+ "name": "charge customer payment",
+ "event": [
+ {
+ "listen": "test",
+ "script": {
+ "exec": [
+ "pm.test(\"Status code is 200\", function () {",
+ " pm.response.to.have.status(200);",
+ "})"
+ ],
+ "type": "text/javascript"
+ }
+ }
+ ],
+ "request": {
+ "method": "PATCH",
+ "header": [
+ {
+ "key": "Content-Type",
+ "value": "application/json"
+ },
+ {
+ "key": "Authorization",
+ "value": "Bearer {{jwt-token-admin-40051333}}"
+ }
+ ],
+ "url": {
+ "raw": "{{api-url}}/customer-payments/{{confirmAndCaptureId}}/charge",
+ "host": [
+ "{{api-url}}"
+ ],
+ "path": [
+ "customer-payments",
+ "{{confirmAndCaptureId}}",
+ "charge"
+ ]
+ }
+ },
+ "response": []
+ }
+ ]
+ },
+ {
+ "name": "Cancel",
+ "item": [
+ {
+ "name": "cancel customer payment",
+ "event": [
+ {
+ "listen": "test",
+ "script": {
+ "exec": [
+ "pm.test(\"Status code is 200\", function () {",
+ " pm.response.to.have.status(200);",
+ "})"
+ ],
+ "type": "text/javascript"
+ }
+ }
+ ],
+ "request": {
+ "method": "PATCH",
+ "header": [
+ {
+ "key": "Content-Type",
+ "value": "application/json"
+ },
+ {
+ "key": "Authorization",
+ "value": "Bearer {{jwt-token-admin-40051333}}"
+ }
+ ],
+ "url": {
+ "raw": "{{api-url}}/customer-payments/:id/cancel",
+ "host": [
+ "{{api-url}}"
+ ],
+ "path": [
+ "customer-payments",
+ ":id",
+ "cancel"
+ ],
+ "variable": [
+ {
+ "key": "id",
+ "value": "9"
+ }
+ ]
+ }
+ },
+ "response": []
+ }
+ ]
+ },
+ {
+ "name": "List customer payment",
+ "event": [
+ {
+ "listen": "test",
+ "script": {
+ "exec": [
+ "pm.test(\"Status code is 200\", function () {",
+ " pm.response.to.have.status(200);",
+ " pm.environment.set(\"customerPaymentId\", pm.response.json()[0].id);",
+ "})"
+ ],
+ "type": "text/javascript"
+ }
+ }
+ ],
+ "request": {
+ "method": "GET",
+ "header": [
+ {
+ "key": "Content-Type",
+ "value": "application/json"
+ },
+ {
+ "key": "Authorization",
+ "value": "Bearer {{jwt-token-admin-40051333}}"
+ }
+ ],
+ "url": {
+ "raw": "{{api-url}}/customer-payments",
+ "host": [
+ "{{api-url}}"
+ ],
+ "path": [
+ "customer-payments"
+ ],
+ "query": [
+ {
+ "key": "reference",
+ "value": "project",
+ "disabled": true
+ },
+ {
+ "key": "referenceId",
+ "value": "1234567",
+ "disabled": true
+ },
+ {
+ "key": "perPage",
+ "value": "1",
+ "disabled": true
+ },
+ {
+ "key": "page",
+ "value": "1",
+ "disabled": true
+ },
+ {
+ "key": "sort",
+ "value": "amount desc",
+ "disabled": true
+ }
+ ]
+ }
+ },
+ "response": []
+ },
+ {
+ "name": "Get customer payment",
+ "event": [
+ {
+ "listen": "test",
+ "script": {
+ "exec": [
+ ""
+ ],
+ "type": "text/javascript"
+ }
+ }
+ ],
+ "request": {
+ "method": "GET",
+ "header": [
+ {
+ "key": "Content-Type",
+ "value": "application/json"
+ },
+ {
+ "key": "Authorization",
+ "value": "Bearer {{jwt-token-admin-40051333}}"
+ }
+ ],
+ "url": {
+ "raw": "{{api-url}}/customer-payments/{{customerPaymentId}}",
+ "host": [
+ "{{api-url}}"
+ ],
+ "path": [
+ "customer-payments",
+ "{{customerPaymentId}}"
+ ]
+ }
+ },
+ "response": []
+ },
+ {
+ "name": "Update customer payment",
+ "event": [
+ {
+ "listen": "test",
+ "script": {
+ "exec": [
+ ""
+ ],
+ "type": "text/javascript"
+ }
+ }
+ ],
+ "request": {
+ "method": "PATCH",
+ "header": [
+ {
+ "key": "Content-Type",
+ "value": "application/json"
+ },
+ {
+ "key": "Authorization",
+ "value": "Bearer {{jwt-token-admin-40051333}}"
+ }
+ ],
+ "body": {
+ "mode": "raw",
+ "raw": "{\n \"reference\": \"project\",\n \"referenceId\": \"123\"\n}"
+ },
+ "url": {
+ "raw": "{{api-url}}/customer-payments/{{customerPaymentId}}",
+ "host": [
+ "{{api-url}}"
+ ],
+ "path": [
+ "customer-payments",
+ "{{customerPaymentId}}"
+ ]
+ }
+ },
+ "response": []
+ }
+ ]
}
]
}
\ No newline at end of file
diff --git a/docs/guides/customer-payments/README.md b/docs/guides/customer-payments/README.md
new file mode 100644
index 00000000..20bcb67f
--- /dev/null
+++ b/docs/guides/customer-payments/README.md
@@ -0,0 +1,55 @@
+# Customer Payments
+
+Customer Payments API is included in the Projects API and uses Stripe under the hood.
+
+## General Idea
+
+Customer Payments API allows customers to create payment holds and we can associate them with any entity like `project`, `challenge`, `phase` and so on. For example, to associated payment with a `project` we could set `reference=project` and `referenceId=12345` (project id) during creating of the payment or after creating using `PATCH /customer-payments/{id}` endpoint.
+
+Then in any Topcoder's API we could retrieve payments associated with any entity using endpoint `GET /customer-payments?reference=project&referenceId=12345`.
+
+See [references](#references) to learn more details about the implementation.
+
+## Deployment Guide
+
+- When deploying Customer Payments API on the server we have to set `STRIPE_SECRET_KEY` with a Stripe's secret key.
+- When implementing client side, we would have to use Public Stripe Key as shown in the [React Demo App](react-stripe-js).
+
+## Quick Demo
+
+The easier way to check it out is to run React Demo App which demonstrate how to use Customer Payments API:
+
+
+
+To run the React Demo App:
+
+1. Run Project Service API
+2. Copy folder [react-stripe-js](./react-stripe-js) OUTSIDE of `tc-project-service` folder, and then run inside `react-stripe-js`:
+
+ ```sh
+ # install dependencies
+ npm i
+
+ # run React Demo App
+ npm start
+ ```
+
+3. Then you can test payments as shown in the [demo video](./assets/react-demo-video.mp4).
+
+## Test Data
+
+Several test cards are available for you to use in test mode to make sure this integration is ready. Use them with any CVC and an expiration date in the future.
+
+NUMBER | DESCRIPTION
+--|--
+4242424242424242 | Succeeds and immediately processes the payment.
+4000002500003155 | Requires authentication. Stripe triggers a modal asking for the customer to authenticate.
+4000000000009995 | Always fails with a decline code of insufficient_funds.
+
+For the full list of test cards see the Stripe's guide on [testing](https://stripe.com/docs/testing).
+
+## References
+
+- [Challenge Specification](https://www.topcoder.com/challenges/25fa2d11-9f69-4d81-9f67-fb2eb15cec6a?tab=details) which was used to implement Customer Payments functionality.
+- [Place a hold on a card](https://stripe.com/docs/payments/capture-later) Stripe's official guide.
+- [Finalize payments on the server](https://stripe.com/docs/payments/accept-a-payment-synchronously) Stripe's official guide.
\ No newline at end of file
diff --git a/docs/guides/customer-payments/assets/react-demo-app.png b/docs/guides/customer-payments/assets/react-demo-app.png
new file mode 100644
index 00000000..903f754d
Binary files /dev/null and b/docs/guides/customer-payments/assets/react-demo-app.png differ
diff --git a/docs/guides/customer-payments/assets/react-demo-video.mp4 b/docs/guides/customer-payments/assets/react-demo-video.mp4
new file mode 100644
index 00000000..ac06c05b
Binary files /dev/null and b/docs/guides/customer-payments/assets/react-demo-video.mp4 differ
diff --git a/docs/guides/customer-payments/react-stripe-js/.gitignore b/docs/guides/customer-payments/react-stripe-js/.gitignore
new file mode 100644
index 00000000..b512c09d
--- /dev/null
+++ b/docs/guides/customer-payments/react-stripe-js/.gitignore
@@ -0,0 +1 @@
+node_modules
\ No newline at end of file
diff --git a/docs/guides/customer-payments/react-stripe-js/package.json b/docs/guides/customer-payments/react-stripe-js/package.json
new file mode 100644
index 00000000..98b3e27c
--- /dev/null
+++ b/docs/guides/customer-payments/react-stripe-js/package.json
@@ -0,0 +1,30 @@
+{
+ "name": "react-stripe-js",
+ "version": "1.0.0",
+ "description": "",
+ "keywords": [],
+ "main": "src/index.js",
+ "dependencies": {
+ "@stripe/react-stripe-js": "1.3.0",
+ "@stripe/stripe-js": "1.3.0",
+ "react": "17.0.1",
+ "react-dom": "17.0.1",
+ "react-router-dom": "5.2.0",
+ "react-scripts": "4.0.3"
+ },
+ "devDependencies": {
+ "typescript": "3.3.3"
+ },
+ "scripts": {
+ "start": "react-scripts start",
+ "build": "react-scripts build",
+ "test": "react-scripts test --env=jsdom",
+ "eject": "react-scripts eject"
+ },
+ "browserslist": [
+ ">0.2%",
+ "not dead",
+ "not ie <= 11",
+ "not op_mini all"
+ ]
+}
diff --git a/docs/guides/customer-payments/react-stripe-js/public/index.html b/docs/guides/customer-payments/react-stripe-js/public/index.html
new file mode 100644
index 00000000..ff7ac3b8
--- /dev/null
+++ b/docs/guides/customer-payments/react-stripe-js/public/index.html
@@ -0,0 +1,44 @@
+
+
+
+
+
+
+
+
+
+
+ React Stripe.js Demo
+
+
+
+
+
+
+
+
diff --git a/docs/guides/customer-payments/react-stripe-js/src/components/ElementDemos.js b/docs/guides/customer-payments/react-stripe-js/src/components/ElementDemos.js
new file mode 100644
index 00000000..ba543c1c
--- /dev/null
+++ b/docs/guides/customer-payments/react-stripe-js/src/components/ElementDemos.js
@@ -0,0 +1,30 @@
+import React from "react";
+import {
+ Switch,
+ Route,
+ Redirect,
+ useLocation,
+ useHistory
+} from "react-router-dom";
+
+const ElementDemos = ({ demos }) => {
+ const location = useLocation();
+ const history = useHistory();
+
+ return (
+
+
+
+ {demos.map(({ path, component: Component }) => (
+
+
+
+
+
+ ))}
+
+
+ );
+};
+
+export default ElementDemos;
diff --git a/docs/guides/customer-payments/react-stripe-js/src/components/demos/SplitForm.js b/docs/guides/customer-payments/react-stripe-js/src/components/demos/SplitForm.js
new file mode 100644
index 00000000..56d3aa63
--- /dev/null
+++ b/docs/guides/customer-payments/react-stripe-js/src/components/demos/SplitForm.js
@@ -0,0 +1,271 @@
+import React, { useMemo, useState } from "react";
+import {
+ useStripe,
+ useElements,
+ CardNumberElement,
+ CardCvcElement,
+ CardExpiryElement
+} from "@stripe/react-stripe-js";
+
+import useResponsiveFontSize from "../../useResponsiveFontSize";
+
+const useOptions = () => {
+ const fontSize = useResponsiveFontSize();
+ const options = useMemo(
+ () => ({
+ style: {
+ base: {
+ fontSize,
+ color: "#424770",
+ letterSpacing: "0.025em",
+ fontFamily: "Source Code Pro, monospace",
+ "::placeholder": {
+ color: "#aab7c4"
+ }
+ },
+ invalid: {
+ color: "#9e2146"
+ }
+ }
+ }),
+ [fontSize]
+ );
+
+ return options;
+};
+
+const SplitForm = () => {
+ const stripe = useStripe();
+ const elements = useElements();
+ const options = useOptions();
+ const [message, setMessage] = useState('');
+ const [submitting, setSubmitting] = useState(false);
+ const [paymentStatus, setPaymentStatus] = useState('');
+ const [customerPaymentId, setCustomerPaymentId] = useState('');
+ const customerToken = "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJyb2xlcyI6WyJUb3Bjb2RlciBVc2VyIl0sImlzcyI6Imh0dHBzOi8vYXBpLnRvcGNvZGVyLWRldi5jb20iLCJoYW5kbGUiOiJ0ZXN0MSIsImV4cCI6MjU2MzA3NjY4OSwidXNlcklkIjoiNDAwNTEzMzMiLCJpYXQiOjE0NjMwNzYwODksImVtYWlsIjoidGVzdEB0b3Bjb2Rlci5jb20iLCJqdGkiOiJiMzNiNzdjZC1iNTJlLTQwZmUtODM3ZS1iZWI4ZTBhZTZhNGEifQ.jl6Lp_friVNwEP8nfsfmL-vrQFzOFp2IfM_HC7AwGcg";
+ const adminToken = "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJyb2xlcyI6WyJUb3Bjb2RlciBVc2VyIiwiYWRtaW5pc3RyYXRvciJdLCJpc3MiOiJodHRwczovL2FwaS50b3Bjb2Rlci1kZXYuY29tIiwiaGFuZGxlIjoidGVzdDEiLCJleHAiOjI1NjMwNzY2ODksInVzZXJJZCI6IjQwMDUxMzMzIiwiaWF0IjoxNDYzMDc2MDg5LCJlbWFpbCI6InRlc3RAdG9wY29kZXIuY29tIiwianRpIjoiYjMzYjc3Y2QtYjUyZS00MGZlLTgzN2UtYmViOGUwYWU2YTRhIn0.wKWUe0-SaiFVN-VR_-GwgFlvWaDkSbc8H55ktb9LAVw";
+
+ const handleSubmit = async event => {
+ event.preventDefault();
+
+ if (!stripe || !elements) {
+ // Stripe.js has not loaded yet. Make sure to disable
+ // form submission until Stripe.js has loaded.
+ return;
+ }
+ setSubmitting(true);
+ setPaymentStatus('');
+
+ // Call stripe api the create payment method, so the card info does not pass to our server.
+ const payload = await stripe.createPaymentMethod({
+ type: "card",
+ card: elements.getElement(CardNumberElement)
+ });
+ console.log("[PaymentMethod]", payload);
+ // Call the server to create the customer payment.
+ const response = await fetch(
+ "http://localhost:8001/v5/customer-payments",
+ {
+ method: "POST",
+ headers: { "Content-Type": "application/json", "Authorization": `Bearer ${customerToken}` },
+ body: JSON.stringify({
+ amount: event.target.amount.value,
+ currency: event.target.currency.value,
+ paymentMethodId: payload.paymentMethod.id,
+ reference: "project",
+ referenceId: "836681",
+ }),
+ }
+ );
+ const customerPayment = await response.json();
+ setCustomerPaymentId(customerPayment.id)
+ console.log("[customerPayment]", customerPayment);
+
+ if (response.status !== 201) {
+ // if the response is not 201, then show the error message.
+ setMessage(`Error: ${customerPayment.message}`);
+ } else if (customerPayment.status === "requires_action") {
+ // if the status is requires_action, then call stripe confirm method to show the payment confirmation modal
+ // since this step need to interact with user, so we just implement it in frond end.
+ const response = await stripe.handleCardAction(
+ customerPayment.clientSecret
+ );
+ if (response.error) {
+ setMessage(`Handle card action error: ${response.error.message}`);
+ } else {
+ const confirmResponse = await fetch(`http://localhost:8001/v5/customer-payments/${customerPayment.id}/confirm`, {
+ method: "PATCH",
+ headers: { "Content-Type": "application/json", "Authorization": `Bearer ${customerToken}`},
+ });
+ const confirmPayment = await confirmResponse.json();
+ console.log(confirmPayment)
+ setPaymentStatus(confirmPayment.status)
+ setMessage(`Current customer payment id: ${confirmPayment.id}, status: ${confirmPayment.status}`);
+ }
+ } else {
+ setPaymentStatus(customerPayment.status)
+ // if the status is not requires_action, then show the customer payment id directly.
+ setMessage(`Current customer payment id: ${customerPayment.id}, status: ${customerPayment.status}`);
+ }
+ setSubmitting(false);
+ };
+
+ const handleCharge = async event => {
+ setPaymentStatus('');
+ const response = await fetch(
+ `http://localhost:8001/v5/customer-payments/${customerPaymentId}/charge`,
+ {
+ method: "PATCH",
+ headers: {
+ "Content-Type": "application/json",
+ Authorization: `Bearer ${adminToken}`,
+ },
+ }
+ );
+ const customerPayment = await response.json();
+ setPaymentStatus(customerPayment.status);
+ setMessage(
+ `Current customer payment id: ${customerPayment.id}, status: ${customerPayment.status}`
+ );
+ };
+
+ const handleCancel = async event => {
+ setPaymentStatus("");
+ const response = await fetch(
+ `http://localhost:8001/v5/customer-payments/${customerPaymentId}/cancel`,
+ {
+ method: "PATCH",
+ headers: {
+ "Content-Type": "application/json",
+ Authorization: `Bearer ${adminToken}`,
+ },
+ }
+ );
+ const customerPayment = await response.json();
+ setPaymentStatus(customerPayment.status);
+ setMessage(
+ `Current customer payment id: ${customerPayment.id}, status: ${customerPayment.status}`
+ );
+ };
+
+ const handleRefund = async (event) => {
+ setPaymentStatus("");
+ const response = await fetch(
+ `http://localhost:8001/v5/customer-payments/${customerPaymentId}/refund`,
+ {
+ method: "PATCH",
+ headers: {
+ "Content-Type": "application/json",
+ Authorization: `Bearer ${adminToken}`,
+ },
+ }
+ );
+ const customerPayment = await response.json();
+ setPaymentStatus(customerPayment.status);
+ setMessage(
+ `Current customer payment id: ${customerPayment.id}, status: ${customerPayment.status}`
+ );
+ };
+ return (
+
+ );
+};
+
+export default SplitForm;
diff --git a/docs/guides/customer-payments/react-stripe-js/src/index.js b/docs/guides/customer-payments/react-stripe-js/src/index.js
new file mode 100644
index 00000000..d04ea4d3
--- /dev/null
+++ b/docs/guides/customer-payments/react-stripe-js/src/index.js
@@ -0,0 +1,36 @@
+import React from "react";
+import ReactDOM from "react-dom";
+
+import { loadStripe } from "@stripe/stripe-js";
+import { Elements } from "@stripe/react-stripe-js";
+import { BrowserRouter } from "react-router-dom";
+
+import ElementDemos from "./components/ElementDemos";
+import SplitForm from "./components/demos/SplitForm";
+
+import "./styles.css";
+
+// set the public stripe key
+const stripePromise = loadStripe("pk_test_rfcS49MHRVUKomQ9JgSH7Xqz", { apiVersion: "2020-08-27" });
+
+const demos = [
+ {
+ path: "/split-card-elements",
+ label: "Split Card Elements",
+ component: SplitForm
+ }
+];
+
+const App = () => {
+ return (
+
+
+
+
+
+ );
+};
+
+const rootElement = document.getElementById("root");
+
+ReactDOM.render(, rootElement);
diff --git a/docs/guides/customer-payments/react-stripe-js/src/styles.css b/docs/guides/customer-payments/react-stripe-js/src/styles.css
new file mode 100644
index 00000000..a5b2d238
--- /dev/null
+++ b/docs/guides/customer-payments/react-stripe-js/src/styles.css
@@ -0,0 +1,155 @@
+* {
+ box-sizing: border-box;
+}
+
+body,
+html {
+ background-color: #f6f9fc;
+ font-size: 18px;
+ font-family: Helvetica Neue, Helvetica, Arial, sans-serif;
+ margin: 0;
+}
+
+.DemoPickerWrapper {
+ padding: 0 12px;
+ font-family: "Source Code Pro", monospace;
+ box-shadow: 0 4px 6px rgba(50, 50, 93, 0.11), 0 1px 3px rgba(0, 0, 0, 0.08);
+ border-radius: 3px;
+ background: white;
+ margin: 24px 0 48px;
+ width: 100%;
+}
+
+.DemoPicker {
+ font-size: 18px;
+ border-radius: 3px;
+ background-color: white;
+ height: 48px;
+ font-family: Helvetica Neue, Helvetica, Arial, sans-serif;
+ border: 0;
+ width: 100%;
+ color: #6772e5;
+ outline: none;
+ background: transparent;
+
+ -webkit-appearance: none;
+}
+
+.DemoWrapper {
+ margin: 0 auto;
+ max-width: 500px;
+ padding: 0 24px;
+ display: flex;
+ flex-direction: column;
+ height: 100vh;
+}
+
+.Demo {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ padding-bottom: 40%;
+}
+
+label {
+ color: #6b7c93;
+ font-weight: 300;
+ letter-spacing: 0.025em;
+}
+
+button {
+ white-space: nowrap;
+ border: 0;
+ outline: 0;
+ display: inline-block;
+ height: 40px;
+ line-height: 40px;
+ padding: 0 14px;
+ box-shadow: 0 4px 6px rgba(50, 50, 93, 0.11), 0 1px 3px rgba(0, 0, 0, 0.08);
+ color: #fff;
+ border-radius: 4px;
+ font-size: 15px;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.025em;
+ background-color: #6772e5;
+ text-decoration: none;
+ -webkit-transition: all 150ms ease;
+ transition: all 150ms ease;
+ margin-top: 10px;
+}
+
+button:hover {
+ color: #fff;
+ cursor: pointer;
+ background-color: #7795f8;
+ transform: translateY(-1px);
+ box-shadow: 0 7px 14px rgba(50, 50, 93, 0.1), 0 3px 6px rgba(0, 0, 0, 0.08);
+}
+
+button:disabled,
+button[disabled]{
+ border: 1px solid #999999;
+ background-color: #cccccc;
+ color: #666666;
+}
+
+.buttonContainer {
+ margin: 0 auto;
+ display: flex;
+ justify-content: space-between;
+}
+
+input,
+.StripeElement {
+ display: block;
+ margin: 10px 0 20px 0;
+ max-width: 500px;
+ padding: 10px 14px;
+ font-size: 1em;
+ font-family: "Source Code Pro", monospace;
+ box-shadow: rgba(50, 50, 93, 0.14902) 0px 1px 3px,
+ rgba(0, 0, 0, 0.0196078) 0px 1px 0px;
+ border: 0;
+ outline: 0;
+ border-radius: 4px;
+ background: white;
+}
+
+input::placeholder {
+ color: #aab7c4;
+}
+
+input:focus,
+.StripeElement--focus {
+ box-shadow: rgba(50, 50, 93, 0.109804) 0px 4px 6px,
+ rgba(0, 0, 0, 0.0784314) 0px 1px 3px;
+ -webkit-transition: all 150ms ease;
+ transition: all 150ms ease;
+}
+
+.StripeElement.IdealBankElement,
+.StripeElement.FpxBankElement,
+.StripeElement.PaymentRequestButton {
+ padding: 0;
+}
+
+.StripeElement.PaymentRequestButton {
+ height: 40px;
+}
+
+select {
+ display: block;
+ margin: 10px 0 20px 0;
+ max-width: 500px;
+ padding: 10px 14px;
+ font-size: 1em;
+ font-family: "Source Code Pro", monospace;
+ box-shadow: rgba(50, 50, 93, 0.14902) 0px 1px 3px,
+ rgba(0, 0, 0, 0.0196078) 0px 1px 0px;
+ border: 0;
+ outline: 0;
+ border-radius: 4px;
+ background: white;
+}
diff --git a/docs/guides/customer-payments/react-stripe-js/src/useResponsiveFontSize.js b/docs/guides/customer-payments/react-stripe-js/src/useResponsiveFontSize.js
new file mode 100644
index 00000000..12dad14e
--- /dev/null
+++ b/docs/guides/customer-payments/react-stripe-js/src/useResponsiveFontSize.js
@@ -0,0 +1,20 @@
+import { useEffect, useState } from "react";
+
+export default function useResponsiveFontSize() {
+ const getFontSize = () => (window.innerWidth < 450 ? "16px" : "18px");
+ const [fontSize, setFontSize] = useState(getFontSize);
+
+ useEffect(() => {
+ const onResize = () => {
+ setFontSize(getFontSize());
+ };
+
+ window.addEventListener("resize", onResize);
+
+ return () => {
+ window.removeEventListener("resize", onResize);
+ };
+ });
+
+ return fontSize;
+}
diff --git a/docs/swagger.yaml b/docs/swagger.yaml
index 68fcf6ed..a370f63d 100644
--- a/docs/swagger.yaml
+++ b/docs/swagger.yaml
@@ -4873,6 +4873,340 @@ paths:
schema:
$ref: "#/definitions/ErrorModel"
+ /customer-payments:
+ get:
+ tags:
+ - customer payment
+ operationId: findCustomerPayments
+ security:
+ - Bearer: []
+ description: Retrieve customerPayments
+ parameters:
+ - $ref: "#/parameters/pageParam"
+ - $ref: "#/parameters/perPageParam"
+ - name: reference
+ required: false
+ type: string
+ in: query
+ description: the reference filter
+ - name: referenceId
+ required: false
+ type: string
+ in: query
+ description: the reference id filter
+ - name: status
+ required: false
+ type: string
+ enum:
+ - canceled
+ - processing
+ - requires_action
+ - requires_capture
+ - requires_confirmation
+ - requires_payment_method
+ - succeeded
+ - refunded
+ - refund_failed
+ - refund_pending
+ in: query
+ description: the customer payment status filter
+ - name: createdBy
+ required: false
+ type: integer
+ format: int64
+ in: query
+ description: customer payment createdBy filter
+ - name: sort
+ required: false
+ description: >
+ sort customerPayments by amount, currency, status, createdAt, createdBy, updatedAt, updatedBy. Default
+ is createdAt asc
+ in: query
+ type: string
+ responses:
+ "200":
+ description: A list of customerPayments
+ schema:
+ type: array
+ items:
+ $ref: "#/definitions/CustomerPayment"
+ headers:
+ X-Next-Page:
+ type: integer
+ description: The index of the next page
+ X-Page:
+ type: integer
+ description: The index of the current page (starting at 1)
+ X-Per-Page:
+ type: integer
+ description: The number of items to list per page
+ X-Prev-Page:
+ type: integer
+ description: The index of the previous page
+ X-Total:
+ type: integer
+ description: The total number of items
+ X-Total-Pages:
+ type: integer
+ description: The total number of pages
+ Link:
+ type: string
+ description: Pagination link header.
+ "401":
+ description: Unauthorized
+ schema:
+ $ref: "#/definitions/ErrorModel"
+ "403":
+ description: Forbidden
+ schema:
+ $ref: "#/definitions/ErrorModel"
+ "400":
+ description: Bad request
+ schema:
+ $ref: "#/definitions/ErrorModel"
+ "500":
+ description: Internal Server Error
+ schema:
+ $ref: "#/definitions/ErrorModel"
+
+ post:
+ tags:
+ - customer payment
+ operationId: addCustomerPayment
+ security:
+ - Bearer: []
+ description: >-
+ Create a customerPayment. All users can access this endpoint.
+ parameters:
+ - in: body
+ name: body
+ required: true
+ schema:
+ $ref: "#/definitions/CustomerPaymentRequest"
+ responses:
+ "200":
+ description: Returns the newly created customerPayment
+ schema:
+ $ref: "#/definitions/CustomerPayment"
+ "401":
+ description: Unauthorized
+ schema:
+ $ref: "#/definitions/ErrorModel"
+ "403":
+ description: Forbidden
+ schema:
+ $ref: "#/definitions/ErrorModel"
+ "400":
+ description: Bad request
+ schema:
+ $ref: "#/definitions/ErrorModel"
+ "500":
+ description: Internal Server Error
+ schema:
+ $ref: "#/definitions/ErrorModel"
+ "/customer-payments/{id}":
+ get:
+ tags:
+ - customer payment
+ description: >-
+ Retrieve customerPayment by id
+ security:
+ - Bearer: []
+ responses:
+ "200":
+ description: a customerPayment
+ schema:
+ $ref: "#/definitions/CustomerPayment"
+ "401":
+ description: Unauthorized
+ schema:
+ $ref: "#/definitions/ErrorModel"
+ "403":
+ description: Forbidden
+ schema:
+ $ref: "#/definitions/ErrorModel"
+ "404":
+ description: Not found
+ schema:
+ $ref: "#/definitions/ErrorModel"
+ "400":
+ description: Bad request
+ schema:
+ $ref: "#/definitions/ErrorModel"
+ "500":
+ description: Internal Server Error
+ schema:
+ $ref: "#/definitions/ErrorModel"
+ parameters:
+ - $ref: "#/parameters/customerPaymentIdParam"
+ operationId: getCustomerPayment
+ patch:
+ tags:
+ - customer payment
+ operationId: updateCustomerPayment
+ security:
+ - Bearer: []
+ description: >-
+ Update a customer payment
+ parameters:
+ - $ref: "#/parameters/customerPaymentIdParam"
+ - in: body
+ name: body
+ required: true
+ schema:
+ $ref: "#/definitions/UpdateCustomerPayment"
+ responses:
+ "200":
+ description: A updated customerPayment
+ schema:
+ $ref: "#/definitions/CustomerPayment"
+ "401":
+ description: Unauthorized
+ schema:
+ $ref: "#/definitions/ErrorModel"
+ "403":
+ description: Forbidden
+ schema:
+ $ref: "#/definitions/ErrorModel"
+ "400":
+ description: Bad request
+ schema:
+ $ref: "#/definitions/ErrorModel"
+ "500":
+ description: Internal Server Error
+ schema:
+ $ref: "#/definitions/ErrorModel"
+
+ "/customer-payments/{id}/confirm":
+ parameters:
+ - $ref: "#/parameters/customerPaymentIdParam"
+ patch:
+ tags:
+ - customer payment
+ operationId: confirmCustomerPayment
+ security:
+ - Bearer: []
+ description: >-
+ Confirm a customer payment
+ responses:
+ "200":
+ description: A updated customerPayment
+ schema:
+ $ref: "#/definitions/CustomerPayment"
+ "401":
+ description: Unauthorized
+ schema:
+ $ref: "#/definitions/ErrorModel"
+ "403":
+ description: Forbidden
+ schema:
+ $ref: "#/definitions/ErrorModel"
+ "400":
+ description: Bad request
+ schema:
+ $ref: "#/definitions/ErrorModel"
+ "500":
+ description: Internal Server Error
+ schema:
+ $ref: "#/definitions/ErrorModel"
+ "/customer-payments/{id}/charge":
+ parameters:
+ - $ref: "#/parameters/customerPaymentIdParam"
+ patch:
+ tags:
+ - customer payment
+ operationId: chargeCustomerPayment
+ security:
+ - Bearer: []
+ description: >-
+ Charge a customer payment
+ responses:
+ "200":
+ description: A updated customerPayment
+ schema:
+ $ref: "#/definitions/CustomerPayment"
+ "401":
+ description: Unauthorized
+ schema:
+ $ref: "#/definitions/ErrorModel"
+ "403":
+ description: Forbidden
+ schema:
+ $ref: "#/definitions/ErrorModel"
+ "400":
+ description: Bad request
+ schema:
+ $ref: "#/definitions/ErrorModel"
+ "500":
+ description: Internal Server Error
+ schema:
+ $ref: "#/definitions/ErrorModel"
+ "/customer-payments/{id}/cancel":
+ parameters:
+ - $ref: "#/parameters/customerPaymentIdParam"
+ patch:
+ tags:
+ - customer payment
+ operationId: cancelCustomerPayment
+ security:
+ - Bearer: []
+ description: >-
+ Cancel a customer payment
+ responses:
+ "200":
+ description: A updated customerPayment
+ schema:
+ $ref: "#/definitions/CustomerPayment"
+ "401":
+ description: Unauthorized
+ schema:
+ $ref: "#/definitions/ErrorModel"
+ "403":
+ description: Forbidden
+ schema:
+ $ref: "#/definitions/ErrorModel"
+ "400":
+ description: Bad request
+ schema:
+ $ref: "#/definitions/ErrorModel"
+ "500":
+ description: Internal Server Error
+ schema:
+ $ref: "#/definitions/ErrorModel"
+ "/customer-payments/{id}/refund":
+ parameters:
+ - $ref: "#/parameters/customerPaymentIdParam"
+ patch:
+ tags:
+ - customer payment
+ operationId: refundCustomerPayment
+ security:
+ - Bearer: []
+ description: >-
+ Refund a customer payment
+ responses:
+ "200":
+ description: A updated customerPayment
+ schema:
+ $ref: "#/definitions/CustomerPayment"
+ "401":
+ description: Unauthorized
+ schema:
+ $ref: "#/definitions/ErrorModel"
+ "403":
+ description: Forbidden
+ schema:
+ $ref: "#/definitions/ErrorModel"
+ "400":
+ description: Bad request
+ schema:
+ $ref: "#/definitions/ErrorModel"
+ "500":
+ description: Internal Server Error
+ schema:
+ $ref: "#/definitions/ErrorModel"
+
+
parameters:
projectIdParam:
name: projectId
@@ -4981,6 +5315,13 @@ parameters:
required: true
type: integer
format: int64
+ customerPaymentIdParam:
+ name: id
+ in: path
+ description: customer payment id
+ required: true
+ type: integer
+ format: int64
pageParam:
name: page
in: query
@@ -7191,3 +7532,90 @@ definitions:
format: int64
description: READ-ONLY. User that last updated this object
readOnly: true
+ CustomerPaymentRequest:
+ title: CustomerPayment request object
+ type: object
+ required:
+ - amount
+ - paymentMethodId
+ properties:
+ amount:
+ type: integer
+ description: the customer payment amount
+ paymentMethodId:
+ type: string
+ description: the payment method id
+ currency:
+ type: string
+ description: the customer payment currency
+ reference:
+ type: string
+ description: the customer payment reference
+ referenceId:
+ type: string
+ description: >-
+ the customer payment reference id (corresponding to
+ the `reference`)
+ UpdateCustomerPayment:
+ type: object
+ properties:
+ reference:
+ type: string
+ description: the customer payment reference
+ referenctId:
+ type: string
+ description: >-
+ the customer payment reference id (corresponding to
+ the `reference`)
+ CustomerPayment:
+ title: CustomerPayment object
+ allOf:
+ - type: object
+ required:
+ - id
+ - status
+ - createdAt
+ - createdBy
+ - updatedAt
+ - updatedBy
+ properties:
+ id:
+ type: number
+ format: int64
+ description: the id
+ clientSecret:
+ type: string
+ description: it's for client to auth confirm
+ status:
+ type: string
+ enum:
+ - canceled
+ - processing
+ - requires_action
+ - requires_capture
+ - requires_confirmation
+ - requires_payment_method
+ - succeeded
+ - refunded
+ - refund_failed
+ - refund_pending
+ description: the customer payment status
+ createdAt:
+ type: string
+ description: Datetime (GMT) when object was created
+ readOnly: true
+ createdBy:
+ type: integer
+ format: int64
+ description: READ-ONLY. User who created this object
+ readOnly: true
+ updatedAt:
+ type: string
+ description: READ-ONLY. Datetime (GMT) when object was updated
+ readOnly: true
+ updatedBy:
+ type: integer
+ format: int64
+ description: READ-ONLY. User that last updated this object
+ readOnly: true
+ - $ref: "#/definitions/CustomerPaymentRequest"
diff --git a/migrations/20212801_customer_payment.sql b/migrations/20212801_customer_payment.sql
new file mode 100644
index 00000000..9fbaea00
--- /dev/null
+++ b/migrations/20212801_customer_payment.sql
@@ -0,0 +1,33 @@
+--
+-- CREATE NEW TABLE:
+-- customer_payments
+--
+CREATE TABLE customer_payments (
+ id bigint NOT NULL,
+ reference character varying(45),
+ "referenceId" character varying(255),
+ amount integer NOT NULL,
+ currency character varying(16) NOT NULL,
+ "paymentIntentId" character varying(255) NOT NULL,
+ "clientSecret" character varying(255),
+ status character varying(64) NOT NULL,
+ "createdAt" timestamp with time zone,
+ "updatedAt" timestamp with time zone,
+ "createdBy" bigint NOT NULL,
+ "updatedBy" bigint NOT NULL,
+ "deletedAt" timestamp with time zone
+);
+
+CREATE SEQUENCE public.customer_payments_id_seq
+ START WITH 1
+ INCREMENT BY 1
+ NO MINVALUE
+ NO MAXVALUE
+ CACHE 1;
+
+ALTER SEQUENCE customer_payments_id_seq OWNED BY customer_payments.id;
+
+ALTER TABLE ONLY customer_payments ALTER COLUMN id SET DEFAULT nextval('customer_payments_id_seq');
+
+ALTER TABLE ONLY customer_payments
+ ADD CONSTRAINT customer_payments_pkey PRIMARY KEY (id);
diff --git a/migrations/elasticsearch_sync.js b/migrations/elasticsearch_sync.js
index cc20a024..e392364a 100644
--- a/migrations/elasticsearch_sync.js
+++ b/migrations/elasticsearch_sync.js
@@ -19,9 +19,10 @@ import { INDEX_TO_DOC_TYPE } from '../src/utils/es-config';
const ES_PROJECT_INDEX = config.get('elasticsearchConfig.indexName');
const ES_TIMELINE_INDEX = config.get('elasticsearchConfig.timelineIndexName');
const ES_METADATA_INDEX = config.get('elasticsearchConfig.metadataIndexName');
+const ES_CUSTOMER_PAYMENT_INDEX = config.get('elasticsearchConfig.customerPaymentIndexName');
// all indexes supported by this script
-const supportedIndexes = [ES_PROJECT_INDEX, ES_TIMELINE_INDEX, ES_METADATA_INDEX];
+const supportedIndexes = [ES_PROJECT_INDEX, ES_TIMELINE_INDEX, ES_METADATA_INDEX, ES_CUSTOMER_PAYMENT_INDEX];
/**
* Sync elasticsearch indices.
diff --git a/package-lock.json b/package-lock.json
index 55a9bf14..6f7e7dd6 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -8823,6 +8823,15 @@
"integrity": "sha1-PFMZQukIwml8DsNEhYwobHygpgo=",
"dev": true
},
+ "stripe": {
+ "version": "8.195.0",
+ "resolved": "https://registry.npmjs.org/stripe/-/stripe-8.195.0.tgz",
+ "integrity": "sha512-pXEZFNJb4p9uZ69+B4A+zJEmBiFw3BzNG51ctPxUZij7ghFTnk2/RuUHmSGto2XVCcC46uG75czXVAvCUkOGtQ==",
+ "requires": {
+ "@types/node": ">=8.1.0",
+ "qs": "^6.6.0"
+ }
+ },
"superagent": {
"version": "3.8.3",
"resolved": "https://registry.npmjs.org/superagent/-/superagent-3.8.3.tgz",
diff --git a/package.json b/package.json
index f7b398d3..79065802 100644
--- a/package.json
+++ b/package.json
@@ -73,6 +73,7 @@
"pg": "^7.11.0",
"pg-native": "^3.0.0",
"sequelize": "^5.8.7",
+ "stripe": "^8.195.0",
"swagger-ui-express": "^4.0.6",
"tc-core-library-js": "github:appirio-tech/tc-core-library-js#v2.6.6",
"traverse": "^0.6.6",
diff --git a/src/constants.js b/src/constants.js
index ad7ba73e..18a9a19d 100644
--- a/src/constants.js
+++ b/src/constants.js
@@ -131,6 +131,10 @@ export const EVENT = {
PROJECT_TEMPLATE_CREATED: 'project.template.created',
PROJECT_TEMPLATE_UPDATED: 'project.template.updated',
PROJECT_TEMPLATE_DELETED: 'project.template.deleted',
+
+ // customer payment
+ CUSTOMER_PAYMENT_CREATED: 'customer.payment.created',
+ CUSTOMER_PAYMENT_UPDATED: 'customer.payment.updated',
},
};
@@ -181,6 +185,10 @@ export const BUS_API_EVENT = {
PROJECT_METADATA_CREATE: 'project.action.create',
PROJECT_METADATA_UPDATE: 'project.action.update',
PROJECT_METADATA_DELETE: 'project.action.delete',
+
+ // Customer Payment
+ CUSTOMER_PAYMENT_CREATE: 'project.action.create',
+ CUSTOMER_PAYMENT_UPDATE: 'project.action.update',
};
export const CONNECT_NOTIFICATION_EVENT = {
@@ -288,6 +296,11 @@ export const M2M_SCOPES = {
READ: 'read:project-invites',
WRITE: 'write:project-invites',
},
+ CUSTOMER_PAYMENT: {
+ ALL: 'all:customer-payments',
+ READ: 'read:customer-payments',
+ WRITE: 'write:customer-payments',
+ },
};
export const TIMELINE_REFERENCES = {
@@ -374,9 +387,171 @@ export const RESOURCES = {
MILESTONE: 'milestone',
MILESTONE_TEMPLATE: 'milestone.template',
ATTACHMENT: 'attachment',
+ CUSTOMER_PAYMENT: 'customer-payment',
};
export const ATTACHMENT_TYPES = {
FILE: 'file',
LINK: 'link',
};
+
+export const CUSTOMER_PAYMENT_STATUS = {
+ CANCELED: 'canceled',
+ PROCESSING: 'processing',
+ REQUIRES_ACTION: 'requires_action',
+ REQUIRES_CAPTURE: 'requires_capture',
+ REQUIRES_CONFIRMATION: 'requires_confirmation',
+ REQUIRES_PAYMENT_METHOD: 'requires_payment_method',
+ SUCCEEDED: 'succeeded',
+ REFUNDED: 'refunded',
+ REFUND_FAILED: 'refund_failed',
+ REFUND_PENDING: 'refund_pending',
+};
+
+export const STRIPE_CONSTANT = {
+ PAYMENT_STATE_ERROR_CODE: 'payment_intent_unexpected_state',
+ CAPTURE_METHOD: 'manual',
+ CONFIRMATION_METHOD: 'manual',
+ REFUNDED_SUCCEEDED: 'succeeded',
+ REFUNDED_PENDING: 'pending',
+ REFUNDED_FAILED: 'failed',
+};
+
+export const CUSTOMER_PAYMENT_CURRENCY = {
+ USD: 'USD',
+ AED: 'AED',
+ AFN: 'AFN',
+ ALL: 'ALL',
+ AMD: 'AMD',
+ ANG: 'ANG',
+ AOA: 'AOA',
+ ARS: 'ARS',
+ AUD: 'AUD',
+ AWG: 'AWG',
+ AZN: 'AZN',
+ BAM: 'BAM',
+ BBD: 'BBD',
+ BDT: 'BDT',
+ BGN: 'BGN',
+ BIF: 'BIF',
+ BMD: 'BMD',
+ BND: 'BND',
+ BOB: 'BOB',
+ BRL: 'BRL',
+ BSD: 'BSD',
+ BWP: 'BWP',
+ BYN: 'BYN',
+ BZD: 'BZD',
+ CAD: 'CAD',
+ CDF: 'CDF',
+ CHF: 'CHF',
+ CLP: 'CLP',
+ CNY: 'CNY',
+ COP: 'COP',
+ CRC: 'CRC',
+ CVE: 'CVE',
+ CZK: 'CZK',
+ DJF: 'DJF',
+ DKK: 'DKK',
+ DOP: 'DOP',
+ DZD: 'DZD',
+ EGP: 'EGP',
+ ETB: 'ETB',
+ EUR: 'EUR',
+ FJD: 'FJD',
+ FKP: 'FKP',
+ GBP: 'GBP',
+ GEL: 'GEL',
+ GIP: 'GIP',
+ GMD: 'GMD',
+ GNF: 'GNF',
+ GTQ: 'GTQ',
+ GYD: 'GYD',
+ HKD: 'HKD',
+ HNL: 'HNL',
+ HRK: 'HRK',
+ HTG: 'HTG',
+ HUF: 'HUF',
+ IDR: 'IDR',
+ ILS: 'ILS',
+ INR: 'INR',
+ ISK: 'ISK',
+ JMD: 'JMD',
+ JPY: 'JPY',
+ KES: 'KES',
+ KGS: 'KGS',
+ KHR: 'KHR',
+ KMF: 'KMF',
+ KRW: 'KRW',
+ KYD: 'KYD',
+ KZT: 'KZT',
+ LAK: 'LAK',
+ LBP: 'LBP',
+ LKR: 'LKR',
+ LRD: 'LRD',
+ LSL: 'LSL',
+ MAD: 'MAD',
+ MDL: 'MDL',
+ MGA: 'MGA',
+ MKD: 'MKD',
+ MMK: 'MMK',
+ MNT: 'MNT',
+ MOP: 'MOP',
+ MRO: 'MRO',
+ MUR: 'MUR',
+ MVR: 'MVR',
+ MWK: 'MWK',
+ MXN: 'MXN',
+ MYR: 'MYR',
+ MZN: 'MZN',
+ NAD: 'NAD',
+ NGN: 'NGN',
+ NIO: 'NIO',
+ NOK: 'NOK',
+ NPR: 'NPR',
+ NZD: 'NZD',
+ PAB: 'PAB',
+ PEN: 'PEN',
+ PGK: 'PGK',
+ PHP: 'PHP',
+ PKR: 'PKR',
+ PLN: 'PLN',
+ PYG: 'PYG',
+ QAR: 'QAR',
+ RON: 'RON',
+ RSD: 'RSD',
+ RUB: 'RUB',
+ RWF: 'RWF',
+ SAR: 'SAR',
+ SBD: 'SBD',
+ SCR: 'SCR',
+ SEK: 'SEK',
+ SGD: 'SGD',
+ SHP: 'SHP',
+ SLL: 'SLL',
+ SOS: 'SOS',
+ SRD: 'SRD',
+ STD: 'STD',
+ SZL: 'SZL',
+ THB: 'THB',
+ TJS: 'TJS',
+ TOP: 'TOP',
+ TRY: 'TRY',
+ TTD: 'TTD',
+ TWD: 'TWD',
+ TZS: 'TZS',
+ UAH: 'UAH',
+ UGX: 'UGX',
+ UYU: 'UYU',
+ UZS: 'UZS',
+ VND: 'VND',
+ VUV: 'VUV',
+ WST: 'WST',
+ XAF: 'XAF',
+ XCD: 'XCD',
+ XOF: 'XOF',
+ XPF: 'XPF',
+ YER: 'YER',
+ ZAR: 'ZAR',
+ ZMW: 'ZMW',
+};
diff --git a/src/events/busApi.js b/src/events/busApi.js
index 4e51c98f..c2bd666d 100644
--- a/src/events/busApi.js
+++ b/src/events/busApi.js
@@ -1085,4 +1085,22 @@ module.exports = (app, logger) => {
createEvent(BUS_API_EVENT.PROJECT_MEMBER_INVITE_REMOVED, resource, logger);
});
+
+ /**
+ * CUSTOMER_PAYMENT_CREATED
+ */
+ app.on(EVENT.ROUTING_KEY.CUSTOMER_PAYMENT_CREATED, ({ req, resource }) => { // eslint-disable-line no-unused-vars
+ logger.debug('receive CUSTOMER_PAYMENT_CREATED event');
+
+ createEvent(BUS_API_EVENT.CUSTOMER_PAYMENT_CREATE, resource, logger);
+ });
+
+ /**
+ * CUSTOMER_PAYMENT_UPDATED
+ */
+ app.on(EVENT.ROUTING_KEY.CUSTOMER_PAYMENT_UPDATED, ({ req, resource }) => { // eslint-disable-line no-unused-vars
+ logger.debug('receive CUSTOMER_PAYMENT_UPDATED event');
+
+ createEvent(BUS_API_EVENT.CUSTOMER_PAYMENT_UPDATE, resource, logger);
+ });
};
diff --git a/src/models/CustomerPayment.js b/src/models/CustomerPayment.js
new file mode 100644
index 00000000..3d9f9d22
--- /dev/null
+++ b/src/models/CustomerPayment.js
@@ -0,0 +1,44 @@
+/* eslint-disable valid-jsdoc */
+
+/**
+ * The CustomerPayment model
+ */
+import _ from 'lodash';
+import { CUSTOMER_PAYMENT_STATUS, CUSTOMER_PAYMENT_CURRENCY } from '../constants';
+
+module.exports = (sequelize, DataTypes) => {
+ const CustomerPayment = sequelize.define('CustomerPayment', {
+ id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true },
+ reference: { type: DataTypes.STRING(45), allowNull: true },
+ referenceId: { type: DataTypes.STRING(255), allowNull: true },
+ amount: { type: DataTypes.INTEGER, allowNull: false },
+ currency: {
+ type: DataTypes.STRING(16),
+ allowNull: false,
+ validate: {
+ isIn: [_.values(CUSTOMER_PAYMENT_CURRENCY)],
+ },
+ },
+ paymentIntentId: { type: DataTypes.STRING(255), allowNull: false },
+ clientSecret: { type: DataTypes.STRING(255), allowNull: true },
+ status: {
+ type: DataTypes.STRING(64),
+ allowNull: false,
+ validate: {
+ isIn: [_.values(CUSTOMER_PAYMENT_STATUS)],
+ },
+ },
+ createdAt: { type: DataTypes.DATE, defaultValue: DataTypes.NOW },
+ updatedAt: { type: DataTypes.DATE, defaultValue: DataTypes.NOW },
+ createdBy: { type: DataTypes.BIGINT, allowNull: false },
+ updatedBy: { type: DataTypes.BIGINT, allowNull: false },
+ }, {
+ tableName: 'customer_payments',
+ paranoid: true,
+ timestamps: true,
+ updatedAt: 'updatedAt',
+ createdAt: 'createdAt',
+ deletedAt: 'deletedAt',
+ });
+ return CustomerPayment;
+};
diff --git a/src/permissions/constants.js b/src/permissions/constants.js
index 31ad8979..1d7949b2 100644
--- a/src/permissions/constants.js
+++ b/src/permissions/constants.js
@@ -144,6 +144,16 @@ const SCOPES_PROJECT_INVITES_WRITE = [
M2M_SCOPES.PROJECT_INVITES.WRITE,
];
+const SCOPES_CUSTOMER_PAYMENT_WRITE = [
+ M2M_SCOPES.CUSTOMER_PAYMENT.ALL,
+ M2M_SCOPES.CUSTOMER_PAYMENT.WRITE,
+];
+
+const SCOPES_CUSTOMER_PAYMENT_READ = [
+ M2M_SCOPES.CUSTOMER_PAYMENT.ALL,
+ M2M_SCOPES.CUSTOMER_PAYMENT.READ,
+];
+
/**
* The full list of possible permission rules in Project Service
*/
@@ -660,6 +670,36 @@ export const PERMISSION = { // eslint-disable-line import/prefer-default-export
PROJECT_MEMBER_ROLE.COPILOT,
],
},
+
+ CREATE_CUSTOMER_PAYMENT: {
+ meta: {
+ title: 'Create Customer Payment',
+ group: 'Customer Payment',
+ description: 'Who can create customer payment',
+ },
+ topcoderRoles: ALL,
+ scopes: SCOPES_CUSTOMER_PAYMENT_WRITE,
+ },
+
+ VIEW_CUSTOMER_PAYMENT: {
+ meta: {
+ title: 'View Customer Payments',
+ group: 'Customer Payment',
+ description: 'Who can view customer payments',
+ },
+ topcoderRoles: [USER_ROLE.TOPCODER_ADMIN],
+ scopes: SCOPES_CUSTOMER_PAYMENT_READ,
+ },
+
+ UPDATE_CUSTOMER_PAYMENT: {
+ meta: {
+ title: 'Update Customer Payment',
+ group: 'Customer Payment',
+ description: 'Who can update customer payment',
+ },
+ topcoderRoles: [USER_ROLE.TOPCODER_ADMIN],
+ scopes: SCOPES_CUSTOMER_PAYMENT_WRITE,
+ },
};
/**
diff --git a/src/permissions/customerPayment.confirm.js b/src/permissions/customerPayment.confirm.js
new file mode 100644
index 00000000..7e551129
--- /dev/null
+++ b/src/permissions/customerPayment.confirm.js
@@ -0,0 +1,48 @@
+
+
+import _ from 'lodash';
+import models from '../models';
+import { PERMISSION } from './constants';
+
+/**
+ * Only users who have "UPDATE_CUSTOMER_PAYMENT" permission or create the customerPayment can confirm the customerPayment.
+ *
+ * @param {Object} freq the express request instance
+ * @return {Promise} Returns a promise
+ */
+module.exports = freq =>
+ new Promise((resolve, reject) =>
+ models.CustomerPayment.findOne({
+ where: { id: freq.params.id },
+ }).then((customerPayment) => {
+ if (!customerPayment) {
+ reject(new Error('Customer Payment not found'));
+ }
+ const isMachineToken = _.get(freq, 'authUser.isMachine', false);
+ const tokenScopes = _.get(freq, 'authUser.scopes', []);
+ if (isMachineToken) {
+ if (
+ _.intersection(tokenScopes, PERMISSION.UPDATE_CUSTOMER_PAYMENT.scopes)
+ .length > 0
+ ) {
+ return resolve(true);
+ }
+ } else if (freq.authUser.userId === customerPayment.createdBy) {
+ return resolve(true);
+ } else {
+ const authRoles = _.get(freq, 'authUser.roles', []).map(s =>
+ s.toLowerCase(),
+ );
+ const requireRoles =
+ PERMISSION.UPDATE_CUSTOMER_PAYMENT.topcoderRoles.map(r =>
+ r.toLowerCase(),
+ );
+ if (_.intersection(authRoles, requireRoles).length > 0) {
+ return resolve(true);
+ }
+ }
+ return reject(
+ new Error('You do not have permissions to perform this action'),
+ );
+ }),
+ );
diff --git a/src/permissions/index.js b/src/permissions/index.js
index e7e6d0a1..edb9cb6f 100644
--- a/src/permissions/index.js
+++ b/src/permissions/index.js
@@ -8,6 +8,7 @@ const connectManagerOrAdmin = require('./connectManagerOrAdmin.ops');
const copilotAndAbove = require('./copilotAndAbove');
const workManagementPermissions = require('./workManagementForTemplate');
const projectSettingEdit = require('./projectSetting.edit');
+const customerPaymentConfirm = require('./customerPayment.confirm');
const generalPermission = require('./generalPermission');
const { PERMISSION } = require('./constants');
@@ -192,4 +193,10 @@ module.exports = () => {
// Project Reporting
Authorizer.setPolicy('projectReporting.view', projectView);
+
+ // Customer payment permission
+ Authorizer.setPolicy('customerPayment.create', generalPermission(PERMISSION.CREATE_CUSTOMER_PAYMENT));
+ Authorizer.setPolicy('customerPayment.view', generalPermission(PERMISSION.VIEW_CUSTOMER_PAYMENT));
+ Authorizer.setPolicy('customerPayment.edit', generalPermission(PERMISSION.UPDATE_CUSTOMER_PAYMENT));
+ Authorizer.setPolicy('customerPayment.confirm', customerPaymentConfirm);
};
diff --git a/src/routes/customerPayment/cancel.js b/src/routes/customerPayment/cancel.js
new file mode 100644
index 00000000..57405e79
--- /dev/null
+++ b/src/routes/customerPayment/cancel.js
@@ -0,0 +1,30 @@
+/**
+ * API to cancel a customer payment
+ */
+import validate from 'express-validation';
+import Joi from 'joi';
+import { middleware as tcMiddleware } from 'tc-core-library-js';
+import { cancelCustomerPayment } from '../../services/customerPaymentService';
+
+const permissions = tcMiddleware.permissions;
+
+const schema = {
+ params: {
+ id: Joi.number().integer().positive().required(),
+ },
+};
+
+module.exports = [
+ validate(schema),
+ permissions('customerPayment.edit'),
+ (req, res, next) => {
+ // cancel the customer payment
+ cancelCustomerPayment(req.params.id, req.authUser.userId, req)
+ .then((updated) => {
+ // Write to response
+ res.json(updated);
+ return Promise.resolve();
+ })
+ .catch(next);
+ },
+];
diff --git a/src/routes/customerPayment/charge.js b/src/routes/customerPayment/charge.js
new file mode 100644
index 00000000..c6ca72fb
--- /dev/null
+++ b/src/routes/customerPayment/charge.js
@@ -0,0 +1,30 @@
+/**
+ * API to charge a customer payment
+ */
+import validate from 'express-validation';
+import Joi from 'joi';
+import { middleware as tcMiddleware } from 'tc-core-library-js';
+import { chargeCustomerPayment } from '../../services/customerPaymentService';
+
+const permissions = tcMiddleware.permissions;
+
+const schema = {
+ params: {
+ id: Joi.number().integer().positive().required(),
+ },
+};
+
+module.exports = [
+ validate(schema),
+ permissions('customerPayment.edit'),
+ (req, res, next) => {
+ // charge the customer payment
+ chargeCustomerPayment(req.params.id, req.authUser.userId, req)
+ .then((updated) => {
+ // Write to response
+ res.json(updated);
+ return Promise.resolve();
+ })
+ .catch(next);
+ },
+];
diff --git a/src/routes/customerPayment/confirm.js b/src/routes/customerPayment/confirm.js
new file mode 100644
index 00000000..0cab981a
--- /dev/null
+++ b/src/routes/customerPayment/confirm.js
@@ -0,0 +1,30 @@
+/**
+ * API to confirm a customer payment
+ */
+import validate from 'express-validation';
+import Joi from 'joi';
+import { middleware as tcMiddleware } from 'tc-core-library-js';
+import { confirmCustomerPayment } from '../../services/customerPaymentService';
+
+const permissions = tcMiddleware.permissions;
+
+const schema = {
+ params: {
+ id: Joi.number().integer().positive().required(),
+ },
+};
+
+module.exports = [
+ validate(schema),
+ permissions('customerPayment.confirm'),
+ (req, res, next) => {
+ // confirm the customer payment
+ confirmCustomerPayment(req.params.id, req.authUser.userId, req)
+ .then((updated) => {
+ // Write to response
+ res.json(updated);
+ return Promise.resolve();
+ })
+ .catch(next);
+ },
+];
diff --git a/src/routes/customerPayment/create.js b/src/routes/customerPayment/create.js
new file mode 100644
index 00000000..897e99cb
--- /dev/null
+++ b/src/routes/customerPayment/create.js
@@ -0,0 +1,36 @@
+/**
+ * API to add a customer payment
+ */
+import validate from 'express-validation';
+import _ from 'lodash';
+import Joi from 'joi';
+import { middleware as tcMiddleware } from 'tc-core-library-js';
+import { CUSTOMER_PAYMENT_CURRENCY } from '../../constants';
+import { createCustomerPayment } from '../../services/customerPaymentService';
+
+const permissions = tcMiddleware.permissions;
+
+const schema = {
+ body: Joi.object().keys({
+ amount: Joi.number().integer().min(1).required(),
+ currency: Joi.string().valid(_.values(CUSTOMER_PAYMENT_CURRENCY)).default(CUSTOMER_PAYMENT_CURRENCY.USD),
+ paymentMethodId: Joi.string().required(),
+ reference: Joi.string().optional(),
+ referenceId: Joi.string().optional(),
+ }).required(),
+};
+
+module.exports = [
+ validate(schema),
+ permissions('customerPayment.create'),
+ (req, res, next) => {
+ const { amount, currency, reference, referenceId, paymentMethodId } = req.body;
+ createCustomerPayment(amount, currency, paymentMethodId, reference, referenceId, req.authUser.userId, req)
+ .then((result) => {
+ // Write to the response
+ res.status(201).json(result);
+ return Promise.resolve();
+ })
+ .catch(next);
+ },
+];
diff --git a/src/routes/customerPayment/get.js b/src/routes/customerPayment/get.js
new file mode 100644
index 00000000..f81dd2dc
--- /dev/null
+++ b/src/routes/customerPayment/get.js
@@ -0,0 +1,49 @@
+/**
+ * API to get a customer payment
+ */
+import validate from 'express-validation';
+import Joi from 'joi';
+import config from 'config';
+import _ from 'lodash';
+import { middleware as tcMiddleware } from 'tc-core-library-js';
+import models from '../../models';
+import util from '../../util';
+
+const permissions = tcMiddleware.permissions;
+
+const ES_CUSTOMER_PAYMENT_INDEX = config.get('elasticsearchConfig.customerPaymentIndexName');
+const ES_CUSTOMER_PAYMENT_TYPE = config.get('elasticsearchConfig.customerPaymentDocType');
+
+const eClient = util.getElasticSearchClient();
+
+const schema = {
+ params: {
+ id: Joi.number().integer().positive().required(),
+ },
+};
+
+module.exports = [
+ validate(schema),
+ // checking by the permissions middleware
+ permissions('customerPayment.view'),
+ (req, res, next) => {
+ eClient.get({ index: ES_CUSTOMER_PAYMENT_INDEX,
+ type: ES_CUSTOMER_PAYMENT_TYPE,
+ id: req.params.id,
+ })
+ .then((doc) => {
+ req.log.debug('customerPayment found in ES');
+ return res.json(doc._source); // eslint-disable-line no-underscore-dangle
+ })
+ .catch((err) => {
+ if (err.status === 404) {
+ req.log.debug('No customerPayment found in ES');
+ return models.CustomerPayment.findOne({
+ where: { id: req.params.id },
+ raw: true,
+ }).then(customerPayment => res.json(_.omit(customerPayment, 'deletedAt', 'deletedBy')));
+ }
+ return next(err);
+ });
+ },
+];
diff --git a/src/routes/customerPayment/list.js b/src/routes/customerPayment/list.js
new file mode 100644
index 00000000..4eafd523
--- /dev/null
+++ b/src/routes/customerPayment/list.js
@@ -0,0 +1,100 @@
+/**
+ * API to list all customerPayments.
+ */
+import config from 'config';
+import _ from 'lodash';
+import { middleware as tcMiddleware } from 'tc-core-library-js';
+import models from '../../models';
+import util from '../../util';
+
+const ES_CUSTOMER_PAYMENT_INDEX = config.get('elasticsearchConfig.customerPaymentIndexName');
+const ES_CUSTOMER_PAYMENT_TYPE = config.get('elasticsearchConfig.customerPaymentDocType');
+
+/**
+ * Retrieve customerPayments from elastic search.
+ *
+ * @param {Object} criteria the elastic search criteria
+ * @returns {Promise} the promise resolves to the results
+ */
+function retrieveCustomerPayments(criteria) {
+ return new Promise((accept, reject) => {
+ const es = util.getElasticSearchClient();
+ es.search({
+ index: ES_CUSTOMER_PAYMENT_INDEX,
+ type: ES_CUSTOMER_PAYMENT_TYPE,
+ size: criteria.size,
+ from: criteria.from,
+ sort: criteria.sort,
+ body: {
+ query: { bool: { must: criteria.esTerms } },
+ },
+ }).then((docs) => {
+ const rows = _.map(docs.hits.hits, '_source');
+ accept({ rows, count: docs.hits.total });
+ }).catch(reject);
+ });
+}
+
+const permissions = tcMiddleware.permissions;
+
+module.exports = [
+ permissions('customerPayment.view'),
+ (req, res, next) => {
+ // handle filters
+ const filters = _.omit(req.query, 'sort', 'perPage', 'page');
+
+ let sort = req.query.sort ? decodeURIComponent(req.query.sort) : 'createdAt';
+ if (sort && sort.indexOf(' ') === -1) {
+ sort += ' asc';
+ }
+
+ const supportedFilters = ['reference', 'referenceId', 'createdBy', 'status'];
+ const sortableProps = [
+ 'amount asc', 'amount desc',
+ 'currency asc', 'currency desc',
+ 'status asc', 'status desc',
+ 'createdAt asc', 'createdAt desc',
+ 'createdBy asc', 'createdBy desc',
+ 'updatedAt asc', 'updatedAt desc',
+ 'updatedBy asc', 'updatedBy desc',
+ ];
+ if (!util.isValidFilter(filters, supportedFilters) ||
+ (sort && _.indexOf(sortableProps, sort) < 0)) {
+ return util.handleError('Invalid filters or sort', null, req, next);
+ }
+
+ // Build the elastic search query
+ const pageSize = Math.min(req.query.perPage || config.pageSize, config.pageSize);
+ const page = req.query.page || 1;
+ const esTerms = _.map(filters, (filter, key) => ({ term: { [key]: filter } }));
+ const criteria = {
+ esTerms,
+ size: pageSize,
+ from: (page - 1) * pageSize,
+ sort: _.join(sort.split(' '), ':'),
+ };
+
+ // Retrieve customer payments from elastic search
+ return retrieveCustomerPayments(criteria)
+ .then((result) => {
+ if (result.rows.length === 0) {
+ req.log.debug('Fetch customerPayment from db');
+ const queryCondition = {
+ attributes: {
+ exclude: ['deletedAt', 'deletedBy'],
+ },
+ where: filters,
+ limit: pageSize,
+ offset: (page - 1) * pageSize,
+ order: [sort.split(' ')],
+ raw: true,
+ };
+ return models.CustomerPayment.findAndCountAll(queryCondition)
+ .then(dbResult => util.setPaginationHeaders(req, res, _.extend(dbResult, { page, pageSize })));
+ }
+ req.log.debug('Fetch customerPayment found from ES');
+ return util.setPaginationHeaders(req, res, _.extend(result, { page, pageSize }));
+ })
+ .catch(err => next(err));
+ },
+];
diff --git a/src/routes/customerPayment/refund.js b/src/routes/customerPayment/refund.js
new file mode 100644
index 00000000..6cd90cfa
--- /dev/null
+++ b/src/routes/customerPayment/refund.js
@@ -0,0 +1,30 @@
+/**
+ * API to refund a customer payment
+ */
+import validate from 'express-validation';
+import Joi from 'joi';
+import { middleware as tcMiddleware } from 'tc-core-library-js';
+import { refundCustomerPayment } from '../../services/customerPaymentService';
+
+const permissions = tcMiddleware.permissions;
+
+const schema = {
+ params: {
+ id: Joi.number().integer().positive().required(),
+ },
+};
+
+module.exports = [
+ validate(schema),
+ permissions('customerPayment.edit'),
+ (req, res, next) => {
+ // refund the customer payment
+ refundCustomerPayment(req.params.id, req.authUser.userId, req)
+ .then((updated) => {
+ // Write to response
+ res.json(updated);
+ return Promise.resolve();
+ })
+ .catch(next);
+ },
+];
diff --git a/src/routes/customerPayment/update.js b/src/routes/customerPayment/update.js
new file mode 100644
index 00000000..db1d7ee2
--- /dev/null
+++ b/src/routes/customerPayment/update.js
@@ -0,0 +1,71 @@
+/**
+ * API to update a customer payment reference, referenceId fields.
+ */
+import validate from 'express-validation';
+import Joi from 'joi';
+import { middleware as tcMiddleware } from 'tc-core-library-js';
+import * as _ from 'lodash';
+import models from '../../models';
+import { EVENT, RESOURCES } from '../../constants';
+import util from '../../util';
+
+const permissions = tcMiddleware.permissions;
+
+const schema = {
+ params: {
+ id: Joi.number().integer().positive().required(),
+ },
+ body: Joi.object().keys({
+ reference: Joi.string().optional(),
+ referenceId: Joi.string().optional(),
+ }).required(),
+};
+
+module.exports = [
+ validate(schema),
+ permissions('customerPayment.edit'),
+ (req, res, next) => {
+ models.CustomerPayment.findOne({
+ where: {
+ id: req.params.id,
+ },
+ })
+ .then(
+ existing =>
+ new Promise((accept, reject) => {
+ if (!existing) {
+ // handle 404
+ const err = new Error(
+ `No Customer payment found for id: ${req.params.id}`,
+ );
+ err.status = 404;
+ reject(err);
+ } else {
+ existing
+ .update(
+ _.extend(
+ {
+ updatedBy: req.authUser.userId,
+ },
+ req.body,
+ ),
+ )
+ .then(accept)
+ .catch(reject);
+ }
+ }),
+ )
+ .then((updated) => {
+ const result = _.omit(updated.toJSON(), 'deletedAt', 'deletedBy');
+ // emit the event
+ util.sendResourceToKafkaBus(
+ req,
+ EVENT.ROUTING_KEY.CUSTOMER_PAYMENT_UPDATED,
+ RESOURCES.CUSTOMER_PAYMENT,
+ result,
+ );
+ res.json(result);
+ })
+ .catch(next);
+ },
+];
diff --git a/src/routes/index.js b/src/routes/index.js
index 522527f6..33b7c902 100644
--- a/src/routes/index.js
+++ b/src/routes/index.js
@@ -28,7 +28,7 @@ router.get(`/${apiVersion}/projects/health`, (req, res) => {
const jwtAuth = require('tc-core-library-js').middleware.jwtAuthenticator;
router.all(
- RegExp(`\\/${apiVersion}\\/(projects|timelines|orgConfig)(?!\\/health).*`), (req, res, next) => (
+ RegExp(`\\/${apiVersion}\\/(projects|timelines|orgConfig|customer-payments)(?!\\/health).*`), (req, res, next) => (
// JWT authentication
jwtAuth(config)(req, res, next)
),
@@ -382,6 +382,22 @@ router.route('/v5/projects/:projectId(\\d+)/settings')
router.route('/v5/projects/:projectId(\\d+)/estimations/:estimationId(\\d+)/items')
.get(require('./projectEstimationItems/list'));
+// Customer Payments
+router.route('/v5/customer-payments')
+ .get(require('./customerPayment/list'))
+ .post(require('./customerPayment/create'));
+router.route('/v5/customer-payments/:id(\\d+)')
+ .get(require('./customerPayment/get'))
+ .patch(require('./customerPayment/update'));
+router.route('/v5/customer-payments/:id(\\d+)/confirm')
+ .patch(require('./customerPayment/confirm'));
+router.route('/v5/customer-payments/:id(\\d+)/charge')
+ .patch(require('./customerPayment/charge'));
+router.route('/v5/customer-payments/:id(\\d+)/cancel')
+ .patch(require('./customerPayment/cancel'));
+router.route('/v5/customer-payments/:id(\\d+)/refund')
+ .patch(require('./customerPayment/refund'));
+
// register error handler
router.use((err, req, res, next) => { // eslint-disable-line no-unused-vars
// DO NOT REMOVE next arg.. even though eslint
diff --git a/src/services/customerPaymentService.js b/src/services/customerPaymentService.js
new file mode 100644
index 00000000..5ab40e86
--- /dev/null
+++ b/src/services/customerPaymentService.js
@@ -0,0 +1,199 @@
+import config from 'config';
+import * as _ from 'lodash';
+import Stripe from 'stripe';
+import models from '../models';
+import { CUSTOMER_PAYMENT_STATUS, STRIPE_CONSTANT, EVENT, RESOURCES } from '../constants';
+import util from '../util';
+
+const stripe = Stripe(config.get('STRIPE_SECRET_KEY'), { apiVersion: '2020-08-27' });
+
+
+/**
+ * Get the customer payment by id.
+ *
+ * @param {number} id the customer payment id
+ * @returns {Promise} the customer payment
+ */
+async function getCustomerPayment(id) {
+ const customerPayment = await models.CustomerPayment.findOne({
+ where: { id },
+ });
+ if (!customerPayment) {
+ const apiErr = new Error(`Customer payment not found for id ${id}`);
+ apiErr.status = 404;
+ throw apiErr;
+ }
+ return customerPayment;
+}
+
+/**
+ * Convert strip error to api error.
+ *
+ * @param {function} stripRequest the stripe request
+ * @returns {Promise} the request result
+ */
+async function convertStripError(stripRequest) {
+ try {
+ const result = await stripRequest();
+ return result;
+ } catch (err) {
+ if (err.code === STRIPE_CONSTANT.PAYMENT_STATE_ERROR_CODE) {
+ const apiErr = new Error(err.message);
+ apiErr.status = 400;
+ throw apiErr;
+ } else {
+ const apiErr = new Error(err.message);
+ apiErr.status = 500;
+ throw apiErr;
+ }
+ }
+}
+
+/**
+ * Send customer payment message to kafka.
+ *
+ * @param {string} event the event name
+ * @param {object} customerPayment the customer payment object
+ * @param {object} req the request
+ * @returns {Promise} the customer payment
+ */
+async function sendCustomerPaymentMessage(event, customerPayment, req) {
+ // Omit deletedAt, deletedBy
+ const result = _.omit(customerPayment.toJSON(), 'deletedAt', 'deletedBy');
+ // emit the event
+ util.sendResourceToKafkaBus(
+ req,
+ event,
+ RESOURCES.CUSTOMER_PAYMENT,
+ result,
+ );
+ return result;
+}
+
+
+/**
+ * Create customer payment.
+ *
+ * @param {number} amount the payment intent id
+ * @param {string} currency the currency
+ * @param {string} paymentMethodId the payment method id
+ * @param {string} reference the payment method id
+ * @param {string} referenceId the payment method id
+ * @param {string} userId the payment method id
+ * @param {object} req the request
+ * @returns {Promise} the customer payment
+ */
+export async function createCustomerPayment(amount, currency, paymentMethodId, reference, referenceId, userId, req) {
+ const intent = await convertStripError(() => stripe.paymentIntents.create({
+ amount,
+ currency: _.lowerCase(currency),
+ payment_method: paymentMethodId,
+ capture_method: STRIPE_CONSTANT.CAPTURE_METHOD,
+ confirmation_method: STRIPE_CONSTANT.CONFIRMATION_METHOD,
+ confirm: true,
+ }));
+ const customerPayment = await models.CustomerPayment.create({
+ reference,
+ referenceId,
+ amount,
+ currency,
+ paymentIntentId: intent.id,
+ clientSecret: intent.client_secret,
+ status: intent.status,
+ createdBy: userId,
+ updatedBy: userId,
+ });
+ return sendCustomerPaymentMessage(EVENT.ROUTING_KEY.CUSTOMER_PAYMENT_CREATED, customerPayment, req);
+}
+
+/**
+ * Confirm customer payment.
+ *
+ * @param {number} id the customer payment id
+ * @param {string} userId the payment method id
+ * @param {object} req the request
+ * @returns {Promise} the customer payment
+ */
+export async function confirmCustomerPayment(id, userId, req) {
+ const customerPayment = await getCustomerPayment(id);
+ const intent = await convertStripError(() => stripe.paymentIntents.confirm(customerPayment.paymentIntentId));
+ if (intent.status !== CUSTOMER_PAYMENT_STATUS.REQUIRES_CAPTURE) {
+ const apiErr = new Error('You need to confirm PaymentIntent on frontend, then call api to update the status');
+ apiErr.status = 400;
+ throw apiErr;
+ }
+ const confirmedCustomerPayment = await customerPayment.update({
+ status: intent.status,
+ updatedBy: userId,
+ });
+ return sendCustomerPaymentMessage(EVENT.ROUTING_KEY.CUSTOMER_PAYMENT_UPDATED, confirmedCustomerPayment, req);
+}
+
+/**
+ * Charge customer payment.
+ *
+ * @param {number} id the customer payment id
+ * @param {string} userId the payment method id
+ * @param {object} req the request
+ * @returns {Promise} the customer payment
+ */
+export async function chargeCustomerPayment(id, userId, req) {
+ const customerPayment = await getCustomerPayment(id);
+ if (customerPayment.status !== CUSTOMER_PAYMENT_STATUS.REQUIRES_CAPTURE) {
+ const apiErr = new Error('You need to call confirm api to update the status first');
+ apiErr.status = 400;
+ throw apiErr;
+ }
+ const intent = await convertStripError(() => stripe.paymentIntents.capture(customerPayment.paymentIntentId));
+ const chargedCustomerPayment = await customerPayment.update({
+ status: intent.status,
+ updatedBy: userId,
+ });
+ return sendCustomerPaymentMessage(EVENT.ROUTING_KEY.CUSTOMER_PAYMENT_UPDATED, chargedCustomerPayment, req);
+}
+
+/**
+ * Cancel customer payment.
+ *
+ * @param {number} id the customer payment id
+ * @param {string} userId the payment method id
+ * @param {object} req the request
+ * @returns {Promise} the customer payment
+ */
+export async function cancelCustomerPayment(id, userId, req) {
+ const customerPayment = await getCustomerPayment(id);
+ const intent = await convertStripError(() => stripe.paymentIntents.cancel(customerPayment.paymentIntentId));
+ const canceledCustomerPayment = await customerPayment.update({
+ status: intent.status,
+ updatedBy: userId,
+ });
+ return sendCustomerPaymentMessage(EVENT.ROUTING_KEY.CUSTOMER_PAYMENT_UPDATED, canceledCustomerPayment, req);
+}
+
+/**
+ * Refund customer payment.
+ *
+ * @param {number} id the customer payment id
+ * @param {string} userId the payment method id
+ * @param {object} req the request
+ * @returns {Promise} the customer payment
+ */
+export async function refundCustomerPayment(id, userId, req) {
+ const customerPayment = await getCustomerPayment(id);
+ const res = await convertStripError(() => stripe.refunds.create({
+ payment_intent: customerPayment.paymentIntentId,
+ }));
+ const data = { updatedBy: userId };
+
+ // update customer payment status
+ if (res.status === STRIPE_CONSTANT.REFUNDED_SUCCEEDED) {
+ data.status = CUSTOMER_PAYMENT_STATUS.REFUNDED;
+ } else if (res.status === STRIPE_CONSTANT.REFUNDED_PENDING) {
+ data.status = CUSTOMER_PAYMENT_STATUS.REFUND_PENDING;
+ } else if (res.status === STRIPE_CONSTANT.REFUNDED_FAILED) {
+ data.status = CUSTOMER_PAYMENT_STATUS.REFUND_FAILED;
+ }
+
+ const refundedCustomerPayment = await customerPayment.update(data);
+ return sendCustomerPaymentMessage(EVENT.ROUTING_KEY.CUSTOMER_PAYMENT_UPDATED, refundedCustomerPayment, req);
+}
diff --git a/src/utils/es-config.js b/src/utils/es-config.js
index 5144c510..ee442b1d 100644
--- a/src/utils/es-config.js
+++ b/src/utils/es-config.js
@@ -6,6 +6,8 @@ const ES_TIMELINE_INDEX = config.get('elasticsearchConfig.timelineIndexName');
const ES_TIMELINE_TYPE = config.get('elasticsearchConfig.timelineDocType');
const ES_METADATA_INDEX = config.get('elasticsearchConfig.metadataIndexName');
const ES_METADATA_TYPE = config.get('elasticsearchConfig.metadataDocType');
+const ES_CUSTOMER_PAYMENT_INDEX = config.get('elasticsearchConfig.customerPaymentIndexName');
+const ES_CUSTOMER_PAYMENT_TYPE = config.get('elasticsearchConfig.customerPaymentDocType');
// form config can be present inside 3 models, so we reuse it
const formConfig = {
@@ -383,6 +385,53 @@ MAPPINGS[ES_TIMELINE_INDEX] = {
},
};
+/**
+ * 'customerPayment' index mapping
+ */
+MAPPINGS[ES_CUSTOMER_PAYMENT_INDEX] = {
+ _all: { enabled: false },
+ properties: {
+ id: {
+ type: 'long',
+ },
+ reference: {
+ type: 'string',
+ },
+ referenceId: {
+ type: 'string',
+ },
+ amount: {
+ type: 'long',
+ },
+ currency: {
+ type: 'string',
+ },
+ paymentIntentId: {
+ type: 'string',
+ },
+ clientSecret: {
+ type: 'string',
+ },
+ status: {
+ type: 'string',
+ },
+ createdAt: {
+ type: 'date',
+ format: 'strict_date_optional_time||epoch_millis',
+ },
+ createdBy: {
+ type: 'integer',
+ },
+ updatedAt: {
+ type: 'date',
+ format: 'strict_date_optional_time||epoch_millis',
+ },
+ updatedBy: {
+ type: 'integer',
+ },
+ },
+};
+
/**
* 'metadata' index mapping
*/
@@ -720,6 +769,7 @@ const INDEX_TO_DOC_TYPE = {};
INDEX_TO_DOC_TYPE[ES_PROJECT_INDEX] = ES_PROJECT_TYPE;
INDEX_TO_DOC_TYPE[ES_TIMELINE_INDEX] = ES_TIMELINE_TYPE;
INDEX_TO_DOC_TYPE[ES_METADATA_INDEX] = ES_METADATA_TYPE;
+INDEX_TO_DOC_TYPE[ES_CUSTOMER_PAYMENT_INDEX] = ES_CUSTOMER_PAYMENT_TYPE;
module.exports = {
MAPPINGS,