├── order-syncer ├── .eslintignore ├── src │ ├── utils │ │ ├── logger.util.js │ │ ├── decoder.util.js │ │ ├── config.util.js │ │ └── async-logger.utils.js │ ├── constants │ │ ├── http.status.constants.js │ │ └── connectors.constants.js │ ├── routes │ │ └── sync.route.js │ ├── extensions │ │ └── stripe │ │ │ ├── configurations │ │ │ └── config.js │ │ │ └── clients │ │ │ └── client.js │ ├── errors │ │ └── custom.error.js │ ├── middlewares │ │ ├── http.middleware.js │ │ ├── auth.middleware.js │ │ └── error.middleware.js │ ├── clients │ │ ├── build.client.js │ │ ├── create.client.js │ │ ├── update.client.js │ │ └── query.client.js │ ├── index.js │ ├── connectors │ │ ├── pre-undeploy.js │ │ ├── customTypes.js │ │ └── post-deploy.js │ ├── validators │ │ ├── order-change.validators.js │ │ ├── env-var.validator.js │ │ └── helpers.validator.js │ └── controllers │ │ └── sync.controller.js ├── babel.config.cjs ├── docs │ └── images │ │ └── order-syncer.architecture.png ├── .prettierrc ├── jest.config.integration.js ├── test │ ├── integration │ │ ├── utils │ │ │ ├── encoder.utils.js │ │ │ └── encoder.utils.spec.js │ │ └── sync.route.spec.js │ └── unit │ │ ├── extensions │ │ └── stripe │ │ │ └── configurations │ │ │ └── config.spec.js │ │ ├── middlewares │ │ ├── http.middleware.spec.js │ │ ├── auth.middleware.spec.js │ │ └── error.middleware.spec.js │ │ ├── utils │ │ ├── decoder.util.spec.js │ │ └── config.util.spec.js │ │ ├── clients │ │ ├── build.client.spec.js │ │ ├── create.client.spec.js │ │ ├── query.client.spec.js │ │ └── update.client.spec.js │ │ └── validators │ │ └── order-change.validators.spec.js ├── jest.config.unit.cjs ├── .eslintrc.cjs ├── .env.example └── package.json ├── tax-calculator ├── .eslintignore ├── src │ ├── utils │ │ ├── logger.utils.js │ │ └── config.util.js │ ├── constants │ │ ├── http.status.constants.js │ │ └── tax-behavior.constants.js │ ├── connectors │ │ ├── constants.js │ │ ├── pre-undeploy.js │ │ ├── post-deploy.js │ │ └── customTypes.js │ ├── routes │ │ ├── tax.calculator.route.js │ │ └── address.validation.route.js │ ├── errors │ │ ├── custom.error.js │ │ ├── shipFromNotFoundError.js │ │ ├── taxCodeNotFound.error.js │ │ ├── missingTaxRateForCountry.error.js │ │ └── taxCodeShippingNotFound.error.js │ ├── middlewares │ │ ├── http.middleware.js │ │ ├── auth.middleware.js │ │ ├── error.middleware.js │ │ └── rate.limiter.middleware.js │ ├── clients │ │ ├── stripe.client.js │ │ ├── build.client.js │ │ └── create.client.js │ ├── index.js │ ├── validators │ │ ├── env-var.validators.js │ │ ├── stripeTaxValidator.js │ │ └── helpers.validators.js │ ├── controllers │ │ ├── tax.calculator.controller.js │ │ └── address.validation.controller.js │ ├── config │ │ └── taxCodeMapping.config.js │ └── services │ │ ├── tax-behavior.service.js │ │ └── tax-error-handler.service.js ├── babel.config.cjs ├── .prettierrc ├── jest.config.integration.js ├── jest.config.unit.cjs ├── .eslintrc.cjs ├── resources │ └── api-extension.json ├── test │ └── unit │ │ ├── errors │ │ ├── custom.error.spec.js │ │ ├── shipFromNotFoundError.spec.js │ │ ├── taxCodeNotFound.error.spec.js │ │ └── missingTaxRateForCountry.error.spec.js │ │ ├── controllers │ │ ├── tax-calculator.controller.spec.js │ │ └── address.validation.controller.spec.js │ │ ├── middlewares │ │ ├── auth.middleware.spec.js │ │ └── rate.limiter.middleware.spec.js │ │ ├── connectors │ │ └── pre-undeploy.spec.js │ │ ├── routes │ │ └── address.validation.route.spec.js │ │ └── utils │ │ └── config.util.spec.js ├── package.json └── .env.example ├── .gitignore ├── LICENSE └── connect.yaml /order-syncer/.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules -------------------------------------------------------------------------------- /tax-calculator/.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | /src/connectors/action.js -------------------------------------------------------------------------------- /order-syncer/src/utils/logger.util.js: -------------------------------------------------------------------------------- 1 | export { logger } from './async-logger.utils.js'; 2 | -------------------------------------------------------------------------------- /order-syncer/babel.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [['@babel/preset-env', {targets: {node: 'current'}}]], 3 | }; -------------------------------------------------------------------------------- /order-syncer/docs/images/order-syncer.architecture.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stripe/stripe-commercetools-tax-app/tax/order-syncer/docs/images/order-syncer.architecture.png -------------------------------------------------------------------------------- /tax-calculator/src/utils/logger.utils.js: -------------------------------------------------------------------------------- 1 | import { createApplicationLogger } from '@commercetools-backend/loggers'; 2 | 3 | export const logger = createApplicationLogger(); 4 | -------------------------------------------------------------------------------- /order-syncer/src/constants/http.status.constants.js: -------------------------------------------------------------------------------- 1 | export const HTTP_STATUS_SUCCESS_ACCEPTED = 202; 2 | export const HTTP_STATUS_SUCCESS_NO_CONTENT = 204; 3 | export const HTTP_STATUS_SERVER_ERROR = 500; 4 | -------------------------------------------------------------------------------- /tax-calculator/babel.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [['@babel/preset-env', {targets: {node: 'current'}}]], 3 | plugins: [ 4 | '@babel/plugin-syntax-import-assertions' 5 | ] 6 | }; -------------------------------------------------------------------------------- /tax-calculator/src/constants/http.status.constants.js: -------------------------------------------------------------------------------- 1 | export const HTTP_STATUS_SUCCESS_ACCEPTED = 200; 2 | 3 | export const HTTP_STATUS_BAD_REQUEST = 400; 4 | 5 | export const HTTP_STATUS_SERVER_ERROR = 500; 6 | -------------------------------------------------------------------------------- /order-syncer/src/routes/sync.route.js: -------------------------------------------------------------------------------- 1 | import { Router } from 'express'; 2 | 3 | import { syncHandler } from '../controllers/sync.controller.js'; 4 | 5 | const syncRouter = Router(); 6 | 7 | syncRouter.post('/', syncHandler); 8 | 9 | export default syncRouter; 10 | -------------------------------------------------------------------------------- /order-syncer/src/constants/connectors.constants.js: -------------------------------------------------------------------------------- 1 | export const MESSAGE_TYPE = ['OrderCreated']; 2 | export const NOTIFICATION_TYPE_RESOURCE_CREATED = 'ResourceCreated'; 3 | export const CTP_ORDER_CHANGE_SUBSCRIPTION_KEY = 4 | 'ct-connect-tax-integration-order-change-subscription'; 5 | -------------------------------------------------------------------------------- /order-syncer/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "es5", 3 | "singleQuote": true, 4 | "parser": "babel", 5 | "overrides": [ 6 | { 7 | "files": "*.json", 8 | "options": { 9 | "parser": "json" 10 | } 11 | } 12 | ] 13 | } -------------------------------------------------------------------------------- /tax-calculator/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "es5", 3 | "singleQuote": true, 4 | "parser": "babel", 5 | "overrides": [ 6 | { 7 | "files": "*.json", 8 | "options": { 9 | "parser": "json" 10 | } 11 | } 12 | ] 13 | } -------------------------------------------------------------------------------- /tax-calculator/src/connectors/constants.js: -------------------------------------------------------------------------------- 1 | export const CONNECT_SERVICE_URL = 'CONNECT_SERVICE_URL'; 2 | export const CTP_TAX_CALCULATOR_EXTENSION_KEY = 'ctpTaxCalculatorExtension'; 3 | export const STRIPE_API_TOKEN = 'STRIPE_API_TOKEN'; 4 | export const TAX_CODE_CATEGORY_MAPPING_JSON_KEY = 'TAX_CODE_CATEGORY_MAPPING_JSON'; -------------------------------------------------------------------------------- /tax-calculator/src/routes/tax.calculator.route.js: -------------------------------------------------------------------------------- 1 | import { Router } from 'express'; 2 | 3 | import { taxHandler } from '../controllers/tax.calculator.controller.js'; 4 | 5 | const taxCalculatorRouter = Router(); 6 | 7 | taxCalculatorRouter.post('/', taxHandler); 8 | 9 | export default taxCalculatorRouter; 10 | -------------------------------------------------------------------------------- /order-syncer/src/extensions/stripe/configurations/config.js: -------------------------------------------------------------------------------- 1 | export function loadConfig() { 2 | if (process.env.STRIPE_API_TOKEN) { 3 | return { 4 | taxProviderApiToken: process.env.STRIPE_API_TOKEN, 5 | }; 6 | } else { 7 | throw new Error('Tax provider API token is not provided.'); 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /order-syncer/jest.config.integration.js: -------------------------------------------------------------------------------- 1 | export default { 2 | displayName: 'Tests Javascript Application - Service', 3 | moduleDirectories: ['node_modules', 'src'], 4 | testMatch: ['**/tests/integration/**/*.js?(x)', '**/?(*.)+(spec|test).js?(x)'], 5 | testEnvironment: 'node', 6 | verbose: true, 7 | silent: true, 8 | }; -------------------------------------------------------------------------------- /order-syncer/src/errors/custom.error.js: -------------------------------------------------------------------------------- 1 | class CustomError extends Error { 2 | constructor(statusCode, message, errors) { 3 | super(message); 4 | this.statusCode = statusCode; 5 | this.message = message; 6 | if (errors) { 7 | this.errors = errors; 8 | } 9 | } 10 | } 11 | export default CustomError; 12 | -------------------------------------------------------------------------------- /tax-calculator/jest.config.integration.js: -------------------------------------------------------------------------------- 1 | export default { 2 | displayName: 'Tests Javascript Application - Service', 3 | moduleDirectories: ['node_modules', 'src'], 4 | testMatch: ['**/tests/integration/**/*.js?(x)', '**/?(*.)+(spec|test).js?(x)'], 5 | testEnvironment: 'node', 6 | verbose: true, 7 | silent: true, 8 | }; -------------------------------------------------------------------------------- /tax-calculator/src/errors/custom.error.js: -------------------------------------------------------------------------------- 1 | class CustomError extends Error { 2 | constructor(statusCode, message, errors) { 3 | super(message); 4 | this.statusCode = statusCode; 5 | this.message = message; 6 | if (errors) { 7 | this.errors = errors; 8 | } 9 | } 10 | } 11 | export default CustomError; 12 | -------------------------------------------------------------------------------- /order-syncer/test/integration/utils/encoder.utils.js: -------------------------------------------------------------------------------- 1 | const encodeString = (message) => { 2 | const buff = Buffer.from(message); 3 | return buff.toString('base64').trim(); 4 | }; 5 | 6 | export const encodeJsonObject = (messageBody) => { 7 | const message = JSON.stringify(messageBody); 8 | return encodeString(message); 9 | }; 10 | -------------------------------------------------------------------------------- /order-syncer/jest.config.unit.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | displayName: 'Tests Javascript Application - Service', 3 | moduleDirectories: ['node_modules', 'src'], 4 | testMatch: ['**/tests/**/*.js?(x)', '**/?(*.)+(spec|test).js?(x)'], 5 | testEnvironment: 'node', 6 | verbose: true, 7 | silent: true, 8 | collectCoverage: true 9 | }; -------------------------------------------------------------------------------- /tax-calculator/jest.config.unit.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | displayName: 'Tests Javascript Application - Service', 3 | moduleDirectories: ['node_modules', 'src'], 4 | testMatch: ['**/tests/**/*.js?(x)', '**/?(*.)+(spec|test).js?(x)'], 5 | testEnvironment: 'node', 6 | verbose: true, 7 | silent: true, 8 | collectCoverage: true 9 | }; -------------------------------------------------------------------------------- /order-syncer/src/middlewares/http.middleware.js: -------------------------------------------------------------------------------- 1 | import readConfiguration from '../utils/config.util.js'; 2 | 3 | /** 4 | * Configure Middleware. Example only. Adapt on your own 5 | */ 6 | 7 | export const getHttpMiddlewareOptions = () => { 8 | return { 9 | host: `https://api.${readConfiguration().region}.commercetools.com`, 10 | }; 11 | }; 12 | -------------------------------------------------------------------------------- /tax-calculator/src/middlewares/http.middleware.js: -------------------------------------------------------------------------------- 1 | import configUtils from '../utils/config.util.js'; 2 | 3 | /** 4 | * Configure Middleware. Example only. Adapt on your own 5 | */ 6 | export const getHttpMiddlewareOptions = () => { 7 | return { 8 | host: `https://api.${ 9 | configUtils.readConfiguration().region 10 | }.commercetools.com`, 11 | }; 12 | }; 13 | -------------------------------------------------------------------------------- /tax-calculator/src/constants/tax-behavior.constants.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Tax behavior constants for Stripe Tax API 3 | * These are the only valid values accepted by Stripe's tax_behavior parameter 4 | */ 5 | 6 | export const TAX_BEHAVIOR_INCLUSIVE = 'inclusive'; 7 | export const TAX_BEHAVIOR_EXCLUSIVE = 'exclusive'; 8 | 9 | /** 10 | * Array of all valid tax behavior values 11 | */ 12 | export const VALID_TAX_BEHAVIORS = [TAX_BEHAVIOR_INCLUSIVE, TAX_BEHAVIOR_EXCLUSIVE]; 13 | -------------------------------------------------------------------------------- /order-syncer/.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | extends: ['eslint:recommended'], 4 | parserOptions: { 5 | ecmaVersion: 2020, 6 | sourceType: 'module', 7 | }, 8 | rules: { 9 | 'no-unused-vars': ['error', { argsIgnorePattern: '^_' }], 10 | 'no-undef': 'error', 11 | 'no-console': 'error', 12 | 'no-const-assign': 'error', 13 | }, 14 | env: { 15 | es6: true, 16 | jest: true, 17 | node: true, //adds things like process to global 18 | }, 19 | }; -------------------------------------------------------------------------------- /tax-calculator/.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | extends: ['eslint:recommended'], 4 | parserOptions: { 5 | ecmaVersion: 2020, 6 | sourceType: 'module', 7 | }, 8 | rules: { 9 | 'no-unused-vars': ['error', { argsIgnorePattern: '^_' }], 10 | 'no-undef': 'error', 11 | 'no-console': 'error', 12 | 'no-const-assign': 'error', 13 | }, 14 | env: { 15 | es6: true, 16 | jest: true, 17 | node: true, //adds things like process to global 18 | }, 19 | }; -------------------------------------------------------------------------------- /order-syncer/src/middlewares/auth.middleware.js: -------------------------------------------------------------------------------- 1 | import readConfiguration from '../utils/config.util.js'; 2 | 3 | /** 4 | * Configure Middleware. Example only. Adapt on your own 5 | */ 6 | 7 | export const getAuthMiddlewareOptions = () => { 8 | const config = readConfiguration(); 9 | return { 10 | host: `https://auth.${config.region}.commercetools.com`, 11 | projectKey: config.projectKey, 12 | credentials: { 13 | clientId: config.clientId, 14 | clientSecret: config.clientSecret, 15 | }, 16 | scopes: [config.scope ? config.scope : 'default'], 17 | }; 18 | }; 19 | -------------------------------------------------------------------------------- /tax-calculator/src/middlewares/auth.middleware.js: -------------------------------------------------------------------------------- 1 | import configUtils from '../utils/config.util.js'; 2 | 3 | /** 4 | * Configure Middleware. Example only. Adapt on your own 5 | */ 6 | export const getAuthMiddlewareOptions = () => { 7 | const config = configUtils.readConfiguration(); 8 | return { 9 | host: `https://auth.${config.region}.commercetools.com`, 10 | projectKey: config.projectKey, 11 | credentials: { 12 | clientId: config.clientId, 13 | clientSecret: config.clientSecret, 14 | }, 15 | scopes: [config.scope ? config.scope : 'default'], 16 | }; 17 | }; 18 | -------------------------------------------------------------------------------- /tax-calculator/src/clients/stripe.client.js: -------------------------------------------------------------------------------- 1 | import stripe from 'stripe'; 2 | import configUtils from '../utils/config.util.js'; 3 | 4 | /** 5 | * Create Stripe client instance 6 | * This client can be used to make API calls to Stripe 7 | * Uses singleton pattern to ensure only one instance is created 8 | */ 9 | export const createStripeClient = ((stripeInstance) => () => { 10 | if (stripeInstance) { 11 | return stripeInstance; 12 | } 13 | 14 | const stripeApiToken = configUtils.readConfiguration().stripeApiToken; 15 | stripeInstance = new stripe(stripeApiToken); 16 | 17 | return stripeInstance; 18 | })(); 19 | -------------------------------------------------------------------------------- /order-syncer/.env.example: -------------------------------------------------------------------------------- 1 | # G E N E R A L C O N F I G U R A T I O N 2 | # Commercetools configuration 3 | CTP_CLIENT_ID=your_24_character_client_id_here 4 | CTP_CLIENT_SECRET=your_32_character_client_secret_here 5 | CTP_PROJECT_KEY=your_project_key_here 6 | CTP_SCOPE=your_scope_here 7 | CTP_REGION=your_region_here 8 | 9 | # Stripe configuration 10 | STRIPE_API_TOKEN=your_STRIPE_API_TOKEN_here 11 | 12 | # Connect suscription configuration 13 | CONNECT_GCP_TOPIC_NAME=your_gcp_topic_name_here 14 | CONNECT_GCP_PROJECT_ID=your_gcp_project_id_here 15 | 16 | #C U S T O M T Y P E S C O N F I G U R A T I O N 17 | CUSTOM_TYPE_ORDER_KEY=connector-stripe-tax-calculation-reference -------------------------------------------------------------------------------- /order-syncer/src/utils/decoder.util.js: -------------------------------------------------------------------------------- 1 | import { HTTP_STATUS_SERVER_ERROR } from "../constants/http.status.constants.js"; 2 | import CustomError from "../errors/custom.error.js"; 3 | 4 | const decodeToString = (encodedMessageBody) => { 5 | const buff = Buffer.from(encodedMessageBody, 'base64'); 6 | return buff.toString().trim(); 7 | }; 8 | 9 | export const decodeToJson = (encodedMessageBody) => { 10 | try { 11 | const decodedString = decodeToString(encodedMessageBody); 12 | return JSON.parse(decodedString); 13 | } catch (error) { 14 | throw new CustomError(HTTP_STATUS_SERVER_ERROR, 'Invalid message format: unable to parse JSON'); 15 | } 16 | }; 17 | -------------------------------------------------------------------------------- /tax-calculator/resources/api-extension.json: -------------------------------------------------------------------------------- 1 | { 2 | "key": "${ctpTaxCalculatorExtensionKey}", 3 | "destination": { 4 | "type": "HTTP", 5 | "url": "${ctpExtensionBaseUrl}" 6 | }, 7 | "triggers": [ 8 | { 9 | "resourceTypeId": "cart", 10 | "actions": ["Update", "Create"], 11 | "condition": "taxMode=\"ExternalAmount\" AND lineItems is defined AND lineItems is not empty AND (shippingInfo is defined OR lineItems(shippingDetails is defined)) AND (taxMode has changed OR lineItems has changed OR shippingInfo has changed OR shippingAddress has changed OR shipping has changed OR itemShippingAddresses has changed)" 12 | } 13 | ], 14 | "timeoutInMs": 2000 15 | } -------------------------------------------------------------------------------- /order-syncer/src/middlewares/error.middleware.js: -------------------------------------------------------------------------------- 1 | import CustomError from '../errors/custom.error.js'; 2 | 3 | /** 4 | * Middleware for error handling 5 | * @param error The error object 6 | * @param req The express request 7 | * @param res The Express response 8 | * @param next 9 | * @returns 10 | */ 11 | export const errorMiddleware = (error, _req, res, _next) => { 12 | if (error instanceof CustomError) { 13 | if (typeof error.statusCode === 'number') { 14 | res.status(error.statusCode).json({ 15 | message: error.message, 16 | errors: error.errors, 17 | }); 18 | 19 | return; 20 | } 21 | } 22 | 23 | res.status(500).send('Internal server error'); 24 | }; 25 | -------------------------------------------------------------------------------- /tax-calculator/src/middlewares/error.middleware.js: -------------------------------------------------------------------------------- 1 | import CustomError from '../errors/custom.error.js'; 2 | 3 | /** 4 | * Middleware for error handling 5 | * @param error The error object 6 | * @param req The express request 7 | * @param res The Express response 8 | * @param next 9 | * @returns 10 | */ 11 | export const errorMiddleware = (error, _req, res, _next) => { 12 | if (error instanceof CustomError) { 13 | if (typeof error.statusCode === 'number') { 14 | res.status(error.statusCode).json({ 15 | message: error.message, 16 | errors: error.errors, 17 | }); 18 | 19 | return; 20 | } 21 | } 22 | 23 | res.status(500).send('Internal server error'); 24 | }; 25 | -------------------------------------------------------------------------------- /order-syncer/src/clients/build.client.js: -------------------------------------------------------------------------------- 1 | import { ClientBuilder } from '@commercetools/sdk-client-v2'; 2 | import { getAuthMiddlewareOptions } from '../middlewares/auth.middleware.js'; 3 | import { getHttpMiddlewareOptions } from '../middlewares/http.middleware.js'; 4 | import readConfiguration from '../utils/config.util.js'; 5 | 6 | /** 7 | * Create a new client builder. 8 | * This code creates a new client builder that can be used to make API calls 9 | */ 10 | export const createClient = () => 11 | new ClientBuilder() 12 | .withProjectKey(readConfiguration().projectKey) 13 | .withClientCredentialsFlow(getAuthMiddlewareOptions()) 14 | .withHttpMiddleware(getHttpMiddlewareOptions()) 15 | .build(); 16 | -------------------------------------------------------------------------------- /tax-calculator/src/clients/build.client.js: -------------------------------------------------------------------------------- 1 | import { ClientBuilder } from '@commercetools/sdk-client-v2'; 2 | import { getAuthMiddlewareOptions } from '../middlewares/auth.middleware.js'; 3 | import { getHttpMiddlewareOptions } from '../middlewares/http.middleware.js'; 4 | import configUtils from '../utils/config.util.js'; 5 | 6 | /** 7 | * Create a new client builder. 8 | * This code creates a new client builder that can be used to make API calls 9 | */ 10 | export const createClient = () => 11 | new ClientBuilder() 12 | .withProjectKey(configUtils.readConfiguration().projectKey) 13 | .withClientCredentialsFlow(getAuthMiddlewareOptions()) 14 | .withHttpMiddleware(getHttpMiddlewareOptions()) 15 | .build(); 16 | -------------------------------------------------------------------------------- /order-syncer/src/index.js: -------------------------------------------------------------------------------- 1 | import 'dotenv/config'; 2 | 3 | import express from 'express'; 4 | import bodyParser from 'body-parser'; 5 | 6 | // Import routes 7 | import SyncRoutes from './routes/sync.route.js'; 8 | import { logger } from './utils/logger.util.js'; 9 | 10 | const PORT = 8080; 11 | 12 | // Create the express app 13 | const app = express(); 14 | 15 | // Define configurations 16 | app.use(bodyParser.json()); 17 | app.use(bodyParser.urlencoded({ extended: true })); 18 | 19 | // Define routes 20 | // TODO: Give a specific route name 21 | app.use('/orderSyncer', SyncRoutes); 22 | 23 | // Listen the application 24 | const server = app.listen(PORT, () => { 25 | logger.info(`Order Syncer service listening on port ${PORT}`); 26 | }); 27 | 28 | export default server; 29 | -------------------------------------------------------------------------------- /tax-calculator/src/routes/address.validation.route.js: -------------------------------------------------------------------------------- 1 | import { Router } from 'express'; 2 | import { validateAddressHandler } from '../controllers/address.validation.controller.js'; 3 | import { rateLimiterMiddleware } from '../middlewares/rate.limiter.middleware.js'; 4 | 5 | const addressValidationRouter = Router(); 6 | 7 | // Rate limiting configuration from environment variables 8 | const RATE_LIMIT_REQUESTS = parseInt(process.env.ADDRESS_VALIDATION_RATE_LIMIT || '100', 10); 9 | const RATE_LIMIT_WINDOW = parseInt(process.env.ADDRESS_VALIDATION_WINDOW_MINUTES || '1', 10); 10 | 11 | addressValidationRouter.post( 12 | '/validateAddress', 13 | rateLimiterMiddleware(RATE_LIMIT_REQUESTS, RATE_LIMIT_WINDOW), 14 | validateAddressHandler 15 | ); 16 | 17 | export default addressValidationRouter; -------------------------------------------------------------------------------- /tax-calculator/src/errors/shipFromNotFoundError.js: -------------------------------------------------------------------------------- 1 | import CustomError from './custom.error.js'; 2 | import { HTTP_STATUS_BAD_REQUEST } from '../constants/http.status.constants.js'; 3 | 4 | class ShipFromNotFoundError extends CustomError { 5 | constructor(message, cart) { 6 | super( 7 | HTTP_STATUS_BAD_REQUEST, 8 | message || 'Ship-from address could not be determined' 9 | ); 10 | this.cart = cart; 11 | this.errorCode = 'ShipFromNotFound'; 12 | } 13 | 14 | toCommercetoolsError() { 15 | return { 16 | code: 'InvalidInput', 17 | message: this.message, 18 | detailedErrorMessage: `${this.errorCode}: Unable to determine ship-from address for tax calculation. Please configure supply channels.` 19 | }; 20 | } 21 | } 22 | 23 | export default ShipFromNotFoundError; -------------------------------------------------------------------------------- /tax-calculator/src/index.js: -------------------------------------------------------------------------------- 1 | import 'dotenv/config'; 2 | 3 | import express from 'express'; 4 | import bodyParser from 'body-parser'; 5 | 6 | // Import routes 7 | import taxCalculatorRouter from './routes/tax.calculator.route.js'; 8 | import addressValidationRouter from './routes/address.validation.route.js'; 9 | import { logger } from './utils/logger.utils.js'; 10 | const PORT = 8080; 11 | 12 | // Create the express app 13 | const app = express(); 14 | 15 | // Define configurations 16 | app.use(bodyParser.json()); 17 | app.use(bodyParser.urlencoded({ extended: true })); 18 | 19 | // Define routes 20 | app.use('/taxCalculator', taxCalculatorRouter); 21 | app.use('/taxCalculator', addressValidationRouter); 22 | 23 | // Listen the application 24 | const server = app.listen(PORT, () => { 25 | logger.info(`Tax Calculator service listening on port ${PORT} and started successfully`); 26 | }); 27 | 28 | export default server; 29 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ### AL ### 2 | #Template for AL projects for Dynamics 365 Business Central 3 | #launch.json folder 4 | .vscode/ 5 | #Cache folder 6 | .alcache/ 7 | #Symbols folder 8 | .alpackages/ 9 | #Snapshots folder 10 | .snapshots/ 11 | #Testing Output folder 12 | .output/ 13 | #Extension App-file 14 | *.app 15 | #Rapid Application Development File 16 | rad.json 17 | #Translation Base-file 18 | *.g.xlf 19 | #License-file 20 | *.flf 21 | #Test results file 22 | TestResults.xml 23 | # Dependency directories 24 | node_modules/ 25 | # environment variables 26 | tax-calculator/.env 27 | order-syncer/.env 28 | .env 29 | .env.local 30 | .idea 31 | .DS_Store 32 | 33 | # Test coverage 34 | tax-calculator/coverage/ 35 | order-syncer/coverage/ 36 | 37 | # commercetools connect 38 | .connect/ 39 | 40 | # AI Generated Files 41 | .claude 42 | CLAUDE.md 43 | commercetools-key.json 44 | .superdesign 45 | .cursor 46 | .specify 47 | specs 48 | context/sessions/ -------------------------------------------------------------------------------- /order-syncer/src/clients/create.client.js: -------------------------------------------------------------------------------- 1 | import { createApiBuilderFromCtpClient } from '@commercetools/platform-sdk'; 2 | import { createClient } from './build.client.js'; 3 | import readConfiguration from '../utils/config.util.js'; 4 | 5 | /** 6 | * Create client with apiRoot 7 | * apiRoot can now be used to build requests to the Composable Commerce API 8 | */ 9 | export const createApiRoot = ((root) => () => { 10 | if (root) { 11 | return root; 12 | } 13 | 14 | root = createApiBuilderFromCtpClient(createClient()).withProjectKey({ 15 | projectKey: readConfiguration().projectKey, 16 | }); 17 | 18 | return root; 19 | })(); 20 | 21 | /** 22 | * Example code to get the Project details 23 | * This code has the same effect as sending a GET 24 | * request to the commercetools Composable Commerce API without any endpoints. 25 | * 26 | */ 27 | export const getProject = async () => { 28 | return await createApiRoot().get().execute(); 29 | }; 30 | -------------------------------------------------------------------------------- /tax-calculator/src/clients/create.client.js: -------------------------------------------------------------------------------- 1 | import { createApiBuilderFromCtpClient } from '@commercetools/platform-sdk'; 2 | import { createClient } from './build.client.js'; 3 | import configUtils from '../utils/config.util.js'; 4 | 5 | /** 6 | * Create client with apiRoot, 7 | * apiRoot can now be used to build requests to Composable Commerce API 8 | */ 9 | export const createApiRoot = ((root) => () => { 10 | if (root) { 11 | return root; 12 | } 13 | 14 | root = createApiBuilderFromCtpClient(createClient()).withProjectKey({ 15 | projectKey: configUtils.readConfiguration().projectKey, 16 | }); 17 | 18 | return root; 19 | })(); 20 | 21 | /** 22 | * Example code to get the Project details 23 | * This code has the same effect as sending a GET 24 | * request to the commercetools Composable Commerce API without any endpoints. 25 | * 26 | */ 27 | export const getProject = async () => { 28 | return await createApiRoot().get().execute(); 29 | }; 30 | -------------------------------------------------------------------------------- /order-syncer/src/utils/config.util.js: -------------------------------------------------------------------------------- 1 | import CustomError from '../errors/custom.error.js'; 2 | import envValidators from '../validators/env-var.validator.js'; 3 | import { getValidateMessages } from '../validators/helpers.validator.js'; 4 | 5 | /** 6 | * Read the configuration env vars 7 | * (Add yours accordingly) 8 | * 9 | * @returns The configuration with the correct env vars 10 | */ 11 | export default function readConfiguration() { 12 | const envVars = { 13 | clientId: process.env.CTP_CLIENT_ID, 14 | clientSecret: process.env.CTP_CLIENT_SECRET, 15 | projectKey: process.env.CTP_PROJECT_KEY, 16 | scope: process.env.CTP_SCOPE, 17 | region: process.env.CTP_REGION, 18 | stripeApiToken: process.env.STRIPE_API_TOKEN, 19 | }; 20 | 21 | const validationErrors = getValidateMessages(envValidators, envVars); 22 | 23 | if (validationErrors.length) { 24 | throw new CustomError( 25 | 'InvalidEnvironmentVariablesError', 26 | 'Invalid Environment Variables please check your .env file', 27 | validationErrors 28 | ); 29 | } 30 | 31 | return envVars; 32 | } 33 | -------------------------------------------------------------------------------- /tax-calculator/src/connectors/pre-undeploy.js: -------------------------------------------------------------------------------- 1 | import 'dotenv/config'; 2 | 3 | import { createApiRoot } from '../clients/create.client.js'; 4 | import { deleteCTPExtension } from './action.js'; 5 | import { CTP_TAX_CALCULATOR_EXTENSION_KEY } from './constants.js'; 6 | import { deleteCustomTypes } from './action.js'; 7 | 8 | /** 9 | * Performs pre-undeploy cleanup operations 10 | * Deletes the commercetools API extension and custom types 11 | */ 12 | async function preUndeploy() { 13 | const apiRoot = createApiRoot(); 14 | 15 | // Step 1: Delete API extension 16 | await deleteCTPExtension(apiRoot, CTP_TAX_CALCULATOR_EXTENSION_KEY); 17 | 18 | // Step 2: Delete custom types 19 | await deleteCustomTypes(apiRoot, true); 20 | } 21 | 22 | /** 23 | * Main entry point for pre-undeploy script 24 | * Executes preUndeploy and handles errors by writing to stderr and setting exit code 25 | */ 26 | async function run() { 27 | try { 28 | await preUndeploy(); 29 | } catch (error) { 30 | process.stderr.write(`Pre-undeploy failed: ${error.message}\n`); 31 | process.exitCode = 1; 32 | } 33 | } 34 | 35 | run(); -------------------------------------------------------------------------------- /order-syncer/src/connectors/pre-undeploy.js: -------------------------------------------------------------------------------- 1 | import 'dotenv/config'; 2 | 3 | import { createApiRoot } from '../clients/create.client.js'; 4 | import { CTP_ORDER_CHANGE_SUBSCRIPTION_KEY } from '../constants/connectors.constants.js'; 5 | import { deleteChangedOrderSubscription, deleteCustomTypes } from './action.js'; 6 | 7 | /** 8 | * Performs pre-undeploy cleanup operations 9 | * Deletes the commercetools API extension and custom types 10 | */ 11 | async function preUndeploy() { 12 | const apiRoot = createApiRoot(); 13 | 14 | // Step 1: Delete API extension 15 | await deleteChangedOrderSubscription(apiRoot, CTP_ORDER_CHANGE_SUBSCRIPTION_KEY); 16 | 17 | // Step 2: Delete custom types 18 | await deleteCustomTypes(apiRoot, true); 19 | } 20 | 21 | /** 22 | * Main entry point for pre-undeploy script 23 | * Executes preUndeploy and handles errors by writing to stderr and setting exit code 24 | */ 25 | async function run() { 26 | try { 27 | await preUndeploy(); 28 | } catch (error) { 29 | process.stderr.write(`Post-undeploy failed: ${error.message}\n`); 30 | process.exitCode = 1; 31 | } 32 | } 33 | 34 | run(); 35 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 commercetools 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 | -------------------------------------------------------------------------------- /order-syncer/src/clients/update.client.js: -------------------------------------------------------------------------------- 1 | import { createApiRoot } from './create.client.js'; 2 | import CustomError from '../errors/custom.error.js'; 3 | import { HTTP_STATUS_SUCCESS_ACCEPTED } from '../constants/http.status.constants.js'; 4 | import { getOrder } from './query.client.js'; 5 | import { ORDER_TAX_FIELD_NAMES } from '../connectors/customTypes.js'; 6 | 7 | /** 8 | * Update the order with tax transaction references. 9 | * @param {Array} taxTransactions - The tax transactions. 10 | * @param {string} orderId - The ID of the order. 11 | * @returns {Promise} The order object. 12 | */ 13 | export async function updateOrderTaxTxn(taxTransactions, orderId) { 14 | const actions = [{ 15 | action: 'setCustomField', 16 | name: ORDER_TAX_FIELD_NAMES.TRANSACTION_REFERENCES, 17 | value: taxTransactions.map(txn => txn?.id) 18 | }]; 19 | 20 | const order = await getOrder(orderId); 21 | return await createApiRoot() 22 | .orders() 23 | .withId({ 24 | ID: order.id, 25 | }) 26 | .post({ 27 | body: { 28 | actions: actions, 29 | version: order.version, 30 | }, 31 | }) 32 | .execute() 33 | .catch((error) => { 34 | throw new CustomError(HTTP_STATUS_SUCCESS_ACCEPTED, error.message, error); 35 | }); 36 | } 37 | -------------------------------------------------------------------------------- /order-syncer/src/connectors/customTypes.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Custom Type Definitions for Stripe Tax Connector 3 | * 4 | * This file contains all custom type definitions required for the Stripe Tax connector. 5 | * Custom types are created during post-deploy to enable merchants to configure 6 | * tax codes at different levels (products, categories, shipping methods). 7 | */ 8 | 9 | export const ORDER_TAX_FIELD_NAMES = { 10 | CALCULATION_REFERENCES: 'connectorStripeTax_calculationReferences', 11 | TRANSACTION_REFERENCES: 'connectorStripeTax_transactionReferences' 12 | }; 13 | 14 | export const ORDER_TAX_CUSTOM_TYPE = { 15 | "key": process.env.CUSTOM_TYPE_ORDER_KEY || "connector-stripe-tax-calculation-reference", 16 | "name": { 17 | "en": "Stripe Tax Calculation Reference" 18 | }, 19 | "description": { 20 | "en": "Stripe tax calculation reference for cart and order" 21 | }, 22 | "resourceTypeIds": ["order"], 23 | "fieldDefinitions": [ 24 | { 25 | "name": ORDER_TAX_FIELD_NAMES.TRANSACTION_REFERENCES, 26 | "label": { 27 | "en": "Stripe Tax Transaction References" 28 | }, 29 | "type": { 30 | "name": "Set", 31 | "elementType": { 32 | "name": "String" 33 | } 34 | }, 35 | "required": false, 36 | "inputHint": "SingleLine" 37 | } 38 | ] 39 | } 40 | -------------------------------------------------------------------------------- /tax-calculator/src/utils/config.util.js: -------------------------------------------------------------------------------- 1 | import CustomError from '../errors/custom.error.js'; 2 | import envValidators from '../validators/env-var.validators.js'; 3 | import { getValidateMessages } from '../validators/helpers.validators.js'; 4 | 5 | /** 6 | * Read the configuration env vars 7 | * (Add yours accordingly) 8 | * 9 | * @returns The configuration with the correct env vars 10 | */ 11 | 12 | function readConfiguration() { 13 | const envVars = { 14 | clientId: process.env.CTP_CLIENT_ID, 15 | clientSecret: process.env.CTP_CLIENT_SECRET, 16 | projectKey: process.env.CTP_PROJECT_KEY, 17 | scope: process.env.CTP_SCOPE, 18 | region: process.env.CTP_REGION, 19 | stripeApiToken: process.env.STRIPE_API_TOKEN, 20 | taxCodeMapping: process.env.TAX_CODE_CATEGORY_MAPPING_JSON, 21 | taxBehaviorDefault: process.env.TAX_BEHAVIOR_DEFAULT, 22 | countryTaxBehaviorMapping: process.env.TAX_BEHAVIOR_COUNTRY_MAPPING 23 | }; 24 | 25 | const validationErrors = getValidateMessages(envValidators, envVars); 26 | 27 | if (validationErrors.length) { 28 | throw new CustomError( 29 | 'InvalidEnvironmentVariablesError', 30 | 'Invalid Environment Variables please check your .env file', 31 | validationErrors 32 | ); 33 | } 34 | 35 | return envVars; 36 | } 37 | 38 | export default { 39 | readConfiguration 40 | }; 41 | -------------------------------------------------------------------------------- /order-syncer/src/clients/query.client.js: -------------------------------------------------------------------------------- 1 | import { createApiRoot } from './create.client.js'; 2 | import CustomError from '../errors/custom.error.js'; 3 | import { HTTP_STATUS_SUCCESS_ACCEPTED } from '../constants/http.status.constants.js'; 4 | 5 | /** 6 | * Get order with payment info expanded. 7 | * @param {string} orderId - The ID of the order. 8 | * @returns {Promise} The order object with payments expanded. 9 | */ 10 | export async function getOrderWithPaymentInfo(orderId) { 11 | return await createApiRoot() 12 | .orders() 13 | .withId({ 14 | ID: orderId, 15 | }) 16 | .get({ queryArgs: { withTotal: false, expand: ['paymentInfo.payments[*]'] } }) 17 | .execute() 18 | .then((response) => response.body) 19 | .catch((error) => { 20 | throw new CustomError(HTTP_STATUS_SUCCESS_ACCEPTED, error.message, error); 21 | }) 22 | } 23 | 24 | /** 25 | * Get the order by order ID. 26 | * @param {string} orderId - The ID of the order. 27 | * @returns {Promise} The order object. 28 | */ 29 | export async function getOrder(orderId) { 30 | return await createApiRoot() 31 | .orders() 32 | .withId({ 33 | ID: orderId, 34 | }) 35 | .get() 36 | .execute() 37 | .then((response) => response.body) 38 | .catch((error) => { 39 | throw new CustomError(HTTP_STATUS_SUCCESS_ACCEPTED, error.message, error); 40 | }); 41 | } 42 | -------------------------------------------------------------------------------- /tax-calculator/test/unit/errors/custom.error.spec.js: -------------------------------------------------------------------------------- 1 | import { expect, describe, it } from '@jest/globals'; 2 | import CustomError from '../../../src/errors/custom.error.js'; 3 | 4 | describe('CustomError', () => { 5 | it('should create error with statusCode and message', () => { 6 | const error = new CustomError(400, 'Test error message'); 7 | 8 | expect(error).toBeInstanceOf(Error); 9 | expect(error.statusCode).toBe(400); 10 | expect(error.message).toBe('Test error message'); 11 | expect(error.errors).toBeUndefined(); 12 | }); 13 | 14 | it('should create error with statusCode, message, and errors', () => { 15 | const errors = [ 16 | { field: 'email', message: 'Invalid email' }, 17 | { field: 'password', message: 'Password too short' } 18 | ]; 19 | const error = new CustomError(400, 'Validation failed', errors); 20 | 21 | expect(error.statusCode).toBe(400); 22 | expect(error.message).toBe('Validation failed'); 23 | expect(error.errors).toEqual(errors); 24 | }); 25 | 26 | it('should be throwable and catchable', () => { 27 | expect(() => { 28 | throw new CustomError(500, 'Server error'); 29 | }).toThrow('Server error'); 30 | 31 | try { 32 | throw new CustomError(404, 'Not found'); 33 | } catch (error) { 34 | expect(error).toBeInstanceOf(CustomError); 35 | expect(error.statusCode).toBe(404); 36 | } 37 | }); 38 | }); 39 | 40 | -------------------------------------------------------------------------------- /order-syncer/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "order-syncher", 3 | "version": "1.0.0", 4 | "type": "module", 5 | "main": "src/index.js", 6 | "private": true, 7 | "scripts": { 8 | "start": "node src/index.js", 9 | "start:dev": "nodemon -q src/index.js", 10 | "lint": "eslint src --ext .js", 11 | "prettier": "prettier --write '**/*.{js,ts}'", 12 | "test": "npm run test:unit", 13 | "test:unit": "jest --config jest.config.unit.cjs", 14 | "test:integration": "jest --config jest.config.integration.js", 15 | "test:ci": "npm run test:unit && npm run test:integration", 16 | "connector:post-deploy": "node src/connectors/post-deploy.js", 17 | "connector:pre-undeploy": "node src/connectors/pre-undeploy.js" 18 | }, 19 | "author": "", 20 | "license": "MIT", 21 | "devDependencies": { 22 | "@babel/core": "^7.28.5", 23 | "@babel/preset-env": "^7.28.5", 24 | "@jest/globals": "^29.7.0", 25 | "babel-jest": "^29.7.0", 26 | "eslint": "^8.57.1", 27 | "jest": "^29.7.0", 28 | "mocha": "^10.8.2", 29 | "nodemon": "^3.1.11", 30 | "prettier": "^3.7.3", 31 | "supertest": "^7.1.4" 32 | }, 33 | "dependencies": { 34 | "@commercetools-backend/loggers": "^22.29.0", 35 | "@commercetools/platform-sdk": "^7.9.0", 36 | "@commercetools/sdk-client-v2": "^2.2.0", 37 | "body-parser": "^1.20.1", 38 | "dotenv": "^16.6.1", 39 | "express": "^4.22.1", 40 | "stripe": "^16.12.0", 41 | "validator": "^13.15.23" 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /order-syncer/src/validators/order-change.validators.js: -------------------------------------------------------------------------------- 1 | import CustomError from '../errors/custom.error.js'; 2 | import { 3 | HTTP_STATUS_SUCCESS_ACCEPTED, 4 | HTTP_STATUS_SUCCESS_NO_CONTENT, 5 | } from '../constants/http.status.constants.js'; 6 | import { 7 | MESSAGE_TYPE, 8 | NOTIFICATION_TYPE_RESOURCE_CREATED, 9 | } from '../constants/connectors.constants.js'; 10 | 11 | export function doValidation(messageBody) { 12 | if (!messageBody) { 13 | throw new CustomError( 14 | HTTP_STATUS_SUCCESS_ACCEPTED, 15 | `The incoming message body is missing. No further action is required. ` 16 | ); 17 | } 18 | 19 | // Make sure incoming message contains correct notification type 20 | if (NOTIFICATION_TYPE_RESOURCE_CREATED === messageBody.notificationType) { 21 | throw new CustomError( 22 | HTTP_STATUS_SUCCESS_NO_CONTENT, 23 | `Incoming message is about subscription resource creation. Skip handling the message` 24 | ); 25 | } 26 | 27 | if (!MESSAGE_TYPE.includes(messageBody.type)) { 28 | throw new CustomError( 29 | HTTP_STATUS_SUCCESS_ACCEPTED, 30 | ` Message type ${messageBody.type} is incorrect.` 31 | ); 32 | } 33 | 34 | // Make sure incoming message contains the identifier of the changed product 35 | const resourceTypeId = messageBody?.resource?.typeId; 36 | const resourceId = messageBody?.resource?.id; 37 | 38 | if (resourceTypeId !== 'order' || !resourceId) { 39 | throw new CustomError( 40 | HTTP_STATUS_SUCCESS_ACCEPTED, 41 | ` No order ID is found in message.` 42 | ); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /tax-calculator/test/unit/controllers/tax-calculator.controller.spec.js: -------------------------------------------------------------------------------- 1 | import {expect, describe, it, jest, beforeEach} from '@jest/globals'; 2 | import configUtil from '../../../src/utils/config.util.js'; 3 | import { HTTP_STATUS_BAD_REQUEST } from '../../../src/constants/http.status.constants.js'; 4 | import {taxHandler} from "../../../src/controllers/tax.calculator.controller.js"; 5 | 6 | describe('tax-calculator.controller.spec', () => { 7 | beforeEach(() => { 8 | jest.clearAllMocks(); 9 | }); 10 | 11 | it(`should return 400 HTTP status when message data is missing in incoming event message.`, async () => { 12 | const dummyConfig = { 13 | clientId: 'dummy-ctp-client-id', 14 | clientSecret: 'dummy-ctp-client-secret', 15 | projectKey: 'dummy-ctp-project-key', 16 | scope: 'dummy-ctp-scope', 17 | region: 'dummy-ctp-region', 18 | stripeApiToken: 'sk_test_dummy-stripe-api-token', 19 | }; 20 | 21 | jest 22 | .spyOn(configUtil, "readConfiguration") 23 | .mockImplementation(({ success }) => success(dummyConfig)); 24 | 25 | const mockRequest = { 26 | method: 'POST', 27 | url: '/', 28 | body: { 29 | message: {}, 30 | }, 31 | }; 32 | const mockResponse = { 33 | status: () => { 34 | return { 35 | send: () => {}, 36 | }; 37 | }, 38 | }; 39 | 40 | const responseStatusSpy = jest.spyOn(mockResponse, 'status') 41 | await taxHandler(mockRequest, mockResponse); 42 | expect(responseStatusSpy).toBeCalledWith(HTTP_STATUS_BAD_REQUEST); 43 | }); 44 | }); 45 | -------------------------------------------------------------------------------- /tax-calculator/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "tax-calculator", 3 | "version": "1.0.0", 4 | "type": "module", 5 | "main": "src/index.js", 6 | "private": true, 7 | "scripts": { 8 | "start": "node src/index.js", 9 | "start:dev": "nodemon -q src/index.js", 10 | "lint": "eslint src --ext .js", 11 | "prettier": "prettier --write '**/*.{js,ts}'", 12 | "test": "npm run test:unit", 13 | "test:unit": "jest --config jest.config.unit.cjs", 14 | "test:integration": "jest --config jest.config.integration.js", 15 | "test:ci": "npm run test:unit && npm run test:integration", 16 | "connector:post-deploy": "node src/connectors/post-deploy.js", 17 | "connector:pre-undeploy": "node src/connectors/pre-undeploy.js" 18 | }, 19 | "author": "", 20 | "license": "MIT", 21 | "devDependencies": { 22 | "@babel/core": "^7.28.5", 23 | "@babel/preset-env": "^7.28.5", 24 | "@jest/globals": "^29.7.0", 25 | "babel-jest": "^29.7.0", 26 | "eslint": "^8.57.1", 27 | "jest": "^29.7.0", 28 | "mocha": "^10.8.2", 29 | "nodemon": "^3.1.11", 30 | "prettier": "^3.7.3", 31 | "supertest": "^7.1.4" 32 | }, 33 | "dependencies": { 34 | "@commercetools-backend/loggers": "^22.29.0", 35 | "@commercetools/platform-sdk": "^7.9.0", 36 | "@commercetools/sdk-client-v2": "^2.2.0", 37 | "body-parser": "^1.20.1", 38 | "dotenv": "^16.6.1", 39 | "express": "^4.22.1", 40 | "express-rate-limit": "^8.2.1", 41 | "lodash": "^4.17.21", 42 | "serialize-error": "^11.0.3", 43 | "stripe": "^16.12.0", 44 | "validator": "^13.15.23" 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /order-syncer/src/connectors/post-deploy.js: -------------------------------------------------------------------------------- 1 | import 'dotenv/config'; 2 | 3 | import { createApiRoot } from '../clients/create.client.js'; 4 | import { createChangedOrderSubscription, createCustomTypes } from './action.js'; 5 | import { CTP_ORDER_CHANGE_SUBSCRIPTION_KEY } from '../constants/connectors.constants.js'; 6 | const CONNECT_GCP_TOPIC_NAME_KEY = 'CONNECT_GCP_TOPIC_NAME'; 7 | const CONNECT_GCP_PROJECT_ID_KEY = 'CONNECT_GCP_PROJECT_ID'; 8 | 9 | /** 10 | * Post-deployment function that creates the commercetools extension and custom types. 11 | * This function is called after the Order Syncer is deployed to ensure proper setup and configuration. 12 | * 13 | * @param {Map} properties - Map containing environment variables and configuration properties 14 | */ 15 | async function postDeploy(properties) { 16 | const topicName = properties.get(CONNECT_GCP_TOPIC_NAME_KEY); 17 | const projectId = properties.get(CONNECT_GCP_PROJECT_ID_KEY); 18 | 19 | const apiRoot = createApiRoot(); 20 | await createChangedOrderSubscription( 21 | apiRoot, 22 | topicName, 23 | projectId, 24 | CTP_ORDER_CHANGE_SUBSCRIPTION_KEY 25 | ); 26 | await createCustomTypes(apiRoot); 27 | } 28 | 29 | /** 30 | * Main entry point for post-deploy script 31 | * Executes postDeploy and handles errors by writing to stderr and setting exit code 32 | */ 33 | async function run() { 34 | try { 35 | const properties = new Map(Object.entries(process.env)); 36 | await postDeploy(properties); 37 | } catch (error) { 38 | process.stderr.write(`Post-deploy failed: ${error.message}\n`); 39 | process.exitCode = 1; 40 | } 41 | } 42 | 43 | run(); 44 | -------------------------------------------------------------------------------- /order-syncer/src/validators/env-var.validator.js: -------------------------------------------------------------------------------- 1 | import { 2 | optional, 3 | standardString, 4 | standardKey, 5 | region, 6 | } from './helpers.validator.js'; 7 | 8 | /** 9 | * Create here your own validators 10 | */ 11 | const envValidators = [ 12 | standardString( 13 | ['clientId'], 14 | { 15 | code: 'InValidClientId', 16 | message: 'Client id should be 24 characters.', 17 | referencedBy: 'environmentVariables', 18 | }, 19 | { min: 24, max: 24 } 20 | ), 21 | 22 | standardString( 23 | ['clientSecret'], 24 | { 25 | code: 'InvalidClientSecret', 26 | message: 'Client secret should be 32 characters.', 27 | referencedBy: 'environmentVariables', 28 | }, 29 | { min: 32, max: 32 } 30 | ), 31 | 32 | standardKey(['projectKey'], { 33 | code: 'InvalidProjectKey', 34 | message: 'Project key should be a valid string.', 35 | referencedBy: 'environmentVariables', 36 | }), 37 | 38 | optional(standardString)( 39 | ['scope'], 40 | { 41 | code: 'InvalidScope', 42 | message: 'Scope should be at least 2 characters long.', 43 | referencedBy: 'environmentVariables', 44 | }, 45 | { min: 2, max: undefined } 46 | ), 47 | 48 | region(['region'], { 49 | code: 'InvalidRegion', 50 | message: 'Not a valid region.', 51 | referencedBy: 'environmentVariables', 52 | }), 53 | 54 | standardString( 55 | ['stripeApiToken'], 56 | { 57 | code: 'InvalidStripeApiToken', 58 | message: 'Stripe API token should be a valid string.', 59 | referencedBy: 'environmentVariables', 60 | }, 61 | { min: 1, max: undefined } 62 | ), 63 | ]; 64 | 65 | export default envValidators; 66 | -------------------------------------------------------------------------------- /order-syncer/test/unit/extensions/stripe/configurations/config.spec.js: -------------------------------------------------------------------------------- 1 | import { expect, describe, it, beforeEach, afterEach } from '@jest/globals'; 2 | import { loadConfig } from '../../../../../src/extensions/stripe/configurations/config.js'; 3 | 4 | describe('stripe-config.spec', () => { 5 | const originalEnv = process.env; 6 | 7 | beforeEach(() => { 8 | process.env = { ...originalEnv }; 9 | }); 10 | 11 | afterEach(() => { 12 | process.env = originalEnv; 13 | }); 14 | 15 | describe('loadConfig', () => { 16 | it('should return config object when STRIPE_API_TOKEN is set', () => { 17 | process.env.STRIPE_API_TOKEN = 'sk_test_token_12345'; 18 | 19 | const config = loadConfig(); 20 | 21 | expect(config).toEqual({ 22 | taxProviderApiToken: 'sk_test_token_12345', 23 | }); 24 | }); 25 | 26 | it('should return config with different token values', () => { 27 | process.env.STRIPE_API_TOKEN = 'sk_live_token_67890'; 28 | 29 | const config = loadConfig(); 30 | 31 | expect(config).toEqual({ 32 | taxProviderApiToken: 'sk_live_token_67890', 33 | }); 34 | }); 35 | 36 | it('should throw error when STRIPE_API_TOKEN is not provided', () => { 37 | delete process.env.STRIPE_API_TOKEN; 38 | 39 | expect(() => loadConfig()).toThrow('Tax provider API token is not provided.'); 40 | }); 41 | 42 | it('should throw error when STRIPE_API_TOKEN is empty string', () => { 43 | process.env.STRIPE_API_TOKEN = ''; 44 | 45 | expect(() => loadConfig()).toThrow('Tax provider API token is not provided.'); 46 | }); 47 | 48 | it('should throw error when STRIPE_API_TOKEN is undefined', () => { 49 | process.env.STRIPE_API_TOKEN = undefined; 50 | 51 | expect(() => loadConfig()).toThrow('Tax provider API token is not provided.'); 52 | }); 53 | }); 54 | }); 55 | 56 | -------------------------------------------------------------------------------- /tax-calculator/src/errors/taxCodeNotFound.error.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Custom error thrown when no tax code can be determined for a product 3 | * This should result in a commercetools validation error (400) response 4 | */ 5 | class TaxCodeNotFoundError extends Error { 6 | constructor(productId, productName, categories) { 7 | const categoryNames = (categories || []) 8 | .map(cat => cat.name || cat.key || 'unknown') 9 | .join(', '); 10 | 11 | const message = categoryNames 12 | ? `No tax code mapping found for product "${productName}" (${productId}). Categories: ${categoryNames}` 13 | : `No tax code mapping found for product "${productName}" (${productId}). No categories assigned.`; 14 | 15 | super(message); 16 | 17 | this.name = 'TaxCodeNotFoundError'; 18 | this.productId = productId; 19 | this.productName = productName; 20 | this.categories = categories || []; 21 | this.statusCode = 400; // Bad Request - validation error 22 | 23 | // Capture stack trace 24 | if (Error.captureStackTrace) { 25 | Error.captureStackTrace(this, TaxCodeNotFoundError); 26 | } 27 | } 28 | 29 | /** 30 | * Convert error to commercetools API Extension error format 31 | * @returns {Object} Error object in commercetools format 32 | */ 33 | toCommerceToolsError() { 34 | return { 35 | code: 'InvalidInput', 36 | message: this.message, 37 | extensionExtraInfo: { 38 | originalError: 'TaxCodeNotFound', 39 | productId: this.productId, 40 | productName: this.productName, 41 | categories: this.categories.map(cat => ({ 42 | id: cat.id, 43 | name: cat.name, 44 | key: cat.key 45 | })), 46 | action: 'Please configure a tax code mapping for one of these categories or add a custom taxCode field to the product.' 47 | } 48 | }; 49 | } 50 | } 51 | 52 | export default TaxCodeNotFoundError; 53 | -------------------------------------------------------------------------------- /order-syncer/test/unit/middlewares/http.middleware.spec.js: -------------------------------------------------------------------------------- 1 | import { expect, describe, it, jest, beforeEach } from '@jest/globals'; 2 | 3 | // Mock dependencies 4 | jest.mock('../../../src/utils/config.util.js', () => ({ 5 | __esModule: true, 6 | default: jest.fn(), 7 | })); 8 | 9 | import readConfiguration from '../../../src/utils/config.util.js'; 10 | import { getHttpMiddlewareOptions } from '../../../src/middlewares/http.middleware.js'; 11 | 12 | describe('http.middleware.spec', () => { 13 | beforeEach(() => { 14 | jest.clearAllMocks(); 15 | }); 16 | 17 | describe('getHttpMiddlewareOptions', () => { 18 | it('should return http middleware options with correct host', () => { 19 | readConfiguration.mockReturnValue({ 20 | region: 'us-central1.gcp', 21 | }); 22 | 23 | const result = getHttpMiddlewareOptions(); 24 | 25 | expect(readConfiguration).toHaveBeenCalled(); 26 | expect(result).toEqual({ 27 | host: 'https://api.us-central1.gcp.commercetools.com', 28 | }); 29 | }); 30 | 31 | it('should handle different regions correctly', () => { 32 | const regions = [ 33 | 'us-central1.gcp', 34 | 'us-east-2.aws', 35 | 'europe-west1.gcp', 36 | 'eu-central-1.aws', 37 | 'australia-southeast1.gcp', 38 | ]; 39 | 40 | regions.forEach((region) => { 41 | readConfiguration.mockReturnValue({ 42 | region: region, 43 | }); 44 | 45 | const result = getHttpMiddlewareOptions(); 46 | 47 | expect(result.host).toBe(`https://api.${region}.commercetools.com`); 48 | }); 49 | }); 50 | 51 | it('should call readConfiguration to get region', () => { 52 | readConfiguration.mockReturnValue({ 53 | region: 'europe-west1.gcp', 54 | }); 55 | 56 | getHttpMiddlewareOptions(); 57 | 58 | expect(readConfiguration).toHaveBeenCalled(); 59 | }); 60 | }); 61 | }); 62 | 63 | -------------------------------------------------------------------------------- /tax-calculator/src/validators/env-var.validators.js: -------------------------------------------------------------------------------- 1 | import { 2 | optional, 3 | standardString, 4 | standardKey, 5 | region, 6 | taxBehavior, 7 | jsonObject, 8 | } from './helpers.validators.js'; 9 | import { VALID_TAX_BEHAVIORS } from '../constants/tax-behavior.constants.js'; 10 | 11 | /** 12 | * Create here your own validators 13 | */ 14 | const envValidators = [ 15 | standardString( 16 | ['clientId'], 17 | { 18 | code: 'InValidClientId', 19 | message: 'Client id should be 24 characters.', 20 | referencedBy: 'environmentVariables', 21 | }, 22 | { min: 24, max: 24 } 23 | ), 24 | 25 | standardString( 26 | ['clientSecret'], 27 | { 28 | code: 'InvalidClientSecret', 29 | message: 'Client secret should be 32 characters.', 30 | referencedBy: 'environmentVariables', 31 | }, 32 | { min: 32, max: 32 } 33 | ), 34 | 35 | standardKey(['projectKey'], { 36 | code: 'InvalidProjectKey', 37 | message: 'Project key should be a valid string.', 38 | referencedBy: 'environmentVariables', 39 | }), 40 | 41 | optional(standardString)( 42 | ['scope'], 43 | { 44 | code: 'InvalidScope', 45 | message: 'Scope should be at least 2 characters long.', 46 | referencedBy: 'environmentVariables', 47 | }, 48 | { min: 2, max: undefined } 49 | ), 50 | 51 | region(['region'], { 52 | code: 'InvalidRegion', 53 | message: 'Not a valid region.', 54 | referencedBy: 'environmentVariables', 55 | }), 56 | 57 | optional(taxBehavior)(['taxBehaviorDefault'], { 58 | code: 'InvalidTaxBehaviorDefault', 59 | message: `Tax behavior default should be one of: ${VALID_TAX_BEHAVIORS.join(', ')}.`, 60 | referencedBy: 'environmentVariables', 61 | }), 62 | 63 | optional(jsonObject)(['countryTaxBehaviorMapping'], { 64 | code: 'InvalidCountryTaxBehaviorMapping', 65 | message: 'Country tax behavior mapping should be valid JSON object.', 66 | referencedBy: 'environmentVariables', 67 | }), 68 | ]; 69 | 70 | export default envValidators; 71 | -------------------------------------------------------------------------------- /order-syncer/test/unit/utils/decoder.util.spec.js: -------------------------------------------------------------------------------- 1 | import { expect, describe, it } from '@jest/globals'; 2 | import { decodeToJson } from '../../../src/utils/decoder.util.js'; 3 | 4 | describe('decoder.util.spec', () => { 5 | // decodeToString is a private function, tested indirectly through decodeToJson 6 | 7 | describe('decodeToJson', () => { 8 | it('should decode base64 string to JSON object', () => { 9 | const jsonObject = { test: 'value', number: 123 }; 10 | const base64String = Buffer.from(JSON.stringify(jsonObject)).toString('base64'); 11 | const decoded = decodeToJson(base64String); 12 | expect(decoded).toEqual(jsonObject); 13 | }); 14 | 15 | it('should handle complex JSON objects', () => { 16 | const complexObject = { 17 | notificationType: 'Message', 18 | resource: { typeId: 'order', id: 'order-123' }, 19 | type: 'OrderCreated', 20 | version: 1, 21 | }; 22 | const base64String = Buffer.from(JSON.stringify(complexObject)).toString('base64'); 23 | const decoded = decodeToJson(base64String); 24 | expect(decoded).toEqual(complexObject); 25 | }); 26 | 27 | it('should handle arrays in JSON', () => { 28 | const arrayObject = { items: [1, 2, 3], names: ['a', 'b', 'c'] }; 29 | const base64String = Buffer.from(JSON.stringify(arrayObject)).toString('base64'); 30 | const decoded = decodeToJson(base64String); 31 | expect(decoded).toEqual(arrayObject); 32 | }); 33 | 34 | it('should throw error for invalid JSON', () => { 35 | const invalidJson = 'not valid json'; 36 | const base64String = Buffer.from(invalidJson).toString('base64'); 37 | expect(() => decodeToJson(base64String)).toThrow(); 38 | }); 39 | 40 | it('should handle empty JSON object', () => { 41 | const emptyObject = {}; 42 | const base64String = Buffer.from(JSON.stringify(emptyObject)).toString('base64'); 43 | const decoded = decodeToJson(base64String); 44 | expect(decoded).toEqual(emptyObject); 45 | }); 46 | }); 47 | }); 48 | 49 | -------------------------------------------------------------------------------- /tax-calculator/src/middlewares/rate.limiter.middleware.js: -------------------------------------------------------------------------------- 1 | import rateLimit from 'express-rate-limit'; 2 | 3 | /** 4 | * Rate limiter middleware to protect endpoints from abuse 5 | * @param {number} maxRequests - Maximum number of requests 6 | * @param {number} windowMinutes - Time window in minutes 7 | * @returns {Function} Express middleware 8 | */ 9 | export const rateLimiterMiddleware = (maxRequests = 100, windowMinutes = 1) => { 10 | return rateLimit({ 11 | windowMs: windowMinutes * 60 * 1000, 12 | max: maxRequests, 13 | message: { 14 | error: 'Too many requests', 15 | message: 'Please try again later.', 16 | retryAfter: windowMinutes * 60, 17 | }, 18 | standardHeaders: true, // Return rate limit info in `RateLimit-*` headers 19 | legacyHeaders: false, // Don't use `X-RateLimit-*` headers 20 | keyGenerator: (req) => { 21 | // Try to get IP from Forwarded header (standardized) 22 | const forwarded = req.headers['forwarded']; 23 | if (forwarded) { 24 | // Parse Forwarded header: "for=192.0.2.60;proto=http;by=203.0.113.43" 25 | const forMatch = forwarded.match(/for=([^;,\s]+)/i); 26 | if (forMatch && forMatch[1]) { 27 | // Remove quotes and brackets if present 28 | return forMatch[1].replace(/^["[\]]+|["[\]]+$/g, ''); 29 | } 30 | } 31 | // Fallback to X-Forwarded-For header 32 | const xForwardedFor = req.headers['x-forwarded-for']; 33 | if (xForwardedFor) { 34 | // X-Forwarded-For can contain multiple IPs, take the first one 35 | const ips = xForwardedFor.split(',').map(ip => ip.trim()); 36 | return ips[0]; 37 | } 38 | // Final fallback to req.ip 39 | return req.ip || req.socket.remoteAddress || 'unknown'; 40 | }, 41 | // Custom handler for errors 42 | handler: (req, res) => { 43 | res.status(429).json({ 44 | error: 'Rate limit exceeded', 45 | message: `Maximum ${maxRequests} requests per ${windowMinutes} minute(s) allowed`, 46 | retryAfter: windowMinutes * 60, 47 | }); 48 | }, 49 | }); 50 | }; 51 | -------------------------------------------------------------------------------- /tax-calculator/src/controllers/tax.calculator.controller.js: -------------------------------------------------------------------------------- 1 | import _ from 'lodash'; 2 | import {logger} from '../utils/logger.utils.js'; 3 | import { 4 | HTTP_STATUS_BAD_REQUEST, 5 | HTTP_STATUS_SUCCESS_ACCEPTED, 6 | } from '../constants/http.status.constants.js'; 7 | 8 | import CustomError from '../errors/custom.error.js'; 9 | import taxOrchestratorService from '../services/tax-orchestrator.service.js'; 10 | import TaxErrorHandlerService from '../services/tax-error-handler.service.js'; 11 | 12 | export const taxHandler = async (request, response) => { 13 | 14 | const cartRequestBody = request.body?.resource?.obj; 15 | if (_.isEmpty(cartRequestBody)) { 16 | logger.warn('Tax calculation request rejected: missing cart information', { 17 | hasBody: !!request.body, 18 | hasResource: !!request.body?.resource 19 | }); 20 | return response 21 | .status(HTTP_STATUS_BAD_REQUEST) 22 | .send( 23 | new CustomError( 24 | HTTP_STATUS_BAD_REQUEST, 25 | 'Missing cart information in the request body.' 26 | ) 27 | ); 28 | } 29 | 30 | logger.info('Tax calculation request received', { 31 | cartId: cartRequestBody.id, 32 | cartVersion: cartRequestBody.version, 33 | customerId: cartRequestBody.customerId ? '[PRESENT]' : null, 34 | anonymousId: cartRequestBody.anonymousId ? '[PRESENT]' : null, 35 | lineItemsCount: cartRequestBody.lineItems?.length || 0, 36 | shippingMode: cartRequestBody.shippingMode, 37 | country: cartRequestBody.country, 38 | currency: cartRequestBody.totalPrice?.currencyCode, 39 | totalAmount: cartRequestBody.totalPrice?.centAmount 40 | }); 41 | 42 | try { 43 | const result = await taxOrchestratorService.orchestrateTaxCalculation(cartRequestBody); 44 | 45 | return response.status(HTTP_STATUS_SUCCESS_ACCEPTED).send(result); 46 | } catch (err) { 47 | return TaxErrorHandlerService.handleTaxCalculationError(err, request, response, cartRequestBody); 48 | } 49 | 50 | }; -------------------------------------------------------------------------------- /tax-calculator/test/unit/errors/shipFromNotFoundError.spec.js: -------------------------------------------------------------------------------- 1 | import { expect, describe, it } from '@jest/globals'; 2 | import ShipFromNotFoundError from '../../../src/errors/shipFromNotFoundError.js'; 3 | import { HTTP_STATUS_BAD_REQUEST } from '../../../src/constants/http.status.constants.js'; 4 | 5 | describe('ShipFromNotFoundError', () => { 6 | it('should create error with default message', () => { 7 | const cart = { id: 'cart-123' }; 8 | const error = new ShipFromNotFoundError(undefined, cart); 9 | 10 | expect(error).toBeInstanceOf(Error); 11 | expect(error.statusCode).toBe(HTTP_STATUS_BAD_REQUEST); 12 | expect(error.message).toBe('Ship-from address could not be determined'); 13 | expect(error.cart).toBe(cart); 14 | expect(error.errorCode).toBe('ShipFromNotFound'); 15 | }); 16 | 17 | it('should create error with custom message', () => { 18 | const cart = { id: 'cart-456' }; 19 | const customMessage = 'Custom ship-from error'; 20 | const error = new ShipFromNotFoundError(customMessage, cart); 21 | 22 | expect(error.message).toBe(customMessage); 23 | expect(error.cart).toBe(cart); 24 | }); 25 | 26 | it('should convert to commercetools error format', () => { 27 | const cart = { id: 'cart-789' }; 28 | const error = new ShipFromNotFoundError('Test message', cart); 29 | 30 | const commercetoolsError = error.toCommercetoolsError(); 31 | 32 | expect(commercetoolsError).toEqual({ 33 | code: 'InvalidInput', 34 | message: 'Test message', 35 | detailedErrorMessage: 'ShipFromNotFound: Unable to determine ship-from address for tax calculation. Please configure supply channels.' 36 | }); 37 | }); 38 | 39 | it('should be throwable and catchable', () => { 40 | const cart = { id: 'cart-error' }; 41 | 42 | expect(() => { 43 | throw new ShipFromNotFoundError('Error message', cart); 44 | }).toThrow('Error message'); 45 | 46 | try { 47 | throw new ShipFromNotFoundError('Test', cart); 48 | } catch (error) { 49 | expect(error).toBeInstanceOf(ShipFromNotFoundError); 50 | expect(error.statusCode).toBe(HTTP_STATUS_BAD_REQUEST); 51 | expect(error.cart).toBe(cart); 52 | } 53 | }); 54 | }); 55 | 56 | -------------------------------------------------------------------------------- /order-syncer/test/integration/utils/encoder.utils.spec.js: -------------------------------------------------------------------------------- 1 | import { expect, describe, it } from '@jest/globals'; 2 | import { encodeJsonObject } from './encoder.utils.js'; 3 | 4 | describe('encoder.utils.spec', () => { 5 | describe('encodeJsonObject', () => { 6 | it('should encode a JSON object to base64', () => { 7 | const messageBody = { 8 | notificationType: 'Message', 9 | resource: { typeId: 'order', id: 'order-123' }, 10 | type: 'OrderCreated', 11 | }; 12 | 13 | const result = encodeJsonObject(messageBody); 14 | 15 | expect(result).toBeDefined(); 16 | expect(typeof result).toBe('string'); 17 | // Decode to verify it's correct base64 18 | const decoded = JSON.parse(Buffer.from(result, 'base64').toString()); 19 | expect(decoded).toEqual(messageBody); 20 | }); 21 | 22 | it('should encode different JSON objects correctly', () => { 23 | const messageBody1 = { test: 'value1' }; 24 | const messageBody2 = { test: 'value2', nested: { key: 'value' } }; 25 | 26 | const result1 = encodeJsonObject(messageBody1); 27 | const result2 = encodeJsonObject(messageBody2); 28 | 29 | expect(result1).not.toBe(result2); 30 | expect(JSON.parse(Buffer.from(result1, 'base64').toString())).toEqual(messageBody1); 31 | expect(JSON.parse(Buffer.from(result2, 'base64').toString())).toEqual(messageBody2); 32 | }); 33 | 34 | it('should handle empty objects', () => { 35 | const messageBody = {}; 36 | 37 | const result = encodeJsonObject(messageBody); 38 | 39 | expect(result).toBeDefined(); 40 | const decoded = JSON.parse(Buffer.from(result, 'base64').toString()); 41 | expect(decoded).toEqual({}); 42 | }); 43 | 44 | it('should handle objects with arrays', () => { 45 | const messageBody = { 46 | items: ['item1', 'item2', 'item3'], 47 | count: 3, 48 | }; 49 | 50 | const result = encodeJsonObject(messageBody); 51 | 52 | const decoded = JSON.parse(Buffer.from(result, 'base64').toString()); 53 | expect(decoded).toEqual(messageBody); 54 | }); 55 | 56 | it('should return trimmed base64 string', () => { 57 | const messageBody = { test: 'value' }; 58 | 59 | const result = encodeJsonObject(messageBody); 60 | 61 | // Should not have leading or trailing whitespace 62 | expect(result).toBe(result.trim()); 63 | }); 64 | }); 65 | }); 66 | 67 | -------------------------------------------------------------------------------- /tax-calculator/src/controllers/address.validation.controller.js: -------------------------------------------------------------------------------- 1 | import { logger } from '../utils/logger.utils.js'; 2 | import { 3 | HTTP_STATUS_BAD_REQUEST, 4 | HTTP_STATUS_SUCCESS_ACCEPTED, 5 | HTTP_STATUS_SERVER_ERROR 6 | } from '../constants/http.status.constants.js'; 7 | import addressService from '../services/address.service.js'; 8 | 9 | /** 10 | * Address validation handler 11 | * Handles only the HTTP layer (request/response) 12 | * All errors are handled directly without using next() 13 | */ 14 | export async function validateAddressHandler(req, res) { 15 | const requestId = req.headers['x-request-id'] || `req_${Date.now()}`; 16 | 17 | try { 18 | // Basic HTTP layer validation - only structure checks 19 | const { address } = req.body; 20 | 21 | if (!address) { 22 | return res.status(HTTP_STATUS_BAD_REQUEST).json({ 23 | error: 'Address is required in request body' 24 | }); 25 | } 26 | 27 | if (typeof address !== 'object' || Array.isArray(address)) { 28 | return res.status(HTTP_STATUS_BAD_REQUEST).json({ 29 | error: 'Address must be a valid object' 30 | }); 31 | } 32 | 33 | logger.info('Address validation request received', { 34 | requestId, 35 | country: address.country || 'unknown' 36 | }); 37 | 38 | // Delegate all business logic to the service 39 | const validationResult = await addressService.validateAddress( 40 | address, 41 | requestId 42 | ); 43 | 44 | logger.info('Address validation completed', { 45 | requestId, 46 | country: address.country || 'unknown', 47 | success: validationResult.success, 48 | hasStripeVerification: !!validationResult.validation.stripe 49 | }); 50 | 51 | return res.status(HTTP_STATUS_SUCCESS_ACCEPTED).json(validationResult); 52 | 53 | } catch (error) { 54 | // Handle unexpected errors (service should not throw, but just in case) 55 | logger.error('Unexpected address validation error', { 56 | requestId, 57 | error: error.message 58 | }); 59 | 60 | return res.status(HTTP_STATUS_SERVER_ERROR).json({ 61 | error: 'An unexpected error occurred during address validation', 62 | message: error.message 63 | }); 64 | } 65 | } -------------------------------------------------------------------------------- /order-syncer/src/utils/async-logger.utils.js: -------------------------------------------------------------------------------- 1 | import { createApplicationLogger } from '@commercetools-backend/loggers'; 2 | 3 | /** 4 | * Async Logger Wrapper (Fire-and-Forget) 5 | * 6 | * Processes logs asynchronously without blocking the main thread. 7 | * Uses setImmediate() for maximum performance and minimum overhead. 8 | */ 9 | class AsyncLogger { 10 | constructor(baseLogger) { 11 | this.baseLogger = baseLogger; 12 | } 13 | 14 | /** 15 | * Fire-and-forget logging 16 | * Does not block, processes in the next tick of the event loop 17 | * 18 | * @param {string} level - The log level 19 | * @param {string} message - The log message 20 | * @param {object} meta - The log metadata 21 | * @returns {void} 22 | */ 23 | logAsync(level, message, meta) { 24 | setImmediate(() => { 25 | try { 26 | this.baseLogger[level](message, meta); 27 | } catch (error) { 28 | // If it fails, try to log the error 29 | try { 30 | this.baseLogger.error('Logging error', { 31 | originalLevel: level, 32 | error: error.message 33 | }); 34 | } catch (e) { 35 | // Throw the error if it fails to log the error 36 | } 37 | } 38 | }); 39 | } 40 | 41 | /** 42 | * Log an info message 43 | * @param {string} message - The log message 44 | * @param {object} meta - The log metadata 45 | * @returns {void} 46 | */ 47 | info(message, meta) { 48 | this.logAsync('info', message, meta); 49 | } 50 | 51 | /** 52 | * Log an error message 53 | * @param {string} message - The log message 54 | * @param {object} meta - The log metadata 55 | * @returns {void} 56 | */ 57 | error(message, meta) { 58 | this.logAsync('error', message, meta); 59 | } 60 | 61 | /** 62 | * Log a warning message 63 | * @param {string} message - The log message 64 | * @param {object} meta - The log metadata 65 | * @returns {void} 66 | */ 67 | warn(message, meta) { 68 | this.logAsync('warn', message, meta); 69 | } 70 | 71 | /** 72 | * Log a debug message 73 | * @param {string} message - The log message 74 | * @param {object} meta - The log metadata 75 | * @returns {void} 76 | */ 77 | debug(message, meta) { 78 | this.logAsync('debug', message, meta); 79 | } 80 | } 81 | 82 | // Create the base commercetools logger 83 | const baseLogger = createApplicationLogger(); 84 | 85 | // Create the asynchronous wrapper 86 | export const logger = new AsyncLogger(baseLogger); 87 | 88 | // Export the base logger for direct access 89 | export { baseLogger }; -------------------------------------------------------------------------------- /tax-calculator/.env.example: -------------------------------------------------------------------------------- 1 | # G E N E R A L C O N F I G U R A T I O N 2 | # Commercetools configuration 3 | CTP_CLIENT_ID=your_24_character_client_id_here 4 | CTP_CLIENT_SECRET=your_32_character_client_secret_here 5 | CTP_PROJECT_KEY=your_project_key_here 6 | CTP_SCOPE=your_scope_here 7 | CTP_REGION=your_region_here 8 | 9 | # Stripe configuration 10 | STRIPE_API_TOKEN=your_stripe_api_token_here 11 | 12 | #C U S T O M T Y P E S C O N F I G U R A T I O N 13 | CUSTOM_TYPE_PRODUCT_KEY=connector-stripe-tax-product 14 | CUSTOM_TYPE_CATEGORY_KEY=connector-stripe-tax-category 15 | CUSTOM_TYPE_SHIPPING_KEY=connector-stripe-tax-shipping 16 | CUSTOM_TYPE_CART_KEY=connector-stripe-tax-calculation-reference 17 | 18 | # T A X C A L C U L A T O R C O N F I G U R A T I O N 19 | # Default behavior for all calculations 20 | TAX_BEHAVIOR_DEFAULT=exclusive 21 | 22 | # Country-specific overrides 23 | TAX_BEHAVIOR_COUNTRY_MAPPING='{"US":"exclusive","CA":"exclusive","DE":"inclusive","FR":"inclusive","GB":"inclusive","AU":"inclusive","IT":"inclusive","ES":"inclusive","NL":"inclusive"}' 24 | 25 | # Tax Code Mapping Configuration 26 | # JSON mapping of commercetools categories to Stripe tax codes 27 | # Format: {"categories": [{"ctCategory": {"id": "category-id", "key": "category-key"}, "taxCode": "txcd_XXXXXXXX"}, ...]} 28 | # Note: Either "id" or "key" must be present in ctCategory (or both) 29 | # Note: During post-deploy installation, all categories are validated to exist in commercetools 30 | # If a category does not exist, the connector installation will fail with an error 31 | # Example: 32 | TAX_CODE_CATEGORY_MAPPING_JSON='{"categories": [{"ctCategory": {"key": "books"}, "taxCode": "txcd_10000000"}, {"ctCategory": {"key": "digital-products"}, "taxCode": "txcd_10401000"}, {"ctCategory": {"key": "clothing"}, "taxCode": "txcd_20030000"}, {"ctCategory": {"key": "food"}, "taxCode": "txcd_30070000"}]}' 33 | 34 | # Ship-from required 35 | SHIP_FROM_REQUIRED=true 36 | 37 | # Default ship-from 38 | SHIP_FROM_DEFAULT_BUSINESS_COUNTRY=US 39 | SHIP_FROM_DEFAULT_BUSINESS_STATE=NY 40 | SHIP_FROM_DEFAULT_BUSINESS_CITY=New York 41 | SHIP_FROM_DEFAULT_BUSINESS_POSTAL_CODE=10001 42 | SHIP_FROM_DEFAULT_BUSINESS_LINE1=123 Main Street 43 | SHIP_FROM_DEFAULT_BUSINESS_LINE2=Suite 100 44 | 45 | # Channel priority 46 | SHIP_FROM_CHANNEL_PRIORITY=channel-id-1,channel-id-2,channel-id-3 47 | 48 | # Connect Service URL (set automatically during deployment) this value come from commercetools 49 | #CONNECT_SERVICE_URL=https://your-connector-url.com 50 | 51 | # A D D R E S S V A L I D A T I O N C O N F I G U R A T I O N 52 | # Rate limiting configuration 53 | ADDRESS_VALIDATION_RATE_LIMIT=100 54 | ADDRESS_VALIDATION_WINDOW_MINUTES=1 55 | 56 | # Default Stripe verification currency 57 | ADDRESS_VALIDATION_STRIPE_DEFAULT_CURRENCY=usd -------------------------------------------------------------------------------- /tax-calculator/src/errors/missingTaxRateForCountry.error.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Custom error thrown when Stripe cannot calculate taxes for a specific country 3 | * This should result in a commercetools MissingTaxRateForCountry validation error (400) response 4 | * 5 | * This error is thrown when: 6 | * - The country is not supported by Stripe Tax 7 | * - No tax registration exists for the country 8 | * - The address cannot be used to determine tax rates 9 | */ 10 | class MissingTaxRateForCountry extends Error { 11 | constructor(country, state = null, originalError = null) { 12 | const stateInfo = state ? ` (${state})` : ''; 13 | const message = `Tax rate not available for country ${country}${stateInfo}. ${ 14 | originalError ? originalError.message : 'This country may not be supported or tax registration is required.' 15 | }`; 16 | 17 | super(message); 18 | 19 | this.name = 'MissingTaxRateForCountry'; 20 | this.country = country; 21 | this.state = state; 22 | this.originalError = originalError; 23 | this.statusCode = 400; // Bad Request - validation error 24 | 25 | // Capture stack trace 26 | if (Error.captureStackTrace) { 27 | Error.captureStackTrace(this, MissingTaxRateForCountry); 28 | } 29 | } 30 | 31 | /** 32 | * Convert error to commercetools API Extension error format 33 | * This matches the commercetools MissingTaxRateForCountry error format 34 | * @returns {Object} Error object in commercetools format 35 | */ 36 | toCommerceToolsError() { 37 | const stateInfo = this.state ? ` and state ${this.state}` : ''; 38 | 39 | return { 40 | code: 'MissingTaxRateForCountry', 41 | message: `Tax rate not available for country ${this.country}${stateInfo}. This may be due to: (1) Country not supported by Stripe Tax, (2) No tax registration for this country, or (3) Invalid address preventing tax calculation.`, 42 | extensionExtraInfo: { 43 | originalError: this.name, 44 | country: this.country, 45 | state: this.state, 46 | stripeErrorCode: this.originalError?.code, 47 | stripeErrorType: this.originalError?.type, 48 | stripeErrorMessage: this.originalError?.message, 49 | action: 'Please verify: (1) Country is supported by Stripe Tax, (2) Tax registration exists for this country in Stripe Dashboard, (3) Customer address is valid and complete.' 50 | } 51 | }; 52 | } 53 | 54 | /** 55 | * Create MissingTaxRateForCountry from Stripe error 56 | * @param {Object} stripeError - Stripe API error object 57 | * @param {string} country - Country code 58 | * @param {string} state - State/province code (optional) 59 | * @returns {MissingTaxRateForCountry} 60 | */ 61 | static fromStripeError(stripeError, country, state = null) { 62 | return new MissingTaxRateForCountry(country, state, stripeError); 63 | } 64 | } 65 | 66 | export default MissingTaxRateForCountry; 67 | -------------------------------------------------------------------------------- /tax-calculator/src/validators/stripeTaxValidator.js: -------------------------------------------------------------------------------- 1 | import stripe from 'stripe'; 2 | import { logger } from '../utils/logger.utils.js'; 3 | 4 | export class StripeTaxValidator { 5 | constructor(apiToken) { 6 | this.stripe = new stripe(apiToken); 7 | } 8 | 9 | /** 10 | * Validates Stripe Tax settings and ensures they are active 11 | * @returns {Promise} Stripe Tax settings object 12 | * @throws {Error} If validation fails 13 | */ 14 | async validateStripeTaxSettings() { 15 | logger.info('Validating Stripe Tax settings...'); 16 | 17 | try { 18 | // Retrieve current tax settings 19 | const settings = await this.stripe.tax.settings.retrieve(); 20 | 21 | if (!settings) { 22 | throw new Error( 23 | 'Unable to retrieve Stripe Tax settings.' 24 | ); 25 | } 26 | 27 | // Validate status 28 | await this.validateTaxStatus(settings); 29 | 30 | // Auto-populate environment variables from Stripe settings 31 | this.autoPopulateDefaults(settings); 32 | 33 | logger.info('Stripe Tax validation successful'); 34 | return settings; 35 | 36 | } catch (error) { 37 | throw new Error(`Stripe Tax validation failed: ${error.message}`, { cause: error }); 38 | } 39 | } 40 | 41 | /** 42 | * Validates that Stripe Tax status is 'active' 43 | * @param {Object} settings - Stripe Tax settings object 44 | * @returns {Promise} 45 | * @throws {Error} If Stripe Tax is not active or missing required configuration 46 | */ 47 | async validateTaxStatus(settings) { 48 | if (settings.status === 'pending') { 49 | const missingFields = settings.status_details?.pending?.missing_fields || []; 50 | throw new Error( 51 | `Stripe Tax is not active. Status: pending. Missing configuration: ${missingFields.join(', ')}. ` 52 | ); 53 | } 54 | 55 | if (settings.status !== 'active') { 56 | throw new Error( 57 | `Stripe Tax is not enabled. Status: ${settings.status}. ` 58 | ); 59 | } 60 | 61 | if (!settings.head_office) { 62 | throw new Error( 63 | `Stripe Tax lacks the head office address configuration.` 64 | ); 65 | } 66 | } 67 | 68 | /** 69 | * Auto-populate TAX_BEHAVIOR_DEFAULT and TAX_CODE_DEFAULT from Stripe settings 70 | * @param {Object} settings - Stripe Tax settings object 71 | */ 72 | autoPopulateDefaults(settings) { 73 | if (!process.env.TAX_BEHAVIOR_DEFAULT && settings.defaults?.tax_behavior) { 74 | process.env.TAX_BEHAVIOR_DEFAULT = settings.defaults.tax_behavior; 75 | logger.info(`Using Stripe default tax_behavior: ${settings.defaults.tax_behavior}`); 76 | } 77 | 78 | 79 | if (!process.env.TAX_CODE_DEFAULT && settings.defaults?.tax_code) { 80 | process.env.TAX_CODE_DEFAULT = settings.defaults.tax_code; 81 | logger.info(`Using Stripe default tax_code: ${settings.defaults.tax_code}`); 82 | } 83 | } 84 | } 85 | 86 | /** 87 | * Convenience function for postDeploy hook 88 | */ 89 | export async function validateStripeTax(apiToken) { 90 | const validator = new StripeTaxValidator(apiToken); 91 | return await validator.validateStripeTaxSettings(); 92 | } 93 | -------------------------------------------------------------------------------- /tax-calculator/src/connectors/post-deploy.js: -------------------------------------------------------------------------------- 1 | import 'dotenv/config'; 2 | 3 | import { createApiRoot } from '../clients/create.client.js'; 4 | import { createCTPExtension, validateTaxCodeMapping, createCustomTypes } from './action.js'; 5 | import { validateStripeTax } from '../validators/stripeTaxValidator.js'; 6 | import { logger } from '../utils/logger.utils.js'; 7 | import { 8 | CONNECT_SERVICE_URL, 9 | CTP_TAX_CALCULATOR_EXTENSION_KEY, 10 | STRIPE_API_TOKEN, 11 | TAX_CODE_CATEGORY_MAPPING_JSON_KEY, 12 | } from './constants.js'; 13 | 14 | /** 15 | * Post-deployment function that validates Stripe Tax configuration and creates the commercetools extension. 16 | * This function is called after the connector is deployed to ensure proper setup and configuration. 17 | * 18 | * @param {Map} properties - Map containing environment variables and configuration properties 19 | * @throws {Error} When required properties are missing or validation/creation fails 20 | * @returns {Promise} Resolves when post-deploy process completes successfully 21 | */ 22 | export async function postDeploy(properties) { 23 | const ctpExtensionBaseUrl = properties.get(CONNECT_SERVICE_URL); 24 | const stripeApiToken = properties.get(STRIPE_API_TOKEN); 25 | 26 | // Validate required properties 27 | if (!stripeApiToken) { 28 | throw new Error(STRIPE_API_TOKEN + ' is required for Stripe Tax validation'); 29 | } 30 | 31 | if (!ctpExtensionBaseUrl) { 32 | throw new Error(CONNECT_SERVICE_URL + ' is required for extension creation'); 33 | } 34 | 35 | logger.info('Starting post-deploy process...'); 36 | logger.info('Validating Stripe Tax configuration...'); 37 | await validateStripeTax(stripeApiToken); 38 | 39 | logger.info('Creating commercetools extension...'); 40 | const taxCodeMappingJson = properties.get(TAX_CODE_CATEGORY_MAPPING_JSON_KEY); 41 | 42 | const apiRoot = createApiRoot(); 43 | 44 | // Step 1: Create custom types for tax code configuration 45 | if (taxCodeMappingJson) { 46 | try { 47 | const mapping = JSON.parse(taxCodeMappingJson); 48 | await validateTaxCodeMapping(apiRoot, mapping); 49 | } catch (error) { 50 | process.stderr.write(`Post-deploy failed: ${error.message}\n`); 51 | if (error instanceof SyntaxError) { 52 | throw new Error( 53 | `Invalid TAX_CODE_CATEGORY_MAPPING_JSON format: ${error.message}` 54 | ); 55 | } 56 | throw error; 57 | } 58 | } else { 59 | logger.info('TAX_CODE_CATEGORY_MAPPING_JSON not provided. Connector will be installed with empty mapping.'); 60 | } 61 | 62 | // Step 2: Create custom types 63 | await createCustomTypes(apiRoot); 64 | 65 | // Step 3: Create API extension for tax calculation 66 | await createCTPExtension( 67 | apiRoot, 68 | CTP_TAX_CALCULATOR_EXTENSION_KEY, 69 | ctpExtensionBaseUrl 70 | ); 71 | 72 | logger.info('Post-deploy completed successfully'); 73 | logger.info('Stripe Tax connector is ready for tax calculations'); 74 | 75 | } 76 | 77 | export async function run() { 78 | try { 79 | const properties = new Map(Object.entries(process.env)); 80 | await postDeploy(properties); 81 | } catch (error) { 82 | process.stderr.write(`Post-deploy failed: ${error.message}\n${error.stack}\n`); 83 | process.exitCode = 1; 84 | } 85 | } 86 | 87 | run(); 88 | -------------------------------------------------------------------------------- /order-syncer/src/extensions/stripe/clients/client.js: -------------------------------------------------------------------------------- 1 | import Stripe from 'stripe'; 2 | import { loadConfig } from '../configurations/config.js'; 3 | import { logger } from '../../../utils/logger.util.js'; 4 | 5 | let stripeClient; 6 | 7 | /** 8 | * Get the Stripe client instance. 9 | * @returns {Stripe} The Stripe client. 10 | */ 11 | function getStripeClient() { 12 | if (!stripeClient) { 13 | stripeClient = new Stripe(loadConfig().taxProviderApiToken); 14 | } 15 | return stripeClient; 16 | } 17 | 18 | /** 19 | * Get tax transaction ID from Tax Calculation. 20 | * @param {string} calculationReference - The calculation reference. 21 | * @param {string} orderId - The order ID. 22 | * @param {string} paymentIntentId - The payment intent ID. 23 | * @returns {Promise} Transaction object. 24 | */ 25 | export async function getTransactionFromTaxCalculation(calculationReference, orderId, paymentIntentId) { 26 | let transaction = {}; 27 | 28 | try { 29 | transaction = await getStripeClient().tax.transactions.createFromCalculation({ 30 | calculation: calculationReference, 31 | reference: calculationReference, 32 | metadata: { 33 | ct_order_id: orderId, 34 | paymentIntentId: paymentIntentId 35 | } 36 | }); 37 | } catch (error) { 38 | transaction.id = error.message.match(/tax transaction (tax_\w+)/)?.[1]; 39 | 40 | if (error.code !== 'resource_missing' && error.statusCode !== 404) { 41 | logger.warn(`Error retrieving Tax Transaction: ${error.message}`); 42 | } 43 | } 44 | 45 | return transaction; 46 | } 47 | 48 | /** 49 | * Create a new tax transaction. 50 | * @param {string} orderId - The ID of the order. 51 | * @param {Array} calculationReferences - The calculation references. 52 | * @param {string} paymentIntentId - The payment intent ID. 53 | * @returns {Promise>} The tax transactions. 54 | */ 55 | export async function createTaxTransactions(orderId, calculationReferences, paymentIntentId) { 56 | const client = getStripeClient(); 57 | 58 | const taxTransactions = []; 59 | 60 | for (const calculationReference of calculationReferences) { 61 | const txnCreateFromCalculationParams = { 62 | calculation: calculationReference, 63 | reference: calculationReference, 64 | metadata: { 65 | ct_order_id: orderId, 66 | paymentIntentId: paymentIntentId 67 | } 68 | }; 69 | 70 | const response = await client.tax.transactions.createFromCalculation(txnCreateFromCalculationParams); 71 | taxTransactions.push(response); 72 | } 73 | 74 | return taxTransactions; 75 | } 76 | 77 | /** 78 | * Update PaymentIntent metadata with transaction IDs. 79 | * @param {string} paymentIntentId - The PaymentIntent ID. 80 | * @param {Array} transactionIds - Transaction IDs. 81 | * @returns {Promise} The updated PaymentIntent metadata. 82 | */ 83 | export async function updatePaymentIntentMetadata(paymentIntentId, transactionIds) { 84 | try { 85 | await getStripeClient().paymentIntents.update(paymentIntentId, { 86 | metadata: { 87 | tax_transactions: transactionIds.join(', ') 88 | }, 89 | }); 90 | logger.info(`Updated PaymentIntent ${paymentIntentId} with metadata: ${transactionIds.join(', ')}`); 91 | } catch (error) { 92 | logger.warn(`Could not update PaymentIntent metadata: ${error.message}`); 93 | } 94 | } -------------------------------------------------------------------------------- /order-syncer/test/unit/clients/build.client.spec.js: -------------------------------------------------------------------------------- 1 | import { expect, describe, it, jest, beforeEach } from '@jest/globals'; 2 | import { ClientBuilder } from '@commercetools/sdk-client-v2'; 3 | 4 | // Mock dependencies 5 | jest.mock('@commercetools/sdk-client-v2'); 6 | jest.mock('../../../src/middlewares/auth.middleware.js', () => ({ 7 | getAuthMiddlewareOptions: jest.fn(), 8 | })); 9 | jest.mock('../../../src/middlewares/http.middleware.js', () => ({ 10 | getHttpMiddlewareOptions: jest.fn(), 11 | })); 12 | jest.mock('../../../src/utils/config.util.js', () => ({ 13 | __esModule: true, 14 | default: jest.fn(), 15 | })); 16 | 17 | import { createClient } from '../../../src/clients/build.client.js'; 18 | import { getAuthMiddlewareOptions } from '../../../src/middlewares/auth.middleware.js'; 19 | import { getHttpMiddlewareOptions } from '../../../src/middlewares/http.middleware.js'; 20 | import readConfiguration from '../../../src/utils/config.util.js'; 21 | 22 | describe('build.client.spec', () => { 23 | let mockClientBuilder; 24 | let mockClient; 25 | 26 | beforeEach(() => { 27 | jest.clearAllMocks(); 28 | 29 | mockClient = { 30 | execute: jest.fn(), 31 | }; 32 | 33 | mockClientBuilder = { 34 | withProjectKey: jest.fn().mockReturnThis(), 35 | withClientCredentialsFlow: jest.fn().mockReturnThis(), 36 | withHttpMiddleware: jest.fn().mockReturnThis(), 37 | build: jest.fn().mockReturnValue(mockClient), 38 | }; 39 | 40 | ClientBuilder.mockImplementation(() => mockClientBuilder); 41 | 42 | readConfiguration.mockReturnValue({ 43 | projectKey: 'test-project-key', 44 | }); 45 | 46 | getAuthMiddlewareOptions.mockReturnValue({ 47 | host: 'https://auth.us-central1.gcp.commercetools.com', 48 | projectKey: 'test-project-key', 49 | credentials: { 50 | clientId: 'test-client-id', 51 | clientSecret: 'test-client-secret', 52 | }, 53 | scopes: ['manage_project'], 54 | }); 55 | 56 | getHttpMiddlewareOptions.mockReturnValue({ 57 | host: 'https://api.us-central1.gcp.commercetools.com', 58 | }); 59 | }); 60 | 61 | describe('createClient', () => { 62 | it('should create a client with correct configuration', () => { 63 | const client = createClient(); 64 | 65 | expect(ClientBuilder).toHaveBeenCalled(); 66 | expect(readConfiguration).toHaveBeenCalled(); 67 | expect(mockClientBuilder.withProjectKey).toHaveBeenCalledWith('test-project-key'); 68 | expect(getAuthMiddlewareOptions).toHaveBeenCalled(); 69 | expect(mockClientBuilder.withClientCredentialsFlow).toHaveBeenCalledWith({ 70 | host: 'https://auth.us-central1.gcp.commercetools.com', 71 | projectKey: 'test-project-key', 72 | credentials: { 73 | clientId: 'test-client-id', 74 | clientSecret: 'test-client-secret', 75 | }, 76 | scopes: ['manage_project'], 77 | }); 78 | expect(getHttpMiddlewareOptions).toHaveBeenCalled(); 79 | expect(mockClientBuilder.withHttpMiddleware).toHaveBeenCalledWith({ 80 | host: 'https://api.us-central1.gcp.commercetools.com', 81 | }); 82 | expect(mockClientBuilder.build).toHaveBeenCalled(); 83 | expect(client).toBe(mockClient); 84 | }); 85 | 86 | it('should call readConfiguration to get projectKey', () => { 87 | readConfiguration.mockReturnValue({ 88 | projectKey: 'another-project-key', 89 | }); 90 | 91 | createClient(); 92 | 93 | expect(mockClientBuilder.withProjectKey).toHaveBeenCalledWith('another-project-key'); 94 | }); 95 | }); 96 | }); 97 | 98 | -------------------------------------------------------------------------------- /order-syncer/test/integration/sync.route.spec.js: -------------------------------------------------------------------------------- 1 | import { expect, describe, afterAll, it, jest } from '@jest/globals'; 2 | import request from 'supertest'; 3 | import express from 'express'; 4 | import bodyParser from 'body-parser'; 5 | import SyncRoutes from '../../src/routes/sync.route.js'; 6 | import { encodeJsonObject } from './utils/encoder.utils.js'; 7 | import { HTTP_STATUS_SUCCESS_ACCEPTED } from '../../src/constants/http.status.constants.js'; 8 | 9 | // Mock the clients that make external API calls 10 | jest.mock('../../src/clients/query.client.js', () => ({ 11 | getOrderWithPaymentInfo: jest.fn().mockRejectedValue( 12 | new (class CustomError extends Error { 13 | constructor(statusCode, message) { 14 | super(message); 15 | this.statusCode = statusCode; 16 | this.message = message; 17 | } 18 | })(202, 'Order not found') 19 | ), 20 | getOrder: jest.fn().mockResolvedValue({ id: 'dummy-order-id', version: 1 }), 21 | })); 22 | 23 | jest.mock('../../src/extensions/stripe/clients/client.js', () => ({ 24 | __esModule: true, 25 | default: jest.fn().mockResolvedValue([]), 26 | })); 27 | 28 | jest.mock('../../src/clients/update.client.js', () => ({ 29 | updateOrderTaxTxn: jest.fn().mockResolvedValue({}), 30 | })); 31 | 32 | // Mock server setup 33 | const app = express(); 34 | app.use(bodyParser.json()); 35 | app.use(bodyParser.urlencoded({ extended: true })); 36 | app.use('/orderSyncer', SyncRoutes); 37 | /** Reminder : Please put mandatory environment variables in the settings of your github repository **/ 38 | describe('Test sync.route.js', () => { 39 | it(`When a non-existent resource identifier is in the URL, it should returns 404 http status`, async () => { 40 | let response = {}; 41 | // Send request to the connector application with following code snippet. 42 | 43 | response = await request(app).post(`/non-existent-resource`); 44 | expect(response).toBeDefined(); 45 | expect(response.statusCode).toEqual(404); 46 | }); 47 | 48 | it(`When payload body does not exist, it should returns 400 http status`, async () => { 49 | let response = {}; 50 | // Send request to the connector application with following code snippet. 51 | let payload = {}; 52 | response = await request(app).post(`/orderSyncer`).send(payload); 53 | 54 | expect(response).toBeDefined(); 55 | expect(response.statusCode).toEqual(HTTP_STATUS_SUCCESS_ACCEPTED); 56 | }); 57 | 58 | // This test needs Environment variables to be set 59 | it(`When payload body exists without correct order ID, it should returns 202 http status`, async () => { 60 | let response = {}; 61 | // Send request to the connector application with following code snippet. 62 | // Following incoming message data is an example. Please define incoming message based on resources identifer in your own Commercetools project 63 | const incomingMessageData = { 64 | notificationType: 'Message', 65 | resource: { typeId: 'order', id: 'dummy-product-id' }, 66 | type: 'OrderCreated', 67 | resourceUserProvidedIdentifiers: { orderNumber: 'dummy-order-number' }, 68 | version: 11, 69 | oldVersion: 10, 70 | modifiedAt: '2023-09-12T00:00:00.000Z', 71 | }; 72 | 73 | const encodedMessageData = encodeJsonObject(incomingMessageData); 74 | let payload = { 75 | message: { 76 | data: encodedMessageData, 77 | }, 78 | }; 79 | response = await request(app).post(`/orderSyncer`).send(payload); 80 | 81 | expect(response).toBeDefined(); 82 | expect(response.statusCode).toEqual(HTTP_STATUS_SUCCESS_ACCEPTED); 83 | }); 84 | 85 | afterAll(() => { 86 | // Mock server doesn't need to be closed 87 | }); 88 | }); 89 | -------------------------------------------------------------------------------- /order-syncer/test/unit/middlewares/auth.middleware.spec.js: -------------------------------------------------------------------------------- 1 | import { expect, describe, it, jest, beforeEach } from '@jest/globals'; 2 | 3 | // Mock dependencies 4 | jest.mock('../../../src/utils/config.util.js', () => ({ 5 | __esModule: true, 6 | default: jest.fn(), 7 | })); 8 | 9 | import readConfiguration from '../../../src/utils/config.util.js'; 10 | import { getAuthMiddlewareOptions } from '../../../src/middlewares/auth.middleware.js'; 11 | 12 | describe('auth.middleware.spec', () => { 13 | beforeEach(() => { 14 | jest.clearAllMocks(); 15 | }); 16 | 17 | describe('getAuthMiddlewareOptions', () => { 18 | it('should return auth middleware options with all configuration', () => { 19 | readConfiguration.mockReturnValue({ 20 | region: 'us-central1.gcp', 21 | projectKey: 'test-project-key', 22 | clientId: 'test-client-id', 23 | clientSecret: 'test-client-secret', 24 | scope: 'manage_project:test-project', 25 | }); 26 | 27 | const result = getAuthMiddlewareOptions(); 28 | 29 | expect(readConfiguration).toHaveBeenCalled(); 30 | expect(result).toEqual({ 31 | host: 'https://auth.us-central1.gcp.commercetools.com', 32 | projectKey: 'test-project-key', 33 | credentials: { 34 | clientId: 'test-client-id', 35 | clientSecret: 'test-client-secret', 36 | }, 37 | scopes: ['manage_project:test-project'], 38 | }); 39 | }); 40 | 41 | it('should use default scope when scope is not provided', () => { 42 | readConfiguration.mockReturnValue({ 43 | region: 'europe-west1.gcp', 44 | projectKey: 'test-project-key', 45 | clientId: 'test-client-id', 46 | clientSecret: 'test-client-secret', 47 | scope: undefined, 48 | }); 49 | 50 | const result = getAuthMiddlewareOptions(); 51 | 52 | expect(result.scopes).toEqual(['default']); 53 | }); 54 | 55 | it('should use default scope when scope is null', () => { 56 | readConfiguration.mockReturnValue({ 57 | region: 'us-east-2.aws', 58 | projectKey: 'test-project-key', 59 | clientId: 'test-client-id', 60 | clientSecret: 'test-client-secret', 61 | scope: null, 62 | }); 63 | 64 | const result = getAuthMiddlewareOptions(); 65 | 66 | expect(result.scopes).toEqual(['default']); 67 | }); 68 | 69 | it('should use default scope when scope is empty string', () => { 70 | readConfiguration.mockReturnValue({ 71 | region: 'eu-central-1.aws', 72 | projectKey: 'test-project-key', 73 | clientId: 'test-client-id', 74 | clientSecret: 'test-client-secret', 75 | scope: '', 76 | }); 77 | 78 | const result = getAuthMiddlewareOptions(); 79 | 80 | expect(result.scopes).toEqual(['default']); 81 | }); 82 | 83 | it('should handle different regions correctly', () => { 84 | const regions = [ 85 | 'us-central1.gcp', 86 | 'us-east-2.aws', 87 | 'europe-west1.gcp', 88 | 'eu-central-1.aws', 89 | 'australia-southeast1.gcp', 90 | ]; 91 | 92 | regions.forEach((region) => { 93 | readConfiguration.mockReturnValue({ 94 | region: region, 95 | projectKey: 'test-project-key', 96 | clientId: 'test-client-id', 97 | clientSecret: 'test-client-secret', 98 | scope: 'manage_project', 99 | }); 100 | 101 | const result = getAuthMiddlewareOptions(); 102 | 103 | expect(result.host).toBe(`https://auth.${region}.commercetools.com`); 104 | }); 105 | }); 106 | }); 107 | }); 108 | 109 | -------------------------------------------------------------------------------- /order-syncer/src/validators/helpers.validator.js: -------------------------------------------------------------------------------- 1 | import validator from 'validator'; 2 | 3 | /** 4 | * File used to create helpers to validate the fields 5 | */ 6 | 7 | const required = 8 | (fn) => 9 | (value, ...args) => 10 | !(value === undefined || value === null) && fn(...[String(value), ...args]); 11 | 12 | export const standardString = (path, message, overrideConfig = {}) => [ 13 | path, 14 | [ 15 | [ 16 | required(validator.isLength), 17 | message, 18 | [{ min: 2, max: 20, ...overrideConfig }], 19 | ], 20 | ], 21 | ]; 22 | 23 | export const standardEmail = (path, message) => [ 24 | path, 25 | [[required(validator.isEmail), message]], 26 | ]; 27 | 28 | export const standardNaturalNumber = (path, message) => [ 29 | path, 30 | [ 31 | [ 32 | required((value) => 33 | validator.isNumeric(String(value), { no_symbols: true }) 34 | ), 35 | message, 36 | ], 37 | ], 38 | ]; 39 | 40 | export const standardKey = (path, message) => [ 41 | path, 42 | [ 43 | [ 44 | required( 45 | (value) => 46 | validator.isLength(String(value), { min: 2 }) && 47 | /^[a-zA-Z0-9-_]+$/.test(value) 48 | ), 49 | 50 | message, 51 | ], 52 | ], 53 | ]; 54 | 55 | export const standardUrl = (path, message, overrideOptions = {}) => [ 56 | path, 57 | [ 58 | [ 59 | required(validator.isURL), 60 | message, 61 | [ 62 | { 63 | require_protocol: true, 64 | require_valid_protocol: true, 65 | protocols: ['http', 'https'], 66 | require_host: true, 67 | require_port: false, 68 | allow_protocol_relative_urls: false, 69 | allow_fragments: false, 70 | allow_query_components: true, 71 | validate_length: true, 72 | ...overrideOptions, 73 | }, 74 | ], 75 | ], 76 | ], 77 | ]; 78 | 79 | export const getValidateMessages = (validatorConfigs, item) => 80 | validatorConfigs.flatMap(([path, validators]) => { 81 | return validators.reduce((acc, [validatorFn, message, args = []]) => { 82 | const valueToValidate = path.reduce((val, property) => { 83 | return val[property]; 84 | }, item); 85 | if (!validatorFn(...[valueToValidate, ...args])) { 86 | return acc.concat(message); 87 | } 88 | return acc; 89 | }, []); 90 | }); 91 | 92 | export const optional = 93 | (fn) => 94 | (...args) => { 95 | const [path, validators] = fn(...args); 96 | return [ 97 | path, 98 | validators.map(([fn, message, validatorArgs]) => [ 99 | (value, ...args) => 100 | value === undefined ? true : fn(...[value, ...args]), 101 | message, 102 | validatorArgs, 103 | ]), 104 | ]; 105 | }; 106 | 107 | export const array = 108 | (fn) => 109 | (...args) => { 110 | const [path, validators] = fn(...args); 111 | return [ 112 | path, 113 | validators.map(([fn, message, validatorArgs]) => [ 114 | (value, ...args) => 115 | Array.isArray(value) && 116 | value.every((value) => fn(...[value, ...args])), 117 | message, 118 | validatorArgs, 119 | ]), 120 | ]; 121 | }; 122 | 123 | export const region = (path, message) => [ 124 | path, 125 | [ 126 | [ 127 | required( 128 | required((value) => 129 | validator.isIn(value, [ 130 | 'us-central1.gcp', 131 | 'us-east-2.aws', 132 | 'europe-west1.gcp', 133 | 'eu-central-1.aws', 134 | 'australia-southeast1.gcp', 135 | ]) 136 | ) 137 | ), 138 | message, 139 | ], 140 | ], 141 | ]; 142 | -------------------------------------------------------------------------------- /tax-calculator/test/unit/middlewares/auth.middleware.spec.js: -------------------------------------------------------------------------------- 1 | import { expect, describe, it, jest, beforeEach, afterEach } from '@jest/globals'; 2 | import { getAuthMiddlewareOptions } from '../../../src/middlewares/auth.middleware.js'; 3 | 4 | // Mock dependencies 5 | jest.mock('../../../src/utils/config.util.js', () => ({ 6 | __esModule: true, 7 | default: { 8 | readConfiguration: jest.fn() 9 | } 10 | })); 11 | 12 | import configUtils from '../../../src/utils/config.util.js'; 13 | 14 | describe('auth.middleware', () => { 15 | let originalEnv; 16 | 17 | beforeEach(() => { 18 | originalEnv = { ...process.env }; 19 | jest.clearAllMocks(); 20 | }); 21 | 22 | afterEach(() => { 23 | process.env = originalEnv; 24 | }); 25 | 26 | describe('getAuthMiddlewareOptions', () => { 27 | it('should return auth middleware options with scope when config.scope is set', () => { 28 | const mockConfig = { 29 | region: 'us-central1', 30 | projectKey: 'test-project', 31 | clientId: 'test-client-id', 32 | clientSecret: 'test-client-secret', 33 | scope: 'manage:test' 34 | }; 35 | 36 | configUtils.readConfiguration.mockReturnValue(mockConfig); 37 | 38 | const result = getAuthMiddlewareOptions(); 39 | 40 | expect(result).toEqual({ 41 | host: 'https://auth.us-central1.commercetools.com', 42 | projectKey: 'test-project', 43 | credentials: { 44 | clientId: 'test-client-id', 45 | clientSecret: 'test-client-secret' 46 | }, 47 | scopes: ['manage:test'] 48 | }); 49 | }); 50 | 51 | it('should return auth middleware options with default scope when config.scope is not set', () => { 52 | const mockConfig = { 53 | region: 'europe-west1', 54 | projectKey: 'test-project-2', 55 | clientId: 'test-client-id-2', 56 | clientSecret: 'test-client-secret-2', 57 | scope: null 58 | }; 59 | 60 | configUtils.readConfiguration.mockReturnValue(mockConfig); 61 | 62 | const result = getAuthMiddlewareOptions(); 63 | 64 | expect(result).toEqual({ 65 | host: 'https://auth.europe-west1.commercetools.com', 66 | projectKey: 'test-project-2', 67 | credentials: { 68 | clientId: 'test-client-id-2', 69 | clientSecret: 'test-client-secret-2' 70 | }, 71 | scopes: ['default'] 72 | }); 73 | }); 74 | 75 | it('should return auth middleware options with default scope when config.scope is undefined', () => { 76 | const mockConfig = { 77 | region: 'us-east-2', 78 | projectKey: 'test-project-3', 79 | clientId: 'test-client-id-3', 80 | clientSecret: 'test-client-secret-3' 81 | // scope is undefined 82 | }; 83 | 84 | configUtils.readConfiguration.mockReturnValue(mockConfig); 85 | 86 | const result = getAuthMiddlewareOptions(); 87 | 88 | expect(result.scopes).toEqual(['default']); 89 | }); 90 | 91 | it('should handle different regions correctly', () => { 92 | const regions = ['us-central1', 'us-east-2', 'europe-west1', 'eu-central-1', 'australia-southeast1']; 93 | 94 | regions.forEach(region => { 95 | const mockConfig = { 96 | region, 97 | projectKey: 'test-project', 98 | clientId: 'test-client-id', 99 | clientSecret: 'test-client-secret', 100 | scope: 'default' 101 | }; 102 | 103 | configUtils.readConfiguration.mockReturnValue(mockConfig); 104 | 105 | const result = getAuthMiddlewareOptions(); 106 | 107 | expect(result.host).toBe(`https://auth.${region}.commercetools.com`); 108 | }); 109 | }); 110 | }); 111 | }); 112 | 113 | -------------------------------------------------------------------------------- /tax-calculator/src/errors/taxCodeShippingNotFound.error.js: -------------------------------------------------------------------------------- 1 | import { TAX_CODE_CUSTOM_TYPE_NAME } from "../connectors/customTypes.js"; 2 | 3 | /** 4 | * Custom error thrown when no tax code can be determined for a shipping method 5 | * This should result in a commercetools validation error (400) response 6 | */ 7 | class TaxCodeShippingNotFoundError extends Error { 8 | constructor(shippingMethodId, shippingMethodTypeId, shippingMethodObj, shippingMode = 'Single') { 9 | const shippingMethodName = shippingMethodObj?.name || 'Unknown Shipping Method'; 10 | const shippingModeText = shippingMode === 'Multiple' ? 'multiple shipping methods' : 'shipping method'; 11 | 12 | const message = shippingMode === 'Multiple' 13 | ? `No tax code mapping found for any of the ${shippingModeText}. Please configure a tax code for at least one shipping method.` 14 | : `No tax code mapping found for ${shippingModeText} "${shippingMethodName}" (${shippingMethodId}). Please configure a tax code for this shipping method.`; 15 | 16 | super(message); 17 | 18 | this.name = 'TaxCodeShippingNotFoundError'; 19 | this.shippingMethodId = shippingMethodId; 20 | this.shippingMethodTypeId = shippingMethodTypeId; 21 | this.shippingMethodObj = shippingMethodObj; 22 | this.shippingMode = shippingMode; 23 | this.statusCode = 400; // Bad Request - validation error 24 | 25 | // Capture stack trace 26 | if (Error.captureStackTrace) { 27 | Error.captureStackTrace(this, TaxCodeShippingNotFoundError); 28 | } 29 | } 30 | 31 | /** 32 | * Convert error to commercetools API Extension error format 33 | * @returns {Object} Error object in commercetools format 34 | */ 35 | toCommerceToolsError() { 36 | const baseError = { 37 | code: 'InvalidInput', 38 | message: this.message, 39 | extensionExtraInfo: { 40 | originalError: 'TaxCodeShippingNotFound', 41 | shippingMode: this.shippingMode, 42 | action: this.getActionMessage() 43 | } 44 | }; 45 | 46 | // Add specific information based on shipping mode 47 | if (this.shippingMode === 'Single') { 48 | baseError.extensionExtraInfo.shippingMethodId = this.shippingMethodId; 49 | baseError.extensionExtraInfo.shippingMethodName = this.shippingMethodObj?.name || 'Unknown Shipping Method'; 50 | baseError.extensionExtraInfo.shippingMethodTypeId = this.shippingMethodTypeId; 51 | } else { 52 | baseError.extensionExtraInfo.affectedShippingMethods = this.getAffectedShippingMethods(); 53 | } 54 | 55 | return baseError; 56 | } 57 | 58 | /** 59 | * Get action message based on shipping mode 60 | * @returns {string} Action message 61 | */ 62 | getActionMessage() { 63 | if (this.shippingMode === 'Single') { 64 | return `Please configure a tax code for this shipping method by adding a custom field "${TAX_CODE_CUSTOM_TYPE_NAME}" to the shipping method.`; 65 | } else { 66 | return `Please configure a tax code for at least one shipping method by adding a custom field "${TAX_CODE_CUSTOM_TYPE_NAME}" to the shipping methods.`; 67 | } 68 | } 69 | 70 | /** 71 | * Get affected shipping methods for Multiple mode 72 | * @returns {Array} Array of affected shipping methods 73 | */ 74 | getAffectedShippingMethods() { 75 | if (this.shippingMode === 'Multiple' && this.shippingMethodObj) { 76 | return Array.isArray(this.shippingMethodObj) 77 | ? this.shippingMethodObj.map(shipping => ({ 78 | shippingKey: shipping.shippingKey, 79 | shippingMethodId: shipping.shippingInfo?.shippingMethod?.id, 80 | shippingMethodName: shipping.shippingInfo?.shippingMethod?.obj?.name, 81 | hasTaxCode: !!shipping.shippingInfo?.shippingMethod?.obj?.custom?.fields?.[TAX_CODE_CUSTOM_TYPE_NAME] 82 | })) 83 | : []; 84 | } 85 | return []; 86 | } 87 | } 88 | 89 | export default TaxCodeShippingNotFoundError; -------------------------------------------------------------------------------- /tax-calculator/test/unit/connectors/pre-undeploy.spec.js: -------------------------------------------------------------------------------- 1 | import { expect, describe, it, jest, beforeEach, afterEach } from '@jest/globals'; 2 | import { createApiRoot } from '../../../src/clients/create.client.js'; 3 | import { deleteCTPExtension, deleteCustomTypes } from '../../../src/connectors/action.js'; 4 | import { CTP_TAX_CALCULATOR_EXTENSION_KEY } from '../../../src/connectors/constants.js'; 5 | 6 | // Mock dependencies 7 | jest.mock('../../../src/clients/create.client.js', () => ({ 8 | createApiRoot: jest.fn(), 9 | })); 10 | 11 | jest.mock('../../../src/connectors/action.js', () => ({ 12 | deleteCTPExtension: jest.fn(), 13 | deleteCustomTypes: jest.fn(), 14 | })); 15 | 16 | jest.mock('dotenv/config', () => ({}), { virtual: true }); 17 | 18 | describe('pre-undeploy.js', () => { 19 | let mockApiRoot; 20 | let originalExitCode; 21 | let originalStderrWrite; 22 | 23 | // Replicate the functions from pre-undeploy.js for testing 24 | async function preUndeploy() { 25 | const apiRoot = createApiRoot(); 26 | await deleteCTPExtension(apiRoot, CTP_TAX_CALCULATOR_EXTENSION_KEY); 27 | await deleteCustomTypes(apiRoot, true); 28 | } 29 | 30 | async function run() { 31 | try { 32 | await preUndeploy(); 33 | } catch (error) { 34 | process.stderr.write(`Pre-undeploy failed: ${error.message}\n`); 35 | process.exitCode = 1; 36 | } 37 | } 38 | 39 | beforeEach(() => { 40 | jest.clearAllMocks(); 41 | 42 | mockApiRoot = { mock: 'apiRoot' }; 43 | createApiRoot.mockReturnValue(mockApiRoot); 44 | deleteCTPExtension.mockResolvedValue(undefined); 45 | deleteCustomTypes.mockResolvedValue(undefined); 46 | 47 | originalExitCode = process.exitCode; 48 | originalStderrWrite = process.stderr.write; 49 | process.exitCode = undefined; 50 | process.stderr.write = jest.fn(); 51 | }); 52 | 53 | afterEach(() => { 54 | process.exitCode = originalExitCode; 55 | process.stderr.write = originalStderrWrite; 56 | }); 57 | 58 | describe('preUndeploy', () => { 59 | it('should create API root and delete extension and custom types', async () => { 60 | await preUndeploy(); 61 | 62 | expect(createApiRoot).toHaveBeenCalledTimes(1); 63 | expect(deleteCTPExtension).toHaveBeenCalledWith( 64 | mockApiRoot, 65 | CTP_TAX_CALCULATOR_EXTENSION_KEY 66 | ); 67 | expect(deleteCustomTypes).toHaveBeenCalledWith(mockApiRoot, true); 68 | }); 69 | 70 | it('should call deleteCTPExtension before deleteCustomTypes', async () => { 71 | const callOrder = []; 72 | 73 | deleteCTPExtension.mockImplementation(async () => { 74 | callOrder.push('deleteCTPExtension'); 75 | }); 76 | 77 | deleteCustomTypes.mockImplementation(async () => { 78 | callOrder.push('deleteCustomTypes'); 79 | }); 80 | 81 | await preUndeploy(); 82 | 83 | expect(callOrder[0]).toBe('deleteCTPExtension'); 84 | expect(callOrder[1]).toBe('deleteCustomTypes'); 85 | }); 86 | }); 87 | 88 | describe('run', () => { 89 | it('should execute preUndeploy successfully', async () => { 90 | await run(); 91 | 92 | expect(createApiRoot).toHaveBeenCalled(); 93 | expect(deleteCTPExtension).toHaveBeenCalled(); 94 | expect(deleteCustomTypes).toHaveBeenCalled(); 95 | expect(process.exitCode).toBeUndefined(); 96 | expect(process.stderr.write).not.toHaveBeenCalled(); 97 | }); 98 | 99 | it('should handle errors and set exit code', async () => { 100 | const error = new Error('Deletion failed'); 101 | deleteCTPExtension.mockRejectedValue(error); 102 | 103 | await run(); 104 | 105 | expect(process.stderr.write).toHaveBeenCalledWith( 106 | `Pre-undeploy failed: ${error.message}\n` 107 | ); 108 | expect(process.exitCode).toBe(1); 109 | }); 110 | 111 | it('should handle errors from deleteCustomTypes', async () => { 112 | const error = new Error('Custom types deletion failed'); 113 | deleteCustomTypes.mockRejectedValue(error); 114 | 115 | await run(); 116 | 117 | expect(process.stderr.write).toHaveBeenCalledWith( 118 | `Pre-undeploy failed: ${error.message}\n` 119 | ); 120 | expect(process.exitCode).toBe(1); 121 | }); 122 | }); 123 | }); 124 | -------------------------------------------------------------------------------- /tax-calculator/test/unit/errors/taxCodeNotFound.error.spec.js: -------------------------------------------------------------------------------- 1 | import { expect, describe, it } from '@jest/globals'; 2 | import TaxCodeNotFoundError from '../../../src/errors/taxCodeNotFound.error.js'; 3 | 4 | describe('TaxCodeNotFoundError', () => { 5 | it('should create error with productId and productName', () => { 6 | const error = new TaxCodeNotFoundError('product-123', 'Test Product', []); 7 | 8 | expect(error).toBeInstanceOf(Error); 9 | expect(error.name).toBe('TaxCodeNotFoundError'); 10 | expect(error.productId).toBe('product-123'); 11 | expect(error.productName).toBe('Test Product'); 12 | expect(error.categories).toEqual([]); 13 | expect(error.statusCode).toBe(400); 14 | }); 15 | 16 | it('should create error message with categories', () => { 17 | const categories = [ 18 | { id: 'cat-1', name: 'Electronics', key: 'electronics' }, 19 | { id: 'cat-2', name: 'Computers', key: 'computers' } 20 | ]; 21 | const error = new TaxCodeNotFoundError('product-123', 'Laptop', categories); 22 | 23 | expect(error.message).toContain('Laptop'); 24 | expect(error.message).toContain('product-123'); 25 | expect(error.message).toContain('Electronics'); 26 | expect(error.message).toContain('Computers'); 27 | }); 28 | 29 | it('should create error message without categories when empty', () => { 30 | const error = new TaxCodeNotFoundError('product-123', 'Test Product', []); 31 | 32 | expect(error.message).toContain('Test Product'); 33 | expect(error.message).toContain('product-123'); 34 | expect(error.message).toContain('No categories assigned'); 35 | }); 36 | 37 | it('should create error message when categories is null', () => { 38 | const error = new TaxCodeNotFoundError('product-123', 'Test Product', null); 39 | 40 | expect(error.message).toContain('No categories assigned'); 41 | expect(error.categories).toEqual([]); 42 | }); 43 | 44 | it('should convert to commercetools error format', () => { 45 | const categories = [ 46 | { id: 'cat-1', name: 'Electronics', key: 'electronics' } 47 | ]; 48 | const error = new TaxCodeNotFoundError('product-123', 'Laptop', categories); 49 | 50 | const commercetoolsError = error.toCommerceToolsError(); 51 | 52 | expect(commercetoolsError).toEqual({ 53 | code: 'InvalidInput', 54 | message: error.message, 55 | extensionExtraInfo: { 56 | originalError: 'TaxCodeNotFound', 57 | productId: 'product-123', 58 | productName: 'Laptop', 59 | categories: [ 60 | { 61 | id: 'cat-1', 62 | name: 'Electronics', 63 | key: 'electronics' 64 | } 65 | ], 66 | action: 'Please configure a tax code mapping for one of these categories or add a custom taxCode field to the product.' 67 | } 68 | }); 69 | }); 70 | 71 | it('should handle categories with missing name or key', () => { 72 | const categories = [ 73 | { id: 'cat-1' }, 74 | { id: 'cat-2', name: 'Electronics' }, 75 | { id: 'cat-3', key: 'electronics' } 76 | ]; 77 | const error = new TaxCodeNotFoundError('product-123', 'Test Product', categories); 78 | 79 | const commercetoolsError = error.toCommerceToolsError(); 80 | 81 | expect(commercetoolsError.extensionExtraInfo.categories).toHaveLength(3); 82 | expect(commercetoolsError.extensionExtraInfo.categories[0]).toEqual({ 83 | id: 'cat-1', 84 | name: undefined, 85 | key: undefined 86 | }); 87 | }); 88 | 89 | it('should capture stack trace when available', () => { 90 | const error = new TaxCodeNotFoundError('product-123', 'Test Product', []); 91 | 92 | // Stack trace should be captured if Error.captureStackTrace is available 93 | if (Error.captureStackTrace) { 94 | expect(error.stack).toBeDefined(); 95 | } 96 | }); 97 | 98 | it('should be throwable and catchable', () => { 99 | expect(() => { 100 | throw new TaxCodeNotFoundError('product-123', 'Test Product', []); 101 | }).toThrow(); 102 | 103 | try { 104 | throw new TaxCodeNotFoundError('product-123', 'Test Product', []); 105 | } catch (error) { 106 | expect(error).toBeInstanceOf(TaxCodeNotFoundError); 107 | expect(error.statusCode).toBe(400); 108 | } 109 | }); 110 | }); 111 | 112 | -------------------------------------------------------------------------------- /tax-calculator/test/unit/middlewares/rate.limiter.middleware.spec.js: -------------------------------------------------------------------------------- 1 | import { expect, describe, it, jest, beforeEach } from '@jest/globals'; 2 | import { rateLimiterMiddleware } from '../../../src/middlewares/rate.limiter.middleware.js'; 3 | 4 | // Mock express-rate-limit 5 | jest.mock('express-rate-limit', () => { 6 | return jest.fn((options) => { 7 | // Return a middleware function that calls the handler when limit is exceeded 8 | return (req, res, next) => { 9 | // Simulate rate limit check 10 | if (options.handler) { 11 | // Call handler to test it 12 | return options.handler(req, res); 13 | } 14 | next(); 15 | }; 16 | }); 17 | }); 18 | 19 | import rateLimit from 'express-rate-limit'; 20 | 21 | describe('rateLimiterMiddleware', () => { 22 | let mockRequest; 23 | let mockResponse; 24 | let statusSpy; 25 | let jsonSpy; 26 | let nextSpy; 27 | 28 | beforeEach(() => { 29 | jsonSpy = jest.fn(); 30 | statusSpy = jest.fn().mockReturnValue({ json: jsonSpy }); 31 | nextSpy = jest.fn(); 32 | 33 | mockRequest = { 34 | ip: '127.0.0.1' 35 | }; 36 | 37 | mockResponse = { 38 | status: statusSpy, 39 | json: jsonSpy 40 | }; 41 | 42 | jest.clearAllMocks(); 43 | }); 44 | 45 | it('should create rate limiter with default values', () => { 46 | const middleware = rateLimiterMiddleware(); 47 | 48 | expect(rateLimit).toHaveBeenCalledWith( 49 | expect.objectContaining({ 50 | windowMs: 60 * 1000, // 1 minute 51 | max: 100, 52 | standardHeaders: true, 53 | legacyHeaders: false 54 | }) 55 | ); 56 | }); 57 | 58 | it('should create rate limiter with custom maxRequests and windowMinutes', () => { 59 | const middleware = rateLimiterMiddleware(50, 5); 60 | 61 | expect(rateLimit).toHaveBeenCalledWith( 62 | expect.objectContaining({ 63 | windowMs: 5 * 60 * 1000, // 5 minutes 64 | max: 50 65 | }) 66 | ); 67 | }); 68 | 69 | it('should have correct message in default configuration', () => { 70 | const middleware = rateLimiterMiddleware(); 71 | const config = rateLimit.mock.calls[0][0]; 72 | 73 | expect(config.message).toEqual({ 74 | error: 'Too many requests', 75 | message: 'Please try again later.', 76 | retryAfter: 60 77 | }); 78 | }); 79 | 80 | it('should have correct message with custom windowMinutes', () => { 81 | const middleware = rateLimiterMiddleware(100, 10); 82 | const config = rateLimit.mock.calls[0][0]; 83 | 84 | expect(config.message).toEqual({ 85 | error: 'Too many requests', 86 | message: 'Please try again later.', 87 | retryAfter: 600 // 10 minutes in seconds 88 | }); 89 | }); 90 | 91 | it('should call handler with correct response when rate limit is exceeded', () => { 92 | const middleware = rateLimiterMiddleware(100, 1); 93 | const config = rateLimit.mock.calls[0][0]; 94 | 95 | // Call the handler directly to test it 96 | config.handler(mockRequest, mockResponse); 97 | 98 | expect(statusSpy).toHaveBeenCalledWith(429); 99 | expect(jsonSpy).toHaveBeenCalledWith({ 100 | error: 'Rate limit exceeded', 101 | message: 'Maximum 100 requests per 1 minute(s) allowed', 102 | retryAfter: 60 103 | }); 104 | }); 105 | 106 | it('should use custom maxRequests in handler message', () => { 107 | const middleware = rateLimiterMiddleware(200, 2); 108 | const config = rateLimit.mock.calls[0][0]; 109 | 110 | config.handler(mockRequest, mockResponse); 111 | 112 | expect(jsonSpy).toHaveBeenCalledWith({ 113 | error: 'Rate limit exceeded', 114 | message: 'Maximum 200 requests per 2 minute(s) allowed', 115 | retryAfter: 120 116 | }); 117 | }); 118 | 119 | it('should return middleware function', () => { 120 | const middleware = rateLimiterMiddleware(); 121 | 122 | expect(typeof middleware).toBe('function'); 123 | expect(middleware.length).toBe(3); // Express middleware signature: (req, res, next) 124 | }); 125 | 126 | it('should configure standardHeaders and legacyHeaders correctly', () => { 127 | const middleware = rateLimiterMiddleware(); 128 | const config = rateLimit.mock.calls[0][0]; 129 | 130 | expect(config.standardHeaders).toBe(true); 131 | expect(config.legacyHeaders).toBe(false); 132 | }); 133 | }); 134 | 135 | -------------------------------------------------------------------------------- /order-syncer/src/controllers/sync.controller.js: -------------------------------------------------------------------------------- 1 | import { logger } from '../utils/logger.util.js'; 2 | import { doValidation } from '../validators/order-change.validators.js'; 3 | import { decodeToJson } from '../utils/decoder.util.js'; 4 | import { getOrderWithPaymentInfo } from '../clients/query.client.js'; 5 | import { updateOrderTaxTxn } from '../clients/update.client.js'; 6 | import { 7 | HTTP_STATUS_SUCCESS_NO_CONTENT, 8 | HTTP_STATUS_SERVER_ERROR, 9 | HTTP_STATUS_SUCCESS_ACCEPTED, 10 | } from '../constants/http.status.constants.js'; 11 | import { 12 | createTaxTransactions, 13 | getTransactionFromTaxCalculation, 14 | updatePaymentIntentMetadata 15 | } from '../extensions/stripe/clients/client.js'; 16 | import CustomError from '../errors/custom.error.js'; 17 | import { ORDER_TAX_FIELD_NAMES } from '../connectors/customTypes.js'; 18 | 19 | /** 20 | * Sync handler for the Order Syncer 21 | * This function is called when a new order is created 22 | * 23 | * @param {Object} request - The request object 24 | * @param {Object} response - The response object 25 | * @returns {Promise} The response object 26 | */ 27 | export const syncHandler = async (request, response) => { 28 | try { 29 | // Receive the Pub/Sub message 30 | logger.info('Received Pub/Sub syncHandler message', { 31 | messageId: request.body?.message?.messageId, 32 | publishTime: request.body?.message?.publishTime, 33 | }); 34 | const encodedMessageBody = request.body?.message?.data; 35 | if (!encodedMessageBody) { 36 | logger.error('Missing message data from incoming event message.'); 37 | throw new CustomError( 38 | HTTP_STATUS_SUCCESS_ACCEPTED, 39 | 'Missing message data from incoming event message.' 40 | ); 41 | } 42 | 43 | const messageBody = decodeToJson(encodedMessageBody); 44 | doValidation(messageBody); 45 | 46 | const orderId = messageBody?.resource?.id; 47 | const order = await getOrderWithPaymentInfo(orderId); 48 | logger.info(`Payment data for order ${orderId}:`, { 49 | calculationReferences: order?.custom?.fields?.[ORDER_TAX_FIELD_NAMES.CALCULATION_REFERENCES] || [], 50 | paymentIntentId: order?.paymentInfo?.payments[0]?.obj?.interfaceId 51 | }); 52 | 53 | if (order) { 54 | await syncOrderToTaxProvider(orderId, order); 55 | } 56 | } catch (err) { 57 | logger.error(`Error in syncHandler: ${err.message}`); 58 | if (err.statusCode) return response.status(err.statusCode).send({ 59 | message: err.message 60 | }); 61 | return response.status(HTTP_STATUS_SERVER_ERROR).send({ 62 | message: 'Internal server error' 63 | }); 64 | } 65 | 66 | // Return the response for the client 67 | return response.status(HTTP_STATUS_SUCCESS_NO_CONTENT).send(); 68 | }; 69 | 70 | /** 71 | * Sync order to tax provider. 72 | * @param {string} orderId - The order ID. 73 | * @param {object} order - The order object. 74 | * @returns {Promise} The updated order tax transactions. 75 | */ 76 | async function syncOrderToTaxProvider(orderId, order) { 77 | const calcRefs = order?.custom?.fields?.[ORDER_TAX_FIELD_NAMES.CALCULATION_REFERENCES] || []; 78 | const paymentIntentId = order?.paymentInfo?.payments[0]?.obj?.interfaceId; 79 | let transactions = []; 80 | 81 | if (calcRefs.length === 0) { 82 | logger.warn(`Order ${orderId} has no calculation references. Skipping.`); 83 | return; 84 | } 85 | 86 | // Single calculation: check if transaction already exists 87 | if (calcRefs.length === 1) { 88 | logger.info(`Case single calculation.`); 89 | 90 | const transaction = await getTransactionFromTaxCalculation(calcRefs[0], orderId, paymentIntentId); 91 | if (transaction?.id) { 92 | transactions.push(transaction); 93 | logger.info(`Found existing transaction with ID: ${transaction.id}`); 94 | await updateOrderTaxTxn(transactions, orderId); 95 | } 96 | } else { 97 | // Multiple calculations: create new transactions 98 | logger.info(`Case multiple calculations.`); 99 | 100 | transactions = await createTaxTransactions(orderId, calcRefs, paymentIntentId); 101 | await updateOrderTaxTxn(transactions, orderId); 102 | } 103 | 104 | // Sync to PaymentIntent if available 105 | if (paymentIntentId && transactions.length > 0) { 106 | await updatePaymentIntentMetadata(paymentIntentId, transactions.map(t => t.id)); 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /tax-calculator/src/validators/helpers.validators.js: -------------------------------------------------------------------------------- 1 | import validator from 'validator'; 2 | import { VALID_TAX_BEHAVIORS } from '../constants/tax-behavior.constants.js'; 3 | 4 | /** 5 | * File used to create helpers to validate the fields 6 | */ 7 | 8 | const required = 9 | (fn) => 10 | (value, ...args) => 11 | !(value === undefined || value === null) && fn(...[String(value), ...args]); 12 | 13 | export const standardString = (path, message, overrideConfig = {}) => [ 14 | path, 15 | [ 16 | [ 17 | required(validator.isLength), 18 | message, 19 | [{ min: 2, max: 20, ...overrideConfig }], 20 | ], 21 | ], 22 | ]; 23 | 24 | export const standardEmail = (path, message) => [ 25 | path, 26 | [[required(validator.isEmail), message]], 27 | ]; 28 | 29 | export const standardNaturalNumber = (path, message) => [ 30 | path, 31 | [ 32 | [ 33 | required((value) => 34 | validator.isNumeric(String(value), { no_symbols: true }) 35 | ), 36 | message, 37 | ], 38 | ], 39 | ]; 40 | 41 | export const standardKey = (path, message) => [ 42 | path, 43 | [ 44 | [ 45 | required( 46 | (value) => 47 | validator.isLength(String(value), { min: 2 }) && 48 | /^[a-zA-Z0-9-_]+$/.test(value) 49 | ), 50 | 51 | message, 52 | ], 53 | ], 54 | ]; 55 | 56 | export const standardUrl = (path, message, overrideOptions = {}) => [ 57 | path, 58 | [ 59 | [ 60 | required(validator.isURL), 61 | message, 62 | [ 63 | { 64 | require_protocol: true, 65 | require_valid_protocol: true, 66 | protocols: ['http', 'https'], 67 | require_host: true, 68 | require_port: false, 69 | allow_protocol_relative_urls: false, 70 | allow_fragments: false, 71 | allow_query_components: true, 72 | validate_length: true, 73 | ...overrideOptions, 74 | }, 75 | ], 76 | ], 77 | ], 78 | ]; 79 | 80 | export const getValidateMessages = (validatorConfigs, item) => 81 | validatorConfigs.flatMap(([path, validators]) => { 82 | return validators.reduce((acc, [validatorFn, message, args = []]) => { 83 | const valueToValidate = path.reduce((val, property) => { 84 | return val[property]; 85 | }, item); 86 | if (!validatorFn(...[valueToValidate, ...args])) { 87 | return acc.concat(message); 88 | } 89 | return acc; 90 | }, []); 91 | }); 92 | 93 | export const optional = 94 | (fn) => 95 | (...args) => { 96 | const [path, validators] = fn(...args); 97 | return [ 98 | path, 99 | validators.map(([fn, message, validatorArgs]) => [ 100 | (value, ...args) => 101 | value === undefined ? true : fn(...[value, ...args]), 102 | message, 103 | validatorArgs, 104 | ]), 105 | ]; 106 | }; 107 | 108 | export const array = 109 | (fn) => 110 | (...args) => { 111 | const [path, validators] = fn(...args); 112 | return [ 113 | path, 114 | validators.map(([fn, message, validatorArgs]) => [ 115 | (value, ...args) => 116 | Array.isArray(value) && 117 | value.every((value) => fn(...[value, ...args])), 118 | message, 119 | validatorArgs, 120 | ]), 121 | ]; 122 | }; 123 | 124 | export const region = (path, message) => [ 125 | path, 126 | [ 127 | [ 128 | required( 129 | required((value) => 130 | validator.isIn(value, [ 131 | 'us-central1.gcp', 132 | 'us-east-2.aws', 133 | 'europe-west1.gcp', 134 | 'eu-central-1.aws', 135 | 'australia-southeast1.gcp', 136 | ]) 137 | ) 138 | ), 139 | message, 140 | ], 141 | ], 142 | ]; 143 | 144 | export const taxBehavior = (path, message) => [ 145 | path, 146 | [ 147 | [ 148 | required((value) => 149 | validator.isIn(value?.toLowerCase(), VALID_TAX_BEHAVIORS) 150 | ), 151 | message, 152 | ], 153 | ], 154 | ]; 155 | 156 | export const jsonObject = (path, message) => [ 157 | path, 158 | [ 159 | [ 160 | (value) => { 161 | if (value === undefined || value === null) return true; 162 | try { 163 | const parsed = JSON.parse(value); 164 | return typeof parsed === 'object' && !Array.isArray(parsed); 165 | } catch { 166 | return false; 167 | } 168 | }, 169 | message, 170 | ], 171 | ], 172 | ]; 173 | -------------------------------------------------------------------------------- /order-syncer/test/unit/clients/create.client.spec.js: -------------------------------------------------------------------------------- 1 | import { expect, describe, it, jest, beforeEach } from '@jest/globals'; 2 | import { createApiBuilderFromCtpClient } from '@commercetools/platform-sdk'; 3 | 4 | // Mock dependencies 5 | jest.mock('@commercetools/platform-sdk', () => ({ 6 | createApiBuilderFromCtpClient: jest.fn(), 7 | })); 8 | jest.mock('../../../src/clients/build.client.js', () => ({ 9 | createClient: jest.fn(), 10 | })); 11 | jest.mock('../../../src/utils/config.util.js', () => ({ 12 | __esModule: true, 13 | default: jest.fn(), 14 | })); 15 | 16 | import { createClient } from '../../../src/clients/build.client.js'; 17 | import readConfiguration from '../../../src/utils/config.util.js'; 18 | import { createApiRoot, getProject } from '../../../src/clients/create.client.js'; 19 | 20 | describe('create.client.spec', () => { 21 | let mockClient; 22 | let mockApiBuilder; 23 | let mockApiRoot; 24 | 25 | beforeEach(() => { 26 | jest.clearAllMocks(); 27 | 28 | mockClient = { 29 | execute: jest.fn(), 30 | }; 31 | 32 | // Create mockApiRoot with proper chainable methods 33 | // get() will be configured per test as needed 34 | mockApiRoot = { 35 | get: jest.fn(), 36 | execute: jest.fn(), 37 | }; 38 | 39 | mockApiBuilder = { 40 | withProjectKey: jest.fn().mockReturnValue(mockApiRoot), 41 | }; 42 | 43 | createClient.mockReturnValue(mockClient); 44 | createApiBuilderFromCtpClient.mockReturnValue(mockApiBuilder); 45 | readConfiguration.mockReturnValue({ 46 | projectKey: 'test-project-key', 47 | }); 48 | }); 49 | 50 | describe('createApiRoot', () => { 51 | it('should create apiRoot on first call (when root is null - lines 14-18)', () => { 52 | const result = createApiRoot(); 53 | 54 | expect(createClient).toHaveBeenCalled(); 55 | expect(createApiBuilderFromCtpClient).toHaveBeenCalledWith(mockClient); 56 | expect(readConfiguration).toHaveBeenCalled(); 57 | expect(mockApiBuilder.withProjectKey).toHaveBeenCalledWith({ 58 | projectKey: 'test-project-key', 59 | }); 60 | // The result should be the mockApiRoot returned by withProjectKey 61 | expect(result).toBe(mockApiRoot); 62 | }); 63 | 64 | it('should reuse apiRoot on subsequent calls (when root exists - lines 10-12)', () => { 65 | const firstCall = createApiRoot(); 66 | 67 | // Clear mocks to verify second call doesn't recreate 68 | jest.clearAllMocks(); 69 | readConfiguration.mockReturnValue({ 70 | projectKey: 'test-project-key', 71 | }); 72 | 73 | const secondCall = createApiRoot(); 74 | 75 | // Should not create client again (closure reuses root - line 10-11) 76 | expect(createClient).not.toHaveBeenCalled(); 77 | expect(createApiBuilderFromCtpClient).not.toHaveBeenCalled(); 78 | // Both calls should return the same object (closure reuses root) 79 | expect(firstCall).toBe(secondCall); 80 | }); 81 | }); 82 | 83 | describe('getProject', () => { 84 | it('should get project details successfully', async () => { 85 | const mockProjectResponse = { 86 | body: { 87 | key: 'test-project-key', 88 | version: 1, 89 | }, 90 | }; 91 | 92 | // Setup the chain: get() returns an object with execute() 93 | const mockGetRequest = { 94 | execute: jest.fn().mockResolvedValue(mockProjectResponse), 95 | }; 96 | 97 | // The mockApiRoot was already created in previous tests via createApiRoot() 98 | // We need to reconfigure its get() method for this test 99 | // Since createApiRoot() returns the same object (closure), we can reconfigure it 100 | const apiRoot = createApiRoot(); // Get the existing apiRoot 101 | apiRoot.get.mockReturnValue(mockGetRequest); 102 | 103 | const result = await getProject(); 104 | 105 | expect(apiRoot.get).toHaveBeenCalled(); 106 | expect(mockGetRequest.execute).toHaveBeenCalled(); 107 | expect(result).toEqual(mockProjectResponse); 108 | }); 109 | 110 | it('should handle errors when getting project', async () => { 111 | const error = new Error('Failed to get project'); 112 | const mockGetRequest = { 113 | execute: jest.fn().mockRejectedValue(error), 114 | }; 115 | 116 | // Get the existing apiRoot and reconfigure get() 117 | const apiRoot = createApiRoot(); 118 | apiRoot.get.mockReturnValue(mockGetRequest); 119 | 120 | await expect(getProject()).rejects.toThrow('Failed to get project'); 121 | expect(apiRoot.get).toHaveBeenCalled(); 122 | expect(mockGetRequest.execute).toHaveBeenCalled(); 123 | }); 124 | }); 125 | }); 126 | 127 | -------------------------------------------------------------------------------- /connect.yaml: -------------------------------------------------------------------------------- 1 | deployAs: 2 | - name: tax-calculator 3 | applicationType: service 4 | endpoint: /taxCalculator 5 | scripts: 6 | postDeploy: npm install && npm run connector:post-deploy 7 | preUndeploy: npm install && npm run connector:pre-undeploy 8 | configuration: 9 | standardConfiguration: 10 | - key: CUSTOM_TYPE_PRODUCT_KEY 11 | description: Custom type key for product and line item tax code configuration 12 | default: 'connector-stripe-tax-product' 13 | required: true 14 | - key: CUSTOM_TYPE_CATEGORY_KEY 15 | description: Custom type key for category tax code configuration 16 | default: 'connector-stripe-tax-category' 17 | required: true 18 | - key: CUSTOM_TYPE_SHIPPING_KEY 19 | description: Custom type key for shipping method tax code configuration 20 | default: 'connector-stripe-tax-shipping' 21 | required: true 22 | - key: CUSTOM_TYPE_CART_KEY 23 | description: Custom type key for cart tax calculation reference 24 | default: 'connector-stripe-tax-calculation-reference' 25 | required: true 26 | - key: TAX_BEHAVIOR_DEFAULT 27 | description: Sets the default tax behavior for all tax calculations when no other rules apply 28 | default: 'exclusive' 29 | - key: TAX_BEHAVIOR_COUNTRY_MAPPING 30 | description: JSON string (with escaped double quotes) mapping country codes to their default tax behavior 31 | default: '{"US":"exclusive","CA":"exclusive","DE":"inclusive","FR":"inclusive","GB":"inclusive","AU":"inclusive","IT":"inclusive","ES":"inclusive","NL":"inclusive"}' 32 | - key: TAX_CODE_CATEGORY_MAPPING_JSON 33 | description: JSON string (with escaped double quotes) mapping commercetools categories to Stripe tax codes 34 | required: true 35 | - key: SHIP_FROM_REQUIRED 36 | description: Whether to require a ship from address for tax calculations (true or false) 37 | default: 'true' 38 | - key: SHIP_FROM_DEFAULT_BUSINESS_COUNTRY 39 | description: Default country for ship from address 40 | - key: SHIP_FROM_DEFAULT_BUSINESS_STATE 41 | description: Default state for ship from address 42 | - key: SHIP_FROM_DEFAULT_BUSINESS_CITY 43 | description: Default city for ship from address 44 | - key: SHIP_FROM_DEFAULT_BUSINESS_POSTAL_CODE 45 | description: Default postal code for ship from address 46 | - key: SHIP_FROM_DEFAULT_BUSINESS_LINE1 47 | description: Default line 1 for ship from address 48 | - key: SHIP_FROM_DEFAULT_BUSINESS_LINE2 49 | description: Default line 2 for ship from address 50 | - key: SHIP_FROM_CHANNEL_PRIORITY 51 | description: Comma-separated channel IDs for priority-based selection (e.g. 'channel-1,channel-2,channel-3') 52 | - key: ADDRESS_VALIDATION_RATE_LIMIT 53 | description: Rate limit for address validation in requests per minute 54 | default: '100' 55 | - key: ADDRESS_VALIDATION_WINDOW_MINUTES 56 | description: Time window in minutes for address validation rate limit 57 | default: '1' 58 | - key: ADDRESS_VALIDATION_STRIPE_DEFAULT_CURRENCY 59 | description: Default currency for Stripe verification 60 | default: 'usd' 61 | - name: order-syncer 62 | applicationType: event 63 | endpoint: /orderSyncer 64 | scripts: 65 | postDeploy: npm install && npm run connector:post-deploy 66 | preUndeploy: npm install && npm run connector:pre-undeploy 67 | configuration: 68 | standardConfiguration: 69 | - key: CUSTOM_TYPE_ORDER_KEY 70 | description: Custom type key for order tax calculation reference 71 | default: 'connector-stripe-tax-calculation-reference' 72 | required: true 73 | 74 | inheritAs: 75 | configuration: 76 | standardConfiguration: 77 | - key: CTP_PROJECT_KEY 78 | description: Project key from commercetools composable commerce project 79 | required: true 80 | - key: CTP_REGION 81 | description: Region of commercetools composable commerce project 82 | required: true 83 | securedConfiguration: 84 | - key: CTP_CLIENT_ID 85 | description: Client id from commercetools composable commerce project 86 | required: true 87 | - key: CTP_CLIENT_SECRET 88 | description: Client secret from commercetools composable commerce project 89 | required: true 90 | - key: CTP_SCOPE 91 | description: Scope from commercetools composable commerce project 92 | required: true 93 | - key: STRIPE_API_TOKEN 94 | description: API Token for communication between the connector and Stripe tax provider 95 | required: true -------------------------------------------------------------------------------- /tax-calculator/src/config/taxCodeMapping.config.js: -------------------------------------------------------------------------------- 1 | import { logger } from '../utils/logger.utils.js'; 2 | 3 | /** 4 | * Tax Code Mapping Configuration 5 | * Loads customer-specific category to tax code mappings from environment variable 6 | */ 7 | class TaxCodeMappingConfig { 8 | constructor() { 9 | this.mapping = null; 10 | this.lastLoadedAt = null; 11 | } 12 | 13 | /** 14 | * Load and parse tax code mapping from environment variable 15 | * @returns {Object} Parsed mapping object 16 | */ 17 | loadMapping() { 18 | // Return cached mapping if available 19 | if (this.mapping) { 20 | logger.debug('Using cached tax code mapping'); 21 | return this.mapping; 22 | } 23 | 24 | const mappingJson = process.env.TAX_CODE_CATEGORY_MAPPING_JSON; 25 | 26 | if (!mappingJson) { 27 | logger.warn('TAX_CODE_CATEGORY_MAPPING_JSON environment variable not set. Tax code lookup will rely on product custom fields only.'); 28 | this.mapping = { categories: [] }; 29 | this.lastLoadedAt = Date.now(); 30 | return this.mapping; 31 | } 32 | 33 | try { 34 | const parsed = JSON.parse(mappingJson); 35 | this.validateMapping(parsed); 36 | this.mapping = parsed; 37 | this.lastLoadedAt = Date.now(); 38 | 39 | logger.info('Tax code mapping loaded successfully', { 40 | categoryCount: this.mapping.categories?.length || 0, 41 | timestamp: new Date().toISOString() 42 | }); 43 | 44 | return this.mapping; 45 | } catch (error) { 46 | logger.error(`Invalid TAX_CODE_CATEGORY_MAPPING_JSON format: ${error.message}`); 47 | throw new Error(error.message); 48 | } 49 | } 50 | 51 | /** 52 | * Validate the structure of the mapping configuration 53 | * @param {Object} mapping - Parsed mapping object 54 | * @throws {Error} If mapping structure is invalid 55 | */ 56 | validateMapping(mapping) { 57 | if (!mapping || typeof mapping !== 'object') { 58 | throw new Error('Mapping must be a valid JSON object'); 59 | } 60 | 61 | if (!mapping.categories || !Array.isArray(mapping.categories)) { 62 | throw new Error('Mapping must contain a "categories" array'); 63 | } 64 | 65 | // Validate each category mapping 66 | for (let i = 0; i < mapping.categories.length; i++) { 67 | const entry = mapping.categories[i]; 68 | 69 | if (!entry.ctCategory || typeof entry.ctCategory !== 'object') { 70 | throw new Error( 71 | `Category entry at index ${i} must contain a "ctCategory" object` 72 | ); 73 | } 74 | 75 | if (!entry.ctCategory.id && !entry.ctCategory.key) { 76 | throw new Error( 77 | `Category entry at index ${i}: ctCategory must have either "id" or "key" property` 78 | ); 79 | } 80 | 81 | if (!entry.taxCode || typeof entry.taxCode !== 'string' || !entry.taxCode.startsWith('txcd_')) { 82 | throw new Error( 83 | `Category entry at index ${i}: Invalid tax code "${entry.taxCode}". Tax codes must be strings starting with "txcd_"` 84 | ); 85 | } 86 | } 87 | 88 | logger.debug('Tax code mapping validation passed', { 89 | categories: mapping.categories.length 90 | }); 91 | } 92 | 93 | /** 94 | * Get tax code for a specific category by id or key 95 | * @param {Object} category - commercetools category object with id and/or key 96 | * @returns {string|null} Tax code if found, null otherwise 97 | */ 98 | getTaxCodeForCategory(category) { 99 | if (!category || typeof category !== 'object') { 100 | logger.debug('Invalid category provided', { category }); 101 | return null; 102 | } 103 | 104 | const mapping = this.loadMapping(); 105 | 106 | // Search through categories array to find matching id or key 107 | for (const entry of mapping.categories) { 108 | const ctCategory = entry.ctCategory; 109 | 110 | // Match by id (if both have id) 111 | if (category.id && ctCategory.id && category.id === ctCategory.id) { 112 | logger.debug(`Tax code found for category id "${category.id}"`, { 113 | taxCode: entry.taxCode 114 | }); 115 | return entry.taxCode; 116 | } 117 | 118 | // Match by key (if both have key) 119 | if (category.key && ctCategory.key && category.key === ctCategory.key) { 120 | logger.debug(`Tax code found for category key "${category.key}"`, { 121 | taxCode: entry.taxCode 122 | }); 123 | return entry.taxCode; 124 | } 125 | } 126 | 127 | logger.debug('No tax code mapping found for category', { 128 | categoryId: category.id, 129 | categoryKey: category.key 130 | }); 131 | 132 | return null; 133 | } 134 | } 135 | 136 | // Singleton instance 137 | const taxCodeMappingConfig = new TaxCodeMappingConfig(); 138 | 139 | export default taxCodeMappingConfig; 140 | -------------------------------------------------------------------------------- /order-syncer/test/unit/validators/order-change.validators.spec.js: -------------------------------------------------------------------------------- 1 | import { expect, describe, it } from '@jest/globals'; 2 | import { doValidation } from '../../../src/validators/order-change.validators.js'; 3 | import CustomError from '../../../src/errors/custom.error.js'; 4 | import { 5 | HTTP_STATUS_SUCCESS_ACCEPTED, 6 | HTTP_STATUS_SUCCESS_NO_CONTENT, 7 | } from '../../../src/constants/http.status.constants.js'; 8 | import { 9 | MESSAGE_TYPE, 10 | NOTIFICATION_TYPE_RESOURCE_CREATED, 11 | } from '../../../src/constants/connectors.constants.js'; 12 | 13 | describe('order-change.validators.spec', () => { 14 | describe('doValidation', () => { 15 | it('should throw CustomError when messageBody is missing', () => { 16 | expect(() => doValidation(null)).toThrow(CustomError); 17 | expect(() => doValidation(null)).toThrow('The incoming message body is missing'); 18 | }); 19 | 20 | it('should throw CustomError when messageBody is undefined', () => { 21 | expect(() => doValidation(undefined)).toThrow(CustomError); 22 | expect(() => doValidation(undefined)).toThrow('The incoming message body is missing'); 23 | }); 24 | 25 | it('should throw CustomError with NO_CONTENT status when notificationType is ResourceCreated', () => { 26 | const messageBody = { 27 | notificationType: NOTIFICATION_TYPE_RESOURCE_CREATED, 28 | type: 'OrderCreated', 29 | resource: { typeId: 'order', id: 'order-123' }, 30 | }; 31 | 32 | expect(() => doValidation(messageBody)).toThrow(CustomError); 33 | try { 34 | doValidation(messageBody); 35 | } catch (error) { 36 | expect(error.statusCode).toBe(HTTP_STATUS_SUCCESS_NO_CONTENT); 37 | expect(error.message).toContain('subscription resource creation'); 38 | } 39 | }); 40 | 41 | it('should throw CustomError when message type is not in MESSAGE_TYPE array', () => { 42 | const messageBody = { 43 | notificationType: 'Message', 44 | type: 'InvalidMessageType', 45 | resource: { typeId: 'order', id: 'order-123' }, 46 | }; 47 | 48 | expect(() => doValidation(messageBody)).toThrow(CustomError); 49 | try { 50 | doValidation(messageBody); 51 | } catch (error) { 52 | expect(error.statusCode).toBe(HTTP_STATUS_SUCCESS_ACCEPTED); 53 | expect(error.message).toContain('Message type InvalidMessageType is incorrect'); 54 | } 55 | }); 56 | 57 | it('should throw CustomError when resource typeId is not "order"', () => { 58 | const messageBody = { 59 | notificationType: 'Message', 60 | type: MESSAGE_TYPE[0], 61 | resource: { typeId: 'product', id: 'product-123' }, 62 | }; 63 | 64 | expect(() => doValidation(messageBody)).toThrow(CustomError); 65 | try { 66 | doValidation(messageBody); 67 | } catch (error) { 68 | expect(error.statusCode).toBe(HTTP_STATUS_SUCCESS_ACCEPTED); 69 | expect(error.message).toContain('No order ID is found in message'); 70 | } 71 | }); 72 | 73 | it('should throw CustomError when resource id is missing', () => { 74 | const messageBody = { 75 | notificationType: 'Message', 76 | type: MESSAGE_TYPE[0], 77 | resource: { typeId: 'order' }, 78 | }; 79 | 80 | expect(() => doValidation(messageBody)).toThrow(CustomError); 81 | try { 82 | doValidation(messageBody); 83 | } catch (error) { 84 | expect(error.statusCode).toBe(HTTP_STATUS_SUCCESS_ACCEPTED); 85 | expect(error.message).toContain('No order ID is found in message'); 86 | } 87 | }); 88 | 89 | it('should throw CustomError when resource is missing', () => { 90 | const messageBody = { 91 | notificationType: 'Message', 92 | type: MESSAGE_TYPE[0], 93 | }; 94 | 95 | expect(() => doValidation(messageBody)).toThrow(CustomError); 96 | try { 97 | doValidation(messageBody); 98 | } catch (error) { 99 | expect(error.statusCode).toBe(HTTP_STATUS_SUCCESS_ACCEPTED); 100 | expect(error.message).toContain('No order ID is found in message'); 101 | } 102 | }); 103 | 104 | it('should not throw error for valid message body', () => { 105 | const messageBody = { 106 | notificationType: 'Message', 107 | type: MESSAGE_TYPE[0], 108 | resource: { typeId: 'order', id: 'order-123' }, 109 | version: 1, 110 | }; 111 | 112 | expect(() => doValidation(messageBody)).not.toThrow(); 113 | }); 114 | 115 | it('should validate successfully with all valid MESSAGE_TYPE values', () => { 116 | MESSAGE_TYPE.forEach((messageType) => { 117 | const messageBody = { 118 | notificationType: 'Message', 119 | type: messageType, 120 | resource: { typeId: 'order', id: 'order-123' }, 121 | }; 122 | 123 | expect(() => doValidation(messageBody)).not.toThrow(); 124 | }); 125 | }); 126 | }); 127 | }); 128 | 129 | -------------------------------------------------------------------------------- /order-syncer/test/unit/middlewares/error.middleware.spec.js: -------------------------------------------------------------------------------- 1 | import { expect, describe, it, jest } from '@jest/globals'; 2 | import { errorMiddleware } from '../../../src/middlewares/error.middleware.js'; 3 | import CustomError from '../../../src/errors/custom.error.js'; 4 | 5 | describe('error.middleware.spec', () => { 6 | let mockRequest; 7 | let mockResponse; 8 | let mockNext; 9 | 10 | beforeEach(() => { 11 | mockRequest = {}; 12 | mockResponse = { 13 | status: jest.fn().mockReturnThis(), 14 | json: jest.fn().mockReturnThis(), 15 | send: jest.fn().mockReturnThis(), 16 | }; 17 | mockNext = jest.fn(); 18 | }); 19 | 20 | describe('errorMiddleware', () => { 21 | it('should handle CustomError with statusCode and return JSON response', () => { 22 | const statusCode = 400; 23 | const errorMessage = 'Validation error'; 24 | const customError = new CustomError(statusCode, errorMessage); 25 | 26 | errorMiddleware(customError, mockRequest, mockResponse, mockNext); 27 | 28 | expect(mockResponse.status).toHaveBeenCalledWith(statusCode); 29 | expect(mockResponse.json).toHaveBeenCalledWith({ 30 | message: errorMessage, 31 | errors: undefined, 32 | }); 33 | expect(mockNext).not.toHaveBeenCalled(); 34 | }); 35 | 36 | it('should handle CustomError with errors array', () => { 37 | const statusCode = 422; 38 | const errorMessage = 'Multiple validation errors'; 39 | const errors = [ 40 | { code: 'Error1', message: 'First error' }, 41 | { code: 'Error2', message: 'Second error' }, 42 | ]; 43 | const customError = new CustomError(statusCode, errorMessage, errors); 44 | 45 | errorMiddleware(customError, mockRequest, mockResponse, mockNext); 46 | 47 | expect(mockResponse.status).toHaveBeenCalledWith(statusCode); 48 | expect(mockResponse.json).toHaveBeenCalledWith({ 49 | message: errorMessage, 50 | errors: errors, 51 | }); 52 | }); 53 | 54 | it('should handle CustomError with numeric statusCode', () => { 55 | const statusCode = 202; 56 | const errorMessage = 'Accepted'; 57 | const customError = new CustomError(statusCode, errorMessage); 58 | 59 | errorMiddleware(customError, mockRequest, mockResponse, mockNext); 60 | 61 | expect(mockResponse.status).toHaveBeenCalledWith(statusCode); 62 | expect(mockResponse.json).toHaveBeenCalled(); 63 | }); 64 | 65 | it('should return 500 for non-CustomError errors', () => { 66 | const genericError = new Error('Generic error'); 67 | 68 | errorMiddleware(genericError, mockRequest, mockResponse, mockNext); 69 | 70 | expect(mockResponse.status).toHaveBeenCalledWith(500); 71 | expect(mockResponse.send).toHaveBeenCalledWith('Internal server error'); 72 | expect(mockResponse.json).not.toHaveBeenCalled(); 73 | expect(mockNext).not.toHaveBeenCalled(); 74 | }); 75 | 76 | it('should return 500 for CustomError without statusCode', () => { 77 | const customError = new CustomError(null, 'Error without status'); 78 | customError.statusCode = null; 79 | 80 | errorMiddleware(customError, mockRequest, mockResponse, mockNext); 81 | 82 | expect(mockResponse.status).toHaveBeenCalledWith(500); 83 | expect(mockResponse.send).toHaveBeenCalledWith('Internal server error'); 84 | }); 85 | 86 | it('should return 500 for CustomError with non-numeric statusCode', () => { 87 | const customError = new CustomError('invalid', 'Error with invalid status'); 88 | customError.statusCode = 'invalid'; 89 | 90 | errorMiddleware(customError, mockRequest, mockResponse, mockNext); 91 | 92 | expect(mockResponse.status).toHaveBeenCalledWith(500); 93 | expect(mockResponse.send).toHaveBeenCalledWith('Internal server error'); 94 | }); 95 | 96 | it('should handle empty errors array in CustomError', () => { 97 | const statusCode = 400; 98 | const errorMessage = 'Error with empty errors'; 99 | const customError = new CustomError(statusCode, errorMessage, []); 100 | 101 | errorMiddleware(customError, mockRequest, mockResponse, mockNext); 102 | 103 | expect(mockResponse.status).toHaveBeenCalledWith(statusCode); 104 | expect(mockResponse.json).toHaveBeenCalledWith({ 105 | message: errorMessage, 106 | errors: [], 107 | }); 108 | }); 109 | 110 | it('should not call next middleware', () => { 111 | const customError = new CustomError(400, 'Test error'); 112 | 113 | errorMiddleware(customError, mockRequest, mockResponse, mockNext); 114 | 115 | expect(mockNext).not.toHaveBeenCalled(); 116 | }); 117 | 118 | it('should handle different HTTP status codes', () => { 119 | const statusCodes = [200, 201, 400, 401, 404, 422, 500, 503]; 120 | 121 | statusCodes.forEach((statusCode) => { 122 | const customError = new CustomError(statusCode, `Error ${statusCode}`); 123 | mockResponse.status.mockClear(); 124 | mockResponse.json.mockClear(); 125 | 126 | errorMiddleware(customError, mockRequest, mockResponse, mockNext); 127 | 128 | expect(mockResponse.status).toHaveBeenCalledWith(statusCode); 129 | }); 130 | }); 131 | }); 132 | }); 133 | 134 | -------------------------------------------------------------------------------- /order-syncer/test/unit/utils/config.util.spec.js: -------------------------------------------------------------------------------- 1 | import { expect, describe, it, jest, beforeEach, afterEach } from '@jest/globals'; 2 | import readConfiguration from '../../../src/utils/config.util.js'; 3 | import CustomError from '../../../src/errors/custom.error.js'; 4 | 5 | // Mock dependencies 6 | jest.mock('../../../src/validators/env-var.validator.js', () => ({ 7 | __esModule: true, 8 | default: [], 9 | })); 10 | 11 | jest.mock('../../../src/validators/helpers.validator.js', () => ({ 12 | getValidateMessages: jest.fn(() => []), 13 | })); 14 | 15 | import { getValidateMessages } from '../../../src/validators/helpers.validator.js'; 16 | 17 | describe('config.util.spec', () => { 18 | const originalEnv = process.env; 19 | 20 | beforeEach(() => { 21 | jest.clearAllMocks(); 22 | process.env = { ...originalEnv }; 23 | }); 24 | 25 | afterEach(() => { 26 | process.env = originalEnv; 27 | }); 28 | 29 | describe('readConfiguration', () => { 30 | it('should return configuration object with all valid environment variables', () => { 31 | process.env.CTP_CLIENT_ID = '123456789012345678901234'; 32 | process.env.CTP_CLIENT_SECRET = '12345678901234567890123456789012'; 33 | process.env.CTP_PROJECT_KEY = 'test-project'; 34 | process.env.CTP_SCOPE = 'manage_project'; 35 | process.env.CTP_REGION = 'us-central1.gcp'; 36 | process.env.STRIPE_API_TOKEN = 'sk_test_token'; 37 | 38 | getValidateMessages.mockReturnValue([]); 39 | 40 | const config = readConfiguration(); 41 | 42 | expect(config).toEqual({ 43 | clientId: '123456789012345678901234', 44 | clientSecret: '12345678901234567890123456789012', 45 | projectKey: 'test-project', 46 | scope: 'manage_project', 47 | region: 'us-central1.gcp', 48 | stripeApiToken: 'sk_test_token', 49 | }); 50 | }); 51 | 52 | it('should return configuration with optional scope when not provided', () => { 53 | process.env.CTP_CLIENT_ID = '123456789012345678901234'; 54 | process.env.CTP_CLIENT_SECRET = '12345678901234567890123456789012'; 55 | process.env.CTP_PROJECT_KEY = 'test-project'; 56 | process.env.CTP_REGION = 'us-central1.gcp'; 57 | process.env.STRIPE_API_TOKEN = 'sk_test_token'; 58 | delete process.env.CTP_SCOPE; 59 | 60 | getValidateMessages.mockReturnValue([]); 61 | 62 | const config = readConfiguration(); 63 | 64 | expect(config.scope).toBeUndefined(); 65 | }); 66 | 67 | it('should throw CustomError when validation fails', () => { 68 | process.env.CTP_CLIENT_ID = 'invalid'; 69 | process.env.CTP_CLIENT_SECRET = '12345678901234567890123456789012'; 70 | process.env.CTP_PROJECT_KEY = 'test-project'; 71 | process.env.CTP_REGION = 'us-central1.gcp'; 72 | process.env.STRIPE_API_TOKEN = 'sk_test_token'; 73 | 74 | const validationErrors = [ 75 | { code: 'InValidClientId', message: 'Client id should be 24 characters.' }, 76 | ]; 77 | 78 | getValidateMessages.mockReturnValue(validationErrors); 79 | 80 | expect(() => readConfiguration()).toThrow(CustomError); 81 | 82 | try { 83 | readConfiguration(); 84 | } catch (error) { 85 | expect(error).toBeInstanceOf(CustomError); 86 | expect(error.statusCode).toBe('InvalidEnvironmentVariablesError'); 87 | expect(error.message).toBe('Invalid Environment Variables please check your .env file'); 88 | expect(error.errors).toEqual(validationErrors); 89 | } 90 | }); 91 | 92 | it('should throw CustomError with multiple validation errors', () => { 93 | process.env.CTP_CLIENT_ID = 'invalid'; 94 | process.env.CTP_CLIENT_SECRET = 'invalid'; 95 | process.env.CTP_PROJECT_KEY = 'test-project'; 96 | process.env.CTP_REGION = 'us-central1.gcp'; 97 | process.env.STRIPE_API_TOKEN = 'sk_test_token'; 98 | 99 | const validationErrors = [ 100 | { code: 'InValidClientId', message: 'Client id should be 24 characters.' }, 101 | { code: 'InvalidClientSecret', message: 'Client secret should be 32 characters.' }, 102 | ]; 103 | 104 | getValidateMessages.mockReturnValue(validationErrors); 105 | 106 | expect(() => readConfiguration()).toThrow(CustomError); 107 | 108 | try { 109 | readConfiguration(); 110 | } catch (error) { 111 | expect(error.errors).toEqual(validationErrors); 112 | expect(error.errors).toHaveLength(2); 113 | } 114 | }); 115 | 116 | it('should call getValidateMessages with correct parameters', () => { 117 | process.env.CTP_CLIENT_ID = '123456789012345678901234'; 118 | process.env.CTP_CLIENT_SECRET = '12345678901234567890123456789012'; 119 | process.env.CTP_PROJECT_KEY = 'test-project'; 120 | process.env.CTP_SCOPE = 'manage_project'; 121 | process.env.CTP_REGION = 'us-central1.gcp'; 122 | process.env.STRIPE_API_TOKEN = 'sk_test_token'; 123 | 124 | getValidateMessages.mockReturnValue([]); 125 | 126 | readConfiguration(); 127 | 128 | expect(getValidateMessages).toHaveBeenCalled(); 129 | const callArgs = getValidateMessages.mock.calls[0]; 130 | expect(callArgs[1]).toHaveProperty('clientId'); 131 | expect(callArgs[1]).toHaveProperty('clientSecret'); 132 | expect(callArgs[1]).toHaveProperty('projectKey'); 133 | expect(callArgs[1]).toHaveProperty('scope'); 134 | expect(callArgs[1]).toHaveProperty('region'); 135 | expect(callArgs[1]).toHaveProperty('stripeApiToken'); 136 | }); 137 | 138 | it('should handle different region values', () => { 139 | const regions = [ 140 | 'us-central1.gcp', 141 | 'us-east-2.aws', 142 | 'europe-west1.gcp', 143 | 'eu-central-1.aws', 144 | 'australia-southeast1.gcp', 145 | ]; 146 | 147 | regions.forEach((region) => { 148 | process.env.CTP_CLIENT_ID = '123456789012345678901234'; 149 | process.env.CTP_CLIENT_SECRET = '12345678901234567890123456789012'; 150 | process.env.CTP_PROJECT_KEY = 'test-project'; 151 | process.env.CTP_REGION = region; 152 | process.env.STRIPE_API_TOKEN = 'sk_test_token'; 153 | 154 | getValidateMessages.mockReturnValue([]); 155 | 156 | const config = readConfiguration(); 157 | expect(config.region).toBe(region); 158 | }); 159 | }); 160 | }); 161 | }); 162 | 163 | -------------------------------------------------------------------------------- /tax-calculator/src/connectors/customTypes.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Custom Type Definitions for Stripe Tax Connector 3 | * 4 | * This file contains all custom type definitions required for the Stripe Tax connector. 5 | * Custom types are created during post-deploy to enable merchants to configure 6 | * tax codes at different levels (products, categories, shipping methods). 7 | */ 8 | 9 | export const TAX_CODE_CUSTOM_TYPE_NAME = 'connectorStripeTax_TaxCode'; 10 | 11 | export const CART_TAX_FIELD_NAMES = { 12 | CALCULATION_REFERENCES: 'connectorStripeTax_calculationReferences', 13 | AMOUNT_TOTAL: 'connectorStripeTax_amountTotal', 14 | TAX_AMOUNT_EXCLUSIVE: 'connectorStripeTax_taxAmountExclusive', 15 | TAX_AMOUNT_INCLUSIVE: 'connectorStripeTax_taxAmountInclusive', 16 | CURRENCIES: 'connectorStripeTax_currencies', 17 | EXPIRES_AT: 'connectorStripeTax_expiresAt', 18 | CALCULATION_TIMESTAMP: 'connectorStripeTax_calculationTimestamp' 19 | }; 20 | 21 | /** 22 | * Custom type for Product and Line Item tax code configuration 23 | * Allows merchants to set tax codes directly on products or line items 24 | */ 25 | export const PRODUCT_TAX_CUSTOM_TYPE = { 26 | key: process.env.CUSTOM_TYPE_PRODUCT_KEY || 'connector-stripe-tax-product', 27 | name: { 28 | en: 'Stripe Tax Connector Configuration', 29 | }, 30 | description: { 31 | en: 'Custom fields for Stripe Tax connector to configure tax codes and settings per product', 32 | }, 33 | resourceTypeIds: ['product-price', 'line-item'], 34 | fieldDefinitions: [ 35 | { 36 | name: TAX_CODE_CUSTOM_TYPE_NAME, 37 | label: { 38 | en: 'Stripe Tax Code', 39 | }, 40 | required: false, 41 | type: { 42 | name: 'String', 43 | }, 44 | inputHint: 'SingleLine', 45 | }, 46 | ], 47 | }; 48 | 49 | /** 50 | * Custom type for Category tax code configuration 51 | * Allows merchants to set tax codes directly on categories for automatic inheritance 52 | */ 53 | export const CATEGORY_TAX_CUSTOM_TYPE = { 54 | key: process.env.CUSTOM_TYPE_CATEGORY_KEY || 'connector-stripe-tax-category', 55 | name: { 56 | en: 'Stripe Tax Category Configuration', 57 | }, 58 | description: { 59 | en: 'Custom fields for Stripe Tax connector to configure tax codes per category', 60 | }, 61 | resourceTypeIds: ['category'], 62 | fieldDefinitions: [ 63 | { 64 | name: TAX_CODE_CUSTOM_TYPE_NAME, 65 | label: { 66 | en: 'Stripe Tax Code', 67 | }, 68 | required: false, 69 | type: { 70 | name: 'String', 71 | }, 72 | inputHint: 'SingleLine', 73 | }, 74 | ], 75 | }; 76 | 77 | /** 78 | * Custom type for Shipping Method tax code configuration 79 | * Allows merchants to set tax codes for shipping methods 80 | */ 81 | export const SHIPPING_TAX_CUSTOM_TYPE = { 82 | key: process.env.CUSTOM_TYPE_SHIPPING_KEY || 'connector-stripe-tax-shipping', 83 | name: { 84 | en: 'Stripe Tax Shipping Configuration', 85 | }, 86 | description: { 87 | en: 'Custom fields for Stripe Tax connector shipping method configuration', 88 | }, 89 | resourceTypeIds: ['shipping-method'], 90 | fieldDefinitions: [ 91 | { 92 | name: TAX_CODE_CUSTOM_TYPE_NAME, 93 | label: { 94 | en: 'Stripe Tax Code', 95 | }, 96 | required: false, 97 | type: { 98 | name: 'String', 99 | }, 100 | inputHint: 'SingleLine', 101 | }, 102 | ], 103 | }; 104 | 105 | /** 106 | * Custom type for Cart tax calculation reference 107 | * Allows merchants to set tax calculation reference for cart and order 108 | */ 109 | export const CART_TAX_CUSTOM_TYPE = { 110 | "key": process.env.CUSTOM_TYPE_CART_KEY || "connector-stripe-tax-calculation-reference", 111 | "name": { 112 | "en": "Stripe Tax Calculation Reference" 113 | }, 114 | "description": { 115 | "en": "Stripe tax calculation reference for cart and order" 116 | }, 117 | "resourceTypeIds": ["order"], 118 | "fieldDefinitions": [ 119 | { 120 | "name": CART_TAX_FIELD_NAMES.CALCULATION_REFERENCES, 121 | "label": { 122 | "en": "Stripe Tax Calculation References" 123 | }, 124 | "type": { 125 | "name": "Set", 126 | "elementType": { 127 | "name": "String" 128 | } 129 | }, 130 | "required": false, 131 | "inputHint": "SingleLine" 132 | }, 133 | { 134 | "name": CART_TAX_FIELD_NAMES.AMOUNT_TOTAL, 135 | "label": { 136 | "en": "Total Amount (cents)" 137 | }, 138 | "type": { 139 | "name": "Number" 140 | }, 141 | "required": false, 142 | "inputHint": "SingleLine" 143 | }, 144 | { 145 | "name": CART_TAX_FIELD_NAMES.TAX_AMOUNT_EXCLUSIVE, 146 | "label": { 147 | "en": "Tax Amount Exclusive (cents)" 148 | }, 149 | "type": { 150 | "name": "Number" 151 | }, 152 | "required": false, 153 | "inputHint": "SingleLine" 154 | }, 155 | { 156 | "name": CART_TAX_FIELD_NAMES.TAX_AMOUNT_INCLUSIVE, 157 | "label": { 158 | "en": "Tax Amount Inclusive (cents)" 159 | }, 160 | "type": { 161 | "name": "Number" 162 | }, 163 | "required": false, 164 | "inputHint": "SingleLine" 165 | }, 166 | { 167 | "name": CART_TAX_FIELD_NAMES.CURRENCIES, 168 | "label": { 169 | "en": "Currencies Codes" 170 | }, 171 | "type": { 172 | "name": "Set", 173 | "elementType": { 174 | "name": "String" 175 | } 176 | }, 177 | "required": false, 178 | "inputHint": "SingleLine" 179 | }, 180 | { 181 | "name": CART_TAX_FIELD_NAMES.EXPIRES_AT, 182 | "label": { 183 | "en": "Tax Calculation Expires At (ISO 8601)" 184 | }, 185 | "type": { 186 | "name": "Set", 187 | "elementType": { 188 | "name": "String" 189 | } 190 | }, 191 | "required": false, 192 | "inputHint": "SingleLine" 193 | }, 194 | { 195 | "name": CART_TAX_FIELD_NAMES.CALCULATION_TIMESTAMP, 196 | "label": { 197 | "en": "Calculation Timestamp (ISO 8601)" 198 | }, 199 | "type": { 200 | "name": "String" 201 | }, 202 | "required": false, 203 | "inputHint": "SingleLine" 204 | } 205 | ] 206 | } 207 | 208 | export const ALL_CUSTOM_TYPES = [ 209 | PRODUCT_TAX_CUSTOM_TYPE, 210 | CATEGORY_TAX_CUSTOM_TYPE, 211 | SHIPPING_TAX_CUSTOM_TYPE, 212 | CART_TAX_CUSTOM_TYPE 213 | ]; -------------------------------------------------------------------------------- /order-syncer/test/unit/clients/query.client.spec.js: -------------------------------------------------------------------------------- 1 | import { expect, describe, it, jest, beforeEach } from '@jest/globals'; 2 | import { getOrderWithPaymentInfo, getOrder } from '../../../src/clients/query.client.js'; 3 | import CustomError from '../../../src/errors/custom.error.js'; 4 | import { HTTP_STATUS_SUCCESS_ACCEPTED } from '../../../src/constants/http.status.constants.js'; 5 | 6 | // Mock dependencies 7 | jest.mock('../../../src/clients/create.client.js', () => ({ 8 | createApiRoot: jest.fn(), 9 | })); 10 | 11 | import { createApiRoot } from '../../../src/clients/create.client.js'; 12 | 13 | describe('query.client.spec', () => { 14 | let mockApiRoot; 15 | 16 | beforeEach(() => { 17 | jest.clearAllMocks(); 18 | mockApiRoot = { 19 | orders: jest.fn(), 20 | }; 21 | createApiRoot.mockReturnValue(mockApiRoot); 22 | }); 23 | 24 | describe('getOrderWithPaymentInfo', () => { 25 | it('should return order object with payment info when order is found', async () => { 26 | const orderId = 'order-123'; 27 | const mockOrder = { 28 | id: orderId, 29 | version: 1, 30 | orderState: 'Confirmed', 31 | totalPrice: { currencyCode: 'USD', centAmount: 1000 }, 32 | paymentInfo: { 33 | payments: [ 34 | { 35 | obj: { 36 | id: 'payment-123', 37 | interfaceId: 'pi_123', 38 | }, 39 | }, 40 | ], 41 | }, 42 | custom: { 43 | fields: { 44 | connectorStripeTax_calculationReferences: ['calc_123'], 45 | }, 46 | }, 47 | }; 48 | 49 | const mockOrderResponse = { 50 | body: mockOrder, 51 | }; 52 | 53 | const mockOrderRequest = { 54 | withId: jest.fn().mockReturnThis(), 55 | get: jest.fn().mockReturnThis(), 56 | execute: jest.fn().mockResolvedValue(mockOrderResponse), 57 | }; 58 | 59 | mockApiRoot.orders.mockReturnValue(mockOrderRequest); 60 | mockOrderRequest.withId.mockReturnValue(mockOrderRequest); 61 | 62 | const result = await getOrderWithPaymentInfo(orderId); 63 | 64 | expect(createApiRoot).toHaveBeenCalled(); 65 | expect(mockApiRoot.orders).toHaveBeenCalled(); 66 | expect(mockOrderRequest.withId).toHaveBeenCalledWith({ ID: orderId }); 67 | expect(mockOrderRequest.get).toHaveBeenCalledWith({ 68 | queryArgs: { withTotal: false, expand: ['paymentInfo.payments[*]'] }, 69 | }); 70 | expect(result).toEqual(mockOrder); 71 | }); 72 | 73 | it('should return order without payment info when payments are not present', async () => { 74 | const orderId = 'order-123'; 75 | const mockOrder = { 76 | id: orderId, 77 | version: 1, 78 | orderState: 'Confirmed', 79 | totalPrice: { currencyCode: 'USD', centAmount: 1000 }, 80 | paymentInfo: undefined, 81 | }; 82 | 83 | const mockOrderResponse = { 84 | body: mockOrder, 85 | }; 86 | 87 | const mockOrderRequest = { 88 | withId: jest.fn().mockReturnThis(), 89 | get: jest.fn().mockReturnThis(), 90 | execute: jest.fn().mockResolvedValue(mockOrderResponse), 91 | }; 92 | 93 | mockApiRoot.orders.mockReturnValue(mockOrderRequest); 94 | mockOrderRequest.withId.mockReturnValue(mockOrderRequest); 95 | 96 | const result = await getOrderWithPaymentInfo(orderId); 97 | 98 | expect(result).toEqual(mockOrder); 99 | expect(result.paymentInfo).toBeUndefined(); 100 | }); 101 | 102 | it('should throw CustomError when API call fails', async () => { 103 | const orderId = 'order-123'; 104 | const apiError = new Error('Order not found'); 105 | 106 | const mockOrderRequest = { 107 | withId: jest.fn().mockReturnThis(), 108 | get: jest.fn().mockReturnThis(), 109 | execute: jest.fn().mockRejectedValue(apiError), 110 | }; 111 | 112 | mockApiRoot.orders.mockReturnValue(mockOrderRequest); 113 | mockOrderRequest.withId.mockReturnValue(mockOrderRequest); 114 | 115 | await expect(getOrderWithPaymentInfo(orderId)).rejects.toThrow(); 116 | 117 | try { 118 | await getOrderWithPaymentInfo(orderId); 119 | } catch (error) { 120 | expect(error).toBeInstanceOf(CustomError); 121 | expect(error.statusCode).toBe(HTTP_STATUS_SUCCESS_ACCEPTED); 122 | expect(error.message).toBe(apiError.message); 123 | } 124 | }); 125 | }); 126 | 127 | describe('getOrder', () => { 128 | it('should return order object when order is found', async () => { 129 | const orderId = 'order-123'; 130 | const mockOrder = { 131 | id: orderId, 132 | version: 1, 133 | orderState: 'Confirmed', 134 | totalPrice: { currencyCode: 'USD', centAmount: 1000 }, 135 | }; 136 | 137 | const mockOrderResponse = { 138 | body: mockOrder, 139 | }; 140 | 141 | const mockOrderRequest = { 142 | withId: jest.fn().mockReturnThis(), 143 | get: jest.fn().mockReturnThis(), 144 | execute: jest.fn().mockResolvedValue(mockOrderResponse), 145 | }; 146 | 147 | mockApiRoot.orders.mockReturnValue(mockOrderRequest); 148 | mockOrderRequest.withId.mockReturnValue(mockOrderRequest); 149 | 150 | const result = await getOrder(orderId); 151 | 152 | expect(createApiRoot).toHaveBeenCalled(); 153 | expect(mockApiRoot.orders).toHaveBeenCalled(); 154 | expect(mockOrderRequest.withId).toHaveBeenCalledWith({ ID: orderId }); 155 | expect(mockOrderRequest.get).toHaveBeenCalled(); 156 | expect(result).toEqual(mockOrder); 157 | }); 158 | 159 | it('should throw CustomError when API call fails', async () => { 160 | const orderId = 'order-123'; 161 | const apiError = new Error('Order not found'); 162 | 163 | const mockOrderRequest = { 164 | withId: jest.fn().mockReturnThis(), 165 | get: jest.fn().mockReturnThis(), 166 | execute: jest.fn().mockRejectedValue(apiError), 167 | }; 168 | 169 | mockApiRoot.orders.mockReturnValue(mockOrderRequest); 170 | mockOrderRequest.withId.mockReturnValue(mockOrderRequest); 171 | 172 | await expect(getOrder(orderId)).rejects.toThrow(); 173 | 174 | try { 175 | await getOrder(orderId); 176 | } catch (error) { 177 | expect(error).toBeInstanceOf(CustomError); 178 | expect(error.statusCode).toBe(HTTP_STATUS_SUCCESS_ACCEPTED); 179 | expect(error.message).toBe(apiError.message); 180 | } 181 | }); 182 | }); 183 | }); 184 | -------------------------------------------------------------------------------- /tax-calculator/test/unit/routes/address.validation.route.spec.js: -------------------------------------------------------------------------------- 1 | import { expect, describe, it, jest, beforeEach, afterEach } from '@jest/globals'; 2 | import request from 'supertest'; 3 | import express from 'express'; 4 | import addressValidationRouter from '../../../src/routes/address.validation.route.js'; 5 | 6 | // Mock dependencies 7 | jest.mock('../../../src/controllers/address.validation.controller.js', () => ({ 8 | validateAddressHandler: jest.fn((req, res) => { 9 | res.status(200).json({ success: true }); 10 | }) 11 | })); 12 | 13 | jest.mock('../../../src/middlewares/rate.limiter.middleware.js', () => ({ 14 | rateLimiterMiddleware: jest.fn((requests, window) => { 15 | return (req, res, next) => { 16 | next(); 17 | }; 18 | }) 19 | })); 20 | 21 | describe('address.validation.route', () => { 22 | let app; 23 | let originalEnv; 24 | let validateAddressHandler; 25 | let rateLimiterMiddleware; 26 | 27 | beforeEach(async () => { 28 | originalEnv = { ...process.env }; 29 | 30 | // Get references to mocked functions before clearing mocks 31 | const controllerModule = await import('../../../src/controllers/address.validation.controller.js'); 32 | const middlewareModule = await import('../../../src/middlewares/rate.limiter.middleware.js'); 33 | validateAddressHandler = controllerModule.validateAddressHandler; 34 | rateLimiterMiddleware = middlewareModule.rateLimiterMiddleware; 35 | 36 | app = express(); 37 | app.use(express.json()); 38 | app.use('/api', addressValidationRouter); 39 | 40 | // Clear mocks after setting up app (this clears call history but not implementations) 41 | jest.clearAllMocks(); 42 | }); 43 | 44 | afterEach(() => { 45 | process.env = originalEnv; 46 | }); 47 | 48 | describe('POST /validateAddress', () => { 49 | it('should use default rate limit when environment variables are not set', async () => { 50 | delete process.env.ADDRESS_VALIDATION_RATE_LIMIT; 51 | delete process.env.ADDRESS_VALIDATION_WINDOW_MINUTES; 52 | 53 | // Re-import to get fresh defaults 54 | jest.resetModules(); 55 | const freshRouter = (await import('../../../src/routes/address.validation.route.js')).default; 56 | const freshApp = express(); 57 | freshApp.use(express.json()); 58 | freshApp.use('/api', freshRouter); 59 | 60 | const response = await request(freshApp) 61 | .post('/api/validateAddress') 62 | .send({ address: { country: 'US' } }); 63 | 64 | expect(response.status).toBe(200); 65 | }); 66 | 67 | it('should use custom rate limit from environment variables', async () => { 68 | process.env.ADDRESS_VALIDATION_RATE_LIMIT = '200'; 69 | process.env.ADDRESS_VALIDATION_WINDOW_MINUTES = '5'; 70 | 71 | // Re-import to get fresh defaults 72 | jest.resetModules(); 73 | const freshRouter = (await import('../../../src/routes/address.validation.route.js')).default; 74 | const freshApp = express(); 75 | freshApp.use(express.json()); 76 | freshApp.use('/api', freshRouter); 77 | 78 | const response = await request(freshApp) 79 | .post('/api/validateAddress') 80 | .send({ address: { country: 'US' } }); 81 | 82 | expect(response.status).toBe(200); 83 | }); 84 | 85 | it('should handle invalid rate limit values and use defaults', async () => { 86 | process.env.ADDRESS_VALIDATION_RATE_LIMIT = 'invalid'; 87 | process.env.ADDRESS_VALIDATION_WINDOW_MINUTES = 'invalid'; 88 | 89 | // Re-import to get fresh defaults 90 | jest.resetModules(); 91 | const freshRouter = (await import('../../../src/routes/address.validation.route.js')).default; 92 | const freshApp = express(); 93 | freshApp.use(express.json()); 94 | freshApp.use('/api', freshRouter); 95 | 96 | const response = await request(freshApp) 97 | .post('/api/validateAddress') 98 | .send({ address: { country: 'US' } }); 99 | 100 | // Should still work with NaN defaults (which parseInt will handle) 101 | expect(response.status).toBe(200); 102 | }); 103 | 104 | it('should call validateAddressHandler when route is accessed', async () => { 105 | // Re-import to ensure we're using the mocked handler 106 | jest.resetModules(); 107 | 108 | // Get fresh references after reset 109 | const { validateAddressHandler: handler } = await import('../../../src/controllers/address.validation.controller.js'); 110 | const testRouter = (await import('../../../src/routes/address.validation.route.js')).default; 111 | 112 | // Set up a fresh app with the re-imported router 113 | const testApp = express(); 114 | testApp.use(express.json()); 115 | testApp.use('/api', testRouter); 116 | 117 | // Clear the handler mock to get a fresh call count 118 | handler.mockClear(); 119 | 120 | const response = await request(testApp) 121 | .post('/api/validateAddress') 122 | .send({ address: { country: 'US' } }); 123 | 124 | expect(response.status).toBe(200); 125 | expect(handler).toHaveBeenCalled(); 126 | // Verify it was called with req, res, and next (Express route handler signature) 127 | expect(handler).toHaveBeenCalledWith( 128 | expect.objectContaining({ body: expect.objectContaining({ address: expect.any(Object) }) }), 129 | expect.any(Object), 130 | expect.any(Function) // next function 131 | ); 132 | }); 133 | 134 | it('should apply rate limiter middleware', async () => { 135 | // Re-import the route to trigger rateLimiterMiddleware call 136 | jest.resetModules(); 137 | 138 | // Get fresh reference to the mocked middleware after reset 139 | const middlewareModule = await import('../../../src/middlewares/rate.limiter.middleware.js'); 140 | const freshRateLimiterMiddleware = middlewareModule.rateLimiterMiddleware; 141 | 142 | const testRouter = (await import('../../../src/routes/address.validation.route.js')).default; 143 | const testApp = express(); 144 | testApp.use(express.json()); 145 | testApp.use('/api', testRouter); 146 | 147 | // Now verify that rateLimiterMiddleware was called with default values (100, 1) 148 | expect(freshRateLimiterMiddleware).toHaveBeenCalledWith(100, 1); 149 | 150 | // Also verify the route works correctly with the middleware applied 151 | const response = await request(testApp) 152 | .post('/api/validateAddress') 153 | .send({ address: { country: 'US' } }); 154 | 155 | expect(response.status).toBe(200); 156 | }); 157 | }); 158 | }); 159 | 160 | -------------------------------------------------------------------------------- /tax-calculator/test/unit/utils/config.util.spec.js: -------------------------------------------------------------------------------- 1 | import { expect, describe, it, jest, beforeEach, afterEach } from '@jest/globals'; 2 | import configUtil from '../../../src/utils/config.util.js'; 3 | import CustomError from '../../../src/errors/custom.error.js'; 4 | 5 | // Mock dependencies 6 | jest.mock('../../../src/validators/env-var.validators.js', () => ({ 7 | default: [] 8 | })); 9 | 10 | jest.mock('../../../src/validators/helpers.validators.js', () => ({ 11 | getValidateMessages: jest.fn() 12 | })); 13 | 14 | jest.mock('../../../src/errors/custom.error.js', () => { 15 | const MockCustomError = jest.fn().mockImplementation((statusCode, message, errors) => { 16 | const error = new Error(message); 17 | error.statusCode = statusCode; 18 | error.message = message; 19 | if (errors) { 20 | error.errors = errors; 21 | } 22 | Object.setPrototypeOf(error, MockCustomError.prototype); 23 | return error; 24 | }); 25 | return MockCustomError; 26 | }); 27 | 28 | import envValidators from '../../../src/validators/env-var.validators.js'; 29 | import { getValidateMessages } from '../../../src/validators/helpers.validators.js'; 30 | 31 | describe('ConfigUtil', () => { 32 | const originalEnv = process.env; 33 | 34 | beforeEach(() => { 35 | jest.resetModules(); 36 | process.env = { ...originalEnv }; 37 | jest.clearAllMocks(); 38 | }); 39 | 40 | afterEach(() => { 41 | process.env = originalEnv; 42 | }); 43 | 44 | describe('readConfiguration', () => { 45 | it('should return configuration when all env vars are valid', () => { 46 | process.env.CTP_CLIENT_ID = '123456789012345678901234'; 47 | process.env.CTP_CLIENT_SECRET = '12345678901234567890123456789012'; 48 | process.env.CTP_PROJECT_KEY = 'test-project'; 49 | process.env.CTP_SCOPE = 'test-scope'; 50 | process.env.CTP_REGION = 'us-central1.gcp'; 51 | process.env.STRIPE_API_TOKEN = 'sk_test_token'; 52 | process.env.TAX_CODE_CATEGORY_MAPPING_JSON = '{}'; 53 | process.env.TAX_BEHAVIOR_DEFAULT = 'exclusive'; 54 | process.env.TAX_BEHAVIOR_COUNTRY_MAPPING = '{}'; 55 | 56 | getValidateMessages.mockReturnValue([]); 57 | 58 | const config = configUtil.readConfiguration(); 59 | 60 | expect(config).toEqual({ 61 | clientId: '123456789012345678901234', 62 | clientSecret: '12345678901234567890123456789012', 63 | projectKey: 'test-project', 64 | scope: 'test-scope', 65 | region: 'us-central1.gcp', 66 | stripeApiToken: 'sk_test_token', 67 | taxCodeMapping: '{}', 68 | taxBehaviorDefault: 'exclusive', 69 | countryTaxBehaviorMapping: '{}' 70 | }); 71 | 72 | expect(getValidateMessages).toHaveBeenCalledWith( 73 | envValidators, 74 | expect.objectContaining({ 75 | clientId: '123456789012345678901234', 76 | clientSecret: '12345678901234567890123456789012', 77 | projectKey: 'test-project', 78 | scope: 'test-scope', 79 | region: 'us-central1.gcp', 80 | stripeApiToken: 'sk_test_token', 81 | taxCodeMapping: '{}', 82 | taxBehaviorDefault: 'exclusive', 83 | countryTaxBehaviorMapping: '{}' 84 | }) 85 | ); 86 | }); 87 | 88 | it('should throw CustomError when validation errors exist', () => { 89 | process.env.CTP_CLIENT_ID = 'invalid'; 90 | process.env.CTP_CLIENT_SECRET = '12345678901234567890123456789012'; 91 | process.env.CTP_PROJECT_KEY = 'test-project'; 92 | process.env.CTP_REGION = 'us-central1.gcp'; 93 | process.env.STRIPE_API_TOKEN = 'sk_test_token'; 94 | 95 | const validationErrors = [ 96 | { 97 | code: 'InValidClientId', 98 | message: 'Client id should be 24 characters.', 99 | referencedBy: 'environmentVariables' 100 | } 101 | ]; 102 | 103 | getValidateMessages.mockReturnValue(validationErrors); 104 | 105 | expect(() => { 106 | configUtil.readConfiguration(); 107 | }).toThrow(CustomError); 108 | 109 | expect(CustomError).toHaveBeenCalledWith( 110 | 'InvalidEnvironmentVariablesError', 111 | 'Invalid Environment Variables please check your .env file', 112 | validationErrors 113 | ); 114 | }); 115 | 116 | it('should handle missing optional env vars', () => { 117 | process.env.CTP_CLIENT_ID = '123456789012345678901234'; 118 | process.env.CTP_CLIENT_SECRET = '12345678901234567890123456789012'; 119 | process.env.CTP_PROJECT_KEY = 'test-project'; 120 | process.env.CTP_REGION = 'us-central1.gcp'; 121 | process.env.STRIPE_API_TOKEN = 'sk_test_token'; 122 | 123 | // Optional vars not set 124 | delete process.env.CTP_SCOPE; 125 | delete process.env.TAX_CODE_CATEGORY_MAPPING_JSON; 126 | delete process.env.TAX_BEHAVIOR_DEFAULT; 127 | delete process.env.TAX_BEHAVIOR_COUNTRY_MAPPING; 128 | 129 | getValidateMessages.mockReturnValue([]); 130 | 131 | const config = configUtil.readConfiguration(); 132 | 133 | expect(config.scope).toBeUndefined(); 134 | expect(config.taxCodeMapping).toBeUndefined(); 135 | expect(config.taxBehaviorDefault).toBeUndefined(); 136 | expect(config.countryTaxBehaviorMapping).toBeUndefined(); 137 | }); 138 | 139 | it('should include all env vars in validation call', () => { 140 | process.env.CTP_CLIENT_ID = '123456789012345678901234'; 141 | process.env.CTP_CLIENT_SECRET = '12345678901234567890123456789012'; 142 | process.env.CTP_PROJECT_KEY = 'test-project'; 143 | process.env.CTP_SCOPE = 'test-scope'; 144 | process.env.CTP_REGION = 'us-central1.gcp'; 145 | process.env.STRIPE_API_TOKEN = 'sk_test_token'; 146 | process.env.TAX_CODE_CATEGORY_MAPPING_JSON = '{"categories": []}'; 147 | process.env.TAX_BEHAVIOR_DEFAULT = 'inclusive'; 148 | process.env.TAX_BEHAVIOR_COUNTRY_MAPPING = '{"US": "exclusive"}'; 149 | 150 | getValidateMessages.mockReturnValue([]); 151 | 152 | configUtil.readConfiguration(); 153 | 154 | expect(getValidateMessages).toHaveBeenCalledWith( 155 | envValidators, 156 | expect.objectContaining({ 157 | clientId: '123456789012345678901234', 158 | clientSecret: '12345678901234567890123456789012', 159 | projectKey: 'test-project', 160 | scope: 'test-scope', 161 | region: 'us-central1.gcp', 162 | stripeApiToken: 'sk_test_token', 163 | taxCodeMapping: '{"categories": []}', 164 | taxBehaviorDefault: 'inclusive', 165 | countryTaxBehaviorMapping: '{"US": "exclusive"}' 166 | }) 167 | ); 168 | }); 169 | }); 170 | }); 171 | 172 | -------------------------------------------------------------------------------- /tax-calculator/src/services/tax-behavior.service.js: -------------------------------------------------------------------------------- 1 | import { logger } from '../utils/logger.utils.js'; 2 | import configUtils from '../utils/config.util.js'; 3 | import { VALID_TAX_BEHAVIORS } from '../constants/tax-behavior.constants.js'; 4 | 5 | class TaxBehaviorService { 6 | constructor() { 7 | // Cache for configuration and parsed JSON to avoid repeated I/O and parsing 8 | this.configCache = null; 9 | this.configCacheTimestamp = 0; 10 | this.countryMappingCache = null; 11 | this.cacheTTL = 5 * 60 * 1000; // 5 minutes 12 | } 13 | 14 | /** 15 | * Determine tax behavior for cart line items with priority-based fallback logic 16 | * Tax behavior is determined once at the cart level and applied to all line items 17 | * @param {Object} cartRequest - commercetools cart request 18 | * @returns {Object} Tax behavior configuration for each line item 19 | */ 20 | determineTaxBehaviorForCart(cartRequest) { 21 | // Determine tax behavior once at cart level 22 | const cartTaxBehavior = this.determineCartTaxBehavior(cartRequest); 23 | 24 | // Apply the same behavior to all line items 25 | const lineItemBehaviors = {}; 26 | for (const lineItem of cartRequest.lineItems) { 27 | lineItemBehaviors[lineItem.id] = cartTaxBehavior; 28 | } 29 | 30 | return lineItemBehaviors; 31 | } 32 | 33 | /** 34 | * Determine tax behavior for the entire cart based on cart context 35 | * Priority order: 36 | * 1. Country-based behavior (country mapping) 37 | * 2. Merchant-wide default configuration 38 | * 39 | * Returns null if no behavior is determined, letting Stripe use its own default behavior 40 | */ 41 | determineCartTaxBehavior(cartContext) { 42 | // Priority 1: Country-based behavior (country mapping) 43 | const countryBehavior = this.getCountryBasedBehavior(cartContext); 44 | if (countryBehavior) { 45 | logger.debug(`Using country-based tax behavior for country ${cartContext.country}: ${countryBehavior}`); 46 | return countryBehavior; 47 | } 48 | 49 | // Priority 2: Merchant-wide default configuration 50 | const merchantBehavior = this.getMerchantDefaultBehavior(); 51 | if (merchantBehavior) { 52 | logger.debug(`Using merchant default tax behavior: ${merchantBehavior}`); 53 | return merchantBehavior; 54 | } 55 | 56 | // No behavior determined - let Stripe use its own default behavior 57 | logger.debug(`No tax behavior determined for cart, letting Stripe use its default behavior`); 58 | return null; 59 | } 60 | 61 | 62 | /** 63 | * Get tax behavior based on country mapping configuration 64 | */ 65 | getCountryBasedBehavior(cartContext) { 66 | const countryCode = cartContext.country; 67 | if (!countryCode) { 68 | return null; 69 | } 70 | 71 | try { 72 | const countryMapping = this.getCountryTaxBehaviorMapping(); 73 | const behavior = countryMapping[countryCode.toUpperCase()]; 74 | 75 | if (behavior && this.isValidBehavior(behavior)) { 76 | return behavior.toLowerCase(); 77 | } 78 | } catch (error) { 79 | logger.warn(`Error reading country tax behavior mapping: ${error.message}`); 80 | } 81 | 82 | return null; 83 | } 84 | 85 | /** 86 | * Get merchant-wide default tax behavior from configuration 87 | */ 88 | getMerchantDefaultBehavior() { 89 | try { 90 | const config = this.getCachedConfiguration(); 91 | const merchantBehavior = config.taxBehaviorDefault; 92 | 93 | if (merchantBehavior && this.isValidBehavior(merchantBehavior)) { 94 | return merchantBehavior.toLowerCase(); 95 | } 96 | } catch (error) { 97 | logger.warn(`Error reading merchant tax behavior configuration: ${error.message}`); 98 | } 99 | 100 | // No fallback - return null to let Stripe use its default behavior 101 | return null; 102 | } 103 | 104 | /** 105 | * Get country to tax behavior mapping from environment variable 106 | */ 107 | getCountryTaxBehaviorMapping() { 108 | // Check cache first 109 | if (this.countryMappingCache && Date.now() - this.configCacheTimestamp < this.cacheTTL) { 110 | return this.countryMappingCache; 111 | } 112 | 113 | try { 114 | const config = this.getCachedConfiguration(); 115 | const countryMappingJson = config.countryTaxBehaviorMapping; 116 | 117 | if (countryMappingJson) { 118 | const parsed = JSON.parse(countryMappingJson); 119 | // Cache the parsed result 120 | this.countryMappingCache = parsed; 121 | return parsed; 122 | } 123 | } catch (error) { 124 | logger.warn(`Error parsing country tax behavior mapping: ${error.message}`); 125 | } 126 | 127 | return {}; 128 | } 129 | 130 | /** 131 | * Get cached configuration to avoid repeated I/O 132 | * @returns {Object} Configuration object 133 | * @private 134 | */ 135 | getCachedConfiguration() { 136 | // Check if cache is valid 137 | if (this.configCache && Date.now() - this.configCacheTimestamp < this.cacheTTL) { 138 | return this.configCache; 139 | } 140 | 141 | // Read and cache configuration 142 | this.configCache = configUtils.readConfiguration(); 143 | this.configCacheTimestamp = Date.now(); 144 | return this.configCache; 145 | } 146 | 147 | /** 148 | * Clear all caches (useful for testing) 149 | */ 150 | clearCache() { 151 | this.configCache = null; 152 | this.configCacheTimestamp = 0; 153 | this.countryMappingCache = null; 154 | } 155 | 156 | 157 | /** 158 | * Validate tax behavior value 159 | */ 160 | isValidBehavior(behavior) { 161 | return VALID_TAX_BEHAVIORS.includes(behavior?.toLowerCase()); 162 | } 163 | 164 | /** 165 | * Log tax behavior decision for audit purposes 166 | */ 167 | logBehaviorDecision(lineItem, behavior, cartContext) { 168 | logger.debug('Tax behavior assignment', { 169 | productId: lineItem.productId, 170 | variantId: lineItem.variant?.id, 171 | assignedBehavior: behavior, 172 | currency: lineItem.totalPrice?.currencyCode, 173 | country: cartContext.country, 174 | decisionReason: this.getDecisionReason(cartContext, behavior), 175 | timestamp: new Date().toISOString() 176 | }); 177 | } 178 | 179 | /** 180 | * Determine the reason for the tax behavior decision 181 | */ 182 | getDecisionReason(cartContext, _behavior) { 183 | // Check if it came from country mapping 184 | if (this.getCountryBasedBehavior(cartContext)) { 185 | return 'country_mapping'; 186 | } 187 | 188 | // Must be merchant default 189 | return 'merchant_default'; 190 | } 191 | } 192 | 193 | export const taxBehaviorService = new TaxBehaviorService(); 194 | -------------------------------------------------------------------------------- /order-syncer/test/unit/clients/update.client.spec.js: -------------------------------------------------------------------------------- 1 | import { expect, describe, it, jest, beforeEach } from '@jest/globals'; 2 | import { updateOrderTaxTxn } from '../../../src/clients/update.client.js'; 3 | import CustomError from '../../../src/errors/custom.error.js'; 4 | import { HTTP_STATUS_SUCCESS_ACCEPTED } from '../../../src/constants/http.status.constants.js'; 5 | import { ORDER_TAX_FIELD_NAMES } from '../../../src/connectors/customTypes.js'; 6 | 7 | // Mock dependencies 8 | jest.mock('../../../src/clients/create.client.js', () => ({ 9 | createApiRoot: jest.fn(), 10 | })); 11 | 12 | jest.mock('../../../src/clients/query.client.js', () => ({ 13 | getOrder: jest.fn(), 14 | })); 15 | 16 | import { createApiRoot } from '../../../src/clients/create.client.js'; 17 | import { getOrder } from '../../../src/clients/query.client.js'; 18 | 19 | describe('update.client.spec', () => { 20 | let mockApiRoot; 21 | 22 | beforeEach(() => { 23 | jest.clearAllMocks(); 24 | mockApiRoot = { 25 | orders: jest.fn(), 26 | }; 27 | createApiRoot.mockReturnValue(mockApiRoot); 28 | }); 29 | 30 | describe('updateOrderTaxTxn', () => { 31 | it('should update order with tax transaction references', async () => { 32 | const orderId = 'order-123'; 33 | const taxTransactions = [ 34 | { id: 'txn_123' }, 35 | { id: 'txn_456' }, 36 | ]; 37 | const mockOrder = { 38 | id: orderId, 39 | version: 5, 40 | }; 41 | 42 | const mockUpdateResponse = { 43 | body: { 44 | id: orderId, 45 | version: 6, 46 | }, 47 | }; 48 | 49 | getOrder.mockResolvedValue(mockOrder); 50 | 51 | const mockOrderRequest = { 52 | withId: jest.fn().mockReturnThis(), 53 | post: jest.fn().mockReturnThis(), 54 | execute: jest.fn().mockResolvedValue(mockUpdateResponse), 55 | }; 56 | 57 | mockApiRoot.orders.mockReturnValue(mockOrderRequest); 58 | mockOrderRequest.withId.mockReturnValue(mockOrderRequest); 59 | 60 | const result = await updateOrderTaxTxn(taxTransactions, orderId); 61 | 62 | expect(getOrder).toHaveBeenCalledWith(orderId); 63 | expect(mockApiRoot.orders).toHaveBeenCalled(); 64 | expect(mockOrderRequest.withId).toHaveBeenCalledWith({ ID: orderId }); 65 | expect(mockOrderRequest.post).toHaveBeenCalledWith({ 66 | body: { 67 | actions: [ 68 | { 69 | action: 'setCustomField', 70 | name: ORDER_TAX_FIELD_NAMES.TRANSACTION_REFERENCES, 71 | value: ['txn_123', 'txn_456'], 72 | }, 73 | ], 74 | version: mockOrder.version, 75 | }, 76 | }); 77 | expect(result).toEqual(mockUpdateResponse); 78 | }); 79 | 80 | it('should handle single tax transaction', async () => { 81 | const orderId = 'order-123'; 82 | const taxTransactions = [{ id: 'txn_123' }]; 83 | const mockOrder = { 84 | id: orderId, 85 | version: 3, 86 | }; 87 | 88 | const mockUpdateResponse = { 89 | body: { 90 | id: orderId, 91 | version: 4, 92 | }, 93 | }; 94 | 95 | getOrder.mockResolvedValue(mockOrder); 96 | 97 | const mockOrderRequest = { 98 | withId: jest.fn().mockReturnThis(), 99 | post: jest.fn().mockReturnThis(), 100 | execute: jest.fn().mockResolvedValue(mockUpdateResponse), 101 | }; 102 | 103 | mockApiRoot.orders.mockReturnValue(mockOrderRequest); 104 | mockOrderRequest.withId.mockReturnValue(mockOrderRequest); 105 | 106 | await updateOrderTaxTxn(taxTransactions, orderId); 107 | 108 | expect(mockOrderRequest.post).toHaveBeenCalledWith({ 109 | body: { 110 | actions: [ 111 | { 112 | action: 'setCustomField', 113 | name: ORDER_TAX_FIELD_NAMES.TRANSACTION_REFERENCES, 114 | value: ['txn_123'], 115 | }, 116 | ], 117 | version: mockOrder.version, 118 | }, 119 | }); 120 | }); 121 | 122 | it('should handle empty tax transactions array', async () => { 123 | const orderId = 'order-123'; 124 | const taxTransactions = []; 125 | const mockOrder = { 126 | id: orderId, 127 | version: 2, 128 | }; 129 | 130 | getOrder.mockResolvedValue(mockOrder); 131 | 132 | const mockOrderRequest = { 133 | withId: jest.fn().mockReturnThis(), 134 | post: jest.fn().mockReturnThis(), 135 | execute: jest.fn().mockResolvedValue({ body: {} }), 136 | }; 137 | 138 | mockApiRoot.orders.mockReturnValue(mockOrderRequest); 139 | mockOrderRequest.withId.mockReturnValue(mockOrderRequest); 140 | 141 | await updateOrderTaxTxn(taxTransactions, orderId); 142 | 143 | expect(mockOrderRequest.post).toHaveBeenCalledWith({ 144 | body: { 145 | actions: [ 146 | { 147 | action: 'setCustomField', 148 | name: ORDER_TAX_FIELD_NAMES.TRANSACTION_REFERENCES, 149 | value: [], 150 | }, 151 | ], 152 | version: mockOrder.version, 153 | }, 154 | }); 155 | }); 156 | 157 | it('should throw CustomError when update API call fails', async () => { 158 | const orderId = 'order-123'; 159 | const taxTransactions = [{ id: 'txn_123' }]; 160 | const mockOrder = { 161 | id: orderId, 162 | version: 1, 163 | }; 164 | const apiError = new Error('Update failed'); 165 | 166 | getOrder.mockResolvedValue(mockOrder); 167 | 168 | const mockOrderRequest = { 169 | withId: jest.fn().mockReturnThis(), 170 | post: jest.fn().mockReturnThis(), 171 | execute: jest.fn().mockRejectedValue(apiError), 172 | }; 173 | 174 | mockApiRoot.orders.mockReturnValue(mockOrderRequest); 175 | mockOrderRequest.withId.mockReturnValue(mockOrderRequest); 176 | 177 | await expect(updateOrderTaxTxn(taxTransactions, orderId)).rejects.toThrow(); 178 | 179 | try { 180 | await updateOrderTaxTxn(taxTransactions, orderId); 181 | } catch (error) { 182 | expect(error).toBeInstanceOf(CustomError); 183 | expect(error.statusCode).toBe(HTTP_STATUS_SUCCESS_ACCEPTED); 184 | expect(error.message).toBe(apiError.message); 185 | } 186 | }); 187 | 188 | it('should throw CustomError when getOrder fails', async () => { 189 | const orderId = 'order-123'; 190 | const taxTransactions = [{ id: 'txn_123' }]; 191 | const apiError = new Error('Order not found'); 192 | 193 | getOrder.mockRejectedValue(apiError); 194 | 195 | await expect(updateOrderTaxTxn(taxTransactions, orderId)).rejects.toThrow(); 196 | }); 197 | }); 198 | }); 199 | 200 | -------------------------------------------------------------------------------- /tax-calculator/test/unit/errors/missingTaxRateForCountry.error.spec.js: -------------------------------------------------------------------------------- 1 | import { expect, describe, it } from '@jest/globals'; 2 | import MissingTaxRateForCountry from '../../../src/errors/missingTaxRateForCountry.error.js'; 3 | 4 | describe('MissingTaxRateForCountry', () => { 5 | describe('constructor', () => { 6 | it('should create error with country only', () => { 7 | const error = new MissingTaxRateForCountry('XX'); 8 | 9 | expect(error).toBeInstanceOf(Error); 10 | expect(error.name).toBe('MissingTaxRateForCountry'); 11 | expect(error.country).toBe('XX'); 12 | expect(error.state).toBeNull(); 13 | expect(error.originalError).toBeNull(); 14 | expect(error.statusCode).toBe(400); 15 | expect(error.message).toContain('Tax rate not available for country XX'); 16 | }); 17 | 18 | it('should create error with country and state', () => { 19 | const error = new MissingTaxRateForCountry('US', 'NY'); 20 | 21 | expect(error.country).toBe('US'); 22 | expect(error.state).toBe('NY'); 23 | expect(error.message).toContain('Tax rate not available for country US (NY)'); 24 | }); 25 | 26 | it('should create error with country, state, and original error', () => { 27 | const originalError = { 28 | message: 'Stripe error message', 29 | code: 'taxes_calculation_failed', 30 | type: 'StripeInvalidRequestError' 31 | }; 32 | 33 | const error = new MissingTaxRateForCountry('US', 'NY', originalError); 34 | 35 | expect(error.country).toBe('US'); 36 | expect(error.state).toBe('NY'); 37 | expect(error.originalError).toBe(originalError); 38 | expect(error.message).toContain('Tax rate not available for country US (NY)'); 39 | expect(error.message).toContain('Stripe error message'); 40 | }); 41 | 42 | it('should create error with country and original error (no state)', () => { 43 | const originalError = { 44 | message: 'Stripe error message' 45 | }; 46 | 47 | const error = new MissingTaxRateForCountry('XX', null, originalError); 48 | 49 | expect(error.country).toBe('XX'); 50 | expect(error.state).toBeNull(); 51 | expect(error.originalError).toBe(originalError); 52 | expect(error.message).toContain('Tax rate not available for country XX'); 53 | expect(error.message).toContain('Stripe error message'); 54 | }); 55 | 56 | it('should have default message when no original error', () => { 57 | const error = new MissingTaxRateForCountry('XX'); 58 | 59 | expect(error.message).toContain('This country may not be supported or tax registration is required.'); 60 | }); 61 | }); 62 | 63 | describe('toCommerceToolsError', () => { 64 | it('should convert error to commercetools format without state', () => { 65 | const error = new MissingTaxRateForCountry('XX'); 66 | const commercetoolsError = error.toCommerceToolsError(); 67 | 68 | expect(commercetoolsError).toEqual({ 69 | code: 'MissingTaxRateForCountry', 70 | message: 'Tax rate not available for country XX. This may be due to: (1) Country not supported by Stripe Tax, (2) No tax registration for this country, or (3) Invalid address preventing tax calculation.', 71 | extensionExtraInfo: { 72 | originalError: 'MissingTaxRateForCountry', 73 | country: 'XX', 74 | state: null, 75 | stripeErrorCode: undefined, 76 | stripeErrorType: undefined, 77 | stripeErrorMessage: undefined, 78 | action: 'Please verify: (1) Country is supported by Stripe Tax, (2) Tax registration exists for this country in Stripe Dashboard, (3) Customer address is valid and complete.' 79 | } 80 | }); 81 | }); 82 | 83 | it('should convert error to commercetools format with state', () => { 84 | const error = new MissingTaxRateForCountry('US', 'NY'); 85 | const commercetoolsError = error.toCommerceToolsError(); 86 | 87 | expect(commercetoolsError.code).toBe('MissingTaxRateForCountry'); 88 | expect(commercetoolsError.message).toContain('Tax rate not available for country US and state NY'); 89 | expect(commercetoolsError.extensionExtraInfo.country).toBe('US'); 90 | expect(commercetoolsError.extensionExtraInfo.state).toBe('NY'); 91 | }); 92 | 93 | it('should include original Stripe error details', () => { 94 | const originalError = { 95 | message: 'Stripe error message', 96 | code: 'taxes_calculation_failed', 97 | type: 'StripeInvalidRequestError' 98 | }; 99 | 100 | const error = new MissingTaxRateForCountry('US', 'NY', originalError); 101 | const commercetoolsError = error.toCommerceToolsError(); 102 | 103 | expect(commercetoolsError.extensionExtraInfo.stripeErrorCode).toBe('taxes_calculation_failed'); 104 | expect(commercetoolsError.extensionExtraInfo.stripeErrorType).toBe('StripeInvalidRequestError'); 105 | expect(commercetoolsError.extensionExtraInfo.stripeErrorMessage).toBe('Stripe error message'); 106 | }); 107 | 108 | it('should handle original error without code or type', () => { 109 | const originalError = { 110 | message: 'Some error' 111 | }; 112 | 113 | const error = new MissingTaxRateForCountry('XX', null, originalError); 114 | const commercetoolsError = error.toCommerceToolsError(); 115 | 116 | expect(commercetoolsError.extensionExtraInfo.stripeErrorCode).toBeUndefined(); 117 | expect(commercetoolsError.extensionExtraInfo.stripeErrorType).toBeUndefined(); 118 | expect(commercetoolsError.extensionExtraInfo.stripeErrorMessage).toBe('Some error'); 119 | }); 120 | }); 121 | 122 | describe('fromStripeError', () => { 123 | it('should create error from Stripe error with country only', () => { 124 | const stripeError = { 125 | message: 'Stripe error', 126 | code: 'taxes_calculation_failed', 127 | type: 'StripeInvalidRequestError' 128 | }; 129 | 130 | const error = MissingTaxRateForCountry.fromStripeError(stripeError, 'XX'); 131 | 132 | expect(error).toBeInstanceOf(MissingTaxRateForCountry); 133 | expect(error.country).toBe('XX'); 134 | expect(error.state).toBeNull(); 135 | expect(error.originalError).toBe(stripeError); 136 | }); 137 | 138 | it('should create error from Stripe error with country and state', () => { 139 | const stripeError = { 140 | message: 'Stripe error', 141 | code: 'taxes_calculation_failed', 142 | type: 'StripeInvalidRequestError' 143 | }; 144 | 145 | const error = MissingTaxRateForCountry.fromStripeError(stripeError, 'US', 'NY'); 146 | 147 | expect(error).toBeInstanceOf(MissingTaxRateForCountry); 148 | expect(error.country).toBe('US'); 149 | expect(error.state).toBe('NY'); 150 | expect(error.originalError).toBe(stripeError); 151 | }); 152 | 153 | it('should handle null state parameter', () => { 154 | const stripeError = { 155 | message: 'Stripe error' 156 | }; 157 | 158 | const error = MissingTaxRateForCountry.fromStripeError(stripeError, 'XX', null); 159 | 160 | expect(error.country).toBe('XX'); 161 | expect(error.state).toBeNull(); 162 | }); 163 | }); 164 | }); 165 | 166 | -------------------------------------------------------------------------------- /tax-calculator/src/services/tax-error-handler.service.js: -------------------------------------------------------------------------------- 1 | // src/services/tax-error-handler.service.js 2 | import { logger } from '../utils/logger.utils.js'; 3 | import { HTTP_STATUS_BAD_REQUEST, HTTP_STATUS_SERVER_ERROR } from '../constants/http.status.constants.js'; 4 | import TaxCodeNotFoundError from '../errors/taxCodeNotFound.error.js'; 5 | import TaxCodeShippingNotFoundError from '../errors/taxCodeShippingNotFound.error.js'; 6 | import MissingTaxRateForCountry from '../errors/missingTaxRateForCountry.error.js'; 7 | import ShipFromNotFoundError from '../errors/shipFromNotFoundError.js'; 8 | 9 | /** 10 | * Error handler specifically for tax calculation errors 11 | * Handles tax code errors, Stripe API errors, and other errors 12 | */ 13 | class TaxErrorHandlerService { 14 | /** 15 | * Handle tax calculation errors 16 | * @param {Error} error - The error to handle 17 | * @param {Object} request - Express request object 18 | * @param {Object} response - Express response object 19 | * @param {Object} cartRequestBody - Cart request body for context 20 | * @returns {Object|null} Response object if handled, null if not handled 21 | */ 22 | static handleTaxCalculationError(error, request, response, cartRequestBody) { 23 | // Handle ship-from not found errors 24 | if (error instanceof ShipFromNotFoundError) { 25 | return this.handleShipFromNotFoundError(error, response); 26 | } 27 | 28 | // Handle tax code not found errors 29 | if (error instanceof TaxCodeNotFoundError) { 30 | return this.handleTaxCodeNotFoundError(error, response); 31 | } 32 | 33 | // Handle tax code shipping not found errors 34 | if (error instanceof TaxCodeShippingNotFoundError) { 35 | return this.handleTaxCodeShippingNotFoundError(error, response); 36 | } 37 | 38 | // Handle Stripe API errors 39 | if (this.isStripeError(error)) { 40 | return this.handleStripeError(error, request, response, cartRequestBody); 41 | } 42 | 43 | // Handle other errors 44 | return this.handleOtherErrors(error, response); 45 | } 46 | 47 | /** 48 | * Handle ShipFromNotFoundError 49 | * @param {ShipFromNotFoundError} error 50 | * @param {Object} response 51 | * @returns {Object} 52 | */ 53 | static handleShipFromNotFoundError(error, response) { 54 | logger.error('Ship-from address not found', { 55 | cartId: error.cart?.id 56 | }); 57 | return response.status(HTTP_STATUS_BAD_REQUEST).json({ 58 | errors: [error.toCommercetoolsError()] 59 | }); 60 | } 61 | 62 | /** 63 | * Handle TaxCodeNotFoundError 64 | * @param {TaxCodeNotFoundError} error 65 | * @param {Object} response 66 | * @returns {Object} 67 | */ 68 | static handleTaxCodeNotFoundError(error, response) { 69 | logger.error('Tax code not found', { 70 | productId: error.productId, 71 | categories: error.categories 72 | }); 73 | return response.status(HTTP_STATUS_BAD_REQUEST).json({ 74 | errors: [error.toCommerceToolsError()] 75 | }); 76 | } 77 | 78 | /** 79 | * Handle TaxCodeShippingNotFoundError 80 | * @param {TaxCodeShippingNotFoundError} error 81 | * @param {Object} response 82 | * @returns {Object} 83 | */ 84 | static handleTaxCodeShippingNotFoundError(error, response) { 85 | logger.error('Tax code shipping not found', { 86 | shippingArray: error.shippingArray 87 | }); 88 | return response.status(HTTP_STATUS_BAD_REQUEST).json({ 89 | errors: [error.toCommerceToolsError()] 90 | }); 91 | } 92 | 93 | /** 94 | * Check if error is a Stripe API error 95 | * @param {Error} error 96 | * @returns {boolean} 97 | */ 98 | static isStripeError(error) { 99 | return error.type === 'StripeInvalidRequestError' || error.type === 'StripeAPIError'; 100 | } 101 | 102 | /** 103 | * Handle Stripe API errors 104 | * @param {Error} error 105 | * @param {Object} request 106 | * @param {Object} response 107 | * @param {Object} cartRequestBody 108 | * @returns {Object} 109 | */ 110 | static handleStripeError(error, request, response, cartRequestBody) { 111 | const country = cartRequestBody.country; 112 | const state = cartRequestBody.shippingMode === 'Single' 113 | ? cartRequestBody.shippingAddress?.state 114 | : cartRequestBody.shipping?.[0]?.shippingAddress?.state; 115 | 116 | // Map Stripe error codes to appropriate responses 117 | const stripeErrorCode = error.code; 118 | 119 | // Tax calculation errors that indicate missing tax rate or unsupported country 120 | const taxRateErrors = [ 121 | 'taxes_calculation_failed', 122 | 'invalid_tax_location', 123 | 'customer_tax_location_invalid', 124 | 'shipping_address_invalid' 125 | ]; 126 | 127 | if (taxRateErrors.includes(stripeErrorCode)) { 128 | logger.error('Stripe tax calculation failed - missing tax rate or unsupported country', { 129 | stripeErrorCode, 130 | stripeErrorType: error.type, 131 | stripeErrorMessage: error.message, 132 | country, 133 | state, 134 | correlationId: request.headers['x-correlation-id'] 135 | }); 136 | 137 | const missingTaxRateError = MissingTaxRateForCountry.fromStripeError(error, country, state); 138 | return response.status(HTTP_STATUS_BAD_REQUEST).json({ 139 | errors: [missingTaxRateError.toCommerceToolsError()] 140 | }); 141 | } 142 | 143 | // Handle stripe_tax_inactive specifically 144 | if (stripeErrorCode === 'stripe_tax_inactive') { 145 | logger.error('Stripe Tax not activated', { 146 | stripeErrorMessage: error.message, 147 | correlationId: request.headers['x-correlation-id'] 148 | }); 149 | return response.status(HTTP_STATUS_BAD_REQUEST).json({ 150 | errors: [{ 151 | code: 'InvalidInput', 152 | message: 'Stripe Tax is not activated. Please enable Stripe Tax in your Stripe Dashboard.', 153 | extensionExtraInfo: { 154 | originalError: 'stripe_tax_inactive', 155 | action: 'Enable Stripe Tax at https://dashboard.stripe.com/settings/tax' 156 | } 157 | }] 158 | }); 159 | } 160 | 161 | // Other Stripe errors - log and return generic error 162 | logger.error('Stripe API error during tax calculation', { 163 | stripeErrorCode, 164 | stripeErrorType: error.type, 165 | stripeErrorMessage: error.message, 166 | correlationId: request.headers['x-correlation-id'] 167 | }); 168 | 169 | // Return generic error for unexpected Stripe errors 170 | return response.status(HTTP_STATUS_SERVER_ERROR).json({ 171 | errors: [{ 172 | code: 'ExternalServiceError', 173 | message: 'Stripe API error during tax calculation. Please try again later or contact support if the issue persists.', 174 | extensionExtraInfo: { 175 | originalError: error.type, 176 | stripeErrorCode: error.code, 177 | action: 'Please try again later or contact support if the issue persists' 178 | } 179 | }] 180 | }); 181 | } 182 | 183 | /** 184 | * Handle other errors 185 | * @param {Error} error 186 | * @param {Object} response 187 | * @returns {Object} 188 | */ 189 | static handleOtherErrors(error, response) { 190 | logger.error(`Unexpected error during tax calculation: ${error.message}`); 191 | if (error.statusCode) return response.status(error.statusCode).send(error); 192 | return response.status(HTTP_STATUS_SERVER_ERROR).send(error); 193 | } 194 | } 195 | 196 | export default TaxErrorHandlerService; -------------------------------------------------------------------------------- /tax-calculator/test/unit/controllers/address.validation.controller.spec.js: -------------------------------------------------------------------------------- 1 | import { expect, describe, it, jest, beforeEach } from '@jest/globals'; 2 | 3 | // Mock dependencies BEFORE importing 4 | jest.mock('../../../src/utils/logger.utils.js', () => ({ 5 | logger: { 6 | info: jest.fn(), 7 | error: jest.fn() 8 | } 9 | })); 10 | 11 | jest.mock('../../../src/services/address.service.js', () => ({ 12 | __esModule: true, 13 | default: { 14 | validateAddress: jest.fn() 15 | } 16 | })); 17 | 18 | import { logger } from '../../../src/utils/logger.utils.js'; 19 | import addressService from '../../../src/services/address.service.js'; 20 | import { validateAddressHandler } from '../../../src/controllers/address.validation.controller.js'; 21 | import { 22 | HTTP_STATUS_BAD_REQUEST, 23 | HTTP_STATUS_SUCCESS_ACCEPTED, 24 | HTTP_STATUS_SERVER_ERROR 25 | } from '../../../src/constants/http.status.constants.js'; 26 | 27 | describe('AddressValidationController', () => { 28 | let mockRequest; 29 | let mockResponse; 30 | let statusSpy; 31 | let jsonSpy; 32 | 33 | beforeEach(() => { 34 | jsonSpy = jest.fn(); 35 | statusSpy = jest.fn().mockReturnValue({ json: jsonSpy }); 36 | 37 | mockRequest = { 38 | body: {}, 39 | headers: {} 40 | }; 41 | 42 | mockResponse = { 43 | status: statusSpy, 44 | json: jsonSpy 45 | }; 46 | 47 | jest.clearAllMocks(); 48 | }); 49 | 50 | describe('validateAddressHandler', () => { 51 | it('should return 400 when address is missing in request body', async () => { 52 | mockRequest.body = {}; 53 | 54 | await validateAddressHandler(mockRequest, mockResponse); 55 | 56 | expect(statusSpy).toHaveBeenCalledWith(HTTP_STATUS_BAD_REQUEST); 57 | expect(jsonSpy).toHaveBeenCalledWith({ 58 | error: 'Address is required in request body' 59 | }); 60 | }); 61 | 62 | it('should return 400 when address is not an object', async () => { 63 | mockRequest.body = { address: 'not-an-object' }; 64 | 65 | await validateAddressHandler(mockRequest, mockResponse); 66 | 67 | expect(statusSpy).toHaveBeenCalledWith(HTTP_STATUS_BAD_REQUEST); 68 | expect(jsonSpy).toHaveBeenCalledWith({ 69 | error: 'Address must be a valid object' 70 | }); 71 | }); 72 | 73 | it('should return 400 when address is an array', async () => { 74 | mockRequest.body = { address: [] }; 75 | 76 | await validateAddressHandler(mockRequest, mockResponse); 77 | 78 | expect(statusSpy).toHaveBeenCalledWith(HTTP_STATUS_BAD_REQUEST); 79 | expect(jsonSpy).toHaveBeenCalledWith({ 80 | error: 'Address must be a valid object' 81 | }); 82 | }); 83 | 84 | it('should call addressService and return 202 with validation result on success', async () => { 85 | const address = { 86 | country: 'US', 87 | line1: '123 Main St', 88 | city: 'New York', 89 | state: 'NY', 90 | postal_code: '10001' 91 | }; 92 | 93 | const validationResult = { 94 | success: true, 95 | address: { 96 | country: 'US', 97 | line1: '123 Main St', 98 | city: 'New York', 99 | state: 'NY', 100 | postal_code: '10001' 101 | }, 102 | validation: { 103 | local: { isValid: true, errors: [] }, 104 | stripe: { isValid: true } 105 | } 106 | }; 107 | 108 | mockRequest.body = { address }; 109 | mockRequest.headers['x-request-id'] = 'test-request-id'; 110 | addressService.validateAddress.mockResolvedValue(validationResult); 111 | 112 | await validateAddressHandler(mockRequest, mockResponse); 113 | 114 | expect(logger.info).toHaveBeenCalledWith( 115 | 'Address validation request received', 116 | { 117 | requestId: 'test-request-id', 118 | country: 'US' 119 | } 120 | ); 121 | 122 | expect(addressService.validateAddress).toHaveBeenCalledWith( 123 | address, 124 | 'test-request-id' 125 | ); 126 | 127 | expect(logger.info).toHaveBeenCalledWith( 128 | 'Address validation completed', 129 | { 130 | requestId: 'test-request-id', 131 | country: 'US', 132 | success: true, 133 | hasStripeVerification: true 134 | } 135 | ); 136 | 137 | expect(statusSpy).toHaveBeenCalledWith(HTTP_STATUS_SUCCESS_ACCEPTED); 138 | expect(jsonSpy).toHaveBeenCalledWith(validationResult); 139 | }); 140 | 141 | it('should generate requestId when x-request-id header is missing', async () => { 142 | const address = { country: 'US' }; 143 | const validationResult = { 144 | success: true, 145 | address, 146 | validation: { local: { isValid: true }, stripe: null } 147 | }; 148 | 149 | mockRequest.body = { address }; 150 | mockRequest.headers = {}; 151 | addressService.validateAddress.mockResolvedValue(validationResult); 152 | 153 | await validateAddressHandler(mockRequest, mockResponse); 154 | 155 | expect(addressService.validateAddress).toHaveBeenCalledWith( 156 | address, 157 | expect.stringMatching(/^req_\d+$/) 158 | ); 159 | }); 160 | 161 | it('should handle address with unknown country', async () => { 162 | const address = {}; 163 | const validationResult = { 164 | success: false, 165 | address, 166 | validation: { local: { isValid: false, errors: [] } } 167 | }; 168 | 169 | mockRequest.body = { address }; 170 | addressService.validateAddress.mockResolvedValue(validationResult); 171 | 172 | await validateAddressHandler(mockRequest, mockResponse); 173 | 174 | expect(logger.info).toHaveBeenCalledWith( 175 | 'Address validation request received', 176 | { 177 | requestId: expect.any(String), 178 | country: 'unknown' 179 | } 180 | ); 181 | }); 182 | 183 | it('should return 500 when addressService throws an error', async () => { 184 | const address = { country: 'US' }; 185 | const error = new Error('Service error'); 186 | 187 | mockRequest.body = { address }; 188 | mockRequest.headers['x-request-id'] = 'test-request-id'; 189 | addressService.validateAddress.mockRejectedValue(error); 190 | 191 | await validateAddressHandler(mockRequest, mockResponse); 192 | 193 | expect(logger.error).toHaveBeenCalledWith( 194 | 'Unexpected address validation error', 195 | { 196 | requestId: 'test-request-id', 197 | error: 'Service error' 198 | } 199 | ); 200 | 201 | expect(statusSpy).toHaveBeenCalledWith(HTTP_STATUS_SERVER_ERROR); 202 | expect(jsonSpy).toHaveBeenCalledWith({ 203 | error: 'An unexpected error occurred during address validation', 204 | message: 'Service error' 205 | }); 206 | }); 207 | 208 | it('should handle validation result without stripe verification', async () => { 209 | const address = { country: 'US' }; 210 | const validationResult = { 211 | success: true, 212 | address, 213 | validation: { local: { isValid: true }, stripe: null } 214 | }; 215 | 216 | mockRequest.body = { address }; 217 | addressService.validateAddress.mockResolvedValue(validationResult); 218 | 219 | await validateAddressHandler(mockRequest, mockResponse); 220 | 221 | expect(logger.info).toHaveBeenCalledWith( 222 | 'Address validation completed', 223 | { 224 | requestId: expect.any(String), 225 | country: 'US', 226 | success: true, 227 | hasStripeVerification: false 228 | } 229 | ); 230 | }); 231 | }); 232 | }); 233 | 234 | --------------------------------------------------------------------------------