From b0c3a8a73cb24a4ffceebc1675d3f10bbca22cfc Mon Sep 17 00:00:00 2001 From: Prajjwal kumar Date: Fri, 4 Sep 2026 00:38:27 +0530 Subject: [PATCH 1/3] feat(routing): show connector labels in the gateway picker and note minor-unit amounts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A merchant running several accounts of one connector saw identical rows ("adyen / mca_…" twice) in the gateway dropdowns, with only the MCA id to tell them apart. The Hyperswitch dashboard hand-off already carries each account's connector_label, so surface it: the picker now shows the label beside the connector name (dropped when it merely repeats the name), and typing filters on the label too. The rule builder also gains the same smallest-currency-unit note the Hyperswitch dashboard shows on its rule builder — amount conditions are matched in minor units, and without the note a 100x mistake is easy. Requested in juspay/hyperswitch-control-center#5495 (items 5 and 6). Co-Authored-By: Claude Fable 5 --- .../pages/EuclidRuleBuilderPage.tsx | 7 +++++++ website/src/components/ui/GatewaySelect.tsx | 19 +++++++++++++++---- website/src/lib/connectors.ts | 18 ++++++++++++++---- 3 files changed, 36 insertions(+), 8 deletions(-) diff --git a/website/src/components/pages/EuclidRuleBuilderPage.tsx b/website/src/components/pages/EuclidRuleBuilderPage.tsx index e7f05508..1fe875df 100644 --- a/website/src/components/pages/EuclidRuleBuilderPage.tsx +++ b/website/src/components/pages/EuclidRuleBuilderPage.tsx @@ -339,6 +339,13 @@ export function EuclidRuleBuilderPage() {

Configured Rules

+ {/* Same warning the Hyperswitch dashboard shows on its rule builder — amounts are + easy to get wrong by a factor of 100 without it. */} +

+ Enter payment amounts in the smallest currency unit (cents for USD, yen for JPY). + For instance, 100 matches $1.00 (USD) but ¥100 + (JPY), since ¥ is a zero-decimal currency. +

