feat(payments): recurring billing — Stripe Checkout switches to subscription mode
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled

Monthly/yearly purchases were one-time charges: our "subscription" was just
an end_date stamp, access silently died at period end and no renewal
machinery existed (no auto-charge, no reminder) — every paying customer had
to notice the lockout and re-buy by hand.

- Checkout now mode:"subscription" with inline recurring price_data; the
  Stripe customer is stored on first purchase and reused (saved card +
  invoice history on one record, with a stale-customer retry guard)
- invoice.paid webhook: extends end_date to the billing-line period end,
  records a completed payment (deduped on stripe_invoice_id against webhook
  retries), captures subscription_renewed with $revenue, mails the receipt;
  late dunning recovery re-activates the row and re-grants Full-plan brands
- invoice.payment_failed webhook: dunning mail with Stripe's next retry
  date; access is NOT cut — end_date governs and the nightly cron closes it
  if every retry fails. Product rule: mail on success, mail on failure,
  never a pre-charge reminder
- customer.subscription.deleted: stamps cancelledAt; renewals stop and
  access runs out at end_date naturally
- cancel()/resume() sync cancel_at_period_end to Stripe (forwardRef pair) —
  an in-app cancel that leaves the card being charged was unacceptable
- subscription_create invoices only enrich the checkout's payment row
  (payment intent + invoice id for receipts/panel refunds); activation,
  revenue and the receipt stay on checkout.session.completed
- migration 0020: users.stripe_customer_id,
  user_subscriptions.stripe_subscription_id (+idx),
  payments.stripe_invoice_id (+idx)

Legacy one-time subs (3 live payers) are untouched: they expire at their
end_date as before and board recurring on their next manual checkout.

Promote checklist: add invoice.paid / invoice.payment_failed /
customer.subscription.deleted to the prod webhook endpoint; verify Stripe
"Customer emails" upcoming-renewal reminders stay OFF.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-06-12 15:55:44 +03:00
parent 4879f7cdef
commit 6c6b74a8e5
9 changed files with 751 additions and 13 deletions

View File

@@ -0,0 +1,17 @@
-- Stripe recurring billing (Checkout mode:"subscription") bağlantı kolonları.
-- users.stripe_customer_id → sonraki checkout'lar aynı Stripe Customer'da
-- toplanır (kayıtlı kart + fatura geçmişi tek kayıtta).
-- user_subscriptions.stripe_subscription_id → yenileme faturaları
-- (invoice.paid/payment_failed webhook'ları) bizim abonelik satırına bu
-- kolondan çözülür; trial ve eski tek-seferlik satırlarda NULL kalır.
-- payments.stripe_invoice_id → invoice.paid webhook retry'larında mükerrer
-- gelir kaydını önleyen dedupe anahtarı.
ALTER TABLE "users" ADD COLUMN "stripe_customer_id" text;
--> statement-breakpoint
ALTER TABLE "user_subscriptions" ADD COLUMN "stripe_subscription_id" text;
--> statement-breakpoint
ALTER TABLE "payments" ADD COLUMN "stripe_invoice_id" text;
--> statement-breakpoint
CREATE INDEX "user_subscriptions_stripe_sub_idx" ON "user_subscriptions" USING btree ("stripe_subscription_id");
--> statement-breakpoint
CREATE INDEX "payments_stripe_invoice_id_idx" ON "payments" USING btree ("stripe_invoice_id");

View File

@@ -141,6 +141,13 @@
"when": 1781485200000,
"tag": "0019_part_price_brand",
"breakpoints": true
},
{
"idx": 20,
"version": "7",
"when": 1781268490495,
"tag": "0020_stripe_recurring",
"breakpoints": true
}
]
}

View File

