Skip to main content

Overview

Scheduled Cloud Functions run periodically to maintain data integrity, process automated transfers, cancel expired orders, and deactivate expired promotions.

Function: processScheduledTransfers

Processes automated account transfers between treasury accounts. Location: scheduler.js:11-115 Schedule: Daily at 12:05 AM (Colombia time)
exports.processScheduledTransfers = onSchedule({
    schedule: "5 0 * * *",
    timeZone: "America/Bogota"
}, async (event) => {
    // Implementation
});

How It Works

1

Query Pending Transfers

Find all transfers with status: 'PENDING' and scheduledDate <= now
2

Process Each Transfer

For each transfer, execute an atomic transaction:
  • Deduct from source account
  • Credit to target account
  • Mark transfer as COMPLETED
  • Create income/expense records
3

Handle Failures

Mark failed transfers as FAILED with error message

Implementation

From scheduler.js:17-115:
const snapshot = await db.collection('scheduled_transfers')
    .where('status', '==', 'PENDING')
    .where('scheduledDate', '<=', admin.firestore.Timestamp.now())
    .get();

const promises = snapshot.docs.map(async (docSnap) => {
    const transfer = docSnap.data();
    
    try {
        await db.runTransaction(async (t) => {
            // Read accounts
            const sourceRef = db.collection('accounts').doc(transfer.sourceAccountId);
            const targetRef = db.collection('accounts').doc(transfer.targetAccountId);
            const sourceDoc = await t.get(sourceRef);
            const targetDoc = await t.get(targetRef);
            
            if (!sourceDoc.exists || !targetDoc.exists) {
                throw new Error("Account not found");
            }
            
            // Move money
            const amount = Number(transfer.amount);
            const newSourceBalance = (sourceDoc.data().balance || 0) - amount;
            const newTargetBalance = (targetDoc.data().balance || 0) + amount;
            
            // Update accounts
            t.update(sourceRef, { balance: newSourceBalance });
            t.update(targetRef, { balance: newTargetBalance });
            
            // Mark transfer as completed
            t.update(db.collection('scheduled_transfers').doc(transferId), {
                status: 'COMPLETED',
                executedAt: admin.firestore.FieldValue.serverTimestamp()
            });
            
            // Create expense records
            const outRef = db.collection('expenses').doc();
            t.set(outRef, {
                description: transfer.description,
                amount: amount,
                category: "Transferencia Saliente (Auto)",
                paymentMethod: sourceDoc.data().name,
                date: admin.firestore.FieldValue.serverTimestamp()
            });
            
            const inRef = db.collection('expenses').doc();
            t.set(inRef, {
                description: transfer.description,
                amount: amount,
                category: "Transferencia Entrante (Auto)",
                paymentMethod: targetDoc.data().name,
                date: admin.firestore.FieldValue.serverTimestamp()
            });
        });
    } catch (err) {
        // Mark as failed
        await db.collection('scheduled_transfers').doc(transferId).update({
            status: 'FAILED',
            error: err.message
        });
    }
});

await Promise.all(promises);

Transfer Document Schema

{
    sourceAccountId: 'account-id-1',
    targetAccountId: 'account-id-2',
    amount: 500000,
    description: 'Transferencia ADDI a Bancolombia',
    scheduledDate: Timestamp,
    status: 'PENDING',  // or COMPLETED, FAILED
    executedAt: Timestamp,
    error: 'Optional error message'
}

Function: cleanupOldOrders

Deletes old abandoned orders to reduce database size. Location: scheduler.js:117-151 Schedule: Every 24 hours
exports.cleanupOldOrders = onSchedule("every 24 hours", async (event) => {
    const sevenDaysAgo = new Date();
    sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7);
    
    const snapshot = await db.collection('orders')
        .where('createdAt', '<', sevenDaysAgo)
        .where('status', 'in', ['PENDIENTE_PAGO', 'RECHAZADO', 'CANCELADO'])
        .get();
    
    if (snapshot.empty) {
        console.log('✅ No hay órdenes antiguas para borrar.');
        return;
    }
    
    const batch = db.batch();
    snapshot.docs.forEach(doc => batch.delete(doc.ref));
    await batch.commit();
    
    console.log(`🗑️ Se eliminaron ${snapshot.size} órdenes basura.`);
});

Cleanup Criteria

