feat(internal-admin): plan change + cancel/resume — Phase D
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
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:
@@ -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)`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user