feat: add reporting api wrapper with retry policy and header-based csv parsing
This commit is contained in:
parent
8eb11897a5
commit
7fe725b00e
@ -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.
|
||||
|
||||
|
||||
30
docs/developer-portal/16-reporting-audit.md
Normal file
30
docs/developer-portal/16-reporting-audit.md
Normal 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
|
||||
|
||||
19
docs/developer-portal/16-reporting.md
Normal file
19
docs/developer-portal/16-reporting.md
Normal 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
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -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));
|
||||
|
||||
|
||||
34
src/modules/reporting/reporting.controller.js
Normal file
34
src/modules/reporting/reporting.controller.js
Normal 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
|
||||
};
|
||||
|
||||
191
src/modules/reporting/reporting.service.js
Normal file
191
src/modules/reporting/reporting.service.js
Normal 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
|
||||
};
|
||||
|
||||
21
src/routes/reporting.routes.js
Normal file
21
src/routes/reporting.routes.js
Normal 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;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user