@@ -39,6 +39,10 @@ export const users = pgTable(
// subscription yet (referrer had no active/trial sub at grant time).
// Consumed when the user next starts a trial or activates a subscription.
referralCreditDays: integer("referral_credit_days").default(0).notNull(),
// Stripe Customer backing this user's recurring billing. Set on the first
// completed checkout and reused on later checkouts so saved cards and
// invoices stay on one customer record.
stripeCustomerId: text("stripe_customer_id"),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
},
@@ -155,12 +159,17 @@ export const userSubscriptions = pgTable(
startDate: timestamp("start_date", { withTimezone: true }),
endDate: timestamp("end_date", { withTimezone: true }),
cancelledAt: timestamp("cancelled_at", { withTimezone: true }),
// Stripe Subscription id for recurring (mode:"subscription") billing.
// NULL for trials and legacy one-time purchases. Renewal invoices resolve
// our row through this.
stripeSubscriptionId: text("stripe_subscription_id"),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
},
(table) => [
index("user_subscriptions_user_id_idx").on(table.userId),
index("user_subscriptions_status_idx").on(table.status),
index("user_subscriptions_stripe_sub_idx").on(table.stripeSubscriptionId),
],
);
@@ -229,6 +238,9 @@ export const payments = pgTable(
iyzicoPaymentId: text("iyzico_payment_id"),
stripeSessionId: text("stripe_session_id"),
stripePaymentIntentId: text("stripe_payment_intent_id"),
// Stripe Invoice behind a recurring charge (renewals). Dedupe key against
// invoice.paid webhook retries.
stripeInvoiceId: text("stripe_invoice_id"),
/** @deprecated EFT/Havale retired (Stripe-only). Kept for historical data. */
bankAccountId: uuid("bank_account_id").references(() => bankAccounts.id),
/** @deprecated EFT/Havale retired (Stripe-only). Kept for historical data. */
@@ -241,6 +253,7 @@ export const payments = pgTable(
index("payments_user_id_idx").on(table.userId),
index("payments_status_idx").on(table.status),
index("payments_stripe_session_id_idx").on(table.stripeSessionId),
index("payments_stripe_invoice_id_idx").on(table.stripeInvoiceId),
],
);

View File

