feat: add reporting api wrapper with retry policy and header-based csv parsing

This commit is contained in:
MOHAN 2026-03-29 18:03:54 +05:30
parent 8eb11897a5
commit 7fe725b00e
9 changed files with 344 additions and 2 deletions

View File

@ -16,7 +16,7 @@ This file intentionally separates high-priority vs extended APIs for easier team
- Promotions
- Ads / sponsored listings
- Payout and financial reconciliation
- Payout and financial reconciliation (Reporting API route now available)
- Store holiday/special schedules
- Catalog media sync
- Audit/event feed replay
@ -28,4 +28,3 @@ Use:
- `POST /api/v1/uber/request`
This endpoint is the fallback for any Uber endpoint that is not yet added as a typed shortcut route.

View File

@ -0,0 +1,30 @@
# 16 Reporting Audit
Source checked: Uber Eats "Reporting Guide" section shared by you.
## Implemented Now
- Dedicated Reporting route:
- `POST /api/v1/uber/reporting/fetch`
- Uses `eats.report` client-credentials scope.
- Retry policy implemented for safe transient failures only:
- `429`, `408`, `500`, `502`, `503`, `504`, and network errors
- total attempts: 4 (initial + 3 retries)
- exponential backoff + jitter
- CSV ingestion strategy aligned:
- parse by header names
- ignore unknown extra columns
- tolerate missing columns with null/default behavior
- report missing required headers in response metadata
## Existing Before
- Store listing and details APIs for installed-store checks
- Order API and operational metrics
## Pending
- Final typed endpoint wrappers for specific reporting reference endpoints once exact paths are shared
- Overnight polling scheduler/job orchestration
- Reconciliation materialization tables for settled vs provisional values

View File

@ -0,0 +1,19 @@
# 16 Reporting
Reporting focus:
- Long-term historical analysis
- Financial reconciliation and payout matching
- Batch ingestion across many stores
Typed route:
- `POST /api/v1/uber/reporting/fetch`
Key behavior:
- Uses `eats.report` app scope
- Retries transient errors only (`408`, `429`, `500`, `502`, `503`, `504`, network timeouts)
- Parses CSV by header names (not fixed column positions)
- Tolerates unknown columns and missing optional fields

View File

