diff --git a/src/app.rs b/src/app.rs index 2a4743a4..877e0a11 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/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 }) => { 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/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/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/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() 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, }