Skip to content

feat(connector): [ilixium] add Ilixium connector with all six core flows - #2125

Open
shuklatushar226 wants to merge 10 commits into
mainfrom
feat/grace-Illexium
Open

feat(connector): [ilixium] add Ilixium connector with all six core flows#2125
shuklatushar226 wants to merge 10 commits into
mainfrom
feat/grace-Illexium

Conversation

@shuklatushar226

@shuklatushar226 shuklatushar226 commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds the Ilixium connector with all six core payment flows. Card, one-time payments only (3DS and no-3DS).

Flow Endpoint
Authorize POST /direct/auth (+ POST /direct/threedcomplete on the 3DS return leg)
Capture POST /direct/capture
Void POST /direct/reversal
Refund POST /direct/refund (variant A, referencing a previous transaction)
PSync POST /history/operations, filtered client-side
RSync POST /history/operations, filtered to type == REFUND

Not in scope: wallets, APMs, bank transfers, mandates/MIT/recurring, tokenization, standalone refunds, disputes.

⚠️ This PR also contains a core-flow change — please review it separately

Commit 664e8abf3 is not connector code. PaymentService::authorize unconditionally required payment_method, but on a 3DS return leg Hyperswitch has no raw card data and sends payment_method_data: None, so UCS rejected the call with InvalidArgument before any connector code ran. This blocked every UCS connector that completes 3DS through the Authorize flow — which is exactly Ilixium's documented design.

The fix makes payment_method required only when redirection_response is absent, and adds a PaymentMethodData::NoInstrumentAfterRedirect variant. It touches 34 connector transformers.rs files, but only to add that variant to each one's existing unsupported-payment-method reject arm — compiler-enforced, no behaviour change when payment_method is present. A plain authorize missing payment_method is still rejected exactly as before.

There was a second gate on the same field in PaymentsAuthorizeData::foreign_try_from; relaxing only the handler would still have failed.

Design notes for reviewers

Everything is keyed off transaction.merchantRef. Ilixium publishes no gateway-id lookup, so Capture, Void and Refund all quote the original authorisation's merchantRef. The existing derive_merchant_ref helper is deterministic (verbatim when it fits [\w]{4,20}, else hex(SHA-512(ref))[..20]), so follow-up operations recompute the same reference with no persisted mapping.

Void must send the full original amount. Partial reversals are rejected with code 142. PaymentVoidData::amount is optional and every amount field on the Void PaymentFlowData is None, so a missing amount fails locally with MissingRequiredField rather than sending a zero or partial reversal — which would earn a 142 and burn an operation slot (Ilixium serialises operations per transaction, code 117).

PSync/RSync have no real endpoint. Ilixium publishes no per-payment or per-refund status query. /ipframe/apm/status is ruled out: different host (pripframev2.ilixium.com), tagged "Alternative Payment Methods", and requires a responseKey that /direct/auth never returns. So both sync flows use the bulk /history/operations reconciliation report and filter client-side. That report caps the window at 24 hours, uses a different status enum (adds EXCEPTION, VALIDATION_ERRORS) and a different transactionType enum (CNP_ECOMMERCE), and carries no paymentHistory/cardResponse. A payment outside the window cannot be synced; the caller can anchor the window via connector_feature_data.ilixium_history_period_start. No-match leaves the status unchanged and returns an actionable error rather than inventing a terminal failure.

build_headers is now generic over the flow-common-data type so the refund flows can reuse it, with connector_base_url_refunds alongside the payments helper.

Known limitations

  • Refunds are not individually identifiable. The refund response carries no refund-specific id — merchantRef and gatewayRef both echo the original payment. connector_refund_id prefers status.operationRef, which the vendor documents as "available soon", and falls back to the refund's own UCS reference. While operationRef is not live, multiple partial refunds of the same amount on one payment cannot be told apart; RSync matches on amount then latest entryDate and warns when more than one candidate exists.
  • customer.dateOfBirth is mandatory at Ilixium with no UCS counterpart. Sourced from merchant metadata key ilixium_date_of_birth, omitted when absent — not fabricated.
  • No production base URL is documented by the vendor. All four env configs point at the sandbox host, with per-merchant override via ConnectorSpecificConfig::Ilixium.base_url.
  • PSync not-found downgrades the HS status (requires_captureprocessing). The connector correctly echoes the incoming status, but PaymentServiceGetRequest carries no current attempt status, so UCS defaults to Pending. Platform limitation affecting all connectors, not fixed here.
  • Partial capture is not affirmatively documented by the vendor (unlike reversal, capture has no amount-mismatch error code). Implemented as pass-through.

Testing

No Ilixium credentials exist, so this is verified against a mock Ilixium server driven end to end from Hyperswitch through UCS — not against the live sandbox.

  • 23+ captures audited: 0 digest mismatches, 0 schema failures. x-merchant-digest was independently recomputed from the raw received bytes on every capture, confirming Base64(SHA512(Base64(SHA512(body)) ‖ password)).
  • Driven from HS /payments and /refunds: no-3DS authorize; 3DS authorize through both legs including the ACS redirect; manual-capture → Capture; manual-capture → Void; authorize → Capture → Refund → RSync; PSync.
  • merchantRef consistency proven, with a negative control: a deliberately wrong reference produced code 104 "No Matching Transaction", so the passing runs are not vacuous.
  • Negative paths: Void with no amount refuses locally and sends nothing; PSync/RSync no-match return ILIXIUM_NOT_IN_HISTORY_WINDOW rather than a terminal failure.
  • cargo build full workspace, fmt and clippy all clean.

Requires the Hyperswitch-side PR juspay/hyperswitch#13708, which registers Ilixium for UCS routing and fixes connector_order_id being hardcoded to None on the refund path (without it, Ilixium refunds are impossible from Hyperswitch).

@shuklatushar226
shuklatushar226 requested review from a team as code owners August 13, 2026 17:22
@shuklatushar226 shuklatushar226 changed the title feat(connector): implement Authorize for ilixium feat(connector): [ilixium] add Ilixium connector with all six core flows Aug 13, 2026
Tushar Shukla and others added 6 commits August 15, 2026 20:01
Add the Ilixium Direct API connector with card Authorize support for both
no-3DS and 3DS, including the POST /direct/threedcomplete 3DS-completion leg.

Auth is X-MERCHANT-DIGEST only: Base64(SHA512(body)) then
Base64(SHA512(that || digest password)). Credentials are read at runtime from
the request context; nothing is hardcoded.

All other flows are registered as not_implemented / not_supported.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Auto-applied by CI:
- cargo +nightly fmt --all
- make -C sdk generate (if applicable)
- make docs (if applicable)

This commit was automatically generated by GitHub Actions.
Connectors that finalise 3-D Secure through the Authorize flow (Ilixium, NMI,
Airwallex, Nexixpay, ...) are re-invoked after the ACS posts back to `TermUrl`.
At that point the caller no longer holds the raw instrument, so Hyperswitch
sends `payment_method_data: None` and omits the proto field. The Authorize
handler required `payment_method` unconditionally and rejected the call with
`InvalidArgument: Missing required field: payment_method` before any connector
code ran, making 3DS completion impossible for every such connector.

`payment_method` is now required only when this is not a redirect-return leg.
The leg is identified by `redirection_response`, the same signal the connectors
themselves key off (`request.redirect_response.is_some()`). A plain authorize
with no `payment_method` is still rejected exactly as before.

`PaymentsAuthorizeData::payment_method_data` is not optional, so the absent
instrument is modelled explicitly as `PaymentMethodData::NoInstrumentAfterRedirect`
rather than substituted with a default that a connector could mistake for a
payable instrument. Every existing match on `PaymentMethodData` now handles the
new variant through its existing unsupported-payment-method rejection, so no
connector's behaviour changes; adding the variant is compiler-enforced, so a
connector that later wants to accept the leg has to opt in deliberately.

Verified end to end against a mock Ilixium server: HS /payments -> UCS ->
`POST /direct/auth`, ACS challenge, complete-authorize -> `POST
/direct/threedcomplete`, payment `succeeded`. The no-3DS authorize and the
missing-`payment_method` rejection were re-checked and are unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Completes the six core payment flows for Ilixium. Card, one-time only.

- Capture  POST /direct/capture
- Void     POST /direct/reversal  (a separate endpoint, not capture-with-zero)
- Refund   POST /direct/refund    (variant A, referencing a previous transaction)
- PSync    POST /history/operations, filtered client-side
- RSync    POST /history/operations, filtered to type == REFUND

build_headers is now generic over the flow-common-data type so the refund
flows can reuse it, and connector_base_url_refunds is added alongside the
payments helper.

Notes for reviewers:

Capture, Void and Refund are all keyed off the ORIGINAL authorisation's
transaction.merchantRef; Ilixium publishes no gateway-id lookup. The existing
derive_merchant_ref helper is deterministic, so follow-up operations recompute
the same reference with no persisted mapping.

Partial reversals are rejected by Ilixium with code 142, so Void must send the
full original amount. PaymentVoidData.amount is optional and every amount field
on the Void PaymentFlowData is None, so a missing amount fails locally with
MissingRequiredField rather than sending a zero or partial reversal.

Ilixium publishes no per-payment or per-refund status endpoint, so PSync and
RSync both go through the bulk /history/operations reconciliation report and
filter client-side. That report caps the window at 24h, uses a different
status enum (EXCEPTION, VALIDATION_ERRORS) and a different transactionType
enum (CNP_ECOMMERCE), and carries no paymentHistory or cardResponse. A payment
outside the window cannot be synced; the window can be anchored by the caller
via connector_feature_data.ilixium_history_period_start.

Refund responses carry no refund-specific identifier: merchantRef and
gatewayRef both echo the original payment. connector_refund_id therefore
prefers status.operationRef and falls back to the refund's own UCS reference.
While operationRef is not live, multiple partial refunds of the same amount on
one payment are not individually distinguishable.

Verified against a mock Ilixium server driven end to end from Hyperswitch:
23 captures, 0 digest mismatches, 0 schema failures, and merchantRef proven
identical between Authorize and its Capture/Void/Refund (with a negative
control confirming a wrong reference is rejected with code 104).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Auto-applied by CI:
- cargo +nightly fmt --all
- make -C sdk generate (if applicable)
- make docs (if applicable)

This commit was automatically generated by GitHub Actions.
Rebasing onto main surfaced two upstream API changes that the branch predates,
plus three spell-check hits. No Ilixium behaviour changes.

- `ErrorResponse` gained `typed_connector_response`, `raw_connector_response`,
  `raw_connector_request` and `typed_connector_request`. All ten Ilixium
  initializers now pass `None` for the four, matching the repo-majority pattern
  (385 sites use `None`) and preserving existing behaviour exactly.

- `ConnectorRequestData` no longer exposes `get_inner_value`; it now wraps the
  wire body (`content`) alongside a masked typed copy kept for observability
  (`typed_request`). `build_headers` takes the digest over `.content`, i.e. the
  unmasked bytes that actually go on the wire — digesting the masked copy would
  sign redacted JSON and the gateway would reject every request.

- typos: `unparseable` -> `unparsable`, `mis-attribution` -> `misattribution`.
  The third hit was the `ThreedSecureComplete` variant. Renaming it naively would
  have been a silent wire-contract change: the enum derives
  `rename_all = "SCREAMING_SNAKE_CASE"`, so any respelling re-derives a different
  string and Ilixium's real value would fall through to `#[serde(other)] Unknown`
  with no error anywhere. The variant is renamed *and* its wire value pinned with
  an explicit `#[serde(rename = "THREED_SECURE_COMPLETE")]`, with `THREED`
  allowlisted in `.typos.toml` the same way `Payed` already is for Santander.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
hyperswitch-bot Bot and others added 4 commits August 15, 2026 19:48
Auto-applied by CI:
- cargo +nightly fmt --all
- make -C sdk generate (if applicable)
- make docs (if applicable)

