import Stripe from 'stripe'; import type { Order } from './orders'; function dbConfig(key: string): string { const envMap: Record = { stripe_secret_key: 'STRIPE_SECRET_KEY', stripe_publishable_key: 'STRIPE_PUBLISHABLE_KEY', stripe_webhook_secret: 'STRIPE_WEBHOOK_SECRET', btcpay_url: 'BTCPAY_URL', btcpay_api_key: 'BTCPAY_API_KEY', btcpay_store_id: 'BTCPAY_STORE_ID', }; const envVar = envMap[key]; const env = envVar ? process.env[envVar] : undefined; if (env) return env; try { const { getDb } = require('./db'); const row = getDb().query('SELECT value FROM content WHERE key = ?').get(key) as { value: string } | null; return row?.value || ''; } catch { return ''; } } function getStripe(): Stripe | null { const key = dbConfig('stripe_secret_key'); return key ? new Stripe(key) : null; } const stripeKey = process.env.STRIPE_SECRET_KEY; const stripe = stripeKey ? new Stripe(stripeKey) : null; // PAYMENT_MOCKS=1 forces mocks for all methods; otherwise an unconfigured // provider mocks only on a localhost site. a misconfigured production // provider throws instead of silently mocking. function forceMocks(): boolean { return process.env.PAYMENT_MOCKS === '1'; } function mocksEnabled(): boolean { if (forceMocks()) return true; return (process.env.SITE_URL || 'http://localhost:3000').includes('localhost'); } // --- BGN -> sats conversion (BGN is EUR-pegged at 1.95583) --- const EUR_BGN = 1.95583; let rateCache: { rate: number; at: number } | null = null; export async function btcBgnRate(): Promise { const override = parseFloat(process.env.BTC_BGN_RATE || ''); if (Number.isFinite(override) && override > 0) return override; if (rateCache && Date.now() - rateCache.at < 10 * 60 * 1000) return rateCache.rate; const resp = await fetch('https://api.kraken.com/0/public/Ticker?pair=XBTEUR'); if (!resp.ok) throw new Error('rate source unreachable'); const data = await resp.json() as any; const pair = Object.values(data.result || {})[0] as any; const eur = parseFloat(pair?.c?.[0]); if (!Number.isFinite(eur) || eur <= 0) throw new Error('bad rate data'); const rate = eur * EUR_BGN; rateCache = { rate, at: Date.now() }; return rate; } export interface PaymentResult { type: string; paymentId?: string; clientSecret?: string; publishableKey?: string; pr?: string; verify?: string; checkoutUrl?: string; message?: string; } export async function createPayment(order: Order): Promise { switch (order.payment_method) { case 'stripe': case 'applepay': case 'googlepay': return createStripePayment(order); case 'lightning': return createLightningPayment(order); case 'onchain': case 'btcpay': return createOnchainPayment(order); case 'bank': return { type: 'bank', message: 'Ще получите банковите данни за превод на посочения имейл.' }; case 'cod': return { type: 'cod', message: 'Плащате в брой при получаване на пратката.' }; default: return null; } } async function createStripePayment(order: Order): Promise { const s = stripe || getStripe(); if (forceMocks() || !s) { if (mocksEnabled()) { return { type: 'stripe', paymentId: 'pi_mock_' + order.id, clientSecret: 'pi_mock_secret', publishableKey: 'pk_mock' }; } throw new Error('Stripe not configured'); } const intent = await s.paymentIntents.create({ amount: order.total_cents, currency: 'eur', automatic_payment_methods: { enabled: true }, metadata: { order_id: order.id }, }); return { type: 'stripe', paymentId: intent.id, clientSecret: intent.client_secret || undefined, publishableKey: dbConfig('stripe_publishable_key') || process.env.STRIPE_PUBLISHABLE_KEY || '', }; } // LUD-16 lnurlp flow with LUD-21 verify when the receiver supports it async function createLightningPayment(order: Order): Promise { const lud16 = process.env.LUD16_ADDRESS || ''; const [username, domain] = lud16.split('@'); if (forceMocks() || !username || !domain) { if (mocksEnabled()) { return { type: 'lightning', paymentId: JSON.stringify({ pr: 'lnbc_mock_' + order.id, verify: '' }), pr: 'lnbc_mock_' + order.id }; } throw new Error('LUD16_ADDRESS not configured'); } const rate = await btcBgnRate(); const sats = Math.ceil((order.total_cents / 100) / rate * 1e8); const milliSats = sats * 1000; const lnurlpUrl = `https://${domain}/.well-known/lnurlp/${username}`; const resp = await fetch(lnurlpUrl); if (!resp.ok) throw new Error(`lnurlp endpoint unreachable: ${resp.status}`); const params = await resp.json() as any; if (params.status === 'ERROR') throw new Error(`lnurlp error: ${params.reason}`); if (milliSats < params.minSendable || milliSats > params.maxSendable) { throw new Error(`amount ${milliSats} msat outside receiver bounds [${params.minSendable}, ${params.maxSendable}]`); } const cb = new URL(params.callback); cb.searchParams.set('amount', String(milliSats)); const invoiceResp = await fetch(cb.toString()); if (!invoiceResp.ok) throw new Error(`lnurlp callback failed: ${invoiceResp.status}`); const invoice = await invoiceResp.json() as any; if (invoice.status === 'ERROR' || !invoice.pr) throw new Error(`lnurlp callback error: ${invoice.reason || 'no invoice'}`); return { type: 'lightning', paymentId: JSON.stringify({ pr: invoice.pr, verify: invoice.verify || '' }), pr: invoice.pr, verify: invoice.verify || '', }; } // LUD-21: poll the verify url; returns true once settled export async function checkLightningPaid(paymentId: string): Promise { try { const { verify } = JSON.parse(paymentId); if (!verify) return false; const resp = await fetch(verify); if (!resp.ok) return false; const data = await resp.json() as any; return data.settled === true; } catch { return false; } } async function createOnchainPayment(order: Order): Promise { const url = dbConfig('btcpay_url') || process.env.BTCPAY_URL; const apiKey = dbConfig('btcpay_api_key') || process.env.BTCPAY_API_KEY; const storeId = dbConfig('btcpay_store_id') || process.env.BTCPAY_STORE_ID; if (forceMocks() || !url || !apiKey || !storeId) { if (mocksEnabled()) { return { type: 'onchain', paymentId: JSON.stringify({ invoiceId: 'mock_' + order.id, checkoutUrl: 'https://mock.btcpay/i/' + order.id }), checkoutUrl: 'https://mock.btcpay/i/' + order.id }; } throw new Error('BTCPay not configured'); } const resp = await fetch(`${url}/api/v1/stores/${storeId}/invoices`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `token ${apiKey}` }, body: JSON.stringify({ amount: (order.total_cents / 100).toFixed(2), currency: 'BGN', metadata: { orderId: order.id }, checkout: { speedPolicy: 'MediumSpeed' }, }), }); if (!resp.ok) throw new Error(`BTCPay invoice creation failed: ${resp.status}`); const data = await resp.json() as any; return { type: 'onchain', paymentId: JSON.stringify({ invoiceId: data.id, checkoutUrl: data.checkoutLink }), checkoutUrl: data.checkoutLink, }; }