@ -641,6 +641,19 @@
}
}
},
"/api/v1/uber/reporting/fetch": {
"post": {
"summary": "Fetch Uber reporting CSV with retries and header-based parsing",
"tags": [
"Uber Reporting"
],
"responses": {
"200": {
"description": "Reporting CSV fetched"
}
}
}
},
"/api/v1/webhooks/uber": {
"post": {
"summary": "Ingest Uber webhook events",

View File

@ -549,6 +549,39 @@
}
}
},
{
"name": "Fetch Reporting CSV",
"request": {
"method": "POST",
"header": [
{
"key": "x-api-key",
"value": "{{apiKey}}"
},
{
"key": "Content-Type",
"value": "application/json"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"method\": \"GET\",\n \"upstreamPath\": \"/v1/eats/reports/payment_details\",\n \"query\": {\n \"storeUUIDs\": \"{{storeId}}\",\n \"startDate\": \"2026-03-01\",\n \"endDate\": \"2026-03-02\"\n },\n \"parseCsv\": true,\n \"requiredHeaders\": [\"order_id\", \"store_id\"]\n}"
},
"url": {
"raw": "{{baseUrl}}/api/v1/uber/reporting/fetch",
"host": [
"{{baseUrl}}"
],
"path": [
"api",
"v1",
"uber",
"reporting",
"fetch"
]
}
}
},
{
"name": "Set Holiday Hours",
"request": {

View File

@ -13,6 +13,7 @@ const connectionRoutes = require("./routes/connections.routes");
const proxyRoutes = require("./routes/proxy.routes");
const webhookRoutes = require("./routes/webhooks.routes");
const metricsRoutes = require("./routes/metrics.routes");
const reportingRoutes = require("./routes/reporting.routes");
const app = express();
@ -34,6 +35,7 @@ app.use("/api/v1/auth", requireWrapperApiKey, authRoutes);
app.use("/api/v1", requireWrapperApiKey, connectionRoutes);
app.use("/api/v1", requireWrapperApiKey, proxyRoutes);
app.use("/api/v1", requireWrapperApiKey, metricsRoutes);
app.use("/api/v1", requireWrapperApiKey, reportingRoutes);
app.use("/api/v1", webhookRoutes);
app.use("/docs", swaggerUi.serve, swaggerUi.setup(spec));

View File

@ -0,0 +1,34 @@
const { z } = require("zod");
const { fetchReport } = require("./reporting.service");
const fetchSchema = z.object({
method: z.enum(["GET", "POST"]).default("GET"),
upstreamPath: z.string().min(1),
query: z.record(z.string(), z.any()).optional(),
body: z.any().optional(),
parseCsv: z.boolean().optional(),
requiredHeaders: z.array(z.string()).optional()
});
async function fetchReportingCsv(req, res) {
const payload = fetchSchema.parse(req.body || {});
const data = await fetchReport({
...payload
});
return res.json({
success: true,
data: {
headers: data.parsed?.headers || [],
rowCount: data.parsed?.rows?.length || 0,
missingRequiredHeaders: data.parsed?.missingRequiredHeaders || [],
rows: data.parsed?.rows || [],
rawCsv: data.rawCsv
}
});
}
module.exports = {
fetchReportingCsv
};

View File

@ -0,0 +1,191 @@
const axios = require("axios");
const env = require("../../config/env");
const { apiLogRepository } = require("../../db/adapter");
const { getCachedClientCredentialsToken, AUTH_SCOPES } = require("../auth/auth.service");
const reportingClient = axios.create({
baseURL: env.UBER_API_BASE_URL,
timeout: 45000
});
const RETRYABLE_STATUSES = new Set([408, 429, 500, 502, 503, 504]);
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function buildAuthorizationHeader(tokenType, accessToken) {
return `${tokenType || "Bearer"} ${accessToken}`;
}
function isRetryableError(error) {
if (!error.response) {
return true;
}
return RETRYABLE_STATUSES.has(error.response.status);
}
async function withRetry(fn, maxAttempts = 4, baseDelayMs = 300) {
let attempt = 0;
let lastError = null;
while (attempt < maxAttempts) {
try {
return await fn();
} catch (error) {
lastError = error;
attempt += 1;
if (attempt >= maxAttempts || !isRetryableError(error)) {
throw error;
}
const jitter = Math.floor(Math.random() * 200);
const delay = baseDelayMs * 2 ** attempt + jitter;
await sleep(delay);
}
}
throw lastError;
}
function parseCsvLine(line) {
const output = [];
let current = "";
let inQuotes = false;
for (let i = 0; i < line.length; i += 1) {
const char = line[i];
const next = line[i + 1];
if (char === '"') {
if (inQuotes && next === '"') {
current += '"';
i += 1;
} else {
inQuotes = !inQuotes;
}
continue;
}
if (char === "," && !inQuotes) {
output.push(current);
current = "";
continue;
}
current += char;
}
output.push(current);
return output;
}
function parseCsvByHeader(csvText, requiredHeaders = []) {
const lines = String(csvText || "")
.replace(/\r\n/g, "\n")
.replace(/\r/g, "\n")
.split("\n")
.filter((line) => line.trim().length > 0);
if (lines.length === 0) {
return {
headers: [],
rows: [],
missingRequiredHeaders: requiredHeaders
};
}
const headers = parseCsvLine(lines[0]).map((value) => value.trim());
const headerSet = new Set(headers);
const missingRequiredHeaders = requiredHeaders.filter((header) => !headerSet.has(header));
const rows = lines.slice(1).map((line) => {
const values = parseCsvLine(line);
const row = {};
headers.forEach((header, index) => {
row[header] = values[index] ?? null;
});
return row;
});
return {
headers,
rows,
missingRequiredHeaders
};
}
async function fetchReport({
method = "GET",
upstreamPath,
query,
body,
parseCsv = true,
requiredHeaders = [],
wrapperRoute = "/api/v1/uber/reporting/fetch"
}) {
const token = await getCachedClientCredentialsToken({
scope: AUTH_SCOPES.REPORT
});
const requestMethod = String(method || "GET").toUpperCase();
try {
const response = await withRetry(
async () =>
reportingClient.request({
method: requestMethod,
url: upstreamPath,
params: query,
data: body,
responseType: "text",
headers: {
Authorization: buildAuthorizationHeader(token.token_type, token.access_token),
"Content-Type": "application/json"
}
}),
4,
400
);
const textData =
typeof response.data === "string" ? response.data : JSON.stringify(response.data || {});
const parsed = parseCsv ? parseCsvByHeader(textData, requiredHeaders) : null;
const payload = {
rawCsv: textData,
parsed
};
apiLogRepository.insert({
method: requestMethod,
wrapperRoute,
uberPath: upstreamPath,
responseStatus: response.status,
requestBody: body,
responseBody: parseCsv ? { headers: parsed?.headers, rowCount: parsed?.rows?.length || 0 } : {}
});
return payload;
} catch (error) {
const status = error.response?.status || 500;
const responseBody = error.response?.data || { message: error.message };
apiLogRepository.insert({
method: requestMethod,
wrapperRoute,
uberPath: upstreamPath,
responseStatus: status,
requestBody: body,
responseBody: typeof responseBody === "string" ? { raw: responseBody } : responseBody
});
const wrapped = new Error(`Reporting fetch failed: ${status}`);
wrapped.status = status;
wrapped.details = responseBody;
throw wrapped;
}
}
module.exports = {
fetchReport,
parseCsvByHeader
};

View File

@ -0,0 +1,21 @@
const express = require("express");
const asyncHandler = require("../middleware/asyncHandler");
const { fetchReportingCsv } = require("../modules/reporting/reporting.controller");
const router = express.Router();
/**
* @openapi
* /api/v1/uber/reporting/fetch:
* post:
* summary: Fetch Uber reporting CSV with retries and header-based parsing
* tags:
* - Uber Reporting
* responses:
* 200:
* description: Reporting CSV fetched
*/
router.post("/uber/reporting/fetch", asyncHandler(fetchReportingCsv));
module.exports = router;