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:
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
@@ -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']);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user