feat(connector): implement Authorize (Card, non-3DS + 3DS redirect), PSync, Capture, Void, Refund and RSync for citigate - #2124
feat(connector): implement Authorize (Card, non-3DS + 3DS redirect), PSync, Capture, Void, Refund and RSync for citigate#2124shuklatushar226 wants to merge 10 commits into
Conversation
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.
883e115 to
64398bd
Compare
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).
|
Same gap as a few other recent connector PRs (Moneris #2127, GrabPay #2063 merged): One thing specific to this PR: the title calls out "Authorize (Card, non-3DS + 3DS redirect)" but there's no Not blocking on our end today — this isn't in |
|
Following up on the earlier comment: two concrete gaps, independent of each other.
|
…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.
… 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>
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'sMerchantRef).Implementation Notes
Citigateover the JSON interface:POST {base_url}/orion/interface/json.ashx,Content-Type: application/json.MerchantName(api_key) andMerchantPassword(key1) are sent in the request body;get_auth_headerreturns empty.PaymentTypeID=1(card) andTransTypeID=0(purchase), withStringMinorUnitamounts.(ResponseCode, TransTypeID)pair:0+1|3→Charged0+2→Authorized0+ other →Pending999+6→Pending600(+ a usableRedirectURL) →AuthenticationPendingwith a redirect form600without aRedirectURL, or103(3D auth failure) →AuthenticationFailedAuthorizationFailed[]): a tolerant deserializer collapses string / number /[]/""/nullintoOption<String>.card_network, with a BIN-detection fallback; unsupported networks are rejected pre-flight.Changes
citigate.rs— connector definition,create_all_prerequisites!,macro_connector_implementation!for Authorize and PSync, BodyKey auth, and error handling.citigate/transformers.rs— Authorize and PSync request/response types, tolerant deserializer, status mapping, redirect-form construction, andTryFromimplementations.Connector::CITIGATE = 137andCitigateConfig, auth config plumbing, field-probe dummy auth, andbase_urlentries 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.rscrates/integrations/connector-integration/src/default_implementations.rscrates/integrations/connector-integration/src/types.rscrates/internal/field-probe/src/auth.rscrates/types-traits/domain_types/src/connector_types.rscrates/types-traits/domain_types/src/router_data.rscrates/types-traits/domain_types/src/types.rscrates/types-traits/grpc-api-types/proto/payment.protoconfig/development.tomlconfig/sandbox.tomlconfig/production.tomlgRPC Test Results
Status: PASS
grpcurl Authorize call — approve path, non-3DS (credentials redacted)
grpcurl Authorize call — expected-decline path, amount >= 1000.00 (credentials redacted)
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_FAILEDrather 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/modentries.transformers.rsCitigatePaymentsRequestgainedSuccessURL/FailURL/CallbackURLasOption<String>withskip_serializing_if.SuccessURLandFailURLcarryrouter_return_url(falling back tocomplete_authorize_url, thenPaymentFlowData::return_url);CallbackURLcarrieswebhook_url.They are omitted entirely when absent, so the non-3DS wire payload is byte-identical to before.
auth_type == ThreeDs, a missing URL is rejected client-side withMissingRequiredFieldrather than being sent and refused by the gateway with
584/585/586.IntegrationError::NotSupported. No 3DS metadatais populated in either direction — this 3DS1-era API exposes no ECI / CAVV / dsTransId / transStatus.
ResponseCode 600moved to the success arm:is_success()is true for600(guarded on a usableRedirectURL),attempt_status()returnsAttemptStatus::AuthenticationPending, andredirect_form()buildsRedirectForm::Form { endpoint: RedirectURL.trim(), method: Get, form_fields: {} }.600with noRedirectURLfalls through toAuthenticationFailed, where code103(3D authfailure) was also added.
CitigateSyncRequest(PaymentTypeID=1,TransTypeID=8,MerchantName/MerchantPassword/MerchantRef) keyed onconnector_request_reference_id, plusCitigateSyncResponse. Sync status mapping:TransTypeID1|3|5→Charged,2→Authorized,4→Voided,6→Pending,99→Failure;ResponseCode 103→AuthenticationFailed,600on sync →Pending.to_error_responsewas split so the sync path attachesFailure/AuthenticationFailedinstead of the authorize-specificAuthorizationFailed.citigate.rsPSyncadded tocreate_all_prerequisites!.macro_connector_implementation!for PSync (POST, same/orion/interface/json.ashxendpoint andheaders, so PSync uses the same MID credentials as the Authorize).
PaymentSyncV2impl, andPSyncremoved from the not-implemented stub list.Full 3DS lifecycle proven end to end
AUTHENTICATION_PENDING→ redirect →PENDING→CHARGED.(1) Authorize on the 3DS-enabled MID — returns a redirect (credentials redacted)
(2) PSync before the cardholder authenticated — PENDING (credentials redacted)
(3) Redirect completed out-of-band, then the identical PSync — CHARGED (credentials redacted)
(4) Non-3DS regression — unchanged, with and without the new URLs (credentials redacted)
Notes for reviewers
RedirectURLhost/path ishttps://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 usedverbatim with no host validation, so both work.
merchant_transaction_id(which becomesconnector_request_reference_id→MerchantRef).connector_transaction_idis required by the proto but is not used for thelookup. Callers must pass the same MID credentials as the original Authorize, or the gateway returns
TransTypeID 99/ "MerchantRef not found".UserIPis blacklisted (596), so adocumentation-range IP was used instead, and amounts were varied per run to avoid the 30-second
duplicate guard (
597)."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/modentries.Verification status per flow — please read before reviewing the evidence
ResponseCode 0) via direct grpcurl, and end to end through the Hyperswitch REST API (POST /refunds→succeeded).MerchantRef.560) were validated live.560) were validated live.Why Capture and Void cannot be proven on this sandbox: the non-3DS test MID auto-captures. An
Authorize sent with
capture_method: MANUALstill comes back with responseTransTypeID = "1"(Sale), never
"2"(Authorise), so no open authorisation is ever created and there is nothing fora 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 0→Charged/Voided) branch of both flows has not been observed and should bereviewed 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 (
Amountpresent). Partial refunds are disabled per-merchantby default (gateway code
629) and the implementation deliberately defaults to a full refund.citigate.rsCapture,Void,RefundandRSyncadded tocreate_all_prerequisites!, each with amacro_connector_implementation!— all four POST to the same/orion/interface/json.ashxendpointwith the same headers, so they reuse the Authorize MID credentials.
RefundFlowDatarather thanPaymentFlowData, so theirget_urlreadsresource_common_data.connectors.citigate.base_urldirectly.PaymentCapture,PaymentVoidV2,RefundV2andRefundSyncV2trait impls; the four flowswere removed from the not-implemented stub list.
transformers.rsTransTypeID = 3) — six documented fields, noAmount: Citigate cannot do apartial, multiple or incremental capture.
is_multiple_capture()and any capture amount thatdiffers from
minor_amount_authorizedare rejected client-side withNotSupportedrather thansilently capturing the full amount the caller did not ask for.
TransTypeID = 4) — same six fields. A void is always full-amount, and the wireformat has no field for a cancellation reason, so a partial void is rejected client-side.
TransactionID(auth310→ capture312). UCS'sresource_idkeeps pointing at the original authorisation; the leg id is preserved inconnector_metadataasleg_transaction_idinstead, so PSync and later operations still key offthe payment.
TransTypeID = 5) —Amountisskip_serializing_if = "Option::is_none"and isomitted whenever
minor_refund_amount == minor_payment_amount. That is both the documented defaultand 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.TransTypeID = 8, five fields). The only thingthat makes it an RSync is that
MerchantRefis the refund's reference. Keying it onconnector_refund_id(a CitigateTransactionID, which the status check has no field for) or onthe Authorize
MerchantRefwould silently resolve the payment leg and report the wrong status.refund_sync_statustherefore only reportsSuccessforResponseCode 0andTransTypeID = 5;0with any other type, and999+TransTypeID 6, arePending;999+TransTypeID 99("MerchantRef not found") isFailure.ResponseCodealone decides Capture / Void/ Refund outcomes. Failure mappings kept deliberately distinct:
561on Capture means the auth wasalready captured (quite possibly by Citigate's own 48-96h auto-capture),
561on Void means theauth is no longer open and a Refund is the right operation instead, and
605on 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_responsewas generalised intoto_flow_error_responseso the refund flows attach aRefundStatuswhile the payment flows keep attaching anAttemptStatus.MerchantRefis validated as non-empty up front on every flow, since it is the only key a statuscheck can be resolved by.
(1) Open-auth probe — Authorize with
capture_method: MANUALstill returns a Sale (credentials redacted)This is the reason the Capture and Void success paths below could not be reached.
(2) Capture — wire format and
560failure mapping validated live; success path NOT exercised (credentials redacted)The gateway parsed and answered the request, so the endpoint, auth, field set and the
560 → CAPTURE_FAILEDmapping are confirmed against the live gateway. The approved(
ResponseCode 0→Charged) branch was never reached — it remains unproven.(3) Void — wire format and
560failure mapping validated live; success path NOT exercised (credentials redacted)Same caveat as Capture: the approved (
ResponseCode 0→Voided) branch was never reached.(4) Fresh sale, then Refund — VERIFIED SUCCESS (credentials redacted)
(5) RSync — VERIFIED SUCCESS, resolves the refund leg (credentials redacted)
This is the key correctness check for RSync: the same
TransTypeID = 8call keyed on the payment'sMerchantRefwould have returnedTransTypeID 1and reported the payment's status as the refund's.(6) Hyperswitch end to end — Refund driven from the HS REST API (credentials redacted)
GET /refunds/ref_RSZ26osieb6pd7vqVLWualso returned200 succeeded / 670598, but it served thepersisted terminal state rather than firing a fresh RSync gRPC call (HS does not re-sync an already
succeededrefund). RSync coverage therefore rests entirely on the direct grpcurl run in (5).(7) No-regression re-runs of the previously merged flows (credentials redacted)
Notes for reviewers
gateway's
560response is confirmed live, but no approved capture or void has ever been observedon these credentials, and no sandbox MID available here can produce an open authorisation.
MerchantRefto be syncable, because the TransactionStatus Check has no
TransactionIDfield. UCS satisfies this naturally —connector_request_reference_iddiffers per flow — but it is worth keeping in mind for any callerthat reuses references.
(spec Appendix 4), so a merchant-initiated Capture must run inside that window or it will hit
561.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-integrationpassed with zero errorscargo fmt --checkclean;cargo clippyclean for citigateCHARGED,statusCode: 200AUTHENTICATION_PENDINGwith a redirect form (2xx)CHARGEDafter the redirect (2xx)AUTHORIZATION_FAILED(gateway returns HTTP 200 for declines)REFUND_SUCCESS(2xx) against the live gateway, and the same refund succeeded end to end through the Hyperswitch REST APIREFUND_SUCCESS(2xx) and resolved the refund leg, not the payment leg560failure mapping were validated live560failure mapping were validated liveAmountpresent) — NOT exercised: partial refunds are disabled per-merchant by default (629) and the implementation defaults to a full refundFinal 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.techpath. Router built fromfeat/grace-citigate; UCSgrpc-serverrebuilt atd32c54742.succeededfailed— gateway51 / Insufficient Fundscapture_method: manualsucceeded— see open-auth probe belowsucceededsucceededsucceededsucceeded3DS lifecycle — all four states observed in sequence, not inferred:
requires_customer_actionwithnext_action.redirect_to_url→ pre-redirect syncprocessing→ drove the realpay.aspx→processing.aspx→tester/Simulate3D.aspxchain and submitted Accept (gateway returnedResponseCode 0 / Approved,TransTypeID 1,Signature 6d4f0c…) → posting the return form to the router's redirect-response endpoint gave302 → status=succeeded→force_syncsucceeded.Open-auth probe (the reason Capture/Void stay unverified). A payment sent with
capture_method: manualcame 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}TransTypeIDis1(Sale), not2(Authorise) — the MID auto-captures even when manual capture is requested, so no open authorisation is ever created. Consequently hyperswitch marks the attemptchargedand refuses both follow-ups at the API layer: capture returnsIR_14("expected state is requires_capture") and cancel returnsIR_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:Refund at the wire, confirming
TransTypeID 5and the refund leg carrying its ownTransactionIDwithMerchantRefset 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. PAN4242424242424242is blacklisted (594), andUserIP 69.203.56.193— the address used in the vendor PDF's own worked example — is blacklisted (596).