Skip to main content

Overview

The Banca Management Backend runs four automated jobs that handle:
  1. Sorteos Automation - Auto-open, auto-create, and auto-close sorteos
  2. Account Statement Settlement - Automatic settlement of old account statements
  3. Monthly Closing - Calculate and save monthly balances for all entities
  4. Activity Log Cleanup - Remove old activity logs to prevent database bloat
All jobs are initialized on server startup and run on configurable schedules.

Job Initialization

Jobs are started in src/server/server.ts:
server.ts
import { startSorteosAutoJobs } from '../jobs/sorteosAuto.job'
import { startAccountStatementSettlementJob } from '../jobs/accountStatementSettlement.job'
import { startMonthlyClosingJob } from '../jobs/monthlyClosing.job'

server.listen(config.port, async () => {
  // Initialize Redis (optional)
  await initRedisClient()
  
  // Start automated jobs
  startSorteosAutoJobs()
  startAccountStatementSettlementJob()
  startMonthlyClosingJob()
  
  logger.info({
    layer: 'server',
    action: 'SERVER_LISTEN',
    payload: { port: config.port }
  })
})

Graceful Shutdown

All jobs are gracefully stopped on server shutdown:
const gracefulShutdown = async (signal: string) => {
  // Stop all jobs
  stopSorteosAutoJobs()
  stopAccountStatementSettlementJob()
  stopMonthlyClosingJob()
  
  // Close connections
  await closeRedisClient()
  await prisma.$disconnect()
  
  process.exit(0)
}

process.on('SIGTERM', () => gracefulShutdown('SIGTERM'))
process.on('SIGINT', () => gracefulShutdown('SIGINT'))

1. Sorteos Automation Jobs

Overview

Three automated tasks manage the sorteo lifecycle:
JobSchedulePurpose
Auto OpenDaily 7:00 AM UTC
(1:00 AM Costa Rica)
Opens SCHEDULED sorteos for the current day
Auto CreateDaily 7:30 AM UTC
(1:30 AM Costa Rica)
Creates sorteos 7 days ahead based on lottery rules
Auto CloseDaily 4:00 AM UTC
(10:00 PM Costa Rica)
Closes sorteos with zero sales

Configuration

Jobs check lottery configuration before executing:
// Auto-open only runs if lottery has autoOpenEnabled = true
// Auto-create only runs if lottery has autoCreateEnabled = true

Auto Open Job

Purpose: Opens sorteos that are scheduled for today Logic:
  1. Find all sorteos with status = SCHEDULED and scheduledAt is today (Costa Rica timezone)
  2. Update status to OPEN for each sorteo
  3. Log results
Example log output:
{
  "layer": "job",
  "action": "SORTEOS_AUTO_OPEN_COMPLETE",
  "payload": {
    "success": true,
    "openedCount": 5,
    "errorsCount": 0,
    "executedAt": "2025-01-25T07:00:00.000Z"
  }
}

Auto Create Job

Purpose: Pre-creates sorteos based on lottery rulesJson.drawSchedule Logic:
  1. Find all lotteries with autoCreateEnabled = true
  2. For each lottery, generate sorteos 7 days ahead using rulesJson.drawSchedule
  3. Create sorteos with status = SCHEDULED
  4. Skip duplicates (unique constraint: loteriaId + scheduledAt)
Example log output:
{
  "layer": "job",
  "action": "SORTEOS_AUTO_CREATE_COMPLETE",
  "payload": {
    "success": true,
    "createdCount": 42,
    "skippedCount": 8,
    "errorsCount": 0,
    "executedAt": "2025-01-25T07:30:00.000Z"
  }
}

Auto Close Job

Purpose: Automatically close sorteos with no sales Logic:
  1. Find all sorteos with status = OPEN that have passed their scheduledAt time
  2. Check if sorteo has zero ticket sales
  3. Update status to CLOSED if no sales
Example log output:
{
  "layer": "job",
  "action": "SORTEOS_AUTO_CLOSE_COMPLETE",
  "payload": {
    "success": true,
    "closedCount": 3,
    "errorsCount": 0,
    "executedAt": "2025-01-25T04:00:00.000Z"
  }
}

Manual Execution

For testing or manual triggers:
import { triggerAutoOpen, triggerAutoCreate, triggerAutoClose } from '../jobs/sorteosAuto.job'

// Trigger auto-open manually
await triggerAutoOpen()

// Trigger auto-create manually
await triggerAutoCreate()

// Trigger auto-close manually
await triggerAutoClose()

2. Account Statement Settlement Job

Overview

Schedule: Daily 3:00 AM UTC (configurable via database) Purpose: Automatically settle account statements older than 7 days (configurable)

Configuration

Managed via account_statement_settlement_config table:
FieldTypeDefaultDescription
enabledBooleanfalseEnable/disable automatic settlement
settlementAgeDaysInteger7Days before settlement
batchSizeInteger1000Max statements per run (capped at 2000)
cronScheduleString0 3 * * *Cron expression (minute hour * * *)

