Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
210 changes: 192 additions & 18 deletions src/routes/account.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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);
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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, {
Expand Down Expand Up @@ -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, {
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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
*/
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -3144,4 +3315,7 @@ router.get("/:id/trustline-health", async (req, res, next) => {
});





module.exports = router;
Loading