From a37d49b45bb6a54c18c956541a3e61a41cc6cf2d Mon Sep 17 00:00:00 2001 From: xxcxy Date: Thu, 6 Jan 2022 15:27:53 +0800 Subject: [PATCH] support customer payment --- config/custom-environment-variables.json | 7 +- config/default.json | 7 +- config/test.json | 4 +- docs/Project API.postman_collection.json | 394 ++++++++++++++++++- docs/swagger.yaml | 428 +++++++++++++++++++++ migrations/20212801_customer_payment.sql | 33 ++ migrations/elasticsearch_sync.js | 3 +- package-lock.json | 9 + package.json | 1 + src/constants.js | 175 +++++++++ src/events/busApi.js | 18 + src/models/CustomerPayment.js | 44 +++ src/permissions/constants.js | 40 ++ src/permissions/customerPayment.confirm.js | 48 +++ src/permissions/index.js | 7 + src/routes/customerPayment/cancel.js | 30 ++ src/routes/customerPayment/charge.js | 30 ++ src/routes/customerPayment/confirm.js | 30 ++ src/routes/customerPayment/create.js | 36 ++ src/routes/customerPayment/get.js | 49 +++ src/routes/customerPayment/list.js | 100 +++++ src/routes/customerPayment/refund.js | 30 ++ src/routes/customerPayment/update.js | 71 ++++ src/routes/index.js | 18 +- src/services/customerPaymentService.js | 199 ++++++++++ src/utils/es-config.js | 50 +++ 26 files changed, 1853 insertions(+), 8 deletions(-) create mode 100644 migrations/20212801_customer_payment.sql create mode 100644 src/models/CustomerPayment.js create mode 100644 src/permissions/customerPayment.confirm.js create mode 100644 src/routes/customerPayment/cancel.js create mode 100644 src/routes/customerPayment/charge.js create mode 100644 src/routes/customerPayment/confirm.js create mode 100644 src/routes/customerPayment/create.js create mode 100644 src/routes/customerPayment/get.js create mode 100644 src/routes/customerPayment/list.js create mode 100644 src/routes/customerPayment/refund.js create mode 100644 src/routes/customerPayment/update.js create mode 100644 src/services/customerPaymentService.js 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/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,