release: prepare RealeseV2.3
This commit is contained in:
@@ -24,6 +24,13 @@ export const DEFAULT_RECEIPT_TIMEOUT_SCAN_INTERVAL_MS = 5 * 60_000;
|
||||
|
||||
export const RECEIPT_TIMEOUT_INITIAL_DELAY_MS = 60_000;
|
||||
|
||||
export function longMessageReceiptMode(config: unknown): 'per_segment' | 'message_level' {
|
||||
if (!config || typeof config !== 'object' || Array.isArray(config)) return 'per_segment';
|
||||
return (config as Record<string, unknown>).longMessageReceiptMode === 'message_level'
|
||||
? 'message_level'
|
||||
: 'per_segment';
|
||||
}
|
||||
|
||||
export const DEFAULT_SCHEDULED_DISPATCH_SCAN_INTERVAL_MS = 5_000;
|
||||
|
||||
export const DEFAULT_SCHEDULED_DISPATCH_STALE_MS = 2 * 60_000;
|
||||
|
||||
@@ -198,6 +198,9 @@ function createPrismaMock() {
|
||||
findFirst: jest.fn().mockResolvedValue(null),
|
||||
findMany: jest.fn(),
|
||||
},
|
||||
smsReceiptAnomaly: {
|
||||
upsert: jest.fn().mockResolvedValue({ id: 'receipt-anomaly-1', status: 'pending' }),
|
||||
},
|
||||
smsUplinkMessage: {
|
||||
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'uplink-1', ...data })),
|
||||
findMany: jest.fn(),
|
||||
@@ -2654,6 +2657,125 @@ describe('SendChainService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('treats one delivered receipt as the whole long-message success only for a message-level receipt channel', async () => {
|
||||
const { service, prisma } = createService();
|
||||
prisma.smsChannel.findUnique.mockResolvedValue({
|
||||
id: 'channel-1',
|
||||
config: { longMessageReceiptMode: 'message_level' },
|
||||
});
|
||||
prisma.smsMessageRecord.findUnique.mockResolvedValue({
|
||||
id: 'record-message-level',
|
||||
tenantId: 'tenant-1',
|
||||
batchTaskId: 'task-1',
|
||||
applicationId: 'app-1',
|
||||
messageId: 'MSG-MESSAGE-LEVEL',
|
||||
submitId: 'SUB-MESSAGE-LEVEL',
|
||||
phoneNumber: '13127620092',
|
||||
channelId: 'channel-1',
|
||||
gatewayMessageId: 'GW-MESSAGE-LEVEL-1',
|
||||
status: 'submitted',
|
||||
billingUnits: 2,
|
||||
});
|
||||
prisma.smsSubmitRecord.findFirst.mockResolvedValue({
|
||||
id: 'submit-message-level',
|
||||
submitId: 'SUB-MESSAGE-LEVEL',
|
||||
channelId: 'channel-1',
|
||||
gatewayMessageId: 'GW-MESSAGE-LEVEL-1',
|
||||
});
|
||||
prisma.smsMessageSegmentAudit.findMany
|
||||
.mockResolvedValueOnce([
|
||||
{ id: 'segment-1', receiptStatus: 'delivered' },
|
||||
{ id: 'segment-2', receiptStatus: null },
|
||||
])
|
||||
.mockResolvedValueOnce([
|
||||
{ segmentIndex: 1, segmentTotal: 2, receiptStatus: 'delivered', rawStatus: 'DELIVRD', deliveredAt: new Date() },
|
||||
{ segmentIndex: 2, segmentTotal: 2, receiptStatus: 'delivered', rawStatus: 'DELIVRD', compensationType: 'supplier_message_level_receipt', deliveredAt: new Date() },
|
||||
]);
|
||||
|
||||
await service.handleReceipt({
|
||||
messageId: 'MSG-MESSAGE-LEVEL',
|
||||
channelId: 'channel-1',
|
||||
gatewayMessageId: 'GW-MESSAGE-LEVEL-1',
|
||||
phoneNumber: '13127620092',
|
||||
receiptStatus: 'delivered',
|
||||
rawStatus: 'DELIVRD',
|
||||
});
|
||||
|
||||
expect(prisma.smsMessageSegmentAudit.updateMany).toHaveBeenCalledWith(expect.objectContaining({
|
||||
where: expect.objectContaining({
|
||||
messageRecordId: 'record-message-level',
|
||||
submitRecordId: 'submit-message-level',
|
||||
receiptStatus: null,
|
||||
}),
|
||||
data: expect.objectContaining({
|
||||
receiptStatus: 'delivered',
|
||||
compensationType: 'supplier_message_level_receipt',
|
||||
}),
|
||||
}));
|
||||
expect(prisma.smsMessageRecord.update).toHaveBeenCalledWith(expect.objectContaining({
|
||||
where: { id: 'record-message-level' },
|
||||
data: expect.objectContaining({ status: 'delivered', receiptStatus: 'delivered' }),
|
||||
}));
|
||||
});
|
||||
|
||||
it('records a receipt anomaly when a message-level success is followed by a failure for the same attempt', async () => {
|
||||
const { service, prisma, billing } = createService();
|
||||
prisma.smsChannel.findUnique.mockResolvedValue({
|
||||
id: 'channel-1',
|
||||
config: { longMessageReceiptMode: 'message_level' },
|
||||
});
|
||||
prisma.smsMessageRecord.findUnique.mockResolvedValue({
|
||||
id: 'record-conflict',
|
||||
tenantId: 'tenant-1',
|
||||
batchTaskId: 'task-1',
|
||||
applicationId: 'app-1',
|
||||
messageId: 'MSG-CONFLICT',
|
||||
submitId: 'SUB-CONFLICT',
|
||||
phoneNumber: '13127620092',
|
||||
channelId: 'channel-1',
|
||||
gatewayMessageId: 'GW-CONFLICT-1',
|
||||
status: 'delivered',
|
||||
billingUnits: 2,
|
||||
});
|
||||
prisma.smsMessageSegmentAudit.findFirst.mockResolvedValue({
|
||||
id: 'segment-conflict-2',
|
||||
messageRecordId: 'record-conflict',
|
||||
submitRecordId: 'submit-conflict',
|
||||
submitId: 'SUB-CONFLICT',
|
||||
channelId: 'channel-1',
|
||||
gatewayMessageId: 'GW-CONFLICT-2',
|
||||
segmentIndex: 2,
|
||||
segmentTotal: 2,
|
||||
});
|
||||
prisma.smsMessageSegmentAudit.findMany.mockResolvedValue([
|
||||
{ segmentIndex: 1, segmentTotal: 2, receiptStatus: 'delivered', rawStatus: 'DELIVRD', deliveredAt: new Date() },
|
||||
{ segmentIndex: 2, segmentTotal: 2, receiptStatus: 'undelivered', rawStatus: 'UNDELIV', deliveredAt: new Date() },
|
||||
]);
|
||||
|
||||
await service.handleReceipt({
|
||||
messageId: 'MSG-CONFLICT',
|
||||
channelId: 'channel-1',
|
||||
gatewayMessageId: 'GW-CONFLICT-2',
|
||||
phoneNumber: '13127620092',
|
||||
receiptStatus: 'undelivered',
|
||||
rawStatus: 'UNDELIV',
|
||||
errorCode: 'SP_CONFLICT',
|
||||
});
|
||||
|
||||
expect(prisma.smsReceiptAnomaly.upsert).toHaveBeenCalledWith(expect.objectContaining({
|
||||
where: { anomalyKey: 'aggregate-receipt-conflict:record-conflict:SUB-CONFLICT' },
|
||||
create: expect.objectContaining({
|
||||
anomalyType: 'aggregate_success_then_failure',
|
||||
previousStatus: 'delivered',
|
||||
incomingStatus: 'undelivered',
|
||||
}),
|
||||
}));
|
||||
expect(prisma.smsMessageRecord.update).not.toHaveBeenCalledWith(expect.objectContaining({
|
||||
data: expect.objectContaining({ status: 'failed' }),
|
||||
}));
|
||||
expect(billing.refund).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('creates and sends only one downstream receipt for the same fragment dedupe key', async () => {
|
||||
const { service, prisma } = createService();
|
||||
let claimedDelivery: Record<string, unknown> | null = null;
|
||||
|
||||
@@ -6,7 +6,7 @@ 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 { 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, longMessageReceiptMode } from './send-chain.helpers';
|
||||
import type { SendSubmissionService } from './send-submission.service';
|
||||
import type { SendCompletionCallbacks, SendCompletionFacade } from './send-completion.service';
|
||||
import { queueFinalReceiptDeliveries } from './downstream-receipt-targets';
|
||||
@@ -230,8 +230,9 @@ export class SendReceiptService {
|
||||
},
|
||||
});
|
||||
}
|
||||
let receiptRecordId: string | undefined;
|
||||
try {
|
||||
await this.prisma.smsReceiptRecord.create({
|
||||
const createdReceipt = await this.prisma.smsReceiptRecord.create({
|
||||
data: {
|
||||
tenantId: message.tenantId,
|
||||
batchTaskId: message.batchTaskId,
|
||||
@@ -249,6 +250,7 @@ export class SendReceiptService {
|
||||
deliveredAt,
|
||||
},
|
||||
});
|
||||
receiptRecordId = createdReceipt.id;
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002') {
|
||||
const duplicate = await this.prisma.smsReceiptRecord.findUnique({
|
||||
@@ -261,6 +263,18 @@ export class SendReceiptService {
|
||||
}
|
||||
const logicalReceipt = { ...data, channelId: logicalChannelId };
|
||||
await this.facade.recordReceiptSegment(message, logicalReceipt, deliveredAt, resolved.submitRecordId);
|
||||
const receiptMode = Number(message.billingUnits ?? 1) > 1
|
||||
? await this.getLongMessageReceiptMode(logicalChannelId)
|
||||
: 'per_segment';
|
||||
if (receiptMode === 'message_level' && data.receiptStatus === 'delivered') {
|
||||
await this.applyMessageLevelSuccess(
|
||||
message,
|
||||
logicalReceipt,
|
||||
deliveredAt,
|
||||
resolved.submitRecordId,
|
||||
resolved.submitId,
|
||||
);
|
||||
}
|
||||
const aggregate = await this.facade.aggregateReceiptSegments(
|
||||
message,
|
||||
logicalReceipt,
|
||||
@@ -279,7 +293,22 @@ export class SendReceiptService {
|
||||
|| message.gatewayMessageId === data.gatewayMessageId
|
||||
|| (aggregate.segmentTotal > 1 && (!message.submitId || message.submitId === resolved.submitId))
|
||||
);
|
||||
if (!isCurrentAttempt || (status === 'failed' && message.status === 'delivered')) {
|
||||
if (!isCurrentAttempt) {
|
||||
return this.prisma.smsMessageRecord.findUnique({ where: { id: message.id } });
|
||||
}
|
||||
if (status === 'failed' && message.status === 'delivered') {
|
||||
if (receiptMode === 'message_level') {
|
||||
// A delivered result may already have been exposed to the customer and settled.
|
||||
// Preserve that terminal decision; the contradictory late receipt is evidence for operations, not a second state transition.
|
||||
await this.recordReceiptConflict({
|
||||
message,
|
||||
submitRecordId: resolved.submitRecordId,
|
||||
submitId: resolved.submitId,
|
||||
receiptRecordId,
|
||||
receiptKey,
|
||||
data: logicalReceipt,
|
||||
});
|
||||
}
|
||||
return this.prisma.smsMessageRecord.findUnique({ where: { id: message.id } });
|
||||
}
|
||||
const isStandaloneChannelTest = !message.tenantId && !message.batchTaskId;
|
||||
@@ -343,6 +372,108 @@ export class SendReceiptService {
|
||||
return this.prisma.smsMessageRecord.findUnique({ where: { id: message.id } });
|
||||
}
|
||||
|
||||
private async getLongMessageReceiptMode(channelId?: string | null) {
|
||||
if (!channelId) return 'per_segment' as const;
|
||||
const channel = await this.prisma.smsChannel.findUnique({
|
||||
where: { id: channelId },
|
||||
select: { config: true },
|
||||
});
|
||||
return longMessageReceiptMode(channel?.config);
|
||||
}
|
||||
|
||||
private async applyMessageLevelSuccess(
|
||||
message: { id: string; channelId?: string | null; submitId?: string | null },
|
||||
data: GatewayReceiptEventDto,
|
||||
deliveredAt: Date,
|
||||
submitRecordId?: string,
|
||||
submitId?: string,
|
||||
) {
|
||||
const belongsToCurrentAttempt = (!message.channelId || message.channelId === data.channelId)
|
||||
&& (!message.submitId || message.submitId === submitId);
|
||||
if (!belongsToCurrentAttempt) return;
|
||||
const attemptWhere = submitRecordId
|
||||
? { messageRecordId: message.id, submitRecordId }
|
||||
: submitId
|
||||
? { messageRecordId: message.id, submitId }
|
||||
: null;
|
||||
if (!attemptWhere) return;
|
||||
const segments = await this.prisma.smsMessageSegmentAudit.findMany({
|
||||
where: attemptWhere,
|
||||
select: { id: true, receiptStatus: true },
|
||||
});
|
||||
if (segments.length <= 1) return;
|
||||
if (segments.some((segment) => segment.receiptStatus && !['delivered', 'unknown'].includes(segment.receiptStatus))) {
|
||||
return;
|
||||
}
|
||||
// This supplier contract reports one message-level success for a multipart SMS.
|
||||
// Mark only missing segments as inferred so the raw receipt remains singular and auditable.
|
||||
await this.prisma.smsMessageSegmentAudit.updateMany({
|
||||
where: { ...attemptWhere, receiptStatus: null },
|
||||
data: {
|
||||
receiptStatus: 'delivered',
|
||||
rawStatus: data.rawStatus,
|
||||
errorCode: data.errorCode ?? null,
|
||||
errorMessage: data.errorMessage ?? null,
|
||||
compensationType: 'supplier_message_level_receipt',
|
||||
deliveredAt,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private async recordReceiptConflict(input: {
|
||||
message: { id: string; tenantId?: string | null; applicationId?: string | null; status: string; messageId: string };
|
||||
submitRecordId?: string;
|
||||
submitId?: string;
|
||||
receiptRecordId?: string;
|
||||
receiptKey: string;
|
||||
data: GatewayReceiptEventDto;
|
||||
}) {
|
||||
// One logical conflict per message attempt keeps repeated supplier packets auditable
|
||||
// without creating an unbounded queue of operationally identical anomalies.
|
||||
const anomalyKey = `aggregate-receipt-conflict:${input.message.id}:${input.submitId ?? input.submitRecordId ?? 'unknown'}`;
|
||||
const occurredAt = new Date();
|
||||
const detail = {
|
||||
messageId: input.message.messageId,
|
||||
submitId: input.submitId,
|
||||
receiptKey: input.receiptKey,
|
||||
gatewayMessageId: input.data.gatewayMessageId,
|
||||
phoneNumber: input.data.phoneNumber,
|
||||
reason: 'message_level_success_followed_by_failure',
|
||||
};
|
||||
await this.prisma.smsReceiptAnomaly.upsert({
|
||||
where: { anomalyKey },
|
||||
update: {
|
||||
status: 'pending',
|
||||
receiptRecordId: input.receiptRecordId,
|
||||
incomingStatus: input.data.receiptStatus,
|
||||
rawStatus: input.data.rawStatus,
|
||||
errorCode: input.data.errorCode ?? null,
|
||||
detail,
|
||||
occurrenceCount: { increment: 1 },
|
||||
lastOccurredAt: occurredAt,
|
||||
resolvedAt: null,
|
||||
resolutionNote: null,
|
||||
},
|
||||
create: {
|
||||
anomalyKey,
|
||||
tenantId: input.message.tenantId,
|
||||
applicationId: input.message.applicationId,
|
||||
channelId: input.data.channelId,
|
||||
messageRecordId: input.message.id,
|
||||
submitRecordId: input.submitRecordId,
|
||||
receiptRecordId: input.receiptRecordId,
|
||||
anomalyType: 'aggregate_success_then_failure',
|
||||
previousStatus: input.message.status,
|
||||
incomingStatus: input.data.receiptStatus,
|
||||
rawStatus: input.data.rawStatus,
|
||||
errorCode: input.data.errorCode ?? null,
|
||||
detail,
|
||||
firstOccurredAt: occurredAt,
|
||||
lastOccurredAt: occurredAt,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async recordReceiptSegment(
|
||||
message: {
|
||||
id: string;
|
||||
|
||||
Reference in New Issue
Block a user