Skip to content

Commit a406dec

Browse files
Merge pull request #68 from chigozirim007/feature/rent-payment-tracker
feat: implement real-time rent payment tracker (#16)
2 parents e0226b6 + c45db71 commit a406dec

File tree

9 files changed

+926
-8
lines changed

9 files changed

+926
-8
lines changed

.env.example

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,3 +59,9 @@ TWILIO_PHONE_NUMBER=+1234567890
5959
# Owner Configuration (for auto-reclaim)
6060
# Owner Account (for executing reclaim transactions)
6161
OWNER_MNEMONIC=
62+
63+
# Real-Time Rent Payment Tracker (Issue #16)
64+
# Cron expression for how often to poll Horizon for new payments (default: every minute)
65+
PAYMENT_TRACKER_CRON=* * * * *
66+
# Override the Horizon base URL used by the payment tracker (defaults to testnet)
67+
LEASEFLOW_HORIZON_URL=https://horizon-testnet.stellar.org

index.js

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,9 @@ const { NotificationService } = require('./src/services/notificationService');
1919
const { SorobanLeaseService } = require('./src/services/sorobanLeaseService');
2020
const { LeaseRenewalService } = require('./src/services/leaseRenewalService');
2121
const { LeaseRenewalJob, startLeaseRenewalScheduler } = require('./src/jobs/leaseRenewalJob');
22+
const { RentPaymentTrackerService } = require('./services/rentPaymentTrackerService');
23+
const { startPaymentTrackerJob } = require('./src/jobs/paymentTrackerJob');
24+
const { createPaymentRoutes } = require('./src/routes/paymentRoutes');
2225
const { getUSDCToFiatRates, getXLMToUSDCPath } = require('./services/priceFeedService');
2326
const AvailabilityService = require('./services/availabilityService');
2427
const AssetMetadataService = require('./services/assetMetadataService');
@@ -80,6 +83,65 @@ function createApp(dependencies = {}) {
8083

8184
// Middleware
8285
app.use(cors());
86+
app.use(express.json());
87+
const express = require('express');
88+
const cors = require('cors');
89+
const { randomUUID } = require('crypto');
90+
91+
const multer = require('multer');
92+
const path = require('path');
93+
const fs = require('fs');
94+
const sharp = require('sharp');
95+
const app = express();
96+
const port = 3000;
97+
const creditScoreAggregator = new TenantCreditScoreAggregator();
98+
const listings = [];
99+
const HORIZON_URL = process.env.HORIZON_URL || 'https://horizon.stellar.org';
100+
101+
const uploadDir = path.join(__dirname, 'uploads');
102+
if (!fs.existsSync(uploadDir)) {
103+
fs.mkdirSync(uploadDir, { recursive: true });
104+
}
105+
106+
const storage = multer.diskStorage({
107+
destination: (req, file, cb) => cb(null, uploadDir),
108+
filename: (req, file, cb) => cb(null, `${Date.now()}_${file.originalname}`)
109+
});
110+
const upload = multer({ storage });
111+
112+
// Middleware
113+
app.use(cors());
114+
app.use(express.json({ limit: '50mb' }));
115+
app.use(express.urlencoded({ extended: true, limit: '50mb' }));
116+
117+
// Routes
118+
app.use('/api/leases', leaseRoutes);
119+
app.use('/api/owners', ownerRoutes);
120+
app.use('/api', createPaymentRoutes(database));
121+
122+
app.get('/', (req, res) => {
123+
res.json({
124+
project: 'LeaseFlow Protocol Backend',
125+
description: 'Secure Lease Indexer and Storage Facilitator',
126+
status: 'Operational',
127+
version: '1.0.0',
128+
contract_id: process.env.CONTRACT_ID || 'CAEGD57WVTVQSYWYB23AISBW334QO7WNA5XQ56S45GH6BP3D2AVHKUG4',
129+
endpoints: {
130+
upload_lease: 'POST /api/leases/upload',
131+
view_lease_handshake: 'GET /api/leases/:leaseCID/handshake',
132+
top_owners: 'GET /api/owners/top'
133+
}
134+
app.use(express.json());
135+
app.use('/uploads', express.static(path.join(__dirname, 'uploads')));
136+
const {
137+
createConditionProofService,
138+
ConditionProofError,
139+
} = require('./services/conditionProofService');
140+
const {
141+
createFileConditionProofStore,
142+
} = require('./services/conditionProofStore');
143+
144+
const port = process.env.PORT || 3000;
83145
app.use(express.json({ limit: '50mb' }));
84146
app.use(express.urlencoded({ extended: true, limit: '50mb' }));
85147

@@ -253,6 +315,53 @@ if (require.main === module) {
253315
}).catch(err => {
254316
console.warn('AutoReclaimWorker failed to initialize:', err.message);
255317
});
318+
},
319+
);
320+
321+
return app;
322+
}
323+
324+
const app = createApp();
325+
326+
if (require.main === module) {
327+
let scheduler;
328+
329+
if (config.jobs.renewalJobEnabled) {
330+
const database = new AppDatabase(config.database.filename);
331+
const notificationService = new NotificationService(database);
332+
const sorobanLeaseService = new SorobanLeaseService(config);
333+
const leaseRenewalService = new LeaseRenewalService(
334+
database,
335+
notificationService,
336+
sorobanLeaseService,
337+
config,
338+
);
339+
scheduler = startLeaseRenewalScheduler(new LeaseRenewalJob(leaseRenewalService), config);
340+
}
341+
342+
const paymentTrackerDb = new AppDatabase(config.database.filename);
343+
const paymentTrackerService = new RentPaymentTrackerService(paymentTrackerDb, {
344+
contractAccountId: config.contracts.defaultContractId,
345+
});
346+
startPaymentTrackerJob(paymentTrackerService, {
347+
cronExpression: process.env.PAYMENT_TRACKER_CRON || '* * * * *',
348+
});
349+
350+
app.listen(port, () => {
351+
console.log(`LeaseFlow Backend running at http://localhost:${port}`);
352+
console.log(`Lease Encryption Service: Active`);
353+
console.log(`IPFS Storage Service: Initialized (Host: ${process.env.IPFS_HOST || 'ipfs.infura.io'})`);
354+
console.log(`LeaseFlow Backend listening at http://localhost:${port}`);
355+
if (scheduler) {
356+
console.log(`Lease renewal scheduler running every ${config.jobs.intervalMs}ms`);
357+
}
358+
const autoReclaimWorker = new AutoReclaimWorker();
359+
360+
autoReclaimWorker.initialize().then(() => {
361+
autoReclaimWorker.start();
362+
app.listen(port, () => {
363+
console.log(`LeaseFlow Backend listening at http://localhost:${port}`);
364+
console.log('Auto-Reclaim Worker started');
256365
});
257366
});
258367
}
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
-- Migration: Add payment_history table and extend leases with payment tracking columns
2+
-- Issue #16: Real-Time Rent Payment Tracker
3+
4+
-- payment_history stores every detected Horizon payment event
5+
CREATE TABLE IF NOT EXISTS payment_history (
6+
id TEXT PRIMARY KEY,
7+
horizon_op_id TEXT NOT NULL UNIQUE,
8+
lease_id TEXT,
9+
tenant_account_id TEXT NOT NULL,
10+
amount TEXT NOT NULL,
11+
asset_code TEXT NOT NULL DEFAULT 'XLM',
12+
asset_issuer TEXT,
13+
transaction_hash TEXT NOT NULL,
14+
paid_at TEXT NOT NULL,
15+
recorded_at TEXT NOT NULL
16+
);
17+
18+
CREATE INDEX IF NOT EXISTS idx_payment_history_lease_id
19+
ON payment_history (lease_id);
20+
21+
CREATE INDEX IF NOT EXISTS idx_payment_history_tenant_account
22+
ON payment_history (tenant_account_id);
23+
24+
CREATE INDEX IF NOT EXISTS idx_payment_history_paid_at
25+
ON payment_history (paid_at DESC);
26+
27+
-- Extend leases table with payment-tracking columns
28+
ALTER TABLE leases ADD COLUMN IF NOT EXISTS tenant_account_id TEXT;
29+
ALTER TABLE leases ADD COLUMN IF NOT EXISTS payment_status TEXT NOT NULL DEFAULT 'pending';
30+
ALTER TABLE leases ADD COLUMN IF NOT EXISTS last_payment_at TEXT;

package.json

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,16 +9,21 @@
99
},
1010
"dependencies": {
1111
"@stellar/stellar-sdk": "^14.6.1",
12+
"algosdk": "^2.0.0",
1213
"axios": "^1.13.6",
1314
"busboy": "^1.6.0",
14-
"algosdk": "^2.0.0",
1515
"cors": "^2.8.6",
1616
"crypto-js": "^4.2.0",
1717
"dotenv": "^17.3.1",
1818
"express": "^5.2.1",
1919
"lodash": "^4.17.23",
2020
"eciesjs": "^0.4.18",
2121
"ipfs-http-client": "^60.0.1",
22+
"lodash": "^4.17.23",
23+
"multer": "^2.1.1",
24+
"node-cron": "^3.0.3",
25+
"pg": "^8.11.3",
26+
"sharp": "^0.34.5"
2227
"multer": "^2.1.1",
2328
"sharp": "^0.34.5",
2429
"pg": "^8.11.3",
Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
/**
2+
* RentPaymentTrackerService
3+
*
4+
* Monitors the Stellar Horizon API for `payment` operations directed at the
5+
* LeaseFlow contract account. When a new payment is detected it is recorded
6+
* in the `payment_history` table and the parent lease `payment_status` is
7+
* updated accordingly — giving landlords a "Stripe-like" real-time view of
8+
* tenant rent payments.
9+
*/
10+
11+
const axios = require('axios');
12+
13+
const HORIZON_BASE_URL =
14+
process.env.HORIZON_URL || 'https://horizon-testnet.stellar.org';
15+
16+
/** How many operations to fetch per Horizon page (max 200). */
17+
const PAGE_LIMIT = 200;
18+
19+
class RentPaymentTrackerService {
20+
/**
21+
* @param {import('../src/db/appDatabase').AppDatabase} database
22+
* @param {{contractAccountId?: string}} [options]
23+
*/
24+
constructor(database, options = {}) {
25+
this.database = database;
26+
/** The Stellar account (contract or landlord escrow) to watch. */
27+
this.contractAccountId =
28+
options.contractAccountId ||
29+
process.env.SOROBAN_CONTRACT_ID ||
30+
process.env.CONTRACT_ID ||
31+
'CAEGD57WVTVQSYWYB23AISBW334QO7WNA5XQ56S45GH6BP3D2AVHKUG4';
32+
33+
/**
34+
* Horizon paging_token of the last successfully processed operation.
35+
* Stored in-memory so each poll only fetches genuinely new operations
36+
* instead of re-fetching (and skipping) the same page every minute.
37+
* @type {string|null}
38+
*/
39+
this._lastPagingToken = null;
40+
}
41+
42+
/**
43+
* Poll Horizon for new payments made TO the contract account.
44+
* Cursor-based — only fetches operations newer than the last poll.
45+
* Idempotent — repeated calls do not create duplicate records.
46+
*
47+
* @returns {Promise<{processed: number, skipped: number, errors: Array<{id: string, message: string}>}>}
48+
*/
49+
async poll() {
50+
const result = { processed: 0, skipped: 0, errors: [] };
51+
52+
// Use ascending order + cursor so we only get operations we haven't seen yet.
53+
let url =
54+
`${HORIZON_BASE_URL}/accounts/${encodeURIComponent(this.contractAccountId)}` +
55+
`/payments?limit=${PAGE_LIMIT}&order=asc&include_failed=false`;
56+
57+
if (this._lastPagingToken) {
58+
url += `&cursor=${encodeURIComponent(this._lastPagingToken)}`;
59+
}
60+
61+
const response = await this._fetchHorizon(url);
62+
const records = response?._embedded?.records ?? [];
63+
64+
let lastToken = null;
65+
for (const op of records) {
66+
try {
67+
const outcome = await this._processPaymentOperation(op);
68+
if (outcome === 'recorded') {
69+
result.processed += 1;
70+
} else {
71+
result.skipped += 1;
72+
}
73+
} catch (err) {
74+
result.errors.push({ id: op.id, message: err.message });
75+
}
76+
// Always advance the cursor, even for skipped ops, so we never
77+
// re-scan the same page on the next poll.
78+
if (op.paging_token) {
79+
lastToken = op.paging_token;
80+
}
81+
}
82+
83+
if (lastToken) {
84+
this._lastPagingToken = lastToken;
85+
}
86+
87+
return result;
88+
}
89+
90+
/**
91+
* Process a single Horizon payment operation record.
92+
*
93+
* @param {object} op Horizon payment operation object.
94+
* @returns {Promise<'recorded'|'skipped'>}
95+
*/
96+
async _processPaymentOperation(op) {
97+
// We only care about incoming credit operations (payment / path_payment).
98+
if (!['payment', 'path_payment_strict_send', 'path_payment_strict_receive'].includes(op.type)) {
99+
return 'skipped';
100+
}
101+
102+
// The payment must be directed *to* the contract account.
103+
if (op.to !== this.contractAccountId) {
104+
return 'skipped';
105+
}
106+
107+
// Deduplicate — skip if this Horizon operation id is already recorded.
108+
if (this.database.getPaymentByHorizonOpId(op.id)) {
109+
return 'skipped';
110+
}
111+
112+
// Try to resolve the lease by matching the sender (tenant Stellar account).
113+
const tenantAccountId = op.from;
114+
const lease = this.database.getActiveLeaseByTenantAccount(tenantAccountId);
115+
116+
const leaseId = lease?.id ?? null;
117+
118+
const payment = {
119+
horizonOperationId: op.id,
120+
leaseId,
121+
tenantAccountId,
122+
amount: op.amount,
123+
assetCode: op.asset_code || 'XLM',
124+
assetIssuer: op.asset_issuer || null,
125+
transactionHash: op.transaction_hash,
126+
paidAt: op.created_at,
127+
};
128+
129+
this.database.insertPayment(payment);
130+
131+
// Update the lease payment status if we matched a lease.
132+
if (leaseId) {
133+
this.database.updateLeasePaymentStatus(leaseId, 'paid', op.created_at);
134+
}
135+
136+
return 'recorded';
137+
}
138+
139+
/**
140+
* Fetch a URL from Horizon and return parsed JSON.
141+
*
142+
* @param {string} url
143+
* @returns {Promise<object>}
144+
*/
145+
async _fetchHorizon(url) {
146+
const response = await axios.get(url, {
147+
headers: { Accept: 'application/json' },
148+
timeout: 10_000,
149+
});
150+
return response.data;
151+
}
152+
}
153+
154+
module.exports = { RentPaymentTrackerService };

0 commit comments

Comments
 (0)