prices-as-code
Version:
Prices as Code (PaC) - Define your product pricing schemas with type-safe definitions
548 lines (547 loc) • 27.4 kB
JavaScript
import Stripe from 'stripe';
/**
* Stripe provider implementation
*/
export class StripeProvider {
constructor(options) {
this.productIdMap = new Map();
this.client = new Stripe(options.secretKey, { apiVersion: '2023-10-16' });
}
getClient() {
return this.client;
}
/**
* Fetch all products from Stripe
*/
async fetchProducts() {
console.log('📥 Fetching products from Stripe...');
try {
// Fetch existing products with pagination support
const stripeProducts = [];
let hasMore = true;
let startingAfter;
while (hasMore) {
const params = { limit: 100, active: true };
if (startingAfter) {
params.starting_after = startingAfter;
}
const response = await this.client.products.list(params);
stripeProducts.push(...response.data);
hasMore = response.has_more;
if (response.data.length > 0) {
startingAfter = response.data[response.data.length - 1].id;
}
else {
hasMore = false;
}
}
console.log(`📋 Found ${stripeProducts.length} products in Stripe`);
// Convert Stripe products to our format
const products = stripeProducts.map(product => {
// Build product from Stripe data
const parsedFeatures = product.metadata?.features ?
JSON.parse(product.metadata.features) : [];
const highlight = product.metadata?.highlight === 'true';
// 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: 'stripe',
key: key,
features: parsedFeatures,
highlight,
metadata: {
...product.metadata,
stripeCreated: product.created.toString()
}
};
});
return products;
}
catch (error) {
console.error(`❌ Error fetching products from Stripe: ${error?.message || error}`);
throw error;
}
}
/**
* Process products before sending to Stripe
*/
prepareProduct(product) {
// Generate key if not present
if (!product.key) {
product.key = product.name.toLowerCase().replace(/\s+/g, '_');
}
// Process features for Stripe format
const features = product.features;
const featuresString = Array.isArray(features)
? JSON.stringify(features)
: features || JSON.stringify([]);
// Convert highlight to string for metadata
const highlight = typeof product.highlight === 'boolean'
? product.highlight.toString()
: product.highlight || 'false';
// Prepare metadata including our custom fields
const metadata = {
...product.metadata,
key: product.key,
features: featuresString,
highlight,
};
return {
name: product.name,
description: product.description,
metadata,
};
}
/**
* Synchronize products with Stripe
*/
async syncProducts(products) {
const stripeProducts = products.filter((p) => p.provider === 'stripe');
const updatedProducts = [];
const otherProviderProducts = products.filter((p) => p.provider !== 'stripe');
console.log(`🚀 Syncing ${stripeProducts.length} products to Stripe...`);
if (stripeProducts.length === 0) {
console.log('No Stripe products to sync');
return products;
}
try {
// Fetch existing products with pagination support
const existingProducts = [];
let hasMore = true;
let startingAfter;
while (hasMore) {
const params = { limit: 100 };
if (startingAfter) {
params.starting_after = startingAfter;
}
const response = await this.client.products.list(params);
existingProducts.push(...response.data);
hasMore = response.has_more;
if (response.data.length > 0) {
startingAfter = response.data[response.data.length - 1].id;
}
else {
hasMore = false;
}
}
console.log(`📋 Found ${existingProducts.length} existing products in Stripe`);
for (const product of stripeProducts) {
const transactionId = `prod_${Date.now()}_${Math.floor(Math.random() * 1000)}`;
try {
const preparedProduct = this.prepareProduct(product);
const key = product.key || preparedProduct.metadata.key;
console.log(`📋 [${transactionId}] 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(`📋 [${transactionId}] Found existing product with key: ${key} (${existingProduct.id})`);
// Update existing product
const updated = await this.client.products.update(existingProduct.id, {
...preparedProduct,
active: true,
});
// Store the mapping
this.productIdMap.set(key, updated.id);
// Update product with Stripe ID
updatedProducts.push({
...product,
id: updated.id,
});
console.log(`✅ [${transactionId}] Updated product: ${product.name} (ID: ${updated.id})`);
}
else {
console.log(`📋 [${transactionId}] Creating new product with key: ${key}`);
// Create new product
const newProduct = await this.client.products.create(preparedProduct);
// Store the mapping
this.productIdMap.set(key, newProduct.id);
// Update product with Stripe ID
updatedProducts.push({
...product,
id: newProduct.id,
});
console.log(`✅ [${transactionId}] Created product: ${product.name} (ID: ${newProduct.id})`);
}
}
catch (error) {
const errorMessage = error?.message || 'Unknown error';
const errorType = error?.type || 'unknown_type';
const errorCode = error?.code || 'unknown_code';
console.error(`❌ [${transactionId}] Error syncing product ${product.name}: ${errorType}/${errorCode} - ${errorMessage}`);
// Mark this product as failed but preserve its data
updatedProducts.push({
...product,
metadata: {
...product.metadata,
syncError: `${errorType}/${errorCode}: ${errorMessage}`,
syncFailed: 'true'
}
});
}
}
// Wait for all product operations to complete
await new Promise(resolve => setTimeout(resolve, 500));
// Verify productIdMap has all expected keys
for (const product of stripeProducts) {
const key = product.key || product.name.toLowerCase().replace(/\s+/g, '_');
if (!this.productIdMap.has(key) && !product.id) {
console.warn(`⚠️ Missing product ID mapping for key: ${key}`);
}
}
return [...updatedProducts, ...otherProviderProducts];
}
catch (error) {
console.error(`❌ Fatal error in syncProducts: ${error?.message || error}`);
// In case of fatal error, return original products
return products;
}
}
/**
* Process prices before sending to Stripe
*/
preparePrice(price) {
let productId = price.productId;
// Use productKey to look up ID if available
if (price.productKey && !productId) {
productId = this.productIdMap.get(price.productKey);
if (!productId) {
throw new Error(`Could not find Stripe product ID for key: ${price.productKey}. ` +
`Product ID map contains ${this.productIdMap.size} entries. ` +
`Available keys: ${Array.from(this.productIdMap.keys()).join(', ')}`);
}
}
if (!productId) {
throw new Error(`No product ID or key specified for price: ${price.name}. ` +
`Either productId or productKey must be provided.`);
}
// Ensure key is present and unique
const key = price.key || `${price.name.toLowerCase().replace(/\s+/g, '_')}_${Date.now()}`;
// Validate required fields
if (!price.currency || price.currency.length !== 3) {
throw new Error(`Invalid currency for price ${price.name}: ${price.currency}. Must be 3-character ISO code.`);
}
if (typeof price.unitAmount !== 'number') {
throw new Error(`Invalid unit amount for price ${price.name}: ${price.unitAmount}. Must be a number.`);
}
if (price.type === 'recurring' && !price.recurring) {
throw new Error(`Price ${price.name} is marked as recurring but missing recurring configuration.`);
}
const priceData = {
product: productId,
nickname: price.nickname || price.name,
unit_amount: price.unitAmount,
currency: price.currency.toLowerCase(), // Ensure lowercase for consistency
metadata: {
...price.metadata,
key,
original_name: price.name, // Store original name for reference
created_at: new Date().toISOString(),
},
active: price.active !== false,
};
// Add tax behavior if specified
if (price.taxBehavior) {
priceData.tax_behavior = price.taxBehavior;
}
// Add billing scheme if specified
if (price.billingScheme) {
priceData.billing_scheme = price.billingScheme;
}
// Add recurring for subscription prices
if (price.type === 'recurring' && price.recurring) {
// Validate recurring configuration
if (!['day', 'week', 'month', 'year'].includes(price.recurring.interval)) {
throw new Error(`Invalid interval for recurring price ${price.name}: ${price.recurring.interval}. ` +
`Must be one of 'day', 'week', 'month', 'year'.`);
}
priceData.recurring = {
interval: price.recurring.interval,
interval_count: price.recurring.intervalCount || 1,
};
// Add trial period if specified
if (price.recurring.trialPeriodDays) {
if (price.recurring.trialPeriodDays < 1 || price.recurring.trialPeriodDays > 365) {
throw new Error(`Invalid trial period for price ${price.name}: ${price.recurring.trialPeriodDays}. ` +
`Must be between 1 and 365.`);
}
priceData.recurring.trial_period_days = price.recurring.trialPeriodDays;
}
// Add usage type if specified
if (price.recurring.usageType) {
// Validate usage type
if (!['licensed', 'metered'].includes(price.recurring.usageType)) {
throw new Error(`Invalid usage type for price ${price.name}: ${price.recurring.usageType}. ` +
`Must be either 'licensed' or 'metered'.`);
}
priceData.recurring.usage_type = price.recurring.usageType;
}
// Add aggregate usage if specified for metered prices
if (price.recurring.aggregateUsage) {
// Validate aggregate usage
if (!['sum', 'last_during_period', 'last_ever', 'max'].includes(price.recurring.aggregateUsage)) {
throw new Error(`Invalid aggregate usage for price ${price.name}: ${price.recurring.aggregateUsage}. ` +
`Must be one of 'sum', 'last_during_period', 'last_ever', 'max'.`);
}
// Ensure usage_type is metered when aggregate_usage is set
if (!price.recurring.usageType || price.recurring.usageType !== 'metered') {
throw new Error(`Aggregate usage specified for price ${price.name} but usage type is not 'metered'.`);
}
priceData.recurring.aggregate_usage = price.recurring.aggregateUsage;
}
}
return priceData;
}
/**
* Synchronize prices with Stripe
*/
/**
* Fetch all prices from Stripe
*/
async fetchPrices() {
console.log('📥 Fetching prices from Stripe...');
try {
// Ensure we have products first
if (this.productIdMap.size === 0) {
console.log('📊 Fetching products first to establish relationships...');
await this.fetchProducts();
}
// Fetch existing prices with pagination support
const stripePrices = [];
let hasMore = true;
let startingAfter;
while (hasMore) {
const params = { limit: 100, active: true };
if (startingAfter) {
params.starting_after = startingAfter;
}
const response = await this.client.prices.list(params);
stripePrices.push(...response.data);
hasMore = response.has_more;
if (response.data.length > 0) {
startingAfter = response.data[response.data.length - 1].id;
}
else {
hasMore = false;
}
}
console.log(`💰 Found ${stripePrices.length} prices in Stripe`);
// Create a reverse mapping from product ID to key
const productKeyMap = new Map();
this.productIdMap.forEach((id, key) => {
productKeyMap.set(id, key);
});
// Convert Stripe prices to our format
const prices = stripePrices.map(price => {
// Determine price type
const type = price.recurring ? 'recurring' : 'one_time';
// Build recurring configuration if needed
let recurring;
if (price.recurring) {
recurring = {
interval: price.recurring.interval,
intervalCount: price.recurring.interval_count !== null ? price.recurring.interval_count : 1,
usageType: price.recurring.usage_type,
aggregateUsage: price.recurring.aggregate_usage,
trialPeriodDays: price.recurring.trial_period_days !== null ? price.recurring.trial_period_days : undefined
};
}
// 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);
return {
id: price.id,
name: price.metadata?.original_name || price.nickname || 'Unnamed Price',
nickname: price.nickname || '',
unitAmount: price.unit_amount !== null ? price.unit_amount : 0,
currency: price.currency.toUpperCase(),
type,
recurring,
provider: 'stripe',
active: price.active,
key,
productId: price.product,
productKey,
taxBehavior: price.tax_behavior,
billingScheme: price.billing_scheme,
metadata: {
...price.metadata,
stripeCreated: price.created.toString()
}
};
});
return prices;
}
catch (error) {
console.error(`❌ Error fetching prices from Stripe: ${error?.message || error}`);
throw error;
}
}
async syncPrices(prices) {
const stripePrices = prices.filter((p) => p.provider === 'stripe');
const updatedPrices = [];
const otherProviderPrices = prices.filter((p) => p.provider !== 'stripe');
console.log(`💰 Syncing ${stripePrices.length} prices to Stripe...`);
if (stripePrices.length === 0) {
console.log('No Stripe prices to sync');
return prices;
}
// Verify product ID map is populated
if (this.productIdMap.size === 0 && stripePrices.some(p => p.productKey && !p.productId)) {
console.warn('⚠️ Product ID map is empty but prices reference products by key. This may cause errors.');
}
try {
// Fetch existing prices with pagination support
const existingPrices = [];
let hasMore = true;
let startingAfter;
while (hasMore) {
const params = { limit: 100 };
if (startingAfter) {
params.starting_after = startingAfter;
}
const response = await this.client.prices.list(params);
existingPrices.push(...response.data);
hasMore = response.has_more;
if (response.data.length > 0) {
startingAfter = response.data[response.data.length - 1].id;
}
else {
hasMore = false;
}
}
console.log(`💰 Found ${existingPrices.length} existing prices in Stripe`);
for (const price of stripePrices) {
const transactionId = `price_${Date.now()}_${Math.floor(Math.random() * 1000)}`;
try {
// Ensure the price has a valid key
const key = price.key || `${price.name.toLowerCase().replace(/\s+/g, '_')}_${Date.now()}`;
console.log(`💰 [${transactionId}] Processing price: ${price.name} (key: ${key})`);
// Resolve product ID early to catch errors
let productId = price.productId;
if (price.productKey && !productId) {
productId = this.productIdMap.get(price.productKey);
if (!productId) {
throw new Error(`Could not find Stripe product ID for key: ${price.productKey}. Ensure products are synced before prices.`);
}
console.log(`💰 [${transactionId}] 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(`💰 [${transactionId}] Found existing price with key: ${key} (${existingPrice.id})`);
// Enhanced price attribute matching
const priceMatchesConfig = existingPrice.unit_amount === price.unitAmount &&
existingPrice.currency.toLowerCase() === price.currency.toLowerCase() &&
(!price.recurring ||
(existingPrice.recurring?.interval === price.recurring.interval &&
existingPrice.recurring?.interval_count === price.recurring.intervalCount &&
(!price.recurring.trialPeriodDays ||
existingPrice.recurring?.trial_period_days === price.recurring.trialPeriodDays) &&
(!price.recurring.usageType ||
existingPrice.recurring?.usage_type === price.recurring.usageType) &&
(!price.recurring.aggregateUsage ||
existingPrice.recurring?.aggregate_usage === price.recurring.aggregateUsage))) &&
(!price.billingScheme || existingPrice.billing_scheme === price.billingScheme) &&
(!price.taxBehavior || existingPrice.tax_behavior === price.taxBehavior);
if (priceMatchesConfig) {
console.log(`✅ [${transactionId}] Existing price matches config, updating metadata if needed`);
// Update metadata
await this.client.prices.update(existingPrice.id, {
metadata: {
...price.metadata,
key,
},
active: price.active !== false,
});
// Update price with Stripe ID
updatedPrices.push({
...price,
id: existingPrice.id,
key,
});
}
else {
console.log(`⚠️ [${transactionId}] Existing price attributes don't match config, creating new version`);
// Log the differences for debugging
if (existingPrice.unit_amount !== price.unitAmount) {
console.log(` - Unit amount differs: ${existingPrice.unit_amount} vs ${price.unitAmount}`);
}
if (existingPrice.currency.toLowerCase() !== price.currency.toLowerCase()) {
console.log(` - Currency differs: ${existingPrice.currency} vs ${price.currency}`);
}
if (price.recurring && existingPrice.recurring) {
if (existingPrice.recurring.interval !== price.recurring.interval) {
console.log(` - Interval differs: ${existingPrice.recurring.interval} vs ${price.recurring.interval}`);
}
if (existingPrice.recurring.interval_count !== price.recurring.intervalCount) {
console.log(` - Interval count differs: ${existingPrice.recurring.interval_count} vs ${price.recurring.intervalCount}`);
}
}
// Create new price version
const priceData = this.preparePrice(price);
const newPrice = await this.client.prices.create(priceData);
// Deactivate old price
await this.client.prices.update(existingPrice.id, {
active: false,
metadata: {
...existingPrice.metadata,
replaced_by: newPrice.id,
},
});
// Update price with new Stripe ID
updatedPrices.push({
...price,
id: newPrice.id,
key,
});
console.log(`✅ [${transactionId}] Created new price version: ${price.name} (ID: ${newPrice.id})`);
}
}
else {
console.log(`💰 [${transactionId}] Creating new price with key: ${key}`);
// Create new price
const priceData = this.preparePrice({ ...price, key }); // Ensure key is set
const newPrice = await this.client.prices.create(priceData);
// Update price with new Stripe ID
updatedPrices.push({
...price,
id: newPrice.id,
key,
});
console.log(`✅ [${transactionId}] Created price: ${price.name} (ID: ${newPrice.id})`);
}
}
catch (error) {
const errorMessage = error?.message || 'Unknown error';
const errorType = error?.type || 'unknown_type';
const errorCode = error?.code || 'unknown_code';
console.error(`❌ [${transactionId}] Error syncing price ${price.name}: ${errorType}/${errorCode} - ${errorMessage}`);
// Mark this price as failed but preserve its data
updatedPrices.push({
...price,
metadata: {
...price.metadata,
syncError: `${errorType}/${errorCode}: ${errorMessage}`,
syncFailed: 'true'
}
});
}
}
// Wait for all price operations to complete
await new Promise(resolve => setTimeout(resolve, 500));
return [...updatedPrices, ...otherProviderPrices];
}
catch (error) {
console.error(`❌ Fatal error in syncPrices: ${error?.message || error}`);
// In case of fatal error, return original prices
return prices;
}
}
}