Implement Marketplace Reporting API create flow and success webhook tracking
This commit is contained in:
parent
8ba675e45a
commit
6ff3d800f4
@ -20,6 +20,10 @@ Source checked: Uber Eats "Webhook" section shared by you.
|
|||||||
- mapped via `store_id`
|
- mapped via `store_id`
|
||||||
- records latest menu refresh request metadata on `uber_connections`
|
- records latest menu refresh request metadata on `uber_connections`
|
||||||
- stores webhook UUID and `X-Environment` marker
|
- 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
|
## Existing Before
|
||||||
|
|
||||||
|
|||||||
@ -27,6 +27,7 @@ Common event types handled:
|
|||||||
- `store.deprovisioned`
|
- `store.deprovisioned`
|
||||||
- `store.menu_refresh_request`
|
- `store.menu_refresh_request`
|
||||||
- `store.status.changed`
|
- `store.status.changed`
|
||||||
|
- `eats.report.success`
|
||||||
|
|
||||||
Menu refresh handling:
|
Menu refresh handling:
|
||||||
|
|
||||||
|
|||||||
@ -6,6 +6,7 @@ Source checked: Uber Eats "Reporting Guide" section shared by you.
|
|||||||
|
|
||||||
- Dedicated Reporting route:
|
- Dedicated Reporting route:
|
||||||
- `POST /api/v1/uber/reporting/fetch`
|
- `POST /api/v1/uber/reporting/fetch`
|
||||||
|
- `POST /api/v1/uber/reporting/create`
|
||||||
- Uses `eats.report` client-credentials scope.
|
- Uses `eats.report` client-credentials scope.
|
||||||
- Retry policy implemented for safe transient failures only:
|
- Retry policy implemented for safe transient failures only:
|
||||||
- `429`, `408`, `500`, `502`, `503`, `504`, and network errors
|
- `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
|
- ignore unknown extra columns
|
||||||
- tolerate missing columns with null/default behavior
|
- tolerate missing columns with null/default behavior
|
||||||
- report missing required headers in response metadata
|
- 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
|
## Existing Before
|
||||||
|
|
||||||
@ -24,7 +30,5 @@ Source checked: Uber Eats "Reporting Guide" section shared by you.
|
|||||||
|
|
||||||
## Pending
|
## Pending
|
||||||
|
|
||||||
- Final typed endpoint wrappers for specific reporting reference endpoints once exact paths are shared
|
- Overnight polling scheduler/job orchestration for report section downloads
|
||||||
- Overnight polling scheduler/job orchestration
|
|
||||||
- Reconciliation materialization tables for settled vs provisional values
|
- Reconciliation materialization tables for settled vs provisional values
|
||||||
|
|
||||||
|
|||||||
@ -9,6 +9,7 @@ Reporting focus:
|
|||||||
Typed route:
|
Typed route:
|
||||||
|
|
||||||
- `POST /api/v1/uber/reporting/fetch`
|
- `POST /api/v1/uber/reporting/fetch`
|
||||||
|
- `POST /api/v1/uber/reporting/create`
|
||||||
|
|
||||||
Key behavior:
|
Key behavior:
|
||||||
|
|
||||||
@ -16,4 +17,6 @@ Key behavior:
|
|||||||
- Retries transient errors only (`408`, `429`, `500`, `502`, `503`, `504`, network timeouts)
|
- Retries transient errors only (`408`, `429`, `500`, `502`, `503`, `504`, network timeouts)
|
||||||
- Parses CSV by header names (not fixed column positions)
|
- Parses CSV by header names (not fixed column positions)
|
||||||
- Tolerates unknown columns and missing optional fields
|
- Tolerates unknown columns and missing optional fields
|
||||||
|
- Reporting job flow:
|
||||||
|
- create job returns `workflow_id`
|
||||||
|
- completion arrives via webhook `eats.report.success`
|
||||||
|
|||||||
28
docs/developer-portal/20-marketplace-reporting-api-audit.md
Normal file
28
docs/developer-portal/20-marketplace-reporting-api-audit.md
Normal 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.
|
||||||
@ -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": {
|
"/api/v1/webhooks/uber": {
|
||||||
"post": {
|
"post": {
|
||||||
"summary": "Ingest Uber webhook events",
|
"summary": "Ingest Uber webhook events",
|
||||||
|
|||||||
@ -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",
|
"name": "Set Holiday Hours",
|
||||||
"request": {
|
"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",
|
"name": "Generic Uber Request",
|
||||||
"request": {
|
"request": {
|
||||||
|
|||||||
@ -11,5 +11,6 @@ module.exports = {
|
|||||||
webhookRepository: repositories.webhookRepository,
|
webhookRepository: repositories.webhookRepository,
|
||||||
apiLogRepository: repositories.apiLogRepository,
|
apiLogRepository: repositories.apiLogRepository,
|
||||||
appTokenRepository: repositories.appTokenRepository,
|
appTokenRepository: repositories.appTokenRepository,
|
||||||
tokenRequestLogRepository: repositories.tokenRequestLogRepository
|
tokenRequestLogRepository: repositories.tokenRequestLogRepository,
|
||||||
|
reportJobRepository: repositories.reportJobRepository
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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 = {
|
module.exports = {
|
||||||
merchantRepository,
|
merchantRepository,
|
||||||
uberConnectionRepository,
|
uberConnectionRepository,
|
||||||
webhookRepository,
|
webhookRepository,
|
||||||
apiLogRepository,
|
apiLogRepository,
|
||||||
appTokenRepository,
|
appTokenRepository,
|
||||||
tokenRequestLogRepository
|
tokenRequestLogRepository,
|
||||||
|
reportJobRepository
|
||||||
};
|
};
|
||||||
|
|||||||
@ -97,6 +97,26 @@ function initSchema() {
|
|||||||
grant_type TEXT NOT NULL,
|
grant_type TEXT NOT NULL,
|
||||||
requested_at 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")) {
|
if (!tableHasColumn("webhook_events", "resource_id")) {
|
||||||
@ -130,6 +150,19 @@ function initSchema() {
|
|||||||
if (!tableHasColumn("uber_connections", "last_webhook_environment")) {
|
if (!tableHasColumn("uber_connections", "last_webhook_environment")) {
|
||||||
db.exec("ALTER TABLE uber_connections ADD COLUMN last_webhook_environment TEXT");
|
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 = {
|
module.exports = {
|
||||||
|
|||||||
@ -1,5 +1,34 @@
|
|||||||
const { z } = require("zod");
|
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({
|
const fetchSchema = z.object({
|
||||||
method: z.enum(["GET", "POST"]).default("GET"),
|
method: z.enum(["GET", "POST"]).default("GET"),
|
||||||
@ -28,7 +57,109 @@ async function fetchReportingCsv(req, res) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = {
|
function parseDateLike(input, allowDateTime) {
|
||||||
fetchReportingCsv
|
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
|
||||||
|
};
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
const axios = require("axios");
|
const axios = require("axios");
|
||||||
const env = require("../../config/env");
|
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 { getCachedClientCredentialsToken, AUTH_SCOPES } = require("../auth/auth.service");
|
||||||
const { normalizeUberError, isRetryableUberError } = require("../common/errors/uberError");
|
const { normalizeUberError, isRetryableUberError } = require("../common/errors/uberError");
|
||||||
const { withExponentialBackoffRetry } = require("../common/http/retry");
|
const { withExponentialBackoffRetry } = require("../common/http/retry");
|
||||||
@ -155,6 +155,93 @@ async function fetchReport({
|
|||||||
}
|
}
|
||||||
|
|
||||||
module.exports = {
|
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,
|
fetchReport,
|
||||||
parseCsvByHeader
|
parseCsvByHeader
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,6 +1,10 @@
|
|||||||
const crypto = require("crypto");
|
const crypto = require("crypto");
|
||||||
const env = require("../../config/env");
|
const env = require("../../config/env");
|
||||||
const { webhookRepository, uberConnectionRepository } = require("../../db/adapter");
|
const {
|
||||||
|
webhookRepository,
|
||||||
|
uberConnectionRepository,
|
||||||
|
reportJobRepository
|
||||||
|
} = require("../../db/adapter");
|
||||||
|
|
||||||
function getSignatureFromHeaders(headers) {
|
function getSignatureFromHeaders(headers) {
|
||||||
const signature = headers["x-uber-signature"];
|
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) {
|
async function handleUberWebhook(req, res) {
|
||||||
if (!verifyBasicAuthIfConfigured(req)) {
|
if (!verifyBasicAuthIfConfigured(req)) {
|
||||||
return res.status(401).json({
|
return res.status(401).json({
|
||||||
@ -151,6 +175,7 @@ async function handleUberWebhook(req, res) {
|
|||||||
|
|
||||||
applyProvisioningStateFromWebhook(eventType, req.body || {});
|
applyProvisioningStateFromWebhook(eventType, req.body || {});
|
||||||
applyMenuRefreshRequestFromWebhook(eventType, req.body || {}, req.headers || {});
|
applyMenuRefreshRequestFromWebhook(eventType, req.body || {}, req.headers || {});
|
||||||
|
applyReportSuccessFromWebhook(eventType, req.body || {});
|
||||||
|
|
||||||
return res.status(200).end();
|
return res.status(200).end();
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,9 @@
|
|||||||
const express = require("express");
|
const express = require("express");
|
||||||
const asyncHandler = require("../middleware/asyncHandler");
|
const asyncHandler = require("../middleware/asyncHandler");
|
||||||
const { fetchReportingCsv } = require("../modules/reporting/reporting.controller");
|
const {
|
||||||
|
fetchReportingCsv,
|
||||||
|
createMarketplaceReport
|
||||||
|
} = require("../modules/reporting/reporting.controller");
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
@ -17,5 +20,17 @@ const router = express.Router();
|
|||||||
*/
|
*/
|
||||||
router.post("/uber/reporting/fetch", asyncHandler(fetchReportingCsv));
|
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;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user