weekly-repricing.js
1 #!/usr/bin/env node 2 3 /** 4 * Weekly Repricing Cron Job 5 * 6 * Fetches exchange rates from Fixer.io and updates country prices weekly. 7 * 8 * Pricing Formula: 9 * 1. Base: $300 USD 10 * 2. PPP Adjustment: Base × (Country PPP / US PPP) 11 * 3. FX Conversion: PPP-adjusted USD × Exchange Rate 12 * 4. Cultural Rounding: Apply culturally-appropriate rounding 13 * - Western markets (US, CA, AU, NZ, UK): Charm pricing (.97/.99) 14 * - German/Nordic (DE, NO, SE, DK, CH, AT): Round numbers 15 * - East Asia (CN, JP, KR): Lucky 8, avoid 4 and 7 16 * - India: Prefer endings in 1 (new beginnings) 17 * - Latin America (MX): Round numbers (stability) 18 * 19 * Example (Australia): 20 * - Base: $300 21 * - PPP: $300 × (64,674 / 80,412) = $241 22 * - FX: $241 × 1.52 = 366.87 AUD 23 * - Cultural Round: 337 AUD ✅ (charm pricing + lucky 7) 24 * 25 * Note: Countries with price_overridden=1 are skipped to preserve manual overrides. 26 * 27 * Schedule: Weekly (Sunday 2am) 28 * Crontab: 0 2 * * 0 cd /path/to/333Method && node src/cron/weekly-repricing.js 29 */ 30 31 import { getAll, run } from './../utils/db.js'; 32 import Logger from '../utils/logger.js'; 33 import '../utils/load-env.js'; 34 35 const logger = new Logger('WeeklyRepricing'); 36 const { FIXER_API_KEY } = process.env; 37 const BASE_USD_PRICE = 300.0; // $300 USD base price 38 const US_PPP = 80412; // US PPP GDP per capita (baseline) 39 40 /** 41 * Fetch exchange rates from Fixer.io 42 * @returns {Promise<Object>} Exchange rates (USD to other currencies) 43 */ 44 async function fetchExchangeRates() { 45 if (!FIXER_API_KEY) { 46 throw new Error('FIXER_API_KEY not set. Sign up at https://fixer.io/ and add to .env'); 47 } 48 49 const currencies = [ 50 'EUR', 51 'GBP', 52 'AUD', 53 'CAD', 54 'JPY', 55 'CNY', 56 'INR', 57 'KRW', 58 'MXN', 59 'IDR', 60 'CHF', 61 'PLN', 62 'SEK', 63 'NOK', 64 'DKK', 65 'SGD', 66 'NZD', 67 ]; 68 69 logger.info(`Fetching exchange rates from Fixer.io...`); 70 71 // Free tier uses data.fixer.io and only supports EUR as base 72 const response = await fetch( 73 `http://data.fixer.io/api/latest?access_key=${FIXER_API_KEY}&symbols=${currencies.join(',')},USD` 74 ); 75 76 if (!response.ok) { 77 throw new Error(`Fixer.io API error: HTTP ${response.status}`); 78 } 79 80 const data = await response.json(); 81 82 if (!data.success) { 83 throw new Error(`Fixer.io API error: ${data.error?.info || 'Unknown error'}`); 84 } 85 86 // Validate data freshness — Fixer.io free tier may return stale data 87 if (data.date) { 88 const rateDate = new Date(data.date); 89 const today = new Date(); 90 const diffDays = (today - rateDate) / (1000 * 60 * 60 * 24); 91 if (diffDays > 2) { 92 throw new Error( 93 `Fixer.io returned stale data (${data.date}, ${Math.round(diffDays)} days old). ` + 94 `Skipping repricing to avoid applying incorrect exchange rates.` 95 ); 96 } 97 } 98 99 // Free tier returns EUR-based rates, convert to USD-based 100 const eurToUsd = data.rates.USD; 101 const usdBasedRates = {}; 102 103 // Convert all EUR-based rates to USD-based rates 104 // Formula: USD -> Currency = (EUR -> Currency) / (EUR -> USD) 105 for (const currency of currencies) { 106 if (data.rates[currency]) { 107 usdBasedRates[currency] = data.rates[currency] / eurToUsd; 108 } 109 } 110 111 logger.success( 112 `Fetched rates for ${Object.keys(usdBasedRates).length} currencies (converted to USD base)` 113 ); 114 return usdBasedRates; 115 } 116 117 /** 118 * Apply PPP adjustment to base price 119 * @param {number} baseUsd - Base USD price 120 * @param {number} countryPPP - Country's PPP GDP per capita 121 * @param {number} usPPP - US PPP GDP per capita (baseline) 122 * @returns {number} PPP-adjusted USD price 123 */ 124 function applyPPPAdjustment(baseUsd, countryPPP, usPPP = US_PPP) { 125 return baseUsd * (countryPPP / usPPP); 126 } 127 128 /** 129 * Round price to nearest number ending in 7 130 * @param {number} price - Raw price 131 * @returns {number} Rounded price ending in 7 132 * @deprecated Use applyCulturalRounding() instead for culturally-appropriate pricing 133 */ 134 function roundToNearest7(price) { 135 // Get base 10 (e.g., 366.87 → 360) 136 const base10 = Math.floor(price / 10) * 10; 137 138 // Calculate options 139 const option7 = base10 + 7; 140 const nextOption7 = base10 + 17; 141 142 // Pick closest 143 const diffTo7 = Math.abs(price - option7); 144 const diffToNext7 = Math.abs(price - nextOption7); 145 146 return diffTo7 < diffToNext7 ? option7 : nextOption7; 147 } 148 149 /** 150 * Apply culturally-appropriate price rounding based on regional preferences 151 */ 152 function applyCulturalRounding(price, countryCode) { 153 // Western markets: Charm pricing (endings in 7 or 9) 154 const charmPricingMarkets = ['US', 'CA', 'AU', 'NZ', 'UK', 'FR', 'IT', 'ES', 'IE']; 155 if (charmPricingMarkets.includes(countryCode)) { 156 const base10 = Math.floor(price / 10) * 10; 157 const option7 = base10 + 7; 158 const option9 = base10 + 9; 159 const nextOption7 = base10 + 17; 160 161 const diffTo7 = Math.abs(price - option7); 162 const diffTo9 = Math.abs(price - option9); 163 const diffToNext7 = Math.abs(price - nextOption7); 164 165 if (diffTo7 <= diffTo9 && diffTo7 <= diffToNext7) return option7; 166 if (diffTo9 < diffToNext7) return option9; 167 return nextOption7; 168 } 169 170 // German/Nordic/Swiss/Austrian: Round numbers (efficiency, clarity) 171 const roundNumberMarkets = ['DE', 'NO', 'SE', 'DK', 'CH', 'AT']; 172 if (roundNumberMarkets.includes(countryCode)) { 173 if (price < 100) { 174 return Math.round(price / 10) * 10; 175 } else if (price < 1000) { 176 return Math.round(price / 50) * 50; 177 } else { 178 return Math.round(price / 100) * 100; 179 } 180 } 181 182 // East Asia (China, Japan, Korea): Lucky 8, avoid 4 and 7 183 if (countryCode === 'CN') { 184 if (price < 1000) { 185 const base100 = Math.floor(price / 100) * 100; 186 const option88 = base100 + 88; 187 const nextOption88 = base100 + 188; 188 const diffTo88 = Math.abs(price - option88); 189 const diffToNext88 = Math.abs(price - nextOption88); 190 191 if (diffTo88 < 20) return option88; 192 if (diffToNext88 < 20) return nextOption88; 193 194 const base10 = Math.floor(price / 10) * 10; 195 return base10 + 8; 196 } else { 197 const base10 = Math.floor(price / 10) * 10; 198 return base10 + 8; 199 } 200 } 201 202 if (countryCode === 'JP') { 203 if (price < 10000) { 204 return Math.round(price / 100) * 100; 205 } else { 206 return Math.round(price / 1000) * 1000; 207 } 208 } 209 210 if (countryCode === 'KR') { 211 const roundedThousand = Math.round(price / 1000) * 1000; 212 const asString = roundedThousand.toString(); 213 214 if (asString.includes('4') || asString.includes('7')) { 215 const nextThousand = roundedThousand + 1000; 216 return nextThousand; 217 } 218 219 return roundedThousand; 220 } 221 222 // India: Prefer endings in 1 (new beginnings, auspicious) 223 if (countryCode === 'IN') { 224 if (price < 1000) { 225 const base10 = Math.floor(price / 10) * 10; 226 return base10 + 1; 227 } else { 228 const base100 = Math.floor(price / 100) * 100; 229 return base100 + 1; 230 } 231 } 232 233 // Latin America (Mexico): Round numbers for stability 234 if (countryCode === 'MX') { 235 if (price < 1000) { 236 return Math.round(price / 100) * 100; 237 } else { 238 return Math.round(price / 500) * 500; 239 } 240 } 241 242 // Indonesia: Round to nearest 1000 for simplicity (large numbers) 243 if (countryCode === 'ID') { 244 return Math.round(price / 1000) * 1000; 245 } 246 247 // Poland: Charm pricing acceptable, prefer 7 (lucky), avoid 13 248 if (countryCode === 'PL') { 249 const base10 = Math.floor(price / 10) * 10; 250 const option7 = base10 + 7; 251 const nextOption7 = base10 + 17; 252 253 const hasThirteen = num => num.toString().includes('13'); 254 255 if (!hasThirteen(option7)) return option7; 256 if (!hasThirteen(nextOption7)) return nextOption7; 257 258 return base10 + 9; 259 } 260 261 // Default: Use charm pricing with 7 (safest universal approach) 262 return roundToNearest7(price); 263 } 264 265 /** 266 * Update country pricing with fresh exchange rates 267 * @param {Object} rates - Exchange rates from Fixer.io 268 */ 269 async function updateCountryPricing(rates) { 270 logger.info('Updating country pricing...'); 271 272 // Get all active countries (skip manually overridden prices) 273 const countries = await getAll(` 274 SELECT country_code, currency_code, ppp_gdp_per_capita, variant_multiplier, price_overridden 275 FROM countries 276 WHERE is_active = true 277 `); 278 279 let updated = 0; 280 let skipped = 0; 281 let errors = 0; 282 283 for (const country of countries) { 284 try { 285 // Skip countries with manual price overrides (preserve cultural optimizations) 286 if (country.price_overridden) { 287 logger.info( 288 `${country.country_code}: Skipping (price manually overridden for cultural optimization)` 289 ); 290 skipped++; 291 continue; 292 } 293 294 // Step 1: PPP adjustment 295 const pppAdjusted = applyPPPAdjustment(BASE_USD_PRICE, country.ppp_gdp_per_capita); 296 297 // Step 2: Apply variant multiplier (for split testing) 298 const variantAdjusted = pppAdjusted * country.variant_multiplier; 299 300 // Step 3: Convert to local currency 301 const rate = country.currency_code === 'USD' ? 1.0 : rates[country.currency_code]; 302 303 if (!rate) { 304 logger.warn( 305 `No exchange rate for ${country.currency_code}, skipping ${country.country_code}` 306 ); 307 errors++; 308 continue; 309 } 310 311 const localPrice = variantAdjusted * rate; 312 313 // Step 4: Apply culturally-appropriate rounding 314 const rounded = applyCulturalRounding(localPrice, country.country_code); 315 316 await run( 317 `UPDATE countries 318 SET 319 price_usd_base = $1, 320 price_usd_ppp = $2, 321 price_local = $3, 322 price_local_formatted = $4, 323 exchange_rate = $5, 324 price_last_updated = CURRENT_TIMESTAMP 325 WHERE country_code = $6`, 326 [ 327 Math.round(BASE_USD_PRICE * 100), // base USD in cents 328 Math.round(pppAdjusted * 100), // PPP-adjusted USD in cents 329 Math.round(rounded * 100), // Local in cents 330 rounded.toString(), // Formatted for display 331 rate, 332 country.country_code, 333 ] 334 ); 335 336 logger.success( 337 `${country.country_code}: ${rounded} ${country.currency_code} (PPP: $${pppAdjusted.toFixed(2)}, Rate: ${rate.toFixed(4)})` 338 ); 339 updated++; 340 } catch (error) { 341 logger.error(`Error updating ${country.country_code}:`, error); 342 errors++; 343 } 344 } 345 346 logger.info(`Updated ${updated} countries, skipped ${skipped} overridden, ${errors} errors`); 347 } 348 349 // ── PayPal subscription plan sync ────────────────────────────────────────── 350 351 // 2Step subscription tier multipliers relative to 333Method base price 352 const TWOSTEP_TIERS = { 353 starter: { multiplier: 0.41, label: 'Starter (4/mo)' }, 354 growth: { multiplier: 0.74, label: 'Growth (8/mo)' }, 355 scale: { multiplier: 1.04, label: 'Scale (12/mo)' }, 356 }; 357 358 // PayPal product IDs (created once, never change) 359 const PAYPAL_PRODUCTS = { 360 live: 'PROD-3B763174TA939220P', 361 sandbox: 'PROD-3V559137HL182344V', 362 }; 363 364 /** 365 * Get a PayPal OAuth2 access token. 366 */ 367 async function getPayPalToken(sandbox = false) { 368 const base = sandbox ? 'https://api-m.sandbox.paypal.com' : 'https://api-m.paypal.com'; 369 const clientId = sandbox ? process.env.PAYPAL_SANDBOX_CLIENT_ID : process.env.PAYPAL_CLIENT_ID; 370 const secret = sandbox ? process.env.PAYPAL_SANDBOX_CLIENT_SECRET : process.env.PAYPAL_CLIENT_SECRET; 371 372 if (!clientId || !secret) { 373 throw new Error(`PayPal ${sandbox ? 'sandbox' : 'live'} credentials not set`); 374 } 375 376 const res = await fetch(`${base}/v1/oauth2/token`, { 377 method: 'POST', 378 headers: { 379 'Authorization': `Basic ${Buffer.from(`${clientId}:${secret}`).toString('base64')}`, 380 'Content-Type': 'application/x-www-form-urlencoded', 381 }, 382 body: 'grant_type=client_credentials', 383 }); 384 385 const data = await res.json(); 386 if (!data.access_token) throw new Error('PayPal auth failed'); 387 return { token: data.access_token, base }; 388 } 389 390 /** 391 * List all existing plans for our product to avoid duplicates. 392 */ 393 async function listPayPalPlans(token, base, productId) { 394 const plans = []; 395 let page = 1; 396 let hasMore = true; 397 398 while (hasMore) { 399 const res = await fetch( 400 `${base}/v1/billing/plans?product_id=${productId}&page_size=20&page=${page}`, 401 { headers: { 'Authorization': `Bearer ${token}` } } 402 ); 403 const data = await res.json(); 404 if (data.plans) plans.push(...data.plans); 405 hasMore = data.plans?.length === 20; 406 page++; 407 } 408 409 return plans; 410 } 411 412 /** 413 * Update pricing on an existing PayPal plan. 414 */ 415 async function updatePlanPricing(token, base, planId, price, currency) { 416 const res = await fetch(`${base}/v1/billing/plans/${planId}/update-pricing-schemes`, { 417 method: 'POST', 418 headers: { 419 'Authorization': `Bearer ${token}`, 420 'Content-Type': 'application/json', 421 }, 422 body: JSON.stringify({ 423 pricing_schemes: [{ 424 billing_cycle_sequence: 1, 425 pricing_scheme: { 426 fixed_price: { value: String(price), currency_code: currency }, 427 }, 428 }], 429 }), 430 }); 431 432 if (!res.ok) { 433 const err = await res.text(); 434 throw new Error(`Update plan ${planId} failed ${res.status}: ${err}`); 435 } 436 } 437 438 /** 439 * Create a new PayPal subscription plan. 440 */ 441 async function createPayPalPlan(token, base, productId, name, price, currency) { 442 const res = await fetch(`${base}/v1/billing/plans`, { 443 method: 'POST', 444 headers: { 445 'Authorization': `Bearer ${token}`, 446 'Content-Type': 'application/json', 447 }, 448 body: JSON.stringify({ 449 product_id: productId, 450 name, 451 billing_cycles: [{ 452 frequency: { interval_unit: 'MONTH', interval_count: 1 }, 453 tenure_type: 'REGULAR', 454 sequence: 1, 455 total_cycles: 0, 456 pricing_scheme: { fixed_price: { value: String(price), currency_code: currency } }, 457 }], 458 payment_preferences: { 459 auto_bill_outstanding: true, 460 setup_fee_failure_action: 'CONTINUE', 461 payment_failure_threshold: 3, 462 }, 463 }), 464 }); 465 466 const data = await res.json(); 467 if (!data.id) throw new Error(`Create plan failed: ${JSON.stringify(data)}`); 468 return data.id; 469 } 470 471 /** 472 * Sync 2Step subscription plan pricing to PayPal. 473 */ 474 async function sync2StepPayPalPlans(rates) { 475 logger.info('Syncing 2Step subscription plans to PayPal...'); 476 477 // Get active countries with their repriced local values 478 const countries = await getAll(` 479 SELECT country_code, currency_code, price_local, ppp_gdp_per_capita 480 FROM countries WHERE is_active = true 481 `); 482 483 // Only sync countries we have currency support for in PayPal 484 const paypalCurrencies = new Set([ 485 'AUD', 'USD', 'GBP', 'CAD', 'NZD', 'EUR', 'CHF', 'SEK', 'NOK', 'DKK', 486 'PLN', 'SGD', 'JPY', 'CNY', 'INR', 'KRW', 'MXN', 487 ]); 488 489 for (const env of ['sandbox', 'live']) { 490 const sandbox = env === 'sandbox'; 491 let token, base; 492 try { 493 ({ token, base } = await getPayPalToken(sandbox)); 494 } catch (e) { 495 logger.warn(`Skipping PayPal ${env}: ${e.message}`); 496 continue; 497 } 498 499 const productId = sandbox ? PAYPAL_PRODUCTS.sandbox : PAYPAL_PRODUCTS.live; 500 const existingPlans = await listPayPalPlans(token, base, productId); 501 502 // Index existing plans by name for matching 503 const plansByName = {}; 504 for (const p of existingPlans) { 505 plansByName[p.name] = p; 506 } 507 508 let updated = 0; 509 let created = 0; 510 511 for (const country of countries) { 512 if (!paypalCurrencies.has(country.currency_code)) continue; 513 const cc = country.country_code; 514 const cur = country.currency_code; 515 516 // Calculate 333Method base price in local currency (from DB, already repriced) 517 const baseLocal = country.price_local / 100; // stored in cents 518 519 for (const [tier, config] of Object.entries(TWOSTEP_TIERS)) { 520 const rawPrice = baseLocal * config.multiplier; 521 const price = applyCulturalRounding(rawPrice, cc); 522 const planName = `Video Reviews — ${config.label} ${cc}`; 523 524 const existing = plansByName[planName]; 525 526 if (existing) { 527 try { 528 await updatePlanPricing(token, base, existing.id, price, cur); 529 logger.info(` ${env} ${planName}: ${price} ${cur} (updated)`); 530 updated++; 531 } catch (e) { 532 logger.warn(` ${env} ${planName}: update failed — ${e.message}`); 533 } 534 } else { 535 try { 536 const planId = await createPayPalPlan(token, base, productId, planName, price, cur); 537 logger.info(` ${env} ${planName}: ${price} ${cur} (created ${planId})`); 538 created++; 539 } catch (e) { 540 logger.warn(` ${env} ${planName}: create failed — ${e.message}`); 541 } 542 } 543 } 544 } 545 546 logger.success(`PayPal ${env}: ${updated} plans updated, ${created} plans created`); 547 } 548 } 549 550 /** 551 * Main function 552 */ 553 async function main() { 554 logger.info('Starting weekly repricing...'); 555 556 // Fetch exchange rates 557 const rates = await fetchExchangeRates(); 558 559 // Update country pricing 560 await updateCountryPricing(rates); 561 562 // Export updated pricing to CF Worker for auditandfix.com 563 try { 564 const { exportPricing } = await import('../api/pricing-export.js'); 565 await exportPricing(); 566 } catch (exportError) { 567 logger.warn(`Pricing export to CF Worker failed: ${exportError.message}`); 568 } 569 570 // Sync 2Step subscription plan pricing to PayPal 571 try { 572 await sync2StepPayPalPlans(rates); 573 } catch (paypalError) { 574 logger.warn(`PayPal plan sync failed: ${paypalError.message}`); 575 } 576 577 logger.success('Weekly repricing completed successfully'); 578 579 // Show summary 580 const summary = await getAll(` 581 SELECT 582 pricing_tier, 583 COUNT(*) as count, 584 MIN(price_local / 100.0) as min_price, 585 MAX(price_local / 100.0) as max_price, 586 AVG(price_local / 100.0) as avg_price 587 FROM countries 588 WHERE is_active = true 589 GROUP BY pricing_tier 590 ORDER BY avg_price DESC 591 `); 592 593 logger.info('Pricing Summary by Tier:'); 594 summary.forEach(tier => { 595 logger.info( 596 ` ${tier.pricing_tier}: ${tier.count} countries, ${Number(tier.min_price).toFixed(0)}-${Number(tier.max_price).toFixed(0)} avg ${Number(tier.avg_price).toFixed(0)}` 597 ); 598 }); 599 600 return 0; 601 } 602 603 // Run if called directly 604 if (import.meta.url === `file://${process.argv[1]}`) { 605 main() 606 .then(code => process.exit(code)) 607 .catch(error => { 608 console.error('Fatal error:', error); 609 process.exit(1); 610 }); 611 } 612 613 export { 614 fetchExchangeRates, 615 updateCountryPricing, 616 applyPPPAdjustment, 617 roundToNearest7, 618 applyCulturalRounding, 619 };