diff --git a/.env.example b/.env.example index f5d8d65..ee36463 100644 --- a/.env.example +++ b/.env.example @@ -59,3 +59,9 @@ TWILIO_PHONE_NUMBER=+1234567890 # Owner Configuration (for auto-reclaim) # Owner Account (for executing reclaim transactions) OWNER_MNEMONIC= + +# Real-Time Rent Payment Tracker (Issue #16) +# Cron expression for how often to poll Horizon for new payments (default: every minute) +PAYMENT_TRACKER_CRON=* * * * * +# Override the Horizon base URL used by the payment tracker (defaults to testnet) +LEASEFLOW_HORIZON_URL=https://horizon-testnet.stellar.org diff --git a/index.js b/index.js index 43e4c7b..ffcdf54 100644 --- a/index.js +++ b/index.js @@ -19,6 +19,9 @@ const { NotificationService } = require('./src/services/notificationService'); const { SorobanLeaseService } = require('./src/services/sorobanLeaseService'); const { LeaseRenewalService } = require('./src/services/leaseRenewalService'); const { LeaseRenewalJob, startLeaseRenewalScheduler } = require('./src/jobs/leaseRenewalJob'); +const { RentPaymentTrackerService } = require('./services/rentPaymentTrackerService'); +const { startPaymentTrackerJob } = require('./src/jobs/paymentTrackerJob'); +const { createPaymentRoutes } = require('./src/routes/paymentRoutes'); const { getUSDCToFiatRates, getXLMToUSDCPath } = require('./services/priceFeedService'); const AvailabilityService = require('./services/availabilityService'); const AssetMetadataService = require('./services/assetMetadataService'); @@ -80,6 +83,65 @@ function createApp(dependencies = {}) { // Middleware app.use(cors()); + app.use(express.json()); +const express = require('express'); +const cors = require('cors'); +const { randomUUID } = require('crypto'); + +const multer = require('multer'); +const path = require('path'); +const fs = require('fs'); +const sharp = require('sharp'); +const app = express(); +const port = 3000; +const creditScoreAggregator = new TenantCreditScoreAggregator(); +const listings = []; +const HORIZON_URL = process.env.HORIZON_URL || 'https://horizon.stellar.org'; + +const uploadDir = path.join(__dirname, 'uploads'); +if (!fs.existsSync(uploadDir)) { + fs.mkdirSync(uploadDir, { recursive: true }); +} + +const storage = multer.diskStorage({ + destination: (req, file, cb) => cb(null, uploadDir), + filename: (req, file, cb) => cb(null, `${Date.now()}_${file.originalname}`) +}); +const upload = multer({ storage }); + +// Middleware +app.use(cors()); +app.use(express.json({ limit: '50mb' })); +app.use(express.urlencoded({ extended: true, limit: '50mb' })); + +// Routes +app.use('/api/leases', leaseRoutes); +app.use('/api/owners', ownerRoutes); +app.use('/api', createPaymentRoutes(database)); + +app.get('/', (req, res) => { + res.json({ + project: 'LeaseFlow Protocol Backend', + description: 'Secure Lease Indexer and Storage Facilitator', + status: 'Operational', + version: '1.0.0', + contract_id: process.env.CONTRACT_ID || 'CAEGD57WVTVQSYWYB23AISBW334QO7WNA5XQ56S45GH6BP3D2AVHKUG4', + endpoints: { + upload_lease: 'POST /api/leases/upload', + view_lease_handshake: 'GET /api/leases/:leaseCID/handshake', + top_owners: 'GET /api/owners/top' + } +app.use(express.json()); +app.use('/uploads', express.static(path.join(__dirname, 'uploads'))); +const { + createConditionProofService, + ConditionProofError, +} = require('./services/conditionProofService'); +const { + createFileConditionProofStore, +} = require('./services/conditionProofStore'); + +const port = process.env.PORT || 3000; app.use(express.json({ limit: '50mb' })); app.use(express.urlencoded({ extended: true, limit: '50mb' })); @@ -253,6 +315,53 @@ if (require.main === module) { }).catch(err => { console.warn('AutoReclaimWorker failed to initialize:', err.message); }); + }, + ); + + return app; +} + +const app = createApp(); + +if (require.main === module) { + let scheduler; + + if (config.jobs.renewalJobEnabled) { + const database = new AppDatabase(config.database.filename); + const notificationService = new NotificationService(database); + const sorobanLeaseService = new SorobanLeaseService(config); + const leaseRenewalService = new LeaseRenewalService( + database, + notificationService, + sorobanLeaseService, + config, + ); + scheduler = startLeaseRenewalScheduler(new LeaseRenewalJob(leaseRenewalService), config); + } + + const paymentTrackerDb = new AppDatabase(config.database.filename); + const paymentTrackerService = new RentPaymentTrackerService(paymentTrackerDb, { + contractAccountId: config.contracts.defaultContractId, + }); + startPaymentTrackerJob(paymentTrackerService, { + cronExpression: process.env.PAYMENT_TRACKER_CRON || '* * * * *', + }); + + app.listen(port, () => { + console.log(`LeaseFlow Backend running at http://localhost:${port}`); + console.log(`Lease Encryption Service: Active`); + console.log(`IPFS Storage Service: Initialized (Host: ${process.env.IPFS_HOST || 'ipfs.infura.io'})`); + console.log(`LeaseFlow Backend listening at http://localhost:${port}`); + if (scheduler) { + console.log(`Lease renewal scheduler running every ${config.jobs.intervalMs}ms`); + } + const autoReclaimWorker = new AutoReclaimWorker(); + + autoReclaimWorker.initialize().then(() => { + autoReclaimWorker.start(); + app.listen(port, () => { + console.log(`LeaseFlow Backend listening at http://localhost:${port}`); + console.log('Auto-Reclaim Worker started'); }); }); } diff --git a/migrations/002_add_payment_history.sql b/migrations/002_add_payment_history.sql new file mode 100644 index 0000000..2a6c0fe --- /dev/null +++ b/migrations/002_add_payment_history.sql @@ -0,0 +1,30 @@ +-- Migration: Add payment_history table and extend leases with payment tracking columns +-- Issue #16: Real-Time Rent Payment Tracker + +-- payment_history stores every detected Horizon payment event +CREATE TABLE IF NOT EXISTS payment_history ( + id TEXT PRIMARY KEY, + horizon_op_id TEXT NOT NULL UNIQUE, + lease_id TEXT, + tenant_account_id TEXT NOT NULL, + amount TEXT NOT NULL, + asset_code TEXT NOT NULL DEFAULT 'XLM', + asset_issuer TEXT, + transaction_hash TEXT NOT NULL, + paid_at TEXT NOT NULL, + recorded_at TEXT NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_payment_history_lease_id + ON payment_history (lease_id); + +CREATE INDEX IF NOT EXISTS idx_payment_history_tenant_account + ON payment_history (tenant_account_id); + +CREATE INDEX IF NOT EXISTS idx_payment_history_paid_at + ON payment_history (paid_at DESC); + +-- Extend leases table with payment-tracking columns +ALTER TABLE leases ADD COLUMN IF NOT EXISTS tenant_account_id TEXT; +ALTER TABLE leases ADD COLUMN IF NOT EXISTS payment_status TEXT NOT NULL DEFAULT 'pending'; +ALTER TABLE leases ADD COLUMN IF NOT EXISTS last_payment_at TEXT; diff --git a/package.json b/package.json index 9f0c182..6a9ff32 100644 --- a/package.json +++ b/package.json @@ -9,9 +9,9 @@ }, "dependencies": { "@stellar/stellar-sdk": "^14.6.1", + "algosdk": "^2.0.0", "axios": "^1.13.6", "busboy": "^1.6.0", - "algosdk": "^2.0.0", "cors": "^2.8.6", "crypto-js": "^4.2.0", "dotenv": "^17.3.1", @@ -19,6 +19,11 @@ "lodash": "^4.17.23", "eciesjs": "^0.4.18", "ipfs-http-client": "^60.0.1", + "lodash": "^4.17.23", + "multer": "^2.1.1", + "node-cron": "^3.0.3", + "pg": "^8.11.3", + "sharp": "^0.34.5" "multer": "^2.1.1", "sharp": "^0.34.5", "pg": "^8.11.3", diff --git a/services/rentPaymentTrackerService.js b/services/rentPaymentTrackerService.js new file mode 100644 index 0000000..08716d1 --- /dev/null +++ b/services/rentPaymentTrackerService.js @@ -0,0 +1,154 @@ +/** + * RentPaymentTrackerService + * + * Monitors the Stellar Horizon API for `payment` operations directed at the + * LeaseFlow contract account. When a new payment is detected it is recorded + * in the `payment_history` table and the parent lease `payment_status` is + * updated accordingly — giving landlords a "Stripe-like" real-time view of + * tenant rent payments. + */ + +const axios = require('axios'); + +const HORIZON_BASE_URL = + process.env.HORIZON_URL || 'https://horizon-testnet.stellar.org'; + +/** How many operations to fetch per Horizon page (max 200). */ +const PAGE_LIMIT = 200; + +class RentPaymentTrackerService { + /** + * @param {import('../src/db/appDatabase').AppDatabase} database + * @param {{contractAccountId?: string}} [options] + */ + constructor(database, options = {}) { + this.database = database; + /** The Stellar account (contract or landlord escrow) to watch. */ + this.contractAccountId = + options.contractAccountId || + process.env.SOROBAN_CONTRACT_ID || + process.env.CONTRACT_ID || + 'CAEGD57WVTVQSYWYB23AISBW334QO7WNA5XQ56S45GH6BP3D2AVHKUG4'; + + /** + * Horizon paging_token of the last successfully processed operation. + * Stored in-memory so each poll only fetches genuinely new operations + * instead of re-fetching (and skipping) the same page every minute. + * @type {string|null} + */ + this._lastPagingToken = null; + } + + /** + * Poll Horizon for new payments made TO the contract account. + * Cursor-based — only fetches operations newer than the last poll. + * Idempotent — repeated calls do not create duplicate records. + * + * @returns {Promise<{processed: number, skipped: number, errors: Array<{id: string, message: string}>}>} + */ + async poll() { + const result = { processed: 0, skipped: 0, errors: [] }; + + // Use ascending order + cursor so we only get operations we haven't seen yet. + let url = + `${HORIZON_BASE_URL}/accounts/${encodeURIComponent(this.contractAccountId)}` + + `/payments?limit=${PAGE_LIMIT}&order=asc&include_failed=false`; + + if (this._lastPagingToken) { + url += `&cursor=${encodeURIComponent(this._lastPagingToken)}`; + } + + const response = await this._fetchHorizon(url); + const records = response?._embedded?.records ?? []; + + let lastToken = null; + for (const op of records) { + try { + const outcome = await this._processPaymentOperation(op); + if (outcome === 'recorded') { + result.processed += 1; + } else { + result.skipped += 1; + } + } catch (err) { + result.errors.push({ id: op.id, message: err.message }); + } + // Always advance the cursor, even for skipped ops, so we never + // re-scan the same page on the next poll. + if (op.paging_token) { + lastToken = op.paging_token; + } + } + + if (lastToken) { + this._lastPagingToken = lastToken; + } + + return result; + } + + /** + * Process a single Horizon payment operation record. + * + * @param {object} op Horizon payment operation object. + * @returns {Promise<'recorded'|'skipped'>} + */ + async _processPaymentOperation(op) { + // We only care about incoming credit operations (payment / path_payment). + if (!['payment', 'path_payment_strict_send', 'path_payment_strict_receive'].includes(op.type)) { + return 'skipped'; + } + + // The payment must be directed *to* the contract account. + if (op.to !== this.contractAccountId) { + return 'skipped'; + } + + // Deduplicate — skip if this Horizon operation id is already recorded. + if (this.database.getPaymentByHorizonOpId(op.id)) { + return 'skipped'; + } + + // Try to resolve the lease by matching the sender (tenant Stellar account). + const tenantAccountId = op.from; + const lease = this.database.getActiveLeaseByTenantAccount(tenantAccountId); + + const leaseId = lease?.id ?? null; + + const payment = { + horizonOperationId: op.id, + leaseId, + tenantAccountId, + amount: op.amount, + assetCode: op.asset_code || 'XLM', + assetIssuer: op.asset_issuer || null, + transactionHash: op.transaction_hash, + paidAt: op.created_at, + }; + + this.database.insertPayment(payment); + + // Update the lease payment status if we matched a lease. + if (leaseId) { + this.database.updateLeasePaymentStatus(leaseId, 'paid', op.created_at); + } + + return 'recorded'; + } + + /** + * Fetch a URL from Horizon and return parsed JSON. + * + * @param {string} url + * @returns {Promise} + */ + async _fetchHorizon(url) { + const response = await axios.get(url, { + headers: { Accept: 'application/json' }, + timeout: 10_000, + }); + return response.data; + } +} + +module.exports = { RentPaymentTrackerService }; diff --git a/src/db/appDatabase.js b/src/db/appDatabase.js index c06f64e..832e5eb 100644 --- a/src/db/appDatabase.js +++ b/src/db/appDatabase.js @@ -50,6 +50,9 @@ class AppDatabase { end_date TEXT NOT NULL, renewable INTEGER NOT NULL DEFAULT 1, disputed INTEGER NOT NULL DEFAULT 0, + tenant_account_id TEXT, + payment_status TEXT NOT NULL DEFAULT 'pending', + last_payment_at TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL ); @@ -100,6 +103,28 @@ class AppDatabase { message TEXT NOT NULL, created_at TEXT NOT NULL ); + + CREATE TABLE IF NOT EXISTS payment_history ( + id TEXT PRIMARY KEY, + horizon_op_id TEXT NOT NULL UNIQUE, + lease_id TEXT, + tenant_account_id TEXT NOT NULL, + amount TEXT NOT NULL, + asset_code TEXT NOT NULL DEFAULT 'XLM', + asset_issuer TEXT, + transaction_hash TEXT NOT NULL, + paid_at TEXT NOT NULL, + recorded_at TEXT NOT NULL + ); + + CREATE INDEX IF NOT EXISTS idx_payment_history_lease_id + ON payment_history (lease_id); + + CREATE INDEX IF NOT EXISTS idx_payment_history_tenant_account + ON payment_history (tenant_account_id); + + CREATE INDEX IF NOT EXISTS idx_payment_history_paid_at + ON payment_history (paid_at); `); } @@ -236,17 +261,20 @@ class AppDatabase { ` SELECT id, - landlord_id AS landlordId, - tenant_id AS tenantId, + landlord_id AS landlordId, + tenant_id AS tenantId, status, - rent_amount AS rentAmount, + rent_amount AS rentAmount, currency, - start_date AS startDate, - end_date AS endDate, + start_date AS startDate, + end_date AS endDate, renewable, disputed, - created_at AS createdAt, - updated_at AS updatedAt + tenant_account_id AS tenantAccountId, + payment_status AS paymentStatus, + last_payment_at AS lastPaymentAt, + created_at AS createdAt, + updated_at AS updatedAt FROM leases WHERE id = ? `, @@ -496,6 +524,181 @@ class AppDatabase { ) .all(proposalId); } + // --------------------------------------------------------------------------- + // Payment history methods (Issue #16 — Real-Time Rent Payment Tracker) + // --------------------------------------------------------------------------- + + /** + * Persist a new payment event. + * + * @param {object} payment Payment data from Horizon. + * @returns {object} The inserted payment record. + */ + insertPayment(payment) { + const id = payment.id || crypto.randomUUID(); + const now = new Date().toISOString(); + this.db + .prepare( + `INSERT INTO payment_history ( + id, horizon_op_id, lease_id, tenant_account_id, + amount, asset_code, asset_issuer, transaction_hash, + paid_at, recorded_at + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ) + .run( + id, + payment.horizonOperationId, + payment.leaseId ?? null, + payment.tenantAccountId, + String(payment.amount), + payment.assetCode || 'XLM', + payment.assetIssuer ?? null, + payment.transactionHash, + payment.paidAt, + now, + ); + return this.getPaymentByHorizonOpId(payment.horizonOperationId); + } + + /** + * Fetch a payment record by Horizon operation ID (for deduplication). + * + * @param {string} horizonOpId Horizon operation identifier. + * @returns {object|null} + */ + getPaymentByHorizonOpId(horizonOpId) { + const row = this.db + .prepare( + `SELECT + id, + horizon_op_id AS horizonOpId, + lease_id AS leaseId, + tenant_account_id AS tenantAccountId, + amount, + asset_code AS assetCode, + asset_issuer AS assetIssuer, + transaction_hash AS transactionHash, + paid_at AS paidAt, + recorded_at AS recordedAt + FROM payment_history + WHERE horizon_op_id = ?`, + ) + .get(horizonOpId); + + return row ?? null; + } + + /** + * List all payments for a specific lease, most-recent first. + * + * @param {string} leaseId Lease identifier. + * @returns {object[]} + */ + listPaymentsByLeaseId(leaseId) { + return this.db + .prepare( + `SELECT + id, + horizon_op_id AS horizonOpId, + lease_id AS leaseId, + tenant_account_id AS tenantAccountId, + amount, + asset_code AS assetCode, + asset_issuer AS assetIssuer, + transaction_hash AS transactionHash, + paid_at AS paidAt, + recorded_at AS recordedAt + FROM payment_history + WHERE lease_id = ? + ORDER BY paid_at DESC`, + ) + .all(leaseId); + } + + /** + * List all payments made from a specific Stellar account, most-recent first. + * + * @param {string} tenantAccountId Stellar account address. + * @returns {object[]} + */ + listPaymentsByTenantAccount(tenantAccountId) { + return this.db + .prepare( + `SELECT + id, + horizon_op_id AS horizonOpId, + lease_id AS leaseId, + tenant_account_id AS tenantAccountId, + amount, + asset_code AS assetCode, + asset_issuer AS assetIssuer, + transaction_hash AS transactionHash, + paid_at AS paidAt, + recorded_at AS recordedAt + FROM payment_history + WHERE tenant_account_id = ? + ORDER BY paid_at DESC`, + ) + .all(tenantAccountId); + } + + /** + * Update a lease's payment_status and last_payment_at columns. + * + * @param {string} leaseId Lease identifier. + * @param {string} status New payment status (e.g. 'paid'). + * @param {string} paidAt ISO timestamp of the payment. + * @returns {void} + */ + updateLeasePaymentStatus(leaseId, status, paidAt) { + this.db + .prepare( + `UPDATE leases + SET payment_status = ?, + last_payment_at = ?, + updated_at = ? + WHERE id = ?`, + ) + .run(status, paidAt, new Date().toISOString(), leaseId); + } + + /** + * Find the active (non-disputed, status = 'active') lease for a given tenant + * Stellar account address so payments can be auto-matched. + * + * @param {string} tenantAccountId Stellar account address. + * @returns {object|null} + */ + getActiveLeaseByTenantAccount(tenantAccountId) { + const row = this.db + .prepare( + `SELECT + id, + landlord_id AS landlordId, + tenant_id AS tenantId, + tenant_account_id AS tenantAccountId, + status, + rent_amount AS rentAmount, + currency, + start_date AS startDate, + end_date AS endDate, + renewable, + disputed, + payment_status AS paymentStatus, + last_payment_at AS lastPaymentAt, + created_at AS createdAt, + updated_at AS updatedAt + FROM leases + WHERE tenant_account_id = ? + AND status = 'active' + AND disputed = 0 + LIMIT 1`, + ) + .get(tenantAccountId); + + return row ? normalizeLeaseRow(row) : null; + } } function normalizeLeaseRow(row) { diff --git a/src/jobs/paymentTrackerJob.js b/src/jobs/paymentTrackerJob.js new file mode 100644 index 0000000..888de04 --- /dev/null +++ b/src/jobs/paymentTrackerJob.js @@ -0,0 +1,74 @@ +/** + * PaymentTrackerJob + * + * Wraps RentPaymentTrackerService in a cron-scheduled job so that the backend + * continuously watches Horizon for new rent payments without requiring + * webhooks or a Mercury subscription. + */ + +const cron = require('node-cron'); + +class PaymentTrackerJob { + /** + * @param {import('../../services/rentPaymentTrackerService').RentPaymentTrackerService} trackerService + */ + constructor(trackerService) { + this.trackerService = trackerService; + this._task = null; + } + + /** + * Start the cron job. + * + * @param {string} [cronExpression='* * * * *'] Defaults to every minute. + * @returns {void} + */ + start(cronExpression = '* * * * *') { + if (this._task) { + return; // already running + } + + this._task = cron.schedule(cronExpression, async () => { + try { + const result = await this.trackerService.poll(); + if (result.processed > 0) { + console.log( + `[PaymentTracker] Poll done — processed: ${result.processed}, skipped: ${result.skipped}` + ); + } + if (result.errors.length > 0) { + console.error('[PaymentTracker] Errors during poll:', result.errors); + } + } catch (err) { + console.error('[PaymentTracker] Unhandled error during poll:', err.message); + } + }); + + console.log( + `[PaymentTracker] Scheduled payment polling (cron: "${cronExpression}")` + ); + } + + /** Stop the cron job. */ + stop() { + if (this._task) { + this._task.stop(); + this._task = null; + } + } +} + +/** + * Convenience factory — creates and starts a PaymentTrackerJob. + * + * @param {import('../../services/rentPaymentTrackerService').RentPaymentTrackerService} trackerService + * @param {{cronExpression?: string}} [config] + * @returns {PaymentTrackerJob} + */ +function startPaymentTrackerJob(trackerService, config = {}) { + const job = new PaymentTrackerJob(trackerService); + job.start(config.cronExpression || process.env.PAYMENT_TRACKER_CRON || '* * * * *'); + return job; +} + +module.exports = { PaymentTrackerJob, startPaymentTrackerJob }; diff --git a/src/routes/paymentRoutes.js b/src/routes/paymentRoutes.js new file mode 100644 index 0000000..d4f4942 --- /dev/null +++ b/src/routes/paymentRoutes.js @@ -0,0 +1,102 @@ +/** + * Payment history API routes. + * + * GET /api/leases/:leaseId/payments — full payment history for a lease + * GET /api/leases/:leaseId/payment-status — current payment status for a lease + * GET /api/tenants/:tenantAccountId/payments — all payments for a Stellar account + */ + +const express = require('express'); + +/** + * @param {import('../db/appDatabase').AppDatabase} database + * @returns {import('express').Router} + */ +function createPaymentRoutes(database) { + const router = express.Router(); + + /** + * GET /api/leases/:leaseId/payments + * Returns the full recorded payment history for a specific lease. + */ + router.get('/leases/:leaseId/payments', (req, res) => { + const { leaseId } = req.params; + + if (!leaseId || !leaseId.trim()) { + return res.status(400).json({ success: false, error: 'leaseId is required' }); + } + + try { + const payments = database.listPaymentsByLeaseId(leaseId.trim()); + return res.status(200).json({ + success: true, + lease_id: leaseId.trim(), + count: payments.length, + payments, + }); + } catch (err) { + console.error('[PaymentRoutes] Error listing payments:', err); + return res.status(500).json({ success: false, error: 'Failed to fetch payment history' }); + } + }); + + /** + * GET /api/leases/:leaseId/payment-status + * Returns the current payment status and last payment timestamp for a lease. + */ + router.get('/leases/:leaseId/payment-status', (req, res) => { + const { leaseId } = req.params; + + if (!leaseId || !leaseId.trim()) { + return res.status(400).json({ success: false, error: 'leaseId is required' }); + } + + try { + const lease = database.getLeaseById(leaseId.trim()); + if (!lease) { + return res.status(404).json({ success: false, error: 'Lease not found' }); + } + + return res.status(200).json({ + success: true, + lease_id: lease.id, + tenant_id: lease.tenantId, + tenant_account_id: lease.tenantAccountId ?? null, + payment_status: lease.paymentStatus ?? 'pending', + last_payment_at: lease.lastPaymentAt ?? null, + }); + } catch (err) { + console.error('[PaymentRoutes] Error fetching payment status:', err); + return res.status(500).json({ success: false, error: 'Failed to fetch payment status' }); + } + }); + + /** + * GET /api/tenants/:tenantAccountId/payments + * Returns all recorded payments for a specific Stellar account (tenant). + */ + router.get('/tenants/:tenantAccountId/payments', (req, res) => { + const { tenantAccountId } = req.params; + + if (!tenantAccountId || !tenantAccountId.trim()) { + return res.status(400).json({ success: false, error: 'tenantAccountId is required' }); + } + + try { + const payments = database.listPaymentsByTenantAccount(tenantAccountId.trim()); + return res.status(200).json({ + success: true, + tenant_account_id: tenantAccountId.trim(), + count: payments.length, + payments, + }); + } catch (err) { + console.error('[PaymentRoutes] Error listing tenant payments:', err); + return res.status(500).json({ success: false, error: 'Failed to fetch tenant payments' }); + } + }); + + return router; +} + +module.exports = { createPaymentRoutes }; diff --git a/tests/rentPaymentTracker.test.js b/tests/rentPaymentTracker.test.js new file mode 100644 index 0000000..3d6331b --- /dev/null +++ b/tests/rentPaymentTracker.test.js @@ -0,0 +1,235 @@ +/** + * Tests for RentPaymentTrackerService — Issue #16 + */ + +'use strict'; + +const { AppDatabase } = require('../src/db/appDatabase'); +const { RentPaymentTrackerService } = require('../services/rentPaymentTrackerService'); + +// -------------------------------------------------------------------------- +// Helpers +// -------------------------------------------------------------------------- + +function makeDb() { + return new AppDatabase(':memory:'); +} + +function seedLease(db, overrides = {}) { + const lease = { + id: 'lease-001', + landlordId: 'landlord-1', + tenantId: 'tenant-1', + status: 'active', + rentAmount: 1000, + currency: 'USDC', + startDate: '2025-01-01', + endDate: '2025-12-31', + renewable: true, + disputed: false, + ...overrides, + }; + db.seedLease(lease); + + // Manually set tenant_account_id (not in seedLease's original API) + if (overrides.tenantAccountId) { + db.db + .prepare(`UPDATE leases SET tenant_account_id = ? WHERE id = ?`) + .run(overrides.tenantAccountId, lease.id); + } + + return lease; +} + +function makeOp(overrides = {}) { + return { + id: 'op-abc123', + type: 'payment', + to: 'CONTRACT_ACCOUNT', + from: 'TENANT_STELLAR_ACCOUNT', + amount: '500.0000000', + asset_code: 'USDC', + asset_issuer: 'GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5', + transaction_hash: 'deadbeefdeadbeef', + created_at: new Date().toISOString(), + ...overrides, + }; +} + +function makeTracker(db, extraOptions = {}, horizonPayload = null) { + const options = { + contractAccountId: 'CONTRACT_ACCOUNT', + ...extraOptions, + }; + const tracker = new RentPaymentTrackerService(db, options); + + // Stub _fetchHorizon to avoid real HTTP calls + tracker._fetchHorizon = async () => + horizonPayload ?? { + _embedded: { records: [] }, + }; + + return tracker; +} + +// -------------------------------------------------------------------------- +// Tests +// -------------------------------------------------------------------------- + +describe('RentPaymentTrackerService', () => { + describe('poll()', () => { + test('returns zero counts when Horizon returns no records', async () => { + const db = makeDb(); + const tracker = makeTracker(db); + + const result = await tracker.poll(); + + expect(result.processed).toBe(0); + expect(result.skipped).toBe(0); + expect(result.errors).toHaveLength(0); + }); + + test('skips operations that are not payment types', async () => { + const db = makeDb(); + const op = makeOp({ type: 'create_account', to: 'CONTRACT_ACCOUNT' }); + const tracker = makeTracker(db, {}, { _embedded: { records: [op] } }); + + const result = await tracker.poll(); + + expect(result.processed).toBe(0); + expect(result.skipped).toBe(1); + }); + + test('skips payments not directed at the contract account', async () => { + const db = makeDb(); + const op = makeOp({ to: 'SOMEONE_ELSE' }); + const tracker = makeTracker(db, {}, { _embedded: { records: [op] } }); + + const result = await tracker.poll(); + + expect(result.processed).toBe(0); + expect(result.skipped).toBe(1); + }); + + test('records a valid incoming payment and returns processed=1', async () => { + const db = makeDb(); + const op = makeOp(); + const tracker = makeTracker(db, {}, { _embedded: { records: [op] } }); + + const result = await tracker.poll(); + + expect(result.processed).toBe(1); + expect(result.skipped).toBe(0); + expect(result.errors).toHaveLength(0); + + const saved = db.getPaymentByHorizonOpId(op.id); + expect(saved).not.toBeNull(); + expect(saved.tenantAccountId).toBe('TENANT_STELLAR_ACCOUNT'); + expect(saved.amount).toBe('500.0000000'); + }); + + test('is idempotent — duplicate Horizon op IDs are skipped', async () => { + const db = makeDb(); + const op = makeOp(); + const tracker = makeTracker(db, {}, { _embedded: { records: [op] } }); + + await tracker.poll(); + const result = await tracker.poll(); + + expect(result.processed).toBe(0); + expect(result.skipped).toBe(1); + + // Only one record should exist in the DB + expect(db.listPaymentsByTenantAccount('TENANT_STELLAR_ACCOUNT')).toHaveLength(1); + }); + + test('updates lease payment_status when tenant account matches an active lease', async () => { + const db = makeDb(); + seedLease(db, { tenantAccountId: 'TENANT_STELLAR_ACCOUNT' }); + + const op = makeOp(); + const tracker = makeTracker(db, {}, { _embedded: { records: [op] } }); + + await tracker.poll(); + + const lease = db.getLeaseById('lease-001'); + expect(lease.paymentStatus).toBe('paid'); + expect(lease.lastPaymentAt).toBe(op.created_at); + }); + + test('records payment without lease_id when no matching lease found', async () => { + const db = makeDb(); + // No lease seeded for this tenant + const op = makeOp({ from: 'UNKNOWN_TENANT' }); + const tracker = makeTracker(db, {}, { _embedded: { records: [op] } }); + + await tracker.poll(); + + const saved = db.getPaymentByHorizonOpId(op.id); + expect(saved.leaseId).toBeNull(); + }); + }); +}); + +// -------------------------------------------------------------------------- +// AppDatabase payment methods +// -------------------------------------------------------------------------- + +describe('AppDatabase — payment methods', () => { + test('insertPayment and getPaymentByHorizonOpId roundtrip', () => { + const db = makeDb(); + + const payment = { + horizonOperationId: 'op-xyz', + leaseId: 'lease-xyz', + tenantAccountId: 'GTENANTXXX', + amount: '250.0000000', + assetCode: 'XLM', + assetIssuer: null, + transactionHash: 'tx-hash-001', + paidAt: new Date().toISOString(), + }; + + db.insertPayment(payment); + const found = db.getPaymentByHorizonOpId('op-xyz'); + + expect(found).not.toBeNull(); + expect(found.amount).toBe('250.0000000'); + expect(found.tenantAccountId).toBe('GTENANTXXX'); + }); + + test('listPaymentsByLeaseId returns only matching records', () => { + const db = makeDb(); + + db.insertPayment({ horizonOperationId: 'op-1', leaseId: 'lease-A', tenantAccountId: 'GT1', amount: '100', assetCode: 'XLM', assetIssuer: null, transactionHash: 'tx1', paidAt: new Date().toISOString() }); + db.insertPayment({ horizonOperationId: 'op-2', leaseId: 'lease-B', tenantAccountId: 'GT2', amount: '200', assetCode: 'XLM', assetIssuer: null, transactionHash: 'tx2', paidAt: new Date().toISOString() }); + + const results = db.listPaymentsByLeaseId('lease-A'); + expect(results).toHaveLength(1); + expect(results[0].horizonOpId).toBe('op-1'); + }); + + test('listPaymentsByTenantAccount returns only matching records', () => { + const db = makeDb(); + + db.insertPayment({ horizonOperationId: 'op-T1', leaseId: null, tenantAccountId: 'GT_ALICE', amount: '50', assetCode: 'XLM', assetIssuer: null, transactionHash: 'txA', paidAt: new Date().toISOString() }); + db.insertPayment({ horizonOperationId: 'op-T2', leaseId: null, tenantAccountId: 'GT_BOB', amount: '75', assetCode: 'XLM', assetIssuer: null, transactionHash: 'txB', paidAt: new Date().toISOString() }); + + expect(db.listPaymentsByTenantAccount('GT_ALICE')).toHaveLength(1); + expect(db.listPaymentsByTenantAccount('GT_BOB')).toHaveLength(1); + }); + + test('getActiveLeaseByTenantAccount returns null when tenant has no active lease', () => { + const db = makeDb(); + expect(db.getActiveLeaseByTenantAccount('GNONEXISTENT')).toBeNull(); + }); + + test('getActiveLeaseByTenantAccount returns active lease for matching account', () => { + const db = makeDb(); + seedLease(db, { tenantAccountId: 'GTENANTABC' }); + + const found = db.getActiveLeaseByTenantAccount('GTENANTABC'); + expect(found).not.toBeNull(); + expect(found.id).toBe('lease-001'); + }); +});