feat(internal-admin): plan change + cancel/resume — Phase D
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled

Three new endpoints on /internal/admin/subscriptions/:id complete the
Süper Panel billing surface.

POST .../change-plan  { newPlanId, reason, founderId }
- Only for active or trial subscriptions.
- Refuses no-op (already on that plan) and inactive plans.
- Updates planId; leaves userBrands intact so the founder can decide.
- Response includes brandReassignmentNeeded flag when the new plan's
  brandCount diverges from the current user-brand count (the panel
  surfaces a warning so the founder reaches out).

POST .../cancel       { reason, founderId }
- Active or trial → cancelled (sets cancelledAt = now).
- Refuses already-cancelled or expired.

POST .../resume       { reason, founderId }
- Cancelled → active (clears cancelledAt).
- All other states rejected.

Controller cleanup: factored requireFounder + requireReason guards so
every endpoint enforces the same validation contract uniformly.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-18 09:43:19 +03:00
parent 0e83353f4e
commit 37a5e8f0f7
2 changed files with 210 additions and 13 deletions

View File

@@ -24,18 +24,16 @@ export class BillingController {
@Param("id") id: string,
@Body() body: { days?: number; reason?: string; founderId?: string },
) {
if (!body.founderId) throw new BadRequestException("founderId required");
if (!body.reason || body.reason.trim().length < 5) {
throw new BadRequestException("reason required (min 5 chars)");
}
this.requireFounder(body);
this.requireReason(body, "trial-extend");
if (typeof body.days !== "number" || body.days <= 0) {
throw new BadRequestException("days must be a positive number");
}
return this.billing.extendTrial({
subscriptionId: id,
days: body.days,
reason: body.reason,
founderId: body.founderId,
reason: body.reason!,
founderId: body.founderId!,
});
}
@@ -45,14 +43,68 @@ export class BillingController {
@Param("id") id: string,
@Body() body: { reason?: string; founderId?: string },
) {
if (!body.founderId) throw new BadRequestException("founderId required");
if (!body.reason || body.reason.trim().length < 5) {
throw new BadRequestException("reason required (min 5 chars)");
}
this.requireFounder(body);
this.requireReason(body, "activate");
return this.billing.manuallyActivate({
subscriptionId: id,
reason: body.reason,
founderId: body.founderId,
reason: body.reason!,
founderId: body.founderId!,
});
}
@Post(":id/change-plan")
@HttpCode(HttpStatus.OK)
async changePlan(
@Param("id") id: string,
@Body() body: { newPlanId?: string; reason?: string; founderId?: string },
) {
this.requireFounder(body);
this.requireReason(body, "change-plan");
if (!body.newPlanId) throw new BadRequestException("newPlanId required");
return this.billing.changePlan({
subscriptionId: id,
newPlanId: body.newPlanId,
reason: body.reason!,
founderId: body.founderId!,
});
}
@Post(":id/cancel")
@HttpCode(HttpStatus.OK)
async cancel(
@Param("id") id: string,
@Body() body: { reason?: string; founderId?: string },
) {
this.requireFounder(body);
this.requireReason(body, "cancel");
return this.billing.cancel({
subscriptionId: id,
reason: body.reason!,
founderId: body.founderId!,
});
}
@Post(":id/resume")
@HttpCode(HttpStatus.OK)
async resume(
@Param("id") id: string,
@Body() body: { reason?: string; founderId?: string },
) {
this.requireFounder(body);
this.requireReason(body, "resume");
return this.billing.resume({
subscriptionId: id,
reason: body.reason!,
founderId: body.founderId!,
});
}
private requireFounder(body: { founderId?: string }) {
if (!body.founderId) throw new BadRequestException("founderId required");
}
private requireReason(body: { reason?: string }, action: string) {
if (!body.reason || body.reason.trim().length < 5) {
throw new BadRequestException(`${action} requires reason (min 5 chars)`);
}
}
}

View File

