feat(internal-admin): refund + generalize subscription extend — Phase E
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled

Refund (Stripe API)
- StripeService.refundPayment({ paymentId, amount?, reason, founderId })
  is a new public method that wraps stripe.refunds.create:
  * Requires the payment to have a stripePaymentIntentId (post-Iyzico).
  * Refuses payments not in completed/partially_refunded status.
  * Partial refund: amount must be in 1..payment.amount (kuruş).
  * Sends panel_* metadata to Stripe for the founder/reason audit trail.
  * Flips payments.status to refunded / partially_refunded.
  * Appends a dated reason line to payments.admin_note.
  * Captures a `payment_refunded` PostHog event (via:'super_panel').
  * Does NOT cancel the subscription — that's a separate decision.
- New endpoint POST /internal/admin/payments/:id/refund behind the
  InternalTokenGuard, body { amount?, reason, founderId }.
- Wired through PaymentsAdminController in InternalAdminModule;
  StripeModule imported.

Extend (goodwill / bonus time)
- BillingService.extendTrial now accepts both trial AND active
  subscriptions (was trial-only). Same end-date semantics
  (base = max(now, current endDate)). Response now also returns
  subscriptionStatus so the panel can surface the right copy.
- Endpoint URL kept as /trial/extend for backward compatibility; the
  panel decides the user-facing label ("Trial uzat" vs "Bonus süre
  ekle / Goodwill") based on current status.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-18 10:03:03 +03:00
parent d2fb64484c
commit 85d0d791ed
4 changed files with 153 additions and 5 deletions

View File

@@ -20,6 +20,11 @@ export class BillingService {
private subscriptionsService: SubscriptionsService,
) {}
/**
* Extend a subscription's endDate by N days. Works on trial OR active
* subscriptions; the panel uses "Trial uzat" copy for trial and
* "Bonus süre ekle" (goodwill) for active.
*/
async extendTrial(input: {
subscriptionId: string;
days: number;
@@ -35,9 +40,9 @@ export class BillingService {
.where(eq(userSubscriptions.id, input.subscriptionId))
.limit(1);
if (!sub) throw new NotFoundException("Subscription bulunamadı");
if (sub.status !== "trial") {
if (sub.status !== "trial" && sub.status !== "active") {
throw new ConflictException(
`Subscription '${sub.status}' durumunda — trial extend yalnız trial için çalışır`,
`Subscription '${sub.status}' durumunda — extend yalnız trial veya active için çalışır`,
);
}
@@ -53,7 +58,7 @@ export class BillingService {
.returning();
this.logger.log(
`trial extend: subscription=${input.subscriptionId} +${input.days}d ` +
`extend: subscription=${input.subscriptionId} (${sub.status}) +${input.days}d ` +
`${sub.endDate?.toISOString() ?? "—"}${newEnd.toISOString()} ` +
`founder=${input.founderId} reason="${input.reason.slice(0, 80)}"`,
);
@@ -62,6 +67,7 @@ export class BillingService {
success: true,
subscriptionId: input.subscriptionId,
userId: sub.userId,
subscriptionStatus: sub.status,
previousEndDate: sub.endDate?.toISOString() ?? null,
newEndDate: updated.endDate?.toISOString() ?? null,
daysAdded: input.days,

View File

@@ -1,4 +1,5 @@
import { Module } from "@nestjs/common";
import { StripeModule } from "../payments/stripe/stripe.module";
import { SubscriptionsModule } from "../subscriptions/subscriptions.module";
import { BillingController } from "./billing.controller";
import { BillingService } from "./billing.service";
@@ -6,10 +7,16 @@ import { ImpersonationController } from "./impersonation.controller";
import { ImpersonationService } from "./impersonation.service";
import { LifecycleController } from "./lifecycle.controller";
import { LifecycleService } from "./lifecycle.service";
import { PaymentsAdminController } from "./payments.controller";
@Module({
imports: [SubscriptionsModule],
controllers: [ImpersonationController, LifecycleController, BillingController],
imports: [SubscriptionsModule, StripeModule],
controllers: [
ImpersonationController,
LifecycleController,
BillingController,
PaymentsAdminController,
],
providers: [ImpersonationService, LifecycleService, BillingService],
})
export class InternalAdminModule {}

View File

@@ -0,0 +1,38 @@
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 { StripeService } from "../payments/stripe/stripe.service";
@Controller("internal/admin/payments")
@Public()
@UseGuards(InternalTokenGuard)
export class PaymentsAdminController {
constructor(private stripeService: StripeService) {}
@Post(":id/refund")
@HttpCode(HttpStatus.OK)
async refund(
@Param("id") id: string,
@Body() body: { amount?: 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)");
}
return this.stripeService.refundPayment({
paymentId: id,
amount: body.amount,
reason: body.reason,
founderId: body.founderId,
});
}
}

View File

@@ -283,4 +283,101 @@ export class StripeService {
reason,
});
}
/**
* Refund a completed Stripe payment. Called from the InternalAdmin module
* via Süper Panel. `amount` is in the smallest currency unit (kuruş for TRY)
* — omit for a full refund.
*
* Side effects:
* - Stripe refund created (idempotent via metadata.panel_payment_id).
* - payments.status flipped to 'refunded' (full) or 'partially_refunded' (partial).
* - adminNote prefixed with the reason for an audit breadcrumb.
* - PostHog event captured for the user.
*
* Does NOT cancel the subscription — that's a separate panel decision.
*/
async refundPayment(input: {
paymentId: string;
amount?: number;
reason: string;
founderId: string;
}) {
if (!this.stripe) {
throw new ServiceUnavailableException("Stripe ödeme şu an kullanılamıyor");
}
const [payment] = await this.db
.select()
.from(payments)
.where(eq(payments.id, input.paymentId))
.limit(1);
if (!payment) throw new NotFoundException("Payment bulunamadı");
if (!payment.stripePaymentIntentId) {
throw new BadRequestException(
"Bu payment Stripe üzerinden alınmadı (legacy/Iyzico)",
);
}
if (payment.status !== "completed" && payment.status !== "partially_refunded") {
throw new BadRequestException(
`Refund yalnız completed/partially_refunded ödemeler için ('${payment.status}')`,
);
}
if (
input.amount !== undefined &&
(input.amount <= 0 || input.amount > Number(payment.amount))
) {
throw new BadRequestException(
`Refund tutarı 1..${payment.amount} aralığında olmalı`,
);
}
const isFullRefund = input.amount === undefined;
const refund = await this.stripe.refunds.create({
payment_intent: payment.stripePaymentIntentId,
amount: isFullRefund ? undefined : input.amount,
reason: "requested_by_customer",
metadata: {
panel_payment_id: payment.id,
panel_reason: input.reason.slice(0, 480),
panel_founder_id: input.founderId,
},
});
const newStatus = isFullRefund ? "refunded" : "partially_refunded";
const adminNoteLine = `[refund ${new Date().toISOString().slice(0, 10)}] ${isFullRefund ? "full" : `${input.amount}`}${input.reason.slice(0, 200)}`;
const newAdminNote = payment.adminNote
? `${payment.adminNote}\n${adminNoteLine}`
: adminNoteLine;
await this.db
.update(payments)
.set({ status: newStatus, adminNote: newAdminNote, updatedAt: new Date() })
.where(eq(payments.id, input.paymentId));
this.posthog.captureForUser(payment.userId, "payment_refunded", {
payment_id: payment.id,
subscription_id: payment.subscriptionId,
amount_refunded: isFullRefund ? Number(payment.amount) : input.amount,
stripe_refund_id: refund.id,
is_full: isFullRefund,
via: "super_panel",
});
this.logger.log(
`refund: payment=${payment.id} ${isFullRefund ? "full" : input.amount} ` +
`stripe=${refund.id} founder=${input.founderId} reason="${input.reason.slice(0, 80)}"`,
);
return {
success: true,
paymentId: payment.id,
userId: payment.userId,
stripeRefundId: refund.id,
amount: isFullRefund ? Number(payment.amount) : input.amount!,
isFullRefund,
newStatus,
currency: payment.currency,
};
}
}