- Add monthly/annual billing toggle with 25% annual discount on pricing page - Implement 14-day no-card free trial (server-side Stripe subscription creation) - Enable upgrade/downgrade via Stripe Customer Portal - Add admin-initiated ACH/invoice billing for enterprise customers - Add billing card to Settings page with plan info and Manage Billing button - Handle past_due status with read-only grace period access - Add trial ending and trial expired email templates - Add DB migration for billing_interval and collection_method columns - Update ONBOARDING-AND-AUTH.md documentation Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
134 lines
4.5 KiB
TypeScript
134 lines
4.5 KiB
TypeScript
import {
|
|
Controller,
|
|
Post,
|
|
Put,
|
|
Get,
|
|
Body,
|
|
Param,
|
|
Query,
|
|
Req,
|
|
UseGuards,
|
|
RawBodyRequest,
|
|
BadRequestException,
|
|
ForbiddenException,
|
|
Request,
|
|
} from '@nestjs/common';
|
|
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
|
import { Throttle } from '@nestjs/throttler';
|
|
import { Request as ExpressRequest } from 'express';
|
|
import { DataSource } from 'typeorm';
|
|
import { BillingService } from './billing.service';
|
|
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
|
|
|
@ApiTags('billing')
|
|
@Controller()
|
|
export class BillingController {
|
|
constructor(
|
|
private billingService: BillingService,
|
|
private dataSource: DataSource,
|
|
) {}
|
|
|
|
@Post('billing/start-trial')
|
|
@ApiOperation({ summary: 'Start a free trial (no card required)' })
|
|
@Throttle({ default: { limit: 10, ttl: 60000 } })
|
|
async startTrial(
|
|
@Body() body: { planId: string; billingInterval?: 'month' | 'year'; email: string; businessName: string },
|
|
) {
|
|
if (!body.planId) throw new BadRequestException('planId is required');
|
|
if (!body.email) throw new BadRequestException('email is required');
|
|
if (!body.businessName) throw new BadRequestException('businessName is required');
|
|
return this.billingService.startTrial(
|
|
body.planId,
|
|
body.billingInterval || 'month',
|
|
body.email,
|
|
body.businessName,
|
|
);
|
|
}
|
|
|
|
@Post('billing/create-checkout-session')
|
|
@ApiOperation({ summary: 'Create a Stripe Checkout Session' })
|
|
@Throttle({ default: { limit: 10, ttl: 60000 } })
|
|
async createCheckout(
|
|
@Body() body: { planId: string; billingInterval?: 'month' | 'year'; email?: string; businessName?: string },
|
|
) {
|
|
if (!body.planId) throw new BadRequestException('planId is required');
|
|
return this.billingService.createCheckoutSession(
|
|
body.planId,
|
|
body.billingInterval || 'month',
|
|
body.email,
|
|
body.businessName,
|
|
);
|
|
}
|
|
|
|
@Post('webhooks/stripe')
|
|
@ApiOperation({ summary: 'Stripe webhook endpoint' })
|
|
async handleWebhook(@Req() req: RawBodyRequest<ExpressRequest>) {
|
|
const signature = req.headers['stripe-signature'] as string;
|
|
if (!signature) throw new BadRequestException('Missing Stripe signature');
|
|
if (!req.rawBody) throw new BadRequestException('Missing raw body');
|
|
await this.billingService.handleWebhook(req.rawBody, signature);
|
|
return { received: true };
|
|
}
|
|
|
|
@Get('billing/status')
|
|
@ApiOperation({ summary: 'Check provisioning status for a checkout session or subscription' })
|
|
async getStatus(@Query('session_id') sessionId: string) {
|
|
if (!sessionId) throw new BadRequestException('session_id required');
|
|
return this.billingService.getProvisioningStatus(sessionId);
|
|
}
|
|
|
|
@Get('billing/subscription')
|
|
@ApiOperation({ summary: 'Get current subscription info' })
|
|
@ApiBearerAuth()
|
|
@UseGuards(JwtAuthGuard)
|
|
async getSubscription(@Request() req: any) {
|
|
const orgId = req.user.orgId;
|
|
if (!orgId) throw new BadRequestException('No organization context');
|
|
return this.billingService.getSubscriptionInfo(orgId);
|
|
}
|
|
|
|
@Post('billing/portal')
|
|
@ApiOperation({ summary: 'Create Stripe Customer Portal session' })
|
|
@ApiBearerAuth()
|
|
@UseGuards(JwtAuthGuard)
|
|
async createPortal(@Request() req: any) {
|
|
const orgId = req.user.orgId;
|
|
if (!orgId) throw new BadRequestException('No organization context');
|
|
return this.billingService.createPortalSession(orgId);
|
|
}
|
|
|
|
// ─── Admin: Switch Billing Method (ACH / Invoice) ──────────
|
|
|
|
@Put('admin/organizations/:id/billing')
|
|
@ApiOperation({ summary: 'Switch organization billing method (superadmin only)' })
|
|
@ApiBearerAuth()
|
|
@UseGuards(JwtAuthGuard)
|
|
async updateBillingMethod(
|
|
@Request() req: any,
|
|
@Param('id') id: string,
|
|
@Body() body: { collectionMethod: 'charge_automatically' | 'send_invoice'; daysUntilDue?: number },
|
|
) {
|
|
// Require superadmin
|
|
const userId = req.user.userId || req.user.sub;
|
|
const userRows = await this.dataSource.query(
|
|
`SELECT is_superadmin FROM shared.users WHERE id = $1`,
|
|
[userId],
|
|
);
|
|
if (!userRows.length || !userRows[0].is_superadmin) {
|
|
throw new ForbiddenException('Superadmin access required');
|
|
}
|
|
|
|
if (!['charge_automatically', 'send_invoice'].includes(body.collectionMethod)) {
|
|
throw new BadRequestException('collectionMethod must be "charge_automatically" or "send_invoice"');
|
|
}
|
|
|
|
await this.billingService.switchToInvoiceBilling(
|
|
id,
|
|
body.collectionMethod,
|
|
body.daysUntilDue || 30,
|
|
);
|
|
|
|
return { success: true };
|
|
}
|
|
}
|