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:
- syncProducts/syncPrices: Implement these to support the Push model (writing to the provider)
- fetchProducts/fetchPrices: Implement these to support the Pull model (reading from the provider)
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
- Explore Working with Metadata for enhanced flexibility
- Learn about CI/CD Integration for automated pricing updates
- Check out the API Documentation for more advanced features