Skip to main content

syrup_rail/
resolver.rs

1use std::{fmt, sync::Arc};
2
3use async_trait::async_trait;
4use thiserror::Error;
5
6use crate::{
7    BillingScopeId, GatewayAccountId, GatewayAccountMode, GatewayConfigurationId, GatewayError,
8    GatewayLifecycleQueryPolicy, GatewayMutationError, GatewayMutationReferenceFactory,
9    GatewayPaymentOutcome, GatewayProviderKey, GatewayQueryRequest, GatewaySaleRequest,
10    GatewayStorePaymentMethodRequest, GatewayTransactionReport, GatewayTransactionReportRequest,
11    PaymentGateway,
12};
13
14#[derive(Clone)]
15pub struct ResolvedGateway {
16    billing_scope_id: BillingScopeId,
17    gateway_account_id: GatewayAccountId,
18    gateway_configuration_id: GatewayConfigurationId,
19    provider_key: GatewayProviderKey,
20    lifecycle_query_policy: GatewayLifecycleQueryPolicy,
21    mutation_reference_factory: Arc<dyn GatewayMutationReferenceFactory>,
22    gateway: Arc<dyn PaymentGateway>,
23}
24
25impl ResolvedGateway {
26    pub fn new(
27        billing_scope_id: BillingScopeId,
28        gateway_account_id: GatewayAccountId,
29        gateway_configuration_id: GatewayConfigurationId,
30        provider_key: GatewayProviderKey,
31        lifecycle_query_policy: GatewayLifecycleQueryPolicy,
32        mutation_reference_factory: Arc<dyn GatewayMutationReferenceFactory>,
33        gateway: Arc<dyn PaymentGateway>,
34    ) -> Self {
35        Self {
36            billing_scope_id,
37            gateway_account_id,
38            gateway_configuration_id,
39            provider_key,
40            lifecycle_query_policy,
41            mutation_reference_factory,
42            gateway,
43        }
44    }
45
46    pub const fn billing_scope_id(&self) -> BillingScopeId {
47        self.billing_scope_id
48    }
49
50    pub const fn gateway_account_id(&self) -> GatewayAccountId {
51        self.gateway_account_id
52    }
53
54    pub const fn gateway_configuration_id(&self) -> GatewayConfigurationId {
55        self.gateway_configuration_id
56    }
57
58    pub const fn provider_key(&self) -> &GatewayProviderKey {
59        &self.provider_key
60    }
61
62    pub const fn lifecycle_query_policy(&self) -> &GatewayLifecycleQueryPolicy {
63        &self.lifecycle_query_policy
64    }
65
66    pub fn mutation_reference_factory(&self) -> Arc<dyn GatewayMutationReferenceFactory> {
67        Arc::clone(&self.mutation_reference_factory)
68    }
69
70    pub async fn account_mode(&self) -> Result<GatewayAccountMode, GatewayError> {
71        self.gateway.account_mode().await
72    }
73
74    pub async fn sale(
75        &self,
76        request: GatewaySaleRequest,
77    ) -> Result<GatewayPaymentOutcome, GatewayMutationError> {
78        self.gateway.sale(request).await
79    }
80
81    pub async fn store_payment_method(
82        &self,
83        request: GatewayStorePaymentMethodRequest,
84    ) -> Result<GatewayPaymentOutcome, GatewayMutationError> {
85        self.gateway.store_payment_method(request).await
86    }
87
88    pub async fn query_transaction(
89        &self,
90        request: GatewayQueryRequest,
91    ) -> Result<Option<GatewayPaymentOutcome>, GatewayError> {
92        self.gateway.query_transaction(request).await
93    }
94
95    pub async fn query_transaction_reports(
96        &self,
97        request: GatewayTransactionReportRequest,
98    ) -> Result<Vec<GatewayTransactionReport>, GatewayError> {
99        self.gateway.query_transaction_reports(request).await
100    }
101}
102
103impl fmt::Debug for ResolvedGateway {
104    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
105        formatter
106            .debug_struct("ResolvedGateway")
107            .field("billing_scope_id", &self.billing_scope_id)
108            .field("gateway_account_id", &self.gateway_account_id)
109            .field("gateway_configuration_id", &self.gateway_configuration_id)
110            .field("provider_key", &self.provider_key)
111            .field("lifecycle_query_policy", &self.lifecycle_query_policy)
112            .field("has_mutation_reference_factory", &true)
113            .field("has_gateway", &true)
114            .finish()
115    }
116}
117
118#[derive(Clone, Copy, Debug, Error, Eq, PartialEq)]
119pub enum GatewayResolutionError {
120    #[error("gateway configuration was not found")]
121    NotFound,
122    #[error("gateway configuration changed")]
123    ConfigurationChanged,
124    #[error("gateway configuration is invalid")]
125    InvalidConfiguration,
126    #[error("gateway resolution is temporarily unavailable")]
127    Unavailable,
128}
129
130#[async_trait]
131pub trait GatewayResolver: Send + Sync {
132    async fn resolve(
133        &self,
134        billing_scope_id: BillingScopeId,
135        gateway_account_id: GatewayAccountId,
136        gateway_configuration_id: GatewayConfigurationId,
137        provider_key: GatewayProviderKey,
138    ) -> Result<ResolvedGateway, GatewayResolutionError>;
139}
140
141#[cfg(test)]
142mod tests {
143    use chrono::Duration;
144    use uuid::Uuid;
145
146    use super::*;
147    use crate::{
148        BillingContact, BillingPeriod, ChargeAmount, CurrencyCode, GatewayLifecycleCursorKey,
149        GatewayOrderId, GatewayTransactionId, IdempotencyKey, PaymentAttemptId, PaymentAttemptKind,
150        PaymentMethodId, PaymentMethodUpdateSnapshot, PaymentToken, PlanKey, SubscriberId,
151        SubscriptionId, SubscriptionPaymentStateSnapshot, SubscriptionStatus,
152    };
153
154    struct NeverCalledGateway;
155
156    #[async_trait]
157    impl PaymentGateway for NeverCalledGateway {
158        async fn account_mode(&self) -> Result<GatewayAccountMode, GatewayError> {
159            panic!("resolved gateway construction must not perform provider I/O")
160        }
161
162        async fn sale(
163            &self,
164            _request: GatewaySaleRequest,
165        ) -> Result<GatewayPaymentOutcome, GatewayMutationError> {
166            panic!("resolved gateway construction must not perform provider I/O")
167        }
168
169        async fn store_payment_method(
170            &self,
171            _request: GatewayStorePaymentMethodRequest,
172        ) -> Result<GatewayPaymentOutcome, GatewayMutationError> {
173            panic!("resolved gateway construction must not perform provider I/O")
174        }
175
176        async fn query_transaction(
177            &self,
178            _request: GatewayQueryRequest,
179        ) -> Result<Option<GatewayPaymentOutcome>, GatewayError> {
180            panic!("resolved gateway construction must not perform provider I/O")
181        }
182
183        async fn query_transaction_reports(
184            &self,
185            _request: GatewayTransactionReportRequest,
186        ) -> Result<Vec<GatewayTransactionReport>, GatewayError> {
187            panic!("resolved gateway construction must not perform provider I/O")
188        }
189    }
190
191    struct NeverCalledReferenceFactory;
192
193    impl GatewayMutationReferenceFactory for NeverCalledReferenceFactory {
194        fn for_attempt(
195            &self,
196            _kind: PaymentAttemptKind,
197            _attempt_id: PaymentAttemptId,
198        ) -> GatewayOrderId {
199            panic!("resolved gateway construction must not format a mutation reference")
200        }
201    }
202
203    struct TestReferenceFactory;
204
205    impl GatewayMutationReferenceFactory for TestReferenceFactory {
206        fn for_attempt(
207            &self,
208            kind: PaymentAttemptKind,
209            attempt_id: PaymentAttemptId,
210        ) -> GatewayOrderId {
211            GatewayOrderId::from_generated_attempt(
212                format!("test_{}_{}", kind.as_str(), attempt_id.as_uuid().simple()),
213                attempt_id,
214            )
215            .expect("test order ID should be valid")
216        }
217    }
218
219    fn test_policy() -> GatewayLifecycleQueryPolicy {
220        GatewayLifecycleQueryPolicy::new(
221            GatewayLifecycleCursorKey::new("test_cursor").expect("valid cursor key"),
222            Duration::minutes(1),
223            10,
224            2,
225            2,
226            20,
227        )
228        .expect("valid lifecycle policy")
229    }
230
231    fn test_gateway(
232        mutation_reference_factory: Arc<dyn GatewayMutationReferenceFactory>,
233    ) -> ResolvedGateway {
234        ResolvedGateway::new(
235            BillingScopeId::new(Uuid::from_u128(1)),
236            GatewayAccountId::new(Uuid::from_u128(2)),
237            GatewayConfigurationId::new(Uuid::from_u128(3)),
238            GatewayProviderKey::new("test_gateway").expect("valid provider key"),
239            test_policy(),
240            mutation_reference_factory,
241            Arc::new(NeverCalledGateway),
242        )
243    }
244
245    #[test]
246    fn resolved_gateway_preserves_exact_identity_without_provider_io() {
247        let scope = BillingScopeId::new(Uuid::from_u128(1));
248        let account = GatewayAccountId::new(Uuid::from_u128(2));
249        let configuration = GatewayConfigurationId::new(Uuid::from_u128(3));
250        let provider = GatewayProviderKey::new("test_gateway").expect("valid provider key");
251        let policy = test_policy();
252        let resolved = test_gateway(Arc::new(NeverCalledReferenceFactory));
253
254        assert_eq!(resolved.billing_scope_id(), scope);
255        assert_eq!(resolved.gateway_account_id(), account);
256        assert_eq!(resolved.gateway_configuration_id(), configuration);
257        assert_eq!(resolved.provider_key(), &provider);
258        assert_eq!(resolved.lifecycle_query_policy(), &policy);
259        let debug = format!("{resolved:?}");
260        assert!(debug.contains("has_mutation_reference_factory: true"));
261        assert!(debug.contains("has_gateway: true"));
262    }
263
264    #[test]
265    fn locked_terms_build_the_same_reservations_as_legacy_arguments() {
266        let gateway = test_gateway(Arc::new(TestReferenceFactory));
267        let subscription_id = SubscriptionId::new(Uuid::from_u128(4));
268        let payment_method_id = PaymentMethodId::new(Uuid::from_u128(5));
269        let initial_transaction_id =
270            GatewayTransactionId::new("initial-transaction").expect("valid transaction ID");
271        let status = SubscriptionStatus::PastDue;
272        let start_at = chrono::DateTime::from_timestamp(1_700_000_000, 0).unwrap();
273        let period = BillingPeriod::new(start_at, start_at + Duration::days(30)).unwrap();
274        let charge = ChargeAmount::new(1_000, CurrencyCode::new("USD").unwrap()).unwrap();
275        let expected_state = SubscriptionPaymentStateSnapshot::new(
276            subscription_id,
277            payment_method_id,
278            initial_transaction_id.clone(),
279            status,
280        )
281        .unwrap();
282        let subscriber_id = SubscriberId::new(Uuid::from_u128(7));
283        let plan_key = PlanKey::new("premium").unwrap();
284        let required_mode = GatewayAccountMode::Test;
285
286        let renewal =
287            crate::ChargeRenewal::new(gateway.billing_scope_id(), subscription_id, start_at);
288        let renewal_attempt_id = PaymentAttemptId::new(Uuid::from_u128(8));
289        assert_eq!(
290            crate::SubscriptionRenewalReservation::from_locked_subscription(
291                renewal,
292                &gateway,
293                renewal_attempt_id,
294                subscriber_id,
295                plan_key.clone(),
296                payment_method_id,
297                initial_transaction_id.clone(),
298                status,
299                period.clone(),
300                charge,
301                3,
302                required_mode,
303            )
304            .unwrap(),
305            crate::SubscriptionRenewalReservation::from_locked_subscription_terms(
306                renewal,
307                &gateway,
308                renewal_attempt_id,
309                subscriber_id,
310                plan_key.clone(),
311                crate::SubscriptionRenewalLockedTerms::new(
312                    gateway.gateway_account_id(),
313                    expected_state.clone(),
314                    period.clone(),
315                    charge,
316                    3,
317                ),
318                required_mode,
319            )
320            .unwrap(),
321        );
322
323        let recovery = crate::RecoverSubscriptionPayment::new(
324            crate::SubscriptionPaymentContext::new(
325                PaymentAttemptId::new(Uuid::from_u128(9)),
326                gateway.billing_scope_id(),
327                subscriber_id,
328                gateway.gateway_configuration_id(),
329                IdempotencyKey::new("recovery-key").unwrap(),
330                PaymentToken::new("recovery-token").unwrap(),
331                BillingContact::new(None, Some("Test".to_owned()), None).unwrap(),
332            ),
333            plan_key.clone(),
334        );
335        assert_eq!(
336            crate::SubscriptionRecoveryReservation::from_locked_subscription(
337                &recovery,
338                &gateway,
339                recovery.attempt_id(),
340                subscription_id,
341                payment_method_id,
342                initial_transaction_id.clone(),
343                status,
344                period.clone(),
345                charge,
346                required_mode,
347            )
348            .unwrap(),
349            crate::SubscriptionRecoveryReservation::from_locked_subscription_terms(
350                &recovery,
351                &gateway,
352                recovery.attempt_id(),
353                crate::SubscriptionRecoveryLockedTerms::new(
354                    gateway.gateway_account_id(),
355                    expected_state.clone(),
356                    period.clone(),
357                    charge,
358                ),
359                required_mode,
360            )
361            .unwrap(),
362        );
363
364        let replacement = crate::ReplaceSubscriptionPaymentMethod::new(
365            crate::SubscriptionPaymentContext::new(
366                PaymentAttemptId::new(Uuid::from_u128(10)),
367                gateway.billing_scope_id(),
368                subscriber_id,
369                gateway.gateway_configuration_id(),
370                IdempotencyKey::new("replacement-key").unwrap(),
371                PaymentToken::new("replacement-token").unwrap(),
372                BillingContact::new(None, Some("Test".to_owned()), None).unwrap(),
373            ),
374            plan_key,
375        );
376        let currency = CurrencyCode::new("USD").unwrap();
377        assert_eq!(
378            crate::SubscriptionPaymentMethodReplacement::from_locked_subscription(
379                &replacement,
380                &gateway,
381                subscription_id,
382                payment_method_id,
383                initial_transaction_id.clone(),
384                currency,
385                required_mode,
386            )
387            .unwrap(),
388            crate::SubscriptionPaymentMethodReplacement::from_locked_subscription_terms(
389                &replacement,
390                &gateway,
391                crate::SubscriptionPaymentMethodReplacementLockedTerms::new(
392                    gateway.gateway_account_id(),
393                    PaymentMethodUpdateSnapshot::new(
394                        subscription_id,
395                        payment_method_id,
396                        initial_transaction_id,
397                    ),
398                    currency,
399                ),
400                required_mode,
401            )
402            .unwrap(),
403        );
404    }
405
406    #[test]
407    fn retry_submission_matching_binds_durable_fields_but_not_one_shot_inputs() {
408        let gateway = test_gateway(Arc::new(TestReferenceFactory));
409        let subscriber_id = SubscriberId::new(Uuid::from_u128(20));
410        let subscription_id = SubscriptionId::new(Uuid::from_u128(21));
411        let payment_method_id = PaymentMethodId::new(Uuid::from_u128(22));
412        let initial_transaction_id =
413            GatewayTransactionId::new("submission-initial").expect("valid transaction ID");
414        let plan_key = PlanKey::new("submission-plan").expect("valid plan key");
415        let canonical_contact = BillingContact::new(
416            Some("Ada".to_owned()),
417            Some("Lovelace".to_owned()),
418            Some("ada@example.test".to_owned()),
419        )
420        .expect("valid billing contact");
421        let changed_contact = BillingContact::new(
422            Some("Grace".to_owned()),
423            Some("Hopper".to_owned()),
424            Some("grace@example.test".to_owned()),
425        )
426        .expect("valid billing contact");
427        let context = |attempt_id, key: &str, token: &str, contact| {
428            crate::SubscriptionPaymentContext::new(
429                PaymentAttemptId::new(Uuid::from_u128(attempt_id)),
430                gateway.billing_scope_id(),
431                subscriber_id,
432                gateway.gateway_configuration_id(),
433                IdempotencyKey::new(key).expect("valid idempotency key"),
434                PaymentToken::new(token).expect("valid payment token"),
435                contact,
436            )
437        };
438
439        let recovery_command = crate::RecoverSubscriptionPayment::new(
440            context(
441                23,
442                "recovery-submission-key",
443                "recovery-token",
444                canonical_contact.clone(),
445            ),
446            plan_key.clone(),
447        );
448        let start_at = chrono::DateTime::from_timestamp(1_700_000_000, 0).unwrap();
449        let recovery_reservation =
450            crate::SubscriptionRecoveryReservation::from_locked_subscription(
451                &recovery_command,
452                &gateway,
453                recovery_command.attempt_id(),
454                subscription_id,
455                payment_method_id,
456                initial_transaction_id.clone(),
457                SubscriptionStatus::PastDue,
458                BillingPeriod::new(start_at, start_at + Duration::days(30)).unwrap(),
459                ChargeAmount::new(1_000, CurrencyCode::new("USD").unwrap()).unwrap(),
460                GatewayAccountMode::Live,
461            )
462            .unwrap();
463        let recovery_retry = crate::RecoverSubscriptionPayment::new(
464            context(
465                24,
466                "recovery-submission-key",
467                "refreshed-recovery-token",
468                canonical_contact.clone(),
469            ),
470            plan_key.clone(),
471        );
472        assert!(recovery_reservation.matches_submission(&recovery_retry, &gateway));
473        assert!(!recovery_reservation.matches_submission(
474            &crate::RecoverSubscriptionPayment::new(
475                context(
476                    25,
477                    "changed-recovery-submission-key",
478                    "refreshed-recovery-token",
479                    canonical_contact.clone(),
480                ),
481                plan_key.clone(),
482            ),
483            &gateway,
484        ));
485        assert!(!recovery_reservation.matches_submission(
486            &crate::RecoverSubscriptionPayment::new(
487                context(
488                    26,
489                    "recovery-submission-key",
490                    "refreshed-recovery-token",
491                    changed_contact.clone(),
492                ),
493                plan_key.clone(),
494            ),
495            &gateway,
496        ));
497
498        let replacement_command = crate::ReplaceSubscriptionPaymentMethod::new(
499            context(
500                27,
501                "replacement-submission-key",
502                "replacement-token",
503                canonical_contact.clone(),
504            ),
505            plan_key.clone(),
506        );
507        let replacement_reservation =
508            crate::SubscriptionPaymentMethodReplacement::from_locked_subscription(
509                &replacement_command,
510                &gateway,
511                subscription_id,
512                payment_method_id,
513                initial_transaction_id,
514                CurrencyCode::new("USD").unwrap(),
515                GatewayAccountMode::Live,
516            )
517            .unwrap();
518        let replacement_retry = crate::ReplaceSubscriptionPaymentMethod::new(
519            context(
520                28,
521                "replacement-submission-key",
522                "refreshed-replacement-token",
523                canonical_contact.clone(),
524            ),
525            plan_key.clone(),
526        );
527        assert!(replacement_reservation.matches_submission(&replacement_retry, &gateway));
528        assert!(!replacement_reservation.matches_submission(
529            &crate::ReplaceSubscriptionPaymentMethod::new(
530                context(
531                    29,
532                    "changed-replacement-submission-key",
533                    "refreshed-replacement-token",
534                    canonical_contact,
535                ),
536                plan_key.clone(),
537            ),
538            &gateway,
539        ));
540        assert!(!replacement_reservation.matches_submission(
541            &crate::ReplaceSubscriptionPaymentMethod::new(
542                context(
543                    30,
544                    "replacement-submission-key",
545                    "refreshed-replacement-token",
546                    changed_contact,
547                ),
548                plan_key,
549            ),
550            &gateway,
551        ));
552    }
553}