prices-as-code
Version:
Prices as Code (PaC) - Define your product pricing schemas with type-safe definitions
727 lines (602 loc) • 23.3 kB
HTML
<!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 © 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>