feat: implement complete commercial monetization loop (Phases 1-4)

## Phase 1 - Token Metering + Quota Enforcement

### Usage Tracking
- agent-service: add UsageRecord entity (per-tenant schema) tracking
  inputTokens/outputTokens/costUsd per AI task
- Modify all 3 AI engines (claude-api, claude-code-cli, claude-agent-sdk)
  to emit separate input/output token counts in the `completed` event
- claude-api-engine: costUsd = (input*3 + output*15) / 1,000,000
  (claude-sonnet-4-5 pricing: $3/MTok in, $15/MTok out)
- agent.controller: persist UsageRecord and publish `usage.recorded`
  event to Redis Streams on every task completion (non-blocking)
- shared/events: new events UsageRecordedEvent, SubscriptionChangedEvent,
  QuotaExceededEvent, PaymentReceivedEvent

### Quota Enforcement
- TenantInfo: add maxServers, maxUsers, maxStandingOrders,
  maxAgentTokensPerMonth fields
- TenantContextMiddleware: rewritten to query public.tenants table for
  real quota values; 5-min in-memory cache; plan-based fallback on error
- TenantContextService: getTenant() returns null instead of throwing;
  added getTenantOrThrow() for strict callers
- inventory-service/server.controller: 429 when maxServers exceeded
- ops-service/standing-order.controller: 429 when maxStandingOrders exceeded
- auth-service/auth.service: 429 when maxUsers exceeded
- 002-create-tenant-schema-template.sql: add usage_records table

## Phase 2 - billing-service (New Microservice, port 3010)

### Domain Layer (public schema, all UUIDs)
Entities: Plan, Subscription, Invoice, InvoiceItem, Payment, PaymentMethod,
UsageAggregate

Domain services:
- SubscriptionLifecycleService: full state machine (trialing -> active ->
  past_due -> cancelled/expired); upgrades immediate, downgrades at period end
- InvoiceGeneratorService: monthly invoice = base fee + overage charges;
  proration item for mid-cycle upgrades
- OverageCalculatorService: (totalTokens - includedTokens) * overageRate

### Infrastructure (all repos use DataSource directly, NOT TenantAwareRepository)
- PlanRepository, SubscriptionRepository, InvoiceRepository (atomic
  transaction for invoice+items), PaymentRepository (payments + methods),
  UsageAggregateRepository (UPSERT via ON CONFLICT for atomic accumulation)

### Application Use Cases
- CreateSubscriptionUseCase: called on tenant registration
- ChangePlanUseCase: upgrade (immediate + proration) or downgrade (scheduled)
- CancelSubscriptionUseCase: immediate or at-period-end
- GenerateMonthlyInvoiceUseCase: cron target (1st of month 00:05 UTC);
  generates invoices, renews periods, applies scheduled downgrades
- AggregateUsageUseCase: Redis Streams consumer group billing-service,
  upserts monthly usage aggregates from usage.recorded events
- CheckTokenQuotaUseCase: hard limit enforcement per plan
- CreatePaymentSessionUseCase + HandlePaymentWebhookUseCase

### REST API
- GET  /api/v1/billing/plans
- GET/POST /api/v1/billing/subscription (+ /upgrade, /cancel)
- GET  /api/v1/billing/invoices (paginated)
- GET  /api/v1/billing/invoices/:id
- POST /api/v1/billing/invoices/:id/pay
- GET  /api/v1/billing/usage/current + /history
- CRUD /api/v1/billing/payment-methods
- POST /api/v1/billing/webhooks/{stripe,alipay,wechat,crypto}

### Plan Seed (auto on startup via PlanSeedService)
- free:       $0/mo,    100K tokens,  no overage,  hard limit 100%
- pro:        $49.99/mo, 1M tokens,  $8/MTok,  hard limit 150%
- enterprise: $199.99/mo, 10M tokens, $5/MTok, no hard limit

## Phase 3 - Payment Provider Integration

### PaymentProviderRegistry (Strategy Pattern, mirrors EngineRegistry)
All providers use @Optional() injection; unconfigured providers omitted

- StripeProvider: PaymentIntent API; webhook via stripe.webhooks.constructEvent
- AlipayProvider: alipay-sdk; Native QR (precreate); RSA2 signature verify
- WeChatPayProvider: v3 REST; Native Pay code_url; AES-256-GCM decrypt;
  HMAC-SHA256 request signing and webhook verification
- CryptoProvider: Coinbase Commerce; hosted checkout; HMAC-SHA256 verify

### WebhookController
All 4 webhook endpoints are public (no JWT) for payment provider callbacks.
rawBody: true enabled in main.ts for signature verification.

## Infrastructure Changes
- docker-compose.yml: billing-service container (port 13010);
  added as dependency of api-gateway
- kong.yml: /api/v1/billing routes (JWT); /api/v1/billing/webhooks (public)
- 005-create-billing-tables.sql: 7 billing tables + invoice sequence +
  ALTER tenants to add quota columns
- run-migrations.ts: 005 runs as part of shared schema step

## Phase 4 - Frontend

### Web Admin (Next.js)
New pages:
- /billing: subscription card + token usage bar + warning banner + invoices
- /billing/plans: comparison grid with USD/CNY toggle + upgrade/downgrade flow
- /billing/invoices: paginated table with Pay Now button
Sidebar: Billing group (CreditCard icon, 3 sub-items)
i18n: billing keys added to en + zh sidebar translations

### Flutter App
New feature module it0_app/lib/features/billing/:
- BillingOverviewPage: plan card + token LinearProgressIndicator +
  latest invoice + upgrade button
- BillingProvider (FutureProvider): parallel fetch subscription/quota/invoice
Settings page: "订阅与用量" entry card
Router: /settings/billing sub-route

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
hailin 2026-03-03 21:09:17 -08:00
parent 54e3d442ed
commit 9ed80cd0bc
72 changed files with 4241 additions and 28 deletions

View File

@ -63,6 +63,8 @@ services:
condition: service_healthy
audit-service:
condition: service_healthy
billing-service:
condition: service_healthy
version-service:
condition: service_healthy
healthcheck:
@ -313,6 +315,48 @@ services:
networks:
- it0-network
billing-service:
build:
context: ../..
dockerfile: Dockerfile.service
args:
SERVICE_NAME: billing-service
SERVICE_PORT: 3010
container_name: it0-billing-service
restart: unless-stopped
ports:
- "13010:3010"
environment:
- DB_HOST=postgres
- DB_PORT=5432
- DB_USERNAME=${POSTGRES_USER:-it0}
- DB_PASSWORD=${POSTGRES_PASSWORD:-it0_dev}
- DB_DATABASE=${POSTGRES_DB:-it0}
- REDIS_URL=redis://redis:6379
- BILLING_SERVICE_PORT=3010
- STRIPE_SECRET_KEY=${STRIPE_SECRET_KEY}
- STRIPE_WEBHOOK_SECRET=${STRIPE_WEBHOOK_SECRET}
- ALIPAY_APP_ID=${ALIPAY_APP_ID}
- ALIPAY_PRIVATE_KEY=${ALIPAY_PRIVATE_KEY}
- WECHAT_MCH_ID=${WECHAT_MCH_ID}
- WECHAT_API_KEY_V3=${WECHAT_API_KEY_V3}
- WECHAT_APP_ID=${WECHAT_APP_ID}
- COINBASE_COMMERCE_API_KEY=${COINBASE_COMMERCE_API_KEY}
- COINBASE_COMMERCE_WEBHOOK_SECRET=${COINBASE_COMMERCE_WEBHOOK_SECRET}
healthcheck:
test: ["CMD-SHELL", "node -e \"require('http').get('http://localhost:3010/',r=>{process.exit(r.statusCode<500?0:1)}).on('error',()=>process.exit(1))\""]
interval: 30s
timeout: 5s
retries: 3
start_period: 15s
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
networks:
- it0-network
version-service:
build:
context: ../..

View File

@ -0,0 +1,146 @@
'use client';
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import Link from 'next/link';
import { apiClient } from '@/infrastructure/api/api-client';
import { FileText, Download } from 'lucide-react';
interface Invoice {
id: string;
invoiceNumber: string;
status: string;
currency: string;
totalAmount: number;
amountDue: number;
periodStart: string;
periodEnd: string;
dueDate: string;
paidAt: string | null;
createdAt: string;
}
function StatusBadge({ status }: { status: string }) {
const colors: Record<string, string> = {
paid: 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400',
open: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400',
past_due: 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400',
void: 'bg-gray-100 text-gray-600',
};
return (
<span className={`px-2 py-0.5 rounded text-xs font-medium ${colors[status] ?? 'bg-muted text-muted-foreground'}`}>
{status}
</span>
);
}
const PAGE_SIZE = 20;
export default function InvoicesPage() {
const [page, setPage] = useState(0);
const { data, isLoading } = useQuery<{ data: Invoice[]; total: number }>({
queryKey: ['billing', 'invoices', page],
queryFn: () => apiClient(`/api/v1/billing/invoices?limit=${PAGE_SIZE}&offset=${page * PAGE_SIZE}`),
});
const invoices = data?.data ?? [];
const total = data?.total ?? 0;
const totalPages = Math.ceil(total / PAGE_SIZE);
return (
<div className="space-y-6">
<h1 className="text-2xl font-bold flex items-center gap-2">
<FileText className="h-6 w-6" /> Invoices
</h1>
<div className="rounded-lg border bg-card overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-muted/50 text-muted-foreground">
<tr>
<th className="text-left p-4 font-medium">Invoice #</th>
<th className="text-left p-4 font-medium">Period</th>
<th className="text-left p-4 font-medium">Amount</th>
<th className="text-left p-4 font-medium">Due Date</th>
<th className="text-left p-4 font-medium">Status</th>
<th className="p-4" />
</tr>
</thead>
<tbody className="divide-y">
{isLoading ? (
<tr>
<td colSpan={6} className="p-8 text-center text-muted-foreground">Loading...</td>
</tr>
) : invoices.length === 0 ? (
<tr>
<td colSpan={6} className="p-8 text-center text-muted-foreground">No invoices found</td>
</tr>
) : (
invoices.map((inv) => (
<tr key={inv.id} className="hover:bg-muted/30 transition-colors">
<td className="p-4">
<Link href={`/billing/invoices/${inv.id}`} className="text-primary hover:underline font-medium">
{inv.invoiceNumber}
</Link>
</td>
<td className="p-4 text-muted-foreground">
{new Date(inv.periodStart).toLocaleDateString()} {new Date(inv.periodEnd).toLocaleDateString()}
</td>
<td className="p-4 font-medium">
{inv.currency} {inv.totalAmount.toFixed(2)}
</td>
<td className="p-4 text-muted-foreground">
{new Date(inv.dueDate).toLocaleDateString()}
</td>
<td className="p-4">
<StatusBadge status={inv.status} />
</td>
<td className="p-4 text-right">
{inv.status === 'open' ? (
<Link
href={`/billing/invoices/${inv.id}?pay=1`}
className="text-xs bg-primary text-primary-foreground px-3 py-1.5 rounded-md hover:bg-primary/90"
>
Pay Now
</Link>
) : (
<button className="p-1.5 rounded hover:bg-muted transition-colors" title="Download">
<Download className="h-4 w-4 text-muted-foreground" />
</button>
)}
</td>
</tr>
))
)}
</tbody>
</table>
</div>
{totalPages > 1 && (
<div className="flex items-center justify-between p-4 border-t">
<span className="text-sm text-muted-foreground">
Page {page + 1} of {totalPages} ({total} total)
</span>
<div className="flex gap-2">
<button
onClick={() => setPage((p) => p - 1)}
disabled={page === 0}
className="px-3 py-1.5 rounded border text-sm disabled:opacity-50"
>
Previous
</button>
<button
onClick={() => setPage((p) => p + 1)}
disabled={page >= totalPages - 1}
className="px-3 py-1.5 rounded border text-sm disabled:opacity-50"
>
Next
</button>
</div>
</div>
)}
</div>
</div>
);
}

View File

@ -0,0 +1,228 @@
'use client';
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import Link from 'next/link';
import { apiClient } from '@/infrastructure/api/api-client';
import { CreditCard, Zap, TrendingUp, AlertCircle, ChevronRight } from 'lucide-react';
interface Subscription {
id: string;
status: string;
plan: { name: string; displayName: string } | null;
nextPlan: { name: string; displayName: string } | null;
currentPeriodStart: string;
currentPeriodEnd: string;
trialEndsAt: string | null;
cancelAtPeriodEnd: boolean;
}
interface QuotaStatus {
allowed: boolean;
remaining: number;
usedTokens: number;
limitTokens: number;
isHardLimit: boolean;
overageAllowed: boolean;
}
interface Invoice {
id: string;
invoiceNumber: string;
status: string;
currency: string;
totalAmount: number;
amountDue: number;
periodStart: string;
periodEnd: string;
dueDate: string;
paidAt: string | null;
createdAt: string;
}
function StatusBadge({ status }: { status: string }) {
const colors: Record<string, string> = {
active: 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400',
trialing: 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400',
past_due: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400',
cancelled: 'bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400',
expired: 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400',
paid: 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400',
open: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400',
};
return (
<span className={`inline-block px-2 py-0.5 rounded text-xs font-medium ${colors[status] ?? 'bg-muted text-muted-foreground'}`}>
{status}
</span>
);
}
function UsageBar({ used, limit }: { used: number; limit: number }) {
const pct = limit <= 0 ? 0 : Math.min(100, (used / limit) * 100);
const color = pct >= 90 ? 'bg-red-500' : pct >= 70 ? 'bg-yellow-500' : 'bg-green-500';
return (
<div className="w-full bg-muted rounded-full h-2">
<div className={`${color} h-2 rounded-full transition-all`} style={{ width: `${pct}%` }} />
</div>
);
}
function formatTokens(n: number) {
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(2)}M`;
if (n >= 1_000) return `${(n / 1_000).toFixed(0)}K`;
return `${n}`;
}
export default function BillingPage() {
const { t } = useTranslation();
const { data: sub } = useQuery<Subscription>({
queryKey: ['billing', 'subscription'],
queryFn: () => apiClient('/api/v1/billing/subscription'),
retry: false,
});
const { data: quota } = useQuery<QuotaStatus>({
queryKey: ['billing', 'quota'],
queryFn: () => apiClient('/api/v1/billing/usage/current'),
refetchInterval: 60_000,
});
const { data: invoicesData } = useQuery<{ data: Invoice[]; total: number }>({
queryKey: ['billing', 'invoices'],
queryFn: () => apiClient('/api/v1/billing/invoices?limit=5'),
});
const recentInvoices = invoicesData?.data ?? [];
const usedPct = quota && quota.limitTokens > 0
? Math.min(100, (quota.usedTokens / quota.limitTokens) * 100)
: 0;
return (
<div className="space-y-6">
<h1 className="text-2xl font-bold">Billing &amp; Subscription</h1>
{/* Usage Warning Banner */}
{usedPct >= 80 && (
<div className="flex items-center gap-3 p-4 rounded-lg bg-yellow-50 border border-yellow-200 dark:bg-yellow-900/20 dark:border-yellow-800">
<AlertCircle className="h-5 w-5 text-yellow-600 flex-shrink-0" />
<span className="text-sm text-yellow-800 dark:text-yellow-300">
You have used {usedPct.toFixed(0)}% of your monthly token quota.{' '}
{usedPct >= 100 ? 'Limit reached — agent tasks are blocked.' : 'Consider upgrading your plan.'}
</span>
<Link href="/billing/plans" className="ml-auto text-sm font-medium text-yellow-800 dark:text-yellow-300 underline whitespace-nowrap">
Upgrade
</Link>
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Subscription Card */}
<div className="rounded-lg border bg-card p-6 space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<CreditCard className="h-5 w-5 text-muted-foreground" />
<h2 className="font-semibold">Current Plan</h2>
</div>
<Link href="/billing/plans" className="text-sm text-primary flex items-center gap-1 hover:underline">
Change <ChevronRight className="h-4 w-4" />
</Link>
</div>
{sub ? (
<div className="space-y-2">
<div className="flex items-center gap-3">
<span className="text-2xl font-bold capitalize">{sub.plan?.displayName ?? 'Unknown'}</span>
<StatusBadge status={sub.status} />
</div>
{sub.trialEndsAt && sub.status === 'trialing' && (
<p className="text-sm text-muted-foreground">
Trial ends {new Date(sub.trialEndsAt).toLocaleDateString()}
</p>
)}
{sub.cancelAtPeriodEnd && (
<p className="text-sm text-yellow-600 dark:text-yellow-400">
Cancels on {new Date(sub.currentPeriodEnd).toLocaleDateString()}
</p>
)}
{sub.nextPlan && (
<p className="text-sm text-muted-foreground">
Downgrade to <strong>{sub.nextPlan.displayName}</strong> on {new Date(sub.currentPeriodEnd).toLocaleDateString()}
</p>
)}
<p className="text-xs text-muted-foreground">
Period: {new Date(sub.currentPeriodStart).toLocaleDateString()} {new Date(sub.currentPeriodEnd).toLocaleDateString()}
</p>
</div>
) : (
<p className="text-muted-foreground text-sm">No active subscription</p>
)}
</div>
{/* Token Usage Card */}
<div className="rounded-lg border bg-card p-6 space-y-4">
<div className="flex items-center gap-2">
<Zap className="h-5 w-5 text-muted-foreground" />
<h2 className="font-semibold">This Month&apos;s Token Usage</h2>
</div>
{quota ? (
<div className="space-y-3">
<div className="flex justify-between text-sm">
<span className="font-medium">{formatTokens(quota.usedTokens)} used</span>
<span className="text-muted-foreground">
{quota.limitTokens === -1 ? 'Unlimited' : `${formatTokens(quota.limitTokens)} limit`}
</span>
</div>
{quota.limitTokens !== -1 && (
<UsageBar used={quota.usedTokens} limit={quota.limitTokens} />
)}
<div className="flex justify-between text-xs text-muted-foreground">
<span>{usedPct.toFixed(1)}%</span>
{quota.overageAllowed && usedPct > 100 && (
<span className="text-yellow-600">Overage charges apply</span>
)}
</div>
</div>
) : (
<p className="text-muted-foreground text-sm">Loading usage data...</p>
)}
</div>
</div>
{/* Recent Invoices */}
<div className="rounded-lg border bg-card">
<div className="flex items-center justify-between p-6 border-b">
<div className="flex items-center gap-2">
<TrendingUp className="h-5 w-5 text-muted-foreground" />
<h2 className="font-semibold">Recent Invoices</h2>
</div>
<Link href="/billing/invoices" className="text-sm text-primary hover:underline flex items-center gap-1">
View all <ChevronRight className="h-4 w-4" />
</Link>
</div>
{recentInvoices.length === 0 ? (
<div className="p-6 text-center text-muted-foreground text-sm">No invoices yet</div>
) : (
<div className="divide-y">
{recentInvoices.map((inv) => (
<Link key={inv.id} href={`/billing/invoices/${inv.id}`} className="flex items-center justify-between p-4 hover:bg-muted/50 transition-colors">
<div>
<p className="text-sm font-medium">{inv.invoiceNumber}</p>
<p className="text-xs text-muted-foreground">
{new Date(inv.periodStart).toLocaleDateString()} {new Date(inv.periodEnd).toLocaleDateString()}
</p>
</div>
<div className="flex items-center gap-3">
<span className="text-sm font-medium">{inv.currency} {inv.totalAmount.toFixed(2)}</span>
<StatusBadge status={inv.status} />
</div>
</Link>
))}
</div>
)}
</div>
</div>
);
}

View File

@ -0,0 +1,214 @@
'use client';
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useRouter } from 'next/navigation';
import { apiClient } from '@/infrastructure/api/api-client';
import { Check, Zap } from 'lucide-react';
interface Plan {
id: string;
name: string;
displayName: string;
monthlyPriceUsd: number;
monthlyPriceCny: number;
includedTokensPerMonth: number;
overageRateUsdPerMToken: number;
maxServers: number;
maxUsers: number;
maxStandingOrders: number;
hardLimitPercent: number;
trialDays: number;
}
interface Subscription {
plan: { name: string } | null;
status: string;
}
function formatLimit(n: number) {
return n === -1 ? 'Unlimited' : n.toLocaleString();
}
function formatTokens(n: number) {
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(0)}M`;
if (n >= 1_000) return `${(n / 1_000).toFixed(0)}K`;
return `${n}`;
}
const PLAN_FEATURES: Record<string, string[]> = {
free: ['100K tokens/month', 'Up to 5 servers', 'Up to 3 users', 'Up to 10 standing orders', 'Hard limit at 100%'],
pro: ['1M tokens/month', 'Up to 50 servers', 'Up to 20 users', 'Up to 100 standing orders', 'Soft limit at 150%', 'Overage at $8/MTok', '14-day trial'],
enterprise: ['10M tokens/month', 'Unlimited servers', 'Unlimited users', 'Unlimited standing orders', 'No hard limit', 'Overage at $5/MTok', '14-day trial'],
};
export default function PlansPage() {
const queryClient = useQueryClient();
const router = useRouter();
const [currency, setCurrency] = useState<'USD' | 'CNY'>('USD');
const [selectedPlan, setSelectedPlan] = useState<string | null>(null);
const { data: plans = [] } = useQuery<Plan[]>({
queryKey: ['billing', 'plans'],
queryFn: () => apiClient('/api/v1/billing/plans'),
});
const { data: sub } = useQuery<Subscription>({
queryKey: ['billing', 'subscription'],
queryFn: () => apiClient('/api/v1/billing/subscription'),
retry: false,
});
const currentPlanName = sub?.plan?.name;
const upgradeMutation = useMutation({
mutationFn: (planName: string) =>
apiClient('/api/v1/billing/subscription/upgrade', {
method: 'POST',
body: { planName, currency },
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['billing'] });
router.push('/billing');
},
});
const handleSelectPlan = (planName: string) => {
if (planName === currentPlanName) return;
setSelectedPlan(planName);
};
const handleConfirm = () => {
if (!selectedPlan) return;
upgradeMutation.mutate(selectedPlan);
};
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold">Choose a Plan</h1>
<div className="flex items-center gap-2 text-sm">
<button
onClick={() => setCurrency('USD')}
className={`px-3 py-1 rounded-full ${currency === 'USD' ? 'bg-primary text-primary-foreground' : 'bg-muted'}`}
>
USD
</button>
<button
onClick={() => setCurrency('CNY')}
className={`px-3 py-1 rounded-full ${currency === 'CNY' ? 'bg-primary text-primary-foreground' : 'bg-muted'}`}
>
CNY
</button>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{plans.map((plan) => {
const isCurrent = plan.name === currentPlanName;
const isSelected = plan.name === selectedPlan;
const price = currency === 'CNY' ? plan.monthlyPriceCny : plan.monthlyPriceUsd;
const symbol = currency === 'CNY' ? '¥' : '$';
const features = PLAN_FEATURES[plan.name] ?? [];
const isEnterprise = plan.name === 'enterprise';
return (
<div
key={plan.id}
onClick={() => handleSelectPlan(plan.name)}
className={`relative rounded-xl border-2 p-6 cursor-pointer transition-all space-y-4 ${
isEnterprise ? 'border-primary' : 'border-border'
} ${isSelected ? 'ring-2 ring-primary ring-offset-2' : ''} ${
isCurrent ? 'opacity-70 cursor-default' : 'hover:border-primary/50'
}`}
>
{isEnterprise && (
<div className="absolute -top-3 left-1/2 -translate-x-1/2">
<span className="bg-primary text-primary-foreground text-xs font-bold px-3 py-1 rounded-full">
MOST POPULAR
</span>
</div>
)}
{isCurrent && (
<div className="absolute top-3 right-3">
<span className="bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400 text-xs font-medium px-2 py-0.5 rounded-full">
Current
</span>
</div>
)}
<div>
<h3 className="text-xl font-bold">{plan.displayName}</h3>
<div className="flex items-end gap-1 mt-2">
<span className="text-3xl font-bold">{symbol}{price.toFixed(2)}</span>
<span className="text-muted-foreground text-sm mb-1">/month</span>
</div>
{plan.trialDays > 0 && (
<p className="text-xs text-primary mt-1">{plan.trialDays}-day free trial</p>
)}
</div>
<div className="border-t pt-4 space-y-2">
<div className="flex items-center gap-2 text-sm font-medium">
<Zap className="h-4 w-4 text-primary" />
{formatTokens(plan.includedTokensPerMonth)} tokens/month
</div>
{plan.overageRateUsdPerMToken > 0 && (
<p className="text-xs text-muted-foreground pl-6">
Overage: {currency === 'CNY'
? `¥${(plan.overageRateUsdPerMToken * 7.2).toFixed(2)}`
: `$${plan.overageRateUsdPerMToken.toFixed(2)}`}/MTok
</p>
)}
</div>
<ul className="space-y-1.5">
{features.map((f) => (
<li key={f} className="flex items-center gap-2 text-sm">
<Check className="h-4 w-4 text-green-500 flex-shrink-0" />
{f}
</li>
))}
</ul>
{!isCurrent && (
<button
onClick={(e) => { e.stopPropagation(); handleSelectPlan(plan.name); }}
className={`w-full py-2 rounded-lg text-sm font-medium transition-colors ${
isSelected
? 'bg-primary text-primary-foreground'
: 'bg-muted hover:bg-primary/10'
}`}
>
{isSelected ? 'Selected' : plan.name === 'free' ? 'Downgrade' : 'Upgrade'}
</button>
)}
</div>
);
})}
</div>
{selectedPlan && (
<div className="flex justify-end gap-3 pt-4">
<button onClick={() => setSelectedPlan(null)} className="px-4 py-2 rounded-lg border text-sm">
Cancel
</button>
<button
onClick={handleConfirm}
disabled={upgradeMutation.isPending}
className="px-6 py-2 rounded-lg bg-primary text-primary-foreground text-sm font-medium disabled:opacity-50"
>
{upgradeMutation.isPending ? 'Processing...' : `Switch to ${selectedPlan}`}
</button>
</div>
)}
{upgradeMutation.isError && (
<p className="text-sm text-red-500 text-center">
Failed to change plan. Please try again.
</p>
)}
</div>
);
}

