diff --git a/package-lock.json b/package-lock.json index 0bc846c..c2309e9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,13 +1,16 @@ { - "name": "chargily-pay-javascript", - "version": "0.0.1-alpha", + "name": "@chargily/chargily-pay", + "version": "2.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "chargily-pay-javascript", - "version": "0.0.1-alpha", + "name": "@chargily/chargily-pay", + "version": "2.1.0", "license": "MIT", + "dependencies": { + "joi": "^17.11.0" + }, "devDependencies": { "@types/node": "^14.0.0", "nodemon": "^3.1.0", @@ -27,6 +30,21 @@ "node": ">=12" } }, + "node_modules/@hapi/hoek": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-9.3.0.tgz", + "integrity": "sha512-/c6rf4UJlmHlC9b5BaNvzAcFv7HZ2QHaV0D4/HNlBdvFnvQq8RI4kYdhyPCl7Xj+oWvTWQ8ujhqS53LIgAe6KQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@hapi/topo": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@hapi/topo/-/topo-5.1.0.tgz", + "integrity": "sha512-foQZKJig7Ob0BMAYBfcJk8d77QtOe7Wo4ox7ff1lQYoNNAb6jwcY1ncdoy2e9wQZzvNy7ODZCYJkK8kzmcAnAg==", + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/hoek": "^9.0.0" + } + }, "node_modules/@jridgewell/resolve-uri": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", @@ -52,6 +70,27 @@ "@jridgewell/sourcemap-codec": "^1.4.10" } }, + "node_modules/@sideway/address": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@sideway/address/-/address-4.1.5.tgz", + "integrity": "sha512-IqO/DUQHUkPeixNQ8n0JA6102hT9CmaljNTPmQ1u8MEhBo/R4Q8eKLN/vGZxuebwOroDB4cbpjheD4+/sKFK4Q==", + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/hoek": "^9.0.0" + } + }, + "node_modules/@sideway/formula": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@sideway/formula/-/formula-3.0.1.tgz", + "integrity": "sha512-/poHZJJVjx3L+zVD6g9KgHfYnb443oi7wLu/XKojDviHy6HOEOA6z1Trk5aR1dGcmPenJEgb2sK2I80LeS3MIg==", + "license": "BSD-3-Clause" + }, + "node_modules/@sideway/pinpoint": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@sideway/pinpoint/-/pinpoint-2.0.0.tgz", + "integrity": "sha512-RNiOoTPkptFtSVzQevY/yWtZwf/RxyVnPy/OcA9HBM3MlGDnBEYL5B41H0MTn0Uec8Hi+2qUtTfG2WWZBmMejQ==", + "license": "BSD-3-Clause" + }, "node_modules/@tsconfig/node10": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.9.tgz", @@ -322,6 +361,19 @@ "node": ">=0.12.0" } }, + "node_modules/joi": { + "version": "17.13.3", + "resolved": "https://registry.npmjs.org/joi/-/joi-17.13.3.tgz", + "integrity": "sha512-otDA4ldcIx+ZXsKHWmp0YizCweVRZG96J10b0FevjfuncLO1oX59THoAmHkNubYJ+9gWsYsp5k8v4ib6oDv1fA==", + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/hoek": "^9.3.0", + "@hapi/topo": "^5.1.0", + "@sideway/address": "^4.1.5", + "@sideway/formula": "^3.0.1", + "@sideway/pinpoint": "^2.0.0" + } + }, "node_modules/lru-cache": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", diff --git a/package.json b/package.json index 5f48dd6..c4f6a5c 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,9 @@ "url": "https://github.com/chargily/chargily-pay-javascript/issues" }, "homepage": "https://github.com/chargily/chargily-pay-javascript#readme", + "dependencies": { + "joi": "^17.11.0" + }, "devDependencies": { "@types/node": "^14.0.0", "nodemon": "^3.1.0", diff --git a/src/classes/client.ts b/src/classes/client.ts index 8bab95b..93dc81c 100644 --- a/src/classes/client.ts +++ b/src/classes/client.ts @@ -22,6 +22,18 @@ import { UpdateProductParams, } from '../types/param'; import { DeleteItemResponse, ListResponse } from '../types/response'; +import { validatePayload } from '../validation/validator'; +import { + createCustomerSchema, + updateCustomerSchema, + createProductSchema, + updateProductSchema, + createPriceSchema, + updatePriceSchema, + createCheckoutSchema, + createPaymentLinkSchema, + updatePaymentLinkSchema, +} from '../validation/schemas'; /** * Configuration options for ChargilyClient. @@ -116,6 +128,7 @@ export class ChargilyClient { public async createCustomer( customer_data: CreateCustomerParams ): Promise { + await validatePayload(createCustomerSchema, customer_data, 'Create Customer'); return this.request('customers', 'POST', customer_data); } @@ -138,6 +151,7 @@ export class ChargilyClient { customer_id: string, update_data: UpdateCustomerParams ): Promise { + await validatePayload(updateCustomerSchema, update_data, 'Update Customer'); return this.request(`customers/${customer_id}`, 'PATCH', update_data); } @@ -176,6 +190,7 @@ export class ChargilyClient { public async createProduct( product_data: CreateProductParams ): Promise { + await validatePayload(createProductSchema, product_data, 'Create Product'); return this.request('products', 'POST', product_data); } @@ -189,6 +204,7 @@ export class ChargilyClient { product_id: string, update_data: UpdateProductParams ): Promise { + await validatePayload(updateProductSchema, update_data, 'Update Product'); return this.request(`products/${product_id}`, 'POST', update_data); } @@ -247,6 +263,7 @@ export class ChargilyClient { * @returns {Promise} The created price object. */ public async createPrice(price_data: CreatePriceParams): Promise { + await validatePayload(createPriceSchema, price_data, 'Create Price'); return this.request('prices', 'POST', price_data); } @@ -260,6 +277,7 @@ export class ChargilyClient { price_id: string, update_data: UpdatePriceParams ): Promise { + await validatePayload(updatePriceSchema, update_data, 'Update Price'); return this.request(`prices/${price_id}`, 'POST', update_data); } @@ -291,22 +309,7 @@ export class ChargilyClient { public async createCheckout( checkout_data: CreateCheckoutParams ): Promise { - if ( - !checkout_data.success_url.startsWith('http') && - !checkout_data.success_url.startsWith('https') - ) { - throw new Error('Invalid success_url, it must begin with http or https.'); - } - - if ( - !checkout_data.items && - (!checkout_data.amount || !checkout_data.currency) - ) { - throw new Error( - 'The items field is required when amount and currency are not present.' - ); - } - + await validatePayload(createCheckoutSchema, checkout_data, 'Create Checkout'); return this.request('checkouts', 'POST', checkout_data); } @@ -370,6 +373,7 @@ export class ChargilyClient { public async createPaymentLink( payment_link_data: CreatePaymentLinkParams ): Promise { + await validatePayload(createPaymentLinkSchema, payment_link_data, 'Create Payment Link'); return this.request('payment-links', 'POST', payment_link_data); } @@ -383,6 +387,7 @@ export class ChargilyClient { payment_link_id: string, update_data: UpdatePaymentLinkParams ): Promise { + await validatePayload(updatePaymentLinkSchema, update_data, 'Update Payment Link'); return this.request( `payment-links/${payment_link_id}`, 'POST', diff --git a/src/index.ts b/src/index.ts index 68a46e4..581f1cf 100644 --- a/src/index.ts +++ b/src/index.ts @@ -15,3 +15,6 @@ export * from './classes/client'; // Exporting all the utility functions from utils folder export * from './utils'; + +// Exporting validation utilities and schemas from validation folder +export * from './validation'; diff --git a/src/validation/index.ts b/src/validation/index.ts new file mode 100644 index 0000000..4f1c506 --- /dev/null +++ b/src/validation/index.ts @@ -0,0 +1,12 @@ +export { validatePayload } from './validator'; +export { + createCustomerSchema, + updateCustomerSchema, + createProductSchema, + updateProductSchema, + createPriceSchema, + updatePriceSchema, + createCheckoutSchema, + createPaymentLinkSchema, + updatePaymentLinkSchema, +} from './schemas'; diff --git a/src/validation/schemas.ts b/src/validation/schemas.ts new file mode 100644 index 0000000..bb997c6 --- /dev/null +++ b/src/validation/schemas.ts @@ -0,0 +1,264 @@ +import Joi from 'joi'; + +/** + * Validation schemas for all Chargily API payloads. + * Each schema corresponds to a parameter interface in src/types/param.ts + */ + +/** + * Shared schema describing a fully populated customer or shipping address. + * @type {Joi.ObjectSchema} + */ +const addressSchema = Joi.object({ + country: Joi.string().required().messages({ + 'string.empty': 'Country code is required', + 'any.required': 'Country code is required', + }), + state: Joi.string().required().messages({ + 'string.empty': 'State is required', + 'any.required': 'State is required', + }), + address: Joi.string().required().messages({ + 'string.empty': 'Address is required', + 'any.required': 'Address is required', + }), +}); + +/** + * Generic metadata container, allowing arbitrary key/value pairs. + * @type {Joi.ObjectSchema} + */ +const metadataSchema = Joi.object().unknown(true).optional(); + +/** + * Runtime validation for CreateCustomerParams payloads. + * Ensures optional fields such as email or address respect formatting rules. + * @type {Joi.ObjectSchema} + */ +export const createCustomerSchema = Joi.object({ + name: Joi.string().optional(), + email: Joi.string().email().optional().messages({ + 'string.email': 'Email must be a valid email address', + }), + phone: Joi.string().optional(), + address: addressSchema.optional(), + metadata: metadataSchema, +}).unknown(false); + +/** + * Runtime validation for UpdateCustomerParams payloads. + * Mirrors the create schema while making every field optional, including nested address keys. + * @type {Joi.ObjectSchema} + */ +export const updateCustomerSchema = Joi.object({ + name: Joi.string().optional(), + email: Joi.string().email().optional().messages({ + 'string.email': 'Email must be a valid email address', + }), + phone: Joi.string().optional(), + address: Joi.object({ + country: Joi.string().optional(), + state: Joi.string().optional(), + address: Joi.string().optional(), + }).unknown(false).optional(), + metadata: metadataSchema, +}).unknown(false); + +/** + * Runtime validation for CreateProductParams payloads. + * Requires the product name and validates media and metadata fields. + * @type {Joi.ObjectSchema} + */ +export const createProductSchema = Joi.object({ + name: Joi.string().required().messages({ + 'string.empty': 'Product name is required', + 'any.required': 'Product name is required', + }), + description: Joi.string().optional(), + images: Joi.array().items(Joi.string().uri()).optional().messages({ + 'string.uri': 'Each image must be a valid URL', + }), + metadata: metadataSchema, +}).unknown(false); + +/** + * Runtime validation for UpdateProductParams payloads. + * Keeps every field optional to support partial product updates. + * @type {Joi.ObjectSchema} + */ +export const updateProductSchema = Joi.object({ + name: Joi.string().optional(), + description: Joi.string().optional(), + images: Joi.array().items(Joi.string().uri()).optional().messages({ + 'string.uri': 'Each image must be a valid URL', + }), + metadata: metadataSchema, +}).unknown(false); + +/** + * Runtime validation for CreatePriceParams payloads. + * Guards against missing or malformed monetary information. + * @type {Joi.ObjectSchema} + */ +export const createPriceSchema = Joi.object({ + amount: Joi.number().positive().required().messages({ + 'number.positive': 'Amount must be a positive number', + 'any.required': 'Amount is required', + }), + currency: Joi.string().lowercase().required().messages({ + 'string.empty': 'Currency code is required', + 'any.required': 'Currency code is required', + }), + product_id: Joi.string().required().messages({ + 'string.empty': 'Product ID is required', + 'any.required': 'Product ID is required', + }), + metadata: metadataSchema, +}).unknown(false); + +/** + * Runtime validation for UpdatePriceParams payloads. + * Ensures metadata is always provided when updating a price object. + * @type {Joi.ObjectSchema} + */ +export const updatePriceSchema = Joi.object({ + metadata: Joi.object().unknown(true).required().messages({ + 'any.required': 'Metadata is required for price update', + }), +}).unknown(false); + +/** + * Internal schema describing the structure of each checkout line item. + * @type {Joi.ObjectSchema} + */ +const checkoutItemSchema = Joi.object({ + price: Joi.string().required().messages({ + 'string.empty': 'Price ID is required', + 'any.required': 'Price ID is required', + }), + quantity: Joi.number().integer().positive().required().messages({ + 'number.positive': 'Quantity must be a positive number', + 'number.integer': 'Quantity must be an integer', + 'any.required': 'Quantity is required', + }), +}).unknown(false); + +/** + * Runtime validation for CreateCheckoutParams payloads. + * Validates URLs and enforces the mutual exclusivity rule between items and amount/currency. + * @type {Joi.ObjectSchema} + */ +export const createCheckoutSchema = Joi.object({ + items: Joi.array().items(checkoutItemSchema).optional(), + amount: Joi.number().positive().optional().messages({ + 'number.positive': 'Amount must be a positive number', + }), + currency: Joi.string().lowercase().optional(), + payment_method: Joi.string().optional(), + success_url: Joi.string().uri().required().messages({ + 'string.uri': 'Success URL must be a valid URL (http or https)', + 'any.required': 'Success URL is required', + }), + failure_url: Joi.string().uri().optional().messages({ + 'string.uri': 'Failure URL must be a valid URL', + }), + webhook_endpoint: Joi.string().uri().optional().messages({ + 'string.uri': 'Webhook endpoint must be a valid URL', + }), + description: Joi.string().optional(), + locale: Joi.string().valid('ar', 'en', 'fr').optional(), + pass_fees_to_customer: Joi.boolean().optional(), + customer_id: Joi.string().optional(), + shipping_address: Joi.string().optional(), + collect_shipping_address: Joi.boolean().optional(), + metadata: metadataSchema, +}) + .unknown(false) + .external(async (value) => { + // Validate mutual exclusivity: either items OR (amount + currency) + const hasItems = value.items && value.items.length > 0; + const hasAmount = value.amount !== undefined; + const hasCurrency = value.currency !== undefined; + + if (!hasItems && (!hasAmount || !hasCurrency)) { + throw new Error( + 'Either items array or both amount and currency must be provided' + ); + } + + if (hasItems && (hasAmount || hasCurrency)) { + throw new Error( + 'Cannot provide both items and amount/currency together' + ); + } + }); + +/** + * Internal schema describing each item attached to a payment link. + * @type {Joi.ObjectSchema} + */ +const paymentLinkItemSchema = Joi.object({ + price: Joi.string().required().messages({ + 'string.empty': 'Price ID is required', + 'any.required': 'Price ID is required', + }), + quantity: Joi.number().integer().positive().required().messages({ + 'number.positive': 'Quantity must be a positive number', + 'number.integer': 'Quantity must be an integer', + 'any.required': 'Quantity is required', + }), + adjustable_quantity: Joi.boolean().optional(), +}).unknown(false); + +/** + * Runtime validation for CreatePaymentLinkParams payloads. + * Requires a name and at least one well-formed item. + * @type {Joi.ObjectSchema} + */ +export const createPaymentLinkSchema = Joi.object({ + name: Joi.string().required().messages({ + 'string.empty': 'Payment link name is required', + 'any.required': 'Payment link name is required', + }), + items: Joi.array().items(paymentLinkItemSchema).required().messages({ + 'array.base': 'Items must be an array', + 'any.required': 'Items array is required', + }), + after_completion_message: Joi.string().optional(), + locale: Joi.string().valid('ar', 'en', 'fr').optional(), + pass_fees_to_customer: Joi.boolean().optional(), + collect_shipping_address: Joi.boolean().optional(), + metadata: metadataSchema, +}).unknown(false); + +/** + * Internal schema representing the mutable fields of each payment link item during updates. + * @type {Joi.ObjectSchema} + */ +const updatePaymentLinkItemSchema = Joi.object({ + price: Joi.string().required().messages({ + 'string.empty': 'Price ID is required', + 'any.required': 'Price ID is required', + }), + quantity: Joi.number().integer().positive().required().messages({ + 'number.positive': 'Quantity must be a positive number', + 'number.integer': 'Quantity must be an integer', + 'any.required': 'Quantity is required', + }), + adjustable_quantity: Joi.boolean().optional(), +}).unknown(false); + +/** + * Runtime validation for UpdatePaymentLinkParams payloads. + * Supports partial updates while keeping enum and type checks aligned with API expectations. + * @type {Joi.ObjectSchema} + */ +export const updatePaymentLinkSchema = Joi.object({ + name: Joi.string().optional(), + items: Joi.array().items(updatePaymentLinkItemSchema).optional(), + after_completion_message: Joi.string().optional(), + locale: Joi.string().valid('ar', 'en', 'fr').optional(), + pass_fees_to_customer: Joi.boolean().optional(), + collect_shipping_address: Joi.boolean().optional(), + metadata: metadataSchema, +}).unknown(false); diff --git a/src/validation/validator.ts b/src/validation/validator.ts new file mode 100644 index 0000000..3c621c4 --- /dev/null +++ b/src/validation/validator.ts @@ -0,0 +1,35 @@ +import Joi from 'joi'; + +/** + * Validates a payload against a Joi schema and throws a descriptive error if validation fails. + * @param schema - The Joi schema to validate against + * @param data - The data to validate + * @param context - A descriptive name for the operation (e.g., "Create Customer") + * @throws {Error} Throws a validation error with a clear message if validation fails + */ +export async function validatePayload( + schema: Joi.Schema, + data: T, + context: string +): Promise { + try { + await schema.validateAsync(data, { + abortEarly: false, + stripUnknown: false, + }); + } catch (error) { + if (error instanceof Joi.ValidationError) { + const messages = error.details + .map((detail) => { + const path = detail.path.join('.'); + return `${path}: ${detail.message}`; + }) + .join('; '); + + throw new Error( + `[${context}] Validation failed: ${messages}` + ); + } + throw error; + } +}