243 lines
9.3 KiB
JavaScript
243 lines
9.3 KiB
JavaScript
/**
|
||
* 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}`);
|
||
if (typeof data.id !== 'string' || !data.id.trim() || typeof data.status !== 'string' || !data.status.trim())
|
||
throw new Error('Stripe abonelik iptal yanıtı geçersiz.');
|
||
if (typeof data.cancel_at_period_end !== 'boolean') throw new Error('Stripe iptal durumu geçersiz.');
|
||
if (data.cancel_at != null && (!Number.isSafeInteger(data.cancel_at) || data.cancel_at <= 0))
|
||
throw new Error('Stripe iptal tarihi geçersiz.');
|
||
return {
|
||
id: data.id,
|
||
status: data.status,
|
||
donemSonunda: Boolean(data.cancel_at_period_end),
|
||
bitis: data.cancel_at ? new Date(data.cancel_at * 1000).toISOString() : null,
|
||
};
|
||
}
|