fix(billing): dunning kurtarma zinciri (migration 0035 + webhook + banner)
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
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:
95
apps/web/src/components/dunning-banner.tsx
Normal file
95
apps/web/src/components/dunning-banner.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user