View File

@ -26,6 +26,10 @@
"logs": "Logs",
"sessionReplay": "Session Replay",
"communication": "Communication",
"billing": "Billing",
"billingOverview": "Overview",
"billingPlans": "Plans",
"billingInvoices": "Invoices",
"tenants": "Tenants",
"users": "Users",
"settings": "Settings",

View File

@ -26,6 +26,10 @@
"logs": "日志",
"sessionReplay": "会话回放",
"communication": "通讯",
"billing": "账单",
"billingOverview": "总览",
"billingPlans": "套餐",
"billingInvoices": "账单列表",
"tenants": "租户",
"users": "用户",
"settings": "设置",

View File

@ -22,6 +22,7 @@ import {
ChevronRight,
PanelLeftClose,
PanelLeft,
CreditCard,
} from 'lucide-react';
/* ---------- Sidebar context for collapse state ---------- */
@ -148,6 +149,17 @@ export function Sidebar() {
],
},
{ key: 'communication', label: t('communication'), href: '/communication', icon: <MessageSquare className={iconClass} /> },
{
key: 'billing',
label: t('billing'),
href: '/billing',
icon: <CreditCard className={iconClass} />,
children: [
{ label: t('billingOverview'), href: '/billing' },
{ label: t('billingPlans'), href: '/billing/plans' },
{ label: t('billingInvoices'), href: '/billing/invoices' },
],
},
{ key: 'tenants', label: t('tenants'), href: '/tenants', icon: <Building2 className={iconClass} /> },
{ key: 'users', label: t('users'), href: '/users', icon: <Users className={iconClass} /> },
{ key: 'settings', label: t('settings'), href: '/settings', icon: <Settings className={iconClass} /> },

View File

@ -16,6 +16,7 @@ import '../../features/alerts/presentation/pages/alerts_page.dart';
import '../../features/settings/presentation/pages/settings_page.dart';
import '../../features/terminal/presentation/pages/terminal_page.dart';
import '../../features/notifications/presentation/providers/notification_providers.dart';
import '../../features/billing/presentation/pages/billing_overview_page.dart';
final routerProvider = Provider<GoRouter>((ref) {
return GoRouter(
@ -67,6 +68,12 @@ final routerProvider = Provider<GoRouter>((ref) {
GoRoute(
path: '/settings',
builder: (context, state) => const SettingsPage(),
routes: [
GoRoute(
path: 'billing',
builder: (context, state) => const BillingOverviewPage(),
),
],
),
],
),

View File

@ -0,0 +1,311 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../../core/theme/app_colors.dart';
import '../providers/billing_provider.dart';
class BillingOverviewPage extends ConsumerWidget {
const BillingOverviewPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final billingAsync = ref.watch(billingOverviewProvider);
final theme = Theme.of(context);
final isDark = theme.brightness == Brightness.dark;
final cardColor = isDark ? AppColors.surface : Colors.white;
return Scaffold(
appBar: AppBar(title: const Text('订阅与用量')),
body: billingAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => Center(child: Text('加载失败: $e')),
data: (billing) => ListView(
padding: const EdgeInsets.all(16),
children: [
// Subscription Card
_SubscriptionCard(
planName: billing.planDisplayName,
status: billing.subscriptionStatus,
periodEnd: billing.currentPeriodEnd,
cardColor: cardColor,
theme: theme,
),
const SizedBox(height: 16),
// Token Usage Card
_UsageCard(
usedTokens: billing.usedTokens,
limitTokens: billing.limitTokens,
cardColor: cardColor,
theme: theme,
),
const SizedBox(height: 16),
// Recent Invoice Card
if (billing.latestInvoice != null)
_InvoiceCard(
invoice: billing.latestInvoice!,
cardColor: cardColor,
theme: theme,
),
const SizedBox(height: 24),
// Upgrade Button
if (billing.subscriptionStatus != 'enterprise')
ElevatedButton.icon(
onPressed: () {
// Open web admin billing/plans in browser
// or show a dialog for now
_showUpgradeDialog(context);
},
icon: const Icon(Icons.upgrade),
label: const Text('升级套餐'),
style: ElevatedButton.styleFrom(
minimumSize: const Size(double.infinity, 48),
backgroundColor: AppColors.primary,
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
),
],
),
),
);
}
void _showUpgradeDialog(BuildContext context) {
showDialog(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('升级套餐'),
content: const Text('请前往 Web 管理后台 → 账单 → 套餐 完成升级。'),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(),
child: const Text('知道了'),
),
],
),
);
}
}
class _SubscriptionCard extends StatelessWidget {
final String planName;
final String status;
final DateTime? periodEnd;
final Color cardColor;
final ThemeData theme;
const _SubscriptionCard({
required this.planName,
required this.status,
required this.periodEnd,
required this.cardColor,
required this.theme,
});
Color _statusColor(String status) => switch (status) {
'active' => Colors.green,
'trialing' => Colors.blue,
'past_due' => Colors.orange,
_ => Colors.grey,
};
String _statusLabel(String status) => switch (status) {
'active' => '正常',
'trialing' => '试用期',
'past_due' => '待付款',
'cancelled' => '已取消',
'expired' => '已过期',
_ => status,
};
@override
Widget build(BuildContext context) {
return Card(
color: cardColor,
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
side: BorderSide(color: Colors.grey.withOpacity(0.15)),
),
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(Icons.credit_card, size: 20, color: AppColors.primary),
const SizedBox(width: 8),
Text('当前套餐', style: theme.textTheme.bodySmall?.copyWith(color: Colors.grey)),
],
),
const SizedBox(height: 12),
Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
planName,
style: theme.textTheme.headlineMedium?.copyWith(fontWeight: FontWeight.bold),
),
const SizedBox(width: 10),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: _statusColor(status).withOpacity(0.1),
borderRadius: BorderRadius.circular(20),
),
child: Text(
_statusLabel(status),
style: TextStyle(fontSize: 12, color: _statusColor(status), fontWeight: FontWeight.w600),
),
),
],
),
if (periodEnd != null) ...[
const SizedBox(height: 6),
Text(
'当期结束:${_formatDate(periodEnd!)}',
style: theme.textTheme.bodySmall?.copyWith(color: Colors.grey),
),
],
],
),
),
);
}
String _formatDate(DateTime d) => '${d.year}-${d.month.toString().padLeft(2, '0')}-${d.day.toString().padLeft(2, '0')}';
}
class _UsageCard extends StatelessWidget {
final int usedTokens;
final int limitTokens;
final Color cardColor;
final ThemeData theme;
const _UsageCard({
required this.usedTokens,
required this.limitTokens,
required this.cardColor,
required this.theme,
});
String _formatTokens(int n) {
if (n >= 1000000) return '${(n / 1000000).toStringAsFixed(2)}M';
if (n >= 1000) return '${(n / 1000).toStringAsFixed(0)}K';
return '$n';
}
@override
Widget build(BuildContext context) {
final isUnlimited = limitTokens == -1;
final pct = isUnlimited ? 0.0 : (usedTokens / limitTokens).clamp(0.0, 1.0);
final barColor = pct >= 0.9 ? Colors.red : pct >= 0.7 ? Colors.orange : Colors.green;
return Card(
color: cardColor,
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
side: BorderSide(color: Colors.grey.withOpacity(0.15)),
),
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(Icons.bolt, size: 20, color: AppColors.primary),
const SizedBox(width: 8),
Text('本月 Token 用量', style: theme.textTheme.bodySmall?.copyWith(color: Colors.grey)),
],
),
const SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
_formatTokens(usedTokens),
style: theme.textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
),
Text(
isUnlimited ? '不限量' : _formatTokens(limitTokens),
style: theme.textTheme.bodyMedium?.copyWith(color: Colors.grey),
),
],
),
if (!isUnlimited) ...[
const SizedBox(height: 10),
ClipRRect(
borderRadius: BorderRadius.circular(4),
child: LinearProgressIndicator(
value: pct,
minHeight: 6,
backgroundColor: Colors.grey.withOpacity(0.2),
valueColor: AlwaysStoppedAnimation(barColor),
),
),
const SizedBox(height: 6),
Text(
'${(pct * 100).toStringAsFixed(1)}%',
style: theme.textTheme.bodySmall?.copyWith(color: Colors.grey),
),
],
],
),
),
);
}
}
class _InvoiceCard extends StatelessWidget {
final LatestInvoice invoice;
final Color cardColor;
final ThemeData theme;
const _InvoiceCard({
required this.invoice,
required this.cardColor,
required this.theme,
});
@override
Widget build(BuildContext context) {
return Card(
color: cardColor,
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
side: BorderSide(color: Colors.grey.withOpacity(0.15)),
),
child: ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8),
leading: const Icon(Icons.receipt_long, color: AppColors.primary),
title: Text(invoice.invoiceNumber, style: const TextStyle(fontWeight: FontWeight.w600)),
subtitle: Text('${invoice.currency} ${invoice.amount.toStringAsFixed(2)}', style: const TextStyle(fontSize: 13)),
trailing: Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: invoice.status == 'paid'
? Colors.green.withOpacity(0.1)
: Colors.orange.withOpacity(0.1),
borderRadius: BorderRadius.circular(20),
),
child: Text(
invoice.status == 'paid' ? '已付款' : '待付款',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: invoice.status == 'paid' ? Colors.green : Colors.orange,
),
),
),
),
);
}
}

View File

@ -0,0 +1,87 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../../core/network/dio_client.dart';
class LatestInvoice {
final String invoiceNumber;
final String status;
final String currency;
final double amount;
const LatestInvoice({
required this.invoiceNumber,
required this.status,
required this.currency,
required this.amount,
});
factory LatestInvoice.fromJson(Map<String, dynamic> json) => LatestInvoice(
invoiceNumber: json['invoiceNumber'] as String? ?? '',
status: json['status'] as String? ?? '',
currency: json['currency'] as String? ?? 'USD',
amount: (json['totalAmount'] as num?)?.toDouble() ?? 0,
);
}
class BillingOverview {
final String planDisplayName;
final String subscriptionStatus;
final DateTime? currentPeriodEnd;
final DateTime? trialEndsAt;
final int usedTokens;
final int limitTokens;
final bool overageAllowed;
final LatestInvoice? latestInvoice;
const BillingOverview({
required this.planDisplayName,
required this.subscriptionStatus,
required this.currentPeriodEnd,
required this.trialEndsAt,
required this.usedTokens,
required this.limitTokens,
required this.overageAllowed,
required this.latestInvoice,
});
}
final billingOverviewProvider = FutureProvider<BillingOverview>((ref) async {
final dio = ref.read(dioClientProvider);
// Fetch subscription, quota, and recent invoice (ignore 404s gracefully)
Map<String, dynamic>? sub;
Map<String, dynamic>? quota;
Map<String, dynamic>? invoicesData;
try {
final r = await dio.get('/api/v1/billing/subscription');
sub = r.data as Map<String, dynamic>?;
} catch (_) {}
try {
final r = await dio.get('/api/v1/billing/usage/current');
quota = r.data as Map<String, dynamic>?;
} catch (_) {}
try {
final r = await dio.get('/api/v1/billing/invoices?limit=1');
invoicesData = r.data as Map<String, dynamic>?;
} catch (_) {}
final invoices = (invoicesData?['data'] as List?)?.cast<Map<String, dynamic>>() ?? [];
final latestInvoice = invoices.isNotEmpty ? LatestInvoice.fromJson(invoices.first) : null;
return BillingOverview(
planDisplayName: ((sub?['plan'] as Map<String, dynamic>?)?['displayName'] as String?) ?? 'Free',
subscriptionStatus: sub?['status'] as String? ?? 'unknown',
currentPeriodEnd: sub?['currentPeriodEnd'] != null
? DateTime.tryParse(sub!['currentPeriodEnd'] as String)
: null,
trialEndsAt: sub?['trialEndsAt'] != null
? DateTime.tryParse(sub!['trialEndsAt'] as String)
: null,
usedTokens: quota?['usedTokens'] as int? ?? 0,
limitTokens: quota?['limitTokens'] as int? ?? 0,
overageAllowed: quota?['overageAllowed'] as bool? ?? false,
latestInvoice: latestInvoice,
);
});

View File

