fix(billing-service): resolve all TypeScript compilation errors

Comprehensive fix of 124 TS errors across the billing-service:

Entity fixes:
- invoice.entity.ts: add InvoiceStatus/InvoiceCurrency const objects,
  rename fields to match DB schema (subtotalCents, taxCents, totalCents,
  amountDueCents), add OneToMany items relation
- invoice-item.entity.ts: add InvoiceItemType const object, add column
  name mappings and currency field
- payment.entity.ts: add PaymentStatus const, rename amount→amountCents
  with column name mapping, add paidAt field
- subscription.entity.ts: add SubscriptionStatus const object
- usage-aggregate.entity.ts: rename periodYear/Month→year/month to match
  DB columns, add periodStart/periodEnd fields
- payment-method.entity.ts: add displayName, expiresAt, updatedAt fields

Port/Provider fixes:
- payment-provider.port.ts: make PaymentProviderType a const object (not
  just a type), add PaymentSessionRequest alias, rename WebhookEvent with
  correct field shape (type vs eventType), make providerPaymentId optional
- All 4 providers: replace PaymentSessionRequest→CreatePaymentParams,
  fix amountCents→amount, remove sessionId from PaymentSession return,
  add confirmPayment() stub, fix Stripe API version to '2023-10-16'

Use case fixes:
- aggregate-usage.use-case.ts: replace 'redis' with 'ioredis' (workspace
  standard); rewrite using ioredis xreadgroup API
- change/check/generate use cases: fix Plan field names
  (monthlyPriceCentsUsd, includedTokens, overageRateCentsPerMTokenUsd)
- generate-monthly-invoice: fix SubscriptionStatus/InvoiceCurrency as
  values (now const objects)
- handle-payment-webhook: fix WebhookResult import, result.type usage,
  payment.paidAt

Controller/Repository fixes:
- plan.controller.ts, plan.repository.ts: fix Plan field names
- webhook.controller.ts: remove express import, use any for req type
- invoice-generator.service.ts: fix overageAmountCents→overageCentsUsd,
  monthlyPriceCny→monthlyPriceFenCny, includedTokensPerMonth→includedTokens

Dependencies:
- billing-service/package.json: replace redis with ioredis dependency
- pnpm-lock.yaml: regenerated after ioredis addition

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
hailin 2026-03-03 23:00:27 -08:00
parent c7f3807148
commit 40ee84a0b7
24 changed files with 253 additions and 153 deletions

View File

@ -22,6 +22,7 @@
"stripe": "^14.0.0",
"alipay-sdk": "^4.0.0",
"coinbase-commerce-node": "^1.0.4",
"ioredis": "^5.3.0",
"@it0/common": "workspace:*",
"@it0/database": "workspace:*",
"@it0/events": "workspace:*"

View File

