1use std::time::Duration;
2
3use chrono::{DateTime, Utc};
4use sqlx::{PgPool, Row};
5use syrup_rail::{
6 ActorId, BillingScopeId, GatewayAccountId, GatewayLifecycleAccount,
7 GatewayLifecycleQuarantineReason, GatewayLifecycleQuarantineResolutionReason,
8};
9use uuid::Uuid;
10
11use crate::lifecycle_reconciliation::{
12 GatewayLifecycleReconciliationError, ensure_account, set_timeouts,
13};
14
15const MAX_REVIEW_PAGE_SIZE: i64 = 101;
16const INVALID_QUARANTINE_REASON: &str = "canonical gateway lifecycle quarantine reason is invalid";
17const INVALID_QUARANTINE_HISTORY: &str =
18 "canonical gateway lifecycle quarantine resolution history is invalid";
19
20#[derive(Clone, Copy, Debug, Eq, PartialEq)]
21pub struct GatewayLifecycleQuarantineAlert {
22 unresolved_count: i64,
23 oldest_first_seen_at: DateTime<Utc>,
24 latest_last_seen_at: DateTime<Utc>,
25}
26
27impl GatewayLifecycleQuarantineAlert {
28 pub const fn unresolved_count(self) -> i64 {
29 self.unresolved_count
30 }
31
32 pub const fn oldest_first_seen_at(self) -> DateTime<Utc> {
33 self.oldest_first_seen_at
34 }
35
36 pub const fn latest_last_seen_at(self) -> DateTime<Utc> {
37 self.latest_last_seen_at
38 }
39}
40
41#[derive(Clone, Debug, Eq, PartialEq)]
42pub struct GatewayLifecycleQuarantineResolutionRecord {
43 quarantine_id: Uuid,
44 actor_id: ActorId,
45 reason: GatewayLifecycleQuarantineResolutionReason,
46 observed_occurrence_count: i64,
47 observed_last_seen_at: DateTime<Utc>,
48 resolved_at: DateTime<Utc>,
49}
50
51impl GatewayLifecycleQuarantineResolutionRecord {
52 pub const fn quarantine_id(&self) -> Uuid {
53 self.quarantine_id
54 }
55
56 pub const fn actor_id(&self) -> ActorId {
57 self.actor_id
58 }
59
60 pub const fn reason(&self) -> &GatewayLifecycleQuarantineResolutionReason {
61 &self.reason
62 }
63
64 pub const fn observed_occurrence_count(&self) -> i64 {
65 self.observed_occurrence_count
66 }
67
68 pub const fn observed_last_seen_at(&self) -> DateTime<Utc> {
69 self.observed_last_seen_at
70 }
71
72 pub const fn resolved_at(&self) -> DateTime<Utc> {
73 self.resolved_at
74 }
75}
76
77#[derive(Clone, Debug, Eq, PartialEq)]
78pub struct GatewayLifecycleQuarantineReviewRecord {
79 id: Uuid,
80 billing_scope_id: BillingScopeId,
81 gateway_account_id: GatewayAccountId,
82 transaction_id: Option<String>,
83 order_id: Option<String>,
84 reason: GatewayLifecycleQuarantineReason,
85 first_seen_at: DateTime<Utc>,
86 last_seen_at: DateTime<Utc>,
87 occurrence_count: i64,
88 resolution_count: i64,
89 latest_resolution: Option<GatewayLifecycleQuarantineResolutionRecord>,
90}
91
92impl GatewayLifecycleQuarantineReviewRecord {
93 pub const fn id(&self) -> Uuid {
94 self.id
95 }
96
97 pub const fn billing_scope_id(&self) -> BillingScopeId {
98 self.billing_scope_id
99 }
100
101 pub const fn gateway_account_id(&self) -> GatewayAccountId {
102 self.gateway_account_id
103 }
104
105 pub fn transaction_id(&self) -> Option<&str> {
107 self.transaction_id.as_deref()
108 }
109
110 pub fn order_id(&self) -> Option<&str> {
112 self.order_id.as_deref()
113 }
114
115 pub const fn reason(&self) -> GatewayLifecycleQuarantineReason {
116 self.reason
117 }
118
119 pub const fn first_seen_at(&self) -> DateTime<Utc> {
120 self.first_seen_at
121 }
122
123 pub const fn last_seen_at(&self) -> DateTime<Utc> {
124 self.last_seen_at
125 }
126
127 pub const fn occurrence_count(&self) -> i64 {
128 self.occurrence_count
129 }
130
131 pub const fn resolution_count(&self) -> i64 {
132 self.resolution_count
133 }
134
135 pub const fn latest_resolution(&self) -> Option<&GatewayLifecycleQuarantineResolutionRecord> {
136 self.latest_resolution.as_ref()
137 }
138}
139
140#[derive(Clone, Debug, Eq, PartialEq)]
141pub enum GatewayLifecycleQuarantineResolutionOutcome {
142 Resolved(GatewayLifecycleQuarantineResolutionRecord),
143 Replayed(GatewayLifecycleQuarantineResolutionRecord),
144 AlreadyResolved,
145 Stale,
146 NotFound,
147}
148
149pub async fn claim_gateway_lifecycle_quarantine_alert(
150 pool: &PgPool,
151 account: &GatewayLifecycleAccount,
152 alert_after: Duration,
153) -> Result<Option<GatewayLifecycleQuarantineAlert>, GatewayLifecycleReconciliationError> {
154 let alert_after_seconds = i64::try_from(alert_after.as_secs()).map_err(|_| {
155 GatewayLifecycleReconciliationError::InvalidState(
156 "gateway lifecycle quarantine alert cadence is too large",
157 )
158 })?;
159 let mut transaction = pool.begin().await?;
160 set_timeouts(&mut transaction).await?;
161 ensure_account(&mut transaction, account).await?;
162 let row = sqlx::query(
163 r#"
164 WITH due AS MATERIALIZED (
165 SELECT EXISTS (
166 SELECT 1
167 FROM billing_gateway_lifecycle_quarantines
168 WHERE billing_scope_id = $1
169 AND gateway_account_id = $2
170 AND resolved_at IS NULL
171 AND (
172 last_operator_alerted_at IS NULL
173 OR last_operator_alerted_at
174 <= now() - ($3::bigint * interval '1 second')
175 )
176 ) AS should_claim
177 ),
178 claimed AS (
179 UPDATE billing_gateway_lifecycle_quarantines
180 SET last_operator_alerted_at = now()
181 WHERE billing_scope_id = $1
182 AND gateway_account_id = $2
183 AND resolved_at IS NULL
184 AND (SELECT should_claim FROM due)
185 RETURNING id
186 )
187 SELECT
188 COUNT(*)::bigint AS unresolved_count,
189 MIN(first_seen_at) AS oldest_first_seen_at,
190 MAX(last_seen_at) AS latest_last_seen_at
191 FROM billing_gateway_lifecycle_quarantines
192 WHERE billing_scope_id = $1
193 AND gateway_account_id = $2
194 AND resolved_at IS NULL
195 HAVING EXISTS (SELECT 1 FROM claimed)
196 "#,
197 )
198 .bind(account.billing_scope_id().as_uuid())
199 .bind(account.gateway_account_id().as_uuid())
200 .bind(alert_after_seconds)
201 .fetch_optional(&mut *transaction)
202 .await?;
203 transaction.commit().await?;
204 row.map(|row| {
205 Ok(GatewayLifecycleQuarantineAlert {
206 unresolved_count: row.try_get("unresolved_count")?,
207 oldest_first_seen_at: row.try_get("oldest_first_seen_at")?,
208 latest_last_seen_at: row.try_get("latest_last_seen_at")?,
209 })
210 })
211 .transpose()
212}
213
214pub async fn gateway_lifecycle_quarantine_review_page(
215 pool: &PgPool,
216 limit: i64,
217 cursor: Option<(DateTime<Utc>, Uuid)>,
218) -> Result<Vec<GatewayLifecycleQuarantineReviewRecord>, GatewayLifecycleReconciliationError> {
219 if !(1..=MAX_REVIEW_PAGE_SIZE).contains(&limit) {
220 return Err(GatewayLifecycleReconciliationError::InvalidState(
221 "gateway lifecycle quarantine review page size is invalid",
222 ));
223 }
224 let rows = sqlx::query(
225 r#"
226 WITH page AS (
227 SELECT quarantine.id,
228 quarantine.billing_scope_id,
229 quarantine.gateway_account_id,
230 quarantine.gateway_transaction_id,
231 quarantine.gateway_order_id,
232 quarantine.reason_code,
233 quarantine.first_seen_at,
234 quarantine.last_seen_at,
235 quarantine.occurrence_count
236 FROM billing_gateway_lifecycle_quarantines quarantine
237 WHERE quarantine.resolved_at IS NULL
238 AND (
239 $1::timestamptz IS NULL
240 OR (quarantine.first_seen_at, quarantine.id)
241 > ($1::timestamptz, $2::uuid)
242 )
243 ORDER BY quarantine.first_seen_at, quarantine.id
244 LIMIT $3
245 )
246 SELECT page.*,
247 COALESCE(history.resolution_count, 0)::bigint AS resolution_count,
248 history.actor_id AS latest_resolution_actor_id,
249 history.reason AS latest_resolution_reason,
250 history.observed_occurrence_count AS latest_observed_occurrence_count,
251 history.observed_last_seen_at AS latest_observed_last_seen_at,
252 history.resolved_at AS latest_resolved_at
253 FROM page
254 LEFT JOIN LATERAL (
255 SELECT resolution.actor_id,
256 resolution.reason,
257 resolution.observed_occurrence_count,
258 resolution.observed_last_seen_at,
259 resolution.resolved_at,
260 COUNT(*) OVER () AS resolution_count
261 FROM billing_gateway_lifecycle_quarantine_resolutions resolution
262 WHERE resolution.quarantine_id = page.id
263 ORDER BY resolution.resolved_at DESC, resolution.id DESC
264 LIMIT 1
265 ) history ON TRUE
266 ORDER BY page.first_seen_at, page.id
267 "#,
268 )
269 .bind(cursor.as_ref().map(|(first_seen_at, _)| first_seen_at))
270 .bind(cursor.as_ref().map(|(_, id)| id))
271 .bind(limit)
272 .fetch_all(pool)
273 .await?;
274 rows.into_iter().map(review_record).collect()
275}
276
277pub async fn resolve_gateway_lifecycle_quarantine(
278 pool: &PgPool,
279 quarantine_id: Uuid,
280 actor_id: ActorId,
281 expected_occurrence_count: i64,
282 expected_last_seen_at: DateTime<Utc>,
283 reason: &GatewayLifecycleQuarantineResolutionReason,
284) -> Result<GatewayLifecycleQuarantineResolutionOutcome, GatewayLifecycleReconciliationError> {
285 if expected_occurrence_count <= 0 {
286 return Err(GatewayLifecycleReconciliationError::InvalidState(
287 "gateway lifecycle quarantine observation is invalid",
288 ));
289 }
290 let mut transaction = pool.begin().await?;
291 set_timeouts(&mut transaction).await?;
292 let quarantine = sqlx::query(
293 r#"
294 SELECT occurrence_count, last_seen_at, resolved_at
295 FROM billing_gateway_lifecycle_quarantines
296 WHERE id = $1
297 FOR UPDATE
298 "#,
299 )
300 .bind(quarantine_id)
301 .fetch_optional(&mut *transaction)
302 .await?;
303 let Some(quarantine) = quarantine else {
304 transaction.commit().await?;
305 return Ok(GatewayLifecycleQuarantineResolutionOutcome::NotFound);
306 };
307 let occurrence_count: i64 = quarantine.try_get("occurrence_count")?;
308 let last_seen_at: DateTime<Utc> = quarantine.try_get("last_seen_at")?;
309 if occurrence_count != expected_occurrence_count || last_seen_at != expected_last_seen_at {
310 transaction.commit().await?;
311 return Ok(GatewayLifecycleQuarantineResolutionOutcome::Stale);
312 }
313 if quarantine
314 .try_get::<Option<DateTime<Utc>>, _>("resolved_at")?
315 .is_some()
316 {
317 let existing = sqlx::query(
318 r#"
319 SELECT quarantine_id, actor_id, reason,
320 observed_occurrence_count, observed_last_seen_at, resolved_at
321 FROM billing_gateway_lifecycle_quarantine_resolutions
322 WHERE quarantine_id = $1
323 AND observed_occurrence_count = $2
324 AND observed_last_seen_at = $3
325 "#,
326 )
327 .bind(quarantine_id)
328 .bind(expected_occurrence_count)
329 .bind(expected_last_seen_at)
330 .fetch_optional(&mut *transaction)
331 .await?;
332 transaction.commit().await?;
333 return Ok(match existing {
334 Some(existing)
335 if existing.try_get::<Uuid, _>("actor_id")? == *actor_id.as_uuid()
336 && existing.try_get::<String, _>("reason")? == reason.expose() =>
337 {
338 GatewayLifecycleQuarantineResolutionOutcome::Replayed(resolution_record(existing)?)
339 }
340 _ => GatewayLifecycleQuarantineResolutionOutcome::AlreadyResolved,
341 });
342 }
343 let resolution = sqlx::query(
344 r#"
345 INSERT INTO billing_gateway_lifecycle_quarantine_resolutions (
346 quarantine_id, actor_id, reason,
347 observed_occurrence_count, observed_last_seen_at
348 ) VALUES ($1, $2, $3, $4, $5)
349 RETURNING quarantine_id, actor_id, reason,
350 observed_occurrence_count, observed_last_seen_at, resolved_at
351 "#,
352 )
353 .bind(quarantine_id)
354 .bind(actor_id.as_uuid())
355 .bind(reason.expose())
356 .bind(expected_occurrence_count)
357 .bind(expected_last_seen_at)
358 .fetch_one(&mut *transaction)
359 .await?;
360 let resolved_at: DateTime<Utc> = resolution.try_get("resolved_at")?;
361 let updated = sqlx::query(
362 r#"
363 UPDATE billing_gateway_lifecycle_quarantines
364 SET resolved_at = $2
365 WHERE id = $1
366 AND resolved_at IS NULL
367 AND occurrence_count = $3
368 AND last_seen_at = $4
369 "#,
370 )
371 .bind(quarantine_id)
372 .bind(resolved_at)
373 .bind(expected_occurrence_count)
374 .bind(expected_last_seen_at)
375 .execute(&mut *transaction)
376 .await?;
377 if updated.rows_affected() != 1 {
378 return Err(GatewayLifecycleReconciliationError::InvalidState(
379 "locked gateway lifecycle quarantine did not accept its operator resolution",
380 ));
381 }
382 transaction.commit().await?;
383 Ok(GatewayLifecycleQuarantineResolutionOutcome::Resolved(
384 resolution_record(resolution)?,
385 ))
386}
387
388fn review_record(
389 row: sqlx::postgres::PgRow,
390) -> Result<GatewayLifecycleQuarantineReviewRecord, GatewayLifecycleReconciliationError> {
391 let latest_resolution = match (
392 row.try_get::<Option<Uuid>, _>("latest_resolution_actor_id")?,
393 row.try_get::<Option<String>, _>("latest_resolution_reason")?,
394 row.try_get::<Option<i64>, _>("latest_observed_occurrence_count")?,
395 row.try_get::<Option<DateTime<Utc>>, _>("latest_observed_last_seen_at")?,
396 row.try_get::<Option<DateTime<Utc>>, _>("latest_resolved_at")?,
397 ) {
398 (Some(actor_id), Some(reason), Some(count), Some(last_seen_at), Some(resolved_at)) => {
399 Some(GatewayLifecycleQuarantineResolutionRecord {
400 quarantine_id: row.try_get("id")?,
401 actor_id: ActorId::new(actor_id),
402 reason: GatewayLifecycleQuarantineResolutionReason::new(reason).map_err(|_| {
403 GatewayLifecycleReconciliationError::InvalidState(INVALID_QUARANTINE_HISTORY)
404 })?,
405 observed_occurrence_count: count,
406 observed_last_seen_at: last_seen_at,
407 resolved_at,
408 })
409 }
410 (None, None, None, None, None) => None,
411 _ => {
412 return Err(GatewayLifecycleReconciliationError::InvalidState(
413 INVALID_QUARANTINE_HISTORY,
414 ));
415 }
416 };
417 let resolution_count: i64 = row.try_get("resolution_count")?;
418 if (resolution_count == 0) != latest_resolution.is_none() {
419 return Err(GatewayLifecycleReconciliationError::InvalidState(
420 INVALID_QUARANTINE_HISTORY,
421 ));
422 }
423 Ok(GatewayLifecycleQuarantineReviewRecord {
424 id: row.try_get("id")?,
425 billing_scope_id: BillingScopeId::new(row.try_get("billing_scope_id")?),
426 gateway_account_id: GatewayAccountId::new(row.try_get("gateway_account_id")?),
427 transaction_id: row.try_get("gateway_transaction_id")?,
428 order_id: row.try_get("gateway_order_id")?,
429 reason: quarantine_reason(&row.try_get::<String, _>("reason_code")?)?,
430 first_seen_at: row.try_get("first_seen_at")?,
431 last_seen_at: row.try_get("last_seen_at")?,
432 occurrence_count: row.try_get("occurrence_count")?,
433 resolution_count,
434 latest_resolution,
435 })
436}
437
438fn resolution_record(
439 row: sqlx::postgres::PgRow,
440) -> Result<GatewayLifecycleQuarantineResolutionRecord, GatewayLifecycleReconciliationError> {
441 Ok(GatewayLifecycleQuarantineResolutionRecord {
442 quarantine_id: row.try_get("quarantine_id")?,
443 actor_id: ActorId::new(row.try_get("actor_id")?),
444 reason: GatewayLifecycleQuarantineResolutionReason::new(
445 row.try_get::<String, _>("reason")?,
446 )
447 .map_err(|_| {
448 GatewayLifecycleReconciliationError::InvalidState(INVALID_QUARANTINE_HISTORY)
449 })?,
450 observed_occurrence_count: row.try_get("observed_occurrence_count")?,
451 observed_last_seen_at: row.try_get("observed_last_seen_at")?,
452 resolved_at: row.try_get("resolved_at")?,
453 })
454}
455
456fn quarantine_reason(
457 value: &str,
458) -> Result<GatewayLifecycleQuarantineReason, GatewayLifecycleReconciliationError> {
459 match value {
460 "ambiguous_reversal_success" => {
461 Ok(GatewayLifecycleQuarantineReason::AmbiguousReversalSuccess)
462 }
463 "invalid_refund_economics" => Ok(GatewayLifecycleQuarantineReason::InvalidRefundEconomics),
464 "malformed_report_structure" => {
465 Ok(GatewayLifecycleQuarantineReason::MalformedReportStructure)
466 }
467 _ => Err(GatewayLifecycleReconciliationError::InvalidState(
468 INVALID_QUARANTINE_REASON,
469 )),
470 }
471}
472
473#[cfg(test)]
474mod tests {
475 use std::error::Error;
476
477 use super::*;
478 use crate::{
479 record_gateway_lifecycle_quarantines,
480 test_support::{TestDatabase, create_gateway_account},
481 };
482 use syrup_rail::{GatewayLifecycleQuarantine, GatewayProviderKey};
483
484 #[tokio::test]
485 async fn alert_review_resolution_replay_and_reopen_are_exact_and_auditable()
486 -> Result<(), Box<dyn Error>> {
487 let database = TestDatabase::start("rail_quarantine").await?;
488 let fixture = create_gateway_account(&database.pool, "nmi").await?;
489 let account = GatewayLifecycleAccount::new(
490 BillingScopeId::new(fixture.billing_scope_id),
491 GatewayAccountId::new(fixture.gateway_account_id),
492 GatewayProviderKey::new("nmi")?,
493 );
494 let quarantine = GatewayLifecycleQuarantine::new(
495 Some(syrup_rail::GatewayTransactionId::new("txn-review")?),
496 None,
497 GatewayLifecycleQuarantineReason::MalformedReportStructure,
498 )?;
499 record_gateway_lifecycle_quarantines(
500 &database.pool,
501 &account,
502 std::slice::from_ref(&quarantine),
503 )
504 .await?;
505
506 let alert = claim_gateway_lifecycle_quarantine_alert(
507 &database.pool,
508 &account,
509 Duration::from_secs(3_600),
510 )
511 .await?
512 .expect("first unresolved alert is due");
513 assert_eq!(alert.unresolved_count(), 1);
514 assert!(
515 claim_gateway_lifecycle_quarantine_alert(
516 &database.pool,
517 &account,
518 Duration::from_secs(3_600),
519 )
520 .await?
521 .is_none()
522 );
523
524 let page = gateway_lifecycle_quarantine_review_page(&database.pool, 10, None).await?;
525 assert_eq!(page.len(), 1);
526 let incident = &page[0];
527 assert_eq!(incident.billing_scope_id(), account.billing_scope_id());
528 assert_eq!(incident.gateway_account_id(), account.gateway_account_id());
529 assert_eq!(incident.transaction_id(), Some("txn-review"));
530 assert_eq!(
531 incident.reason(),
532 GatewayLifecycleQuarantineReason::MalformedReportStructure
533 );
534 assert_eq!(incident.resolution_count(), 0);
535
536 let actor = ActorId::new(Uuid::now_v7());
537 let reason = GatewayLifecycleQuarantineResolutionReason::new("provider report reviewed")?;
538 let resolved = resolve_gateway_lifecycle_quarantine(
539 &database.pool,
540 incident.id(),
541 actor,
542 incident.occurrence_count(),
543 incident.last_seen_at(),
544 &reason,
545 )
546 .await?;
547 let GatewayLifecycleQuarantineResolutionOutcome::Resolved(resolution) = resolved else {
548 panic!("expected a new resolution");
549 };
550 assert_eq!(resolution.actor_id(), actor);
551 assert_eq!(resolution.reason(), &reason);
552 assert!(
553 gateway_lifecycle_quarantine_review_page(&database.pool, 10, None)
554 .await?
555 .is_empty()
556 );
557
558 assert!(matches!(
559 resolve_gateway_lifecycle_quarantine(
560 &database.pool,
561 incident.id(),
562 actor,
563 incident.occurrence_count(),
564 incident.last_seen_at(),
565 &reason,
566 )
567 .await?,
568 GatewayLifecycleQuarantineResolutionOutcome::Replayed(_)
569 ));
570
571 record_gateway_lifecycle_quarantines(&database.pool, &account, &[quarantine]).await?;
572 let reopened = gateway_lifecycle_quarantine_review_page(&database.pool, 10, None).await?;
573 assert_eq!(reopened.len(), 1);
574 assert_eq!(reopened[0].resolution_count(), 1);
575 assert_eq!(reopened[0].latest_resolution(), Some(&resolution));
576 assert!(matches!(
577 resolve_gateway_lifecycle_quarantine(
578 &database.pool,
579 reopened[0].id(),
580 actor,
581 incident.occurrence_count(),
582 incident.last_seen_at(),
583 &reason,
584 )
585 .await?,
586 GatewayLifecycleQuarantineResolutionOutcome::Stale
587 ));
588
589 let wrong_provider = GatewayLifecycleAccount::new(
590 account.billing_scope_id(),
591 account.gateway_account_id(),
592 GatewayProviderKey::new("other")?,
593 );
594 assert!(matches!(
595 claim_gateway_lifecycle_quarantine_alert(
596 &database.pool,
597 &wrong_provider,
598 Duration::ZERO,
599 )
600 .await,
601 Err(GatewayLifecycleReconciliationError::AccountNotFound)
602 ));
603
604 database.cleanup().await?;
605 Ok(())
606 }
607}