feat(internal-admin): trial extend + manual activate — Phase C
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
Two new endpoints on /internal/admin/subscriptions/:id behind the
InternalTokenGuard.
POST .../trial/extend { days, reason, founderId }
- Only operates on status='trial' subscriptions.
- 1..90 day clamp; new endDate = max(now, current endDate) + days
(never shrinks the trial window).
- Returns previous/new endDate + daysAdded.
POST .../activate { reason, founderId }
- Wraps SubscriptionsService.activateSubscription which handles
status transition, startDate/endDate by billing period, and
brand auto-assignment for Full plan.
- Refuses already-active, cancelled, or expired subscriptions.
Wiring
- BillingService + BillingController added to InternalAdminModule.
- SubscriptionsModule imported so we can call activateSubscription.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
58
apps/api/src/internal-admin/billing.controller.ts
Normal file
58
apps/api/src/internal-admin/billing.controller.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Body,
|
||||
Controller,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Param,
|
||||
Post,
|
||||
UseGuards,
|
||||
} from "@nestjs/common";
|
||||
import { Public } from "../common/decorators/public.decorator";
|
||||
import { InternalTokenGuard } from "../common/guards/internal-token.guard";
|
||||
import { BillingService } from "./billing.service";
|
||||
|
||||
@Controller("internal/admin/subscriptions")
|
||||
@Public()
|
||||
@UseGuards(InternalTokenGuard)
|
||||
export class BillingController {
|
||||
constructor(private billing: BillingService) {}
|
||||
|
||||
@Post(":id/trial/extend")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async extendTrial(
|
||||
@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)");
|
||||
}
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
@Post(":id/activate")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async manuallyActivate(
|
||||
@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)");
|
||||
}
|
||||
return this.billing.manuallyActivate({
|
||||
subscriptionId: id,
|
||||
reason: body.reason,
|
||||
founderId: body.founderId,
|
||||
});
|
||||
}
|
||||
}
|
||||
109
apps/api/src/internal-admin/billing.service.ts
Normal file
109
apps/api/src/internal-admin/billing.service.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
Inject,
|
||||
Injectable,
|
||||
Logger,
|
||||
NotFoundException,
|
||||
} from "@nestjs/common";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { DATABASE, type Database } from "../database/database.provider";
|
||||
import { userSubscriptions } from "../database/schema/core";
|
||||
import { SubscriptionsService } from "../subscriptions/subscriptions.service";
|
||||
|
||||
@Injectable()
|
||||
export class BillingService {
|
||||
private readonly logger = new Logger(BillingService.name);
|
||||
|
||||
constructor(
|
||||
@Inject(DATABASE) private db: Database,
|
||||
private subscriptionsService: SubscriptionsService,
|
||||
) {}
|
||||
|
||||
async extendTrial(input: {
|
||||
subscriptionId: string;
|
||||
days: number;
|
||||
reason: string;
|
||||
founderId: string;
|
||||
}) {
|
||||
if (!Number.isFinite(input.days) || input.days <= 0 || input.days > 90) {
|
||||
throw new BadRequestException("days must be 1..90");
|
||||
}
|
||||
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 !== "trial") {
|
||||
throw new ConflictException(
|
||||
`Subscription '${sub.status}' durumunda — trial extend yalnız trial için çalışır`,
|
||||
);
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
// Use the larger of (now, current endDate) as the base — never shrink the window.
|
||||
const base = sub.endDate && sub.endDate > now ? sub.endDate : now;
|
||||
const newEnd = new Date(base.getTime() + input.days * 24 * 60 * 60 * 1000);
|
||||
|
||||
const [updated] = await this.db
|
||||
.update(userSubscriptions)
|
||||
.set({ endDate: newEnd, updatedAt: now })
|
||||
.where(eq(userSubscriptions.id, input.subscriptionId))
|
||||
.returning();
|
||||
|
||||
this.logger.log(
|
||||
`trial extend: subscription=${input.subscriptionId} +${input.days}d ` +
|
||||
`${sub.endDate?.toISOString() ?? "—"} → ${newEnd.toISOString()} ` +
|
||||
`founder=${input.founderId} reason="${input.reason.slice(0, 80)}"`,
|
||||
);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
subscriptionId: input.subscriptionId,
|
||||
userId: sub.userId,
|
||||
previousEndDate: sub.endDate?.toISOString() ?? null,
|
||||
newEndDate: updated.endDate?.toISOString() ?? null,
|
||||
daysAdded: input.days,
|
||||
};
|
||||
}
|
||||
|
||||
async manuallyActivate(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 === "active") {
|
||||
throw new ConflictException("Subscription zaten aktif");
|
||||
}
|
||||
if (sub.status === "cancelled" || sub.status === "expired") {
|
||||
throw new ConflictException(
|
||||
`'${sub.status}' subscription manuel aktive edilemez — yeni subscription başlatın`,
|
||||
);
|
||||
}
|
||||
|
||||
// activateSubscription handles status, dates, plan-based brand auto-assignment.
|
||||
const updated = await this.subscriptionsService.activateSubscription(input.subscriptionId);
|
||||
|
||||
this.logger.log(
|
||||
`manual activate: subscription=${input.subscriptionId} ${sub.status} → active ` +
|
||||
`founder=${input.founderId} reason="${input.reason.slice(0, 80)}"`,
|
||||
);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
subscriptionId: input.subscriptionId,
|
||||
userId: sub.userId,
|
||||
previousStatus: sub.status,
|
||||
newStatus: "active",
|
||||
startDate: updated?.startDate?.toISOString() ?? null,
|
||||
endDate: updated?.endDate?.toISOString() ?? null,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,15 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { SubscriptionsModule } from "../subscriptions/subscriptions.module";
|
||||
import { BillingController } from "./billing.controller";
|
||||
import { BillingService } from "./billing.service";
|
||||
import { ImpersonationController } from "./impersonation.controller";
|
||||
import { ImpersonationService } from "./impersonation.service";
|
||||
import { LifecycleController } from "./lifecycle.controller";
|
||||
import { LifecycleService } from "./lifecycle.service";
|
||||
|
||||
@Module({
|
||||
controllers: [ImpersonationController, LifecycleController],
|
||||
providers: [ImpersonationService, LifecycleService],
|
||||
imports: [SubscriptionsModule],
|
||||
controllers: [ImpersonationController, LifecycleController, BillingController],
|
||||
providers: [ImpersonationService, LifecycleService, BillingService],
|
||||
})
|
||||
export class InternalAdminModule {}
|
||||
|
||||
Reference in New Issue
Block a user