@ -148,6 +148,20 @@ class _SettingsPageState extends ConsumerState<SettingsPage> {
),
const SizedBox(height: 24),
// ===== Billing Group =====
_SettingsGroup(
cardColor: cardColor,
children: [
_SettingsRow(
icon: Icons.credit_card_outlined,
iconBg: const Color(0xFF10B981),
title: '订阅与用量',
onTap: () => context.push('/settings/billing'),
),
],
),
const SizedBox(height: 24),
// ===== Security Group =====
_SettingsGroup(
cardColor: cardColor,

View File

@ -121,6 +121,18 @@ services:
- /api/v1/versions
strip_path: false
- name: billing-service
url: http://billing-service:3010
routes:
- name: billing-routes
paths:
- /api/v1/billing
strip_path: false
- name: billing-webhooks
paths:
- /api/v1/billing/webhooks
strip_path: false
plugins:
# ===== Global plugins (apply to ALL routes) =====
- name: cors
@ -219,6 +231,13 @@ plugins:
claims_to_verify:
- exp
- name: jwt
route: billing-routes
config:
key_claim_name: kid
claims_to_verify:
- exp
- name: jwt
route: admin-routes
config:

View File

@ -37,11 +37,14 @@ import { AgentConfig } from './domain/entities/agent-config.entity';
import { HookScript } from './domain/entities/hook-script.entity';
import { VoiceConfig } from './domain/entities/voice-config.entity';
import { ConversationMessage } from './domain/entities/conversation-message.entity';
import { UsageRecord } from './domain/entities/usage-record.entity';
import { MessageRepository } from './infrastructure/repositories/message.repository';
import { VoiceConfigRepository } from './infrastructure/repositories/voice-config.repository';
import { UsageRecordRepository } from './infrastructure/repositories/usage-record.repository';
import { VoiceConfigService } from './infrastructure/services/voice-config.service';
import { VoiceConfigController } from './interfaces/rest/controllers/voice-config.controller';
import { ConversationContextService } from './domain/services/conversation-context.service';
import { EventPublisherService } from './infrastructure/messaging/event-publisher.service';
@Module({
imports: [
@ -50,7 +53,7 @@ import { ConversationContextService } from './domain/services/conversation-conte
TypeOrmModule.forFeature([
AgentSession, AgentTask, CommandRecord, StandingOrderRef,
TenantAgentConfig, AgentConfig, HookScript, VoiceConfig,
ConversationMessage,
ConversationMessage, UsageRecord,
]),
],
controllers: [
@ -72,6 +75,7 @@ import { ConversationContextService } from './domain/services/conversation-conte
SessionRepository,
TaskRepository,
MessageRepository,
UsageRecordRepository,
TenantAgentConfigRepository,
AgentConfigRepository,
VoiceConfigRepository,
@ -81,6 +85,7 @@ import { ConversationContextService } from './domain/services/conversation-conte
VoiceConfigService,
AgentSkillService,
HookScriptService,
EventPublisherService,
],
})
export class AgentModule {}

View File

@ -2,14 +2,16 @@ import { Injectable, Logger } from '@nestjs/common';
import { EngineRegistry } from '../../infrastructure/engines/engine-registry';
import { SessionRepository } from '../../infrastructure/repositories/session.repository';
import { TaskRepository } from '../../infrastructure/repositories/task.repository';
import { UsageRecordRepository } from '../../infrastructure/repositories/usage-record.repository';
import { AgentStreamGateway } from '../../interfaces/ws/agent-stream.gateway';
import { AgentSession } from '../../domain/entities/agent-session.entity';
import { AgentTask } from '../../domain/entities/agent-task.entity';
import { UsageRecord } from '../../domain/entities/usage-record.entity';
import { TaskStatus } from '../../domain/value-objects/task-status.vo';
import { EngineStreamEvent } from '../../domain/ports/outbound/agent-engine.port';
import { EventPublisherPort } from '../../domain/ports/outbound/event-publisher.port';
import { ExecuteTaskDto } from '../dto/execute-task.dto';
import { TaskResultDto } from '../dto/task-result.dto';
import { EventPatterns } from '@it0/common';
import * as crypto from 'crypto';
@Injectable()
@ -20,7 +22,9 @@ export class ExecuteTaskUseCase {
private readonly engineRegistry: EngineRegistry,
private readonly sessionRepository: SessionRepository,
private readonly taskRepository: TaskRepository,
private readonly usageRecordRepository: UsageRecordRepository,
private readonly gateway: AgentStreamGateway,
private readonly eventPublisher: EventPublisherPort,
) {}
async execute(
@ -55,7 +59,7 @@ export class ExecuteTaskUseCase {
await this.taskRepository.save(task);
// Fire-and-forget: iterate engine stream and emit events via gateway
this.runEngineStream(engine, session, task, dto);
this.runEngineStream(engine, session, task, dto, tenantId);
return { sessionId: session.id, taskId: task.id };
}
@ -77,6 +81,7 @@ export class ExecuteTaskUseCase {
session: AgentSession,
task: AgentTask,
dto: ExecuteTaskDto,
tenantId: string,
): Promise<void> {
try {
const stream = engine.executeTask({
@ -102,6 +107,9 @@ export class ExecuteTaskUseCase {
session.status = 'completed';
session.updatedAt = new Date();
await this.sessionRepository.save(session);
// Record usage for billing
await this.recordUsage(tenantId, task, session, event, engine.engineType);
}
if (event.type === 'error') {
@ -141,4 +149,49 @@ export class ExecuteTaskUseCase {
});
}
}
private async recordUsage(
tenantId: string,
task: AgentTask,
session: AgentSession,
event: Extract<EngineStreamEvent, { type: 'completed' }>,
engineType: string,
): Promise<void> {
const inputTokens = event.inputTokens ?? 0;
const outputTokens = event.outputTokens ?? 0;
const totalTokens = event.tokensUsed ?? (inputTokens + outputTokens);
if (totalTokens === 0) return;
try {
const record = new UsageRecord();
record.id = crypto.randomUUID();
record.tenantId = tenantId;
record.taskId = task.id;
record.sessionId = session.id;
record.engineType = engineType;
record.inputTokens = inputTokens;
record.outputTokens = outputTokens;
record.totalTokens = totalTokens;
record.costUsd = event.costUsd ?? 0;
record.model = event.model ?? '';
await this.usageRecordRepository.save(record);
// Publish billing event for billing-service to aggregate
await this.eventPublisher.publish(EventPatterns.USAGE_RECORDED, {
tenantId,
taskId: task.id,
sessionId: session.id,
engineType,
inputTokens,
outputTokens,
totalTokens,
costUsd: event.costUsd ?? 0,
model: event.model ?? '',
});
} catch (err: any) {
this.logger.error(`Failed to record usage for task ${task.id}: ${err.message}`);
}
}
}

View File

@ -0,0 +1,37 @@
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn } from 'typeorm';
@Entity('usage_records')
export class UsageRecord {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column({ type: 'varchar', length: 20 })
tenantId!: string;
@Column({ type: 'uuid' })
taskId!: string;
@Column({ type: 'uuid' })
sessionId!: string;
@Column({ type: 'varchar', length: 30 })
engineType!: string;
@Column({ type: 'int', default: 0 })
inputTokens!: number;
@Column({ type: 'int', default: 0 })
outputTokens!: number;
@Column({ type: 'int', default: 0 })
totalTokens!: number;
@Column({ type: 'numeric', precision: 10, scale: 6, default: 0 })
costUsd!: number;
@Column({ type: 'varchar', length: 100, default: '' })
model!: string;
@CreateDateColumn({ type: 'timestamptz' })
recordedAt!: Date;
}

View File

@ -36,7 +36,15 @@ export type EngineStreamEvent =
| { type: 'tool_use'; toolName: string; input: Record<string, unknown> }
| { type: 'tool_result'; toolName: string; output: string; isError: boolean }
| { type: 'approval_required'; command: string; riskLevel: number; taskId: string }
| { type: 'completed'; summary: string; tokensUsed?: number }
| {
type: 'completed';
summary: string;
tokensUsed?: number;
inputTokens?: number;
outputTokens?: number;
costUsd?: number;
model?: string;
}
| { type: 'error'; message: string; code: string }
| { type: 'cancelled'; message: string; code: string }
| { type: 'task_info'; taskId: string };

View File

@ -578,12 +578,20 @@ export class ClaudeAgentSdkEngine implements AgentEnginePort {
}
}
} else if (message.type === 'result') {
const inputTokens = message.usage?.input_tokens ?? 0;
const outputTokens = message.usage?.output_tokens ?? 0;
const totalTokens = inputTokens + outputTokens;
const costUsd = message.total_cost_usd != null
? Number(message.total_cost_usd)
: (inputTokens * 3 + outputTokens * 15) / 1_000_000;
events.push({
type: 'completed',
summary: message.result ?? '',
tokensUsed: message.usage
? (message.usage.input_tokens ?? 0) + (message.usage.output_tokens ?? 0)
: undefined,
tokensUsed: totalTokens || undefined,
inputTokens: inputTokens || undefined,
outputTokens: outputTokens || undefined,
costUsd: costUsd || undefined,
model: message.model ?? 'claude-sonnet-4-5-20250929',
});
} else if (message.type === 'system' && message.subtype === 'init') {
this.logger.log(`SDK session initialized: ${message.session_id}`);

View File

@ -68,8 +68,10 @@ export class ClaudeApiEngine implements AgentEnginePort {
? [...history]
: [...history, { role: 'user', content: params.prompt }];
let totalTokensUsed = 0;
let totalInputTokens = 0;
let totalOutputTokens = 0;
let turnCount = 0;
const engineModel = 'claude-sonnet-4-5-20250929';
try {
// Agent loop: continue until end_turn or max turns reached
@ -87,7 +89,7 @@ export class ClaudeApiEngine implements AgentEnginePort {
}
const requestParams: any = {
model: 'claude-sonnet-4-5-20250929',
model: engineModel,
max_tokens: 8192,
messages,
};
@ -130,9 +132,10 @@ export class ClaudeApiEngine implements AgentEnginePort {
// Get final message for tool use and usage
const response = await stream.finalMessage();
// Track token usage
// Track token usage (input/output separate for billing)
if (response.usage) {
totalTokensUsed += (response.usage.input_tokens ?? 0) + (response.usage.output_tokens ?? 0);
totalInputTokens += response.usage.input_tokens ?? 0;
totalOutputTokens += response.usage.output_tokens ?? 0;
}
// Collect tool_use blocks from final response
@ -158,10 +161,17 @@ export class ClaudeApiEngine implements AgentEnginePort {
);
const summary = (summaryBlock as any)?.text ?? 'Task completed';
const totalTokens = totalInputTokens + totalOutputTokens;
// claude-sonnet-4-5: $3/MTok input, $15/MTok output
const costUsd = (totalInputTokens * 3 + totalOutputTokens * 15) / 1_000_000;
yield {
type: 'completed' as const,
summary,
tokensUsed: totalTokensUsed,
tokensUsed: totalTokens,
inputTokens: totalInputTokens,
outputTokens: totalOutputTokens,
costUsd,
model: engineModel,
};
return;
}
@ -203,10 +213,16 @@ export class ClaudeApiEngine implements AgentEnginePort {
}
// Max turns reached
const totalTokens = totalInputTokens + totalOutputTokens;
const costUsd = (totalInputTokens * 3 + totalOutputTokens * 15) / 1_000_000;
yield {
type: 'completed',
summary: `Agent loop completed after reaching max turns (${params.maxTurns})`,
tokensUsed: totalTokensUsed,
tokensUsed: totalTokens,
inputTokens: totalInputTokens,
outputTokens: totalOutputTokens,
costUsd,
model: engineModel,
};
} catch (error: any) {
if (error.name === 'AbortError' || abortController.signal.aborted) {

View File

@ -279,12 +279,21 @@ export class ClaudeCodeCliEngine implements AgentEnginePort {
isError: parsed.is_error ?? false,
});
} else if (parsed.type === 'result') {
const inputTokens = parsed.usage?.input_tokens ?? 0;
const outputTokens = parsed.usage?.output_tokens ?? 0;
const totalTokens = inputTokens + outputTokens;
// Use reported cost if available, otherwise estimate at claude-sonnet-4-5 pricing
const costUsd = parsed.total_cost_usd != null
? Number(parsed.total_cost_usd)
: (inputTokens * 3 + outputTokens * 15) / 1_000_000;
events.push({
type: 'completed',
summary: parsed.result ?? parsed.text ?? 'Task completed',
tokensUsed: parsed.total_cost_usd !== undefined
? undefined
: (parsed.usage?.input_tokens ?? 0) + (parsed.usage?.output_tokens ?? 0) || undefined,
tokensUsed: totalTokens || undefined,
inputTokens: inputTokens || undefined,
outputTokens: outputTokens || undefined,
costUsd: costUsd || undefined,
model: parsed.model ?? 'claude-sonnet-4-5-20250929',
});
} else if (parsed.type === 'error') {
events.push({

View File

@ -0,0 +1,24 @@
import { Injectable } from '@nestjs/common';
import { DataSource } from 'typeorm';
import { TenantAwareRepository } from '@it0/database';
import { UsageRecord } from '../../domain/entities/usage-record.entity';
@Injectable()
export class UsageRecordRepository extends TenantAwareRepository<UsageRecord> {
constructor(dataSource: DataSource) {
super(dataSource, UsageRecord);
}
async sumMonthTokens(year: number, month: number): Promise<number> {
return this.withRepository(async (repo) => {
const start = new Date(year, month - 1, 1);
const end = new Date(year, month, 1);
const result = await repo
.createQueryBuilder('ur')
.select('COALESCE(SUM(ur.total_tokens), 0)', 'total')
.where('ur.recorded_at >= :start AND ur.recorded_at < :end', { start, end })
.getRawOne();
return Number(result?.total ?? 0);
});
}
}

View File

@ -1,15 +1,18 @@
import { Controller, Post, Body, Param, Delete, Get, NotFoundException, BadRequestException, ForbiddenException, Logger } from '@nestjs/common';
import { TenantId } from '@it0/common';
import { TenantId, EventPatterns } from '@it0/common';
import { EngineRegistry } from '../../../infrastructure/engines/engine-registry';
import { AgentStreamGateway } from '../../ws/agent-stream.gateway';
import { SessionRepository } from '../../../infrastructure/repositories/session.repository';
import { TaskRepository } from '../../../infrastructure/repositories/task.repository';
import { UsageRecordRepository } from '../../../infrastructure/repositories/usage-record.repository';
import { ConversationContextService } from '../../../domain/services/conversation-context.service';
import { EventPublisherService } from '../../../infrastructure/messaging/event-publisher.service';
import { AgentSession } from '../../../domain/entities/agent-session.entity';
import { AgentTask } from '../../../domain/entities/agent-task.entity';
import { UsageRecord } from '../../../domain/entities/usage-record.entity';
import { TaskStatus } from '../../../domain/value-objects/task-status.vo';
import { AgentEngineType } from '../../../domain/value-objects/agent-engine-type.vo';
import { AgentEnginePort } from '../../../domain/ports/outbound/agent-engine.port';
import { AgentEnginePort, EngineStreamEvent } from '../../../domain/ports/outbound/agent-engine.port';
import { ClaudeAgentSdkEngine } from '../../../infrastructure/engines/claude-agent-sdk/claude-agent-sdk-engine';
import * as crypto from 'crypto';
@ -24,7 +27,9 @@ export class AgentController {
private readonly gateway: AgentStreamGateway,
private readonly sessionRepository: SessionRepository,
private readonly taskRepository: TaskRepository,
private readonly usageRecordRepository: UsageRecordRepository,
private readonly contextService: ConversationContextService,
private readonly eventPublisher: EventPublisherService,
) {}
@Post('tasks')
@ -475,6 +480,11 @@ export class AgentController {
// Keep session active so it can be reused (also persists captured SDK session ID)
session.updatedAt = new Date();
await this.sessionRepository.save(session);
// Record usage for billing (non-blocking)
this.recordUsage(session.tenantId, task, session, event, engine.engineType).catch(
(err) => this.logger.error(`[Task ${task.id}] Usage recording failed: ${err.message}`),
);
}
if (event.type === 'error' && !finished) {
@ -570,6 +580,45 @@ export class AgentController {
}
}
private async recordUsage(
tenantId: string,
task: AgentTask,
session: AgentSession,
event: Extract<EngineStreamEvent, { type: 'completed' }>,
engineType: string,
): Promise<void> {
const inputTokens = event.inputTokens ?? 0;
const outputTokens = event.outputTokens ?? 0;
const totalTokens = event.tokensUsed ?? (inputTokens + outputTokens);
if (totalTokens === 0) return;
const record = new UsageRecord();
record.id = crypto.randomUUID();
record.tenantId = tenantId;
record.taskId = task.id;
record.sessionId = session.id;
record.engineType = engineType;
record.inputTokens = inputTokens;
record.outputTokens = outputTokens;
record.totalTokens = totalTokens;
record.costUsd = event.costUsd ?? 0;
record.model = event.model ?? '';
await this.usageRecordRepository.save(record);
await this.eventPublisher.publish(EventPatterns.USAGE_RECORDED, {
tenantId,
taskId: task.id,
sessionId: session.id,
engineType,
inputTokens,
outputTokens,
totalTokens,
costUsd: event.costUsd ?? 0,
model: event.model ?? '',
});
}
private createNewSession(tenantId: string, engineType: string, systemPrompt?: string): AgentSession {
const session = new AgentSession();
session.id = crypto.randomUUID();

View File

@ -4,6 +4,8 @@ import {
ConflictException,
BadRequestException,
NotFoundException,
HttpException,
HttpStatus,
Logger,
} from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
@ -235,6 +237,21 @@ export class AuthService {
throw new NotFoundException('Tenant not found');
}
// Check user quota
if (tenant.maxUsers !== -1) {
const schemaName = `it0_t_${tenantId}`;
const countResult = await this.dataSource.query(
`SELECT COUNT(*) AS cnt FROM "${schemaName}".users WHERE is_active = true`,
);
const currentCount = Number(countResult[0]?.cnt ?? 0);
if (currentCount >= tenant.maxUsers) {
throw new HttpException(
{ message: `User quota exceeded (${currentCount}/${tenant.maxUsers}). Upgrade your plan to invite more users.`, code: 'QUOTA_EXCEEDED' },
HttpStatus.TOO_MANY_REQUESTS,
);
}
}
// Check for existing pending invite
const existing = await this.inviteRepository.findOne({
where: { tenantId, email, status: 'pending' as const },

View File

@ -0,0 +1,34 @@
{
"name": "@it0/billing-service",
"version": "0.1.0",
"private": true,
"scripts": {
"build": "nest build",
"dev": "nest start --watch",
"start": "node dist/main",
"test": "jest"
},
"dependencies": {
"@nestjs/common": "^10.3.0",
"@nestjs/core": "^10.3.0",
"@nestjs/config": "^3.2.0",
"@nestjs/typeorm": "^10.0.0",
"@nestjs/platform-express": "^10.3.0",
"@nestjs/schedule": "^4.0.0",
"typeorm": "^0.3.20",
"pg": "^8.11.0",
"reflect-metadata": "^0.2.0",
"rxjs": "^7.8.0",
"stripe": "^14.0.0",
"alipay-sdk": "^4.0.0",
"coinbase-commerce-node": "^1.0.4",
"@it0/common": "workspace:*",
"@it0/database": "workspace:*",
"@it0/events": "workspace:*"
},
"devDependencies": {
"@nestjs/cli": "^10.3.0",
"@types/node": "^20.11.0",
"typescript": "^5.4.0"
}
}

View File

@ -0,0 +1,103 @@
import { Injectable, Logger, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { createClient, RedisClientType } from 'redis';
import { UsageAggregateRepository } from '../../infrastructure/repositories/usage-aggregate.repository';
interface UsageRecordedEvent {
tenantId: string;
taskId: string;
inputTokens: number;
outputTokens: number;
totalTokens: number;
costUsd: number;
model: string;
recordedAt: string;
}
const STREAM_KEY = 'events:usage.recorded';
const CONSUMER_GROUP = 'billing-service';
const CONSUMER_NAME = 'billing-consumer-1';
@Injectable()
export class AggregateUsageUseCase implements OnModuleInit, OnModuleDestroy {
private readonly logger = new Logger(AggregateUsageUseCase.name);
private client: RedisClientType;
private running = false;
constructor(
private readonly usageAggRepo: UsageAggregateRepository,
private readonly configService: ConfigService,
) {}
async onModuleInit() {
const redisUrl = this.configService.get<string>('REDIS_URL', 'redis://localhost:6379');
this.client = createClient({ url: redisUrl }) as RedisClientType;
await this.client.connect();
// Create consumer group (ignore error if already exists)
try {
await this.client.xGroupCreate(STREAM_KEY, CONSUMER_GROUP, '0', { MKSTREAM: true });
} catch {
// Group already exists
}
this.running = true;
this.consumeLoop().catch((err) =>
this.logger.error(`Usage consumer loop error: ${err.message}`),
);
}
async onModuleDestroy() {
this.running = false;
await this.client.quit();
}
private async consumeLoop() {
while (this.running) {
try {
const response = await (this.client as any).xReadGroup(
CONSUMER_GROUP,
CONSUMER_NAME,
[{ key: STREAM_KEY, id: '>' }],
{ COUNT: 10, BLOCK: 5000 },
);
if (!response) continue;
for (const stream of response) {
for (const message of stream.messages) {
await this.processMessage(message);
await this.client.xAck(STREAM_KEY, CONSUMER_GROUP, message.id);
}
}
} catch (err) {
if (this.running) {
this.logger.error(`Redis consumer error: ${err.message}`);
await new Promise((r) => setTimeout(r, 5000));
}
}
}
}
private async processMessage(message: { id: string; message: Record<string, string> }) {
try {
const payload: UsageRecordedEvent = JSON.parse(message.message.data ?? '{}');
if (!payload.tenantId || !payload.inputTokens) return;
const recordedAt = new Date(payload.recordedAt ?? Date.now());
const year = recordedAt.getFullYear();
const month = recordedAt.getMonth() + 1;
await this.usageAggRepo.upsertTokens(
payload.tenantId,
year,
month,
payload.inputTokens ?? 0,
payload.outputTokens ?? 0,
payload.costUsd ?? 0,
);
} catch (err) {
this.logger.error(`Failed to process usage message ${message.id}: ${err.message}`);
}
}
}

View File

@ -0,0 +1,30 @@
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
import { SubscriptionRepository } from '../../infrastructure/repositories/subscription.repository';
import { SubscriptionLifecycleService } from '../../domain/services/subscription-lifecycle.service';
@Injectable()
export class CancelSubscriptionUseCase {
private readonly logger = new Logger(CancelSubscriptionUseCase.name);
constructor(
private readonly subscriptionRepo: SubscriptionRepository,
private readonly lifecycle: SubscriptionLifecycleService,
) {}
/**
* @param immediate true = cancel now, false = cancel at period end
*/
async execute(tenantId: string, immediate = false): Promise<void> {
const subscription = await this.subscriptionRepo.findByTenantId(tenantId);
if (!subscription) throw new NotFoundException(`No subscription found for tenant ${tenantId}`);
const updated = this.lifecycle.cancel(subscription, immediate);
await this.subscriptionRepo.save(updated);
if (immediate) {
this.logger.log(`Tenant ${tenantId} subscription cancelled immediately`);
} else {
this.logger.log(`Tenant ${tenantId} subscription set to cancel at period end`);
}
}
}

View File

@ -0,0 +1,86 @@
import { Injectable, Logger, NotFoundException, BadRequestException } from '@nestjs/common';
import { PlanRepository } from '../../infrastructure/repositories/plan.repository';
import { SubscriptionRepository } from '../../infrastructure/repositories/subscription.repository';
import { InvoiceRepository } from '../../infrastructure/repositories/invoice.repository';
import { SubscriptionLifecycleService } from '../../domain/services/subscription-lifecycle.service';
import { InvoiceGeneratorService } from '../../domain/services/invoice-generator.service';
import { InvoiceCurrency } from '../../domain/entities/invoice.entity';
export interface ChangePlanDto {
tenantId: string;
newPlanName: string;
currency?: InvoiceCurrency;
}
@Injectable()
export class ChangePlanUseCase {
private readonly logger = new Logger(ChangePlanUseCase.name);
constructor(
private readonly planRepo: PlanRepository,
private readonly subscriptionRepo: SubscriptionRepository,
private readonly invoiceRepo: InvoiceRepository,
private readonly lifecycle: SubscriptionLifecycleService,
private readonly invoiceGenerator: InvoiceGeneratorService,
) {}
async execute(dto: ChangePlanDto): Promise<void> {
const { tenantId, newPlanName, currency = InvoiceCurrency.USD } = dto;
const subscription = await this.subscriptionRepo.findByTenantId(tenantId);
if (!subscription) throw new NotFoundException(`No subscription found for tenant ${tenantId}`);
const currentPlan = await this.planRepo.findById(subscription.planId);
if (!currentPlan) throw new NotFoundException('Current plan not found');
const newPlan = await this.planRepo.findByName(newPlanName);
if (!newPlan) throw new NotFoundException(`Plan '${newPlanName}' not found`);
if (currentPlan.id === newPlan.id) {
throw new BadRequestException('Already on this plan');
}
const isUpgrade = newPlan.monthlyPriceUsdCents > currentPlan.monthlyPriceUsdCents;
if (isUpgrade) {
// Immediate upgrade with proration invoice item
const updated = this.lifecycle.upgradeNow(subscription, newPlan.id);
await this.subscriptionRepo.save(updated);
// Generate proration charge if not on free plan
if (newPlan.monthlyPriceUsdCents > 0 && currentPlan.monthlyPriceUsdCents >= 0) {
const invoiceNumber = await this.invoiceRepo.getNextInvoiceNumber();
const proratedItem = this.invoiceGenerator.generateProratedUpgradeItem(
currentPlan,
newPlan,
new Date(),
subscription.currentPeriodEnd,
currency,
);
if (proratedItem.amount > 0) {
const proInvoice = {
tenantId,
subscriptionId: subscription.id,
invoiceNumber,
currency,
periodStart: new Date(),
periodEnd: subscription.currentPeriodEnd,
dueDate: new Date(),
subtotalCents: proratedItem.amount,
taxCents: 0,
totalCents: proratedItem.amount,
amountDueCents: proratedItem.amount,
} as any;
await this.invoiceRepo.saveWithItems(proInvoice, [proratedItem]);
}
}
this.logger.log(`Tenant ${tenantId} upgraded to ${newPlanName}`);
} else {
// Downgrade: schedule for next period
const updated = this.lifecycle.scheduleDowngrade(subscription, newPlan.id);
await this.subscriptionRepo.save(updated);
this.logger.log(`Tenant ${tenantId} scheduled downgrade to ${newPlanName} at period end`);
}
}
}

View File

@ -0,0 +1,82 @@
import { Injectable } from '@nestjs/common';
import { PlanRepository } from '../../infrastructure/repositories/plan.repository';
import { SubscriptionRepository } from '../../infrastructure/repositories/subscription.repository';
import { UsageAggregateRepository } from '../../infrastructure/repositories/usage-aggregate.repository';
export interface QuotaCheckResult {
allowed: boolean;
remaining: number;
usedTokens: number;
limitTokens: number;
isHardLimit: boolean;
overageAllowed: boolean;
}
@Injectable()
export class CheckTokenQuotaUseCase {
constructor(
private readonly planRepo: PlanRepository,
private readonly subscriptionRepo: SubscriptionRepository,
private readonly usageAggRepo: UsageAggregateRepository,
) {}
async execute(tenantId: string): Promise<QuotaCheckResult> {
const subscription = await this.subscriptionRepo.findByTenantId(tenantId);
if (!subscription) {
// No subscription: block all usage
return { allowed: false, remaining: 0, usedTokens: 0, limitTokens: 0, isHardLimit: true, overageAllowed: false };
}
const plan = await this.planRepo.findById(subscription.planId);
if (!plan) {
return { allowed: false, remaining: 0, usedTokens: 0, limitTokens: 0, isHardLimit: true, overageAllowed: false };
}
const now = new Date();
const usage = await this.usageAggRepo.findByTenantAndPeriod(
tenantId,
now.getFullYear(),
now.getMonth() + 1,
);
const usedTokens = usage?.totalTokens ?? 0;
const baseLimit = plan.includedTokensPerMonth;
// Enterprise: no limit (-1)
if (baseLimit === -1) {
return {
allowed: true,
remaining: Number.MAX_SAFE_INTEGER,
usedTokens,
limitTokens: -1,
isHardLimit: false,
overageAllowed: true,
};
}
const hardLimitTokens = Math.floor(baseLimit * (plan.hardLimitPercent / 100));
const overageAllowed = plan.overageRateCentsPerMToken > 0;
// Hard limit check (applies to all plans)
if (plan.hardLimitPercent > 0 && usedTokens >= hardLimitTokens) {
return {
allowed: false,
remaining: 0,
usedTokens,
limitTokens: hardLimitTokens,
isHardLimit: true,
overageAllowed,
};
}
const remaining = hardLimitTokens - usedTokens;
return {
allowed: true,
remaining,
usedTokens,
limitTokens: hardLimitTokens,
isHardLimit: false,
overageAllowed,
};
}
}

View File

@ -0,0 +1,66 @@
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
import { PaymentProviderRegistry } from '../../infrastructure/payment-providers/payment-provider.registry';
import { PaymentRepository } from '../../infrastructure/repositories/payment.repository';
import { InvoiceRepository } from '../../infrastructure/repositories/invoice.repository';
import { SubscriptionRepository } from '../../infrastructure/repositories/subscription.repository';
import { Payment, PaymentStatus } from '../../domain/entities/payment.entity';
import { PaymentProviderType } from '../../domain/ports/payment-provider.port';
import { InvoiceStatus } from '../../domain/entities/invoice.entity';
export interface CreatePaymentSessionDto {
tenantId: string;
invoiceId: string;
provider: PaymentProviderType;
returnUrl?: string;
}
@Injectable()
export class CreatePaymentSessionUseCase {
constructor(
private readonly providerRegistry: PaymentProviderRegistry,
private readonly paymentRepo: PaymentRepository,
private readonly invoiceRepo: InvoiceRepository,
private readonly subscriptionRepo: SubscriptionRepository,
) {}
async execute(dto: CreatePaymentSessionDto) {
const { tenantId, invoiceId, provider, returnUrl } = dto;
const invoice = await this.invoiceRepo.findById(invoiceId);
if (!invoice || invoice.tenantId !== tenantId) {
throw new NotFoundException('Invoice not found');
}
if (invoice.status === InvoiceStatus.PAID) {
throw new BadRequestException('Invoice is already paid');
}
const paymentProvider = this.providerRegistry.get(provider);
if (!paymentProvider) {
throw new BadRequestException(`Payment provider '${provider}' is not configured`);
}
const session = await paymentProvider.createPaymentSession({
invoiceId,
tenantId,
amountCents: invoice.amountDueCents,
currency: invoice.currency,
description: `IT0 Invoice ${invoice.invoiceNumber}`,
returnUrl,
});
// Create a pending payment record
const payment = new Payment();
payment.tenantId = tenantId;
payment.invoiceId = invoiceId;
payment.provider = provider;
payment.providerPaymentId = session.providerPaymentId;
payment.amountCents = invoice.amountDueCents;
payment.currency = invoice.currency;
payment.status = PaymentStatus.PENDING;
payment.metadata = { sessionId: session.sessionId };
await this.paymentRepo.savePayment(payment);
return session;
}
}

View File

@ -0,0 +1,34 @@
import { Injectable, Logger } from '@nestjs/common';
import { PlanRepository } from '../../infrastructure/repositories/plan.repository';
import { SubscriptionRepository } from '../../infrastructure/repositories/subscription.repository';
import { SubscriptionLifecycleService } from '../../domain/services/subscription-lifecycle.service';
@Injectable()
export class CreateSubscriptionUseCase {
private readonly logger = new Logger(CreateSubscriptionUseCase.name);
constructor(
private readonly planRepo: PlanRepository,
private readonly subscriptionRepo: SubscriptionRepository,
private readonly lifecycle: SubscriptionLifecycleService,
) {}
async execute(tenantId: string, planName = 'free'): Promise<void> {
// Check if subscription already exists
const existing = await this.subscriptionRepo.findByTenantId(tenantId);
if (existing) {
this.logger.warn(`Subscription already exists for tenant ${tenantId}`);
return;
}
const plan = await this.planRepo.findByName(planName);
if (!plan) {
throw new Error(`Plan '${planName}' not found`);
}
const subscription = this.lifecycle.createTrial(tenantId, plan);
await this.subscriptionRepo.save(subscription);
this.logger.log(`Created ${planName} trial subscription for tenant ${tenantId}`);
}
}

View File

@ -0,0 +1,122 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource } from 'typeorm';
import { PlanRepository } from '../../infrastructure/repositories/plan.repository';
import { SubscriptionRepository } from '../../infrastructure/repositories/subscription.repository';
import { InvoiceRepository } from '../../infrastructure/repositories/invoice.repository';
import { UsageAggregateRepository } from '../../infrastructure/repositories/usage-aggregate.repository';
import { InvoiceGeneratorService } from '../../domain/services/invoice-generator.service';
import { SubscriptionLifecycleService } from '../../domain/services/subscription-lifecycle.service';
import { InvoiceCurrency } from '../../domain/entities/invoice.entity';
import { SubscriptionStatus } from '../../domain/entities/subscription.entity';
@Injectable()
export class GenerateMonthlyInvoiceUseCase {
private readonly logger = new Logger(GenerateMonthlyInvoiceUseCase.name);
constructor(
private readonly planRepo: PlanRepository,
private readonly subscriptionRepo: SubscriptionRepository,
private readonly invoiceRepo: InvoiceRepository,
private readonly usageAggRepo: UsageAggregateRepository,
private readonly invoiceGenerator: InvoiceGeneratorService,
private readonly lifecycle: SubscriptionLifecycleService,
) {}
/**
* Called by cron on the 1st of each month.
* Generates invoices for the previous month and renews active subscriptions.
*/
async execute(): Promise<void> {
const now = new Date();
const prevMonth = now.getMonth() === 0 ? 12 : now.getMonth();
const prevYear = now.getMonth() === 0 ? now.getFullYear() - 1 : now.getFullYear();
this.logger.log(`Generating monthly invoices for ${prevYear}-${String(prevMonth).padStart(2, '0')}`);
// Find all subscriptions that expired (period end passed) or are active with cancelAtPeriodEnd
const expired = await this.subscriptionRepo.findExpired();
const cancelAtEnd = await this.subscriptionRepo.findCancelAtPeriodEnd();
// Process expired subscriptions
for (const sub of expired) {
try {
await this.processSubscription(sub, prevYear, prevMonth);
} catch (err) {
this.logger.error(`Failed to process subscription ${sub.id} for tenant ${sub.tenantId}: ${err.message}`);
}
}
// Process cancellations
for (const sub of cancelAtEnd) {
try {
if (sub.currentPeriodEnd <= now) {
const updated = this.lifecycle.cancel(sub, true);
await this.subscriptionRepo.save(updated);
this.logger.log(`Cancelled subscription for tenant ${sub.tenantId} at period end`);
}
} catch (err) {
this.logger.error(`Failed to cancel subscription ${sub.id}: ${err.message}`);
}
}
}
private async processSubscription(sub: any, year: number, month: number): Promise<void> {
const plan = await this.planRepo.findById(sub.planId);
if (!plan) {
this.logger.warn(`Plan ${sub.planId} not found for subscription ${sub.id}`);
return;
}
const usage = await this.usageAggRepo.findByTenantAndPeriod(sub.tenantId, year, month);
if (!usage) {
this.logger.debug(`No usage data for tenant ${sub.tenantId} in ${year}-${month}`);
}
// Only generate invoice if plan costs money
if (plan.monthlyPriceUsdCents > 0 || (usage && usage.totalTokens > plan.includedTokensPerMonth)) {
const invoiceNumber = await this.invoiceRepo.getNextInvoiceNumber();
const { invoice, items } = this.invoiceGenerator.generateMonthlyInvoice(
sub,
plan,
usage ?? this.emptyUsage(sub.tenantId, year, month),
InvoiceCurrency.USD,
invoiceNumber,
);
await this.invoiceRepo.saveWithItems(invoice, items);
this.logger.log(`Generated invoice ${invoiceNumber} for tenant ${sub.tenantId}`);
}
// Renew the subscription (apply scheduled downgrade if any)
if (sub.status === SubscriptionStatus.ACTIVE) {
const renewed = this.lifecycle.renew(sub);
await this.subscriptionRepo.save(renewed);
} else if (sub.status === SubscriptionStatus.TRIALING && sub.trialEndsAt <= new Date()) {
// Trial expired: activate if paid plan, else keep on free
if (plan.monthlyPriceUsdCents === 0) {
const activated = this.lifecycle.activate(sub);
await this.subscriptionRepo.save(activated);
} else {
// Mark as past_due until payment received
const pastDue = this.lifecycle.markPastDue(sub);
await this.subscriptionRepo.save(pastDue);
}
}
}
private emptyUsage(tenantId: string, year: number, month: number): any {
return {
tenantId,
year,
month,
totalInputTokens: 0,
totalOutputTokens: 0,
totalTokens: 0,
totalCostUsd: 0,
taskCount: 0,
periodStart: new Date(year, month - 1, 1),
periodEnd: new Date(year, month, 0, 23, 59, 59),
};
}
}

View File

@ -0,0 +1,101 @@
import { Injectable, Logger } from '@nestjs/common';
import { PaymentRepository } from '../../infrastructure/repositories/payment.repository';
import { InvoiceRepository } from '../../infrastructure/repositories/invoice.repository';
import { SubscriptionRepository } from '../../infrastructure/repositories/subscription.repository';
import { PaymentStatus } from '../../domain/entities/payment.entity';
import { InvoiceStatus } from '../../domain/entities/invoice.entity';
import { SubscriptionLifecycleService } from '../../domain/services/subscription-lifecycle.service';
import { PaymentProviderType, WebhookResult } from '../../domain/ports/payment-provider.port';
import { PaymentProviderRegistry } from '../../infrastructure/payment-providers/payment-provider.registry';
@Injectable()
export class HandlePaymentWebhookUseCase {
private readonly logger = new Logger(HandlePaymentWebhookUseCase.name);
constructor(
private readonly providerRegistry: PaymentProviderRegistry,
private readonly paymentRepo: PaymentRepository,
private readonly invoiceRepo: InvoiceRepository,
private readonly subscriptionRepo: SubscriptionRepository,
private readonly lifecycle: SubscriptionLifecycleService,
) {}
async execute(provider: PaymentProviderType, rawBody: Buffer, headers: Record<string, string>): Promise<void> {
const paymentProvider = this.providerRegistry.get(provider);
if (!paymentProvider) {
this.logger.warn(`Webhook received for unconfigured provider: ${provider}`);
return;
}
let result: WebhookResult;
try {
result = await paymentProvider.handleWebhook(rawBody, headers);
} catch (err) {
this.logger.error(`Webhook signature verification failed for ${provider}: ${err.message}`);
throw err;
}
if (!result || result.type === 'unknown') return;
if (result.type === 'payment_succeeded') {
await this.handlePaymentSucceeded(result.providerPaymentId, result.metadata);
} else if (result.type === 'payment_failed') {
await this.handlePaymentFailed(result.providerPaymentId);
} else if (result.type === 'refunded') {
await this.handleRefund(result.providerPaymentId);
}
}
private async handlePaymentSucceeded(providerPaymentId: string, metadata?: Record<string, any>) {
const payment = await this.paymentRepo.findByProviderPaymentId(providerPaymentId);
if (!payment) {
this.logger.warn(`Payment not found for provider ID: ${providerPaymentId}`);
return;
}
payment.status = PaymentStatus.SUCCEEDED;
payment.paidAt = new Date();
await this.paymentRepo.savePayment(payment);
// Mark invoice as paid
const invoice = await this.invoiceRepo.findById(payment.invoiceId);
if (invoice) {
invoice.status = InvoiceStatus.PAID;
invoice.paidAt = new Date();
await this.invoiceRepo.save(invoice);
}
// Activate/renew subscription
const subscription = await this.subscriptionRepo.findByTenantId(payment.tenantId);
if (subscription) {
const activated = this.lifecycle.activate(subscription);
await this.subscriptionRepo.save(activated);
this.logger.log(`Subscription activated for tenant ${payment.tenantId}`);
}
}
private async handlePaymentFailed(providerPaymentId: string) {
const payment = await this.paymentRepo.findByProviderPaymentId(providerPaymentId);
if (!payment) return;
payment.status = PaymentStatus.FAILED;
await this.paymentRepo.savePayment(payment);
// Mark subscription as past_due
const subscription = await this.subscriptionRepo.findByTenantId(payment.tenantId);
if (subscription) {
const pastDue = this.lifecycle.markPastDue(subscription);
await this.subscriptionRepo.save(pastDue);
this.logger.warn(`Subscription marked past_due for tenant ${payment.tenantId}`);
}
}
private async handleRefund(providerPaymentId: string) {
const payment = await this.paymentRepo.findByProviderPaymentId(providerPaymentId);
if (!payment) return;
payment.status = PaymentStatus.REFUNDED;
await this.paymentRepo.savePayment(payment);
this.logger.log(`Refund processed for payment ${providerPaymentId}`);
}
}

View File

@ -0,0 +1,108 @@
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ScheduleModule } from '@nestjs/schedule';
import { DatabaseModule } from '@it0/database';
// Domain Entities
import { Plan } from './domain/entities/plan.entity';
import { Subscription } from './domain/entities/subscription.entity';
import { Invoice } from './domain/entities/invoice.entity';
import { InvoiceItem } from './domain/entities/invoice-item.entity';
import { Payment } from './domain/entities/payment.entity';
import { PaymentMethod } from './domain/entities/payment-method.entity';
import { UsageAggregate } from './domain/entities/usage-aggregate.entity';
// Domain Services
import { OverageCalculatorService } from './domain/services/overage-calculator.service';
import { SubscriptionLifecycleService } from './domain/services/subscription-lifecycle.service';
import { InvoiceGeneratorService } from './domain/services/invoice-generator.service';
// Infrastructure Repositories
import { PlanRepository } from './infrastructure/repositories/plan.repository';
import { SubscriptionRepository } from './infrastructure/repositories/subscription.repository';
import { InvoiceRepository } from './infrastructure/repositories/invoice.repository';
import { PaymentRepository } from './infrastructure/repositories/payment.repository';
import { UsageAggregateRepository } from './infrastructure/repositories/usage-aggregate.repository';
// Payment Providers
import { PaymentProviderRegistry } from './infrastructure/payment-providers/payment-provider.registry';
import { StripeProvider } from './infrastructure/payment-providers/stripe/stripe.provider';
import { AlipayProvider } from './infrastructure/payment-providers/alipay/alipay.provider';
import { WeChatPayProvider } from './infrastructure/payment-providers/wechat/wechat-pay.provider';
import { CryptoProvider } from './infrastructure/payment-providers/crypto/crypto.provider';
// Application Use Cases
import { CreateSubscriptionUseCase } from './application/use-cases/create-subscription.use-case';
import { ChangePlanUseCase } from './application/use-cases/change-plan.use-case';
import { CancelSubscriptionUseCase } from './application/use-cases/cancel-subscription.use-case';
import { GenerateMonthlyInvoiceUseCase } from './application/use-cases/generate-monthly-invoice.use-case';
import { AggregateUsageUseCase } from './application/use-cases/aggregate-usage.use-case';
import { CheckTokenQuotaUseCase } from './application/use-cases/check-token-quota.use-case';
import { CreatePaymentSessionUseCase } from './application/use-cases/create-payment-session.use-case';
import { HandlePaymentWebhookUseCase } from './application/use-cases/handle-payment-webhook.use-case';
// Controllers
import { PlanController } from './interfaces/rest/controllers/plan.controller';
import { SubscriptionController } from './interfaces/rest/controllers/subscription.controller';
import { InvoiceController } from './interfaces/rest/controllers/invoice.controller';
import { PaymentMethodController } from './interfaces/rest/controllers/payment-method.controller';
import { WebhookController } from './interfaces/rest/controllers/webhook.controller';
// Cron scheduler
import { BillingScheduler } from './infrastructure/scheduler/billing.scheduler';
import { PlanSeedService } from './infrastructure/seed/plan-seed.service';
@Module({
imports: [
ConfigModule.forRoot({ isGlobal: true }),
DatabaseModule.forRoot(),
TypeOrmModule.forFeature([
Plan, Subscription, Invoice, InvoiceItem,
Payment, PaymentMethod, UsageAggregate,
]),
ScheduleModule.forRoot(),
],
controllers: [
PlanController,
SubscriptionController,
InvoiceController,
PaymentMethodController,
WebhookController,
],
providers: [
// Domain services
OverageCalculatorService,
SubscriptionLifecycleService,
InvoiceGeneratorService,
// Repositories
PlanRepository,
SubscriptionRepository,
InvoiceRepository,
PaymentRepository,
UsageAggregateRepository,
// Payment providers
StripeProvider,
AlipayProvider,
WeChatPayProvider,
CryptoProvider,
PaymentProviderRegistry,
// Use cases
CreateSubscriptionUseCase,
ChangePlanUseCase,
CancelSubscriptionUseCase,
GenerateMonthlyInvoiceUseCase,
AggregateUsageUseCase,
CheckTokenQuotaUseCase,
CreatePaymentSessionUseCase,
HandlePaymentWebhookUseCase,
// Scheduler
BillingScheduler,
PlanSeedService,
],
})
export class BillingModule {}

View File

@ -0,0 +1,25 @@
import { Entity, PrimaryGeneratedColumn, Column } from 'typeorm';
@Entity({ name: 'invoice_items', schema: 'public' })
export class InvoiceItem {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column({ type: 'uuid' })
invoiceId!: string;
@Column({ type: 'varchar', length: 200 })
description!: string; // e.g. 'Pro Plan - Monthly', 'Token Overage (2.5M tokens)'
@Column({ type: 'varchar', length: 30 })
itemType!: string; // 'subscription' | 'overage' | 'credit' | 'adjustment'
@Column({ type: 'numeric', precision: 12, scale: 4 })
quantity!: number; // 1 for subscription, 2.5 for 2.5M tokens overage
@Column({ type: 'int' })
unitPrice!: number; // in cents/fen
@Column({ type: 'int' })
amount!: number; // quantity * unitPrice (rounded)
}

View File

@ -0,0 +1,48 @@
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn } from 'typeorm';
export type InvoiceStatus = 'draft' | 'open' | 'paid' | 'void' | 'uncollectible';
@Entity({ name: 'invoices', schema: 'public' })
export class Invoice {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column({ type: 'varchar', length: 100 })
tenantId!: string;
@Column({ type: 'uuid' })
subscriptionId!: string;
@Column({ type: 'varchar', length: 30, unique: true })
invoiceNumber!: string; // e.g. INV-2026-03-0001
@Column({ type: 'varchar', length: 20, default: 'draft' })
status!: InvoiceStatus;
@Column({ type: 'varchar', length: 3 })
currency!: string; // 'USD' | 'CNY'
@Column({ type: 'int', default: 0 })
subtotalAmount!: number; // in cents/fen
@Column({ type: 'int', default: 0 })
taxAmount!: number;
@Column({ type: 'int', default: 0 })
totalAmount!: number;
@Column({ type: 'timestamptz' })
periodStart!: Date;
@Column({ type: 'timestamptz' })
periodEnd!: Date;
@Column({ type: 'timestamptz' })
dueDate!: Date;
@Column({ type: 'timestamptz', nullable: true })
paidAt?: Date;
@CreateDateColumn({ type: 'timestamptz' })
createdAt!: Date;
}

View File

@ -0,0 +1,28 @@
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn } from 'typeorm';
@Entity({ name: 'payment_methods', schema: 'public' })
export class PaymentMethod {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column({ type: 'varchar', length: 100 })
tenantId!: string;
@Column({ type: 'varchar', length: 20 })
provider!: string; // 'stripe' | 'alipay' | 'wechat_pay' | 'crypto'
@Column({ type: 'varchar', length: 30 })
type!: string; // 'card' | 'alipay_account' | 'wechat_openid' | 'crypto_wallet'
@Column({ type: 'boolean', default: false })
isDefault!: boolean;
@Column({ type: 'jsonb', default: '{}' })
details!: Record<string, any>; // { last4: '4242', brand: 'visa' } — NEVER store full card numbers
@Column({ type: 'varchar', length: 200, nullable: true })
providerCustomerId?: string;
@CreateDateColumn({ type: 'timestamptz' })
createdAt!: Date;
}

View File

@ -0,0 +1,39 @@
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn } from 'typeorm';
export type PaymentStatus = 'pending' | 'processing' | 'succeeded' | 'failed' | 'refunded';
@Entity({ name: 'payments', schema: 'public' })
export class Payment {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column({ type: 'varchar', length: 100 })
tenantId!: string;
@Column({ type: 'uuid', nullable: true })
invoiceId?: string;
@Column({ type: 'varchar', length: 20 })
provider!: string; // 'stripe' | 'alipay' | 'wechat_pay' | 'crypto'
@Column({ type: 'varchar', length: 200 })
providerPaymentId!: string;
@Column({ type: 'int' })
amount!: number; // in cents/fen
@Column({ type: 'varchar', length: 3 })
currency!: string;
@Column({ type: 'varchar', length: 20, default: 'pending' })
status!: PaymentStatus;
@Column({ type: 'jsonb', default: '{}' })
metadata!: Record<string, any>;
@CreateDateColumn({ type: 'timestamptz' })
createdAt!: Date;
@UpdateDateColumn({ type: 'timestamptz' })
updatedAt!: Date;
}

View File

@ -0,0 +1,55 @@
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn } from 'typeorm';
@Entity({ name: 'plans', schema: 'public' })
export class Plan {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column({ type: 'varchar', length: 30, unique: true })
name!: string; // 'free' | 'pro' | 'enterprise'
@Column({ type: 'varchar', length: 100 })
displayName!: string;
@Column({ type: 'int', default: 0 })
monthlyPriceCentsUsd!: number; // e.g. 4999 = $49.99
@Column({ type: 'int', default: 0 })
monthlyPriceFenCny!: number; // e.g. 34900 = ¥349.00
@Column({ type: 'bigint', default: 100000 })
includedTokens!: number; // tokens per month included in plan
@Column({ type: 'int', default: 0 })
overageRateCentsPerMTokenUsd!: number; // price per 1M overage tokens in cents
@Column({ type: 'int', default: 5 })
maxServers!: number; // -1 = unlimited
@Column({ type: 'int', default: 3 })
maxUsers!: number; // -1 = unlimited
@Column({ type: 'int', default: 10 })
maxStandingOrders!: number; // -1 = unlimited
@Column({ type: 'int', default: 100 })
hardLimitPercent!: number; // 100 = block at 100%, 150 = block at 150%, 0 = no limit
@Column({ type: 'jsonb', default: '{}' })
features!: Record<string, boolean>;
@Column({ type: 'boolean', default: true })
isActive!: boolean;
@Column({ type: 'int', default: 0 })
sortOrder!: number;
@Column({ type: 'int', default: 14 })
trialDays!: number;
@CreateDateColumn({ type: 'timestamptz' })
createdAt!: Date;
@UpdateDateColumn({ type: 'timestamptz' })
updatedAt!: Date;
}

View File

@ -0,0 +1,42 @@
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn } from 'typeorm';
export type SubscriptionStatus = 'trialing' | 'active' | 'past_due' | 'cancelled' | 'expired';
@Entity({ name: 'subscriptions', schema: 'public' })
export class Subscription {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column({ type: 'varchar', length: 100 })
tenantId!: string;
@Column({ type: 'uuid' })
planId!: string;
@Column({ type: 'varchar', length: 20, default: 'trialing' })
status!: SubscriptionStatus;
@Column({ type: 'timestamptz' })
currentPeriodStart!: Date;
@Column({ type: 'timestamptz' })
currentPeriodEnd!: Date;
@Column({ type: 'timestamptz', nullable: true })
trialEndsAt?: Date;
@Column({ type: 'timestamptz', nullable: true })
cancelledAt?: Date;
@Column({ type: 'boolean', default: false })
cancelAtPeriodEnd!: boolean;
@Column({ type: 'uuid', nullable: true })
nextPlanId?: string; // for scheduled downgrades
@CreateDateColumn({ type: 'timestamptz' })
createdAt!: Date;
@UpdateDateColumn({ type: 'timestamptz' })
updatedAt!: Date;
}

View File

@ -0,0 +1,34 @@
import { Entity, PrimaryGeneratedColumn, Column, UpdateDateColumn } from 'typeorm';
@Entity({ name: 'usage_aggregates', schema: 'public' })
export class UsageAggregate {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column({ type: 'varchar', length: 100 })
tenantId!: string;
@Column({ type: 'int' })
periodYear!: number;
@Column({ type: 'int' })
periodMonth!: number;
@Column({ type: 'bigint', default: 0 })
totalInputTokens!: number;
@Column({ type: 'bigint', default: 0 })
totalOutputTokens!: number;
@Column({ type: 'bigint', default: 0 })
totalTokens!: number;
@Column({ type: 'numeric', precision: 14, scale: 6, default: 0 })
totalCostUsd!: number;
@Column({ type: 'int', default: 0 })
taskCount!: number;
@UpdateDateColumn({ type: 'timestamptz' })
updatedAt!: Date;
}

View File

@ -0,0 +1,44 @@
export type PaymentProviderType = 'stripe' | 'alipay' | 'wechat_pay' | 'crypto';
export interface CreatePaymentParams {
amount: number; // in cents/fen
currency: string; // 'USD' | 'CNY'
description: string;
tenantId: string;
invoiceId: string;
returnUrl: string; // for redirect-based flows
cancelUrl?: string;
metadata?: Record<string, any>;
}
export interface PaymentSession {
providerPaymentId: string;
redirectUrl?: string; // Alipay, WeChat, Crypto
qrCodeUrl?: string; // WeChat Native Pay
clientSecret?: string; // Stripe PaymentIntent
hostedUrl?: string; // Coinbase Commerce
expiresAt?: Date;
}
export interface PaymentResult {
providerPaymentId: string;
status: 'succeeded' | 'failed' | 'pending';
amount: number;
currency: string;
paidAt?: Date;
}
export interface WebhookEvent {
eventType: 'payment_succeeded' | 'payment_failed' | 'refund_completed';
providerPaymentId: string;
amount: number;
currency: string;
metadata: Record<string, any>;
}
export interface PaymentProviderPort {
readonly providerType: PaymentProviderType;
createPaymentSession(params: CreatePaymentParams): Promise<PaymentSession>;
confirmPayment(providerPaymentId: string): Promise<PaymentResult>;
handleWebhook(payload: Buffer, headers: Record<string, string>): Promise<WebhookEvent>;
}

View File

@ -0,0 +1,134 @@
import { Injectable } from '@nestjs/common';
import { Invoice, InvoiceStatus, InvoiceCurrency } from '../entities/invoice.entity';
import { InvoiceItem, InvoiceItemType } from '../entities/invoice-item.entity';
import { Subscription } from '../entities/subscription.entity';
import { Plan } from '../entities/plan.entity';
import { UsageAggregate } from '../entities/usage-aggregate.entity';
import { OverageCalculatorService } from './overage-calculator.service';
@Injectable()
export class InvoiceGeneratorService {
constructor(private readonly overageCalculator: OverageCalculatorService) {}
generateMonthlyInvoice(
subscription: Subscription,
plan: Plan,
usage: UsageAggregate,
currency: InvoiceCurrency = InvoiceCurrency.USD,
invoiceNumber: string,
): { invoice: Invoice; items: InvoiceItem[] } {
const invoice = new Invoice();
invoice.tenantId = subscription.tenantId;
invoice.subscriptionId = subscription.id;
invoice.invoiceNumber = invoiceNumber;
invoice.status = InvoiceStatus.OPEN;
invoice.currency = currency;
invoice.periodStart = usage.periodStart;
invoice.periodEnd = usage.periodEnd;
invoice.dueDate = this.addDays(new Date(), 14);
const items: InvoiceItem[] = [];
// 1. Base subscription fee
const baseAmountCents =
currency === InvoiceCurrency.CNY ? plan.monthlyPriceCny : plan.monthlyPriceUsdCents;
if (baseAmountCents > 0) {
const subItem = new InvoiceItem();
subItem.itemType = InvoiceItemType.SUBSCRIPTION;
subItem.description = `${plan.displayName} subscription (${this.formatPeriod(usage.periodStart, usage.periodEnd)})`;
subItem.quantity = 1;
subItem.unitPrice = baseAmountCents;
subItem.amount = baseAmountCents;
subItem.currency = currency;
items.push(subItem);
}
// 2. Overage charges (token usage beyond plan quota)
if (plan.overageRateCentsPerMToken > 0 && usage.totalTokens > plan.includedTokensPerMonth) {
const overageResult = this.overageCalculator.calculate(
usage.totalTokens,
plan.includedTokensPerMonth,
plan.overageRateCentsPerMToken,
);
if (overageResult.overageAmountCents > 0) {
const overageItem = new InvoiceItem();
overageItem.itemType = InvoiceItemType.OVERAGE;
overageItem.description = `Token overage: ${this.formatTokens(overageResult.overageTokens)} tokens × ${this.formatRate(plan.overageRateCentsPerMToken, currency)}/MTok`;
overageItem.quantity = overageResult.overageTokens;
overageItem.unitPrice = plan.overageRateCentsPerMToken; // per million tokens
overageItem.amount = overageResult.overageAmountCents;
overageItem.currency = currency;
items.push(overageItem);
}
}
// Calculate totals
const subtotal = items.reduce((sum, item) => sum + item.amount, 0);
invoice.subtotalCents = subtotal;
invoice.taxCents = 0; // Tax calculation handled separately
invoice.totalCents = subtotal + invoice.taxCents;
invoice.amountDueCents = invoice.totalCents;
return { invoice, items };
}
/**
* Generate a proration invoice item for mid-cycle upgrades.
* Calculates the pro-rated difference between old and new plan for remaining days.
*/
generateProratedUpgradeItem(
oldPlan: Plan,
newPlan: Plan,
upgradeDate: Date,
periodEnd: Date,
currency: InvoiceCurrency,
): InvoiceItem {
const totalDays = this.daysBetween(
new Date(periodEnd.getFullYear(), periodEnd.getMonth() - 1, 1),
periodEnd,
);
const remainingDays = this.daysBetween(upgradeDate, periodEnd);
const fraction = remainingDays / totalDays;
const oldPrice = currency === InvoiceCurrency.CNY ? oldPlan.monthlyPriceCny : oldPlan.monthlyPriceUsdCents;
const newPrice = currency === InvoiceCurrency.CNY ? newPlan.monthlyPriceCny : newPlan.monthlyPriceUsdCents;
const proratedAmount = Math.round((newPrice - oldPrice) * fraction);
const item = new InvoiceItem();
item.itemType = InvoiceItemType.ADJUSTMENT;
item.description = `Plan upgrade proration: ${oldPlan.displayName}${newPlan.displayName} (${remainingDays}/${totalDays} days remaining)`;
item.quantity = 1;
item.unitPrice = proratedAmount;
item.amount = proratedAmount;
item.currency = currency;
return item;
}
private addDays(date: Date, days: number): Date {
const d = new Date(date);
d.setDate(d.getDate() + days);
return d;
}
private daysBetween(start: Date, end: Date): number {
const ms = end.getTime() - start.getTime();
return Math.ceil(ms / (1000 * 60 * 60 * 24));
}
private formatPeriod(start: Date, end: Date): string {
return `${start.toISOString().slice(0, 7)}`;
}
private formatTokens(tokens: number): string {
if (tokens >= 1_000_000) return `${(tokens / 1_000_000).toFixed(2)}M`;
if (tokens >= 1_000) return `${(tokens / 1_000).toFixed(0)}K`;
return `${tokens}`;
}
private formatRate(centsPerMToken: number, currency: InvoiceCurrency): string {
const symbol = currency === InvoiceCurrency.CNY ? '¥' : '$';
return `${symbol}${(centsPerMToken / 100).toFixed(2)}`;
}
}

View File

@ -0,0 +1,23 @@
import { Injectable } from '@nestjs/common';
export interface OverageResult {
overageTokens: number;
overageMTokens: number;
overageCentsUsd: number;
}
@Injectable()
export class OverageCalculatorService {
/**
* Calculate overage charges for tokens consumed beyond the included quota.
* @param totalTokens - total tokens consumed in the period
* @param includedTokens - tokens included in the plan (no extra charge)
* @param overageRateCentsPerMToken - price per 1M overage tokens in USD cents
*/
calculate(totalTokens: number, includedTokens: number, overageRateCentsPerMToken: number): OverageResult {
const overageTokens = Math.max(0, totalTokens - includedTokens);
const overageMTokens = overageTokens / 1_000_000;
const overageCentsUsd = Math.ceil(overageMTokens * overageRateCentsPerMToken);
return { overageTokens, overageMTokens, overageCentsUsd };
}
}

View File

@ -0,0 +1,92 @@
import { Injectable } from '@nestjs/common';
import { Subscription, SubscriptionStatus } from '../entities/subscription.entity';
import { Plan } from '../entities/plan.entity';
@Injectable()
export class SubscriptionLifecycleService {
createTrial(tenantId: string, plan: Plan): Subscription {
const now = new Date();
const sub = new Subscription();
sub.tenantId = tenantId;
sub.planId = plan.id;
sub.status = 'trialing';
sub.currentPeriodStart = now;
sub.currentPeriodEnd = this.addDays(now, plan.trialDays);
sub.trialEndsAt = sub.currentPeriodEnd;
return sub;
}
activate(sub: Subscription): Subscription {
sub.status = 'active';
// Start full billing period from now
const now = new Date();
sub.currentPeriodStart = now;
sub.currentPeriodEnd = this.addMonths(now, 1);
sub.trialEndsAt = undefined;
return sub;
}
renew(sub: Subscription): Subscription {
sub.currentPeriodStart = sub.currentPeriodEnd;
sub.currentPeriodEnd = this.addMonths(sub.currentPeriodEnd, 1);
sub.status = 'active';
// Apply scheduled downgrade if any
if (sub.nextPlanId) {
sub.planId = sub.nextPlanId;
sub.nextPlanId = undefined;
}
return sub;
}
upgradeNow(sub: Subscription, newPlanId: string): Subscription {
sub.planId = newPlanId;
sub.nextPlanId = undefined;
if (sub.status === 'trialing') {
sub.status = 'active';
}
return sub;
}
scheduleDowngrade(sub: Subscription, newPlanId: string): Subscription {
sub.nextPlanId = newPlanId;
sub.cancelAtPeriodEnd = false;
return sub;
}
cancel(sub: Subscription, immediate: boolean): Subscription {
if (immediate) {
sub.status = 'cancelled';
sub.cancelledAt = new Date();
} else {
sub.cancelAtPeriodEnd = true;
}
return sub;
}
markPastDue(sub: Subscription): Subscription {
sub.status = 'past_due';
return sub;
}
expire(sub: Subscription): Subscription {
sub.status = 'expired';
sub.cancelledAt = new Date();
return sub;
}
isActive(sub: Subscription): boolean {
return sub.status === 'active' || sub.status === 'trialing';
}
private addDays(date: Date, days: number): Date {
const d = new Date(date);
d.setDate(d.getDate() + days);
return d;
}
private addMonths(date: Date, months: number): Date {
const d = new Date(date);
d.setMonth(d.getMonth() + months);
return d;
}
}

View File

@ -0,0 +1,101 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import {
PaymentProviderPort,
PaymentProviderType,
PaymentSessionRequest,
PaymentSession,
WebhookResult,
} from '../../../domain/ports/payment-provider.port';
import { InvoiceCurrency } from '../../../domain/entities/invoice.entity';
@Injectable()
export class AlipayProvider implements PaymentProviderPort {
readonly providerType = PaymentProviderType.ALIPAY;
private readonly logger = new Logger(AlipayProvider.name);
private appId: string;
private privateKey: string;
private configured = false;
constructor(private readonly configService: ConfigService) {
this.appId = this.configService.get<string>('ALIPAY_APP_ID', '');
this.privateKey = this.configService.get<string>('ALIPAY_PRIVATE_KEY', '');
this.configured = !!(this.appId && this.privateKey);
}
isConfigured(): boolean {
return this.configured;
}
async createPaymentSession(req: PaymentSessionRequest): Promise<PaymentSession> {
if (!this.configured) throw new Error('Alipay not configured');
// For CNY: use amount directly; for USD: this should not happen but handle gracefully
if (req.currency !== InvoiceCurrency.CNY) {
throw new Error('Alipay only supports CNY payments');
}
// Dynamic import to avoid load errors when alipay-sdk is not installed
const AlipaySdk = (await import('alipay-sdk')).default;
const client = new AlipaySdk({
appId: this.appId,
privateKey: this.privateKey,
signType: 'RSA2',
gateway: 'https://openapi.alipay.com/gateway.do',
});
// alipay amount is in yuan (CNY fen / 100)
const amountYuan = (req.amountCents / 100).toFixed(2);
const outTradeNo = `it0-${req.invoiceId}-${Date.now()}`;
// PC scan QR code payment
const result = await client.exec('alipay.trade.precreate', {
bizContent: {
out_trade_no: outTradeNo,
total_amount: amountYuan,
subject: req.description,
},
});
return {
sessionId: outTradeNo,
providerPaymentId: outTradeNo,
qrCodeUrl: result.qrCode,
redirectUrl: undefined,
clientSecret: undefined,
expiresAt: new Date(Date.now() + 2 * 60 * 60 * 1000), // 2 hours
};
}
async handleWebhook(rawBody: Buffer, headers: Record<string, string>): Promise<WebhookResult> {
if (!this.configured) throw new Error('Alipay not configured');
// Parse URL-encoded body
const params = new URLSearchParams(rawBody.toString());
const notifyData: Record<string, string> = {};
params.forEach((v, k) => { notifyData[k] = v; });
// Verify Alipay signature using alipay-sdk
const AlipaySdk = (await import('alipay-sdk')).default;
const client = new AlipaySdk({
appId: this.appId,
privateKey: this.privateKey,
signType: 'RSA2',
gateway: 'https://openapi.alipay.com/gateway.do',
});
const isValid = client.checkNotifySign(notifyData);
if (!isValid) throw new Error('Alipay webhook signature invalid');
const tradeStatus = notifyData['trade_status'];
const outTradeNo = notifyData['out_trade_no'];
if (tradeStatus === 'TRADE_SUCCESS' || tradeStatus === 'TRADE_FINISHED') {
return { type: 'payment_succeeded', providerPaymentId: outTradeNo };
} else if (tradeStatus === 'TRADE_CLOSED') {
return { type: 'payment_failed', providerPaymentId: outTradeNo };
}
return { type: 'unknown' };
}
}

View File

@ -0,0 +1,101 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import * as crypto from 'crypto';
import {
PaymentProviderPort,
PaymentProviderType,
PaymentSessionRequest,
PaymentSession,
WebhookResult,
} from '../../../domain/ports/payment-provider.port';
@Injectable()
export class CryptoProvider implements PaymentProviderPort {
readonly providerType = PaymentProviderType.CRYPTO;
private readonly logger = new Logger(CryptoProvider.name);
private apiKey: string;
private webhookSecret: string;
private configured = false;
private static readonly BASE_URL = 'https://api.commerce.coinbase.com';
constructor(private readonly configService: ConfigService) {
this.apiKey = this.configService.get<string>('COINBASE_COMMERCE_API_KEY', '');
this.webhookSecret = this.configService.get<string>('COINBASE_COMMERCE_WEBHOOK_SECRET', '');
this.configured = !!(this.apiKey && this.webhookSecret);
}
isConfigured(): boolean {
return this.configured;
}
async createPaymentSession(req: PaymentSessionRequest): Promise<PaymentSession> {
if (!this.configured) throw new Error('Coinbase Commerce not configured');
// Convert cents to dollar amount
const amount = (req.amountCents / 100).toFixed(2);
const response = await fetch(`${CryptoProvider.BASE_URL}/charges`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CC-Api-Key': this.apiKey,
'X-CC-Version': '2018-03-22',
},
body: JSON.stringify({
name: 'IT0 Platform',
description: req.description,
local_price: { amount, currency: 'USD' },
pricing_type: 'fixed_price',
metadata: {
invoiceId: req.invoiceId,
tenantId: req.tenantId,
},
redirect_url: req.returnUrl,
}),
});
if (!response.ok) {
const text = await response.text();
throw new Error(`Coinbase Commerce API error ${response.status}: ${text}`);
}
const data = await response.json();
const charge = data.data;
return {
sessionId: charge.id,
providerPaymentId: charge.id,
redirectUrl: charge.hosted_url,
qrCodeUrl: undefined,
clientSecret: undefined,
expiresAt: new Date(charge.expires_at),
};
}
async handleWebhook(rawBody: Buffer, headers: Record<string, string>): Promise<WebhookResult> {
if (!this.configured) throw new Error('Coinbase Commerce not configured');
const signature = headers['x-cc-webhook-signature'];
const expectedSig = crypto
.createHmac('sha256', this.webhookSecret)
.update(rawBody)
.digest('hex');
if (!crypto.timingSafeEqual(Buffer.from(signature, 'hex'), Buffer.from(expectedSig, 'hex'))) {
throw new Error('Coinbase Commerce webhook signature invalid');
}
const payload = JSON.parse(rawBody.toString());
const eventType = payload.event?.type;
const chargeCode = payload.event?.data?.code;
if (eventType === 'charge:confirmed' || eventType === 'charge:resolved') {
return { type: 'payment_succeeded', providerPaymentId: chargeCode };
} else if (eventType === 'charge:failed' || eventType === 'charge:delayed') {
return { type: 'payment_failed', providerPaymentId: chargeCode };
}
return { type: 'unknown' };
}
}

