├── enabler ├── test │ ├── jest.setup.ts │ └── elements.spec.ts ├── decs.d.ts ├── .env.test ├── src │ ├── constants.ts │ ├── main.ts │ ├── utils │ │ └── index.ts │ ├── style │ │ ├── _a11y.scss │ │ ├── _vx.scss │ │ ├── _colors.scss │ │ ├── _variables.scss │ │ ├── style.module.scss │ │ ├── button.module.scss │ │ └── inputField.module.scss │ ├── fake-sdk.ts │ ├── components │ │ └── base.ts │ ├── dtos │ │ └── mock-payment.dto.ts │ ├── dropin │ │ └── dropin-embedded.ts │ └── payment-enabler │ │ ├── payment-enabler.ts │ │ └── payment-enabler-mock.ts ├── jest.config.ts ├── .gitignore ├── .env.template ├── vite.config.ts ├── tsconfig.json ├── package.json ├── dev-utils │ └── session.js └── README.md ├── processor ├── test │ ├── jest.setup.ts │ ├── sample.spec.ts │ ├── services │ │ ├── commerce-tools │ │ │ ├── customerClient.spec.ts │ │ │ ├── productTypeClient.spec.ts │ │ │ └── customTypeClient.spec.ts │ │ └── converters │ │ │ └── stripeEvent.converter.spec.ts │ ├── clients │ │ └── stripe.client.spec.ts │ ├── utils │ │ ├── utils.spec.ts │ │ ├── mock-customer-data.ts │ │ ├── mock-cart-data.ts │ │ └── mock-actions-data.ts │ ├── libs │ │ └── fastify │ │ │ ├── context │ │ │ └── context.spec.ts │ │ │ └── error-handler.spec.ts │ ├── payment-sdk.test.ts │ ├── connectors │ │ └── post-deploy.spec.ts │ └── routes.test │ │ └── operations.spec.ts ├── .prettierignore ├── jest.config.ts ├── .prettierrc.js ├── docker-compose.yaml ├── .gitignore ├── src │ ├── dtos │ │ ├── operations │ │ │ ├── config.dto.ts │ │ │ ├── payment-componets.dto.ts │ │ │ ├── status.dto.ts │ │ │ ├── transaction.dto.ts │ │ │ └── payment-intents.dto.ts │ │ ├── mock-payment.dto.ts │ │ └── stripe-payment.dto.ts │ ├── main.ts │ ├── utils.ts │ ├── libs │ │ ├── fastify │ │ │ ├── hooks │ │ │ │ └── stripe-header-auth.hook.ts │ │ │ ├── dtos │ │ │ │ └── error.dto.ts │ │ │ ├── context │ │ │ │ └── context.ts │ │ │ └── error-handler.ts │ │ └── logger │ │ │ └── index.ts │ ├── global.d.ts │ ├── services │ │ ├── types │ │ │ ├── mock-payment.type.ts │ │ │ ├── operation.type.ts │ │ │ └── stripe-payment.type.ts │ │ ├── commerce-tools │ │ │ ├── customerClient.ts │ │ │ ├── productTypeClient.ts │ │ │ ├── customTypeClient.ts │ │ │ └── customTypeHelper.ts │ │ ├── converters │ │ │ └── stripeEventConverter.ts │ │ └── abstract-payment.service.ts │ ├── server │ │ ├── app.ts │ │ ├── plugins │ │ │ ├── operation.plugin.ts │ │ │ └── stripe-payment.plugin.ts │ │ └── server.ts │ ├── connectors │ │ ├── pre-undeploy.ts │ │ ├── post-deploy.ts │ │ └── actions.ts │ ├── errors │ │ └── stripe-api.error.ts │ ├── clients │ │ └── stripe.client.ts │ ├── payment-sdk.ts │ ├── config │ │ └── config.ts │ └── routes │ │ ├── operation.route.ts │ │ └── stripe-payment.route.ts ├── tsconfig.json ├── .env.template ├── eslint.config.mjs └── package.json ├── docs ├── overview.png └── StripeCustomerWorkflow.png ├── .gitignore ├── LICENSE ├── docker-compose.yaml ├── CHANGELOG.md └── connect.yaml /enabler/test/jest.setup.ts: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /processor/test/jest.setup.ts: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /enabler/decs.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.scss'; 2 | -------------------------------------------------------------------------------- /enabler/.env.test: -------------------------------------------------------------------------------- 1 | VITE_PROCESSOR_URL=http://localhost:8080 2 | -------------------------------------------------------------------------------- /enabler/src/constants.ts: -------------------------------------------------------------------------------- 1 | const env = import.meta.env; 2 | 3 | export default env; -------------------------------------------------------------------------------- /processor/.prettierignore: -------------------------------------------------------------------------------- 1 | dist 2 | coverage 3 | node_modules 4 | pnpm-lock.yaml 5 | -------------------------------------------------------------------------------- /docs/overview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stripe/stripe-commercetools-checkout-app/main/docs/overview.png -------------------------------------------------------------------------------- /docs/StripeCustomerWorkflow.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stripe/stripe-commercetools-checkout-app/main/docs/StripeCustomerWorkflow.png -------------------------------------------------------------------------------- /enabler/src/main.ts: -------------------------------------------------------------------------------- 1 | import { MockPaymentEnabler } from './payment-enabler/payment-enabler-mock'; 2 | 3 | export { MockPaymentEnabler as Enabler }; 4 | -------------------------------------------------------------------------------- /enabler/jest.config.ts: -------------------------------------------------------------------------------- 1 | 2 | module.exports = { 3 | preset: 'ts-jest', 4 | testEnvironment: 'node', 5 | setupFiles: ['./test/jest.setup.ts'], 6 | roots: ['./test'], 7 | 8 | }; 9 | -------------------------------------------------------------------------------- /processor/jest.config.ts: -------------------------------------------------------------------------------- 1 | /** @type {import('ts-jest').JestConfigWithTsJest} */ 2 | 3 | module.exports = { 4 | preset: 'ts-jest', 5 | testEnvironment: 'node', 6 | setupFiles: ['./test/jest.setup.ts'], 7 | roots: ['./test'], 8 | }; 9 | -------------------------------------------------------------------------------- /processor/.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | arrowParens: 'always', 3 | bracketSameLine: true, 4 | bracketSpacing: true, 5 | printWidth: 120, 6 | singleQuote: true, 7 | tabWidth: 2, 8 | trailingComma: 'all', 9 | useTabs: false, 10 | }; 11 | -------------------------------------------------------------------------------- /enabler/src/utils/index.ts: -------------------------------------------------------------------------------- 1 | export const parseJSON = (json: string): T => { 2 | try { 3 | return JSON.parse(json || "{}"); 4 | } catch (error) { 5 | console.error("Error parsing JSON", error); 6 | return {} as T; 7 | } 8 | }; 9 | -------------------------------------------------------------------------------- /enabler/src/style/_a11y.scss: -------------------------------------------------------------------------------- 1 | .visuallyHidden { 2 | clip: rect(0 0 0 0); 3 | clip-path: inset(50%); 4 | height: 1px; 5 | overflow: hidden; 6 | position: absolute; 7 | white-space: nowrap; 8 | width: 1px; 9 | } 10 | 11 | .dNone { 12 | display: none; 13 | } 14 | -------------------------------------------------------------------------------- /processor/docker-compose.yaml: -------------------------------------------------------------------------------- 1 | version: "3.8" 2 | 3 | services: 4 | jwt-server: 5 | image: node:alpine 6 | restart: always 7 | command: 8 | - npx 9 | - --package 10 | - jwt-mock-server 11 | - -y 12 | - start 13 | ports: 14 | - 9000:9000 15 | -------------------------------------------------------------------------------- /enabler/test/elements.spec.ts: -------------------------------------------------------------------------------- 1 | 2 | import { expect, describe, it } from '@jest/globals'; 3 | 4 | describe("StripePayment Module", () => { 5 | let testing = {}; 6 | 7 | it("should setup enabler with mocks", async () => { 8 | 9 | expect(testing).toStrictEqual({}); 10 | }); 11 | 12 | }); 13 | -------------------------------------------------------------------------------- /processor/test/sample.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, test, expect } from '@jest/globals'; 2 | 3 | describe('sample-test-suite', () => { 4 | // Please customize test cases below 5 | test('sample-test-case', async () => { 6 | const result = {}; 7 | expect(result).toStrictEqual({}); 8 | }); 9 | }); 10 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Code Editor directories and files 2 | .idea 3 | .env 4 | .idea/* 5 | 6 | # MCP (Model Context Protocol) configuration and data 7 | .mcp/ 8 | .DS_Store 9 | 10 | #Context documentation 11 | docs/context7-libraries 12 | context 13 | context7-config.json 14 | CLAUDE.md 15 | 16 | # Connect CLI generated files 17 | .connect -------------------------------------------------------------------------------- /enabler/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | public 13 | dist-ssr 14 | *.local 15 | 16 | # Editor directories and files 17 | .vscode/* 18 | !.vscode/extensions.json 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | .env 26 | -------------------------------------------------------------------------------- /processor/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .DS_Store 19 | *.suo 20 | *.ntvs* 21 | *.njsproj 22 | *.sln 23 | *.sw? 24 | 25 | #test coverage 26 | coverage/* -------------------------------------------------------------------------------- /enabler/.env.template: -------------------------------------------------------------------------------- 1 | VITE_CTP_AUTH_URL=https://auth.[region].commercetools.com 2 | VITE_CTP_API_URL=https://api.[region].commercetools.com 3 | VITE_CTP_SESSION_URL=https://session.[region].commercetools.com 4 | VITE_CTP_CLIENT_ID=[composable-commerce-client-id] 5 | VITE_CTP_CLIENT_SECRET=[composable-commerce-client-secret] 6 | VITE_CTP_PROJECT_KEY=[composable-commerce-project-key] 7 | VITE_PROCESSOR_URL=http://localhost:8080 8 | -------------------------------------------------------------------------------- /processor/src/dtos/operations/config.dto.ts: -------------------------------------------------------------------------------- 1 | import { Static, Type } from '@sinclair/typebox'; 2 | 3 | /** 4 | * Public shareable payment provider configuration. Do not include any sensitive data. 5 | */ 6 | export const ConfigResponseSchema = Type.Object({ 7 | environment: Type.String(), 8 | publishableKey: Type.String(), 9 | }); 10 | 11 | export type ConfigResponseSchemaDTO = Static; 12 | -------------------------------------------------------------------------------- /processor/src/main.ts: -------------------------------------------------------------------------------- 1 | import * as dotenv from 'dotenv'; 2 | dotenv.config(); 3 | 4 | import { setupFastify } from './server/server'; 5 | 6 | (async () => { 7 | const server = await setupFastify(); 8 | 9 | const HOST = '0.0.0.0'; 10 | try { 11 | await server.listen({ 12 | port: 8080, 13 | host: HOST, 14 | }); 15 | } catch (err) { 16 | server.log.error(err); 17 | process.exit(1); 18 | } 19 | })(); 20 | -------------------------------------------------------------------------------- /processor/src/utils.ts: -------------------------------------------------------------------------------- 1 | export const parseJSON = (json?: string): T => { 2 | try { 3 | return JSON.parse(json || '{}'); 4 | } catch (error) { 5 | console.error('Error parsing JSON', error); 6 | return {} as T; 7 | } 8 | }; 9 | 10 | export const isValidUUID = (uuid: string): boolean => { 11 | const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; 12 | return uuidRegex.test(uuid); 13 | }; 14 | -------------------------------------------------------------------------------- /processor/src/libs/fastify/hooks/stripe-header-auth.hook.ts: -------------------------------------------------------------------------------- 1 | import { FastifyRequest } from 'fastify'; 2 | import { ErrorAuthErrorResponse } from '@commercetools/connect-payments-sdk'; 3 | 4 | export class StripeHeaderAuthHook { 5 | public authenticate() { 6 | return async (request: FastifyRequest): Promise => { 7 | if (request.headers['stripe-signature']) { 8 | return; 9 | } 10 | throw new ErrorAuthErrorResponse('Stripe signature is not valid'); 11 | }; 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /processor/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "ts-node": { 3 | "files": true 4 | }, 5 | "files": ["src/main.ts", "src/global.d.ts"], 6 | "compilerOptions": { 7 | "target": "es2022", 8 | "module": "Node16", 9 | "esModuleInterop": true, 10 | "forceConsistentCasingInFileNames": true, 11 | "resolveJsonModule": true, 12 | "strict": true, 13 | "skipLibCheck": true, 14 | "rootDir": "./src", 15 | "outDir": "./dist" 16 | }, 17 | "include": ["src/**/*"], 18 | "exclude": ["src/**/*.[test|spec].ts"] 19 | } 20 | -------------------------------------------------------------------------------- /processor/src/global.d.ts: -------------------------------------------------------------------------------- 1 | import '@fastify/request-context'; 2 | import { ContextData, SessionContextData } from './libs/fastify/context/context'; 3 | 4 | declare module '@fastify/request-context' { 5 | interface RequestContextData { 6 | request: ContextData; 7 | session?: SessionContextData; 8 | } 9 | } 10 | 11 | declare module 'fastify' { 12 | interface FastifyInstance { 13 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 14 | vite: any; 15 | } 16 | 17 | export interface FastifyRequest { 18 | correlationId?: string; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /processor/src/services/types/mock-payment.type.ts: -------------------------------------------------------------------------------- 1 | import { PaymentRequestSchemaDTO } from '../../dtos/stripe-payment.dto'; 2 | import { 3 | CommercetoolsCartService, 4 | CommercetoolsOrderService, 5 | CommercetoolsPaymentService, 6 | } from '@commercetools/connect-payments-sdk'; 7 | 8 | export type MockPaymentServiceOptions = { 9 | ctCartService: CommercetoolsCartService; 10 | ctPaymentService: CommercetoolsPaymentService; 11 | ctOrderService: CommercetoolsOrderService; 12 | }; 13 | 14 | export type CreatePaymentRequest = { 15 | data: PaymentRequestSchemaDTO; 16 | }; 17 | -------------------------------------------------------------------------------- /processor/src/server/app.ts: -------------------------------------------------------------------------------- 1 | import { paymentSDK } from '../payment-sdk'; 2 | import { StripePaymentService } from '../services/stripe-payment.service'; 3 | 4 | const paymentService = new StripePaymentService({ 5 | ctCartService: paymentSDK.ctCartService, 6 | ctPaymentService: paymentSDK.ctPaymentService, 7 | ctOrderService: paymentSDK.ctOrderService, 8 | ctPaymentMethodService: paymentSDK.ctPaymentMethodService, 9 | ctRecurringPaymentJobService: paymentSDK.ctRecurringPaymentJobService, 10 | }); 11 | 12 | export const app = { 13 | services: { 14 | paymentService, 15 | }, 16 | }; 17 | -------------------------------------------------------------------------------- /processor/src/services/commerce-tools/customerClient.ts: -------------------------------------------------------------------------------- 1 | import { Customer, CustomerUpdateAction } from '@commercetools/connect-payments-sdk'; 2 | import { paymentSDK } from '../../payment-sdk'; 3 | 4 | const apiClient = paymentSDK.ctAPI.client; 5 | 6 | export async function updateCustomerById({ 7 | id, 8 | version, 9 | actions, 10 | }: { 11 | id: string; 12 | version: number; 13 | actions: CustomerUpdateAction[]; 14 | }): Promise { 15 | const response = await apiClient.customers().withId({ ID: id }).post({ body: { version, actions } }).execute(); 16 | return response.body; 17 | } 18 | -------------------------------------------------------------------------------- /enabler/src/fake-sdk.ts: -------------------------------------------------------------------------------- 1 | 2 | /** 3 | * Represents a fake SDK. 4 | */ 5 | export class FakeSdk { 6 | private environment: string; 7 | 8 | /** 9 | * Creates an instance of FakeSdk. 10 | * @param environment - The environment for the SDK. 11 | */ 12 | constructor({ environment }) { 13 | this.environment = environment; 14 | console.log('FakeSdk constructor', this.environment); 15 | } 16 | 17 | /** 18 | * Initializes the SDK with the specified options. 19 | * @param opts - The options for initializing the SDK. 20 | */ 21 | init(opts: any) { 22 | console.log('FakeSdk init', opts); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /enabler/src/style/_vx.scss: -------------------------------------------------------------------------------- 1 | // BUTTONS 2 | :root, 3 | :host { 4 | --ctc-button: #186ec3; 5 | --ctc-button-hover: color-mix(in srgb, var(--ctc-button), black 15%); 6 | --ctc-button-disabled: #e0e0e0; 7 | --ctc-button-text: #fff; 8 | --ctc-button-disabled-text: #a2a3a4; 9 | } 10 | 11 | // RADIO 12 | :root, 13 | :host { 14 | --ctc-radio: #186ec3; 15 | } 16 | 17 | // CHECKBOX 18 | :root, 19 | :host { 20 | --ctc-checkbox: #186ec3; 21 | } 22 | 23 | // INPUT FIELDS 24 | :root, 25 | :host { 26 | --ctc-input-field-focus: #186ec3; 27 | } 28 | 29 | // FONTS 30 | :root, 31 | :host { 32 | --ctc-font-family: 'Roboto', sans-serif; 33 | } 34 | 35 | -------------------------------------------------------------------------------- /enabler/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { resolve } from 'path'; 2 | import { defineConfig } from 'vite'; 3 | import cssInjectedByJsPlugin from "vite-plugin-css-injected-by-js"; 4 | 5 | export default defineConfig({ 6 | plugins: [ 7 | cssInjectedByJsPlugin(), 8 | ], 9 | build: { 10 | outDir: resolve(__dirname, 'public'), 11 | lib: { 12 | // Could also be a dictionary or array of multiple entry points 13 | entry: resolve(__dirname, 'src/main.ts'), 14 | name: 'Connector', 15 | formats: ['es','umd'], 16 | // the proper extensions will be added 17 | fileName: (format) => `connector-enabler.${format}.js`, 18 | }, 19 | }, 20 | }) 21 | -------------------------------------------------------------------------------- /processor/src/connectors/pre-undeploy.ts: -------------------------------------------------------------------------------- 1 | import * as dotenv from 'dotenv'; 2 | dotenv.config(); 3 | 4 | import { removeCustomerCustomType, removeLineItemCustomType, removeProductTypeSubscription } from './actions'; 5 | 6 | async function preUndeploy() { 7 | await removeProductTypeSubscription(); 8 | await removeLineItemCustomType(); 9 | await removeCustomerCustomType(); 10 | } 11 | 12 | export async function run() { 13 | try { 14 | await preUndeploy(); 15 | } catch (error) { 16 | if (error instanceof Error) { 17 | process.stderr.write(`Post-undeploy failed: ${error.message}\n`); 18 | } 19 | process.exitCode = 1; 20 | } 21 | } 22 | 23 | run(); 24 | -------------------------------------------------------------------------------- /processor/src/server/plugins/operation.plugin.ts: -------------------------------------------------------------------------------- 1 | import { FastifyInstance } from 'fastify'; 2 | import { paymentSDK } from '../../payment-sdk'; 3 | import { operationsRoute } from '../../routes/operation.route'; 4 | import { app } from '../app'; 5 | 6 | export default async function (server: FastifyInstance) { 7 | await server.register(operationsRoute, { 8 | prefix: '/operations', 9 | paymentService: app.services.paymentService, 10 | jwtAuthHook: paymentSDK.jwtAuthHookFn, 11 | oauth2AuthHook: paymentSDK.oauth2AuthHookFn, 12 | sessionHeaderAuthHook: paymentSDK.sessionHeaderAuthHookFn, 13 | authorizationHook: paymentSDK.authorityAuthorizationHookFn, 14 | }); 15 | } 16 | -------------------------------------------------------------------------------- /processor/src/errors/stripe-api.error.ts: -------------------------------------------------------------------------------- 1 | import { Errorx, ErrorxAdditionalOpts } from '@commercetools/connect-payments-sdk'; 2 | 3 | export type StripeApiErrorData = { 4 | code: string; 5 | doc_url: string; 6 | message: string; 7 | param: string; 8 | request_log_url: string; 9 | type: string; 10 | statusCode: number; 11 | requestId: string; 12 | }; 13 | 14 | export class StripeApiError extends Errorx { 15 | constructor(errorData: StripeApiErrorData, additionalOpts?: ErrorxAdditionalOpts) { 16 | super({ 17 | code: errorData.code, 18 | httpErrorStatus: errorData.statusCode, 19 | message: errorData.message, 20 | ...additionalOpts, 21 | }); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /processor/src/libs/logger/index.ts: -------------------------------------------------------------------------------- 1 | import { createApplicationLogger } from '@commercetools-backend/loggers'; 2 | import { defaultFieldsFormatter } from '@commercetools/connect-payments-sdk'; 3 | import { getRequestContext } from '../fastify/context/context'; 4 | import { config } from '../../config/config'; 5 | 6 | export const log = createApplicationLogger({ 7 | formatters: [ 8 | defaultFieldsFormatter({ 9 | projectKey: config.projectKey, 10 | version: process.env.npm_package_version, 11 | name: process.env.npm_package_name, 12 | correlationId: () => getRequestContext().correlationId, 13 | pathTemplate: () => getRequestContext().pathTemplate, 14 | path: () => getRequestContext().path, 15 | }), 16 | ], 17 | }); 18 | -------------------------------------------------------------------------------- /enabler/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "useDefineForClassFields": true, 5 | "module": "ESNext", 6 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 7 | "skipLibCheck": true, 8 | 9 | /* Bundler mode */ 10 | "moduleResolution": "bundler", 11 | "allowImportingTsExtensions": true, 12 | "allowSyntheticDefaultImports": true, 13 | "resolveJsonModule": true, 14 | "isolatedModules": true, 15 | "noEmit": true, 16 | 17 | /* Linting */ 18 | // "strict": true, 19 | "noUnusedLocals": true, 20 | "noUnusedParameters": true, 21 | "noFallthroughCasesInSwitch": true, 22 | 23 | "types": ["vite/client", "node"] 24 | 25 | }, 26 | "include": [ 27 | "src", "decs.d.ts"] 28 | } 29 | -------------------------------------------------------------------------------- /processor/src/dtos/mock-payment.dto.ts: -------------------------------------------------------------------------------- 1 | import { Static, Type } from '@sinclair/typebox'; 2 | 3 | export enum PaymentOutcome { 4 | AUTHORIZED = 'Authorized', 5 | REJECTED = 'Rejected', 6 | } 7 | 8 | export enum PaymentMethodType { 9 | CARD = 'card', 10 | INVOICE = 'invoice', 11 | PURCHASE_ORDER = 'purchaseorder', 12 | PAYMENT = 'payment', 13 | } 14 | 15 | export const PaymentResponseSchema = Type.Object({ 16 | paymentReference: Type.String(), 17 | }); 18 | 19 | export const PaymentOutcomeSchema = Type.Enum(PaymentOutcome); 20 | 21 | export const PaymentRequestSchema = Type.Object({ 22 | paymentMethod: Type.Object({ 23 | type: Type.Enum(PaymentMethodType), 24 | poNumber: Type.Optional(Type.String()), 25 | invoiceMemo: Type.Optional(Type.String()), 26 | }), 27 | paymentOutcome: PaymentOutcomeSchema, 28 | }); 29 | 30 | export type PaymentRequestSchemaDTO = Static; 31 | export type PaymentResponseSchemaDTO = Static; 32 | -------------------------------------------------------------------------------- /enabler/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "enabler", 3 | "private": true, 4 | "version": "4.0.1", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite --port 3000", 8 | "start": "serve public -l 8080 --cors", 9 | "test": "jest --detectOpenHandles", 10 | "build": "tsc && vite build", 11 | "preview": "vite preview", 12 | "serve": "npm run build && serve public -l 3000 --cors" 13 | }, 14 | "dependencies": { 15 | "@babel/preset-typescript": "^7.27.1", 16 | "@stripe/stripe-js": "^5.5.0", 17 | "serve": "14.2.5", 18 | "ts-node": "^10.9.2" 19 | }, 20 | "devDependencies": { 21 | "@types/node": "^24.3.0", 22 | "dotenv": "^16.4.7", 23 | "jest": "30.1.1", 24 | "jest-environment-jsdom": "^30.1.1", 25 | "jest-fetch-mock": "^3.0.3", 26 | "sass": "1.91.0", 27 | "ts-jest": "^29.4.1", 28 | "typescript": "5.9.2", 29 | "vite": "7.1.11", 30 | "vite-plugin-css-injected-by-js": "3.5.2" 31 | }, 32 | "overrides": { 33 | "path-to-regexp": "3.3.0", 34 | "esbuild": "^0.24.0" 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /processor/src/clients/stripe.client.ts: -------------------------------------------------------------------------------- 1 | import Stripe from 'stripe'; 2 | import { getConfig } from '../config/config'; 3 | import { StripeApiError, StripeApiErrorData } from '../errors/stripe-api.error'; 4 | import { log } from '../libs/logger'; 5 | 6 | export const stripeApi = (): Stripe => { 7 | const properties = new Map(Object.entries(process.env)); 8 | const appInfoUrl = properties.get('CONNECT_SERVICE_URL') ?? 'https://example.com'; 9 | return new Stripe(getConfig().stripeSecretKey, { 10 | appInfo: { 11 | name: 'Stripe app for Commercetools Connect', 12 | version: '1.0.00', 13 | url: appInfoUrl, //need to be updated 14 | partner_id: 'pp_partner_c0mmercet00lsc0NNect', // Used by Stripe to identify your connector 15 | }, 16 | }); 17 | }; 18 | 19 | export const wrapStripeError = (e: any): Error => { 20 | if (e?.raw) { 21 | const errorData = JSON.parse(JSON.stringify(e.raw)) as StripeApiErrorData; 22 | return new StripeApiError(errorData, { cause: e }); 23 | } 24 | 25 | log.error('Unexpected error calling Stripe API:', e); 26 | return e; 27 | }; 28 | -------------------------------------------------------------------------------- /processor/src/libs/fastify/dtos/error.dto.ts: -------------------------------------------------------------------------------- 1 | import { Static, Type } from '@sinclair/typebox'; 2 | 3 | /** 4 | * Represents https://docs.commercetools.com/api/errors#errorobject 5 | */ 6 | export const ErrorObject = Type.Object( 7 | { 8 | code: Type.String(), 9 | message: Type.String(), 10 | }, 11 | { additionalProperties: true }, 12 | ); 13 | 14 | /** 15 | * Represents https://docs.commercetools.com/api/errors#errorresponse 16 | */ 17 | export const ErrorResponse = Type.Object({ 18 | statusCode: Type.Integer(), 19 | message: Type.String(), 20 | errors: Type.Array(ErrorObject), 21 | }); 22 | 23 | /** 24 | * Represents https://docs.commercetools.com/api/errors#autherrorresponse 25 | */ 26 | export const AuthErrorResponse = Type.Composite([ 27 | ErrorResponse, 28 | Type.Object({ 29 | error: Type.String(), 30 | error_description: Type.Optional(Type.String()), 31 | }), 32 | ]); 33 | 34 | export type TErrorObject = Static; 35 | export type TErrorResponse = Static; 36 | export type TAuthErrorResponse = Static; 37 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Stripe, Inc. (https://stripe.com) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /docker-compose.yaml: -------------------------------------------------------------------------------- 1 | version: "3.8" 2 | 3 | services: 4 | jwt-server: 5 | image: node:alpine 6 | restart: always 7 | command: 8 | - npx 9 | - --package 10 | - jwt-mock-server 11 | - -y 12 | - start 13 | ports: 14 | - 9000:9000 15 | 16 | enabler: 17 | image: node:20 18 | volumes: 19 | - ./enabler:/home/node/app 20 | restart: always 21 | working_dir: /home/node/app 22 | depends_on: 23 | - processor 24 | command: /bin/sh -c 'npm install && npm run dev -- --host 0.0.0.0 --port 3000' 25 | env_file: 26 | - ./enabler/.env 27 | environment: 28 | - VITE_PROCESSOR_URL=http://localhost:8080 29 | ports: 30 | - 3000:3000 31 | 32 | processor: 33 | image: node:20 34 | volumes: 35 | - ./processor:/home/node/app 36 | working_dir: /home/node/app 37 | depends_on: 38 | - jwt-server 39 | env_file: 40 | - ./processor/.env 41 | environment: 42 | - CTP_JWKS_URL=http://jwt-server:9000/jwt/.well-known/jwks.json 43 | - CTP_JWT_ISSUER=https://issuer.com 44 | command: /bin/sh -c 'npm install && npm run watch' 45 | ports: 46 | - 8080:8080 47 | -------------------------------------------------------------------------------- /processor/src/dtos/operations/payment-componets.dto.ts: -------------------------------------------------------------------------------- 1 | import { Static, Type } from '@sinclair/typebox'; 2 | 3 | const DropinType = Type.Enum({ 4 | EMBEDDED: 'embedded', 5 | HPP: 'hpp', 6 | }); 7 | 8 | export const SupportedPaymentDropinsData = Type.Object({ 9 | type: DropinType, 10 | }); 11 | 12 | export const SupportedPaymentComponentsData = Type.Object({ 13 | type: Type.String(), 14 | subtypes: Type.Optional(Type.Array(Type.String())), 15 | }); 16 | 17 | /** 18 | * Supported payment components schema. 19 | * 20 | * Example: 21 | * { 22 | * "dropins": [ 23 | * { 24 | * "type": "embedded" 25 | * } 26 | * ], 27 | * "components": [ 28 | * { 29 | * "type": "card" 30 | * }, 31 | * { 32 | * "type": "applepay" 33 | * } 34 | * ] 35 | * } 36 | */ 37 | export const SupportedPaymentComponentsSchema = Type.Object({ 38 | dropins: Type.Array(SupportedPaymentDropinsData), 39 | components: Type.Array(SupportedPaymentComponentsData), 40 | }); 41 | 42 | export enum PaymentComponentsSupported { 43 | PAYMENT_ELEMENT = 'payment', 44 | EMBEDDED = 'embedded', 45 | } 46 | 47 | export type SupportedPaymentComponentsSchemaDTO = Static; 48 | -------------------------------------------------------------------------------- /processor/src/services/commerce-tools/productTypeClient.ts: -------------------------------------------------------------------------------- 1 | import { ProductType, ProductTypeDraft } from '@commercetools/platform-sdk'; 2 | import { paymentSDK } from '../../payment-sdk'; 3 | import { KeyAndVersion } from './customTypeHelper'; 4 | 5 | const apiClient = paymentSDK.ctAPI.client; 6 | 7 | export async function getProductTypeByKey(key: string): Promise { 8 | const res = await apiClient 9 | .productTypes() 10 | .get({ queryArgs: { where: `key="${key}"` } }) 11 | .execute(); 12 | return res.body.results[0] || undefined; 13 | } 14 | 15 | export async function getProductsByProductTypeId(productTypeId?: string, limit = 1) { 16 | const res = await apiClient 17 | .products() 18 | .get({ queryArgs: { where: `productType(id="${productTypeId}")`, limit } }) 19 | .execute(); 20 | return res.body.results; 21 | } 22 | 23 | export async function deleteProductType({ key, version }: KeyAndVersion) { 24 | await apiClient.productTypes().withKey({ key }).delete({ queryArgs: { version } }).execute(); 25 | } 26 | 27 | export async function createProductType(body: ProductTypeDraft) { 28 | const newProductType = await apiClient.productTypes().post({ body }).execute(); 29 | return newProductType.body; 30 | } 31 | -------------------------------------------------------------------------------- /processor/src/dtos/operations/status.dto.ts: -------------------------------------------------------------------------------- 1 | import { Static, Type } from '@sinclair/typebox'; 2 | 3 | /** 4 | * Status response schema. 5 | * 6 | * Example: 7 | * { 8 | * "status": "OK", 9 | * "timestamp": "2024-07-15T14:00:43.068Z", 10 | * "version": "3.0.2", 11 | * "metadata": { 12 | * "name": "payment-integration-template", 13 | * "description": "Payment provider integration template", 14 | * "@commercetools/connect-payments-sdk": "" 15 | * }, 16 | * "checks": [ 17 | * { 18 | * "name": "CoCo Permissions", 19 | * "status": "UP" 20 | * }, 21 | * { 22 | * "name": "Mock Payment API", 23 | * "status": "UP" 24 | * } 25 | * ] 26 | * } 27 | * 28 | * 29 | */ 30 | export const StatusResponseSchema = Type.Object({ 31 | status: Type.String(), 32 | timestamp: Type.String(), 33 | version: Type.String(), 34 | metadata: Type.Optional(Type.Any()), 35 | checks: Type.Array( 36 | Type.Object({ 37 | name: Type.String(), 38 | status: Type.String(), 39 | details: Type.Optional(Type.Any()), 40 | message: Type.Optional(Type.String()), 41 | }), 42 | ), 43 | }); 44 | 45 | export type StatusResponseSchemaDTO = Static; 46 | -------------------------------------------------------------------------------- /processor/src/services/types/operation.type.ts: -------------------------------------------------------------------------------- 1 | import { ConfigResponseSchemaDTO } from '../../dtos/operations/config.dto'; 2 | import { 3 | AmountSchemaDTO, 4 | PaymentIntentRequestSchemaDTO, 5 | PaymentModificationStatus, 6 | } from '../../dtos/operations/payment-intents.dto'; 7 | import { StatusResponseSchemaDTO } from '../../dtos/operations/status.dto'; 8 | import { Payment } from '@commercetools/connect-payments-sdk/dist/commercetools'; 9 | 10 | export type CapturePaymentRequest = { 11 | amount: AmountSchemaDTO; 12 | payment: Payment; 13 | merchantReference?: string; 14 | }; 15 | 16 | export type CancelPaymentRequest = { 17 | payment: Payment; 18 | merchantReference?: string; 19 | }; 20 | 21 | export type RefundPaymentRequest = { 22 | amount: AmountSchemaDTO; 23 | payment: Payment; 24 | merchantReference?: string; 25 | }; 26 | 27 | export type ReversePaymentRequest = { 28 | payment: Payment; 29 | merchantReference?: string; 30 | }; 31 | 32 | export type PaymentProviderModificationResponse = { 33 | outcome: PaymentModificationStatus; 34 | pspReference: string; 35 | }; 36 | 37 | export type ConfigResponse = ConfigResponseSchemaDTO; 38 | 39 | export type StatusResponse = StatusResponseSchemaDTO; 40 | 41 | export type ModifyPayment = { 42 | paymentId: string; 43 | stripePaymentIntent?: string; 44 | stripeEventType?: string; 45 | data: PaymentIntentRequestSchemaDTO; 46 | }; 47 | -------------------------------------------------------------------------------- /processor/test/services/commerce-tools/customerClient.spec.ts: -------------------------------------------------------------------------------- 1 | import { afterEach, beforeEach, describe, expect, it, jest } from '@jest/globals'; 2 | import { mock_SetCustomFieldActions } from '../../utils/mock-actions-data'; 3 | import { paymentSDK } from '../../../src/payment-sdk'; 4 | import { mockCtCustomerData } from '../../utils/mock-customer-data'; 5 | import { updateCustomerById } from '../../../src/services/commerce-tools/customerClient'; 6 | 7 | describe('ProductTypeHelper testing', () => { 8 | beforeEach(() => { 9 | jest.setTimeout(10000); 10 | jest.resetAllMocks(); 11 | }); 12 | 13 | afterEach(() => { 14 | jest.restoreAllMocks(); 15 | }); 16 | 17 | describe('updateCustomerById', () => { 18 | it('should update the customer successfully', async () => { 19 | const executeMock = jest.fn().mockReturnValue(Promise.resolve({ body: mockCtCustomerData })); 20 | const client = paymentSDK.ctAPI.client; 21 | client.customers = jest.fn(() => ({ 22 | withId: jest.fn(() => ({ 23 | post: jest.fn(() => ({ 24 | execute: executeMock, 25 | })), 26 | })), 27 | })) as never; 28 | const result = await updateCustomerById({ 29 | id: 'customer-id', 30 | version: 1, 31 | actions: mock_SetCustomFieldActions, 32 | }); 33 | expect(result).toEqual(mockCtCustomerData); 34 | }); 35 | }); 36 | }); 37 | -------------------------------------------------------------------------------- /processor/test/clients/stripe.client.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, test, expect, afterEach, jest, beforeEach } from '@jest/globals'; 2 | import * as StripeClient from '../../src/clients/stripe.client'; 3 | import { mockCancelPaymentErrorResult } from '../utils/mock-payment-results'; 4 | import { StripeApiError } from '../../src/errors/stripe-api.error'; 5 | import * as Logger from '../../src/libs/logger'; 6 | 7 | jest.mock('../../src/libs/logger'); 8 | 9 | describe('wrapStripeError', () => { 10 | beforeEach(() => { 11 | jest.setTimeout(10000); 12 | jest.resetAllMocks(); 13 | }); 14 | 15 | afterEach(() => { 16 | jest.restoreAllMocks(); 17 | }); 18 | 19 | test('should return the original error due to a general error', async () => { 20 | const error = new Error('Error'); 21 | 22 | const result = StripeClient.wrapStripeError(error); 23 | 24 | expect(result.message).toBe('Error'); 25 | expect(Logger.log.error).toHaveBeenCalledTimes(1); 26 | }); 27 | 28 | test('should return a StripeApiError', async () => { 29 | const result = StripeClient.wrapStripeError(JSON.parse(JSON.stringify(mockCancelPaymentErrorResult))); 30 | 31 | const resultStripeApiError = result as StripeApiError; 32 | 33 | expect(result).toBeInstanceOf(StripeApiError); 34 | expect(resultStripeApiError.message).toBe('No such payment_intent: 07bd2613-8daf-4760-a5c4-21d6cca91276'); 35 | expect(resultStripeApiError.code).toBe('resource_missing'); 36 | }); 37 | }); 38 | -------------------------------------------------------------------------------- /processor/test/utils/utils.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, test, expect, jest } from '@jest/globals'; 2 | import { parseJSON } from '../../src/utils'; 3 | 4 | describe('parseJSON', () => { 5 | test('should parse valid JSON string', () => { 6 | const jsonString = '{"key": "test value"}'; 7 | const result = parseJSON<{ key: string }>(jsonString); 8 | expect(result).toEqual({ key: 'test value' }); 9 | }); 10 | 11 | test('should return empty object for invalid string and log error', () => { 12 | const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); 13 | const jsonString = 'invalid json'; 14 | const result = parseJSON<{ key: string }>(jsonString); 15 | expect(consoleErrorSpy).toHaveBeenCalledWith('Error parsing JSON', expect.any(SyntaxError)); 16 | expect(result).toEqual({}); 17 | consoleErrorSpy.mockRestore(); 18 | }); 19 | 20 | test('should return empty object for empty string', () => { 21 | const jsonString = ''; 22 | const result = parseJSON<{ key: string }>(jsonString); 23 | expect(result).toEqual({}); 24 | }); 25 | 26 | test('should return empty object for null', () => { 27 | const jsonString = null as unknown as string; 28 | const result = parseJSON<{ key: string }>(jsonString); 29 | expect(result).toEqual({}); 30 | }); 31 | 32 | test('should return empty object for undefined', () => { 33 | const jsonString = undefined as unknown as string; 34 | const result = parseJSON<{ key: string }>(jsonString); 35 | expect(result).toEqual({}); 36 | }); 37 | }); 38 | -------------------------------------------------------------------------------- /processor/src/dtos/operations/transaction.dto.ts: -------------------------------------------------------------------------------- 1 | import { Static, Type } from '@sinclair/typebox'; 2 | 3 | export const TransactionDraft = Type.Object({ 4 | cartId: Type.String({ format: 'uuid' }), 5 | paymentInterface: Type.String({ format: 'uuid' }), 6 | amount: Type.Optional( 7 | Type.Object({ 8 | centAmount: Type.Number(), 9 | currencyCode: Type.String(), 10 | }), 11 | ), 12 | }); 13 | 14 | const TransactionStatePending = Type.Literal('Pending', { 15 | description: 'The authorization/capture has not happened yet. Most likely because we need to receive notification.', 16 | }); 17 | 18 | const TransactionStateFailed = Type.Literal('Failed', { 19 | description: "Any error that occured for which the system can't recover automatically from.", 20 | }); 21 | 22 | const TransactionStateComplete = Type.Literal('Completed', { 23 | description: 'If there is a successful authorization/capture on the payment-transaction.', 24 | }); 25 | 26 | export const TransactionStatusState = Type.Union([ 27 | TransactionStateComplete, 28 | TransactionStateFailed, 29 | TransactionStatePending, 30 | ]); 31 | 32 | export const TransactionResponse = Type.Object({ 33 | transactionStatus: Type.Object({ 34 | state: TransactionStatusState, 35 | errors: Type.Array( 36 | Type.Object({ 37 | code: Type.Literal('PaymentRejected'), 38 | message: Type.String(), 39 | }), 40 | ), 41 | }), 42 | }); 43 | 44 | export type TransactionDraftDTO = Static; 45 | export type TransactionResponseDTO = Static; 46 | -------------------------------------------------------------------------------- /enabler/src/components/base.ts: -------------------------------------------------------------------------------- 1 | import { ComponentOptions, PaymentComponent, PaymentMethod } from '../payment-enabler/payment-enabler'; 2 | import { BaseOptions } from "../payment-enabler/payment-enabler-mock"; 3 | import {Stripe, StripePaymentElement} from "@stripe/stripe-js"; 4 | 5 | 6 | /** 7 | * Base Web Component 8 | */ 9 | export abstract class BaseComponent implements PaymentComponent { 10 | 11 | protected paymentMethod: PaymentMethod; 12 | protected processorUrl: BaseOptions['processorUrl']; 13 | protected sessionId: BaseOptions['sessionId']; 14 | protected environment: BaseOptions['environment']; 15 | protected sdk: Stripe; 16 | protected stripePaymentElement: StripePaymentElement; 17 | 18 | constructor(paymentMethod: PaymentMethod, baseOptions: BaseOptions, _componentOptions: ComponentOptions) { 19 | this.paymentMethod = paymentMethod; 20 | this.sdk = baseOptions.sdk; 21 | this.processorUrl = baseOptions.processorUrl; 22 | this.sessionId = baseOptions.sessionId; 23 | this.environment = baseOptions.environment; 24 | this.stripePaymentElement = baseOptions.paymentElement; 25 | 26 | /**this.onComplete = baseOptions.configuration.onComplete; 27 | this.onError = baseOptions.configuration.onError;**/ 28 | } 29 | 30 | abstract submit(): void; 31 | 32 | abstract mount(selector: string): void ; 33 | 34 | showValidation?(): void; 35 | isValid?(): boolean; 36 | getState?(): { 37 | card?: { 38 | endDigits?: string; 39 | brand?: string; 40 | expiryDate? : string; 41 | } 42 | }; 43 | isAvailable?(): Promise; 44 | } 45 | -------------------------------------------------------------------------------- /processor/src/services/commerce-tools/customTypeClient.ts: -------------------------------------------------------------------------------- 1 | import { Type, TypeDraft, TypeUpdateAction } from '@commercetools/connect-payments-sdk'; 2 | import { KeyAndVersion } from './customTypeHelper'; 3 | import { paymentSDK } from '../../payment-sdk'; 4 | 5 | const apiClient = paymentSDK.ctAPI.client; 6 | 7 | export async function getTypeByKey(key: string): Promise { 8 | const res = await apiClient 9 | .types() 10 | .get({ queryArgs: { where: `key="${key}"` } }) 11 | .execute(); 12 | return res.body.results[0] || undefined; 13 | } 14 | 15 | export async function getTypesByResourceTypeId(resourceTypeId: string) { 16 | const res = await apiClient 17 | .types() 18 | .get({ 19 | queryArgs: { 20 | where: `resourceTypeIds contains any ("${resourceTypeId}")`, 21 | }, 22 | }) 23 | .execute(); 24 | return res.body.results; 25 | } 26 | 27 | export async function createCustomType(customType: TypeDraft): Promise { 28 | const res = await apiClient.types().post({ body: customType }).execute(); 29 | return res.body.id; 30 | } 31 | 32 | export async function updateCustomTypeByKey({ 33 | key, 34 | version, 35 | actions, 36 | }: KeyAndVersion & { actions: TypeUpdateAction[] }) { 37 | await apiClient.types().withKey({ key }).post({ body: { version, actions } }).execute(); 38 | } 39 | 40 | export async function deleteCustomTypeByKey({ key, version }: KeyAndVersion): Promise { 41 | await apiClient 42 | .types() 43 | .withKey({ key }) 44 | .delete({ 45 | queryArgs: { version }, 46 | }) 47 | .execute(); 48 | } 49 | -------------------------------------------------------------------------------- /processor/src/server/plugins/stripe-payment.plugin.ts: -------------------------------------------------------------------------------- 1 | import { FastifyInstance } from 'fastify'; 2 | import { paymentSDK } from '../../payment-sdk'; 3 | import { 4 | configElementRoutes, 5 | customerRoutes, 6 | paymentRoutes, 7 | stripeWebhooksRoutes, 8 | } from '../../routes/stripe-payment.route'; 9 | import { StripePaymentService } from '../../services/stripe-payment.service'; 10 | import { StripeHeaderAuthHook } from '../../libs/fastify/hooks/stripe-header-auth.hook'; 11 | 12 | export default async function (server: FastifyInstance) { 13 | const stripePaymentService = new StripePaymentService({ 14 | ctCartService: paymentSDK.ctCartService, 15 | ctPaymentService: paymentSDK.ctPaymentService, 16 | ctOrderService: paymentSDK.ctOrderService, 17 | ctPaymentMethodService: paymentSDK.ctPaymentMethodService, 18 | ctRecurringPaymentJobService: paymentSDK.ctRecurringPaymentJobService, 19 | }); 20 | 21 | await server.register(customerRoutes, { 22 | paymentService: stripePaymentService, 23 | sessionHeaderAuthHook: paymentSDK.sessionHeaderAuthHookFn, 24 | }); 25 | 26 | await server.register(paymentRoutes, { 27 | paymentService: stripePaymentService, 28 | sessionHeaderAuthHook: paymentSDK.sessionHeaderAuthHookFn, 29 | }); 30 | 31 | const stripeHeaderAuthHook = new StripeHeaderAuthHook(); 32 | await server.register(stripeWebhooksRoutes, { 33 | paymentService: stripePaymentService, 34 | stripeHeaderAuthHook: stripeHeaderAuthHook, 35 | }); 36 | 37 | await server.register(configElementRoutes, { 38 | paymentService: stripePaymentService, 39 | sessionHeaderAuthHook: paymentSDK.sessionHeaderAuthHookFn, 40 | }); 41 | } 42 | -------------------------------------------------------------------------------- /processor/.env.template: -------------------------------------------------------------------------------- 1 | # CoCo credentials 2 | CTP_AUTH_URL=https://auth.[region].commercetools.com 3 | CTP_API_URL=https://api.[region].commercetools.com 4 | CTP_SESSION_URL=https://session.[region].commercetools.com 5 | CTP_CHECKOUT_URL=https://checkout.[region].commercetools.com 6 | CTP_CLIENT_ID=[composable-commerce-client-id] 7 | CTP_CLIENT_SECRET=[composable-commerce-client-secret] 8 | CTP_PROJECT_KEY=[composable-commerce-project-key] 9 | CTP_JWKS_URL=https://mc-api.[region].commercetools.com/.well-known/jwks.json 10 | CTP_JWT_ISSUER=https://mc-api.[region].commercetools.com 11 | 12 | # Merchant return URL for redirecting the user back to the merchant website after the payment is completed 13 | # Use it as a fallback if no merchantReturnUrl is provided in the session 14 | MERCHANT_RETURN_URL=[Merchant-website-return-url] 15 | # The payment interface value used in the commercetools payment/payment methods. Default value is "checkout-stripe". 16 | PAYMENT_INTERFACE=[Custom-payment-interface] 17 | 18 | STRIPE_SECRET_KEY=[Secret-key] 19 | STRIPE_PUBLISHABLE_KEY=[Publishable-key] 20 | STRIPE_CAPTURE_METHOD=[Posibble-values:manual|automatic|automatic_async] 21 | STRIPE_APPEARANCE_PAYMENT_ELEMENT=[Json-value] 22 | STRIPE_WEBHOOK_SIGNING_SECRET=[Webhook-secret-key] 23 | STRIPE_WEBHOOK_ID=[Stripe-Webhook-ID] 24 | STRIPE_APPLE_PAY_WELL_KNOWN=[Apple:.well-known-file-string] 25 | STRIPE_SAVED_PAYMENT_METHODS_CONFIG=[Stripe-SPM-Config] 26 | 27 | # Enable multicapture and multirefund support (default: false) 28 | # Set to 'true' to enable multiple partial captures and refunds on a single payment 29 | # Requires multicapture to be enabled in your Stripe account 30 | STRIPE_ENABLE_MULTI_OPERATIONS=[true|false] 31 | -------------------------------------------------------------------------------- /processor/src/connectors/post-deploy.ts: -------------------------------------------------------------------------------- 1 | import * as dotenv from 'dotenv'; 2 | dotenv.config(); 3 | 4 | import { 5 | createOrUpdateCustomerCustomType, 6 | createLaunchpadPurchaseOrderNumberCustomType, 7 | retrieveWebhookEndpoint, 8 | updateWebhookEndpoint, 9 | } from './actions'; 10 | 11 | const STRIPE_WEBHOOKS_ROUTE = 'stripe/webhooks'; 12 | const CONNECT_SERVICE_URL = 'CONNECT_SERVICE_URL'; 13 | const STRIPE_WEBHOOK_ID = 'STRIPE_WEBHOOK_ID'; 14 | const msgError = 'Post-deploy failed:'; 15 | 16 | async function postDeploy(properties: Map) { 17 | await createLaunchpadPurchaseOrderNumberCustomType(); 18 | 19 | const applicationUrl = properties.get(CONNECT_SERVICE_URL) as string; 20 | const stripeWebhookId = (properties.get(STRIPE_WEBHOOK_ID) as string) ?? ''; 21 | 22 | if (properties) { 23 | if (stripeWebhookId === '') { 24 | process.stderr.write( 25 | `${msgError} STRIPE_WEBHOOK_ID var is not assigned. Add the connector URL manually on the Stripe Webhook Dashboard\n`, 26 | ); 27 | } else { 28 | const we = await retrieveWebhookEndpoint(stripeWebhookId); 29 | const weAppUrl = `${applicationUrl}${STRIPE_WEBHOOKS_ROUTE}`; 30 | if (we?.url !== weAppUrl) { 31 | await updateWebhookEndpoint(stripeWebhookId, weAppUrl); 32 | } 33 | } 34 | } 35 | 36 | await createOrUpdateCustomerCustomType(); 37 | } 38 | 39 | export async function runPostDeployScripts() { 40 | try { 41 | const properties = new Map(Object.entries(process.env)); 42 | await postDeploy(properties); 43 | } catch (error) { 44 | if (error instanceof Error) { 45 | process.stderr.write(`Post-deploy failed: ${error.message}\n`); 46 | } 47 | process.exitCode = 1; 48 | } 49 | } 50 | 51 | runPostDeployScripts(); 52 | -------------------------------------------------------------------------------- /enabler/dev-utils/session.js: -------------------------------------------------------------------------------- 1 | const projectKey = __VITE_CTP_PROJECT_KEY__; 2 | 3 | const fetchAdminToken = async () => { 4 | const myHeaders = new Headers(); 5 | 6 | myHeaders.append('Authorization', `Basic ${btoa(`${__VITE_CTP_CLIENT_ID__}:${__VITE_CTP_CLIENT_SECRET__}`)}`); 7 | myHeaders.append('Content-Type', 'application/x-www-form-urlencoded'); 8 | 9 | var urlencoded = new URLSearchParams(); 10 | urlencoded.append('grant_type', 'client_credentials'); 11 | //urlencoded.append('scope', __VITE_ADMIN_SCOPE__); 12 | 13 | const response = await fetch(`${__VITE_CTP_AUTH_URL__}/oauth/token`, { 14 | body: urlencoded, 15 | headers: myHeaders, 16 | method: 'POST', 17 | redirect: 'follow', 18 | }); 19 | 20 | const token = await response.json(); 21 | 22 | if (response.status !== 200) { 23 | return; 24 | } else { 25 | } 26 | return token.access_token; 27 | } 28 | 29 | const getSessionId = async(cartId) => { 30 | const accessToken = await fetchAdminToken(); 31 | 32 | const sessionMetadata = { 33 | processorUrl: __VITE_PROCESSOR_URL__, 34 | allowedPaymentMethods: ["card", "invoice", "purchaseorder", "dropin"], // add here your allowed methods for development purposes 35 | }; 36 | 37 | const url = `${__VITE_CTP_SESSION_URL__}/${projectKey}/sessions` 38 | 39 | const res = await fetch(url, { 40 | method: 'POST', 41 | headers: { 42 | 'Content-Type': 'application/json', 43 | Authorization: `Bearer ${accessToken}`, 44 | }, 45 | body: JSON.stringify({ 46 | cart: { 47 | cartRef: { 48 | id: cartId, 49 | } 50 | }, 51 | metadata: sessionMetadata, 52 | }), 53 | }); 54 | const data = await res.json(); 55 | 56 | if (!res.ok) { 57 | throw new Error("Not able to create session") 58 | } 59 | 60 | return data.id; 61 | } 62 | -------------------------------------------------------------------------------- /processor/src/services/types/stripe-payment.type.ts: -------------------------------------------------------------------------------- 1 | import { PaymentRequestSchemaDTO } from '../../dtos/stripe-payment.dto'; 2 | import { 3 | CommercetoolsCartService, 4 | CommercetoolsOrderService, 5 | CommercetoolsPaymentMethodService, 6 | CommercetoolsPaymentService, 7 | CommercetoolsRecurringPaymentJobService, 8 | PaymentMethodInfoDraft, 9 | TransactionData, 10 | } from '@commercetools/connect-payments-sdk'; 11 | import { PSPInteraction } from '@commercetools/connect-payments-sdk/dist/commercetools/types/payment.type'; 12 | 13 | export type StripePaymentServiceOptions = { 14 | ctCartService: CommercetoolsCartService; 15 | ctPaymentService: CommercetoolsPaymentService; 16 | ctOrderService: CommercetoolsOrderService; 17 | ctPaymentMethodService: CommercetoolsPaymentMethodService; 18 | ctRecurringPaymentJobService: CommercetoolsRecurringPaymentJobService; 19 | }; 20 | 21 | export type CreatePayment = { 22 | data: PaymentRequestSchemaDTO; 23 | }; 24 | export type CaptureMethod = 'automatic' | 'automatic_async' | 'manual'; 25 | 26 | export type StripeEventUpdatePayment = { 27 | id: string; 28 | pspReference?: string; 29 | transactions: TransactionData[]; 30 | paymentMethod?: string; 31 | paymentMethodInfo?: PaymentMethodInfoDraft; 32 | pspInteraction?: PSPInteraction; 33 | }; 34 | 35 | export enum StripeEvent { 36 | PAYMENT_INTENT__SUCCEEDED = 'payment_intent.succeeded', 37 | PAYMENT_INTENT__CANCELED = 'payment_intent.canceled', 38 | PAYMENT_INTENT__REQUIRED_ACTION = 'payment_intent.requires_action', 39 | PAYMENT_INTENT__PAYMENT_FAILED = 'payment_intent.payment_failed', 40 | CHARGE__REFUNDED = 'charge.refunded', 41 | CHARGE__SUCCEEDED = 'charge.succeeded', 42 | CHARGE__UPDATED = 'charge.updated', 43 | } 44 | 45 | export enum PaymentStatus { 46 | FAILURE = 'Failure', 47 | SUCCESS = 'Success', 48 | PENDING = 'Pending', 49 | INITIAL = 'Initial', 50 | } 51 | -------------------------------------------------------------------------------- /processor/src/payment-sdk.ts: -------------------------------------------------------------------------------- 1 | import { RequestContextData, setupPaymentSDK, Logger } from '@commercetools/connect-payments-sdk'; 2 | import { config } from './config/config'; 3 | import { getRequestContext, updateRequestContext } from './libs/fastify/context/context'; 4 | import { log } from './libs/logger/index'; 5 | 6 | export class AppLogger implements Logger { 7 | public debug = (obj: object, message: string) => { 8 | log.debug(message, obj || undefined); 9 | }; 10 | public info = (obj: object, message: string) => { 11 | log.info(message, obj || undefined); 12 | }; 13 | public warn = (obj: object, message: string) => { 14 | log.warn(message, obj || undefined); 15 | }; 16 | public error = (obj: object, message: string) => { 17 | log.error(message, obj || undefined); 18 | }; 19 | } 20 | 21 | export const appLogger = new AppLogger(); 22 | 23 | export const paymentSDK = setupPaymentSDK({ 24 | apiUrl: config.apiUrl, 25 | authUrl: config.authUrl, 26 | clientId: config.clientId, 27 | clientSecret: config.clientSecret, 28 | projectKey: config.projectKey, 29 | sessionUrl: config.sessionUrl, 30 | checkoutUrl: config.checkoutUrl, 31 | jwksUrl: config.jwksUrl, 32 | jwtIssuer: config.jwtIssuer, 33 | getContextFn: (): RequestContextData => { 34 | const { correlationId, requestId, authentication } = getRequestContext(); 35 | return { 36 | correlationId: correlationId || '', 37 | requestId: requestId || '', 38 | authentication, 39 | }; 40 | }, 41 | updateContextFn: (context: Partial) => { 42 | const requestContext = Object.assign( 43 | {}, 44 | context.correlationId ? { correlationId: context.correlationId } : {}, 45 | context.requestId ? { requestId: context.requestId } : {}, 46 | context.authentication ? { authentication: context.authentication } : {}, 47 | ); 48 | updateRequestContext(requestContext); 49 | }, 50 | logger: appLogger, 51 | }); 52 | -------------------------------------------------------------------------------- /enabler/src/dtos/mock-payment.dto.ts: -------------------------------------------------------------------------------- 1 | import { Static, Type } from '@sinclair/typebox'; 2 | 3 | export enum PaymentOutcome { 4 | AUTHORIZED = 'Authorized', 5 | REJECTED = 'Rejected', 6 | } 7 | 8 | export const PaymentOutcomeSchema = Type.Enum(PaymentOutcome); 9 | 10 | export const PaymentRequestSchema = Type.Object({ 11 | paymentMethod: Type.Object({ 12 | type: Type.String(), 13 | poNumber: Type.Optional(Type.String()), 14 | invoiceMemo: Type.Optional(Type.String()), 15 | }), 16 | paymentOutcome: PaymentOutcomeSchema, 17 | }); 18 | 19 | export const PaymentResponseSchema = Type.Object({ 20 | sClientSecret: Type.String(), 21 | paymentReference: Type.String(), 22 | merchantReturnUrl: Type.String(), 23 | cartId: Type.String(), 24 | billingAddress: Type.Optional(Type.String()), 25 | }); 26 | 27 | export const ConfigElementResponseSchema = Type.Object({ 28 | cartInfo: Type.Object({ 29 | amount: Type.Number(), 30 | currency: Type.String(), 31 | }), 32 | appearance: Type.Optional(Type.String()), 33 | captureMethod: Type.Union([Type.Literal('manual'), Type.Literal('automatic')]), 34 | setupFutureUsage: Type.Optional(Type.Union([Type.Literal('off_session'), Type.Literal('on_session')])), 35 | layout: Type.String(), 36 | collectBillingAddress: Type.Union([Type.Literal('auto'), Type.Literal('never'), Type.Literal('if_required')]), 37 | }); 38 | 39 | export const ConfigResponseSchema = Type.Object({ 40 | environment: Type.String(), 41 | publishableKey: Type.String(), 42 | }); 43 | 44 | export const CustomerResponseSchema = Type.Object({ 45 | stripeCustomerId: Type.String(), 46 | ephemeralKey: Type.String(), 47 | sessionId: Type.String(), 48 | }); 49 | 50 | export type PaymentRequestSchemaDTO = Static; 51 | export type PaymentResponseSchemaDTO = Static; 52 | export type ConfigElementResponseSchemaDTO = Static; 53 | export type ConfigResponseSchemaDTO = Static; 54 | export type CustomerResponseSchemaDTO = Static; 55 | -------------------------------------------------------------------------------- /processor/src/server/server.ts: -------------------------------------------------------------------------------- 1 | import autoLoad from '@fastify/autoload'; 2 | import cors from '@fastify/cors'; 3 | import fastifyFormBody from '@fastify/formbody'; 4 | import Fastify from 'fastify'; 5 | import { randomUUID } from 'node:crypto'; 6 | import { join } from 'path'; 7 | import { config } from '../config/config'; 8 | import { requestContextPlugin } from '../libs/fastify/context/context'; 9 | import { errorHandler } from '../libs/fastify/error-handler'; 10 | const rawBody = import('fastify-raw-body'); 11 | 12 | /** 13 | * Setup Fastify server instance 14 | * @returns 15 | */ 16 | export const setupFastify = async () => { 17 | // Create fastify server instance 18 | const server = Fastify({ 19 | logger: { 20 | level: config.loggerLevel, 21 | }, 22 | genReqId: () => randomUUID().toString(), 23 | requestIdLogLabel: 'requestId', 24 | requestIdHeader: 'x-request-id', 25 | }); 26 | 27 | // Config raw body for webhooks routes 28 | await server.register(rawBody, { 29 | field: 'rawBody', // change the default request.rawBody property name 30 | global: false, // add the rawBody to every request. **Default true** 31 | encoding: false, // set it to false to set rawBody as a Buffer **Default utf8** 32 | runFirst: true, // get the body before any preParsing hook change/uncompress it. **Default false** 33 | routes: ['/stripe/webhooks'], // array of routes, **`global`** will be ignored, wildcard routes not supported 34 | }); 35 | 36 | // Setup error handler 37 | server.setErrorHandler(errorHandler); 38 | 39 | // Enable CORS 40 | await server.register(cors, { 41 | allowedHeaders: ['Content-Type', 'Authorization', 'X-Correlation-ID', 'X-Request-ID', 'X-Session-ID'], 42 | origin: '*', 43 | }); 44 | 45 | // Add content type parser for the content type application/x-www-form-urlencoded 46 | await server.register(fastifyFormBody); 47 | 48 | // Register context plugin 49 | await server.register(requestContextPlugin); 50 | 51 | await server.register(autoLoad, { 52 | dir: join(__dirname, 'plugins'), 53 | }); 54 | 55 | return server; 56 | }; 57 | -------------------------------------------------------------------------------- /processor/eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import typescriptEslint from '@typescript-eslint/eslint-plugin'; 2 | import jest from 'eslint-plugin-jest'; 3 | import prettier from 'eslint-plugin-prettier'; 4 | import unusedImports from 'eslint-plugin-unused-imports'; 5 | import globals from 'globals'; 6 | import tsParser from '@typescript-eslint/parser'; 7 | import path from 'node:path'; 8 | import { fileURLToPath } from 'node:url'; 9 | import js from '@eslint/js'; 10 | import { FlatCompat } from '@eslint/eslintrc'; 11 | 12 | const __filename = fileURLToPath(import.meta.url); 13 | const __dirname = path.dirname(__filename); 14 | const compat = new FlatCompat({ 15 | baseDirectory: __dirname, 16 | recommendedConfig: js.configs.recommended, 17 | allConfig: js.configs.all, 18 | }); 19 | 20 | export default [ 21 | ...compat.extends( 22 | 'eslint:recommended', 23 | 'plugin:@typescript-eslint/recommended', 24 | 'prettier', 25 | 'plugin:prettier/recommended', 26 | ), 27 | { 28 | plugins: { 29 | '@typescript-eslint': typescriptEslint, 30 | jest, 31 | prettier, 32 | 'unused-imports': unusedImports, 33 | }, 34 | files: ['*.ts'], 35 | languageOptions: { 36 | globals: { 37 | ...globals.node, 38 | ...globals.jest, 39 | }, 40 | 41 | parser: tsParser, 42 | }, 43 | 44 | rules: { 45 | 'no-redeclare': ['warn'], 46 | 'no-console': ['error'], 47 | 'no-unused-vars': 'off', 48 | 'no-irregular-whitespace': 'warn', 49 | 'unused-imports/no-unused-imports': 'error', 50 | 51 | 'unused-imports/no-unused-vars': [ 52 | 'warn', 53 | { 54 | vars: 'all', 55 | varsIgnorePattern: '^_', 56 | args: 'after-used', 57 | argsIgnorePattern: '^_', 58 | }, 59 | ], 60 | 61 | '@typescript-eslint/no-unused-vars': ['warn'], 62 | '@typescript-eslint/no-explicit-any': 'off', 63 | '@typescript-eslint/no-var-requires': 'off', 64 | 65 | 'sort-imports': [ 66 | 'error', 67 | { 68 | ignoreCase: true, 69 | ignoreDeclarationSort: true, 70 | ignoreMemberSort: false, 71 | memberSyntaxSortOrder: ['none', 'all', 'multiple', 'single'], 72 | }, 73 | ], 74 | }, 75 | }, 76 | ]; 77 | -------------------------------------------------------------------------------- /enabler/src/style/_colors.scss: -------------------------------------------------------------------------------- 1 | /** 2 | * From https://www.figma.com/file/B3AuN5HVBARkLRXuYUWVY0/CA_UI-library?node-id=10-17139&t=HIYfSjMPWF7Q8zEX-0 3 | */ 4 | 5 | $color-style-black: #000; 6 | $color-style-white: #fff; 7 | 8 | // Primary 9 | $color-style-primary-active: #115190; 10 | $color-style-primary-disabled-background: #e0e0e0; 11 | $color-style-primary-disabled-stroke: #9d9d9d; 12 | $color-style-primary-hover-background: #edf4fc; 13 | $color-style-primary-hover: #115fac; 14 | $color-style-primary-low-capacity: #5b9ede; 15 | $color-style-primary-main: #186ec3; 16 | 17 | // Secondary 18 | $color-style-secondary-card-background: #f2f2f2; 19 | $color-style-secondary-disabled-background: #fcfcfc; 20 | $color-style-secondary-disabled-stroke: #e0e0e0; 21 | $color-style-secondary-main: #555557; 22 | 23 | // Error 24 | $color-style-error-active: #ffc5c5; 25 | $color-style-error-background: linear-gradient( 26 | 0deg, 27 | rgba(255, 255, 255, 0.9), 28 | rgba(255, 255, 255, 0.9) 29 | ), 30 | #d32f2f; 31 | $color-style-error-dark: #b52323; 32 | $color-style-error-hover: #fbdada; 33 | $color-style-error-main: #d32f2f; 34 | 35 | // Warning 36 | $color-style-warning-active: #ffd0a9; 37 | $color-style-warning-background: #ffebdb; 38 | $color-style-warning-dark: #e16600; 39 | $color-style-warning-hover: #ffd6b5; 40 | $color-style-warning-main: #ed6c02; 41 | 42 | // Success 43 | $color-style-success-active: #c8e4ca; 44 | $color-style-success-background: #e8f5e9; 45 | $color-style-success-dark: #2e7d32; 46 | $color-style-success-hover: #dcecdd; 47 | $color-style-success-main: #4caf50; 48 | 49 | // Info 50 | $color-style-info-active: #cce0eb; 51 | $color-style-info-background: #e8f3fa; 52 | $color-style-info-dark: #006fac; 53 | $color-style-info-hover: #d6e9f3; 54 | $color-style-info-main: #0288d1; 55 | 56 | // Other 57 | $color-style-other-border-dark: #4f4f4f; 58 | $color-style-other-border-default: #949494; 59 | $color-style-other-border-divider: #dee2e6; 60 | $color-style-other-card-background: #f7f7f7; 61 | $color-style-other-divider: rgba(0, 0, 0, 0.12); 62 | $color-style-other-tag-background: #ededed; 63 | $color-style-other-tag-background-discount: #ddf7de; 64 | $color-style-other-tag-text-discount: #105e14; 65 | $color-style-other-tooltip-background: rgba(97, 97, 97, 0.9); 66 | 67 | // Text 68 | $color-style-text-black: #333333; 69 | $color-style-text-disabled: #a2a3a4; 70 | $color-style-text-helper: #5e6368; 71 | $color-style-text-label: #4f4f4f; 72 | 73 | // Layout 74 | $color-style-side-background: #f8f8f8; 75 | -------------------------------------------------------------------------------- /processor/src/dtos/stripe-payment.dto.ts: -------------------------------------------------------------------------------- 1 | import { Static, Type } from '@sinclair/typebox'; 2 | import { PaymentMethodType, PaymentOutcomeSchema } from './mock-payment.dto'; 3 | 4 | export const CreatePaymentMethodSchema = Type.Object({ 5 | type: Type.Union([Type.Enum(PaymentMethodType), Type.String()]), 6 | poNumber: Type.Optional(Type.String()), 7 | invoiceMemo: Type.Optional(Type.String()), 8 | confirmationToken: Type.Optional(Type.String()), 9 | }); 10 | 11 | export const PaymentRequestSchema = Type.Object({ 12 | paymentMethod: Type.Composite([CreatePaymentMethodSchema]), 13 | cart: Type.Optional( 14 | Type.Object({ 15 | id: Type.String(), 16 | }), 17 | ), 18 | paymentIntent: Type.Optional( 19 | Type.Object({ 20 | id: Type.String(), 21 | }), 22 | ), 23 | paymentOutcome: Type.Optional(PaymentOutcomeSchema), 24 | }); 25 | 26 | export enum PaymentOutcome { 27 | AUTHORIZED = 'Authorized', 28 | REJECTED = 'Rejected', 29 | INITIAL = 'Initial', 30 | } 31 | 32 | export const PaymentResponseSchema = Type.Object({ 33 | sClientSecret: Type.String(), 34 | paymentReference: Type.String(), 35 | merchantReturnUrl: Type.String(), 36 | cartId: Type.String(), 37 | billingAddress: Type.Optional(Type.String()), 38 | }); 39 | 40 | export enum CollectBillingAddressOptions { 41 | AUTO = 'auto', 42 | NEVER = 'never', 43 | IF_REQUIRED = 'if_required', 44 | } 45 | 46 | export const ConfigElementResponseSchema = Type.Object({ 47 | cartInfo: Type.Object({ 48 | amount: Type.Number(), 49 | currency: Type.String(), 50 | }), 51 | appearance: Type.Optional(Type.String()), 52 | captureMethod: Type.String(), 53 | setupFutureUsage: Type.Optional(Type.String()), 54 | layout: Type.String(), 55 | collectBillingAddress: Type.Enum(CollectBillingAddressOptions), 56 | }); 57 | 58 | export const CtPaymentSchema = Type.Object({ 59 | ctPaymentReference: Type.String(), 60 | }); 61 | 62 | export const CustomerResponseSchema = Type.Optional( 63 | Type.Object({ 64 | stripeCustomerId: Type.String(), 65 | ephemeralKey: Type.String(), 66 | sessionId: Type.String(), 67 | }), 68 | ); 69 | 70 | export type PaymentRequestSchemaDTO = Static; 71 | export type PaymentResponseSchemaDTO = Static; 72 | export type ConfigElementResponseSchemaDTO = Static; 73 | export type CtPaymentSchemaDTO = Static; 74 | export type CustomerResponseSchemaDTO = Static; 75 | -------------------------------------------------------------------------------- /processor/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "payment-integration-template", 3 | "version": "4.0.1", 4 | "description": "Payment provider integration template", 5 | "main": "dist/server.js", 6 | "scripts": { 7 | "start": "node dist/main.js", 8 | "start:dev": "node_modules/.bin/nodemon -q dist/main.js", 9 | "lint": "prettier --check \"**/**/*.{ts,js,json}\" && eslint src test", 10 | "lint:fix": "prettier --write \"**/**/*.{ts,js,json}\" && eslint --fix src test", 11 | "build": "rm -rf /dist && tsc", 12 | "dev": "ts-node src/main.ts", 13 | "watch": "nodemon --watch \"src/**\" --ext \"ts,json\" --ignore \"src/**/*.spec.ts\" --exec \"ts-node src/main.ts\"", 14 | "test": "jest --detectOpenHandles --collect-coverage", 15 | "test:watch": "jest --watch --detectOpenHandles", 16 | "connector:post-deploy": "node dist/connectors/post-deploy.js", 17 | "connector:pre-undeploy": "node dist/connectors/pre-undeploy.js" 18 | }, 19 | "keywords": [], 20 | "author": "", 21 | "license": "ISC", 22 | "dependencies": { 23 | "@commercetools-backend/loggers": "24.7.2", 24 | "@commercetools/connect-payments-sdk": "0.27.1", 25 | "@fastify/autoload": "6.3.1", 26 | "@fastify/cors": "11.1.0", 27 | "@fastify/formbody": "8.0.2", 28 | "@fastify/http-proxy": "11.3.0", 29 | "@fastify/request-context": "6.2.1", 30 | "@fastify/static": "8.2.0", 31 | "@fastify/type-provider-typebox": "5.2.0", 32 | "@sinclair/typebox": "0.34.41", 33 | "dotenv": "17.2.2", 34 | "fastify": "5.6.1", 35 | "fastify-plugin": "5.1.0", 36 | "fastify-raw-body": "^5.0.0", 37 | "stripe": "^17.7.0", 38 | "fluent-schema": "^1.1.0" 39 | }, 40 | "devDependencies": { 41 | "@eslint/compat": "^1.4.0", 42 | "@eslint/eslintrc": "^3.3.1", 43 | "@eslint/js": "^9.36.0", 44 | "@jest/globals": "30.2.0", 45 | "@types/jest": "30.0.0", 46 | "@types/node": "24.5.2", 47 | "@typescript-eslint/eslint-plugin": "8.44.1", 48 | "@typescript-eslint/parser": "8.44.1", 49 | "eslint": "9.36.0", 50 | "eslint-config-prettier": "10.1.8", 51 | "eslint-plugin-import": "2.32.0", 52 | "eslint-plugin-jest": "20.0.1", 53 | "eslint-plugin-prettier": "5.5.4", 54 | "eslint-plugin-unused-imports": "4.2.0", 55 | "globals": "^16.4.0", 56 | "jest": "30.2.0", 57 | "msw": "2.11.3", 58 | "node-fetch": "3.3.2", 59 | "nodemon": "3.1.10", 60 | "prettier": "3.6.2", 61 | "ts-jest": "29.4.4", 62 | "ts-node": "10.9.2", 63 | "typescript": "5.9.2" 64 | }, 65 | "overrides": { 66 | "path-to-regexp": "3.3.0" 67 | } 68 | } -------------------------------------------------------------------------------- /processor/test/libs/fastify/context/context.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, test, expect, afterEach, jest, beforeEach } from '@jest/globals'; 2 | import { SessionAuthentication, SessionPrincipal } from '@commercetools/connect-payments-sdk'; 3 | import * as Context from '../../../../src/libs/fastify/context/context'; 4 | 5 | describe('context', () => { 6 | const sessionId: string = '123456-123456-123456-123456'; 7 | const principal: SessionPrincipal = { 8 | cartId: '123456', 9 | allowedPaymentMethods: [], 10 | processorUrl: 'http://127.0.0.1', 11 | paymentInterface: 'dummyPaymentInterface', 12 | merchantReturnUrl: 'https://merchant.return.url', 13 | }; 14 | 15 | const mockSessionAuthentication: SessionAuthentication = new SessionAuthentication(sessionId, principal); 16 | 17 | beforeEach(() => { 18 | jest.setTimeout(10000); 19 | jest.resetAllMocks(); 20 | }); 21 | 22 | afterEach(() => { 23 | jest.restoreAllMocks(); 24 | }); 25 | 26 | test('getCtSessionIdFromContext', async () => { 27 | const mockRequestContext = { 28 | authentication: mockSessionAuthentication, 29 | }; 30 | jest.spyOn(Context, 'getRequestContext').mockReturnValue(mockRequestContext); 31 | const result = Context.getCtSessionIdFromContext(); 32 | expect(result).toStrictEqual(sessionId); 33 | }); 34 | 35 | test('getAllowedPaymentMethodsFromContext', async () => { 36 | const mockRequestContext = { 37 | authentication: mockSessionAuthentication, 38 | }; 39 | jest.spyOn(Context, 'getRequestContext').mockReturnValue(mockRequestContext); 40 | const result = Context.getAllowedPaymentMethodsFromContext(); 41 | expect(result).toHaveLength(0); 42 | }); 43 | 44 | test('getCartIdFromContext', async () => { 45 | const mockRequestContext = { 46 | authentication: mockSessionAuthentication, 47 | }; 48 | jest.spyOn(Context, 'getRequestContext').mockReturnValue(mockRequestContext); 49 | const result = Context.getCartIdFromContext(); 50 | expect(result).toStrictEqual('123456'); 51 | }); 52 | 53 | test('getMerchantReturnUrlFromContext', async () => { 54 | const mockRequestContext = { 55 | authentication: mockSessionAuthentication, 56 | }; 57 | jest.spyOn(Context, 'getRequestContext').mockReturnValue(mockRequestContext); 58 | const result = Context.getMerchantReturnUrlFromContext(); 59 | expect(result).toStrictEqual('https://merchant.return.url'); 60 | }); 61 | 62 | test('getProcessorUrlFromContext', async () => { 63 | const mockRequestContext = { 64 | authentication: mockSessionAuthentication, 65 | }; 66 | jest.spyOn(Context, 'getRequestContext').mockReturnValue(mockRequestContext); 67 | const result = Context.getProcessorUrlFromContext(); 68 | expect(result).toStrictEqual('http://127.0.0.1'); 69 | }); 70 | }); 71 | -------------------------------------------------------------------------------- /processor/src/dtos/operations/payment-intents.dto.ts: -------------------------------------------------------------------------------- 1 | import { Static, Type } from '@sinclair/typebox'; 2 | 3 | export const AmountSchema = Type.Object({ 4 | centAmount: Type.Integer(), 5 | currencyCode: Type.String(), 6 | }); 7 | 8 | export const ActionCapturePaymentSchema = Type.Composite([ 9 | Type.Object({ 10 | action: Type.Literal('capturePayment'), 11 | }), 12 | Type.Object({ 13 | amount: AmountSchema, 14 | merchantReference: Type.Optional(Type.String()), 15 | }), 16 | ]); 17 | 18 | export const ActionRefundPaymentSchema = Type.Composite([ 19 | Type.Object({ 20 | action: Type.Literal('refundPayment'), 21 | }), 22 | Type.Object({ 23 | amount: AmountSchema, 24 | merchantReference: Type.Optional(Type.String()), 25 | }), 26 | ]); 27 | 28 | export const ActionCancelPaymentSchema = Type.Composite([ 29 | Type.Object({ 30 | action: Type.Literal('cancelPayment'), 31 | merchantReference: Type.Optional(Type.String()), 32 | }), 33 | ]); 34 | 35 | export const ActionReversePaymentSchema = Type.Composite([ 36 | Type.Object({ 37 | action: Type.Literal('reversePayment'), 38 | merchantReference: Type.Optional(Type.String()), 39 | }), 40 | ]); 41 | 42 | /** 43 | * Payment intent request schema. 44 | * 45 | * Example: 46 | * { 47 | * "actions": [ 48 | * { 49 | * "action": "capturePayment", 50 | * "amount": { 51 | * "centAmount": 100, 52 | * "currencyCode": "EUR" 53 | * } 54 | * ] 55 | * } 56 | */ 57 | export const PaymentIntentRequestSchema = Type.Object({ 58 | actions: Type.Array( 59 | Type.Union([ 60 | ActionCapturePaymentSchema, 61 | ActionRefundPaymentSchema, 62 | ActionCancelPaymentSchema, 63 | ActionReversePaymentSchema, 64 | ]), 65 | { 66 | maxItems: 1, 67 | }, 68 | ), 69 | merchantReference: Type.Optional(Type.String()), 70 | }); 71 | 72 | export const PaymentIntentConfirmRequestSchema = Type.Object({ 73 | paymentIntent: Type.String(), 74 | confirmationToken: Type.Optional(Type.String()), 75 | }); 76 | 77 | export enum PaymentModificationStatus { 78 | APPROVED = 'approved', 79 | REJECTED = 'rejected', 80 | RECEIVED = 'received', 81 | } 82 | const PaymentModificationSchema = Type.Enum(PaymentModificationStatus); 83 | 84 | export const PaymentIntentResponseSchema = Type.Object({ 85 | outcome: PaymentModificationSchema, 86 | error: Type.Optional(Type.String()), 87 | }); 88 | 89 | export enum PaymentTransactions { 90 | AUTHORIZATION = 'Authorization', 91 | CANCEL_AUTHORIZATION = 'CancelAuthorization', 92 | CHARGE = 'Charge', 93 | CHARGE_BACK = 'Chargeback', 94 | REFUND = 'Refund', 95 | REVERSE = 'Reverse', 96 | } 97 | 98 | export type PaymentIntentRequestSchemaDTO = Static; 99 | export type PaymentIntentResponseSchemaDTO = Static; 100 | export type AmountSchemaDTO = Static; 101 | export type PaymentIntenConfirmRequestSchemaDTO = Static; 102 | export type PaymentIntentConfirmResponseSchemaDTO = Static; 103 | -------------------------------------------------------------------------------- /enabler/README.md: -------------------------------------------------------------------------------- 1 | # Payment Integration Enabler 2 | This module provides an application based on [commercetools Connect](https://docs.commercetools.com/connect), which acts a wrapper implementation to cover frontend components provided by Payment Service Providers (PSPs) 3 | 4 | PSPs provide libraries that can be used on client side to load on browser or other user agent which securely load DOM elements for payment methods and/or payment fields inputs. These libraries take control on saving PAN data of customer and reduce PCI scopes of the seller implementation. Now, with the usage of `enabler`, it allows the control to checkout product on when and how to load the `enabler` as connector UI based on business configuration. In cases connector is used directly and not through Checkout product, this connector UI can be loaded directly on frontend than the libraries provided by PSPs. 5 | 6 | ## Considerations for Apple Pay and Google Pay 7 | 8 | ### Apple Pay Requirements 9 | To enable Apple Pay, you must ensure the following conditions are satisfied: 10 | 11 | 1. The website must include a `https://www.website.com/.well-known/apple-developer-merchantid-domain-association` call that redirects to: 12 | ```text 13 | {COMMERCETOOLS_PROCESSOR_URL}/applePayConfig 14 | ``` 15 | This endpoint retrieves the required merchant ID domain association file declared in the installation configuration `STRIPE_APPLE_PAY_WELL_KNOWN`. For more details, refer to Stripe’s official [Apple Pay domain association documentation](https://support.stripe.com/questions/enable-apple-pay-on-your-stripe-account). 16 | 17 | 18 | 2. The environment and devices must meet Apple Pay testing requirements: 19 | - You need an **iOS device** running iOS 11.3 or later, or a **Mac** running macOS 11.3 or later with Safari. 20 | - The browser must be configured with an active card in the Apple Wallet in sandbox mode. 21 | - A valid Stripe account must be linked with Apple Pay and properly configured. 22 | - All webpages hosting an Apple Pay button are HTTPS. 23 | 24 | 3. Make sure your Stripe account has Apple Pay enabled (this is configured via your Stripe dashboard). 25 | 26 | ### Google Pay Requirements 27 | To enable Google Pay, you must ensure the following conditions are satisfied: 28 | 29 | 1. The device and browser requirements for testing Google Pay are met: 30 | - Use a **Chrome browser** on any device (mobile or desktop) supporting Google Pay. 31 | - Add a payment method (card) to your Google Pay account and ensure your testing environment is set up for sandbox mode. 32 | 33 | 2. Additional configuration for your Stripe account: 34 | - Ensure **Google Pay** is enabled via your Stripe dashboard. 35 | - Stripe automatically manages domain validation for Google Pay—manual setup is not required. 36 | 37 | 38 | ## Getting Started 39 | Please run following npm commands under `enabler` folder for development work in local environment. 40 | 41 | #### Install dependencies 42 | ``` 43 | $ npm install 44 | ``` 45 | #### Build the application in local environment. NodeJS source codes are then generated under public folder 46 | ``` 47 | $ npm run build 48 | ``` 49 | #### Build development site in local environment. The location of the site is http://127.0.0.1:3000/ 50 | ``` 51 | $ npm run dev 52 | ``` 53 | -------------------------------------------------------------------------------- /processor/src/config/config.ts: -------------------------------------------------------------------------------- 1 | import Stripe from 'stripe'; 2 | import { parseJSON } from '../utils'; 3 | 4 | type PaymentFeatures = Stripe.CustomerSessionCreateParams.Components.PaymentElement.Features; 5 | 6 | const getSavedPaymentConfig = (): PaymentFeatures => { 7 | const config = process.env.STRIPE_SAVED_PAYMENT_METHODS_CONFIG; 8 | return { 9 | //default values disabled {"payment_method_save":"disabled"} 10 | ...(config ? parseJSON(config) : null), 11 | }; 12 | }; 13 | 14 | export const config = { 15 | // Required by Payment SDK 16 | projectKey: process.env.CTP_PROJECT_KEY || 'payment-integration', 17 | clientId: process.env.CTP_CLIENT_ID || 'xxx', 18 | clientSecret: process.env.CTP_CLIENT_SECRET || 'xxx', 19 | jwksUrl: process.env.CTP_JWKS_URL || 'https://mc-api.europe-west1.gcp.commercetools.com/.well-known/jwks.json', 20 | jwtIssuer: process.env.CTP_JWT_ISSUER || 'https://mc-api.europe-west1.gcp.commercetools.com', 21 | authUrl: process.env.CTP_AUTH_URL || 'https://auth.europe-west1.gcp.commercetools.com', 22 | apiUrl: process.env.CTP_API_URL || 'https://api.europe-west1.gcp.commercetools.com', 23 | sessionUrl: process.env.CTP_SESSION_URL || 'https://session.europe-west1.gcp.commercetools.com/', 24 | checkoutUrl: process.env.CTP_CHECKOUT_URL || 'https://checkout.europe-west1.gcp.commercetools.com', 25 | healthCheckTimeout: parseInt(process.env.HEALTH_CHECK_TIMEOUT || '5000'), 26 | 27 | // Required by logger 28 | loggerLevel: process.env.LOGGER_LEVEL || 'info', 29 | 30 | // Update with specific payment providers config 31 | mockClientKey: process.env.MOCK_CLIENT_KEY || 'stripe', 32 | mockEnvironment: process.env.MOCK_ENVIRONMENT || 'TEST', 33 | 34 | // Update with specific payment providers config 35 | stripeSecretKey: process.env.STRIPE_SECRET_KEY || 'stripeSecretKey', 36 | stripeWebhookSigningSecret: process.env.STRIPE_WEBHOOK_SIGNING_SECRET || '', 37 | stripeCaptureMethod: process.env.STRIPE_CAPTURE_METHOD || 'automatic', 38 | stripePaymentElementAppearance: process.env.STRIPE_APPEARANCE_PAYMENT_ELEMENT, 39 | stripePublishableKey: process.env.STRIPE_PUBLISHABLE_KEY || '', 40 | stripeApplePayWellKnown: process.env.STRIPE_APPLE_PAY_WELL_KNOWN || 'mockWellKnown', 41 | stripeApiVersion: process.env.STRIPE_API_VERSION || '2025-02-24.acacia', 42 | stripeSavedPaymentMethodConfig: getSavedPaymentConfig(), 43 | stripeLayout: process.env.STRIPE_LAYOUT || '{"type":"tabs","defaultCollapsed":false}', 44 | stripeCollectBillingAddress: process.env.STRIPE_COLLECT_BILLING_ADDRESS || 'auto', 45 | 46 | // Payment Providers config 47 | paymentInterface: process.env.PAYMENT_INTERFACE || 'checkout-stripe', 48 | merchantReturnUrl: process.env.MERCHANT_RETURN_URL || '', 49 | 50 | /** 51 | * Enable multicapture and multirefund support for Stripe payments 52 | * When enabled, allows: 53 | * - Multiple partial captures on a single payment (multicapture) 54 | * - Multiple refunds to be processed on a single charge (multirefund) 55 | * 56 | * Default: false (disabled) - Merchants must opt-in to enable these advanced features 57 | * Note: This feature requires multicapture to be enabled in your Stripe account 58 | * 59 | * Environment variable: STRIPE_ENABLE_MULTI_OPERATIONS 60 | */ 61 | stripeEnableMultiOperations: process.env.STRIPE_ENABLE_MULTI_OPERATIONS === 'true' || false, 62 | }; 63 | 64 | export const getConfig = () => { 65 | return config; 66 | }; 67 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 | 8 | ## [Unreleased] 9 | 10 | ### Added 11 | - Enhanced refund processing with support for multiple refunded events 12 | - New `processStripeEventRefunded` method in StripePaymentService for dedicated refund event handling 13 | - Improved refund data accuracy by retrieving latest refund information from Stripe API 14 | - Comprehensive test coverage for refund processing scenarios 15 | - New `populateAmountCanceled` method in StripeEventConverter for improved amount handling in canceled payment events 16 | - **Multicapture Support**: Comprehensive support for multiple partial captures on the same payment intent 17 | - New `processStripeEventMultipleCaptured` method for handling `charge.updated` webhook events 18 | - Enhanced `capturePayment` method with partial capture logic and `final_capture` parameter support 19 | - Balance transaction tracking for accurate multicapture amount calculations 20 | 21 | ### Changed 22 | - Updated webhook handling to use dedicated method for `charge.refunded` events 23 | - Improved refund transaction updates with correct refund IDs and amounts 24 | - Enhanced error handling and logging for refund processing 25 | - **Simplified payment cancellation logic** - Removed redundant `updatePayment` call during payment cancellation in StripePaymentService 26 | - **Enhanced event amount handling** - Updated canceled payment events to use proper amount values instead of zero 27 | - **Improved API response handling** - Payment cancellation now returns Stripe API response ID instead of payment intent ID 28 | - **Webhook Event Migration** - Replaced `charge.captured` with `charge.updated` webhook event for better multicapture support 29 | - **Payment Intent Configuration** - Added `request_multicapture: 'if_available'` to payment method options for multicapture enablement 30 | - **Event Processing Logic** - Enhanced `processStripeEvent` method with multicapture detection and balance transaction tracking 31 | 32 | ### Technical Details 33 | - Modified `stripe-payment.route.ts` to route `charge.refunded` events to dedicated processing method 34 | - Updated `stripe-payment.service.ts` with new `processStripeEventRefunded` method 35 | - Enhanced test coverage in `stripe-payment.service.spec.ts` and `stripe-payment.spec.ts` 36 | - Updated `.gitignore` to include context documentation and generated files 37 | - **Refactored payment cancellation flow** - Streamlined the cancellation process by removing unnecessary payment updates 38 | - **Updated test expectations** - Adjusted test cases to reflect simplified cancellation logic and proper amount handling 39 | - **Webhook Configuration Updates** - Modified `actions.ts` to listen for `charge.updated` instead of `charge.captured` 40 | - **Event Converter Enhancements** - Added `CHARGE__UPDATED` case in `StripeEventConverter` for partial capture transactions 41 | - **Service Method Additions** - Implemented `processStripeEventMultipleCaptured` method for handling multicapture webhook events 42 | - **Payment Intent Enhancements** - Added multicapture configuration to payment intent creation in `createPaymentIntentStripe` method 43 | 44 | ## [Previous Versions] 45 | 46 | *Previous changelog entries would be documented here as the project evolves.* 47 | -------------------------------------------------------------------------------- /processor/test/services/commerce-tools/productTypeClient.spec.ts: -------------------------------------------------------------------------------- 1 | import { afterEach, beforeEach, describe, expect, it, jest } from '@jest/globals'; 2 | import { mock_Product, mock_ProductType } from '../../utils/mock-actions-data'; 3 | import { paymentSDK } from '../../../src/payment-sdk'; 4 | import { 5 | createProductType, 6 | deleteProductType, 7 | getProductsByProductTypeId, 8 | getProductTypeByKey, 9 | } from '../../../src/services/commerce-tools/productTypeClient'; 10 | 11 | describe('ProductTypeHelper testing', () => { 12 | beforeEach(() => { 13 | jest.setTimeout(10000); 14 | jest.resetAllMocks(); 15 | }); 16 | 17 | afterEach(() => { 18 | jest.restoreAllMocks(); 19 | }); 20 | 21 | describe('getProductTypeByKey', () => { 22 | it('should return the product type successfully', async () => { 23 | const executeMock = jest.fn().mockReturnValue(Promise.resolve({ body: { results: [mock_ProductType] } })); 24 | const apiClient = paymentSDK.ctAPI.client; 25 | apiClient.productTypes = jest.fn(() => ({ 26 | get: jest.fn(() => ({ 27 | execute: executeMock, 28 | })), 29 | })) as never; 30 | 31 | const result = await getProductTypeByKey('type-key'); 32 | expect(result).toEqual(mock_ProductType); 33 | }); 34 | 35 | it('should return undefined', async () => { 36 | const executeMock = jest.fn().mockReturnValue(Promise.resolve({ body: { results: [] } })); 37 | const apiClient = paymentSDK.ctAPI.client; 38 | apiClient.productTypes = jest.fn(() => ({ 39 | get: jest.fn(() => ({ 40 | execute: executeMock, 41 | })), 42 | })) as never; 43 | 44 | const result = await getProductTypeByKey('type-key'); 45 | expect(result).toBeUndefined(); 46 | }); 47 | }); 48 | 49 | describe('getProductsByProductTypeId', () => { 50 | it('should return the products successfully', async () => { 51 | const mockProducts = [mock_Product]; 52 | const executeMock = jest.fn().mockReturnValue(Promise.resolve({ body: { results: mockProducts } })); 53 | const apiClient = paymentSDK.ctAPI.client; 54 | apiClient.products = jest.fn(() => ({ 55 | get: jest.fn(() => ({ 56 | execute: executeMock, 57 | })), 58 | })) as never; 59 | 60 | const result = await getProductsByProductTypeId('product-type-id'); 61 | expect(result).toEqual(mockProducts); 62 | }); 63 | }); 64 | 65 | describe('deleteProductType', () => { 66 | it('should delete the product type successfully', async () => { 67 | const executeMock = jest.fn().mockReturnValue(Promise.resolve()); 68 | const apiClient = paymentSDK.ctAPI.client; 69 | apiClient.productTypes = jest.fn(() => ({ 70 | withKey: jest.fn(() => ({ 71 | delete: jest.fn(() => ({ 72 | execute: executeMock, 73 | })), 74 | })), 75 | })) as never; 76 | 77 | await deleteProductType({ key: 'product-type-key', version: 1 }); 78 | expect(executeMock).toHaveBeenCalled(); 79 | }); 80 | }); 81 | 82 | describe('createProductType', () => { 83 | it('should create the product type successfully', async () => { 84 | const executeMock = jest.fn().mockReturnValue(Promise.resolve({ body: mock_ProductType })); 85 | const apiClient = paymentSDK.ctAPI.client; 86 | apiClient.productTypes = jest.fn(() => ({ 87 | post: jest.fn(() => ({ 88 | execute: executeMock, 89 | })), 90 | })) as never; 91 | 92 | const result = await createProductType(mock_ProductType); 93 | expect(result).toEqual(mock_ProductType); 94 | expect(executeMock).toHaveBeenCalled(); 95 | }); 96 | }); 97 | }); 98 | -------------------------------------------------------------------------------- /processor/src/routes/operation.route.ts: -------------------------------------------------------------------------------- 1 | import { 2 | AuthorityAuthorizationHook, 3 | JWTAuthenticationHook, 4 | Oauth2AuthenticationHook, 5 | SessionHeaderAuthenticationHook, 6 | } from '@commercetools/connect-payments-sdk'; 7 | import { Type } from '@sinclair/typebox'; 8 | import { FastifyInstance, FastifyPluginOptions } from 'fastify'; 9 | import { ConfigResponseSchema, ConfigResponseSchemaDTO } from '../dtos/operations/config.dto'; 10 | import { SupportedPaymentComponentsSchema } from '../dtos/operations/payment-componets.dto'; 11 | import { 12 | PaymentIntentRequestSchema, 13 | PaymentIntentRequestSchemaDTO, 14 | PaymentIntentResponseSchema, 15 | PaymentIntentResponseSchemaDTO, 16 | } from '../dtos/operations/payment-intents.dto'; 17 | import { StatusResponseSchema, StatusResponseSchemaDTO } from '../dtos/operations/status.dto'; 18 | import { StripePaymentService } from '../services/stripe-payment.service'; 19 | 20 | type OperationRouteOptions = { 21 | sessionHeaderAuthHook: SessionHeaderAuthenticationHook; 22 | oauth2AuthHook: Oauth2AuthenticationHook; 23 | jwtAuthHook: JWTAuthenticationHook; 24 | authorizationHook: AuthorityAuthorizationHook; 25 | paymentService: StripePaymentService; 26 | }; 27 | 28 | export const operationsRoute = async (fastify: FastifyInstance, opts: FastifyPluginOptions & OperationRouteOptions) => { 29 | fastify.get<{ Reply: ConfigResponseSchemaDTO }>( 30 | '/config', 31 | { 32 | preHandler: [opts.sessionHeaderAuthHook.authenticate()], 33 | schema: { 34 | response: { 35 | 200: ConfigResponseSchema, 36 | }, 37 | }, 38 | }, 39 | async (_, reply) => { 40 | const config = await opts.paymentService.config(); 41 | reply.code(200).send(config); 42 | }, 43 | ); 44 | 45 | fastify.get<{ Reply: StatusResponseSchemaDTO }>( 46 | '/status', 47 | { 48 | preHandler: [opts.jwtAuthHook.authenticate()], 49 | schema: { 50 | response: { 51 | 200: StatusResponseSchema, 52 | }, 53 | }, 54 | }, 55 | async (_, reply) => { 56 | const status = await opts.paymentService.status(); 57 | reply.code(200).send(status); 58 | }, 59 | ); 60 | 61 | fastify.get( 62 | '/payment-components', 63 | { 64 | preHandler: [opts.jwtAuthHook.authenticate()], 65 | schema: { 66 | response: { 67 | 200: SupportedPaymentComponentsSchema, 68 | }, 69 | }, 70 | }, 71 | async (_, reply) => { 72 | const result = await opts.paymentService.getSupportedPaymentComponents(); 73 | reply.code(200).send(result); 74 | }, 75 | ); 76 | 77 | fastify.post<{ Body: PaymentIntentRequestSchemaDTO; Reply: PaymentIntentResponseSchemaDTO; Params: { id: string } }>( 78 | '/payment-intents/:id', 79 | { 80 | preHandler: [ 81 | opts.oauth2AuthHook.authenticate(), 82 | opts.authorizationHook.authorize('manage_project', 'manage_checkout_payment_intents'), 83 | ], 84 | schema: { 85 | params: { 86 | $id: 'paramsSchema', 87 | type: 'object', 88 | properties: { 89 | id: Type.String(), 90 | }, 91 | required: ['id'], 92 | }, 93 | body: PaymentIntentRequestSchema, 94 | response: { 95 | 200: PaymentIntentResponseSchema, 96 | }, 97 | }, 98 | }, 99 | async (request, reply) => { 100 | const { id } = request.params; 101 | const resp = await opts.paymentService.modifyPayment({ 102 | paymentId: id, 103 | data: request.body, 104 | }); 105 | 106 | return reply.status(200).send(resp); 107 | }, 108 | ); 109 | }; 110 | -------------------------------------------------------------------------------- /enabler/src/style/_variables.scss: -------------------------------------------------------------------------------- 1 | @use './colors' as *; 2 | 3 | $color-black: $color-style-black; 4 | $color-white: $color-style-white; 5 | 6 | /** 7 | * From https://www.figma.com/file/B3AuN5HVBARkLRXuYUWVY0/CA_UI-library?node-id=10-17139&t=HIYfSjMPWF7Q8zEX-0 8 | */ 9 | 10 | // TEXT 11 | $color-text-default: $color-style-text-black; 12 | $color-text-disabled: $color-style-text-disabled; 13 | $color-text-error: $color-style-error-main; 14 | $color-text-helper: $color-style-text-helper; 15 | $color-text-label-input-focused: $color-style-primary-main; 16 | $color-text-label: $color-style-text-label; 17 | $color-text-white: $color-style-white; 18 | 19 | // BORDER 20 | $color-border-default: $color-style-other-border-default; 21 | $color-border-divider: $color-style-other-border-divider; 22 | $color-border-disabled: $color-style-secondary-disabled-stroke; 23 | $color-border-error: $color-style-error-main; 24 | $color-border-focus: $color-style-other-border-dark; 25 | 26 | // STATUS 27 | $color-status-default: $color-style-primary-main; 28 | $color-status-focus: $color-style-primary-active; 29 | $color-status-hover: $color-style-primary-hover; 30 | 31 | // success 32 | $color-status-successful-active: $color-style-success-active; 33 | $color-status-successful-background: $color-style-success-background; 34 | $color-status-successful-dark: $color-style-success-dark; 35 | $color-status-successful-hover: $color-style-success-hover; 36 | $color-status-successful: $color-style-success-main; 37 | 38 | // warning 39 | $color-status-warning-active: $color-style-warning-active; 40 | $color-status-warning-background: $color-style-warning-background; 41 | $color-status-warning-dark: $color-style-warning-dark; 42 | $color-status-warning-hover: $color-style-warning-hover; 43 | $color-status-warning: $color-style-warning-main; 44 | 45 | // info 46 | $color-status-info-active: $color-style-info-active; 47 | $color-status-info-background: $color-style-info-background; 48 | $color-status-info-dark: $color-style-info-dark; 49 | $color-status-info-hover: $color-style-info-hover; 50 | $color-status-info: $color-style-info-main; 51 | 52 | // error 53 | $color-status-error-active: $color-style-error-active; 54 | $color-status-error-background: $color-style-error-background; 55 | $color-status-error-dark: $color-style-error-dark; 56 | $color-status-error-hover: $color-style-error-hover; 57 | $color-status-error: $color-style-error-main; 58 | 59 | // BUTTON 60 | $color-button-disabled: $color-style-primary-disabled-background; 61 | $color-button-focus: $color-style-primary-active; 62 | $color-button-hover: $color-style-primary-hover; 63 | $color-button-low-opacity: $color-style-primary-low-capacity; 64 | $color-button: $color-style-primary-main; 65 | 66 | // OTHER COMPONENTS 67 | $color-card-background: $color-style-other-card-background; 68 | $color-divider: $color-style-other-divider; 69 | $color-tag-background: $color-style-other-tag-background; 70 | $color-tag-background-discount: $color-style-other-tag-background-discount; 71 | $color-tooltip-background: $color-style-other-tooltip-background; 72 | 73 | // FONTS 74 | $font-family: var(--ctc-font-family); 75 | $font-weight-light: 300; 76 | $font-weight-regular: 400; 77 | $font-weight-medium: 500; 78 | 79 | // Radiuses 80 | $border-radius: 0.25rem; 81 | 82 | // Shadows 83 | $box-shadow: 84 | 0 2px 2px 0 rgba(0, 0, 0, 0.14), 85 | 0 3px 1px -2px rgba(0, 0, 0, 0.12), 86 | 0 1px 5px 0 rgba(0, 0, 0, 0.2); 87 | 88 | // Z-Index 89 | $z-index-1: 1; 90 | $z-index-100: 100; 91 | $z-index-200: 200; 92 | 93 | // Breakpoints 94 | $breakpoints: ( 95 | 'small': ( 96 | min-width: 360px, 97 | ), 98 | 'medium': ( 99 | min-width: 576px, 100 | ), 101 | 'large': ( 102 | min-width: 1024px, 103 | ), 104 | ) !default; 105 | -------------------------------------------------------------------------------- /processor/test/payment-sdk.test.ts: -------------------------------------------------------------------------------- 1 | import { setupPaymentSDK } from '@commercetools/connect-payments-sdk'; 2 | import { config } from '../src/config/config'; 3 | import { getRequestContext, updateRequestContext } from '../src/libs/fastify/context/context'; 4 | import { log } from '../src/libs/logger'; 5 | import { AppLogger } from '../src/payment-sdk'; 6 | 7 | jest.mock('@commercetools/connect-payments-sdk'); 8 | jest.mock('../src/config/config'); 9 | jest.mock('../src/libs/fastify/context/context'); 10 | jest.mock('../src/libs/logger/index', () => { 11 | return { 12 | log: { 13 | debug: jest.fn(), 14 | info: jest.fn(), 15 | warn: jest.fn(), 16 | error: jest.fn(), 17 | // Add any additional methods as required by the library 18 | }, 19 | }; 20 | }); 21 | 22 | describe('Payment-sdk test', () => { 23 | let logger: AppLogger; 24 | 25 | beforeEach(() => { 26 | logger = new AppLogger(); 27 | }); 28 | 29 | it('should log debug messages', () => { 30 | const debugSpy = jest.spyOn(log, 'debug'); 31 | const message = 'Debug message'; 32 | logger.debug({}, message); 33 | expect(debugSpy).toHaveBeenCalledWith(message, {}); 34 | }); 35 | 36 | it('should log info messages', () => { 37 | const infoSpy = jest.spyOn(log, 'info'); 38 | const message = 'Info message'; 39 | logger.info({}, message); 40 | expect(infoSpy).toHaveBeenCalledWith(message, {}); 41 | }); 42 | 43 | it('should log warn messages', () => { 44 | const warnSpy = jest.spyOn(log, 'warn'); 45 | const message = 'Warn message'; 46 | logger.warn({}, message); 47 | expect(warnSpy).toHaveBeenCalledWith(message, {}); 48 | }); 49 | 50 | it('should log error messages', () => { 51 | const errorSpy = jest.spyOn(log, 'error'); 52 | const message = 'Error message'; 53 | logger.error({}, message); 54 | expect(errorSpy).toHaveBeenCalledWith(message, {}); 55 | }); 56 | }); 57 | 58 | describe('paymentSDK', () => { 59 | it('should set up payment SDK with the correct configuration', () => { 60 | expect(setupPaymentSDK).toHaveBeenCalledWith({ 61 | apiUrl: config.apiUrl, 62 | authUrl: config.authUrl, 63 | clientId: config.clientId, 64 | clientSecret: config.clientSecret, 65 | projectKey: config.projectKey, 66 | sessionUrl: config.sessionUrl, 67 | jwksUrl: config.jwksUrl, 68 | jwtIssuer: config.jwtIssuer, 69 | getContextFn: expect.any(Function), 70 | updateContextFn: expect.any(Function), 71 | logger: expect.any(AppLogger), 72 | }); 73 | }); 74 | 75 | it('should get context correctly', () => { 76 | const mockContext = { 77 | correlationId: 'test-correlation-id', 78 | requestId: 'test-request-id', 79 | authentication: {}, 80 | }; 81 | (getRequestContext as jest.Mock).mockReturnValue(mockContext); 82 | 83 | const sdkConfig = (setupPaymentSDK as jest.Mock).mock.calls[0][0]; 84 | const context = sdkConfig.getContextFn(); 85 | expect(context).toEqual(mockContext); 86 | }); 87 | 88 | it('should update context correctly', () => { 89 | const mockUpdateContext = jest.fn(); 90 | (updateRequestContext as jest.Mock).mockImplementation(mockUpdateContext); 91 | 92 | const contextUpdate = { 93 | correlationId: 'new-correlation-id', 94 | requestId: 'new-request-id', 95 | authentication: { user: 'test' }, 96 | }; 97 | 98 | const sdkConfig = (setupPaymentSDK as jest.Mock).mock.calls[0][0]; 99 | sdkConfig.updateContextFn(contextUpdate); 100 | expect(mockUpdateContext).toHaveBeenCalledWith({ 101 | correlationId: 'new-correlation-id', 102 | requestId: 'new-request-id', 103 | authentication: { user: 'test' }, 104 | }); 105 | }); 106 | }); 107 | -------------------------------------------------------------------------------- /processor/src/libs/fastify/context/context.ts: -------------------------------------------------------------------------------- 1 | import { Authentication, SessionAuthentication } from '@commercetools/connect-payments-sdk'; 2 | import { fastifyRequestContext, requestContext } from '@fastify/request-context'; 3 | import { randomUUID } from 'crypto'; 4 | import { FastifyInstance, FastifyRequest } from 'fastify'; 5 | import fp from 'fastify-plugin'; 6 | 7 | export type ContextData = { 8 | anonymousId?: string; 9 | customerId?: string; 10 | path?: string; 11 | pathTemplate?: string; 12 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 13 | pathParams?: any; 14 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 15 | query?: any; 16 | correlationId: string; 17 | requestId: string; 18 | authentication?: Authentication; 19 | }; 20 | 21 | export const getRequestContext = (): Partial => { 22 | return requestContext.get('request') ?? {}; 23 | }; 24 | 25 | export const setRequestContext = (ctx: ContextData) => { 26 | requestContext.set('request', ctx); 27 | }; 28 | 29 | export const updateRequestContext = (ctx: Partial) => { 30 | const currentContext = getRequestContext(); 31 | setRequestContext({ 32 | ...(currentContext as ContextData), 33 | ...ctx, 34 | }); 35 | }; 36 | 37 | export const getCtSessionIdFromContext = (): string => { 38 | const authentication = getRequestContext().authentication as SessionAuthentication; 39 | return authentication?.getCredentials(); 40 | }; 41 | 42 | export const getCartIdFromContext = (): string => { 43 | const authentication = getRequestContext().authentication as SessionAuthentication; 44 | return authentication?.getPrincipal().cartId; 45 | }; 46 | 47 | export const getAllowedPaymentMethodsFromContext = (): string[] => { 48 | const authentication = getRequestContext().authentication as SessionAuthentication; 49 | return authentication?.getPrincipal().allowedPaymentMethods; 50 | }; 51 | 52 | export const getPaymentInterfaceFromContext = (): string | undefined => { 53 | const authentication = getRequestContext().authentication as SessionAuthentication; 54 | return authentication?.getPrincipal().paymentInterface; 55 | }; 56 | 57 | export const getCheckoutTransactionItemIdFromContext = (): string | undefined => { 58 | const authentication = getRequestContext().authentication as SessionAuthentication; 59 | return authentication?.getPrincipal().checkoutTransactionItemId; 60 | }; 61 | 62 | export const getProcessorUrlFromContext = (): string => { 63 | const authentication = getRequestContext().authentication as SessionAuthentication; 64 | return authentication?.getPrincipal().processorUrl; 65 | }; 66 | 67 | export const getMerchantReturnUrlFromContext = (): string | undefined => { 68 | const authentication = getRequestContext().authentication as SessionAuthentication; 69 | return authentication?.getPrincipal().merchantReturnUrl; 70 | }; 71 | 72 | export const requestContextPlugin = fp(async (fastify: FastifyInstance) => { 73 | // Enhance the request object with a correlationId property 74 | fastify.decorateRequest('correlationId', ''); 75 | 76 | // Propagate the correlationId from the request header to the request object 77 | fastify.addHook('onRequest', (req, reply, done) => { 78 | req.correlationId = req.headers['x-correlation-id'] ? (req.headers['x-correlation-id'] as string) : undefined; 79 | done(); 80 | }); 81 | 82 | // Register the request context 83 | await fastify.register(fastifyRequestContext, { 84 | defaultStoreValues: (req: FastifyRequest) => ({ 85 | request: { 86 | path: req.url, 87 | pathTemplate: req.routeOptions.url, 88 | pathParams: req.params, 89 | query: req.query, 90 | correlationId: req.correlationId || randomUUID().toString(), 91 | requestId: req.id, 92 | }, 93 | }), 94 | hook: 'onRequest', 95 | }); 96 | }); 97 | -------------------------------------------------------------------------------- /processor/test/connectors/post-deploy.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, test, expect, jest, afterEach, beforeEach } from '@jest/globals'; 2 | import * as Actions from '../../src/connectors/actions'; 3 | import * as PostDeploy from '../../src/connectors/post-deploy'; 4 | import { mock_Stripe_retrieveWebhookEnpoints_response } from '../utils/mock-actions-data'; 5 | 6 | jest.mock('../../src/connectors/actions'); 7 | 8 | describe('runPostDeployScripts', () => { 9 | beforeEach(() => { 10 | jest.setTimeout(10000); 11 | jest.resetAllMocks(); 12 | }); 13 | 14 | afterEach(() => { 15 | jest.restoreAllMocks(); 16 | }); 17 | 18 | test('should update the webhook endpoint URL when the URLs are different', async () => { 19 | process.env = { 20 | CONNECT_SERVICE_URL: 'https://yourApp.com/', 21 | STRIPE_WEBHOOK_ID: 'we_11111', 22 | }; 23 | 24 | const mockRetrieveWe = jest 25 | .spyOn(Actions, 'retrieveWebhookEndpoint') 26 | .mockResolvedValue(mock_Stripe_retrieveWebhookEnpoints_response); 27 | const mockUpdateWe = jest.spyOn(Actions, 'updateWebhookEndpoint').mockResolvedValue(); 28 | const createCustomerCustomTypeMock = jest.spyOn(Actions, 'createOrUpdateCustomerCustomType').mockResolvedValue(); 29 | 30 | await PostDeploy.runPostDeployScripts(); 31 | 32 | expect(mockRetrieveWe).toHaveBeenCalled(); 33 | expect(mockUpdateWe).toHaveBeenCalled(); 34 | expect(createCustomerCustomTypeMock).toHaveBeenCalled(); 35 | }); 36 | 37 | test('should not update the webhook endpoint URL when the URLs are the same', async () => { 38 | process.env = { 39 | CONNECT_SERVICE_URL: 'https://myApp.com/', 40 | STRIPE_WEBHOOK_ID: 'we_11111', 41 | }; 42 | 43 | const mockRetrieveWe = jest 44 | .spyOn(Actions, 'retrieveWebhookEndpoint') 45 | .mockResolvedValue(mock_Stripe_retrieveWebhookEnpoints_response); 46 | const mockUpdateWe = jest.spyOn(Actions, 'updateWebhookEndpoint').mockResolvedValue(); 47 | const createCustomerCustomTypeMock = jest.spyOn(Actions, 'createOrUpdateCustomerCustomType').mockResolvedValue(); 48 | 49 | await PostDeploy.runPostDeployScripts(); 50 | 51 | expect(mockRetrieveWe).toHaveBeenCalled(); 52 | expect(mockUpdateWe).toHaveBeenCalledTimes(0); 53 | expect(createCustomerCustomTypeMock).toHaveBeenCalled(); 54 | }); 55 | 56 | test('should throw an error when a call to Stripe throws an error', async () => { 57 | process.env = { CONNECT_SERVICE_URL: 'https://yourApp.com/', STRIPE_WEBHOOK_ID: 'we_11111' }; 58 | process.exitCode = '0'; 59 | 60 | const exitCodeMock = jest.fn(); 61 | 62 | Object.defineProperty(process, 'exitCode', { 63 | configurable: true, 64 | get: () => undefined, 65 | set: exitCodeMock, 66 | }); 67 | 68 | const mockError = new Error('No such webhook endpoint'); 69 | const mockErrorMessage = `Post-deploy failed: ${mockError.message}\n`; 70 | const mockRetrieveWe = jest.spyOn(Actions, 'retrieveWebhookEndpoint').mockRejectedValueOnce(mockError); 71 | const writeSpy = jest.spyOn(process.stderr, 'write'); 72 | 73 | await PostDeploy.runPostDeployScripts(); 74 | 75 | expect(mockRetrieveWe).toHaveBeenCalled(); 76 | expect(writeSpy).toHaveBeenCalledWith(mockErrorMessage); 77 | expect(exitCodeMock).toHaveBeenCalledWith(1); 78 | Object.defineProperty(process, 'exitCode', { 79 | value: '0', 80 | }); 81 | }); 82 | 83 | test('should throw an error when the STRIPE_WEBHOOK_ID var is not assigned', async () => { 84 | process.env = { CONNECT_SERVICE_URL: 'https://yourApp.com/' }; 85 | 86 | const mockErrorMessage = `Post-deploy failed: STRIPE_WEBHOOK_ID var is not assigned. Add the connector URL manually on the Stripe Webhook Dashboard\n`; 87 | const writeSpy = jest.spyOn(process.stderr, 'write'); 88 | const createCustomerCustomTypeMock = jest.spyOn(Actions, 'createOrUpdateCustomerCustomType').mockResolvedValue(); 89 | 90 | await PostDeploy.runPostDeployScripts(); 91 | 92 | expect(createCustomerCustomTypeMock).toHaveBeenCalled(); 93 | expect(writeSpy).toHaveBeenCalledWith(mockErrorMessage); 94 | }); 95 | }); 96 | -------------------------------------------------------------------------------- /enabler/src/style/style.module.scss: -------------------------------------------------------------------------------- 1 | @use './vx' as *; 2 | @use "variables"; 3 | 4 | // from mock 5 | 6 | .paymentForm { 7 | margin-top: 1rem; 8 | } 9 | 10 | .wrapper { 11 | position: relative; 12 | width: 100%; 13 | margin-bottom: 1rem; 14 | } 15 | 16 | .row { 17 | display: flex; 18 | flex-direction: row; 19 | gap: 1rem; 20 | position: relative; 21 | } 22 | 23 | .subHeading { 24 | color: variables.$color-text-helper; 25 | font-size: 0.8125rem; 26 | margin-top: 0.125rem; 27 | margin-bottom: 1.5rem; 28 | } 29 | 30 | 31 | // from worldpay 32 | 33 | 34 | .wrapper { 35 | * { 36 | font-family: 'Roboto', sans-serif; 37 | box-sizing: border-box; 38 | } 39 | } 40 | 41 | 42 | .twoColumnLayout { 43 | display: flex; 44 | flex-flow: row wrap; 45 | column-gap: 1rem; 46 | 47 | // render children as columns when each child has at least 150px of space 48 | > * { 49 | flex: 1 0 150px; 50 | } 51 | } 52 | 53 | .container { 54 | margin-top: 1rem; 55 | 56 | iframe { 57 | height: 3.317rem !important; 58 | border: 1px solid variables.$color-border-default !important; 59 | border-radius: variables.$border-radius; 60 | padding-left: 1rem; 61 | width: 100%; 62 | float: none !important; 63 | } 64 | 65 | .input { 66 | display: inline-block; 67 | position: relative; 68 | width: 100%; 69 | margin-bottom: 1rem; 70 | } 71 | 72 | .error { 73 | color: variables.$color-status-error; 74 | font-size: 0.75rem; 75 | padding: 0 0.75rem 0.25rem 1rem; 76 | position: rela2tive; 77 | } 78 | } 79 | 80 | /* input fields border colors */ 81 | :global(.is-valid) { 82 | & iframe { 83 | border: 1px solid variables.$color-border-default !important; 84 | } 85 | } 86 | 87 | :global(.is-onfocus) { 88 | & iframe { 89 | border: 2px solid variables.$color-status-default !important; 90 | } 91 | ~ .error { 92 | display: none; 93 | } 94 | } 95 | 96 | :global(.is-invalid), 97 | .inputError :not(:global(.is-onfocus)), 98 | .inputErrorEmptyText { 99 | & iframe { 100 | border: 2px solid variables.$color-status-error !important; 101 | } 102 | } 103 | 104 | /* floting labels */ 105 | .floatingLabel { 106 | position: absolute; 107 | pointer-events: none; 108 | left: 0.8rem; 109 | top: 1rem; 110 | transition: 0.2s ease all; 111 | background-color: white; 112 | padding: 0 0.25rem; 113 | width: 80%; 114 | } 115 | 116 | .wrapper { 117 | position: relative; 118 | width: 100%; 119 | margin-bottom: 1rem; 120 | } 121 | 122 | .cardIcons { 123 | display: none; 124 | } 125 | 126 | :global(.is-empty) ~ .cardIcons { 127 | display: block; 128 | } 129 | 130 | :global(.is-empty) ~ .floatingCard { 131 | display: none; 132 | } 133 | 134 | :global(.is-onfocus) ~ .floatingLabel, 135 | :global(.is-valid) ~ .floatingLabel, 136 | :global(.is-invalid):not(.inputEmpty) ~ .floatingLabel { 137 | top: -0.5rem; 138 | font-size: 0.75rem; 139 | width: auto; 140 | } 141 | 142 | /* floating labels colors */ 143 | :global(.is-valid) ~ .floatingLabel { 144 | color: variables.$color-border-default; 145 | } 146 | 147 | :global(.is-onfocus) ~ .floatingLabel { 148 | color: variables.$color-status-default; 149 | } 150 | 151 | :global(.is-invalid):not(.inputEmpty) ~ .floatingLabel { 152 | color: variables.$color-status-error; 153 | } 154 | 155 | .inputErrorEmptyText ~ .floatingLabel { 156 | color: black; 157 | } 158 | 159 | /* floating card icon */ 160 | .floatingCard { 161 | position: absolute !important; 162 | pointer-events: none; 163 | right: 0; 164 | top: 0; 165 | transition: 0.2s ease all; 166 | background-color: white; 167 | padding: 0 0.25rem; 168 | } 169 | 170 | .subHeading { 171 | color: variables.$color-text-helper; 172 | font-size: 0.8125rem; 173 | margin-top: 0.125rem; 174 | margin-bottom: 1.5rem; 175 | } 176 | 177 | .alert { 178 | margin: 1em 0; 179 | } 180 | 181 | 182 | 183 | .cardRow { 184 | display: flex; 185 | gap: 0.25rem; 186 | margin-top: 0.5rem; 187 | } 188 | 189 | .cardIcon { 190 | border: 1px solid #d9d9d9; 191 | border-radius: 0.156rem; 192 | } 193 | 194 | .hidden { 195 | display: none; 196 | } 197 | -------------------------------------------------------------------------------- /enabler/src/style/button.module.scss: -------------------------------------------------------------------------------- 1 | @use "colors"; 2 | @use "variables"; 3 | 4 | @use './vx' as *; 5 | 6 | // legacy browser fallback 7 | @supports not (background: color-mix(in srgb, red 50%, blue)) { 8 | :root { 9 | --ctc-button-hover: var(--ctc-button); 10 | } 11 | } 12 | 13 | .button { 14 | color: var(--ctc-button-text); 15 | padding: 0.5rem 1.375rem; 16 | background-color: var(--ctc-button); 17 | border: 0 none; 18 | border-radius: variables.$border-radius; 19 | font-size: 0.9375rem; 20 | font-weight: variables.$font-weight-regular; 21 | font-family: variables.$font-family; 22 | text-transform: uppercase; 23 | line-height: 1.5rem; 24 | letter-spacing: 0.43px; 25 | box-shadow: variables.$box-shadow; 26 | background-position: center; 27 | transition: background-color 0.8s; 28 | cursor: pointer; 29 | 30 | &:hover { 31 | background: var(--ctc-button-hover) 32 | radial-gradient(circle, transparent 1%, var(--ctc-button-hover) 1%) center/15000%; 33 | } 34 | 35 | &:active { 36 | background-color: var(--ctc-button-hover); 37 | background-size: 100%; 38 | transition: background-color 0s; 39 | } 40 | 41 | &:disabled { 42 | color: var(--ctc-button-disabled-text); 43 | background-color: var(--ctc-button-disabled); 44 | pointer-events: none; 45 | box-shadow: none; 46 | 47 | &:hover, 48 | &:active { 49 | background-color: var(--ctc-button-disabled); 50 | cursor: not-allowed; 51 | } 52 | } 53 | } 54 | 55 | .fullWidth { 56 | width: 100%; 57 | } 58 | 59 | .linkButton { 60 | text-decoration: none; 61 | text-transform: unset; 62 | background: none; 63 | border-bottom: 1.5px solid transparent; 64 | box-shadow: none; 65 | padding: 0; 66 | border-radius: 0; 67 | color: var(--ctc-button); 68 | 69 | font-size: 1rem; 70 | font-weight: 700; 71 | line-height: 1.188rem; 72 | letter-spacing: 0.009rem; 73 | 74 | &:hover, 75 | &:active { 76 | cursor: pointer; 77 | border-bottom: 1.5px solid var(--ctc-button); 78 | background: none; 79 | } 80 | 81 | &.disabled { 82 | color: colors.$color-style-text-disabled; 83 | text-decoration: none; 84 | cursor: not-allowed; 85 | pointer-events: none; 86 | background-color: transparent; 87 | box-shadow: none; 88 | 89 | &:hover, 90 | &:active { 91 | border-bottom: none; 92 | } 93 | } 94 | } 95 | 96 | // variants 97 | .lowOpacityButton { 98 | background-color: color-mix(in srgb, var(--ctc-button), transparent 30%); 99 | 100 | // legacy browser fallback 101 | @supports not (background: color-mix(in srgb, red 50%, blue)) { 102 | background-color: var(--ctc-button); 103 | opacity: 60%; 104 | } 105 | } 106 | 107 | .textButton { 108 | background-color: transparent; 109 | color: var(--ctc-button); 110 | box-shadow: none; 111 | padding: 0.5rem 0.6875rem; 112 | 113 | $color-style-primary-hover-background: color-mix(in srgb, var(--ctc-button), transparent 90%); 114 | 115 | &:hover { 116 | background: $color-style-primary-hover-background 117 | radial-gradient(circle, transparent 1%, $color-style-primary-hover-background 1%) 118 | center/15000%; 119 | } 120 | 121 | &:active { 122 | background-color: $color-style-primary-hover-background; 123 | background-size: 100%; 124 | transition: background-color 0s; 125 | } 126 | 127 | &:focus { 128 | background-color: $color-style-primary-hover-background; 129 | } 130 | 131 | &:disabled { 132 | background-color: transparent; 133 | color: variables.$color-button-disabled; 134 | } 135 | } 136 | 137 | .errorButton { 138 | color: variables.$color-status-error-dark; 139 | background: transparent; 140 | border: 1px solid transparent; 141 | transition: none; 142 | box-shadow: none; 143 | outline: none; 144 | 145 | &:hover { 146 | background: variables.$color-status-error-hover; 147 | } 148 | 149 | &:active { 150 | border: 1px solid variables.$color-status-error-dark; 151 | background: variables.$color-status-error-active; 152 | } 153 | 154 | &:disabled { 155 | background-color: transparent; 156 | color: variables.$color-button-disabled; 157 | } 158 | } 159 | -------------------------------------------------------------------------------- /processor/test/utils/mock-customer-data.ts: -------------------------------------------------------------------------------- 1 | import Stripe from 'stripe'; 2 | import { Customer } from '@commercetools/platform-sdk/dist/declarations/src/generated/models/customer'; 3 | 4 | export const mockCtCustomerId = '437906f7-1aaa-41dd-8775-3a03d8aa1258'; 5 | 6 | const lastResponse = { 7 | headers: {}, 8 | requestId: '11111', 9 | statusCode: 200, 10 | apiVersion: '', 11 | idempotencyKey: '', 12 | stripeAccount: '', 13 | }; 14 | 15 | export const mockStripeCustomerId = 'cus_Example'; 16 | 17 | export const mockEphemeralKeySecret = 'ephkey_123'; 18 | 19 | export const mockEphemeralKeyResult: Stripe.Response = { 20 | created: 1687991030, 21 | expires: 1687991030, 22 | id: 'ephkey_123', 23 | livemode: false, 24 | object: 'ephemeral_key', 25 | secret: mockEphemeralKeySecret, 26 | lastResponse, 27 | }; 28 | 29 | export const mockCreateSessionResult: Stripe.Response = { 30 | client_secret: 'cs_test_1234567890', 31 | created: 1687991030, 32 | livemode: false, 33 | customer: 'cus_Example', 34 | expires_at: 1687991030, 35 | object: 'customer_session', 36 | lastResponse, 37 | }; 38 | 39 | export const mockCustomerData: Stripe.Response = { 40 | id: 'cus_Example', 41 | object: 'customer', 42 | balance: 0, 43 | created: 1742596970, 44 | currency: null, 45 | default_source: null, 46 | delinquent: false, 47 | description: null, 48 | discount: null, 49 | email: 'test@example.com', 50 | invoice_settings: { 51 | custom_fields: null, 52 | default_payment_method: null, 53 | footer: null, 54 | rendering_options: null, 55 | }, 56 | livemode: false, 57 | metadata: { ct_customer_id: mockCtCustomerId }, 58 | name: 'John Smith', 59 | next_invoice_sequence: 1, 60 | phone: null, 61 | preferred_locales: [], 62 | shipping: null, 63 | tax_exempt: 'none', 64 | test_clock: null, 65 | lastResponse, 66 | }; 67 | 68 | export const mockCtCustomerData: Customer = { 69 | id: mockCtCustomerId, 70 | version: 1, 71 | createdAt: '2025-03-19T00:09:28.752Z', 72 | lastModifiedAt: '2025-03-19T00:48:46.632Z', 73 | email: 'test@example.com', 74 | firstName: 'Gildardo', 75 | lastName: 'Diaz', 76 | addresses: [ 77 | { 78 | id: 'xxxxxx-test-id', 79 | country: 'US', 80 | city: 'San Francisco', 81 | state: 'CA', 82 | streetName: 'Main St', 83 | streetNumber: '123', 84 | postalCode: '94105', 85 | }, 86 | ], 87 | isEmailVerified: false, 88 | stores: [], 89 | authenticationMode: 'Password', 90 | custom: { 91 | type: { 92 | typeId: 'type', 93 | id: 'mock-type-id', 94 | }, 95 | fields: { 96 | stripeConnector_stripeCustomerId: 'cus_Example', 97 | }, 98 | }, 99 | }; 100 | 101 | export const mockCtCustomerData_withoutType: Customer = { 102 | id: mockCtCustomerId, 103 | version: 1, 104 | createdAt: '2025-03-19T00:09:28.752Z', 105 | lastModifiedAt: '2025-03-19T00:48:46.632Z', 106 | email: 'test@example.com', 107 | firstName: 'Gildardo', 108 | lastName: 'Diaz', 109 | addresses: [ 110 | { 111 | id: 'xxxxxx-test-id', 112 | country: 'US', 113 | city: 'San Francisco', 114 | state: 'CA', 115 | streetName: 'Main St', 116 | streetNumber: '123', 117 | postalCode: '94105', 118 | }, 119 | ], 120 | isEmailVerified: false, 121 | stores: [], 122 | authenticationMode: 'Password', 123 | }; 124 | 125 | export const mockCtCustomerWithoutCustomFieldsData: Customer = { 126 | id: mockCtCustomerId, 127 | version: 1, 128 | createdAt: '2025-03-19T00:09:28.752Z', 129 | lastModifiedAt: '2025-03-19T00:48:46.632Z', 130 | email: 'test@example.com', 131 | firstName: 'Gildardo', 132 | lastName: 'Diaz', 133 | addresses: [ 134 | { 135 | id: 'xxxxxx-test-id', 136 | country: 'US', 137 | city: 'San Francisco', 138 | state: 'CA', 139 | streetName: 'Main St', 140 | streetNumber: '123', 141 | postalCode: '94105', 142 | }, 143 | ], 144 | isEmailVerified: false, 145 | stores: [], 146 | authenticationMode: 'Password', 147 | custom: { 148 | type: { 149 | typeId: 'type', 150 | id: 'mock-type-id', 151 | }, 152 | fields: {}, 153 | }, 154 | }; 155 | 156 | export const mockSearchCustomerResponse: Stripe.Response> = { 157 | data: [mockCustomerData], 158 | has_more: false, 159 | lastResponse, 160 | next_page: null, 161 | object: 'search_result', 162 | url: '/customers', 163 | }; 164 | -------------------------------------------------------------------------------- /processor/src/libs/fastify/error-handler.ts: -------------------------------------------------------------------------------- 1 | import { FastifyError, type FastifyReply, type FastifyRequest } from 'fastify'; 2 | 3 | import { FastifySchemaValidationError } from 'fastify/types/schema'; 4 | import { log } from '../logger'; 5 | import { 6 | ErrorAuthErrorResponse, 7 | ErrorGeneral, 8 | ErrorInvalidField, 9 | ErrorInvalidJsonInput, 10 | ErrorRequiredField, 11 | Errorx, 12 | MultiErrorx, 13 | } from '@commercetools/connect-payments-sdk'; 14 | import { TAuthErrorResponse, TErrorObject, TErrorResponse } from './dtos/error.dto'; 15 | 16 | function isFastifyValidationError(error: Error): error is FastifyError { 17 | return (error as unknown as FastifyError).validation != undefined; 18 | } 19 | 20 | export const errorHandler = (error: Error, req: FastifyRequest, reply: FastifyReply) => { 21 | if (isFastifyValidationError(error) && error.validation) { 22 | return handleErrors(transformValidationErrors(error.validation, req), reply); 23 | } else if (error instanceof ErrorAuthErrorResponse) { 24 | return handleAuthError(error, reply); 25 | } else if (error instanceof Errorx) { 26 | return handleErrors([error], reply); 27 | } else if (error instanceof MultiErrorx) { 28 | return handleErrors(error.errors, reply); 29 | } 30 | 31 | // If it isn't any of the cases above (for example a normal Error is thrown) then fallback to a general 500 internal server error 32 | return handleErrors([new ErrorGeneral('Internal server error.', { cause: error, skipLog: false })], reply); 33 | }; 34 | 35 | const handleAuthError = (error: ErrorAuthErrorResponse, reply: FastifyReply) => { 36 | const transformedErrors: TErrorObject[] = transformErrorxToHTTPModel([error]); 37 | 38 | const response: TAuthErrorResponse = { 39 | message: error.message, 40 | statusCode: error.httpErrorStatus, 41 | errors: transformedErrors, 42 | error: transformedErrors[0].code, 43 | error_description: transformedErrors[0].message, 44 | }; 45 | 46 | return reply.code(error.httpErrorStatus).send(response); 47 | }; 48 | 49 | const handleErrors = (errorxList: Errorx[], reply: FastifyReply) => { 50 | const transformedErrors: TErrorObject[] = transformErrorxToHTTPModel(errorxList); 51 | 52 | // Based on CoCo specs, the root level message attribute is always set to the values from the first error. MultiErrorx enforces the same HTTP status code. 53 | const response: TErrorResponse = { 54 | message: errorxList[0].message, 55 | statusCode: errorxList[0].httpErrorStatus, 56 | errors: transformedErrors, 57 | }; 58 | 59 | return reply.code(errorxList[0].httpErrorStatus).send(response); 60 | }; 61 | 62 | const transformErrorxToHTTPModel = (errors: Errorx[]): TErrorObject[] => { 63 | const errorObjectList: TErrorObject[] = []; 64 | 65 | for (const err of errors) { 66 | if (err.skipLog) { 67 | log.debug(err.message, err); 68 | } else { 69 | log.error(err.message, err); 70 | } 71 | 72 | const tErrObj: TErrorObject = { 73 | code: err.code, 74 | message: err.message, 75 | ...(err.fields ? err.fields : {}), // Add any additional field to the response object (which will differ per type of error) 76 | }; 77 | 78 | errorObjectList.push(tErrObj); 79 | } 80 | 81 | return errorObjectList; 82 | }; 83 | 84 | const transformValidationErrors = (errors: FastifySchemaValidationError[], req: FastifyRequest): Errorx[] => { 85 | const errorxList: Errorx[] = []; 86 | 87 | for (const err of errors) { 88 | switch (err.keyword) { 89 | case 'required': 90 | errorxList.push(new ErrorRequiredField(err.params.missingProperty as string)); 91 | break; 92 | case 'enum': 93 | errorxList.push( 94 | new ErrorInvalidField( 95 | getKeys(err.instancePath).join('.'), 96 | getPropertyFromPath(err.instancePath, req.body), 97 | err.params.allowedValues as string, 98 | ), 99 | ); 100 | break; 101 | } 102 | } 103 | 104 | // If we cannot map the validation error to a CoCo error then return a general InvalidJsonError 105 | if (errorxList.length === 0) { 106 | errorxList.push(new ErrorInvalidJsonInput()); 107 | } 108 | 109 | return errorxList; 110 | }; 111 | 112 | const getKeys = (path: string) => path.replace(/^\//, '').split('/'); 113 | 114 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 115 | const getPropertyFromPath = (path: string, obj: any): any => { 116 | const keys = getKeys(path); 117 | let value = obj; 118 | for (const key of keys) { 119 | value = value[key]; 120 | } 121 | return value; 122 | }; 123 | -------------------------------------------------------------------------------- /processor/test/services/commerce-tools/customTypeClient.spec.ts: -------------------------------------------------------------------------------- 1 | import { afterEach, beforeEach, describe, expect, it, jest } from '@jest/globals'; 2 | import { mock_AddFieldDefinitionActions, mock_CustomType_withFieldDefinition } from '../../utils/mock-actions-data'; 3 | import { paymentSDK } from '../../../src/payment-sdk'; 4 | import { 5 | createCustomType, 6 | deleteCustomTypeByKey, 7 | getTypeByKey, 8 | getTypesByResourceTypeId, 9 | updateCustomTypeByKey, 10 | } from '../../../src/services/commerce-tools/customTypeClient'; 11 | 12 | describe('ProductTypeHelper testing', () => { 13 | beforeEach(() => { 14 | jest.setTimeout(10000); 15 | jest.resetAllMocks(); 16 | }); 17 | 18 | afterEach(() => { 19 | jest.restoreAllMocks(); 20 | }); 21 | 22 | describe('getTypeByKey', () => { 23 | it('should return the type when found', async () => { 24 | const executeMock = jest.fn().mockReturnValue( 25 | Promise.resolve({ 26 | body: { results: [mock_CustomType_withFieldDefinition] }, 27 | }), 28 | ); 29 | const client = paymentSDK.ctAPI.client; 30 | client.types = jest.fn(() => ({ 31 | get: jest.fn(() => ({ 32 | execute: executeMock, 33 | })), 34 | })) as never; 35 | 36 | const result = await getTypeByKey('type-key'); 37 | expect(result).toEqual(mock_CustomType_withFieldDefinition); 38 | }); 39 | 40 | it('should return undefined when not found', async () => { 41 | const executeMock = jest.fn().mockReturnValue(Promise.resolve({ body: { results: [] } })); 42 | const client = paymentSDK.ctAPI.client; 43 | client.types = jest.fn(() => ({ 44 | get: jest.fn(() => ({ 45 | execute: executeMock, 46 | })), 47 | })) as never; 48 | 49 | const result = await getTypeByKey('type-key'); 50 | expect(result).toEqual(undefined); 51 | }); 52 | }); 53 | 54 | describe('getTypesByResourceTypeId', () => { 55 | it('should return the types successfully', async () => { 56 | const executeMock = jest 57 | .fn() 58 | .mockReturnValue(Promise.resolve({ body: { results: [mock_CustomType_withFieldDefinition] } })); 59 | const client = paymentSDK.ctAPI.client; 60 | client.types = jest.fn(() => ({ 61 | get: jest.fn(() => ({ 62 | execute: executeMock, 63 | })), 64 | })) as never; 65 | 66 | const result = await getTypesByResourceTypeId('resource-type-id'); 67 | expect(result).toEqual([mock_CustomType_withFieldDefinition]); 68 | }); 69 | }); 70 | 71 | describe('createCustomType', () => { 72 | it('should create a custom type successfully', async () => { 73 | const executeMock = jest.fn().mockReturnValue(Promise.resolve({ body: mock_CustomType_withFieldDefinition })); 74 | const client = paymentSDK.ctAPI.client; 75 | client.types = jest.fn(() => ({ 76 | post: jest.fn(() => ({ 77 | execute: executeMock, 78 | })), 79 | })) as never; 80 | 81 | const result = await createCustomType(mock_CustomType_withFieldDefinition); 82 | expect(result).toEqual(mock_CustomType_withFieldDefinition.id); 83 | }); 84 | }); 85 | 86 | describe('updateCustomTypeByKey', () => { 87 | it('should update the custom type successfully', async () => { 88 | const executeMock = jest.fn().mockReturnValue(Promise.resolve({ body: mock_CustomType_withFieldDefinition })); 89 | const client = paymentSDK.ctAPI.client; 90 | client.types = jest.fn(() => ({ 91 | withKey: jest.fn(() => ({ 92 | post: jest.fn(() => ({ 93 | execute: executeMock, 94 | })), 95 | })), 96 | })) as never; 97 | 98 | const result = updateCustomTypeByKey({ 99 | key: 'type-key', 100 | version: 1, 101 | actions: mock_AddFieldDefinitionActions, 102 | }); 103 | await expect(result).resolves.not.toThrow(); 104 | }); 105 | }); 106 | 107 | describe('deleteCustomTypeByKey', () => { 108 | it('should delete the custom type successfully', async () => { 109 | const executeMock = jest.fn().mockReturnValue(Promise.resolve()); 110 | const client = paymentSDK.ctAPI.client; 111 | client.types = jest.fn(() => ({ 112 | withKey: jest.fn(() => ({ 113 | delete: jest.fn(() => ({ 114 | execute: executeMock, 115 | })), 116 | })), 117 | })) as never; 118 | const result = deleteCustomTypeByKey({ key: 'type-key', version: 1 }); 119 | 120 | await expect(result).resolves.not.toThrow(); 121 | }); 122 | }); 123 | }); 124 | -------------------------------------------------------------------------------- /connect.yaml: -------------------------------------------------------------------------------- 1 | deployAs: 2 | - name: enabler 3 | applicationType: assets 4 | - name: processor 5 | applicationType: service 6 | endpoint: / 7 | scripts: 8 | postDeploy: npm install && npm run connector:post-deploy 9 | preUndeploy: npm install && npm run connector:pre-undeploy 10 | configuration: 11 | standardConfiguration: 12 | - key: CTP_PROJECT_KEY 13 | description: commercetools project key 14 | required: true 15 | - key: CTP_AUTH_URL 16 | description: commercetools Auth URL (example - https://auth.europe-west1.gcp.commercetools.com). 17 | required: true 18 | default: https://auth.europe-west1.gcp.commercetools.com 19 | - key: CTP_API_URL 20 | description: commercetools API URL (example - https://api.europe-west1.gcp.commercetools.com). 21 | required: true 22 | default: https://api.europe-west1.gcp.commercetools.com 23 | - key: CTP_SESSION_URL 24 | description: Session API URL (example - https://session.europe-west1.gcp.commercetools.com). 25 | required: true 26 | default: https://session.europe-west1.gcp.commercetools.com 27 | - key: CTP_CHECKOUT_URL 28 | description: Checkout API URL (example - https://checkout.europe-west1.gcp.commercetools.com). 29 | required: true 30 | - key: CTP_JWKS_URL 31 | description: JWKs url (example - https://mc-api.europe-west1.gcp.commercetools.com/.well-known/jwks.json) 32 | required: true 33 | default: https://mc-api.europe-west1.gcp.commercetools.com/.well-known/jwks.json 34 | - key: CTP_JWT_ISSUER 35 | description: JWT Issuer for jwt validation (example - https://mc-api.europe-west1.gcp.commercetools.com) 36 | required: true 37 | default: https://mc-api.europe-west1.gcp.commercetools.com 38 | - key: STRIPE_CAPTURE_METHOD 39 | description: Stripe capture method (example - manual|automatic). 40 | default: automatic 41 | - key: STRIPE_WEBHOOK_ID 42 | description: Stripe unique identifier for the Webhook Endpoints (example - we_*****). 43 | required: true 44 | - key: STRIPE_APPEARANCE_PAYMENT_ELEMENT 45 | description: Stripe Appearance for Payment Element (example - {"theme":"night","labels":"floating"} ). 46 | - key: STRIPE_LAYOUT 47 | description: Stripe Layout for Payment Element (example - {"type":"accordion","defaultCollapsed":false,"radios":true,"spacedAccordionItems":false} ). 48 | default: '{"type":"tabs","defaultCollapsed":false}' 49 | - key: STRIPE_PUBLISHABLE_KEY 50 | description: Stripe Publishable Key 51 | required: true 52 | - key: STRIPE_APPLE_PAY_WELL_KNOWN 53 | description: Domain association file from Stripe. (example - https://stripe.com/files/apple-pay/apple-developer-merchantid-domain-association) 54 | - key: STRIPE_SAVED_PAYMENT_METHODS_CONFIG 55 | description: Stripe configuration for saved payment methods (example - {"payment_method_save":"enabled","payment_method_save_usage":"off_session","payment_method_redisplay":"enabled","payment_method_redisplay_limit":10}). 56 | default: '{"payment_method_save":"disabled"}' 57 | - key: MERCHANT_RETURN_URL 58 | description: Merchant return URL 59 | required: true 60 | - key: PAYMENT_INTERFACE 61 | description: The payment interface value used in the commercetools payment/payment methods. Default value is "checkout-stripe". 62 | required: false 63 | - key: STRIPE_COLLECT_BILLING_ADDRESS 64 | description: Stripe collect billing address information in Payment Element (example - 'auto' | 'never' | 'if_required'). 65 | default: 'auto' 66 | required: true 67 | - key: STRIPE_ENABLE_MULTI_OPERATIONS 68 | description: Enable multicapture and multirefund support. Requires multicapture to be enabled in your Stripe account (example - true | false). 69 | default: 'false' 70 | securedConfiguration: 71 | - key: CTP_CLIENT_SECRET 72 | description: commercetools client secret. 73 | required: true 74 | - key: CTP_CLIENT_ID 75 | description: commercetools client ID with manage_payments, manage_orders, view_sessions, view_api_clients, manage_checkout_payment_intents, introspect_oauth_tokens, manage_types and view_types scopes 76 | required: true 77 | - key: STRIPE_SECRET_KEY 78 | description: Stripe secret key (example - sk_*****). 79 | required: true 80 | - key: STRIPE_WEBHOOK_SIGNING_SECRET 81 | description: Stripe Webhook signing secret (example - whsec_*****). 82 | required: true 83 | -------------------------------------------------------------------------------- /processor/test/libs/fastify/error-handler.spec.ts: -------------------------------------------------------------------------------- 1 | import { afterEach, beforeEach, describe, expect, test } from '@jest/globals'; 2 | import Fastify, { type FastifyInstance, FastifyError } from 'fastify'; 3 | import { errorHandler } from '../../../src/libs/fastify/error-handler'; 4 | import { ErrorAuthErrorResponse, Errorx, ErrorxAdditionalOpts } from '@commercetools/connect-payments-sdk'; 5 | import { requestContextPlugin } from '../../../src/libs/fastify/context/context'; 6 | import { FastifySchemaValidationError } from 'fastify/types/schema'; 7 | 8 | describe('error-handler', () => { 9 | let fastify: FastifyInstance; 10 | beforeEach(async () => { 11 | fastify = Fastify(); 12 | fastify.setErrorHandler(errorHandler); 13 | await fastify.register(requestContextPlugin); 14 | }); 15 | 16 | afterEach(async () => { 17 | await fastify.close(); 18 | }); 19 | 20 | test('errox error', async () => { 21 | fastify.get('/', () => { 22 | throw new Errorx({ 23 | code: 'ErrorCode', 24 | message: 'someMessage', 25 | httpErrorStatus: 404, 26 | }); 27 | }); 28 | 29 | const response = await fastify.inject({ 30 | method: 'GET', 31 | url: '/', 32 | }); 33 | 34 | expect(response.json()).toStrictEqual({ 35 | message: 'someMessage', 36 | statusCode: 404, 37 | errors: [ 38 | { 39 | code: 'ErrorCode', 40 | message: 'someMessage', 41 | }, 42 | ], 43 | }); 44 | }); 45 | 46 | test('errox with fields', async () => { 47 | fastify.get('/', () => { 48 | throw new Errorx({ 49 | code: 'ErrorCode', 50 | message: 'someMessage', 51 | httpErrorStatus: 404, 52 | fields: { 53 | test: 'field1', 54 | }, 55 | }); 56 | }); 57 | 58 | const response = await fastify.inject({ 59 | method: 'GET', 60 | url: '/', 61 | }); 62 | 63 | expect(response.json()).toStrictEqual({ 64 | message: 'someMessage', 65 | statusCode: 404, 66 | errors: [ 67 | { 68 | code: 'ErrorCode', 69 | message: 'someMessage', 70 | test: 'field1', 71 | }, 72 | ], 73 | }); 74 | }); 75 | 76 | test('general error', async () => { 77 | fastify.get('/', () => { 78 | throw new Error('some message goes here'); 79 | }); 80 | 81 | const response = await fastify.inject({ 82 | method: 'GET', 83 | url: '/', 84 | }); 85 | 86 | expect(response.json()).toStrictEqual({ 87 | message: 'Internal server error.', 88 | statusCode: 500, 89 | errors: [ 90 | { 91 | code: 'General', 92 | message: 'Internal server error.', 93 | }, 94 | ], 95 | }); 96 | }); 97 | 98 | test('Fastify error with missing required field', async () => { 99 | const validationError: FastifySchemaValidationError = { 100 | keyword: 'required', 101 | instancePath: 'instancePath/domain/value', 102 | schemaPath: 'schemaPath/domain/value', 103 | params: { 104 | missingProperty: 'dummy-field', 105 | }, 106 | message: 'fastify-error-message', 107 | }; 108 | const fastifyError: FastifyError = { 109 | code: 'ErrorCode', 110 | name: 'fastify-error', 111 | message: 'fastify-error-message', 112 | validation: [validationError], 113 | }; 114 | fastify.get('/', () => { 115 | throw fastifyError; 116 | }); 117 | 118 | const response = await fastify.inject({ 119 | method: 'GET', 120 | url: '/', 121 | }); 122 | expect(response.json()).toStrictEqual({ 123 | message: 'A value is required for field dummy-field.', 124 | statusCode: 400, 125 | errors: [ 126 | { 127 | code: 'RequiredField', 128 | field: 'dummy-field', 129 | message: 'A value is required for field dummy-field.', 130 | }, 131 | ], 132 | }); 133 | }); 134 | 135 | test('ErrorAuthErrorResponse', async () => { 136 | const opts: ErrorxAdditionalOpts = { 137 | privateFields: {}, 138 | privateMessage: '', 139 | fields: {}, 140 | skipLog: true, 141 | cause: undefined, 142 | }; 143 | const authErrorResponse: ErrorAuthErrorResponse = new ErrorAuthErrorResponse('someMessage', opts, '401'); 144 | 145 | fastify.get('/', () => { 146 | throw authErrorResponse; 147 | }); 148 | 149 | const response = await fastify.inject({ 150 | method: 'GET', 151 | url: '/', 152 | }); 153 | expect(response.json()).toStrictEqual({ 154 | message: 'someMessage', 155 | statusCode: 401, 156 | errors: [ 157 | { 158 | code: '401', 159 | message: 'someMessage', 160 | }, 161 | ], 162 | error: '401', 163 | error_description: 'someMessage', 164 | }); 165 | }); 166 | }); 167 | -------------------------------------------------------------------------------- /enabler/src/style/inputField.module.scss: -------------------------------------------------------------------------------- 1 | @use "colors"; 2 | @use "variables"; 3 | 4 | 5 | @use './vx' as *; 6 | 7 | .inputContainer { 8 | position: relative; 9 | width: 100%; 10 | font-family: variables.$font-family; 11 | margin-bottom: 1.5rem; 12 | 13 | .inputLabel { 14 | position: absolute; 15 | width: 100%; 16 | top: 1rem; 17 | padding: 0 0.25rem 0 1rem; 18 | 19 | font-size: 1rem; 20 | line-height: 1.5rem; 21 | background-color: transparent; 22 | color: variables.$color-border-focus; 23 | transition: 0.3s all; 24 | pointer-events: none; 25 | z-index: variables.$z-index-1; 26 | text-overflow: ellipsis; 27 | overflow: hidden; 28 | white-space: nowrap; 29 | } 30 | 31 | .inputField { 32 | width: 100%; 33 | margin: 0; 34 | padding: 1rem 0.75rem; 35 | box-sizing: border-box; 36 | font-size: 1rem; 37 | line-height: 1rem; 38 | border: 1px solid variables.$color-border-default; 39 | border-radius: 4px; 40 | &::placeholder { 41 | opacity: 0; 42 | transition: 0.3s opacity; 43 | } 44 | } 45 | 46 | .inputField:hover { 47 | border: 1px solid variables.$color-border-focus; 48 | cursor: pointer; 49 | } 50 | 51 | .helperText { 52 | color: variables.$color-text-helper; 53 | font-size: 0.75rem; 54 | line-height: 0.75rem; 55 | padding: 0.25rem 0.75rem 0.25rem 1rem; 56 | } 57 | } 58 | 59 | .inputContainer:not(.containValue) { 60 | .inputField { 61 | width: 100%; 62 | margin: 0; 63 | padding: 1rem 0.75rem; 64 | box-sizing: border-box; 65 | font-size: 1rem; 66 | line-height: 1rem; 67 | border: 1px solid variables.$color-border-default; 68 | border-radius: 4px; 69 | &::placeholder { 70 | opacity: 0; 71 | transition: 0.3s opacity; 72 | } 73 | &:not(:focus)::-webkit-datetime-edit-year-field:not([aria-valuenow]), 74 | &:not(:focus)::-webkit-datetime-edit-month-field:not([aria-valuenow]), 75 | &:not(:focus)::-webkit-datetime-edit-day-field:not([aria-valuenow]) { 76 | color: transparent; 77 | } 78 | &:not(:focus):in-range::-webkit-datetime-edit-year-field, 79 | &:not(:focus):in-range::-webkit-datetime-edit-month-field, 80 | &:not(:focus):in-range::-webkit-datetime-edit-day-field, 81 | &:not(:focus):in-range::-webkit-datetime-edit-hour-field, 82 | &:not(:focus):in-range::-webkit-datetime-edit-minute-field, 83 | &:not(:focus):in-range::-webkit-datetime-edit-text { 84 | color: transparent; 85 | } 86 | } 87 | } 88 | 89 | .inputContainer:focus-within, 90 | .disabledWithValue, 91 | .inputContainer.containValue { 92 | overflow: initial; 93 | 94 | .inputLabel { 95 | transform: translateY(-1.375rem); 96 | width: auto; 97 | padding: 0 0.25rem; 98 | left: 0.75rem; 99 | color: var(--ctc-input-field-focus); 100 | background-color: #fff; 101 | font-size: 0.75rem; 102 | line-height: 0.75rem; 103 | } 104 | 105 | .inputField { 106 | outline-color: var(--ctc-input-field-focus); 107 | } 108 | 109 | .inputField::placeholder { 110 | opacity: 1; 111 | } 112 | } 113 | 114 | .hasGreyBackground.inputContainer:focus-within { 115 | .inputLabel { 116 | background-color: colors.$color-style-side-background; 117 | } 118 | } 119 | 120 | .containValue:not(:focus-within), 121 | .disabledWithValue { 122 | .inputLabel { 123 | color: variables.$color-border-default; 124 | } 125 | } 126 | 127 | .trailingIconContainer { 128 | .inputField { 129 | padding-right: 3.25rem; 130 | } 131 | .trailingIcon { 132 | position: absolute; 133 | right: 1rem; 134 | top: 1rem; 135 | width: 1.5rem; 136 | height: 1.5rem; 137 | cursor: pointer; 138 | } 139 | } 140 | 141 | .leadingIconContainer { 142 | .inputField { 143 | padding-left: 3rem; 144 | } 145 | .leadingIcon { 146 | position: absolute; 147 | left: 0.75rem; 148 | top: 1rem; 149 | width: 1.5rem; 150 | height: 1.5rem; 151 | cursor: pointer; 152 | } 153 | .inputLabel { 154 | left: 3rem; 155 | } 156 | } 157 | 158 | .inputContainer.error { 159 | margin-bottom: 1rem; 160 | .inputField { 161 | border: 2px solid variables.$color-status-error; 162 | outline: none; 163 | } 164 | .helperText { 165 | color: variables.$color-status-error; 166 | } 167 | } 168 | 169 | .inputContainer.error:focus-within, 170 | .inputContainer.error.containValue { 171 | .inputLabel { 172 | color: variables.$color-status-error; 173 | max-width: calc(100% - 1rem); 174 | } 175 | } 176 | 177 | .inputContainer.disabled { 178 | .inputField { 179 | cursor: not-allowed; 180 | border: 1px solid variables.$color-border-default; 181 | color: variables.$color-border-default; 182 | background-color: variables.$color-text-white; 183 | } 184 | .inputLabel { 185 | color: variables.$color-border-default; 186 | } 187 | } 188 | 189 | .inputFieldTooltip { 190 | position: absolute; 191 | top: 1rem; 192 | right: 0.35rem; 193 | } 194 | 195 | .errorField { 196 | color: variables.$color-status-error; 197 | font-size: 0.75rem; 198 | padding: 0.25rem 0.75rem 0.25rem 1rem; 199 | } 200 | -------------------------------------------------------------------------------- /processor/test/utils/mock-cart-data.ts: -------------------------------------------------------------------------------- 1 | import { Cart, LineItem, CustomLineItem, ShippingInfo } from '@commercetools/connect-payments-sdk'; 2 | import { randomUUID } from 'crypto'; 3 | import { mockCtCustomerId } from './mock-customer-data'; 4 | 5 | export const mockGetCartResult = () => { 6 | const cartId = randomUUID(); 7 | const mockGetCartResult: Cart = { 8 | id: cartId, 9 | customerId: mockCtCustomerId, 10 | version: 1, 11 | lineItems: [lineItem], 12 | customLineItems: [customLineItem], 13 | totalPrice: { 14 | type: 'centPrecision', 15 | currencyCode: 'USD', 16 | centAmount: 150000, 17 | fractionDigits: 2, 18 | }, 19 | cartState: 'Ordered', 20 | origin: 'Customer', 21 | taxMode: 'ExternalAmount', 22 | taxRoundingMode: 'HalfEven', 23 | taxCalculationMode: 'LineItemLevel', 24 | shipping: [], 25 | discountCodes: [], 26 | directDiscounts: [], 27 | refusedGifts: [], 28 | itemShippingAddresses: [], 29 | inventoryMode: 'ReserveOnOrder', 30 | shippingMode: 'Single', 31 | shippingInfo: shippingInfo, 32 | createdAt: '2024-01-01T00:00:00Z', 33 | lastModifiedAt: '2024-01-01T00:00:00Z', 34 | customerEmail: 'test@example.com', 35 | paymentInfo: { 36 | payments: [ 37 | { 38 | id: 'paymentId', 39 | typeId: 'payment', 40 | obj: undefined, 41 | }, 42 | ], 43 | }, 44 | shippingAddress: { 45 | title: 'Mr.', 46 | firstName: 'John', 47 | lastName: 'Smith', 48 | streetName: 'Test street', 49 | streetNumber: '123', 50 | postalCode: '12345', 51 | city: 'Los Angeles', 52 | state: 'CA', 53 | country: 'US', 54 | phone: '+312345678', 55 | mobile: '+312345679', 56 | email: 'test@example.com', 57 | key: 'address1', 58 | }, 59 | priceRoundingMode: 'HalfUp', 60 | }; 61 | return mockGetCartResult; 62 | }; 63 | 64 | export const mockGetCartWithoutCustomerIdResult = () => { 65 | const cartId = randomUUID(); 66 | const mockGetCartResult: Cart = { 67 | id: cartId, 68 | customerId: '', 69 | version: 1, 70 | lineItems: [lineItem], 71 | customLineItems: [customLineItem], 72 | totalPrice: { 73 | type: 'centPrecision', 74 | currencyCode: 'USD', 75 | centAmount: 150000, 76 | fractionDigits: 2, 77 | }, 78 | cartState: 'Ordered', 79 | origin: 'Customer', 80 | taxMode: 'ExternalAmount', 81 | taxRoundingMode: 'HalfEven', 82 | taxCalculationMode: 'LineItemLevel', 83 | shipping: [], 84 | discountCodes: [], 85 | directDiscounts: [], 86 | refusedGifts: [], 87 | itemShippingAddresses: [], 88 | inventoryMode: 'ReserveOnOrder', 89 | shippingMode: 'Single', 90 | shippingInfo: shippingInfo, 91 | createdAt: '2024-01-01T00:00:00Z', 92 | lastModifiedAt: '2024-01-01T00:00:00Z', 93 | customerEmail: 'test@example.com', 94 | paymentInfo: { 95 | payments: [ 96 | { 97 | id: 'paymentId', 98 | typeId: 'payment', 99 | obj: undefined, 100 | }, 101 | ], 102 | }, 103 | shippingAddress: { 104 | title: 'Mr.', 105 | firstName: 'John', 106 | lastName: 'Smith', 107 | streetName: 'Test street', 108 | streetNumber: '123', 109 | postalCode: '12345', 110 | city: 'Los Angeles', 111 | state: 'CA', 112 | country: 'US', 113 | phone: '+312345678', 114 | mobile: '+312345679', 115 | email: 'test@example.com', 116 | key: 'address1', 117 | }, 118 | priceRoundingMode: 'HalfUp', 119 | }; 120 | return mockGetCartResult; 121 | }; 122 | 123 | const lineItem: LineItem = { 124 | id: 'lineitem-id-1', 125 | productId: 'product-id-1', 126 | name: { 127 | en: 'lineitem-name-1', 128 | }, 129 | productType: { 130 | id: 'product-type-reference-1', 131 | typeId: 'product-type', 132 | }, 133 | price: { 134 | id: 'price-id-1', 135 | value: { 136 | type: 'centPrecision', 137 | currencyCode: 'USD', 138 | centAmount: 150000, 139 | fractionDigits: 2, 140 | }, 141 | }, 142 | quantity: 1, 143 | totalPrice: { 144 | type: 'centPrecision', 145 | currencyCode: 'USD', 146 | centAmount: 150000, 147 | fractionDigits: 2, 148 | }, 149 | discountedPricePerQuantity: [], 150 | taxedPricePortions: [], 151 | state: [], 152 | perMethodTaxRate: [], 153 | priceMode: 'Platform', 154 | lineItemMode: 'Standard', 155 | variant: { 156 | id: 1, 157 | sku: 'variant-sku-1', 158 | }, 159 | }; 160 | 161 | const customLineItem: CustomLineItem = { 162 | id: 'customLineItem-id-1', 163 | name: { 164 | en: 'customLineItem-name-1', 165 | }, 166 | slug: '', 167 | money: { 168 | type: 'centPrecision', 169 | currencyCode: 'USD', 170 | centAmount: 150000, 171 | fractionDigits: 2, 172 | }, 173 | quantity: 1, 174 | totalPrice: { 175 | type: 'centPrecision', 176 | currencyCode: 'USD', 177 | centAmount: 150000, 178 | fractionDigits: 2, 179 | }, 180 | discountedPricePerQuantity: [], 181 | taxedPricePortions: [], 182 | state: [], 183 | perMethodTaxRate: [], 184 | priceMode: 'Platform', 185 | }; 186 | 187 | const shippingInfo: ShippingInfo = { 188 | shippingMethodName: 'shippingMethodName1', 189 | price: { 190 | type: 'centPrecision', 191 | currencyCode: 'USD', 192 | centAmount: 150000, 193 | fractionDigits: 2, 194 | }, 195 | shippingRate: { 196 | price: { 197 | type: 'centPrecision', 198 | currencyCode: 'USD', 199 | centAmount: 1000, 200 | fractionDigits: 2, 201 | }, 202 | tiers: [], 203 | }, 204 | shippingMethodState: 'MatchesCart', 205 | }; 206 | -------------------------------------------------------------------------------- /processor/src/connectors/actions.ts: -------------------------------------------------------------------------------- 1 | import { 2 | launchpadPurchaseOrderCustomType, 3 | productTypeSubscription, 4 | stripeCustomerIdCustomType, 5 | typeLineItem, 6 | } from '../custom-types/custom-types'; 7 | import { log } from '../libs/logger'; 8 | import Stripe from 'stripe'; 9 | import { stripeApi } from '../clients/stripe.client'; 10 | import { addOrUpdateCustomType, deleteOrUpdateCustomType } from '../services/commerce-tools/customTypeHelper'; 11 | import { 12 | createProductType, 13 | deleteProductType, 14 | getProductsByProductTypeId, 15 | getProductTypeByKey, 16 | } from '../services/commerce-tools/productTypeClient'; 17 | import { getTypeByKey } from '../services/commerce-tools/customTypeClient'; 18 | 19 | export async function handleRequest({ 20 | loggerId, 21 | startMessage, 22 | throwError = true, 23 | fn, 24 | }: { 25 | loggerId: string; 26 | startMessage: string; 27 | throwError?: boolean; 28 | fn: () => void; 29 | }): Promise { 30 | try { 31 | log.info(`${loggerId} ${startMessage}`); 32 | fn(); 33 | } catch (error) { 34 | log.error(loggerId, error); 35 | if (throwError) { 36 | throw error; 37 | } 38 | } 39 | } 40 | 41 | export async function createLaunchpadPurchaseOrderNumberCustomType(): Promise { 42 | const getRes = await getTypeByKey(launchpadPurchaseOrderCustomType.key); 43 | if (getRes) { 44 | log.info('Launchpad purchase order number custom type already exists. Skipping creation.'); 45 | } 46 | } 47 | 48 | export async function retrieveWebhookEndpoint(weId: string): Promise { 49 | log.info(`[RETRIEVE_WEBHOOK_ENDPOINT] Starting the process for retrieving webhook endpoint[${weId}].`); 50 | 51 | try { 52 | return await stripeApi().webhookEndpoints.retrieve(weId); 53 | } catch (error) { 54 | log.error('[RETRIEVE_WEBHOOK_ENDPOINT]', error); 55 | } 56 | } 57 | 58 | export async function updateWebhookEndpoint(weId: string, weAppUrl: string): Promise { 59 | log.info( 60 | `[UPDATE_WEBHOOK_ENDPOINT] Starting the process for updating webhook endpoint[${weId}] with url[${weAppUrl}].`, 61 | ); 62 | 63 | try { 64 | await stripeApi().webhookEndpoints.update(weId, { 65 | enabled_events: [ 66 | 'charge.succeeded', 67 | 'charge.updated', 68 | 'payment_intent.succeeded', 69 | 'charge.refunded', 70 | 'payment_intent.canceled', 71 | 'payment_intent.payment_failed', 72 | 'payment_intent.requires_action', 73 | ], 74 | url: weAppUrl, 75 | }); 76 | } catch (error) { 77 | log.error('[UPDATE_WEBHOOK_ENDPOINT]', error); 78 | } 79 | } 80 | 81 | export async function createOrUpdateCustomerCustomType(): Promise { 82 | await handleRequest({ 83 | loggerId: '[CREATE_CUSTOMER_CUSTOM_TYPE]', 84 | startMessage: 'Starting the process for creating "Customer" Custom Type.', 85 | fn: async () => await addOrUpdateCustomType(stripeCustomerIdCustomType), 86 | }); 87 | } 88 | 89 | export async function createLineItemCustomType(): Promise { 90 | await handleRequest({ 91 | loggerId: '[CREATE_LINE_ITEM_CUSTOM_TYPE]', 92 | startMessage: 'Starting the process for creating "Line Item" Custom Type.', 93 | fn: async () => await addOrUpdateCustomType(typeLineItem), 94 | }); 95 | } 96 | 97 | export async function removeLineItemCustomType(): Promise { 98 | await handleRequest({ 99 | loggerId: '[REMOVE_LINE_ITEM_CUSTOM_TYPE]', 100 | startMessage: 'Starting the process for removing "Line Item" Custom Type.', 101 | fn: async () => await deleteOrUpdateCustomType(typeLineItem), 102 | }); 103 | } 104 | 105 | export async function removeCustomerCustomType(): Promise { 106 | await handleRequest({ 107 | loggerId: '[REMOVE_CUSTOMER_CUSTOM_TYPE]', 108 | startMessage: 'Starting the process for removing "Customer" Custom Type.', 109 | fn: async () => { 110 | await deleteOrUpdateCustomType(stripeCustomerIdCustomType); 111 | }, 112 | }); 113 | } 114 | 115 | export async function createProductTypeSubscription(): Promise { 116 | await handleRequest({ 117 | loggerId: '[CREATE_PRODUCT_TYPE_SUBSCRIPTION]', 118 | startMessage: 'Starting the process for creating Product Type "Subscription".', 119 | fn: async () => { 120 | const productType = await getProductTypeByKey(productTypeSubscription.key!); 121 | if (productType) { 122 | log.info('Product type subscription already exists. Skipping creation.'); 123 | } else { 124 | const newProductType = await createProductType(productTypeSubscription); 125 | log.info(`Product Type "${newProductType.key}" created successfully.`); 126 | } 127 | }, 128 | }); 129 | } 130 | 131 | export async function removeProductTypeSubscription(): Promise { 132 | await handleRequest({ 133 | loggerId: '[REMOVE_PRODUCT_TYPE_SUBSCRIPTION]', 134 | startMessage: 'Starting the process for removing Product Type "Subscription".', 135 | fn: async () => { 136 | const productTypeKey = productTypeSubscription.key!; 137 | const productType = await getProductTypeByKey(productTypeKey); 138 | if (!productType) { 139 | log.info(`Product Type "${productTypeKey}" is already deleted. Skipping deletion.`); 140 | return; 141 | } 142 | 143 | const products = await getProductsByProductTypeId(productType?.id); 144 | if (products.length) { 145 | log.warn(`Product Type "${productTypeKey}" is in use. Skipping deletion.`); 146 | } else { 147 | await deleteProductType({ key: productTypeKey, version: productType.version }); 148 | log.info(`Product Type "${productTypeKey}" deleted successfully.`); 149 | } 150 | }, 151 | }); 152 | } 153 | -------------------------------------------------------------------------------- /processor/src/services/converters/stripeEventConverter.ts: -------------------------------------------------------------------------------- 1 | import { TransactionData, Money } from '@commercetools/connect-payments-sdk'; 2 | 3 | import Stripe from 'stripe'; 4 | import { PaymentStatus, StripeEvent, StripeEventUpdatePayment } from '../types/stripe-payment.type'; 5 | import { PaymentTransactions } from '../../dtos/operations/payment-intents.dto'; 6 | import { wrapStripeError } from '../../clients/stripe.client'; 7 | 8 | export class StripeEventConverter { 9 | public convert(opts: Stripe.Event): StripeEventUpdatePayment { 10 | let data, paymentIntentId, paymentMethod; 11 | if (opts.type.startsWith('payment')) { 12 | data = opts.data.object as Stripe.PaymentIntent; 13 | paymentIntentId = data.id; 14 | } else { 15 | data = opts.data.object as Stripe.Charge; 16 | paymentIntentId = (data.payment_intent || data.id) as string; 17 | paymentMethod = (data.payment_method_details?.type as string) || ''; 18 | } 19 | 20 | return { 21 | id: this.getCtPaymentId(data), 22 | pspReference: paymentIntentId, 23 | paymentMethodInfo: { 24 | method: paymentMethod, 25 | }, 26 | pspInteraction: { 27 | response: JSON.stringify(opts), 28 | }, 29 | transactions: this.populateTransactions(opts, paymentIntentId), 30 | }; 31 | } 32 | 33 | private populateTransactions(event: Stripe.Event, paymentIntentId: string): TransactionData[] { 34 | switch (event.type) { 35 | case StripeEvent.PAYMENT_INTENT__CANCELED: 36 | return [ 37 | { 38 | type: PaymentTransactions.AUTHORIZATION, 39 | state: PaymentStatus.FAILURE, 40 | amount: this.populateAmountCanceled(event), 41 | interactionId: paymentIntentId, //Deprecated but kept for backward compatibility 42 | interfaceId: paymentIntentId, 43 | }, 44 | { 45 | type: PaymentTransactions.CANCEL_AUTHORIZATION, 46 | state: PaymentStatus.SUCCESS, 47 | amount: this.populateAmountCanceled(event), 48 | interactionId: paymentIntentId, //Deprecated but kept for backward compatibility 49 | interfaceId: paymentIntentId, 50 | }, 51 | ]; 52 | case StripeEvent.PAYMENT_INTENT__SUCCEEDED: 53 | return [ 54 | { 55 | type: PaymentTransactions.CHARGE, 56 | state: PaymentStatus.SUCCESS, 57 | amount: this.populateAmount(event), 58 | interactionId: paymentIntentId, //Deprecated but kept for backward compatibility 59 | interfaceId: paymentIntentId, 60 | }, 61 | ]; 62 | case StripeEvent.PAYMENT_INTENT__PAYMENT_FAILED: 63 | return [ 64 | { 65 | type: PaymentTransactions.AUTHORIZATION, 66 | state: PaymentStatus.FAILURE, 67 | amount: this.populateAmount(event), 68 | interactionId: paymentIntentId, //Deprecated but kept for backward compatibility 69 | interfaceId: paymentIntentId, 70 | }, 71 | ]; 72 | case StripeEvent.CHARGE__REFUNDED: { 73 | return [ 74 | { 75 | type: PaymentTransactions.REFUND, 76 | state: PaymentStatus.SUCCESS, 77 | amount: this.populateAmount(event), 78 | interactionId: paymentIntentId, //Deprecated but kept for backward compatibility 79 | interfaceId: paymentIntentId, 80 | }, 81 | { 82 | type: PaymentTransactions.CHARGE_BACK, 83 | state: PaymentStatus.SUCCESS, 84 | amount: this.populateAmount(event), 85 | interactionId: paymentIntentId, //Deprecated but kept for backward compatibility 86 | interfaceId: paymentIntentId, 87 | }, 88 | ]; 89 | } 90 | case StripeEvent.CHARGE__SUCCEEDED: { 91 | return [ 92 | { 93 | type: PaymentTransactions.AUTHORIZATION, 94 | state: PaymentStatus.SUCCESS, 95 | amount: this.populateAmount(event), 96 | interactionId: paymentIntentId, //Deprecated but kept for backward compatibility 97 | interfaceId: paymentIntentId, 98 | }, 99 | ]; 100 | } 101 | case StripeEvent.CHARGE__UPDATED: 102 | return [ 103 | { 104 | type: PaymentTransactions.CHARGE, 105 | state: PaymentStatus.SUCCESS, 106 | amount: this.populateAmount(event), 107 | interactionId: paymentIntentId, //Deprecated but kept for backward compatibility 108 | interfaceId: paymentIntentId, 109 | }, 110 | ]; 111 | default: { 112 | const error = `Unsupported event ${event.type}`; 113 | throw wrapStripeError(new Error(error)); 114 | } 115 | } 116 | } 117 | 118 | private populateAmount(opts: Stripe.Event): Money { 119 | let data, centAmount; 120 | if (opts.type.startsWith('payment')) { 121 | data = opts.data.object as Stripe.PaymentIntent; 122 | centAmount = data.amount_received; 123 | } else { 124 | data = opts.data.object as Stripe.Charge; 125 | centAmount = data.amount_refunded; 126 | } 127 | 128 | return { 129 | centAmount: centAmount, 130 | currencyCode: data.currency.toUpperCase(), 131 | }; 132 | } 133 | 134 | private populateAmountCanceled(opts: Stripe.Event): Money { 135 | const data = opts.data.object as Stripe.PaymentIntent; 136 | const currencyCode = data.currency.toUpperCase(); 137 | const centAmount = data.amount; 138 | 139 | return { 140 | centAmount: centAmount, 141 | currencyCode: currencyCode, 142 | }; 143 | } 144 | 145 | private getCtPaymentId(event: Stripe.PaymentIntent | Stripe.Charge): string { 146 | return event.metadata.ct_payment_id; 147 | } 148 | } 149 | -------------------------------------------------------------------------------- /enabler/src/dropin/dropin-embedded.ts: -------------------------------------------------------------------------------- 1 | 2 | import { PaymentResponseSchemaDTO } from "../dtos/mock-payment.dto"; 3 | import { 4 | DropinComponent, 5 | DropinOptions, 6 | PaymentDropinBuilder, 7 | } from "../payment-enabler/payment-enabler"; 8 | import { BaseOptions } from "../payment-enabler/payment-enabler-mock"; 9 | import { StripePaymentElement} from "@stripe/stripe-js"; 10 | 11 | interface BillingAddress { 12 | name: string; 13 | email: string; 14 | phone: string; 15 | address: { 16 | city: string; 17 | country: string; 18 | line1: string; 19 | line2: string; 20 | postal_code: string; 21 | state: string; 22 | } 23 | } 24 | 25 | interface ConfirmPaymentProps { 26 | merchantReturnUrl: string; 27 | cartId: string; 28 | clientSecret: string; 29 | paymentReference: string; 30 | billingAddress?: BillingAddress; 31 | } 32 | 33 | interface ConfirmPaymentIntentProps { 34 | paymentIntentId: string; 35 | paymentReference: string; 36 | } 37 | 38 | export class DropinEmbeddedBuilder implements PaymentDropinBuilder { 39 | public dropinHasSubmit = true; 40 | private baseOptions: BaseOptions; 41 | 42 | constructor(baseOptions: BaseOptions) { 43 | this.baseOptions = baseOptions; 44 | } 45 | 46 | build(config: DropinOptions): DropinComponent { 47 | const dropin = new DropinComponents({ 48 | baseOptions: this.baseOptions, 49 | dropinOptions: config, 50 | }); 51 | 52 | dropin.init(); 53 | return dropin; 54 | } 55 | } 56 | 57 | export class DropinComponents implements DropinComponent { 58 | private baseOptions: BaseOptions; 59 | private paymentElement: StripePaymentElement; 60 | private dropinOptions: DropinOptions; 61 | 62 | constructor(opts: { 63 | baseOptions: BaseOptions, 64 | dropinOptions: DropinOptions 65 | }) { 66 | this.baseOptions = opts.baseOptions; 67 | this.dropinOptions = opts.dropinOptions; 68 | } 69 | 70 | init(): void { 71 | this.dropinOptions.showPayButton = false; 72 | this.paymentElement = this.baseOptions.paymentElement; 73 | } 74 | 75 | async mount(selector: string) { 76 | if (this.baseOptions.paymentElement) { 77 | this.paymentElement.mount(selector); 78 | } else { 79 | console.error("Payment Element not initialized"); 80 | } 81 | } 82 | 83 | async submit(): Promise { 84 | try { 85 | const { error: submitError } = await this.baseOptions.elements.submit(); 86 | 87 | if (submitError) { 88 | throw submitError; 89 | } 90 | 91 | const { 92 | sClientSecret, 93 | paymentReference, 94 | merchantReturnUrl, 95 | cartId, 96 | billingAddress 97 | } = await this.getPayment(); 98 | 99 | const { paymentIntent } = await this.confirmStripePayment({ 100 | merchantReturnUrl, 101 | cartId, 102 | clientSecret: sClientSecret, 103 | paymentReference, 104 | ...(billingAddress && {billingAddress: JSON.parse(billingAddress) as BillingAddress}), 105 | }); 106 | 107 | await this.confirmPaymentIntent({ 108 | paymentIntentId: paymentIntent.id, 109 | paymentReference, 110 | }); 111 | } catch(error) { 112 | this.baseOptions.onError?.(error); 113 | } 114 | } 115 | 116 | private async getPayment(): Promise { 117 | const apiUrl = new URL(`${this.baseOptions.processorUrl}/payments`); 118 | const response = await fetch(apiUrl.toString(), { 119 | method: "GET", 120 | headers: this.getHeadersConfig(), 121 | }); 122 | 123 | if (!response.ok) { 124 | const error = await response.json(); 125 | console.warn(`Error in processor getting Payment: ${error.message}`); 126 | throw error; 127 | } else { 128 | return await response.json(); 129 | } 130 | } 131 | 132 | private async confirmStripePayment({ 133 | merchantReturnUrl, 134 | cartId, 135 | clientSecret, 136 | paymentReference, 137 | billingAddress, 138 | }: ConfirmPaymentProps) { 139 | const returnUrl = new URL(merchantReturnUrl); 140 | returnUrl.searchParams.append("cartId", cartId); 141 | returnUrl.searchParams.append("paymentReference", paymentReference); 142 | 143 | const { error, paymentIntent } = await this.baseOptions.sdk.confirmPayment({ 144 | elements: this.baseOptions.elements, 145 | clientSecret, 146 | confirmParams: { 147 | return_url: returnUrl.toString(), 148 | ...(billingAddress &&{ 149 | payment_method_data: { 150 | billing_details: billingAddress 151 | } 152 | }) 153 | }, 154 | redirect: "if_required", 155 | }); 156 | 157 | if (error) { 158 | throw error; 159 | } 160 | 161 | if (paymentIntent.status === "requires_action") { 162 | const error: any = new Error("Payment requires additional action"); 163 | error.type = "requires_action"; 164 | error.next_action = paymentIntent.next_action; 165 | throw error; 166 | } 167 | if(paymentIntent.last_payment_error) { 168 | const error: any = new Error(`${paymentIntent.last_payment_error.message}`); 169 | error.type = "payment_failed"; 170 | error.last_payment_error = paymentIntent.last_payment_error; 171 | throw error; 172 | } 173 | 174 | return { paymentIntent }; 175 | } 176 | 177 | private async confirmPaymentIntent({ 178 | paymentIntentId, 179 | paymentReference, 180 | }: ConfirmPaymentIntentProps) { 181 | const apiUrl = `${this.baseOptions.processorUrl}/confirmPayments/${paymentReference}`; 182 | const response = await fetch(apiUrl, { 183 | method: "POST", 184 | headers: this.getHeadersConfig(), 185 | body: JSON.stringify({ paymentIntent: paymentIntentId }), 186 | }); 187 | 188 | if (!response.ok) { 189 | throw "Error on /confirmPayments"; 190 | } 191 | 192 | this.baseOptions.onComplete?.({ isSuccess: true, paymentReference }); 193 | } 194 | 195 | private getHeadersConfig(): HeadersInit { 196 | return { 197 | "Content-Type": "application/json", 198 | "x-session-id": this.baseOptions.sessionId, 199 | }; 200 | } 201 | } 202 | 203 | -------------------------------------------------------------------------------- /processor/src/services/commerce-tools/customTypeHelper.ts: -------------------------------------------------------------------------------- 1 | import { 2 | CustomFields, 3 | Type, 4 | TypeAddFieldDefinitionAction, 5 | TypeDraft, 6 | TypeRemoveFieldDefinitionAction, 7 | CustomerSetCustomFieldAction, 8 | CustomerSetCustomTypeAction, 9 | } from '@commercetools/connect-payments-sdk'; 10 | import { log } from '../../libs/logger'; 11 | import { 12 | createCustomType, 13 | deleteCustomTypeByKey, 14 | getTypesByResourceTypeId, 15 | updateCustomTypeByKey, 16 | } from './customTypeClient'; 17 | 18 | export interface KeyAndVersion { 19 | key: string; 20 | version: number; 21 | } 22 | 23 | export function hasField(type: Type | TypeDraft, fieldName: string): boolean { 24 | return !!type.fieldDefinitions?.some((field) => field.name === fieldName); 25 | } 26 | 27 | export function hasAllFields(customType: Type | TypeDraft, type: Type | TypeDraft) { 28 | return customType.fieldDefinitions?.every(({ name }) => hasField(type, name)); 29 | } 30 | 31 | export function findValidCustomType(allTypes: (Type | TypeDraft)[], customType: Type | TypeDraft) { 32 | if (customType.fieldDefinitions?.length === 0) { 33 | return undefined; 34 | } 35 | 36 | for (const type of allTypes) { 37 | const match = hasAllFields(customType, type); 38 | if (match) { 39 | return type; 40 | } 41 | } 42 | return undefined; 43 | } 44 | 45 | export async function addOrUpdateCustomType(customType: TypeDraft): Promise { 46 | const resourceTypeId = customType.resourceTypeIds[0]; 47 | const types = await getTypesByResourceTypeId(resourceTypeId); 48 | 49 | // Check if the specific custom type (by key) already exists 50 | const existingType = types.find((type) => type.key === customType.key); 51 | 52 | if (!existingType) { 53 | await createCustomType(customType); 54 | log.info(`Custom Type "${customType.key}" created successfully.`); 55 | return; 56 | } 57 | 58 | log.info(`Custom Type with resourceTypeId "${resourceTypeId}" already exists. Skipping creation.`); 59 | for (const type of types) { 60 | const { key, version } = type; 61 | const fieldUpdates: TypeAddFieldDefinitionAction[] = (customType.fieldDefinitions ?? []) 62 | .filter(({ name }) => !hasField(type, name)) 63 | .map((fieldDefinition) => ({ 64 | action: 'addFieldDefinition', 65 | fieldDefinition, 66 | })); 67 | 68 | if (!fieldUpdates.length) { 69 | log.info(`Custom Type "${key}" already contains all required fields. Skipping update.`); 70 | continue; 71 | } 72 | 73 | await updateCustomTypeByKey({ key, version, actions: fieldUpdates }); 74 | log.info(`Custom Type "${key}" updated successfully with new fields.`); 75 | } 76 | } 77 | 78 | export async function deleteOrUpdateCustomType(customType: TypeDraft): Promise { 79 | const resourceTypeId = customType.resourceTypeIds[0]; 80 | const types = await getTypesByResourceTypeId(resourceTypeId); 81 | 82 | if (!types.length) { 83 | log.info(`Custom Type with resourceTypeId "${resourceTypeId}" does not exist. Skipping deletion.`); 84 | return; 85 | } 86 | 87 | for (const type of types) { 88 | const { key, version } = type; 89 | const fieldUpdates: TypeRemoveFieldDefinitionAction[] = (customType.fieldDefinitions ?? []) 90 | .filter(({ name }) => hasField(type, name)) 91 | .map(({ name }) => ({ 92 | action: 'removeFieldDefinition', 93 | fieldName: name, 94 | })); 95 | 96 | if (!fieldUpdates.length) { 97 | log.info(`Custom Type "${key}" has no matching fields to remove. Skipping deletion.`); 98 | continue; 99 | } 100 | 101 | const hasSameFields = fieldUpdates.length === type.fieldDefinitions?.length; 102 | if (!hasSameFields) { 103 | await updateCustomTypeByKey({ key, version, actions: fieldUpdates }); 104 | log.info(`Removed ${fieldUpdates.length} fields(s) from Custom Type "${key}" successfully.`); 105 | continue; 106 | } 107 | 108 | try { 109 | await deleteCustomTypeByKey({ key, version }); 110 | log.info(`Custom Type "${key}" deleted successfully.`); 111 | } catch (error) { 112 | const referencedMessage = 'Can not delete a type while it is referenced'; 113 | if (error instanceof Error && error.message.includes(referencedMessage)) { 114 | log.warn(`Custom Type "${key}" is referenced by at least one customer. Skipping deletion.`); 115 | } else { 116 | throw error; 117 | } 118 | } 119 | } 120 | } 121 | 122 | /** 123 | * This function is used to get the actions for setting a custom field in a customer. 124 | * If the custom type exists and all fields exist, it returns `setCustomField` actions, 125 | * if not, it returns `setCustomType` action. 126 | * @returns An array of actions to update the custom field in the customer. 127 | **/ 128 | export async function getCustomFieldUpdateActions({ 129 | customFields, 130 | fields, 131 | customType, 132 | }: { 133 | customFields?: CustomFields; 134 | fields: Record; 135 | customType: TypeDraft; 136 | }): Promise<(CustomerSetCustomTypeAction | CustomerSetCustomFieldAction)[]> { 137 | const resourceTypeId = customType.resourceTypeIds[0]; 138 | const allTypes = await getTypesByResourceTypeId(resourceTypeId); 139 | if (!allTypes.length) { 140 | throw new Error(`Custom Type not found for resource "${resourceTypeId.toUpperCase()}"`); 141 | } 142 | 143 | const typeAssigned = allTypes.find(({ id }) => id === customFields?.type.id); 144 | const allFieldsExist = !!(typeAssigned && hasAllFields(customType, typeAssigned)); 145 | 146 | if (customFields?.type.id && allFieldsExist) { 147 | return Object.entries(fields).map(([name, value]) => ({ 148 | action: 'setCustomField', 149 | name, 150 | value, 151 | })); 152 | } 153 | 154 | const newType = allTypes.find(({ key }) => key === customType.key) ?? findValidCustomType(allTypes, customType); 155 | if (!newType) { 156 | throw new Error(`A valid Custom Type was not found for resource "${resourceTypeId.toUpperCase()}"`); 157 | } 158 | 159 | return [ 160 | { 161 | action: 'setCustomType', 162 | type: { key: newType.key, typeId: 'type' }, 163 | fields, 164 | }, 165 | ]; 166 | } 167 | -------------------------------------------------------------------------------- /processor/src/services/abstract-payment.service.ts: -------------------------------------------------------------------------------- 1 | import { 2 | CommercetoolsCartService, 3 | CommercetoolsOrderService, 4 | CommercetoolsPaymentMethodService, 5 | CommercetoolsPaymentService, 6 | CommercetoolsRecurringPaymentJobService, 7 | ErrorInvalidOperation, 8 | } from '@commercetools/connect-payments-sdk'; 9 | import { 10 | CancelPaymentRequest, 11 | CapturePaymentRequest, 12 | ConfigResponse, 13 | ModifyPayment, 14 | PaymentProviderModificationResponse, 15 | RefundPaymentRequest, 16 | ReversePaymentRequest, 17 | StatusResponse, 18 | } from './types/operation.type'; 19 | import { PaymentIntentResponseSchemaDTO } from '../dtos/operations/payment-intents.dto'; 20 | import { log } from '../libs/logger'; 21 | 22 | import { SupportedPaymentComponentsSchemaDTO } from '../dtos/operations/payment-componets.dto'; 23 | 24 | export abstract class AbstractPaymentService { 25 | protected ctCartService: CommercetoolsCartService; 26 | protected ctPaymentService: CommercetoolsPaymentService; 27 | protected ctOrderService: CommercetoolsOrderService; 28 | protected ctPaymentMethodService: CommercetoolsPaymentMethodService; 29 | protected ctRecurringPaymentJobService: CommercetoolsRecurringPaymentJobService; 30 | 31 | protected constructor( 32 | ctCartService: CommercetoolsCartService, 33 | ctPaymentService: CommercetoolsPaymentService, 34 | ctOrderService: CommercetoolsOrderService, 35 | ctPaymentMethodService: CommercetoolsPaymentMethodService, 36 | ctRecurringPaymentJobService: CommercetoolsRecurringPaymentJobService, 37 | ) { 38 | this.ctCartService = ctCartService; 39 | this.ctPaymentService = ctPaymentService; 40 | this.ctOrderService = ctOrderService; 41 | this.ctPaymentMethodService = ctPaymentMethodService; 42 | this.ctRecurringPaymentJobService = ctRecurringPaymentJobService; 43 | } 44 | 45 | /** 46 | * Get configurations 47 | * 48 | * @remarks 49 | * Abstract method to get configuration information 50 | * 51 | * @returns Promise with object containing configuration information 52 | */ 53 | abstract config(): Promise; 54 | 55 | /** 56 | * Get status 57 | * 58 | * @remarks 59 | * Abstract method to get status of external systems 60 | * 61 | * @returns Promise with a list of status from different external systems 62 | */ 63 | abstract status(): Promise; 64 | 65 | /** 66 | * Get supported payment components 67 | * 68 | * @remarks 69 | * Abstract method to fetch the supported payment components by the processor. The actual invocation should be implemented in subclasses 70 | * 71 | * @returns Promise with a list of supported payment components 72 | */ 73 | abstract getSupportedPaymentComponents(): Promise; 74 | 75 | /** 76 | * Capture payment 77 | * 78 | * @remarks 79 | * Abstract method to execute payment capture in external PSPs. The actual invocation to PSPs should be implemented in subclasses 80 | * 81 | * @param request - contains the amount and {@link https://docs.commercetools.com/api/projects/payments | Payment } defined in composable commerce 82 | * @returns Promise with the outcome containing operation status and PSP reference 83 | */ 84 | abstract capturePayment(request: CapturePaymentRequest): Promise; 85 | 86 | /** 87 | * Cancel payment 88 | * 89 | * @remarks 90 | * Abstract method to execute payment cancel in external PSPs. The actual invocation to PSPs should be implemented in subclasses 91 | * 92 | * @param request - contains {@link https://docs.commercetools.com/api/projects/payments | Payment } defined in composable commerce 93 | * @returns Promise with outcome containing operation status and PSP reference 94 | */ 95 | abstract cancelPayment(request: CancelPaymentRequest): Promise; 96 | 97 | /** 98 | * Refund payment 99 | * 100 | * @remarks 101 | * Abstract method to execute payment refund in external PSPs. The actual invocation to PSPs should be implemented in subclasses 102 | * 103 | * @param request 104 | * @returns Promise with outcome containing operation status and PSP reference 105 | */ 106 | abstract refundPayment(request: RefundPaymentRequest): Promise; 107 | 108 | /** 109 | * Reverse payment 110 | * 111 | * @remarks 112 | * Abstract method to execute payment reversals in support of automated reversals to be triggered by checkout api. The actual invocation to PSPs should be implemented in subclasses 113 | * 114 | * @param request 115 | * @returns Promise with outcome containing operation status and PSP reference 116 | */ 117 | abstract reversePayment(request: ReversePaymentRequest): Promise; 118 | 119 | /** 120 | * Modify payment 121 | * 122 | * @remarks 123 | * This method is used to execute Capture/Cancel/Refund payment in external PSPs and update composable commerce. The actual invocation to PSPs should be implemented in subclasses 124 | * MVP - capture/refund the total of the order 125 | * 126 | * @param opts - input for payment modification including payment ID, action and payment amount 127 | * @returns Promise with outcome of payment modification after invocation to PSPs 128 | */ 129 | public async modifyPayment(opts: ModifyPayment): Promise { 130 | const ctPayment = await this.ctPaymentService.getPayment({ 131 | id: opts.paymentId, 132 | }); 133 | const request = opts.data.actions[0]; 134 | 135 | log.info(`Payment modification ${request.action} start.`); 136 | 137 | switch (request.action) { 138 | case 'cancelPayment': { 139 | return await this.cancelPayment({ payment: ctPayment, merchantReference: request.merchantReference }); 140 | } 141 | case 'capturePayment': { 142 | return await this.capturePayment({ 143 | payment: ctPayment, 144 | merchantReference: request.merchantReference, 145 | amount: request.amount, 146 | }); 147 | } 148 | case 'refundPayment': { 149 | return await this.refundPayment({ 150 | amount: request.amount, 151 | payment: ctPayment, 152 | merchantReference: request.merchantReference, 153 | }); 154 | } 155 | case 'reversePayment': { 156 | return await this.reversePayment({ 157 | payment: ctPayment, 158 | merchantReference: request.merchantReference, 159 | }); 160 | } 161 | default: { 162 | throw new ErrorInvalidOperation(`Operation not supported.`); 163 | } 164 | } 165 | } 166 | } 167 | -------------------------------------------------------------------------------- /processor/test/services/converters/stripeEvent.converter.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, test, expect } from '@jest/globals'; 2 | import { StripeEventConverter } from '../../../src/services/converters/stripeEventConverter'; 3 | import { 4 | mockEvent__charge_refund_captured, 5 | mockEvent__paymentIntent_canceled, 6 | mockEvent__paymentIntent_paymentFailed, 7 | mockEvent__paymentIntent_succeeded_captureMethodAutomatic, 8 | mockEvent__charge_succeeded_notCaptured, 9 | mockEvent__charge_refund_notCaptured, 10 | } from '../../utils/mock-routes-data'; 11 | 12 | describe('stripeEvent.converter', () => { 13 | const converter = new StripeEventConverter(); 14 | 15 | test('convert a payment_intent.succeeded event', () => { 16 | const result = converter.convert(mockEvent__paymentIntent_succeeded_captureMethodAutomatic); 17 | 18 | expect(result).toEqual({ 19 | paymentMethodInfo: { 20 | method: undefined, 21 | }, 22 | id: 'pi_11111', 23 | pspReference: 'pi_11111', 24 | pspInteraction: { 25 | response: JSON.stringify(mockEvent__paymentIntent_succeeded_captureMethodAutomatic), 26 | }, 27 | transactions: [ 28 | { 29 | amount: { 30 | centAmount: 13200, 31 | currencyCode: 'MXN', 32 | }, 33 | interactionId: 'pi_11111', 34 | interfaceId: 'pi_11111', 35 | state: 'Success', 36 | type: 'Charge', 37 | }, 38 | ], 39 | }); 40 | }); 41 | 42 | test('convert a payment_intent.canceled event', () => { 43 | const result = converter.convert(mockEvent__paymentIntent_canceled); 44 | 45 | expect(result).toEqual({ 46 | id: 'pi_11111', 47 | paymentMethodInfo: { 48 | method: undefined, 49 | }, 50 | pspReference: 'pi_11111', 51 | pspInteraction: { 52 | response: JSON.stringify(mockEvent__paymentIntent_canceled), 53 | }, 54 | transactions: [ 55 | { 56 | amount: { 57 | centAmount: 45600, 58 | currencyCode: 'MXN', 59 | }, 60 | interactionId: 'pi_11111', 61 | interfaceId: 'pi_11111', 62 | state: 'Failure', 63 | type: 'Authorization', 64 | }, 65 | { 66 | amount: { 67 | centAmount: 45600, 68 | currencyCode: 'MXN', 69 | }, 70 | interactionId: 'pi_11111', 71 | interfaceId: 'pi_11111', 72 | state: 'Success', 73 | type: 'CancelAuthorization', 74 | }, 75 | ], 76 | }); 77 | }); 78 | 79 | test('convert a payment_intent.payment_failed event transaction', () => { 80 | const result = converter.convert(mockEvent__paymentIntent_paymentFailed); 81 | 82 | expect(result).toEqual({ 83 | id: undefined, 84 | paymentMethodInfo: { 85 | method: undefined, 86 | }, 87 | pspInteraction: { 88 | response: JSON.stringify(mockEvent__paymentIntent_paymentFailed), 89 | }, 90 | pspReference: 'pi_11111', 91 | transactions: [ 92 | { 93 | amount: { 94 | centAmount: 0, 95 | currencyCode: 'MXN', 96 | }, 97 | interactionId: 'pi_11111', 98 | interfaceId: 'pi_11111', 99 | state: 'Failure', 100 | type: 'Authorization', 101 | }, 102 | ], 103 | }); 104 | }); 105 | 106 | test('convert a charge.refunded event captured to transaction', () => { 107 | const result = converter.convert(mockEvent__charge_refund_captured); 108 | 109 | expect(result).toEqual({ 110 | id: 'pi_11111', 111 | paymentMethodInfo: { 112 | method: 'card', 113 | }, 114 | pspReference: 'pi_11111', 115 | pspInteraction: { 116 | response: JSON.stringify(mockEvent__charge_refund_captured), 117 | }, 118 | transactions: [ 119 | { 120 | amount: { 121 | centAmount: 34500, 122 | currencyCode: 'MXN', 123 | }, 124 | interactionId: 'pi_11111', 125 | interfaceId: 'pi_11111', 126 | state: 'Success', 127 | type: 'Refund', 128 | }, 129 | { 130 | amount: { 131 | centAmount: 34500, 132 | currencyCode: 'MXN', 133 | }, 134 | interactionId: 'pi_11111', 135 | interfaceId: 'pi_11111', 136 | state: 'Success', 137 | type: 'Chargeback', 138 | }, 139 | ], 140 | }); 141 | }); 142 | 143 | test('convert a charge.refunded event not captured to refund and chargeback transactions', () => { 144 | const result = converter.convert(mockEvent__charge_refund_notCaptured); 145 | 146 | expect(result).toEqual({ 147 | id: 'pi_11111', 148 | paymentMethodInfo: { 149 | method: 'card', 150 | }, 151 | pspReference: 'pi_11111', 152 | transactions: [ 153 | { 154 | amount: { 155 | centAmount: 34500, 156 | currencyCode: 'MXN', 157 | }, 158 | interactionId: 'pi_11111', 159 | interfaceId: 'pi_11111', 160 | state: 'Success', 161 | type: 'Refund', 162 | }, 163 | { 164 | amount: { 165 | centAmount: 34500, 166 | currencyCode: 'MXN', 167 | }, 168 | interactionId: 'pi_11111', 169 | interfaceId: 'pi_11111', 170 | state: 'Success', 171 | type: 'Chargeback', 172 | }, 173 | ], 174 | pspInteraction: { 175 | response: JSON.stringify(mockEvent__charge_refund_notCaptured), 176 | }, 177 | }); 178 | }); 179 | 180 | test('convert a non supported event notification', () => { 181 | const event = mockEvent__charge_refund_captured; 182 | event.type = 'account.application.deauthorized'; 183 | 184 | try { 185 | converter.convert(event); 186 | } catch (error) { 187 | expect(error).toBeInstanceOf(Error); 188 | } 189 | }); 190 | 191 | test('convert a charge.succeeded event captured return transaction', () => { 192 | const result = converter.convert(mockEvent__charge_succeeded_notCaptured); 193 | 194 | expect(result).toEqual({ 195 | id: 'pi_11111', 196 | paymentMethodInfo: { 197 | method: 'card', 198 | }, 199 | pspReference: 'pi_11111', 200 | pspInteraction: { 201 | response: JSON.stringify(mockEvent__charge_succeeded_notCaptured), 202 | }, 203 | transactions: [ 204 | { 205 | amount: { 206 | centAmount: 0, 207 | currencyCode: 'MXN', 208 | }, 209 | interactionId: 'pi_11111', 210 | interfaceId: 'pi_11111', 211 | state: 'Success', 212 | type: 'Authorization', 213 | }, 214 | ], 215 | }); 216 | }); 217 | }); 218 | -------------------------------------------------------------------------------- /processor/test/utils/mock-actions-data.ts: -------------------------------------------------------------------------------- 1 | import Stripe from 'stripe'; 2 | import { 3 | Type, 4 | TypeAddFieldDefinitionAction, 5 | TypeDraft, 6 | } from '@commercetools/platform-sdk/dist/declarations/src/generated/models/type'; 7 | import { 8 | CustomerSetCustomFieldAction, 9 | CustomerSetCustomTypeAction, 10 | Product, 11 | ProductType, 12 | } from '@commercetools/platform-sdk'; 13 | import { mockStripeCustomerId } from './mock-customer-data'; 14 | 15 | export const mock_Stripe_retrieveWebhookEnpoints_response: Stripe.Response = { 16 | id: 'we_11111', 17 | object: 'webhook_endpoint', 18 | api_version: null, 19 | application: null, 20 | created: 1718392528, 21 | description: null, 22 | enabled_events: [ 23 | 'charge.succeeded', 24 | 'charge.refunded', 25 | 'payment_intent.canceled', 26 | 'payment_intent.succeeded', 27 | 'payment_intent.payment_failed', 28 | 'payment_intent.requires_action', 29 | ], 30 | livemode: false, 31 | metadata: {}, 32 | status: 'enabled', 33 | url: 'https://myApp.com/stripe/webhooks', 34 | lastResponse: { 35 | headers: {}, 36 | requestId: '11111', 37 | statusCode: 201, 38 | }, 39 | }; 40 | 41 | export const mock_Stripe_updateWebhookEnpoints_response: Stripe.Response = { 42 | id: 'we_11111', 43 | object: 'webhook_endpoint', 44 | api_version: null, 45 | application: null, 46 | created: 1718392528, 47 | description: null, 48 | enabled_events: [ 49 | 'charge.succeeded', 50 | 'charge.refunded', 51 | 'payment_intent.canceled', 52 | 'payment_intent.succeeded', 53 | 'payment_intent.payment_failed', 54 | 'payment_intent.requires_action', 55 | ], 56 | livemode: false, 57 | metadata: {}, 58 | status: 'enabled', 59 | url: 'https://yourApp.com/stripe/webhooks', 60 | lastResponse: { 61 | headers: {}, 62 | requestId: '11111', 63 | statusCode: 201, 64 | }, 65 | }; 66 | 67 | export const mock_CustomType_withFieldDefinition: Type = { 68 | id: 'mock-type-id', 69 | version: 1, 70 | createdAt: '2023-01-01T00:00:00.000Z', 71 | lastModifiedAt: '2023-01-01T00:00:00.000Z', 72 | key: 'payment-connector-stripe-customer-id', 73 | name: { 74 | en: 'Stripe Customer ID', 75 | }, 76 | description: { 77 | en: 'Stores the Stripe Customer ID on a commercetools customer', 78 | }, 79 | resourceTypeIds: ['customer'], 80 | fieldDefinitions: [ 81 | { 82 | name: 'stripeConnector_stripeCustomerId', 83 | label: { 84 | en: 'Stripe Customer ID', 85 | }, 86 | required: false, 87 | type: { 88 | name: 'String', 89 | }, 90 | inputHint: 'SingleLine', 91 | }, 92 | ], 93 | }; 94 | 95 | export const mock_CustomType_withManyFieldDefinition: Type = { 96 | id: 'mock-type-id', 97 | version: 1, 98 | createdAt: '2023-01-01T00:00:00.000Z', 99 | lastModifiedAt: '2023-01-01T00:00:00.000Z', 100 | key: 'payment-connector-stripe-customer-id', 101 | name: { 102 | en: 'Stripe Customer ID', 103 | }, 104 | description: { 105 | en: 'Stores the Stripe Customer ID on a commercetools customer', 106 | }, 107 | resourceTypeIds: ['customer'], 108 | fieldDefinitions: [ 109 | { 110 | name: 'stripeConnector_stripeCustomerId', 111 | label: { 112 | en: 'Stripe Customer ID', 113 | }, 114 | required: false, 115 | type: { 116 | name: 'String', 117 | }, 118 | inputHint: 'SingleLine', 119 | }, 120 | { 121 | name: 'stripeConnector_stripeTest', 122 | label: { 123 | en: 'Stripe Test', 124 | }, 125 | required: false, 126 | type: { 127 | name: 'String', 128 | }, 129 | inputHint: 'SingleLine', 130 | }, 131 | ], 132 | }; 133 | 134 | export const mock_CustomType_withDifferentFieldDefinition: Type = { 135 | id: 'mock-type-id', 136 | version: 1, 137 | createdAt: '2023-01-01T00:00:00.000Z', 138 | lastModifiedAt: '2023-01-01T00:00:00.000Z', 139 | key: 'payment-connector-stripe-customer-id-different', 140 | name: { 141 | en: 'Stripe Customer ID', 142 | }, 143 | description: { 144 | en: 'Stores the Stripe Customer ID on a commercetools customer', 145 | }, 146 | resourceTypeIds: ['customer'], 147 | fieldDefinitions: [ 148 | { 149 | name: 'stripeConnector_stripeTest', 150 | label: { 151 | en: 'Stripe Test', 152 | }, 153 | required: false, 154 | type: { 155 | name: 'String', 156 | }, 157 | inputHint: 'SingleLine', 158 | }, 159 | ], 160 | }; 161 | 162 | export const mock_CustomType_withNoFieldDefinition: Type = { 163 | id: 'mock-type-id', 164 | version: 1, 165 | createdAt: '2023-01-01T00:00:00.000Z', 166 | lastModifiedAt: '2023-01-01T00:00:00.000Z', 167 | key: 'payment-connector-stripe-customer-id', 168 | name: { 169 | en: 'Stripe Customer ID', 170 | }, 171 | description: { 172 | en: 'Stores the Stripe Customer ID on a commercetools customer', 173 | }, 174 | resourceTypeIds: ['customer'], 175 | fieldDefinitions: [], 176 | }; 177 | 178 | export const mock_CustomTypeDraft: TypeDraft = { 179 | key: 'payment-connector-stripe-customer-id', 180 | name: { 181 | en: 'Stripe Customer ID', 182 | }, 183 | description: { 184 | en: 'Stores the Stripe Customer ID on a commercetools customer', 185 | }, 186 | resourceTypeIds: ['customer'], 187 | fieldDefinitions: [ 188 | { 189 | name: 'stripeConnector_stripeCustomerId', 190 | label: { 191 | en: 'Stripe Customer ID', 192 | }, 193 | required: false, 194 | type: { 195 | name: 'String', 196 | }, 197 | inputHint: 'SingleLine', 198 | }, 199 | ], 200 | }; 201 | 202 | //mock the get types for fot launchpad purchase order number 203 | export const mock_CustomType_withLaunchpadPurchaseOrderNumber: Type = { 204 | id: 'mock-type-id', 205 | key: 'payment-launchpad-purchase-order', 206 | version: 1, 207 | name: { 208 | en: 'Mock Launchpad Purchase Order Number Custom Type', 209 | }, 210 | description: { 211 | en: 'Mock description for the custom type.', 212 | }, 213 | fieldDefinitions: [ 214 | { 215 | name: 'mockField', 216 | label: { 217 | en: 'Mock Field', 218 | }, 219 | type: { 220 | name: 'String', 221 | }, 222 | required: false, 223 | }, 224 | ], 225 | createdAt: '2023-01-01T00:00:00.000Z', 226 | lastModifiedAt: '2023-01-01T00:00:00.000Z', 227 | resourceTypeIds: ['customer'], 228 | }; 229 | 230 | export const mock_SetCustomTypeActions: CustomerSetCustomTypeAction[] = [ 231 | { 232 | action: 'setCustomType', 233 | type: { 234 | typeId: 'type', 235 | key: 'payment-connector-stripe-customer-id', 236 | }, 237 | fields: { 238 | stripeConnector_stripeCustomerId: mockStripeCustomerId, 239 | }, 240 | }, 241 | ]; 242 | 243 | export const mock_SetCustomFieldActions: CustomerSetCustomFieldAction[] = [ 244 | { 245 | action: 'setCustomField', 246 | name: 'stripeConnector_stripeCustomerId', 247 | value: mockStripeCustomerId, 248 | }, 249 | ]; 250 | 251 | export const mock_AddFieldDefinitionActions: TypeAddFieldDefinitionAction[] = [ 252 | { 253 | action: 'addFieldDefinition', 254 | fieldDefinition: mock_CustomType_withFieldDefinition.fieldDefinitions[0], 255 | }, 256 | ]; 257 | 258 | export const mock_ProductType: ProductType = { 259 | id: 'mock-product-type-id', 260 | version: 1, 261 | name: 'Mock Product Type', 262 | key: 'mock', 263 | description: 'Mock description', 264 | attributes: [], 265 | createdAt: '2025-01-01T00:00:00.000Z', 266 | lastModifiedAt: '2025-01-01T00:00:00.000Z', 267 | }; 268 | 269 | export const mock_Product = { 270 | id: 'mock-product-id', 271 | key: 'subscription-test', 272 | version: 32, 273 | createdAt: '2025-03-31T22:18:38.763Z', 274 | lastModifiedAt: '2025-04-21T17:22:40.009Z', 275 | productType: { 276 | typeId: 'product-type', 277 | id: 'mock-product-type-id', 278 | }, 279 | masterData: {}, 280 | } as Product; 281 | -------------------------------------------------------------------------------- /processor/src/routes/stripe-payment.route.ts: -------------------------------------------------------------------------------- 1 | import Stripe from 'stripe'; 2 | import { SessionHeaderAuthenticationHook } from '@commercetools/connect-payments-sdk'; 3 | import { FastifyInstance, FastifyPluginOptions } from 'fastify'; 4 | import { 5 | ConfigElementResponseSchema, 6 | ConfigElementResponseSchemaDTO, 7 | CustomerResponseSchema, 8 | CustomerResponseSchemaDTO, 9 | PaymentResponseSchema, 10 | PaymentResponseSchemaDTO, 11 | } from '../dtos/stripe-payment.dto'; 12 | import { log } from '../libs/logger'; 13 | import { stripeApi } from '../clients/stripe.client'; 14 | import { StripePaymentService } from '../services/stripe-payment.service'; 15 | import { StripeHeaderAuthHook } from '../libs/fastify/hooks/stripe-header-auth.hook'; 16 | import { Type } from '@sinclair/typebox'; 17 | import { getConfig } from '../config/config'; 18 | import { 19 | PaymentIntenConfirmRequestSchemaDTO, 20 | PaymentIntentConfirmRequestSchema, 21 | PaymentIntentConfirmResponseSchemaDTO, 22 | PaymentIntentResponseSchema, 23 | PaymentModificationStatus, 24 | } from '../dtos/operations/payment-intents.dto'; 25 | import { StripeEvent } from '../services/types/stripe-payment.type'; 26 | 27 | type PaymentRoutesOptions = { 28 | paymentService: StripePaymentService; 29 | sessionHeaderAuthHook: SessionHeaderAuthenticationHook; 30 | }; 31 | 32 | type StripeRoutesOptions = { 33 | paymentService: StripePaymentService; 34 | stripeHeaderAuthHook: StripeHeaderAuthHook; 35 | }; 36 | 37 | export const customerRoutes = async (fastify: FastifyInstance, opts: FastifyPluginOptions & PaymentRoutesOptions) => { 38 | fastify.get<{ Reply: CustomerResponseSchemaDTO }>( 39 | '/customer/session', 40 | { 41 | preHandler: [opts.sessionHeaderAuthHook.authenticate()], 42 | schema: { 43 | response: { 44 | 200: CustomerResponseSchema, 45 | 204: Type.Null(), 46 | }, 47 | }, 48 | }, 49 | async (_, reply) => { 50 | const resp = await opts.paymentService.getCustomerSession(); 51 | if (!resp) { 52 | return reply.status(204).send(resp); 53 | } 54 | return reply.status(200).send(resp); 55 | }, 56 | ); 57 | }; 58 | 59 | /** 60 | * MVP if additional information needs to be included in the payment intent, this method should be supplied with the necessary data. 61 | * 62 | */ 63 | export const paymentRoutes = async (fastify: FastifyInstance, opts: FastifyPluginOptions & PaymentRoutesOptions) => { 64 | fastify.get<{ Reply: PaymentResponseSchemaDTO }>( 65 | '/payments', 66 | { 67 | preHandler: [opts.sessionHeaderAuthHook.authenticate()], 68 | schema: { 69 | response: { 70 | 200: PaymentResponseSchema, 71 | }, 72 | }, 73 | }, 74 | async (_, reply) => { 75 | const resp = await opts.paymentService.createPaymentIntentStripe(); 76 | return reply.status(200).send(resp); 77 | }, 78 | ); 79 | fastify.post<{ 80 | Body: PaymentIntenConfirmRequestSchemaDTO; 81 | Reply: PaymentIntentConfirmResponseSchemaDTO; 82 | Params: { id: string }; 83 | }>( 84 | '/confirmPayments/:id', 85 | { 86 | preHandler: [opts.sessionHeaderAuthHook.authenticate()], 87 | schema: { 88 | params: { 89 | $id: 'paramsSchema', 90 | type: 'object', 91 | properties: { 92 | id: Type.String(), 93 | }, 94 | required: ['id'], 95 | }, 96 | body: PaymentIntentConfirmRequestSchema, 97 | response: { 98 | 200: PaymentIntentResponseSchema, 99 | }, 100 | }, 101 | }, 102 | async (request, reply) => { 103 | const { id } = request.params; // paymentReference 104 | try { 105 | await opts.paymentService.updatePaymentIntentStripeSuccessful(request.body.paymentIntent, id); 106 | 107 | return reply.status(200).send({ outcome: PaymentModificationStatus.APPROVED }); 108 | } catch (error) { 109 | return reply.status(400).send({ outcome: PaymentModificationStatus.REJECTED, error: JSON.stringify(error) }); 110 | } 111 | }, 112 | ); 113 | }; 114 | 115 | export const stripeWebhooksRoutes = async (fastify: FastifyInstance, opts: StripeRoutesOptions) => { 116 | fastify.post<{ Body: string }>( 117 | '/stripe/webhooks', 118 | { 119 | preHandler: [opts.stripeHeaderAuthHook.authenticate()], 120 | config: { rawBody: true }, 121 | }, 122 | async (request, reply) => { 123 | const signature = request.headers['stripe-signature'] as string; 124 | 125 | let event: Stripe.Event; 126 | 127 | try { 128 | event = await stripeApi().webhooks.constructEvent( 129 | request.rawBody as string, 130 | signature, 131 | getConfig().stripeWebhookSigningSecret, 132 | ); 133 | } catch (error) { 134 | const err = error as Error; 135 | log.error(JSON.stringify(err)); 136 | return reply.status(400).send(`Webhook Error: ${err.message}`); 137 | } 138 | 139 | switch (event.type) { 140 | case StripeEvent.PAYMENT_INTENT__SUCCEEDED: 141 | case StripeEvent.CHARGE__SUCCEEDED: 142 | log.info(`Received: ${event.type} event of ${event.data.object.id}`); 143 | await opts.paymentService.processStripeEvent(event); 144 | // Stores payment method in commercetools if customer opted-in during checkout 145 | await opts.paymentService.storePaymentMethod(event); 146 | break; 147 | case StripeEvent.PAYMENT_INTENT__CANCELED: 148 | case StripeEvent.PAYMENT_INTENT__REQUIRED_ACTION: 149 | case StripeEvent.PAYMENT_INTENT__PAYMENT_FAILED: 150 | log.info(`Received: ${event.type} event of ${event.data.object.id}`); 151 | await opts.paymentService.processStripeEvent(event); 152 | break; 153 | case StripeEvent.CHARGE__REFUNDED: 154 | if (getConfig().stripeEnableMultiOperations) { 155 | log.info(`Processing Stripe multirefund event with enhanced tracking: ${event.type}`); 156 | await opts.paymentService.processStripeEventRefunded(event); 157 | } else { 158 | log.info(`Processing Stripe refund event with basic tracking (multi-operations disabled): ${event.type}`); 159 | await opts.paymentService.processStripeEvent(event); 160 | } 161 | break; 162 | case StripeEvent.CHARGE__UPDATED: 163 | if (getConfig().stripeEnableMultiOperations) { 164 | log.info(`Processing Stripe multicapture event: ${event.type}`); 165 | await opts.paymentService.processStripeEventMultipleCaptured(event); 166 | } else { 167 | log.info(`Multi-operations disabled, skipping multicapture: ${event.type}`); 168 | } 169 | break; 170 | default: 171 | log.info(`--->>> This Stripe event is not supported: ${event.type}`); 172 | break; 173 | } 174 | 175 | return reply.status(200).send(); 176 | }, 177 | ); 178 | }; 179 | 180 | export const configElementRoutes = async ( 181 | fastify: FastifyInstance, 182 | opts: FastifyPluginOptions & PaymentRoutesOptions, 183 | ) => { 184 | fastify.get<{ Reply: ConfigElementResponseSchemaDTO; Params: { paymentComponent: string } }>( 185 | '/config-element/:paymentComponent', 186 | { 187 | preHandler: [opts.sessionHeaderAuthHook.authenticate()], 188 | schema: { 189 | params: { 190 | $id: 'paramsSchema', 191 | type: 'object', 192 | properties: { 193 | paymentComponent: Type.String(), 194 | }, 195 | required: ['paymentComponent'], 196 | }, 197 | response: { 198 | 200: ConfigElementResponseSchema, 199 | }, 200 | }, 201 | }, 202 | async (request, reply) => { 203 | const { paymentComponent } = request.params; 204 | const resp = await opts.paymentService.initializeCartPayment(paymentComponent); 205 | 206 | return reply.status(200).send(resp); 207 | }, 208 | ); 209 | fastify.get<{ Reply: string }>('/applePayConfig', async (request, reply) => { 210 | const resp = opts.paymentService.applePayConfig(); 211 | return reply.status(200).send(resp); 212 | }); 213 | }; 214 | -------------------------------------------------------------------------------- /enabler/src/payment-enabler/payment-enabler.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Represents the payment enabler. The payment enabler is the entry point for creating the components. 3 | * 4 | * Usage: 5 | * const enabler = new Enabler({ 6 | * processorUrl: __VITE_PROCESSOR_URL__, 7 | * sessionId: sessionId, 8 | * config: { 9 | * 10 | * }, 11 | * onComplete: ({ isSuccess, paymentReference }) => { 12 | * console.log('onComplete', { isSuccess, paymentReference }); 13 | * }, 14 | * }); 15 | * 16 | * enabler.createComponentBuilder('card') 17 | * .then(builder => { 18 | * const paymentElement = builder.build({ 19 | * showPayButton: false, 20 | * }); 21 | * paymentElement.mount('#card-component') 22 | * }) 23 | * 24 | * enabler.createComponentBuilder('invoice') 25 | * .then(builder => { 26 | * const paymentElement = builder.build({}); 27 | * paymentElement.mount('#invoice-component') 28 | * }) 29 | */ 30 | export interface PaymentEnabler { 31 | /** 32 | * Creates a payment component builder of the specified type. 33 | * @param type - The type of the payment component builder. 34 | * @returns A promise that resolves to the payment component builder. 35 | * @throws {Error} If the payment component builder cannot be created. 36 | */ 37 | createComponentBuilder: ( 38 | type: string 39 | ) => Promise; 40 | 41 | /** 42 | * Creates a payment drop-in builder of the specified type. 43 | * @param type - The type of the payment drop-in builder. 44 | * @returns A promise that resolves to the payment drop-in builder. 45 | * @throws {Error} If the payment drop-in builder cannot be created. 46 | */ 47 | createDropinBuilder: ( 48 | type: DropinType 49 | ) => Promise; 50 | } 51 | 52 | /** 53 | * Represents the interface for a payment component. 54 | */ 55 | export interface PaymentComponent { 56 | /** 57 | * Mounts the payment component to the specified selector. 58 | * @param selector - The selector where the component will be mounted. 59 | */ 60 | mount(selector: string): void; 61 | 62 | /** 63 | * Submits the payment. 64 | */ 65 | submit(): void; 66 | 67 | /** 68 | * Shows the validation for the payment component. 69 | */ 70 | showValidation?(): void; 71 | 72 | /** 73 | * Checks if the payment component is valid. 74 | * @returns A boolean indicating whether the payment component is valid. 75 | */ 76 | isValid?(): boolean; 77 | 78 | /** 79 | * Gets the state of the payment component. 80 | * @returns An object representing the state of the payment component. 81 | */ 82 | getState?(): { 83 | card?: { 84 | endDigits?: string; 85 | brand?: string; 86 | expiryDate?: string; 87 | }; 88 | }; 89 | 90 | /** 91 | * Checks if the payment component is available for use. 92 | * @returns A promise that resolves to a boolean indicating whether the payment component is available. 93 | */ 94 | isAvailable?(): Promise; 95 | } 96 | 97 | /** 98 | * Represents the interface for a payment component builder. 99 | */ 100 | export interface PaymentComponentBuilder { 101 | /** 102 | * Indicates whether the component has a submit action. 103 | */ 104 | componentHasSubmit?: boolean; 105 | 106 | /** 107 | * Builds a payment component with the specified configuration. 108 | * @param config - The configuration options for the payment component. 109 | * @returns The built payment component. 110 | */ 111 | build(config: ComponentOptions): PaymentComponent; 112 | } 113 | 114 | /** 115 | * Represents the options for the payment enabler. 116 | */ 117 | export type EnablerOptions = { 118 | /** 119 | * The URL of the payment processor. 120 | */ 121 | processorUrl: string; 122 | 123 | /** 124 | * The session ID for the payment. 125 | */ 126 | sessionId: string; 127 | 128 | /** 129 | * The locale for the payment. 130 | */ 131 | locale?: string; 132 | 133 | /** 134 | * A callback function that is called when an action is required during the payment process. 135 | * @returns A promise that resolves when the action is completed. 136 | */ 137 | onActionRequired?: () => Promise; 138 | 139 | /** 140 | * A callback function that is called when the payment is completed. 141 | * @param result - The result of the payment. 142 | */ 143 | onComplete?: (result: PaymentResult) => void; 144 | 145 | /** 146 | * A callback function that is called when an error occurs during the payment process. 147 | * @param error - The error that occurred. 148 | */ 149 | onError?: (error: any) => void; 150 | }; 151 | 152 | /** 153 | * Represents the payment method code. 154 | */ 155 | export enum PaymentMethod { 156 | /* Apple Pay */ 157 | applepay = "applepay", 158 | /* Bancontact card */ 159 | bancontactcard = "bcmc", 160 | /* Card */ 161 | card = "card", 162 | /* EPS */ 163 | eps = "eps", 164 | /* Google Pay */ 165 | googlepay = "googlepay", 166 | /* iDeal */ 167 | ideal = "ideal", 168 | /* iDeal */ 169 | invoice = "invoice", 170 | /* Klarna Pay Later */ 171 | klarna_pay_later = "klarna", 172 | /* Klarna Pay Now */ 173 | klarna_pay_now = "klarna_paynow", 174 | /* Klarna Pay Over Time */ 175 | klarna_pay_overtime = "klarna_account", 176 | /* PayPal */ 177 | paypal = "paypal", 178 | /* Purchase Order */ 179 | purchaseorder = "purchaseorder", 180 | /* TWINT */ 181 | twint = "twint", 182 | dropin = "dropin", 183 | } 184 | 185 | /** 186 | * Represents the result of a payment. 187 | */ 188 | export type PaymentResult = 189 | | { 190 | /** 191 | * Indicates whether the payment was successful. 192 | */ 193 | isSuccess: true; 194 | 195 | /** 196 | * The payment reference. 197 | */ 198 | paymentReference: string; 199 | } 200 | | { 201 | /** 202 | * Indicates whether the payment was unsuccessful. 203 | */ 204 | isSuccess: false; 205 | }; 206 | 207 | /** 208 | * Represents the options for a payment component. 209 | */ 210 | export type ComponentOptions = { 211 | /** 212 | * Indicates whether to show the pay button. 213 | */ 214 | showPayButton?: boolean; 215 | 216 | /** 217 | * A callback function that is called when the pay button is clicked. 218 | * @returns A Promise indicating whether the payment should proceed. 219 | */ 220 | onPayButtonClick?: () => Promise; 221 | }; 222 | 223 | /** 224 | * Represents the payment drop-in types. 225 | */ 226 | export enum DropinType { 227 | /* 228 | * The embedded drop-in type which is rendered within the page. 229 | */ 230 | embedded = "embedded", 231 | /* 232 | * The hosted payment page (HPP) drop-in type which redirects the user to a hosted payment page. 233 | */ 234 | hpp = "hpp", 235 | } 236 | 237 | /** 238 | * Represents the interface for a drop-in component. 239 | */ 240 | export interface DropinComponent { 241 | /** 242 | * Submits the drop-in component. 243 | */ 244 | submit(): void; 245 | 246 | /** 247 | * Mounts the drop-in component to the specified selector. 248 | * @param selector - The selector where the drop-in component will be mounted. 249 | */ 250 | mount(selector: string): void; 251 | } 252 | 253 | /** 254 | * Represents the options for a drop-in component. 255 | */ 256 | export type DropinOptions = { 257 | /** 258 | * Indicates whether to show the pay button. 259 | **/ 260 | showPayButton?: boolean; 261 | 262 | /** 263 | * A callback function that is called when the drop-in component is ready. 264 | * @returns A Promise indicating whether the drop-in component is ready. 265 | */ 266 | onDropinReady?: () => Promise; 267 | 268 | /** 269 | * A callback function that is called when the pay button is clicked. 270 | * @returns A Promise indicating whether the payment should proceed. 271 | */ 272 | onPayButtonClick?: () => Promise; 273 | }; 274 | 275 | /** 276 | * Represents the interface for a payment drop-in builder. 277 | */ 278 | export interface PaymentDropinBuilder { 279 | /** 280 | * Indicates whether the drop-in component has a submit action. 281 | */ 282 | dropinHasSubmit: boolean; 283 | 284 | /** 285 | * Builds a drop-in component with the specified configuration. 286 | * @param config - The configuration options for the drop-in component. 287 | * @returns The built drop-in component. 288 | */ 289 | build(config: DropinOptions): DropinComponent; 290 | } 291 | -------------------------------------------------------------------------------- /enabler/src/payment-enabler/payment-enabler-mock.ts: -------------------------------------------------------------------------------- 1 | import { 2 | DropinType, EnablerOptions, 3 | PaymentComponentBuilder, 4 | PaymentDropinBuilder, 5 | PaymentEnabler, PaymentResult, 6 | } from "./payment-enabler"; 7 | import { DropinEmbeddedBuilder } from "../dropin/dropin-embedded"; 8 | import { 9 | Appearance, 10 | LayoutObject, 11 | loadStripe, 12 | Stripe, 13 | StripeElements, 14 | StripePaymentElementOptions 15 | } from "@stripe/stripe-js"; 16 | import { StripePaymentElement } from "@stripe/stripe-js"; 17 | import { 18 | ConfigElementResponseSchemaDTO, 19 | ConfigResponseSchemaDTO, 20 | CustomerResponseSchemaDTO 21 | } from "../dtos/mock-payment.dto.ts"; 22 | import { parseJSON } from "../utils"; 23 | 24 | declare global { 25 | interface ImportMeta { 26 | // @ts-ignore 27 | env: any; 28 | } 29 | } 30 | 31 | export type BaseOptions = { 32 | sdk: Stripe; 33 | environment: string; 34 | processorUrl: string; 35 | sessionId: string; 36 | locale?: string; 37 | onComplete: (result: PaymentResult) => void; 38 | onError: (error?: any) => void; 39 | paymentElement: StripePaymentElement; // MVP https://docs.stripe.com/payments/payment-element 40 | elements: StripeElements; // MVP https://docs.stripe.com/js/elements_object 41 | stripeCustomerId?: string; 42 | }; 43 | 44 | interface ElementsOptions { 45 | type: string; 46 | options: Record; 47 | onComplete: (result: PaymentResult) => void; 48 | onError: (error?: any) => void; 49 | layout: LayoutObject; 50 | appearance: Appearance; 51 | fields: { 52 | billingDetails: { 53 | address: string; 54 | }; 55 | }; 56 | } 57 | 58 | export class MockPaymentEnabler implements PaymentEnabler { 59 | setupData: Promise<{ baseOptions: BaseOptions }>; 60 | 61 | constructor(options: EnablerOptions) { 62 | this.setupData = MockPaymentEnabler._Setup(options); 63 | } 64 | 65 | private static _Setup = async ( 66 | options: EnablerOptions 67 | ): Promise<{ baseOptions: BaseOptions }> => { 68 | const paymentMethodType : string = 'payment' 69 | const [cartInfoResponse, configEnvResponse] = await MockPaymentEnabler.fetchConfigData(paymentMethodType, options); 70 | const stripeSDK = await MockPaymentEnabler.getStripeSDK(configEnvResponse); 71 | const customer = await MockPaymentEnabler.getCustomerOptions(options); 72 | const elements = MockPaymentEnabler.getElements(stripeSDK, cartInfoResponse, customer); 73 | const elementsOptions = MockPaymentEnabler.getElementsOptions(options, cartInfoResponse); 74 | 75 | return Promise.resolve({ 76 | baseOptions: { 77 | sdk: stripeSDK, 78 | environment: configEnvResponse.publishableKey.includes("_test_") ? "test" : configEnvResponse.environment, // MVP do we get this from the env of processor? or we leave the responsability to the publishableKey from Stripe? 79 | processorUrl: options.processorUrl, 80 | sessionId: options.sessionId, 81 | onComplete: options.onComplete || (() => {}), 82 | onError: options.onError || (() => {}), 83 | paymentElement: elements.create('payment', elementsOptions as StripePaymentElementOptions ),// MVP this could be expressCheckout or payment for subscritpion. 84 | elements: elements, 85 | ...(customer && {stripeCustomerId: customer?.stripeCustomerId,}) 86 | }, 87 | }); 88 | }; 89 | 90 | async createComponentBuilder( 91 | type: string 92 | ): Promise { 93 | const { baseOptions } = await this.setupData; 94 | const supportedMethods = {}; 95 | 96 | if (!Object.keys(supportedMethods).includes(type)) { 97 | throw new Error( 98 | `Component type not supported: ${type}. Supported types: ${Object.keys( 99 | supportedMethods 100 | ).join(", ")}` 101 | ); 102 | } 103 | 104 | return new supportedMethods[type](baseOptions); 105 | } 106 | 107 | async createDropinBuilder( 108 | type: DropinType 109 | ): Promise { 110 | 111 | const setupData = await this.setupData; 112 | if (!setupData) { 113 | throw new Error("StripePaymentEnabler not initialized"); 114 | } 115 | const supportedMethods = { 116 | embedded: DropinEmbeddedBuilder, 117 | // hpp: DropinHppBuilder, 118 | }; 119 | 120 | if (!Object.keys(supportedMethods).includes(type)) { 121 | throw new Error( 122 | `Component type not supported: ${type}. Supported types: ${Object.keys( 123 | supportedMethods 124 | ).join(", ")}` 125 | ); 126 | } 127 | return new supportedMethods[type](setupData.baseOptions); 128 | } 129 | 130 | private static async getStripeSDK(configEnvResponse: ConfigResponseSchemaDTO): Promise { 131 | try { 132 | const sdk = await loadStripe(configEnvResponse.publishableKey); 133 | if (!sdk) throw new Error("Failed to load Stripe SDK."); 134 | return sdk; 135 | } catch (error) { 136 | console.error("Error loading Stripe SDK:", error); 137 | throw error; // or handle based on your requirements 138 | } 139 | } 140 | 141 | private static getElements( 142 | stripeSDK: Stripe | null, 143 | cartInfoResponse: ConfigElementResponseSchemaDTO, 144 | customer: CustomerResponseSchemaDTO 145 | ): StripeElements | null { 146 | if (!stripeSDK) return null; 147 | try { 148 | return stripeSDK.elements?.({ 149 | mode: 'payment', 150 | amount: cartInfoResponse.cartInfo.amount, 151 | currency: cartInfoResponse.cartInfo.currency.toLowerCase(), 152 | ...(customer && { 153 | customerOptions: { 154 | customer: customer.stripeCustomerId, 155 | ephemeralKey: customer.ephemeralKey, 156 | }, 157 | setupFutureUsage: cartInfoResponse.setupFutureUsage, 158 | customerSessionClientSecret: customer.sessionId, 159 | }), 160 | appearance: parseJSON(cartInfoResponse.appearance), 161 | capture_method: cartInfoResponse.captureMethod, 162 | }); 163 | } catch (error) { 164 | console.error("Error initializing elements:", error); 165 | return null; 166 | } 167 | } 168 | 169 | private static async fetchConfigData( 170 | paymentMethodType: string, options: EnablerOptions 171 | ): Promise<[ConfigElementResponseSchemaDTO, ConfigResponseSchemaDTO]> { 172 | const headers = MockPaymentEnabler.getFetchHeader(options); 173 | 174 | const [configElementResponse, configEnvResponse] = await Promise.all([ 175 | fetch(`${options.processorUrl}/config-element/${paymentMethodType}`, headers), // MVP this could be used by expressCheckout and Subscription 176 | fetch(`${options.processorUrl}/operations/config`, headers), 177 | ]); 178 | 179 | return Promise.all([configElementResponse.json(), configEnvResponse.json()]); 180 | } 181 | 182 | private static getFetchHeader(options: EnablerOptions): { method: string, headers: { [key: string]: string }} { 183 | return { 184 | method: "GET", 185 | headers: { 186 | "Content-Type": "application/json", 187 | "X-Session-Id": options.sessionId, 188 | }, 189 | } 190 | } 191 | 192 | private static getElementsOptions( 193 | options: EnablerOptions, 194 | config: ConfigElementResponseSchemaDTO 195 | ): ElementsOptions { 196 | const { appearance, layout, collectBillingAddress } = config; 197 | return { 198 | type: 'payment', 199 | options: {}, 200 | onComplete: options.onComplete, 201 | onError: options.onError, 202 | layout: this.getLayoutObject(layout), 203 | appearance: parseJSON(appearance), 204 | ...(collectBillingAddress !== 'auto' && { 205 | fields: { 206 | billingDetails: { 207 | address: collectBillingAddress, 208 | } 209 | } 210 | }), 211 | } 212 | } 213 | 214 | private static async getCustomerOptions(options: EnablerOptions): Promise { 215 | const headers = MockPaymentEnabler.getFetchHeader(options); 216 | const apiUrl = new URL(`${options.processorUrl}/customer/session`); 217 | const response = await fetch(apiUrl.toString(), headers); 218 | 219 | if (response.status === 204) { 220 | console.log("No Stripe customer session"); 221 | return undefined; 222 | } 223 | const data: CustomerResponseSchemaDTO = await response.json(); 224 | return data; 225 | } 226 | 227 | private static getLayoutObject(layout: string): LayoutObject { 228 | if (layout) { 229 | const parsedObject = parseJSON(layout); 230 | const isValid = this.validateLayoutObject(parsedObject); 231 | if (isValid) { 232 | return parsedObject; 233 | } 234 | } 235 | 236 | return { 237 | type: 'tabs', 238 | defaultCollapsed: false, 239 | }; 240 | } 241 | 242 | private static validateLayoutObject(layout: LayoutObject): boolean { 243 | if (!layout) return false; 244 | const validLayouts = ['tabs', 'accordion', 'auto']; 245 | return validLayouts.includes(layout.type); 246 | } 247 | } 248 | -------------------------------------------------------------------------------- /processor/test/routes.test/operations.spec.ts: -------------------------------------------------------------------------------- 1 | import fastify from 'fastify'; 2 | import { describe, beforeAll, afterAll, test, expect, jest, afterEach } from '@jest/globals'; 3 | import { 4 | AuthorityAuthorizationHook, 5 | AuthorityAuthorizationManager, 6 | CommercetoolsCartService, 7 | CommercetoolsOrderService, 8 | CommercetoolsPaymentMethodService, 9 | CommercetoolsPaymentService, 10 | ContextProvider, 11 | JWTAuthenticationHook, 12 | JWTAuthenticationManager, 13 | Oauth2AuthenticationHook, 14 | Oauth2AuthenticationManager, 15 | RequestContextData, 16 | SessionHeaderAuthenticationHook, 17 | SessionHeaderAuthenticationManager, 18 | } from '@commercetools/connect-payments-sdk'; 19 | import { IncomingHttpHeaders } from 'node:http'; 20 | import { operationsRoute } from '../../src/routes/operation.route'; 21 | import { StripePaymentService } from '../../src/services/stripe-payment.service'; 22 | import { mockRoute__paymentIntent_succeed, mockRoute__paymentsComponents_succeed } from '../utils/mock-routes-data'; 23 | import { appLogger } from '../../src/payment-sdk'; 24 | 25 | describe('/operations APIs', () => { 26 | const app = fastify({ logger: false }); 27 | const token = 'token'; 28 | const jwtToken = 'jwtToken'; 29 | const sessionId = 'session-id'; 30 | 31 | const spyAuthenticateJWT = jest 32 | .spyOn(JWTAuthenticationHook.prototype, 'authenticate') 33 | .mockImplementation(() => async (request: { headers: IncomingHttpHeaders }) => { 34 | expect(request.headers['authorization']).toContain(`Bearer ${jwtToken}`); 35 | }); 36 | 37 | const spyAuthenticateOauth2 = jest 38 | .spyOn(Oauth2AuthenticationHook.prototype, 'authenticate') 39 | .mockImplementation(() => async (request: { headers: IncomingHttpHeaders }) => { 40 | expect(request.headers['authorization']).toContain(`Bearer ${token}`); 41 | }); 42 | 43 | const spyAuthenticateSession = jest 44 | .spyOn(SessionHeaderAuthenticationHook.prototype, 'authenticate') 45 | .mockImplementationOnce(() => async (request: { headers: IncomingHttpHeaders }) => { 46 | expect(request.headers['x-session-id']).toContain('session-id'); 47 | }); 48 | 49 | const spyAuthenticateAuthority = jest 50 | .spyOn(AuthorityAuthorizationHook.prototype, 'authorize') 51 | .mockImplementation(() => async () => { 52 | expect('manage_project').toEqual('manage_project'); 53 | }); 54 | 55 | const spiedJwtAuthenticationHook = new JWTAuthenticationHook({ 56 | logger: appLogger, 57 | authenticationManager: jest.fn() as unknown as JWTAuthenticationManager, 58 | contextProvider: jest.fn() as unknown as ContextProvider, 59 | }); 60 | 61 | const spiedOauth2AuthenticationHook = new Oauth2AuthenticationHook({ 62 | logger: appLogger, 63 | authenticationManager: jest.fn() as unknown as Oauth2AuthenticationManager, 64 | contextProvider: jest.fn() as unknown as ContextProvider, 65 | }); 66 | 67 | const spiedSessionHeaderAuthenticationHook = new SessionHeaderAuthenticationHook({ 68 | logger: appLogger, 69 | authenticationManager: jest.fn() as unknown as SessionHeaderAuthenticationManager, 70 | contextProvider: jest.fn() as unknown as ContextProvider, 71 | }); 72 | 73 | const spiedAuthorityAuthorizationHook = new AuthorityAuthorizationHook({ 74 | logger: appLogger, 75 | authorizationManager: jest.fn() as unknown as AuthorityAuthorizationManager, 76 | contextProvider: jest.fn() as unknown as ContextProvider, 77 | }); 78 | 79 | const spiedPaymentService = new StripePaymentService({ 80 | ctCartService: jest.fn() as unknown as CommercetoolsCartService, 81 | ctPaymentService: jest.fn() as unknown as CommercetoolsPaymentService, 82 | ctOrderService: jest.fn() as unknown as CommercetoolsOrderService, 83 | ctPaymentMethodService: jest.fn() as unknown as CommercetoolsPaymentMethodService, 84 | }); 85 | 86 | beforeAll(async () => { 87 | await app.register(operationsRoute, { 88 | prefix: '/operations', 89 | oauth2AuthHook: spiedOauth2AuthenticationHook, 90 | jwtAuthHook: spiedJwtAuthenticationHook, 91 | sessionHeaderAuthHook: spiedSessionHeaderAuthenticationHook, 92 | authorizationHook: spiedAuthorityAuthorizationHook, 93 | paymentService: spiedPaymentService, 94 | }); 95 | }); 96 | 97 | afterEach(async () => { 98 | jest.clearAllMocks(); 99 | spyAuthenticateJWT.mockClear(); 100 | spyAuthenticateOauth2.mockClear(); 101 | spyAuthenticateSession.mockClear(); 102 | spyAuthenticateAuthority.mockClear(); 103 | await app.ready(); 104 | }); 105 | 106 | afterAll(async () => { 107 | await app.close(); 108 | }); 109 | 110 | describe('GET /operations/config', () => { 111 | test('it should return the Stripe client config', async () => { 112 | //When 113 | const responseGetConfig = await app.inject({ 114 | method: 'GET', 115 | url: `/operations/config`, 116 | headers: { 117 | 'x-session-id': sessionId, 118 | 'content-type': 'application/json', 119 | }, 120 | }); 121 | 122 | //Then 123 | expect(responseGetConfig.statusCode).toEqual(200); 124 | expect(responseGetConfig.json()).toEqual({ 125 | environment: 'TEST', 126 | publishableKey: '', 127 | }); 128 | }); 129 | }); 130 | 131 | describe('GET /operations/status', () => { 132 | test('it should return the status of the connector', async () => { 133 | //Given 134 | jest.spyOn(spiedPaymentService, 'status').mockResolvedValue({ 135 | metadata: { 136 | name: 'payment-integration-stripe', 137 | description: 'Payment integration with Stripe', 138 | }, 139 | version: '1.0.0', 140 | timestamp: '2024-01-01T00:00:00Z', 141 | status: 'UP', 142 | checks: [ 143 | { 144 | name: 'CoCo Permissions', 145 | status: 'UP', 146 | }, 147 | { 148 | name: 'Stripe Status check', 149 | status: 'UP', 150 | }, 151 | ], 152 | }); 153 | 154 | //When 155 | const responseGetStatus = await app.inject({ 156 | method: 'GET', 157 | url: `/operations/status`, 158 | headers: { 159 | authorization: `Bearer ${jwtToken}`, 160 | 'content-type': 'application/json', 161 | }, 162 | }); 163 | 164 | //Then 165 | expect(responseGetStatus.statusCode).toEqual(200); 166 | expect(responseGetStatus.json()).toEqual( 167 | expect.objectContaining({ 168 | metadata: expect.any(Object), 169 | status: 'UP', 170 | timestamp: expect.any(String), 171 | version: '1.0.0', 172 | checks: expect.arrayContaining([ 173 | expect.objectContaining({ 174 | name: 'CoCo Permissions', 175 | status: 'UP', 176 | }), 177 | expect.objectContaining({ 178 | name: 'Stripe Status check', 179 | status: 'UP', 180 | }), 181 | ]), 182 | }), 183 | ); 184 | }); 185 | }); 186 | 187 | describe('GET /payment-components', () => { 188 | test('it should return the supported payment components ', async () => { 189 | //Given 190 | jest 191 | .spyOn(spiedPaymentService, 'getSupportedPaymentComponents') 192 | .mockResolvedValue(mockRoute__paymentsComponents_succeed); 193 | 194 | //When 195 | const responseGetStatus = await app.inject({ 196 | method: 'GET', 197 | url: `/operations/payment-components`, 198 | headers: { 199 | authorization: `Bearer ${jwtToken}`, 200 | 'content-type': 'application/json', 201 | }, 202 | }); 203 | 204 | //Then 205 | expect(responseGetStatus.statusCode).toEqual(200); 206 | expect(responseGetStatus.json()).toEqual(mockRoute__paymentsComponents_succeed); 207 | }); 208 | }); 209 | 210 | describe('POST /payment-intents/:id', () => { 211 | test('it should return the payment intent capturePayment response ', async () => { 212 | //Given 213 | const optsMock = { 214 | actions: [ 215 | { 216 | action: 'capturePayment', 217 | amount: { 218 | centAmount: 1000, 219 | currencyCode: 'USD', 220 | }, 221 | }, 222 | ], 223 | }; 224 | jest.spyOn(spiedPaymentService, 'modifyPayment').mockResolvedValue(mockRoute__paymentIntent_succeed); 225 | 226 | //When 227 | const responseGetStatus = await app.inject({ 228 | method: 'POST', 229 | url: `/operations/payment-intents/`, 230 | headers: { 231 | authorization: `Bearer ${token}`, 232 | 'content-type': 'application/json', 233 | }, 234 | body: optsMock, 235 | }); 236 | 237 | //Then 238 | expect(responseGetStatus.statusCode).toEqual(200); 239 | expect(responseGetStatus.json()).toEqual(mockRoute__paymentIntent_succeed); 240 | }); 241 | }); 242 | }); 243 | --------------------------------------------------------------------------------