diff --git a/TODO.md b/TODO.md index 02d03d8..e18e7cb 100644 --- a/TODO.md +++ b/TODO.md @@ -29,6 +29,19 @@ - [ ] Add minimal query handling (if any). - [ ] Add tests (or extend existing test coverage) to validate response shape and error handling. +## Issue #585: New Endpoint GET /account/:id/payment-summary +- [x] Add GET /:id/payment-summary route handler to `src/routes/account.js` +- [x] Add "payment-summary" to reserved words list to prevent routing conflicts +- [x] Returns { success: true, data: { totalSent, totalReceived, volumeSent, volumeReceived, topCounterparty, topAsset } } +- [x] All volume values are seven-decimal strings +- [x] Returns zeroed values for accounts with no payment history rather than a 404 + +## Issue #579: Add ?assets= filter to GET /account/:id/balances +- [x] Add optional ?assets= query param parsing to /balances route +- [x] "XLM" returns only native balance, "CODE:ISSUER" filters asset balances +- [x] Invalid identifiers are ignored +- [x] Returns empty array when no assets match + ## Repo integrity - [ ] Resolve merge conflict markers in `src/index.js` (currently present as `<<<<<<< HEAD` / `=======` / `>>>>>>>`). - [ ] Ensure `npm test` passes. diff --git a/src/routes/account.js b/src/routes/account.js index 407550c..1f11626 100644 --- a/src/routes/account.js +++ b/src/routes/account.js @@ -271,6 +271,13 @@ router.get("/:id/trustlines", async (req, res, next) => { /** * GET /account/:id/balances + * + * Returns XLM and asset balances for an account. + * + * Query params: + * - assets (string, optional) — comma-separated asset identifiers to filter by. + * Format: "XLM" for native, "CODE:ISSUER" for issued assets. + * Invalid identifiers are ignored. Example: ?assets=XLM,USDC:GA... */ router.get("/:id/balances", async (req, res, next) => { try { @@ -280,6 +287,42 @@ router.get("/:id/balances", async (req, res, next) => { const account = await server.loadAccount(id); const formatted = formatAccountBalances(account); + const assetsFilter = req.query.assets; + if (assetsFilter) { + const requested = assetsFilter.split(",").map((s) => s.trim()).filter(Boolean); + const matches = []; + + for (const identifier of requested) { + if (identifier.toUpperCase() === "XLM") { + matches.push("__xlm__"); + continue; + } + const colonIdx = identifier.indexOf(":"); + if (colonIdx === -1) continue; // invalid, ignore + const code = identifier.slice(0, colonIdx).toUpperCase(); + const issuer = identifier.slice(colonIdx + 1); + if (!code || !issuer) continue; + matches.push(`${code}:${issuer}`); + } + + const matchSet = new Set(matches); + + // Filter xlm + if (!matchSet.has("__xlm__")) { + formatted.xlm = { + balance: "0.0000000", + buyingLiabilities: "0.0000000", + sellingLiabilities: "0.0000000", + }; + } + + // Filter assets + formatted.assets = formatted.assets.filter((a) => { + const key = `${a.asset.code}:${a.asset.issuer}`; + return matchSet.has(key); + }); + } + return success(res, formatted); } catch (err) { handleAccountNotFound(err, next, req.params.id); @@ -537,7 +580,8 @@ router.get("/:id/payments", async (req, res, next) => { "trustlines", "analytics", "balances", "summary", "sponsorship", "subentry-health", "merge-eligibility", "offers", "payments", "operation-breakdown", "offer-history", "timeline", "data", - "pool-positions", "risk-score", "trustline-health", "age", "volume" + "pool-positions", "risk-score", "trustline-health", "age", "volume", + "payment-summary" ]; if (reservedWords.includes(id)) { return next(); @@ -835,7 +879,7 @@ router.get("/:id/offers", async (req, res, next) => { const hasMore = (offerResponse.records || []).length === limit; const nextCursor = hasMore ? (offerResponse.records[offerResponse.records.length - 1] || {}) - .paging_token + .paging_token : null; return success(res, { @@ -1123,19 +1167,19 @@ router.get("/:id/analytics", async (req, res, next) => { : null; const lastSeen = successfulTransactions[successfulTransactions.length - 1] ? toISOTimestamp( - successfulTransactions[successfulTransactions.length - 1].created_at, - ) + successfulTransactions[successfulTransactions.length - 1].created_at, + ) : null; const activeDays = firstSeen && lastSeen ? Math.max( - 1, - Math.ceil( - (new Date(lastSeen).getTime() - new Date(firstSeen).getTime()) / - 86400000, - ), - ) + 1, + Math.ceil( + (new Date(lastSeen).getTime() - new Date(firstSeen).getTime()) / + 86400000, + ), + ) : 0; return success(res, { @@ -1625,11 +1669,11 @@ router.get( normalizedAssetCode === "XLM" ? (account.balances || []).find((b) => b.asset_type === "native") : (account.balances || []).find( - (b) => - b.asset_type !== "native" && - b.asset_code === normalizedAssetCode && - b.asset_issuer === assetIssuer, - ); + (b) => + b.asset_type !== "native" && + b.asset_code === normalizedAssetCode && + b.asset_issuer === assetIssuer, + ); if (!trustline) { const notFoundErr = new Error( @@ -1996,6 +2040,133 @@ router.get("/:id/volume", async (req, res, next) => { } }); +/** + * GET /account/:id/payment-summary + * + * Returns a summary of an account's payment activity, including: + * - totalSent: Number of payments sent + * - totalReceived: Number of payments received + * - volumeSent: Total volume sent (7-decimal string) + * - volumeReceived: Total volume received (7-decimal string) + * - topCounterparty: Most frequent counterparty (public key or null) + * - topAsset: Most used asset object { code, issuer, type } or null + * + * Paginates through the entire payment history via the Horizon payments + * endpoint. Returns zeroed values for accounts with no payment history + * rather than a 404. + * + * @example + * GET /account/GABC.../payment-summary + */ +router.get("/:id/payment-summary", async (req, res, next) => { + try { + const { id } = req.params; + validateAccountId(id); + + // Ensure account exists (404 for non-existent accounts) + await server.loadAccount(id); + + let totalSent = 0; + let totalReceived = 0; + let volumeSent = 0; + let volumeReceived = 0; + const counterpartyCounts = {}; + const assetCounts = {}; + + let cursor; + let done = false; + + while (!done) { + let query = server.payments().forAccount(id).limit(200).order("asc"); + if (cursor) query = query.cursor(cursor); + + const page = await query.call(); + const records = page.records || []; + + if (records.length === 0) break; + + for (const op of records) { + if (op.type !== "payment" && op.type !== "create_account") continue; + + const isSent = + (op.type === "payment" && op.from === id) || + (op.type === "create_account" && op.funder === id); + + const counterparty = isSent + ? op.type === "payment" + ? op.to + : op.account + : op.type === "payment" + ? op.from + : op.funder; + + const amount = parseFloat(op.amount || op.starting_balance || "0"); + + if (isSent) { + totalSent++; + volumeSent += amount; + } else { + totalReceived++; + volumeReceived += amount; + } + + // Track counterparty frequency + if (counterparty) { + counterpartyCounts[counterparty] = (counterpartyCounts[counterparty] || 0) + 1; + } + + // Track asset frequency + const assetCode = op.asset_code || "XLM"; + const assetIssuer = op.asset_issuer || null; + const assetType = op.asset_type || (assetCode === "XLM" ? "native" : null); + const assetKey = assetIssuer ? `${assetCode}:${assetIssuer}` : assetCode; + if (!assetCounts[assetKey]) { + assetCounts[assetKey] = { + count: 0, + asset: normalizeAsset(assetCode, assetIssuer, assetType), + }; + } + assetCounts[assetKey].count++; + + cursor = op.paging_token; + } + + if (records.length < 200) done = true; + } + + // Find top counterparty + let topCounterparty = null; + let maxCounterpartyCount = 0; + for (const [key, count] of Object.entries(counterpartyCounts)) { + if (count > maxCounterpartyCount) { + maxCounterpartyCount = count; + topCounterparty = key; + } + } + + // Find top asset + let topAsset = null; + let maxAssetCount = 0; + for (const entry of Object.values(assetCounts)) { + if (entry.count > maxAssetCount) { + maxAssetCount = entry.count; + topAsset = entry.asset; + } + } + + return success(res, { + totalSent, + totalReceived, + volumeSent: volumeSent.toFixed(7), + volumeReceived: volumeReceived.toFixed(7), + topCounterparty, + topAsset, + }); + } catch (err) { + handleAccountNotFound(err, next, req.params.id); + } +}); + /** * GET /account/:id/offer-history */ @@ -2581,9 +2752,9 @@ router.get("/:id/signing-keys", async (req, res, next) => { const data = minWeight !== null ? { - ...cached, - signers: cached.signers.filter((s) => s.weight >= minWeight), - } + ...cached, + signers: cached.signers.filter((s) => s.weight >= minWeight), + } : cached; return success(res, data); } @@ -3144,4 +3315,7 @@ router.get("/:id/trustline-health", async (req, res, next) => { }); + + + module.exports = router;