UNPKG

prices-as-code

Version:

Prices as Code (PaC) - Define your product pricing schemas with type-safe definitions

727 lines (602 loc) 23.3 kB
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Custom Providers - Prices as Code</title> <meta name="description" content="Extend Prices as Code with custom payment providers"> <link rel="stylesheet" href="../assets/css/main.css"> <link rel="icon" href="https://raw.githubusercontent.com/wickdninja/assets/refs/heads/main/PaC.webp"> </head> <body> <header class="site-header"> <div class="container"> <div class="header-content"> <div class="logo"> <a href="../index.html"> <img src="https://raw.githubusercontent.com/wickdninja/assets/refs/heads/main/PaC.webp" alt="Prices as Code" width="40"> <span>Prices as Code</span> </a> </div> <nav class="main-nav"> <ul> <li><a href="index.html">Guides</a></li> <li><a href="../api/index.html">API</a></li> <li><a href="../providers/index.html">Providers</a></li> <li><a href="https://github.com/wickdninja/prices-as-code" target="_blank">GitHub</a></li> <li><a href="https://www.npmjs.com/package/prices-as-code" target="_blank">NPM</a></li> </ul> </nav> </div> </div> </header> <main class="content"> <div class="container"> <h1>Custom Providers</h1> <p>While Prices as Code comes with built-in support for Stripe, you might need to integrate with other payment providers or custom systems. This guide explains how to create custom provider integrations.</p> <h2>Provider Interface</h2> <p>Every provider in Prices as Code must implement the ProviderClient interface, which defines the methods needed to synchronize products and prices in both push and pull modes:</p> <div class="highlight"> <pre><code class="language-typescript">interface ProviderClient { // Push mode methods (for writing to the provider) syncProducts(products: Product[]): Promise<Product[]>; syncPrices(prices: Price[]): Promise<Price[]>; // Pull mode methods (for reading from the provider) fetchProducts(): Promise<Product[]>; fetchPrices(): Promise<Price[]>; }</code></pre> </div> <p>The interface is intentionally simple, with just four methods:</p> <ul> <li><strong>syncProducts/syncPrices</strong>: Implement these to support the Push model (writing to the provider)</li> <li><strong>fetchProducts/fetchPrices</strong>: Implement these to support the Pull model (reading from the provider)</li> </ul> <h2>Creating a Custom Provider</h2> <p>Here's how to create a custom provider implementation for a fictional payment system called "PaymentX":</p> <div class="highlight"> <pre><code class="language-typescript">import { Provider, Product, Price } from 'prices-as-code'; // Assuming you have a PaymentX SDK import { PaymentXClient } from 'paymentx-sdk'; // Define the options interface for your provider interface PaymentXOptions { apiKey: string; baseUrl?: string; } export class PaymentXProvider implements ProviderClient { // PaymentX client instance private client: PaymentXClient; // Store mappings from keys to IDs private productIdMap: Map<string, string> = new Map(); constructor(options: PaymentXOptions) { if (!options.apiKey) { throw new Error('PaymentX API key is required'); } this.client = new PaymentXClient({ apiKey: options.apiKey, baseUrl: options.baseUrl || 'https://api.paymentx.com', }); } /** * Fetch products from PaymentX (Pull model) */ async fetchProducts(): Promise<Product[]> { console.log('📥 Fetching products from PaymentX...'); // Fetch products from PaymentX const paymentXProducts = await this.client.products.list(); console.log(`📋 Found ${paymentXProducts.length} products in PaymentX`); // Map PaymentX products to Prices as Code products const products = paymentXProducts.map(product => { // Generate a key or use the one from metadata const key = product.metadata?.key || product.name.toLowerCase().replace(/\s+/g, '_'); // Store in mapping for prices lookup this.productIdMap.set(key, product.id); return { id: product.id, name: product.name, description: product.description || '', provider: 'paymentx', key: key, metadata: { ...product.metadata, // Store provider-specific details in metadata paymentxCreated: product.created_at } }; }); return products; } /** * Fetch prices from PaymentX (Pull model) */ async fetchPrices(): Promise<Price[]> { console.log('📥 Fetching prices from PaymentX...'); // Ensure we have products first for establishing relationships if (this.productIdMap.size === 0) { console.log('📊 Fetching products first to establish relationships...'); await this.fetchProducts(); } // Fetch prices from PaymentX const paymentXPrices = await this.client.prices.list(); console.log(`💰 Found ${paymentXPrices.length} prices in PaymentX`); // Create a reverse mapping from product ID to key const productKeyMap = new Map<string, string>(); this.productIdMap.forEach((id, key) => { productKeyMap.set(id, key); }); // Map PaymentX prices to Prices as Code prices const prices = paymentXPrices.map(price => { // Determine price type const type = price.recurring ? 'recurring' : 'one_time'; // Build recurring configuration if needed let recurring = undefined; if (price.recurring) { recurring = { interval: price.recurring.interval, intervalCount: price.recurring.interval_count, }; } // Generate a key or use existing one const key = price.metadata?.key || `${price.nickname?.toLowerCase().replace(/\s+/g, '_') || 'price'}_${Date.now()}`; // Find product key const productKey = productKeyMap.get(price.product_id); return { id: price.id, name: price.nickname || 'Unnamed Price', nickname: price.nickname || '', unitAmount: price.amount, currency: price.currency.toUpperCase(), type, recurring, provider: 'paymentx', active: price.active, key, productId: price.product_id, productKey, metadata: { ...price.metadata, paymentxCreated: price.created_at } }; }); return prices; } /** * Sync products to PaymentX (Push model) */ async syncProducts(products: Product[]): Promise<Product[]> { // Filter products for this provider const paymentXProducts = products.filter(p => p.provider === 'paymentx'); const updatedProducts = []; const otherProviderProducts = products.filter(p => p.provider !== 'paymentx'); console.log(`🚀 Syncing ${paymentXProducts.length} products to PaymentX...`); if (paymentXProducts.length === 0) { console.log('No PaymentX products to sync'); return products; } // Fetch existing products const existingProducts = await this.client.products.list(); console.log(`📋 Found ${existingProducts.length} existing products in PaymentX`); for (const product of paymentXProducts) { try { // Generate key if not present const key = product.key || product.name.toLowerCase().replace(/\s+/g, '_'); console.log(`📋 Processing product: ${product.name} (key: ${key})`); // Find existing product by key in metadata const existingProduct = existingProducts.find( p => p.metadata && p.metadata.key === key ); if (existingProduct) { console.log(`📋 Found existing product with key: ${key} (${existingProduct.id})`); // Update existing product const updated = await this.client.products.update(existingProduct.id, { name: product.name, description: product.description || '', metadata: { ...product.metadata, key } }); // Store the mapping this.productIdMap.set(key, updated.id); // Update product with provider ID updatedProducts.push({ ...product, id: updated.id, key }); console.log(` Updated product: ${product.name} (ID: ${updated.id})`); } else { console.log(`📋 Creating new product with key: ${key}`); // Create new product const newProduct = await this.client.products.create({ name: product.name, description: product.description || '', metadata: { ...product.metadata, key } }); // Store the mapping this.productIdMap.set(key, newProduct.id); // Update product with provider ID updatedProducts.push({ ...product, id: newProduct.id, key }); console.log(` Created product: ${product.name} (ID: ${newProduct.id})`); } } catch (error) { console.error(` Error syncing product ${product.name}: ${error.message}`); // Add to updated list but mark as failed updatedProducts.push({ ...product, metadata: { ...product.metadata, syncError: error.message, syncFailed: 'true' } }); } } return [...updatedProducts, ...otherProviderProducts]; } /** * Sync prices to PaymentX (Push model) */ async syncPrices(prices: Price[]): Promise<Price[]> { // Filter prices for this provider const paymentXPrices = prices.filter(p => p.provider === 'paymentx'); const updatedPrices = []; const otherProviderPrices = prices.filter(p => p.provider !== 'paymentx'); console.log(`💰 Syncing ${paymentXPrices.length} prices to PaymentX...`); if (paymentXPrices.length === 0) { console.log('No PaymentX prices to sync'); return prices; } // Fetch existing prices const existingPrices = await this.client.prices.list(); console.log(`💰 Found ${existingPrices.length} existing prices in PaymentX`); for (const price of paymentXPrices) { try { // Ensure the price has a valid key const key = price.key || `${price.name.toLowerCase().replace(/\s+/g, '_')}_${Date.now()}`; console.log(`💰 Processing price: ${price.name} (key: ${key})`); // Resolve product ID let productId = price.productId; if (price.productKey && !productId) { productId = this.productIdMap.get(price.productKey); if (!productId) { throw new Error( `Could not find product ID for key: ${price.productKey}. Ensure products are synced before prices.` ); } console.log(`💰 Resolved product ID ${productId} from key ${price.productKey}`); } // Find existing price by key in metadata const existingPrice = existingPrices.find( p => p.metadata && p.metadata.key === key ); if (existingPrice) { console.log(`💰 Found existing price with key: ${key} (${existingPrice.id})`); // Create update data const updateData = { nickname: price.nickname || price.name, metadata: { ...price.metadata, key }, active: price.active !== false }; // Update existing price const updated = await this.client.prices.update(existingPrice.id, updateData); // Update price with provider ID updatedPrices.push({ ...price, id: updated.id, key }); console.log(` Updated price: ${price.name} (ID: ${updated.id})`); } else { console.log(`💰 Creating new price with key: ${key}`); // Create new price const newPrice = await this.client.prices.create({ product_id: productId, nickname: price.nickname || price.name, amount: price.unitAmount, currency: price.currency.toLowerCase(), recurring: price.recurring ? { interval: price.recurring.interval, interval_count: price.recurring.intervalCount || 1 } : undefined, metadata: { ...price.metadata, key, original_name: price.name } }); // Update price with provider ID updatedPrices.push({ ...price, id: newPrice.id, key }); console.log(` Created price: ${price.name} (ID: ${newPrice.id})`); } } catch (error) { console.error(` Error syncing price ${price.name}: ${error.message}`); // Add to updated list but mark as failed updatedPrices.push({ ...price, metadata: { ...price.metadata, syncError: error.message, syncFailed: 'true' } }); } } return [...updatedPrices, ...otherProviderPrices]; } }</code></pre> </div> <h2>Using Your Custom Provider</h2> <p>After creating your custom provider, you can use it with Prices as Code for both Push and Pull operations:</p> <h3>For Push Operations (Sync)</h3> <div class="highlight"> <pre><code class="language-typescript">import { pac } from 'prices-as-code'; import { PaymentXProvider } from './providers/paymentx.js'; async function syncPricing() { try { const result = await pac({ configPath: './pricing.ts', providers: [ { provider: 'paymentx', options: { apiKey: process.env.PAYMENTX_API_KEY, // Any other options your provider needs } } ] }); console.log('Sync completed!'); console.log(`Updated products: ${result.config.products.length}`); console.log(`Updated prices: ${result.config.prices.length}`); } catch (error) { console.error('Sync failed:', error); } } syncPricing();</code></pre> </div> <h3>For Pull Operations</h3> <div class="highlight"> <pre><code class="language-typescript">import { pac } from 'prices-as-code'; import { PaymentXProvider } from './providers/paymentx.js'; async function pullPricing() { try { const result = await pac.pull({ configPath: './pricing.yml', providers: [ { provider: 'paymentx', options: { apiKey: process.env.PAYMENTX_API_KEY, // Any other options your provider needs } } ], format: 'yaml', // Can be 'yaml', 'json', or 'ts' }); console.log('Pull completed!'); console.log(`Pulled ${result.config.products.length} products and ${result.config.prices.length} prices`); console.log(`Saved to ${result.configPath}`); } catch (error) { console.error('Pull failed:', error); } } pullPricing();</code></pre> </div> <h2>Provider Registration</h2> <p>You can also register your custom provider globally:</p> <div class="highlight"> <pre><code class="language-typescript">import { registerProvider, pac } from 'prices-as-code'; import { PaymentXProvider } from './providers/paymentx.js'; // Register your custom provider registerProvider('paymentx', PaymentXProvider); // Now you can use it by name async function syncPricing() { try { const result = await pac({ configPath: './pricing.ts', providers: [ { provider: 'paymentx', // Use by name options: { apiKey: process.env.PAYMENTX_API_KEY, } } ] }); console.log('Sync completed!'); } catch (error) { console.error('Sync failed:', error); } } syncPricing();</code></pre> </div> <h2>Handling Provider-Specific Features</h2> <p>Different payment providers might have unique features or data structures. Your custom provider can handle these by implementing additional methods or transformations:</p> <div class="highlight"> <pre><code class="language-typescript">export class PaymentXProvider implements Provider { // ... other methods ... // Example of a provider-specific method async createSubscriptionPlan(plan: any): Promise<any> { if (!this.client) { throw new Error('Provider not initialized'); } // Create a subscription plan in PaymentX return this.client.subscriptionPlans.create(plan); } // Transform provider-specific data transformProductData(product: Product): any { // Convert Prices as Code product format to PaymentX format return { name: product.name, description: product.description || '', is_active: true, // PaymentX-specific fields category: product.metadata?.category || 'default', billing_scheme: product.metadata?.billingScheme || 'standard', // Other transformations }; } // Handle feature flags or capabilities supportsFeature(featureName: string): boolean { const supportedFeatures = [ 'tiered_pricing', 'metered_billing', 'tax_rates', ]; return supportedFeatures.includes(featureName); } }</code></pre> </div> <h2>Error Handling and Logging</h2> <p>Implement robust error handling in your custom provider:</p> <div class="highlight"> <pre><code class="language-typescript">export class PaymentXProvider implements Provider { // ... other fields ... private logger: any; constructor(logger?: any) { this.logger = logger || console; } async createProduct(product: Product): Promise<Product> { if (!this.client) { throw new Error('Provider not initialized'); } try { this.logger.debug(`Creating product: ${product.name}`); const paymentXProduct = await this.client.products.create({ name: product.name, description: product.description || '', metadata: product.metadata || {}, }); this.logger.info(`Product created: ${paymentXProduct.id}`); return { ...product, id: paymentXProduct.id, }; } catch (error) { this.logger.error(`Failed to create product: ${product.name}`, error); // Enhance error with context const enhancedError = new Error( `Failed to create product in PaymentX: ${error.message}` ); enhancedError.cause = error; enhancedError.context = { product }; throw enhancedError; } } // ... other methods with similar error handling ... }</code></pre> </div> <h2>Testing Custom Providers</h2> <p>It's important to thoroughly test your custom provider:</p> <div class="highlight"> <pre><code class="language-typescript">import { PaymentXProvider } from './paymentx.js'; // Create a mock PaymentX client for testing const mockClient = { test: jest.fn().mockResolvedValue(true), products: { list: jest.fn().mockResolvedValue([ { id: 'prod_1', name: 'Test Product', description: 'A test product' } ]), create: jest.fn().mockImplementation((data) => Promise.resolve({ id: 'prod_new', ...data }) ), update: jest.fn().mockImplementation((id, data) => Promise.resolve({ id, ...data }) ), }, prices: { list: jest.fn().mockResolvedValue([ { id: 'price_1', product_id: 'prod_1', nickname: 'Test Price', amount: 1000, currency: 'usd', recurring: { interval: 'month', interval_count: 1 } } ]), create: jest.fn().mockImplementation((data) => Promise.resolve({ id: 'price_new', ...data }) ), update: jest.fn().mockImplementation((id, data) => Promise.resolve({ id, ...data }) ), }, }; // Mock the PaymentX SDK jest.mock('paymentx-sdk', () => ({ PaymentXClient: jest.fn().mockImplementation(() => mockClient) })); describe('PaymentXProvider', () => { let provider; beforeEach(() => { provider = new PaymentXProvider(); return provider.initialize({ apiKey: 'test_key' }); }); test('fetchProducts returns mapped products', async () => { const products = await provider.fetchProducts(); expect(products).toHaveLength(1); expect(products[0]).toEqual({ provider: 'paymentx', id: 'prod_1', name: 'Test Product', description: 'A test product', metadata: {}, }); expect(mockClient.products.list).toHaveBeenCalled(); }); test('createProduct maps and creates a product', async () => { const product = { provider: 'paymentx', name: 'New Product', description: 'A new product', }; const result = await provider.createProduct(product); expect(result).toEqual({ ...product, id: 'prod_new', }); expect(mockClient.products.create).toHaveBeenCalledWith({ name: 'New Product', description: 'A new product', metadata: {}, }); }); // Additional tests for other methods });</code></pre> </div> <h2>Next Steps</h2> <ul> <li>Explore <a href="metadata.html">Working with Metadata</a> for enhanced flexibility</li> <li>Learn about <a href="ci-cd.html">CI/CD Integration</a> for automated pricing updates</li> <li>Check out the <a href="../api/index.html">API Documentation</a> for more advanced features</li> </ul> </div> </main> <footer class="site-footer"> <div class="container"> <div class="footer-content"> <p>Copyright &copy; 2025 Nate Ross. Distributed by an <a href="https://github.com/wickdninja/prices-as-code/blob/main/LICENSE">MIT license.</a></p> <p><a href="#top" class="back-to-top">Back to top</a></p> </div> </div> </footer> <script src="../assets/js/main.js"></script> </body> </html>