View File

@ -0,0 +1,31 @@
import { Injectable, Optional } from '@nestjs/common';
import { PaymentProviderPort, PaymentProviderType } from '../../domain/ports/payment-provider.port';
import { StripeProvider } from './stripe/stripe.provider';
import { AlipayProvider } from './alipay/alipay.provider';
import { WeChatPayProvider } from './wechat/wechat-pay.provider';
import { CryptoProvider } from './crypto/crypto.provider';
@Injectable()
export class PaymentProviderRegistry {
private readonly providers = new Map<PaymentProviderType, PaymentProviderPort>();
constructor(
@Optional() stripe: StripeProvider,
@Optional() alipay: AlipayProvider,
@Optional() wechat: WeChatPayProvider,
@Optional() crypto: CryptoProvider,
) {
if (stripe?.isConfigured()) this.providers.set(PaymentProviderType.STRIPE, stripe);
if (alipay?.isConfigured()) this.providers.set(PaymentProviderType.ALIPAY, alipay);
if (wechat?.isConfigured()) this.providers.set(PaymentProviderType.WECHAT_PAY, wechat);
if (crypto?.isConfigured()) this.providers.set(PaymentProviderType.CRYPTO, crypto);
}
get(type: PaymentProviderType): PaymentProviderPort | undefined {
return this.providers.get(type);
}
getAvailable(): PaymentProviderType[] {
return Array.from(this.providers.keys());
}
}

