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 5fb217d8..4094fc0c 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
+
+ {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.
+
+