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
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:
17
apps/api/drizzle/0020_stripe_recurring.sql
Normal file
17
apps/api/drizzle/0020_stripe_recurring.sql
Normal 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");
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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),
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -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],
|
||||
|
||||
192
apps/api/src/payments/stripe/stripe.service.spec.ts
Normal file
192
apps/api/src/payments/stripe/stripe.service.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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)
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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() })
|
||||
|
||||
Reference in New Issue
Block a user