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