View File

@ -0,0 +1,82 @@
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import Stripe from 'stripe';
import {
PaymentProviderPort,
PaymentProviderType,
PaymentSessionRequest,
PaymentSession,
WebhookResult,
} from '../../../domain/ports/payment-provider.port';
@Injectable()
export class StripeProvider implements PaymentProviderPort {
readonly providerType = PaymentProviderType.STRIPE;
private client: Stripe | null = null;
private webhookSecret: string;
constructor(private readonly configService: ConfigService) {
const secretKey = this.configService.get<string>('STRIPE_SECRET_KEY');
this.webhookSecret = this.configService.get<string>('STRIPE_WEBHOOK_SECRET', '');
if (secretKey) {
this.client = new Stripe(secretKey, { apiVersion: '2024-11-20.acacia' });
}
}
isConfigured(): boolean {
return !!this.client;
}
async createPaymentSession(req: PaymentSessionRequest): Promise<PaymentSession> {
if (!this.client) throw new Error('Stripe not configured');
const intent = await this.client.paymentIntents.create({
amount: req.amountCents,
currency: req.currency.toLowerCase(),
metadata: {
invoiceId: req.invoiceId,
tenantId: req.tenantId,
},
description: req.description,
});
return {
sessionId: intent.id,
providerPaymentId: intent.id,
clientSecret: intent.client_secret ?? undefined,
redirectUrl: undefined,
qrCodeUrl: undefined,
expiresAt: new Date(Date.now() + 30 * 60 * 1000), // 30 minutes
};
}
async handleWebhook(rawBody: Buffer, headers: Record<string, string>): Promise<WebhookResult> {
if (!this.client) throw new Error('Stripe not configured');
const sig = headers['stripe-signature'];
let event: Stripe.Event;
try {
event = this.client.webhooks.constructEvent(rawBody, sig, this.webhookSecret);
} catch (err) {
throw new Error(`Stripe webhook signature verification failed: ${err.message}`);
}
switch (event.type) {
case 'payment_intent.succeeded': {
const intent = event.data.object as Stripe.PaymentIntent;
return { type: 'payment_succeeded', providerPaymentId: intent.id, metadata: intent.metadata };
}
case 'payment_intent.payment_failed': {
const intent = event.data.object as Stripe.PaymentIntent;
return { type: 'payment_failed', providerPaymentId: intent.id };
}
case 'charge.refunded': {
const charge = event.data.object as Stripe.Charge;
return { type: 'refunded', providerPaymentId: charge.payment_intent as string };
}
default:
return { type: 'unknown' };
}
}
}

