/** * Abonelik / ödeme — Stripe Checkout (zero-dependency, form-encoded REST). * * Env: * STRIPE_SECRET_KEY — sk_live_... / sk_test_... * STRIPE_PRICE_PRO — Pro planın Stripe Price ID'si (price_...) * STRIPE_PRICE_KURUMSAL — Kurumsal planın Price ID'si * APP_URL — başarı/iptal dönüş adresi (örn. https://ihaleist.seymata.com) * * Not: Abonelik hakkının kalıcı tanınması için Stripe webhook'u + kullanıcı * kaydı gerekir (bkz. /api/billing/webhook). Anahtar yoksa uçlar "yapılandırılmadı" * döner; uygulama ücretsiz katmanda çalışmaya devam eder. */ import crypto from 'node:crypto'; export const PLANS = [ { id: 'ucretsiz', ad: 'Ücretsiz', fiyat: 0, periyot: 'ay', ozet: 'İhaleleri keşfedin, takibe alın.', ozellikler: [ 'Canlı ihale + doğrudan temin listesi', 'Gelişmiş arama ve filtreler', 'Favori kurumlar', '10 ihale takibi', 'Tarayıcı bildirimleri', ], sinirlar: { takip: 10, ekip: 1 }, }, { id: 'pro', ad: 'Pro', fiyat: 899, periyot: 'ay', vurgu: true, rozet: 'En popüler', ozet: 'Teklif hazırlayan firmalar için tam analiz.', ozellikler: [ 'Ücretsiz plandaki her şey', 'Sınırsız ihale takibi', 'Kanban süreç yönetimi', 'Kazanan firma & rakip analizi', 'Kalem bazlı fiyat analizi', 'AI şartname analizi', 'PDF teklif cetveli (kaşe/imza)', 'E-posta bildirimleri', '5 ekip üyesi', ], sinirlar: { takip: null, ekip: 5 }, priceEnv: 'STRIPE_PRICE_PRO', priceEnvYillik: 'STRIPE_PRICE_PRO_YILLIK', fiyatYillik: 8954, }, { id: 'kurumsal', ad: 'Kurumsal', fiyat: 2490, periyot: 'ay', ozet: 'Çok kullanıcılı ekipler ve entegrasyon.', ozellikler: [ 'Pro plandaki her şey', 'Sınırsız ekip üyesi', 'Kırmızı bayrak & şeffaflık analizi', 'KİK kurul kararı eşleştirme', 'AB ihaleleri (TED) katmanı', 'API erişimi & webhook', 'Öncelikli destek', ], sinirlar: { takip: null, ekip: null }, priceEnv: 'STRIPE_PRICE_KURUMSAL', priceEnvYillik: 'STRIPE_PRICE_KURUMSAL_YILLIK', fiyatYillik: 24800, }, ]; export function billingConfigured() { const k = (process.env.STRIPE_SECRET_KEY || '').trim(); return k.startsWith('sk_') && k.length > 12; } export function webhookConfigured() { return Boolean((process.env.STRIPE_WEBHOOK_SECRET || '').trim().startsWith('whsec_')); } /** * Stripe webhook imzasını SDK olmadan doğrular. * Stripe-Signature: t=,v1= * imzalanan yük = `${t}.${hamGovde}` ; HMAC-SHA256(whsec) */ export function verifyWebhook(hamGovde, imzaBasligi, { tolerans = 300 } = {}) { const secret = (process.env.STRIPE_WEBHOOK_SECRET || '').trim(); if (!secret) throw new Error('STRIPE_WEBHOOK_SECRET tanımlı değil'); if (!imzaBasligi) throw new Error('Stripe-Signature başlığı yok'); const parcalar = Object.fromEntries( String(imzaBasligi).split(',').map((p) => { const i = p.indexOf('='); return [p.slice(0, i).trim(), p.slice(i + 1).trim()]; }) ); const t = parcalar.t; const v1 = parcalar.v1; if (!t || !v1) throw new Error('İmza başlığı biçimi geçersiz'); // Tekrar saldırısına karşı zaman penceresi const yas = Math.abs(Math.floor(Date.now() / 1000) - Number(t)); if (!Number.isFinite(yas) || yas > tolerans) throw new Error('İmza zaman aşımına uğradı'); const beklenen = crypto.createHmac('sha256', secret).update(`${t}.${hamGovde}`, 'utf8').digest('hex'); const a = Buffer.from(beklenen, 'utf8'); const b = Buffer.from(v1, 'utf8'); if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) throw new Error('İmza doğrulanamadı'); return JSON.parse(hamGovde); } /** Stripe Price ID → plan kimliği */ export function planForPrice(priceId) { if (!priceId) return null; for (const p of PLANS) { if (p.priceEnv && process.env[p.priceEnv] === priceId) return p.id; if (p.priceEnvYillik && process.env[p.priceEnvYillik] === priceId) return p.id; } return null; } export function planCatalog() { return PLANS.map((p) => ({ ...p, satinAlinabilir: Boolean(p.priceEnv && process.env[p.priceEnv] && billingConfigured()), satinAlinabilirYillik: Boolean(p.priceEnvYillik && process.env[p.priceEnvYillik] && billingConfigured()), })); } function formEncode(obj) { const parts = []; const walk = (prefix, val) => { if (val === undefined || val === null) return; if (Array.isArray(val)) val.forEach((v, i) => walk(`${prefix}[${i}]`, v)); else if (typeof val === 'object') Object.entries(val).forEach(([k, v]) => walk(`${prefix}[${k}]`, v)); else parts.push(`${encodeURIComponent(prefix)}=${encodeURIComponent(String(val))}`); }; Object.entries(obj).forEach(([k, v]) => walk(k, v)); return parts.join('&'); } export async function createCheckoutSession({ planId, email, periyot = 'yillik' }) { const key = process.env.STRIPE_SECRET_KEY; if (!key) throw new Error('Ödeme yapılandırılmamış (STRIPE_SECRET_KEY yok)'); const plan = PLANS.find((p) => p.id === planId); if (!plan || !plan.priceEnv) throw new Error('Geçersiz plan'); const yillik = periyot === 'yillik'; const env = yillik && plan.priceEnvYillik ? plan.priceEnvYillik : plan.priceEnv; const price = process.env[env]; if (!price) throw new Error(`${plan.ad} planı için ${env} tanımlı değil`); const appUrl = process.env.APP_URL || 'https://ihaleist.seymata.com'; const body = formEncode({ mode: 'subscription', 'line_items[0][price]': price, 'line_items[0][quantity]': 1, success_url: `${appUrl}/?odeme=basarili&plan=${planId}&periyot=${periyot}`, cancel_url: `${appUrl}/?odeme=iptal`, customer_email: email, allow_promotion_codes: 'true', client_reference_id: email || '', 'metadata[planId]': planId, 'metadata[periyot]': periyot, 'metadata[email]': email || '', }); const res = await fetch('https://api.stripe.com/v1/checkout/sessions', { method: 'POST', headers: { Authorization: `Bearer ${key}`, 'Content-Type': 'application/x-www-form-urlencoded', }, body, }); const data = await res.json().catch(() => ({})); if (!res.ok) throw new Error(data?.error?.message || `Stripe hatası: HTTP ${res.status}`); return { url: data.url, id: data.id }; }