diff --git a/backend/src/billing/billing.controller.ts b/backend/src/billing/billing.controller.ts index 4944211..4c66e01 100644 --- a/backend/src/billing/billing.controller.ts +++ b/backend/src/billing/billing.controller.ts @@ -254,7 +254,6 @@ export class BillingController { metadata: { action: 'activate', cycle, - planExpiresAt: payment.planExpiresAt?.toISOString(), }, }); } @@ -269,7 +268,6 @@ export class BillingController { const activated = await this.lifecycleService.activateApp( applicationId, cycle, - payment.planExpiresAt, ); return { diff --git a/backend/src/deployments/deployments.service.ts b/backend/src/deployments/deployments.service.ts index 169754b..014f521 100644 --- a/backend/src/deployments/deployments.service.ts +++ b/backend/src/deployments/deployments.service.ts @@ -6,7 +6,7 @@ import { Deployment } from './entities/deployment.entity'; import { ApplicationsService } from '../applications/applications.service'; import { KubernetesService } from '../kubernetes/kubernetes.service'; import { BuildService, BuildProgress, BuildCancelledError } from '../build/build.service'; -import { DeploymentStatus } from '../common/enums'; +import { AppLifecycleStatus, DeploymentStatus } from '../common/enums'; @Injectable() export class DeploymentsService { @@ -100,7 +100,15 @@ export class DeploymentsService { percent: 96, message: 'Waiting for all application pods to become ready...', }); - await this.kubernetesService.waitForApplicationReady(app); + await this.kubernetesService.waitForApplicationReady( + app, + 600_000, + () => this.isDeploymentCancelled(deploymentId), + ); + + if (await this.isDeploymentCancelled(deploymentId)) { + return; + } // Step 5: Mark success this.buildService.setProgress(deploymentId, { @@ -114,7 +122,7 @@ export class DeploymentsService { finishedAt: new Date(), }); } catch (error: any) { - if (error instanceof BuildCancelledError || error?.name === 'BuildCancelledError') { + if (this.isCancellationError(error) || await this.isDeploymentCancelled(deploymentId)) { this.logger.log(`Deployment ${deploymentId} cancelled by user`); await this.deploymentsRepository.update(deploymentId, { status: DeploymentStatus.CANCELLED, @@ -142,9 +150,35 @@ export class DeploymentsService { } async updateStatus(id: string, status: DeploymentStatus): Promise { + if (await this.isDeploymentCancelled(id)) { + return; + } await this.deploymentsRepository.update(id, { status }); } + private async isDeploymentCancelled(id: string): Promise { + const deployment = await this.deploymentsRepository.findOne({ where: { id } }); + return deployment?.status === DeploymentStatus.CANCELLED || deployment?.errorMessage === 'Cancelled by user'; + } + + private isCancellationError(error: any): boolean { + return error instanceof BuildCancelledError || + error?.name === 'BuildCancelledError' || + String(error?.message || '').toLowerCase().includes('cancelled'); + } + + private ensureRedeployAllowed(app: any): void { + if (!app.billingCycle) return; + + const isActive = app.lifecycleStatus === AppLifecycleStatus.ACTIVE; + const expiresAt = app.planExpiresAt ? new Date(app.planExpiresAt) : null; + const hasPaidTimeRemaining = !!expiresAt && expiresAt > new Date(); + + if (!isActive || !hasPaidTimeRemaining) { + throw new BadRequestException('Payment must be completed successfully before redeploying this application.'); + } + } + async findByApplication(applicationId: string): Promise { return this.deploymentsRepository.find({ where: { applicationId }, @@ -242,13 +276,31 @@ export class DeploymentsService { throw new BadRequestException('No deployment in progress to cancel'); } - await this.buildService.cancelBuild(latest.id); - await this.buildService.cleanupBuildResourcesForApp(app); - latest.status = DeploymentStatus.CANCELLED; latest.errorMessage = 'Cancelled by user'; latest.finishedAt = new Date(); - return this.deploymentsRepository.save(latest); + await this.deploymentsRepository.save(latest); + this.buildService.setProgress(latest.id, { phase: 'cancelled', percent: 0, message: 'Cancelled by user' }); + + try { + await this.buildService.cancelBuild(latest.id); + } catch (error: any) { + this.logger.warn(`Failed to cancel build resources for ${latest.id}: ${error.message}`); + } + + try { + await this.buildService.cleanupBuildResourcesForApp(app); + } catch (error: any) { + this.logger.warn(`Failed to cleanup build resources for ${app.name}: ${error.message}`); + } + + try { + await this.kubernetesService.deleteApplication(app); + } catch (error: any) { + this.logger.warn(`Failed to cleanup deployed resources for cancelled app ${app.name}: ${error.message}`); + } + + return this.deploymentsRepository.findOneOrFail({ where: { id: latest.id } }); } async stopDeployment(applicationId: string, userId: string): Promise { @@ -300,6 +352,8 @@ export class DeploymentsService { throw new NotFoundException('No source code available. Upload code or set a git URL first.'); } + this.ensureRedeployAllowed(app); + // Create new deployment record const deployment = this.deploymentsRepository.create({ applicationId: app.id, diff --git a/frontend/src/app/dashboard/apps/[id]/page.tsx b/frontend/src/app/dashboard/apps/[id]/page.tsx index 177db98..89ff313 100644 --- a/frontend/src/app/dashboard/apps/[id]/page.tsx +++ b/frontend/src/app/dashboard/apps/[id]/page.tsx @@ -894,6 +894,8 @@ export default function AppDetailPage() { const isStopped = latestStatus === 'stopped'; const isRunning = latestStatus === 'running'; const isInProgress = latestStatus === 'building' || latestStatus === 'deploying' || latestStatus === 'pending'; + const hasPaidAccess = !app.billingCycle || + (app.lifecycleStatus === 'active' && (!app.planExpiresAt || new Date(app.planExpiresAt) > new Date())); const handleDelete = async () => { const ok = await confirm({ @@ -956,7 +958,7 @@ export default function AppDetailPage() { {restartMutation.isPending ? <> : <> Restart} )} - {!isInProgress && ( + {!isInProgress && hasPaidAccess && (