{routingKeysLoading && (

Loading routing keys from backend...

)} diff --git a/website/src/components/ui/GatewaySelect.tsx b/website/src/components/ui/GatewaySelect.tsx index f84ab8e2..f6197765 100644 --- a/website/src/components/ui/GatewaySelect.tsx +++ b/website/src/components/ui/GatewaySelect.tsx @@ -37,7 +37,11 @@ export function GatewaySelect({ const dropdownRef = useRef(null) const query = value.trim().toLowerCase() - const filtered = query ? options.filter((o) => o.name.toLowerCase().includes(query)) : options + const filtered = query + ? options.filter( + (o) => o.name.toLowerCase().includes(query) || o.label?.toLowerCase().includes(query), + ) + : options function openDropdown() { const rect = inputRef.current?.getBoundingClientRect() @@ -166,9 +170,16 @@ export function GatewaySelect({ i === highlight ? 'bg-slate-50 dark:bg-[#1c1c24]' : '' } ${o.name === value ? 'bg-brand-50/50 font-medium text-brand-600 dark:bg-brand-900/10 dark:text-brand-400' : ''}`} > - {o.name} - {/* The merchant may run several accounts of one connector, so the id is what - actually tells two options apart. */} + {/* The merchant may run several accounts of one connector, so the label (their + own name for the account) and the id are what tell two options apart. */} + + {o.name} + {o.label && ( + + {o.label} + + )} + {o.gatewayId && ( !pickedIds.has(connector.merchant_connector_id)) - .map((connector) => ({ - name: connector.connector_name, - gatewayId: connector.merchant_connector_id, - })) + .map((connector) => { + // The label is the merchant's own name for the account, so it is what tells two accounts + // of one connector apart. When it just repeats the connector name it adds nothing — drop + // it so the option does not read "stripe stripe". + const label = + typeof connector.connector_label === 'string' ? connector.connector_label.trim() : '' + return { + name: connector.connector_name, + gatewayId: connector.merchant_connector_id, + label: label && label !== connector.connector_name ? label : undefined, + } + }) } const seen = new Set() From 544e4f49613ce5d32f049edc80573faacee49720 Mon Sep 17 00:00:00 2001 From: Prajjwal kumar Date: Fri, 4 Sep 2026 14:04:41 +0530 Subject: [PATCH 2/3] fix(routing): disable rule deletion for parity with the Hyperswitch dashboard The Hyperswitch dashboard cannot delete routing rules (no delete endpoint exists in hyperswitch), so offering deletion only here left the two dashboards inconsistent (juspay/hyperswitch-control-center#5495, item 4). Comment out the /routing/delete route, and hide the Delete actions on the rules, volume-split, and A/B testing pages behind a RULE_DELETION feature flag so nothing dangles in the UI. Re-enabling is one flag flip plus uncommenting the route, once both dashboards can offer deletion. Co-Authored-By: Claude Fable 5 --- src/app.rs | 11 ++++++---- .../src/components/pages/ABTestingPage.tsx | 5 ++++- .../src/components/pages/EuclidRulesPage.tsx | 21 ++++++++++++------- .../src/components/pages/VolumeSplitPage.tsx | 21 ++++++++++++------- website/src/lib/featureFlags.ts | 4 ++++ 5 files changed, 41 insertions(+), 21 deletions(-) diff --git a/src/app.rs b/src/app.rs index 412b24ea..1aa7449c 100644 --- a/src/app.rs +++ b/src/app.rs @@ -377,10 +377,13 @@ where "/routing/update", axum::routing::post(crate::euclid::handlers::routing_rules::update_routing_rule), ) - .route( - "/routing/delete", - axum::routing::post(crate::euclid::handlers::routing_rules::delete_routing_rule), - ) + // Rule deletion is disabled for parity with the Hyperswitch dashboard, which + // cannot delete routing rules yet (juspay/hyperswitch-control-center#5495, + // item 4). Re-enable by uncommenting once both dashboards can offer it. + // .route( + // "/routing/delete", + // axum::routing::post(crate::euclid::handlers::routing_rules::delete_routing_rule), + // ) .route( "/routing/list/:created_by", axum::routing::post( diff --git a/website/src/components/pages/ABTestingPage.tsx b/website/src/components/pages/ABTestingPage.tsx index 56048d1e..417548cf 100644 --- a/website/src/components/pages/ABTestingPage.tsx +++ b/website/src/components/pages/ABTestingPage.tsx @@ -29,6 +29,7 @@ import { useMerchantFeatures } from '../../hooks/useMerchantFeatures' import { PageHeading } from '../ui/PageHeading' import { Notice } from '../ui/Notice' +import { FEATURE_FLAGS } from '../../lib/featureFlags' const SAMPLE_SIZE_PRESETS = [1000, 5000, 10000, 50000] const EXPERIMENT_TYPE_HELP: Record = { @@ -838,7 +839,9 @@ function ExperimentDetailPanel({ Duplicate, being read-only on this experiment, is offered in every state. */} - + {FEATURE_FLAGS.RULE_DELETION && ( + + )} )} diff --git a/website/src/components/pages/EuclidRulesPage.tsx b/website/src/components/pages/EuclidRulesPage.tsx index 64e9a3ba..c4e2463e 100644 --- a/website/src/components/pages/EuclidRulesPage.tsx +++ b/website/src/components/pages/EuclidRulesPage.tsx @@ -19,6 +19,7 @@ import { import { PageHeading } from '../ui/PageHeading' import { Notice } from '../ui/Notice' +import { FEATURE_FLAGS } from '../../lib/featureFlags' type StatusFilter = 'all' | 'active' | 'inactive' export function EuclidRulesPage() { @@ -370,14 +371,18 @@ export function EuclidRulesPage() { onSelect: () => navigate(`/routing/rules/new?cloneFrom=${algo.id}`), disabled: !canEditRouting, }, - { - label: 'Delete', - icon: Trash2, - tone: 'danger', - onSelect: () => setPendingDeleteId(algo.id), - disabled: Boolean(lockedReason), - hint: lockedReason, - }, + ...(FEATURE_FLAGS.RULE_DELETION + ? [ + { + label: 'Delete', + icon: Trash2, + tone: 'danger' as const, + onSelect: () => setPendingDeleteId(algo.id), + disabled: Boolean(lockedReason), + hint: lockedReason, + }, + ] + : []), ]} /> diff --git a/website/src/components/pages/VolumeSplitPage.tsx b/website/src/components/pages/VolumeSplitPage.tsx index c5d84be1..3b703699 100644 --- a/website/src/components/pages/VolumeSplitPage.tsx +++ b/website/src/components/pages/VolumeSplitPage.tsx @@ -17,6 +17,7 @@ import { SplitBreakdown } from '../routing/volumeSplit/SplitBreakdown' import { PageHeading } from '../ui/PageHeading' import { Notice } from '../ui/Notice' +import { FEATURE_FLAGS } from '../../lib/featureFlags' type StatusFilter = 'all' | 'active' | 'inactive' /** "stripe 50% / adyen 50%" — the row's one-line view of where traffic goes. */ @@ -357,14 +358,18 @@ export function VolumeSplitPage() { onSelect: () => navigate(`/routing/volume/new?cloneFrom=${algo.id}`), disabled: !canEditRouting, }, - { - label: 'Delete', - icon: Trash2, - tone: 'danger', - onSelect: () => setPendingDeleteId(algo.id), - disabled: Boolean(lockedReason), - hint: lockedReason, - }, + ...(FEATURE_FLAGS.RULE_DELETION + ? [ + { + label: 'Delete', + icon: Trash2, + tone: 'danger' as const, + onSelect: () => setPendingDeleteId(algo.id), + disabled: Boolean(lockedReason), + hint: lockedReason, + }, + ] + : []), ]} /> diff --git a/website/src/lib/featureFlags.ts b/website/src/lib/featureFlags.ts index 6e43ef83..b9f1691a 100644 --- a/website/src/lib/featureFlags.ts +++ b/website/src/lib/featureFlags.ts @@ -2,4 +2,8 @@ export const FEATURE_FLAGS = { GSM_RETRY_IN_SIMULATION: false, SMART_RETRY_IN_ANALYTICS: false, SMART_RETRY_IN_SIMULATION: false, + // Rule deletion is hidden for parity with the Hyperswitch dashboard, which cannot + // delete routing rules yet (juspay/hyperswitch-control-center#5495, item 4). + // Flip together with the commented-out /routing/delete route in src/app.rs. + RULE_DELETION: false, } From df6bb90bc9c21c1540574979bf39c7dfed7e7dc8 Mon Sep 17 00:00:00 2001 From: Prajjwal kumar Date: Tue, 8 Sep 2026 16:01:39 +0530 Subject: [PATCH 3/3] test(e2e): align rule-deletion tests with deletion being disabled MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #410 removes the /routing/delete route and hides the Delete UI action (RULE_DELETION flag) for parity with the Hyperswitch dashboard, but the E2E suite still asserted deletion succeeds — so every deletion test 404'd. Update them to the new contract: the delete route 404s and the rule survives (API), the lifecycle stops at deactivate, and the Delete menu action is absent rather than merely disabled (UI). The flaky analytics panel test passed on retry and is unrelated. Co-Authored-By: Claude Fable 5 --- .../routing/routing-rule-mutations.spec.ts | 49 ++++--------------- tests/api/routing/volume-contracts.spec.ts | 8 +-- .../routing/rules/euclid-lifecycle.spec.ts | 19 ++++--- tests/e2e/routing/volume-split-page.spec.ts | 19 ++++--- 4 files changed, 32 insertions(+), 63 deletions(-) diff --git a/tests/api/routing/routing-rule-mutations.spec.ts b/tests/api/routing/routing-rule-mutations.spec.ts index 8a796975..bbcc0898 100644 --- a/tests/api/routing/routing-rule-mutations.spec.ts +++ b/tests/api/routing/routing-rule-mutations.spec.ts @@ -140,7 +140,10 @@ test.describe('Routing rule mutations (API)', () => { expect(evaluated.status).toBe(400) }) - test('deleting an inactive rule removes it from the list', async ({ api, merchant }) => { + test('rule deletion is disabled -- the delete route is removed', async ({ api, merchant }) => { + // Deletion is disabled for parity with the Hyperswitch dashboard, which cannot delete + // routing rules yet (juspay/hyperswitch-control-center#5495, item 4). The /routing/delete + // route is commented out in src/app.rs, so any call to it 404s and the rule survives. const m = merchant.id const created = await api.createRoutingAlgorithm( factory.singleRoutingPayload(m, { name: factory.ruleName('mutate_delete'), gateway: 'stripe' }), @@ -151,38 +154,10 @@ test.describe('Routing rule mutations (API)', () => { failOnStatusCode: false, body: { created_by: m, routing_algorithm_id: ruleId }, }) - - expect(deleted.status).toBe(200) - expect(deleted.body.status).toBe('deleted') - expect(deleted.body.routing_algorithm_id).toBe(ruleId) + expect(deleted.status).toBe(404) const all = await api.listRoutingAlgorithms(m) - expect(all.body.some((r: any) => r.id === ruleId)).toBe(false) - }) - - test('an active rule cannot be deleted until it is deactivated', async ({ api, merchant }) => { - const m = merchant.id - const created = await api.createRoutingAlgorithm( - factory.singleRoutingPayload(m, { name: factory.ruleName('mutate_del_active'), gateway: 'stripe' }), - ) - const ruleId = created.body.rule_id - await api.activateRoutingAlgorithm(m, ruleId) - - const blocked = await api.raw('POST', '/routing/delete', { - failOnStatusCode: false, - body: { created_by: m, routing_algorithm_id: ruleId }, - }) - expect(blocked.status).toBe(400) - - // Deactivate first, then the delete goes through — this is the operator's actual path. - await api.raw('POST', '/routing/deactivate', { - body: { created_by: m, routing_algorithm_id: ruleId }, - }) - const deleted = await api.raw('POST', '/routing/delete', { - failOnStatusCode: false, - body: { created_by: m, routing_algorithm_id: ruleId }, - }) - expect(deleted.status).toBe(200) + expect(all.body.some((r: any) => r.id === ruleId)).toBe(true) }) test('mutating an unknown rule id is rejected', async ({ api, merchant }) => { @@ -206,14 +181,8 @@ test.describe('Routing rule mutations (API)', () => { body: { created_by: m, routing_algorithm_id: unknown }, }) expect(deactivated.status).toBe(400) - - // NOTE: delete of an unknown id currently surfaces as a 500 (the storage layer's "no rows to - // delete" is not mapped to a 404). Asserting >=400 documents "it is rejected" without pinning the - // suite to a status that is arguably a bug. - const deleted = await api.raw('POST', '/routing/delete', { - failOnStatusCode: false, - body: { created_by: m, routing_algorithm_id: unknown }, - }) - expect(deleted.status).toBeGreaterThanOrEqual(400) + // Delete is intentionally not exercised here: the /routing/delete route is removed + // (deletion disabled for dashboard parity), so it 404s regardless of the id. The + // dedicated "rule deletion is disabled" test covers that. }) }) diff --git a/tests/api/routing/volume-contracts.spec.ts b/tests/api/routing/volume-contracts.spec.ts index c04cc2e6..aadfac17 100644 --- a/tests/api/routing/volume-contracts.spec.ts +++ b/tests/api/routing/volume-contracts.spec.ts @@ -154,7 +154,7 @@ test.describe('Volume contracts (API)', () => { expect(amount.status).toBe(400) }) - test('runs the activate/update/deactivate/delete lifecycle in its own slot', async ({ api, merchant }) => { + test('runs the activate/update/deactivate lifecycle in its own slot', async ({ api, merchant }) => { const m = merchant.id const created = await createContract(api, volumeContractPayload(m)) const ruleId = created.body.rule_id @@ -199,13 +199,15 @@ test.describe('Volume contracts (API)', () => { }) expect(rejectedEdit.status).toBe(400) + // Deletion is disabled for parity with the Hyperswitch dashboard: the /routing/delete + // route is removed, so the (deactivated) contract cannot be deleted and remains listed. const deleted = await api.raw('POST', '/routing/delete', { failOnStatusCode: false, body: { created_by: m, routing_algorithm_id: ruleId }, }) - expect(deleted.status).toBe(200) + expect(deleted.status).toBe(404) const all = await api.listRoutingAlgorithms(m) - expect(all.body.some((r: any) => r.id === ruleId)).toBe(false) + expect(all.body.some((r: any) => r.id === ruleId)).toBe(true) }) test('an active volume contract does not disturb payment routing for the same merchant', async ({ api, merchant }) => { diff --git a/tests/e2e/routing/rules/euclid-lifecycle.spec.ts b/tests/e2e/routing/rules/euclid-lifecycle.spec.ts index eea30233..e8d31d46 100644 --- a/tests/e2e/routing/rules/euclid-lifecycle.spec.ts +++ b/tests/e2e/routing/rules/euclid-lifecycle.spec.ts @@ -199,14 +199,15 @@ test.describe('Rule Lifecycle — creation and management', () => { await expect(euclid.ruleRow(ruleName).getByText('Inactive')).toBeVisible() }) - test('blocks Edit and Delete while the rule is active', async ({ authedPage }) => { + test('blocks Edit while active and hides Delete', async ({ authedPage }) => { await euclid.ruleAction(ruleName, 'Activate') await expect(authedPage.getByText('Rule activated successfully.')).toBeVisible() - // /routing/update and /routing/delete both reject an active algorithm. + // /routing/update rejects an active algorithm, so Edit is disabled. Delete is hidden + // entirely (RULE_DELETION feature flag off) for parity with the Hyperswitch dashboard. await euclid.openRuleMenu(ruleName) await expect(euclid.menuItem('Edit')).toBeDisabled() - await expect(euclid.menuItem('Delete')).toBeDisabled() + await expect(euclid.menuItem('Delete')).toHaveCount(0) }) test('edits an inactive rule and sends the update to the backend', async ({ authedPage }) => { @@ -227,13 +228,11 @@ test.describe('Rule Lifecycle — creation and management', () => { await expect(euclid.ruleRow(renamed)).toBeVisible() }) - test('deletes an inactive rule', async ({ authedPage }) => { - await euclid.ruleAction(ruleName, 'Delete') - - await expect(authedPage.getByText('Delete this rule?')).toBeVisible() - await authedPage.locator('.fixed.inset-0').getByRole('button', { name: 'Delete' }).click() - - await expect(euclid.ruleRow(ruleName)).toHaveCount(0) + test('does not offer Delete for an inactive rule', async ({ authedPage }) => { + // Deletion is disabled for parity with the Hyperswitch dashboard: no Delete action is + // rendered even for an inactive rule (RULE_DELETION feature flag off). + await euclid.openRuleMenu(ruleName) + await expect(euclid.menuItem('Delete')).toHaveCount(0) }) test('filters the list by name from the column header', async ({ authedPage }) => { diff --git a/tests/e2e/routing/volume-split-page.spec.ts b/tests/e2e/routing/volume-split-page.spec.ts index 2d06ddb5..ee890321 100644 --- a/tests/e2e/routing/volume-split-page.spec.ts +++ b/tests/e2e/routing/volume-split-page.spec.ts @@ -107,14 +107,15 @@ test.describe('Volume Split', () => { await expect(volume.ruleRow(ruleName).getByText('Inactive')).toBeVisible() }) - test('blocks Edit and Delete while the rule is active', async ({ authedPage }) => { + test('blocks Edit while active and hides Delete', async ({ authedPage }) => { await volume.ruleAction(ruleName, 'Activate') await expect(authedPage.getByText('Rule activated.')).toBeVisible() - // /routing/update and /routing/delete both reject an active algorithm. + // /routing/update rejects an active algorithm, so Edit is disabled. Delete is hidden + // entirely (RULE_DELETION feature flag off) for parity with the Hyperswitch dashboard. await volume.openRuleMenu(ruleName) await expect(volume.menuItem('Edit')).toBeDisabled() - await expect(volume.menuItem('Delete')).toBeDisabled() + await expect(volume.menuItem('Delete')).toHaveCount(0) }) test('edits an inactive rule and sends the update to the backend', async ({ authedPage }) => { @@ -142,13 +143,11 @@ test.describe('Volume Split', () => { await expect(authedPage.getByPlaceholder('e.g. ab-test-split')).toHaveValue(`copy-of-${ruleName}`) }) - test('deletes an inactive rule', async ({ authedPage }) => { - await volume.ruleAction(ruleName, 'Delete') - - await expect(authedPage.getByText('Delete this rule?')).toBeVisible() - await authedPage.locator('.fixed.inset-0').getByRole('button', { name: 'Delete' }).click() - - await expect(volume.ruleRow(ruleName)).toHaveCount(0) + test('does not offer Delete for an inactive rule', async ({ authedPage }) => { + // Deletion is disabled for parity with the Hyperswitch dashboard: no Delete action is + // rendered even for an inactive rule (RULE_DELETION feature flag off). + await volume.openRuleMenu(ruleName) + await expect(volume.menuItem('Delete')).toHaveCount(0) }) test('filters the list by status from the column header', async ({ authedPage }) => {