Implement Marketplace Reporting API create flow and success webhook tracking

This commit is contained in:
MOHAN 2026-03-29 18:57:14 +05:30
parent 8ba675e45a
commit 6ff3d800f4
14 changed files with 561 additions and 14 deletions

View File

@ -20,6 +20,10 @@ Source checked: Uber Eats "Webhook" section shared by you.
- mapped via `store_id`
- records latest menu refresh request metadata on `uber_connections`
- stores webhook UUID and `X-Environment` marker
- Added explicit event handling for `eats.report.success`:
- maps by `job_id` / `workflow_id`
- marks report jobs completed
- persists report sections metadata for download orchestration
## Existing Before

View File

@ -27,6 +27,7 @@ Common event types handled:
- `store.deprovisioned`
- `store.menu_refresh_request`
- `store.status.changed`
- `eats.report.success`
Menu refresh handling:

View File

@ -6,6 +6,7 @@ Source checked: Uber Eats "Reporting Guide" section shared by you.
- Dedicated Reporting route:
- `POST /api/v1/uber/reporting/fetch`
- `POST /api/v1/uber/reporting/create`
- Uses `eats.report` client-credentials scope.
- Retry policy implemented for safe transient failures only:
- `429`, `408`, `500`, `502`, `503`, `504`, and network errors
@ -16,6 +17,11 @@ Source checked: Uber Eats "Reporting Guide" section shared by you.
- ignore unknown extra columns
- tolerate missing columns with null/default behavior
- report missing required headers in response metadata
- Marketplace Reporting API async flow aligned:
- `POST /v1/eats/report` wrapper support with report-type/date constraints
- workflow tracking via `workflow_id`
- webhook completion handling for `eats.report.success`
- report sections metadata persisted on completion
## Existing Before
@ -24,7 +30,5 @@ Source checked: Uber Eats "Reporting Guide" section shared by you.
## Pending
- Final typed endpoint wrappers for specific reporting reference endpoints once exact paths are shared
- Overnight polling scheduler/job orchestration
- Overnight polling scheduler/job orchestration for report section downloads
- Reconciliation materialization tables for settled vs provisional values

View File

@ -9,6 +9,7 @@ Reporting focus:
Typed route:
- `POST /api/v1/uber/reporting/fetch`
- `POST /api/v1/uber/reporting/create`
Key behavior:
@ -16,4 +17,6 @@ Key behavior:
- 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
- Reporting job flow:
- create job returns `workflow_id`
- completion arrives via webhook `eats.report.success`

View File

@ -0,0 +1,28 @@
# 20 Marketplace Reporting API 1.0.0 Audit
Source checked: Uber Eats "Marketplace Reporting API (1.0.0)" section shared by you.
## Implemented Now
- Added official create-report wrapper route:
- `POST /api/v1/uber/reporting/create`
- upstream `POST /v1/eats/report`
- returns `workflow_id`
- Added request validation:
- `report_type` enum validation (10 report types)
- requires at least one `store_uuids` or `group_uuids`
- validates date format by report type
- validates `start_date <= end_date`
- validates range/lookback constraints from the docs
- Added report job persistence (`report_jobs`):
- workflow tracking + report type + request windows + status
- Added webhook completion handling:
- `eats.report.success` marks job completed by `job_id`
- persists report sections metadata for downstream download workers
- Existing CSV fetch route retained:
- `POST /api/v1/uber/reporting/fetch` for direct CSV pulls where applicable
## Pending
- Worker queue for downloading and processing each `report_metadata.sections[]` URL.
- Retry orchestration for section URL fetches and long-running reconciliation pipelines.

View File

