import { BadRequestException, HttpException, HttpStatus, Logger, NotFoundException } from '@nestjs/common'; import { Prisma } from '@prisma/client'; import { createHash } from 'node:crypto'; import { BillingService } from '../billing/billing.service'; import { moneyToNumber } from '../common/money'; import type { OpenApiService } from '../open-api/open-api.service'; import { PrismaService } from '../prisma/prisma.service'; import type { GatewaySubmitResultDto, GatewaySubmitSegmentResultDto, GatewayReceiptEventDto, GatewayUplinkEventDto, GatewayPendingDeliveryQueryDto, GatewayDownstreamSentDto, GatewayDownstreamAcknowledgedDto, GatewayDownstreamFailureType, GatewayControlDeliveryResult, GatewaySubmitDeadLetterDto, RequeueGatewaySubmitExceptionDto, GatewayDownstreamRecoveryStatusDto, TimeoutUnknownDto } from './send-chain.contracts'; import { positiveInteger, normalizeReceiptStatus, normalizeCarrier, DEFAULT_DOWNSTREAM_RETRY_DELAY_MS, DEFAULT_DOWNSTREAM_RETRY_MAX_DELAY_MS, DEFAULT_DOWNSTREAM_MAX_RETRIES, DEFAULT_DOWNSTREAM_PENDING_TIMEOUT_HOURS, DEFAULT_RECEIPT_TIMEOUT_HOURS, DEFAULT_GATEWAY_SUBMIT_REQUEUE_STALE_MS, DEFAULT_DOWNSTREAM_MANUAL_REQUEUE_STALE_MS, DEFAULT_UPSTREAM_RECEIPT_INBOX_PROCESSING_STALE_MS, DEFAULT_UPSTREAM_RECEIPT_INBOX_MAX_ATTEMPTS, DEFAULT_UPSTREAM_RECEIPT_INBOX_MAX_AGE_HOURS, gatewaySubmitRequeueKey, isObjectRecord, asDateOrNull, downstreamRetryDelayMs, downstreamAckTimeoutMs, downstreamRetryBaseDelayMs, downstreamRetryMaxDelayMs, downstreamMaxRetries, downstreamPendingTimeoutHours, downstreamControlFailureMessage, normalizeSubmitStatus, downstreamDeliveryAttemptKey, hasRecoveryAuditStateChanged, normalizeRecoveryFailureCategory, aggregateReceiptSegmentState, isSameUpstreamEndpointIdentity, receiptEventKey } from './send-chain.helpers'; import type { SendSubmissionService } from './send-submission.service'; import type { SendCompletionCallbacks, SendCompletionFacade } from './send-completion.service'; /** * R10 accounting implementation. * Cross-domain calls return through the stable SendChainService seam to preserve locking and test observability. */ export class SendAccountingService { private readonly logger = new Logger('SendChainService'); constructor( private readonly prisma: PrismaService, private readonly billing: BillingService, private readonly openApi: OpenApiService | undefined, private readonly facade: SendCompletionFacade, private readonly callbacks: SendCompletionCallbacks, ) {} async chargeAcceptedMessage(message: { tenantId: string; applicationId?: string | null; batchTaskId: string; messageId: string; phoneNumber: string; content: string; billingUnits: number; unitPrice: number | bigint; amountCents: number | bigint; }) { const amountCents = moneyToNumber(message.amountCents); const unitPrice = moneyToNumber(message.unitPrice); const billingUnits = message.billingUnits ?? 0; const exists = await this.prisma.smsBillingRecord.findFirst({ where: { messageId: message.messageId } }); if (exists?.billingStatus === 'charged') { return; } if (amountCents > 0) { await this.billing.release({ tenantId: message.tenantId, amountCents, idempotencyKey: `sms-charge-release:${message.messageId}`, relatedType: 'sms_batch_task', relatedId: message.batchTaskId, remark: `短信 ${message.messageId} 提交成功释放冻结并转扣费`, }); } const transaction = await this.billing.charge({ tenantId: message.tenantId, amountCents, idempotencyKey: `sms-charge:${message.messageId}`, relatedType: 'sms_message_record', relatedId: message.messageId, remark: '提交成功扣费', }); const data = { tenantId: message.tenantId, applicationId: message.applicationId ?? undefined, taskId: message.batchTaskId, messageId: message.messageId, phoneNumber: message.phoneNumber, contentLength: [...message.content].length, billingUnits, unitPrice, amountCents, billingStatus: 'charged', transactionId: transaction.id, }; if (exists) { await this.prisma.smsBillingRecord.update({ where: { id: exists.id }, data }); return; } await this.prisma.smsBillingRecord.create({ data }); } async releaseMessageReservation( message: { tenantId: string; batchTaskId: string; messageId: string; amountCents: number | bigint; billingUnits: number }, remark: string, ) { const amountCents = moneyToNumber(message.amountCents); if (amountCents <= 0) { return; } const charged = await this.prisma.smsBillingRecord.findFirst({ where: { messageId: message.messageId, billingStatus: 'charged' } }); if (charged) { return; } const released = await this.prisma.accountTransaction.findFirst({ where: { relatedType: 'sms_message_record', relatedId: message.messageId, transactionType: 'released' }, }); if (released) { return; } await this.billing.release({ tenantId: message.tenantId, amountCents, idempotencyKey: `sms-reservation-release:${message.messageId}`, relatedType: 'sms_message_record', relatedId: message.messageId, remark: `${remark}: ${message.messageId}`, }); } async refundMessage( message: { tenantId: string; messageId: string; amountCents: number | bigint; billingUnits: number }, remark: string, ) { const amountCents = moneyToNumber(message.amountCents); if (amountCents <= 0) { return; } const refunded = await this.prisma.smsBillingRecord.findFirst({ where: { messageId: message.messageId, billingStatus: 'refunded' } }); if (refunded) { return; } const charged = await this.prisma.smsBillingRecord.findFirst({ where: { messageId: message.messageId, billingStatus: 'charged' } }); if (!charged) { return; } const transaction = await this.billing.refund({ tenantId: message.tenantId, amountCents, idempotencyKey: `sms-refund:${message.messageId}`, relatedType: 'sms_message_record', relatedId: message.messageId, remark, }); await this.prisma.smsBillingRecord.updateMany({ where: { messageId: message.messageId }, data: { billingStatus: 'refunded', transactionId: transaction.id }, }); } }