Skip to main content

syrup_rail_postgres/subscription_billing_service/
host_charge.rs

1use super::*;
2
3#[derive(Clone, Copy)]
4enum HostChargePreSubmissionStage<'a> {
5    Unreserved,
6    Reserved {
7        targets: &'a dyn HostChargeTargetStore,
8        reservation: &'a HostChargeReservation,
9        resolution: HostChargeBeforeSubmissionResolution,
10    },
11}
12
13enum HostChargePreSubmissionOutcome<T = ()> {
14    Ready(T),
15    Resolved(Box<HostChargePaymentResult>),
16}
17
18#[derive(Clone, Copy)]
19struct HostChargeCooldownSurface {
20    scope: GatewayMutationCooldownScope,
21    code: PaymentResolutionCode,
22}
23
24impl<T> HostChargePreSubmissionOutcome<T> {
25    fn resolved(payment: HostChargePaymentResult) -> Self {
26        Self::Resolved(Box::new(payment))
27    }
28}
29
30fn host_charge_payment_result(
31    attempt: PaymentAttempt,
32) -> Result<HostChargePaymentResult, SubscriptionBillingServiceError> {
33    HostChargePaymentResult::new(attempt)
34        .map_err(|_| SubscriptionBillingServiceError::InvalidState(INVALID_SERVICE_STATE))
35}
36
37impl SubscriptionBillingService {
38    /// Charges one host-owned target through the canonical attempt ledger.
39    ///
40    /// Target eligibility and economics are supplied by the configured host
41    /// extension. No transaction or target lock spans gateway I/O.
42    pub async fn charge_host_target(
43        &self,
44        command: ChargeHostTarget,
45    ) -> Result<HostChargePaymentResult, SubscriptionBillingServiceError> {
46        let targets = self
47            .host_charge_targets
48            .as_deref()
49            .ok_or(SubscriptionBillingServiceError::HostChargeUnavailable)?;
50        let (snapshot, prepared_reservation) = match self
51            .preflight_host_charge(targets, &command)
52            .await?
53        {
54            HostChargePreflightOutcome::Continue(snapshot) => (snapshot, None),
55            HostChargePreflightOutcome::Replay(attempt) if attempt_is_prepared(&attempt) => {
56                let reservation = HostChargeReservation::from_attempt(&attempt).map_err(|_| {
57                    SubscriptionBillingServiceError::InvalidState(INVALID_SERVICE_STATE)
58                })?;
59                (reservation.snapshot(), Some(reservation))
60            }
61            HostChargePreflightOutcome::Replay(attempt) => {
62                return host_charge_payment_result(*attempt);
63            }
64            HostChargePreflightOutcome::IdempotencyConflict => {
65                return Err(SubscriptionBillingServiceError::IdempotencyConflict);
66            }
67            HostChargePreflightOutcome::Rejected { reason } => {
68                return Err(SubscriptionBillingServiceError::HostChargeReservationRejected(reason));
69            }
70        };
71        if prepared_reservation.as_ref().is_some_and(|reservation| {
72            reservation.identity().required_gateway_account_mode()
73                != self.required_gateway_account_mode
74        }) {
75            return Err(SubscriptionBillingServiceError::GatewayConfigurationChanged);
76        }
77
78        let account = self
79            .gateway_account(
80                command.billing_scope_id(),
81                command.gateway_configuration_id(),
82            )
83            .await?;
84        if prepared_reservation.as_ref().is_some_and(|reservation| {
85            reservation.identity().gateway_account_id() != account.account_id
86        }) {
87            return Err(SubscriptionBillingServiceError::InvalidState(
88                INVALID_SERVICE_STATE,
89            ));
90        }
91        let cooldown_stage = prepared_reservation.as_ref().map_or(
92            HostChargePreSubmissionStage::Unreserved,
93            |reservation| HostChargePreSubmissionStage::Reserved {
94                targets,
95                reservation,
96                resolution: HostChargeBeforeSubmissionResolution::prepared(),
97            },
98        );
99        match self.host_charge_cooldown(&account, cooldown_stage).await? {
100            HostChargePreSubmissionOutcome::Ready(()) => {}
101            HostChargePreSubmissionOutcome::Resolved(payment) => return Ok(*payment),
102        }
103        let gateway = self
104            .resolver
105            .resolve(
106                command.billing_scope_id(),
107                account.account_id,
108                command.gateway_configuration_id(),
109                account.provider_key.clone(),
110            )
111            .await?;
112        if gateway.billing_scope_id() != command.billing_scope_id()
113            || gateway.gateway_account_id() != account.account_id
114            || gateway.gateway_configuration_id() != command.gateway_configuration_id()
115            || gateway.provider_key() != &account.provider_key
116        {
117            return Err(SubscriptionBillingServiceError::ResolvedGatewayIdentityMismatch);
118        }
119        if prepared_reservation.as_ref().is_some_and(|reservation| {
120            reservation.identity().gateway_account_id() != gateway.gateway_account_id()
121                || reservation.request().gateway_order_id()
122                    != &gateway.mutation_reference_factory().for_attempt(
123                        PaymentAttemptKind::HostCharge,
124                        reservation.identity().attempt_id(),
125                    )
126        }) {
127            return Err(SubscriptionBillingServiceError::InvalidState(
128                INVALID_SERVICE_STATE,
129            ));
130        }
131        let verified_gateway = if let Some(reservation) = prepared_reservation.as_ref() {
132            match self
133                .host_charge_prepared_gateway_readiness(&gateway, targets, reservation)
134                .await?
135            {
136                HostChargePreSubmissionOutcome::Ready(verified_gateway) => verified_gateway,
137                HostChargePreSubmissionOutcome::Resolved(payment) => return Ok(*payment),
138            }
139        } else {
140            self.host_charge_unreserved_gateway_readiness(&account, &gateway)
141                .await?
142        };
143        if prepared_reservation.is_none() {
144            self.admit_subscriber_mutation(
145                command.billing_scope_id(),
146                command.subscriber_id(),
147                EndUserMutationOperation::HostCharge,
148            )
149            .await?;
150        }
151
152        let candidate_id = prepared_reservation.as_ref().map_or_else(
153            || PaymentAttemptId::new(uuid::Uuid::now_v7()),
154            |reservation| reservation.identity().attempt_id(),
155        );
156        let mut reservation = match prepared_reservation {
157            Some(reservation) => reservation,
158            None => HostChargeReservation::from_command(
159                &command,
160                snapshot,
161                &gateway,
162                candidate_id,
163                self.required_gateway_account_mode,
164            )
165            .map_err(|_| SubscriptionBillingServiceError::InvalidState(INVALID_SERVICE_STATE))?,
166        };
167        let attempt = match self.reserve_host_charge(targets, &reservation).await? {
168            HostChargeReservationOutcome::Reserved(attempt)
169            | HostChargeReservationOutcome::Replay(attempt)
170                if attempt_is_prepared(&attempt) =>
171            {
172                attempt
173            }
174            HostChargeReservationOutcome::Replay(attempt) => {
175                return host_charge_payment_result(attempt);
176            }
177            HostChargeReservationOutcome::IdempotencyConflict => {
178                return Err(SubscriptionBillingServiceError::IdempotencyConflict);
179            }
180            HostChargeReservationOutcome::GatewayAccountModeChanged => {
181                return Err(SubscriptionBillingServiceError::GatewayConfigurationChanged);
182            }
183            HostChargeReservationOutcome::Rejected { reason } => {
184                return Err(SubscriptionBillingServiceError::HostChargeReservationRejected(reason));
185            }
186            HostChargeReservationOutcome::Reserved(_) => {
187                return Err(SubscriptionBillingServiceError::InvalidState(
188                    INVALID_SERVICE_STATE,
189                ));
190            }
191        };
192        if attempt.identity().attempt_id() != candidate_id {
193            reservation = HostChargeReservation::from_attempt(&attempt).map_err(|_| {
194                SubscriptionBillingServiceError::InvalidState(INVALID_SERVICE_STATE)
195            })?;
196            if reservation.identity().required_gateway_account_mode()
197                != self.required_gateway_account_mode
198            {
199                return Err(SubscriptionBillingServiceError::GatewayConfigurationChanged);
200            }
201            if reservation.identity().gateway_account_id() != gateway.gateway_account_id()
202                || reservation.request().gateway_order_id()
203                    != &gateway.mutation_reference_factory().for_attempt(
204                        PaymentAttemptKind::HostCharge,
205                        reservation.identity().attempt_id(),
206                    )
207            {
208                return Err(SubscriptionBillingServiceError::InvalidState(
209                    INVALID_SERVICE_STATE,
210                ));
211            }
212        }
213
214        let prepared_stage = HostChargePreSubmissionStage::Reserved {
215            targets,
216            reservation: &reservation,
217            resolution: HostChargeBeforeSubmissionResolution::prepared(),
218        };
219        match self.host_charge_cooldown(&account, prepared_stage).await? {
220            HostChargePreSubmissionOutcome::Ready(()) => {}
221            HostChargePreSubmissionOutcome::Resolved(payment) => return Ok(*payment),
222        }
223
224        let admission =
225            match admit_host_charge_submission(&self.pool, targets, &reservation).await? {
226                HostChargeAdmissionOutcome::Admitted(admission) => *admission,
227                HostChargeAdmissionOutcome::AlreadyAdmitted(attempt) => {
228                    return host_charge_payment_result(attempt);
229                }
230                HostChargeAdmissionOutcome::Rejected { attempt, .. } => {
231                    return host_charge_payment_result(attempt);
232                }
233            };
234        let admitted_stage = HostChargePreSubmissionStage::Reserved {
235            targets,
236            reservation: &reservation,
237            resolution: HostChargeBeforeSubmissionResolution::admitted_not_submitted(),
238        };
239        match self.host_charge_cooldown(&account, admitted_stage).await? {
240            HostChargePreSubmissionOutcome::Ready(()) => {}
241            HostChargePreSubmissionOutcome::Resolved(payment) => return Ok(*payment),
242        }
243        match submit_admitted_host_charge(
244            &self.pool,
245            self.coordinator.as_ref(),
246            targets,
247            admission,
248            &command,
249            verified_gateway,
250        )
251        .await?
252        {
253            HostChargeProviderResult::Payment(payment) => Ok(payment),
254            HostChargeProviderResult::NotSubmitted { error, .. } => {
255                Err(SubscriptionBillingServiceError::GatewayNotSubmitted(error))
256            }
257        }
258    }
259
260    /// Applies an exact-query outcome to one host charge without resubmission.
261    pub async fn apply_reconciled_host_charge_outcome(
262        &self,
263        billing_scope_id: BillingScopeId,
264        attempt_id: PaymentAttemptId,
265        outcome: &GatewayPaymentOutcome,
266    ) -> Result<HostChargePaymentResult, SubscriptionBillingServiceError> {
267        let targets = self
268            .host_charge_targets
269            .as_deref()
270            .ok_or(SubscriptionBillingServiceError::HostChargeUnavailable)?;
271        apply_reconciled_host_charge_gateway_outcome(
272            &self.pool,
273            self.coordinator.as_ref(),
274            targets,
275            billing_scope_id,
276            attempt_id,
277            outcome,
278        )
279        .await
280        .map_err(Into::into)
281    }
282
283    pub(super) async fn preflight_host_charge(
284        &self,
285        targets: &dyn HostChargeTargetStore,
286        command: &ChargeHostTarget,
287    ) -> Result<HostChargePreflightOutcome, SubscriptionBillingServiceError> {
288        let mut transaction = self.pool.begin().await?;
289        let outcome = preflight_host_charge_in_transaction(
290            &mut transaction,
291            targets,
292            command,
293            self.required_gateway_account_mode,
294        )
295        .await?;
296        transaction.commit().await?;
297        Ok(outcome)
298    }
299
300    pub(super) async fn reserve_host_charge(
301        &self,
302        targets: &dyn HostChargeTargetStore,
303        reservation: &HostChargeReservation,
304    ) -> Result<HostChargeReservationOutcome, SubscriptionBillingServiceError> {
305        let mut transaction = self.pool.begin().await?;
306        let outcome =
307            reserve_host_charge_in_transaction(&mut transaction, targets, reservation).await?;
308        transaction.commit().await?;
309        Ok(outcome)
310    }
311
312    async fn host_charge_cooldown(
313        &self,
314        account: &GatewayAccountSnapshot,
315        stage: HostChargePreSubmissionStage<'_>,
316    ) -> Result<HostChargePreSubmissionOutcome, SubscriptionBillingServiceError> {
317        if let Some(scope) = self.active_cooldown(account).await? {
318            return match stage {
319                HostChargePreSubmissionStage::Unreserved => {
320                    Err(SubscriptionBillingServiceError::GatewayMutationCooldown { scope })
321                }
322                HostChargePreSubmissionStage::Reserved {
323                    targets,
324                    reservation,
325                    resolution,
326                } => self
327                    .resolve_host_charge_cooldown(
328                        targets,
329                        reservation,
330                        &account.provider_key,
331                        scope,
332                        resolution,
333                    )
334                    .await
335                    .map(HostChargePreSubmissionOutcome::resolved),
336            };
337        }
338        Ok(HostChargePreSubmissionOutcome::Ready(()))
339    }
340
341    async fn host_charge_unreserved_gateway_readiness<'gateway>(
342        &self,
343        account: &GatewayAccountSnapshot,
344        gateway: &'gateway syrup_rail::ResolvedGateway,
345    ) -> Result<ModeVerifiedGateway<'gateway>, SubscriptionBillingServiceError> {
346        match verify_gateway_account_mode(gateway, self.required_gateway_account_mode).await {
347            Ok(verified_gateway) => Ok(verified_gateway),
348            Err(GatewayAccountModeVerificationError::AccountModeMismatch { .. }) => {
349                Err(SubscriptionBillingServiceError::GatewayReadiness(
350                    GatewayError::Configuration(gateway_account_mode_mismatch_detail()),
351                ))
352            }
353            Err(GatewayAccountModeVerificationError::Gateway(GatewayError::RateLimited(_))) => {
354                self.extend_provider_cooldown(
355                    gateway.billing_scope_id(),
356                    gateway.gateway_account_id(),
357                    &account.provider_key,
358                )
359                .await?;
360                Err(SubscriptionBillingServiceError::GatewayMutationCooldown {
361                    scope: GatewayMutationCooldownScope::Provider,
362                })
363            }
364            Err(GatewayAccountModeVerificationError::Gateway(error)) => {
365                Err(SubscriptionBillingServiceError::GatewayReadiness(error))
366            }
367        }
368    }
369
370    async fn host_charge_prepared_gateway_readiness<'gateway>(
371        &self,
372        gateway: &'gateway syrup_rail::ResolvedGateway,
373        targets: &dyn HostChargeTargetStore,
374        reservation: &HostChargeReservation,
375    ) -> Result<
376        HostChargePreSubmissionOutcome<ModeVerifiedGateway<'gateway>>,
377        SubscriptionBillingServiceError,
378    > {
379        let readiness =
380            verify_gateway_account_mode(gateway, self.required_gateway_account_mode).await;
381        let resolution = HostChargeBeforeSubmissionResolution::prepared();
382        match readiness {
383            Ok(verified_gateway) => Ok(HostChargePreSubmissionOutcome::Ready(verified_gateway)),
384            Err(GatewayAccountModeVerificationError::AccountModeMismatch { required, .. }) => {
385                let detail = gateway_account_mode_mismatch_detail();
386                let policy = GatewayNotSubmittedPolicy::for_account_mode_mismatch(required);
387                let code = policy.resolution_code();
388                let payment = self
389                    .resolve_host_charge_readiness(
390                        targets,
391                        reservation,
392                        gateway.provider_key(),
393                        detail.clone(),
394                        code,
395                        resolution.with_not_submitted_policy(policy),
396                    )
397                    .await?;
398                if payment.attempt().state().resolution_code() == Some(code) {
399                    Err(SubscriptionBillingServiceError::GatewayReadiness(
400                        GatewayError::Configuration(detail),
401                    ))
402                } else {
403                    Ok(HostChargePreSubmissionOutcome::resolved(payment))
404                }
405            }
406            Err(GatewayAccountModeVerificationError::Gateway(error)) => {
407                let policy = GatewayNotSubmittedPolicy::for_readiness_error(&error);
408                if policy.restores_prepared_attempt_when_supported() {
409                    return Err(SubscriptionBillingServiceError::GatewayReadiness(error));
410                }
411                let resolution = resolution.with_not_submitted_policy(policy);
412                if let Some(cooldown) = policy.cooldown() {
413                    return self
414                        .resolve_host_charge_cooldown_with_detail(
415                            targets,
416                            reservation,
417                            gateway.provider_key(),
418                            error.detail().clone(),
419                            HostChargeCooldownSurface {
420                                scope: GatewayMutationCooldownScope::from_rate_limit_cooldown(
421                                    cooldown,
422                                ),
423                                code: policy.resolution_code(),
424                            },
425                            resolution,
426                        )
427                        .await
428                        .map(HostChargePreSubmissionOutcome::resolved);
429                }
430                let code = policy.resolution_code();
431                let payment = self
432                    .resolve_host_charge_readiness(
433                        targets,
434                        reservation,
435                        gateway.provider_key(),
436                        error.detail().clone(),
437                        code,
438                        resolution,
439                    )
440                    .await?;
441                if payment.attempt().state().resolution_code() == Some(code) {
442                    Err(SubscriptionBillingServiceError::GatewayReadiness(error))
443                } else {
444                    Ok(HostChargePreSubmissionOutcome::resolved(payment))
445                }
446            }
447        }
448    }
449
450    pub(super) async fn resolve_host_charge_readiness(
451        &self,
452        targets: &dyn HostChargeTargetStore,
453        reservation: &HostChargeReservation,
454        provider_key: &GatewayProviderKey,
455        detail: GatewayDiagnostic,
456        code: PaymentResolutionCode,
457        resolution: HostChargeBeforeSubmissionResolution,
458    ) -> Result<HostChargePaymentResult, SubscriptionBillingServiceError> {
459        resolve_host_charge_before_submission(
460            &self.pool,
461            targets,
462            reservation,
463            provider_key,
464            detail,
465            code,
466            resolution,
467        )
468        .await
469        .map_err(Into::into)
470    }
471
472    pub(super) async fn resolve_host_charge_cooldown(
473        &self,
474        targets: &dyn HostChargeTargetStore,
475        reservation: &HostChargeReservation,
476        provider_key: &GatewayProviderKey,
477        scope: GatewayMutationCooldownScope,
478        resolution: HostChargeBeforeSubmissionResolution,
479    ) -> Result<HostChargePaymentResult, SubscriptionBillingServiceError> {
480        let provider_name = provider_key.as_str().to_ascii_uppercase();
481        let detail = match scope {
482            GatewayMutationCooldownScope::Account => GatewayDiagnostic::new(&format!(
483                "{provider_name} account mutation cooldown is active."
484            )),
485            GatewayMutationCooldownScope::Provider => GatewayDiagnostic::new(&format!(
486                "{provider_name} system provider cooldown is active."
487            )),
488        };
489        let code = match scope {
490            GatewayMutationCooldownScope::Account => {
491                PaymentResolutionCode::GatewayAccountMutationCooldownBeforeSubmission
492            }
493            GatewayMutationCooldownScope::Provider => {
494                PaymentResolutionCode::GatewayProviderRateLimitedBeforeSubmission
495            }
496        };
497        self.resolve_host_charge_cooldown_with_detail(
498            targets,
499            reservation,
500            provider_key,
501            detail,
502            HostChargeCooldownSurface { scope, code },
503            resolution,
504        )
505        .await
506    }
507
508    async fn resolve_host_charge_cooldown_with_detail(
509        &self,
510        targets: &dyn HostChargeTargetStore,
511        reservation: &HostChargeReservation,
512        provider_key: &GatewayProviderKey,
513        detail: GatewayDiagnostic,
514        cooldown: HostChargeCooldownSurface,
515        resolution: HostChargeBeforeSubmissionResolution,
516    ) -> Result<HostChargePaymentResult, SubscriptionBillingServiceError> {
517        let payment = self
518            .resolve_host_charge_readiness(
519                targets,
520                reservation,
521                provider_key,
522                detail,
523                cooldown.code,
524                resolution,
525            )
526            .await?;
527        if payment.attempt().state().resolution_code() == Some(cooldown.code) {
528            Err(SubscriptionBillingServiceError::GatewayMutationCooldown {
529                scope: cooldown.scope,
530            })
531        } else {
532            Ok(payment)
533        }
534    }
535}