From 7fe725b00e80dfc93520fe6637bd1455431d3c16 Mon Sep 17 00:00:00 2001 From: MOHAN Date: Sun, 29 Mar 2026 18:03:54 +0530 Subject: [PATCH] feat: add reporting api wrapper with retry policy and header-based csv parsing --- docs/developer-portal/02-api-groups.md | 3 +- docs/developer-portal/16-reporting-audit.md | 30 +++ docs/developer-portal/16-reporting.md | 19 ++ docs/openapi/openapi.json | 13 ++ postman/Uber_Wrapper.postman_collection.json | 33 +++ src/app.js | 2 + src/modules/reporting/reporting.controller.js | 34 ++++ src/modules/reporting/reporting.service.js | 191 ++++++++++++++++++ src/routes/reporting.routes.js | 21 ++ 9 files changed, 344 insertions(+), 2 deletions(-) create mode 100644 docs/developer-portal/16-reporting-audit.md create mode 100644 docs/developer-portal/16-reporting.md create mode 100644 src/modules/reporting/reporting.controller.js create mode 100644 src/modules/reporting/reporting.service.js create mode 100644 src/routes/reporting.routes.js diff --git a/docs/developer-portal/02-api-groups.md b/docs/developer-portal/02-api-groups.md index 73b60d3..02692fa 100644 --- a/docs/developer-portal/02-api-groups.md +++ b/docs/developer-portal/02-api-groups.md @@ -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. - diff --git a/docs/developer-portal/16-reporting-audit.md b/docs/developer-portal/16-reporting-audit.md new file mode 100644 index 0000000..683186e --- /dev/null +++ b/docs/developer-portal/16-reporting-audit.md @@ -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 + diff --git a/docs/developer-portal/16-reporting.md b/docs/developer-portal/16-reporting.md new file mode 100644 index 0000000..4cf8dac --- /dev/null +++ b/docs/developer-portal/16-reporting.md @@ -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 + diff --git a/docs/openapi/openapi.json b/docs/openapi/openapi.json index 1ca3dd8..1918aac 100644 --- a/docs/openapi/openapi.json +++ b/docs/openapi/openapi.json @@ -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", diff --git a/postman/Uber_Wrapper.postman_collection.json b/postman/Uber_Wrapper.postman_collection.json index f9f04de..9b04d81 100644 --- a/postman/Uber_Wrapper.postman_collection.json +++ b/postman/Uber_Wrapper.postman_collection.json @@ -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": { diff --git a/src/app.js b/src/app.js index 4444a8c..459f6f1 100644 --- a/src/app.js +++ b/src/app.js @@ -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)); diff --git a/src/modules/reporting/reporting.controller.js b/src/modules/reporting/reporting.controller.js new file mode 100644 index 0000000..ff48ce8 --- /dev/null +++ b/src/modules/reporting/reporting.controller.js @@ -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 +}; + diff --git a/src/modules/reporting/reporting.service.js b/src/modules/reporting/reporting.service.js new file mode 100644 index 0000000..e2cd43d --- /dev/null +++ b/src/modules/reporting/reporting.service.js @@ -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 +}; + diff --git a/src/routes/reporting.routes.js b/src/routes/reporting.routes.js new file mode 100644 index 0000000..2cdf8fc --- /dev/null +++ b/src/routes/reporting.routes.js @@ -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; +