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