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:
parent
54e3d442ed
commit
9ed80cd0bc
|
|
@ -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: ../..
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 & 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'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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -26,6 +26,10 @@
|
|||
"logs": "日志",
|
||||
"sessionReplay": "会话回放",
|
||||
"communication": "通讯",
|
||||
"billing": "账单",
|
||||
"billingOverview": "总览",
|
||||
"billingPlans": "套餐",
|
||||
"billingInvoices": "账单列表",
|
||||
"tenants": "租户",
|
||||
"users": "用户",
|
||||
"settings": "设置",
|
||||
|
|
|
|||
|
|
@ -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} /> },
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
);
|
||||
});
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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 {}
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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}`);
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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}`);
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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>;
|
||||
}
|
||||
|
|
@ -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)}`;
|
||||
}
|
||||
}
|
||||
|
|
@ -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 };
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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' };
|
||||
}
|
||||
}
|
||||
|
|
@ -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' };
|
||||
}
|
||||
}
|
||||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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' };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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() };
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -3,4 +3,8 @@ export interface TenantInfo {
|
|||
tenantName: string;
|
||||
plan: 'free' | 'pro' | 'enterprise';
|
||||
schemaName: string;
|
||||
maxServers: number;
|
||||
maxUsers: number;
|
||||
maxStandingOrders: number;
|
||||
maxAgentTokensPerMonth: number;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in New Issue