@ -1,6 +1,6 @@
import { Injectable, Logger, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { createClient, RedisClientType } from 'redis';
import Redis from 'ioredis';
import { UsageAggregateRepository } from '../../infrastructure/repositories/usage-aggregate.repository';
interface UsageRecordedEvent {
@ -21,7 +21,7 @@ const CONSUMER_NAME = 'billing-consumer-1';
@Injectable()
export class AggregateUsageUseCase implements OnModuleInit, OnModuleDestroy {
private readonly logger = new Logger(AggregateUsageUseCase.name);
private client: RedisClientType;
private client: Redis;
private running = false;
constructor(
@ -31,12 +31,11 @@ export class AggregateUsageUseCase implements OnModuleInit, OnModuleDestroy {
async onModuleInit() {
const redisUrl = this.configService.get<string>('REDIS_URL', 'redis://localhost:6379');
this.client = createClient({ url: redisUrl }) as RedisClientType;
await this.client.connect();
this.client = new Redis(redisUrl);
// Create consumer group (ignore error if already exists)
try {
await this.client.xGroupCreate(STREAM_KEY, CONSUMER_GROUP, '0', { MKSTREAM: true });
await this.client.xgroup('CREATE', STREAM_KEY, CONSUMER_GROUP, '0', 'MKSTREAM');
} catch {
// Group already exists
}
@ -49,25 +48,29 @@ export class AggregateUsageUseCase implements OnModuleInit, OnModuleDestroy {
async onModuleDestroy() {
this.running = false;
await this.client.quit();
this.client.disconnect();
}
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 },
);
const response = await this.client.xreadgroup(
'GROUP', CONSUMER_GROUP, CONSUMER_NAME,
'COUNT', '10',
'BLOCK', '5000',
'STREAMS', STREAM_KEY, '>',
) as Array<[string, Array<[string, string[]]>]> | null;
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);
for (const [, messages] of response) {
for (const [id, fields] of messages) {
const record: Record<string, string> = {};
for (let i = 0; i < fields.length; i += 2) {
record[fields[i]] = fields[i + 1];
}
await this.processMessage(id, record);
await this.client.xack(STREAM_KEY, CONSUMER_GROUP, id);
}
}
} catch (err) {
@ -79,9 +82,9 @@ export class AggregateUsageUseCase implements OnModuleInit, OnModuleDestroy {
}
}
private async processMessage(message: { id: string; message: Record<string, string> }) {
private async processMessage(id: string, record: Record<string, string>) {
try {
const payload: UsageRecordedEvent = JSON.parse(message.message.data ?? '{}');
const payload: UsageRecordedEvent = JSON.parse(record['data'] ?? '{}');
if (!payload.tenantId || !payload.inputTokens) return;
const recordedAt = new Date(payload.recordedAt ?? Date.now());
@ -97,7 +100,7 @@ export class AggregateUsageUseCase implements OnModuleInit, OnModuleDestroy {
payload.costUsd ?? 0,
);
} catch (err) {
this.logger.error(`Failed to process usage message ${message.id}: ${err.message}`);
this.logger.error(`Failed to process usage message ${id}: ${err.message}`);
}
}
}

View File

@ -40,7 +40,7 @@ export class ChangePlanUseCase {
throw new BadRequestException('Already on this plan');
}
const isUpgrade = newPlan.monthlyPriceUsdCents > currentPlan.monthlyPriceUsdCents;
const isUpgrade = newPlan.monthlyPriceCentsUsd > currentPlan.monthlyPriceCentsUsd;
if (isUpgrade) {
// Immediate upgrade with proration invoice item
@ -48,7 +48,7 @@ export class ChangePlanUseCase {
await this.subscriptionRepo.save(updated);
// Generate proration charge if not on free plan
if (newPlan.monthlyPriceUsdCents > 0 && currentPlan.monthlyPriceUsdCents >= 0) {
if (newPlan.monthlyPriceCentsUsd > 0 && currentPlan.monthlyPriceCentsUsd >= 0) {
const invoiceNumber = await this.invoiceRepo.getNextInvoiceNumber();
const proratedItem = this.invoiceGenerator.generateProratedUpgradeItem(
currentPlan,

View File

@ -40,7 +40,7 @@ export class CheckTokenQuotaUseCase {
);
const usedTokens = usage?.totalTokens ?? 0;
const baseLimit = plan.includedTokensPerMonth;
const baseLimit = plan.includedTokens;
// Enterprise: no limit (-1)
if (baseLimit === -1) {
@ -55,7 +55,7 @@ export class CheckTokenQuotaUseCase {
}
const hardLimitTokens = Math.floor(baseLimit * (plan.hardLimitPercent / 100));
const overageAllowed = plan.overageRateCentsPerMToken > 0;
const overageAllowed = plan.overageRateCentsPerMTokenUsd > 0;
// Hard limit check (applies to all plans)
if (plan.hardLimitPercent > 0 && usedTokens >= hardLimitTokens) {

View File

@ -42,10 +42,10 @@ export class CreatePaymentSessionUseCase {
const session = await paymentProvider.createPaymentSession({
invoiceId,
tenantId,
amountCents: invoice.amountDueCents,
amount: invoice.amountDueCents,
currency: invoice.currency,
description: `IT0 Invoice ${invoice.invoiceNumber}`,
returnUrl,
returnUrl: returnUrl ?? '',
});
// Create a pending payment record
@ -57,7 +57,7 @@ export class CreatePaymentSessionUseCase {
payment.amountCents = invoice.amountDueCents;
payment.currency = invoice.currency;
payment.status = PaymentStatus.PENDING;
payment.metadata = { sessionId: session.sessionId };
payment.metadata = { provider };
await this.paymentRepo.savePayment(payment);

View File

@ -1,6 +1,4 @@
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';
@ -74,7 +72,7 @@ export class GenerateMonthlyInvoiceUseCase {
}
// Only generate invoice if plan costs money
if (plan.monthlyPriceUsdCents > 0 || (usage && usage.totalTokens > plan.includedTokensPerMonth)) {
if (plan.monthlyPriceCentsUsd > 0 || (usage && usage.totalTokens > plan.includedTokens)) {
const invoiceNumber = await this.invoiceRepo.getNextInvoiceNumber();
const { invoice, items } = this.invoiceGenerator.generateMonthlyInvoice(
sub,
@ -94,7 +92,7 @@ export class GenerateMonthlyInvoiceUseCase {
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) {
if (plan.monthlyPriceCentsUsd === 0) {
const activated = this.lifecycle.activate(sub);
await this.subscriptionRepo.save(activated);
} else {
@ -110,13 +108,13 @@ export class GenerateMonthlyInvoiceUseCase {
tenantId,
year,
month,
periodStart: new Date(year, month - 1, 1),
periodEnd: new Date(year, month, 0, 23, 59, 59),
totalInputTokens: 0,
totalOutputTokens: 0,
totalTokens: 0,
totalCostUsd: 0,
taskCount: 0,
periodStart: new Date(year, month - 1, 1),
periodEnd: new Date(year, month, 0, 23, 59, 59),
};
}
}

View File

@ -58,7 +58,7 @@ export class HandlePaymentWebhookUseCase {
await this.paymentRepo.savePayment(payment);
// Mark invoice as paid
const invoice = await this.invoiceRepo.findById(payment.invoiceId);
const invoice = await this.invoiceRepo.findById(payment.invoiceId ?? '');
if (invoice) {
invoice.status = InvoiceStatus.PAID;
invoice.paidAt = new Date();

View File

@ -1,25 +1,36 @@
import { Entity, PrimaryGeneratedColumn, Column } from 'typeorm';
export const InvoiceItemType = {
SUBSCRIPTION: 'subscription',
OVERAGE: 'overage',
CREDIT: 'credit',
ADJUSTMENT: 'adjustment',
} as const;
export type InvoiceItemType = typeof InvoiceItemType[keyof typeof InvoiceItemType];
@Entity({ name: 'invoice_items', schema: 'public' })
export class InvoiceItem {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column({ type: 'uuid' })
@Column({ name: 'invoice_id', 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({ name: 'item_type', type: 'varchar', length: 30 })
itemType!: InvoiceItemType; // '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' })
@Column({ name: 'unit_price', type: 'int' })
unitPrice!: number; // in cents/fen
@Column({ type: 'int' })
amount!: number; // quantity * unitPrice (rounded)
@Column({ type: 'varchar', length: 3, default: 'USD' })
currency!: string;
}

View File

@ -1,19 +1,34 @@
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn } from 'typeorm';
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, OneToMany } from 'typeorm';
import { InvoiceItem } from './invoice-item.entity';
export type InvoiceStatus = 'draft' | 'open' | 'paid' | 'void' | 'uncollectible';
export const InvoiceStatus = {
DRAFT: 'draft',
OPEN: 'open',
PAID: 'paid',
PAST_DUE: 'past_due',
VOID: 'void',
UNCOLLECTIBLE: 'uncollectible',
} as const;
export type InvoiceStatus = typeof InvoiceStatus[keyof typeof InvoiceStatus];
export const InvoiceCurrency = {
USD: 'USD',
CNY: 'CNY',
} as const;
export type InvoiceCurrency = typeof InvoiceCurrency[keyof typeof InvoiceCurrency];
@Entity({ name: 'invoices', schema: 'public' })
export class Invoice {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column({ type: 'varchar', length: 100 })
@Column({ name: 'tenant_id', type: 'varchar', length: 100 })
tenantId!: string;
@Column({ type: 'uuid' })
subscriptionId!: string;
@Column({ name: 'subscription_id', type: 'uuid', nullable: true })
subscriptionId?: string;
@Column({ type: 'varchar', length: 30, unique: true })
@Column({ name: 'invoice_number', type: 'varchar', length: 30, unique: true })
invoiceNumber!: string; // e.g. INV-2026-03-0001
@Column({ type: 'varchar', length: 20, default: 'draft' })
@ -22,27 +37,33 @@ export class Invoice {
@Column({ type: 'varchar', length: 3 })
currency!: string; // 'USD' | 'CNY'
@Column({ type: 'int', default: 0 })
subtotalAmount!: number; // in cents/fen
@Column({ name: 'subtotal_cents', type: 'int', default: 0 })
subtotalCents!: number; // in cents/fen
@Column({ type: 'int', default: 0 })
taxAmount!: number;
@Column({ name: 'tax_cents', type: 'int', default: 0 })
taxCents!: number;
@Column({ type: 'int', default: 0 })
totalAmount!: number;
@Column({ name: 'total_cents', type: 'int', default: 0 })
totalCents!: number;
@Column({ type: 'timestamptz' })
@Column({ name: 'amount_due_cents', type: 'int', default: 0 })
amountDueCents!: number;
@Column({ name: 'period_start', type: 'timestamptz' })
periodStart!: Date;
@Column({ type: 'timestamptz' })
@Column({ name: 'period_end', type: 'timestamptz' })
periodEnd!: Date;
@Column({ type: 'timestamptz' })
@Column({ name: 'due_date', type: 'timestamptz' })
dueDate!: Date;
@Column({ type: 'timestamptz', nullable: true })
@Column({ name: 'paid_at', type: 'timestamptz', nullable: true })
paidAt?: Date;
@OneToMany(() => InvoiceItem, (item) => item.invoiceId, { eager: false })
items?: InvoiceItem[];
@CreateDateColumn({ type: 'timestamptz' })
createdAt!: Date;
}

View File

@ -1,28 +1,37 @@
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn } from 'typeorm';
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn } from 'typeorm';
@Entity({ name: 'payment_methods', schema: 'public' })
export class PaymentMethod {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column({ type: 'varchar', length: 100 })
@Column({ name: 'tenant_id', 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({ name: 'display_name', type: 'varchar', length: 200, default: '' })
displayName!: string; // e.g. 'Visa *4242', 'Alipay xxx@qq.com'
@Column({ type: 'boolean', default: false })
@Column({ type: 'varchar', length: 30, nullable: true })
type?: string; // 'card' | 'alipay_account' | 'wechat_openid' | 'crypto_wallet'
@Column({ name: 'is_default', type: 'boolean', default: false })
isDefault!: boolean;
@Column({ type: 'jsonb', default: '{}' })
details!: Record<string, any>; // { last4: '4242', brand: 'visa' } — NEVER store full card numbers
@Column({ type: 'varchar', length: 200, nullable: true })
@Column({ name: 'provider_customer_id', type: 'varchar', length: 200, nullable: true })
providerCustomerId?: string;
@Column({ name: 'expires_at', type: 'timestamptz', nullable: true })
expiresAt?: Date;
@CreateDateColumn({ type: 'timestamptz' })
createdAt!: Date;
@UpdateDateColumn({ type: 'timestamptz' })
updatedAt!: Date;
}

View File

@ -1,6 +1,13 @@
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn } from 'typeorm';
export type PaymentStatus = 'pending' | 'processing' | 'succeeded' | 'failed' | 'refunded';
export const PaymentStatus = {
PENDING: 'pending',
PROCESSING: 'processing',
SUCCEEDED: 'succeeded',
FAILED: 'failed',
REFUNDED: 'refunded',
} as const;
export type PaymentStatus = typeof PaymentStatus[keyof typeof PaymentStatus];
@Entity({ name: 'payments', schema: 'public' })
export class Payment {
@ -16,11 +23,11 @@ export class Payment {
@Column({ type: 'varchar', length: 20 })
provider!: string; // 'stripe' | 'alipay' | 'wechat_pay' | 'crypto'
@Column({ type: 'varchar', length: 200 })
@Column({ name: 'provider_payment_id', type: 'varchar', length: 200 })
providerPaymentId!: string;
@Column({ type: 'int' })
amount!: number; // in cents/fen
@Column({ name: 'amount_cents', type: 'int' })
amountCents!: number; // in cents/fen
@Column({ type: 'varchar', length: 3 })
currency!: string;
@ -28,6 +35,9 @@ export class Payment {
@Column({ type: 'varchar', length: 20, default: 'pending' })
status!: PaymentStatus;
@Column({ name: 'paid_at', type: 'timestamptz', nullable: true })
paidAt?: Date;
@Column({ type: 'jsonb', default: '{}' })
metadata!: Record<string, any>;

View File

@ -1,6 +1,13 @@
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn } from 'typeorm';
export type SubscriptionStatus = 'trialing' | 'active' | 'past_due' | 'cancelled' | 'expired';
export const SubscriptionStatus = {
TRIALING: 'trialing',
ACTIVE: 'active',
PAST_DUE: 'past_due',
CANCELLED: 'cancelled',
EXPIRED: 'expired',
} as const;
export type SubscriptionStatus = typeof SubscriptionStatus[keyof typeof SubscriptionStatus];
@Entity({ name: 'subscriptions', schema: 'public' })
export class Subscription {

View File

@ -5,28 +5,34 @@ export class UsageAggregate {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column({ type: 'varchar', length: 100 })
@Column({ name: 'tenant_id', type: 'varchar', length: 100 })
tenantId!: string;
@Column({ type: 'int' })
periodYear!: number;
year!: number;
@Column({ type: 'int' })
periodMonth!: number;
month!: number;
@Column({ type: 'bigint', default: 0 })
@Column({ name: 'period_start', type: 'timestamptz', nullable: true })
periodStart?: Date;
@Column({ name: 'period_end', type: 'timestamptz', nullable: true })
periodEnd?: Date;
@Column({ name: 'total_input_tokens', type: 'bigint', default: 0 })
totalInputTokens!: number;
@Column({ type: 'bigint', default: 0 })
@Column({ name: 'total_output_tokens', type: 'bigint', default: 0 })
totalOutputTokens!: number;
@Column({ type: 'bigint', default: 0 })
@Column({ name: 'total_tokens', type: 'bigint', default: 0 })
totalTokens!: number;
@Column({ type: 'numeric', precision: 14, scale: 6, default: 0 })
@Column({ name: 'total_cost_usd', type: 'numeric', precision: 14, scale: 6, default: 0 })
totalCostUsd!: number;
@Column({ type: 'int', default: 0 })
@Column({ name: 'task_count', type: 'int', default: 0 })
taskCount!: number;
@UpdateDateColumn({ type: 'timestamptz' })

View File

@ -1,4 +1,10 @@
export type PaymentProviderType = 'stripe' | 'alipay' | 'wechat_pay' | 'crypto';
export const PaymentProviderType = {
STRIPE: 'stripe' as const,
ALIPAY: 'alipay' as const,
WECHAT_PAY: 'wechat_pay' as const,
CRYPTO: 'crypto' as const,
};
export type PaymentProviderType = typeof PaymentProviderType[keyof typeof PaymentProviderType];
export interface CreatePaymentParams {
amount: number; // in cents/fen
@ -11,6 +17,9 @@ export interface CreatePaymentParams {
metadata?: Record<string, any>;
}
// Alias for backwards compatibility
export type PaymentSessionRequest = CreatePaymentParams;
export interface PaymentSession {
providerPaymentId: string;
redirectUrl?: string; // Alipay, WeChat, Crypto
@ -28,17 +37,20 @@ export interface PaymentResult {
paidAt?: Date;
}
export interface WebhookEvent {
eventType: 'payment_succeeded' | 'payment_failed' | 'refund_completed';
providerPaymentId: string;
amount: number;
currency: string;
metadata: Record<string, any>;
export interface WebhookResult {
type: 'payment_succeeded' | 'payment_failed' | 'refunded' | 'unknown';
providerPaymentId?: string;
amount?: number;
currency?: string;
metadata?: Record<string, any>;
}
// Alias for backwards compatibility
export type WebhookEvent = WebhookResult;
export interface PaymentProviderPort {
readonly providerType: PaymentProviderType;
createPaymentSession(params: CreatePaymentParams): Promise<PaymentSession>;
confirmPayment(providerPaymentId: string): Promise<PaymentResult>;
handleWebhook(payload: Buffer, headers: Record<string, string>): Promise<WebhookEvent>;
handleWebhook(payload: Buffer, headers: Record<string, string>): Promise<WebhookResult>;
}

View File

@ -23,20 +23,20 @@ export class InvoiceGeneratorService {
invoice.invoiceNumber = invoiceNumber;
invoice.status = InvoiceStatus.OPEN;
invoice.currency = currency;
invoice.periodStart = usage.periodStart;
invoice.periodEnd = usage.periodEnd;
invoice.periodStart = usage.periodStart ?? new Date(usage.year, usage.month - 1, 1);
invoice.periodEnd = usage.periodEnd ?? new Date(usage.year, usage.month, 0, 23, 59, 59);
invoice.dueDate = this.addDays(new Date(), 14);
const items: InvoiceItem[] = [];
// 1. Base subscription fee
const baseAmountCents =
currency === InvoiceCurrency.CNY ? plan.monthlyPriceCny : plan.monthlyPriceUsdCents;
currency === InvoiceCurrency.CNY ? plan.monthlyPriceFenCny : plan.monthlyPriceCentsUsd;
if (baseAmountCents > 0) {
const subItem = new InvoiceItem();
subItem.itemType = InvoiceItemType.SUBSCRIPTION;
subItem.description = `${plan.displayName} subscription (${this.formatPeriod(usage.periodStart, usage.periodEnd)})`;
subItem.description = `${plan.displayName} subscription (${this.formatPeriod(invoice.periodStart, invoice.periodEnd)})`;
subItem.quantity = 1;
subItem.unitPrice = baseAmountCents;
subItem.amount = baseAmountCents;
@ -45,20 +45,20 @@ export class InvoiceGeneratorService {
}
// 2. Overage charges (token usage beyond plan quota)
if (plan.overageRateCentsPerMToken > 0 && usage.totalTokens > plan.includedTokensPerMonth) {
if (plan.overageRateCentsPerMTokenUsd > 0 && usage.totalTokens > plan.includedTokens) {
const overageResult = this.overageCalculator.calculate(
usage.totalTokens,
plan.includedTokensPerMonth,
plan.overageRateCentsPerMToken,
plan.includedTokens,
plan.overageRateCentsPerMTokenUsd,
);
if (overageResult.overageAmountCents > 0) {
if (overageResult.overageCentsUsd > 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.description = `Token overage: ${this.formatTokens(overageResult.overageTokens)} tokens × ${this.formatRate(plan.overageRateCentsPerMTokenUsd, currency)}/MTok`;
overageItem.quantity = overageResult.overageTokens;
overageItem.unitPrice = plan.overageRateCentsPerMToken; // per million tokens
overageItem.amount = overageResult.overageAmountCents;
overageItem.unitPrice = plan.overageRateCentsPerMTokenUsd; // per million tokens
overageItem.amount = overageResult.overageCentsUsd;
overageItem.currency = currency;
items.push(overageItem);
}
@ -92,8 +92,8 @@ export class InvoiceGeneratorService {
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 oldPrice = currency === InvoiceCurrency.CNY ? oldPlan.monthlyPriceFenCny : oldPlan.monthlyPriceCentsUsd;
const newPrice = currency === InvoiceCurrency.CNY ? newPlan.monthlyPriceFenCny : newPlan.monthlyPriceCentsUsd;
const proratedAmount = Math.round((newPrice - oldPrice) * fraction);
const item = new InvoiceItem();
@ -117,7 +117,7 @@ export class InvoiceGeneratorService {
return Math.ceil(ms / (1000 * 60 * 60 * 24));
}
private formatPeriod(start: Date, end: Date): string {
private formatPeriod(start: Date, _end: Date): string {
return `${start.toISOString().slice(0, 7)}`;
}

View File

@ -3,8 +3,9 @@ import { ConfigService } from '@nestjs/config';
import {
PaymentProviderPort,
PaymentProviderType,
PaymentSessionRequest,
CreatePaymentParams,
PaymentSession,
PaymentResult,
WebhookResult,
} from '../../../domain/ports/payment-provider.port';
import { InvoiceCurrency } from '../../../domain/entities/invoice.entity';
@ -27,17 +28,18 @@ export class AlipayProvider implements PaymentProviderPort {
return this.configured;
}
async createPaymentSession(req: PaymentSessionRequest): Promise<PaymentSession> {
async createPaymentSession(params: CreatePaymentParams): Promise<PaymentSession> {
if (!this.configured) throw new Error('Alipay not configured');
// For CNY: use amount directly; for USD: this should not happen but handle gracefully
if (req.currency !== InvoiceCurrency.CNY) {
if (params.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({
const AlipaySdkModule = await import('alipay-sdk');
const AlipaySdk = AlipaySdkModule.default ?? AlipaySdkModule;
const client = new (AlipaySdk as any)({
appId: this.appId,
privateKey: this.privateKey,
signType: 'RSA2',
@ -45,29 +47,31 @@ export class AlipayProvider implements PaymentProviderPort {
});
// alipay amount is in yuan (CNY fen / 100)
const amountYuan = (req.amountCents / 100).toFixed(2);
const outTradeNo = `it0-${req.invoiceId}-${Date.now()}`;
const amountYuan = (params.amount / 100).toFixed(2);
const outTradeNo = `it0-${params.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,
subject: params.description,
},
});
return {
sessionId: outTradeNo,
providerPaymentId: outTradeNo,
qrCodeUrl: result.qrCode,
redirectUrl: undefined,
clientSecret: undefined,
expiresAt: new Date(Date.now() + 2 * 60 * 60 * 1000), // 2 hours
};
}
async handleWebhook(rawBody: Buffer, headers: Record<string, string>): Promise<WebhookResult> {
async confirmPayment(providerPaymentId: string): Promise<PaymentResult> {
// Alipay uses async webhook notification; sync confirm not needed
return { providerPaymentId, status: 'pending', amount: 0, currency: 'CNY' };
}
async handleWebhook(rawBody: Buffer, _headers: Record<string, string>): Promise<WebhookResult> {
if (!this.configured) throw new Error('Alipay not configured');
// Parse URL-encoded body
@ -76,8 +80,9 @@ export class AlipayProvider implements PaymentProviderPort {
params.forEach((v, k) => { notifyData[k] = v; });
// Verify Alipay signature using alipay-sdk
const AlipaySdk = (await import('alipay-sdk')).default;
const client = new AlipaySdk({
const AlipaySdkModule = await import('alipay-sdk');
const AlipaySdk = AlipaySdkModule.default ?? AlipaySdkModule;
const client = new (AlipaySdk as any)({
appId: this.appId,
privateKey: this.privateKey,
signType: 'RSA2',

View File

@ -4,8 +4,9 @@ import * as crypto from 'crypto';
import {
PaymentProviderPort,
PaymentProviderType,
PaymentSessionRequest,
CreatePaymentParams,
PaymentSession,
PaymentResult,
WebhookResult,
} from '../../../domain/ports/payment-provider.port';
@ -29,11 +30,11 @@ export class CryptoProvider implements PaymentProviderPort {
return this.configured;
}
async createPaymentSession(req: PaymentSessionRequest): Promise<PaymentSession> {
async createPaymentSession(params: CreatePaymentParams): Promise<PaymentSession> {
if (!this.configured) throw new Error('Coinbase Commerce not configured');
// Convert cents to dollar amount
const amount = (req.amountCents / 100).toFixed(2);
const amount = (params.amount / 100).toFixed(2);
const response = await fetch(`${CryptoProvider.BASE_URL}/charges`, {
method: 'POST',
@ -44,14 +45,14 @@ export class CryptoProvider implements PaymentProviderPort {
},
body: JSON.stringify({
name: 'IT0 Platform',
description: req.description,
description: params.description,
local_price: { amount, currency: 'USD' },
pricing_type: 'fixed_price',
metadata: {
invoiceId: req.invoiceId,
tenantId: req.tenantId,
invoiceId: params.invoiceId,
tenantId: params.tenantId,
},
redirect_url: req.returnUrl,
redirect_url: params.returnUrl,
}),
});
@ -64,15 +65,17 @@ export class CryptoProvider implements PaymentProviderPort {
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 confirmPayment(providerPaymentId: string): Promise<PaymentResult> {
// Coinbase Commerce uses async webhook notification
return { providerPaymentId, status: 'pending', amount: 0, currency: 'USD' };
}
async handleWebhook(rawBody: Buffer, headers: Record<string, string>): Promise<WebhookResult> {
if (!this.configured) throw new Error('Coinbase Commerce not configured');

View File

@ -4,8 +4,9 @@ import Stripe from 'stripe';
import {
PaymentProviderPort,
PaymentProviderType,
PaymentSessionRequest,
CreatePaymentParams,
PaymentSession,
PaymentResult,
WebhookResult,
} from '../../../domain/ports/payment-provider.port';
@ -19,7 +20,7 @@ export class StripeProvider implements PaymentProviderPort {
const secretKey = this.configService.get<string>('STRIPE_SECRET_KEY');
this.webhookSecret = this.configService.get<string>('STRIPE_WEBHOOK_SECRET', '');
if (secretKey) {
this.client = new Stripe(secretKey, { apiVersion: '2024-11-20.acacia' });
this.client = new Stripe(secretKey, { apiVersion: '2023-10-16' });
}
}
@ -27,29 +28,37 @@ export class StripeProvider implements PaymentProviderPort {
return !!this.client;
}
async createPaymentSession(req: PaymentSessionRequest): Promise<PaymentSession> {
async createPaymentSession(params: CreatePaymentParams): Promise<PaymentSession> {
if (!this.client) throw new Error('Stripe not configured');
const intent = await this.client.paymentIntents.create({
amount: req.amountCents,
currency: req.currency.toLowerCase(),
amount: params.amount,
currency: params.currency.toLowerCase(),
metadata: {
invoiceId: req.invoiceId,
tenantId: req.tenantId,
invoiceId: params.invoiceId,
tenantId: params.tenantId,
},
description: req.description,
description: params.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 confirmPayment(providerPaymentId: string): Promise<PaymentResult> {
if (!this.client) throw new Error('Stripe not configured');
const intent = await this.client.paymentIntents.retrieve(providerPaymentId);
return {
providerPaymentId: intent.id,
status: intent.status === 'succeeded' ? 'succeeded' : intent.status === 'canceled' ? 'failed' : 'pending',
amount: intent.amount,
currency: intent.currency.toUpperCase(),
};
}
async handleWebhook(rawBody: Buffer, headers: Record<string, string>): Promise<WebhookResult> {
if (!this.client) throw new Error('Stripe not configured');

View File

@ -4,8 +4,9 @@ import * as crypto from 'crypto';
import {
PaymentProviderPort,
PaymentProviderType,
PaymentSessionRequest,
CreatePaymentParams,
PaymentSession,
PaymentResult,
WebhookResult,
} from '../../../domain/ports/payment-provider.port';
import { InvoiceCurrency } from '../../../domain/entities/invoice.entity';
@ -33,14 +34,14 @@ export class WeChatPayProvider implements PaymentProviderPort {
return this.configured;
}
async createPaymentSession(req: PaymentSessionRequest): Promise<PaymentSession> {
async createPaymentSession(params: CreatePaymentParams): Promise<PaymentSession> {
if (!this.configured) throw new Error('WeChat Pay not configured');
if (req.currency !== InvoiceCurrency.CNY) {
if (params.currency !== InvoiceCurrency.CNY) {
throw new Error('WeChat Pay only supports CNY payments');
}
const outTradeNo = `it0-${req.invoiceId}-${Date.now()}`;
const outTradeNo = `it0-${params.invoiceId}-${Date.now()}`;
const notifyUrl = this.configService.get<string>(
'WECHAT_NOTIFY_URL',
'https://it0api.szaiai.com/api/v1/billing/webhooks/wechat',
@ -49,25 +50,27 @@ export class WeChatPayProvider implements PaymentProviderPort {
const body = {
appid: this.appId,
mchid: this.mchId,
description: req.description,
description: params.description,
out_trade_no: outTradeNo,
notify_url: notifyUrl,
amount: { total: req.amountCents, currency: 'CNY' },
amount: { total: params.amount, 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 confirmPayment(providerPaymentId: string): Promise<PaymentResult> {
// WeChat Pay uses async webhook notification; sync confirm not needed
return { providerPaymentId, status: 'pending', amount: 0, currency: 'CNY' };
}
async handleWebhook(rawBody: Buffer, headers: Record<string, string>): Promise<WebhookResult> {
if (!this.configured) throw new Error('WeChat Pay not configured');

View File

@ -11,7 +11,7 @@ export class PlanRepository {
}
async findAll(): Promise<Plan[]> {
return this.repo.find({ where: { isActive: true }, order: { monthlyPriceUsdCents: 'ASC' } });
return this.repo.find({ where: { isActive: true }, order: { monthlyPriceCentsUsd: 'ASC' } });
}
async findById(id: string): Promise<Plan | null> {

View File

@ -22,7 +22,7 @@ export class UsageAggregateRepository {
async findHistoryByTenantId(tenantId: string, limit = 12): Promise<UsageAggregate[]> {
return this.repo.find({
where: { tenantId },
order: { year: 'DESC', month: 'DESC' },
order: { year: 'DESC', month: 'DESC' } as any,
take: limit,
});
}
@ -43,9 +43,9 @@ export class UsageAggregateRepository {
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)
total_cost_usd, task_count, updated_at)
VALUES
(gen_random_uuid(), $1, $2, $3, $4, $5, $6, $7, $8, $9, 1, NOW(), NOW())
(gen_random_uuid(), $1, $2, $3, $4, $5, $6, $7, $8, $9, 1, NOW())
ON CONFLICT (tenant_id, year, month)
DO UPDATE SET
total_input_tokens = billing_usage_aggregates.total_input_tokens + EXCLUDED.total_input_tokens,

View File

@ -12,10 +12,10 @@ export class PlanController {
id: p.id,
name: p.name,
displayName: p.displayName,
monthlyPriceUsd: p.monthlyPriceUsdCents / 100,
monthlyPriceCny: p.monthlyPriceCny / 100,
includedTokensPerMonth: p.includedTokensPerMonth,
overageRateUsdPerMToken: p.overageRateCentsPerMToken / 100,
monthlyPriceUsd: p.monthlyPriceCentsUsd / 100,
monthlyPriceCny: p.monthlyPriceFenCny / 100,
includedTokensPerMonth: p.includedTokens,
overageRateUsdPerMToken: p.overageRateCentsPerMTokenUsd / 100,
maxServers: p.maxServers,
maxUsers: p.maxUsers,
maxStandingOrders: p.maxStandingOrders,

View File

@ -1,7 +1,6 @@
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';
@ -13,13 +12,13 @@ export class WebhookController {
@Post('stripe')
@HttpCode(HttpStatus.OK)
async stripeWebhook(@Req() req: Request, @Headers() headers: Record<string, string>) {
async stripeWebhook(@Req() req: any, @Headers() headers: Record<string, string>) {
return this.processWebhook(PaymentProviderType.STRIPE, req, headers);
}
@Post('alipay')
@HttpCode(HttpStatus.OK)
async alipayWebhook(@Req() req: Request, @Headers() headers: Record<string, string>) {
async alipayWebhook(@Req() req: any, @Headers() headers: Record<string, string>) {
await this.processWebhook(PaymentProviderType.ALIPAY, req, headers);
// Alipay expects plain text "success" response
return 'success';
@ -27,23 +26,23 @@ export class WebhookController {
@Post('wechat')
@HttpCode(HttpStatus.OK)
async wechatWebhook(@Req() req: Request, @Headers() headers: Record<string, string>) {
async wechatWebhook(@Req() req: any, @Headers() headers: Record<string, string>) {
await this.processWebhook(PaymentProviderType.WECHAT_PAY, req, headers);
return { code: 'SUCCESS', message: '成功' };
}
@Post('crypto')
@HttpCode(HttpStatus.OK)
async cryptoWebhook(@Req() req: Request, @Headers() headers: Record<string, string>) {
async cryptoWebhook(@Req() req: any, @Headers() headers: Record<string, string>) {
return this.processWebhook(PaymentProviderType.CRYPTO, req, headers);
}
private async processWebhook(
provider: PaymentProviderType,
req: Request,
req: any,
headers: Record<string, string>,
) {
const rawBody = (req as any).rawBody as Buffer;
const rawBody = req.rawBody as Buffer;
if (!rawBody) {
throw new BadRequestException('Raw body not available — ensure rawBody middleware is enabled');
}

View File

@ -305,6 +305,9 @@ importers:
coinbase-commerce-node:
specifier: ^1.0.4
version: 1.0.4
ioredis:
specifier: ^5.3.0
version: 5.9.2
pg:
specifier: ^8.11.0
version: 8.18.0