Skip to main content

syrup_rail_postgres/
lifecycle_quarantine.rs

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    /// Persisted review evidence only; this is not a provider-command capability.
106    pub fn transaction_id(&self) -> Option<&str> {
107        self.transaction_id.as_deref()
108    }
109
110    /// Persisted review evidence only; this is not a provider-command capability.
111    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}