Skip to content

feat(connector): implement Authorize (Card, non-3DS + 3DS redirect), PSync, Capture, Void, Refund and RSync for citigate - #2124

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

feat(connector): implement Authorize (Card, non-3DS + 3DS redirect), PSync, Capture, Void, Refund and RSync for citigate#2124
shuklatushar226 wants to merge 10 commits into
mainfrom
feat/grace-Citigate

Conversation

@shuklatushar226

@shuklatushar226 shuklatushar226 commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

Summary

Implement the Authorize, PSync, Capture, Void, Refund and RSync flows for the new Citigate connector.

This implementation was generated and validated by GRACE (automated connector integration pipeline).

Scope: Card payment method, one-time payments — non-3DS Authorize, the 3DS redirect path, PSync (Transaction Status Check), and the post-authorization operations Capture (TransTypeID 3), Void / Cancel (4), Refund (5) and RSync (the same status check keyed on the refund's MerchantRef).

Live-verification status, up front: Refund and RSync are verified against the live sandbox gateway (and Refund additionally end to end through the Hyperswitch REST API). Capture and Void success paths are NOT verified — they are not exercisable on these credentials, because the sandbox MID auto-captures and so never leaves an open authorisation. Only their wire format and their 560 failure mapping were validated live. See Verification status per flow.

This PR has been extended twice since the initial review round. Commit 234f1bf adds the 3DS redirect path and PSync on top of the original non-3DS Authorize commit — see 3DS redirect + PSync. Commit d32c547 adds Capture, Void, Refund and RSync — see Capture, Void, Refund and RSync.

Implementation Notes

  • New UCS connector Citigate over the JSON interface: POST {base_url}/orion/interface/json.ashx, Content-Type: application/json.
  • Auth is BodyKeyMerchantName (api_key) and MerchantPassword (key1) are sent in the request body; get_auth_header returns empty.
  • Authorize sends PaymentTypeID=1 (card) and TransTypeID=0 (purchase), with StringMinorUnit amounts.
  • The gateway returns HTTP 200 for everything, including declines. Status is therefore classified from the (ResponseCode, TransTypeID) pair:
    • 0 + 1|3Charged
    • 0 + 2Authorized
    • 0 + other → Pending
    • 999 + 6Pending
    • 600 (+ a usable RedirectURL) → AuthenticationPending with a redirect form
    • 600 without a RedirectURL, or 103 (3D auth failure) → AuthenticationFailed
    • otherwise → AuthorizationFailed
  • Handles a .NET serializer quirk where empty string fields are emitted as empty arrays ([]): a tolerant deserializer collapses string / number / [] / "" / null into Option<String>.
  • Card brand is resolved from card_network, with a BIN-detection fallback; unsupported networks are rejected pre-flight.

Changes

  • Added citigate.rs — connector definition, create_all_prerequisites!, macro_connector_implementation! for Authorize and PSync, BodyKey auth, and error handling.
  • Added citigate/transformers.rs — Authorize and PSync request/response types, tolerant deserializer, status mapping, redirect-form construction, and TryFrom implementations.
  • Connector registration (required for a brand-new connector): connector enum + variant wiring, default implementations for unimplemented flows, proto Connector::CITIGATE = 137 and CitigateConfig, auth config plumbing, field-probe dummy auth, and base_url entries in the development / sandbox / production configs.

Files Modified

  • crates/integrations/connector-integration/src/connectors/citigate.rs (new)
  • crates/integrations/connector-integration/src/connectors/citigate/transformers.rs (new)
  • crates/integrations/connector-integration/src/connectors.rs
  • crates/integrations/connector-integration/src/default_implementations.rs
  • crates/integrations/connector-integration/src/types.rs
  • crates/internal/field-probe/src/auth.rs
  • crates/types-traits/domain_types/src/connector_types.rs
  • crates/types-traits/domain_types/src/router_data.rs
  • crates/types-traits/domain_types/src/types.rs
  • crates/types-traits/grpc-api-types/proto/payment.proto
  • config/development.toml
  • config/sandbox.toml
  • config/production.toml

gRPC Test Results

Status: PASS

grpcurl Authorize call — approve path, non-3DS (credentials redacted)
grpcurl -plaintext \
  -H 'x-connector: citigate' \
  -H 'x-auth: body-key' \
  -H 'x-api-key: <REDACTED>' \
  -H 'x-key1: <REDACTED>' \
  -H 'x-merchant-id: <REDACTED>' \
  -H 'x-tenant-id: default' \
  -H 'x-request-id: req_citigate_auth_004' \
  -H 'x-connector-request-reference-id: citigate_ref_004' \
  -d '{
    "merchant_transaction_id": "citigate_txn_004",
    "amount": {"minor_amount": 2316, "currency": "USD"},
    "payment_method": {
      "card": {
        "card_number": {"value": "<REDACTED>"},
        "card_exp_month": {"value": "<REDACTED>"},
        "card_exp_year": {"value": "<REDACTED>"},
        "card_cvc": {"value": "<REDACTED>"},
        "card_holder_name": {"value": "<REDACTED>"},
        "card_network": "VISA"
      }
    },
    "capture_method": "AUTOMATIC",
    "auth_type": "NO_THREE_DS",
    "enrolled_for_3ds": false,
    "return_url": "https://example.com/return",
    "browser_info": {"ip_address": "<REDACTED>"},
    "address": {
      "billing_address": {
        "first_name": {"value": "<REDACTED>"},
        "last_name": {"value": "<REDACTED>"},
        "line1": {"value": "<REDACTED>"},
        "city": {"value": "<REDACTED>"},
        "state": {"value": "<REDACTED>"},
        "zip_code": {"value": "<REDACTED>"},
        "country_alpha2_code": "US",
        "email": {"value": "<REDACTED>"},
        "phone_number": {"value": "<REDACTED>"},
        "phone_country_code": "+1"
      }
    }
  }' \
  localhost:8000 types.PaymentService/Authorize

Response:
{
  "merchantTransactionId": "citigate_txn_004",
  "connectorTransactionId": "670580",
  "status": "CHARGED",
  "statusCode": 200,
  "connectorFeatureData": {
    "value": "{\"business_case\":\"<REDACTED>\",\"descriptor\":\"<REDACTED>\",\"bank\":\"DummyBank\"}"
  },
  "connectorReferenceId": "citigate_txn_004"
}

