feat(internal-admin): trial extend + manual activate — Phase C
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:
2026-05-18 09:39:18 +03:00
parent 96a9d11015
commit 0e83353f4e
3 changed files with 173 additions and 2 deletions

View 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,
});
}
}

View 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,
};
}
}

View File

@@ -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 {}