View File

@ -0,0 +1,156 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import * as crypto from 'crypto';
import {
PaymentProviderPort,
PaymentProviderType,
PaymentSessionRequest,
PaymentSession,
WebhookResult,
} from '../../../domain/ports/payment-provider.port';
import { InvoiceCurrency } from '../../../domain/entities/invoice.entity';
@Injectable()
export class WeChatPayProvider implements PaymentProviderPort {
readonly providerType = PaymentProviderType.WECHAT_PAY;
private readonly logger = new Logger(WeChatPayProvider.name);
private mchId: string;
private apiKeyV3: string;
private appId: string;
private configured = false;
// WeChat Pay v3 API base
private static readonly BASE_URL = 'https://api.mch.weixin.qq.com';
constructor(private readonly configService: ConfigService) {
this.mchId = this.configService.get<string>('WECHAT_MCH_ID', '');
this.apiKeyV3 = this.configService.get<string>('WECHAT_API_KEY_V3', '');
this.appId = this.configService.get<string>('WECHAT_APP_ID', '');
this.configured = !!(this.mchId && this.apiKeyV3 && this.appId);
}
isConfigured(): boolean {
return this.configured;
}
async createPaymentSession(req: PaymentSessionRequest): Promise<PaymentSession> {
if (!this.configured) throw new Error('WeChat Pay not configured');
if (req.currency !== InvoiceCurrency.CNY) {
throw new Error('WeChat Pay only supports CNY payments');
}
const outTradeNo = `it0-${req.invoiceId}-${Date.now()}`;
const notifyUrl = this.configService.get<string>(
'WECHAT_NOTIFY_URL',
'https://it0api.szaiai.com/api/v1/billing/webhooks/wechat',
);
const body = {
appid: this.appId,
mchid: this.mchId,
description: req.description,
out_trade_no: outTradeNo,
notify_url: notifyUrl,
amount: { total: req.amountCents, currency: 'CNY' },
};
const response = await this.request('POST', '/v3/pay/transactions/native', body);
const qrCodeUrl = response.code_url;
return {
sessionId: outTradeNo,
providerPaymentId: outTradeNo,
qrCodeUrl,
redirectUrl: undefined,
clientSecret: undefined,
expiresAt: new Date(Date.now() + 2 * 60 * 60 * 1000),
};
}
async handleWebhook(rawBody: Buffer, headers: Record<string, string>): Promise<WebhookResult> {
if (!this.configured) throw new Error('WeChat Pay not configured');
// Verify HMAC-SHA256 signature
const timestamp = headers['wechatpay-timestamp'];
const nonce = headers['wechatpay-nonce'];
const signature = headers['wechatpay-signature'];
const message = `${timestamp}\n${nonce}\n${rawBody.toString()}\n`;
const expectedSig = crypto
.createHmac('sha256', this.apiKeyV3)
.update(message)
.digest('base64');
if (signature !== expectedSig) {
throw new Error('WeChat Pay webhook signature invalid');
}
const payload = JSON.parse(rawBody.toString());
const eventType = payload.event_type;
if (eventType === 'TRANSACTION.SUCCESS') {
// Decrypt resource
const resource = payload.resource;
const decrypted = this.decryptResource(resource.ciphertext, resource.associated_data, resource.nonce);
const transaction = JSON.parse(decrypted);
return { type: 'payment_succeeded', providerPaymentId: transaction.out_trade_no };
} else if (eventType === 'TRANSACTION.FAIL') {
const resource = payload.resource;
const decrypted = this.decryptResource(resource.ciphertext, resource.associated_data, resource.nonce);
const transaction = JSON.parse(decrypted);
return { type: 'payment_failed', providerPaymentId: transaction.out_trade_no };
}
return { type: 'unknown' };
}
private decryptResource(ciphertext: string, associatedData: string, nonce: string): string {
const key = Buffer.from(this.apiKeyV3, 'utf-8');
const ciphertextBuffer = Buffer.from(ciphertext, 'base64');
const authTag = ciphertextBuffer.slice(-16);
const encrypted = ciphertextBuffer.slice(0, -16);
const decipher = crypto.createDecipheriv('aes-256-gcm', key, nonce);
decipher.setAuthTag(authTag);
decipher.setAAD(Buffer.from(associatedData));
return Buffer.concat([decipher.update(encrypted), decipher.final()]).toString('utf-8');
}
private async request(method: string, path: string, body: any): Promise<any> {
const url = `${WeChatPayProvider.BASE_URL}${path}`;
const timestamp = Math.floor(Date.now() / 1000);
const nonce = crypto.randomBytes(16).toString('hex');
const bodyStr = JSON.stringify(body);
const message = `${method}\n${path}\n${timestamp}\n${nonce}\n${bodyStr}\n`;
// HMAC-SHA256 signature for request
const signature = crypto
.createHmac('sha256', this.apiKeyV3)
.update(message)
.digest('base64');
const authorization =
`WECHATPAY2-SHA256-RSA2048 mchid="${this.mchId}",` +
`nonce_str="${nonce}",timestamp="${timestamp}",` +
`signature="${signature}"`;
const res = await fetch(url, {
method,
headers: {
'Content-Type': 'application/json',
Authorization: authorization,
Accept: 'application/json',
},
body: bodyStr,
});
if (!res.ok) {
const text = await res.text();
throw new Error(`WeChat Pay API error ${res.status}: ${text}`);
}
return res.json();
}
}

View File

@ -0,0 +1,78 @@
import { Injectable } from '@nestjs/common';
import { DataSource, Repository } from 'typeorm';
import { Invoice, InvoiceStatus } from '../../domain/entities/invoice.entity';
import { InvoiceItem } from '../../domain/entities/invoice-item.entity';
@Injectable()
export class InvoiceRepository {
private readonly repo: Repository<Invoice>;
private readonly itemRepo: Repository<InvoiceItem>;
constructor(private readonly dataSource: DataSource) {
this.repo = this.dataSource.getRepository(Invoice);
this.itemRepo = this.dataSource.getRepository(InvoiceItem);
}
async findByTenantId(tenantId: string, limit = 20, offset = 0): Promise<[Invoice[], number]> {
return this.repo.findAndCount({
where: { tenantId },
order: { createdAt: 'DESC' },
take: limit,
skip: offset,
});
}
async findById(id: string): Promise<Invoice | null> {
return this.repo.findOne({ where: { id }, relations: ['items'] });
}
async findByInvoiceNumber(invoiceNumber: string): Promise<Invoice | null> {
return this.repo.findOne({ where: { invoiceNumber } });
}
async findUnpaidByTenantId(tenantId: string): Promise<Invoice[]> {
return this.repo.find({
where: [
{ tenantId, status: InvoiceStatus.OPEN },
{ tenantId, status: InvoiceStatus.PAST_DUE },
],
});
}
async getNextInvoiceNumber(): Promise<string> {
const result = await this.dataSource.query(
`SELECT nextval('billing_invoice_number_seq') AS seq`,
);
const seq = result[0]?.seq ?? Date.now();
const year = new Date().getFullYear();
return `INV-${year}-${String(seq).padStart(6, '0')}`;
}
async save(invoice: Invoice): Promise<Invoice> {
return this.repo.save(invoice);
}
async saveItems(items: InvoiceItem[]): Promise<InvoiceItem[]> {
return this.itemRepo.save(items);
}
async saveWithItems(invoice: Invoice, items: InvoiceItem[]): Promise<Invoice> {
const runner = this.dataSource.createQueryRunner();
await runner.connect();
await runner.startTransaction();
try {
const savedInvoice = await runner.manager.save(Invoice, invoice);
for (const item of items) {
item.invoiceId = savedInvoice.id;
}
await runner.manager.save(InvoiceItem, items);
await runner.commitTransaction();
return savedInvoice;
} catch (err) {
await runner.rollbackTransaction();
throw err;
} finally {
await runner.release();
}
}
}