Outbound body actually sent to the gateway (from server log, secrets masked):
{"PaymentTypeID":"1","TransTypeID":"0","MerchantName":"<REDACTED>","MerchantPassword":"<REDACTED>",
 "MerchantRef":"citigate_txn_004","Currency":"USD","Amount":"2316","Brand":"VISA",
 "CardholderName":"<REDACTED>","CardNo":"<REDACTED>","ExpiryYear":"<REDACTED>","ExpiryMonth":"<REDACTED>",
 "CVV":"<REDACTED>","Firstname":"<REDACTED>","Surname":"<REDACTED>","StreetLine1":"<REDACTED>","City":"<REDACTED>",
 "PostalCode":"<REDACTED>","StateProvince":"<REDACTED>","Country":"US","Email":"<REDACTED>",
 "Telephone":"<REDACTED>","UserIP":"<REDACTED>"}
grpcurl Authorize call — expected-decline path, amount >= 1000.00 (credentials redacted)
Same command as above, with:
  -H 'x-request-id: req_citigate_auth_003'
  -H 'x-connector-request-reference-id: citigate_ref_003'
  "merchant_transaction_id": "citigate_txn_003",
  "amount": {"minor_amount": 120000, "currency": "USD"},
(all other fields identical)

Response:
{
  "connectorTransactionId": "670579",
  "status": "AUTHORIZATION_FAILED",
  "error": {
    "issuerDetails": {
      "message": "Insufficient Funds",
      "networkDetails": {
        "declineCode": "51",
        "errorMessage": "Insufficient Funds"
      }
    },
    "connectorDetails": {
      "code": "51",
      "message": "Not sufficient funds",
      "reason": "Insufficient Funds",
      "connectorTransactionId": "670579"
    }
  },
  "statusCode": 200
}

This is a deliberate negative test: the sandbox declines amounts >= 1000.00. It confirms that the
HTTP-200-on-decline behaviour is correctly classified as AUTHORIZATION_FAILED rather than a success,
and that issuer/network decline details are surfaced.


3DS redirect + PSync

Added in commit 234f1bf. Only the two connector source files changed — no new registration, enums,
config, or lib/mod entries.

transformers.rs

  • CitigatePaymentsRequest gained SuccessURL / FailURL / CallbackURL as Option<String> with
    skip_serializing_if. SuccessURL and FailURL carry router_return_url (falling back to
    complete_authorize_url, then PaymentFlowData::return_url); CallbackURL carries webhook_url.
    They are omitted entirely when absent, so the non-3DS wire payload is byte-identical to before.
  • When auth_type == ThreeDs, a missing URL is rejected client-side with MissingRequiredField
    rather than being sent and refused by the gateway with 584 / 585 / 586.
  • External / merchant-provided 3DS is rejected with IntegrationError::NotSupported. No 3DS metadata
    is populated in either direction — this 3DS1-era API exposes no ECI / CAVV / dsTransId / transStatus.
  • ResponseCode 600 moved to the success arm: is_success() is true for 600 (guarded on a usable
    RedirectURL), attempt_status() returns AttemptStatus::AuthenticationPending, and
    redirect_form() builds RedirectForm::Form { endpoint: RedirectURL.trim(), method: Get, form_fields: {} }.
    600 with no RedirectURL falls through to AuthenticationFailed, where code 103 (3D auth
    failure) was also added.
  • New PSync support: CitigateSyncRequest (PaymentTypeID=1, TransTypeID=8, MerchantName /
    MerchantPassword / MerchantRef) keyed on connector_request_reference_id, plus
    CitigateSyncResponse. Sync status mapping: TransTypeID 1|3|5Charged, 2Authorized,
    4Voided, 6Pending, 99Failure; ResponseCode 103AuthenticationFailed,
    600 on sync → Pending. to_error_response was split so the sync path attaches
    Failure / AuthenticationFailed instead of the authorize-specific AuthorizationFailed.

citigate.rs

  • PSync added to create_all_prerequisites!.
  • A macro_connector_implementation! for PSync (POST, same /orion/interface/json.ashx endpoint and
    headers, so PSync uses the same MID credentials as the Authorize).
  • A manual PaymentSyncV2 impl, and PSync removed from the not-implemented stub list.

Full 3DS lifecycle proven end to end

AUTHENTICATION_PENDING → redirect → PENDINGCHARGED.

(1) Authorize on the 3DS-enabled MID — returns a redirect (credentials redacted)
grpcurl -plaintext \
  -H 'x-connector: citigate' -H 'x-auth: body-key' \
  -H 'x-api-key: <REDACTED>' -H 'x-key1: <REDACTED>' \
  -H 'x-merchant-id: <REDACTED>' -H 'x-request-id: req-cg-3ds-001' -H 'x-tenant-id: default' \
  -d '{
  "merchant_transaction_id": "cg3ds1786631201",
  "amount": {"minor_amount": 2317, "currency": "USD"},
  "payment_method": {"card": {
      "card_number": {"value": "<REDACTED>"},
      "card_exp_month": {"value": "<REDACTED>"},
      "card_exp_year": {"value": "<REDACTED>"},
      "card_cvc": {"value": "<REDACTED>"},
      "card_holder_name": {"value": "<REDACTED>"},
      "card_network": "VISA"}},
  "address": {"billing_address": {
      "first_name": {"value": "<REDACTED>"}, "last_name": {"value": "<REDACTED>"},
      "line1": {"value": "<REDACTED>"}, "city": {"value": "<REDACTED>"},
      "state": {"value": "<REDACTED>"}, "zip_code": {"value": "<REDACTED>"},
      "country_alpha2_code": "US",
      "email": {"value": "<REDACTED>"},
      "phone_number": {"value": "<REDACTED>"}}},
  "auth_type": "THREE_DS",
  "enrolled_for_3ds": true,
  "capture_method": "AUTOMATIC",
  "return_url": "https://merchant.example/return",
  "webhook_url": "https://merchant.example/webhook",
  "browser_info": {"ip_address": "<REDACTED>", "user_agent": "Mozilla/5.0", "accept_header": "*/*", "language": "en-US"},
  "customer": {"email": {"value": "<REDACTED>"}, "first_name": "<REDACTED>", "last_name": "<REDACTED>"}
}' \
  localhost:8000 types.PaymentService/Authorize

