Summary
Two categories of accounts will accumulate in the database indefinitely without scheduled cleanup: accounts that have passed their expiresAt without being claimed, and accounts stuck in INITIALIZING status due to transient failures during creation. This issue implements both cleanup jobs, the expiry scheduler that calls StellarService.expireAccount() for unclaimed accounts, and a simpler cleanup job that marks stale INITIALIZING accounts as FAILED.
Background: Why both jobs are needed
Expiry scheduler
When an account's expiresAt passes without a claim, the account should transition to EXPIRED status and the on-chain contract should be called via StellarService.expireAccount() to direct the recovery of funds back to the recovery_address. Without this scheduler, accounts stay in PENDING_PAYMENT or PENDING_CLAIM forever after their window closes. The contract's expire() function can only be called after the expiry_ledger is reached, so the scheduler must also verify current ledger state before attempting the call, which expireAccount() already handles internally.
INITIALIZING cleanup
Issue #85 introduced INITIALIZING as a transitional status between DB record creation and successful contract initialization. In the normal case this window is a few seconds. But if the Soroban RPC times out, if the network is unavailable, or if the process crashes mid-creation, the account will be stuck in INITIALIZING indefinitely. These accounts will never become usable and should be marked FAILED after a reasonable timeout, 10 minutes is a sensible default for MVP.
How the expiry scheduler should work
The scheduler runs on a fixed interval, every 5 minutes is reasonable for MVP. On each tick it queries for accounts where status is PENDING_PAYMENT or PENDING_CLAIM and expiresAt is in the past. For each such account it calls StellarService.expireAccount(), which internally checks the current ledger before submitting the contract call. On success it updates account status to EXPIRED and sets expiredAt to the current timestamp (resolving Issue #84). Failures on individual accounts should be logged and not abort the rest of the batch.
How the INITIALIZING cleanup should work
The cleanup job can run less frequently, every 15 minutes is fine. It queries for accounts where status is INITIALIZING and createdAt is older than the configured timeout (default 10 minutes, configurable via INITIALIZING_TIMEOUT_MS). For each such account it sets status to FAILED and stores a reason in metadata, something like { failureReason: 'initialization_timeout', detectedAt: }. No contract call is needed since the contract was never successfully initialized for these accounts.
Shared infrastructure
Both jobs can live in the same module, src/modules/scheduler/ is a natural home. They both need access to the Account repository. The expiry job additionally needs StellarService. Both should use @nestjs/schedule consistent with Issue #98's payment monitor.
Failure handling
Neither job should crash the application on error. Individual account failures should be logged with the account ID and error, and the job should continue with the next account. If the entire job tick fails (e.g. database unavailable), log the error and wait for the next scheduled run.
Environment configuration
Add to .env.example:
EXPIRY_CHECK_INTERVAL_MS=300000 # 5 minutes
INITIALIZING_TIMEOUT_MS=600000 # 10 minutes
INITIALIZING_CLEANUP_INTERVAL_MS=900000 # 15 minutes
Where to look
Acceptance Criteria
Summary
Two categories of accounts will accumulate in the database indefinitely without scheduled cleanup: accounts that have passed their expiresAt without being claimed, and accounts stuck in INITIALIZING status due to transient failures during creation. This issue implements both cleanup jobs, the expiry scheduler that calls StellarService.expireAccount() for unclaimed accounts, and a simpler cleanup job that marks stale INITIALIZING accounts as FAILED.
Background: Why both jobs are needed
Expiry scheduler
When an account's expiresAt passes without a claim, the account should transition to EXPIRED status and the on-chain contract should be called via StellarService.expireAccount() to direct the recovery of funds back to the recovery_address. Without this scheduler, accounts stay in PENDING_PAYMENT or PENDING_CLAIM forever after their window closes. The contract's expire() function can only be called after the expiry_ledger is reached, so the scheduler must also verify current ledger state before attempting the call, which expireAccount() already handles internally.
INITIALIZING cleanup
Issue #85 introduced INITIALIZING as a transitional status between DB record creation and successful contract initialization. In the normal case this window is a few seconds. But if the Soroban RPC times out, if the network is unavailable, or if the process crashes mid-creation, the account will be stuck in INITIALIZING indefinitely. These accounts will never become usable and should be marked FAILED after a reasonable timeout, 10 minutes is a sensible default for MVP.
How the expiry scheduler should work
The scheduler runs on a fixed interval, every 5 minutes is reasonable for MVP. On each tick it queries for accounts where status is PENDING_PAYMENT or PENDING_CLAIM and expiresAt is in the past. For each such account it calls StellarService.expireAccount(), which internally checks the current ledger before submitting the contract call. On success it updates account status to EXPIRED and sets expiredAt to the current timestamp (resolving Issue #84). Failures on individual accounts should be logged and not abort the rest of the batch.
How the INITIALIZING cleanup should work
The cleanup job can run less frequently, every 15 minutes is fine. It queries for accounts where status is INITIALIZING and createdAt is older than the configured timeout (default 10 minutes, configurable via INITIALIZING_TIMEOUT_MS). For each such account it sets status to FAILED and stores a reason in metadata, something like { failureReason: 'initialization_timeout', detectedAt: }. No contract call is needed since the contract was never successfully initialized for these accounts.
Shared infrastructure
Both jobs can live in the same module, src/modules/scheduler/ is a natural home. They both need access to the Account repository. The expiry job additionally needs StellarService. Both should use @nestjs/schedule consistent with Issue #98's payment monitor.
Failure handling
Neither job should crash the application on error. Individual account failures should be logged with the account ID and error, and the job should continue with the next account. If the entire job tick fails (e.g. database unavailable), log the error and wait for the next scheduled run.
Environment configuration
Add to .env.example:
EXPIRY_CHECK_INTERVAL_MS=300000 # 5 minutes
INITIALIZING_TIMEOUT_MS=600000 # 10 minutes
INITIALIZING_CLEANUP_INTERVAL_MS=900000 # 15 minutes
Where to look
Acceptance Criteria