@@ -1,10 +1,12 @@
import { Module } from "@nestjs/common";
import { Module, forwardRef } from "@nestjs/common";
import { SubscriptionsModule } from "../../subscriptions/subscriptions.module";
import { StripeController } from "./stripe.controller";
import { StripeService } from "./stripe.service";
@Module({
imports: [SubscriptionsModule],
// forwardRef: SubscriptionsModule imports us back (cancel/resume sync
// auto-renewal to Stripe; the webhook here activates subscriptions).
imports: [forwardRef(() => SubscriptionsModule)],
controllers: [StripeController],
providers: [StripeService],
exports: [StripeService],

View File

@@ -0,0 +1,192 @@
import { describe, expect, it, vi } from "vitest";
import { StripeService } from "./stripe.service";
/**
* Unit tests for the recurring-billing webhook handlers (invoice.paid /
* invoice.payment_failed). The service is constructed WITHOUT a Stripe key
* (client = null), which the handlers tolerate: the only Stripe API call on
* these paths (payment-intent backfill) is fail-open.
*/
/** Sequenced select mock: call N resolves results[N] (last repeats). */
function sequencedSelect(results: unknown[][]) {
let i = 0;
return vi.fn().mockImplementation(() => {
const rows = results[Math.min(i, results.length - 1)];
i++;
return {
from: vi.fn().mockReturnThis(),
where: vi.fn().mockReturnThis(),
innerJoin: vi.fn().mockReturnThis(),
orderBy: vi.fn().mockReturnThis(),
limit: vi.fn().mockReturnValue(rows),
};
});
}
function createMocks(selectResults: unknown[][]) {
const updateChains: Array<{ set: ReturnType<typeof vi.fn> }> = [];
const db = {
select: sequencedSelect(selectResults),
update: vi.fn().mockImplementation(() => {
const chain = {
set: vi.fn(),
where: vi.fn().mockResolvedValue(undefined),
};
chain.set.mockReturnValue(chain);
updateChains.push(chain);
return chain;
}),
insert: vi.fn().mockReturnValue({ values: vi.fn().mockResolvedValue(undefined) }),
delete: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue(undefined) }),
};
const posthog = {
captureForUser: vi.fn(),
capture: vi.fn(),
flush: vi.fn().mockResolvedValue(undefined),
};
const novu = {
paymentSuccess: vi.fn().mockResolvedValue(undefined),
paymentFailed: vi.fn().mockResolvedValue(undefined),
};
const config = { get: vi.fn().mockReturnValue(undefined) }; // no Stripe key → client null
const service = new StripeService(
db as any,
config as any,
{} as any, // SubscriptionsService — unused on invoice paths
posthog as any,
novu as any,
);
return { service: service as any, db, updateChains, posthog, novu };
}
const PERIOD_END_SEC = 1784278800; // 2026-07-17T01:00:00Z
const RETRY_AT_SEC = 1781700000;
function invoiceFixture(over: Record<string, unknown> = {}) {
return {
id: "in_1",
billing_reason: "subscription_cycle",
amount_paid: 5000,
amount_due: 5000,
next_payment_attempt: RETRY_AT_SEC,
parent: {
type: "subscription_details",
subscription_details: {
subscription: "sub_stripe1",
metadata: { subscription_id: "our-sub-1", user_id: "user-1" },
},
},
lines: { data: [{ period: { end: PERIOD_END_SEC } }] },
...over,
};
}
const activeSub = {
id: "our-sub-1",
userId: "user-1",
planId: "plan-1",
status: "active",
billingPeriod: "monthly",
stripeSubscriptionId: "sub_stripe1",
cancelledAt: null,
};
const fullPlan = { id: "plan-1", name: "Full Paket", brandCount: 0, priceMonthly: 5000 };
const user = { id: "user-1", email: "u@example.com", name: "U" };
describe("StripeService recurring webhooks", () => {
describe("handleInvoicePaid (renewal)", () => {
it("extends the paid-through date, records the payment once, counts revenue, mails the receipt", async () => {
// selects: resolve sub → dedupe (none) → plan → user (for the mail)
const { service, db, updateChains, posthog, novu } = createMocks([
[activeSub],
[],
[fullPlan],
[user],
]);
await service.handleInvoicePaid(invoiceFixture());
// end_date extended to Stripe's billing-line period end
expect(db.update).toHaveBeenCalledTimes(1);
const setArg = updateChains[0].set.mock.calls[0][0] as { endDate?: Date };
expect(setArg.endDate?.getTime()).toBe(PERIOD_END_SEC * 1000);
// exactly one completed payment row, keyed to the invoice
expect(db.insert).toHaveBeenCalledTimes(1);
const valuesArg = (db.insert.mock.results[0].value as { values: ReturnType<typeof vi.fn> })
.values.mock.calls[0][0];
expect(valuesArg).toEqual(
expect.objectContaining({
status: "completed",
amount: 5000,
stripeInvoiceId: "in_1",
userId: "user-1",
}),
);
// renewal revenue + receipt mail (success mail YES, reminder mails never)
expect(posthog.captureForUser).toHaveBeenCalledWith(
"user-1",
"subscription_renewed",
expect.objectContaining({ $revenue: 50, amount_kurus: 5000 }),
);
expect(novu.paymentSuccess).toHaveBeenCalledTimes(1);
});
it("ignores a webhook retry for an already-recorded invoice", async () => {
// selects: resolve sub → dedupe finds the prior payment row
const { service, db, posthog, novu } = createMocks([[activeSub], [{ id: "pay-1" }]]);
await service.handleInvoicePaid(invoiceFixture());
expect(db.insert).not.toHaveBeenCalled();
expect(db.update).not.toHaveBeenCalled();
expect(posthog.captureForUser).not.toHaveBeenCalled();
expect(novu.paymentSuccess).not.toHaveBeenCalled();
});
it("subscription_create invoices only enrich the checkout's payment row (no double revenue)", async () => {
const { service, db, updateChains, posthog, novu } = createMocks([[activeSub]]);
await service.handleInvoicePaid(invoiceFixture({ billing_reason: "subscription_create" }));
// single update: invoice id (+ intent when available) onto the payment row
expect(db.update).toHaveBeenCalledTimes(1);
expect(updateChains[0].set).toHaveBeenCalledWith(
expect.objectContaining({ stripeInvoiceId: "in_1" }),
);
expect(db.insert).not.toHaveBeenCalled();
expect(posthog.captureForUser).not.toHaveBeenCalled();
expect(novu.paymentSuccess).not.toHaveBeenCalled();
});
});
describe("handleInvoiceFailed", () => {
it("mails dunning with Stripe's retry date and leaves the paid-through date alone", async () => {
// selects: resolve sub → user
const { service, db, posthog, novu } = createMocks([[activeSub], [user]]);
await service.handleInvoiceFailed(invoiceFixture());
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 };
expect(opts.retryDate?.getTime()).toBe(RETRY_AT_SEC * 1000);
expect(posthog.captureForUser).toHaveBeenCalledWith(
"user-1",
"payment_failed",
expect.objectContaining({ reason: "renewal_charge_failed" }),
);
});
it("ignores first-charge failures (checkout flow owns those)", async () => {
const { service, posthog, novu } = createMocks([[activeSub]]);
await service.handleInvoiceFailed(invoiceFixture({ billing_reason: "subscription_create" }));
expect(novu.paymentFailed).not.toHaveBeenCalled();
expect(posthog.captureForUser).not.toHaveBeenCalled();
});
});
});

View File