Response:
{
  "merchantTransactionId": "cg3ds1786631201",
  "connectorTransactionId": "670582",
  "status": "AUTHENTICATION_PENDING",
  "statusCode": 200,
  "redirectionData": {
    "form": {
      "endpoint": "https://gw-test.cgate.tech/orion/redirect/pay.aspx?cgid=997c085959e649f6ab2455fa4f2e2d640670582",
      "method": "HTTP_METHOD_GET"
    }
  },
  "connectorFeatureData": {"value": "{\"business_case\":\"<REDACTED>\",\"descriptor\":\"<REDACTED>\",\"bank\":\"DummyBank\"}"},
  "connectorReferenceId": "cg3ds1786631201"
}

Raw gateway response — ResponseCode=600, response TransTypeID=1 (Sale):
{"TransactionID":"670582","MerchantRef":"cg3ds1786631201","TransTypeID":"1","Currency":"USD",
 "Amount":"2317","BusinessCase":"<REDACTED>","Descriptor":"<REDACTED>","Bank":"DummyBank",
 "ResponseCode":"600","ResponseDescription":"Redirect required","BankCode":null,"BankDescription":null,
 "RedirectURL":"https://gw-test.cgate.tech/orion/redirect/pay.aspx?cgid=997c085959e649f6ab2455fa4f2e2d640670582"}

Outgoing body carried the three new fields:
..."SuccessURL":"https://merchant.example/return","FailURL":"https://merchant.example/return",
   "CallbackURL":"https://merchant.example/webhook"}
(2) PSync before the cardholder authenticated — PENDING (credentials redacted)
grpcurl -plaintext \
  -H 'x-connector: citigate' -H 'x-auth: body-key' \
  -H 'x-api-key: <REDACTED>' -H 'x-key1: <REDACTED>' \
  -H 'x-merchant-id: <REDACTED>' -H 'x-request-id: req-cg-3ds-psync-001' -H 'x-tenant-id: default' \
  -d '{"merchant_transaction_id":"cg3ds1786631201","connector_transaction_id":"670582",
       "amount":{"minor_amount":2317,"currency":"USD"},"capture_method":"AUTOMATIC"}' \
  localhost:8000 types.PaymentService/Get

Response:
{
  "connectorTransactionId": "670582",
  "status": "PENDING",
  "statusCode": 200,
  "amount": {"minorAmount": "2317", "currency": "USD"},
  "merchantTransactionId": "cg3ds1786631201",
  "connectorReferenceId": "cg3ds1786631201"
}

Raw: {"TransactionID":"670582","MerchantRef":"cg3ds1786631201","TransTypeID":"6",...,
      "ResponseCode":"999","ResponseDescription":"Transaction Pending",...}

Outgoing sync body:
{"PaymentTypeID":"1","TransTypeID":"8","MerchantName":"<REDACTED>","MerchantPassword":"<REDACTED>",
 "MerchantRef":"cg3ds1786631201"}
(3) Redirect completed out-of-band, then the identical PSync — CHARGED (credentials redacted)
The returned RedirectURL was driven end to end (pay.aspx -> processing.aspx -> tester/Simulate3D.aspx,
"Simulate an approved"). The gateway POSTed the return form back to SuccessURL with ResponseCode=0,
TransTypeID=1, Approved, plus a Signature. Re-running the identical PSync:

{
  "connectorTransactionId": "670582",
  "status": "CHARGED",
  "statusCode": 200,
  "amount": {"minorAmount": "2317", "currency": "USD"},
  "merchantTransactionId": "cg3ds1786631201",
  "connectorFeatureData": {"value": "{\"business_case\":\"<REDACTED>\",\"descriptor\":\"<REDACTED>\",\"bank\":\"DummyBank\"}"},
  "connectorReferenceId": "cg3ds1786631201"
}

Raw: {"TransactionID":"670582",...,"TransTypeID":"1",...,"ResponseCode":"0",
      "ResponseDescription":"Approved","BankCode":"0","BankDescription":"Approved","RedirectURL":null}
(4) Non-3DS regression — unchanged, with and without the new URLs (credentials redacted)
(4a) Non-3DS MID, WITH return_url / webhook_url present.
Same payload as (1) but merchant_transaction_id "cgn3d1786631337", minor_amount 2318,
auth_type "NO_THREE_DS", enrolled_for_3ds false, non-3DS MID credentials:

{
  "merchantTransactionId": "cgn3d1786631337",
  "connectorTransactionId": "670583",
  "status": "CHARGED",
  "statusCode": 200,
  "connectorFeatureData": {"value": "{\"business_case\":\"<REDACTED>\",\"descriptor\":\"<REDACTED>\",\"bank\":\"DummyBank\"}"},
  "connectorReferenceId": "cgn3d1786631337"
}
Raw: ResponseCode=0, TransTypeID=1, RedirectURL:null, no redirectionData in the UCS response.

(4b) Non-3DS MID, WITHOUT return_url / webhook_url — proves the fields are omitted.
Same call with both removed, merchant_transaction_id "cgn3db1786631353", minor_amount 2319:

{
  "merchantTransactionId": "cgn3db1786631353",
  "connectorTransactionId": "670584",
  "status": "CHARGED",
  "statusCode": 200,
  "connectorFeatureData": {"value": "{\"business_case\":\"<REDACTED>\",\"descriptor\":\"<REDACTED>\",\"bank\":\"DummyBank\"}"},
  "connectorReferenceId": "cgn3db1786631353"
}

Outgoing body ends ..."Telephone":"<REDACTED>","UserIP":"<REDACTED>"} — the three new fields are
absent, so the pre-existing non-3DS payload is unchanged.

Notes for reviewers

  • The sandbox RedirectURL host/path is https://gw-test.cgate.tech/orion/redirect/pay.aspx?cgid=...
    rather than the spec's https://gw.cgate.tech/redirect/processing.aspx?<token>. The value is used
    verbatim with no host validation, so both work.
  • PSync is keyed on merchant_transaction_id (which becomes connector_request_reference_id
    MerchantRef). connector_transaction_id is required by the proto but is not used for the
    lookup. Callers must pass the same MID credentials as the original Authorize, or the gateway returns
    TransTypeID 99 / "MerchantRef not found".
  • Sandbox quirks respected while testing: the spec's own example UserIP is blacklisted (596), so a
    documentation-range IP was used instead, and amounts were varied per run to avoid the 30-second
    duplicate guard (597).
  • Citigate has no public developer portal; the authoritative reference is the vendor-supplied
    "Citigate API Card v1.27" PDF, which is intentionally not committed.

Capture, Void, Refund and RSync

Added in commit d32c547. Only the two connector source files changed — no new registration, enums,
config, or lib/mod entries.

Verification status per flow — please read before reviewing the evidence

Flow Live gateway verification
Refund VERIFIED live. Approved (ResponseCode 0) via direct grpcurl, and end to end through the Hyperswitch REST API (POST /refundssucceeded).
RSync VERIFIED live via direct grpcurl — resolved the refund leg through the refund's own MerchantRef.
Capture Success path NOT exercised — not exercisable on these sandbox credentials. Only the wire format and the failure mapping (gateway code 560) were validated live.
Void Success path NOT exercised — not exercisable on these sandbox credentials. Only the wire format and the failure mapping (gateway code 560) were validated live.
Authorize non-3DS / Authorize 3DS / PSync Re-run as regression checks after the change — all still pass.

Why Capture and Void cannot be proven on this sandbox: the non-3DS test MID auto-captures. An
Authorize sent with capture_method: MANUAL still comes back with response TransTypeID = "1"
(Sale), never "2" (Authorise), so no open authorisation is ever created and there is nothing for
a Capture or a Void to act on. Every attempt therefore reaches the real gateway and comes back 560
"No corresponding successful authorisation". That does confirm the request serialization, the
endpoint, the auth and the error mapping against the live gateway — but the approved
(ResponseCode 0Charged / Voided) branch of both flows has not been observed and should be
reviewed as unproven code. Citigate's auto-capture cannot be disabled (spec Appendix 4), so this is a
property of the credentials, not of the implementation.

Also not exercised: partial refund (Amount present). Partial refunds are disabled per-merchant
by default (gateway code 629) and the implementation deliberately defaults to a full refund.

citigate.rs

  • Capture, Void, Refund and RSync added to create_all_prerequisites!, each with a
    macro_connector_implementation! — all four POST to the same /orion/interface/json.ashx endpoint
    with the same headers, so they reuse the Authorize MID credentials.
  • Refund and RSync carry RefundFlowData rather than PaymentFlowData, so their get_url reads
    resource_common_data.connectors.citigate.base_url directly.
  • Added PaymentCapture, PaymentVoidV2, RefundV2 and RefundSyncV2 trait impls; the four flows
    were removed from the not-implemented stub list.

transformers.rs

  • Capture (TransTypeID = 3) — six documented fields, no Amount: Citigate cannot do a
    partial, multiple or incremental capture. is_multiple_capture() and any capture amount that
    differs from minor_amount_authorized are rejected client-side with NotSupported rather than
    silently capturing the full amount the caller did not ask for.
  • Void / Cancel (TransTypeID = 4) — same six fields. A void is always full-amount, and the wire
    format has no field for a cancellation reason, so a partial void is rejected client-side.
  • Capture and Void responses return a new leg TransactionID (auth 310 → capture 312). UCS's
    resource_id keeps pointing at the original authorisation; the leg id is preserved in
    connector_metadata as leg_transaction_id instead, so PSync and later operations still key off
    the payment.
  • Refund (TransTypeID = 5)Amount is skip_serializing_if = "Option::is_none" and is
    omitted whenever minor_refund_amount == minor_payment_amount. That is both the documented default
    and the only form that works on an account without partial refunds enabled. Here the new leg id
    genuinely is the refund, so it becomes connector_refund_id.
  • RSync — byte-identical on the wire to PSync (TransTypeID = 8, five fields). The only thing
    that makes it an RSync is that MerchantRef is the refund's reference. Keying it on
    connector_refund_id (a Citigate TransactionID, which the status check has no field for) or on
    the Authorize MerchantRef would silently resolve the payment leg and report the wrong status.
    refund_sync_status therefore only reports Success for ResponseCode 0 and
    TransTypeID = 5; 0 with any other type, and 999 + TransTypeID 6, are Pending; 999 +
    TransTypeID 99 ("MerchantRef not found") is Failure.
  • Since these flows have no redirect and no pending state, ResponseCode alone decides Capture / Void
    / Refund outcomes. Failure mappings kept deliberately distinct: 561 on Capture means the auth was
    already captured (quite possibly by Citigate's own 48-96h auto-capture), 561 on Void means the
    auth is no longer open and a Refund is the right operation instead, and 605 on Refund
    ("bank does not support API refunds") is reported as a failure verbatim — Citigate logs a manual
    refund out of band and nothing in this flow can resolve that.
  • to_error_response was generalised into to_flow_error_response so the refund flows attach a
    RefundStatus while the payment flows keep attaching an AttemptStatus.
  • MerchantRef is validated as non-empty up front on every flow, since it is the only key a status
    check can be resolved by.
(1) Open-auth probe — Authorize with capture_method: MANUAL still returns a Sale (credentials redacted)
grpcurl -plaintext \
  -H 'x-connector: citigate' \
  -H 'x-connector-config: {"config":{"Citigate":{"api_key":"<REDACTED>","key1":"<REDACTED>"}}}' \
  -H 'x-merchant-id: <REDACTED>' -H 'x-tenant-id: default' \
  -d '{
  "merchant_transaction_id": "cg_probe_1786651799",
  "amount": {"minor_amount": 41733, "currency": "USD"},
  "payment_method": {"card": {
      "card_number": {"value": "<REDACTED>"}, "card_exp_month": {"value": "<REDACTED>"},
      "card_exp_year": {"value": "<REDACTED>"}, "card_cvc": {"value": "<REDACTED>"},
      "card_holder_name": {"value": "<REDACTED>"}}},
  "capture_method": "MANUAL",
  "address": {"billing_address": {
      "first_name": {"value": "<REDACTED>"}, "last_name": {"value": "<REDACTED>"},
      "line1": {"value": "<REDACTED>"}, "city": {"value": "<REDACTED>"},
      "zip_code": {"value": "<REDACTED>"}, "country_alpha2_code": "US",
      "email": {"value": "<REDACTED>"}}},
  "auth_type": "NO_THREE_DS",
  "return_url": "https://example.com/return",
  "browser_info": {"ip_address": "<REDACTED>"}
}' localhost:8010 types.PaymentService/Authorize

Response:
{
  "merchantTransactionId": "cg_probe_1786651799",
  "connectorTransactionId": "670591",
  "status": "CHARGED",
  "statusCode": 200,
  "connectorFeatureData": {"value": "{\"business_case\":\"<REDACTED>\",\"descriptor\":\"<REDACTED>\",\"bank\":\"DummyBank\"}"},
  "connectorReferenceId": "cg_probe_1786651799"
}

Raw gateway response — "TransTypeID":"1" (Sale), so NO open auth is created:
{"TransactionID":"670591","MerchantRef":"cg_probe_1786651799","TransTypeID":"1","Currency":"USD",
 "Amount":"41733","BusinessCase":"<REDACTED>","Descriptor":"<REDACTED>","Bank":"DummyBank",
 "ResponseCode":"0","ResponseDescription":"Approved","BankCode":"0","BankDescription":"Approved"}

This is the reason the Capture and Void success paths below could not be reached.

(2) Capture — wire format and 560 failure mapping validated live; success path NOT exercised (credentials redacted)
grpcurl -plaintext \
  -H 'x-connector: citigate' \
  -H 'x-connector-config: {"config":{"Citigate":{"api_key":"<REDACTED>","key1":"<REDACTED>"}}}' \
  -H 'x-merchant-id: <REDACTED>' -H 'x-tenant-id: default' \
  -d '{"merchant_capture_id": "cg_cap_1786651819",
       "connector_transaction_id": "670591",
       "amount_to_capture": {"minor_amount": 41733, "currency": "USD"}}' \
  localhost:8010 types.PaymentService/Capture

Response:
{
  "status": "CAPTURE_FAILED",
  "error": {
    "issuerDetails": {"networkDetails": {}},
    "connectorDetails": {
      "code": "560",
      "message": "No corresponding successful authorisation",
      "reason": "No corresponding successful authorisation"
    }
  },
  "statusCode": 200
}

Raw gateway response:
{"TransactionID":"0","MerchantRef":"cg_cap_1786651819","TransTypeID":"0","Currency":null,"Amount":"0",
 "BusinessCase":null,"Descriptor":null,"Bank":null,"ResponseCode":"560",
 "ResponseDescription":"No corresponding successful authorisation","BankCode":null,"BankDescription":null}

The gateway parsed and answered the request, so the endpoint, auth, field set and the
560 → CAPTURE_FAILED mapping are confirmed against the live gateway. The approved
(ResponseCode 0Charged) branch was never reached — it remains unproven.

(3) Void — wire format and 560 failure mapping validated live; success path NOT exercised (credentials redacted)
grpcurl -plaintext \
  -H 'x-connector: citigate' \
  -H 'x-connector-config: {"config":{"Citigate":{"api_key":"<REDACTED>","key1":"<REDACTED>"}}}' \
  -H 'x-merchant-id: <REDACTED>' -H 'x-tenant-id: default' \
  -d '{"merchant_void_id": "cg_void_1786651825", "connector_transaction_id": "670591"}' \
  localhost:8010 types.PaymentService/Void

Response:
{
  "status": "VOID_FAILED",
  "error": {
    "issuerDetails": {"networkDetails": {}},
    "connectorDetails": {
      "code": "560",
      "message": "No corresponding successful authorisation",
      "reason": "No corresponding successful authorisation"
    }
  },
  "statusCode": 200
}

Raw gateway response:
{"TransactionID":"0","MerchantRef":"cg_void_1786651825","TransTypeID":"0","Currency":null,"Amount":"0",
 "BusinessCase":null,"Descriptor":null,"Bank":null,"ResponseCode":"560",
 "ResponseDescription":"No corresponding successful authorisation","BankCode":null,"BankDescription":null}

Same caveat as Capture: the approved (ResponseCode 0Voided) branch was never reached.

(4) Fresh sale, then Refund — VERIFIED SUCCESS (credentials redacted)
Sale (same Authorize shape as (1), capture_method AUTOMATIC):
  "merchant_transaction_id": "cg_sale_1786651830", "minor_amount": 52471

Response:
{
  "merchantTransactionId": "cg_sale_1786651830",
  "connectorTransactionId": "670592",
  "status": "CHARGED",
  "statusCode": 200,
  "connectorFeatureData": {"value": "{\"business_case\":\"<REDACTED>\",\"descriptor\":\"<REDACTED>\",\"bank\":\"DummyBank\"}"},
  "connectorReferenceId": "cg_sale_1786651830"
}

Refund:
grpcurl -plaintext \
  -H 'x-connector: citigate' \
  -H 'x-connector-config: {"config":{"Citigate":{"api_key":"<REDACTED>","key1":"<REDACTED>"}}}' \
  -H 'x-merchant-id: <REDACTED>' -H 'x-tenant-id: default' \
  -d '{"merchant_refund_id": "cg_rfnd_1786651836",
       "connector_transaction_id": "670592",
       "payment_amount": 52471,
       "refund_amount": {"minor_amount": 52471, "currency": "USD"},
       "reason": "customer_request"}' \
  localhost:8010 types.PaymentService/Refund

Response:
{
  "merchantRefundId": "cg_rfnd_1786651836",
  "connectorRefundId": "670593",
  "status": "REFUND_SUCCESS",
  "statusCode": 200,
  "connectorTransactionId": "670592"
}

Outbound body — note there is NO "Amount" field (the full-refund default, which avoids code 629):
{"PaymentTypeID":"1","TransTypeID":"5","MerchantName":"<REDACTED>","MerchantPassword":"<REDACTED>",
 "MerchantRef":"cg_rfnd_1786651836","TransactionID":"670592"}