@ -1174,6 +1174,19 @@
}
}
},
"/api/v1/uber/reporting/create": {
"post": {
"summary": "Create Marketplace Reporting API job (POST /v1/eats/report)",
"tags": [
"Uber Reporting"
],
"responses": {
"200": {
"description": "Report creation requested successfully (workflow_id returned)"
}
}
}
},
"/api/v1/webhooks/uber": {
"post": {
"summary": "Ingest Uber webhook events",

View File

@ -1531,6 +1531,39 @@
}
}
},
{
"name": "Create Marketplace Report Job",
"request": {
"method": "POST",
"header": [
{
"key": "x-api-key",
"value": "{{apiKey}}"
},
{
"key": "Content-Type",
"value": "application/json"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"merchantId\": \"{{merchantId}}\",\n \"report_type\": \"ORDERS_AND_ITEMS_REPORT\",\n \"store_uuids\": [\"{{storeId}}\"],\n \"start_date\": \"2026-03-01\",\n \"end_date\": \"2026-03-10\"\n}"
},
"url": {
"raw": "{{baseUrl}}/api/v1/uber/reporting/create",
"host": [
"{{baseUrl}}"
],
"path": [
"api",
"v1",
"uber",
"reporting",
"create"
]
}
}
},
{
"name": "Set Holiday Hours",
"request": {
@ -1753,6 +1786,38 @@
}
}
},
{
"name": "Webhook Ingest - Report Success (Simulation)",
"request": {
"method": "POST",
"header": [
{
"key": "Content-Type",
"value": "application/json"
},
{
"key": "X-Uber-Signature",
"value": "replace-with-valid-hmac"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"event_type\": \"eats.report.success\",\n \"event_id\": \"cd14f0bb-2d8c-44fb-9622-f6a4be18773e_2f7a1bdd-7993-4485-a11b-eacadce96b67\",\n \"job_id\": \"c7e05234-04ca-4460-8b03-d587df71228e\",\n \"report_type\": \"ORDERS_AND_ITEMS_REPORT\",\n \"start_time_ms\": 1742169600000,\n \"end_time_ms\": 1743119999000,\n \"report_metadata\": {\n \"sections\": []\n },\n \"webhook_meta\": {\n \"client_id\": \"ndkjscgfS5bvdiuyhv84sdhviudn\",\n \"webhook_config_id\": \"restaurant-financial-data.road-report-completion\",\n \"webhook_msg_timestamp\": 1743119999000,\n \"webhook_msg_uuid\": \"cd14f0bb-2d8c-44fb-9622-f6a4be18773e\"\n }\n}"
},
"url": {
"raw": "{{baseUrl}}/api/v1/webhooks/uber",
"host": [
"{{baseUrl}}"
],
"path": [
"api",
"v1",
"webhooks",
"uber"
]
}
}
},
{
"name": "Generic Uber Request",
"request": {

View File

@ -11,5 +11,6 @@ module.exports = {
webhookRepository: repositories.webhookRepository,
apiLogRepository: repositories.apiLogRepository,
appTokenRepository: repositories.appTokenRepository,
tokenRequestLogRepository: repositories.tokenRequestLogRepository
tokenRequestLogRepository: repositories.tokenRequestLogRepository,
reportJobRepository: repositories.reportJobRepository
};

View File

@ -459,11 +459,148 @@ const tokenRequestLogRepository = {
}
};
const reportJobRepository = {
createRequested({
merchantId,
workflowId,
reportType,
storeUuids,
groupUuids,
startDate,
endDate
}) {
const existing = this.findByWorkflowId(workflowId);
const timestamp = nowIso();
const row = {
id: existing?.id || uuidv4(),
merchant_id: merchantId || existing?.merchant_id || null,
workflow_id: workflowId,
report_type: reportType,
store_uuids_json: JSON.stringify(storeUuids || []),
group_uuids_json: JSON.stringify(groupUuids || []),
start_date: startDate,
end_date: endDate,
status: existing?.status === "completed" ? "completed" : "requested",
webhook_event_id: existing?.webhook_event_id || null,
webhook_msg_uuid: existing?.webhook_msg_uuid || null,
report_sections_json: existing?.report_sections_json || null,
webhook_payload_json: existing?.webhook_payload_json || null,
created_at: existing?.created_at || timestamp,
completed_at: existing?.completed_at || null,
updated_at: timestamp
};
db.prepare(
`
INSERT INTO report_jobs (
id, merchant_id, workflow_id, report_type, store_uuids_json, group_uuids_json,
start_date, end_date, status, webhook_event_id, webhook_msg_uuid, report_sections_json,
webhook_payload_json, created_at, completed_at, updated_at
)
VALUES (
@id, @merchant_id, @workflow_id, @report_type, @store_uuids_json, @group_uuids_json,
@start_date, @end_date, @status, @webhook_event_id, @webhook_msg_uuid, @report_sections_json,
@webhook_payload_json, @created_at, @completed_at, @updated_at
)
ON CONFLICT(workflow_id) DO UPDATE SET
merchant_id = excluded.merchant_id,
report_type = excluded.report_type,
store_uuids_json = excluded.store_uuids_json,
group_uuids_json = excluded.group_uuids_json,
start_date = excluded.start_date,
end_date = excluded.end_date,
updated_at = excluded.updated_at
`
).run(row);
return this.findByWorkflowId(workflowId);
},
markSuccessFromWebhook({
workflowId,
eventId,
webhookMsgUuid,
reportType,
sections,
payload
}) {
const existing = this.findByWorkflowId(workflowId);
const timestamp = nowIso();
if (!existing) {
const row = {
id: uuidv4(),
merchant_id: null,
workflow_id: workflowId,
report_type: reportType || "UNKNOWN",
store_uuids_json: JSON.stringify([]),
group_uuids_json: JSON.stringify([]),
start_date: "",
end_date: "",
status: "completed",
webhook_event_id: eventId || null,
webhook_msg_uuid: webhookMsgUuid || null,
report_sections_json: JSON.stringify(sections || []),
webhook_payload_json: JSON.stringify(payload || {}),
created_at: timestamp,
completed_at: timestamp,
updated_at: timestamp
};
db.prepare(
`
INSERT INTO report_jobs (
id, merchant_id, workflow_id, report_type, store_uuids_json, group_uuids_json,
start_date, end_date, status, webhook_event_id, webhook_msg_uuid, report_sections_json,
webhook_payload_json, created_at, completed_at, updated_at
)
VALUES (
@id, @merchant_id, @workflow_id, @report_type, @store_uuids_json, @group_uuids_json,
@start_date, @end_date, @status, @webhook_event_id, @webhook_msg_uuid, @report_sections_json,
@webhook_payload_json, @created_at, @completed_at, @updated_at
)
`
).run(row);
return this.findByWorkflowId(workflowId);
}
db.prepare(
`
UPDATE report_jobs
SET status = 'completed',
report_type = COALESCE(?, report_type),
webhook_event_id = ?,
webhook_msg_uuid = ?,
report_sections_json = ?,
webhook_payload_json = ?,
completed_at = ?,
updated_at = ?
WHERE workflow_id = ?
`
).run(
reportType || null,
eventId || null,
webhookMsgUuid || null,
JSON.stringify(sections || []),
JSON.stringify(payload || {}),
timestamp,
timestamp,
workflowId
);
return this.findByWorkflowId(workflowId);
},
findByWorkflowId(workflowId) {
return db.prepare("SELECT * FROM report_jobs WHERE workflow_id = ? LIMIT 1").get(workflowId);
}
};
module.exports = {
merchantRepository,
uberConnectionRepository,
webhookRepository,
apiLogRepository,
appTokenRepository,
tokenRequestLogRepository
tokenRequestLogRepository,
reportJobRepository
};

View File

@ -97,6 +97,26 @@ function initSchema() {
grant_type TEXT NOT NULL,
requested_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS report_jobs (
id TEXT PRIMARY KEY,
merchant_id TEXT,
workflow_id TEXT NOT NULL UNIQUE,
report_type TEXT NOT NULL,
store_uuids_json TEXT,
group_uuids_json TEXT,
start_date TEXT NOT NULL,
end_date TEXT NOT NULL,
status TEXT NOT NULL,
webhook_event_id TEXT,
webhook_msg_uuid TEXT,
report_sections_json TEXT,
webhook_payload_json TEXT,
created_at TEXT NOT NULL,
completed_at TEXT,
updated_at TEXT NOT NULL,
FOREIGN KEY(merchant_id) REFERENCES merchants(id)
);
`);
if (!tableHasColumn("webhook_events", "resource_id")) {
@ -130,6 +150,19 @@ function initSchema() {
if (!tableHasColumn("uber_connections", "last_webhook_environment")) {
db.exec("ALTER TABLE uber_connections ADD COLUMN last_webhook_environment TEXT");
}
if (!tableHasColumn("report_jobs", "webhook_event_id")) {
db.exec("ALTER TABLE report_jobs ADD COLUMN webhook_event_id TEXT");
}
if (!tableHasColumn("report_jobs", "webhook_msg_uuid")) {
db.exec("ALTER TABLE report_jobs ADD COLUMN webhook_msg_uuid TEXT");
}
if (!tableHasColumn("report_jobs", "report_sections_json")) {
db.exec("ALTER TABLE report_jobs ADD COLUMN report_sections_json TEXT");
}
if (!tableHasColumn("report_jobs", "webhook_payload_json")) {
db.exec("ALTER TABLE report_jobs ADD COLUMN webhook_payload_json TEXT");
}
}
module.exports = {

View File

@ -1,5 +1,34 @@
const { z } = require("zod");
const { fetchReport } = require("./reporting.service");
const { fetchReport, createReportJob } = require("./reporting.service");
const REPORT_TYPES = [
"PAYMENT_DETAILS_REPORT",
"ORDER_ERRORS_MENU_ITEM_REPORT",
"ORDER_ERRORS_TRANSACTION_REPORT",
"ORDER_HISTORY_REPORT",
"DOWNTIME_REPORT",
"CUSTOMER_AND_DELIVERY_FEEDBACK_REPORT",
"MENU_ITEM_FEEDBACK_REPORT",
"BILLING_DETAILS_REPORT",
"ORDERS_AND_ITEMS_REPORT",
"FINANCE_SUMMARY_REPORT"
];
const RANGE_LIMIT_DAYS = {
PAYMENT_DETAILS_REPORT: 30,
ORDERS_AND_ITEMS_REPORT: 15,
FINANCE_SUMMARY_REPORT: 30
};
const LOOKBACK_RULES = {
ORDER_ERRORS_MENU_ITEM_REPORT: { minDaysAgo: 188, maxDaysAgo: 2 },
ORDER_ERRORS_TRANSACTION_REPORT: { minDaysAgo: 190, maxDaysAgo: 4 },
ORDER_HISTORY_REPORT: { minDaysAgo: 188, maxDaysAgo: 2 },
DOWNTIME_REPORT: { minDaysAgo: 188, maxDaysAgo: 2 },
CUSTOMER_AND_DELIVERY_FEEDBACK_REPORT: { minDaysAgo: 188, maxDaysAgo: 2 },
MENU_ITEM_FEEDBACK_REPORT: { minDaysAgo: 188, maxDaysAgo: 2 },
BILLING_DETAILS_REPORT: { minDaysAgo: 1825, maxDaysAgo: 2 }
};
const fetchSchema = z.object({
method: z.enum(["GET", "POST"]).default("GET"),
@ -28,7 +57,109 @@ async function fetchReportingCsv(req, res) {
});
}
module.exports = {
fetchReportingCsv
};
function parseDateLike(input, allowDateTime) {
const raw = String(input || "");
if (allowDateTime) {
if (/^\d{4}-\d{2}-\d{2}$/.test(raw)) {
return new Date(`${raw}T00:00:00.000Z`);
}
return new Date(raw);
}
if (!/^\d{4}-\d{2}-\d{2}$/.test(raw)) {
return null;
}
return new Date(`${raw}T00:00:00.000Z`);
}
function daysBetweenInclusive(start, end) {
const msPerDay = 24 * 60 * 60 * 1000;
return Math.floor((end.getTime() - start.getTime()) / msPerDay) + 1;
}
function daysAgo(date) {
const now = new Date();
const utcNow = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()));
const utcDate = new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()));
const msPerDay = 24 * 60 * 60 * 1000;
return Math.floor((utcNow.getTime() - utcDate.getTime()) / msPerDay);
}
const createReportSchema = z
.object({
merchantId: z.string().min(1).optional(),
report_type: z.enum(REPORT_TYPES),
store_uuids: z.array(z.string().uuid()).optional(),
group_uuids: z.array(z.string().uuid()).optional(),
start_date: z.string().min(1),
end_date: z.string().min(1)
})
.refine((value) => (value.store_uuids?.length || 0) + (value.group_uuids?.length || 0) > 0, {
message: "At least one store_uuids or group_uuids value is required."
});
function validateCreateReportConstraints(payload) {
const allowDateTime = payload.report_type === "PAYMENT_DETAILS_REPORT";
const start = parseDateLike(payload.start_date, allowDateTime);
const end = parseDateLike(payload.end_date, allowDateTime);
if (!start || Number.isNaN(start.getTime()) || !end || Number.isNaN(end.getTime())) {
const error = new Error("Invalid report date format for selected report_type.");
error.status = 400;
throw error;
}
if (start.getTime() > end.getTime()) {
const error = new Error("start_date must be on or before end_date.");
error.status = 414;
throw error;
}
const rangeLimit = RANGE_LIMIT_DAYS[payload.report_type];
if (rangeLimit) {
const rangeDays = daysBetweenInclusive(start, end);
if (rangeDays > rangeLimit) {
const error = new Error(
`${payload.report_type} supports a maximum range period of ${rangeLimit} days.`
);
error.status = 416;
throw error;
}
}
const lookback = LOOKBACK_RULES[payload.report_type];
if (lookback) {
const startAgo = daysAgo(start);
const endAgo = daysAgo(end);
const startOutOfWindow = startAgo < lookback.maxDaysAgo || startAgo > lookback.minDaysAgo;
const endOutOfWindow = endAgo < lookback.maxDaysAgo || endAgo > lookback.minDaysAgo;
if (startOutOfWindow || endOutOfWindow) {
const error = new Error(
`${payload.report_type} must be within [T-${lookback.minDaysAgo}, T-${lookback.maxDaysAgo}] days.`
);
error.status = 416;
throw error;
}
}
}
async function createMarketplaceReport(req, res) {
const payload = createReportSchema.parse(req.body || {});
validateCreateReportConstraints(payload);
const data = await createReportJob({
merchantId: payload.merchantId || null,
reportType: payload.report_type,
storeUuids: payload.store_uuids || [],
groupUuids: payload.group_uuids || [],
startDate: payload.start_date,
endDate: payload.end_date
});
return res.json({
success: true,
data
});
}
module.exports = {
fetchReportingCsv,
createMarketplaceReport
};

View File

@ -1,6 +1,6 @@
const axios = require("axios");
const env = require("../../config/env");
const { apiLogRepository } = require("../../db/adapter");
const { apiLogRepository, reportJobRepository } = require("../../db/adapter");
const { getCachedClientCredentialsToken, AUTH_SCOPES } = require("../auth/auth.service");
const { normalizeUberError, isRetryableUberError } = require("../common/errors/uberError");
const { withExponentialBackoffRetry } = require("../common/http/retry");
@ -155,6 +155,93 @@ async function fetchReport({
}
module.exports = {
async createReportJob({
merchantId,
reportType,
storeUuids,
groupUuids,
startDate,
endDate
}) {
const token = await getCachedClientCredentialsToken({
scope: AUTH_SCOPES.REPORT
});
const body = {
report_type: reportType,
store_uuids: storeUuids || [],
group_uuids: groupUuids || [],
start_date: startDate,
end_date: endDate
};
try {
const response = await withExponentialBackoffRetry({
fn: async () =>
reportingClient.request({
method: "POST",
url: "/v1/eats/report",
data: body,
headers: {
Authorization: buildAuthorizationHeader(token.token_type, token.access_token),
"Content-Type": "application/json"
}
}),
maxAttempts: 4,
baseDelayMs: 400,
shouldRetry: (error) => isRetryableUberError(error)
});
const workflowId = response?.data?.workflow_id;
if (!workflowId) {
const error = new Error("Missing workflow_id in report creation response.");
error.status = 502;
throw error;
}
reportJobRepository.createRequested({
merchantId,
workflowId,
reportType,
storeUuids,
groupUuids,
startDate,
endDate
});
apiLogRepository.insert({
merchantId,
method: "POST",
wrapperRoute: "/api/v1/uber/reporting/create",
uberPath: "/v1/eats/report",
responseStatus: response.status,
requestBody: body,
responseBody: response.data
});
return {
workflow_id: workflowId
};
} catch (error) {
const normalized = normalizeUberError(error);
apiLogRepository.insert({
merchantId,
method: "POST",
wrapperRoute: "/api/v1/uber/reporting/create",
uberPath: "/v1/eats/report",
responseStatus: normalized.status,
requestBody: body,
responseBody: {
code: normalized.code,
message: normalized.message,
transient: normalized.transient,
details:
typeof normalized.details === "string" ? { raw: normalized.details } : normalized.details
}
});
throw normalized;
}
},
fetchReport,
parseCsvByHeader
};

View File

@ -1,6 +1,10 @@
const crypto = require("crypto");
const env = require("../../config/env");
const { webhookRepository, uberConnectionRepository } = require("../../db/adapter");
const {
webhookRepository,
uberConnectionRepository,
reportJobRepository
} = require("../../db/adapter");
function getSignatureFromHeaders(headers) {
const signature = headers["x-uber-signature"];
@ -104,6 +108,26 @@ function applyMenuRefreshRequestFromWebhook(eventType, payload, headers) {
});
}
function applyReportSuccessFromWebhook(eventType, payload) {
if (eventType !== "eats.report.success") {
return;
}
const workflowId = payload?.job_id;
if (!workflowId) {
return;
}
reportJobRepository.markSuccessFromWebhook({
workflowId: String(workflowId),
eventId: payload?.event_id || null,
webhookMsgUuid: payload?.webhook_meta?.webhook_msg_uuid || null,
reportType: payload?.report_type || null,
sections: payload?.report_metadata?.sections || [],
payload
});
}
async function handleUberWebhook(req, res) {
if (!verifyBasicAuthIfConfigured(req)) {
return res.status(401).json({
@ -151,6 +175,7 @@ async function handleUberWebhook(req, res) {
applyProvisioningStateFromWebhook(eventType, req.body || {});
applyMenuRefreshRequestFromWebhook(eventType, req.body || {}, req.headers || {});
applyReportSuccessFromWebhook(eventType, req.body || {});
return res.status(200).end();
}

View File

@ -1,6 +1,9 @@
const express = require("express");
const asyncHandler = require("../middleware/asyncHandler");
const { fetchReportingCsv } = require("../modules/reporting/reporting.controller");
const {
fetchReportingCsv,
createMarketplaceReport
} = require("../modules/reporting/reporting.controller");
const router = express.Router();
@ -17,5 +20,17 @@ const router = express.Router();
*/
router.post("/uber/reporting/fetch", asyncHandler(fetchReportingCsv));
module.exports = router;
/**
* @openapi
* /api/v1/uber/reporting/create:
* post:
* summary: Create Marketplace Reporting API job (POST /v1/eats/report)
* tags:
* - Uber Reporting
* responses:
* 200:
* description: Report creation requested successfully (workflow_id returned)
*/
router.post("/uber/reporting/create", asyncHandler(createMarketplaceReport));
module.exports = router;