Settlement Criteria

A statement is settled if:
  • isSettled = false
  • date < (today - settlementAgeDays) in Costa Rica timezone
  • Has activity: ticketCount > 0
Important: Settlement does NOT require remainingBalance ≈ 0. Account statements always have balances (positive or negative).

Settlement Process

1

Connection Warmup

Warmup database connection to ensure readiness:
const isReady = await warmupConnection({ 
  useDirect: false, 
  context: 'settlement' 
})
2

Load Configuration

Read settlement configuration from database:
const config = await prisma.accountStatementSettlementConfig.findFirst()

if (!config.enabled && !userId) {
  // Skip if disabled and not manual execution
  return
}
3

Calculate Cutoff Date

Calculate cutoff date in Costa Rica timezone:
const todayCR = crDateService.dateUTCToCRString(new Date())
const cutoffDateCR = new Date(todayCR)
cutoffDateCR.setUTCDate(cutoffDateCR.getUTCDate() - config.settlementAgeDays)
4

Fetch Statements to Settle

Query statements older than cutoff:
const statementsToSettle = await prisma.accountStatement.findMany({
  where: {
    isSettled: false,
    date: { lt: cutoffDateCR }
  },
  orderBy: { date: 'asc' },
  take: safeBatchSize
})
5

Process Each Statement

Update statement as settled:
await AccountStatementRepository.update(statement.id, {
  isSettled: true,
  canEdit: false,
  totalPaid,
  totalCollected,
  settledAt: new Date(),
  settledBy: userId || null,
})
6

Carry Forward Balances

Create statements for entities without activity but with pending balance:
// Creates AccountStatement for today if:
// - Entity has remainingBalance > 0
// - No statement exists for today
// - Entity is active

Example Log Output

{
  "layer": "job",
  "action": "SETTLEMENT_COMPLETE",
  "payload": {
    "cutoffDateCR": "2025-01-18",
    "settlementAgeDays": 7,
    "totalProcessed": 30,
    "settledCount": 30,
    "skippedCount": 0,
    "errorCount": 0,
    "carryForward": {
      "createdCount": 15,
      "skippedCount": 235,
      "errorCount": 0
    }
  }
}

Manual Execution

import { executeSettlement } from '../jobs/accountStatementSettlement.job'

// Manual execution (bypasses enabled check)
await executeSettlement('user-uuid')

// Specific month
await executeSettlement('user-uuid')

3. Monthly Closing Job

Overview

Schedule: 1st of each month at 2:00 AM Costa Rica (8:00 AM UTC) Purpose: Calculate and save monthly closing balances for:
  • All vendedores (vendors)
  • All ventanas (windows)
  • All bancas (banks)

Process

The job processes the previous month (not current month):
1

Calculate Previous Month

const now = new Date()
const nowCRStr = crDateService.dateUTCToCRString(now)
const [currentYear, currentMonth] = nowCRStr.split('-').map(Number)

let previousMonth = currentMonth - 1
let previousYear = currentYear

if (previousMonth < 1) {
  previousMonth = 12
  previousYear--
}

const closingMonth = `${previousYear}-${String(previousMonth).padStart(2, '0')}`
// Example: "2024-12"
2

Process Vendedores

Calculate real balance from tickets and payments:
const balance = await calculateRealMonthBalance(
  closingMonth,
  'vendedor',
  vendedorId,
  ventanaId,
  bancaId
)

await saveMonthlyClosingBalance(
  closingMonth,
  'vendedor',
  balance,
  vendedorId,
  ventanaId,
  bancaId
)
3

Process Ventanas (Batched)

Process in batches of 100 to prevent memory issues:
const BATCH_SIZE = 100
let skip = 0
let hasMore = true

while (hasMore) {
  const ventanas = await prisma.ventana.findMany({
    where: { isActive: true },
    take: BATCH_SIZE,
    skip,
    orderBy: { id: 'asc' }
  })
  
  // Process batch...
  skip += BATCH_SIZE
  hasMore = ventanas.length === BATCH_SIZE
}
4

Process Bancas (Batched)

Same batched approach for bancas.

Balance Calculation

Calculates from source of truth (tickets + payments):
type MonthBalance = {
  totalSales: number
  totalPayouts: number
  totalCommissions: number
  netBalance: number
  totalPaid: number
  totalCollected: number
  remainingBalance: number
}

Example Log Output

{
  "layer": "job",
  "action": "MONTHLY_CLOSING_COMPLETE",
  "payload": {
    "closingMonth": "2024-12",
    "totalSuccess": 250,
    "totalErrors": 0,
    "vendedores": { "success": 200, "errors": 0 },
    "ventanas": { "success": 40, "errors": 0 },
    "bancas": { "success": 10, "errors": 0 },
    "executedBy": "SYSTEM"
  }
}

Manual Execution

import { executeMonthlyClosing } from '../jobs/monthlyClosing.job'