This commit was automatically generated by GitHub Actions.
`Compilation Check` runs `check_connector_specs`, which asserts that every connector
registered in connector-integration has a matching `connector_specs/<name>/` directory.
Ilixium was added without one, so Phase 1 reported 104 integration connectors against
103 spec directories and the job failed. The branch's compile errors had been masking
this — the job never got far enough to run the check.

The six suites listed are exactly the flows the connector implements (Authorize,
Capture, Void, PSync, Refund, RSync); `PaymentService/Reverse` is not among them.
Phase 2 ("flow coverage") independently validates this: it now checks 101 connectors
instead of 100 and reports all flows accounted, which it would not do if the suite
list disagreed with the macro's declared flows.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`execute_connector_processing_step` passed `&router_data.clone()` to `build_request_v2`
and `build_kafka_record`. `router_data` is already owned and still live afterwards, so the
clone existed only to produce a reference the borrow checker would have given for free.

`RouterDataV2` is on the order of 15-20 KB by value here — `PaymentFlowData` alone is 13 KB,
most of which is the `Connectors` config — so each of these materialised a large temporary on
the hot path of every request, once per transport.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`Connectors` carries one `ConnectorParams` (96 bytes) per supported connector — a fixed inline
footprint even when every URL is empty. At 108 connectors it is 10,344 bytes and grows by 96
bytes with every connector added.

It is stored *by value* in ten structs: nine flow-data types (`PaymentFlowData`,
`RefundFlowData`, `DisputeFlowData`, `VerifyWebhookSourceFlowData`,
`RefreshPaymentMethodFlowData`, `MerchantAuthenticationFlowData`, `FrmFlowData`,
`SurchargeFlowData`, `PayoutFlowData`) plus the top-level `Config`. All nine derive `Clone` and
are embedded by value in `RouterDataV2`, which is cloned several times per request, so the size
is paid repeatedly in both stack and memcpy — and it scales with the number of connectors in
the repo rather than with anything about the payment.

That limit is now being reached: on a branch adding one connector the Stripe flow tests abort
with `fatal runtime error: stack overflow`, while the same tests pass on main. Stripe is the
only connector with credentials in CI and therefore the only test that exercises the full
request/response path. The connector added there contributes 96 bytes; it is the straw, not the
cause, and the next connector added would do the same.

`Connectors` becomes a newtype over `Arc<ConnectorsInner>`:

  size_of::<Connectors>()      10344 -> 8
  size_of::<PaymentFlowData>() 13056 -> 2816
  size_of::<RefundFlowData>()  10752 -> 512
  size_of::<PayoutFlowData>()  10608 -> 368

The `Arc` goes *inside* the type rather than at each use site, which is what keeps the change
small: `Deref<Target = ConnectorsInner>` means all 113 `ConnectorCommon::base_url`
implementations and every other read site compile untouched (each reads exactly one field, e.g.
`connectors.stripe.base_url`), and `Connectors` stays a by-value type so the `ForeignTryFrom`
tuple impls, the `apply_url_overrides` call sites and the `patch_*` methods returning `Self`
keep compiling while moving 8 bytes instead of 10 KB. Only the 18 literal `Connectors { .. }`
constructions, all in connector test modules, needed updating.

Notes:

- `Serialize`/`Deserialize` are implemented by hand rather than via `#[serde(transparent)]`,
  because serde only supports `Arc` under its `rc` feature, which this workspace does not
  enable. They round-trip through `ConnectorsInner`, so the on-disk config shape is unchanged;
  verified by booting grpc-server against the existing `config/development.toml`.
- `config_patch_derive::Patch` names a field's patch type `<FieldType>Patch`, so
  `ConnectorsPatch` is kept as an alias of `ConnectorsInnerPatch` with a delegating impl.
- The five `patch_*_connector_urls` helpers mutate through `Arc::make_mut`, so the deep copy
  now happens only when a patch actually applies rather than on every call.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant