Complete SaaS self-service onboarding sprint: - Stripe-powered signup flow: pricing page → checkout → provisioning → activation - Refresh token infrastructure: 1h access tokens + 30-day httpOnly cookie refresh - TOTP MFA with QR setup, recovery codes, and login challenge flow - Google + Azure AD SSO (conditional on env vars) with account linking - WebAuthn passkey registration and passwordless login - Guided onboarding checklist with server-side progress tracking - Stubbed email service (console + DB logging, ready for real provider) - Settings page with tabbed security settings (MFA, passkeys, linked accounts) - Login page enhanced with MFA verification, SSO buttons, passkey login - Database migration 015 with all new tables and columns - Version bump to 2026.03.17 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
64 lines
2.3 KiB
TypeScript
64 lines
2.3 KiB
TypeScript
import {
|
|
Controller,
|
|
Post,
|
|
Get,
|
|
Body,
|
|
Query,
|
|
Req,
|
|
UseGuards,
|
|
RawBodyRequest,
|
|
BadRequestException,
|
|
Request,
|
|
} from '@nestjs/common';
|
|
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
|
import { Throttle } from '@nestjs/throttler';
|
|
import { Request as ExpressRequest } from 'express';
|
|
import { BillingService } from './billing.service';
|
|
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
|
|
|
@ApiTags('billing')
|
|
@Controller()
|
|
export class BillingController {
|
|
constructor(private billingService: BillingService) {}
|
|
|
|
@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 },
|
|
) {
|
|
if (!body.planId) throw new BadRequestException('planId is required');
|
|
return this.billingService.createCheckoutSession(body.planId, 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' })
|
|
async getStatus(@Query('session_id') sessionId: string) {
|
|
if (!sessionId) throw new BadRequestException('session_id required');
|
|
return this.billingService.getProvisioningStatus(sessionId);
|
|
}
|
|
|
|
@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');
|
|
}
|
|
}
|