Age
condition
Orders older than 7 days
Status
array
Only deletes orders with status:
  • PENDIENTE_PAGO (never paid)
  • RECHAZADO (payment rejected)
  • CANCELADO (expired or admin cancelled)
Paid orders (PAGADO, DESPACHADO, ENTREGADO) are never deleted automatically.

Function: cancelAbandonedPayments

Cancels orders that remain unpaid after payment link expires. Location: scheduler.js:157-211 Schedule: Every 15 minutes
exports.cancelAbandonedPayments = onSchedule({
    schedule: "every 15 minutes",
    timeZone: "America/Bogota"
}, async (event) => {
    const timeout = new Date();
    timeout.setMinutes(timeout.getMinutes() - 35);
    
    const snapshot = await db.collection('orders')
        .where('status', '==', 'PENDIENTE_PAGO')
        .where('createdAt', '<=', admin.firestore.Timestamp.fromDate(timeout))
        .get();
    
    const batch = db.batch();
    snapshot.docs.forEach(doc => {
        if (doc.data().paymentStatus !== 'PAID') {
            batch.update(doc.ref, {
                status: 'CANCELADO',
                statusDetail: 'expired_by_system',
                updatedAt: admin.firestore.FieldValue.serverTimestamp(),
                notes: "[Sistema: Cancelado por inactividad de pago]"
            });
        }
    });
    
    await batch.commit();
});

Timeout Logic

1

Calculate Timeout

Current time minus 35 minutes (gives 5-minute grace period over MercadoPago’s 30-minute expiration)
2

Query Old Orders

Find orders with status === 'PENDIENTE_PAGO' created before timeout
3

Verify Not Paid

Double-check paymentStatus !== 'PAID' to avoid race condition with webhooks
4

Mark as Cancelled

Update status to CANCELADO with system note
This function does not restore inventory. For COD orders (which deduct stock immediately), manual intervention is required.

Function: checkExpiredPromotions

Deactivates product promotions that have passed their end date. Location: scheduler.js:217-264 Schedule: Every 60 minutes
exports.checkExpiredPromotions = onSchedule({
    schedule: "every 60 minutes",
    timeZone: "America/Bogota"
}, async (event) => {
    const now = admin.firestore.Timestamp.now();
    
    const snapshot = await db.collection('products')
        .where('promoEndsAt', '<=', now)
        .get();
    
    if (snapshot.empty) {
        console.log('✅ No hay promociones vencidas.');
        return;
    }
    
    const batch = db.batch();
    snapshot.docs.forEach(doc => {
        const p = doc.data();
        
        if (p.originalPrice && p.originalPrice > 0) {
            batch.update(doc.ref, {
                price: p.originalPrice,      // Restore original price
                originalPrice: 0,             // Clear promo price
                promoEndsAt: null             // Remove expiration
            });
        }
    });
    
    await batch.commit();
    console.log(`🏷️ Desactivadas ${snapshot.size} ofertas vencidas.`);
});

Promotion Schema

Products with active promotions have:
{
    price: 3500000,           // Current discounted price
    originalPrice: 4500000,   // Price before discount
    promoEndsAt: Timestamp    // When to restore originalPrice
}

Restoration Logic

  1. Query products where promoEndsAt <= now
  2. For each product with originalPrice > 0:
    • Set price = originalPrice
    • Set originalPrice = 0
    • Set promoEndsAt = null
  3. Batch commit all changes
The SmartProductSync system automatically detects these price changes via onSnapshot and updates the frontend catalog in real-time.

Scheduler Best Practices

Use Transactions

Always use Firestore transactions when moving money or updating related documents

Handle Failures Gracefully

Log errors and mark failed operations for manual review

Batch Operations

Use batch writes for multiple independent updates (max 500 per batch)

Set Reasonable Intervals

Balance between timely execution and cost (Firebase charges per invocation)

Monitoring Scheduled Functions

View Logs

firebase functions:log --only processScheduledTransfers

Check Execution History

In Firebase Console:
  1. Go to Functions tab
  2. Select function name
  3. View Logs and Health tabs

Set Up Alerts

Configure Cloud Monitoring alerts for:
  • Function execution failures
  • Abnormally long execution times
  • High error rates

Cron Expression Reference

ExpressionDescription
5 0 * * *Daily at 12:05 AM
0 */6 * * *Every 6 hours
every 15 minutesEvery 15 minutes
every 24 hoursOnce per day
0 9 * * 1Every Monday at 9 AM

Build docs developers (and LLMs) love