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:
parent
c7f3807148
commit
40ee84a0b7
|
|
@ -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:*"
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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' })
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)}`;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue