syrup_rail_postgres/operator_review/
manual_failure.rs1use super::*;
2
3#[derive(Debug)]
5pub struct ManualAttemptFailureHostStoreError {
6 source: RedactedHostErrorSource,
7}
8
9impl ManualAttemptFailureHostStoreError {
10 pub fn new(source: impl Error + Send + Sync + 'static) -> Self {
13 Self {
14 source: RedactedHostErrorSource::new(source),
15 }
16 }
17
18 pub fn into_source(self) -> BoxError {
20 self.source.into_inner()
21 }
22}
23
24impl fmt::Display for ManualAttemptFailureHostStoreError {
25 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
26 formatter.write_str("manual attempt failure host target transition failed")
27 }
28}
29
30impl Error for ManualAttemptFailureHostStoreError {}
31
32#[derive(Clone, Copy, Debug, Eq, PartialEq)]
33pub enum ManualAttemptFailureHostTransitionOutcome {
34 Changed,
35 Unchanged,
36}
37
38#[async_trait]
39pub trait ManualAttemptFailureHostStore: Send + Sync {
40 async fn lock_payment_failure_target(
41 &self,
42 connection: &mut PgConnection,
43 charge: ManualFailureHostCharge,
44 ) -> Result<(), ManualAttemptFailureHostStoreError>;
45
46 async fn mark_payment_failed(
47 &self,
48 connection: &mut PgConnection,
49 charge: ManualFailureHostCharge,
50 ) -> Result<ManualAttemptFailureHostTransitionOutcome, ManualAttemptFailureHostStoreError>;
51}
52
53pub async fn fail_review_required_attempt(
54 pool: &PgPool,
55 coordinator: &dyn BillingTransactionCoordinator,
56 host: &dyn ManualAttemptFailureHostStore,
57 attempt_id: PaymentAttemptId,
58) -> Result<ManualAttemptFailureOutcome, OperatorReviewError> {
59 let Some(preloaded) = payment_attempt_by_id(pool, attempt_id).await? else {
60 return Ok(ManualAttemptFailureOutcome::NotFound);
61 };
62 if !review_required_attempt_can_be_manually_failed(&preloaded) {
63 return Ok(ManualAttemptFailureOutcome::KeptOpen(preloaded));
64 }
65
66 let identity = preloaded.identity();
67 let subject = BillingEventSubject::new(identity.billing_scope_id(), identity.subscriber_id());
68 let mut transaction = coordinator
69 .begin(subject, std::time::Duration::from_millis(250))
70 .await?;
71 let result = async {
72 let connection = transaction.connection();
73 set_enrollment_timeouts(connection).await?;
74 let target = preloaded.request().target();
75 if let Some(plan_key) = target.plan_key() {
76 lock_subscription_aggregate(connection, identity.subscriber_id(), plan_key).await?;
77 } else if let Some(target_id) = target.host_charge_target_id() {
78 host.lock_payment_failure_target(
79 connection,
80 ManualFailureHostCharge::new(
81 identity.billing_scope_id(),
82 identity.subscriber_id(),
83 target_id,
84 ),
85 )
86 .await?;
87 }
88
89 let Some(current) = lock_payment_attempt_by_id_on_connection(
90 connection,
91 identity.billing_scope_id(),
92 attempt_id,
93 )
94 .await?
95 else {
96 return Ok((ManualAttemptFailureOutcome::NotFound, Vec::new()));
97 };
98 if current.identity() != preloaded.identity() || current.request() != preloaded.request() {
99 return Err(OperatorReviewError::InvalidState(INVALID_OPERATOR_STATE));
100 }
101 if !review_required_attempt_can_be_manually_failed(¤t)
102 || (current.kind() != PaymentAttemptKind::SubscriptionPaymentMethodUpdate
103 && unresolved_processor_charge_exists(connection, attempt_id).await?)
104 {
105 return Ok((ManualAttemptFailureOutcome::KeptOpen(current), Vec::new()));
106 }
107
108 let evidence = review_required_manual_failure_evidence(¤t);
109 let updated = update_attempt_for_manual_failure(connection, ¤t, &evidence).await?;
110 if updated != 1 {
111 return Err(OperatorReviewError::InvalidState(INVALID_OPERATOR_STATE));
112 }
113
114 if let Some(target_id) = current.request().target().host_charge_target_id() {
115 host.mark_payment_failed(
116 connection,
117 ManualFailureHostCharge::new(
118 identity.billing_scope_id(),
119 identity.subscriber_id(),
120 target_id,
121 ),
122 )
123 .await?;
124 }
125 let attempt = lock_payment_attempt_by_id_on_connection(
126 connection,
127 identity.billing_scope_id(),
128 attempt_id,
129 )
130 .await?
131 .ok_or(OperatorReviewError::InvalidState(INVALID_OPERATOR_STATE))?;
132 let events = if attempt.kind() == PaymentAttemptKind::SubscriptionRenewal
133 && attempt.state().timestamps().submitted_at().is_some()
134 && attempt.state().resolution_code().is_none()
135 {
136 match apply_resolved_automatic_renewal_failure(connection, &attempt).await? {
137 RenewalFailureApplication::Applied { events, .. } => events,
138 RenewalFailureApplication::Noop => Vec::new(),
139 }
140 } else {
141 Vec::new()
142 };
143 Ok((ManualAttemptFailureOutcome::Failed(attempt), events))
144 }
145 .await;
146
147 let (outcome, events) = match result {
148 Ok(value) => value,
149 Err(error) => {
150 let _ = transaction.rollback().await;
151 return Err(error);
152 }
153 };
154 for event in &events {
155 if let Err(error) = transaction.append_event(event).await {
156 let _ = transaction.rollback().await;
157 return Err(error.into());
158 }
159 }
160 transaction.commit().await?;
161 Ok(outcome)
162}
163
164async fn payment_attempt_by_id(
165 pool: &PgPool,
166 attempt_id: PaymentAttemptId,
167) -> Result<Option<PaymentAttempt>, OperatorReviewError> {
168 let query = format!("{} WHERE id = $1", crate::attempts::PAYMENT_ATTEMPT_SELECT);
169 let row = sqlx::query(&query)
170 .bind(attempt_id.as_uuid())
171 .fetch_optional(pool)
172 .await?;
173 row.as_ref()
174 .map(payment_attempt_from_row)
175 .transpose()
176 .map_err(Into::into)
177}
178
179async fn unresolved_processor_charge_exists(
180 connection: &mut PgConnection,
181 attempt_id: PaymentAttemptId,
182) -> Result<bool, sqlx::Error> {
183 sqlx::query_scalar(
184 r#"
185 SELECT EXISTS (
186 SELECT 1
187 FROM billing_processor_charges
188 WHERE attempt_id = $1
189 AND progression_state IN (
190 'pending', 'reconciliation_required', 'external_reversal_required'
191 )
192 )
193 "#,
194 )
195 .bind(attempt_id.as_uuid())
196 .fetch_one(&mut *connection)
197 .await
198}
199
200async fn update_attempt_for_manual_failure(
201 connection: &mut PgConnection,
202 attempt: &PaymentAttempt,
203 evidence: &ProcessorEvidence,
204) -> Result<u64, sqlx::Error> {
205 let descriptor = evidence.descriptor();
206 let result = sqlx::query(
207 r#"
208 UPDATE billing_payment_attempts
209 SET status = 'failed',
210 gateway_transaction_id = $2,
211 gateway_payment_method_reference = $3,
212 gateway_response = $4,
213 gateway_response_code = $5,
214 gateway_response_text = $6,
215 gateway_condition = $7,
216 payment_type = $8,
217 card_brand = $9,
218 card_last4 = $10,
219 card_exp_month = $11,
220 card_exp_year = $12,
221 resolved_at = clock_timestamp(),
222 updated_at = clock_timestamp()
223 WHERE id = $1 AND status = 'review_required'
224 "#,
225 )
226 .bind(attempt.identity().attempt_id().as_uuid())
227 .bind(evidence.transaction_id().map(GatewayTransactionId::expose))
228 .bind(
229 evidence
230 .payment_method_reference()
231 .map(GatewayPaymentMethodReference::expose),
232 )
233 .bind(evidence.response().map(GatewayDiagnostic::expose))
234 .bind(evidence.response_code().map(GatewayDiagnostic::expose))
235 .bind(evidence.response_text().map(GatewayDiagnostic::expose))
236 .bind(evidence.condition().map(GatewayDiagnostic::expose))
237 .bind(descriptor.payment_type().map(GatewayDiagnostic::expose))
238 .bind(descriptor.card_brand().map(GatewayDiagnostic::expose))
239 .bind(
240 descriptor
241 .card_last_four()
242 .map(syrup_rail::CardLastFour::expose),
243 )
244 .bind(descriptor.card_exp_month())
245 .bind(descriptor.card_exp_year())
246 .execute(&mut *connection)
247 .await?;
248 Ok(result.rows_affected())
249}