From 405a6bc6eac8addcb886c1647dfe387f364931fe Mon Sep 17 00:00:00 2001 From: donjerick Date: Fri, 5 Sep 2025 14:51:00 +0800 Subject: [PATCH 1/9] feat: add Organizations resource - Add Organization type definitions with comprehensive property types - Implement OrganizationsResource with me() method for retrieving organization data - Add comprehensive test suite covering success scenarios, error handling, and request options - Support for organization branding, payment method settings, and payout configurations --- src/resources/organizations.test.ts | 283 ++++++++++++++++++++++++++++ src/resources/organizations.ts | 71 +++++++ src/types/organization.ts | 147 +++++++++++++++ 3 files changed, 501 insertions(+) create mode 100644 src/resources/organizations.test.ts create mode 100644 src/resources/organizations.ts create mode 100644 src/types/organization.ts diff --git a/src/resources/organizations.test.ts b/src/resources/organizations.test.ts new file mode 100644 index 0000000..b2d7686 --- /dev/null +++ b/src/resources/organizations.test.ts @@ -0,0 +1,283 @@ +/* eslint-disable @typescript-eslint/no-unsafe-assignment */ +import { createTestOrganization, getSpyableMagpie } from '../__tests__/testUtils'; +import type { Organization } from '../types/organization'; + +describe('OrganizationsResource', () => { + let magpie: ReturnType; + + beforeEach(() => { + magpie = getSpyableMagpie('sk_test_123'); + }); + + describe('me', () => { + it('should send GET request to /me', async () => { + const mockOrganization = createTestOrganization() as Organization; + magpie.mockRequest('GET', '/me', mockOrganization); + + const result = await magpie.organizations.me(); + + expect(magpie.LAST_REQUEST).toMatchObject({ + method: 'GET', + url: expect.stringContaining('/me') + }); + + expect(result).toMatchObject(mockOrganization); + }); + + it('should return organization with correct structure', async () => { + const customOrg = createTestOrganization({ + title: 'Custom Test Org', + status: 'pending', + branding: { + icon: null, + logo: 'https://example.com/logo.png', + use_logo: true, + brand_color: '#123456', + accent_color: '#789abc' + } + }) as Organization; + + magpie.mockRequest('GET', '/me', customOrg); + + const result = await magpie.organizations.me(); + + expect(result.object).toBe('organization'); + expect(result.title).toBe('Custom Test Org'); + expect(result.status).toBe('pending'); + expect(result.branding.use_logo).toBe(true); + expect(result.branding.brand_color).toBe('#123456'); + }); + + it('should handle minimal organization data', async () => { + const minimalOrg = createTestOrganization({ + business_address: '123 Test Street, Test City', + payment_method_settings: {}, + rates: {}, + metadata: {} + }) as Organization; + + magpie.mockRequest('GET', '/me', minimalOrg); + + const result = await magpie.organizations.me(); + + expect(result.object).toBe('organization'); + expect(result.business_address).toBe('123 Test Street, Test City'); + expect(result.payment_method_settings).toEqual({}); + expect(result.rates).toEqual({}); + expect(result.metadata).toEqual({}); + }); + + it('should handle different payment method settings', async () => { + const orgWithPaymentMethods = createTestOrganization({ + payment_method_settings: { + card: { + mid: 'card_mid_123', + gateway: { id: 'gateway_1', name: 'Custom Gateway' }, + rate: { mdr: 0.035, fixed_fee: 1500, formula: 'mdr_plus_fixed' }, + status: 'approved' + }, + gcash: { + mid: null, + gateway: null, + rate: { mdr: 0.025, fixed_fee: 0, formula: 'mdr_plus_fixed' }, + status: 'pending' + }, + paymaya: { + mid: 'paymaya_mid_456', + gateway: null, + rate: { mdr: 0.018, fixed_fee: 0, formula: 'mdr_plus_fixed' }, + status: 'rejected' + } + }, + rates: { + card: { mdr: 0.035, fixed_fee: 1500 }, + gcash: { mdr: 0.025, fixed_fee: 0 }, + paymaya: { mdr: 0.018, fixed_fee: 0 } + } + }) as Organization; + + magpie.mockRequest('GET', '/me', orgWithPaymentMethods); + + const result = await magpie.organizations.me(); + + expect(result.payment_method_settings.card?.mid).toBe('card_mid_123'); + expect(result.payment_method_settings.card?.status).toBe('approved'); + expect(result.payment_method_settings.gcash?.status).toBe('pending'); + expect(result.payment_method_settings.paymaya?.status).toBe('rejected'); + expect(result.rates.card?.mdr).toBe(0.035); + expect(result.rates.gcash?.fixed_fee).toBe(0); + }); + + it('should handle different payout settings', async () => { + const orgWithPayoutSettings = createTestOrganization({ + payout_settings: { + schedule: 'manual', + delivery_type: 'express', + bank_code: 'CUSTOM_BANK', + account_number: '9876543210', + account_name: 'Custom Payout Account' + } + }) as Organization; + + magpie.mockRequest('GET', '/me', orgWithPayoutSettings); + + const result = await magpie.organizations.me(); + + expect(result.payout_settings.schedule).toBe('manual'); + expect(result.payout_settings.delivery_type).toBe('express'); + expect(result.payout_settings.bank_code).toBe('CUSTOM_BANK'); + expect(result.payout_settings.account_number).toBe('9876543210'); + }); + }); + + describe('request options', () => { + it('should handle idempotency key', async () => { + const mockOrganization = createTestOrganization() as Organization; + magpie.mockRequest('GET', '/me', mockOrganization); + + const idempotencyKey = 'test_idempotency_key'; + await magpie.organizations.me({ idempotencyKey }); + + expect(magpie.LAST_REQUEST?.headers).toMatchObject({ + 'X-Idempotency-Key': idempotencyKey + }); + }); + + it('should handle expand options', async () => { + const mockOrganization = createTestOrganization() as Organization; + magpie.mockRequest('GET', '/me', mockOrganization); + + await magpie.organizations.me({ expand: ['payment_method_settings', 'payout_settings'] }); + + expect(magpie.LAST_REQUEST?.params).toMatchObject({ + expand: ['payment_method_settings', 'payout_settings'] + }); + }); + + it('should handle multiple request options combined', async () => { + const mockOrganization = createTestOrganization() as Organization; + magpie.mockRequest('GET', '/me', mockOrganization); + + const options = { + idempotencyKey: 'combined_test_key', + expand: ['branding'], + retryable: false + }; + + await magpie.organizations.me(options); + + expect(magpie.LAST_REQUEST?.headers).toMatchObject({ + 'X-Idempotency-Key': 'combined_test_key' + }); + expect(magpie.LAST_REQUEST?.params).toMatchObject({ + expand: ['branding'] + }); + }); + }); + + describe('error handling', () => { + it('should handle API errors gracefully', async () => { + const errorResponse = { + error: { + type: 'api_error', + code: 'organization_not_found', + message: 'Organization not found' + } + }; + + magpie.mockRequest('GET', '/me', errorResponse, 404); + + await expect(magpie.organizations.me()).rejects.toThrow(); + }); + + it('should handle authentication errors', async () => { + const authError = { + error: { + type: 'authentication_error', + code: 'invalid_api_key', + message: 'Invalid API key provided' + } + }; + + magpie.mockRequest('GET', '/me', authError, 401); + + await expect(magpie.organizations.me()).rejects.toThrow(); + }); + + it('should handle network errors', async () => { + // Mock a network error + magpie.mockNetworkError('GET', '/me'); + + await expect(magpie.organizations.me()).rejects.toThrow(); + }); + }); + + describe('response metadata', () => { + it('should include lastResponse metadata', async () => { + const mockOrganization = createTestOrganization() as Organization; + magpie.mockRequest('GET', '/me', mockOrganization, 200, { + 'request-id': 'req_test_12345' + }); + + const result = await magpie.organizations.me(); + + expect(result.lastResponse).toBeDefined(); + expect(result.lastResponse.statusCode).toBe(200); + expect(result.lastResponse.requestId).toBe('req_test_12345'); + }); + + it('should handle different status codes', async () => { + const mockOrganization = createTestOrganization() as Organization; + magpie.mockRequest('GET', '/me', mockOrganization, 202, { + 'request-id': 'req_accepted_789' + }); + + const result = await magpie.organizations.me(); + + expect(result.lastResponse.statusCode).toBe(202); + expect(result.lastResponse.requestId).toBe('req_accepted_789'); + }); + }); + + describe('key extraction for sources authentication', () => { + it('should provide test keys for test secret key', async () => { + const testSecretKey = 'sk_test_12345'; + const testOrgClient = getSpyableMagpie(testSecretKey); + + const orgWithKeys = createTestOrganization({ + pk_test_key: 'pk_test_abcdef', + sk_test_key: testSecretKey, + pk_live_key: 'pk_live_xyz789', + sk_live_key: 'sk_live_uvw456' + }) as Organization; + + testOrgClient.mockRequest('GET', '/me', orgWithKeys); + + const result = await testOrgClient.organizations.me(); + + expect(result.pk_test_key).toBe('pk_test_abcdef'); + expect(result.sk_test_key).toBe(testSecretKey); + expect(result.pk_live_key).toBe('pk_live_xyz789'); + expect(result.sk_live_key).toBe('sk_live_uvw456'); + }); + + it('should provide live keys for live secret key', async () => { + const liveSecretKey = 'sk_live_98765'; + const liveOrgClient = getSpyableMagpie(liveSecretKey); + + const orgWithKeys = createTestOrganization({ + pk_test_key: 'pk_test_test123', + sk_test_key: 'sk_test_test123', + pk_live_key: 'pk_live_live456', + sk_live_key: liveSecretKey + }) as Organization; + + liveOrgClient.mockRequest('GET', '/me', orgWithKeys); + + const result = await liveOrgClient.organizations.me(); + + expect(result.pk_live_key).toBe('pk_live_live456'); + expect(result.sk_live_key).toBe(liveSecretKey); + }); + }); +}); diff --git a/src/resources/organizations.ts b/src/resources/organizations.ts new file mode 100644 index 0000000..d6d2aeb --- /dev/null +++ b/src/resources/organizations.ts @@ -0,0 +1,71 @@ +import { BaseClient } from "../base-client"; +import { LastResponse, Organization, RequestOptions } from "../types"; +import { BaseResource } from "./base"; + +/** + * Resource class for managing organization information. + * + * The OrganizationsResource provides methods to retrieve organization + * information including branding, payment method settings, and payout configurations. + * This resource is read-only as organization modifications are typically handled + * through the Magpie dashboard. + * + * @example + * ```typescript + * const magpie = new Magpie('sk_test_123'); + * + * // Retrieve organization info + * const org = await magpie.organizations.me(); + * console.log(org.title); // "My Business" + * console.log(org.branding.brand_color); // "#fffefd" + * ``` + */ +export class OrganizationsResource extends BaseResource { + /** + * Creates a new OrganizationsResource instance. + * + * @param client - The BaseClient instance for API communication + */ + constructor(client: BaseClient) { + super(client, '/me'); + } + + /** + * Retrieves the current organization information. + * + * This method returns comprehensive organization details including branding settings, + * payment method configurations, payout settings, and API keys. The organization + * is determined by the secret key used to authenticate the request. + * + * @param options - Additional request options + * + * @returns Promise that resolves to the organization with response metadata + * + * @example + * ```typescript + * const organization = await magpie.organizations.me(); + * + * console.log(organization.title); // "Magpie Test" + * console.log(organization.status); // "approved" + * console.log(organization.branding.brand_color); // "#fffefd" + * + * // Access payment method settings + * const cardSettings = organization.payment_method_settings.card; + * console.log(cardSettings.rate.mdr); // 0.029 + * console.log(cardSettings.status); // "approved" + * + * // Access payout settings + * console.log(organization.payout_settings.schedule); // "automatic" + * console.log(organization.payout_settings.bank_code); // "BPI/BPI Family Savings Bank" + * + * // Access metadata + * console.log(organization.metadata.business_website); // "https://example.com" + * ``` + */ + async me( + options: RequestOptions = {}, + ): Promise { + const response = await this.client.get(this.basePath, undefined, options); + return this.attachLastResponse(response.data, response); + } +} diff --git a/src/types/organization.ts b/src/types/organization.ts new file mode 100644 index 0000000..16edb0d --- /dev/null +++ b/src/types/organization.ts @@ -0,0 +1,147 @@ +/** + * Organization-related types for the Magpie API. + * + * These types define the structure of organization objects used when + * interacting with organization-related endpoints. + */ + +/** + * Payment method rate configuration. + */ +export interface PaymentMethodRate { + /** Merchant Discount Rate (percentage) */ + mdr: number; + + /** Fixed fee in centavos */ + fixed_fee: number; + + /** Rate formula type */ + formula?: 'mdr_plus_fixed' | 'mdr_or_fixed'; +} + +/** + * Gateway information for payment methods. + */ +export interface PaymentGateway { + /** Gateway ID */ + id: string; + + /** Gateway display name */ + name: string; +} + +/** + * Payment method configuration. + */ +export interface PaymentMethodSettings { + /** Merchant ID for this payment method */ + mid: string | null; + + /** Gateway configuration */ + gateway: PaymentGateway | null; + + /** Rate configuration */ + rate: PaymentMethodRate; + + /** Approval status */ + status: 'approved' | 'pending' | 'rejected'; +} + +/** + * Organization branding configuration. + */ +export interface OrganizationBranding { + /** Icon URL */ + icon: string | null; + + /** Logo URL */ + logo: string | null; + + /** Whether to use logo in branding */ + use_logo: boolean; + + /** Primary brand color in hex format */ + brand_color: string; + + /** Accent color in hex format */ + accent_color: string; +} + +/** + * Payout settings configuration. + */ +export interface PayoutSettings { + /** Payout schedule type */ + schedule: 'automatic' | 'manual'; + + /** Delivery type */ + delivery_type: 'standard' | 'express'; + + /** Bank code */ + bank_code: string; + + /** Account number */ + account_number: string; + + /** Account holder name */ + account_name: string; +} + +/** + * Represents an organization in the Magpie system. + */ +export interface Organization { + /** Type identifier, always 'organization' */ + object: 'organization'; + + /** Unique identifier for the organization */ + id: string; + + /** Organization title/display name */ + title: string; + + /** Account name */ + account_name: string; + + /** Statement descriptor shown on customer statements */ + statement_descriptor: string; + + /** Test public key */ + pk_test_key: string; + + /** Test secret key */ + sk_test_key: string; + + /** Live public key */ + pk_live_key: string; + + /** Live secret key */ + sk_live_key: string; + + /** Organization branding settings */ + branding: OrganizationBranding; + + /** Organization status */ + status: 'approved' | 'pending' | 'rejected'; + + /** Timestamp when the organization was created */ + created_at: string; + + /** Timestamp when the organization was last updated */ + updated_at: string; + + /** Business address */ + business_address: string | null; + + /** Payment method settings for various payment types */ + payment_method_settings: Record; + + /** Simplified rates configuration */ + rates: Record>; + + /** Payout settings */ + payout_settings: PayoutSettings; + + /** Additional metadata */ + metadata: Record; +} From 4f27612658b5db87a707d6a26e2a9f3dee16464f Mon Sep 17 00:00:00 2001 From: donjerick Date: Fri, 5 Sep 2025 14:51:46 +0800 Subject: [PATCH 2/9] feat: add dynamic API key management to BaseClient - Add setApiKey() method for runtime API key switching - Add getApiKey() method for retrieving current API key - Support switching between secret key (SK) and public key (PK) authentication - Maintains existing authentication validation and error handling --- src/base-client.ts | 54 +++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 53 insertions(+), 1 deletion(-) diff --git a/src/base-client.ts b/src/base-client.ts index 086e819..cf8b5be 100644 --- a/src/base-client.ts +++ b/src/base-client.ts @@ -510,7 +510,59 @@ export class BaseClient { this.config.debug = debug; } - // Test the connection + /** + * Updates the API key used for authentication. + * + * This method allows switching between different API keys (e.g., from secret key + * to public key) for specific requests. The key validation ensures only valid + * Magpie API keys are accepted. + * + * @param apiKey - New API key (must start with 'sk_' or 'pk_') + * + * @throws {Error} When apiKey is missing, invalid, or malformed + * + * @example + * ```typescript + * // Switch to public key for sources operations + * client.setApiKey('pk_test_123'); + * + * // Switch back to secret key + * client.setApiKey('sk_test_456'); + * ``` + */ + public setApiKey(apiKey: string): void { + if (!apiKey || typeof apiKey !== 'string') { + throw new Error('Missing or invalid API key'); + } + + if (!apiKey.startsWith('sk_') && !apiKey.startsWith('pk_')) { + throw new Error('Invalid API key - must start with sk_ or pk_'); + } + + this.secretKey = apiKey; + + // Update the Axios instance with the new authentication + this.http.defaults.auth = { + username: apiKey, + password: '', + }; + } + + /** + * Gets the currently configured API key. + * + * @returns The current API key (secret or public) + * + * @example + * ```typescript + * const currentKey = client.getApiKey(); + * console.log(currentKey.startsWith('sk_') ? 'Secret Key' : 'Public Key'); + * ``` + */ + public getApiKey(): string { + return this.secretKey; + } + /** * Tests connectivity to the Magpie API. * From b85a701d07ea2768bb3f51c9e65b0190a02d69ab Mon Sep 17 00:00:00 2001 From: donjerick Date: Fri, 5 Sep 2025 14:53:22 +0800 Subject: [PATCH 3/9] feat: enhance Sources resource with PK authentication - Remove create() method (BREAKING CHANGE) - Add lazy PK authentication switching for retrieve() - Auto-fetch and cache public keys from organization endpoint - Add tests for PK authentication flow --- src/resources/sources.test.ts | 185 ++++++---------------------------- src/resources/sources.ts | 96 ++++++++++-------- 2 files changed, 84 insertions(+), 197 deletions(-) diff --git a/src/resources/sources.test.ts b/src/resources/sources.test.ts index 65c73f7..0e72e96 100644 --- a/src/resources/sources.test.ts +++ b/src/resources/sources.test.ts @@ -1,111 +1,17 @@ /* eslint-disable @typescript-eslint/no-unsafe-assignment */ -/* eslint-disable @typescript-eslint/no-unsafe-argument */ -import { createTestSource, getSpyableMagpie } from '../__tests__/testUtils'; +import { getSpyableMagpie } from '../__tests__/testUtils'; describe('SourcesResource', () => { let magpie: ReturnType; beforeEach(() => { magpie = getSpyableMagpie('sk_test_123'); - }); - - describe('create', () => { - it('should send POST request to /sources', async () => { - const sourceData = createTestSource(); - - await magpie.sources.create(sourceData as any); - - expect(magpie.LAST_REQUEST).toMatchObject({ - method: 'POST', - url: expect.stringContaining('/sources'), - data: sourceData - }); - }); - - it('should handle card source creation', async () => { - const cardData = { - type: 'card', - card: { - number: '4242424242424242', - exp_month: 12, - exp_year: 2025, - cvc: '123' - }, - redirect: { - success: 'https://example.com/success', - fail: 'https://example.com/fail' - } - }; - - await magpie.sources.create(cardData as any); - - expect(magpie.LAST_REQUEST).toMatchObject({ - method: 'POST', - data: cardData - }); - }); - - it('should handle source creation with owner information', async () => { - const sourceData = { - ...createTestSource(), - owner: { - name: 'John Doe', - email: 'john@example.com', - phone: '+639151234567' - } - }; - - await magpie.sources.create(sourceData as any); - - expect(magpie.LAST_REQUEST?.data).toEqual(sourceData); - }); - - it('should handle source creation with billing information', async () => { - const sourceData = { - type: 'card', - card: { - number: '4242424242424242', - exp_month: 12, - exp_year: 2025, - cvc: '123' - }, - redirect: { - success: 'https://example.com/success', - fail: 'https://example.com/fail' - }, - billing: { - name: 'Jane Smith', - email: 'jane@example.com', - address: { - line1: '123 Main St', - city: 'Manila', - country: 'PH' - } - } - }; - - await magpie.sources.create(sourceData as any); - - expect(magpie.LAST_REQUEST?.data).toEqual(sourceData); - }); - - it('should handle minimal card source creation', async () => { - const minimalCard = { - type: 'card', - card: { - number: '4242424242424242', - exp_month: 12, - exp_year: 2025 - }, - redirect: { - success: 'https://example.com/success', - fail: 'https://example.com/fail' - } - }; - - await magpie.sources.create(minimalCard as any); - - expect(magpie.LAST_REQUEST?.data).toEqual(minimalCard); + + // Mock the organization endpoint to return public keys + magpie.mockRequest('GET', '/me', { + object: 'organization', + pk_test_key: 'pk_test_456', + pk_live_key: 'pk_live_789' }); }); @@ -139,17 +45,6 @@ describe('SourcesResource', () => { }); describe('request options', () => { - it('should handle idempotency key in create request', async () => { - const sourceData = createTestSource(); - const idempotencyKey = 'test_key_123'; - - await magpie.sources.create(sourceData as any, { idempotencyKey }); - - expect(magpie.LAST_REQUEST?.headers).toMatchObject({ - 'X-Idempotency-Key': idempotencyKey - }); - }); - it('should handle idempotency key in retrieve request', async () => { const sourceId = 'src_test_123'; const idempotencyKey = 'retrieve_key_456'; @@ -190,49 +85,33 @@ describe('SourcesResource', () => { }); }); - describe('card source specific tests', () => { - it('should handle different card types', async () => { - const visaCard = { - type: 'card', - card: { - number: '4242424242424242', - exp_month: 12, - exp_year: 2025, - cvc: '123' - }, - redirect: { - success: 'https://example.com/success', - fail: 'https://example.com/fail' - } - }; - - await magpie.sources.create(visaCard as any); - - expect(magpie.LAST_REQUEST?.data).toEqual(visaCard); + describe('PK authentication', () => { + it('should switch to public key authentication before retrieve', async () => { + const sourceId = 'src_test_123'; + + await magpie.sources.retrieve(sourceId); + + // Should have made a call to get organization info first + const organizationCall = magpie.REQUESTS.find(req => req.url?.includes('/me')); + expect(organizationCall).toBeDefined(); + + // The retrieve call should use public key authentication + expect(magpie.LAST_REQUEST?.url).toContain(`/sources/${sourceId}`); }); - it('should handle card with additional metadata', async () => { - const cardWithMetadata = { - type: 'card', - card: { - number: '4242424242424242', - exp_month: 6, - exp_year: 2026, - cvc: '456' - }, - redirect: { - success: 'https://example.com/success', - fail: 'https://example.com/fail' - }, - metadata: { - customer_reference: 'cust_ref_123', - payment_method: 'primary' - } - }; - - await magpie.sources.create(cardWithMetadata as any); - - expect(magpie.LAST_REQUEST?.data).toEqual(cardWithMetadata); + it('should cache public key after first retrieval', async () => { + const sourceId1 = 'src_test_123'; + const sourceId2 = 'src_test_456'; + + // Make first retrieve call + await magpie.sources.retrieve(sourceId1); + + // Make second retrieve call + await magpie.sources.retrieve(sourceId2); + + // Should not make additional organization calls + const orgCalls = magpie.REQUESTS.filter(req => req.url?.includes('/me')); + expect(orgCalls).toHaveLength(1); // Only one organization call }); }); }); \ No newline at end of file diff --git a/src/resources/sources.ts b/src/resources/sources.ts index be9c2d4..232d36f 100644 --- a/src/resources/sources.ts +++ b/src/resources/sources.ts @@ -3,34 +3,37 @@ import { LastResponse, RequestOptions, Source, - SourceCreateParams, } from "../types"; import { BaseResource } from "./base"; /** * Resource class for managing payment sources. * - * The SourcesResource provides methods to create and retrieve payment sources + * The SourcesResource provides methods to retrieve payment sources * such as credit cards, debit cards, and bank accounts. Sources represent - * payment methods that can be attached to customers or used for one-time payments. + * payment methods that are created through secure client-side processes + * and can be retrieved using their identifiers. + * + * Note: Sources are created client-side using public keys for security. + * This resource only provides retrieval functionality. * * @example * ```typescript * const magpie = new Magpie('sk_test_123'); * - * // Create a card source - * const source = await magpie.sources.create({ - * type: 'card', - * card: { - * number: '4242424242424242', - * exp_month: 12, - * exp_year: 2025, - * cvc: '123' - * } - * }); + * // Retrieve a source by ID + * const source = await magpie.sources.retrieve('src_1234567890'); + * console.log(source.type); // 'card' + * console.log(source.used); // false * ``` */ export class SourcesResource extends BaseResource { + /** Flag to track if we've already switched to PK authentication */ + private pkSwitched = false; + + /** Cached public key to avoid multiple organization API calls */ + private cachedPK: string | null = null; + /** * Creates a new SourcesResource instance. * @@ -41,44 +44,46 @@ export class SourcesResource extends BaseResource { } /** - * Creates a new payment source. + * Ensures the client is using public key authentication. + * This method switches to PK authentication lazily when needed. * - * Payment sources represent payment methods like credit cards or bank accounts - * that can be used to process payments. Sources can be reusable or single-use. - * - * @param params - The parameters for creating the source - * @param options - Additional request options - * - * @returns Promise that resolves to the created source with response metadata - * - * @example - * ```typescript - * // Create a card source - * const cardSource = await magpie.sources.create({ - * type: 'card', - * card: { - * number: '4242424242424242', - * exp_month: 12, - * exp_year: 2025, - * cvc: '123' - * }, - * owner: { - * name: 'John Doe', - * email: 'john@example.com' - * } - * }); - * ``` + * @private */ - async create( - params: SourceCreateParams, - options: RequestOptions = {}, - ): Promise { - return this.createResource(params, options); + private async ensurePKAuthentication(): Promise { + if (this.pkSwitched) { + return; + } + + if (!this.cachedPK) { + // Get organization details to retrieve the public key + const originalKey = this.client.getApiKey(); + + try { + const orgResponse = await this.client.get<{ pk_test_key: string; pk_live_key: string }>('/me'); + const organization = orgResponse.data; + + // Determine which public key to use based on the current secret key + if (originalKey.includes('_test_')) { + this.cachedPK = organization.pk_test_key; + } else { + this.cachedPK = organization.pk_live_key; + } + } catch { + throw new Error('Failed to retrieve organization public key for sources authentication'); + } + } + + // Switch to public key authentication + this.client.setApiKey(this.cachedPK); + this.pkSwitched = true; } /** * Retrieves an existing payment source by ID. * + * This method automatically switches to public key authentication before + * making the request, as required by the sources API endpoint. + * * @param id - The unique identifier of the source * @param options - Additional request options * @@ -95,6 +100,9 @@ export class SourcesResource extends BaseResource { id: string, options: RequestOptions = {}, ): Promise { + // Ensure we're using public key authentication + await this.ensurePKAuthentication(); + return this.retrieveResource(id, options); } } \ No newline at end of file From b9581b792b5884835f1b62bd5e5b95bfa47b0669 Mon Sep 17 00:00:00 2001 From: donjerick Date: Fri, 5 Sep 2025 14:53:47 +0800 Subject: [PATCH 4/9] refactor: remove deprecated source creation types - Remove CardSourceCreateParams and SourceCreateParams types - Update type exports to exclude removed source creation types - Clean up type system following removal of sources create() method --- src/types/index.ts | 12 +++++++-- src/types/source.ts | 60 --------------------------------------------- 2 files changed, 10 insertions(+), 62 deletions(-) diff --git a/src/types/index.ts b/src/types/index.ts index 5c211b3..889b2b8 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -6,6 +6,7 @@ import type * as CommonTypes from './common'; import type * as CustomerTypes from './customer'; import type * as PaymentLinkTypes from './link'; import type * as MagpieTypes from './magpie'; +import type * as OrganizationTypes from './organization'; import type * as RefundTypes from './refund'; import type * as PaymentRequestTypes from './request'; import type * as CheckoutSessionTypes from './session'; @@ -42,12 +43,10 @@ export namespace Magpie { // Source types export type Source = SourceTypes.Source; - export type SourceCreateParams = SourceTypes.SourceCreateParams; export type SourceCard = SourceTypes.SourceCard; export type SourceBankAccount = SourceTypes.SourceBankAccount; export type SourceRedirect = SourceTypes.SourceRedirect; export type SourceOwner = SourceTypes.SourceOwner; - export type CardSourceCreateParams = SourceTypes.CardSourceCreateParams; export type SourceType = SourceTypes.SourceType; // Charge types @@ -77,6 +76,14 @@ export namespace Magpie { export type PaymentLinkCreateParams = PaymentLinkTypes.PaymentLinkCreateParams; export type PaymentLinkUpdateParams = PaymentLinkTypes.PaymentLinkUpdateParams; + // Organization types + export type Organization = OrganizationTypes.Organization; + export type OrganizationBranding = OrganizationTypes.OrganizationBranding; + export type PaymentMethodSettings = OrganizationTypes.PaymentMethodSettings; + export type PaymentMethodRate = OrganizationTypes.PaymentMethodRate; + export type PaymentGateway = OrganizationTypes.PaymentGateway; + export type PayoutSettings = OrganizationTypes.PayoutSettings; + // Webhook types export type WebhookEvent = WebhookTypes.WebhookEvent; export type WebhookEndpoint = WebhookTypes.WebhookEndpoint; @@ -94,6 +101,7 @@ export * from './common'; export * from './customer'; export * from './link'; export * from './magpie'; +export * from './organization'; export * from './refund'; export * from './request'; export * from './session'; diff --git a/src/types/source.ts b/src/types/source.ts index c476c3c..e22c6ad 100644 --- a/src/types/source.ts +++ b/src/types/source.ts @@ -55,66 +55,6 @@ export interface SourceOwner { shipping?: Shipping; } -/** - * Parameters for creating a card payment source. - * - * Card sources represent credit or debit cards that can be used - * for payments. All card information is tokenized for security. - */ -export interface CardSourceCreateParams { - /** The name of the card holder. */ - name: string; - - /** The card number. */ - number: string; - - /** The card expiration month. */ - exp_month: string; - - /** The card expiration year. */ - exp_year: string; - - /** The card security code. */ - cvc: string; - - /** The card billing address line 1. */ - address_line1?: string; - - /** The card billing address line 2. */ - address_line2?: string; - - /** The card billing address city. */ - address_city?: string; - - /** The card billing address state. */ - address_state?: string; - - /** The card billing address country. */ - address_country?: string; - - /** The card billing address zip code. */ - address_zip?: string; -} - -/** - * Parameters for creating a payment source. - * - * Payment sources represent different payment methods that can be used - * for payments. All payment information is tokenized for security. - */ -export interface SourceCreateParams { - /** The type of the source. */ - type: SourceType; - - /** The details of the card, if source type is card. */ - card?: CardSourceCreateParams; - - /** The payment redirect params. */ - redirect: SourceRedirect; - - /** The owner details. */ - owner?: SourceOwner; -} /** * A card object represents a credit or debit card payment source type. From 8ca28c9b03efddb0d10c2006a9c56752e5998ff6 Mon Sep 17 00:00:00 2001 From: donjerick Date: Fri, 5 Sep 2025 14:54:08 +0800 Subject: [PATCH 5/9] feat: integrate Organizations resource into main client - Add OrganizationsResource to main Magpie client class - Export Organization-related types in Magpie namespace - Remove deprecated source creation types from exports - Maintain backward compatibility for all existing resources --- src/magpie.ts | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/src/magpie.ts b/src/magpie.ts index 0397457..d53d554 100644 --- a/src/magpie.ts +++ b/src/magpie.ts @@ -4,6 +4,7 @@ import { ChargesResource } from "./resources/charges"; import { CheckoutResource } from "./resources/checkout"; import { CustomersResource } from "./resources/customers"; import { PaymentLinksResource } from "./resources/links"; +import { OrganizationsResource } from "./resources/organizations"; import { PaymentRequestsResource } from "./resources/requests"; import { SourcesResource } from "./resources/sources"; import { WebhooksResource } from "./resources/webhooks"; @@ -56,6 +57,9 @@ export class Magpie extends BaseClient { /** API resource for managing payment links */ public paymentLinks: PaymentLinksResource; + /** API resource for managing organization information */ + public organizations: OrganizationsResource; + /** API resource for managing webhooks */ public webhooks: WebhooksResource; @@ -88,6 +92,7 @@ export class Magpie extends BaseClient { this.checkout = new CheckoutResource(this); this.paymentRequests = new PaymentRequestsResource(this); this.paymentLinks = new PaymentLinksResource(this); + this.organizations = new OrganizationsResource(this); this.webhooks = new WebhooksResource(); } } @@ -122,12 +127,10 @@ export namespace Magpie { // Source types export type Source = MagpieNamespace.Source; - export type SourceCreateParams = MagpieNamespace.SourceCreateParams; export type SourceCard = MagpieNamespace.SourceCard; export type SourceBankAccount = MagpieNamespace.SourceBankAccount; export type SourceRedirect = MagpieNamespace.SourceRedirect; export type SourceOwner = MagpieNamespace.SourceOwner; - export type CardSourceCreateParams = MagpieNamespace.CardSourceCreateParams; export type SourceType = MagpieNamespace.SourceType; // Charge types @@ -157,6 +160,14 @@ export namespace Magpie { export type PaymentLinkCreateParams = MagpieNamespace.PaymentLinkCreateParams; export type PaymentLinkUpdateParams = MagpieNamespace.PaymentLinkUpdateParams; + // Organization types + export type Organization = MagpieNamespace.Organization; + export type OrganizationBranding = MagpieNamespace.OrganizationBranding; + export type PaymentMethodSettings = MagpieNamespace.PaymentMethodSettings; + export type PaymentMethodRate = MagpieNamespace.PaymentMethodRate; + export type PaymentGateway = MagpieNamespace.PaymentGateway; + export type PayoutSettings = MagpieNamespace.PayoutSettings; + // Webhook types export type WebhookEvent = MagpieNamespace.WebhookEvent; export type WebhookEndpoint = MagpieNamespace.WebhookEndpoint; From 420d40e82a0dce52feba483e8ae48e029ee4fa48 Mon Sep 17 00:00:00 2001 From: donjerick Date: Fri, 5 Sep 2025 14:54:58 +0800 Subject: [PATCH 6/9] feat: enhance test utilities with improved mocking capabilities - Add mockRequest() method for custom HTTP response mocking - Add mockNetworkError() method for simulating network failures - Add createTestOrganization() utility function - Remove deprecated createTestSource() function - Improve error handling and response structure in test mocks - Add support for custom status codes and headers in mocks --- src/__tests__/testUtils.ts | 165 ++++++++++++++++++++++++++++++------- 1 file changed, 136 insertions(+), 29 deletions(-) diff --git a/src/__tests__/testUtils.ts b/src/__tests__/testUtils.ts index 5531c35..6eb0e75 100644 --- a/src/__tests__/testUtils.ts +++ b/src/__tests__/testUtils.ts @@ -22,6 +22,9 @@ export interface LastRequest { */ interface SpyableMagpie extends Magpie { LAST_REQUEST: LastRequest | null; + REQUESTS: LastRequest[]; + mockRequest(method: string, url: string, responseData: any, status?: number, headers?: Record): void; + mockNetworkError(method: string, url: string): void; } /** @@ -39,6 +42,22 @@ export function getSpyableMagpie( // Initialize the LAST_REQUEST tracker magpie.LAST_REQUEST = null; + magpie.REQUESTS = []; + + // Store mock responses and errors + const mockResponses: Map }> = new Map(); + const mockErrors: Map = new Map(); + + // Add mock methods + magpie.mockRequest = (method: string, url: string, responseData: any, status = 200, headers = {}) => { + const key = `${method.toUpperCase()}:${url}`; + mockResponses.set(key, { data: responseData, status, headers }); + }; + + magpie.mockNetworkError = (method: string, url: string) => { + const key = `${method.toUpperCase()}:${url}`; + mockErrors.set(key, new Error('Network Error')); + }; // Mock the HTTP client to capture requests (magpie as any).http.request = jest.fn().mockImplementation((requestConfig: AxiosRequestConfig) => { @@ -89,7 +108,7 @@ export function getSpyableMagpie( }; // Capture request details - magpie.LAST_REQUEST = { + const requestDetails = { method: requestConfig.method?.toUpperCase() ?? 'GET', url: requestConfig.url ?? '', data: requestConfig.data, @@ -103,17 +122,52 @@ export function getSpyableMagpie( } }; - // Return a mock successful response - const mockResponse: AxiosResponse = { + magpie.LAST_REQUEST = requestDetails; + magpie.REQUESTS.push(requestDetails); + + // Check for mock responses and errors + const key = `${requestDetails.method}:${requestDetails.url}`; + + // Check for network error first + if (mockErrors.has(key)) { + const error = mockErrors.get(key); + return Promise.reject(error ?? new Error('Network Error')); + } + + // Check for custom mock response + if (mockResponses.has(key)) { + const mockConfig = mockResponses.get(key)!; + const mockResponse: AxiosResponse = { + data: mockConfig.data, + status: mockConfig.status, + statusText: mockConfig.status >= 400 ? 'Error' : 'OK', + headers: { 'request-id': 'req_test_123', ...mockConfig.headers }, + config: requestConfig as any, + request: {} + }; + + // Throw error for 4xx and 5xx status codes + if (mockConfig.status >= 400) { + const error = new Error(`HTTP ${mockConfig.status} Error`) as any; + error.response = mockResponse; + // eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors + return Promise.reject(error); + } + + return Promise.resolve(mockResponse); + } + + // Return default mock response + const defaultResponse: AxiosResponse = { data: { id: 'mock_response', object: 'test' }, status: 200, statusText: 'OK', - headers: {}, + headers: { 'request-id': 'req_default_123' }, config: requestConfig as any, request: {} }; - return Promise.resolve(mockResponse); + return Promise.resolve(defaultResponse); }); return magpie; @@ -259,29 +313,6 @@ export function createTestCharge(overrides: Record = {}): Record = {}): Record { - return { - type: 'card', - card: { - number: '4242424242424242', - exp_month: 12, - exp_year: 2025, - cvc: '123' - }, - redirect: { - success: 'https://example.com/success', - fail: 'https://example.com/fail' - }, - billing: { - name: 'Test User', - email: 'test@example.com' - }, - ...overrides - }; -} /** * Utility to create test line item data @@ -415,6 +446,82 @@ export function createTestWebhookHeaders(signature: string, timestamp?: number, return headers; } +/** + * Utility to create test organization data + */ +export function createTestOrganization(overrides: Record = {}): Record { + return { + object: 'organization', + id: `org_test_${getRandomString()}`, + title: 'Test Organization', + account_name: 'Test Org Account', + statement_descriptor: 'TEST_ORG', + pk_test_key: `pk_test_${getRandomString()}`, + sk_test_key: `sk_test_${getRandomString()}`, + pk_live_key: `pk_live_${getRandomString()}`, + sk_live_key: `sk_live_${getRandomString()}`, + branding: { + icon: 'https://example.com/icon.png', + logo: null, + use_logo: false, + brand_color: '#fffefd', + accent_color: '#1a3da6' + }, + status: 'approved', + created_at: '2021-08-15T23:13:11.682944+08:00', + updated_at: '2025-09-05T11:35:51.957059+08:00', + business_address: null, + payment_method_settings: { + card: { + mid: null, + gateway: { + id: 'default', + name: 'Magpie Gateway' + }, + rate: { + mdr: 0.029, + fixed_fee: 1000, + formula: 'mdr_plus_fixed' + }, + status: 'approved' + }, + gcash: { + mid: '217020000038029496672', + gateway: null, + rate: { + mdr: 0.022, + fixed_fee: 0, + formula: 'mdr_plus_fixed' + }, + status: 'approved' + } + }, + rates: { + card: { + mdr: 0.029, + fixed_fee: 1000 + }, + gcash: { + mdr: 0.022, + fixed_fee: 0 + } + }, + payout_settings: { + schedule: 'automatic', + delivery_type: 'standard', + bank_code: 'BPI/BPI Family Savings Bank', + account_number: '3259442965', + account_name: 'Test Account' + }, + metadata: { + business_website: 'https://test.com', + support_phone: '917 513 4281', + support_email: 'support@test.com' + }, + ...overrides + }; +} + /** * Utility to create test webhook signature configuration */ @@ -427,4 +534,4 @@ export function createTestWebhookConfig(overrides: Record = {}): Re prefix: 'v1=', ...overrides }; -} \ No newline at end of file +} From 7a6e9ac9d1faded722a3390b68e39a4f5ac54c31 Mon Sep 17 00:00:00 2001 From: donjerick Date: Fri, 5 Sep 2025 14:56:10 +0800 Subject: [PATCH 7/9] chore: bump version to 1.1.0 and update changelog - Update package version to 1.1.0 - Add comprehensive changelog for v1.1.0 release - Document new features, breaking changes, and improvements - Include migration notes for deprecated source creation --- CHANGELOG.md | 26 ++++++++++++++++++++++++++ package.json | 2 +- 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4149f40..b9c433a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,32 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.1.0] - 2025-09-05 + +### Added + +- **Organization Resource** - New resource for retrieving organization information including branding, payment method settings, and payout configurations +- **createTestOrganization()** utility function for testing with realistic organization data +- **Organization Types** - Complete TypeScript definitions for Organization, OrganizationBranding, PaymentMethodSettings, PaymentGateway, PaymentMethodRate, and PayoutSettings + +### Changed + +- **BREAKING: SourcesResource** - Removed `create()` method as sources should only be created client-side for security +- **SourcesResource Authentication** - Implemented lazy public key (PK) authentication that automatically switches from secret key to public key when retrieving sources +- **BaseClient** - Added `setApiKey()` and `getApiKey()` methods for dynamic API key switching +- **Enhanced Test Utilities** - Extended SpyableMagpie with `mockRequest()` and `mockNetworkError()` methods for better test coverage + +### Fixed + +- **Sources Security** - Sources now correctly use public key authentication as required by the API +- **Test Coverage** - Updated sources tests to remove create method tests and add PK authentication tests + +### Technical Improvements + +- Lazy authentication switching prevents unnecessary HTTP calls during SDK initialization +- Public key caching eliminates redundant organization API calls +- Enhanced mock testing infrastructure with request tracking and custom response handling + ## [1.0.0] - 2025-09-04 ### Added diff --git a/package.json b/package.json index 1fa816e..c060eef 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@magpieim/magpie-node", - "version": "1.0.0", + "version": "1.1.0", "description": "The Magpie API Library for NodeJS enables you to work with Magpie APIs.", "keywords": [ "magpie", From f9615211ba941ff44653c0243c902e120976e570 Mon Sep 17 00:00:00 2001 From: donjerick Date: Fri, 5 Sep 2025 14:57:16 +0800 Subject: [PATCH 8/9] chore: minor license and resource updates - Update LICENSE file - Minor updates to links and sessions resources --- LICENSE | 2 +- src/resources/links.ts | 12 ++---------- src/resources/sessions.ts | 4 ++-- 3 files changed, 5 insertions(+), 13 deletions(-) diff --git a/LICENSE b/LICENSE index 3f95a24..dc083b8 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2024 Magpie IM +Copyright (c) 2025 Magpie IM Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/src/resources/links.ts b/src/resources/links.ts index bb52343..325d51d 100644 --- a/src/resources/links.ts +++ b/src/resources/links.ts @@ -26,10 +26,6 @@ import { BaseResource } from "./base"; * description: 'Consultation Fee', * quantity: 1 * }], - * after_completion: { - * type: 'redirect', - * redirect: { url: 'https://example.com/thanks' } - * } * }); * * // Share the payment link @@ -60,6 +56,8 @@ export class PaymentLinksResource extends BaseResource { * @example * ```typescript * const paymentLink = await magpie.paymentLinks.create({ + * allow_adjustable_quantity: false, + * internal_name: 'Website Design Service', * line_items: [ * { * amount: 100000, // PHP 1,000.00 @@ -68,12 +66,6 @@ export class PaymentLinksResource extends BaseResource { * image: 'https://example.com/service.jpg' * } * ], - * after_completion: { - * type: 'redirect', - * redirect: { url: 'https://example.com/thank-you' } - * }, - * allow_promotion_codes: true, - * billing_address_collection: 'auto' * }); * * console.log(paymentLink.url); // Share this URL with customers diff --git a/src/resources/sessions.ts b/src/resources/sessions.ts index d253830..15211f6 100644 --- a/src/resources/sessions.ts +++ b/src/resources/sessions.ts @@ -27,7 +27,7 @@ import { BaseResource } from "./base"; * quantity: 1, * image: 'https://example.com/product.jpg' * }], - * success_url: 'https://example.com/success?session_id={CHECKOUT_SESSION_ID}', + * success_url: 'https://example.com/success', * cancel_url: 'https://example.com/cancel', * customer_email: 'customer@example.com' * }); @@ -68,7 +68,7 @@ export class CheckoutSessionsResource extends BaseResource { * quantity: 1 * } * ], - * success_url: 'https://example.com/success?session_id={CHECKOUT_SESSION_ID}', + * success_url: 'https://example.com/success', * cancel_url: 'https://example.com/cancel', * customer_email: 'customer@example.com', * expires_at: Math.floor(Date.now() / 1000) + 3600 // 1 hour from now From c016350b903ca6b84d83f388c9bd718ef0e6e815 Mon Sep 17 00:00:00 2001 From: donjerick Date: Fri, 5 Sep 2025 15:00:25 +0800 Subject: [PATCH 9/9] chore: remove docs initial plan --- docs/plan.md | 60 ---------------------------------------------------- 1 file changed, 60 deletions(-) delete mode 100644 docs/plan.md diff --git a/docs/plan.md b/docs/plan.md deleted file mode 100644 index e4aa99d..0000000 --- a/docs/plan.md +++ /dev/null @@ -1,60 +0,0 @@ -# Architecture Overview - -## Core Structure - -- Main client class (similar to Stripe's new Stripe()) -- Resource-based organization (payments, customers, subscriptions, etc.) -- Method chaining and intuitive naming -- TypeScript support with strong typing -- Built-in error handling and retries - -## Key Components to Build - -1. Core Client - -- Authentication handling (API keys, tokens) -- Base HTTP client with retry logic -- Request/response interceptors -- Environment configuration (sandbox/production) - -2. Resource Classes - -- Each API endpoint group becomes a resource (e.g., client.payments.create()) -- CRUD operations with consistent naming -- Pagination handling -- Webhooks support - -3. Developer Experience Features - -- Comprehensive TypeScript definitions -- Detailed JSDoc documentation -- Request/response logging (debug mode) -- Idempotency key support -- Rate limiting handling - -## Implementation Strategy - -### Phase 1: Foundation - -- Set up project structure with TypeScript -- Create base HTTP client -- Implement authentication -- Add basic error handling - -### Phase 2: Core Resources - -- Build your most important resources first -- Implement CRUD operations -- Add validation and error handling - -### Phase 3: Advanced Features - -- Webhooks handling -- Pagination utilities -- Request retries and timeouts -- File uploads (if needed) - -### Phase 4: Developer Tools - -- Testing utilities -- Documentation and examples \ No newline at end of file