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
|
- Promotions
|
||||||
- Ads / sponsored listings
|
- Ads / sponsored listings
|
||||||
- Payout and financial reconciliation
|
- Payout and financial reconciliation (Reporting API route now available)
|
||||||
- Store holiday/special schedules
|
- Store holiday/special schedules
|
||||||
- Catalog media sync
|
- Catalog media sync
|
||||||
- Audit/event feed replay
|
- Audit/event feed replay
|
||||||
@ -28,4 +28,3 @@ Use:
|
|||||||
- `POST /api/v1/uber/request`
|
- `POST /api/v1/uber/request`
|
||||||
|
|
||||||
This endpoint is the fallback for any Uber endpoint that is not yet added as a typed shortcut route.
|
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": {
|
"/api/v1/webhooks/uber": {
|
||||||
"post": {
|
"post": {
|
||||||
"summary": "Ingest Uber webhook events",
|
"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",
|
"name": "Set Holiday Hours",
|
||||||
"request": {
|
"request": {
|
||||||
|
|||||||
@ -13,6 +13,7 @@ const connectionRoutes = require("./routes/connections.routes");
|
|||||||
const proxyRoutes = require("./routes/proxy.routes");
|
const proxyRoutes = require("./routes/proxy.routes");
|
||||||
const webhookRoutes = require("./routes/webhooks.routes");
|
const webhookRoutes = require("./routes/webhooks.routes");
|
||||||
const metricsRoutes = require("./routes/metrics.routes");
|
const metricsRoutes = require("./routes/metrics.routes");
|
||||||
|
const reportingRoutes = require("./routes/reporting.routes");
|
||||||
|
|
||||||
const app = express();
|
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, connectionRoutes);
|
||||||
app.use("/api/v1", requireWrapperApiKey, proxyRoutes);
|
app.use("/api/v1", requireWrapperApiKey, proxyRoutes);
|
||||||
app.use("/api/v1", requireWrapperApiKey, metricsRoutes);
|
app.use("/api/v1", requireWrapperApiKey, metricsRoutes);
|
||||||
|
app.use("/api/v1", requireWrapperApiKey, reportingRoutes);
|
||||||
app.use("/api/v1", webhookRoutes);
|
app.use("/api/v1", webhookRoutes);
|
||||||
app.use("/docs", swaggerUi.serve, swaggerUi.setup(spec));
|
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