837f0fa63f
Close deployment IDOR and gate stub payment endpoints, add production secret validation, health probes, Redis-backed build progress, GitHub Actions CI, expanded tests, billing/k8s refactors, and ops runbooks. Co-authored-by: Cursor <cursoragent@cursor.com>
191 lines
6.4 KiB
TypeScript
191 lines
6.4 KiB
TypeScript
import {
|
|
Controller,
|
|
Get,
|
|
Post,
|
|
Body,
|
|
Param,
|
|
Query,
|
|
UseGuards,
|
|
Request,
|
|
BadRequestException,
|
|
Inject,
|
|
forwardRef,
|
|
} from '@nestjs/common';
|
|
import { AuthGuard } from '@nestjs/passport';
|
|
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
|
import { BillingService } from './billing.service';
|
|
import { assertStubGatewayAllowed } from './payment-gateway.util';
|
|
import { AppLifecycleService } from '../lifecycle/app-lifecycle.service';
|
|
import { ApplicationsService } from '../applications/applications.service';
|
|
import { ChargeWalletDto, PayApplicationDto } from './dto/billing.dto';
|
|
import { RolesGuard } from '../common/guards/roles.guard';
|
|
import { BillingCycle, InvoiceReason } from '../common/enums';
|
|
|
|
@ApiTags('Billing')
|
|
@ApiBearerAuth()
|
|
@Controller('billing')
|
|
@UseGuards(AuthGuard('jwt'), RolesGuard)
|
|
export class BillingWalletController {
|
|
constructor(
|
|
private readonly billingService: BillingService,
|
|
@Inject(forwardRef(() => AppLifecycleService))
|
|
private readonly lifecycleService: AppLifecycleService,
|
|
@Inject(forwardRef(() => ApplicationsService))
|
|
private readonly applicationsService: ApplicationsService,
|
|
) {}
|
|
|
|
// ─── Wallet (User) ───────────────────────────────────────────────
|
|
|
|
@Get('wallet')
|
|
@ApiOperation({ summary: 'Get my wallet balance' })
|
|
async getBalance(@Request() req: any) {
|
|
return this.billingService.getBalance(req.user.id);
|
|
}
|
|
|
|
@Post('wallet/charge')
|
|
@ApiOperation({ summary: 'Charge my wallet (self top-up)' })
|
|
async chargeMyWallet(@Request() req: any, @Body() dto: ChargeWalletDto) {
|
|
return this.billingService.chargeWallet(req.user.id, dto.amount, dto.description || 'Self top-up');
|
|
}
|
|
|
|
@Get('wallet/transactions')
|
|
@ApiOperation({ summary: 'Get my wallet transactions' })
|
|
async getTransactions(@Request() req: any, @Query('limit') limit?: string) {
|
|
return this.billingService.getTransactions(req.user.id, limit ? parseInt(limit, 10) : 50);
|
|
}
|
|
|
|
@Get('resource-credits')
|
|
@ApiOperation({ summary: 'List active prepaid resource credits (from deleted apps)' })
|
|
async getResourceCredits(@Request() req: any) {
|
|
const credits = await this.billingService.getActiveCredits(req.user.id);
|
|
return credits.map((c) => this.billingService.formatCreditForApi(c));
|
|
}
|
|
|
|
@Post('wallet/pay/:applicationId')
|
|
@ApiOperation({ summary: 'Pay for an application plan from wallet — activates/renews the app' })
|
|
async payForApplication(
|
|
@Request() req: any,
|
|
@Param('applicationId') applicationId: string,
|
|
@Body() body: PayApplicationDto,
|
|
) {
|
|
const cycle = body.cycle as BillingCycle;
|
|
if (!Object.values(BillingCycle).includes(cycle)) {
|
|
throw new BadRequestException(`Invalid billing cycle: ${body.cycle}`);
|
|
}
|
|
|
|
const app = await this.applicationsService.findOne(applicationId, req.user.id);
|
|
|
|
const payment = await this.billingService.resolveAppPayment(
|
|
req.user.id,
|
|
app,
|
|
cycle,
|
|
);
|
|
|
|
const coupon = await this.billingService.resolveCoupon(
|
|
req.user.id,
|
|
body.couponCode,
|
|
await this.billingService.getAppChargeBreakdown(app),
|
|
cycle,
|
|
payment.amountDue,
|
|
);
|
|
|
|
let invoice = null;
|
|
if (payment.amountDue > 0) {
|
|
invoice = await this.billingService.createInvoice({
|
|
userId: req.user.id,
|
|
applicationId: app.id,
|
|
reason: InvoiceReason.DEPLOY,
|
|
lines: [
|
|
{
|
|
label: `Application payment: ${app.name}`,
|
|
description: `Billing cycle: ${cycle}`,
|
|
amount: payment.amountDue,
|
|
metadata: {
|
|
cycle,
|
|
waivedAmount: payment.waivedAmount,
|
|
creditApplied: payment.creditId || null,
|
|
},
|
|
},
|
|
],
|
|
metadata: {
|
|
action: 'activate',
|
|
cycle,
|
|
},
|
|
discount: coupon ?? undefined,
|
|
});
|
|
}
|
|
|
|
let tx = null;
|
|
if (invoice) {
|
|
const paid = await this.billingService.payInvoiceWithWallet(invoice.id, req.user);
|
|
tx = paid.transaction;
|
|
invoice = paid.invoice;
|
|
}
|
|
|
|
const activated = await this.lifecycleService.activateApp(
|
|
applicationId,
|
|
cycle,
|
|
);
|
|
|
|
return {
|
|
transaction: tx,
|
|
invoice,
|
|
creditApplied: payment.creditId || null,
|
|
waivedAmount: payment.waivedAmount,
|
|
discountAmount: coupon?.amount ?? 0,
|
|
discountCode: coupon?.code ?? null,
|
|
paidAmount: invoice ? Number(invoice.total) : 0,
|
|
application: {
|
|
id: activated.id,
|
|
name: activated.name,
|
|
lifecycleStatus: activated.lifecycleStatus,
|
|
planExpiresAt: activated.planExpiresAt,
|
|
},
|
|
message: payment.waivedAmount > 0
|
|
? payment.amountDue > 0
|
|
? `Application "${activated.name}" activated — prepaid credit applied; you paid ${payment.amountDue} Toman for additional services.`
|
|
: `Application "${activated.name}" activated using your prepaid resource credit (no charge).`
|
|
: payment.amountDue > 0
|
|
? `Application "${activated.name}" activated until ${activated.planExpiresAt?.toISOString()}`
|
|
: `Application "${activated.name}" activated`,
|
|
};
|
|
}
|
|
|
|
// ─── Payment Gateway ─────────────────────────────────────────────
|
|
|
|
@Post('gateway/initiate')
|
|
@ApiOperation({ summary: 'Initiate a payment gateway transaction' })
|
|
async initiateGateway(
|
|
@Request() req: any,
|
|
@Body() body: { amount: number; description?: string; callbackUrl: string },
|
|
) {
|
|
assertStubGatewayAllowed();
|
|
const trackingCode = `PAY-${Date.now()}-${Math.random().toString(36).substring(2, 8).toUpperCase()}`;
|
|
return {
|
|
success: true,
|
|
trackingCode,
|
|
gatewayUrl: `${body.callbackUrl}?trackingCode=${trackingCode}&amount=${body.amount}&status=success`,
|
|
message: 'Redirect user to gatewayUrl to complete payment',
|
|
};
|
|
}
|
|
|
|
@Post('gateway/verify')
|
|
@ApiOperation({ summary: 'Verify a payment gateway transaction and charge wallet' })
|
|
async verifyGateway(
|
|
@Request() req: any,
|
|
@Body() body: { trackingCode: string; amount: number },
|
|
) {
|
|
assertStubGatewayAllowed();
|
|
await this.billingService.chargeWallet(
|
|
req.user.id,
|
|
body.amount,
|
|
`Payment gateway: ${body.trackingCode}`,
|
|
);
|
|
return {
|
|
success: true,
|
|
message: 'Payment verified and wallet charged',
|
|
trackingCode: body.trackingCode,
|
|
};
|
|
}
|
|
}
|