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}