Skip to content

Instantly share code, notes, and snippets.

@Shahinyanm
Created July 29, 2026 09:24
Show Gist options
  • Select an option

  • Save Shahinyanm/ca5ec81e71edf4cf09eca0aa933eb76a to your computer and use it in GitHub Desktop.

Select an option

Save Shahinyanm/ca5ec81e71edf4cf09eca0aa933eb76a to your computer and use it in GitHub Desktop.
Nest code example
//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}`);
}
}
}
}
//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}`,
);
}
}
}
//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),
},
},
);
}
}
//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