Created
July 29, 2026 09:24
-
-
Save Shahinyanm/ca5ec81e71edf4cf09eca0aa933eb76a to your computer and use it in GitHub Desktop.
Nest code example
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| //infrastructure/cron/batch-dispatcher.service.ts | |
| import { Inject, Injectable, Logger } from '@nestjs/common'; | |
| import { Cron } from '@nestjs/schedule'; | |
| import * as Sentry from '@sentry/nestjs'; | |
| import { | |
| DepositRepositoryPort, | |
| DEPOSIT_REPOSITORY_PORT, | |
| } from '../../application/ports/output/deposit-repository.port'; | |
| import { | |
| TonWalletPort, | |
| TON_WALLET_PORT, | |
| BatchOrder, | |
| } from '../../application/ports/output/ton-wallet.port'; | |
| import { CachePort, CACHE_PORT } from '../../application/ports/output/cache.port'; | |
| import { AnalyticsPort, ANALYTICS_PORT } from '../../application/ports/output/analytics.port'; | |
| import { SettingsPort, SETTINGS_PORT } from '../../application/ports/output/settings.port'; | |
| import { MetricsService } from '../../../shared/metrics/metrics.service'; | |
| import { DepositCompletionService } from '../services/deposit-completion.service'; | |
| import { DepositStatus, FragmentDeposit } from '../../domain/types'; | |
| import { DepositLogEvent } from '../../domain/deposit-log-event.enum'; | |
| import { BatchStatus } from '../../domain/batch-status'; | |
| import { | |
| BATCH_DISPATCH_CRON, | |
| BATCH_MAX_SIZE, | |
| } from '../../domain/constants'; | |
| import { DRIZZLE, DrizzleDB } from '../../../shared/database/drizzle.provider'; | |
| import { depositBatches } from '../persistence/schema'; | |
| import { eq } from 'drizzle-orm'; | |
| const BATCH_LOCK_KEY = 'cron:batch-dispatcher:lock'; | |
| const BATCH_LOCK_TTL_MS = 30_000; | |
| @Injectable() | |
| export class BatchDispatcherService { | |
| private readonly logger = new Logger(BatchDispatcherService.name); | |
| private isRunning = false; | |
| constructor( | |
| @Inject(DEPOSIT_REPOSITORY_PORT) private readonly deposits: DepositRepositoryPort, | |
| @Inject(TON_WALLET_PORT) private readonly tonWallet: TonWalletPort, | |
| @Inject(CACHE_PORT) private readonly cache: CachePort, | |
| @Inject(ANALYTICS_PORT) private readonly analytics: AnalyticsPort, | |
| @Inject(SETTINGS_PORT) private readonly settings: SettingsPort, | |
| @Inject(DRIZZLE) private readonly db: DrizzleDB, | |
| private readonly metrics: MetricsService, | |
| private readonly completionService: DepositCompletionService, | |
| ) { } | |
| @Cron(BATCH_DISPATCH_CRON) | |
| async handleCron(): Promise<void> { | |
| if (this.isRunning) return; | |
| if (await this.settings.isServiceDisabled()) return; | |
| const lockToken = await this.cache.acquireLock(BATCH_LOCK_KEY, BATCH_LOCK_TTL_MS); | |
| if (!lockToken) return; | |
| this.isRunning = true; | |
| try { | |
| await this.dispatchBatch(); | |
| } catch (error) { | |
| this.logger.error(`Batch dispatch cycle failed: ${error}`); | |
| } finally { | |
| this.isRunning = false; | |
| await this.cache.releaseLock(BATCH_LOCK_KEY, lockToken); | |
| } | |
| } | |
| private async dispatchBatch(): Promise<void> { | |
| const queuedDeposits = await this.deposits.findByStatus( | |
| DepositStatus.QUEUED, | |
| BATCH_MAX_SIZE, | |
| ); | |
| if (queuedDeposits.length === 0) return; | |
| const validDeposits = await this.rejectIncompleteOrders(queuedDeposits); | |
| if (validDeposits.length === 0) return; | |
| const batchQueryId = await this.tonWallet.generateQueryId(); | |
| const totalTon = validDeposits.reduce((sum, d) => sum + parseFloat(d.tonAmount!), 0); | |
| this.logger.log( | |
| `Dispatching batch: ${validDeposits.length} deposits, total ${totalTon.toFixed(4)} TON, queryId: ${batchQueryId}`, | |
| ); | |
| const [batchRecord] = await this.db | |
| .insert(depositBatches) | |
| .values({ | |
| queryId: batchQueryId, | |
| status: BatchStatus.PENDING, | |
| depositCount: validDeposits.length, | |
| totalTon: totalTon.toFixed(9), | |
| }) | |
| .returning(); | |
| const batchId = batchRecord!.id; | |
| await this.deposits.claimForBatch( | |
| validDeposits.map((d) => d.id), | |
| batchId, | |
| batchQueryId, | |
| ); | |
| const orders: BatchOrder[] = validDeposits.map((d) => ({ | |
| depositId: d.id, | |
| destinationAddress: d.destinationAddress!, | |
| tonAmount: d.tonAmount!, | |
| payload: d.fragmentPayload!, | |
| })); | |
| const result = await this.tonWallet.sendBatchPurchases(orders, batchQueryId); | |
| if (!result.success) { | |
| await this.handleBatchFailure(validDeposits, batchId, result.error); | |
| return; | |
| } | |
| const sentAt = new Date(); | |
| await this.markDepositsSent(validDeposits, batchId, batchQueryId, sentAt); | |
| await this.db | |
| .update(depositBatches) | |
| .set({ status: BatchStatus.SENT, sentAt }) | |
| .where(eq(depositBatches.id, batchId)) | |
| .catch((err) => this.logger.warn(`Failed to mark batch ${batchId} SENT: ${err}`)); | |
| this.metrics.batchTotal.inc({ status: 'sent' }); | |
| this.metrics.batchSize.observe(validDeposits.length); | |
| this.logger.log( | |
| `Batch ${batchId} SENT: ${validDeposits.length} deposits, queryId: ${batchQueryId}`, | |
| ); | |
| } | |
| private async rejectIncompleteOrders(candidates: FragmentDeposit[]): Promise<FragmentDeposit[]> { | |
| const valid: FragmentDeposit[] = []; | |
| for (const deposit of candidates) { | |
| if (deposit.destinationAddress && deposit.tonAmount && deposit.fragmentPayload) { | |
| valid.push(deposit); | |
| continue; | |
| } | |
| this.logger.error(`QUEUED deposit ${deposit.id} missing required data, marking FAILED`); | |
| await this.completionService | |
| .failAndRefund( | |
| deposit, | |
| 'QUEUED deposit missing required order data (destinationAddress/tonAmount/fragmentPayload)', | |
| ) | |
| .catch((err) => this.logger.error(`Failed to fail invalid deposit ${deposit.id}: ${err}`)); | |
| } | |
| return valid; | |
| } | |
| private async markDepositsSent( | |
| batch: FragmentDeposit[], | |
| batchId: string, | |
| queryId: number, | |
| paidAt: Date, | |
| ): Promise<void> { | |
| for (const deposit of batch) { | |
| try { | |
| const transitioned = await this.deposits.transitionStatus( | |
| deposit.id, | |
| DepositStatus.SENT, | |
| DepositStatus.QUEUED, | |
| { batchId, queryId, paidAt }, | |
| ); | |
| if (!transitioned) { | |
| this.logger.warn(`Deposit ${deposit.id} was no longer QUEUED, skipping SENT transition`); | |
| continue; | |
| } | |
| await this.analytics.logDepositEvent(DepositLogEvent.SENT, { | |
| ...deposit, | |
| status: DepositStatus.SENT, | |
| queryId, | |
| tonAmount: deposit.tonAmount!, | |
| }); | |
| } catch (error) { | |
| this.logger.error(`Failed to transition deposit ${deposit.id} to SENT: ${error}`); | |
| } | |
| } | |
| } | |
| private async handleBatchFailure( | |
| batch: FragmentDeposit[], | |
| batchId: string, | |
| error: string, | |
| ): Promise<void> { | |
| this.logger.error(`Batch ${batchId} FAILED: ${error}, rolling back ${batch.length} deposits`); | |
| this.metrics.batchTotal.inc({ status: 'failed' }); | |
| Sentry.captureMessage( | |
| `Batch send failed: ${batch.length} deposits, error: ${error}`, | |
| { | |
| level: 'error', | |
| tags: { alert_type: 'batch_send_failed' }, | |
| extra: { batchId, depositCount: batch.length, error }, | |
| }, | |
| ); | |
| await this.db | |
| .update(depositBatches) | |
| .set({ status: BatchStatus.FAILED }) | |
| .where(eq(depositBatches.id, batchId)) | |
| .catch((err) => this.logger.warn(`Failed to mark batch ${batchId} FAILED: ${err}`)); | |
| const reason = `Batch send failed: ${error}`; | |
| for (const deposit of batch) { | |
| try { | |
| await this.completionService.failAndRefund(deposit, reason); | |
| } catch (refundErr) { | |
| this.logger.error(`Failed to fail+refund deposit ${deposit.id}: ${refundErr}`); | |
| } | |
| } | |
| } | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| //infrastructure/services/deposit-completion.service.ts | |
| import { Inject, Injectable, Logger } from '@nestjs/common'; | |
| import * as Sentry from '@sentry/nestjs'; | |
| import { MetricsService } from '../../../shared/metrics/metrics.service'; | |
| import { | |
| DepositRepositoryPort, | |
| DEPOSIT_REPOSITORY_PORT, | |
| } from '../../application/ports/output/deposit-repository.port'; | |
| import { | |
| TonWalletPort, | |
| TON_WALLET_PORT, | |
| } from '../../application/ports/output/ton-wallet.port'; | |
| import { AnalyticsPort, ANALYTICS_PORT } from '../../application/ports/output/analytics.port'; | |
| import { EventBusPort, EVENT_BUS_PORT } from '../../application/ports/output/event-bus.port'; | |
| import { | |
| TENANT_REPOSITORY_PORT, | |
| TenantRepositoryPort, | |
| } from '../../../tenant/application/ports/output/tenant-repository.port'; | |
| import { DepositStatus, FragmentDeposit } from '../../domain/types'; | |
| import { DepositLogEvent } from '../../domain/deposit-log-event.enum'; | |
| import { | |
| VALID_COMPLETION_SOURCES, | |
| VALID_FAILURE_SOURCES, | |
| } from '../../domain/deposit-status-transitions'; | |
| import { | |
| resolveCommissionTier, | |
| getEffectiveCommissionPercent, | |
| } from '../../../tenant/domain/commission-tier'; | |
| @Injectable() | |
| export class DepositCompletionService { | |
| private readonly logger = new Logger(DepositCompletionService.name); | |
| constructor( | |
| @Inject(DEPOSIT_REPOSITORY_PORT) private readonly deposits: DepositRepositoryPort, | |
| @Inject(TON_WALLET_PORT) private readonly tonWallet: TonWalletPort, | |
| @Inject(ANALYTICS_PORT) private readonly analytics: AnalyticsPort, | |
| @Inject(EVENT_BUS_PORT) private readonly eventBus: EventBusPort, | |
| @Inject(TENANT_REPOSITORY_PORT) private readonly tenantRepo: TenantRepositoryPort, | |
| private readonly metrics: MetricsService, | |
| ) { } | |
| async markDepositCompleted( | |
| deposit: FragmentDeposit, | |
| txHash: string, | |
| tonAmount?: string, | |
| ): Promise<void> { | |
| const current = await this.deposits.findByIdGlobal(deposit.id); | |
| if (!current) { | |
| this.logger.warn(`Deposit ${deposit.id} not found, skipping COMPLETED transition`); | |
| return; | |
| } | |
| const wasRolledBack = current.status === DepositStatus.FAILED; | |
| const allowedSources = wasRolledBack | |
| ? [DepositStatus.FAILED] | |
| : VALID_COMPLETION_SOURCES.filter((status) => status !== DepositStatus.FAILED); | |
| const claimed = await this.deposits.transitionStatus( | |
| deposit.id, | |
| DepositStatus.COMPLETED, | |
| allowedSources, | |
| { | |
| txHash, | |
| finishedAt: new Date(), | |
| ...(tonAmount !== undefined ? { tonAmount } : {}), | |
| }, | |
| ); | |
| if (!claimed) { | |
| this.logger.warn( | |
| `Deposit ${deposit.id} cannot transition to COMPLETED (read as ${current.status}), skipping`, | |
| ); | |
| return; | |
| } | |
| if (wasRolledBack) { | |
| this.logger.warn( | |
| `Deposit ${deposit.id} went FAILED→COMPLETED, reversing the earlier refund`, | |
| ); | |
| await this.reverseRefund(current); | |
| } | |
| const completed: FragmentDeposit = { | |
| ...current, | |
| status: DepositStatus.COMPLETED, | |
| txHash, | |
| }; | |
| await this.analytics.logDepositEvent(DepositLogEvent.COMPLETED, completed, { txHash }); | |
| this.metrics.incDeposit(current.tenantId, current.productType ?? 'stars', 'completed'); | |
| await this.sendWebhookForDeposit(current.tenantId, 'deposit.completed', completed); | |
| await this.recordCommission(current); | |
| this.logger.log(`Deposit ${deposit.id} COMPLETED (txHash: ${txHash})`); | |
| await this.tonWallet.checkAndAlertLowBalance(); | |
| } | |
| async failAndRefund(deposit: FragmentDeposit, reason: string): Promise<void> { | |
| const current = await this.deposits.findByIdGlobal(deposit.id); | |
| if (!current) { | |
| this.logger.warn(`Deposit ${deposit.id} not found, skipping FAILED transition`); | |
| return; | |
| } | |
| if (current.txHash) { | |
| this.logger.warn( | |
| `Deposit ${deposit.id} has txHash ${current.txHash} — TON was sent, completing instead of failing`, | |
| ); | |
| await this.markDepositCompleted(current, current.txHash, current.tonAmount ?? undefined); | |
| return; | |
| } | |
| const claimed = await this.deposits.transitionStatus( | |
| deposit.id, | |
| DepositStatus.FAILED, | |
| VALID_FAILURE_SOURCES, | |
| { errorMessage: reason, finishedAt: new Date() }, | |
| ); | |
| if (!claimed) { | |
| this.logger.warn( | |
| `Deposit ${deposit.id} cannot transition to FAILED (read as ${current.status}), skipping`, | |
| ); | |
| return; | |
| } | |
| const failed: FragmentDeposit = { | |
| ...current, | |
| status: DepositStatus.FAILED, | |
| errorMessage: reason, | |
| }; | |
| await this.analytics.logDepositEvent(DepositLogEvent.FAILED, failed); | |
| this.metrics.incDeposit(current.tenantId, current.productType ?? 'stars', 'failed'); | |
| await this.refundTenantBalance(current); | |
| await this.sendWebhookForDeposit(current.tenantId, 'deposit.failed', failed); | |
| this.logger.warn(`Deposit ${deposit.id} FAILED: ${reason}`); | |
| } | |
| private async recordCommission(deposit: FragmentDeposit): Promise<void> { | |
| try { | |
| if (!deposit.tonAmount) return; | |
| const tenant = await this.tenantRepo.findById(deposit.tenantId); | |
| if (!tenant) return; | |
| const tier = resolveCommissionTier(tenant.monthlyTransactionCount); | |
| const commissionPercent = getEffectiveCommissionPercent(tier, tenant.customCommissionPercent); | |
| if (commissionPercent > 0) { | |
| const tonAmount = parseFloat(deposit.tonAmount); | |
| const commissionTon = tonAmount * commissionPercent / 100; | |
| await this.tenantRepo.createTransaction({ | |
| tenantId: deposit.tenantId, | |
| type: 'deposit_commission', | |
| depositId: deposit.id, | |
| amountTon: deposit.tonAmount, | |
| commissionTon: commissionTon.toFixed(9), | |
| commissionPercent: commissionPercent.toString(), | |
| }); | |
| } | |
| await this.tenantRepo.incrementTransactionCount(deposit.tenantId); | |
| } catch (error) { | |
| this.logger.warn(`Failed to record commission for deposit ${deposit.id}: ${error}`); | |
| } | |
| } | |
| private async refundTenantBalance(deposit: FragmentDeposit): Promise<void> { | |
| try { | |
| const refundAmount = await this.resolveDebitedAmount(deposit); | |
| if (refundAmount === null) return; | |
| const newBalance = await this.tenantRepo.creditBalance(deposit.tenantId, refundAmount); | |
| this.logger.log( | |
| `Refunded ${refundAmount} TON to tenant ${deposit.tenantId} for deposit ${deposit.id} (new balance: ${newBalance})`, | |
| ); | |
| } catch (error) { | |
| this.logger.error( | |
| `Failed to refund tenant ${deposit.tenantId} for deposit ${deposit.id}: ${error}`, | |
| ); | |
| } | |
| } | |
| private async reverseRefund(deposit: FragmentDeposit): Promise<void> { | |
| try { | |
| const debitAmount = await this.resolveDebitedAmount(deposit); | |
| if (debitAmount === null) return; | |
| const debited = await this.tenantRepo.debitBalance(deposit.tenantId, debitAmount); | |
| if (!debited) { | |
| this.logger.error( | |
| `Insufficient balance to reverse refund for deposit ${deposit.id} (need ${debitAmount} TON). REQUIRES MANUAL INTERVENTION.`, | |
| ); | |
| return; | |
| } | |
| this.logger.log( | |
| `Reversed refund: debited ${debitAmount} TON from tenant ${deposit.tenantId} for deposit ${deposit.id}`, | |
| ); | |
| } catch (error) { | |
| this.logger.error( | |
| `Failed to reverse refund for tenant ${deposit.tenantId}, deposit ${deposit.id}: ${error}. REQUIRES MANUAL INTERVENTION.`, | |
| ); | |
| Sentry.captureMessage( | |
| `Failed to reverse refund for reconciled deposit ${deposit.id}`, | |
| { | |
| level: 'fatal', | |
| tags: { alert_type: 'reverse_refund_failed', requires_manual_intervention: 'true' }, | |
| extra: { depositId: deposit.id, tenantId: deposit.tenantId, tonAmount: deposit.tonAmount }, | |
| }, | |
| ); | |
| } | |
| } | |
| private async resolveDebitedAmount(deposit: FragmentDeposit): Promise<string | null> { | |
| if (deposit.debitAmountTon) return deposit.debitAmountTon; | |
| if (!deposit.tonAmount) { | |
| this.logger.warn( | |
| `Deposit ${deposit.id} has neither debitAmountTon nor tonAmount, cannot settle balance`, | |
| ); | |
| return null; | |
| } | |
| const tenant = await this.tenantRepo.findById(deposit.tenantId); | |
| if (!tenant) { | |
| this.logger.warn( | |
| `Tenant ${deposit.tenantId} not found, cannot settle balance for deposit ${deposit.id}`, | |
| ); | |
| return null; | |
| } | |
| const baseTon = parseFloat(deposit.tonAmount); | |
| const tier = resolveCommissionTier(tenant.monthlyTransactionCount); | |
| const commissionPercent = getEffectiveCommissionPercent(tier, tenant.customCommissionPercent); | |
| return (baseTon + baseTon * commissionPercent / 100).toFixed(9); | |
| } | |
| private async sendWebhookForDeposit( | |
| tenantId: string, | |
| event: string, | |
| deposit: FragmentDeposit, | |
| ): Promise<void> { | |
| try { | |
| const tenant = await this.tenantRepo.findById(tenantId); | |
| if (!tenant?.webhookUrl) return; | |
| await this.eventBus.sendWebhook( | |
| tenant.webhookUrl, | |
| tenant.webhookSecret, | |
| event, | |
| deposit, | |
| ); | |
| } catch (error) { | |
| this.logger.warn( | |
| `Failed to send ${event} webhook for deposit ${deposit.id}: ${error}`, | |
| ); | |
| } | |
| } | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| //infrastructure/cron/deposit-confirmation.service.ts | |
| import { Inject, Injectable, Logger } from '@nestjs/common'; | |
| import { Cron } from '@nestjs/schedule'; | |
| import * as Sentry from '@sentry/nestjs'; | |
| import { | |
| DepositRepositoryPort, | |
| DEPOSIT_REPOSITORY_PORT, | |
| } from '../../application/ports/output/deposit-repository.port'; | |
| import { | |
| TonWalletPort, | |
| TON_WALLET_PORT, | |
| } from '../../application/ports/output/ton-wallet.port'; | |
| import { CachePort, CACHE_PORT } from '../../application/ports/output/cache.port'; | |
| import { DepositCompletionService } from '../services/deposit-completion.service'; | |
| import { DepositStatus, FragmentDeposit } from '../../domain/types'; | |
| import { BatchStatus } from '../../domain/batch-status'; | |
| import { | |
| CONFIRMATION_CHECK_CRON, | |
| SENT_TIMEOUT_MINUTES, | |
| SENT_HARD_TIMEOUT_MINUTES, | |
| } from '../../domain/constants'; | |
| import { DRIZZLE, DrizzleDB } from '../../../shared/database/drizzle.provider'; | |
| import { depositBatches } from '../persistence/schema'; | |
| import { eq } from 'drizzle-orm'; | |
| const CONFIRMATION_LOCK_KEY = 'cron:deposit-confirmation:lock'; | |
| const CONFIRMATION_LOCK_TTL_MS = 25_000; | |
| const CONFIRMATION_BATCH_SIZE = 50; | |
| @Injectable() | |
| export class DepositConfirmationService { | |
| private readonly logger = new Logger(DepositConfirmationService.name); | |
| private isRunning = false; | |
| constructor( | |
| @Inject(DEPOSIT_REPOSITORY_PORT) private readonly deposits: DepositRepositoryPort, | |
| @Inject(TON_WALLET_PORT) private readonly tonWallet: TonWalletPort, | |
| @Inject(CACHE_PORT) private readonly cache: CachePort, | |
| @Inject(DRIZZLE) private readonly db: DrizzleDB, | |
| private readonly completionService: DepositCompletionService, | |
| ) { } | |
| @Cron(CONFIRMATION_CHECK_CRON) | |
| async handleCron(): Promise<void> { | |
| if (this.isRunning) return; | |
| const lockToken = await this.cache.acquireLock( | |
| CONFIRMATION_LOCK_KEY, | |
| CONFIRMATION_LOCK_TTL_MS, | |
| ); | |
| if (!lockToken) return; | |
| this.isRunning = true; | |
| try { | |
| await this.checkConfirmations(); | |
| } catch (error) { | |
| this.logger.error(`Confirmation check cycle failed: ${error}`); | |
| } finally { | |
| this.isRunning = false; | |
| await this.cache.releaseLock(CONFIRMATION_LOCK_KEY, lockToken); | |
| } | |
| } | |
| private async checkConfirmations(): Promise<void> { | |
| const sentDeposits = await this.deposits.findByStatus( | |
| DepositStatus.SENT, | |
| CONFIRMATION_BATCH_SIZE, | |
| ); | |
| if (sentDeposits.length === 0) return; | |
| this.logger.log(`Checking ${sentDeposits.length} SENT deposits for confirmation...`); | |
| const byQueryId = new Map<number, FragmentDeposit[]>(); | |
| for (const deposit of sentDeposits) { | |
| if (deposit.queryId == null) { | |
| this.logger.warn(`Deposit ${deposit.id} has no queryId, cannot check confirmation`); | |
| continue; | |
| } | |
| const group = byQueryId.get(deposit.queryId); | |
| if (group) group.push(deposit); | |
| else byQueryId.set(deposit.queryId, [deposit]); | |
| } | |
| for (const [queryId, group] of byQueryId) { | |
| try { | |
| await this.checkDepositGroup(queryId, group); | |
| } catch (error) { | |
| this.logger.warn(`Failed to check queryId ${queryId} (${group.length} deposits): ${error}`); | |
| } | |
| } | |
| } | |
| private async checkDepositGroup(queryId: number, group: FragmentDeposit[]): Promise<void> { | |
| const representative = group[0]!; | |
| const result = await this.tonWallet.checkConfirmation( | |
| queryId, | |
| representative.destinationAddress ?? undefined, | |
| representative.tonAmount ?? undefined, | |
| ); | |
| if (result.confirmed) { | |
| await this.completeGroup(group, result.txHash ?? ''); | |
| return; | |
| } | |
| const oldest = group.reduce((a, b) => | |
| a.updatedAt.getTime() < b.updatedAt.getTime() ? a : b, | |
| ); | |
| const elapsedMs = Date.now() - oldest.updatedAt.getTime(); | |
| if (elapsedMs <= SENT_TIMEOUT_MINUTES * 60_000) return; | |
| if (elapsedMs > SENT_HARD_TIMEOUT_MINUTES * 60_000) { | |
| this.alertHardTimeout(queryId, group, result.isProcessed, elapsedMs); | |
| return; | |
| } | |
| if (result.isProcessed === false) { | |
| this.logger.warn( | |
| `queryId ${queryId} timed out (${SENT_TIMEOUT_MINUTES}min), isProcessed=false — rolling back ${group.length} deposits`, | |
| ); | |
| const reason = `Confirmation timeout (${SENT_TIMEOUT_MINUTES}min, isProcessed=false)`; | |
| for (const deposit of group) { | |
| try { | |
| await this.completionService.failAndRefund(deposit, reason); | |
| } catch (error) { | |
| this.logger.warn(`Failed to roll back deposit ${deposit.id}: ${error}`); | |
| } | |
| } | |
| return; | |
| } | |
| this.logger.warn( | |
| `queryId ${queryId} timed out but isProcessed=${result.isProcessed} — NOT rolling back ${group.length} deposits (will retry)`, | |
| ); | |
| } | |
| private async completeGroup(group: FragmentDeposit[], txHash: string): Promise<void> { | |
| let confirmedCount = 0; | |
| for (const deposit of group) { | |
| try { | |
| await this.completionService.markDepositCompleted( | |
| deposit, | |
| txHash, | |
| deposit.tonAmount ?? undefined, | |
| ); | |
| confirmedCount++; | |
| } catch (error) { | |
| this.logger.warn(`Failed to complete deposit ${deposit.id}: ${error}`); | |
| } | |
| } | |
| const batchId = group[0]?.batchId; | |
| if (confirmedCount !== group.length || !batchId) return; | |
| await this.db | |
| .update(depositBatches) | |
| .set({ status: BatchStatus.CONFIRMED, confirmedAt: new Date(), txHash: txHash || null }) | |
| .where(eq(depositBatches.id, batchId)) | |
| .catch((err) => this.logger.warn(`Failed to mark batch ${batchId} CONFIRMED: ${err}`)); | |
| } | |
| private alertHardTimeout( | |
| queryId: number, | |
| group: FragmentDeposit[], | |
| isProcessed: boolean | undefined, | |
| elapsedMs: number, | |
| ): void { | |
| const depositIds = group.map((d) => d.id); | |
| this.logger.error( | |
| `queryId ${queryId} exceeded hard timeout (${SENT_HARD_TIMEOUT_MINUTES}min), ` + | |
| `isProcessed=${isProcessed}, ${group.length} deposits`, | |
| ); | |
| Sentry.captureMessage( | |
| `Fragment batch queryId=${queryId} stuck for >${SENT_HARD_TIMEOUT_MINUTES}min (isProcessed=${isProcessed})`, | |
| { | |
| level: 'fatal', | |
| tags: { | |
| alert_type: 'deposit_hard_timeout', | |
| requires_manual_intervention: 'true', | |
| }, | |
| extra: { | |
| queryId, | |
| depositIds, | |
| isProcessed, | |
| elapsedMinutes: Math.round(elapsedMs / 60_000), | |
| }, | |
| }, | |
| ); | |
| } | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| //domain/deposit-status-transitions.ts | |
| import { DepositStatus } from './types'; | |
| export const VALID_COMPLETION_SOURCES: DepositStatus[] = [ | |
| DepositStatus.SENT, | |
| DepositStatus.PROCESSING, | |
| DepositStatus.FAILED, | |
| ]; | |
| export const VALID_FAILURE_SOURCES: DepositStatus[] = [ | |
| DepositStatus.SENT, | |
| DepositStatus.PROCESSING, | |
| DepositStatus.QUEUED, | |
| DepositStatus.PENDING, | |
| ]; | |
| export const VALID_SENT_SOURCES: DepositStatus[] = [ | |
| DepositStatus.QUEUED, | |
| ]; |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment