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    BillingScopeId, ChargeAmount, ChargeHostTarget, HostChargeReservation, HostChargeTargetId,
7    HostChargeTargetRejection, HostChargeTargetSnapshot, HostChargeTargetTransition,
8    HostChargeTargetTransitionOutcome, IdempotencyKey, PaymentAttempt, PaymentAttemptFingerprint,
9    PaymentAttemptId, PaymentAttemptKind, SubscriberId,
10};
11use thiserror::Error;
12
13type BoxError = Box<dyn Error + Send + Sync + 'static>;
14
15#[derive(Debug)]
16pub struct HostChargeTargetError {
17    source: BoxError,
18}
19
20impl HostChargeTargetError {
21    pub fn new(source: impl Error + Send + Sync + 'static) -> Self {
22        Self {
23            source: Box::new(source),
24        }
25    }
26
27    pub fn into_source(self) -> BoxError {
28        self.source
29    }
30}
31
32impl fmt::Display for HostChargeTargetError {
33    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
34        formatter.write_str("host charge target operation failed")
35    }
36}
37
38impl Error for HostChargeTargetError {
39    fn source(&self) -> Option<&(dyn Error + 'static)> {
40        Some(self.source.as_ref())
41    }
42}
43
44#[derive(Clone, Debug, Eq, PartialEq)]
45pub struct HostChargeTargetReservation {
46    billing_scope_id: BillingScopeId,
47    subscriber_id: SubscriberId,
48    target_id: HostChargeTargetId,
49    idempotency_key: IdempotencyKey,
50}
51
52impl HostChargeTargetReservation {
53    pub const fn new(
54        billing_scope_id: BillingScopeId,
55        subscriber_id: SubscriberId,
56        target_id: HostChargeTargetId,
57        idempotency_key: IdempotencyKey,
58    ) -> Self {
59        Self {
60            billing_scope_id,
61            subscriber_id,
62            target_id,
63            idempotency_key,
64        }
65    }
66
67    pub const fn billing_scope_id(&self) -> BillingScopeId {
68        self.billing_scope_id
69    }
70
71    pub const fn subscriber_id(&self) -> SubscriberId {
72        self.subscriber_id
73    }
74
75    pub const fn target_id(&self) -> HostChargeTargetId {
76        self.target_id
77    }
78
79    pub const fn idempotency_key(&self) -> &IdempotencyKey {
80        &self.idempotency_key
81    }
82}
83
84#[derive(Clone, Copy, Debug, Eq, PartialEq)]
85pub enum HostChargeReservationDecision {
86    Reserved(HostChargeTargetSnapshot),
87    IdempotentContender,
88    Rejected {
89        reason: syrup_rail::HostChargeTargetRejection,
90    },
91}
92
93#[derive(Clone, Debug, Eq, PartialEq)]
94pub struct HostChargeSubmissionAdmission {
95    billing_scope_id: BillingScopeId,
96    subscriber_id: SubscriberId,
97    target_id: HostChargeTargetId,
98    attempt_id: PaymentAttemptId,
99    expected_charge: ChargeAmount,
100}
101
102impl HostChargeSubmissionAdmission {
103    pub const fn new(
104        billing_scope_id: BillingScopeId,
105        subscriber_id: SubscriberId,
106        target_id: HostChargeTargetId,
107        attempt_id: PaymentAttemptId,
108        expected_charge: ChargeAmount,
109    ) -> Self {
110        Self {
111            billing_scope_id,
112            subscriber_id,
113            target_id,
114            attempt_id,
115            expected_charge,
116        }
117    }
118
119    pub const fn billing_scope_id(&self) -> BillingScopeId {
120        self.billing_scope_id
121    }
122
123    pub const fn subscriber_id(&self) -> SubscriberId {
124        self.subscriber_id
125    }
126
127    pub const fn target_id(&self) -> HostChargeTargetId {
128        self.target_id
129    }
130
131    pub const fn attempt_id(&self) -> PaymentAttemptId {
132        self.attempt_id
133    }
134
135    pub const fn expected_charge(&self) -> ChargeAmount {
136        self.expected_charge
137    }
138}
139
140#[derive(Clone, Copy, Debug, Eq, PartialEq)]
141pub enum HostChargeSubmissionDecision {
142    Admitted(HostChargeTargetSnapshot),
143    Rejected {
144        reason: syrup_rail::HostChargeTargetRejection,
145    },
146}
147
148/// Host-owned target extension composed into shared ledger transactions.
149///
150/// Implementations must use only the supplied connection. Reservation and
151/// submission lock the target before calling [`host_charge_ledger_admission`].
152#[async_trait]
153pub trait HostChargeTargetStore: Send + Sync {
154    /// Locks and snapshots the target for replay/conflict preflight without
155    /// changing host business state.
156    async fn preflight_target(
157        &self,
158        connection: &mut PgConnection,
159        reservation: &HostChargeTargetReservation,
160    ) -> Result<HostChargeReservationDecision, HostChargeTargetError>;
161
162    async fn reserve_target(
163        &self,
164        connection: &mut PgConnection,
165        reservation: &HostChargeTargetReservation,
166    ) -> Result<HostChargeReservationDecision, HostChargeTargetError>;
167
168    async fn admit_submission(
169        &self,
170        connection: &mut PgConnection,
171        admission: &HostChargeSubmissionAdmission,
172    ) -> Result<HostChargeSubmissionDecision, HostChargeTargetError>;
173
174    async fn apply_transition(
175        &self,
176        connection: &mut PgConnection,
177        transition: HostChargeTargetTransition,
178    ) -> Result<HostChargeTargetTransitionOutcome, HostChargeTargetError>;
179}
180
181#[derive(Debug, Error)]
182pub enum HostChargeStoreError {
183    #[error("host charge storage operation failed")]
184    Sql(#[from] sqlx::Error),
185    #[error("host charge payment-attempt operation failed")]
186    Attempt(#[from] crate::attempts::PaymentAttemptStoreError),
187    #[error(transparent)]
188    Target(#[from] HostChargeTargetError),
189    #[error("canonical host charge state is invalid")]
190    InvalidState,
191}
192
193#[derive(Clone, Debug, Eq, PartialEq)]
194pub enum HostChargePreflightOutcome {
195    Continue(HostChargeTargetSnapshot),
196    Replay(Box<PaymentAttempt>),
197    IdempotencyConflict,
198    Rejected { reason: HostChargeTargetRejection },
199}
200
201#[derive(Clone, Debug, Eq, PartialEq)]
202pub enum HostChargeReservationOutcome {
203    Reserved(PaymentAttempt),
204    Replay(PaymentAttempt),
205    IdempotencyConflict,
206    Rejected { reason: HostChargeTargetRejection },
207}
208
209#[derive(Clone, Debug, Eq, PartialEq)]
210pub enum HostChargeSubmissionOutcome {
211    Admitted(PaymentAttempt),
212    AlreadyAdmitted(PaymentAttempt),
213    Rejected {
214        attempt: PaymentAttempt,
215        reason: HostChargeTargetRejection,
216    },
217}
218
219pub async fn preflight_host_charge_in_transaction(
220    transaction: &mut Transaction<'_, Postgres>,
221    targets: &dyn HostChargeTargetStore,
222    command: &ChargeHostTarget,
223) -> Result<HostChargePreflightOutcome, HostChargeStoreError> {
224    crate::attempts::set_enrollment_timeouts(transaction).await?;
225    let target_reservation = HostChargeTargetReservation::new(
226        command.billing_scope_id(),
227        command.subscriber_id(),
228        command.target_id(),
229        command.idempotency_key().clone(),
230    );
231    let decision = targets
232        .preflight_target(transaction, &target_reservation)
233        .await?;
234    let existing = crate::lock_payment_attempt_by_idempotency_in_transaction(
235        transaction,
236        command.billing_scope_id(),
237        command.subscriber_id(),
238        command.idempotency_key(),
239    )
240    .await?;
241    let snapshot = match decision {
242        HostChargeReservationDecision::Reserved(snapshot) => snapshot,
243        HostChargeReservationDecision::IdempotentContender => {
244            let Some(existing) = existing else {
245                return Err(HostChargeStoreError::InvalidState);
246            };
247            return Ok(
248                if host_charge_attempt_matches_command(&existing, command, None) {
249                    HostChargePreflightOutcome::Replay(Box::new(existing))
250                } else {
251                    HostChargePreflightOutcome::IdempotencyConflict
252                },
253            );
254        }
255        HostChargeReservationDecision::Rejected { reason } => {
256            return Ok(match existing {
257                Some(existing) if host_charge_attempt_matches_command(&existing, command, None) => {
258                    HostChargePreflightOutcome::Replay(Box::new(existing))
259                }
260                Some(_) => HostChargePreflightOutcome::IdempotencyConflict,
261                None => HostChargePreflightOutcome::Rejected { reason },
262            });
263        }
264    };
265    let Some(existing) = existing else {
266        return Ok(HostChargePreflightOutcome::Continue(snapshot));
267    };
268    Ok(
269        if host_charge_attempt_matches_command(&existing, command, Some(snapshot)) {
270            HostChargePreflightOutcome::Replay(Box::new(existing))
271        } else {
272            HostChargePreflightOutcome::IdempotencyConflict
273        },
274    )
275}
276
277pub async fn reserve_host_charge_in_transaction(
278    transaction: &mut Transaction<'_, Postgres>,
279    targets: &dyn HostChargeTargetStore,
280    reservation: &HostChargeReservation,
281) -> Result<HostChargeReservationOutcome, HostChargeStoreError> {
282    crate::attempts::set_enrollment_timeouts(transaction).await?;
283    let identity = reservation.identity();
284    let request = reservation.request();
285    let target_id = request
286        .target()
287        .host_charge_target_id()
288        .ok_or(HostChargeStoreError::InvalidState)?;
289    let target_reservation = HostChargeTargetReservation::new(
290        identity.billing_scope_id(),
291        identity.subscriber_id(),
292        target_id,
293        request.idempotency_key().clone(),
294    );
295    let decision = targets
296        .reserve_target(transaction, &target_reservation)
297        .await?;
298    let snapshot = match decision {
299        HostChargeReservationDecision::Reserved(snapshot) => snapshot,
300        HostChargeReservationDecision::IdempotentContender => {
301            let existing = crate::lock_payment_attempt_by_idempotency_in_transaction(
302                transaction,
303                identity.billing_scope_id(),
304                identity.subscriber_id(),
305                request.idempotency_key(),
306            )
307            .await?
308            .ok_or(HostChargeStoreError::InvalidState)?;
309            return Ok(
310                if host_charge_attempt_matches_reservation(&existing, reservation) {
311                    HostChargeReservationOutcome::Replay(existing)
312                } else {
313                    HostChargeReservationOutcome::IdempotencyConflict
314                },
315            );
316        }
317        HostChargeReservationDecision::Rejected { reason } => {
318            return Ok(HostChargeReservationOutcome::Rejected { reason });
319        }
320    };
321    if snapshot != reservation.snapshot() {
322        return Ok(HostChargeReservationOutcome::Rejected {
323            reason: HostChargeTargetRejection::ChargeChanged,
324        });
325    }
326
327    let inserted = sqlx::query(
328        r#"
329        INSERT INTO billing_payment_attempts (
330            id, billing_scope_id, subscriber_id, host_charge_target_id,
331            attempt_kind, status, idempotency_key, request_fingerprint,
332            amount_cents, currency, gateway_account_id,
333            gateway_configuration_id, gateway_order_id, billing_name, billing_email
334        ) VALUES (
335            $1, $2, $3, $4, 'host_charge', 'pending', $5, $6,
336            $7, $8, $9, $10, $11, $12, $13
337        )
338        ON CONFLICT (billing_scope_id, subscriber_id, idempotency_key) DO NOTHING
339        "#,
340    )
341    .bind(identity.attempt_id().as_uuid())
342    .bind(identity.billing_scope_id().as_uuid())
343    .bind(identity.subscriber_id().as_uuid())
344    .bind(target_id.as_uuid())
345    .bind(request.idempotency_key().expose())
346    .bind(request.fingerprint().expose())
347    .bind(request.amount().cents())
348    .bind(request.amount().currency().as_str())
349    .bind(identity.gateway_account_id().as_uuid())
350    .bind(identity.gateway_configuration_id().as_uuid())
351    .bind(request.gateway_order_id().expose())
352    .bind(request.billing_contact().name())
353    .bind(request.billing_contact().email())
354    .execute(&mut **transaction)
355    .await?;
356    let attempt_id = if inserted.rows_affected() == 1 {
357        identity.attempt_id()
358    } else {
359        crate::lock_payment_attempt_by_idempotency_in_transaction(
360            transaction,
361            identity.billing_scope_id(),
362            identity.subscriber_id(),
363            request.idempotency_key(),
364        )
365        .await?
366        .ok_or(HostChargeStoreError::InvalidState)?
367        .identity()
368        .attempt_id()
369    };
370    let attempt = crate::find_payment_attempt_by_id_in_transaction(
371        transaction,
372        identity.billing_scope_id(),
373        attempt_id,
374    )
375    .await?
376    .ok_or(HostChargeStoreError::InvalidState)?;
377    if !host_charge_attempt_matches_reservation(&attempt, reservation) {
378        return Ok(HostChargeReservationOutcome::IdempotencyConflict);
379    }
380    Ok(if inserted.rows_affected() == 1 {
381        HostChargeReservationOutcome::Reserved(attempt)
382    } else {
383        HostChargeReservationOutcome::Replay(attempt)
384    })
385}
386
387pub async fn admit_host_charge_submission_in_transaction(
388    transaction: &mut Transaction<'_, Postgres>,
389    targets: &dyn HostChargeTargetStore,
390    reservation: &HostChargeReservation,
391) -> Result<HostChargeSubmissionOutcome, HostChargeStoreError> {
392    crate::attempts::set_enrollment_timeouts(transaction).await?;
393    let identity = reservation.identity();
394    let target_id = reservation.snapshot().target_id();
395    let admission = HostChargeSubmissionAdmission::new(
396        identity.billing_scope_id(),
397        identity.subscriber_id(),
398        target_id,
399        identity.attempt_id(),
400        reservation.snapshot().charge(),
401    );
402    let decision = targets.admit_submission(transaction, &admission).await?;
403    let rejection = match decision {
404        HostChargeSubmissionDecision::Admitted(snapshot) if snapshot == reservation.snapshot() => {
405            None
406        }
407        HostChargeSubmissionDecision::Admitted(_) => Some(HostChargeTargetRejection::ChargeChanged),
408        HostChargeSubmissionDecision::Rejected { reason } => Some(reason),
409    };
410    if let Some(reason) = rejection {
411        let updated = sqlx::query(
412            r#"
413            UPDATE billing_payment_attempts
414            SET status = 'failed',
415                gateway_response_text = 'Host target changed before payment submission.',
416                gateway_condition = 'failed', resolved_at = clock_timestamp(),
417                updated_at = clock_timestamp()
418            WHERE id = $1 AND billing_scope_id = $2 AND subscriber_id = $3
419                AND host_charge_target_id = $4 AND attempt_kind = 'host_charge'
420                AND status = 'pending' AND submitted_at IS NULL
421            "#,
422        )
423        .bind(identity.attempt_id().as_uuid())
424        .bind(identity.billing_scope_id().as_uuid())
425        .bind(identity.subscriber_id().as_uuid())
426        .bind(target_id.as_uuid())
427        .execute(&mut **transaction)
428        .await?;
429        let attempt = crate::find_payment_attempt_by_id_in_transaction(
430            transaction,
431            identity.billing_scope_id(),
432            identity.attempt_id(),
433        )
434        .await?
435        .ok_or(HostChargeStoreError::InvalidState)?;
436        return Ok(if updated.rows_affected() == 1 {
437            HostChargeSubmissionOutcome::Rejected { attempt, reason }
438        } else {
439            HostChargeSubmissionOutcome::AlreadyAdmitted(attempt)
440        });
441    }
442    let updated = sqlx::query(
443        r#"
444        UPDATE billing_payment_attempts
445        SET submitted_at = clock_timestamp(), updated_at = clock_timestamp()
446        WHERE id = $1 AND billing_scope_id = $2 AND subscriber_id = $3
447            AND host_charge_target_id = $4 AND attempt_kind = 'host_charge'
448            AND status = 'pending' AND submitted_at IS NULL
449        "#,
450    )
451    .bind(identity.attempt_id().as_uuid())
452    .bind(identity.billing_scope_id().as_uuid())
453    .bind(identity.subscriber_id().as_uuid())
454    .bind(target_id.as_uuid())
455    .execute(&mut **transaction)
456    .await?;
457    let attempt = crate::find_payment_attempt_by_id_in_transaction(
458        transaction,
459        identity.billing_scope_id(),
460        identity.attempt_id(),
461    )
462    .await?
463    .ok_or(HostChargeStoreError::InvalidState)?;
464    Ok(if updated.rows_affected() == 1 {
465        HostChargeSubmissionOutcome::Admitted(attempt)
466    } else {
467        HostChargeSubmissionOutcome::AlreadyAdmitted(attempt)
468    })
469}
470
471fn host_charge_attempt_matches_command(
472    attempt: &PaymentAttempt,
473    command: &ChargeHostTarget,
474    snapshot: Option<HostChargeTargetSnapshot>,
475) -> bool {
476    let identity = attempt.identity();
477    let request = attempt.request();
478    let canonical_fingerprint =
479        PaymentAttemptFingerprint::for_host_charge(command.target_id(), request.amount());
480    attempt.kind() == PaymentAttemptKind::HostCharge
481        && identity.billing_scope_id() == command.billing_scope_id()
482        && identity.subscriber_id() == command.subscriber_id()
483        && identity.gateway_configuration_id() == command.gateway_configuration_id()
484        && request.target().host_charge_target_id() == Some(command.target_id())
485        && request.fingerprint() == &canonical_fingerprint
486        && snapshot.is_none_or(|snapshot| {
487            request.amount() == snapshot.charge().money()
488                && request.fingerprint()
489                    == &PaymentAttemptFingerprint::for_host_charge(
490                        command.target_id(),
491                        snapshot.charge().money(),
492                    )
493        })
494}
495
496fn host_charge_attempt_matches_reservation(
497    attempt: &PaymentAttempt,
498    reservation: &HostChargeReservation,
499) -> bool {
500    let identity = attempt.identity();
501    let requested_identity = reservation.identity();
502    let request = attempt.request();
503    let requested = reservation.request();
504    attempt.kind() == PaymentAttemptKind::HostCharge
505        && identity.billing_scope_id() == requested_identity.billing_scope_id()
506        && identity.subscriber_id() == requested_identity.subscriber_id()
507        && identity.gateway_account_id() == requested_identity.gateway_account_id()
508        && identity.gateway_configuration_id() == requested_identity.gateway_configuration_id()
509        && request.target() == requested.target()
510        && request.idempotency_key() == requested.idempotency_key()
511        && request.fingerprint() == requested.fingerprint()
512        && request.amount() == requested.amount()
513}
514
515#[derive(Clone, Debug, Eq, PartialEq)]
516pub enum HostChargeLedgerAdmissionMode {
517    Reserve { idempotency_key: IdempotencyKey },
518    Submit { attempt_id: PaymentAttemptId },
519    Release,
520}
521
522#[derive(Clone, Debug, Eq, PartialEq)]
523pub struct HostChargeLedgerAdmissionQuery {
524    billing_scope_id: BillingScopeId,
525    subscriber_id: SubscriberId,
526    target_id: HostChargeTargetId,
527    mode: HostChargeLedgerAdmissionMode,
528}
529
530impl HostChargeLedgerAdmissionQuery {
531    pub const fn new(
532        billing_scope_id: BillingScopeId,
533        subscriber_id: SubscriberId,
534        target_id: HostChargeTargetId,
535        mode: HostChargeLedgerAdmissionMode,
536    ) -> Self {
537        Self {
538            billing_scope_id,
539            subscriber_id,
540            target_id,
541            mode,
542        }
543    }
544
545    pub const fn billing_scope_id(&self) -> BillingScopeId {
546        self.billing_scope_id
547    }
548
549    pub const fn subscriber_id(&self) -> SubscriberId {
550        self.subscriber_id
551    }
552
553    pub const fn target_id(&self) -> HostChargeTargetId {
554        self.target_id
555    }
556
557    pub const fn mode(&self) -> &HostChargeLedgerAdmissionMode {
558        &self.mode
559    }
560}
561
562#[derive(Clone, Copy, Debug, Eq, PartialEq)]
563pub enum HostChargeLedgerAdmission {
564    Safe,
565    IdempotentContender,
566    Unsafe,
567}
568
569#[derive(Debug, Error)]
570pub enum HostChargeLedgerAdmissionError {
571    #[error("host charge ledger admission query failed")]
572    Sql(#[from] sqlx::Error),
573    #[error("host charge ledger admission returned an invalid result")]
574    InvalidResult,
575}
576
577pub async fn host_charge_ledger_admission(
578    connection: &mut PgConnection,
579    query: &HostChargeLedgerAdmissionQuery,
580) -> Result<HostChargeLedgerAdmission, HostChargeLedgerAdmissionError> {
581    let (mode, idempotency_key, attempt_id) = match query.mode() {
582        HostChargeLedgerAdmissionMode::Reserve { idempotency_key } => {
583            ("reserve", Some(idempotency_key.expose()), None)
584        }
585        HostChargeLedgerAdmissionMode::Submit { attempt_id } => {
586            ("submit", None, Some(attempt_id.into_uuid()))
587        }
588        HostChargeLedgerAdmissionMode::Release => ("release", None, None),
589    };
590    let result: String = sqlx::query_scalar(
591        r#"
592        SELECT billing_host_charge_ledger_admission($1, $2, $3, $4, $5, $6)
593        "#,
594    )
595    .bind(query.billing_scope_id().into_uuid())
596    .bind(query.subscriber_id().into_uuid())
597    .bind(query.target_id().into_uuid())
598    .bind(mode)
599    .bind(idempotency_key)
600    .bind(attempt_id)
601    .fetch_one(connection)
602    .await?;
603
604    match result.as_str() {
605        "safe" => Ok(HostChargeLedgerAdmission::Safe),
606        "idempotent_contender" => Ok(HostChargeLedgerAdmission::IdempotentContender),
607        "unsafe" => Ok(HostChargeLedgerAdmission::Unsafe),
608        _ => Err(HostChargeLedgerAdmissionError::InvalidResult),
609    }
610}
611
612#[cfg(test)]
613mod tests {
614    use std::error::Error;
615
616    use uuid::Uuid;
617
618    use super::*;
619    use crate::test_support::{TestDatabase, create_gateway_account};
620
621    #[tokio::test]
622    async fn admission_distinguishes_safe_contender_and_unsafe_modes() -> Result<(), Box<dyn Error>>
623    {
624        let database = TestDatabase::start("rail_host_admit").await?;
625        let result = async {
626            let gateway = create_gateway_account(&database.pool, "test_gateway").await?;
627            let subscriber_id = SubscriberId::new(Uuid::now_v7());
628            let target_id = HostChargeTargetId::new(Uuid::now_v7());
629            let idempotency_key = IdempotencyKey::new("host-charge-test")?;
630
631            let mut connection = database.pool.acquire().await?;
632            let reserve = HostChargeLedgerAdmissionQuery::new(
633                BillingScopeId::new(gateway.billing_scope_id),
634                subscriber_id,
635                target_id,
636                HostChargeLedgerAdmissionMode::Reserve {
637                    idempotency_key: idempotency_key.clone(),
638                },
639            );
640            assert_eq!(
641                host_charge_ledger_admission(&mut connection, &reserve).await?,
642                HostChargeLedgerAdmission::Safe
643            );
644
645            let attempt_id = PaymentAttemptId::new(Uuid::now_v7());
646            sqlx::query(
647                r#"
648                INSERT INTO billing_payment_attempts (
649                    id, billing_scope_id, subscriber_id, host_charge_target_id,
650                    attempt_kind, status, idempotency_key, request_fingerprint,
651                    amount_cents, currency, gateway_account_id,
652                    gateway_configuration_id, gateway_order_id
653                ) VALUES (
654                    $1, $2, $3, $4, 'host_charge', 'pending', $5, $6,
655                    100, 'USD', $7, $8, 'host-charge-test-order'
656                )
657                "#,
658            )
659            .bind(attempt_id.as_uuid())
660            .bind(gateway.billing_scope_id)
661            .bind(subscriber_id.as_uuid())
662            .bind(target_id.as_uuid())
663            .bind(idempotency_key.expose())
664            .bind(format!("host_charge:{}:100:USD", target_id.as_uuid()))
665            .bind(gateway.gateway_account_id)
666            .bind(gateway.gateway_configuration_id)
667            .execute(&mut *connection)
668            .await?;
669
670            assert_eq!(
671                host_charge_ledger_admission(&mut connection, &reserve).await?,
672                HostChargeLedgerAdmission::IdempotentContender
673            );
674            let submit = HostChargeLedgerAdmissionQuery::new(
675                BillingScopeId::new(gateway.billing_scope_id),
676                subscriber_id,
677                target_id,
678                HostChargeLedgerAdmissionMode::Submit { attempt_id },
679            );
680            assert_eq!(
681                host_charge_ledger_admission(&mut connection, &submit).await?,
682                HostChargeLedgerAdmission::Safe
683            );
684            let release = HostChargeLedgerAdmissionQuery::new(
685                BillingScopeId::new(gateway.billing_scope_id),
686                subscriber_id,
687                target_id,
688                HostChargeLedgerAdmissionMode::Release,
689            );
690            assert_eq!(
691                host_charge_ledger_admission(&mut connection, &release).await?,
692                HostChargeLedgerAdmission::Unsafe
693            );
694            Ok::<_, Box<dyn Error>>(())
695        }
696        .await;
697        let cleanup = database.cleanup().await;
698        result?;
699        cleanup
700    }
701}