Skip to main content

syrup_rail_postgres/
host_charges.rs

1use std::{error::Error, fmt};
2
3use async_trait::async_trait;
4use sqlx::{PgConnection, Postgres, Transaction};
5use syrup_rail::{
6    BillingContactSnapshot, BillingScopeId, ChargeAmount, ChargeHostTarget, GatewayAccountMode,
7    HostChargeReservation, HostChargeTargetId, HostChargeTargetRejection, HostChargeTargetSnapshot,
8    HostChargeTargetTransition, HostChargeTargetTransitionOutcome, IdempotencyKey, PaymentAttempt,
9    PaymentAttemptFingerprint, PaymentAttemptId, PaymentAttemptKind, SubscriberId,
10};
11use thiserror::Error;
12
13use crate::{
14    attempts::{
15        AttemptReplayDisposition, attempt_replay_disposition,
16        find_payment_attempt_by_idempotency_in_transaction, prepared_replay_required_mode_changed,
17    },
18    host_error::{BoxError, RedactedHostErrorSource},
19};
20
21/// Value-redacted failure returned by the host charge-target store.
22#[derive(Debug)]
23pub struct HostChargeTargetError {
24    source: RedactedHostErrorSource,
25}
26
27impl HostChargeTargetError {
28    /// Wraps a host error without exposing its value through ordinary error
29    /// formatting or the standard error-source chain.
30    pub fn new(source: impl Error + Send + Sync + 'static) -> Self {
31        Self {
32            source: RedactedHostErrorSource::new(source),
33        }
34    }
35
36    /// Returns the host error for explicit application-level inspection.
37    pub fn into_source(self) -> BoxError {
38        self.source.into_inner()
39    }
40}
41
42impl fmt::Display for HostChargeTargetError {
43    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
44        formatter.write_str("host charge target operation failed")
45    }
46}
47
48impl Error for HostChargeTargetError {}
49
50#[derive(Clone, Debug, Eq, PartialEq)]
51pub struct HostChargeTargetReservation {
52    billing_scope_id: BillingScopeId,
53    subscriber_id: SubscriberId,
54    target_id: HostChargeTargetId,
55    idempotency_key: IdempotencyKey,
56}
57
58impl HostChargeTargetReservation {
59    pub const fn new(
60        billing_scope_id: BillingScopeId,
61        subscriber_id: SubscriberId,
62        target_id: HostChargeTargetId,
63        idempotency_key: IdempotencyKey,
64    ) -> Self {
65        Self {
66            billing_scope_id,
67            subscriber_id,
68            target_id,
69            idempotency_key,
70        }
71    }
72
73    pub const fn billing_scope_id(&self) -> BillingScopeId {
74        self.billing_scope_id
75    }
76
77    pub const fn subscriber_id(&self) -> SubscriberId {
78        self.subscriber_id
79    }
80
81    pub const fn target_id(&self) -> HostChargeTargetId {
82        self.target_id
83    }
84
85    pub const fn idempotency_key(&self) -> &IdempotencyKey {
86        &self.idempotency_key
87    }
88}
89
90#[derive(Clone, Copy, Debug, Eq, PartialEq)]
91pub enum HostChargeReservationDecision {
92    Reserved(HostChargeTargetSnapshot),
93    /// The target is already claimed by this idempotency key.
94    ///
95    /// The current snapshot is mandatory so the ledger can still reject a
96    /// same-key replay after host-owned economics changed.
97    IdempotentContender(HostChargeTargetSnapshot),
98    Rejected {
99        reason: syrup_rail::HostChargeTargetRejection,
100    },
101}
102
103#[derive(Clone, Debug, Eq, PartialEq)]
104pub struct HostChargeSubmissionAdmission {
105    billing_scope_id: BillingScopeId,
106    subscriber_id: SubscriberId,
107    target_id: HostChargeTargetId,
108    attempt_id: PaymentAttemptId,
109    expected_charge: ChargeAmount,
110}
111
112impl HostChargeSubmissionAdmission {
113    pub const fn new(
114        billing_scope_id: BillingScopeId,
115        subscriber_id: SubscriberId,
116        target_id: HostChargeTargetId,
117        attempt_id: PaymentAttemptId,
118        expected_charge: ChargeAmount,
119    ) -> Self {
120        Self {
121            billing_scope_id,
122            subscriber_id,
123            target_id,
124            attempt_id,
125            expected_charge,
126        }
127    }
128
129    pub const fn billing_scope_id(&self) -> BillingScopeId {
130        self.billing_scope_id
131    }
132
133    pub const fn subscriber_id(&self) -> SubscriberId {
134        self.subscriber_id
135    }
136
137    pub const fn target_id(&self) -> HostChargeTargetId {
138        self.target_id
139    }
140
141    pub const fn attempt_id(&self) -> PaymentAttemptId {
142        self.attempt_id
143    }
144
145    pub const fn expected_charge(&self) -> ChargeAmount {
146        self.expected_charge
147    }
148}
149
150#[derive(Clone, Copy, Debug, Eq, PartialEq)]
151pub enum HostChargeSubmissionDecision {
152    Admitted(HostChargeTargetSnapshot),
153    Rejected {
154        reason: syrup_rail::HostChargeTargetRejection,
155    },
156}
157
158/// Host-owned target extension composed into shared ledger transactions.
159///
160/// Implementations must use only the supplied connection. Reservation and
161/// submission lock the target before calling [`host_charge_ledger_admission`].
162#[async_trait]
163pub trait HostChargeTargetStore: Send + Sync {
164    /// Locks and snapshots the target for replay/conflict preflight without
165    /// changing host business state.
166    ///
167    /// A same-mode prepared replay invokes this callback again so its current
168    /// economics can be compared with the durable attempt. Terminal replay and
169    /// a prepared replay owned by another deployment mode skip it. When the
170    /// target is already claimed by the same idempotency key, return
171    /// [`HostChargeReservationDecision::IdempotentContender`] with its current
172    /// snapshot; the ledger still validates that snapshot.
173    async fn preflight_target(
174        &self,
175        connection: &mut PgConnection,
176        reservation: &HostChargeTargetReservation,
177    ) -> Result<HostChargeReservationDecision, HostChargeTargetError>;
178
179    async fn reserve_target(
180        &self,
181        connection: &mut PgConnection,
182        reservation: &HostChargeTargetReservation,
183    ) -> Result<HostChargeReservationDecision, HostChargeTargetError>;
184
185    /// Revalidates the target immediately before provider submission.
186    ///
187    /// This callback must be repeat-safe for the same attempt. A transient
188    /// control-plane failure can prove that the provider mutation was not
189    /// contacted, restore the canonical attempt to prepared state, and invoke
190    /// admission again on a same-key retry. Implementations should return the
191    /// same admitted snapshot while the target and expected charge are
192    /// unchanged and produce no additional observable side effect: do not
193    /// increment counters or append duplicate audit rows. One-shot paid/failed
194    /// business transitions belong in [`Self::apply_transition`].
195    async fn ensure_submission_admitted(
196        &self,
197        connection: &mut PgConnection,
198        admission: &HostChargeSubmissionAdmission,
199    ) -> Result<HostChargeSubmissionDecision, HostChargeTargetError>;
200
201    /// Applies one repeat-safe business transition keyed by its attempt.
202    ///
203    /// Return [`HostChargeTargetTransitionOutcome::ExactReplay`] when the same
204    /// transition was already applied. In particular,
205    /// [`syrup_rail::HostChargeTargetTransitionKind::ReleasedBeforeSubmission`]
206    /// releases a
207    /// claimed target after a determinate pre-submission failure and must not
208    /// be interpreted as a card decline.
209    ///
210    /// Application and reconciliation transactions ordinarily commit only when
211    /// this callback returns `Applied` or `ExactReplay`. One canonical replay
212    /// exception preserves the target-before-attempt lock order: if a `Paid`
213    /// callback returns `StaleTarget` or `Unchanged` and the subsequently locked
214    /// attempt is already approved, Syrup Rail returns that approved attempt.
215    /// This permits a later reversal to remain monotonic; implementations must
216    /// never move a reversed target back to paid. Other persistent refusals
217    /// intentionally leave the canonical attempt unresolved and require the
218    /// host to repair or explicitly reconcile its target state before retrying
219    /// cleanup.
220    async fn apply_transition(
221        &self,
222        connection: &mut PgConnection,
223        transition: HostChargeTargetTransition,
224    ) -> Result<HostChargeTargetTransitionOutcome, HostChargeTargetError>;
225}
226
227#[derive(Debug, Error)]
228pub enum HostChargeStoreError {
229    #[error("host charge storage operation failed")]
230    Sql(#[from] sqlx::Error),
231    #[error("host charge payment-attempt operation failed")]
232    Attempt(#[from] crate::attempts::PaymentAttemptStoreError),
233    #[error(transparent)]
234    Target(#[from] HostChargeTargetError),
235    #[error("canonical host charge state is invalid")]
236    InvalidState,
237}
238
239#[derive(Clone, Debug, Eq, PartialEq)]
240pub enum HostChargePreflightOutcome {
241    Continue(HostChargeTargetSnapshot),
242    Replay(Box<PaymentAttempt>),
243    IdempotencyConflict,
244    Rejected { reason: HostChargeTargetRejection },
245}
246
247#[derive(Clone, Debug, Eq, PartialEq)]
248pub enum HostChargeReservationOutcome {
249    Reserved(PaymentAttempt),
250    Replay(PaymentAttempt),
251    GatewayAccountModeChanged,
252    IdempotencyConflict,
253    Rejected { reason: HostChargeTargetRejection },
254}
255
256#[derive(Clone, Debug, Eq, PartialEq)]
257pub enum HostChargeSubmissionOutcome {
258    Admitted(PaymentAttempt),
259    AlreadyAdmitted(PaymentAttempt),
260    Rejected {
261        attempt: PaymentAttempt,
262        reason: HostChargeTargetRejection,
263    },
264}
265
266pub async fn preflight_host_charge_in_transaction(
267    transaction: &mut Transaction<'_, Postgres>,
268    targets: &dyn HostChargeTargetStore,
269    command: &ChargeHostTarget,
270    required_gateway_account_mode: GatewayAccountMode,
271) -> Result<HostChargePreflightOutcome, HostChargeStoreError> {
272    crate::attempts::set_enrollment_timeouts(transaction).await?;
273    // This first observation is intentionally lock-free. Canonical replay
274    // skips the host callback; unlike 0.3, an in-flight submitted attempt can
275    // be returned as a pending snapshot instead of waiting for its concurrent
276    // application transaction. Repairable review and wrong-mode prepared replay
277    // lock the attempt and return without taking a target lock; same-mode
278    // prepared work continues in the target -> attempt lock order for economics
279    // revalidation.
280    let observed = find_payment_attempt_by_idempotency_in_transaction(
281        transaction,
282        command.billing_scope_id(),
283        command.subscriber_id(),
284        command.idempotency_key(),
285    )
286    .await?;
287    if let Some(observed) = observed {
288        if !host_charge_attempt_matches_command(&observed, command, None) {
289            return Ok(HostChargePreflightOutcome::IdempotencyConflict);
290        }
291        let disposition = attempt_replay_disposition(&observed);
292        if disposition == AttemptReplayDisposition::ReturnCanonical {
293            return Ok(HostChargePreflightOutcome::Replay(Box::new(observed)));
294        }
295        if disposition == AttemptReplayDisposition::RepairUnsubmittedReview
296            || prepared_replay_required_mode_changed(&observed, required_gateway_account_mode)
297        {
298            let locked = crate::lock_payment_attempt_by_idempotency_in_transaction(
299                transaction,
300                command.billing_scope_id(),
301                command.subscriber_id(),
302                command.idempotency_key(),
303            )
304            .await?
305            .ok_or(HostChargeStoreError::InvalidState)?;
306            if !host_charge_attempt_matches_command(&locked, command, None) {
307                return Ok(HostChargePreflightOutcome::IdempotencyConflict);
308            }
309            return Ok(HostChargePreflightOutcome::Replay(Box::new(locked)));
310        }
311    }
312    let target_reservation = HostChargeTargetReservation::new(
313        command.billing_scope_id(),
314        command.subscriber_id(),
315        command.target_id(),
316        command.idempotency_key().clone(),
317    );
318    let decision = targets
319        .preflight_target(transaction, &target_reservation)
320        .await?;
321    let existing = crate::lock_payment_attempt_by_idempotency_in_transaction(
322        transaction,
323        command.billing_scope_id(),
324        command.subscriber_id(),
325        command.idempotency_key(),
326    )
327    .await?;
328    Ok(match (decision, existing) {
329        (HostChargeReservationDecision::Reserved(snapshot), None) => {
330            HostChargePreflightOutcome::Continue(snapshot)
331        }
332        (HostChargeReservationDecision::Reserved(snapshot), Some(existing))
333        | (HostChargeReservationDecision::IdempotentContender(snapshot), Some(existing)) => {
334            if host_charge_attempt_matches_command(&existing, command, Some(snapshot)) {
335                HostChargePreflightOutcome::Replay(Box::new(existing))
336            } else {
337                HostChargePreflightOutcome::IdempotencyConflict
338            }
339        }
340        (HostChargeReservationDecision::IdempotentContender(_), None) => {
341            return Err(HostChargeStoreError::InvalidState);
342        }
343        (HostChargeReservationDecision::Rejected { .. }, Some(existing))
344            if host_charge_attempt_matches_command(&existing, command, None) =>
345        {
346            HostChargePreflightOutcome::Replay(Box::new(existing))
347        }
348        (HostChargeReservationDecision::Rejected { .. }, Some(_)) => {
349            HostChargePreflightOutcome::IdempotencyConflict
350        }
351        (HostChargeReservationDecision::Rejected { reason }, None) => {
352            HostChargePreflightOutcome::Rejected { reason }
353        }
354    })
355}
356
357pub async fn reserve_host_charge_in_transaction(
358    transaction: &mut Transaction<'_, Postgres>,
359    targets: &dyn HostChargeTargetStore,
360    reservation: &HostChargeReservation,
361) -> Result<HostChargeReservationOutcome, HostChargeStoreError> {
362    crate::attempts::set_enrollment_timeouts(transaction).await?;
363    let identity = reservation.identity();
364    let request = reservation.request();
365    let target_id = request
366        .target()
367        .host_charge_target_id()
368        .ok_or(HostChargeStoreError::InvalidState)?;
369    let target_reservation = HostChargeTargetReservation::new(
370        identity.billing_scope_id(),
371        identity.subscriber_id(),
372        target_id,
373        request.idempotency_key().clone(),
374    );
375    let decision = targets
376        .reserve_target(transaction, &target_reservation)
377        .await?;
378    let snapshot = match decision {
379        HostChargeReservationDecision::Reserved(snapshot) => snapshot,
380        HostChargeReservationDecision::IdempotentContender(snapshot) => {
381            let existing = crate::lock_payment_attempt_by_idempotency_in_transaction(
382                transaction,
383                identity.billing_scope_id(),
384                identity.subscriber_id(),
385                request.idempotency_key(),
386            )
387            .await?
388            .ok_or(HostChargeStoreError::InvalidState)?;
389            return Ok(
390                if snapshot != reservation.snapshot()
391                    || !host_charge_attempt_matches_reservation_without_required_mode(
392                        &existing,
393                        reservation,
394                    )
395                {
396                    HostChargeReservationOutcome::IdempotencyConflict
397                } else if prepared_replay_required_mode_changed(
398                    &existing,
399                    identity.required_gateway_account_mode(),
400                ) {
401                    HostChargeReservationOutcome::GatewayAccountModeChanged
402                } else {
403                    HostChargeReservationOutcome::Replay(existing)
404                },
405            );
406        }
407        HostChargeReservationDecision::Rejected { reason } => {
408            return Ok(HostChargeReservationOutcome::Rejected { reason });
409        }
410    };
411    if snapshot != reservation.snapshot() {
412        return Ok(HostChargeReservationOutcome::Rejected {
413            reason: HostChargeTargetRejection::ChargeChanged,
414        });
415    }
416
417    let inserted = sqlx::query(
418        r#"
419        INSERT INTO billing_payment_attempts (
420            id, billing_scope_id, subscriber_id, host_charge_target_id,
421            attempt_kind, status, idempotency_key, request_fingerprint,
422            amount_cents, currency, gateway_account_id,
423            gateway_configuration_id, gateway_order_id,
424            billing_first_name, billing_last_name, billing_email,
425            required_gateway_account_mode
426        ) VALUES (
427            $1, $2, $3, $4, 'host_charge', 'pending', $5, $6,
428            $7, $8, $9, $10, $11, $12, $13, $14, $15
429        )
430        ON CONFLICT (billing_scope_id, subscriber_id, idempotency_key) DO NOTHING
431        "#,
432    )
433    .bind(identity.attempt_id().as_uuid())
434    .bind(identity.billing_scope_id().as_uuid())
435    .bind(identity.subscriber_id().as_uuid())
436    .bind(target_id.as_uuid())
437    .bind(request.idempotency_key().expose())
438    .bind(request.fingerprint().expose())
439    .bind(request.amount().cents())
440    .bind(request.amount().currency().as_str())
441    .bind(identity.gateway_account_id().as_uuid())
442    .bind(identity.gateway_configuration_id().as_uuid())
443    .bind(request.gateway_order_id().expose())
444    .bind(request.billing_contact().first_name())
445    .bind(request.billing_contact().last_name())
446    .bind(request.billing_contact().email())
447    .bind(identity.required_gateway_account_mode().as_str())
448    .execute(&mut **transaction)
449    .await?;
450    let attempt_id = if inserted.rows_affected() == 1 {
451        identity.attempt_id()
452    } else {
453        crate::lock_payment_attempt_by_idempotency_in_transaction(
454            transaction,
455            identity.billing_scope_id(),
456            identity.subscriber_id(),
457            request.idempotency_key(),
458        )
459        .await?
460        .ok_or(HostChargeStoreError::InvalidState)?
461        .identity()
462        .attempt_id()
463    };
464    let attempt = crate::find_payment_attempt_by_id_in_transaction(
465        transaction,
466        identity.billing_scope_id(),
467        attempt_id,
468    )
469    .await?
470    .ok_or(HostChargeStoreError::InvalidState)?;
471    if !host_charge_attempt_matches_reservation_without_required_mode(&attempt, reservation) {
472        return Ok(HostChargeReservationOutcome::IdempotencyConflict);
473    }
474    if prepared_replay_required_mode_changed(&attempt, identity.required_gateway_account_mode()) {
475        return Ok(HostChargeReservationOutcome::GatewayAccountModeChanged);
476    }
477    Ok(if inserted.rows_affected() == 1 {
478        HostChargeReservationOutcome::Reserved(attempt)
479    } else {
480        HostChargeReservationOutcome::Replay(attempt)
481    })
482}
483
484pub async fn admit_host_charge_submission_in_transaction(
485    transaction: &mut Transaction<'_, Postgres>,
486    targets: &dyn HostChargeTargetStore,
487    reservation: &HostChargeReservation,
488) -> Result<HostChargeSubmissionOutcome, HostChargeStoreError> {
489    crate::attempts::set_enrollment_timeouts(transaction).await?;
490    let identity = reservation.identity();
491    let target_id = reservation.snapshot().target_id();
492    // Observe without locking before entering the host callback. The callback
493    // owns the target -> attempt lock order, but it must never receive an
494    // admission assembled from a different durable attempt.
495    let observed = crate::find_payment_attempt_by_id_in_transaction(
496        transaction,
497        identity.billing_scope_id(),
498        identity.attempt_id(),
499    )
500    .await?
501    .ok_or(HostChargeStoreError::InvalidState)?;
502    if !host_charge_attempt_matches_reservation_without_required_mode(&observed, reservation) {
503        return Err(HostChargeStoreError::InvalidState);
504    }
505    if observed.status() != syrup_rail::PaymentAttemptStatus::Pending
506        || observed.state().timestamps().submitted_at().is_some()
507    {
508        return Ok(HostChargeSubmissionOutcome::AlreadyAdmitted(observed));
509    }
510    if !host_charge_attempt_matches_reservation(&observed, reservation) {
511        return Err(HostChargeStoreError::InvalidState);
512    }
513    let admission = HostChargeSubmissionAdmission::new(
514        identity.billing_scope_id(),
515        identity.subscriber_id(),
516        target_id,
517        identity.attempt_id(),
518        reservation.snapshot().charge(),
519    );
520    let decision = targets
521        .ensure_submission_admitted(transaction, &admission)
522        .await?;
523    // Lock and revalidate after the target callback. This preserves the
524    // documented lock order and makes the durable row, rather than the
525    // caller-built reservation, the authority for the transition.
526    let attempt = crate::attempts::lock_payment_attempt_by_id_on_connection(
527        transaction,
528        identity.billing_scope_id(),
529        identity.attempt_id(),
530    )
531    .await?
532    .ok_or(HostChargeStoreError::InvalidState)?;
533    if !host_charge_attempt_matches_reservation_without_required_mode(&attempt, reservation) {
534        return Err(HostChargeStoreError::InvalidState);
535    }
536    if attempt.status() != syrup_rail::PaymentAttemptStatus::Pending
537        || attempt.state().timestamps().submitted_at().is_some()
538    {
539        return Ok(HostChargeSubmissionOutcome::AlreadyAdmitted(attempt));
540    }
541    if !host_charge_attempt_matches_reservation(&attempt, reservation) {
542        return Err(HostChargeStoreError::InvalidState);
543    }
544    let rejection = match decision {
545        HostChargeSubmissionDecision::Admitted(snapshot) if snapshot == reservation.snapshot() => {
546            None
547        }
548        HostChargeSubmissionDecision::Admitted(_) => Some(HostChargeTargetRejection::ChargeChanged),
549        HostChargeSubmissionDecision::Rejected { reason } => Some(reason),
550    };
551    if let Some(reason) = rejection {
552        let updated = sqlx::query(
553            r#"
554            UPDATE billing_payment_attempts
555            SET status = 'failed',
556                gateway_response_text = 'Host target changed before payment submission.',
557                gateway_condition = 'failed', resolved_at = clock_timestamp(),
558                updated_at = clock_timestamp()
559            WHERE id = $1 AND billing_scope_id = $2 AND subscriber_id = $3
560                AND host_charge_target_id = $4 AND attempt_kind = 'host_charge'
561                AND status = 'pending' AND submitted_at IS NULL
562            "#,
563        )
564        .bind(identity.attempt_id().as_uuid())
565        .bind(identity.billing_scope_id().as_uuid())
566        .bind(identity.subscriber_id().as_uuid())
567        .bind(target_id.as_uuid())
568        .execute(&mut **transaction)
569        .await?;
570        if updated.rows_affected() != 1 {
571            return Err(HostChargeStoreError::InvalidState);
572        }
573        let attempt = crate::find_payment_attempt_by_id_in_transaction(
574            transaction,
575            identity.billing_scope_id(),
576            identity.attempt_id(),
577        )
578        .await?
579        .ok_or(HostChargeStoreError::InvalidState)?;
580        return Ok(HostChargeSubmissionOutcome::Rejected { attempt, reason });
581    }
582    let attempt = crate::attempts::admit_prepared_attempt(transaction, &attempt).await?;
583    Ok(HostChargeSubmissionOutcome::Admitted(attempt))
584}
585
586fn host_charge_attempt_matches_command(
587    attempt: &PaymentAttempt,
588    command: &ChargeHostTarget,
589    snapshot: Option<HostChargeTargetSnapshot>,
590) -> bool {
591    let identity = attempt.identity();
592    let request = attempt.request();
593    let canonical_fingerprint =
594        PaymentAttemptFingerprint::for_host_charge(command.target_id(), request.amount());
595    let billing_contact = command
596        .billing_contact()
597        .map(BillingContactSnapshot::from_billing_contact)
598        .unwrap_or_else(|| BillingContactSnapshot::new(None, None));
599    attempt.kind() == PaymentAttemptKind::HostCharge
600        && identity.billing_scope_id() == command.billing_scope_id()
601        && identity.subscriber_id() == command.subscriber_id()
602        && identity.gateway_configuration_id() == command.gateway_configuration_id()
603        && request.target().host_charge_target_id() == Some(command.target_id())
604        && request.fingerprint() == &canonical_fingerprint
605        && request.billing_contact() == &billing_contact
606        && snapshot.is_none_or(|snapshot| {
607            request.amount() == snapshot.charge().money()
608                && request.fingerprint()
609                    == &PaymentAttemptFingerprint::for_host_charge(
610                        command.target_id(),
611                        snapshot.charge().money(),
612                    )
613        })
614}
615
616fn host_charge_attempt_matches_reservation_without_required_mode(
617    attempt: &PaymentAttempt,
618    reservation: &HostChargeReservation,
619) -> bool {
620    let identity = attempt.identity();
621    let requested_identity = reservation.identity();
622    let request = attempt.request();
623    let requested = reservation.request();
624    attempt.kind() == PaymentAttemptKind::HostCharge
625        && identity.billing_scope_id() == requested_identity.billing_scope_id()
626        && identity.subscriber_id() == requested_identity.subscriber_id()
627        && identity.gateway_account_id() == requested_identity.gateway_account_id()
628        && identity.gateway_configuration_id() == requested_identity.gateway_configuration_id()
629        && request.target() == requested.target()
630        && request.idempotency_key() == requested.idempotency_key()
631        && request.fingerprint() == requested.fingerprint()
632        && request.amount() == requested.amount()
633        && request.billing_contact() == requested.billing_contact()
634}
635
636pub(crate) fn host_charge_attempt_matches_reservation(
637    attempt: &PaymentAttempt,
638    reservation: &HostChargeReservation,
639) -> bool {
640    attempt.kind() == PaymentAttemptKind::HostCharge
641        && attempt.identity() == reservation.identity()
642        && attempt.request() == reservation.request()
643}
644
645#[derive(Clone, Debug, Eq, PartialEq)]
646pub enum HostChargeLedgerAdmissionMode {
647    Reserve { idempotency_key: IdempotencyKey },
648    Submit { attempt_id: PaymentAttemptId },
649    Release,
650}
651
652#[derive(Clone, Debug, Eq, PartialEq)]
653pub struct HostChargeLedgerAdmissionQuery {
654    billing_scope_id: BillingScopeId,
655    subscriber_id: SubscriberId,
656    target_id: HostChargeTargetId,
657    mode: HostChargeLedgerAdmissionMode,
658}
659
660impl HostChargeLedgerAdmissionQuery {
661    pub const fn new(
662        billing_scope_id: BillingScopeId,
663        subscriber_id: SubscriberId,
664        target_id: HostChargeTargetId,
665        mode: HostChargeLedgerAdmissionMode,
666    ) -> Self {
667        Self {
668            billing_scope_id,
669            subscriber_id,
670            target_id,
671            mode,
672        }
673    }
674
675    pub const fn billing_scope_id(&self) -> BillingScopeId {
676        self.billing_scope_id
677    }
678
679    pub const fn subscriber_id(&self) -> SubscriberId {
680        self.subscriber_id
681    }
682
683    pub const fn target_id(&self) -> HostChargeTargetId {
684        self.target_id
685    }
686
687    pub const fn mode(&self) -> &HostChargeLedgerAdmissionMode {
688        &self.mode
689    }
690}
691
692#[derive(Clone, Copy, Debug, Eq, PartialEq)]
693pub enum HostChargeLedgerAdmission {
694    Safe,
695    IdempotentContender,
696    Unsafe,
697}
698
699#[derive(Debug, Error)]
700pub enum HostChargeLedgerAdmissionError {
701    #[error("host charge ledger admission query failed")]
702    Sql(#[from] sqlx::Error),
703    #[error("host charge ledger admission returned an invalid result")]
704    InvalidResult,
705}
706
707pub async fn host_charge_ledger_admission(
708    connection: &mut PgConnection,
709    query: &HostChargeLedgerAdmissionQuery,
710) -> Result<HostChargeLedgerAdmission, HostChargeLedgerAdmissionError> {
711    let (mode, idempotency_key, attempt_id) = match query.mode() {
712        HostChargeLedgerAdmissionMode::Reserve { idempotency_key } => {
713            ("reserve", Some(idempotency_key.expose()), None)
714        }
715        HostChargeLedgerAdmissionMode::Submit { attempt_id } => {
716            ("submit", None, Some(attempt_id.into_uuid()))
717        }
718        HostChargeLedgerAdmissionMode::Release => ("release", None, None),
719    };
720    let result: String = sqlx::query_scalar(
721        r#"
722        SELECT billing_host_charge_ledger_admission($1, $2, $3, $4, $5, $6)
723        "#,
724    )
725    .bind(query.billing_scope_id().into_uuid())
726    .bind(query.subscriber_id().into_uuid())
727    .bind(query.target_id().into_uuid())
728    .bind(mode)
729    .bind(idempotency_key)
730    .bind(attempt_id)
731    .fetch_one(connection)
732    .await?;
733
734    match result.as_str() {
735        "safe" => Ok(HostChargeLedgerAdmission::Safe),
736        "idempotent_contender" => Ok(HostChargeLedgerAdmission::IdempotentContender),
737        "unsafe" => Ok(HostChargeLedgerAdmission::Unsafe),
738        _ => Err(HostChargeLedgerAdmissionError::InvalidResult),
739    }
740}
741
742#[cfg(test)]
743mod tests {
744    use std::error::Error;
745
746    use uuid::Uuid;
747
748    use super::*;
749    use crate::test_support::{TestDatabase, create_gateway_account};
750
751    #[tokio::test]
752    async fn admission_distinguishes_safe_contender_and_unsafe_modes() -> Result<(), Box<dyn Error>>
753    {
754        let database = TestDatabase::start("rail_host_admit").await?;
755        let result = async {
756            let gateway = create_gateway_account(&database.pool, "test_gateway").await?;
757            let subscriber_id = SubscriberId::new(Uuid::now_v7());
758            let target_id = HostChargeTargetId::new(Uuid::now_v7());
759            let idempotency_key = IdempotencyKey::new("host-charge-test")?;
760
761            let mut connection = database.pool.acquire().await?;
762            let reserve = HostChargeLedgerAdmissionQuery::new(
763                BillingScopeId::new(gateway.billing_scope_id),
764                subscriber_id,
765                target_id,
766                HostChargeLedgerAdmissionMode::Reserve {
767                    idempotency_key: idempotency_key.clone(),
768                },
769            );
770            assert_eq!(
771                host_charge_ledger_admission(&mut connection, &reserve).await?,
772                HostChargeLedgerAdmission::Safe
773            );
774
775            let attempt_id = PaymentAttemptId::new(Uuid::now_v7());
776            sqlx::query(
777                r#"
778                INSERT INTO billing_payment_attempts (
779                    id, billing_scope_id, subscriber_id, host_charge_target_id,
780                    attempt_kind, status, idempotency_key, request_fingerprint,
781                    amount_cents, currency, gateway_account_id,
782                    gateway_configuration_id, gateway_order_id,
783                    required_gateway_account_mode
784                ) VALUES (
785                    $1, $2, $3, $4, 'host_charge', 'pending', $5, $6,
786                    100, 'USD', $7, $8, 'host-charge-test-order', 'live'
787                )
788                "#,
789            )
790            .bind(attempt_id.as_uuid())
791            .bind(gateway.billing_scope_id)
792            .bind(subscriber_id.as_uuid())
793            .bind(target_id.as_uuid())
794            .bind(idempotency_key.expose())
795            .bind(format!("host_charge:{}:100:USD", target_id.as_uuid()))
796            .bind(gateway.gateway_account_id)
797            .bind(gateway.gateway_configuration_id)
798            .execute(&mut *connection)
799            .await?;
800
801            assert_eq!(
802                host_charge_ledger_admission(&mut connection, &reserve).await?,
803                HostChargeLedgerAdmission::IdempotentContender
804            );
805            let submit = HostChargeLedgerAdmissionQuery::new(
806                BillingScopeId::new(gateway.billing_scope_id),
807                subscriber_id,
808                target_id,
809                HostChargeLedgerAdmissionMode::Submit { attempt_id },
810            );
811            assert_eq!(
812                host_charge_ledger_admission(&mut connection, &submit).await?,
813                HostChargeLedgerAdmission::Safe
814            );
815            let release = HostChargeLedgerAdmissionQuery::new(
816                BillingScopeId::new(gateway.billing_scope_id),
817                subscriber_id,
818                target_id,
819                HostChargeLedgerAdmissionMode::Release,
820            );
821            assert_eq!(
822                host_charge_ledger_admission(&mut connection, &release).await?,
823                HostChargeLedgerAdmission::Unsafe
824            );
825            Ok::<_, Box<dyn Error>>(())
826        }
827        .await;
828        let cleanup = database.cleanup().await;
829        result?;
830        cleanup
831    }
832}