payments.ts raw

   1  import Stripe from 'stripe';
   2  import type { Order } from './orders';
   3  
   4  function dbConfig(key: string): string {
   5    const envMap: Record<string, string> = {
   6      stripe_secret_key: 'STRIPE_SECRET_KEY',
   7      stripe_publishable_key: 'STRIPE_PUBLISHABLE_KEY',
   8      stripe_webhook_secret: 'STRIPE_WEBHOOK_SECRET',
   9      btcpay_url: 'BTCPAY_URL',
  10      btcpay_api_key: 'BTCPAY_API_KEY',
  11      btcpay_store_id: 'BTCPAY_STORE_ID',
  12    };
  13    const envVar = envMap[key];
  14    const env = envVar ? process.env[envVar] : undefined;
  15    if (env) return env;
  16    try {
  17      const { getDb } = require('./db');
  18      const row = getDb().query('SELECT value FROM content WHERE key = ?').get(key) as { value: string } | null;
  19      return row?.value || '';
  20    } catch { return ''; }
  21  }
  22  
  23  function getStripe(): Stripe | null {
  24    const key = dbConfig('stripe_secret_key');
  25    return key ? new Stripe(key) : null;
  26  }
  27  
  28  const stripeKey = process.env.STRIPE_SECRET_KEY;
  29  const stripe = stripeKey ? new Stripe(stripeKey) : null;
  30  
  31  // PAYMENT_MOCKS=1 forces mocks for all methods; otherwise an unconfigured
  32  // provider mocks only on a localhost site. a misconfigured production
  33  // provider throws instead of silently mocking.
  34  function forceMocks(): boolean {
  35    return process.env.PAYMENT_MOCKS === '1';
  36  }
  37  function mocksEnabled(): boolean {
  38    if (forceMocks()) return true;
  39    return (process.env.SITE_URL || 'http://localhost:3000').includes('localhost');
  40  }
  41  
  42  // --- BGN -> sats conversion (BGN is EUR-pegged at 1.95583) ---
  43  const EUR_BGN = 1.95583;
  44  let rateCache: { rate: number; at: number } | null = null;
  45  
  46  export async function btcBgnRate(): Promise<number> {
  47    const override = parseFloat(process.env.BTC_BGN_RATE || '');
  48    if (Number.isFinite(override) && override > 0) return override;
  49    if (rateCache && Date.now() - rateCache.at < 10 * 60 * 1000) return rateCache.rate;
  50    const resp = await fetch('https://api.kraken.com/0/public/Ticker?pair=XBTEUR');
  51    if (!resp.ok) throw new Error('rate source unreachable');
  52    const data = await resp.json() as any;
  53    const pair = Object.values(data.result || {})[0] as any;
  54    const eur = parseFloat(pair?.c?.[0]);
  55    if (!Number.isFinite(eur) || eur <= 0) throw new Error('bad rate data');
  56    const rate = eur * EUR_BGN;
  57    rateCache = { rate, at: Date.now() };
  58    return rate;
  59  }
  60  
  61  export interface PaymentResult {
  62    type: string;
  63    paymentId?: string;
  64    clientSecret?: string;
  65    publishableKey?: string;
  66    pr?: string;
  67    verify?: string;
  68    checkoutUrl?: string;
  69    message?: string;
  70  }
  71  
  72  export async function createPayment(order: Order): Promise<PaymentResult | null> {
  73    switch (order.payment_method) {
  74      case 'stripe':
  75      case 'applepay':
  76      case 'googlepay': return createStripePayment(order);
  77      case 'lightning': return createLightningPayment(order);
  78      case 'onchain':
  79      case 'btcpay': return createOnchainPayment(order);
  80      case 'bank': return { type: 'bank', message: 'Ще получите банковите данни за превод на посочения имейл.' };
  81      case 'cod': return { type: 'cod', message: 'Плащате в брой при получаване на пратката.' };
  82      default: return null;
  83    }
  84  }
  85  
  86  async function createStripePayment(order: Order): Promise<PaymentResult> {
  87    const s = stripe || getStripe();
  88    if (forceMocks() || !s) {
  89      if (mocksEnabled()) {
  90        return { type: 'stripe', paymentId: 'pi_mock_' + order.id, clientSecret: 'pi_mock_secret', publishableKey: 'pk_mock' };
  91      }
  92      throw new Error('Stripe not configured');
  93    }
  94    const intent = await s.paymentIntents.create({
  95      amount: order.total_cents,
  96      currency: 'eur',
  97      automatic_payment_methods: { enabled: true },
  98      metadata: { order_id: order.id },
  99    });
 100    return {
 101      type: 'stripe',
 102      paymentId: intent.id,
 103      clientSecret: intent.client_secret || undefined,
 104      publishableKey: dbConfig('stripe_publishable_key') || process.env.STRIPE_PUBLISHABLE_KEY || '',
 105    };
 106  }
 107  
 108  // LUD-16 lnurlp flow with LUD-21 verify when the receiver supports it
 109  async function createLightningPayment(order: Order): Promise<PaymentResult> {
 110    const lud16 = process.env.LUD16_ADDRESS || '';
 111    const [username, domain] = lud16.split('@');
 112    if (forceMocks() || !username || !domain) {
 113      if (mocksEnabled()) {
 114        return { type: 'lightning', paymentId: JSON.stringify({ pr: 'lnbc_mock_' + order.id, verify: '' }), pr: 'lnbc_mock_' + order.id };
 115      }
 116      throw new Error('LUD16_ADDRESS not configured');
 117    }
 118  
 119    const rate = await btcBgnRate();
 120    const sats = Math.ceil((order.total_cents / 100) / rate * 1e8);
 121    const milliSats = sats * 1000;
 122  
 123    const lnurlpUrl = `https://${domain}/.well-known/lnurlp/${username}`;
 124    const resp = await fetch(lnurlpUrl);
 125    if (!resp.ok) throw new Error(`lnurlp endpoint unreachable: ${resp.status}`);
 126    const params = await resp.json() as any;
 127    if (params.status === 'ERROR') throw new Error(`lnurlp error: ${params.reason}`);
 128    if (milliSats < params.minSendable || milliSats > params.maxSendable) {
 129      throw new Error(`amount ${milliSats} msat outside receiver bounds [${params.minSendable}, ${params.maxSendable}]`);
 130    }
 131  
 132    const cb = new URL(params.callback);
 133    cb.searchParams.set('amount', String(milliSats));
 134    const invoiceResp = await fetch(cb.toString());
 135    if (!invoiceResp.ok) throw new Error(`lnurlp callback failed: ${invoiceResp.status}`);
 136    const invoice = await invoiceResp.json() as any;
 137    if (invoice.status === 'ERROR' || !invoice.pr) throw new Error(`lnurlp callback error: ${invoice.reason || 'no invoice'}`);
 138  
 139    return {
 140      type: 'lightning',
 141      paymentId: JSON.stringify({ pr: invoice.pr, verify: invoice.verify || '' }),
 142      pr: invoice.pr,
 143      verify: invoice.verify || '',
 144    };
 145  }
 146  
 147  // LUD-21: poll the verify url; returns true once settled
 148  export async function checkLightningPaid(paymentId: string): Promise<boolean> {
 149    try {
 150      const { verify } = JSON.parse(paymentId);
 151      if (!verify) return false;
 152      const resp = await fetch(verify);
 153      if (!resp.ok) return false;
 154      const data = await resp.json() as any;
 155      return data.settled === true;
 156    } catch {
 157      return false;
 158    }
 159  }
 160  
 161  async function createOnchainPayment(order: Order): Promise<PaymentResult> {
 162    const url = dbConfig('btcpay_url') || process.env.BTCPAY_URL;
 163    const apiKey = dbConfig('btcpay_api_key') || process.env.BTCPAY_API_KEY;
 164    const storeId = dbConfig('btcpay_store_id') || process.env.BTCPAY_STORE_ID;
 165  
 166    if (forceMocks() || !url || !apiKey || !storeId) {
 167      if (mocksEnabled()) {
 168        return { type: 'onchain', paymentId: JSON.stringify({ invoiceId: 'mock_' + order.id, checkoutUrl: 'https://mock.btcpay/i/' + order.id }), checkoutUrl: 'https://mock.btcpay/i/' + order.id };
 169      }
 170      throw new Error('BTCPay not configured');
 171    }
 172  
 173    const resp = await fetch(`${url}/api/v1/stores/${storeId}/invoices`, {
 174      method: 'POST',
 175      headers: { 'Content-Type': 'application/json', 'Authorization': `token ${apiKey}` },
 176      body: JSON.stringify({
 177        amount: (order.total_cents / 100).toFixed(2),
 178        currency: 'BGN',
 179        metadata: { orderId: order.id },
 180        checkout: { speedPolicy: 'MediumSpeed' },
 181      }),
 182    });
 183    if (!resp.ok) throw new Error(`BTCPay invoice creation failed: ${resp.status}`);
 184    const data = await resp.json() as any;
 185  
 186    return {
 187      type: 'onchain',
 188      paymentId: JSON.stringify({ invoiceId: data.id, checkoutUrl: data.checkoutLink }),
 189      checkoutUrl: data.checkoutLink,
 190    };
 191  }
 192