diff --git a/src/app.module.ts b/src/app.module.ts index 8183bea..6e0363d 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -21,6 +21,7 @@ import { IndexerModule } from './indexer/indexer.module'; import { LoanPaymentReminderModule } from './jobs/loan-payment-reminder/loan-payment-reminder.module'; import { TransactionStatusCheckerModule } from './jobs/transaction-status-checker/transaction-status-checker.module'; import { NonceCleanupModule } from './jobs/nonce-cleanup/nonce-cleanup.module'; +import { DefaultDetectionModule } from './jobs/default-detection/default-detection.module'; import { StellarModule } from './stellar/stellar.module'; import { LoggerModule } from './common/logger/logger.module'; import { MetricsModule } from './modules/metrics/metrics.module'; @@ -66,6 +67,7 @@ import { CorrelationIdMiddleware } from './common/logger/correlation-id.middlewa LoanPaymentReminderModule, TransactionStatusCheckerModule, NonceCleanupModule, + DefaultDetectionModule, CreditScoringModule, AdminModule, StellarModule, diff --git a/src/jobs/default-detection/default-detection.constants.ts b/src/jobs/default-detection/default-detection.constants.ts new file mode 100644 index 0000000..02a0943 --- /dev/null +++ b/src/jobs/default-detection/default-detection.constants.ts @@ -0,0 +1,7 @@ +/** + * Grace period (in days) before an overdue loan is considered defaulted. + * + * A loan whose `next_payment_due` is more than this many days past will be + * flagged as defaulted by both the automated job and the manual endpoint. + */ +export const DEFAULT_GRACE_PERIOD_DAYS = 30; diff --git a/src/jobs/default-detection/default-detection.module.ts b/src/jobs/default-detection/default-detection.module.ts new file mode 100644 index 0000000..c1cdbcd --- /dev/null +++ b/src/jobs/default-detection/default-detection.module.ts @@ -0,0 +1,23 @@ +import { Module } from '@nestjs/common'; +import { BullModule } from '@nestjs/bullmq'; +import { ConfigModule } from '@nestjs/config'; +import { DefaultDetectionService } from './default-detection.service'; +import { DefaultDetectionProcessor } from './default-detection.processor'; +import { SupabaseService } from '../../database/supabase.client'; +import { StellarModule } from '../../stellar/stellar.module'; +import { BlockchainService } from '../../modules/blockchain/blockchain.service'; + +@Module({ + imports: [ + ConfigModule, + StellarModule, + BullModule.registerQueue({ name: 'default-detection' }), + ], + providers: [ + DefaultDetectionService, + DefaultDetectionProcessor, + SupabaseService, + BlockchainService, + ], +}) +export class DefaultDetectionModule {} diff --git a/src/jobs/default-detection/default-detection.processor.ts b/src/jobs/default-detection/default-detection.processor.ts new file mode 100644 index 0000000..3824bc8 --- /dev/null +++ b/src/jobs/default-detection/default-detection.processor.ts @@ -0,0 +1,311 @@ +import { Processor, WorkerHost } from '@nestjs/bullmq'; +import { Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { Job } from 'bullmq'; +import * as StellarSdk from 'stellar-sdk'; +import { SupabaseService } from '../../database/supabase.client'; +import { CreditLineContractClient } from '../../stellar/contracts/clients/creditline.client'; +import { BlockchainService } from '../../modules/blockchain/blockchain.service'; + +import { DEFAULT_GRACE_PERIOD_DAYS } from './default-detection.constants'; + +interface ActiveLoanRow { + id: string; + loan_id: string; + user_wallet: string; + next_payment_due: string | null; + remaining_balance: number | string; + status: string; +} + +interface DetectionResult { + loanDbId: string; + loanId: string; + userWallet: string; + status: 'detected' | 'skipped' | 'failed'; + reason: string; + onChainTxHash?: string; +} + +/** + * BullMQ processor for the `default-detection` queue. + * + * Runs every 6 hours via cron schedule. + * Fetches all active loans with an overdue next_payment_due beyond the grace + * period, triggers an on-chain `declare_default` call, and persists the result. + */ +@Processor('default-detection') +export class DefaultDetectionProcessor extends WorkerHost { + private readonly logger = new Logger(DefaultDetectionProcessor.name); + private readonly adminSecretKey: string; + + constructor( + private readonly supabaseService: SupabaseService, + private readonly creditLineContractClient: CreditLineContractClient, + private readonly blockchainService: BlockchainService, + private readonly configService: ConfigService, + ) { + super(); + this.adminSecretKey = this.configService.get('STELLAR_ADMIN_SECRET') || ''; + if (!this.adminSecretKey) { + this.logger.warn( + 'STELLAR_ADMIN_SECRET is not configured — on-chain default transactions cannot be submitted', + ); + } + } + + async process(_job: Job): Promise { + this.logger.log( + { context: 'DefaultDetectionProcessor', action: 'process' }, + 'Default detection job started', + ); + + const summary = { checked: 0, detected: 0, skipped: 0, failed: 0 }; + + try { + const overdueLoans = await this.fetchOverdueActiveLoans(); + + if (overdueLoans.length === 0) { + this.logger.log( + { context: 'DefaultDetectionProcessor', action: 'process' }, + 'No overdue active loans found — skipping run', + ); + return; + } + + this.logger.log( + { + context: 'DefaultDetectionProcessor', + action: 'process', + overdueCount: overdueLoans.length, + }, + `Processing ${overdueLoans.length} overdue loan(s)`, + ); + + for (const loan of overdueLoans) { + try { + const result = await this.processLoan(loan); + summary.checked++; + + if (result.status === 'detected') summary.detected++; + else if (result.status === 'skipped') summary.skipped++; + else if (result.status === 'failed') summary.failed++; + + await this.persistResult(result); + } catch (error) { + summary.failed++; + this.logger.error( + { + context: 'DefaultDetectionProcessor', + action: 'processLoan', + loanId: loan.loan_id, + error: error.message, + stack: error.stack, + }, + 'Failed to process loan for default — continuing with next', + ); + } + } + } catch (error) { + this.logger.error( + { + context: 'DefaultDetectionProcessor', + action: 'process', + error: error.message, + stack: error.stack, + }, + 'Fatal error during default detection job', + ); + } finally { + this.logger.log( + { + context: 'DefaultDetectionProcessor', + action: 'summary', + ...summary, + }, + `Default detection complete — checked: ${summary.checked}, detected: ${summary.detected}, skipped: ${summary.skipped}, failed: ${summary.failed}`, + ); + } + } + + // --------------------------------------------------------------------------- + // Private helpers + // --------------------------------------------------------------------------- + + /** + * Fetches active loans whose next_payment_due is past the grace period. + */ + private async fetchOverdueActiveLoans(): Promise { + const db = this.supabaseService.getServiceRoleClient(); + const cutoff = new Date( + Date.now() - DEFAULT_GRACE_PERIOD_DAYS * 24 * 60 * 60 * 1000, + ).toISOString(); + + const { data, error } = await db + .from('loans') + .select('id, loan_id, user_wallet, next_payment_due, remaining_balance, status') + .eq('status', 'active') + .not('next_payment_due', 'is', null) + .lt('next_payment_due', cutoff); + + if (error) { + throw new Error(`Failed to fetch overdue active loans: ${error.message}`); + } + + return (data ?? []) as ActiveLoanRow[]; + } + + /** + * Processes a single overdue loan: + * 1. Builds and submits an on-chain declare_default transaction + * 2. Updates the loan record to 'defaulted' in Supabase + */ + private async processLoan(loan: ActiveLoanRow): Promise { + // Skip if no remaining balance (shouldn't happen for active loans but guard) + if (Number(loan.remaining_balance) <= 0) { + return { + loanDbId: loan.id, + loanId: loan.loan_id, + userWallet: loan.user_wallet, + status: 'skipped', + reason: 'Remaining balance is zero — loan may already be fully paid', + }; + } + + // Trigger on-chain default + if (!this.adminSecretKey) { + this.logger.warn( + { + context: 'DefaultDetectionProcessor', + action: 'processLoan', + loanId: loan.loan_id, + }, + 'STELLAR_ADMIN_SECRET not set — falling back to direct Supabase update', + ); + + // Fallback: just update the loan status directly without on-chain call + await this.updateLoanToDefaulted(loan.id, loan.loan_id); + return { + loanDbId: loan.id, + loanId: loan.loan_id, + userWallet: loan.user_wallet, + status: 'detected', + reason: 'Default recorded in database (no on-chain trigger — admin key not configured)', + }; + } + + try { + const unsignedXdr = await this.creditLineContractClient.buildDeclareDefaultTx( + StellarSdk.Keypair.fromSecret(this.adminSecretKey).publicKey(), + loan.loan_id, + ); + + // Sign the XDR with the admin keypair + const keypair = StellarSdk.Keypair.fromSecret(this.adminSecretKey); + const networkPassphrase = + this.configService.get('STELLAR_NETWORK_PASSPHRASE') || + StellarSdk.Networks.TESTNET; + + const transaction = StellarSdk.TransactionBuilder.fromXDR( + unsignedXdr, + networkPassphrase, + ) as StellarSdk.Transaction; + + transaction.sign(keypair); + const signedXdr = transaction.toXDR(); + + // Submit to Horizon + const { transactionHash } = await this.blockchainService.submitTransaction(signedXdr); + + // Update loan in Supabase + await this.updateLoanToDefaulted(loan.id, loan.loan_id); + + this.logger.log( + { + context: 'DefaultDetectionProcessor', + action: 'processLoan', + loanId: loan.loan_id, + txHash: transactionHash, + }, + `Loan ${loan.loan_id} declared defaulted on-chain in tx ${transactionHash}`, + ); + + return { + loanDbId: loan.id, + loanId: loan.loan_id, + userWallet: loan.user_wallet, + status: 'detected', + reason: 'Default declared on-chain and Supabase updated', + onChainTxHash: transactionHash, + }; + } catch (error) { + this.logger.error( + { + context: 'DefaultDetectionProcessor', + action: 'processLoan', + loanId: loan.loan_id, + error: error.message, + }, + 'On-chain default transaction failed — marking as failed', + ); + return { + loanDbId: loan.id, + loanId: loan.loan_id, + userWallet: loan.user_wallet, + status: 'failed', + reason: `On-chain default failed: ${error.message}`, + }; + } + } + + /** + * Updates the loan record to 'defaulted' status. + */ + private async updateLoanToDefaulted(id: string, loanId: string): Promise { + const db = this.supabaseService.getServiceRoleClient(); + const now = new Date().toISOString(); + + const { error } = await db + .from('loans') + .update({ + status: 'defaulted', + defaulted_at: now, + updated_at: now, + }) + .eq('id', id) + .eq('status', 'active'); + + if (error) { + throw new Error(`Failed to update loan ${loanId} to defaulted: ${error.message}`); + } + } + + /** + * Persists the detection result to the default_detection_results table. + */ + private async persistResult(result: DetectionResult): Promise { + const db = this.supabaseService.getServiceRoleClient(); + + const { error } = await db.from('default_detection_results').insert({ + loan_id: result.loanId, + loan_db_id: result.loanDbId, + user_wallet: result.userWallet, + status: result.status, + reason: result.reason, + on_chain_tx_hash: result.onChainTxHash ?? null, + detected_at: new Date().toISOString(), + }); + + if (error) { + this.logger.error( + { + context: 'DefaultDetectionProcessor', + action: 'persistResult', + loanId: result.loanId, + error: error.message, + }, + 'Failed to persist default detection result — continuing', + ); + } + } +} diff --git a/src/jobs/default-detection/default-detection.service.ts b/src/jobs/default-detection/default-detection.service.ts new file mode 100644 index 0000000..7cc7ab6 --- /dev/null +++ b/src/jobs/default-detection/default-detection.service.ts @@ -0,0 +1,46 @@ +import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; +import { InjectQueue } from '@nestjs/bullmq'; +import { Queue } from 'bullmq'; + +/** + * Schedules the default-detection repeating job on module initialisation. + * + * The job runs every 6 hours. Stale repeatable jobs from previous runs + * are removed before re-scheduling to avoid duplicate executions after + * hot-reloads or restarts. + */ +@Injectable() +export class DefaultDetectionService implements OnModuleInit { + private readonly logger = new Logger(DefaultDetectionService.name); + + constructor( + @InjectQueue('default-detection') + private readonly defaultDetectionQueue: Queue, + ) {} + + async onModuleInit(): Promise { + // Clean up any stale repeatable jobs from a previous run + const existing = await this.defaultDetectionQueue.getRepeatableJobs(); + for (const job of existing) { + await this.defaultDetectionQueue.removeRepeatableByKey(job.key); + } + + await this.defaultDetectionQueue.add( + 'detect-defaults', + {}, + { + repeat: { pattern: '0 */6 * * *' }, + removeOnComplete: { count: 10 }, + removeOnFail: { count: 50 }, + }, + ); + + this.logger.log( + { + context: 'DefaultDetectionService', + action: 'onModuleInit', + }, + 'Default detection job scheduled — runs every 6 hours', + ); + } +} diff --git a/src/modules/blockchain/blockchain.service.ts b/src/modules/blockchain/blockchain.service.ts index 1f94532..279528b 100644 --- a/src/modules/blockchain/blockchain.service.ts +++ b/src/modules/blockchain/blockchain.service.ts @@ -27,6 +27,20 @@ export class BlockchainService { this.logger.log(`BlockchainService Horizon client initialized: ${horizonUrl}`); } + /** + * Submits a signed transaction XDR to the Stellar network and waits for + * ledger confirmation. + */ + async submitTransaction(signedXdr: string): Promise<{ transactionHash: string }> { + const transaction = this.parseTransaction(signedXdr); + + const hash = await this.submitToHorizon(transaction); + + await this.waitForLedgerConfirmation(hash); + + return { transactionHash: hash }; + } + async submitRepayment(signedXdr: string): Promise<{ transactionHash: string }> { const transaction = this.parseTransaction(signedXdr); diff --git a/src/modules/loans/loans.controller.ts b/src/modules/loans/loans.controller.ts index 7b9d253..c33a0e7 100644 --- a/src/modules/loans/loans.controller.ts +++ b/src/modules/loans/loans.controller.ts @@ -250,6 +250,35 @@ export class LoansController { return { success: true, data, message: 'Repayment submitted and confirmed successfully' }; } + @Post(':loanId/check-default') + @HttpCode(HttpStatus.OK) + @UseGuards(JwtAuthGuard) + @ApiBearerAuth() + @ApiParam({ + name: 'loanId', + description: 'UUID of the loan to check for default', + example: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890', + }) + @ApiOperation({ + summary: 'Check and trigger default on an overdue loan', + description: + 'Checks if an active loan is overdue beyond the grace period (30 days). If so, triggers an on-chain default declaration via the Soroban contract and updates the loan status to defaulted in the database. Requires JWT authentication.', + }) + @ApiResponse({ + status: 200, + description: 'Default check completed', + }) + @ApiResponse({ status: 400, description: 'Loan is not active or not overdue enough' }) + @ApiResponse({ status: 401, description: 'Unauthorized - missing or invalid JWT' }) + @ApiResponse({ status: 404, description: 'Loan not found or does not belong to user' }) + async checkDefault( + @CurrentUser() user: { wallet: string }, + @Param('loanId', ParseUUIDPipe) loanId: string, + ) { + const data = await this.loansService.checkDefault(user.wallet, loanId); + return { success: true, data, message: 'Default check completed successfully' }; + } + @Post(':loanId/assess') @HttpCode(HttpStatus.OK) @UseGuards(JwtAuthGuard) diff --git a/src/modules/loans/loans.service.ts b/src/modules/loans/loans.service.ts index 91b3377..f64b51f 100644 --- a/src/modules/loans/loans.service.ts +++ b/src/modules/loans/loans.service.ts @@ -5,11 +5,16 @@ import { Logger, InternalServerErrorException, ServiceUnavailableException, + ConflictException, } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import * as StellarSdk from 'stellar-sdk'; +import { DEFAULT_GRACE_PERIOD_DAYS } from '../../jobs/default-detection/default-detection.constants'; import { ReputationService } from '../reputation/reputation.service'; import { SupabaseService } from '../../database/supabase.client'; import { CreditLineContractClient } from '../../stellar/contracts/clients/creditline.client'; import { ReputationContractClient } from '../../stellar/contracts/clients/reputation.client'; +import { BlockchainService } from '../blockchain/blockchain.service'; import { LoanQuoteRequestDto } from './dto/loan-quote-request.dto'; import { LoanQuoteResponseDto, SchedulePaymentDto } from './dto/loan-quote-response.dto'; import { CreateLoanRequestDto } from './dto/create-loan-request.dto'; @@ -92,6 +97,8 @@ export class LoansService { private readonly creditLineContractClient: CreditLineContractClient, private readonly reputationContractClient: ReputationContractClient, private readonly creditScoringService: CreditScoringService, + private readonly blockchainService: BlockchainService, + private readonly configService: ConfigService, ) {} async calculateLoanQuote( @@ -641,6 +648,192 @@ export class LoansService { }; } + /** + * Checks whether an active loan is overdue beyond the grace period and, if so, + * triggers an on-chain default declaration. + * + * Returns the detection result including whether the loan was defaulted. + */ + async checkDefault( + wallet: string, + loanId: string, + ): Promise<{ + loanId: string; + defaulted: boolean; + reason: string; + onChainTxHash?: string; + }> { + const client = this.supabaseService.getServiceRoleClient(); + const { data: loan, error } = await client + .from('loans') + .select('id, loan_id, user_wallet, status, next_payment_due, remaining_balance') + .eq('id', loanId) + .single(); + + if (error || !loan) { + throw new NotFoundException({ + code: 'LOAN_NOT_FOUND', + message: 'Loan not found. Please provide a valid loan ID.', + }); + } + + if (loan.user_wallet !== wallet) { + throw new NotFoundException({ + code: 'LOAN_NOT_FOUND', + message: 'Loan not found. Please provide a valid loan ID.', + }); + } + + if (loan.status !== 'active') { + throw new BadRequestException({ + code: 'LOAN_NOT_ACTIVE', + message: `Cannot check default on a loan with status '${loan.status}'. Only active loans can be checked.`, + }); + } + + if (!loan.next_payment_due) { + throw new BadRequestException({ + code: 'LOAN_NO_DUE_DATE', + message: 'Loan has no next payment due date — cannot determine overdue status.', + }); + } + + const now = new Date(); + const dueDate = new Date(loan.next_payment_due); + const daysOverdue = Math.floor( + (now.getTime() - dueDate.getTime()) / (24 * 60 * 60 * 1000), + ); + + if (daysOverdue < DEFAULT_GRACE_PERIOD_DAYS) { + return { + loanId: loan.loan_id, + defaulted: false, + reason: `Loan is ${daysOverdue} day(s) overdue — still within the ${DEFAULT_GRACE_PERIOD_DAYS}-day grace period.`, + }; + } + + if (Number(loan.remaining_balance) <= 0) { + return { + loanId: loan.loan_id, + defaulted: false, + reason: 'Loan has no remaining balance — cannot be defaulted.', + }; + } + + // Trigger on-chain default + const adminSecretKey = this.configService.get('STELLAR_ADMIN_SECRET'); + let onChainTxHash: string | undefined; + + if (adminSecretKey) { + try { + const unsignedXdr = await this.creditLineContractClient.buildDeclareDefaultTx( + StellarSdk.Keypair.fromSecret(adminSecretKey).publicKey(), + loan.loan_id, + ); + + const keypair = StellarSdk.Keypair.fromSecret(adminSecretKey); + const networkPassphrase = + this.configService.get('STELLAR_NETWORK_PASSPHRASE') || + StellarSdk.Networks.TESTNET; + + const transaction = StellarSdk.TransactionBuilder.fromXDR( + unsignedXdr, + networkPassphrase, + ) as StellarSdk.Transaction; + + transaction.sign(keypair); + const signedXdr = transaction.toXDR(); + + const result = await this.blockchainService.submitTransaction(signedXdr); + onChainTxHash = result.transactionHash; + + this.logger.log( + { + context: 'LoansService', + action: 'checkDefault', + loanId: loan.loan_id, + txHash: onChainTxHash, + }, + `Loan ${loan.loan_id} declared defaulted on-chain in tx ${onChainTxHash}`, + ); + } catch (error) { + this.logger.error( + { + context: 'LoansService', + action: 'checkDefault', + loanId: loan.loan_id, + error: error.message, + }, + 'On-chain default transaction failed — will still update Supabase', + ); + } + } else { + this.logger.warn( + { + context: 'LoansService', + action: 'checkDefault', + loanId: loan.loan_id, + }, + 'STELLAR_ADMIN_SECRET not configured — updating Supabase without on-chain call', + ); + } + + // Update loan to defaulted + const nowIso = new Date().toISOString(); + const { error: updateError } = await client + .from('loans') + .update({ + status: 'defaulted', + defaulted_at: nowIso, + updated_at: nowIso, + }) + .eq('id', loan.id) + .eq('status', 'active'); + + if (updateError) { + throw new InternalServerErrorException({ + code: 'LOAN_DEFAULT_UPDATE_FAILED', + message: `Failed to update loan status to defaulted: ${updateError.message}`, + }); + } + + // Persist detection result + const { error: persistError } = await client + .from('default_detection_results') + .insert({ + loan_id: loan.loan_id, + loan_db_id: loan.id, + user_wallet: loan.user_wallet, + status: 'detected', + reason: onChainTxHash + ? 'Default declared on-chain and Supabase updated (manual trigger)' + : 'Default recorded in database (no on-chain — admin key not configured)', + on_chain_tx_hash: onChainTxHash ?? null, + detected_at: nowIso, + }); + + if (persistError) { + this.logger.error( + { + context: 'LoansService', + action: 'checkDefault', + loanId: loan.loan_id, + error: persistError.message, + }, + 'Failed to persist default detection result', + ); + } + + return { + loanId: loan.loan_id, + defaulted: true, + reason: onChainTxHash + ? `Loan defaulted on-chain (tx: ${onChainTxHash}) and in database.` + : 'Loan defaulted in database. Configure STELLAR_ADMIN_SECRET for on-chain triggering.', + onChainTxHash, + }; + } + private async runCreditAssessment( wallet: string, amount: number, diff --git a/src/stellar/contracts/clients/creditline.client.ts b/src/stellar/contracts/clients/creditline.client.ts index ddd231d..41c46f4 100644 --- a/src/stellar/contracts/clients/creditline.client.ts +++ b/src/stellar/contracts/clients/creditline.client.ts @@ -102,6 +102,39 @@ export class CreditLineContractClient { } } + async buildDeclareDefaultTx(callerWallet: string, loanId: string): Promise { + this.ensureConfigured(); + + try { + const contract = new StellarSdk.Contract(this.contractId); + const server = this.sorobanService.getServer(); + const networkPassphrase = this.sorobanService.getNetworkPassphrase(); + + const loanIdArg = StellarSdk.nativeToScVal(loanId, { type: 'string' }); + + // Use a disposable source account when building the XDR + const sourceKeypair = StellarSdk.Keypair.random(); + const sourceAccount = new StellarSdk.Account(sourceKeypair.publicKey(), '0'); + + const tx = new StellarSdk.TransactionBuilder(sourceAccount, { + fee: StellarSdk.BASE_FEE, + networkPassphrase, + }) + .addOperation(contract.call('declare_default', loanIdArg)) + .setTimeout(300) + .build(); + + const prepared = await server.prepareTransaction(tx); + return prepared.toXDR(); + } catch (error) { + if (error instanceof ContractNotConfiguredError) { + throw error; + } + this.logger.error(`Failed to build declare_default transaction: ${error.message}`); + throw new ContractTxBuildError('declare_default'); + } + } + private ensureConfigured(): void { if (!this.contractId) { throw new ContractNotConfiguredError('Credit line contract'); diff --git a/src/stellar/contracts/interfaces/creditline.interface.ts b/src/stellar/contracts/interfaces/creditline.interface.ts index 8a60d2d..67a392f 100644 --- a/src/stellar/contracts/interfaces/creditline.interface.ts +++ b/src/stellar/contracts/interfaces/creditline.interface.ts @@ -21,4 +21,9 @@ export interface ICreditLineClient { loanId: string, amount: number, ): Promise; + + buildDeclareDefaultTx( + callerWallet: string, + loanId: string, + ): Promise; } diff --git a/src/stellar/contracts/mocks/creditline.mock.ts b/src/stellar/contracts/mocks/creditline.mock.ts index 525d0c6..29b9d48 100644 --- a/src/stellar/contracts/mocks/creditline.mock.ts +++ b/src/stellar/contracts/mocks/creditline.mock.ts @@ -14,4 +14,10 @@ export class MockCreditLineContractClient { return 'AAAAAgAAAQAAAAAAAAAAiZ3TgwAAAAAyMZyAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=='; }, ); + + buildDeclareDefaultTx = jest.fn( + async (_callerWallet: string, _loanId: string): Promise => { + return 'AAAAAgAAAQAAAAAAAAAAiZ3TgwAAAAAyMZyAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=='; + }, + ); } diff --git a/supabase/migrations/20260625000000_create_default_detection_results.sql b/supabase/migrations/20260625000000_create_default_detection_results.sql new file mode 100644 index 0000000..3b3cda0 --- /dev/null +++ b/supabase/migrations/20260625000000_create_default_detection_results.sql @@ -0,0 +1,24 @@ +-- Create table to store default detection job results +CREATE TABLE public.default_detection_results ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + loan_id TEXT NOT NULL REFERENCES public.loans(loan_id), + loan_db_id UUID NOT NULL REFERENCES public.loans(id), + user_wallet TEXT NOT NULL, + status TEXT NOT NULL CHECK (status IN ('detected', 'skipped', 'failed')), + reason TEXT, + on_chain_tx_hash TEXT, + detected_at TIMESTAMPTZ NOT NULL DEFAULT now(), + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +-- Index for looking up detection history by loan +CREATE INDEX idx_default_detection_loan_id ON public.default_detection_results(loan_id); +CREATE INDEX idx_default_detection_user_wallet ON public.default_detection_results(user_wallet); + +-- Index for quick lookups of defaulted loans +CREATE INDEX idx_loans_defaulted_at ON public.loans(defaulted_at) WHERE status = 'defaulted'; + +ALTER TABLE public.default_detection_results ENABLE ROW LEVEL SECURITY; + +COMMENT ON TABLE public.default_detection_results IS 'Records from the automated default detection job — tracks which loans were checked and the outcome'; +COMMENT ON COLUMN public.default_detection_results.status IS 'detected = loan was marked defaulted on-chain, skipped = loan was not overdue enough, failed = on-chain call errored';