diff --git a/server/billing.mjs b/server/billing.mjs index 3c4b41e..49ea83a 100644 --- a/server/billing.mjs +++ b/server/billing.mjs @@ -187,3 +187,45 @@ export async function createCheckoutSession({ planId, email, periyot = 'yillik' if (!res.ok) throw new Error(data?.error?.message || `Stripe hatası: HTTP ${res.status}`); 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, + }; +} diff --git a/server/entitlements.mjs b/server/entitlements.mjs index 6325f64..ddbb8d3 100644 --- a/server/entitlements.mjs +++ b/server/entitlements.mjs @@ -62,9 +62,19 @@ export function kullanim(email, opts) { const belge = documentUsage(email); const ekip = (getData(email, 'ekip') || []).length; const takip = (getData(email, 'kanban') || []).filter?.((t) => t?.takipte)?.length ?? null; + // Yönetici hesapları her zaman tam erişimlidir; bu, satın alınan aboneliği + // GİZLEMEMELİ — arayüz ikisini ayrı gösterebilsin diye ikisini de döndürüyoruz. + const u = getUser(email); + const abonelik = u && PLAN_HAKLARI[u.plan] && !(u.plan_bitis && new Date(u.plan_bitis) < new Date()) + ? u.plan : 'ucretsiz'; return { plan: planOf(email, opts), planAdi: h.ad, + yonetici: Boolean(opts?.isAdmin), + abonelik, // gerçekten satın alınan plan + abonelikAdi: PLAN_HAKLARI[abonelik]?.ad || 'Ücretsiz', + abonelikVar: abonelik !== 'ucretsiz', + iptalEdilebilir: Boolean(u?.stripe_sub_id) && abonelik !== 'ucretsiz', haklar: h, kullanilan: { belgeAdet: belge.adet, diff --git a/server/index.js b/server/index.js index 7888e4a..721f3af 100644 --- a/server/index.js +++ b/server/index.js @@ -20,7 +20,7 @@ import { readDtTenders } from './dt-store.mjs'; import { loginWithGoogle, userFromAuthHeader } from './auth.mjs'; import { emailConfigured, sendEmail, tenderDigestHtml, emailFrom } from './email.mjs'; import { aiConfigured, availableProviders, analyzeSpec } from './ai.mjs'; -import { billingConfigured, webhookConfigured, planCatalog, createCheckoutSession, verifyWebhook, planForPrice } from './billing.mjs'; +import { billingConfigured, webhookConfigured, planCatalog, createCheckoutSession, verifyWebhook, planForPrice, cancelSubscription, IPTAL_NEDENLERI } from './billing.mjs'; import * as store from './user-store.mjs'; import { planOf, haklar, kullanim, kotaKontrol, ozellikVar } from './entitlements.mjs'; import { searchTed } from './ted.mjs'; @@ -679,6 +679,28 @@ const server = http.createServer(async (req, res) => { // --- Stripe webhook (imza doğrulamalı) --- // NOT: Bu uç kimlik doğrulaması İSTEMEZ; güvenlik imza doğrulamasıyla sağlanır. + if (url.pathname === '/api/billing/cancel' && req.method === 'POST') { + const user = userFromAuthHeader(req.headers['authorization']); + if (!user) return json(res, 401, { success: false, error: 'oturum yok' }); + const kayit = store.getUser(user.email); + const subId = kayit?.stripe_sub_id; + if (!subId) return json(res, 400, { success: false, error: 'Aktif bir aboneliğiniz görünmüyor.' }); + const body = await readJson(req); + try { + const out = await cancelSubscription({ subId, neden: body.neden, aciklama: body.aciklama }); + // Neden kaydı: ürün kararları için kendi tarafımızda da tutulur. + store.setData(user.email, 'iptal_geri_bildirim', { + neden: body.neden || null, + aciklama: String(body.aciklama || '').slice(0, 500), + tarih: new Date().toISOString(), + }); + console.log(`[billing] ${user.email} aboneliğini iptal etti (neden: ${body.neden || 'belirtilmedi'})`); + return json(res, 200, { success: true, ...out }); + } catch (e) { + return json(res, 502, { success: false, error: e.message }); + } + } + if (url.pathname === '/api/billing/webhook' && req.method === 'POST') { if (!webhookConfigured()) { return json(res, 503, { success: false, error: 'STRIPE_WEBHOOK_SECRET tanımlı değil' }); @@ -750,7 +772,7 @@ const server = http.createServer(async (req, res) => { // --- Abonelik / ödeme --- if (url.pathname === '/api/billing/plans') { - return json(res, 200, { configured: billingConfigured(), webhookHazir: webhookConfigured(), plans: planCatalog() }); + return json(res, 200, { configured: billingConfigured(), webhookHazir: webhookConfigured(), plans: planCatalog(), iptalNedenleri: IPTAL_NEDENLERI }); } if (url.pathname === '/api/billing/checkout' && req.method === 'POST') { const user = userFromAuthHeader(req.headers['authorization']); diff --git a/server/user-store.mjs b/server/user-store.mjs index ac0adbf..32f0976 100644 --- a/server/user-store.mjs +++ b/server/user-store.mjs @@ -142,6 +142,7 @@ export function findByStripe({ customerId, subId }) { const IZINLI_ANAHTARLAR = new Set([ 'favori_kurumlar', 'ekip', 'saved_filters', 'reminder', 'rakip_izleme', 'ciro_hedefi', 'ai_model_config', 'kanban', + 'iptal_geri_bildirim', ]); export function getData(email, anahtar) { diff --git a/src/components/CancelSubscriptionModal.jsx b/src/components/CancelSubscriptionModal.jsx new file mode 100644 index 0000000..71f1bef --- /dev/null +++ b/src/components/CancelSubscriptionModal.jsx @@ -0,0 +1,119 @@ +import React, { useState } from 'react'; +import { X, AlertTriangle, Loader2 } from 'lucide-react'; +import { cancelSubscription } from '../services/account'; + +/** + * Abonelik iptali — nedeni sorar, dönem sonunda iptal eder. + * Erişim ödenen sürenin sonuna kadar sürer; anında kesilmez. + */ +export const CancelSubscriptionModal = ({ planAdi, nedenler = [], onKapat, onIptalEdildi }) => { + const [neden, setNeden] = useState(''); + const [aciklama, setAciklama] = useState(''); + const [busy, setBusy] = useState(false); + const [hata, setHata] = useState(null); + + // "Diğer" seçildiyse açıklama zorunlu — yoksa geri bildirim işe yaramaz. + const digerSecili = neden === 'other'; + const gonderilebilir = Boolean(neden) && (!digerSecili || aciklama.trim().length >= 3); + + const gonder = async () => { + setBusy(true); + setHata(null); + try { + const out = await cancelSubscription(neden, aciklama.trim()); + onIptalEdildi(out); + } catch (e) { + setHata(e.message || 'İptal edilemedi'); + } finally { + setBusy(false); + } + }; + + return ( +
+
+
+
+
+ +
+
+

Aboneliği iptal et

+

+ {planAdi} planınız dönem sonunda sona erer. Ödediğiniz sürenin sonuna + kadar tüm özellikler açık kalır, yenileme yapılmaz. +

+
+
+ +
+ +
+
+

Ayrılma nedeniniz nedir? *

+
+ {nedenler.map((n) => ( + + ))} +
+
+ +
+ +