Harden platform security, reliability, and CI after full audit.
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>
This commit is contained in:
@@ -0,0 +1,190 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user