/** * 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ı'); const olay = JSON.parse(hamGovde); if (!olay || typeof olay !== 'object' || Array.isArray(olay) || typeof olay.type !== 'string' || !olay.type.trim()) throw new Error('Stripe webhook olay biçimi geçersiz'); return olay; } /** 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()), // Tanılama: fiyat kimliği sunucuya ulaştı mı? (anahtardan bağımsız — değer sızdırmaz) fiyatIdVar: Boolean(p.priceEnv && process.env[p.priceEnv]), fiyatIdVarYillik: Boolean(p.priceEnvYillik && process.env[p.priceEnvYillik]), })); } 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}`); if (typeof data.url !== 'string' || !/^https:\/\//.test(data.url) || typeof data.id !== 'string' || !data.id.trim()) throw new Error('Stripe checkout yanıtı geçersiz.'); return { url: data.url, id: data.id }; } /** Ayrılma nedeni seçenekleri — arayüz ve Stripe aynı listeyi kullanır. */ export const IPTAL_NEDENLERI = [ { id: 'too_expensive', label: 'Fiyat bütçemize uygun değil' }, { id: 'missing_features', label: 'İhtiyacım olan özellik yok' }, { id: 'switched_service', label: 'Başka bir hizmete geçtim' }, { id: 'unused', label: 'Yeterince kullanmıyorum' }, { id: 'too_complex', label: 'Kullanımı karmaşık geldi' }, { id: 'low_quality', label: 'Veri/sonuç kalitesi beklentimi karşılamadı' }, { id: 'customer_service', label: 'Destekten memnun kalmadım' }, { id: 'other', label: 'Diğer' }, ]; /** * Aboneliği dönem sonunda iptal eder (erişim ödenen sürenin sonuna kadar sürer) * ve ayrılma nedenini Stripe'a işler. */ export async function cancelSubscription({ subId, neden, aciklama }) { const key = process.env.STRIPE_SECRET_KEY; if (!key) throw new Error('Ödeme yapılandırılmamış (STRIPE_SECRET_KEY yok)'); if (!subId) throw new Error('İptal edilecek abonelik bulunamadı'); const govde = { cancel_at_period_end: 'true' }; if (IPTAL_NEDENLERI.some((n) => n.id === neden)) { govde['cancellation_details[feedback]'] = neden; } const not = String(aciklama || '').trim().slice(0, 500); if (not) govde['cancellation_details[comment]'] = not; const res = await fetch(`https://api.stripe.com/v1/subscriptions/${encodeURIComponent(subId)}`, { method: 'POST', headers: { Authorization: `Bearer ${key}`, 'Content-Type': 'application/x-www-form-urlencoded' }, body: formEncode(govde), }); const data = await res.json().catch(() => ({})); if (!res.ok) throw new Error(data?.error?.message || `Stripe hatası: HTTP ${res.status}`); return { id: data.id, donemSonunda: Boolean(data.cancel_at_period_end), bitis: data.cancel_at ? new Date(data.cancel_at * 1000).toISOString() : null, }; }