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#[async_trait]
153pub trait HostChargeTargetStore: Send + Sync {
154 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}