View File

@ -0,0 +1,59 @@
import { Injectable } from '@nestjs/common';
import { DataSource, Repository } from 'typeorm';
import { Payment, PaymentStatus } from '../../domain/entities/payment.entity';
import { PaymentMethod } from '../../domain/entities/payment-method.entity';
@Injectable()
export class PaymentRepository {
private readonly paymentRepo: Repository<Payment>;
private readonly methodRepo: Repository<PaymentMethod>;
constructor(private readonly dataSource: DataSource) {
this.paymentRepo = this.dataSource.getRepository(Payment);
this.methodRepo = this.dataSource.getRepository(PaymentMethod);
}
async findPaymentById(id: string): Promise<Payment | null> {
return this.paymentRepo.findOne({ where: { id } });
}
async findByProviderPaymentId(providerPaymentId: string): Promise<Payment | null> {
return this.paymentRepo.findOne({ where: { providerPaymentId } });
}
async findPaymentsByTenantId(tenantId: string, limit = 20): Promise<Payment[]> {
return this.paymentRepo.find({
where: { tenantId },
order: { createdAt: 'DESC' },
take: limit,
});
}
async savePayment(payment: Payment): Promise<Payment> {
return this.paymentRepo.save(payment);
}
// Payment Methods
async findMethodsByTenantId(tenantId: string): Promise<PaymentMethod[]> {
return this.methodRepo.find({
where: { tenantId },
order: { isDefault: 'DESC', createdAt: 'ASC' },
});
}
async findMethodById(id: string): Promise<PaymentMethod | null> {
return this.methodRepo.findOne({ where: { id } });
}
async saveMethod(method: PaymentMethod): Promise<PaymentMethod> {
return this.methodRepo.save(method);
}
async deleteMethod(id: string): Promise<void> {
await this.methodRepo.delete(id);
}
async clearDefaultMethods(tenantId: string): Promise<void> {
await this.methodRepo.update({ tenantId, isDefault: true }, { isDefault: false });
}
}

View File

@ -0,0 +1,37 @@
import { Injectable } from '@nestjs/common';
import { DataSource, Repository } from 'typeorm';
import { Plan } from '../../domain/entities/plan.entity';
@Injectable()
export class PlanRepository {
private readonly repo: Repository<Plan>;
constructor(private readonly dataSource: DataSource) {
this.repo = this.dataSource.getRepository(Plan);
}
async findAll(): Promise<Plan[]> {
return this.repo.find({ where: { isActive: true }, order: { monthlyPriceUsdCents: 'ASC' } });
}
async findById(id: string): Promise<Plan | null> {
return this.repo.findOne({ where: { id } });
}
async findByName(name: string): Promise<Plan | null> {
return this.repo.findOne({ where: { name } });
}
async save(plan: Plan): Promise<Plan> {
return this.repo.save(plan);
}
async upsertSeedPlans(plans: Partial<Plan>[]): Promise<void> {
for (const p of plans) {
const existing = await this.repo.findOne({ where: { name: p.name } });
if (!existing) {
await this.repo.save(this.repo.create(p));
}
}
}
}

View File

@ -0,0 +1,41 @@
import { Injectable } from '@nestjs/common';
import { DataSource, Repository } from 'typeorm';
import { Subscription, SubscriptionStatus } from '../../domain/entities/subscription.entity';
@Injectable()
export class SubscriptionRepository {
private readonly repo: Repository<Subscription>;
constructor(private readonly dataSource: DataSource) {
this.repo = this.dataSource.getRepository(Subscription);
}
async findByTenantId(tenantId: string): Promise<Subscription | null> {
return this.repo.findOne({
where: { tenantId },
order: { createdAt: 'DESC' },
});
}
async findById(id: string): Promise<Subscription | null> {
return this.repo.findOne({ where: { id } });
}
async findExpired(): Promise<Subscription[]> {
return this.repo
.createQueryBuilder('s')
.where('s.currentPeriodEnd < NOW()')
.andWhere('s.status IN (:...statuses)', {
statuses: [SubscriptionStatus.ACTIVE, SubscriptionStatus.TRIALING],
})
.getMany();
}
async findCancelAtPeriodEnd(): Promise<Subscription[]> {
return this.repo.find({ where: { cancelAtPeriodEnd: true, status: SubscriptionStatus.ACTIVE } });
}
async save(subscription: Subscription): Promise<Subscription> {
return this.repo.save(subscription);
}
}

View File

@ -0,0 +1,68 @@
import { Injectable } from '@nestjs/common';
import { DataSource, Repository } from 'typeorm';
import { UsageAggregate } from '../../domain/entities/usage-aggregate.entity';
@Injectable()
export class UsageAggregateRepository {
private readonly repo: Repository<UsageAggregate>;
constructor(private readonly dataSource: DataSource) {
this.repo = this.dataSource.getRepository(UsageAggregate);
}
async findByTenantAndPeriod(tenantId: string, year: number, month: number): Promise<UsageAggregate | null> {
return this.repo.findOne({ where: { tenantId, year, month } });
}
async findCurrentMonth(tenantId: string): Promise<UsageAggregate | null> {
const now = new Date();
return this.findByTenantAndPeriod(tenantId, now.getFullYear(), now.getMonth() + 1);
}
async findHistoryByTenantId(tenantId: string, limit = 12): Promise<UsageAggregate[]> {
return this.repo.find({
where: { tenantId },
order: { year: 'DESC', month: 'DESC' },
take: limit,
});
}
async upsertTokens(
tenantId: string,
year: number,
month: number,
inputTokens: number,
outputTokens: number,
costUsd: number,
): Promise<void> {
const periodStart = new Date(year, month - 1, 1);
const periodEnd = new Date(year, month, 0, 23, 59, 59); // last day of month
await this.dataSource.query(
`
INSERT INTO billing_usage_aggregates
(id, tenant_id, year, month, period_start, period_end,
total_input_tokens, total_output_tokens, total_tokens,
total_cost_usd, task_count, created_at, updated_at)
VALUES
(gen_random_uuid(), $1, $2, $3, $4, $5, $6, $7, $8, $9, 1, NOW(), NOW())
ON CONFLICT (tenant_id, year, month)
DO UPDATE SET
total_input_tokens = billing_usage_aggregates.total_input_tokens + EXCLUDED.total_input_tokens,
total_output_tokens = billing_usage_aggregates.total_output_tokens + EXCLUDED.total_output_tokens,
total_tokens = billing_usage_aggregates.total_tokens + EXCLUDED.total_tokens,
total_cost_usd = billing_usage_aggregates.total_cost_usd + EXCLUDED.total_cost_usd,
task_count = billing_usage_aggregates.task_count + 1,
updated_at = NOW()
`,
[
tenantId, year, month, periodStart, periodEnd,
inputTokens, outputTokens, inputTokens + outputTokens, costUsd,
],
);
}
async save(aggregate: UsageAggregate): Promise<UsageAggregate> {
return this.repo.save(aggregate);
}
}

View File

@ -0,0 +1,26 @@
import { Injectable, Logger } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
import { GenerateMonthlyInvoiceUseCase } from '../../application/use-cases/generate-monthly-invoice.use-case';
import { PlanSeedService } from '../seed/plan-seed.service';
@Injectable()
export class BillingScheduler {
private readonly logger = new Logger(BillingScheduler.name);
constructor(
private readonly generateMonthlyInvoice: GenerateMonthlyInvoiceUseCase,
private readonly planSeed: PlanSeedService,
) {}
// Run on the 1st of each month at 00:05 UTC
@Cron('5 0 1 * *')
async handleMonthlyBilling() {
this.logger.log('Running monthly billing job...');
try {
await this.generateMonthlyInvoice.execute();
this.logger.log('Monthly billing job completed');
} catch (err) {
this.logger.error(`Monthly billing job failed: ${err.message}`);
}
}
}

View File

@ -0,0 +1,63 @@
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { PlanRepository } from '../repositories/plan.repository';
const SEED_PLANS = [
{
name: 'free',
displayName: 'Free',
monthlyPriceUsdCents: 0,
monthlyPriceCny: 0,
includedTokensPerMonth: 100_000,
overageRateCentsPerMToken: 0,
maxServers: 5,
maxUsers: 3,
maxStandingOrders: 10,
hardLimitPercent: 100,
trialDays: 0,
isActive: true,
},
{
name: 'pro',
displayName: 'Pro',
monthlyPriceUsdCents: 4999, // $49.99
monthlyPriceCny: 34900, // ¥349.00
includedTokensPerMonth: 1_000_000,
overageRateCentsPerMToken: 800, // $8.00 per MTok
maxServers: 50,
maxUsers: 20,
maxStandingOrders: 100,
hardLimitPercent: 150,
trialDays: 14,
isActive: true,
},
{
name: 'enterprise',
displayName: 'Enterprise',
monthlyPriceUsdCents: 19999, // $199.99
monthlyPriceCny: 139900, // ¥1399.00
includedTokensPerMonth: 10_000_000,
overageRateCentsPerMToken: 500, // $5.00 per MTok
maxServers: -1,
maxUsers: -1,
maxStandingOrders: -1,
hardLimitPercent: 0, // no hard limit
trialDays: 14,
isActive: true,
},
];
@Injectable()
export class PlanSeedService implements OnModuleInit {
private readonly logger = new Logger(PlanSeedService.name);
constructor(private readonly planRepo: PlanRepository) {}
async onModuleInit() {
try {
await this.planRepo.upsertSeedPlans(SEED_PLANS);
this.logger.log('Plan seed completed');
} catch (err) {
this.logger.error(`Plan seed failed: ${err.message}`);
}
}
}

View File

@ -0,0 +1,109 @@
import {
Controller, Get, Post, Param, Query, Headers, NotFoundException,
} from '@nestjs/common';
import { InvoiceRepository } from '../../../infrastructure/repositories/invoice.repository';
import { UsageAggregateRepository } from '../../../infrastructure/repositories/usage-aggregate.repository';
import { CreatePaymentSessionUseCase } from '../../../application/use-cases/create-payment-session.use-case';
import { PaymentProviderRegistry } from '../../../infrastructure/payment-providers/payment-provider.registry';
import { PaymentProviderType } from '../../../domain/ports/payment-provider.port';
@Controller('api/v1/billing')
export class InvoiceController {
constructor(
private readonly invoiceRepo: InvoiceRepository,
private readonly usageAggRepo: UsageAggregateRepository,
private readonly createPaymentSession: CreatePaymentSessionUseCase,
private readonly providerRegistry: PaymentProviderRegistry,
) {}
@Get('invoices')
async listInvoices(
@Headers('x-tenant-id') tenantId: string,
@Query('limit') limit = 20,
@Query('offset') offset = 0,
) {
const [invoices, total] = await this.invoiceRepo.findByTenantId(tenantId, +limit, +offset);
return {
data: invoices.map((inv) => ({
id: inv.id,
invoiceNumber: inv.invoiceNumber,
status: inv.status,
currency: inv.currency,
totalAmount: inv.totalCents / 100,
amountDue: inv.amountDueCents / 100,
periodStart: inv.periodStart,
periodEnd: inv.periodEnd,
dueDate: inv.dueDate,
paidAt: inv.paidAt,
createdAt: inv.createdAt,
})),
total,
};
}
@Get('invoices/:id')
async getInvoice(
@Headers('x-tenant-id') tenantId: string,
@Param('id') id: string,
) {
const invoice = await this.invoiceRepo.findById(id);
if (!invoice || invoice.tenantId !== tenantId) throw new NotFoundException('Invoice not found');
return {
id: invoice.id,
invoiceNumber: invoice.invoiceNumber,
status: invoice.status,
currency: invoice.currency,
subtotal: invoice.subtotalCents / 100,
tax: invoice.taxCents / 100,
total: invoice.totalCents / 100,
amountDue: invoice.amountDueCents / 100,
periodStart: invoice.periodStart,
periodEnd: invoice.periodEnd,
dueDate: invoice.dueDate,
paidAt: invoice.paidAt,
items: (invoice.items ?? []).map((item) => ({
id: item.id,
itemType: item.itemType,
description: item.description,
quantity: item.quantity,
unitPrice: item.unitPrice / 100,
amount: item.amount / 100,
})),
};
}
@Post('invoices/:id/pay')
async createPayment(
@Headers('x-tenant-id') tenantId: string,
@Param('id') invoiceId: string,
@Query('provider') provider: PaymentProviderType,
@Query('returnUrl') returnUrl?: string,
) {
return this.createPaymentSession.execute({ tenantId, invoiceId, provider, returnUrl });
}
@Get('usage/history')
async getUsageHistory(
@Headers('x-tenant-id') tenantId: string,
@Query('limit') limit = 12,
) {
const history = await this.usageAggRepo.findHistoryByTenantId(tenantId, +limit);
return history.map((u) => ({
year: u.year,
month: u.month,
totalInputTokens: u.totalInputTokens,
totalOutputTokens: u.totalOutputTokens,
totalTokens: u.totalTokens,
totalCostUsd: u.totalCostUsd,
taskCount: u.taskCount,
periodStart: u.periodStart,
periodEnd: u.periodEnd,
}));
}
@Get('providers')
getAvailableProviders() {
return { providers: this.providerRegistry.getAvailable() };
}
}

View File

@ -0,0 +1,67 @@
import {
Controller, Get, Post, Delete, Patch, Param, Body, Headers, HttpCode, HttpStatus, NotFoundException,
} from '@nestjs/common';
import { PaymentRepository } from '../../../infrastructure/repositories/payment.repository';
import { PaymentMethod } from '../../../domain/entities/payment-method.entity';
import { PaymentProviderType } from '../../../domain/ports/payment-provider.port';
@Controller('api/v1/billing/payment-methods')
export class PaymentMethodController {
constructor(private readonly paymentRepo: PaymentRepository) {}
@Get()
async listMethods(@Headers('x-tenant-id') tenantId: string) {
const methods = await this.paymentRepo.findMethodsByTenantId(tenantId);
return methods.map((m) => ({
id: m.id,
provider: m.provider,
displayName: m.displayName,
isDefault: m.isDefault,
expiresAt: m.expiresAt,
createdAt: m.createdAt,
}));
}
@Post()
async addMethod(
@Headers('x-tenant-id') tenantId: string,
@Body() body: { provider: PaymentProviderType; displayName: string; providerCustomerId?: string },
) {
const method = new PaymentMethod();
method.tenantId = tenantId;
method.provider = body.provider;
method.displayName = body.displayName;
method.providerCustomerId = body.providerCustomerId ?? null;
method.isDefault = false;
const saved = await this.paymentRepo.saveMethod(method);
return { id: saved.id, provider: saved.provider, displayName: saved.displayName };
}
@Patch(':id/default')
@HttpCode(HttpStatus.OK)
async setDefault(
@Headers('x-tenant-id') tenantId: string,
@Param('id') id: string,
) {
const method = await this.paymentRepo.findMethodById(id);
if (!method || method.tenantId !== tenantId) throw new NotFoundException('Payment method not found');
await this.paymentRepo.clearDefaultMethods(tenantId);
method.isDefault = true;
await this.paymentRepo.saveMethod(method);
return { success: true };
}
@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT)
async deleteMethod(
@Headers('x-tenant-id') tenantId: string,
@Param('id') id: string,
) {
const method = await this.paymentRepo.findMethodById(id);
if (!method || method.tenantId !== tenantId) throw new NotFoundException('Payment method not found');
await this.paymentRepo.deleteMethod(id);
}
}

View File

@ -0,0 +1,26 @@
import { Controller, Get } from '@nestjs/common';
import { PlanRepository } from '../../../infrastructure/repositories/plan.repository';
@Controller('api/v1/billing/plans')
export class PlanController {
constructor(private readonly planRepo: PlanRepository) {}
@Get()
async findAll() {
const plans = await this.planRepo.findAll();
return plans.map((p) => ({
id: p.id,
name: p.name,
displayName: p.displayName,
monthlyPriceUsd: p.monthlyPriceUsdCents / 100,
monthlyPriceCny: p.monthlyPriceCny / 100,
includedTokensPerMonth: p.includedTokensPerMonth,
overageRateUsdPerMToken: p.overageRateCentsPerMToken / 100,
maxServers: p.maxServers,
maxUsers: p.maxUsers,
maxStandingOrders: p.maxStandingOrders,
hardLimitPercent: p.hardLimitPercent,
trialDays: p.trialDays,
}));
}
}

View File

@ -0,0 +1,70 @@
import {
Controller, Get, Post, Body, Headers, HttpCode, HttpStatus, NotFoundException,
} from '@nestjs/common';
import { SubscriptionRepository } from '../../../infrastructure/repositories/subscription.repository';
import { PlanRepository } from '../../../infrastructure/repositories/plan.repository';
import { ChangePlanUseCase } from '../../../application/use-cases/change-plan.use-case';
import { CancelSubscriptionUseCase } from '../../../application/use-cases/cancel-subscription.use-case';
import { CheckTokenQuotaUseCase } from '../../../application/use-cases/check-token-quota.use-case';
import { InvoiceCurrency } from '../../../domain/entities/invoice.entity';
@Controller('api/v1/billing')
export class SubscriptionController {
constructor(
private readonly subscriptionRepo: SubscriptionRepository,
private readonly planRepo: PlanRepository,
private readonly changePlan: ChangePlanUseCase,
private readonly cancelSubscription: CancelSubscriptionUseCase,
private readonly checkQuota: CheckTokenQuotaUseCase,
) {}
@Get('subscription')
async getSubscription(@Headers('x-tenant-id') tenantId: string) {
const sub = await this.subscriptionRepo.findByTenantId(tenantId);
if (!sub) throw new NotFoundException('No active subscription');
const plan = await this.planRepo.findById(sub.planId);
const nextPlan = sub.nextPlanId ? await this.planRepo.findById(sub.nextPlanId) : null;
return {
id: sub.id,
status: sub.status,
plan: plan ? { name: plan.name, displayName: plan.displayName } : null,
nextPlan: nextPlan ? { name: nextPlan.name, displayName: nextPlan.displayName } : null,
currentPeriodStart: sub.currentPeriodStart,
currentPeriodEnd: sub.currentPeriodEnd,
trialEndsAt: sub.trialEndsAt,
cancelAtPeriodEnd: sub.cancelAtPeriodEnd,
cancelledAt: sub.cancelledAt,
};
}
@Post('subscription/upgrade')
@HttpCode(HttpStatus.OK)
async upgradePlan(
@Headers('x-tenant-id') tenantId: string,
@Body() body: { planName: string; currency?: 'USD' | 'CNY' },
) {
await this.changePlan.execute({
tenantId,
newPlanName: body.planName,
currency: body.currency === 'CNY' ? InvoiceCurrency.CNY : InvoiceCurrency.USD,
});
return { success: true };
}
@Post('subscription/cancel')
@HttpCode(HttpStatus.OK)
async cancelPlan(
@Headers('x-tenant-id') tenantId: string,
@Body() body: { immediate?: boolean },
) {
await this.cancelSubscription.execute(tenantId, body.immediate ?? false);
return { success: true };
}
@Get('usage/current')
async getCurrentUsage(@Headers('x-tenant-id') tenantId: string) {
return this.checkQuota.execute(tenantId);
}
}

View File