// Execute for previous month
await executeMonthlyClosing('user-uuid')

// Execute for specific month
await executeMonthlyClosing('user-uuid', '2024-11')

4. Activity Log Cleanup Job

Overview

Schedule: Daily 2:00 AM UTC Purpose: Delete activity logs older than 45 days to prevent database bloat Retention: 45 days

Process

activityLogCleanup.job.ts
const RETENTION_DAYS = 45

const result = await ActivityLogService.cleanupOldLogs(RETENTION_DAYS)

console.log(`Deleted ${result.deletedCount} activity log records`)

Example Log Output

[Activity Log Cleanup] Starting cleanup job at 2025-01-25T02:00:00.000Z
[Activity Log Cleanup] Deleting logs older than 45 days...
[Activity Log Cleanup] ✅ Cleanup completed successfully
[Activity Log Cleanup] Deleted 1234 activity log records

Manual Execution

import { triggerActivityLogCleanup } from '../jobs/activityLogCleanup.job'

await triggerActivityLogCleanup()

Job Monitoring

Health Checks

All jobs implement connection warmup before execution:
const isReady = await warmupConnection({ 
  useDirect: false, 
  context: 'jobName' 
})

if (!isReady) {
  logger.error({
    layer: 'job',
    action: 'JOB_SKIP',
    payload: { reason: 'Connection warmup failed after retries' }
  })
  return
}

Active Operations Tracking

Jobs register themselves for graceful shutdown:
const operationId = `settlement-${Date.now()}`

try {
  activeOperationsService.register(operationId, 'job', 'Settlement Job')
  
  // Execute job...
  
} finally {
  activeOperationsService.unregister(operationId)
}

Error Handling

Jobs classify errors for better diagnostics:
let errorType = "UNKNOWN_ERROR"
if (errorCode === "P1001") errorType = "DB_UNREACHABLE"
if (errorCode === "P2028") errorType = "POOLER_TIMEOUT"
if (errorMessage.includes("query_wait_timeout")) errorType = "POOLER_WAIT_TIMEOUT"

logger.error({
  layer: "job",
  action: "JOB_FAIL",
  payload: { errorType, errorCode, error: errorMessage }
})

Timezone Handling

Critical: All jobs use Costa Rica timezone (UTC-6) for business logic, but schedule times are in UTC.

Schedule Conversion Examples

JobCosta Rica TimeUTC Time
Auto Open1:00 AM CR7:00 AM UTC
Auto Create1:30 AM CR7:30 AM UTC
Auto Close10:00 PM CR4:00 AM UTC (next day)
Settlement9:00 PM CR3:00 AM UTC (next day)
Monthly Closing2:00 AM CR8:00 AM UTC
Log Cleanup8:00 PM CR2:00 AM UTC (next day)

Date Calculations

Jobs use crDateService for timezone-aware operations:
import { crDateService } from '../utils/crDateService'

// Convert UTC Date to CR date string
const todayCR = crDateService.dateUTCToCRString(new Date())
// "2025-01-25"

// Convert Postgres date to CR string
const crDate = crDateService.postgresDateToCRString(statement.date)

Troubleshooting

Job Not Running

Check logs for scheduling confirmation:
grep "JOB_SCHEDULED" logs/app.log
Example output:
{
  "layer": "job",
  "action": "SORTEOS_AUTO_OPEN_SCHEDULED",
  "payload": {
    "nextRun": "2025-01-26T07:00:00.000Z",
    "delayMinutes": 1320
  }
}

Connection Warmup Failures

Problem: Job skipped due to warmup failure Solution:
  1. Check database connectivity
  2. Verify DATABASE_URL is correct
  3. Check Supabase project status
  4. Review connection pool settings

Batch Size Warnings

Problem: Job hitting batch size limit Example log:
{
  "layer": "job",
  "action": "SETTLEMENT_MORE_RECORDS_AVAILABLE",
  "payload": {
    "batchSize": 1000,
    "totalPending": 1500,
    "message": "Processed 1000 records. 500 remaining for next execution."
  }
}
Solution: Increase batchSize in settlement configuration (max 2000)

Best Practices

1

Monitor Job Logs

Set up alerts for:
  • *_JOB_FAIL actions
  • *_SKIP actions (connection failures)
  • High error counts in completion logs
2

Configure Retention Appropriately

  • Activity logs: 45 days (configurable)
  • Account statements: 7 days before settlement (configurable)
  • Monthly closures: Previous month only
3

Test Manual Execution

Periodically test manual job execution:
# Via endpoint (if implemented)
curl -X POST https://api.example.com/admin/jobs/settlement/trigger

# Or via code
await executeSettlement('admin-uuid')
4

Review Batch Sizes

  • Settlement: Default 1000, max 2000
  • Monthly closing: Default 100 per entity type
  • Adjust based on database performance

Next Steps

Deployment

Learn about production deployment

Monitoring

Set up job monitoring and alerting

Build docs developers (and LLMs) love