feat: abonelik iptal ekranı (ayrılma nedeni + açıklama) & gerçek abonelik rozeti (yönetici erişimi aboneliği gizlemesin)

This commit is contained in:
2026-08-29 21:47:58 +03:00
parent 5d502c8ae4
commit 65997055fe
8 changed files with 261 additions and 7 deletions
+42
View File
@@ -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,
};
}
+10
View File
@@ -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,
+24 -2
View File
@@ -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']);
+1
View File
@@ -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) {