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

@@ -163,16 +163,26 @@ describe("StripeService recurring webhooks", () => {
});
describe("handleInvoiceFailed", () => {
it("mails dunning with Stripe's retry date and leaves the paid-through date alone", async () => {
it("persists dunning state, mails with retry date + hosted invoice URL, leaves end_date alone", async () => {
// selects: resolve sub → user
const { service, db, posthog, novu } = createMocks([[activeSub], [user]]);
const { service, db, updateChains, posthog, novu } = createMocks([[activeSub], [user]]);
await service.handleInvoiceFailed(invoiceFixture());
await service.handleInvoiceFailed(
invoiceFixture({ attempt_count: 1, hosted_invoice_url: "https://invoice.stripe.com/i/x" }),
);
// dunning stamped (so /subscriptions/me + banner can surface it) — but
// end_date untouched: access keeps running to the paid-through date.
expect(db.update).toHaveBeenCalledTimes(1);
const setArg = updateChains[0].set.mock.calls[0][0] as Record<string, unknown>;
expect(setArg.dunningSince).toBeInstanceOf(Date);
expect(setArg.dunningInvoiceUrl).toBe("https://invoice.stripe.com/i/x");
expect(setArg.endDate).toBeUndefined();
expect(db.update).not.toHaveBeenCalled(); // access keeps running to end_date
expect(novu.paymentFailed).toHaveBeenCalledTimes(1);
const opts = novu.paymentFailed.mock.calls[0][1] as { retryDate?: Date };
const opts = novu.paymentFailed.mock.calls[0][1] as { retryDate?: Date; invoiceUrl?: string };
expect(opts.retryDate?.getTime()).toBe(RETRY_AT_SEC * 1000);
expect(opts.invoiceUrl).toBe("https://invoice.stripe.com/i/x");
expect(posthog.captureForUser).toHaveBeenCalledWith(
"user-1",
"payment_failed",
@@ -180,6 +190,28 @@ describe("StripeService recurring webhooks", () => {
);
});
it("suppresses the mail on non-milestone attempts but still records the failure", async () => {
const { service, db, posthog, novu } = createMocks([[activeSub], [user]]);
await service.handleInvoiceFailed(invoiceFixture({ attempt_count: 2 }));
expect(db.update).toHaveBeenCalledTimes(1); // dunning state still stamped
expect(posthog.captureForUser).toHaveBeenCalledTimes(1);
expect(novu.paymentFailed).not.toHaveBeenCalled(); // attempt 2 ≠ 1/3/final
});
it("sends the final notice when Stripe schedules no further retry", async () => {
const { service, novu } = createMocks([[activeSub], [user]]);
await service.handleInvoiceFailed(
invoiceFixture({ attempt_count: 7, next_payment_attempt: null }),
);
expect(novu.paymentFailed).toHaveBeenCalledTimes(1);
const opts = novu.paymentFailed.mock.calls[0][1] as { retryDate?: Date | null };
expect(opts.retryDate).toBeNull();
});
it("ignores first-charge failures (checkout flow owns those)", async () => {
const { service, posthog, novu } = createMocks([[activeSub]]);

View File

@@ -634,17 +634,24 @@ export class StripeService {
if (!recovered) {
// Normal renewal — extend. A 'cancelled' row that still got billed keeps
// its status; access is governed by end_date either way.
// its status; access is governed by end_date either way. A paid invoice
// also ends any dunning episode.
await this.db
.update(userSubscriptions)
.set({ endDate: newEndDate, updatedAt: now })
.set({ endDate: newEndDate, dunningSince: null, dunningInvoiceUrl: null, updatedAt: now })
.where(eq(userSubscriptions.id, sub.id));
} else {
// Late dunning recovery: the nightly cron already expired the row and
// purged its brand grants. Restore access for the freshly paid period.
await this.db
.update(userSubscriptions)
.set({ status: "active", endDate: newEndDate, updatedAt: now })
.set({
status: "active",
endDate: newEndDate,
dunningSince: null,
dunningInvoiceUrl: null,
updatedAt: now,
})
.where(eq(userSubscriptions.id, sub.id));
if (plan?.brandCount === 0) {
await this.db.delete(userBrands).where(eq(userBrands.subscriptionId, sub.id));
@@ -745,6 +752,19 @@ export class StripeService {
const retryDate = invoice.next_payment_attempt
? new Date(invoice.next_payment_attempt * 1000)
: null;
const hostedUrl = invoice.hosted_invoice_url ?? null;
// Persist the dunning episode so /subscriptions/me (and the dashboard
// banner) can surface it — before this, the app had no record that a
// subscription was failing and the user saw nothing until hard cutoff.
await this.db
.update(userSubscriptions)
.set({
dunningSince: sub.dunningSince ?? new Date(),
...(hostedUrl ? { dunningInvoiceUrl: hostedUrl } : {}),
updatedAt: new Date(),
})
.where(eq(userSubscriptions.id, sub.id));
this.posthog.captureForUser(sub.userId, "payment_failed", {
method: "stripe",
@@ -752,28 +772,42 @@ export class StripeService {
reason: "renewal_charge_failed",
amount: invoice.amount_due ?? null,
stripe_invoice_id: invoice.id,
attempt_count: invoice.attempt_count ?? null,
next_retry_at: retryDate ? retryDate.toISOString() : null,
});
try {
const [user] = await this.db
.select({ id: users.id, email: users.email, name: users.name })
.from(users)
.where(eq(users.id, sub.userId))
.limit(1);
if (user) {
await this.novu.paymentFailed(user, {
amountKurus: invoice.amount_due ?? undefined,
retryDate,
});
// Mail on milestones only — first failure, mid-cycle nudge, final notice.
// Stripe retries ~4-9 times; one identical mail per attempt trained users
// to ignore them (measured recovery: 0%).
const attempt = invoice.attempt_count ?? 1;
const isFinal = !invoice.next_payment_attempt;
const shouldMail = attempt <= 1 || attempt === 3 || isFinal;
if (shouldMail) {
try {
const [user] = await this.db
.select({ id: users.id, email: users.email, name: users.name })
.from(users)
.where(eq(users.id, sub.userId))
.limit(1);
if (user) {
await this.novu.paymentFailed(user, {
amountKurus: invoice.amount_due ?? undefined,
retryDate,
// CTA goes to Stripe's hosted invoice page (pay now / new card /
// 3DS) — the dashboard has no self-serve payment surface.
invoiceUrl: hostedUrl,
});
}
} catch (err) {
this.logger.error(`renewal dunning mail failed (user=${sub.userId}): ${String(err)}`);
}
} catch (err) {
this.logger.error(`renewal dunning mail failed (user=${sub.userId}): ${String(err)}`);
}
this.logger.warn(
`Renewal charge failed: sub ${sub.id} (invoice ${invoice.id}), ` +
`next retry ${retryDate ? retryDate.toISOString() : "none (final)"}`,
`Renewal charge failed: sub ${sub.id} (invoice ${invoice.id}, attempt ${attempt}), ` +
`next retry ${retryDate ? retryDate.toISOString() : "none (final)"}` +
`${shouldMail ? "" : " — mail suppressed (non-milestone attempt)"}`,
);
}
@@ -790,14 +824,35 @@ export class StripeService {
.limit(1);
if (!sub) return;
const wasDunning = !!sub.dunningSince;
// Flip to 'cancelled' (not just stamp cancelledAt): create() rejects new
// checkouts only while a row is 'active', so leaving a dunning-cancelled
// sub as 'active' silently BLOCKED the customer from re-subscribing — one
// of the root causes of 0% dunning recovery. Access still runs to end_date
// via the nightly expiry cron.
await this.db
.update(userSubscriptions)
.set({ cancelledAt: sub.cancelledAt ?? new Date(), updatedAt: new Date() })
.set({
status: "cancelled",
cancelledAt: sub.cancelledAt ?? new Date(),
dunningSince: null,
dunningInvoiceUrl: null,
updatedAt: new Date(),
})
.where(eq(userSubscriptions.id, sub.id));
this.logger.log(
`Stripe subscription ${subscription.id} deleted — our sub ${sub.id} ` +
`(status=${sub.status}) will not renew; access runs out at its end_date`,
// Churn was previously invisible in analytics (this handler captured
// nothing) — reason distinguishes dunning losses from voluntary cancels.
this.posthog.captureForUser(sub.userId, "subscription_churned", {
reason: wasDunning ? "payment_failure" : "cancelled",
subscription_id: sub.id,
stripe_subscription_id: subscription.id,
prior_status: sub.status,
});
this.logger.warn(
`Stripe subscription ${subscription.id} deleted (${wasDunning ? "dunning exhausted" : "cancel executed"}) — ` +
`our sub ${sub.id} → cancelled; access runs out at its end_date`,
);
}