fix(billing): dunning kurtarma zinciri (migration 0035 + webhook + banner)
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled

Stripe gerçeği: 3 abonelik past_due (3×₺999 = brüt MRR'ın ~%27'si), 1 abonelik
dün dunning'den iptal (tugem, ₺999 kayıp); kurtarma tarihsel %0. Kök nedenler:
(1) dunning e-postasının CTA'sı ödeme imkânı olmayan /dashboard/subscription'a
gidiyordu, (2) kart güncelleme yüzeyi hiç yok + yeniden checkout 'Zaten aktif
aboneliğiniz var' ile bloklu, (3) app past_due'yu hiç bilmiyordu (DB state yok,
banner yok), (4) subscription.deleted no-op'tu (churn görünmez + status active
kaldığı için re-subscribe kalıcı bloklu).

- migration 0035: user_subscriptions.dunning_since + dunning_invoice_url
- handleInvoiceFailed: dunning state persist + e-posta CTA'sı Stripe hosted
  invoice sayfasına (öde/yeni kart/3DS) + milestone e-posta (1./3./final —
  deneme başına aynı mail spam'i bitti) + attempt_count PostHog'a
- handleInvoicePaid: her iki dalda dunning temizliği
- handleSubscriptionDeleted: status→cancelled (re-subscribe deblke),
  subscription_churned PostHog event'i (reason: payment_failure/cancelled)
- web: DunningBanner (kapatılamaz, kırmızı; CTA hosted invoice) dashboard'da
- spec: yeni davranışa güncellendi + milestone/final testleri (14/14)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-11 11:10:44 +03:00
parent 57d16b9aea
commit 181bdbe971
8 changed files with 242 additions and 29 deletions

View File

@@ -0,0 +1,95 @@
import { api } from "@/lib/api-client";
import { capture } from "@/lib/posthog";
import { Button } from "@sase/ui";
import { useQuery } from "@tanstack/react-query";
import { CreditCard } from "lucide-react";
import { useEffect, useRef } from "react";
interface Subscription {
status: string;
plan?: { name: string; key: string };
// Dunning fields — stamped by the invoice.payment_failed webhook, cleared on
// recovery. invoiceUrl is Stripe's hosted invoice page (pay / new card / 3DS).
dunningSince?: string | null;
dunningInvoiceUrl?: string | null;
}
/**
* Failed-renewal (dunning) banner. Before this, a past_due subscriber saw NO
* in-app signal at all — full access continued until the hard cutoff, then the
* subscription silently died (measured dunning recovery: 0%, first churn
* 2026-08-10). Renders on every dashboard page while a renewal invoice is
* unpaid; not dismissible on purpose — this is about to cost the user their
* subscription. CTA opens Stripe's hosted invoice page, the only surface where
* they can actually pay or enter a new card.
*/
export function DunningBanner() {
const viewedRef = useRef(false);
const { data: subData } = useQuery({
queryKey: ["subscription", "me"],
queryFn: () =>
api.get<{ subscription: Subscription | null; eligibleForTrial: boolean }>(
"/subscriptions/me",
),
});
const sub = subData?.subscription;
// status stays 'active' during dunning (access runs to end_date); once the
// sub is cancelled/expired the trial/expiry surfaces take over.
const visible = !!sub?.dunningSince && sub.status === "active";
const invoiceUrl = sub?.dunningInvoiceUrl ?? null;
useEffect(() => {
if (!visible || viewedRef.current) return;
viewedRef.current = true;
capture("dunning_banner_viewed", {
plan_name: sub?.plan?.name,
has_invoice_url: !!invoiceUrl,
});
}, [visible, sub?.plan?.name, invoiceUrl]);
if (!visible) return null;
const handleCta = () => {
capture("dunning_banner_cta_clicked", { has_invoice_url: !!invoiceUrl });
};
return (
<section
aria-label="Ödeme sorunu"
className="flex items-start gap-3 border-b border-destructive/40 bg-destructive/5 px-4 py-3 sm:items-center sm:px-6"
>
<CreditCard className="mt-0.5 size-5 shrink-0 text-destructive sm:mt-0" />
<div className="flex flex-1 flex-col gap-1 sm:flex-row sm:items-center sm:gap-4">
<div>
<p className="text-sm font-semibold text-foreground">Abonelik ödemen alınamadı</p>
<p className="text-xs text-muted-foreground">
Bankan yenileme tahsilatını reddetti. Erişimin kesilmemesi için faturayı öde veya farklı
bir kartla tamamla banka onayı (3D Secure) gerekebilir.
</p>
</div>
{invoiceUrl ? (
<a
href={invoiceUrl}
target="_blank"
rel="noreferrer"
className="shrink-0"
onClick={handleCta}
>
<Button
size="sm"
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Faturayı öde
</Button>
</a>
) : (
<p className="shrink-0 text-xs text-muted-foreground">
Ödeme e-postandaki bağlantıyı kullan veya destek ile iletişime geç.
</p>
)}
</div>
</section>
);
}