@@ -5,9 +5,10 @@ import {
Logger,
NotFoundException,
ServiceUnavailableException,
forwardRef,
} from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { and, eq } from "drizzle-orm";
import { and, eq, isNull } from "drizzle-orm";
import Stripe from "stripe";
import { DATABASE, type Database } from "../../database/database.provider";
@@ -17,7 +18,17 @@ import { DATABASE, type Database } from "../../database/database.provider";
type StripeNs = import("stripe/cjs/stripe.core").Stripe;
type StripeEvent = import("stripe/cjs/stripe.core").Stripe.Event;
type CheckoutSession = import("stripe/cjs/stripe.core").Stripe.Checkout.Session;
import { payments, plans, userSubscriptions, users } from "../../database/schema/core";
type SessionCreateParams = import("stripe/cjs/stripe.core").Stripe.Checkout.SessionCreateParams;
type StripeInvoice = import("stripe/cjs/stripe.core").Stripe.Invoice;
type StripeSubscriptionObj = import("stripe/cjs/stripe.core").Stripe.Subscription;
import {
brands,
payments,
plans,
userBrands,
userSubscriptions,
users,
} from "../../database/schema/core";
import { NovuService } from "../../notifications/novu.service";
import { PostHogService } from "../../posthog/posthog.service";
import { SubscriptionsService } from "../../subscriptions/subscriptions.service";
@@ -40,6 +51,9 @@ export class StripeService {
constructor(
@Inject(DATABASE) private db: Database,
private configService: ConfigService,
// forwardRef: SubscriptionsService also injects StripeService (cancel/resume
// must sync auto-renewal to Stripe), so the two providers are circular.
@Inject(forwardRef(() => SubscriptionsService))
private subscriptionsService: SubscriptionsService,
private posthog: PostHogService,
private novu: NovuService,
@@ -141,24 +155,48 @@ export class StripeService {
})
.returning();
const session = await this.stripe.checkout.sessions.create({
mode: "payment",
// Reuse the Stripe customer from earlier purchases so the saved card and
// invoice history stay on one record.
const [buyer] = await this.db
.select({ stripeCustomerId: users.stripeCustomerId })
.from(users)
.where(eq(users.id, userId))
.limit(1);
const buildParams = (customer: string | null): SessionCreateParams => ({
// Recurring billing: Stripe owns the renewal schedule and auto-charges the
// saved card each period. No pre-charge reminder mails (product decision) —
// only a receipt on success and dunning on failure, both webhook-driven.
// Our row's end_date is extended on every paid invoice (handleInvoicePaid);
// access keeps gating on end_date, so a failed renewal naturally lapses.
mode: "subscription",
// Render Stripe's hosted page in Turkish. The audience is Turkish B2B; a
// foreign-language checkout is a known abandonment driver (~60% of sessions
// reached the page but never started a payment intent).
locale: "tr",
payment_method_types: ["card"],
customer_email: userEmail,
...(customer ? { customer } : { customer_email: userEmail }),
line_items: [
{
price_data: {
currency: "try",
product_data: { name: productName },
unit_amount: amount, // already in kuruş (smallest unit)
recurring: { interval: billingPeriod === "yearly" ? "year" : "month" },
},
quantity: 1,
},
],
subscription_data: {
// Mirrored onto every invoice (parent.subscription_details.metadata), so
// renewal webhooks can resolve our rows even before/without the
// stripe_subscription_id column link.
metadata: {
subscription_id: subscription.id,
user_id: userId,
plan_key: planKey,
},
},
success_url: `${this.successUrl}&session_id={CHECKOUT_SESSION_ID}`,
cancel_url: this.cancelUrl,
client_reference_id: payment.id,
@@ -171,6 +209,28 @@ export class StripeService {
},
});
let session: CheckoutSession;
try {
session = await this.stripe.checkout.sessions.create(
buildParams(buyer?.stripeCustomerId ?? null),
);
} catch (err) {
// A stored customer deleted on Stripe's side must not brick the user's
// checkout forever: clear the stale id and retry with a fresh customer.
if (buyer?.stripeCustomerId && String(err).includes("No such customer")) {
this.logger.warn(
`Stored Stripe customer ${buyer.stripeCustomerId} is gone — clearing and retrying (user=${userId})`,
);
await this.db
.update(users)
.set({ stripeCustomerId: null, updatedAt: new Date() })
.where(eq(users.id, userId));
session = await this.stripe.checkout.sessions.create(buildParams(null));
} else {
throw err;
}
}
await this.db
.update(payments)
.set({ stripeSessionId: session.id, updatedAt: new Date() })
@@ -222,6 +282,18 @@ export class StripeService {
await this.handleCheckoutFailed(session, event.type);
break;
}
case "invoice.paid": {
await this.handleInvoicePaid(event.data.object as StripeInvoice);
break;
}
case "invoice.payment_failed": {
await this.handleInvoiceFailed(event.data.object as StripeInvoice);
break;
}
case "customer.subscription.deleted": {
await this.handleSubscriptionDeleted(event.data.object as StripeSubscriptionObj);
break;
}
default:
this.logger.debug(`Unhandled Stripe event type: ${event.type}`);
}
@@ -258,6 +330,31 @@ export class StripeService {
return;
}
// Persist the Stripe linkage this checkout created BEFORE activating, so a
// failed activation (webhook retry) never loses it: the recurring
// subscription id (renewal invoices resolve through it) and the customer
// id (the next checkout reuses the same Stripe customer).
const stripeSubId =
typeof session.subscription === "string"
? session.subscription
: (session.subscription?.id ?? null);
if (stripeSubId) {
await this.db
.update(userSubscriptions)
.set({ stripeSubscriptionId: stripeSubId, updatedAt: new Date() })
.where(eq(userSubscriptions.id, payment.subscriptionId));
}
const stripeCustomerId =
typeof session.customer === "string" ? session.customer : (session.customer?.id ?? null);
if (stripeCustomerId) {
await this.db
.update(users)
.set({ stripeCustomerId, updatedAt: new Date() })
.where(eq(users.id, payment.userId));
}
// In subscription mode the charge lives on the first invoice, so the
// session itself carries no payment_intent — handleInvoicePaid backfills it.
const paymentIntentId =
typeof session.payment_intent === "string" ? session.payment_intent : null;
@@ -382,6 +479,308 @@ export class StripeService {
}
}
/**
* Resolve our user_subscriptions row for a subscription invoice: primarily
* via the stripe_subscription_id column, falling back to the subscription
* metadata we stamp at checkout (covers invoice events that arrive before
* checkout.session.completed has linked the column).
*/
private async resolveOurSubscription(stripeSubId: string | null, ourSubId: string | null) {
if (stripeSubId) {
const [byColumn] = await this.db
.select()
.from(userSubscriptions)
.where(eq(userSubscriptions.stripeSubscriptionId, stripeSubId))
.limit(1);
if (byColumn) return byColumn;
}
if (ourSubId) {
const [byMetadata] = await this.db
.select()
.from(userSubscriptions)
.where(eq(userSubscriptions.id, ourSubId))
.limit(1);
if (byMetadata) return byMetadata;
}
return undefined;
}
/**
* Fetch the PaymentIntent id behind an invoice's charge (webhook payloads
* don't embed it). Receipts and panel refunds key on the intent. Best-effort.
*/
private async fetchInvoicePaymentIntentId(invoiceId: string): Promise<string | null> {
if (!this.stripe) return null;
try {
const inv = await this.stripe.invoices.retrieve(invoiceId, {
expand: ["payments.data.payment.payment_intent"],
});
const paid = inv.payments?.data?.find((p) => p.status === "paid") ?? inv.payments?.data?.[0];
const pi = paid?.payment?.payment_intent;
return typeof pi === "string" ? pi : (pi?.id ?? null);
} catch (err) {
this.logger.warn(`invoice PI fetch failed (${invoiceId}): ${String(err)}`);
return null;
}
}
/**
* invoice.paid — the heartbeat of recurring billing.
*
* billing_reason=subscription_create (first charge): activation, revenue and
* the receipt mail are owned by checkout.session.completed; here we only
* enrich the original payment row (intent + invoice id) and backfill the
* subscription link in case events arrived out of order.
*
* Any other billing_reason (subscription_cycle renewals, updates): extend the
* paid-through date, record a completed payment (deduped on invoice id),
* count renewal revenue, and mail the receipt.
*/
private async handleInvoicePaid(invoice: StripeInvoice) {
const details = invoice.parent?.subscription_details ?? null;
if (!details) return; // not a subscription invoice
const subRef = details.subscription;
const stripeSubId = typeof subRef === "string" ? subRef : (subRef?.id ?? null);
const metaSubId = details.metadata?.subscription_id ?? null;
const sub = await this.resolveOurSubscription(stripeSubId, metaSubId);
if (!sub) {
this.logger.warn(`invoice.paid ${invoice.id}: no matching subscription (${stripeSubId})`);
return;
}
// Backfill the column link (events can beat checkout.session.completed).
if (!sub.stripeSubscriptionId && stripeSubId) {
await this.db
.update(userSubscriptions)
.set({ stripeSubscriptionId: stripeSubId, updatedAt: new Date() })
.where(eq(userSubscriptions.id, sub.id));
}
if (invoice.billing_reason === "subscription_create") {
const paymentIntentId = await this.fetchInvoicePaymentIntentId(invoice.id);
await this.db
.update(payments)
.set({
...(paymentIntentId ? { stripePaymentIntentId: paymentIntentId } : {}),
stripeInvoiceId: invoice.id,
updatedAt: new Date(),
})
.where(and(eq(payments.subscriptionId, sub.id), isNull(payments.stripeInvoiceId)));
return;
}
// ── Renewal ──
const [already] = await this.db
.select({ id: payments.id })
.from(payments)
.where(eq(payments.stripeInvoiceId, invoice.id))
.limit(1);
if (already) {
this.logger.debug(`invoice.paid ${invoice.id} already recorded — skipping`);
return;
}
const now = new Date();
// Paid-through date comes from the invoice line period (Stripe's truth for
// the billing window); fall back to now+period if the payload lacks lines.
let newEndDate: Date;
const periodEndSec = invoice.lines?.data?.[0]?.period?.end;
if (periodEndSec) {
newEndDate = new Date(periodEndSec * 1000);
} else {
newEndDate = new Date(now);
if (sub.billingPeriod === "yearly") newEndDate.setFullYear(newEndDate.getFullYear() + 1);
else newEndDate.setMonth(newEndDate.getMonth() + 1);
}
const [plan] = await this.db.select().from(plans).where(eq(plans.id, sub.planId)).limit(1);
const recovered = sub.status !== "active" && sub.status !== "cancelled";
if (!recovered) {
// Normal renewal — extend. A 'cancelled' row that still got billed keeps
// its status; access is governed by end_date either way.
await this.db
.update(userSubscriptions)
.set({ endDate: newEndDate, 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 })
.where(eq(userSubscriptions.id, sub.id));
if (plan?.brandCount === 0) {
await this.db.delete(userBrands).where(eq(userBrands.subscriptionId, sub.id));
const allBrands = await this.db.select().from(brands).where(eq(brands.isActive, true));
if (allBrands.length > 0) {
await this.db
.insert(userBrands)
.values(
allBrands.map((b) => ({ userId: sub.userId, subscriptionId: sub.id, brandId: b.id })),
);
}
} else {
this.logger.error(
`invoice.paid ${invoice.id}: sub ${sub.id} recovered from '${sub.status}' but its ` +
"brand-plan grants were purged at expiry — re-grant brands manually",
);
}
}
const paymentIntentId = await this.fetchInvoicePaymentIntentId(invoice.id);
const amountKurus = invoice.amount_paid ?? 0;
await this.db.insert(payments).values({
userId: sub.userId,
subscriptionId: sub.id,
amount: amountKurus,
currency: "TRY",
method: "stripe",
status: "completed",
stripeInvoiceId: invoice.id,
...(paymentIntentId ? { stripePaymentIntentId: paymentIntentId } : {}),
});
// Renewal revenue is realized revenue: counted here as subscription_renewed,
// while first-charge revenue stays on subscription_activated — together they
// are the only $revenue sources (funnel steps intentionally carry none).
this.posthog.captureForUser(sub.userId, "subscription_renewed", {
$revenue: amountKurus / 100,
currency: "TRY",
amount_kurus: amountKurus,
mrr: sub.billingPeriod === "yearly" ? amountKurus / 12 / 100 : amountKurus / 100,
plan: plan?.name ?? null,
plan_id: sub.planId,
billing_period: sub.billingPeriod,
stripe_invoice_id: invoice.id,
recovered,
});
// Receipt mail. Product rule: mail on success, mail on failure, never a
// pre-charge reminder.
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.paymentSuccess(user, {
amountKurus,
plan: plan?.name ?? null,
nextBillingDate: newEndDate,
});
}
} catch (err) {
this.logger.error(`renewal receipt mail failed (user=${sub.userId}): ${String(err)}`);
}
this.logger.log(
`Renewal recorded: sub ${sub.id} paid ${amountKurus} kuruş through ` +
`${newEndDate.toISOString()} (invoice ${invoice.id})`,
);
}
/**
* invoice.payment_failed — a renewal charge bounced. Mail the dunning notice
* with Stripe's next retry date. Access is NOT cut here: end_date still
* governs, Stripe keeps smart-retrying, and the nightly expiry cron closes
* access only when the paid-through date lapses.
*/
private async handleInvoiceFailed(invoice: StripeInvoice) {
const details = invoice.parent?.subscription_details ?? null;
if (!details) return;
// First-charge failures surface inline in the checkout flow; the
// session-expiry handler owns dunning for abandons.
if (invoice.billing_reason === "subscription_create") return;
const subRef = details.subscription;
const stripeSubId = typeof subRef === "string" ? subRef : (subRef?.id ?? null);
const metaSubId = details.metadata?.subscription_id ?? null;
const sub = await this.resolveOurSubscription(stripeSubId, metaSubId);
if (!sub) {
this.logger.warn(
`invoice.payment_failed ${invoice.id}: no matching subscription (${stripeSubId})`,
);
return;
}
const retryDate = invoice.next_payment_attempt
? new Date(invoice.next_payment_attempt * 1000)
: null;
this.posthog.captureForUser(sub.userId, "payment_failed", {
method: "stripe",
subscription_id: sub.id,
reason: "renewal_charge_failed",
amount: invoice.amount_due ?? null,
stripe_invoice_id: invoice.id,
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,
});
}
} 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)"}`,
);
}
/**
* customer.subscription.deleted — Stripe will send no further invoices
* (cancel-at-period-end executed, or dunning gave up). Access keeps running
* to end_date; the nightly cron flips the row to expired after that.
*/
private async handleSubscriptionDeleted(subscription: StripeSubscriptionObj) {
const [sub] = await this.db
.select()
.from(userSubscriptions)
.where(eq(userSubscriptions.stripeSubscriptionId, subscription.id))
.limit(1);
if (!sub) return;
await this.db
.update(userSubscriptions)
.set({ cancelledAt: sub.cancelledAt ?? new Date(), 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`,
);
}
/**
* Toggle auto-renewal on the Stripe subscription backing a recurring plan.
* cancel() sets it (no further charges; access runs to end_date), resume()
* clears it. Callers skip legacy one-time subs (no Stripe subscription id).
*/
async setCancelAtPeriodEnd(stripeSubscriptionId: string, cancel: boolean): Promise<void> {
if (!this.stripe) {
throw new ServiceUnavailableException("Stripe ödeme şu an kullanılamıyor");
}
await this.stripe.subscriptions.update(stripeSubscriptionId, {
cancel_at_period_end: cancel,
});
}
/**
* Refund a completed Stripe payment. Called from the InternalAdmin module
* via Süper Panel. `amount` is in the smallest currency unit (kuruş for TRY)

View File

@@ -1,11 +1,14 @@
import { Module } from "@nestjs/common";
import { Module, forwardRef } from "@nestjs/common";
import { BrandsModule } from "../brands/brands.module";
import { StripeModule } from "../payments/stripe/stripe.module";
import { PlansModule } from "../plans/plans.module";
import { SubscriptionsController } from "./subscriptions.controller";
import { SubscriptionsService } from "./subscriptions.service";
@Module({
imports: [BrandsModule, PlansModule],
// forwardRef: StripeModule imports us back (webhook activation needs
// SubscriptionsService; cancel/resume here need StripeService).
imports: [BrandsModule, PlansModule, forwardRef(() => StripeModule)],
controllers: [SubscriptionsController],
providers: [SubscriptionsService],
exports: [SubscriptionsService],

View File

@@ -55,14 +55,23 @@ function createMockDb(overrides: Record<string, unknown> = {}) {
/**
* Creates the service with a given mock db injected via reflection.
*/
function createService(db: unknown): SubscriptionsService {
function createService(
db: unknown,
stripe?: { setCancelAtPeriodEnd: ReturnType<typeof vi.fn> },
): SubscriptionsService {
const posthog = {
captureForUser: vi.fn(),
capture: vi.fn(),
flush: vi.fn().mockResolvedValue(undefined),
};
const metaCapi = { sendPurchase: vi.fn().mockResolvedValue(undefined) };
const service = new SubscriptionsService(db as any, posthog as any, metaCapi as any);
const stripeService = stripe ?? { setCancelAtPeriodEnd: vi.fn().mockResolvedValue(undefined) };
const service = new SubscriptionsService(
db as any,
posthog as any,
metaCapi as any,
stripeService as any,
);
return service;
}
@@ -338,6 +347,67 @@ describe("SubscriptionsService", () => {
});
});
describe("Stripe auto-renewal sync", () => {
function dbWithSub(sub: Record<string, unknown>, updated: Record<string, unknown>) {
return {
select: vi.fn().mockReturnValue({
from: vi.fn().mockReturnThis(),
where: vi.fn().mockReturnThis(),
limit: vi.fn().mockReturnValue([sub]),
}),
update: vi.fn().mockReturnValue({
set: vi.fn().mockReturnThis(),
where: vi.fn().mockReturnThis(),
returning: vi.fn().mockReturnValue([updated]),
}),
};
}
it("cancel() stops Stripe auto-renewal for a recurring subscription", async () => {
const sub = { id: "sub-1", stripeSubscriptionId: "sub_stripe1", status: "active" };
const db = dbWithSub(sub, { ...sub, status: "cancelled" });
const stripe = { setCancelAtPeriodEnd: vi.fn().mockResolvedValue(undefined) };
const service = createService(db, stripe);
await service.cancel("user-1");
expect(stripe.setCancelAtPeriodEnd).toHaveBeenCalledWith("sub_stripe1", true);
});
it("cancel() skips Stripe for legacy one-time subscriptions", async () => {
const sub = { id: "sub-1", stripeSubscriptionId: null, status: "active" };
const db = dbWithSub(sub, { ...sub, status: "cancelled" });
const stripe = { setCancelAtPeriodEnd: vi.fn() };
const service = createService(db, stripe);
await service.cancel("user-1");
expect(stripe.setCancelAtPeriodEnd).not.toHaveBeenCalled();
});
it("resume() re-enables Stripe auto-renewal for a recurring subscription", async () => {
const sub = { id: "sub-1", stripeSubscriptionId: "sub_stripe1", status: "cancelled" };
const db = dbWithSub(sub, { ...sub, status: "active" });
const stripe = { setCancelAtPeriodEnd: vi.fn().mockResolvedValue(undefined) };
const service = createService(db, stripe);
await service.resume("user-1");
expect(stripe.setCancelAtPeriodEnd).toHaveBeenCalledWith("sub_stripe1", false);
});
it("resume() maps a closed Stripe subscription to ConflictException (fresh checkout needed)", async () => {
const sub = { id: "sub-1", stripeSubscriptionId: "sub_gone", status: "cancelled" };
const db = dbWithSub(sub, { ...sub, status: "active" });
const stripe = {
setCancelAtPeriodEnd: vi.fn().mockRejectedValue(new Error("No such subscription")),
};
const service = createService(db, stripe);
await expect(service.resume("user-1")).rejects.toThrow(ConflictException);
});
});
describe("activateSubscription", () => {
it("is idempotent: skips re-activation when already active (no revenue double-count)", async () => {
// A Stripe webhook retry must not re-fire revenue events, re-consume
@@ -365,7 +435,13 @@ describe("SubscriptionsService", () => {
flush: vi.fn().mockResolvedValue(undefined),
};
const metaCapi = { sendPurchase: vi.fn().mockResolvedValue(undefined) };
const service = new SubscriptionsService(db as any, posthog as any, metaCapi as any);
const stripe = { setCancelAtPeriodEnd: vi.fn() };
const service = new SubscriptionsService(
db as any,
posthog as any,
metaCapi as any,
stripe as any,
);
const result = await service.activateSubscription("sub-1");

View File

@@ -5,6 +5,7 @@ import {
Injectable,
Logger,
NotFoundException,
forwardRef,
} from "@nestjs/common";
import { and, desc, eq, inArray, or } from "drizzle-orm";
import { DATABASE, type Database } from "../database/database.provider";
@@ -17,6 +18,7 @@ import {
users,
} from "../database/schema/core";
import { MetaCapiService } from "../meta-capi/meta-capi.service";
import { StripeService } from "../payments/stripe/stripe.service";
import { PostHogService } from "../posthog/posthog.service";
@Injectable()
@@ -27,6 +29,11 @@ export class SubscriptionsService {
@Inject(DATABASE) private db: Database,
private posthog: PostHogService,
private metaCapi: MetaCapiService,
// forwardRef: StripeService also injects SubscriptionsService (webhook
// activation), so the two providers are circular. cancel()/resume() must
// sync auto-renewal to Stripe for recurring subscriptions.
@Inject(forwardRef(() => StripeService))
private stripeService: StripeService,
) {}
/**
@@ -193,7 +200,9 @@ export class SubscriptionsService {
// activation, so total paid revenue becomes measurable in PostHog regardless
// of payment method (a Stripe DWH connector alone would miss EFT/havale).
// $revenue is in major TRY (PostHog revenue convention); plan prices are kuruş.
// This is the single source of $revenue — funnel steps (payment_initiated etc.)
// First-charge revenue lives here; renewal revenue is captured as
// subscription_renewed (stripe.service handleInvoicePaid). Together they are
// the only $revenue sources — funnel steps (payment_initiated etc.)
// intentionally do NOT carry $revenue so revenue isn't double-counted.
const priceKurus =
sub.billingPeriod === "yearly" ? (plan[0]?.priceYearly ?? 0) : (plan[0]?.priceMonthly ?? 0);
@@ -304,6 +313,13 @@ export class SubscriptionsService {
if (!sub) throw new NotFoundException("Aktif abonelik bulunamadı");
// Recurring (Stripe-billed) sub: stop auto-renewal at Stripe FIRST. If that
// call fails we keep our row active — a DB row that says "cancelled" while
// the card keeps being charged is the one unacceptable state.
if (sub.stripeSubscriptionId) {
await this.stripeService.setCancelAtPeriodEnd(sub.stripeSubscriptionId, true);
}
const [updated] = await this.db
.update(userSubscriptions)
.set({ status: "cancelled", cancelledAt: new Date(), updatedAt: new Date() })
@@ -322,6 +338,19 @@ export class SubscriptionsService {
if (!sub) throw new NotFoundException("Devam ettirilecek iptal edilmiş abonelik bulunamadı");
// Mirror of cancel(): re-enable auto-renewal at Stripe first. If the Stripe
// subscription is already fully closed (period ended), resuming is no
// longer possible — the user needs a fresh checkout.
if (sub.stripeSubscriptionId) {
try {
await this.stripeService.setCancelAtPeriodEnd(sub.stripeSubscriptionId, false);
} catch {
throw new ConflictException(
"Aboneliğin yenilemesi tamamen kapanmış — devam ettirmek için yeni bir ödeme başlatın",
);
}
}
const [updated] = await this.db
.update(userSubscriptions)
.set({ status: "active", cancelledAt: null, updatedAt: new Date() })