From 9ed80cd0bc56a011ba9b95f0b0f5dcba5f949088 Mon Sep 17 00:00:00 2001 From: hailin Date: Tue, 3 Mar 2026 21:09:17 -0800 Subject: [PATCH] feat: implement complete commercial monetization loop (Phases 1-4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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 --- deploy/docker/docker-compose.yml | 44 +++ .../src/app/(admin)/billing/invoices/page.tsx | 146 ++++++++ .../src/app/(admin)/billing/page.tsx | 228 +++++++++++++ .../src/app/(admin)/billing/plans/page.tsx | 214 ++++++++++++ .../src/i18n/locales/en/sidebar.json | 4 + .../src/i18n/locales/zh/sidebar.json | 4 + .../components/layout/sidebar.tsx | 12 + it0_app/lib/core/router/app_router.dart | 7 + .../pages/billing_overview_page.dart | 311 ++++++++++++++++++ .../providers/billing_provider.dart | 87 +++++ .../presentation/pages/settings_page.dart | 14 + packages/gateway/config/kong.yml | 19 ++ .../agent-service/src/agent.module.ts | 7 +- .../use-cases/execute-task.use-case.ts | 57 +++- .../domain/entities/usage-record.entity.ts | 37 +++ .../ports/outbound/agent-engine.port.ts | 10 +- .../claude-agent-sdk-engine.ts | 14 +- .../engines/claude-api/claude-api-engine.ts | 28 +- .../claude-code-cli/claude-code-engine.ts | 15 +- .../repositories/usage-record.repository.ts | 24 ++ .../rest/controllers/agent.controller.ts | 53 ++- .../src/application/services/auth.service.ts | 17 + .../services/billing-service/package.json | 34 ++ .../use-cases/aggregate-usage.use-case.ts | 103 ++++++ .../use-cases/cancel-subscription.use-case.ts | 30 ++ .../use-cases/change-plan.use-case.ts | 86 +++++ .../use-cases/check-token-quota.use-case.ts | 82 +++++ .../create-payment-session.use-case.ts | 66 ++++ .../use-cases/create-subscription.use-case.ts | 34 ++ .../generate-monthly-invoice.use-case.ts | 122 +++++++ .../handle-payment-webhook.use-case.ts | 101 ++++++ .../billing-service/src/billing.module.ts | 108 ++++++ .../domain/entities/invoice-item.entity.ts | 25 ++ .../src/domain/entities/invoice.entity.ts | 48 +++ .../domain/entities/payment-method.entity.ts | 28 ++ .../src/domain/entities/payment.entity.ts | 39 +++ .../src/domain/entities/plan.entity.ts | 55 ++++ .../domain/entities/subscription.entity.ts | 42 +++ .../domain/entities/usage-aggregate.entity.ts | 34 ++ .../src/domain/ports/payment-provider.port.ts | 44 +++ .../services/invoice-generator.service.ts | 134 ++++++++ .../services/overage-calculator.service.ts | 23 ++ .../subscription-lifecycle.service.ts | 92 ++++++ .../alipay/alipay.provider.ts | 101 ++++++ .../crypto/crypto.provider.ts | 101 ++++++ .../payment-provider.registry.ts | 31 ++ .../stripe/stripe.provider.ts | 82 +++++ .../wechat/wechat-pay.provider.ts | 156 +++++++++ .../repositories/invoice.repository.ts | 78 +++++ .../repositories/payment.repository.ts | 59 ++++ .../repositories/plan.repository.ts | 37 +++ .../repositories/subscription.repository.ts | 41 +++ .../usage-aggregate.repository.ts | 68 ++++ .../scheduler/billing.scheduler.ts | 26 ++ .../infrastructure/seed/plan-seed.service.ts | 63 ++++ .../rest/controllers/invoice.controller.ts | 109 ++++++ .../controllers/payment-method.controller.ts | 67 ++++ .../rest/controllers/plan.controller.ts | 26 ++ .../controllers/subscription.controller.ts | 70 ++++ .../rest/controllers/webhook.controller.ts | 58 ++++ packages/services/billing-service/src/main.ts | 33 ++ .../services/billing-service/tsconfig.json | 21 ++ .../rest/controllers/server.controller.ts | 14 +- .../controllers/standing-order.controller.ts | 16 +- .../common/src/constants/event-patterns.ts | 6 + .../src/interfaces/tenant-info.interface.ts | 4 + .../src/utils/tenant-context.service.ts | 10 +- .../002-create-tenant-schema-template.sql | 18 + .../migrations/005-create-billing-tables.sql | 141 ++++++++ .../shared/database/src/run-migrations.ts | 4 + .../database/src/tenant-context.middleware.ts | 97 +++++- packages/shared/events/src/event-types.ts | 50 ++- 72 files changed, 4241 insertions(+), 28 deletions(-) create mode 100644 it0-web-admin/src/app/(admin)/billing/invoices/page.tsx create mode 100644 it0-web-admin/src/app/(admin)/billing/page.tsx create mode 100644 it0-web-admin/src/app/(admin)/billing/plans/page.tsx create mode 100644 it0_app/lib/features/billing/presentation/pages/billing_overview_page.dart create mode 100644 it0_app/lib/features/billing/presentation/providers/billing_provider.dart create mode 100644 packages/services/agent-service/src/domain/entities/usage-record.entity.ts create mode 100644 packages/services/agent-service/src/infrastructure/repositories/usage-record.repository.ts create mode 100644 packages/services/billing-service/package.json create mode 100644 packages/services/billing-service/src/application/use-cases/aggregate-usage.use-case.ts create mode 100644 packages/services/billing-service/src/application/use-cases/cancel-subscription.use-case.ts create mode 100644 packages/services/billing-service/src/application/use-cases/change-plan.use-case.ts create mode 100644 packages/services/billing-service/src/application/use-cases/check-token-quota.use-case.ts create mode 100644 packages/services/billing-service/src/application/use-cases/create-payment-session.use-case.ts create mode 100644 packages/services/billing-service/src/application/use-cases/create-subscription.use-case.ts create mode 100644 packages/services/billing-service/src/application/use-cases/generate-monthly-invoice.use-case.ts create mode 100644 packages/services/billing-service/src/application/use-cases/handle-payment-webhook.use-case.ts create mode 100644 packages/services/billing-service/src/billing.module.ts create mode 100644 packages/services/billing-service/src/domain/entities/invoice-item.entity.ts create mode 100644 packages/services/billing-service/src/domain/entities/invoice.entity.ts create mode 100644 packages/services/billing-service/src/domain/entities/payment-method.entity.ts create mode 100644 packages/services/billing-service/src/domain/entities/payment.entity.ts create mode 100644 packages/services/billing-service/src/domain/entities/plan.entity.ts create mode 100644 packages/services/billing-service/src/domain/entities/subscription.entity.ts create mode 100644 packages/services/billing-service/src/domain/entities/usage-aggregate.entity.ts create mode 100644 packages/services/billing-service/src/domain/ports/payment-provider.port.ts create mode 100644 packages/services/billing-service/src/domain/services/invoice-generator.service.ts create mode 100644 packages/services/billing-service/src/domain/services/overage-calculator.service.ts create mode 100644 packages/services/billing-service/src/domain/services/subscription-lifecycle.service.ts create mode 100644 packages/services/billing-service/src/infrastructure/payment-providers/alipay/alipay.provider.ts create mode 100644 packages/services/billing-service/src/infrastructure/payment-providers/crypto/crypto.provider.ts create mode 100644 packages/services/billing-service/src/infrastructure/payment-providers/payment-provider.registry.ts create mode 100644 packages/services/billing-service/src/infrastructure/payment-providers/stripe/stripe.provider.ts create mode 100644 packages/services/billing-service/src/infrastructure/payment-providers/wechat/wechat-pay.provider.ts create mode 100644 packages/services/billing-service/src/infrastructure/repositories/invoice.repository.ts create mode 100644 packages/services/billing-service/src/infrastructure/repositories/payment.repository.ts create mode 100644 packages/services/billing-service/src/infrastructure/repositories/plan.repository.ts create mode 100644 packages/services/billing-service/src/infrastructure/repositories/subscription.repository.ts create mode 100644 packages/services/billing-service/src/infrastructure/repositories/usage-aggregate.repository.ts create mode 100644 packages/services/billing-service/src/infrastructure/scheduler/billing.scheduler.ts create mode 100644 packages/services/billing-service/src/infrastructure/seed/plan-seed.service.ts create mode 100644 packages/services/billing-service/src/interfaces/rest/controllers/invoice.controller.ts create mode 100644 packages/services/billing-service/src/interfaces/rest/controllers/payment-method.controller.ts create mode 100644 packages/services/billing-service/src/interfaces/rest/controllers/plan.controller.ts create mode 100644 packages/services/billing-service/src/interfaces/rest/controllers/subscription.controller.ts create mode 100644 packages/services/billing-service/src/interfaces/rest/controllers/webhook.controller.ts create mode 100644 packages/services/billing-service/src/main.ts create mode 100644 packages/services/billing-service/tsconfig.json create mode 100644 packages/shared/database/src/migrations/005-create-billing-tables.sql diff --git a/deploy/docker/docker-compose.yml b/deploy/docker/docker-compose.yml index c174923..fa3cf63 100644 --- a/deploy/docker/docker-compose.yml +++ b/deploy/docker/docker-compose.yml @@ -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: ../.. diff --git a/it0-web-admin/src/app/(admin)/billing/invoices/page.tsx b/it0-web-admin/src/app/(admin)/billing/invoices/page.tsx new file mode 100644 index 0000000..b3c315a --- /dev/null +++ b/it0-web-admin/src/app/(admin)/billing/invoices/page.tsx @@ -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 = { + 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 ( + + {status} + + ); +} + +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 ( +
+

+ Invoices +

+ +
+
+ + + + + + + + + + + + {isLoading ? ( + + + + ) : invoices.length === 0 ? ( + + + + ) : ( + invoices.map((inv) => ( + + + + + + + + + )) + )} + +
Invoice #PeriodAmountDue DateStatus +
Loading...
No invoices found
+ + {inv.invoiceNumber} + + + {new Date(inv.periodStart).toLocaleDateString()} – {new Date(inv.periodEnd).toLocaleDateString()} + + {inv.currency} {inv.totalAmount.toFixed(2)} + + {new Date(inv.dueDate).toLocaleDateString()} + + + + {inv.status === 'open' ? ( + + Pay Now + + ) : ( + + )} +
+
+ + {totalPages > 1 && ( +
+ + Page {page + 1} of {totalPages} ({total} total) + +
+ + +
+
+ )} +
+
+ ); +} diff --git a/it0-web-admin/src/app/(admin)/billing/page.tsx b/it0-web-admin/src/app/(admin)/billing/page.tsx new file mode 100644 index 0000000..17cdf3a --- /dev/null +++ b/it0-web-admin/src/app/(admin)/billing/page.tsx @@ -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 = { + 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 ( + + {status} + + ); +} + +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 ( +
+
+
+ ); +} + +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({ + queryKey: ['billing', 'subscription'], + queryFn: () => apiClient('/api/v1/billing/subscription'), + retry: false, + }); + + const { data: quota } = useQuery({ + 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 ( +
+

Billing & Subscription

+ + {/* Usage Warning Banner */} + {usedPct >= 80 && ( +
+ + + You have used {usedPct.toFixed(0)}% of your monthly token quota.{' '} + {usedPct >= 100 ? 'Limit reached — agent tasks are blocked.' : 'Consider upgrading your plan.'} + + + Upgrade + +
+ )} + +
+ {/* Subscription Card */} +
+
+
+ +

Current Plan

+
+ + Change + +
+ + {sub ? ( +
+
+ {sub.plan?.displayName ?? 'Unknown'} + +
+ {sub.trialEndsAt && sub.status === 'trialing' && ( +

+ Trial ends {new Date(sub.trialEndsAt).toLocaleDateString()} +

+ )} + {sub.cancelAtPeriodEnd && ( +

+ Cancels on {new Date(sub.currentPeriodEnd).toLocaleDateString()} +

+ )} + {sub.nextPlan && ( +

+ Downgrade to {sub.nextPlan.displayName} on {new Date(sub.currentPeriodEnd).toLocaleDateString()} +

+ )} +

+ Period: {new Date(sub.currentPeriodStart).toLocaleDateString()} – {new Date(sub.currentPeriodEnd).toLocaleDateString()} +

+
+ ) : ( +

No active subscription

+ )} +
+ + {/* Token Usage Card */} +
+
+ +

This Month's Token Usage

+
+ + {quota ? ( +
+
+ {formatTokens(quota.usedTokens)} used + + {quota.limitTokens === -1 ? 'Unlimited' : `${formatTokens(quota.limitTokens)} limit`} + +
+ {quota.limitTokens !== -1 && ( + + )} +
+ {usedPct.toFixed(1)}% + {quota.overageAllowed && usedPct > 100 && ( + Overage charges apply + )} +
+
+ ) : ( +

Loading usage data...

+ )} +
+
+ + {/* Recent Invoices */} +
+
+
+ +

Recent Invoices

+
+ + View all + +
+ + {recentInvoices.length === 0 ? ( +
No invoices yet
+ ) : ( +
+ {recentInvoices.map((inv) => ( + +
+

{inv.invoiceNumber}

+

+ {new Date(inv.periodStart).toLocaleDateString()} – {new Date(inv.periodEnd).toLocaleDateString()} +

+
+
+ {inv.currency} {inv.totalAmount.toFixed(2)} + +
+ + ))} +
+ )} +
+
+ ); +} diff --git a/it0-web-admin/src/app/(admin)/billing/plans/page.tsx b/it0-web-admin/src/app/(admin)/billing/plans/page.tsx new file mode 100644 index 0000000..b02a9b4 --- /dev/null +++ b/it0-web-admin/src/app/(admin)/billing/plans/page.tsx @@ -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 = { + 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(null); + + const { data: plans = [] } = useQuery({ + queryKey: ['billing', 'plans'], + queryFn: () => apiClient('/api/v1/billing/plans'), + }); + + const { data: sub } = useQuery({ + 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 ( +
+
+

Choose a Plan

+
+ + +
+
+ +
+ {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 ( +
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 && ( +
+ + MOST POPULAR + +
+ )} + + {isCurrent && ( +
+ + Current + +
+ )} + +
+

{plan.displayName}

+
+ {symbol}{price.toFixed(2)} + /month +
+ {plan.trialDays > 0 && ( +

{plan.trialDays}-day free trial

+ )} +
+ +
+
+ + {formatTokens(plan.includedTokensPerMonth)} tokens/month +
+ {plan.overageRateUsdPerMToken > 0 && ( +

+ Overage: {currency === 'CNY' + ? `¥${(plan.overageRateUsdPerMToken * 7.2).toFixed(2)}` + : `$${plan.overageRateUsdPerMToken.toFixed(2)}`}/MTok +

+ )} +
+ +
    + {features.map((f) => ( +
  • + + {f} +
  • + ))} +
+ + {!isCurrent && ( + + )} +
+ ); + })} +
+ + {selectedPlan && ( +
+ + +
+ )} + + {upgradeMutation.isError && ( +

+ Failed to change plan. Please try again. +

+ )} +
+ ); +} diff --git a/it0-web-admin/src/i18n/locales/en/sidebar.json b/it0-web-admin/src/i18n/locales/en/sidebar.json index 1ea6599..3042149 100644 --- a/it0-web-admin/src/i18n/locales/en/sidebar.json +++ b/it0-web-admin/src/i18n/locales/en/sidebar.json @@ -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", diff --git a/it0-web-admin/src/i18n/locales/zh/sidebar.json b/it0-web-admin/src/i18n/locales/zh/sidebar.json index df7def3..a06c1b9 100644 --- a/it0-web-admin/src/i18n/locales/zh/sidebar.json +++ b/it0-web-admin/src/i18n/locales/zh/sidebar.json @@ -26,6 +26,10 @@ "logs": "日志", "sessionReplay": "会话回放", "communication": "通讯", + "billing": "账单", + "billingOverview": "总览", + "billingPlans": "套餐", + "billingInvoices": "账单列表", "tenants": "租户", "users": "用户", "settings": "设置", diff --git a/it0-web-admin/src/presentation/components/layout/sidebar.tsx b/it0-web-admin/src/presentation/components/layout/sidebar.tsx index 1f89a7e..c743f77 100644 --- a/it0-web-admin/src/presentation/components/layout/sidebar.tsx +++ b/it0-web-admin/src/presentation/components/layout/sidebar.tsx @@ -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: }, + { + key: 'billing', + label: t('billing'), + href: '/billing', + icon: , + 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: }, { key: 'users', label: t('users'), href: '/users', icon: }, { key: 'settings', label: t('settings'), href: '/settings', icon: }, diff --git a/it0_app/lib/core/router/app_router.dart b/it0_app/lib/core/router/app_router.dart index 398cb64..b7f8fe4 100644 --- a/it0_app/lib/core/router/app_router.dart +++ b/it0_app/lib/core/router/app_router.dart @@ -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((ref) { return GoRouter( @@ -67,6 +68,12 @@ final routerProvider = Provider((ref) { GoRoute( path: '/settings', builder: (context, state) => const SettingsPage(), + routes: [ + GoRoute( + path: 'billing', + builder: (context, state) => const BillingOverviewPage(), + ), + ], ), ], ), diff --git a/it0_app/lib/features/billing/presentation/pages/billing_overview_page.dart b/it0_app/lib/features/billing/presentation/pages/billing_overview_page.dart new file mode 100644 index 0000000..7834236 --- /dev/null +++ b/it0_app/lib/features/billing/presentation/pages/billing_overview_page.dart @@ -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, + ), + ), + ), + ), + ); + } +} diff --git a/it0_app/lib/features/billing/presentation/providers/billing_provider.dart b/it0_app/lib/features/billing/presentation/providers/billing_provider.dart new file mode 100644 index 0000000..d103796 --- /dev/null +++ b/it0_app/lib/features/billing/presentation/providers/billing_provider.dart @@ -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 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((ref) async { + final dio = ref.read(dioClientProvider); + + // Fetch subscription, quota, and recent invoice (ignore 404s gracefully) + Map? sub; + Map? quota; + Map? invoicesData; + + try { + final r = await dio.get('/api/v1/billing/subscription'); + sub = r.data as Map?; + } catch (_) {} + + try { + final r = await dio.get('/api/v1/billing/usage/current'); + quota = r.data as Map?; + } catch (_) {} + + try { + final r = await dio.get('/api/v1/billing/invoices?limit=1'); + invoicesData = r.data as Map?; + } catch (_) {} + + final invoices = (invoicesData?['data'] as List?)?.cast>() ?? []; + final latestInvoice = invoices.isNotEmpty ? LatestInvoice.fromJson(invoices.first) : null; + + return BillingOverview( + planDisplayName: ((sub?['plan'] as Map?)?['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, + ); +}); diff --git a/it0_app/lib/features/settings/presentation/pages/settings_page.dart b/it0_app/lib/features/settings/presentation/pages/settings_page.dart index b8f55ee..7ca338c 100644 --- a/it0_app/lib/features/settings/presentation/pages/settings_page.dart +++ b/it0_app/lib/features/settings/presentation/pages/settings_page.dart @@ -148,6 +148,20 @@ class _SettingsPageState extends ConsumerState { ), 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, diff --git a/packages/gateway/config/kong.yml b/packages/gateway/config/kong.yml index 7556197..9ce0dea 100644 --- a/packages/gateway/config/kong.yml +++ b/packages/gateway/config/kong.yml @@ -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: diff --git a/packages/services/agent-service/src/agent.module.ts b/packages/services/agent-service/src/agent.module.ts index b32f678..7d2caaf 100644 --- a/packages/services/agent-service/src/agent.module.ts +++ b/packages/services/agent-service/src/agent.module.ts @@ -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 {} diff --git a/packages/services/agent-service/src/application/use-cases/execute-task.use-case.ts b/packages/services/agent-service/src/application/use-cases/execute-task.use-case.ts index e5d0649..90fae10 100644 --- a/packages/services/agent-service/src/application/use-cases/execute-task.use-case.ts +++ b/packages/services/agent-service/src/application/use-cases/execute-task.use-case.ts @@ -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 { 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, + engineType: string, + ): Promise { + 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}`); + } + } } diff --git a/packages/services/agent-service/src/domain/entities/usage-record.entity.ts b/packages/services/agent-service/src/domain/entities/usage-record.entity.ts new file mode 100644 index 0000000..44678e2 --- /dev/null +++ b/packages/services/agent-service/src/domain/entities/usage-record.entity.ts @@ -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; +} diff --git a/packages/services/agent-service/src/domain/ports/outbound/agent-engine.port.ts b/packages/services/agent-service/src/domain/ports/outbound/agent-engine.port.ts index 0bbc294..0772c57 100644 --- a/packages/services/agent-service/src/domain/ports/outbound/agent-engine.port.ts +++ b/packages/services/agent-service/src/domain/ports/outbound/agent-engine.port.ts @@ -36,7 +36,15 @@ export type EngineStreamEvent = | { type: 'tool_use'; toolName: string; input: Record } | { 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 }; diff --git a/packages/services/agent-service/src/infrastructure/engines/claude-agent-sdk/claude-agent-sdk-engine.ts b/packages/services/agent-service/src/infrastructure/engines/claude-agent-sdk/claude-agent-sdk-engine.ts index 6c6b060..594e8da 100644 --- a/packages/services/agent-service/src/infrastructure/engines/claude-agent-sdk/claude-agent-sdk-engine.ts +++ b/packages/services/agent-service/src/infrastructure/engines/claude-agent-sdk/claude-agent-sdk-engine.ts @@ -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}`); diff --git a/packages/services/agent-service/src/infrastructure/engines/claude-api/claude-api-engine.ts b/packages/services/agent-service/src/infrastructure/engines/claude-api/claude-api-engine.ts index ef492c8..c8d285e 100644 --- a/packages/services/agent-service/src/infrastructure/engines/claude-api/claude-api-engine.ts +++ b/packages/services/agent-service/src/infrastructure/engines/claude-api/claude-api-engine.ts @@ -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) { diff --git a/packages/services/agent-service/src/infrastructure/engines/claude-code-cli/claude-code-engine.ts b/packages/services/agent-service/src/infrastructure/engines/claude-code-cli/claude-code-engine.ts index efb3f10..65848a6 100644 --- a/packages/services/agent-service/src/infrastructure/engines/claude-code-cli/claude-code-engine.ts +++ b/packages/services/agent-service/src/infrastructure/engines/claude-code-cli/claude-code-engine.ts @@ -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({ diff --git a/packages/services/agent-service/src/infrastructure/repositories/usage-record.repository.ts b/packages/services/agent-service/src/infrastructure/repositories/usage-record.repository.ts new file mode 100644 index 0000000..3299961 --- /dev/null +++ b/packages/services/agent-service/src/infrastructure/repositories/usage-record.repository.ts @@ -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 { + constructor(dataSource: DataSource) { + super(dataSource, UsageRecord); + } + + async sumMonthTokens(year: number, month: number): Promise { + 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); + }); + } +} diff --git a/packages/services/agent-service/src/interfaces/rest/controllers/agent.controller.ts b/packages/services/agent-service/src/interfaces/rest/controllers/agent.controller.ts index aa6a5eb..bffce03 100644 --- a/packages/services/agent-service/src/interfaces/rest/controllers/agent.controller.ts +++ b/packages/services/agent-service/src/interfaces/rest/controllers/agent.controller.ts @@ -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, + engineType: string, + ): Promise { + 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(); diff --git a/packages/services/auth-service/src/application/services/auth.service.ts b/packages/services/auth-service/src/application/services/auth.service.ts index 7755326..0ccf481 100644 --- a/packages/services/auth-service/src/application/services/auth.service.ts +++ b/packages/services/auth-service/src/application/services/auth.service.ts @@ -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 }, diff --git a/packages/services/billing-service/package.json b/packages/services/billing-service/package.json new file mode 100644 index 0000000..3b3974b --- /dev/null +++ b/packages/services/billing-service/package.json @@ -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" + } +} diff --git a/packages/services/billing-service/src/application/use-cases/aggregate-usage.use-case.ts b/packages/services/billing-service/src/application/use-cases/aggregate-usage.use-case.ts new file mode 100644 index 0000000..fdfa166 --- /dev/null +++ b/packages/services/billing-service/src/application/use-cases/aggregate-usage.use-case.ts @@ -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('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 }) { + 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}`); + } + } +} diff --git a/packages/services/billing-service/src/application/use-cases/cancel-subscription.use-case.ts b/packages/services/billing-service/src/application/use-cases/cancel-subscription.use-case.ts new file mode 100644 index 0000000..0c4f61d --- /dev/null +++ b/packages/services/billing-service/src/application/use-cases/cancel-subscription.use-case.ts @@ -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 { + 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`); + } + } +} diff --git a/packages/services/billing-service/src/application/use-cases/change-plan.use-case.ts b/packages/services/billing-service/src/application/use-cases/change-plan.use-case.ts new file mode 100644 index 0000000..6c34b11 --- /dev/null +++ b/packages/services/billing-service/src/application/use-cases/change-plan.use-case.ts @@ -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 { + 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`); + } + } +} diff --git a/packages/services/billing-service/src/application/use-cases/check-token-quota.use-case.ts b/packages/services/billing-service/src/application/use-cases/check-token-quota.use-case.ts new file mode 100644 index 0000000..33df31c --- /dev/null +++ b/packages/services/billing-service/src/application/use-cases/check-token-quota.use-case.ts @@ -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 { + 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, + }; + } +} diff --git a/packages/services/billing-service/src/application/use-cases/create-payment-session.use-case.ts b/packages/services/billing-service/src/application/use-cases/create-payment-session.use-case.ts new file mode 100644 index 0000000..69ddc05 --- /dev/null +++ b/packages/services/billing-service/src/application/use-cases/create-payment-session.use-case.ts @@ -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; + } +} diff --git a/packages/services/billing-service/src/application/use-cases/create-subscription.use-case.ts b/packages/services/billing-service/src/application/use-cases/create-subscription.use-case.ts new file mode 100644 index 0000000..67f0631 --- /dev/null +++ b/packages/services/billing-service/src/application/use-cases/create-subscription.use-case.ts @@ -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 { + // 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}`); + } +} diff --git a/packages/services/billing-service/src/application/use-cases/generate-monthly-invoice.use-case.ts b/packages/services/billing-service/src/application/use-cases/generate-monthly-invoice.use-case.ts new file mode 100644 index 0000000..1e69c57 --- /dev/null +++ b/packages/services/billing-service/src/application/use-cases/generate-monthly-invoice.use-case.ts @@ -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 { + 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 { + 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), + }; + } +} diff --git a/packages/services/billing-service/src/application/use-cases/handle-payment-webhook.use-case.ts b/packages/services/billing-service/src/application/use-cases/handle-payment-webhook.use-case.ts new file mode 100644 index 0000000..5a62e17 --- /dev/null +++ b/packages/services/billing-service/src/application/use-cases/handle-payment-webhook.use-case.ts @@ -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): Promise { + 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) { + 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}`); + } +} diff --git a/packages/services/billing-service/src/billing.module.ts b/packages/services/billing-service/src/billing.module.ts new file mode 100644 index 0000000..e45c7e5 --- /dev/null +++ b/packages/services/billing-service/src/billing.module.ts @@ -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 {} diff --git a/packages/services/billing-service/src/domain/entities/invoice-item.entity.ts b/packages/services/billing-service/src/domain/entities/invoice-item.entity.ts new file mode 100644 index 0000000..36619e9 --- /dev/null +++ b/packages/services/billing-service/src/domain/entities/invoice-item.entity.ts @@ -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) +} diff --git a/packages/services/billing-service/src/domain/entities/invoice.entity.ts b/packages/services/billing-service/src/domain/entities/invoice.entity.ts new file mode 100644 index 0000000..13245bf --- /dev/null +++ b/packages/services/billing-service/src/domain/entities/invoice.entity.ts @@ -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; +} diff --git a/packages/services/billing-service/src/domain/entities/payment-method.entity.ts b/packages/services/billing-service/src/domain/entities/payment-method.entity.ts new file mode 100644 index 0000000..ce5c8bf --- /dev/null +++ b/packages/services/billing-service/src/domain/entities/payment-method.entity.ts @@ -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; // { last4: '4242', brand: 'visa' } — NEVER store full card numbers + + @Column({ type: 'varchar', length: 200, nullable: true }) + providerCustomerId?: string; + + @CreateDateColumn({ type: 'timestamptz' }) + createdAt!: Date; +} diff --git a/packages/services/billing-service/src/domain/entities/payment.entity.ts b/packages/services/billing-service/src/domain/entities/payment.entity.ts new file mode 100644 index 0000000..0e26461 --- /dev/null +++ b/packages/services/billing-service/src/domain/entities/payment.entity.ts @@ -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; + + @CreateDateColumn({ type: 'timestamptz' }) + createdAt!: Date; + + @UpdateDateColumn({ type: 'timestamptz' }) + updatedAt!: Date; +} diff --git a/packages/services/billing-service/src/domain/entities/plan.entity.ts b/packages/services/billing-service/src/domain/entities/plan.entity.ts new file mode 100644 index 0000000..36d9b96 --- /dev/null +++ b/packages/services/billing-service/src/domain/entities/plan.entity.ts @@ -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; + + @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; +} diff --git a/packages/services/billing-service/src/domain/entities/subscription.entity.ts b/packages/services/billing-service/src/domain/entities/subscription.entity.ts new file mode 100644 index 0000000..bc156ea --- /dev/null +++ b/packages/services/billing-service/src/domain/entities/subscription.entity.ts @@ -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; +} diff --git a/packages/services/billing-service/src/domain/entities/usage-aggregate.entity.ts b/packages/services/billing-service/src/domain/entities/usage-aggregate.entity.ts new file mode 100644 index 0000000..c7b3904 --- /dev/null +++ b/packages/services/billing-service/src/domain/entities/usage-aggregate.entity.ts @@ -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; +} diff --git a/packages/services/billing-service/src/domain/ports/payment-provider.port.ts b/packages/services/billing-service/src/domain/ports/payment-provider.port.ts new file mode 100644 index 0000000..0f38664 --- /dev/null +++ b/packages/services/billing-service/src/domain/ports/payment-provider.port.ts @@ -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; +} + +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; +} + +export interface PaymentProviderPort { + readonly providerType: PaymentProviderType; + createPaymentSession(params: CreatePaymentParams): Promise; + confirmPayment(providerPaymentId: string): Promise; + handleWebhook(payload: Buffer, headers: Record): Promise; +} diff --git a/packages/services/billing-service/src/domain/services/invoice-generator.service.ts b/packages/services/billing-service/src/domain/services/invoice-generator.service.ts new file mode 100644 index 0000000..b012e9a --- /dev/null +++ b/packages/services/billing-service/src/domain/services/invoice-generator.service.ts @@ -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)}`; + } +} diff --git a/packages/services/billing-service/src/domain/services/overage-calculator.service.ts b/packages/services/billing-service/src/domain/services/overage-calculator.service.ts new file mode 100644 index 0000000..595652e --- /dev/null +++ b/packages/services/billing-service/src/domain/services/overage-calculator.service.ts @@ -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 }; + } +} diff --git a/packages/services/billing-service/src/domain/services/subscription-lifecycle.service.ts b/packages/services/billing-service/src/domain/services/subscription-lifecycle.service.ts new file mode 100644 index 0000000..38ff4ef --- /dev/null +++ b/packages/services/billing-service/src/domain/services/subscription-lifecycle.service.ts @@ -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; + } +} diff --git a/packages/services/billing-service/src/infrastructure/payment-providers/alipay/alipay.provider.ts b/packages/services/billing-service/src/infrastructure/payment-providers/alipay/alipay.provider.ts new file mode 100644 index 0000000..4f60a6d --- /dev/null +++ b/packages/services/billing-service/src/infrastructure/payment-providers/alipay/alipay.provider.ts @@ -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('ALIPAY_APP_ID', ''); + this.privateKey = this.configService.get('ALIPAY_PRIVATE_KEY', ''); + this.configured = !!(this.appId && this.privateKey); + } + + isConfigured(): boolean { + return this.configured; + } + + async createPaymentSession(req: PaymentSessionRequest): Promise { + 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): Promise { + if (!this.configured) throw new Error('Alipay not configured'); + + // Parse URL-encoded body + const params = new URLSearchParams(rawBody.toString()); + const notifyData: Record = {}; + 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' }; + } +} diff --git a/packages/services/billing-service/src/infrastructure/payment-providers/crypto/crypto.provider.ts b/packages/services/billing-service/src/infrastructure/payment-providers/crypto/crypto.provider.ts new file mode 100644 index 0000000..29915f1 --- /dev/null +++ b/packages/services/billing-service/src/infrastructure/payment-providers/crypto/crypto.provider.ts @@ -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('COINBASE_COMMERCE_API_KEY', ''); + this.webhookSecret = this.configService.get('COINBASE_COMMERCE_WEBHOOK_SECRET', ''); + this.configured = !!(this.apiKey && this.webhookSecret); + } + + isConfigured(): boolean { + return this.configured; + } + + async createPaymentSession(req: PaymentSessionRequest): Promise { + 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): Promise { + 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' }; + } +} diff --git a/packages/services/billing-service/src/infrastructure/payment-providers/payment-provider.registry.ts b/packages/services/billing-service/src/infrastructure/payment-providers/payment-provider.registry.ts new file mode 100644 index 0000000..12c00a5 --- /dev/null +++ b/packages/services/billing-service/src/infrastructure/payment-providers/payment-provider.registry.ts @@ -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(); + + 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()); + } +} diff --git a/packages/services/billing-service/src/infrastructure/payment-providers/stripe/stripe.provider.ts b/packages/services/billing-service/src/infrastructure/payment-providers/stripe/stripe.provider.ts new file mode 100644 index 0000000..05651ed --- /dev/null +++ b/packages/services/billing-service/src/infrastructure/payment-providers/stripe/stripe.provider.ts @@ -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('STRIPE_SECRET_KEY'); + this.webhookSecret = this.configService.get('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 { + 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): Promise { + 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' }; + } + } +} diff --git a/packages/services/billing-service/src/infrastructure/payment-providers/wechat/wechat-pay.provider.ts b/packages/services/billing-service/src/infrastructure/payment-providers/wechat/wechat-pay.provider.ts new file mode 100644 index 0000000..dbd3bec --- /dev/null +++ b/packages/services/billing-service/src/infrastructure/payment-providers/wechat/wechat-pay.provider.ts @@ -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('WECHAT_MCH_ID', ''); + this.apiKeyV3 = this.configService.get('WECHAT_API_KEY_V3', ''); + this.appId = this.configService.get('WECHAT_APP_ID', ''); + this.configured = !!(this.mchId && this.apiKeyV3 && this.appId); + } + + isConfigured(): boolean { + return this.configured; + } + + async createPaymentSession(req: PaymentSessionRequest): Promise { + 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( + '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): Promise { + 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 { + 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(); + } +} diff --git a/packages/services/billing-service/src/infrastructure/repositories/invoice.repository.ts b/packages/services/billing-service/src/infrastructure/repositories/invoice.repository.ts new file mode 100644 index 0000000..74746a5 --- /dev/null +++ b/packages/services/billing-service/src/infrastructure/repositories/invoice.repository.ts @@ -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; + private readonly itemRepo: Repository; + + 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 { + return this.repo.findOne({ where: { id }, relations: ['items'] }); + } + + async findByInvoiceNumber(invoiceNumber: string): Promise { + return this.repo.findOne({ where: { invoiceNumber } }); + } + + async findUnpaidByTenantId(tenantId: string): Promise { + return this.repo.find({ + where: [ + { tenantId, status: InvoiceStatus.OPEN }, + { tenantId, status: InvoiceStatus.PAST_DUE }, + ], + }); + } + + async getNextInvoiceNumber(): Promise { + 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 { + return this.repo.save(invoice); + } + + async saveItems(items: InvoiceItem[]): Promise { + return this.itemRepo.save(items); + } + + async saveWithItems(invoice: Invoice, items: InvoiceItem[]): Promise { + 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(); + } + } +} diff --git a/packages/services/billing-service/src/infrastructure/repositories/payment.repository.ts b/packages/services/billing-service/src/infrastructure/repositories/payment.repository.ts new file mode 100644 index 0000000..4ef75f4 --- /dev/null +++ b/packages/services/billing-service/src/infrastructure/repositories/payment.repository.ts @@ -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; + private readonly methodRepo: Repository; + + constructor(private readonly dataSource: DataSource) { + this.paymentRepo = this.dataSource.getRepository(Payment); + this.methodRepo = this.dataSource.getRepository(PaymentMethod); + } + + async findPaymentById(id: string): Promise { + return this.paymentRepo.findOne({ where: { id } }); + } + + async findByProviderPaymentId(providerPaymentId: string): Promise { + return this.paymentRepo.findOne({ where: { providerPaymentId } }); + } + + async findPaymentsByTenantId(tenantId: string, limit = 20): Promise { + return this.paymentRepo.find({ + where: { tenantId }, + order: { createdAt: 'DESC' }, + take: limit, + }); + } + + async savePayment(payment: Payment): Promise { + return this.paymentRepo.save(payment); + } + + // Payment Methods + async findMethodsByTenantId(tenantId: string): Promise { + return this.methodRepo.find({ + where: { tenantId }, + order: { isDefault: 'DESC', createdAt: 'ASC' }, + }); + } + + async findMethodById(id: string): Promise { + return this.methodRepo.findOne({ where: { id } }); + } + + async saveMethod(method: PaymentMethod): Promise { + return this.methodRepo.save(method); + } + + async deleteMethod(id: string): Promise { + await this.methodRepo.delete(id); + } + + async clearDefaultMethods(tenantId: string): Promise { + await this.methodRepo.update({ tenantId, isDefault: true }, { isDefault: false }); + } +} diff --git a/packages/services/billing-service/src/infrastructure/repositories/plan.repository.ts b/packages/services/billing-service/src/infrastructure/repositories/plan.repository.ts new file mode 100644 index 0000000..7646d42 --- /dev/null +++ b/packages/services/billing-service/src/infrastructure/repositories/plan.repository.ts @@ -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; + + constructor(private readonly dataSource: DataSource) { + this.repo = this.dataSource.getRepository(Plan); + } + + async findAll(): Promise { + return this.repo.find({ where: { isActive: true }, order: { monthlyPriceUsdCents: 'ASC' } }); + } + + async findById(id: string): Promise { + return this.repo.findOne({ where: { id } }); + } + + async findByName(name: string): Promise { + return this.repo.findOne({ where: { name } }); + } + + async save(plan: Plan): Promise { + return this.repo.save(plan); + } + + async upsertSeedPlans(plans: Partial[]): Promise { + 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)); + } + } + } +} diff --git a/packages/services/billing-service/src/infrastructure/repositories/subscription.repository.ts b/packages/services/billing-service/src/infrastructure/repositories/subscription.repository.ts new file mode 100644 index 0000000..f54612e --- /dev/null +++ b/packages/services/billing-service/src/infrastructure/repositories/subscription.repository.ts @@ -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; + + constructor(private readonly dataSource: DataSource) { + this.repo = this.dataSource.getRepository(Subscription); + } + + async findByTenantId(tenantId: string): Promise { + return this.repo.findOne({ + where: { tenantId }, + order: { createdAt: 'DESC' }, + }); + } + + async findById(id: string): Promise { + return this.repo.findOne({ where: { id } }); + } + + async findExpired(): Promise { + return this.repo + .createQueryBuilder('s') + .where('s.currentPeriodEnd < NOW()') + .andWhere('s.status IN (:...statuses)', { + statuses: [SubscriptionStatus.ACTIVE, SubscriptionStatus.TRIALING], + }) + .getMany(); + } + + async findCancelAtPeriodEnd(): Promise { + return this.repo.find({ where: { cancelAtPeriodEnd: true, status: SubscriptionStatus.ACTIVE } }); + } + + async save(subscription: Subscription): Promise { + return this.repo.save(subscription); + } +} diff --git a/packages/services/billing-service/src/infrastructure/repositories/usage-aggregate.repository.ts b/packages/services/billing-service/src/infrastructure/repositories/usage-aggregate.repository.ts new file mode 100644 index 0000000..80152ec --- /dev/null +++ b/packages/services/billing-service/src/infrastructure/repositories/usage-aggregate.repository.ts @@ -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; + + constructor(private readonly dataSource: DataSource) { + this.repo = this.dataSource.getRepository(UsageAggregate); + } + + async findByTenantAndPeriod(tenantId: string, year: number, month: number): Promise { + return this.repo.findOne({ where: { tenantId, year, month } }); + } + + async findCurrentMonth(tenantId: string): Promise { + const now = new Date(); + return this.findByTenantAndPeriod(tenantId, now.getFullYear(), now.getMonth() + 1); + } + + async findHistoryByTenantId(tenantId: string, limit = 12): Promise { + 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 { + 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 { + return this.repo.save(aggregate); + } +} diff --git a/packages/services/billing-service/src/infrastructure/scheduler/billing.scheduler.ts b/packages/services/billing-service/src/infrastructure/scheduler/billing.scheduler.ts new file mode 100644 index 0000000..611fb7b --- /dev/null +++ b/packages/services/billing-service/src/infrastructure/scheduler/billing.scheduler.ts @@ -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}`); + } + } +} diff --git a/packages/services/billing-service/src/infrastructure/seed/plan-seed.service.ts b/packages/services/billing-service/src/infrastructure/seed/plan-seed.service.ts new file mode 100644 index 0000000..d31c0c5 --- /dev/null +++ b/packages/services/billing-service/src/infrastructure/seed/plan-seed.service.ts @@ -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}`); + } + } +} diff --git a/packages/services/billing-service/src/interfaces/rest/controllers/invoice.controller.ts b/packages/services/billing-service/src/interfaces/rest/controllers/invoice.controller.ts new file mode 100644 index 0000000..12be928 --- /dev/null +++ b/packages/services/billing-service/src/interfaces/rest/controllers/invoice.controller.ts @@ -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() }; + } +} diff --git a/packages/services/billing-service/src/interfaces/rest/controllers/payment-method.controller.ts b/packages/services/billing-service/src/interfaces/rest/controllers/payment-method.controller.ts new file mode 100644 index 0000000..51738d0 --- /dev/null +++ b/packages/services/billing-service/src/interfaces/rest/controllers/payment-method.controller.ts @@ -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); + } +} diff --git a/packages/services/billing-service/src/interfaces/rest/controllers/plan.controller.ts b/packages/services/billing-service/src/interfaces/rest/controllers/plan.controller.ts new file mode 100644 index 0000000..b1a506a --- /dev/null +++ b/packages/services/billing-service/src/interfaces/rest/controllers/plan.controller.ts @@ -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, + })); + } +} diff --git a/packages/services/billing-service/src/interfaces/rest/controllers/subscription.controller.ts b/packages/services/billing-service/src/interfaces/rest/controllers/subscription.controller.ts new file mode 100644 index 0000000..9c79ce9 --- /dev/null +++ b/packages/services/billing-service/src/interfaces/rest/controllers/subscription.controller.ts @@ -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); + } +} diff --git a/packages/services/billing-service/src/interfaces/rest/controllers/webhook.controller.ts b/packages/services/billing-service/src/interfaces/rest/controllers/webhook.controller.ts new file mode 100644 index 0000000..1aed59f --- /dev/null +++ b/packages/services/billing-service/src/interfaces/rest/controllers/webhook.controller.ts @@ -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) { + return this.processWebhook(PaymentProviderType.STRIPE, req, headers); + } + + @Post('alipay') + @HttpCode(HttpStatus.OK) + async alipayWebhook(@Req() req: Request, @Headers() headers: Record) { + 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) { + 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) { + return this.processWebhook(PaymentProviderType.CRYPTO, req, headers); + } + + private async processWebhook( + provider: PaymentProviderType, + req: Request, + headers: Record, + ) { + 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; + } + } +} diff --git a/packages/services/billing-service/src/main.ts b/packages/services/billing-service/src/main.ts new file mode 100644 index 0000000..7376a5d --- /dev/null +++ b/packages/services/billing-service/src/main.ts @@ -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('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); +}); diff --git a/packages/services/billing-service/tsconfig.json b/packages/services/billing-service/tsconfig.json new file mode 100644 index 0000000..95f5641 --- /dev/null +++ b/packages/services/billing-service/tsconfig.json @@ -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 + } +} diff --git a/packages/services/inventory-service/src/interfaces/rest/controllers/server.controller.ts b/packages/services/inventory-service/src/interfaces/rest/controllers/server.controller.ts index 1717908..88431a2 100644 --- a/packages/services/inventory-service/src/interfaces/rest/controllers/server.controller.ts +++ b/packages/services/inventory-service/src/interfaces/rest/controllers/server.controller.ts @@ -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; diff --git a/packages/services/ops-service/src/interfaces/rest/controllers/standing-order.controller.ts b/packages/services/ops-service/src/interfaces/rest/controllers/standing-order.controller.ts index bf4f617..378bfb3 100644 --- a/packages/services/ops-service/src/interfaces/rest/controllers/standing-order.controller.ts +++ b/packages/services/ops-service/src/interfaces/rest/controllers/standing-order.controller.ts @@ -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; diff --git a/packages/shared/common/src/constants/event-patterns.ts b/packages/shared/common/src/constants/event-patterns.ts index 5d027b7..6b97997 100644 --- a/packages/shared/common/src/constants/event-patterns.ts +++ b/packages/shared/common/src/constants/event-patterns.ts @@ -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; diff --git a/packages/shared/common/src/interfaces/tenant-info.interface.ts b/packages/shared/common/src/interfaces/tenant-info.interface.ts index 4578393..06a7987 100644 --- a/packages/shared/common/src/interfaces/tenant-info.interface.ts +++ b/packages/shared/common/src/interfaces/tenant-info.interface.ts @@ -3,4 +3,8 @@ export interface TenantInfo { tenantName: string; plan: 'free' | 'pro' | 'enterprise'; schemaName: string; + maxServers: number; + maxUsers: number; + maxStandingOrders: number; + maxAgentTokensPerMonth: number; } diff --git a/packages/shared/common/src/utils/tenant-context.service.ts b/packages/shared/common/src/utils/tenant-context.service.ts index f051df1..5f73930 100644 --- a/packages/shared/common/src/utils/tenant-context.service.ts +++ b/packages/shared/common/src/utils/tenant-context.service.ts @@ -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; } } diff --git a/packages/shared/database/src/migrations/002-create-tenant-schema-template.sql b/packages/shared/database/src/migrations/002-create-tenant-schema-template.sql index e8bb73f..f58fc38 100644 --- a/packages/shared/database/src/migrations/002-create-tenant-schema-template.sql +++ b/packages/shared/database/src/migrations/002-create-tenant-schema-template.sql @@ -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); diff --git a/packages/shared/database/src/migrations/005-create-billing-tables.sql b/packages/shared/database/src/migrations/005-create-billing-tables.sql new file mode 100644 index 0000000..4af58c1 --- /dev/null +++ b/packages/shared/database/src/migrations/005-create-billing-tables.sql @@ -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; diff --git a/packages/shared/database/src/run-migrations.ts b/packages/shared/database/src/run-migrations.ts index c0c1a16..b511cf9 100644 --- a/packages/shared/database/src/run-migrations.ts +++ b/packages/shared/database/src/run-migrations.ts @@ -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) { diff --git a/packages/shared/database/src/tenant-context.middleware.ts b/packages/shared/database/src/tenant-context.middleware.ts index 798b1f2..a5695dd 100644 --- a/packages/shared/database/src/tenant-context.middleware.ts +++ b/packages/shared/database/src/tenant-context.middleware.ts @@ -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> = { + 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(); + + 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 { + 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); } } diff --git a/packages/shared/events/src/event-types.ts b/packages/shared/events/src/event-types.ts index 891c87e..d1d0d01 100644 --- a/packages/shared/events/src/event-types.ts +++ b/packages/shared/events/src/event-types.ts @@ -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;