Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 7 additions & 4 deletions src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
49 changes: 9 additions & 40 deletions tests/api/routing/routing-rule-mutations.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' }),
Expand All @@ -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 }) => {
Expand All @@ -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.
})
})
8 changes: 5 additions & 3 deletions tests/api/routing/volume-contracts.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 }) => {
Expand Down
19 changes: 9 additions & 10 deletions tests/e2e/routing/rules/euclid-lifecycle.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) => {
Expand All @@ -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 }) => {
Expand Down
19 changes: 9 additions & 10 deletions tests/e2e/routing/volume-split-page.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) => {
Expand Down Expand Up @@ -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 }) => {
Expand Down
5 changes: 4 additions & 1 deletion website/src/components/pages/ABTestingPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<ABTestExperimentType, string> = {
Expand Down Expand Up @@ -838,7 +839,9 @@ function ExperimentDetailPanel({
Duplicate, being read-only on this experiment, is offered in every state. */}
<Button size="sm" variant="secondary" onClick={onClone} disabled={!canEditRouting}><Copy size={13} /> Duplicate</Button>
<Button size="sm" variant="secondary" onClick={onEdit} disabled={!canEditRouting}><Pencil size={13} /> Edit</Button>
<Button size="sm" variant="secondary" onClick={onDelete} disabled={!canEditRouting}><Trash2 size={13} /> Delete</Button>
{FEATURE_FLAGS.RULE_DELETION && (
<Button size="sm" variant="secondary" onClick={onDelete} disabled={!canEditRouting}><Trash2 size={13} /> Delete</Button>
)}
<Button size="sm" variant="primary" onClick={onActivate} disabled={!canEditRouting}>Activate</Button>
</>
)}
Expand Down
7 changes: 7 additions & 0 deletions website/src/components/pages/EuclidRuleBuilderPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -339,6 +339,13 @@ export function EuclidRuleBuilderPage() {
<p className="border-b border-slate-100 pb-2.5 text-xs font-semibold uppercase tracking-widest text-slate-500 dark:border-[#1c1c24] dark:text-[#8d96a8]">
Configured Rules
</p>
{/* Same warning the Hyperswitch dashboard shows on its rule builder — amounts are
easy to get wrong by a factor of 100 without it. */}
<p className="text-[13px] text-slate-500 dark:text-[#8d96a8] leading-[18px]">
Enter payment amounts in the smallest currency unit (cents for USD, yen for JPY).
For instance, <code className="font-mono">100</code> matches $1.00 (USD) but ¥100
(JPY), since ¥ is a zero-decimal currency.
</p>
{routingKeysLoading && (
<p className="text-sm text-slate-500">Loading routing keys from backend...</p>
)}
Expand Down
21 changes: 13 additions & 8 deletions website/src/components/pages/EuclidRulesPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -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,
},
]
: []),
]}
/>
</td>
Expand Down
21 changes: 13 additions & 8 deletions website/src/components/pages/VolumeSplitPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -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,
},
]
: []),
]}
/>
</td>
Expand Down
19 changes: 15 additions & 4 deletions website/src/components/ui/GatewaySelect.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,11 @@ export function GatewaySelect({
const dropdownRef = useRef<HTMLDivElement>(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()
Expand Down Expand Up @@ -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. */}
<span className="flex min-w-0 items-baseline gap-2">
<span>{o.name}</span>
{o.label && (
<span className="min-w-0 truncate font-sans text-xs font-normal text-slate-600 dark:text-slate-400">
{o.label}
</span>
)}
</span>
{o.gatewayId && (
<span
data-gateway-id={o.gatewayId}
Expand Down
18 changes: 14 additions & 4 deletions website/src/lib/connectors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,8 @@ export interface GatewayOption {
name: string
/** The connector's merchant connector account id, when the dashboard handed one over. */
gatewayId?: string
/** The merchant's own label for the account, when the dashboard handed one over. */
label?: string
}

/**
Expand Down Expand Up @@ -173,10 +175,18 @@ export function gatewayOptions(
const pickedIds = new Set(alreadyPickedIds.filter(Boolean))
return handoff
.filter((connector) => !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<string>()
Expand Down
4 changes: 4 additions & 0 deletions website/src/lib/featureFlags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
Loading