feat(internal-admin): refund + generalize subscription extend — Phase E
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
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:
@@ -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,
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
38
apps/api/src/internal-admin/payments.controller.ts
Normal file
38
apps/api/src/internal-admin/payments.controller.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user