Skip to main content

syrup_rail_postgres/
mode_verified_gateway.rs

1use std::fmt;
2
3use syrup_rail::{
4    GatewayAccountMode, GatewayDiagnostic, GatewayError, GatewayMutationError,
5    GatewayNotSubmittedError, GatewayPaymentOutcome, GatewaySaleRequest,
6    GatewayStorePaymentMethodRequest, PaymentAttemptIdentity, ResolvedGateway,
7};
8use thiserror::Error;
9
10/// Why a resolved gateway could not be authorized for a provider submission.
11#[derive(Error)]
12#[non_exhaustive]
13pub enum GatewayAccountModeVerificationError {
14    /// The account answered successfully but is not in the trusted deployment mode.
15    #[error("gateway account mode does not match the required deployment mode")]
16    AccountModeMismatch {
17        /// Mode required by trusted service or low-level caller configuration.
18        required: GatewayAccountMode,
19        /// Mode observed from the provider account for this submission authority.
20        observed: GatewayAccountMode,
21    },
22    /// The provider account-mode query failed.
23    #[error("gateway account mode query failed")]
24    Gateway(#[source] GatewayError),
25}
26
27impl fmt::Debug for GatewayAccountModeVerificationError {
28    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
29        match self {
30            Self::AccountModeMismatch { required, observed } => formatter
31                .debug_struct("GatewayAccountModeVerificationError::AccountModeMismatch")
32                .field("required", required)
33                .field("observed", observed)
34                .finish(),
35            Self::Gateway(error) => formatter
36                .debug_tuple("GatewayAccountModeVerificationError::Gateway")
37                .field(error)
38                .finish(),
39        }
40    }
41}
42
43/// Opaque readiness capability for a resolved gateway and required account mode.
44///
45/// This value is intentionally non-cloneable and can be constructed only by
46/// [`verify_gateway_account_mode`]. Supported low-level submission functions
47/// consume it, compare its mode and gateway identity with the durable admitted
48/// attempt, and re-query the account mode immediately before the real provider
49/// mutation.
50///
51/// Account-mode lookup and provider mutation are separate NMI requests, so the
52/// first lookup is only an early readiness observation. The mandatory second
53/// lookup narrows, but cannot eliminate, a provider-side race. Deployments that
54/// require hard test/live isolation should use separate gateway accounts.
55#[must_use = "a verified gateway must be consumed by an admitted provider submission"]
56pub struct ModeVerifiedGateway<'gateway> {
57    gateway: &'gateway ResolvedGateway,
58    required_mode: GatewayAccountMode,
59}
60
61impl fmt::Debug for ModeVerifiedGateway<'_> {
62    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
63        formatter
64            .debug_struct("ModeVerifiedGateway")
65            .field("required_mode", &self.required_mode)
66            .finish_non_exhaustive()
67    }
68}
69
70#[must_use = "an attempt-verified gateway must perform its provider submission"]
71pub(crate) struct AttemptVerifiedGateway<'gateway> {
72    gateway: &'gateway ResolvedGateway,
73    required_mode: GatewayAccountMode,
74}
75
76pub(crate) fn gateway_account_mode_mismatch_detail() -> GatewayDiagnostic {
77    GatewayDiagnostic::new(
78        "Payment was not submitted because the payment processor account mode did not match this deployment.",
79    )
80}
81
82fn normalize_adapter_mutation_error(error: GatewayMutationError) -> GatewayMutationError {
83    match error {
84        GatewayMutationError::NotSubmitted(GatewayNotSubmittedError::AccountModeMismatch {
85            detail,
86            ..
87        }) => GatewayMutationError::NotSubmitted(GatewayNotSubmittedError::Malformed(detail)),
88        GatewayMutationError::NotSubmitted(GatewayNotSubmittedError::AccountModeVerification(
89            error,
90        )) => GatewayMutationError::NotSubmitted(GatewayNotSubmittedError::Malformed(
91            error.detail().clone(),
92        )),
93        other => other,
94    }
95}
96
97impl<'gateway> ModeVerifiedGateway<'gateway> {
98    pub(crate) const fn resolved_gateway(&self) -> &'gateway ResolvedGateway {
99        self.gateway
100    }
101
102    pub(crate) fn authorize_attempt(
103        self,
104        identity: &PaymentAttemptIdentity,
105    ) -> Option<AttemptVerifiedGateway<'gateway>> {
106        (self.required_mode == identity.required_gateway_account_mode()
107            && self.gateway.billing_scope_id() == identity.billing_scope_id()
108            && self.gateway.gateway_account_id() == identity.gateway_account_id()
109            && self.gateway.gateway_configuration_id() == identity.gateway_configuration_id())
110        .then_some(AttemptVerifiedGateway {
111            gateway: self.gateway,
112            required_mode: self.required_mode,
113        })
114    }
115}
116
117impl AttemptVerifiedGateway<'_> {
118    pub(crate) const fn provider_key(&self) -> &syrup_rail::GatewayProviderKey {
119        self.gateway.provider_key()
120    }
121
122    async fn verify_current_mode(&self) -> Result<(), GatewayMutationError> {
123        let observed = self.gateway.account_mode().await.map_err(|error| {
124            GatewayMutationError::NotSubmitted(GatewayNotSubmittedError::AccountModeVerification(
125                error,
126            ))
127        })?;
128        if observed != self.required_mode {
129            return Err(GatewayMutationError::NotSubmitted(
130                GatewayNotSubmittedError::AccountModeMismatch {
131                    required: self.required_mode,
132                    observed,
133                    detail: gateway_account_mode_mismatch_detail(),
134                },
135            ));
136        }
137        Ok(())
138    }
139
140    pub(crate) async fn sale(
141        self,
142        request: GatewaySaleRequest,
143    ) -> Result<GatewayPaymentOutcome, GatewayMutationError> {
144        self.verify_current_mode().await?;
145        self.gateway
146            .sale(request)
147            .await
148            .map_err(normalize_adapter_mutation_error)
149    }
150
151    pub(crate) async fn store_payment_method(
152        self,
153        request: GatewayStorePaymentMethodRequest,
154    ) -> Result<GatewayPaymentOutcome, GatewayMutationError> {
155        self.verify_current_mode().await?;
156        self.gateway
157            .store_payment_method(request)
158            .await
159            .map_err(normalize_adapter_mutation_error)
160    }
161}
162
163/// Queries the real provider account and mints one readiness capability only
164/// when its observed mode exactly matches the trusted required mode. Consuming
165/// the capability for submission performs another account-mode query.
166///
167/// A successful `Test` verification still authorizes the real gateway API
168/// request; NMI's account-wide TEST setting determines simulated processing.
169pub async fn verify_gateway_account_mode(
170    gateway: &ResolvedGateway,
171    required_mode: GatewayAccountMode,
172) -> Result<ModeVerifiedGateway<'_>, GatewayAccountModeVerificationError> {
173    let observed_mode = gateway
174        .account_mode()
175        .await
176        .map_err(GatewayAccountModeVerificationError::Gateway)?;
177    if observed_mode != required_mode {
178        return Err(GatewayAccountModeVerificationError::AccountModeMismatch {
179            required: required_mode,
180            observed: observed_mode,
181        });
182    }
183    Ok(ModeVerifiedGateway {
184        gateway,
185        required_mode,
186    })
187}