From 226b71529312cc9524fea41a46b1e8d05e0f5a83 Mon Sep 17 00:00:00 2001 From: Ankit Kumar Gupta Date: Tue, 11 Aug 2026 15:22:22 +0530 Subject: [PATCH 1/3] feat(cost_ingestion): scope settlement webhooks by merchant --- docs/api-refs/cost-ingestion-setup.mdx | 23 ++- .../down.sql | 39 +++- .../up.sql | 99 +++------ .../down.sql | 3 + .../up.sql | 3 + .../down.sql | 36 +++- .../down.sql | 4 + .../up.sql | 4 + src/app.rs | 2 +- src/cost_ingestion/creds.rs | 190 ++++++++++++++---- src/cost_ingestion/poller.rs | 35 +++- src/cost_ingestion/store.rs | 19 +- src/cost_ingestion/worker.rs | 9 +- src/routes/settlement_webhook.rs | 53 +++-- 14 files changed, 371 insertions(+), 148 deletions(-) create mode 100644 migrations/2026-08-11-000001_cost_ingestion_merchant_scoped_dedupe/down.sql create mode 100644 migrations/2026-08-11-000001_cost_ingestion_merchant_scoped_dedupe/up.sql create mode 100644 migrations_pg/2026-08-11-000001_cost_ingestion_merchant_scoped_dedupe/down.sql create mode 100644 migrations_pg/2026-08-11-000001_cost_ingestion_merchant_scoped_dedupe/up.sql diff --git a/docs/api-refs/cost-ingestion-setup.mdx b/docs/api-refs/cost-ingestion-setup.mdx index 55d377a5..efce9add 100644 --- a/docs/api-refs/cost-ingestion-setup.mdx +++ b/docs/api-refs/cost-ingestion-setup.mdx @@ -82,19 +82,36 @@ Returns `204 No Content`. Deleting a source that isn't configured also returns s Once credentials are registered, point the connector's settlement-report notification at: ``` -POST /webhooks/settlement/:connector +POST /webhooks/settlement/:merchant_id/:connector ``` -This route is **public** (no `AUTH_HEADER`) — the connector authenticates itself via its own signature, which Decision Engine verifies against the `webhook_secret` stored for that `(connector, account)` pair. Adyen is the first supported connector (`:connector = adyen`). +This route is **public** (no `AUTH_HEADER`) — the connector authenticates itself via its own signature, which Decision Engine verifies against the `webhook_secret` stored for that `(merchant_id, connector, account)` triple. Adyen is the first supported connector (`:connector = adyen`). ```bash -curl --location "$BASE_URL/webhooks/settlement/adyen" \ +curl --location "$BASE_URL/webhooks/settlement/merchant_demo/adyen" \ --header "Content-Type: application/json" \ --data @adyen-settlement-notification.json ``` The handler ACKs immediately and enqueues the report for background processing — download, parse, and re-fit all happen asynchronously in the ingest worker, so the connector always gets a fast response. A bad signature returns `401`; an unrecognized connector or malformed payload returns `400`. + + The URL carries your `merchant_id` because a connector's notification names only its own account (for Adyen, the `merchantAccountCode`) — never your Decision Engine merchant. Registering another merchant's id gets a caller nowhere without that merchant's signing secret, since the path only selects which stored `webhook_secret` the signature is checked against. + + +### Sharing one connector account between two merchants + +Two Decision Engine merchants can ingest from the **same** connector account — a shared platform account, for example. Register a separate webhook endpoint at the connector for each merchant, pointing at that merchant's own URL: + +``` +POST /webhooks/settlement/merchant_a/adyen +POST /webhooks/settlement/merchant_b/adyen +``` + +Each merchant stores their own credentials for the shared account, so the endpoints may use different HMAC keys and different report-download users (the same key on both also works). The connector delivers its report-ready event to both endpoints and each merchant gets their own ingestion job. + +One endpoint cannot serve two merchants: it points at a single URL naming a single merchant, so only that merchant receives deliveries. The other will show its source as configured with no ingested data. + ## Notes - Manual and webhook/poll-based ingestions share the same pipeline and history — see [Uploads](https://github.com/juspay/decision-engine/blob/main/docs/api-refs/cost-ingestion-uploads.mdx) for the manual path and ingestion history. diff --git a/migrations/00000000000000_diesel_initial_setup/down.sql b/migrations/00000000000000_diesel_initial_setup/down.sql index a60dc179..c5f0c681 100644 --- a/migrations/00000000000000_diesel_initial_setup/down.sql +++ b/migrations/00000000000000_diesel_initial_setup/down.sql @@ -1,6 +1,39 @@ --- This file was automatically created by Diesel to setup helper functions --- and other internal bookkeeping. This file is safe to edit, any future --- changes will be added to existing projects as new migrations. +USE jdb; + +DROP TABLE IF EXISTS co_badged_cards_info_test; +DROP TABLE IF EXISTS routing_algorithm; +DROP TABLE IF EXISTS tenant_config_filter; +DROP TABLE IF EXISTS merchant_gateway_account; +DROP TABLE IF EXISTS user_eligibility_info; +DROP TABLE IF EXISTS gateway_bank_emi_support; +DROP TABLE IF EXISTS emi_bank_code; +DROP TABLE IF EXISTS juspay_bank_code; +DROP TABLE IF EXISTS gateway_card_info; +DROP TABLE IF EXISTS card_info; +DROP TABLE IF EXISTS txn_detail; +DROP TABLE IF EXISTS txn_card_info; +DROP TABLE IF EXISTS payment_method; +DROP TABLE IF EXISTS service_configuration; +DROP TABLE IF EXISTS merchant_config; +DROP TABLE IF EXISTS feature; +DROP TABLE IF EXISTS isin_routes; +DROP TABLE IF EXISTS txn_offer; +DROP TABLE IF EXISTS merchant_gateway_payment_method_flow; +DROP TABLE IF EXISTS merchant_account; +DROP TABLE IF EXISTS merchant_gateway_card_info; +DROP TABLE IF EXISTS txn_offer_detail; +DROP TABLE IF EXISTS token_bin_info; +DROP TABLE IF EXISTS merchant_iframe_preferences; +DROP TABLE IF EXISTS gateway_payment_method_flow; +DROP TABLE IF EXISTS merchant_gateway_account_sub_info; +DROP TABLE IF EXISTS issuer_routes; +DROP TABLE IF EXISTS card_brand_routes; +DROP TABLE IF EXISTS tenant_config; +DROP TABLE IF EXISTS merchant_priority_logic; +DROP TABLE IF EXISTS gateway_outage; +DROP TABLE IF EXISTS gateway_bank_emi_support_v2; + +DROP DATABASE IF EXISTS jdb; DROP FUNCTION IF EXISTS diesel_manage_updated_at(_tbl regclass); DROP FUNCTION IF EXISTS diesel_set_updated_at(); \ No newline at end of file diff --git a/migrations/00000000000000_diesel_initial_setup/up.sql b/migrations/00000000000000_diesel_initial_setup/up.sql index 9e7cbc44..2ffdf3d2 100644 --- a/migrations/00000000000000_diesel_initial_setup/up.sql +++ b/migrations/00000000000000_diesel_initial_setup/up.sql @@ -1,11 +1,9 @@ -DROP DATABASE IF EXISTS jdb; -CREATE DATABASE jdb; +CREATE DATABASE IF NOT EXISTS jdb; USE jdb; SET FOREIGN_KEY_CHECKS = 0; -DROP TABLE IF EXISTS gateway_bank_emi_support_v2; -CREATE TABLE gateway_bank_emi_support_v2 ( +CREATE TABLE IF NOT EXISTS gateway_bank_emi_support_v2 ( id BIGINT PRIMARY KEY AUTO_INCREMENT, version BIGINT NOT NULL, gateway VARCHAR(255) NOT NULL, @@ -20,8 +18,7 @@ CREATE TABLE gateway_bank_emi_support_v2 ( last_updated DATETIME ); -DROP TABLE IF EXISTS gateway_outage; -CREATE TABLE gateway_outage ( +CREATE TABLE IF NOT EXISTS gateway_outage ( id VARCHAR(255) PRIMARY KEY, version INT NOT NULL, end_time DATETIME NOT NULL, @@ -38,8 +35,7 @@ CREATE TABLE gateway_outage ( metadata TEXT ); -DROP TABLE IF EXISTS merchant_priority_logic; -CREATE TABLE merchant_priority_logic ( +CREATE TABLE IF NOT EXISTS merchant_priority_logic ( id VARCHAR(255) PRIMARY KEY, version BIGINT NOT NULL, date_created DATETIME NOT NULL, @@ -53,8 +49,7 @@ CREATE TABLE merchant_priority_logic ( is_active_logic bit(1) NOT NULL ); -DROP TABLE IF EXISTS tenant_config; -CREATE TABLE tenant_config ( +CREATE TABLE IF NOT EXISTS tenant_config ( id VARCHAR(255) PRIMARY KEY, type VARCHAR(255) NOT NULL, module_key VARCHAR(255) NOT NULL, @@ -67,8 +62,7 @@ CREATE TABLE tenant_config ( country_code_alpha_3 VARCHAR(3) ); -DROP TABLE IF EXISTS card_brand_routes; -CREATE TABLE card_brand_routes ( +CREATE TABLE IF NOT EXISTS card_brand_routes ( id BIGINT PRIMARY KEY AUTO_INCREMENT, card_brand TEXT NOT NULL, date_created DATETIME NOT NULL, @@ -78,8 +72,7 @@ CREATE TABLE card_brand_routes ( preferred_gateway TEXT NOT NULL ); -DROP TABLE IF EXISTS issuer_routes; -CREATE TABLE issuer_routes ( +CREATE TABLE IF NOT EXISTS issuer_routes ( id BIGINT PRIMARY KEY AUTO_INCREMENT, issuer TEXT NOT NULL, merchant_id TEXT NOT NULL, @@ -89,8 +82,7 @@ CREATE TABLE issuer_routes ( last_updated DATETIME NOT NULL ); -DROP TABLE IF EXISTS merchant_gateway_account_sub_info; -CREATE TABLE merchant_gateway_account_sub_info ( +CREATE TABLE IF NOT EXISTS merchant_gateway_account_sub_info ( id BIGINT PRIMARY KEY AUTO_INCREMENT, merchant_gateway_account_id BIGINT NOT NULL, sub_info_type TEXT NOT NULL, @@ -100,8 +92,7 @@ CREATE TABLE merchant_gateway_account_sub_info ( disabled bit(1) NOT NULL ); -DROP TABLE IF EXISTS gateway_payment_method_flow; -CREATE TABLE gateway_payment_method_flow ( +CREATE TABLE IF NOT EXISTS gateway_payment_method_flow ( id TEXT NOT NULL, gateway_payment_flow_id TEXT NOT NULL, payment_method_id BIGINT, @@ -120,8 +111,7 @@ CREATE TABLE gateway_payment_method_flow ( PRIMARY KEY (id(255)) ); -DROP TABLE IF EXISTS merchant_iframe_preferences; -CREATE TABLE merchant_iframe_preferences ( +CREATE TABLE IF NOT EXISTS merchant_iframe_preferences ( id INT AUTO_INCREMENT PRIMARY KEY, merchant_id TEXT NOT NULL, dynamic_switching_enabled bit(1), @@ -131,8 +121,7 @@ CREATE TABLE merchant_iframe_preferences ( card_brand_routing_enabled bit(1) ); -DROP TABLE IF EXISTS token_bin_info; -CREATE TABLE token_bin_info ( +CREATE TABLE IF NOT EXISTS token_bin_info ( token_bin TEXT NOT NULL, card_bin TEXT NOT NULL, provider TEXT NOT NULL, @@ -140,8 +129,7 @@ CREATE TABLE token_bin_info ( last_updated DATETIME ); -DROP TABLE IF EXISTS txn_offer_detail; -CREATE TABLE txn_offer_detail ( +CREATE TABLE IF NOT EXISTS txn_offer_detail ( id TEXT NOT NULL, txn_detail_id TEXT NOT NULL, offer_id TEXT NOT NULL, @@ -154,8 +142,7 @@ CREATE TABLE txn_offer_detail ( PRIMARY KEY (id(255)) ); -DROP TABLE IF EXISTS merchant_gateway_card_info; -CREATE TABLE merchant_gateway_card_info ( +CREATE TABLE IF NOT EXISTS merchant_gateway_card_info ( id BIGINT AUTO_INCREMENT PRIMARY KEY, disabled bit(1) NOT NULL, gateway_card_info_id BIGINT NOT NULL, @@ -164,8 +151,7 @@ CREATE TABLE merchant_gateway_card_info ( merchant_gateway_account_id BIGINT ); -DROP TABLE IF EXISTS merchant_account; -CREATE TABLE merchant_account ( +CREATE TABLE IF NOT EXISTS merchant_account ( id BIGINT PRIMARY KEY AUTO_INCREMENT, merchant_id TEXT, date_created DATETIME NOT NULL, @@ -190,8 +176,7 @@ CREATE TABLE merchant_account ( merchant_category_code TEXT ); -DROP TABLE IF EXISTS merchant_gateway_payment_method_flow; -CREATE TABLE merchant_gateway_payment_method_flow ( +CREATE TABLE IF NOT EXISTS merchant_gateway_payment_method_flow ( id BIGINT PRIMARY KEY AUTO_INCREMENT, gateway_payment_method_flow_id TEXT NOT NULL, merchant_gateway_account_id BIGINT NOT NULL, @@ -202,8 +187,7 @@ CREATE TABLE merchant_gateway_payment_method_flow ( gateway_bank_code TEXT ); -DROP TABLE IF EXISTS txn_offer; -CREATE TABLE txn_offer ( +CREATE TABLE IF NOT EXISTS txn_offer ( id BIGINT PRIMARY KEY AUTO_INCREMENT, version BIGINT NOT NULL, discount_amount BIGINT NOT NULL, @@ -212,8 +196,7 @@ CREATE TABLE txn_offer ( txn_detail_id BIGINT NOT NULL ); -DROP TABLE IF EXISTS isin_routes; -CREATE TABLE isin_routes ( +CREATE TABLE IF NOT EXISTS isin_routes ( id BIGINT PRIMARY KEY AUTO_INCREMENT, isin TEXT NOT NULL, merchant_id TEXT NOT NULL, @@ -223,16 +206,14 @@ CREATE TABLE isin_routes ( last_updated DATETIME NOT NULL ); -DROP TABLE IF EXISTS feature; -CREATE TABLE feature ( +CREATE TABLE IF NOT EXISTS feature ( id INT AUTO_INCREMENT PRIMARY KEY, enabled bit(1) NOT NULL, name TEXT NOT NULL, merchant_id TEXT NULL ); -DROP TABLE IF EXISTS merchant_config; -CREATE TABLE merchant_config ( +CREATE TABLE IF NOT EXISTS merchant_config ( id TEXT NOT NULL, merchant_account_id BIGINT NOT NULL, config_category TEXT NOT NULL, @@ -244,8 +225,7 @@ CREATE TABLE merchant_config ( PRIMARY KEY (id(255)) ); -DROP TABLE IF EXISTS service_configuration; -CREATE TABLE service_configuration ( +CREATE TABLE IF NOT EXISTS service_configuration ( id BIGINT PRIMARY KEY AUTO_INCREMENT, name TEXT NOT NULL, value TEXT NULL, @@ -254,8 +234,7 @@ CREATE TABLE service_configuration ( new_value_status TEXT NULL ); -DROP TABLE IF EXISTS payment_method; -CREATE TABLE payment_method ( +CREATE TABLE IF NOT EXISTS payment_method ( id BIGINT PRIMARY KEY AUTO_INCREMENT, date_created DATETIME NOT NULL, last_updated DATETIME NOT NULL, @@ -269,8 +248,7 @@ CREATE TABLE payment_method ( dsl TEXT NULL ); -DROP TABLE IF EXISTS txn_card_info; -CREATE TABLE txn_card_info ( +CREATE TABLE IF NOT EXISTS txn_card_info ( id BIGINT PRIMARY KEY AUTO_INCREMENT, txn_id TEXT NOT NULL, card_isin TEXT NULL, @@ -287,8 +265,7 @@ CREATE TABLE txn_card_info ( partition_key DATETIME NULL ); -DROP TABLE IF EXISTS txn_detail; -CREATE TABLE txn_detail ( +CREATE TABLE IF NOT EXISTS txn_detail ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_id TEXT NOT NULL, status TEXT NOT NULL, @@ -320,8 +297,7 @@ CREATE TABLE txn_detail ( txn_amount_breakup TEXT ); -DROP TABLE IF EXISTS card_info; -CREATE TABLE card_info ( +CREATE TABLE IF NOT EXISTS card_info ( card_isin TEXT NOT NULL, card_switch_provider TEXT NOT NULL, card_type TEXT, @@ -333,8 +309,7 @@ CREATE TABLE card_info ( PRIMARY KEY (card_isin(255)) ); -DROP TABLE IF EXISTS gateway_card_info; -CREATE TABLE gateway_card_info ( +CREATE TABLE IF NOT EXISTS gateway_card_info ( id BIGINT PRIMARY KEY AUTO_INCREMENT, isin TEXT, gateway TEXT, @@ -346,23 +321,20 @@ CREATE TABLE gateway_card_info ( payment_method_type TEXT ); -DROP TABLE IF EXISTS juspay_bank_code; -CREATE TABLE juspay_bank_code ( +CREATE TABLE IF NOT EXISTS juspay_bank_code ( id BIGINT PRIMARY KEY AUTO_INCREMENT, bank_code TEXT NOT NULL, bank_name TEXT NOT NULL ); -DROP TABLE IF EXISTS emi_bank_code; -CREATE TABLE emi_bank_code ( +CREATE TABLE IF NOT EXISTS emi_bank_code ( id BIGINT PRIMARY KEY AUTO_INCREMENT, emi_bank TEXT NOT NULL, juspay_bank_code_id BIGINT NOT NULL, last_updated DATETIME ); -DROP TABLE IF EXISTS gateway_bank_emi_support; -CREATE TABLE gateway_bank_emi_support ( +CREATE TABLE IF NOT EXISTS gateway_bank_emi_support ( id BIGINT PRIMARY KEY AUTO_INCREMENT, gateway TEXT NOT NULL, bank TEXT NOT NULL, @@ -370,8 +342,7 @@ CREATE TABLE gateway_bank_emi_support ( scope TEXT ); -DROP TABLE IF EXISTS user_eligibility_info; -CREATE TABLE user_eligibility_info ( +CREATE TABLE IF NOT EXISTS user_eligibility_info ( id TEXT NOT NULL, flow_type TEXT NOT NULL, identifier_name TEXT NOT NULL, @@ -381,8 +352,7 @@ CREATE TABLE user_eligibility_info ( PRIMARY KEY (id(255)) ); -DROP TABLE IF EXISTS merchant_gateway_account; -CREATE TABLE merchant_gateway_account ( +CREATE TABLE IF NOT EXISTS merchant_gateway_account ( id BIGINT PRIMARY KEY AUTO_INCREMENT, account_details TEXT NOT NULL, gateway TEXT NOT NULL, @@ -397,8 +367,7 @@ CREATE TABLE merchant_gateway_account ( supported_txn_type TEXT ); -DROP TABLE IF EXISTS tenant_config_filter; -CREATE TABLE tenant_config_filter ( +CREATE TABLE IF NOT EXISTS tenant_config_filter ( id VARCHAR(255) NOT NULL PRIMARY KEY, filter_group_id VARCHAR(255) NOT NULL, dimension_value VARCHAR(255) NOT NULL, @@ -406,8 +375,7 @@ CREATE TABLE tenant_config_filter ( tenant_config_id VARCHAR(255) NOT NULL ); -DROP TABLE IF EXISTS routing_algorithm; -CREATE TABLE routing_algorithm ( +CREATE TABLE IF NOT EXISTS routing_algorithm ( id VARCHAR(255) NOT NULL PRIMARY KEY, created_by VARCHAR(255) NOT NULL, name VARCHAR(255) NOT NULL, @@ -418,8 +386,7 @@ CREATE TABLE routing_algorithm ( ); SET FOREIGN_KEY_CHECKS = 1; -DROP TABLE IF EXISTS co_badged_cards_info_test; -CREATE TABLE co_badged_cards_info_test ( +CREATE TABLE IF NOT EXISTS co_badged_cards_info_test ( id VARCHAR(64) PRIMARY KEY, card_bin_min BIGINT NOT NULL, card_bin_max BIGINT NOT NULL, diff --git a/migrations/2026-08-11-000001_cost_ingestion_merchant_scoped_dedupe/down.sql b/migrations/2026-08-11-000001_cost_ingestion_merchant_scoped_dedupe/down.sql new file mode 100644 index 00000000..50968cc9 --- /dev/null +++ b/migrations/2026-08-11-000001_cost_ingestion_merchant_scoped_dedupe/down.sql @@ -0,0 +1,3 @@ +ALTER TABLE cost_ingestion + DROP INDEX uq_cost_ingestion_notif, + ADD UNIQUE KEY uq_cost_ingestion_notif (connector, notification_id); diff --git a/migrations/2026-08-11-000001_cost_ingestion_merchant_scoped_dedupe/up.sql b/migrations/2026-08-11-000001_cost_ingestion_merchant_scoped_dedupe/up.sql new file mode 100644 index 00000000..9f3f98ea --- /dev/null +++ b/migrations/2026-08-11-000001_cost_ingestion_merchant_scoped_dedupe/up.sql @@ -0,0 +1,3 @@ +ALTER TABLE cost_ingestion + DROP INDEX uq_cost_ingestion_notif, + ADD UNIQUE KEY uq_cost_ingestion_notif (merchant_id, connector, notification_id); diff --git a/migrations_pg/00000000000000_diesel_postgresql_initial_setup/down.sql b/migrations_pg/00000000000000_diesel_postgresql_initial_setup/down.sql index a60dc179..d1b629d5 100644 --- a/migrations_pg/00000000000000_diesel_postgresql_initial_setup/down.sql +++ b/migrations_pg/00000000000000_diesel_postgresql_initial_setup/down.sql @@ -1,6 +1,36 @@ --- This file was automatically created by Diesel to setup helper functions --- and other internal bookkeeping. This file is safe to edit, any future --- changes will be added to existing projects as new migrations. +DROP TABLE IF EXISTS routing_algorithm_mapper; +DROP TABLE IF EXISTS co_badged_cards_info_test; +DROP TABLE IF EXISTS routing_algorithm; +DROP TABLE IF EXISTS tenant_config_filter; +DROP TABLE IF EXISTS merchant_gateway_account; +DROP TABLE IF EXISTS user_eligibility_info; +DROP TABLE IF EXISTS gateway_bank_emi_support; +DROP TABLE IF EXISTS emi_bank_code; +DROP TABLE IF EXISTS juspay_bank_code; +DROP TABLE IF EXISTS gateway_card_info; +DROP TABLE IF EXISTS card_info; +DROP TABLE IF EXISTS txn_detail; +DROP TABLE IF EXISTS txn_card_info; +DROP TABLE IF EXISTS payment_method; +DROP TABLE IF EXISTS service_configuration; +DROP TABLE IF EXISTS merchant_config; +DROP TABLE IF EXISTS feature; +DROP TABLE IF EXISTS isin_routes; +DROP TABLE IF EXISTS txn_offer; +DROP TABLE IF EXISTS merchant_gateway_payment_method_flow; +DROP TABLE IF EXISTS merchant_account; +DROP TABLE IF EXISTS merchant_gateway_card_info; +DROP TABLE IF EXISTS txn_offer_detail; +DROP TABLE IF EXISTS token_bin_info; +DROP TABLE IF EXISTS merchant_iframe_preferences; +DROP TABLE IF EXISTS gateway_payment_method_flow; +DROP TABLE IF EXISTS merchant_gateway_account_sub_info; +DROP TABLE IF EXISTS issuer_routes; +DROP TABLE IF EXISTS card_brand_routes; +DROP TABLE IF EXISTS tenant_config; +DROP TABLE IF EXISTS merchant_priority_logic; +DROP TABLE IF EXISTS gateway_outage; +DROP TABLE IF EXISTS gateway_bank_emi_support_v2; DROP FUNCTION IF EXISTS diesel_manage_updated_at(_tbl regclass); DROP FUNCTION IF EXISTS diesel_set_updated_at(); \ No newline at end of file diff --git a/migrations_pg/2026-08-11-000001_cost_ingestion_merchant_scoped_dedupe/down.sql b/migrations_pg/2026-08-11-000001_cost_ingestion_merchant_scoped_dedupe/down.sql new file mode 100644 index 00000000..aa32449f --- /dev/null +++ b/migrations_pg/2026-08-11-000001_cost_ingestion_merchant_scoped_dedupe/down.sql @@ -0,0 +1,4 @@ +ALTER TABLE cost_ingestion + DROP CONSTRAINT cost_ingestion_merchant_connector_notification_id_key, + ADD CONSTRAINT cost_ingestion_connector_notification_id_key + UNIQUE (connector, notification_id); diff --git a/migrations_pg/2026-08-11-000001_cost_ingestion_merchant_scoped_dedupe/up.sql b/migrations_pg/2026-08-11-000001_cost_ingestion_merchant_scoped_dedupe/up.sql new file mode 100644 index 00000000..e0742a4d --- /dev/null +++ b/migrations_pg/2026-08-11-000001_cost_ingestion_merchant_scoped_dedupe/up.sql @@ -0,0 +1,4 @@ +ALTER TABLE cost_ingestion + DROP CONSTRAINT cost_ingestion_connector_notification_id_key, + ADD CONSTRAINT cost_ingestion_merchant_connector_notification_id_key + UNIQUE (merchant_id, connector, notification_id); diff --git a/src/app.rs b/src/app.rs index e7218743..b05946f0 100644 --- a/src/app.rs +++ b/src/app.rs @@ -613,7 +613,7 @@ where post(routes::merchant_account_config::create_merchant_config), ) .route( - "/webhooks/settlement/:connector", + "/webhooks/settlement/:merchant_id/:connector", post(routes::settlement_webhook::settlement_webhook), ) .route("/auth/signup", post(routes::user_auth::signup)) diff --git a/src/cost_ingestion/creds.rs b/src/cost_ingestion/creds.rs index b6d6a05b..e278706c 100644 --- a/src/cost_ingestion/creds.rs +++ b/src/cost_ingestion/creds.rs @@ -1,10 +1,18 @@ //! Encrypted storage for per-settlement-source ingestion credentials. //! -//! A settlement source is `(connector, account)` — e.g. one Adyen `merchantAccountCode`. A single -//! merchant may own several accounts (each with its own HMAC key, report-user auth, region, and -//! markup), so the account is the real unit, and it carries *our* `merchant_id`. Keying on -//! `(connector, account)` also resolves the webhook chicken-and-egg: the handler reads the -//! account from the unverified body, then loads that account's secret *and* merchant id to verify. +//! A settlement source is `(merchant_id, connector, account)` — one of *our* merchants plus e.g. one +//! Adyen `merchantAccountCode`. A single merchant may own several accounts (each with its own HMAC +//! key, report-user auth, region, and markup), so the account is the unit *within* a merchant. +//! +//! The merchant is part of the key rather than just the payload because one connector account can +//! be shared by two of our merchants, each having registered their own webhook endpoint (with its +//! own HMAC key) at the connector. Keyed on `(connector, account)` alone there is room for exactly +//! one secret, so the second merchant to configure would overwrite the first's key and the first's +//! deliveries would start failing signature verification. +//! +//! That is also why the webhook carries the merchant in its path +//! (`/webhooks/settlement/:merchant_id/:connector`): the handler must build this key *before* it can +//! verify anything, and the connector's payload names only its own account, never our merchant. //! //! Credentials must be *decryptable* (we use them to download reports), so they are encrypted at //! rest with AES-256-GCM ([`GcmAes256`]) rather than hashed, and persisted as a base64 blob in the @@ -30,13 +38,26 @@ pub struct ResolvedCreds { pub creds: ConnectorCreds, } -/// A `(connector, account)` a merchant has configured — the non-secret half, safe to list. +/// A `(connector, account)` a merchant has configured — the non-secret half, safe to list. The +/// merchant is the index's own key (`cost_ingest_sources::{merchant_id}`), so it isn't repeated here. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct SourceRef { pub connector: String, pub account: String, } +/// One entry of a pull connector's poll index. Unlike [`SourceRef`] this is a cross-merchant list, +/// so it must name the merchant to reach its credentials. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct PollSource { + /// Empty only when read back from an index written before the merchant joined the credential + /// key; [`list_poll_sources`] filters those out. + #[serde(default)] + pub merchant_id: String, + pub connector: String, + pub account: String, +} + /// A configured source plus *masked* previews of its stored credentials — just enough to recognize /// which key is set (the last few characters) without disclosing it. Never carries a full secret. #[derive(Debug, Clone, Serialize)] @@ -140,24 +161,30 @@ async fn remove_source( } /// Delete a settlement source: its encrypted credentials *and* its entry in the merchant's source -/// index, so it disappears from the configured list. Idempotent — deleting an absent source is not -/// an error. No keyring needed (we're removing, not decrypting), so this is a free function. +/// index, so it disappears from the configured list. Pull connectors are dropped from the poll index +/// too, or the poller would keep sweeping a source whose credentials are gone. Idempotent — +/// deleting an absent source is not an error. No keyring needed (we're removing, not decrypting), +/// so this is a free function. pub async fn delete_source( connector: &str, account: &str, merchant_id: &str, ) -> Result<(), IngestError> { - service_configuration::delete_config(config_name(connector, account)) + service_configuration::delete_config(config_name(merchant_id, connector, account)) .await .map_err(|e| IngestError::Storage(e.to_string()))?; + if is_pull_connector(connector) { + remove_poll_source(merchant_id, connector, account).await?; + } remove_source(merchant_id, connector, account).await } -/// `service_configuration.name` under which a `(connector, account)`'s encrypted creds live. -/// The account (e.g. Adyen `merchantAccountCode`) is unique within a connector, so this key is -/// stable even when one merchant owns several accounts. -fn config_name(connector: &str, account: &str) -> String { - format!("cost_ingest_creds::{connector}::{account}") +/// `service_configuration.name` under which a `(merchant_id, connector, account)`'s encrypted creds +/// live. The merchant leads the key so two of our merchants can share one connector account, each +/// with their own webhook secret and download auth; the account keeps one merchant's several +/// accounts apart. +fn config_name(merchant_id: &str, connector: &str, account: &str) -> String { + format!("cost_ingest_creds::{merchant_id}::{connector}::{account}") } /// Whether a connector is *pulled* (we poll its API for ready reports) rather than *pushed* (it @@ -171,29 +198,42 @@ pub fn is_pull_connector(connector: &str) -> bool { .unwrap_or(false) } -/// Per-connector index name holding every `(connector, account)` the poller must sweep. The KV -/// store has no prefix/list-all query, so a pull connector's sources are enumerated from here -/// rather than by scanning `cost_ingest_creds::{connector}::*`. +/// Per-connector index name holding every `(merchant_id, connector, account)` the poller must +/// sweep. The KV store has no prefix/list-all query, so a pull connector's sources are enumerated +/// from here rather than by scanning `cost_ingest_creds::*::{connector}::*`. fn poll_index_name(connector: &str) -> String { format!("cost_ingest_poll::{connector}") } -/// List all `(connector, account)` sources the poller should sweep for `connector`, across every -/// merchant. Empty when none are configured. -pub async fn list_poll_sources(connector: &str) -> Result, IngestError> { +/// List all sources the poller should sweep for `connector`, across every merchant. Empty when none +/// are configured. Carries `merchant_id` because that is now part of the credential key — the +/// poller has no webhook payload to recover it from. +pub async fn list_poll_sources(connector: &str) -> Result, IngestError> { let stored = service_configuration::find_config_by_name(poll_index_name(connector)) .await .map_err(|e| IngestError::Storage(e.to_string()))?; - match stored.and_then(|c| c.value) { - Some(v) => serde_json::from_str(&v).map_err(|e| IngestError::Storage(e.to_string())), - None => Ok(Vec::new()), - } + let sources: Vec = match stored.and_then(|c| c.value) { + Some(v) => serde_json::from_str(&v).map_err(|e| IngestError::Storage(e.to_string()))?, + None => return Ok(Vec::new()), + }; + // Entries written before the merchant became part of the key deserialize with an empty + // `merchant_id` (see `PollSource::merchant_id`). Their credentials are unreachable under the + // current key anyway, so drop them here rather than letting one stale entry fail the sweep. + Ok(sources + .into_iter() + .filter(|s| !s.merchant_id.is_empty()) + .collect()) } -/// Record a pull connector's `(connector, account)` in its poll index (idempotent). -async fn add_poll_source(connector: &str, account: &str) -> Result<(), IngestError> { +/// Record a pull connector's `(merchant_id, connector, account)` in its poll index (idempotent). +async fn add_poll_source( + merchant_id: &str, + connector: &str, + account: &str, +) -> Result<(), IngestError> { let mut sources = list_poll_sources(connector).await?; - let entry = SourceRef { + let entry = PollSource { + merchant_id: merchant_id.to_string(), connector: connector.to_string(), account: account.to_string(), }; @@ -201,7 +241,36 @@ async fn add_poll_source(connector: &str, account: &str) -> Result<(), IngestErr return Ok(()); } sources.push(entry); - let value = serde_json::to_string(&sources).map_err(|e| IngestError::Storage(e.to_string()))?; + write_poll_index(connector, &sources).await +} + +/// Drop a `(merchant_id, connector, account)` from the poll index (idempotent). Without this a +/// deleted source would be swept forever, failing on missing credentials every cycle. +async fn remove_poll_source( + merchant_id: &str, + connector: &str, + account: &str, +) -> Result<(), IngestError> { + let mut sources = list_poll_sources(connector).await?; + let before = sources.len(); + sources.retain(|s| { + !(s.merchant_id == merchant_id && s.connector == connector && s.account == account) + }); + if sources.len() == before { + return Ok(()); // nothing was registered for this triple + } + let name = poll_index_name(connector); + if sources.is_empty() { + return service_configuration::delete_config(name) + .await + .map_err(|e| IngestError::Storage(e.to_string())); + } + write_poll_index(connector, &sources).await +} + +/// Upsert the poll index for `connector` with `sources`. +async fn write_poll_index(connector: &str, sources: &[PollSource]) -> Result<(), IngestError> { + let value = serde_json::to_string(sources).map_err(|e| IngestError::Storage(e.to_string()))?; let name = poll_index_name(connector); let exists = service_configuration::find_config_by_name(name.clone()) .await @@ -312,7 +381,7 @@ impl ConnectorCredsStore { merchant_id: &str, creds: &ConnectorCreds, ) -> Result<(), IngestError> { - let name = config_name(connector, account); + let name = config_name(merchant_id, connector, account); let value = self.seal(merchant_id, creds)?; let exists = service_configuration::find_config_by_name(name.clone()) .await @@ -331,7 +400,7 @@ impl ConnectorCredsStore { // Pull connectors also go in a per-connector poll index so the background poller can find // every source to sweep, across merchants, without a prefix scan. if is_pull_connector(connector) { - add_poll_source(connector, account).await?; + add_poll_source(merchant_id, connector, account).await?; } Ok(()) } @@ -344,7 +413,7 @@ impl ConnectorCredsStore { for s in sources { // A missing/undecryptable blob still lists the source, just without hints. let (webhook_secret_hint, download_auth_hint) = - match self.get(&s.connector, &s.account).await { + match self.get(merchant_id, &s.connector, &s.account).await { Ok(Some(r)) => ( mask_secret(r.creds.webhook_secret.peek()), mask_download_auth(r.creds.download_auth.peek()), @@ -361,13 +430,16 @@ impl ConnectorCredsStore { Ok(out) } - /// Load and decrypt a settlement source's credentials, or `None` if none are stored. + /// Load and decrypt a settlement source's credentials, or `None` if none are stored. Scoped to + /// `merchant_id`: another merchant's credentials for the same connector account are a different + /// key and are never returned here. pub async fn get( &self, + merchant_id: &str, connector: &str, account: &str, ) -> Result, IngestError> { - let name = config_name(connector, account); + let name = config_name(merchant_id, connector, account); let stored = service_configuration::find_config_by_name(name) .await .map_err(|e| IngestError::Storage(e.to_string()))?; @@ -442,15 +514,59 @@ mod tests { fn two_accounts_key_independently() { // Same merchant, two Adyen accounts -> distinct config keys, no collision. assert_eq!( - config_name("adyen", "AcmeEU"), - "cost_ingest_creds::adyen::AcmeEU" + config_name("merchant_A", "adyen", "AcmeEU"), + "cost_ingest_creds::merchant_A::adyen::AcmeEU" + ); + assert_ne!( + config_name("merchant_A", "adyen", "AcmeEU"), + config_name("merchant_A", "adyen", "AcmeUS") ); + } + + #[test] + fn two_merchants_sharing_one_account_key_independently() { + // The shared-account case: one Adyen merchantAccountCode, two of our merchants, each with + // their own webhook endpoint and HMAC key. Distinct keys, so neither overwrites the other. assert_ne!( - config_name("adyen", "AcmeEU"), - config_name("adyen", "AcmeUS") + config_name("merchant_A", "adyen", "AcmeEU"), + config_name("merchant_B", "adyen", "AcmeEU") ); } + #[test] + fn shared_account_stores_a_distinct_secret_per_merchant() { + let s = store(); + let a = ConnectorCreds { + webhook_secret: Secret::new("hmac-A".to_string()), + download_auth: Secret::new("reportuser_a:pass".to_string()), + }; + let b = ConnectorCreds { + webhook_secret: Secret::new("hmac-B".to_string()), + download_auth: Secret::new("reportuser_b:pass".to_string()), + }; + // Same connector account, sealed under each merchant: each blob opens to its own secret. + let opened_a = s.open(&s.seal("merchant_A", &a).unwrap()).unwrap(); + let opened_b = s.open(&s.seal("merchant_B", &b).unwrap()).unwrap(); + assert_eq!(opened_a.creds.webhook_secret.peek(), "hmac-A"); + assert_eq!(opened_b.creds.webhook_secret.peek(), "hmac-B"); + assert_eq!(opened_a.merchant_id, "merchant_A"); + assert_eq!(opened_b.merchant_id, "merchant_B"); + } + + #[test] + fn poll_index_entries_carry_the_merchant() { + // The poller has no webhook payload to recover the merchant from, so the index must name it. + let json = r#"[{"merchant_id":"merchant_A","connector":"chase","account":"acct1"}]"#; + let parsed: Vec = serde_json::from_str(json).unwrap(); + assert_eq!(parsed[0].merchant_id, "merchant_A"); + + // A pre-merchant-key entry still parses (empty merchant) so one stale row can't fail the + // whole sweep; `list_poll_sources` is what drops it. + let legacy: Vec = + serde_json::from_str(r#"[{"connector":"chase","account":"acct1"}]"#).unwrap(); + assert!(legacy[0].merchant_id.is_empty()); + } + #[test] fn ciphertext_is_not_plaintext_and_is_nonce_randomized() { let s = store(); diff --git a/src/cost_ingestion/poller.rs b/src/cost_ingestion/poller.rs index 1b61c787..c507397a 100644 --- a/src/cost_ingestion/poller.rs +++ b/src/cost_ingestion/poller.rs @@ -5,7 +5,8 @@ //! Each cycle it sweeps every registered pull connector, lists each configured source's ready //! reports, and enqueues a `pending` job per report (`source = "poll"`). From there it is identical //! to a webhook delivery: the existing `worker` claims the job, downloads, parses, and fits. Enqueue -//! is idempotent on `(connector, report_id)`, so re-listing an already-ingested report is a no-op. +//! is idempotent on `(merchant_id, connector, report_id)`, so re-listing an already-ingested report +//! is a no-op — while two merchants sharing one connector account each get their own job. //! //! Modeled on `worker::spawn` — a panic-isolated interval loop that runs only where //! `cost_ingestion.report_poll_enabled` is set, so a dedicated ingest deployment owns it. Nothing @@ -93,11 +94,19 @@ async fn run_once() { } }; for src in sources { - if let Err(e) = poll_source(&creds_store, source.as_ref(), &src.account).await { + if let Err(e) = poll_source( + &creds_store, + source.as_ref(), + &src.merchant_id, + &src.account, + ) + .await + { // One bad source (expired key, API error) must not stop the others. logger::warn!( tag = "report_poller", - "poll of {}/{} failed: {:?}", + "poll of {}/{}/{} failed: {:?}", + src.merchant_id, connector, src.account, e @@ -111,22 +120,29 @@ async fn run_once() { async fn poll_source( creds_store: &ConnectorCredsStore, source: &dyn SettlementReportSource, + merchant_id: &str, account: &str, ) -> Result<(), super::IngestError> { let connector = source.connector(); - let resolved = creds_store.get(connector, account).await?.ok_or_else(|| { - super::IngestError::Storage(format!("no credentials for {connector}/{account}")) - })?; + let resolved = creds_store + .get(merchant_id, connector, account) + .await? + .ok_or_else(|| { + super::IngestError::Storage(format!( + "no credentials for {merchant_id}/{connector}/{account}" + )) + })?; let ready = source.poll_ready_reports(&resolved.creds).await?; let mut enqueued = 0usize; for report in ready { - // Idempotent on (connector, report_id): already-enqueued reports are skipped. + // Idempotent on (merchant_id, connector, report_id): already-enqueued reports are skipped, + // while the same report for another merchant sharing this account stays a separate job. // `source = "poll"` distinguishes pull-discovered reports from pushed webhooks in history. let created = store::enqueue_pending( connector, account, - &resolved.merchant_id, + merchant_id, &report.report_id, &report.report_ref, "poll", @@ -139,7 +155,8 @@ async fn poll_source( if enqueued > 0 { logger::info!( tag = "report_poller", - "{}/{}: enqueued {} new report(s)", + "{}/{}/{}: enqueued {} new report(s)", + merchant_id, connector, account, enqueued diff --git a/src/cost_ingestion/store.rs b/src/cost_ingestion/store.rs index ac6bf859..23a5f03a 100644 --- a/src/cost_ingestion/store.rs +++ b/src/cost_ingestion/store.rs @@ -43,8 +43,14 @@ pub struct Completion { /// Enqueue a report discovered automatically — either pushed by a connector webhook (`source = /// "webhook"`) or found by polling a connector's API (`source = "poll"`). **Idempotent** on -/// `(connector, notification_id)`: a re-delivered/re-listed report is a no-op. Returns `true` when a -/// new job was created. +/// `(merchant_id, connector, notification_id)`: a re-delivered/re-listed report is a no-op. Returns +/// `true` when a new job was created. +/// +/// The merchant is part of that key because two of our merchants can share one connector account +/// and each register their own webhook endpoint. The connector then delivers the *same* event — +/// same notification id — to both, and each delivery is a genuine job for a different merchant. +/// Keyed on `(connector, notification_id)` alone the second one would be silently swallowed as a +/// replay. pub async fn enqueue_pending( connector: &str, account: &str, @@ -55,16 +61,17 @@ pub async fn enqueue_pending( ) -> Result { let app_state = get_tenant_app_state().await; - // The UNIQUE (connector, notification_id) constraint is the real guard; this check keeps a - // duplicate delivery from erroring in the common case. + // The UNIQUE (merchant_id, connector, notification_id) constraint is the real guard; this check + // keeps a duplicate delivery from erroring in the common case. let existing = generics::generic_find_one_optional::< ::Table, _, CostIngestion, >( &app_state.db, - dsl::connector - .eq(connector.to_string()) + dsl::merchant_id + .eq(merchant_id.to_string()) + .and(dsl::connector.eq(connector.to_string())) .and(dsl::notification_id.eq(Some(notification_id.to_string()))), ) .await diff --git a/src/cost_ingestion/worker.rs b/src/cost_ingestion/worker.rs index ed9b0493..38a1093a 100644 --- a/src/cost_ingestion/worker.rs +++ b/src/cost_ingestion/worker.rs @@ -111,7 +111,8 @@ async fn process( let registry = ConnectorRegistry::with_builtins(); let source = registry.get(&job.connector)?; - // Credentials for this (connector, account). + // Credentials for this (merchant, connector, account) — the download auth is the job owner's, + // not the account's, since two merchants may share one connector account. let store_ = ConnectorCredsStore::from_keyring( &cfg.creds_encryption_current, &cfg.creds_encryption_keys, @@ -120,12 +121,12 @@ async fn process( IngestError::Storage("credential encryption keyring not configured".to_string()) })?; let resolved = store_ - .get(&job.connector, &job.account) + .get(&job.merchant_id, &job.connector, &job.account) .await? .ok_or_else(|| { IngestError::Storage(format!( - "no credentials for {}/{}", - job.connector, job.account + "no credentials for {}/{}/{}", + job.merchant_id, job.connector, job.account )) })?; diff --git a/src/routes/settlement_webhook.rs b/src/routes/settlement_webhook.rs index acb66b29..3bcb18db 100644 --- a/src/routes/settlement_webhook.rs +++ b/src/routes/settlement_webhook.rs @@ -1,10 +1,17 @@ -//! Connector-generic settlement-report webhook ingress: `POST /webhooks/settlement/:connector`. +//! Connector-generic settlement-report webhook ingress: +//! `POST /webhooks/settlement/:merchant_id/:connector`. //! //! A connector (Adyen first) calls this when a settlement report is ready. We verify the //! signature, ACK immediately, and enqueue — every heavy step (download, parse, fit) is deferred //! to the ingest worker so the connector always gets a fast response. Public (unauthenticated by //! our API key): the caller authenticates via its own signature, checked here. //! +//! The merchant is in the path because it cannot be recovered from the payload: a connector's +//! notification names only its own account, and one account can be shared by two of our merchants +//! (each registering their own endpoint, with their own HMAC key, at the connector). The path +//! merchant selects *which* credentials to verify against — so a caller cannot pass another +//! merchant's id and get anywhere without also holding that merchant's signing secret. +//! //! See `scratch/inhouse-cost-architecture.md` §7. use axum::body::Bytes; @@ -17,16 +24,17 @@ use crate::cost_ingestion::{store, ConnectorCredsStore, ConnectorRegistry, Inges use crate::logger; pub async fn settlement_webhook( - Path(connector): Path, + Path((merchant_id, connector)): Path<(String, String)>, headers: HeaderMap, body: Bytes, // must be the last extractor — it consumes the request body ) -> impl IntoResponse { - match handle(&connector, &headers, &body).await { + match handle(&merchant_id, &connector, &headers, &body).await { Ok(created) => { logger::info!( tag = "settlement_webhook", - "accepted {} settlement webhook (new_job={})", + "accepted {} settlement webhook for {} (new_job={})", connector, + merchant_id, created ); // Adyen expects the literal body "[accepted]"; harmless for other connectors. @@ -35,8 +43,9 @@ pub async fn settlement_webhook( Err(e) => { logger::warn!( tag = "settlement_webhook", - "rejected {} settlement webhook: {:?}", + "rejected {} settlement webhook for {}: {:?}", connector, + merchant_id, e ); (status_for(&e), "rejected") @@ -46,14 +55,21 @@ pub async fn settlement_webhook( /// Verify + enqueue. Everything here is cheap (a couple of DB round-trips + an HMAC); the report /// download and fit happen later in the worker. -async fn handle(connector: &str, headers: &HeaderMap, body: &[u8]) -> Result { +async fn handle( + merchant_id: &str, + connector: &str, + headers: &HeaderMap, + body: &[u8], +) -> Result { let registry = ConnectorRegistry::with_builtins(); let source = registry.get(connector)?; - // 1. Read the connector-side account from the *unverified* body, to find whose secret to use. + // 1. Read the connector-side account from the *unverified* body. Together with the path + // merchant it names which credentials to verify against. let account = source.peek_account(body)?; - // 2. Load that (connector, account)'s credentials + the merchant that owns it. + // 2. Load this merchant's credentials for that account. Scoped to the path merchant, so a + // second merchant sharing the same connector account keeps their own signing secret. let app_state = get_tenant_app_state().await; let cfg = &app_state.config.cost_ingestion; let creds_store = ConnectorCredsStore::from_keyring( @@ -61,21 +77,26 @@ async fn handle(connector: &str, headers: &HeaderMap, body: &[u8]) -> Result Date: Tue, 11 Aug 2026 18:58:19 +0530 Subject: [PATCH 2/3] feat(website): show copyable settlement webhook URL per merchant --- .../pages/ConnectorCredentialsForm.tsx | 30 +++++++++++++++++++ website/src/lib/api.ts | 13 ++++++++ website/src/vite-env.d.ts | 1 + 3 files changed, 44 insertions(+) diff --git a/website/src/components/pages/ConnectorCredentialsForm.tsx b/website/src/components/pages/ConnectorCredentialsForm.tsx index c133d7e3..8bc0466e 100644 --- a/website/src/components/pages/ConnectorCredentialsForm.tsx +++ b/website/src/components/pages/ConnectorCredentialsForm.tsx @@ -3,8 +3,11 @@ import { Pencil, ShieldCheck, Trash2 } from 'lucide-react' import { Card, CardBody, CardHeader } from '../ui/Card' import * as type from '../ui/typography' import { Button } from '../ui/Button' +import { CopyButton } from '../ui/CopyButton' import { ErrorMessage } from '../ui/ErrorMessage' import { Spinner } from '../ui/Spinner' +import { publicApiUrl } from '../../lib/api' +import { isProduction } from '../../lib/appConfig' import { deleteConnectorCredentials, setConnectorCredentials, @@ -45,6 +48,16 @@ export function ConnectorCredentialsForm({ merchantId }: { merchantId?: string } } const isCheckout = connector === 'checkout' + const connectorLabel = isCheckout ? 'Checkout' : 'Adyen' + + // The endpoint this merchant registers at the connector. Merchant-scoped, because a notification + // names only the connector-side account — the path is what picks whose secret verifies it. + // Production-only: anywhere else the host isn't one a connector can deliver to, so showing an + // address that quietly doesn't work is worse than showing none. + const webhookUrl = + merchantId && isProduction + ? publicApiUrl(`/webhooks/settlement/${encodeURIComponent(merchantId)}/${connector}`) + : null async function handleSave() { if (!merchantId) { @@ -128,6 +141,23 @@ export function ConnectorCredentialsForm({ merchantId }: { merchantId?: string } + + {webhookUrl && ( +
+ Webhook URL +
+ + {webhookUrl} + + +
+ + Register this in your {connectorLabel} dashboard as the settlement-report notification + endpoint. It's public - deliveries are authenticated by the webhook secret below. + +
+ )} +