@ -0,0 +1,58 @@
import {
Controller, Post, Req, Headers, HttpCode, HttpStatus, Logger, BadRequestException,
} from '@nestjs/common';
import { Request } from 'express';
import { HandlePaymentWebhookUseCase } from '../../../application/use-cases/handle-payment-webhook.use-case';
import { PaymentProviderType } from '../../../domain/ports/payment-provider.port';
@Controller('api/v1/billing/webhooks')
export class WebhookController {
private readonly logger = new Logger(WebhookController.name);
constructor(private readonly handleWebhook: HandlePaymentWebhookUseCase) {}
@Post('stripe')
@HttpCode(HttpStatus.OK)
async stripeWebhook(@Req() req: Request, @Headers() headers: Record<string, string>) {
return this.processWebhook(PaymentProviderType.STRIPE, req, headers);
}
@Post('alipay')
@HttpCode(HttpStatus.OK)
async alipayWebhook(@Req() req: Request, @Headers() headers: Record<string, string>) {
await this.processWebhook(PaymentProviderType.ALIPAY, req, headers);
// Alipay expects plain text "success" response
return 'success';
}
@Post('wechat')
@HttpCode(HttpStatus.OK)
async wechatWebhook(@Req() req: Request, @Headers() headers: Record<string, string>) {
await this.processWebhook(PaymentProviderType.WECHAT_PAY, req, headers);
return { code: 'SUCCESS', message: '成功' };
}
@Post('crypto')
@HttpCode(HttpStatus.OK)
async cryptoWebhook(@Req() req: Request, @Headers() headers: Record<string, string>) {
return this.processWebhook(PaymentProviderType.CRYPTO, req, headers);
}
private async processWebhook(
provider: PaymentProviderType,
req: Request,
headers: Record<string, string>,
) {
const rawBody = (req as any).rawBody as Buffer;
if (!rawBody) {
throw new BadRequestException('Raw body not available — ensure rawBody middleware is enabled');
}
try {
await this.handleWebhook.execute(provider, rawBody, headers);
} catch (err) {
this.logger.error(`Webhook error for ${provider}: ${err.message}`);
throw err;
}
}
}

View File

@ -0,0 +1,33 @@
import { NestFactory } from '@nestjs/core';
import { Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { BillingModule } from './billing.module';
const logger = new Logger('BillingService');
process.on('unhandledRejection', (reason) => {
logger.error(`Unhandled Rejection: ${reason}`);
});
process.on('uncaughtException', (error) => {
logger.error(`Uncaught Exception: ${error.message}`, error.stack);
});
async function bootstrap() {
const app = await NestFactory.create(BillingModule, {
// Enable raw body for webhook signature verification
rawBody: true,
});
const config = app.get(ConfigService);
const port = config.get<number>('BILLING_SERVICE_PORT', 3010);
app.enableCors();
await app.listen(port);
logger.log(`billing-service running on port ${port}`);
}
bootstrap().catch((err) => {
logger.error(`Failed to start billing-service: ${err.message}`, err.stack);
process.exit(1);
});

View File

@ -0,0 +1,21 @@
{
"compilerOptions": {
"module": "commonjs",
"declaration": true,
"removeComments": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"target": "ES2021",
"sourceMap": true,
"outDir": "./dist",
"baseUrl": "./",
"incremental": true,
"skipLibCheck": true,
"strictNullChecks": false,
"noImplicitAny": false,
"strictBindCallApply": false,
"forceConsistentCasingInFileNames": false,
"noFallthroughCasesInSwitch": false
}
}

View File

@ -1,6 +1,7 @@
import { Controller, Get, Post, Put, Delete, Body, Param, Query, NotFoundException } from '@nestjs/common';
import { Controller, Get, Post, Put, Delete, Body, Param, Query, NotFoundException, HttpException, HttpStatus } from '@nestjs/common';
import { ServerRepository } from '../../../infrastructure/repositories/server.repository';
import { Server } from '../../../domain/entities/server.entity';
import { TenantContextService } from '@it0/common';
import * as crypto from 'crypto';
@Controller('api/v1/inventory/servers')
@ -25,6 +26,17 @@ export class ServerController {
@Post()
async createServer(@Body() body: any) {
// Quota enforcement
const tenant = TenantContextService.getTenant();
if (tenant && tenant.maxServers !== -1) {
const existing = await this.serverRepository.findAll();
if (existing.length >= tenant.maxServers) {
throw new HttpException(
{ message: `Server quota exceeded (${existing.length}/${tenant.maxServers}). Upgrade your plan to add more servers.`, code: 'QUOTA_EXCEEDED' },
HttpStatus.TOO_MANY_REQUESTS,
);
}
}
const server = new Server();
server.id = crypto.randomUUID();
server.tenantId = body.tenantId;

View File

@ -1,7 +1,8 @@
import { Controller, Get, Post, Put, Patch, Param, Body, NotFoundException } from '@nestjs/common';
import { Controller, Get, Post, Put, Patch, Param, Body, NotFoundException, HttpException, HttpStatus } from '@nestjs/common';
import { StandingOrderRepository } from '../../../infrastructure/repositories/standing-order.repository';
import { StandingOrderExecutionRepository } from '../../../infrastructure/repositories/standing-order-execution.repository';
import { StandingOrder } from '../../../domain/entities/standing-order.entity';
import { TenantContextService } from '@it0/common';
import * as crypto from 'crypto';
@Controller('api/v1/ops/standing-orders')
@ -92,6 +93,19 @@ export class StandingOrderController {
@Post()
async createOrder(@Body() body: any) {
// Quota enforcement
const tenant = TenantContextService.getTenant();
if (tenant && tenant.maxStandingOrders !== -1) {
const existing = await this.standingOrderRepository.findAll();
const activeCount = existing.filter((o) => o.status === 'active').length;
if (activeCount >= tenant.maxStandingOrders) {
throw new HttpException(
{ message: `Standing order quota exceeded (${activeCount}/${tenant.maxStandingOrders}). Upgrade your plan to create more.`, code: 'QUOTA_EXCEEDED' },
HttpStatus.TOO_MANY_REQUESTS,
);
}
}
const order = new StandingOrder();
order.id = crypto.randomUUID();
order.tenantId = body.tenantId;

View File

@ -32,4 +32,10 @@ export const EventPatterns = {
// audit events
AUDIT_LOG_CREATED: 'audit.log.created',
// billing events
USAGE_RECORDED: 'usage.recorded',
SUBSCRIPTION_CHANGED: 'subscription.changed',
QUOTA_EXCEEDED: 'quota.exceeded',
PAYMENT_RECEIVED: 'payment.received',
} as const;

View File

@ -3,4 +3,8 @@ export interface TenantInfo {
tenantName: string;
plan: 'free' | 'pro' | 'enterprise';
schemaName: string;
maxServers: number;
maxUsers: number;
maxStandingOrders: number;
maxAgentTokensPerMonth: number;
}

View File

@ -8,17 +8,21 @@ export class TenantContextService {
return this.storage.run(tenant, fn);
}
static getTenant(): TenantInfo {
static getTenant(): TenantInfo | null {
return this.storage.getStore() ?? null;
}
static getTenantOrThrow(): TenantInfo {
const tenant = this.storage.getStore();
if (!tenant) throw new Error('Tenant context not initialized');
return tenant;
}
static getTenantId(): string {
return this.getTenant().tenantId;
return this.getTenantOrThrow().tenantId;
}
static getSchemaName(): string {
return this.getTenant().schemaName;
return this.getTenantOrThrow().schemaName;
}
}

View File

@ -433,3 +433,21 @@ CREATE TABLE metric_snapshots (
CREATE INDEX idx_metrics_server_type ON metric_snapshots(server_id, metric_type, recorded_at DESC);
CREATE INDEX idx_metrics_recorded ON metric_snapshots(recorded_at);
-- Usage Records (for billing metering)
CREATE TABLE usage_records (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id VARCHAR(20) NOT NULL,
task_id UUID NOT NULL,
session_id UUID NOT NULL,
engine_type VARCHAR(30) NOT NULL,
input_tokens INTEGER NOT NULL DEFAULT 0,
output_tokens INTEGER NOT NULL DEFAULT 0,
total_tokens INTEGER NOT NULL DEFAULT 0,
cost_usd NUMERIC(10, 6) NOT NULL DEFAULT 0,
model VARCHAR(100) NOT NULL DEFAULT '',
recorded_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_usage_records_tenant_month ON usage_records(tenant_id, recorded_at DESC);
CREATE INDEX idx_usage_records_task ON usage_records(task_id);

View File

@ -0,0 +1,141 @@
-- IT0 Billing Tables (public schema)
-- Phase 2: Subscription management + invoicing
-- Invoice number sequence
CREATE SEQUENCE IF NOT EXISTS billing_invoice_number_seq START 1000;
-- Plans
CREATE TABLE IF NOT EXISTS billing_plans (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(50) NOT NULL UNIQUE, -- free, pro, enterprise
display_name VARCHAR(100) NOT NULL,
monthly_price_usd_cents INTEGER NOT NULL DEFAULT 0,
monthly_price_cny INTEGER NOT NULL DEFAULT 0, -- in fen (1/100 yuan)
included_tokens_per_month BIGINT NOT NULL DEFAULT 100000,
overage_rate_cents_per_m_token INTEGER NOT NULL DEFAULT 0, -- per million tokens
max_servers INTEGER NOT NULL DEFAULT 5, -- -1 = unlimited
max_users INTEGER NOT NULL DEFAULT 3,
max_standing_orders INTEGER NOT NULL DEFAULT 10,
hard_limit_percent INTEGER NOT NULL DEFAULT 100, -- 0 = no hard limit
trial_days INTEGER NOT NULL DEFAULT 0,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Subscriptions
CREATE TABLE IF NOT EXISTS billing_subscriptions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id VARCHAR(20) NOT NULL REFERENCES it0_shared.tenants(id),
plan_id UUID NOT NULL REFERENCES billing_plans(id),
status VARCHAR(20) NOT NULL DEFAULT 'trialing',
current_period_start TIMESTAMPTZ NOT NULL,
current_period_end TIMESTAMPTZ NOT NULL,
trial_ends_at TIMESTAMPTZ,
cancel_at_period_end BOOLEAN NOT NULL DEFAULT FALSE,
cancelled_at TIMESTAMPTZ,
next_plan_id UUID REFERENCES billing_plans(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_billing_subs_tenant ON billing_subscriptions(tenant_id);
CREATE INDEX IF NOT EXISTS idx_billing_subs_status ON billing_subscriptions(status);
CREATE INDEX IF NOT EXISTS idx_billing_subs_period_end ON billing_subscriptions(current_period_end);
-- Invoices
CREATE TABLE IF NOT EXISTS billing_invoices (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id VARCHAR(20) NOT NULL REFERENCES it0_shared.tenants(id),
subscription_id UUID REFERENCES billing_subscriptions(id),
invoice_number VARCHAR(50) NOT NULL UNIQUE,
status VARCHAR(20) NOT NULL DEFAULT 'open', -- open, paid, void, past_due, uncollectible
currency VARCHAR(3) NOT NULL DEFAULT 'USD',
subtotal_cents INTEGER NOT NULL DEFAULT 0,
tax_cents INTEGER NOT NULL DEFAULT 0,
total_cents INTEGER NOT NULL DEFAULT 0,
amount_due_cents INTEGER NOT NULL DEFAULT 0,
period_start TIMESTAMPTZ NOT NULL,
period_end TIMESTAMPTZ NOT NULL,
due_date TIMESTAMPTZ NOT NULL,
paid_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_billing_invoices_tenant ON billing_invoices(tenant_id);
CREATE INDEX IF NOT EXISTS idx_billing_invoices_status ON billing_invoices(status);
-- Invoice Items
CREATE TABLE IF NOT EXISTS billing_invoice_items (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
invoice_id UUID NOT NULL REFERENCES billing_invoices(id) ON DELETE CASCADE,
item_type VARCHAR(30) NOT NULL, -- subscription, overage, credit, adjustment
description TEXT NOT NULL,
quantity BIGINT NOT NULL DEFAULT 1,
unit_price INTEGER NOT NULL, -- cents
amount INTEGER NOT NULL, -- cents
currency VARCHAR(3) NOT NULL DEFAULT 'USD',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_billing_items_invoice ON billing_invoice_items(invoice_id);
-- Payments
CREATE TABLE IF NOT EXISTS billing_payments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id VARCHAR(20) NOT NULL REFERENCES it0_shared.tenants(id),
invoice_id UUID NOT NULL REFERENCES billing_invoices(id),
provider VARCHAR(20) NOT NULL, -- stripe, alipay, wechat_pay, crypto
provider_payment_id VARCHAR(255) NOT NULL UNIQUE,
amount_cents INTEGER NOT NULL,
currency VARCHAR(3) NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'pending', -- pending, succeeded, failed, refunded
paid_at TIMESTAMPTZ,
metadata JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_billing_payments_tenant ON billing_payments(tenant_id);
CREATE INDEX IF NOT EXISTS idx_billing_payments_provider_id ON billing_payments(provider_payment_id);
-- Payment Methods
CREATE TABLE IF NOT EXISTS billing_payment_methods (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id VARCHAR(20) NOT NULL REFERENCES it0_shared.tenants(id),
provider VARCHAR(20) NOT NULL,
display_name VARCHAR(200) NOT NULL,
provider_customer_id VARCHAR(255),
details JSONB, -- masked card info, alipay account, etc. NO raw card numbers
is_default BOOLEAN NOT NULL DEFAULT FALSE,
expires_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_billing_methods_tenant ON billing_payment_methods(tenant_id);
-- Monthly Usage Aggregates
CREATE TABLE IF NOT EXISTS billing_usage_aggregates (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id VARCHAR(20) NOT NULL REFERENCES it0_shared.tenants(id),
year INTEGER NOT NULL,
month INTEGER NOT NULL, -- 1-12
period_start TIMESTAMPTZ NOT NULL,
period_end TIMESTAMPTZ NOT NULL,
total_input_tokens BIGINT NOT NULL DEFAULT 0,
total_output_tokens BIGINT NOT NULL DEFAULT 0,
total_tokens BIGINT NOT NULL DEFAULT 0,
total_cost_usd NUMERIC(12, 6) NOT NULL DEFAULT 0,
task_count INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(tenant_id, year, month)
);
CREATE INDEX IF NOT EXISTS idx_billing_usage_tenant_period ON billing_usage_aggregates(tenant_id, year, month);
-- Add max_standing_orders column to tenants if missing
ALTER TABLE it0_shared.tenants ADD COLUMN IF NOT EXISTS max_standing_orders INTEGER NOT NULL DEFAULT 10;
ALTER TABLE it0_shared.tenants ADD COLUMN IF NOT EXISTS max_agent_tokens_per_month BIGINT NOT NULL DEFAULT 100000;

View File

@ -66,6 +66,10 @@ async function runSharedSchema(client: Client) {
log('Running 001-create-shared-schema.sql ...');
await runSqlFile(client, path.join(MIGRATIONS_DIR, '001-create-shared-schema.sql'));
log('Shared schema created.');
log('Running 005-create-billing-tables.sql ...');
await runSqlFile(client, path.join(MIGRATIONS_DIR, '005-create-billing-tables.sql'));
log('Billing tables created.');
}
async function runTenantSchema(client: Client, tenantId: string) {

View File

@ -1,8 +1,37 @@
import { Injectable, NestMiddleware } from '@nestjs/common';
import { Injectable, NestMiddleware, Optional } from '@nestjs/common';
import { DataSource } from 'typeorm';
import { TenantContextService, TenantInfo } from '@it0/common';
interface TenantRow {
id: string;
name: string;
plan: 'free' | 'pro' | 'enterprise';
max_servers: number;
max_users: number;
max_standing_orders: number;
max_agent_tokens_per_month: number;
}
interface CacheEntry {
info: TenantInfo;
expiresAt: number;
}
/** Default quota limits per plan (used when tenant row is missing) */
const DEFAULT_QUOTAS: Record<string, Pick<TenantInfo, 'maxServers' | 'maxUsers' | 'maxStandingOrders' | 'maxAgentTokensPerMonth'>> = {
free: { maxServers: 5, maxUsers: 3, maxStandingOrders: 10, maxAgentTokensPerMonth: 100_000 },
pro: { maxServers: 50, maxUsers: 20, maxStandingOrders: 100, maxAgentTokensPerMonth: 1_000_000 },
enterprise: { maxServers: -1, maxUsers: -1, maxStandingOrders: -1, maxAgentTokensPerMonth: 10_000_000 },
};
const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
@Injectable()
export class TenantContextMiddleware implements NestMiddleware {
private readonly cache = new Map<string, CacheEntry>();
constructor(@Optional() private readonly dataSource: DataSource) {}
use(req: any, res: any, next: () => void) {
let tenantId = req.headers?.['x-tenant-id'] as string;
@ -34,13 +63,73 @@ export class TenantContextMiddleware implements NestMiddleware {
return next();
}
const tenantInfo: TenantInfo = {
// Serve from cache when available (synchronous fast path)
const cached = this.cache.get(tenantId);
if (cached && cached.expiresAt > Date.now()) {
TenantContextService.run(cached.info, () => next());
return;
}
// Load from DB, then run the request in tenant context
this.loadTenantInfo(tenantId)
.then((tenantInfo) => {
TenantContextService.run(tenantInfo, () => next());
})
.catch(() => {
const fallback = this.buildFallback(tenantId, 'free');
TenantContextService.run(fallback, () => next());
});
}
private async loadTenantInfo(tenantId: string): Promise<TenantInfo> {
const schemaName = `it0_t_${tenantId}`;
if (!this.dataSource) {
return this.buildFallback(tenantId, 'free');
}
try {
const rows: TenantRow[] = await this.dataSource.query(
`SELECT id, name, plan, max_servers, max_users, max_standing_orders, max_agent_tokens_per_month
FROM public.tenants WHERE id = $1 LIMIT 1`,
[tenantId],
);
const row = rows[0];
const plan = (row?.plan ?? 'free') as 'free' | 'pro' | 'enterprise';
const defaults = DEFAULT_QUOTAS[plan] ?? DEFAULT_QUOTAS['free'];
const tenantInfo: TenantInfo = {
tenantId,
tenantName: row?.name ?? tenantId,
plan,
schemaName,
maxServers: row?.max_servers ?? defaults.maxServers,
maxUsers: row?.max_users ?? defaults.maxUsers,
maxStandingOrders: row?.max_standing_orders ?? defaults.maxStandingOrders,
maxAgentTokensPerMonth: row?.max_agent_tokens_per_month ?? defaults.maxAgentTokensPerMonth,
};
this.cache.set(tenantId, { info: tenantInfo, expiresAt: Date.now() + CACHE_TTL_MS });
return tenantInfo;
} catch {
return this.buildFallback(tenantId, 'free');
}
}
private buildFallback(tenantId: string, plan: 'free' | 'pro' | 'enterprise'): TenantInfo {
const defaults = DEFAULT_QUOTAS[plan] ?? DEFAULT_QUOTAS['free'];
return {
tenantId,
tenantName: tenantId,
plan: 'free',
plan,
schemaName: `it0_t_${tenantId}`,
...defaults,
};
}
TenantContextService.run(tenantInfo, () => next());
/** Invalidate cached tenant info — call after plan upgrade/downgrade. */
invalidate(tenantId: string): void {
this.cache.delete(tenantId);
}
}

View File

@ -56,9 +56,57 @@ export interface StandingOrderExecutedEvent extends BaseEvent {
};
}
export interface UsageRecordedEvent extends BaseEvent {
type: 'usage.recorded';
payload: {
taskId: string;
sessionId: string;
engineType: string;
inputTokens: number;
outputTokens: number;
totalTokens: number;
costUsd: number;
model: string;
};
}
export interface SubscriptionChangedEvent extends BaseEvent {
type: 'subscription.changed';
payload: {
subscriptionId: string;
fromPlan: string;
toPlan: string;
status: string;
};
}
export interface QuotaExceededEvent extends BaseEvent {
type: 'quota.exceeded';
payload: {
quotaType: 'tokens' | 'servers' | 'users' | 'standing_orders';
current: number;
limit: number;
};
}
export interface PaymentReceivedEvent extends BaseEvent {
type: 'payment.received';
payload: {
invoiceId: string;
paymentId: string;
amount: number;
currency: string;
provider: string;
};
}
export type IT0Event =
| CommandExecutedEvent
| ApprovalRequiredEvent
| AlertFiredEvent
| TaskCompletedEvent
| StandingOrderExecutedEvent;
| StandingOrderExecutedEvent
| UsageRecordedEvent
| SubscriptionChangedEvent
| QuotaExceededEvent
| PaymentReceivedEvent;