Files
ihaleist/server/billing.mjs
T

237 lines
8.9 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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=<zaman>,v1=<hmac>
* 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,
};
}