diff --git a/backend/frontend/app/dashboard/billing/page.tsx b/backend/frontend/app/dashboard/billing/page.tsx new file mode 100644 index 00000000..f2784f42 --- /dev/null +++ b/backend/frontend/app/dashboard/billing/page.tsx @@ -0,0 +1,434 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { Progress } from '@/components/ui/progress'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table'; +import { AlertCircle, CheckCircle, CreditCard, TrendingUp, Zap } from 'lucide-react'; +import { api } from '@/lib/api'; + +interface SubscriptionPlan { + id: string; + name: string; + description: string; + basePrice: number; + currency: string; + billingInterval: 'monthly' | 'yearly'; + features: string[]; + usageLimits: Record; + meteredPricing: Array<{ + metricType: string; + unitPrice: number; + includedUnits: number; + }>; +} + +interface Subscription { + id: string; + status: string; + currentPeriodStart: string; + currentPeriodEnd: string; + cancelAtPeriodEnd: boolean; + plan: SubscriptionPlan; + currentPeriodUsage?: Array<{ + metricType: string; + totalQuantity: number; + }>; +} + +interface UsageSummary { + subscription: { + id: string; + planName: string; + currentPeriodStart: string; + currentPeriodEnd: string; + }; + usage: Array<{ + metricType: string; + totalUsage: number; + limit: number; + percentage: number; + remaining: number; + }>; +} + +interface Invoice { + id: string; + amount: number; + currency: string; + status: string; + periodStart: string; + periodEnd: string; + dueDate: string; + paidAt?: string; +} + +export default function BillingPage() { + const [subscriptions, setSubscriptions] = useState([]); + const [selectedSubscription, setSelectedSubscription] = useState(null); + const [usageSummary, setUsageSummary] = useState(null); + const [invoices, setInvoices] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + fetchSubscriptions(); + }, []); + + useEffect(() => { + if (selectedSubscription) { + fetchUsageSummary(selectedSubscription); + fetchInvoices(selectedSubscription); + } + }, [selectedSubscription]); + + const fetchSubscriptions = async () => { + try { + setLoading(true); + const response = await api.get('/api/v1/subscriptions'); + const data = response.data; + setSubscriptions(data.data || []); + if (data.data && data.data.length > 0) { + setSelectedSubscription(data.data[0].id); + } + } catch (error) { + console.error('Failed to fetch subscriptions:', error); + } finally { + setLoading(false); + } + }; + + const fetchUsageSummary = async (subscriptionId: string) => { + try { + const response = await api.get(`/api/v1/subscriptions/${subscriptionId}/usage/summary`); + setUsageSummary(response.data.data); + } catch (error) { + console.error('Failed to fetch usage summary:', error); + } + }; + + const fetchInvoices = async (subscriptionId: string) => { + try { + const response = await api.get(`/api/v1/subscriptions/${subscriptionId}/invoices`); + setInvoices(response.data.data || []); + } catch (error) { + console.error('Failed to fetch invoices:', error); + } + }; + + const handleCancelSubscription = async (subscriptionId: string) => { + if (!confirm('Are you sure you want to cancel your subscription?')) { + return; + } + + try { + await api.patch(`/api/v1/subscriptions/${subscriptionId}`, { + cancelAtPeriodEnd: true, + }); + fetchSubscriptions(); + alert('Subscription will be cancelled at the end of the current period'); + } catch (error) { + console.error('Failed to cancel subscription:', error); + alert('Failed to cancel subscription'); + } + }; + + const getStatusBadge = (status: string) => { + const statusConfig = { + active: { label: 'Active', variant: 'default' as const, icon: CheckCircle }, + trialing: { label: 'Trial', variant: 'secondary' as const, icon: Zap }, + past_due: { label: 'Past Due', variant: 'destructive' as const, icon: AlertCircle }, + canceled: { label: 'Cancelled', variant: 'outline' as const, icon: AlertCircle }, + unpaid: { label: 'Unpaid', variant: 'destructive' as const, icon: AlertCircle }, + }; + + const config = statusConfig[status as keyof typeof statusConfig] || statusConfig.active; + const Icon = config.icon; + + return ( + + + {config.label} + + ); + }; + + const formatMetricType = (metricType: string) => { + return metricType + .split('_') + .map(word => word.charAt(0).toUpperCase() + word.slice(1)) + .join(' '); + }; + + const formatCurrency = (amount: number, currency: string) => { + return new Intl.NumberFormat('en-US', { + style: 'currency', + currency: currency.toUpperCase(), + }).format(amount); + }; + + const formatDate = (dateString: string) => { + return new Date(dateString).toLocaleDateString('en-US', { + year: 'numeric', + month: 'short', + day: 'numeric', + }); + }; + + if (loading) { + return ( +
+
+
+ ); + } + + const activeSubscription = subscriptions.find(s => s.id === selectedSubscription); + + return ( +
+
+
+

Billing & Subscriptions

+

+ Manage your subscription plans and usage +

+
+ +
+ + {subscriptions.length === 0 ? ( + + +
+ +

No Active Subscription

+

+ Choose a plan to get started with AgenticPay +

+ +
+
+
+ ) : ( + <> + {/* Current Plan Overview */} + {activeSubscription && ( + + +
+
+ {activeSubscription.plan.name} + {activeSubscription.plan.description} +
+ {getStatusBadge(activeSubscription.status)} +
+
+ +
+
+

Price

+

+ {formatCurrency( + activeSubscription.plan.basePrice, + activeSubscription.plan.currency + )} + + /{activeSubscription.plan.billingInterval} + +

+
+
+

Current Period

+

+ {formatDate(activeSubscription.currentPeriodStart)} -{' '} + {formatDate(activeSubscription.currentPeriodEnd)} +

+
+
+

Renewal

+

+ {activeSubscription.cancelAtPeriodEnd + ? 'Cancelled at period end' + : `Auto-renews on ${formatDate(activeSubscription.currentPeriodEnd)}`} +

+
+
+ +
+ + +
+
+
+ )} + + {/* Usage & Invoices Tabs */} + + + Usage Metrics + Invoices + Features + + + + {usageSummary && usageSummary.usage.length > 0 ? ( +
+ {usageSummary.usage.map((metric) => ( + + +
+
+ + {formatMetricType(metric.metricType)} + + + {metric.totalUsage.toLocaleString()} /{' '} + {metric.limit.toLocaleString()} units + +
+ = 100 ? 'destructive' : metric.percentage >= 80 ? 'default' : 'secondary'}> + {metric.percentage.toFixed(1)}% + +
+
+ + +

+ {metric.remaining > 0 + ? `${metric.remaining.toLocaleString()} units remaining` + : 'Limit exceeded'} +

+
+
+ ))} +
+ ) : ( + + + +

+ No usage data for the current period +

+
+
+ )} +
+ + + + + Invoice History + Your billing history and invoices + + + {invoices.length > 0 ? ( + + + + Period + Amount + Status + Due Date + Paid + Actions + + + + {invoices.map((invoice) => ( + + + {formatDate(invoice.periodStart)} - {formatDate(invoice.periodEnd)} + + + {formatCurrency(invoice.amount, invoice.currency)} + + + + {invoice.status} + + + {formatDate(invoice.dueDate)} + + {invoice.paidAt ? formatDate(invoice.paidAt) : '-'} + + + + + + ))} + +
+ ) : ( +
+

No invoices yet

+
+ )} +
+
+
+ + + {activeSubscription && ( + + + Plan Features + + Features included in your {activeSubscription.plan.name} plan + + + +
    + {activeSubscription.plan.features?.map((feature, index) => ( +
  • + + {feature} +
  • + ))} +
+ + {activeSubscription.plan.meteredPricing?.length > 0 && ( +
+

Metered Pricing

+
+ {activeSubscription.plan.meteredPricing.map((pricing) => ( +
+ {formatMetricType(pricing.metricType)} + + {formatCurrency(pricing.unitPrice, activeSubscription.plan.currency)}{' '} + per unit (first {pricing.includedUnits.toLocaleString()} included) + +
+ ))} +
+
+ )} +
+
+ )} +
+
+ + )} +
+ ); +} diff --git a/backend/prisma/migrations/20260101000000_subscription_billing/migration.sql b/backend/prisma/migrations/20260101000000_subscription_billing/migration.sql new file mode 100644 index 00000000..b3f2f3c8 --- /dev/null +++ b/backend/prisma/migrations/20260101000000_subscription_billing/migration.sql @@ -0,0 +1,161 @@ +-- Subscription billing with metered usage and tiered pricing (Issue #570) + +-- Create subscription-related enums +CREATE TYPE "SubscriptionStatus" AS ENUM ('active', 'past_due', 'canceled', 'unpaid', 'trialing', 'paused'); +CREATE TYPE "UsageMetricType" AS ENUM ('api_calls', 'storage_gb', 'compute_hours', 'transactions', 'custom'); +CREATE TYPE "BillingInterval" AS ENUM ('monthly', 'yearly'); + +-- Subscription plans table +CREATE TABLE "subscription_plans" ( + "id" TEXT PRIMARY KEY, + "tenant_id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "description" TEXT, + "stripe_price_id" TEXT, + "base_price" DECIMAL(10, 2) NOT NULL, + "currency" TEXT NOT NULL DEFAULT 'USD', + "billing_interval" "BillingInterval" NOT NULL, + "features" JSONB, + "usage_limits" JSONB, + "is_active" BOOLEAN NOT NULL DEFAULT true, + "trial_days" INTEGER NOT NULL DEFAULT 0, + "grace_period_days" INTEGER NOT NULL DEFAULT 3, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + "deleted_at" TIMESTAMP(3), + CONSTRAINT "subscription_plans_tenant_id_name_key" UNIQUE ("tenant_id", "name") +); + +CREATE INDEX "subscription_plans_tenant_id_idx" ON "subscription_plans" ("tenant_id"); +CREATE INDEX "subscription_plans_is_active_idx" ON "subscription_plans" ("is_active"); + +-- Metered pricing configuration +CREATE TABLE "metered_pricing" ( + "id" TEXT PRIMARY KEY, + "plan_id" TEXT NOT NULL, + "metric_type" "UsageMetricType" NOT NULL, + "stripe_meter_id" TEXT, + "unit_price" DECIMAL(10, 4) NOT NULL, + "currency" TEXT NOT NULL DEFAULT 'USD', + "included_units" INTEGER NOT NULL DEFAULT 0, + "tiers" JSONB, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + CONSTRAINT "metered_pricing_plan_id_metric_type_key" UNIQUE ("plan_id", "metric_type"), + CONSTRAINT "metered_pricing_plan_id_fkey" FOREIGN KEY ("plan_id") REFERENCES "subscription_plans" ("id") ON DELETE RESTRICT ON UPDATE CASCADE +); + +CREATE INDEX "metered_pricing_plan_id_idx" ON "metered_pricing" ("plan_id"); + +-- Subscriptions table +CREATE TABLE "subscriptions" ( + "id" TEXT PRIMARY KEY, + "tenant_id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "plan_id" TEXT NOT NULL, + "stripe_subscription_id" TEXT UNIQUE, + "stripe_customer_id" TEXT, + "status" "SubscriptionStatus" NOT NULL DEFAULT 'trialing', + "current_period_start" TIMESTAMP(3) NOT NULL, + "current_period_end" TIMESTAMP(3) NOT NULL, + "cancel_at_period_end" BOOLEAN NOT NULL DEFAULT false, + "canceled_at" TIMESTAMP(3), + "trial_start" TIMESTAMP(3), + "trial_end" TIMESTAMP(3), + "metadata" JSONB, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + "deleted_at" TIMESTAMP(3), + CONSTRAINT "subscriptions_plan_id_fkey" FOREIGN KEY ("plan_id") REFERENCES "subscription_plans" ("id") ON DELETE RESTRICT ON UPDATE CASCADE +); + +CREATE INDEX "subscriptions_tenant_id_idx" ON "subscriptions" ("tenant_id"); +CREATE INDEX "subscriptions_user_id_idx" ON "subscriptions" ("user_id"); +CREATE INDEX "subscriptions_plan_id_idx" ON "subscriptions" ("plan_id"); +CREATE INDEX "subscriptions_status_idx" ON "subscriptions" ("status"); +CREATE INDEX "subscriptions_stripe_subscription_id_idx" ON "subscriptions" ("stripe_subscription_id"); + +-- Usage records table +CREATE TABLE "usage_records" ( + "id" TEXT PRIMARY KEY, + "subscription_id" TEXT NOT NULL, + "tenant_id" TEXT NOT NULL, + "metric_type" "UsageMetricType" NOT NULL, + "quantity" INTEGER NOT NULL, + "timestamp" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "metadata" JSONB, + "aggregated_at" TIMESTAMP(3), + "billed_at" TIMESTAMP(3), + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "usage_records_subscription_id_fkey" FOREIGN KEY ("subscription_id") REFERENCES "subscriptions" ("id") ON DELETE RESTRICT ON UPDATE CASCADE +); + +CREATE INDEX "usage_records_subscription_id_idx" ON "usage_records" ("subscription_id"); +CREATE INDEX "usage_records_tenant_id_idx" ON "usage_records" ("tenant_id"); +CREATE INDEX "usage_records_metric_type_idx" ON "usage_records" ("metric_type"); +CREATE INDEX "usage_records_timestamp_idx" ON "usage_records" ("timestamp"); +CREATE INDEX "usage_records_aggregated_at_idx" ON "usage_records" ("aggregated_at"); + +-- Usage aggregates table +CREATE TABLE "usage_aggregates" ( + "id" TEXT PRIMARY KEY, + "subscription_id" TEXT NOT NULL, + "tenant_id" TEXT NOT NULL, + "metric_type" "UsageMetricType" NOT NULL, + "total_quantity" INTEGER NOT NULL, + "period_start" TIMESTAMP(3) NOT NULL, + "period_end" TIMESTAMP(3) NOT NULL, + "cost" DECIMAL(10, 4) NOT NULL, + "currency" TEXT NOT NULL DEFAULT 'USD', + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + CONSTRAINT "usage_aggregates_subscription_id_metric_type_period_start_key" UNIQUE ("subscription_id", "metric_type", "period_start") +); + +CREATE INDEX "usage_aggregates_subscription_id_idx" ON "usage_aggregates" ("subscription_id"); +CREATE INDEX "usage_aggregates_tenant_id_idx" ON "usage_aggregates" ("tenant_id"); +CREATE INDEX "usage_aggregates_period_start_idx" ON "usage_aggregates" ("period_start"); + +-- Subscription invoices table +CREATE TABLE "subscription_invoices" ( + "id" TEXT PRIMARY KEY, + "subscription_id" TEXT NOT NULL, + "tenant_id" TEXT NOT NULL, + "stripe_invoice_id" TEXT UNIQUE, + "amount" DECIMAL(10, 2) NOT NULL, + "currency" TEXT NOT NULL DEFAULT 'USD', + "status" TEXT NOT NULL, + "period_start" TIMESTAMP(3) NOT NULL, + "period_end" TIMESTAMP(3) NOT NULL, + "due_date" TIMESTAMP(3) NOT NULL, + "paid_at" TIMESTAMP(3), + "attempt_count" INTEGER NOT NULL DEFAULT 0, + "next_payment_attempt" TIMESTAMP(3), + "metadata" JSONB, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + CONSTRAINT "subscription_invoices_subscription_id_fkey" FOREIGN KEY ("subscription_id") REFERENCES "subscriptions" ("id") ON DELETE RESTRICT ON UPDATE CASCADE +); + +CREATE INDEX "subscription_invoices_subscription_id_idx" ON "subscription_invoices" ("subscription_id"); +CREATE INDEX "subscription_invoices_tenant_id_idx" ON "subscription_invoices" ("tenant_id"); +CREATE INDEX "subscription_invoices_status_idx" ON "subscription_invoices" ("status"); +CREATE INDEX "subscription_invoices_due_date_idx" ON "subscription_invoices" ("due_date"); + +-- Usage alerts table +CREATE TABLE "usage_alerts" ( + "id" TEXT PRIMARY KEY, + "subscription_id" TEXT NOT NULL, + "tenant_id" TEXT NOT NULL, + "metric_type" "UsageMetricType" NOT NULL, + "threshold" INTEGER NOT NULL, + "triggered" BOOLEAN NOT NULL DEFAULT false, + "triggered_at" TIMESTAMP(3), + "notified_at" TIMESTAMP(3), + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + CONSTRAINT "usage_alerts_subscription_id_metric_type_threshold_key" UNIQUE ("subscription_id", "metric_type", "threshold") +); + +CREATE INDEX "usage_alerts_subscription_id_idx" ON "usage_alerts" ("subscription_id"); +CREATE INDEX "usage_alerts_triggered_idx" ON "usage_alerts" ("triggered"); diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index be3ff720..7a0cf273 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -870,6 +870,193 @@ model EmailAnalytics { @@map("email_analytics") } +// ─── Subscription & Billing Models ───────────────────────────────────────────── + +enum SubscriptionStatus { + active + past_due + canceled + unpaid + trialing + paused +} + +enum UsageMetricType { + api_calls + storage_gb + compute_hours + transactions + custom +} + +enum BillingInterval { + monthly + yearly +} + +model SubscriptionPlan { + id String @id @default(uuid()) + tenantId String @map("tenant_id") + name String + description String? + stripePriceId String? @map("stripe_price_id") + basePrice Decimal @db.Decimal(10, 2) + currency String @default("USD") + billingInterval BillingInterval @map("billing_interval") + features Json? // Array of feature names + usageLimits Json? @map("usage_limits") // { api_calls: 10000, storage_gb: 100 } + isActive Boolean @default(true) @map("is_active") + trialDays Int @default(0) @map("trial_days") + gracePeriodDays Int @default(3) @map("grace_period_days") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + deletedAt DateTime? @map("deleted_at") + + subscriptions Subscription[] + meteredPricing MeteredPricing[] + + @@unique([tenantId, name]) + @@index([tenantId]) + @@index([isActive]) + @@map("subscription_plans") +} + +model MeteredPricing { + id String @id @default(uuid()) + planId String @map("plan_id") + metricType UsageMetricType @map("metric_type") + stripeMeterId String? @map("stripe_meter_id") + unitPrice Decimal @db.Decimal(10, 4) @map("unit_price") + currency String @default("USD") + includedUnits Int @default(0) @map("included_units") // Free tier + tiers Json? // Volume discount tiers: [{ upTo: 1000, price: 0.01 }, { upTo: null, price: 0.005 }] + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + plan SubscriptionPlan @relation(fields: [planId], references: [id]) + + @@unique([planId, metricType]) + @@index([planId]) + @@map("metered_pricing") +} + +model Subscription { + id String @id @default(uuid()) + tenantId String @map("tenant_id") + userId String @map("user_id") + planId String @map("plan_id") + stripeSubscriptionId String? @unique @map("stripe_subscription_id") + stripeCustomerId String? @map("stripe_customer_id") + status SubscriptionStatus @default(trialing) + currentPeriodStart DateTime @map("current_period_start") + currentPeriodEnd DateTime @map("current_period_end") + cancelAtPeriodEnd Boolean @default(false) @map("cancel_at_period_end") + canceledAt DateTime? @map("canceled_at") + trialStart DateTime? @map("trial_start") + trialEnd DateTime? @map("trial_end") + metadata Json? + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + deletedAt DateTime? @map("deleted_at") + + plan SubscriptionPlan @relation(fields: [planId], references: [id]) + usageRecords UsageRecord[] + invoices SubscriptionInvoice[] + + @@index([tenantId]) + @@index([userId]) + @@index([planId]) + @@index([status]) + @@index([stripeSubscriptionId]) + @@map("subscriptions") +} + +model UsageRecord { + id String @id @default(uuid()) + subscriptionId String @map("subscription_id") + tenantId String @map("tenant_id") + metricType UsageMetricType @map("metric_type") + quantity Int + timestamp DateTime @default(now()) + metadata Json? + aggregatedAt DateTime? @map("aggregated_at") + billedAt DateTime? @map("billed_at") + createdAt DateTime @default(now()) @map("created_at") + + subscription Subscription @relation(fields: [subscriptionId], references: [id]) + + @@index([subscriptionId]) + @@index([tenantId]) + @@index([metricType]) + @@index([timestamp]) + @@index([aggregatedAt]) + @@map("usage_records") +} + +model UsageAggregate { + id String @id @default(uuid()) + subscriptionId String @map("subscription_id") + tenantId String @map("tenant_id") + metricType UsageMetricType @map("metric_type") + totalQuantity Int @map("total_quantity") + periodStart DateTime @map("period_start") + periodEnd DateTime @map("period_end") + cost Decimal @db.Decimal(10, 4) + currency String @default("USD") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + @@unique([subscriptionId, metricType, periodStart]) + @@index([subscriptionId]) + @@index([tenantId]) + @@index([periodStart]) + @@map("usage_aggregates") +} + +model SubscriptionInvoice { + id String @id @default(uuid()) + subscriptionId String @map("subscription_id") + tenantId String @map("tenant_id") + stripeInvoiceId String? @unique @map("stripe_invoice_id") + amount Decimal @db.Decimal(10, 2) + currency String @default("USD") + status String // draft, open, paid, void, uncollectible + periodStart DateTime @map("period_start") + periodEnd DateTime @map("period_end") + dueDate DateTime @map("due_date") + paidAt DateTime? @map("paid_at") + attemptCount Int @default(0) @map("attempt_count") + nextPaymentAttempt DateTime? @map("next_payment_attempt") + metadata Json? + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + subscription Subscription @relation(fields: [subscriptionId], references: [id]) + + @@index([subscriptionId]) + @@index([tenantId]) + @@index([status]) + @@index([dueDate]) + @@map("subscription_invoices") +} + +model UsageAlert { + id String @id @default(uuid()) + subscriptionId String @map("subscription_id") + tenantId String @map("tenant_id") + metricType UsageMetricType @map("metric_type") + threshold Int // Percentage: 80 or 100 + triggered Boolean @default(false) + triggeredAt DateTime? @map("triggered_at") + notifiedAt DateTime? @map("notified_at") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + @@unique([subscriptionId, metricType, threshold]) + @@index([subscriptionId]) + @@index([triggered]) + @@map("usage_alerts") +} // ─── Push Notification Models ────────────────────────────────────────────────── model PushSubscription { diff --git a/backend/src/config/scheduled-tasks.ts b/backend/src/config/scheduled-tasks.ts index 63282faf..709d6843 100644 --- a/backend/src/config/scheduled-tasks.ts +++ b/backend/src/config/scheduled-tasks.ts @@ -19,6 +19,7 @@ import { markOverdueRequests } from '../services/gdpr.js'; import { sandboxCleanupJobs } from '../jobs/sandbox-cleanup.js'; import { SubscriptionService } from '../services/subscription.service.js'; import { SubscriptionProcessor } from '../jobs/subscription-processor.js'; +import { aggregateUsage, checkUsageAlerts, processDunning } from '../jobs/usageAggregation.js'; import { getArchivalService } from '../services/archival/index.js'; import { getBridgeMonitorService } from '../services/bridge-monitor/bridge-monitor.js'; import { ethers } from 'ethers'; @@ -161,6 +162,28 @@ const RAW_TASKS: Omit & { defaultSchedule: string handler: sandboxCleanupJobs.find((j) => j.id === 'sandbox-maintenance-stats')!.handler, }, { + id: 'usage-aggregation', + name: 'Usage Aggregation and Billing', + description: 'Aggregates metered usage records and syncs to Stripe for subscription billing.', + defaultSchedule: '0 * * * *', // Hourly + timeoutMs: 10 * 60 * 1000, // 10 minutes + handler: aggregateUsage, + }, + { + id: 'usage-alerts-check', + name: 'Usage Alerts Check', + description: 'Checks subscription usage against limits and triggers alerts at 80% and 100%.', + defaultSchedule: '0 * * * *', // Hourly + timeoutMs: 5 * 60 * 1000, // 5 minutes + handler: checkUsageAlerts, + }, + { + id: 'subscription-dunning', + name: 'Subscription Dunning Process', + description: 'Processes failed payments with retry logic and grace period management.', + defaultSchedule: '0 0 * * *', // Daily + timeoutMs: 15 * 60 * 1000, // 15 minutes + handler: processDunning, id: 'daily-onchain-archival', name: 'Daily On-Chain Data Archival', description: 'Backs up transaction data, event logs, and contract state to IPFS with integrity verification.', diff --git a/backend/src/index.ts b/backend/src/index.ts index f963fd9a..ba413959 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -91,6 +91,7 @@ import { createAnalyticsRouter } from './routes/analytics.js'; import { paymentQueue } from './queue/payment-queue.js'; import './events/projections.js'; import { stripeRouter } from './routes/stripe.js'; +import subscriptionsRouter from './routes/subscriptions.js'; import { SecurityMiddleware, SecurityMonitor } from './middleware/security.js'; import { sanitizeInput, contentSecurityPolicy } from './middleware/sanitize.js'; import { requestSizeLimit } from './middleware/request-size-limit.js'; @@ -439,6 +440,9 @@ app.use('/api/v1/sandbox', sandboxRateLimiter, sandboxRouter); // Email system v2 with templates, analytics, and localization app.use('/api/v2/email', emailV2Router); +// Subscription billing with metered usage and tiered pricing (Issue #570) +app.use('/api/v1/subscriptions', subscriptionsRouter); + // GraphQL gateway with federation-ready schema and subscriptions stream app.use('/graphql', graphQLRouter); app.use('/graphql/ws', graphQLWsRouter); diff --git a/backend/src/jobs/usageAggregation.ts b/backend/src/jobs/usageAggregation.ts new file mode 100644 index 00000000..f89a6207 --- /dev/null +++ b/backend/src/jobs/usageAggregation.ts @@ -0,0 +1,371 @@ +import { PrismaClient } from '@prisma/client'; +import * as stripeService from '../services/stripe.js'; +import { logger } from '../logging/logger.js'; + +const prisma = new PrismaClient(); + +/** + * Aggregate usage records and sync to Stripe + * Should run hourly via cron job + */ +export async function aggregateUsage(): Promise { + logger.info('Starting usage aggregation job'); + + try { + // Get all active subscriptions + const subscriptions = await prisma.subscription.findMany({ + where: { + status: { in: ['active', 'trialing'] }, + deletedAt: null, + }, + include: { + plan: { + include: { meteredPricing: true }, + }, + }, + }); + + logger.info(`Found ${subscriptions.length} active subscriptions`); + + for (const subscription of subscriptions) { + try { + await aggregateSubscriptionUsage(subscription.id); + } catch (error) { + logger.error(`Failed to aggregate usage for subscription ${subscription.id}`, { error }); + } + } + + logger.info('Usage aggregation job completed'); + } catch (error) { + logger.error('Usage aggregation job failed', { error }); + throw error; + } +} + +/** + * Aggregate usage for a single subscription + */ +async function aggregateSubscriptionUsage(subscriptionId: string): Promise { + const subscription = await prisma.subscription.findUnique({ + where: { id: subscriptionId }, + include: { + plan: { + include: { meteredPricing: true }, + }, + }, + }); + + if (!subscription) { + return; + } + + // Get unaggregated usage records + const usageRecords = await prisma.usageRecord.findMany({ + where: { + subscriptionId, + aggregatedAt: null, + timestamp: { + gte: subscription.currentPeriodStart, + lte: subscription.currentPeriodEnd, + }, + }, + }); + + if (usageRecords.length === 0) { + return; + } + + // Group by metric type + const usageByMetric = usageRecords.reduce((acc, record) => { + if (!acc[record.metricType]) { + acc[record.metricType] = []; + } + acc[record.metricType].push(record); + return acc; + }, {} as Record); + + // Process each metric type + for (const [metricType, records] of Object.entries(usageByMetric)) { + const totalQuantity = records.reduce((sum, r) => sum + r.quantity, 0); + + // Find pricing config + const pricingConfig = subscription.plan.meteredPricing.find( + m => m.metricType === metricType + ); + + if (!pricingConfig) { + logger.warn(`No pricing config found for metric ${metricType}`); + continue; + } + + // Calculate cost based on tiered pricing + const cost = calculateCost(totalQuantity, pricingConfig); + + // Create or update aggregate + const periodStart = subscription.currentPeriodStart; + const periodEnd = new Date(); + + await prisma.usageAggregate.upsert({ + where: { + subscriptionId_metricType_periodStart: { + subscriptionId, + metricType: metricType as any, + periodStart, + }, + }, + create: { + subscriptionId, + tenantId: subscription.tenantId, + metricType: metricType as any, + totalQuantity, + periodStart, + periodEnd, + cost, + currency: pricingConfig.currency, + }, + update: { + totalQuantity: { increment: totalQuantity }, + periodEnd, + cost: { increment: cost }, + }, + }); + + // Mark records as aggregated + await prisma.usageRecord.updateMany({ + where: { + id: { in: records.map(r => r.id) }, + }, + data: { + aggregatedAt: new Date(), + }, + }); + + // Sync to Stripe if subscription has Stripe ID + if (subscription.stripeSubscriptionId && pricingConfig.stripeMeterId) { + try { + await stripeService.recordUsage({ + subscriptionItemId: pricingConfig.stripeMeterId, + quantity: totalQuantity, + action: 'increment', + }); + logger.info(`Synced ${totalQuantity} units of ${metricType} to Stripe`); + } catch (error) { + logger.error(`Failed to sync usage to Stripe`, { error, subscriptionId, metricType }); + } + } + } +} + +/** + * Calculate cost based on tiered pricing + */ +function calculateCost(quantity: number, pricing: any): number { + const includedUnits = pricing.includedUnits || 0; + const billableQuantity = Math.max(0, quantity - includedUnits); + + if (billableQuantity === 0) { + return 0; + } + + // If no tiers, use flat unit price + if (!pricing.tiers || pricing.tiers.length === 0) { + return billableQuantity * Number(pricing.unitPrice); + } + + // Calculate with tiered pricing + let cost = 0; + let remaining = billableQuantity; + let previousTier = 0; + + for (const tier of pricing.tiers) { + const tierLimit = tier.upTo || Infinity; + const tierSize = tierLimit - previousTier; + const unitsInTier = Math.min(remaining, tierSize); + + cost += unitsInTier * tier.price; + remaining -= unitsInTier; + + if (remaining <= 0) { + break; + } + + previousTier = tierLimit; + } + + return cost; +} + +/** + * Check usage alerts and send notifications + * Should run hourly via cron job + */ +export async function checkUsageAlerts(): Promise { + logger.info('Starting usage alerts check'); + + try { + const activeSubscriptions = await prisma.subscription.findMany({ + where: { + status: { in: ['active', 'trialing'] }, + deletedAt: null, + }, + include: { + plan: true, + }, + }); + + for (const subscription of activeSubscriptions) { + const usageLimits = subscription.plan.usageLimits as Record | null; + if (!usageLimits) { + continue; + } + + // Get current usage by metric + const currentUsage = await prisma.usageRecord.groupBy({ + by: ['metricType'], + where: { + subscriptionId: subscription.id, + timestamp: { + gte: subscription.currentPeriodStart, + lte: subscription.currentPeriodEnd, + }, + }, + _sum: { quantity: true }, + }); + + // Check each metric against limits + for (const usage of currentUsage) { + const limit = usageLimits[usage.metricType]; + if (!limit) { + continue; + } + + const totalUsage = usage._sum.quantity || 0; + const percentage = (totalUsage / limit) * 100; + + // Trigger alerts at 80% and 100% + for (const threshold of [80, 100]) { + if (percentage >= threshold) { + const alert = await prisma.usageAlert.findUnique({ + where: { + subscriptionId_metricType_threshold: { + subscriptionId: subscription.id, + metricType: usage.metricType, + threshold, + }, + }, + }); + + if (alert && !alert.triggered) { + await prisma.usageAlert.update({ + where: { id: alert.id }, + data: { + triggered: true, + triggeredAt: new Date(), + }, + }); + + // TODO: Send notification email/webhook + logger.info(`Usage alert triggered: ${usage.metricType} at ${threshold}%`, { + subscriptionId: subscription.id, + totalUsage, + limit, + }); + } + } + } + } + } + + logger.info('Usage alerts check completed'); + } catch (error) { + logger.error('Usage alerts check failed', { error }); + throw error; + } +} + +/** + * Process failed payments and dunning + * Should run daily via cron job + */ +export async function processDunning(): Promise { + logger.info('Starting dunning process'); + + try { + const pastDueSubscriptions = await prisma.subscription.findMany({ + where: { + status: 'past_due', + deletedAt: null, + }, + include: { + plan: true, + invoices: { + where: { status: { in: ['open', 'uncollectible'] } }, + orderBy: { createdAt: 'desc' }, + }, + }, + }); + + for (const subscription of pastDueSubscriptions) { + const gracePeriodDays = subscription.plan.gracePeriodDays || 3; + const gracePeriodEnd = new Date( + subscription.currentPeriodEnd.getTime() + gracePeriodDays * 24 * 60 * 60 * 1000 + ); + + const now = new Date(); + + if (now > gracePeriodEnd) { + // Grace period expired - suspend subscription + await prisma.subscription.update({ + where: { id: subscription.id }, + data: { + status: 'unpaid', + canceledAt: now, + }, + }); + + logger.info(`Subscription ${subscription.id} suspended after grace period`); + } else { + // Within grace period - retry payment + const latestInvoice = subscription.invoices[0]; + if (latestInvoice && latestInvoice.stripeInvoiceId) { + try { + const paidInvoice = await stripeService.payInvoice(latestInvoice.stripeInvoiceId); + + if (paidInvoice.status === 'paid') { + await prisma.subscriptionInvoice.update({ + where: { id: latestInvoice.id }, + data: { + status: 'paid', + paidAt: new Date(), + }, + }); + + await prisma.subscription.update({ + where: { id: subscription.id }, + data: { status: 'active' }, + }); + + logger.info(`Payment successful for subscription ${subscription.id}`); + } + } catch (error) { + // Payment failed - increment attempt count + await prisma.subscriptionInvoice.update({ + where: { id: latestInvoice.id }, + data: { + attemptCount: { increment: 1 }, + nextPaymentAttempt: new Date(Date.now() + 24 * 60 * 60 * 1000), // Retry in 24 hours + }, + }); + + logger.warn(`Payment retry failed for subscription ${subscription.id}`, { error }); + } + } + } + } + + logger.info('Dunning process completed'); + } catch (error) { + logger.error('Dunning process failed', { error }); + throw error; + } +} diff --git a/backend/src/routes/subscriptions.ts b/backend/src/routes/subscriptions.ts new file mode 100644 index 00000000..271076ea --- /dev/null +++ b/backend/src/routes/subscriptions.ts @@ -0,0 +1,547 @@ +import { Router } from 'express'; +import { z } from 'zod'; +import { PrismaClient } from '@prisma/client'; +import * as stripeService from '../services/stripe.js'; +import { AppError } from '../middleware/errorHandler.js'; + +const router = Router(); +const prisma = new PrismaClient(); + +// ── Validation Schemas ─────────────────────────────────────────────────────── + +const CreateSubscriptionSchema = z.object({ + planId: z.string().uuid(), + userId: z.string().uuid(), + paymentMethodId: z.string().optional(), +}); + +const UpdateSubscriptionSchema = z.object({ + planId: z.string().uuid().optional(), + cancelAtPeriodEnd: z.boolean().optional(), +}); + +const RecordUsageSchema = z.object({ + metricType: z.enum(['api_calls', 'storage_gb', 'compute_hours', 'transactions', 'custom']), + quantity: z.number().int().positive(), + metadata: z.record(z.string()).optional(), +}); + +// ── Routes ─────────────────────────────────────────────────────────────────── + +/** + * POST /api/subscriptions + * Create a new subscription for a user + */ +router.post('/', async (req, res, next) => { + try { + const { planId, userId, paymentMethodId } = CreateSubscriptionSchema.parse(req.body); + const tenantId = req.headers['x-tenant-id'] as string; + + if (!tenantId) { + throw new AppError(400, 'Tenant ID required', 'TENANT_ID_REQUIRED'); + } + + // Get plan details + const plan = await prisma.subscriptionPlan.findFirst({ + where: { id: planId, tenantId, isActive: true, deletedAt: null }, + include: { meteredPricing: true }, + }); + + if (!plan) { + throw new AppError(404, 'Plan not found', 'PLAN_NOT_FOUND'); + } + + // Get or create Stripe customer + const user = await prisma.user.findUnique({ where: { id: userId } }); + if (!user) { + throw new AppError(404, 'User not found', 'USER_NOT_FOUND'); + } + + let stripeCustomerId = user.walletAddress; // Temporary - should store in separate field + + // Create Stripe customer if doesn't exist + const stripeCustomer = await stripeService.createCustomer(user.email, `User ${userId}`); + stripeCustomerId = stripeCustomer.id; + + // Create Stripe subscription + const stripeSubscription = await stripeService.createSubscription({ + customerId: stripeCustomerId, + priceId: plan.stripePriceId!, + trialPeriodDays: plan.trialDays > 0 ? plan.trialDays : undefined, + metadata: { + planId, + userId, + tenantId, + }, + }); + + // Create subscription record + const now = new Date(); + const periodEnd = new Date(stripeSubscription.current_period_end * 1000); + const subscription = await prisma.subscription.create({ + data: { + tenantId, + userId, + planId, + stripeSubscriptionId: stripeSubscription.id, + stripeCustomerId, + status: stripeSubscription.status === 'trialing' ? 'trialing' : 'active', + currentPeriodStart: now, + currentPeriodEnd: periodEnd, + trialStart: plan.trialDays > 0 ? now : null, + trialEnd: plan.trialDays > 0 ? new Date(now.getTime() + plan.trialDays * 24 * 60 * 60 * 1000) : null, + }, + include: { + plan: { + include: { meteredPricing: true }, + }, + }, + }); + + // Create usage alerts at 80% and 100% + for (const pricing of plan.meteredPricing) { + await prisma.usageAlert.createMany({ + data: [ + { + subscriptionId: subscription.id, + tenantId, + metricType: pricing.metricType, + threshold: 80, + }, + { + subscriptionId: subscription.id, + tenantId, + metricType: pricing.metricType, + threshold: 100, + }, + ], + }); + } + + res.status(201).json({ + success: true, + data: subscription, + }); + } catch (error) { + next(error); + } +}); + +/** + * GET /api/subscriptions + * List all subscriptions for a tenant + */ +router.get('/', async (req, res, next) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const userId = req.query.userId as string | undefined; + + if (!tenantId) { + throw new AppError(400, 'Tenant ID required', 'TENANT_ID_REQUIRED'); + } + + const subscriptions = await prisma.subscription.findMany({ + where: { + tenantId, + userId, + deletedAt: null, + }, + include: { + plan: { + include: { meteredPricing: true }, + }, + }, + orderBy: { createdAt: 'desc' }, + }); + + res.json({ + success: true, + data: subscriptions, + }); + } catch (error) { + next(error); + } +}); + +/** + * GET /api/subscriptions/:id + * Get subscription details with current usage + */ +router.get('/:id', async (req, res, next) => { + try { + const { id } = req.params; + const tenantId = req.headers['x-tenant-id'] as string; + + if (!tenantId) { + throw new AppError(400, 'Tenant ID required', 'TENANT_ID_REQUIRED'); + } + + const subscription = await prisma.subscription.findFirst({ + where: { id, tenantId, deletedAt: null }, + include: { + plan: { + include: { meteredPricing: true }, + }, + usageRecords: { + where: { + timestamp: { + gte: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000), // Last 30 days + }, + }, + orderBy: { timestamp: 'desc' }, + take: 100, + }, + }, + }); + + if (!subscription) { + throw new AppError(404, 'Subscription not found', 'SUBSCRIPTION_NOT_FOUND'); + } + + // Calculate current period usage + const currentPeriodUsage = await prisma.usageRecord.groupBy({ + by: ['metricType'], + where: { + subscriptionId: id, + timestamp: { + gte: subscription.currentPeriodStart, + lte: subscription.currentPeriodEnd, + }, + }, + _sum: { + quantity: true, + }, + }); + + res.json({ + success: true, + data: { + ...subscription, + currentPeriodUsage: currentPeriodUsage.map(u => ({ + metricType: u.metricType, + totalQuantity: u._sum.quantity || 0, + })), + }, + }); + } catch (error) { + next(error); + } +}); + +/** + * PATCH /api/subscriptions/:id + * Update subscription (upgrade/downgrade plan or cancel) + */ +router.patch('/:id', async (req, res, next) => { + try { + const { id } = req.params; + const tenantId = req.headers['x-tenant-id'] as string; + const updates = UpdateSubscriptionSchema.parse(req.body); + + if (!tenantId) { + throw new AppError(400, 'Tenant ID required', 'TENANT_ID_REQUIRED'); + } + + const subscription = await prisma.subscription.findFirst({ + where: { id, tenantId, deletedAt: null }, + }); + + if (!subscription) { + throw new AppError(404, 'Subscription not found', 'SUBSCRIPTION_NOT_FOUND'); + } + + // Handle plan change + if (updates.planId) { + const newPlan = await prisma.subscriptionPlan.findFirst({ + where: { id: updates.planId, tenantId, isActive: true, deletedAt: null }, + }); + + if (!newPlan) { + throw new AppError(404, 'New plan not found', 'PLAN_NOT_FOUND'); + } + + // Update Stripe subscription + if (subscription.stripeSubscriptionId && newPlan.stripePriceId) { + await stripeService.updateSubscription(subscription.stripeSubscriptionId, { + items: [{ + id: subscription.stripeSubscriptionId, + price: newPlan.stripePriceId, + }], + proration_behavior: 'create_prorations', + }); + } + + await prisma.subscription.update({ + where: { id }, + data: { planId: updates.planId }, + }); + } + + // Handle cancellation + if (updates.cancelAtPeriodEnd !== undefined) { + if (subscription.stripeSubscriptionId) { + await stripeService.cancelSubscription( + subscription.stripeSubscriptionId, + updates.cancelAtPeriodEnd + ); + } + + await prisma.subscription.update({ + where: { id }, + data: { + cancelAtPeriodEnd: updates.cancelAtPeriodEnd, + canceledAt: updates.cancelAtPeriodEnd ? new Date() : null, + }, + }); + } + + const updatedSubscription = await prisma.subscription.findUnique({ + where: { id }, + include: { plan: true }, + }); + + res.json({ + success: true, + data: updatedSubscription, + }); + } catch (error) { + next(error); + } +}); + +/** + * DELETE /api/subscriptions/:id + * Cancel subscription immediately + */ +router.delete('/:id', async (req, res, next) => { + try { + const { id } = req.params; + const tenantId = req.headers['x-tenant-id'] as string; + + if (!tenantId) { + throw new AppError(400, 'Tenant ID required', 'TENANT_ID_REQUIRED'); + } + + const subscription = await prisma.subscription.findFirst({ + where: { id, tenantId, deletedAt: null }, + }); + + if (!subscription) { + throw new AppError(404, 'Subscription not found', 'SUBSCRIPTION_NOT_FOUND'); + } + + // Cancel in Stripe + if (subscription.stripeSubscriptionId) { + await stripeService.cancelSubscription(subscription.stripeSubscriptionId, false); + } + + // Soft delete + await prisma.subscription.update({ + where: { id }, + data: { + status: 'canceled', + canceledAt: new Date(), + deletedAt: new Date(), + }, + }); + + res.json({ + success: true, + message: 'Subscription cancelled', + }); + } catch (error) { + next(error); + } +}); + +/** + * POST /api/subscriptions/:id/usage + * Record usage for metered billing + */ +router.post('/:id/usage', async (req, res, next) => { + try { + const { id } = req.params; + const tenantId = req.headers['x-tenant-id'] as string; + const { metricType, quantity, metadata } = RecordUsageSchema.parse(req.body); + + if (!tenantId) { + throw new AppError(400, 'Tenant ID required', 'TENANT_ID_REQUIRED'); + } + + const subscription = await prisma.subscription.findFirst({ + where: { id, tenantId, deletedAt: null, status: { in: ['active', 'trialing'] } }, + include: { plan: { include: { meteredPricing: true } } }, + }); + + if (!subscription) { + throw new AppError(404, 'Active subscription not found', 'SUBSCRIPTION_NOT_FOUND'); + } + + // Verify metric type is configured for this plan + const metricConfig = subscription.plan.meteredPricing.find(m => m.metricType === metricType); + if (!metricConfig) { + throw new AppError(400, 'Metric type not configured for this plan', 'METRIC_NOT_CONFIGURED'); + } + + // Record usage + const usageRecord = await prisma.usageRecord.create({ + data: { + subscriptionId: id, + tenantId, + metricType, + quantity, + metadata, + timestamp: new Date(), + }, + }); + + // Check if we need to trigger alerts + const usageLimits = subscription.plan.usageLimits as Record | null; + if (usageLimits && usageLimits[metricType]) { + const currentUsage = await prisma.usageRecord.aggregate({ + where: { + subscriptionId: id, + metricType, + timestamp: { + gte: subscription.currentPeriodStart, + lte: subscription.currentPeriodEnd, + }, + }, + _sum: { quantity: true }, + }); + + const totalUsage = currentUsage._sum.quantity || 0; + const limit = usageLimits[metricType]; + const percentage = (totalUsage / limit) * 100; + + // Trigger alerts at 80% and 100% + for (const threshold of [80, 100]) { + if (percentage >= threshold) { + await prisma.usageAlert.updateMany({ + where: { + subscriptionId: id, + metricType, + threshold, + triggered: false, + }, + data: { + triggered: true, + triggeredAt: new Date(), + }, + }); + } + } + } + + res.status(201).json({ + success: true, + data: usageRecord, + }); + } catch (error) { + next(error); + } +}); + +/** + * GET /api/subscriptions/:id/usage/summary + * Get usage summary for current period + */ +router.get('/:id/usage/summary', async (req, res, next) => { + try { + const { id } = req.params; + const tenantId = req.headers['x-tenant-id'] as string; + + if (!tenantId) { + throw new AppError(400, 'Tenant ID required', 'TENANT_ID_REQUIRED'); + } + + const subscription = await prisma.subscription.findFirst({ + where: { id, tenantId, deletedAt: null }, + include: { plan: { include: { meteredPricing: true } } }, + }); + + if (!subscription) { + throw new AppError(404, 'Subscription not found', 'SUBSCRIPTION_NOT_FOUND'); + } + + const usageSummary = await prisma.usageRecord.groupBy({ + by: ['metricType'], + where: { + subscriptionId: id, + timestamp: { + gte: subscription.currentPeriodStart, + lte: subscription.currentPeriodEnd, + }, + }, + _sum: { quantity: true }, + }); + + const usageLimits = subscription.plan.usageLimits as Record | null; + + const summary = usageSummary.map(u => { + const totalUsage = u._sum.quantity || 0; + const limit = usageLimits?.[u.metricType] || 0; + const percentage = limit > 0 ? (totalUsage / limit) * 100 : 0; + + return { + metricType: u.metricType, + totalUsage, + limit, + percentage: Math.round(percentage * 100) / 100, + remaining: Math.max(0, limit - totalUsage), + }; + }); + + res.json({ + success: true, + data: { + subscription: { + id: subscription.id, + planName: subscription.plan.name, + currentPeriodStart: subscription.currentPeriodStart, + currentPeriodEnd: subscription.currentPeriodEnd, + }, + usage: summary, + }, + }); + } catch (error) { + next(error); + } +}); + +/** + * GET /api/subscriptions/:id/invoices + * Get invoices for a subscription + */ +router.get('/:id/invoices', async (req, res, next) => { + try { + const { id } = req.params; + const tenantId = req.headers['x-tenant-id'] as string; + + if (!tenantId) { + throw new AppError(400, 'Tenant ID required', 'TENANT_ID_REQUIRED'); + } + + const subscription = await prisma.subscription.findFirst({ + where: { id, tenantId, deletedAt: null }, + }); + + if (!subscription) { + throw new AppError(404, 'Subscription not found', 'SUBSCRIPTION_NOT_FOUND'); + } + + const invoices = await prisma.subscriptionInvoice.findMany({ + where: { subscriptionId: id }, + orderBy: { createdAt: 'desc' }, + }); + + res.json({ + success: true, + data: invoices, + }); + } catch (error) { + next(error); + } +}); + +export default router; diff --git a/backend/src/services/stripe.ts b/backend/src/services/stripe.ts index e5018054..48fc23d2 100644 --- a/backend/src/services/stripe.ts +++ b/backend/src/services/stripe.ts @@ -205,3 +205,213 @@ export function listFeeRecords(): FeeRecord[] { export function estimateStripeFee(amountCents: number): number { return Math.round(amountCents * 0.029 + 30); } + +// ── Subscriptions ──────────────────────────────────────────────────────────── + +export interface CreateSubscriptionInput { + customerId: string; + priceId: string; + trialPeriodDays?: number; + metadata?: Record; +} + +export async function createSubscription(input: CreateSubscriptionInput): Promise { + return withCircuitBreaker( + STRIPE_CIRCUIT_NAME, + async () => { + const stripe = getStripe(); + return stripe.subscriptions.create({ + customer: input.customerId, + items: [{ price: input.priceId }], + trial_period_days: input.trialPeriodDays, + metadata: input.metadata ?? {}, + payment_behavior: 'default_incomplete', + expand: ['latest_invoice.payment_intent'], + }); + }, + ); +} + +export async function getSubscription(subscriptionId: string): Promise { + return withCircuitBreaker( + STRIPE_CIRCUIT_NAME, + async () => { + const stripe = getStripe(); + return stripe.subscriptions.retrieve(subscriptionId); + }, + ); +} + +export async function updateSubscription( + subscriptionId: string, + params: Stripe.SubscriptionUpdateParams +): Promise { + return withCircuitBreaker( + STRIPE_CIRCUIT_NAME, + async () => { + const stripe = getStripe(); + return stripe.subscriptions.update(subscriptionId, params); + }, + ); +} + +export async function cancelSubscription( + subscriptionId: string, + cancelAtPeriodEnd = false +): Promise { + return withCircuitBreaker( + STRIPE_CIRCUIT_NAME, + async () => { + const stripe = getStripe(); + if (cancelAtPeriodEnd) { + return stripe.subscriptions.update(subscriptionId, { + cancel_at_period_end: true, + }); + } + return stripe.subscriptions.cancel(subscriptionId); + }, + ); +} + +export async function listSubscriptions(customerId?: string): Promise> { + return withCircuitBreaker( + STRIPE_CIRCUIT_NAME, + async () => { + const stripe = getStripe(); + return stripe.subscriptions.list(customerId ? { customer: customerId } : {}); + }, + ); +} + +// ── Prices & Products ─────────────────────────────────────────────────────── + +export interface CreatePriceInput { + productId: string; + unitAmount: number; + currency: string; + recurring?: { + interval: 'day' | 'week' | 'month' | 'year'; + interval_count?: number; + }; + metadata?: Record; +} + +export async function createPrice(input: CreatePriceInput): Promise { + return withCircuitBreaker( + STRIPE_CIRCUIT_NAME, + async () => { + const stripe = getStripe(); + return stripe.prices.create({ + product: input.productId, + unit_amount: input.unitAmount, + currency: input.currency.toLowerCase(), + recurring: input.recurring, + metadata: input.metadata ?? {}, + }); + }, + ); +} + +export async function getPrice(priceId: string): Promise { + return withCircuitBreaker( + STRIPE_CIRCUIT_NAME, + async () => { + const stripe = getStripe(); + return stripe.prices.retrieve(priceId); + }, + ); +} + +export interface CreateProductInput { + name: string; + description?: string; + metadata?: Record; +} + +export async function createProduct(input: CreateProductInput): Promise { + return withCircuitBreaker( + STRIPE_CIRCUIT_NAME, + async () => { + const stripe = getStripe(); + return stripe.products.create({ + name: input.name, + description: input.description, + metadata: input.metadata ?? {}, + }); + }, + ); +} + +// ── Usage Records (Metered Billing) ────────────────────────────────────────── + +export interface RecordUsageInput { + subscriptionItemId: string; + quantity: number; + timestamp?: number; + action?: 'increment' | 'set'; +} + +export async function recordUsage(input: RecordUsageInput): Promise { + return withCircuitBreaker( + STRIPE_CIRCUIT_NAME, + async () => { + const stripe = getStripe(); + return stripe.subscriptionItems.createUsageRecord( + input.subscriptionItemId, + { + quantity: input.quantity, + timestamp: input.timestamp ?? Math.floor(Date.now() / 1000), + action: input.action ?? 'increment', + } + ); + }, + ); +} + +export async function listUsageRecords( + subscriptionItemId: string, + limit = 100 +): Promise> { + return withCircuitBreaker( + STRIPE_CIRCUIT_NAME, + async () => { + const stripe = getStripe(); + return stripe.subscriptionItems.listUsageRecordSummaries( + subscriptionItemId, + { limit } + ); + }, + ); +} + +// ── Invoices ───────────────────────────────────────────────────────────────── + +export async function getInvoice(invoiceId: string): Promise { + return withCircuitBreaker( + STRIPE_CIRCUIT_NAME, + async () => { + const stripe = getStripe(); + return stripe.invoices.retrieve(invoiceId); + }, + ); +} + +export async function listInvoices(customerId?: string): Promise> { + return withCircuitBreaker( + STRIPE_CIRCUIT_NAME, + async () => { + const stripe = getStripe(); + return stripe.invoices.list(customerId ? { customer: customerId } : {}); + }, + ); +} + +export async function payInvoice(invoiceId: string): Promise { + return withCircuitBreaker( + STRIPE_CIRCUIT_NAME, + async () => { + const stripe = getStripe(); + return stripe.invoices.pay(invoiceId); + }, + ); +}