use chrono::{DateTime, Utc};
use sqlx::PgPool;
use syrup_rail::{
BillingScopeId, GatewayAccountId, HostChargeTargetId, HostChargeTargetTransition,
HostChargeTargetTransitionKind, PaymentAttemptId, PaymentAttemptKind, SubscriberId,
};
use uuid::Uuid;
use crate::{
attempts::LocalAttemptPolicy,
enrollment_application::set_application_timeouts,
host_charge_application::HostChargeApplicationError,
host_charges::HostChargeTargetStore,
reconciliation::{RECONCILIATION_CLAIM_RETRY_AFTER_SECONDS, RECONCILIATION_PHASE_BATCH_SIZE},
};
const STALE_UNSUBMITTED_HOST_CHARGE_TEXT: &str =
"Host charge was abandoned before gateway submission.";
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub struct StaleHostChargeCleanupSummary {
failed: u64,
skipped: u64,
}
impl StaleHostChargeCleanupSummary {
pub const fn failed(self) -> u64 {
self.failed
}
pub const fn skipped(self) -> u64 {
self.skipped
}
}
#[derive(Clone, Copy)]
struct StaleHostChargeCandidate {
attempt_id: Uuid,
billing_scope_id: Uuid,
subscriber_id: Uuid,
target_id: Uuid,
}
pub async fn fail_stale_unsubmitted_host_charges(
pool: &PgPool,
targets: &dyn HostChargeTargetStore,
gateway_account_id: GatewayAccountId,
) -> Result<StaleHostChargeCleanupSummary, HostChargeApplicationError> {
let policy = LocalAttemptPolicy::for_kind(PaymentAttemptKind::HostCharge);
let candidates = claim_stale_host_charge_candidates(pool, gateway_account_id).await?;
let mut summary = StaleHostChargeCleanupSummary::default();
for candidate in candidates {
let mut transaction = pool.begin().await?;
set_application_timeouts(&mut transaction).await?;
let effective_at: DateTime<Utc> = sqlx::query_scalar("SELECT clock_timestamp()")
.fetch_one(&mut *transaction)
.await?;
let target_outcome = targets
.apply_transition(
&mut transaction,
HostChargeTargetTransition::new(
BillingScopeId::new(candidate.billing_scope_id),
SubscriberId::new(candidate.subscriber_id),
PaymentAttemptId::new(candidate.attempt_id),
HostChargeTargetId::new(candidate.target_id),
HostChargeTargetTransitionKind::ReleasedBeforeSubmission,
effective_at,
),
)
.await?;
if !target_outcome.is_applied() {
tracing::warn!(
target: "syrup_rail::host_charge_reconciliation",
billing_scope_id = %candidate.billing_scope_id,
subscriber_id = %candidate.subscriber_id,
attempt_id = %candidate.attempt_id,
target_id = %candidate.target_id,
?target_outcome,
"host target refused stale unsubmitted charge release; leaving attempt unresolved"
);
transaction.rollback().await?;
summary.skipped += 1;
continue;
}
let result = sqlx::query(
r#"
UPDATE billing_payment_attempts
SET status = 'failed',
gateway_response_text = $6,
gateway_condition = COALESCE(gateway_condition, 'failed'),
resolved_at = COALESCE(resolved_at, clock_timestamp()),
updated_at = clock_timestamp()
WHERE id = $1 AND billing_scope_id = $2 AND subscriber_id = $3
AND host_charge_target_id = $4 AND gateway_account_id = $5
AND attempt_kind = 'host_charge'
AND status = ANY($7::text[])
AND submitted_at IS NULL
AND created_at <= clock_timestamp()
- ($8::bigint * interval '1 second')
"#,
)
.bind(candidate.attempt_id)
.bind(candidate.billing_scope_id)
.bind(candidate.subscriber_id)
.bind(candidate.target_id)
.bind(gateway_account_id.as_uuid())
.bind(STALE_UNSUBMITTED_HOST_CHARGE_TEXT)
.bind(LocalAttemptPolicy::expirable_status_values())
.bind(policy.stale_after_seconds())
.execute(&mut *transaction)
.await;
let result = match result {
Ok(result) => result,
Err(error) if is_lock_not_available(&error) => {
transaction.rollback().await?;
summary.skipped += 1;
continue;
}
Err(error) => return Err(error.into()),
};
if result.rows_affected() == 0 {
transaction.rollback().await?;
summary.skipped += 1;
continue;
}
transaction.commit().await?;
summary.failed += 1;
}
Ok(summary)
}
async fn claim_stale_host_charge_candidates(
pool: &PgPool,
gateway_account_id: GatewayAccountId,
) -> Result<Vec<StaleHostChargeCandidate>, sqlx::Error> {
let policy = LocalAttemptPolicy::for_kind(PaymentAttemptKind::HostCharge);
let mut transaction = pool.begin().await?;
set_application_timeouts(&mut transaction).await?;
let candidates = sqlx::query_as::<_, (Uuid, Uuid, Uuid, Uuid)>(
r#"
WITH candidate_attempts AS MATERIALIZED (
SELECT attempts.id AS attempt_id,
attempts.billing_scope_id,
attempts.subscriber_id,
attempts.host_charge_target_id AS target_id,
attempts.created_at,
attempts.updated_at AS claimed_order_at
FROM billing_payment_attempts AS attempts
WHERE attempts.gateway_account_id = $1
AND attempts.attempt_kind = 'host_charge'
AND attempts.status = ANY($2::text[])
AND attempts.submitted_at IS NULL
AND attempts.created_at <= clock_timestamp()
- ($3::bigint * interval '1 second')
AND attempts.updated_at <= clock_timestamp()
- ($4::bigint * interval '1 second')
ORDER BY attempts.updated_at, attempts.created_at, attempts.id
LIMIT $5
FOR UPDATE OF attempts SKIP LOCKED
), claimed_attempts AS (
UPDATE billing_payment_attempts AS attempts
SET updated_at = clock_timestamp()
FROM candidate_attempts
WHERE attempts.id = candidate_attempts.attempt_id
RETURNING attempts.id
)
SELECT candidate_attempts.attempt_id,
candidate_attempts.billing_scope_id,
candidate_attempts.subscriber_id,
candidate_attempts.target_id
FROM candidate_attempts
INNER JOIN claimed_attempts
ON claimed_attempts.id = candidate_attempts.attempt_id
ORDER BY candidate_attempts.claimed_order_at,
candidate_attempts.created_at,
candidate_attempts.attempt_id
"#,
)
.bind(gateway_account_id.as_uuid())
.bind(LocalAttemptPolicy::expirable_status_values())
.bind(policy.stale_after_seconds())
.bind(RECONCILIATION_CLAIM_RETRY_AFTER_SECONDS)
.bind(RECONCILIATION_PHASE_BATCH_SIZE)
.fetch_all(&mut *transaction)
.await?
.into_iter()
.map(
|(attempt_id, billing_scope_id, subscriber_id, target_id)| StaleHostChargeCandidate {
attempt_id,
billing_scope_id,
subscriber_id,
target_id,
},
)
.collect::<Vec<_>>();
transaction.commit().await?;
Ok(candidates)
}
fn is_lock_not_available(error: &sqlx::Error) -> bool {
matches!(
error,
sqlx::Error::Database(error) if error.code().as_deref() == Some("55P03")
)
}
#[cfg(test)]
mod tests;