Raw gateway response — TransTypeID 5, new leg id 670593, approved:
{"TransactionID":"670593","MerchantRef":"cg_rfnd_1786651836","TransTypeID":"5","Currency":"USD",
 "Amount":"52471","BusinessCase":"<REDACTED>","Descriptor":"<REDACTED>","Bank":"DummyBank",
 "ResponseCode":"0","ResponseDescription":"Approved","BankCode":"0","BankDescription":"Approved"}
(5) RSync — VERIFIED SUCCESS, resolves the refund leg (credentials redacted)
grpcurl -plaintext \
  -H 'x-connector: citigate' \
  -H 'x-connector-config: {"config":{"Citigate":{"api_key":"<REDACTED>","key1":"<REDACTED>"}}}' \
  -H 'x-merchant-id: <REDACTED>' -H 'x-tenant-id: default' \
  -d '{"merchant_refund_id": "cg_rfnd_1786651836",
       "connector_transaction_id": "670592",
       "refund_id": "670593",
       "connector_refund_id": "670593"}' \
  localhost:8010 types.RefundService/Get

Response:
{
  "merchantRefundId": "cg_rfnd_1786651836",
  "connectorRefundId": "670593",
  "status": "REFUND_SUCCESS",
  "statusCode": 200,
  "connectorTransactionId": "670592"
}

Outbound body — five-field status check keyed on the REFUND's MerchantRef, no TransactionID:
{"PaymentTypeID":"1","TransTypeID":"8","MerchantName":"<REDACTED>","MerchantPassword":"<REDACTED>",
 "MerchantRef":"cg_rfnd_1786651836"}

Raw gateway response — resolved the refund leg (TransTypeID 5), NOT the payment leg:
{"TransactionID":"670593","MerchantRef":"cg_rfnd_1786651836","TransTypeID":"5","Currency":"USD",
 "Amount":"52471","BusinessCase":"<REDACTED>","Descriptor":"<REDACTED>","Bank":"DummyBank",
 "ResponseCode":"0","ResponseDescription":"Approved","BankCode":"0","BankDescription":"Approved"}

This is the key correctness check for RSync: the same TransTypeID = 8 call keyed on the payment's
MerchantRef would have returned TransTypeID 1 and reported the payment's status as the refund's.

(6) Hyperswitch end to end — Refund driven from the HS REST API (credentials redacted)
No HS router change is needed: `citigate` is already in `ucs_only_connectors`
(config/development.toml:1579), and crates/router/src/core/refunds.rs dispatches
ExecutionPath::UnifiedConnectorService -> call_unified_connector_service_for_refund_execute
generically, with no per-connector registration.

Payment pay_sxX1LWWPcFdbSOAHLVBv, amount 19730, status succeeded, connector_transaction_id 670597.

Request — POST http://localhost:8098/refunds:
{"payment_id":"pay_sxX1LWWPcFdbSOAHLVBv","amount":19730,"reason":"Customer request","refund_type":"instant"}

Response — HTTP/1.1 200 OK:
{"refund_id":"ref_RSZ26osieb6pd7vqVLWu","payment_id":"pay_sxX1LWWPcFdbSOAHLVBv","amount":19730,
 "currency":"USD","status":"succeeded","reason":"Customer request","error_message":null,
 "error_code":null,"connector":"citigate","profile_id":"<REDACTED>",
 "merchant_connector_id":"<REDACTED>","connector_refund_id":"670598"}

Router log — primary UCS path, not shadow, not direct:
router::core::refunds: Executing refund via UnifiedConnectorService,
  refund_id: "ref_RSZ26osieb6pd7vqVLWu", execution_path: UnifiedConnectorService

UCS log — outbound Citigate call with TransTypeID=5:
{"flow_type":"Refund","connector":"citigate","url":"https://gw-test.cgate.tech/orion/interface/json.ashx",
 "method":"POST","stage":"ConnectorCall","execution_mode":"primary","latency_ms":209,"status_code":200,
 "request_data":{"PaymentTypeID":"1","TransTypeID":"5","MerchantRef":"ref_RSZ26osieb6pd7vqVLWu",
                 "TransactionID":"670597"},
 "response_data":{"TransactionID":"670598","MerchantRef":"ref_RSZ26osieb6pd7vqVLWu","TransTypeID":"5",
                  "Currency":"USD","Amount":"19730","ResponseCode":"0","ResponseDescription":"Approved"}}

GET /refunds/ref_RSZ26osieb6pd7vqVLWu also returned 200 succeeded / 670598, but it served the
persisted terminal state rather than firing a fresh RSync gRPC call (HS does not re-sync an already
succeeded refund). RSync coverage therefore rests entirely on the direct grpcurl run in (5).

(7) No-regression re-runs of the previously merged flows (credentials redacted)
(7a) non-3DS Authorize — merchant_transaction_id "cg_reg_1786651864", minor_amount 63109:
{
  "merchantTransactionId": "cg_reg_1786651864", "connectorTransactionId": "670594",
  "status": "CHARGED", "statusCode": 200,
  "connectorFeatureData": {"value": "{\"business_case\":\"<REDACTED>\",\"descriptor\":\"<REDACTED>\",\"bank\":\"DummyBank\"}"},
  "connectorReferenceId": "cg_reg_1786651864"
}

(7b) PSync on that payment — types.PaymentService/Get:
{
  "connectorTransactionId": "670594", "status": "CHARGED", "statusCode": 200,
  "amount": {"minorAmount": "63109", "currency": "USD"},
  "merchantTransactionId": "cg_reg_1786651864",
  "connectorFeatureData": {"value": "{\"business_case\":\"<REDACTED>\",\"descriptor\":\"<REDACTED>\",\"bank\":\"DummyBank\"}"},
  "connectorReferenceId": "cg_reg_1786651864"
}

(7c) 3DS Authorize on the 3DS MID — merchant_transaction_id "cg_3ds_1786651889", minor_amount 88217.
     (A first attempt at 88217's predecessor hit the sandbox duplicate guard 597; re-run with a fresh
      amount.)
{
  "merchantTransactionId": "cg_3ds_1786651889", "connectorTransactionId": "670596",
  "status": "AUTHENTICATION_PENDING", "statusCode": 200,
  "redirectionData": {"form": {
      "endpoint": "https://gw-test.cgate.tech/orion/redirect/pay.aspx?cgid=957b80e623dc45a6b871d0586b68af940670596",
      "method": "HTTP_METHOD_GET"}},
  "connectorFeatureData": {"value": "{\"business_case\":\"<REDACTED>\",\"descriptor\":\"<REDACTED>\",\"bank\":\"DummyBank\"}"},
  "connectorReferenceId": "cg_3ds_1786651889"
}

Notes for reviewers

  • Please review Capture and Void as unproven success paths. Everything up to and including the
    gateway's 560 response is confirmed live, but no approved capture or void has ever been observed
    on these credentials, and no sandbox MID available here can produce an open authorisation.
  • Every Citigate leg needs its own unique MerchantRef to be syncable, because the Transaction
    Status Check has no TransactionID field. UCS satisfies this naturally —
    connector_request_reference_id differs per flow — but it is worth keeping in mind for any caller
    that reuses references.
  • Citigate auto-captures open authorisations 48-96h after the auth and this cannot be turned off
    (spec Appendix 4), so a merchant-initiated Capture must run inside that window or it will hit 561.
  • Refunds can also appear out of band: the daily acquirer reconciliation auto-voids mismatch auths and
    by default auto-refunds mismatch sales. The async "Refund and Fraud Notification" webhook that
    reports these is not implemented in this PR.

Validation Checklist

  • cargo build -p connector-integration passed with zero errors
  • cargo fmt --check clean; cargo clippy clean for citigate
  • grpcurl Authorize returned success status (2xx) — CHARGED, statusCode: 200
  • grpcurl 3DS Authorize returned AUTHENTICATION_PENDING with a redirect form (2xx)
  • grpcurl PSync resolved the 3DS payment to CHARGED after the redirect (2xx)
  • Non-3DS path regression-tested — wire payload byte-identical when the new URLs are absent
  • Decline path correctly classified as AUTHORIZATION_FAILED (gateway returns HTTP 200 for declines)
  • grpcurl Refund returned REFUND_SUCCESS (2xx) against the live gateway, and the same refund succeeded end to end through the Hyperswitch REST API
  • grpcurl RSync returned REFUND_SUCCESS (2xx) and resolved the refund leg, not the payment leg
  • grpcurl Capture success path — NOT exercised: not exercisable on these sandbox credentials (auto-capturing MID leaves no open authorisation); only the wire format and the 560 failure mapping were validated live
  • grpcurl Void success path — NOT exercised: same cause; only the wire format and the 560 failure mapping were validated live
  • Partial refund (Amount present) — NOT exercised: partial refunds are disabled per-merchant by default (629) and the implementation defaults to a full refund
  • Non-3DS Authorize, 3DS Authorize and PSync re-run as regression checks after the change — all still pass
  • No credentials in committed source code
  • Only connector-specific and connector-registration files modified

Final full-stack regression — hyperswitch → UCS → Citigate

Re-run in full after the Capture/Void/Refund commit, driving the hyperswitch REST API (not grpcurl) so every flow crosses the real router → UCS gRPC → gw-test.cgate.tech path. Router built from feat/grace-citigate; UCS grpc-server rebuilt at d32c54742.

# Flow Result Gateway txn
1 Authorize, non-3DS, auto capture succeeded 670599
2 Authorize, non-3DS, amount ≥ USD 1000.00 failed — gateway 51 / Insufficient Funds 670602
3 Authorize, non-3DS, capture_method: manual succeeded — see open-auth probe below 670604
4 Authorize, 3DS, full redirect lifecycle succeeded 670605
5 Authorize, non-3DS (refund base) succeeded 670606
6 Refund (full) succeeded 670607
7 RSync succeeded 670607
8 Capture success path not exercisable — see below
9 Void success path not exercisable — see below

3DS lifecycle — all four states observed in sequence, not inferred: requires_customer_action with next_action.redirect_to_url → pre-redirect sync processing → drove the real pay.aspxprocessing.aspxtester/Simulate3D.aspx chain and submitted Accept (gateway returned ResponseCode 0 / Approved, TransTypeID 1, Signature 6d4f0c…) → posting the return form to the router's redirect-response endpoint gave 302 → status=succeededforce_sync succeeded.

Open-auth probe (the reason Capture/Void stay unverified). A payment sent with capture_method: manual came back from the gateway as:

{"TransactionID":"670604","MerchantRef":"pay_OZ7UIHndzBGrN9o31WlD_1","TransTypeID":"1",
 "Currency":"USD","Amount":"8814","BusinessCase":"Dummy1","Descriptor":"Dummy1 Descriptor",
 "Bank":"DummyBank","ResponseCode":"0","ResponseDescription":"Approved",
 "BankCode":"0","BankDescription":"Approved","RedirectURL":null}

TransTypeID is 1 (Sale), not 2 (Authorise) — the MID auto-captures even when manual capture is requested, so no open authorisation is ever created. Consequently hyperswitch marks the attempt charged and refuses both follow-ups at the API layer: capture returns IR_14 ("expected state is requires_capture") and cancel returns IR_16. Capture and Void are therefore unreachable end to end on these credentials, not merely unverified. Both were still exercised directly against UCS to prove the wire format, and both correctly surfaced the gateway's documented rejection:

Capture → {"status":"CAPTURE_FAILED","error":{"connectorDetails":
           {"code":"560","message":"No corresponding successful authorisation"}},"statusCode":200}
Void    → {"status":"VOID_FAILED","error":{"connectorDetails":
           {"code":"560","message":"No corresponding successful authorisation"}},"statusCode":200}

Refund at the wire, confirming TransTypeID 5 and the refund leg carrying its own TransactionID with MerchantRef set to the hyperswitch refund id:

{"TransactionID":"670607","MerchantRef":"ref_J5cCxn570YDhiQ7pE9Kj","TransTypeID":"5",
 "Currency":"USD","Amount":"5772","BusinessCase":"Dummy1","Descriptor":"Dummy1 Descriptor",
 "Bank":"DummyBank","ResponseCode":"0","ResponseDescription":"Approved",
 "BankCode":"0","BankDescription":"Approved","RedirectURL":null}

All outcomes were confirmed against the router's own database (payment_attempt / refund), not just API responses.