@@ -8,7 +8,7 @@ import {
} from "@nestjs/common";
import { eq } from "drizzle-orm";
import { DATABASE, type Database } from "../database/database.provider";
import { userSubscriptions } from "../database/schema/core";
import { plans, userBrands, userSubscriptions } from "../database/schema/core";
import { SubscriptionsService } from "../subscriptions/subscriptions.service";
@Injectable()
@@ -106,4 +106,149 @@ export class BillingService {
endDate: updated?.endDate?.toISOString() ?? null,
};
}
async changePlan(input: {
subscriptionId: string;
newPlanId: string;
reason: string;
founderId: string;
}) {
const [sub] = await this.db
.select()
.from(userSubscriptions)
.where(eq(userSubscriptions.id, input.subscriptionId))
.limit(1);
if (!sub) throw new NotFoundException("Subscription bulunamadı");
if (sub.status !== "active" && sub.status !== "trial") {
throw new ConflictException(
`'${sub.status}' subscription için plan değişimi yapılamaz`,
);
}
if (sub.planId === input.newPlanId) {
throw new ConflictException("Subscription zaten bu planda");
}
const [newPlan] = await this.db
.select()
.from(plans)
.where(eq(plans.id, input.newPlanId))
.limit(1);
if (!newPlan) throw new NotFoundException("Yeni plan bulunamadı");
if (!newPlan.isActive) {
throw new ConflictException("Yeni plan aktif değil");
}
const now = new Date();
const [updated] = await this.db
.update(userSubscriptions)
.set({ planId: input.newPlanId, updatedAt: now })
.where(eq(userSubscriptions.id, input.subscriptionId))
.returning();
// Brand assignment: if old/new brand counts differ, the userBrands set may
// no longer match. We do NOT auto-pick brands — that's a user choice. The
// panel surfaces a warning so the founder reaches out. Exception: Full plan
// (brandCount=0) means "all brands"; we leave existing userBrands alone.
const oldUserBrands = await this.db
.select()
.from(userBrands)
.where(eq(userBrands.subscriptionId, input.subscriptionId));
this.logger.log(
`plan change: subscription=${input.subscriptionId} plan ${sub.planId}${input.newPlanId} ` +
`founder=${input.founderId} brands=${oldUserBrands.length}/${newPlan.brandCount} ` +
`reason="${input.reason.slice(0, 80)}"`,
);
return {
success: true,
subscriptionId: input.subscriptionId,
userId: sub.userId,
previousPlanId: sub.planId,
newPlanId: input.newPlanId,
newPlanName: newPlan.name,
newPlanBrandCount: newPlan.brandCount,
currentBrandCount: oldUserBrands.length,
brandReassignmentNeeded:
newPlan.brandCount > 0 && oldUserBrands.length !== newPlan.brandCount,
changedAt: updated.updatedAt?.toISOString() ?? null,
};
}
async cancel(input: {
subscriptionId: string;
reason: string;
founderId: string;
}) {
const [sub] = await this.db
.select()
.from(userSubscriptions)
.where(eq(userSubscriptions.id, input.subscriptionId))
.limit(1);
if (!sub) throw new NotFoundException("Subscription bulunamadı");
if (sub.status === "cancelled") {
throw new ConflictException("Subscription zaten iptal edilmiş");
}
if (sub.status === "expired") {
throw new ConflictException("Expired subscription iptal edilemez");
}
const now = new Date();
const [updated] = await this.db
.update(userSubscriptions)
.set({ status: "cancelled", cancelledAt: now, updatedAt: now })
.where(eq(userSubscriptions.id, input.subscriptionId))
.returning();
this.logger.log(
`cancel: subscription=${input.subscriptionId} ${sub.status} → cancelled ` +
`founder=${input.founderId} reason="${input.reason.slice(0, 80)}"`,
);
return {
success: true,
subscriptionId: input.subscriptionId,
userId: sub.userId,
previousStatus: sub.status,
newStatus: "cancelled",
cancelledAt: updated.cancelledAt?.toISOString() ?? null,
};
}
async resume(input: {
subscriptionId: string;
reason: string;
founderId: string;
}) {
const [sub] = await this.db
.select()
.from(userSubscriptions)
.where(eq(userSubscriptions.id, input.subscriptionId))
.limit(1);
if (!sub) throw new NotFoundException("Subscription bulunamadı");
if (sub.status !== "cancelled") {
throw new ConflictException("Yalnız cancelled subscription resume edilebilir");
}
const now = new Date();
const [updated] = await this.db
.update(userSubscriptions)
.set({ status: "active", cancelledAt: null, updatedAt: now })
.where(eq(userSubscriptions.id, input.subscriptionId))
.returning();
this.logger.log(
`resume: subscription=${input.subscriptionId} cancelled → active ` +
`founder=${input.founderId} reason="${input.reason.slice(0, 80)}"`,
);
return {
success: true,
subscriptionId: input.subscriptionId,
userId: sub.userId,
previousStatus: "cancelled",
newStatus: "active",
resumedAt: updated.updatedAt?.toISOString() ?? null,
};
}
}