Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
434 changes: 434 additions & 0 deletions backend/frontend/app/dashboard/billing/page.tsx

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -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");
187 changes: 187 additions & 0 deletions backend/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
23 changes: 23 additions & 0 deletions backend/src/config/scheduled-tasks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -161,6 +162,28 @@ const RAW_TASKS: Omit<ScheduledTaskMeta, 'schedule'> & { 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.',
Expand Down
Loading
Loading