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#[derive(Debug)]
23pub struct HostChargeTargetError {
24 source: RedactedHostErrorSource,
25}
26
27impl HostChargeTargetError {
28 pub fn new(source: impl Error + Send + Sync + 'static) -> Self {
31 Self {
32 source: RedactedHostErrorSource::new(source),
33 }
34 }
35
36 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 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#[async_trait]
163pub trait HostChargeTargetStore: Send + Sync {
164 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 async fn ensure_submission_admitted(
196 &self,
197 connection: &mut PgConnection,
198 admission: &HostChargeSubmissionAdmission,
199 ) -> Result<HostChargeSubmissionDecision, HostChargeTargetError>;
200
201 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 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 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 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}