Sandbox behaviours worth knowing when re-running this: the gateway dedups on card number, not amount — a repeat returns 597 Duplicate transaction, so vary the PAN per test. PAN 4242424242424242 is blacklisted (594), and UserIP 69.203.56.193 — the address used in the vendor PDF's own worked example — is blacklisted (596).

@shuklatushar226
shuklatushar226 requested review from a team as code owners August 13, 2026 14:03
@shuklatushar226 shuklatushar226 changed the title feat(connector): implement Authorize for citigate feat(connector): implement Authorize (Card, non-3DS + 3DS redirect) and PSync for citigate Aug 13, 2026
@shuklatushar226 shuklatushar226 changed the title feat(connector): implement Authorize (Card, non-3DS + 3DS redirect) and PSync for citigate feat(connector): implement Authorize (Card, non-3DS + 3DS redirect), PSync, Capture, Void, Refund and RSync for citigate Aug 13, 2026
shuklatushar226 and others added 6 commits August 14, 2026 02:30
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.
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.
Adds the post-authorization operations on the Citigate JSON interface:
Capture (TransTypeID 3), Void/Cancel (4), Refund (5) and RSync, which
reuses the Transaction Status Check (8) keyed on the refund leg's
MerchantRef.

Refund and RSync are verified live against the sandbox gateway (approved,
ResponseCode 0), including end to end through the Hyperswitch REST API.
Capture and Void success paths are not exercisable on the sandbox MID,
which auto-captures and returns TransTypeID 1 (Sale) even for a manual
capture request, so no open authorisation ever exists; only their wire
format and the gateway 560 failure mapping were validated live.
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.
The connector list parity check (check_connector_specs, Phase 1) requires
every connector with integration code to have a connector_specs/ directory.
citigate was missing one, so the Compilation Check job failed with
'1 missing spec dir(s)'.

Declares the six suites citigate implements: Authorize, Get (PSync),
Capture, Void, Refund and RefundService/Get (RSync).
@gopikrishna000

Copy link
Copy Markdown
Contributor

Same gap as a few other recent connector PRs (Moneris #2127, GrabPay #2063 merged): specs.json here only declares supported suites for schema/fixture validation — there's no scenario that makes a real call against Citigate's sandbox, so none of Authorize/PSync/Capture/Void/Refund/RSync have live proof yet.

One thing specific to this PR: the title calls out "Authorize (Card, non-3DS + 3DS redirect)" but there's no browser_automation_spec.json (pattern: stripe/cashfree/nexixpay/paypal use this for redirect-flow verification). Redirect/3DS is the flow type most likely to have a subtle break (redirect URL construction, callback parsing) that a static fixture won't catch.

Not blocking on our end today — this isn't in certified_connectors.json and our cert-gate doesn't touch this PR. But could you confirm the 3DS redirect path has been tested against Citigate's real sandbox at least once (manually is fine), given it's the higher-risk flow here?

@gopikrishna000

Copy link
Copy Markdown
Contributor

Following up on the earlier comment: two concrete gaps, independent of each other.

  1. Missing override.json. The base PaymentService/Authorize scenario (global_suites/PaymentService_Authorize/scenario.json) is a generic, connector-agnostic fixture — no Citigate-specific test-card data or any fields your transformers.rs might require. Without crates/internal/integration-tests/src/connector_specs/citigate/override.json patching those in, the harness has nothing connector-real to send.

  2. Never actually run against a real sandbox. Have you personally run ./scripts/run-tests --connector citigate --suite PaymentService/Authorize --scenario <name> --interface grpc and gotten a PASS? That's the only thing that catches a genuinely broken transformer (wrong field mapping, wrong response parsing) — specs.json and unit tests never leave the process, so they can't catch that. If you have run it, a screenshot of the output here would help confirm it.

shuklatushar226 and others added 2 commits August 14, 2026 19:34
…age statuses

The API Card v1.27 field table (p. 14) qualifies three purchase-request fields
with "Mandatory for Country = "US"" on top of their Y/R flag. A plain Option<T>
cannot express that, so all three conditions were unenforced:

- StateProvince (Y) and Telephone (R) are now resolved against the billing
  country and use the required-variant helpers when it is US, so an incomplete
  US address fails in UCS with MissingRequiredField instead of being rejected by
  the gateway. Non-US billing keeps the previous optional behaviour, since most
  countries have no state.
- DateOfBirth (R) is declared but always None: UCS carries no billing date of
  birth on the card Authorize path, so there is nothing to source it from.
  Declaring it keeps the gap visible at the field rather than silently absent
  from the wire format. Wiring it needs a billing-DOB on the proto/domain.

Separately, ResponseCodes 106 (user aborted), 700 (no user redirect) and 800
(no user return) all fail at the authentication stage, before the bank is asked
to authorize, but were reported as AuthorizationFailed/Failure. They now map to
AuthenticationFailed alongside 103, in both failure_status and
sync_failure_status.

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.
@shuklatushar226 shuklatushar226 linked an issue Aug 15, 2026 that may be closed by this pull request
14 tasks
… sandbox

config/production.toml carried the sandbox host, so production traffic would have
been posted to https://gw-test.cgate.tech/orion/interface/json.ashx. The two hosts
are separate infrastructure (gw.cgate.tech 37.35.89.132, gw-test.cgate.tech
20.68.195.195), so this was a real misroute rather than an alias.

Hyperswitch's own config/deployments/production.toml already carries
https://gw.cgate.tech, but that entry is inert for citigate: the connector is in
ucs_only_connectors, so HS never issues the HTTP call, and it builds the UCS
config as Citigate { api_key, key1, base_url: None }. With no override,
base_url_override() yields None, no connector patch is applied, and the
transformer reads UCS's own TOML. That made this file the authoritative one.

sandbox.toml and development.toml keep gw-test.cgate.tech, which is correct.

Note: the API Card v1.27 does not document a production interface URL — Appendix 2
lists test URLs only, and gw.cgate.tech appears solely inside sample RedirectURL
response values. This aligns UCS with the host Hyperswitch already assumes; the
value should be confirmed with Citigate before production traffic is enabled.

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.

[Integration][P0] Citigate — cards one-time payments

2 participants