feat: add annual billing, free trial, upgrade/downgrade, and ACH invoice support
- 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>
This commit is contained in:
@@ -1,34 +1,63 @@
|
||||
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) {}
|
||||
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; email?: string; businessName?: string },
|
||||
@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.email, body.businessName);
|
||||
return this.billingService.createCheckoutSession(
|
||||
body.planId,
|
||||
body.billingInterval || 'month',
|
||||
body.email,
|
||||
body.businessName,
|
||||
);
|
||||
}
|
||||
|
||||
@Post('webhooks/stripe')
|
||||
@@ -42,22 +71,63 @@ export class BillingController {
|
||||
}
|
||||
|
||||
@Get('billing/status')
|
||||
@ApiOperation({ summary: 'Check provisioning status for a checkout session' })
|
||||
@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) {
|
||||
// Lookup the org's stripe_customer_id
|
||||
// Only allow president or superadmin
|
||||
const orgId = req.user.orgId;
|
||||
if (!orgId) throw new BadRequestException('No organization context');
|
||||
// For now, we'd look this up from the org
|
||||
throw new BadRequestException('Portal session requires stripe_customer_id lookup — implement per org 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 };
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user