Custom Providers

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.

Provider Interface

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:

interface ProviderClient {
  // Push mode methods (for writing to the provider)
  syncProducts(products: Product[]): Promise;
  syncPrices(prices: Price[]): Promise;
  
  // Pull mode methods (for reading from the provider)
  fetchProducts(): Promise;
  fetchPrices(): Promise;
}

The interface is intentionally simple, with just four methods:

Creating a Custom Provider

Here's how to create a custom provider implementation for a fictional payment system called "PaymentX":

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 = 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 {
    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 {
    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();
    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 {
    // 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 {
    // 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];
  }
}

Using Your Custom Provider

After creating your custom provider, you can use it with Prices as Code for both Push and Pull operations:

For Push Operations (Sync)

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();

For Pull Operations

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();

Provider Registration

You can also register your custom provider globally:

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();

Handling Provider-Specific Features

Different payment providers might have unique features or data structures. Your custom provider can handle these by implementing additional methods or transformations:

export class PaymentXProvider implements Provider {
  // ... other methods ...
  
  // Example of a provider-specific method
  async createSubscriptionPlan(plan: any): Promise {
    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);
  }
}

Error Handling and Logging

Implement robust error handling in your custom provider:

export class PaymentXProvider implements Provider {
  // ... other fields ...
  private logger: any;
  
  constructor(logger?: any) {
    this.logger = logger || console;
  }
  
  async createProduct(product: Product): Promise {
    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 ...
}

Testing Custom Providers

It's important to thoroughly test your custom provider:

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
});

Next Steps