lgtm 2
This commit is contained in:
parent
a8de0a614d
commit
3fa95819e3
30 changed files with 907 additions and 205 deletions
|
|
@ -112,6 +112,13 @@ async fn validate_token(
|
|||
}
|
||||
|
||||
pub async fn auth_middleware(req: Request, next: Next) -> Response {
|
||||
if bypass_auth_for_path(req.uri().path()) {
|
||||
let (mut parts, body) = req.into_parts();
|
||||
parts.extensions.insert(OptionalUser(None));
|
||||
let req = Request::from_parts(parts, body);
|
||||
return next.run(req).await;
|
||||
}
|
||||
|
||||
let state = req
|
||||
.extensions()
|
||||
.get::<Arc<crate::state::AppState>>()
|
||||
|
|
@ -150,3 +157,19 @@ pub async fn auth_middleware(req: Request, next: Next) -> Response {
|
|||
|
||||
next.run(req).await
|
||||
}
|
||||
|
||||
fn bypass_auth_for_path(path: &str) -> bool {
|
||||
path == "/api/stripe-webhook"
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn bypass_auth_only_for_stripe_webhook() {
|
||||
assert!(bypass_auth_for_path("/api/stripe-webhook"));
|
||||
assert!(!bypass_auth_for_path("/api/checkout"));
|
||||
assert!(!bypass_auth_for_path("/api/stripe-webhook/extra"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -34,6 +34,32 @@ pub enum CheckoutCompletion {
|
|||
Rejected(String),
|
||||
}
|
||||
|
||||
pub enum PaymentReversalOutcome {
|
||||
Applied {
|
||||
user_id: String,
|
||||
},
|
||||
AlreadyHandled {
|
||||
user_id: String,
|
||||
},
|
||||
IgnoredPartialRefund {
|
||||
user_id: String,
|
||||
refunded_amount_pence: u64,
|
||||
paid_amount_pence: u64,
|
||||
},
|
||||
NoMatchingCheckout,
|
||||
NotReversible {
|
||||
user_id: String,
|
||||
status: String,
|
||||
},
|
||||
}
|
||||
|
||||
pub enum PaymentReinstatementOutcome {
|
||||
Applied { user_id: String },
|
||||
AlreadyHandled { user_id: String },
|
||||
Ignored { user_id: String, reason: String },
|
||||
NoMatchingCheckout,
|
||||
}
|
||||
|
||||
pub struct VerifiedCheckout {
|
||||
pub reservation_id: String,
|
||||
pub user_id: String,
|
||||
|
|
@ -54,6 +80,9 @@ struct PendingCheckout {
|
|||
currency: String,
|
||||
referral_invite_id: String,
|
||||
status: String,
|
||||
payment_intent_id: String,
|
||||
paid_amount_pence: u64,
|
||||
reversal_reason: String,
|
||||
}
|
||||
|
||||
pub fn now_unix_secs() -> u64 {
|
||||
|
|
@ -424,6 +453,14 @@ async fn complete_verified_checkout_locked(
|
|||
return Err(anyhow!("checkout reservation is {}", live_checkout.status));
|
||||
}
|
||||
|
||||
grant_license(state, &checkout.user_id).await?;
|
||||
mark_checkout_completed(
|
||||
state,
|
||||
&checkout.reservation_id,
|
||||
checkout.paid_amount_pence,
|
||||
&checkout.payment_intent_id,
|
||||
)
|
||||
.await?;
|
||||
if !checkout.referral_invite_id.is_empty() {
|
||||
mark_referral_invite_used(
|
||||
state,
|
||||
|
|
@ -433,14 +470,6 @@ async fn complete_verified_checkout_locked(
|
|||
)
|
||||
.await?;
|
||||
}
|
||||
grant_license(state, &checkout.user_id).await?;
|
||||
mark_checkout_completed(
|
||||
state,
|
||||
&checkout.reservation_id,
|
||||
checkout.paid_amount_pence,
|
||||
&checkout.payment_intent_id,
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
|
@ -466,6 +495,135 @@ pub async fn grant_license(state: &AppState, user_id: &str) -> anyhow::Result<()
|
|||
set_user_subscription(state, user_id, "licensed").await
|
||||
}
|
||||
|
||||
pub async fn reverse_license_for_payment_intent(
|
||||
state: &AppState,
|
||||
payment_intent_id: &str,
|
||||
reason: &str,
|
||||
refunded_amount_pence: Option<u64>,
|
||||
) -> anyhow::Result<PaymentReversalOutcome> {
|
||||
if !is_safe_stripe_session_id(payment_intent_id) {
|
||||
return Err(anyhow!("invalid Stripe payment intent id"));
|
||||
}
|
||||
if !is_safe_reversal_reason(reason) {
|
||||
return Err(anyhow!("invalid Stripe reversal reason"));
|
||||
}
|
||||
|
||||
let _guard = CHECKOUT_RESERVATION_LOCK.lock().await;
|
||||
let checkout = match find_checkout_by_payment_intent_or_checkout_session(
|
||||
state,
|
||||
payment_intent_id,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
Some(checkout) => checkout,
|
||||
None => return Ok(PaymentReversalOutcome::NoMatchingCheckout),
|
||||
};
|
||||
|
||||
let paid_amount_pence = checkout
|
||||
.paid_amount_pence
|
||||
.max(checkout.expected_total_pence);
|
||||
if let Some(refunded_amount_pence) = refunded_amount_pence {
|
||||
if refunded_amount_pence < paid_amount_pence {
|
||||
return Ok(PaymentReversalOutcome::IgnoredPartialRefund {
|
||||
user_id: checkout.user_id,
|
||||
refunded_amount_pence,
|
||||
paid_amount_pence,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if checkout.status == "reversed" {
|
||||
return Ok(PaymentReversalOutcome::AlreadyHandled {
|
||||
user_id: checkout.user_id,
|
||||
});
|
||||
}
|
||||
|
||||
if matches!(checkout.status.as_str(), "pending" | "expired" | "failed") {
|
||||
mark_checkout_reversed(state, &checkout.id, reason, payment_intent_id).await?;
|
||||
return Ok(PaymentReversalOutcome::Applied {
|
||||
user_id: checkout.user_id,
|
||||
});
|
||||
}
|
||||
|
||||
if checkout.status != "completed" {
|
||||
return Ok(PaymentReversalOutcome::NotReversible {
|
||||
user_id: checkout.user_id,
|
||||
status: checkout.status,
|
||||
});
|
||||
}
|
||||
|
||||
let has_other_license = has_other_completed_checkout_for_user(
|
||||
state,
|
||||
&checkout.user_id,
|
||||
&checkout.id,
|
||||
payment_intent_id,
|
||||
)
|
||||
.await?;
|
||||
if !has_other_license {
|
||||
revoke_license(state, &checkout.user_id).await?;
|
||||
}
|
||||
mark_checkout_reversed(state, &checkout.id, reason, payment_intent_id).await?;
|
||||
|
||||
Ok(PaymentReversalOutcome::Applied {
|
||||
user_id: checkout.user_id,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn reinstate_license_for_payment_intent(
|
||||
state: &AppState,
|
||||
payment_intent_id: &str,
|
||||
reason: &str,
|
||||
) -> anyhow::Result<PaymentReinstatementOutcome> {
|
||||
if !is_safe_stripe_session_id(payment_intent_id) {
|
||||
return Err(anyhow!("invalid Stripe payment intent id"));
|
||||
}
|
||||
if !is_safe_reversal_reason(reason) {
|
||||
return Err(anyhow!("invalid Stripe reinstatement reason"));
|
||||
}
|
||||
|
||||
let _guard = CHECKOUT_RESERVATION_LOCK.lock().await;
|
||||
let checkout = match find_checkout_by_payment_intent_or_checkout_session(
|
||||
state,
|
||||
payment_intent_id,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
Some(checkout) => checkout,
|
||||
None => return Ok(PaymentReinstatementOutcome::NoMatchingCheckout),
|
||||
};
|
||||
|
||||
if checkout.status == "completed" {
|
||||
return Ok(PaymentReinstatementOutcome::AlreadyHandled {
|
||||
user_id: checkout.user_id,
|
||||
});
|
||||
}
|
||||
|
||||
if checkout.status != "reversed" {
|
||||
return Ok(PaymentReinstatementOutcome::Ignored {
|
||||
user_id: checkout.user_id,
|
||||
reason: format!("checkout status is {}", checkout.status),
|
||||
});
|
||||
}
|
||||
|
||||
if !checkout.reversal_reason.starts_with("charge.dispute.") {
|
||||
return Ok(PaymentReinstatementOutcome::Ignored {
|
||||
user_id: checkout.user_id,
|
||||
reason: format!("checkout was reversed by {}", checkout.reversal_reason),
|
||||
});
|
||||
}
|
||||
|
||||
grant_license(state, &checkout.user_id).await?;
|
||||
mark_checkout_reinstated(state, &checkout.id, reason).await?;
|
||||
|
||||
Ok(PaymentReinstatementOutcome::Applied {
|
||||
user_id: checkout.user_id,
|
||||
})
|
||||
}
|
||||
|
||||
async fn revoke_license(state: &AppState, user_id: &str) -> anyhow::Result<()> {
|
||||
set_user_subscription(state, user_id, "free").await
|
||||
}
|
||||
|
||||
async fn set_user_subscription(
|
||||
state: &AppState,
|
||||
user_id: &str,
|
||||
|
|
@ -510,20 +668,34 @@ pub async fn mark_referral_invite_used(
|
|||
let token = get_superuser_token(state).await?;
|
||||
let pb_url = state.pocketbase_url.trim_end_matches('/');
|
||||
let invite = fetch_invite_record(state, pb_url, &token, invite_id).await?;
|
||||
let existing_used_by = invite["used_by_id"].as_str().unwrap_or_default();
|
||||
if existing_used_by == user_id {
|
||||
return Ok(());
|
||||
}
|
||||
if !existing_used_by.is_empty() {
|
||||
return Err(anyhow!("referral invite already used by another account"));
|
||||
}
|
||||
let reserved_by_id = invite["reserved_by_id"].as_str().unwrap_or_default();
|
||||
let reserved_checkout_id = invite["reserved_checkout_id"].as_str().unwrap_or_default();
|
||||
if !reserved_by_id.is_empty() && reserved_by_id != user_id {
|
||||
return Err(anyhow!("referral invite reserved by another account"));
|
||||
}
|
||||
if !reserved_checkout_id.is_empty() && reserved_checkout_id != reservation_id {
|
||||
return Err(anyhow!("referral invite reserved by another checkout"));
|
||||
|
||||
// A verified Stripe payment must not lose entitlement just because local
|
||||
// invite reservation bookkeeping expired or moved before webhook delivery.
|
||||
match referral_invite_completion_action(&invite, user_id, reservation_id) {
|
||||
ReferralInviteCompletionAction::AlreadyRecorded => return Ok(()),
|
||||
ReferralInviteCompletionAction::AlreadyUsedByAnother => {
|
||||
warn!(
|
||||
invite_id,
|
||||
user_id,
|
||||
existing_used_by = invite["used_by_id"].as_str().unwrap_or_default(),
|
||||
"Referral invite was already used by another account; preserving verified checkout entitlement"
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
ReferralInviteCompletionAction::Record {
|
||||
reservation_reassigned,
|
||||
} => {
|
||||
if reservation_reassigned {
|
||||
warn!(
|
||||
invite_id,
|
||||
user_id,
|
||||
reservation_id,
|
||||
reserved_by_id = invite["reserved_by_id"].as_str().unwrap_or_default(),
|
||||
reserved_checkout_id = invite["reserved_checkout_id"].as_str().unwrap_or_default(),
|
||||
"Referral invite reservation moved before webhook completion; verified checkout will consume it"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let url = format!("{pb_url}/api/collections/invites/records/{invite_id}");
|
||||
|
|
@ -546,6 +718,36 @@ pub async fn mark_referral_invite_used(
|
|||
.context("PocketBase invite usage update failed")
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
enum ReferralInviteCompletionAction {
|
||||
AlreadyRecorded,
|
||||
AlreadyUsedByAnother,
|
||||
Record { reservation_reassigned: bool },
|
||||
}
|
||||
|
||||
fn referral_invite_completion_action(
|
||||
invite: &Value,
|
||||
user_id: &str,
|
||||
reservation_id: &str,
|
||||
) -> ReferralInviteCompletionAction {
|
||||
let existing_used_by = invite["used_by_id"].as_str().unwrap_or_default();
|
||||
if existing_used_by == user_id {
|
||||
return ReferralInviteCompletionAction::AlreadyRecorded;
|
||||
}
|
||||
if !existing_used_by.is_empty() {
|
||||
return ReferralInviteCompletionAction::AlreadyUsedByAnother;
|
||||
}
|
||||
|
||||
let reserved_by_id = invite["reserved_by_id"].as_str().unwrap_or_default();
|
||||
let reserved_checkout_id = invite["reserved_checkout_id"].as_str().unwrap_or_default();
|
||||
let reservation_reassigned = (!reserved_by_id.is_empty() && reserved_by_id != user_id)
|
||||
|| (!reserved_checkout_id.is_empty() && reserved_checkout_id != reservation_id);
|
||||
|
||||
ReferralInviteCompletionAction::Record {
|
||||
reservation_reassigned,
|
||||
}
|
||||
}
|
||||
|
||||
async fn fetch_invite_record(
|
||||
state: &AppState,
|
||||
pb_url: &str,
|
||||
|
|
@ -1038,6 +1240,56 @@ async fn mark_checkout_status(
|
|||
.with_context(|| format!("PocketBase checkout status update failed for {reservation_id}"))
|
||||
}
|
||||
|
||||
async fn mark_checkout_reversed(
|
||||
state: &AppState,
|
||||
reservation_id: &str,
|
||||
reason: &str,
|
||||
payment_intent_id: &str,
|
||||
) -> anyhow::Result<()> {
|
||||
let token = get_superuser_token(state).await?;
|
||||
let pb_url = state.pocketbase_url.trim_end_matches('/');
|
||||
let url = format!("{pb_url}/api/collections/{CHECKOUT_COLLECTION}/records/{reservation_id}");
|
||||
let resp = state
|
||||
.http_client
|
||||
.patch(&url)
|
||||
.header("Authorization", format!("Bearer {token}"))
|
||||
.json(&serde_json::json!({
|
||||
"status": "reversed",
|
||||
"reversal_reason": reason,
|
||||
"stripe_payment_intent_id": payment_intent_id,
|
||||
}))
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
ensure_success(resp)
|
||||
.await
|
||||
.with_context(|| format!("PocketBase checkout reversal update failed for {reservation_id}"))
|
||||
}
|
||||
|
||||
async fn mark_checkout_reinstated(
|
||||
state: &AppState,
|
||||
reservation_id: &str,
|
||||
_reason: &str,
|
||||
) -> anyhow::Result<()> {
|
||||
let token = get_superuser_token(state).await?;
|
||||
let pb_url = state.pocketbase_url.trim_end_matches('/');
|
||||
let url = format!("{pb_url}/api/collections/{CHECKOUT_COLLECTION}/records/{reservation_id}");
|
||||
let resp = state
|
||||
.http_client
|
||||
.patch(&url)
|
||||
.header("Authorization", format!("Bearer {token}"))
|
||||
.json(&serde_json::json!({
|
||||
"status": "completed",
|
||||
"reversal_reason": "",
|
||||
}))
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
ensure_success(resp).await.with_context(|| {
|
||||
format!("PocketBase checkout reinstatement update failed for {reservation_id}")
|
||||
})
|
||||
}
|
||||
|
||||
async fn find_checkout_by_stripe_session(
|
||||
state: &AppState,
|
||||
stripe_session_id: &str,
|
||||
|
|
@ -1067,6 +1319,164 @@ async fn find_checkout_by_stripe_session(
|
|||
item.map(parse_pending_checkout).transpose()
|
||||
}
|
||||
|
||||
async fn find_checkout_by_payment_intent(
|
||||
state: &AppState,
|
||||
payment_intent_id: &str,
|
||||
) -> anyhow::Result<Option<PendingCheckout>> {
|
||||
let token = get_superuser_token(state).await?;
|
||||
let pb_url = state.pocketbase_url.trim_end_matches('/');
|
||||
let filter = format!("stripe_payment_intent_id=\"{}\"", payment_intent_id);
|
||||
let url = format!(
|
||||
"{pb_url}/api/collections/{CHECKOUT_COLLECTION}/records?filter={}&perPage=1",
|
||||
urlencoding::encode(&filter)
|
||||
);
|
||||
let resp = state
|
||||
.http_client
|
||||
.get(&url)
|
||||
.header("Authorization", format!("Bearer {token}"))
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
ensure_success_ref(&resp).await?;
|
||||
|
||||
let body: Value = resp.json().await?;
|
||||
let item = body["items"]
|
||||
.as_array()
|
||||
.and_then(|items| items.first())
|
||||
.cloned();
|
||||
|
||||
item.map(parse_pending_checkout).transpose()
|
||||
}
|
||||
|
||||
async fn find_checkout_by_payment_intent_or_checkout_session(
|
||||
state: &AppState,
|
||||
payment_intent_id: &str,
|
||||
) -> anyhow::Result<Option<PendingCheckout>> {
|
||||
if let Some(checkout) = find_checkout_by_payment_intent(state, payment_intent_id).await? {
|
||||
return Ok(Some(checkout));
|
||||
}
|
||||
|
||||
let Some(session_id) =
|
||||
fetch_stripe_checkout_session_id_for_payment_intent(state, payment_intent_id).await?
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let Some(mut checkout) = find_checkout_by_stripe_session(state, &session_id).await? else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
if checkout.payment_intent_id.is_empty() {
|
||||
attach_payment_intent_to_checkout(state, &checkout.id, payment_intent_id).await?;
|
||||
checkout.payment_intent_id = payment_intent_id.to_string();
|
||||
} else if checkout.payment_intent_id != payment_intent_id {
|
||||
mark_checkout_status(state, &checkout.id, "invalid").await?;
|
||||
return Err(anyhow!(
|
||||
"checkout reservation payment intent changed before reversal"
|
||||
));
|
||||
}
|
||||
|
||||
Ok(Some(checkout))
|
||||
}
|
||||
|
||||
async fn fetch_stripe_checkout_session_id_for_payment_intent(
|
||||
state: &AppState,
|
||||
payment_intent_id: &str,
|
||||
) -> anyhow::Result<Option<String>> {
|
||||
let url = format!(
|
||||
"https://api.stripe.com/v1/checkout/sessions?payment_intent={}&limit=1",
|
||||
urlencoding::encode(payment_intent_id)
|
||||
);
|
||||
let resp = state
|
||||
.http_client
|
||||
.get(&url)
|
||||
.basic_auth(&state.stripe_secret_key, None::<&str>)
|
||||
.send()
|
||||
.await
|
||||
.context("Stripe checkout session lookup failed")?;
|
||||
|
||||
ensure_success_ref(&resp)
|
||||
.await
|
||||
.context("Stripe checkout session lookup returned error")?;
|
||||
|
||||
let body: Value = resp
|
||||
.json()
|
||||
.await
|
||||
.context("Failed to parse Stripe checkout session lookup")?;
|
||||
Ok(body["data"]
|
||||
.as_array()
|
||||
.and_then(|items| items.first())
|
||||
.and_then(|item| item["id"].as_str())
|
||||
.filter(|id| is_safe_stripe_session_id(id))
|
||||
.map(str::to_string))
|
||||
}
|
||||
|
||||
async fn attach_payment_intent_to_checkout(
|
||||
state: &AppState,
|
||||
reservation_id: &str,
|
||||
payment_intent_id: &str,
|
||||
) -> anyhow::Result<()> {
|
||||
let token = get_superuser_token(state).await?;
|
||||
let pb_url = state.pocketbase_url.trim_end_matches('/');
|
||||
let url = format!("{pb_url}/api/collections/{CHECKOUT_COLLECTION}/records/{reservation_id}");
|
||||
let resp = state
|
||||
.http_client
|
||||
.patch(&url)
|
||||
.header("Authorization", format!("Bearer {token}"))
|
||||
.json(&serde_json::json!({
|
||||
"stripe_payment_intent_id": payment_intent_id,
|
||||
}))
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
ensure_success(resp)
|
||||
.await
|
||||
.context("PocketBase checkout payment intent attach failed")
|
||||
}
|
||||
|
||||
async fn has_other_completed_checkout_for_user(
|
||||
state: &AppState,
|
||||
user_id: &str,
|
||||
reservation_id: &str,
|
||||
payment_intent_id: &str,
|
||||
) -> anyhow::Result<bool> {
|
||||
if !is_safe_pocketbase_id(user_id) || !is_safe_pocketbase_id(reservation_id) {
|
||||
return Err(anyhow!("invalid PocketBase id"));
|
||||
}
|
||||
if !is_safe_stripe_session_id(payment_intent_id) {
|
||||
return Err(anyhow!("invalid Stripe payment intent id"));
|
||||
}
|
||||
|
||||
let token = get_superuser_token(state).await?;
|
||||
let pb_url = state.pocketbase_url.trim_end_matches('/');
|
||||
let filter = format!("user=\"{user_id}\" && status=\"completed\"");
|
||||
let url = format!(
|
||||
"{pb_url}/api/collections/{CHECKOUT_COLLECTION}/records?filter={}&perPage=50",
|
||||
urlencoding::encode(&filter)
|
||||
);
|
||||
let resp = state
|
||||
.http_client
|
||||
.get(&url)
|
||||
.header("Authorization", format!("Bearer {token}"))
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
ensure_success_ref(&resp).await?;
|
||||
|
||||
let body: Value = resp.json().await?;
|
||||
let Some(items) = body["items"].as_array() else {
|
||||
return Ok(false);
|
||||
};
|
||||
|
||||
Ok(items.iter().any(|item| {
|
||||
let other_id = item["id"].as_str().unwrap_or_default();
|
||||
let other_payment_intent = item["stripe_payment_intent_id"]
|
||||
.as_str()
|
||||
.unwrap_or_default();
|
||||
other_id != reservation_id && other_payment_intent != payment_intent_id
|
||||
}))
|
||||
}
|
||||
|
||||
fn parse_pending_checkout(item: Value) -> anyhow::Result<PendingCheckout> {
|
||||
Ok(PendingCheckout {
|
||||
id: item["id"]
|
||||
|
|
@ -1098,6 +1508,15 @@ fn parse_pending_checkout(item: Value) -> anyhow::Result<PendingCheckout> {
|
|||
.unwrap_or_default()
|
||||
.to_string(),
|
||||
status: item["status"].as_str().unwrap_or_default().to_string(),
|
||||
payment_intent_id: item["stripe_payment_intent_id"]
|
||||
.as_str()
|
||||
.unwrap_or_default()
|
||||
.to_string(),
|
||||
paid_amount_pence: number_field(&item, "paid_amount_pence").unwrap_or(0),
|
||||
reversal_reason: item["reversal_reason"]
|
||||
.as_str()
|
||||
.unwrap_or_default()
|
||||
.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -1129,6 +1548,14 @@ fn is_safe_pocketbase_id(id: &str) -> bool {
|
|||
!id.is_empty() && id.len() <= 32 && id.bytes().all(|b| b.is_ascii_alphanumeric())
|
||||
}
|
||||
|
||||
fn is_safe_reversal_reason(reason: &str) -> bool {
|
||||
!reason.is_empty()
|
||||
&& reason.len() <= 128
|
||||
&& reason
|
||||
.bytes()
|
||||
.all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-' || b == b'.')
|
||||
}
|
||||
|
||||
async fn ensure_success(resp: reqwest::Response) -> anyhow::Result<()> {
|
||||
if resp.status().is_success() {
|
||||
return Ok(());
|
||||
|
|
@ -1182,4 +1609,51 @@ mod tests {
|
|||
assert_eq!(expected_total_for_checkout(1, Some("coupon_30")), 1);
|
||||
assert_eq!(expected_total_for_checkout(999, None), 999);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn referral_invite_completion_records_available_invite() {
|
||||
let invite = serde_json::json!({
|
||||
"used_by_id": "",
|
||||
"reserved_by_id": "",
|
||||
"reserved_checkout_id": "",
|
||||
});
|
||||
|
||||
assert_eq!(
|
||||
referral_invite_completion_action(&invite, "user123", "checkout123"),
|
||||
ReferralInviteCompletionAction::Record {
|
||||
reservation_reassigned: false
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn referral_invite_completion_records_reassigned_reservation() {
|
||||
let invite = serde_json::json!({
|
||||
"used_by_id": "",
|
||||
"reserved_by_id": "otheruser",
|
||||
"reserved_checkout_id": "othercheckout",
|
||||
});
|
||||
|
||||
assert_eq!(
|
||||
referral_invite_completion_action(&invite, "user123", "checkout123"),
|
||||
ReferralInviteCompletionAction::Record {
|
||||
reservation_reassigned: true
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn referral_invite_completion_detects_existing_usage() {
|
||||
let used_by_same_user = serde_json::json!({ "used_by_id": "user123" });
|
||||
let used_by_another_user = serde_json::json!({ "used_by_id": "otheruser" });
|
||||
|
||||
assert_eq!(
|
||||
referral_invite_completion_action(&used_by_same_user, "user123", "checkout123"),
|
||||
ReferralInviteCompletionAction::AlreadyRecorded
|
||||
);
|
||||
assert_eq!(
|
||||
referral_invite_completion_action(&used_by_another_user, "user123", "checkout123"),
|
||||
ReferralInviteCompletionAction::AlreadyUsedByAnother
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,11 +11,8 @@ pub const SERVER_ADDRESS: &str = "0.0.0.0:8001";
|
|||
|
||||
pub const GRID_CELL_SIZE: f32 = 0.01;
|
||||
pub const MAX_POIS_PER_REQUEST: usize = 10000;
|
||||
pub const MAX_CELLS_PER_REQUEST: usize = 50000;
|
||||
pub const MAX_ROWS_PER_BOUNDS_QUERY: usize = 2_000_000;
|
||||
pub const MAX_ROWS_PER_EXPORT: usize = 250_000;
|
||||
|
||||
pub const DEFAULT_PROPERTIES_LIMIT: usize = 100;
|
||||
pub const MAX_PROPERTIES_LIMIT: usize = 500;
|
||||
pub const MAX_PRICE_HISTORY_POINTS: usize = 5000;
|
||||
pub const POSTCODE_SEARCH_OFFSET: f64 = 0.02;
|
||||
|
||||
|
|
@ -30,3 +27,10 @@ pub const SERVICE_CALL_TIMEOUT: u64 = 120;
|
|||
/// Demo free zone bounds (south, west, north, east) — inner London, roughly zone 1.
|
||||
/// Users without a license can only query data within these bounds.
|
||||
pub const FREE_ZONE_BOUNDS: (f64, f64, f64, f64) = (51.44, -0.31, 51.59, 0.05);
|
||||
|
||||
pub const SHARE_CACHE_TTL_SECS: u64 = 300;
|
||||
pub const SHARE_CACHE_MAX_ENTRIES: usize = 1024;
|
||||
pub const MIN_SHARE_ZOOM: f64 = 11.0;
|
||||
pub const MAX_SHARE_ZOOM: f64 = 20.0;
|
||||
pub const MAX_SHARE_LAT_SPAN: f64 = 1.2;
|
||||
pub const MAX_SHARE_LON_SPAN: f64 = 2.0;
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ mod checkout_sessions;
|
|||
mod consts;
|
||||
mod data;
|
||||
mod features;
|
||||
mod language;
|
||||
mod licensing;
|
||||
mod metrics;
|
||||
mod og_middleware;
|
||||
|
|
@ -496,7 +497,7 @@ async fn main() -> anyhow::Result<()> {
|
|||
|
||||
let shared = Arc::new(SharedState::new(app_state));
|
||||
|
||||
// Start background PocketBase metrics poller (users, saved searches/properties counts)
|
||||
// Start background PocketBase metrics poller (users, saved searches counts)
|
||||
pocketbase::start_metrics_poller(shared.clone());
|
||||
|
||||
let initial_state = shared.load_state();
|
||||
|
|
|
|||
|
|
@ -1085,40 +1085,6 @@ pub async fn ensure_collections(
|
|||
ensure_notes_field(client, base_url, &token, "saved_searches").await?;
|
||||
}
|
||||
|
||||
if !existing.iter().any(|n| n == "saved_properties") {
|
||||
let users_id = find_users_collection_id(client, base_url, &token).await?;
|
||||
let user_only = Some("user = @request.auth.id".to_string());
|
||||
create_collection(
|
||||
client,
|
||||
base_url,
|
||||
&token,
|
||||
CreateCollection {
|
||||
name: "saved_properties".to_string(),
|
||||
r#type: "base".to_string(),
|
||||
fields: vec![
|
||||
Field::relation("user", &users_id),
|
||||
Field::text("address", true),
|
||||
Field::text("postcode", true),
|
||||
Field::text("data", false),
|
||||
Field::text("notes", false),
|
||||
Field::autodate("created", true, false),
|
||||
Field::autodate("updated", true, true),
|
||||
],
|
||||
list_rule: user_only.clone(),
|
||||
view_rule: user_only.clone(),
|
||||
create_rule: user_only.clone(),
|
||||
update_rule: user_only.clone(),
|
||||
delete_rule: user_only,
|
||||
indexes: Vec::new(),
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
} else {
|
||||
ensure_user_owned_rules(client, base_url, &token, "saved_properties").await?;
|
||||
ensure_autodate_fields(client, base_url, &token, "saved_properties").await?;
|
||||
ensure_notes_field(client, base_url, &token, "saved_properties").await?;
|
||||
}
|
||||
|
||||
if !existing.iter().any(|n| n == "invites") {
|
||||
create_collection(
|
||||
client,
|
||||
|
|
@ -1460,7 +1426,6 @@ async fn poll_pocketbase_counts(state: &AppState) {
|
|||
for (collection, metric_name) in [
|
||||
("users", "pocketbase_users_total"),
|
||||
("saved_searches", "pocketbase_saved_searches_total"),
|
||||
("saved_properties", "pocketbase_saved_properties_total"),
|
||||
] {
|
||||
if let Some(total) = pb_count(&state.http_client, pb_url, &token, collection, None).await {
|
||||
gauge!(metric_name).set(total as f64);
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ use crate::state::SharedState;
|
|||
#[derive(Deserialize)]
|
||||
pub struct CheckoutRequest {
|
||||
referral_code: Option<String>,
|
||||
return_path: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
|
|
@ -21,6 +22,27 @@ struct CheckoutResponse {
|
|||
url: String,
|
||||
}
|
||||
|
||||
fn sanitize_return_path(path: Option<&str>) -> &str {
|
||||
let Some(path) = path else {
|
||||
return "/pricing";
|
||||
};
|
||||
let path = path.split('#').next().unwrap_or(path);
|
||||
if path.is_empty()
|
||||
|| path.len() > 2048
|
||||
|| !path.starts_with('/')
|
||||
|| path.starts_with("//")
|
||||
|| path.chars().any(char::is_control)
|
||||
{
|
||||
return "/pricing";
|
||||
}
|
||||
path
|
||||
}
|
||||
|
||||
fn append_query_param(path: &str, key: &str, value: &str) -> String {
|
||||
let separator = if path.contains('?') { '&' } else { '?' };
|
||||
format!("{path}{separator}{key}={value}")
|
||||
}
|
||||
|
||||
/// Create a reserved Stripe Checkout session for the lifetime license.
|
||||
/// Requires authentication. Referral discounts are issued via invite redemption.
|
||||
pub async fn post_checkout(
|
||||
|
|
@ -34,9 +56,13 @@ pub async fn post_checkout(
|
|||
None => return StatusCode::UNAUTHORIZED.into_response(),
|
||||
};
|
||||
|
||||
let public_url = &state.public_url;
|
||||
let success_url = format!("{public_url}/pricing?license_success=1");
|
||||
let cancel_url = format!("{public_url}/pricing");
|
||||
let public_url = state.public_url.trim_end_matches('/');
|
||||
let return_path = sanitize_return_path(req.return_path.as_deref());
|
||||
let success_url = format!(
|
||||
"{public_url}{}",
|
||||
append_query_param(return_path, "license_success", "1")
|
||||
);
|
||||
let cancel_url = format!("{public_url}{return_path}");
|
||||
|
||||
if req.referral_code.is_some() {
|
||||
return (
|
||||
|
|
@ -46,6 +72,10 @@ pub async fn post_checkout(
|
|||
.into_response();
|
||||
}
|
||||
|
||||
if user.is_admin || user.subscription == "licensed" {
|
||||
return (StatusCode::CONFLICT, "This account already has full access").into_response();
|
||||
}
|
||||
|
||||
match start_license_checkout(&state, &user, &success_url, &cancel_url, None, None).await {
|
||||
Ok(CheckoutStart::Free) => {
|
||||
info!(user_id = %user.id, "Granted free early-bird license");
|
||||
|
|
@ -58,3 +88,38 @@ pub async fn post_checkout(
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn sanitize_return_path_accepts_local_paths_and_strips_fragments() {
|
||||
assert_eq!(
|
||||
sanitize_return_path(Some("/map?postcode=SW1A#details")),
|
||||
"/map?postcode=SW1A"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitize_return_path_rejects_external_or_control_paths() {
|
||||
assert_eq!(sanitize_return_path(Some("//evil.test/path")), "/pricing");
|
||||
assert_eq!(
|
||||
sanitize_return_path(Some("https://evil.test/path")),
|
||||
"/pricing"
|
||||
);
|
||||
assert_eq!(sanitize_return_path(Some("/map\nbad")), "/pricing");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn append_query_param_preserves_existing_query_separator() {
|
||||
assert_eq!(
|
||||
append_query_param("/map?postcode=SW1A", "license_success", "1"),
|
||||
"/map?postcode=SW1A&license_success=1"
|
||||
);
|
||||
assert_eq!(
|
||||
append_query_param("/pricing", "license_success", "1"),
|
||||
"/pricing?license_success=1"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,13 +10,13 @@ use tracing::{info, warn};
|
|||
use crate::auth::OptionalUser;
|
||||
use crate::consts::POSTCODE_SEARCH_OFFSET;
|
||||
use crate::licensing::{check_license_point, resolve_share_code};
|
||||
use crate::parsing::{
|
||||
parse_field_set, parse_filters_with_poi, row_passes_filters, row_passes_poi_filters,
|
||||
};
|
||||
use crate::parsing::{parse_filters_with_poi, row_passes_filters, row_passes_poi_filters};
|
||||
use crate::state::SharedState;
|
||||
use crate::utils::normalize_postcode;
|
||||
|
||||
use super::hexagon_stats::HexagonStatsResponse;
|
||||
use super::hexagon_stats::{
|
||||
parse_area_stats_field_set, top_filter_exclusions, HexagonStatsResponse,
|
||||
};
|
||||
use super::stats;
|
||||
use super::travel_time::{load_travel_data, parse_optional_travel, row_passes_travel_filters};
|
||||
|
||||
|
|
@ -24,8 +24,9 @@ use super::travel_time::{load_travel_data, parse_optional_travel, row_passes_tra
|
|||
pub struct PostcodeStatsParams {
|
||||
pub postcode: String,
|
||||
pub filters: Option<String>,
|
||||
/// Comma-separated feature names to include in stats response.
|
||||
/// Only listed features are computed; if absent or empty, no features are returned.
|
||||
/// `;;`-separated feature names to include in stats response.
|
||||
/// Only listed features are computed. If absent, area stats default to
|
||||
/// displayable groups; if empty, no feature stats are returned.
|
||||
pub fields: Option<String>,
|
||||
/// Pipe-separated travel time entries: `mode:slug|mode:slug:min:max`.
|
||||
/// Optional min:max applies as a filter (exclude properties outside range).
|
||||
|
|
@ -80,7 +81,7 @@ pub async fn get_postcode_stats(
|
|||
let filters_str = params.filters;
|
||||
let has_poi_filters = !parsed_poi_filters.is_empty();
|
||||
|
||||
let (fields_specified, field_set) = parse_field_set(params.fields.as_deref());
|
||||
let (fields_specified, field_set) = parse_area_stats_field_set(params.fields.as_deref());
|
||||
let travel_entries = parse_optional_travel(params.travel.as_deref())
|
||||
.map_err(|err| (StatusCode::BAD_REQUEST, err).into_response())?;
|
||||
|
||||
|
|
@ -100,26 +101,26 @@ pub async fn get_postcode_stats(
|
|||
let min_lon = centroid_lon as f64 - offset;
|
||||
let max_lon = centroid_lon as f64 + offset;
|
||||
|
||||
let mut area_rows: Vec<usize> = Vec::new();
|
||||
let mut matching_rows: Vec<usize> = Vec::new();
|
||||
state
|
||||
.grid
|
||||
.for_each_in_bounds(min_lat, min_lon, max_lat, max_lon, |row_idx| {
|
||||
let row = row_idx as usize;
|
||||
let row_postcode = state.data.postcode(row);
|
||||
if row_postcode == postcode_str
|
||||
&& row_passes_filters(
|
||||
row,
|
||||
&parsed_filters,
|
||||
&parsed_enum_filters,
|
||||
feature_data,
|
||||
num_features,
|
||||
)
|
||||
&& (!has_poi_filters
|
||||
|| row_passes_poi_filters(
|
||||
row,
|
||||
&parsed_poi_filters,
|
||||
&state.data.poi_metrics,
|
||||
))
|
||||
if row_postcode != postcode_str {
|
||||
return;
|
||||
}
|
||||
|
||||
area_rows.push(row);
|
||||
if row_passes_filters(
|
||||
row,
|
||||
&parsed_filters,
|
||||
&parsed_enum_filters,
|
||||
feature_data,
|
||||
num_features,
|
||||
) && (!has_poi_filters
|
||||
|| row_passes_poi_filters(row, &parsed_poi_filters, &state.data.poi_metrics))
|
||||
{
|
||||
if has_travel
|
||||
&& !row_passes_travel_filters(row_postcode, &travel_entries, &travel_data)
|
||||
|
|
@ -131,6 +132,19 @@ pub async fn get_postcode_stats(
|
|||
});
|
||||
|
||||
let total_count = matching_rows.len();
|
||||
let filter_exclusions = if total_count == 0 {
|
||||
top_filter_exclusions(
|
||||
&area_rows,
|
||||
&parsed_filters,
|
||||
&parsed_enum_filters,
|
||||
&parsed_poi_filters,
|
||||
&travel_entries,
|
||||
&travel_data,
|
||||
&state.data,
|
||||
)
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
let price_history =
|
||||
stats::extract_price_history(&matching_rows, &state.data, &state.feature_name_to_index);
|
||||
|
|
@ -168,6 +182,7 @@ pub async fn get_postcode_stats(
|
|||
enum_features: enum_features_out,
|
||||
price_history,
|
||||
central_postcode: None,
|
||||
filter_exclusions,
|
||||
})
|
||||
})
|
||||
.await
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ pub struct PostcodesResponse {
|
|||
pub struct NearestPostcodeParams {
|
||||
lat: f64,
|
||||
lng: f64,
|
||||
log: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
|
|
@ -468,15 +469,17 @@ pub async fn get_nearest_postcode(
|
|||
let postcode = &postcode_data.postcodes[idx];
|
||||
|
||||
// Log location for authenticated users (best-effort, non-blocking)
|
||||
if let Some(ref pb_user) = user.0 {
|
||||
let state = state.clone();
|
||||
let user_id = pb_user.id.clone();
|
||||
let lat_f64 = params.lat;
|
||||
let lng_f64 = params.lng;
|
||||
let pc = postcode.clone();
|
||||
tokio::spawn(async move {
|
||||
log_user_location(&state, &user_id, lat_f64, lng_f64, &pc).await;
|
||||
});
|
||||
if params.log.unwrap_or(true) {
|
||||
if let Some(ref pb_user) = user.0 {
|
||||
let state = state.clone();
|
||||
let user_id = pb_user.id.clone();
|
||||
let lat_f64 = params.lat;
|
||||
let lng_f64 = params.lng;
|
||||
let pc = postcode.clone();
|
||||
tokio::spawn(async move {
|
||||
log_user_location(&state, &user_id, lat_f64, lng_f64, &pc).await;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
info!(postcode = %postcode, "GET /api/nearest-postcode");
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ use serde::{Deserialize, Serialize};
|
|||
use tracing::{info, warn};
|
||||
|
||||
use crate::auth::OptionalUser;
|
||||
use crate::consts::{DEFAULT_PROPERTIES_LIMIT, MAX_PROPERTIES_LIMIT};
|
||||
use crate::consts::DEFAULT_PROPERTIES_LIMIT;
|
||||
use crate::data::RenovationEvent;
|
||||
use crate::licensing::{check_license_bounds, resolve_share_code};
|
||||
use crate::parsing::{
|
||||
|
|
@ -273,10 +273,7 @@ pub async fn get_hexagon_properties(
|
|||
matching_rows.sort_unstable_by_key(|&row| state.data.address(row).trim().is_empty());
|
||||
|
||||
let total = matching_rows.len();
|
||||
let limit = params
|
||||
.limit
|
||||
.unwrap_or(DEFAULT_PROPERTIES_LIMIT)
|
||||
.min(MAX_PROPERTIES_LIMIT);
|
||||
let limit = params.limit.unwrap_or(DEFAULT_PROPERTIES_LIMIT);
|
||||
let offset = params.offset.unwrap_or(0);
|
||||
let truncated = total > offset + limit;
|
||||
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ use crate::utils::normalize_postcode;
|
|||
const RIGHTMOVE_TYPEAHEAD_URL: &str = "https://los.rightmove.co.uk/typeahead";
|
||||
const RIGHTMOVE_HOST: &str = "www.rightmove.co.uk";
|
||||
const RIGHTMOVE_FIND_PATH: &str = "/property-for-sale/find.html";
|
||||
const RIGHTMOVE_POSTCODE_RADIUS_MILES: &str = "0.25";
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct RightmoveRedirectParams {
|
||||
|
|
@ -151,7 +152,10 @@ fn apply_exact_postcode_location(url: &mut Url, postcode: &str, location_identif
|
|||
"locationIdentifier".to_string(),
|
||||
location_identifier.to_string(),
|
||||
));
|
||||
pairs.push(("radius".to_string(), "0.0".to_string()));
|
||||
pairs.push((
|
||||
"radius".to_string(),
|
||||
RIGHTMOVE_POSTCODE_RADIUS_MILES.to_string(),
|
||||
));
|
||||
|
||||
let mut query = url.query_pairs_mut();
|
||||
query.clear();
|
||||
|
|
@ -200,7 +204,7 @@ mod tests {
|
|||
#[test]
|
||||
fn rewrites_rightmove_url_to_exact_postcode_location() {
|
||||
let mut url = Url::parse(
|
||||
"https://www.rightmove.co.uk/property-for-sale/find.html?searchLocation=SW1A+1AA&useLocationIdentifier=true&locationIdentifier=OUTCODE%5E2506&radius=0.25&minPrice=100000",
|
||||
"https://www.rightmove.co.uk/property-for-sale/find.html?searchLocation=SW1A+1AA&useLocationIdentifier=true&locationIdentifier=OUTCODE%5E2506&radius=0.0&minPrice=100000",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
|
|
@ -210,7 +214,7 @@ mod tests {
|
|||
assert_eq!(pairs.get("searchLocation").unwrap(), "SW1A 1AA");
|
||||
assert_eq!(pairs.get("useLocationIdentifier").unwrap(), "true");
|
||||
assert_eq!(pairs.get("locationIdentifier").unwrap(), "POSTCODE^837246");
|
||||
assert_eq!(pairs.get("radius").unwrap(), "0.0");
|
||||
assert_eq!(pairs.get("radius").unwrap(), "0.25");
|
||||
assert_eq!(pairs.get("minPrice").unwrap(), "100000");
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue