Skip to main content

systemprompt_cloud/checkout/client/
mod.rs

1//! Browser-driven Paddle checkout flow used by `systemprompt cloud
2//! checkout`.
3
4mod handler;
5
6use std::sync::Arc;
7use std::time::Duration;
8
9use axum::Router;
10use axum::routing::get;
11use serde::{Deserialize, Serialize};
12use systemprompt_identifiers::{CheckoutSessionId, TenantId, TransactionId};
13use systemprompt_logging::CliService;
14use tokio::sync::{Mutex, oneshot};
15
16use handler::{callback_handler, status_handler};
17
18use crate::CloudApiClient;
19use crate::constants::checkout::{CALLBACK_PORT, CALLBACK_TIMEOUT_SECS};
20use crate::error::{CloudError, CloudResult};
21
22#[derive(Debug, Deserialize)]
23pub(super) struct CallbackParams {
24    pub(super) transaction_id: Option<TransactionId>,
25    pub(super) tenant_id: Option<TenantId>,
26    pub(super) status: Option<String>,
27    pub(super) error: Option<String>,
28    pub(super) checkout_session_id: Option<CheckoutSessionId>,
29}
30
31#[derive(Debug, Clone, Serialize)]
32pub(super) struct StatusResponse {
33    pub(super) status: String,
34    pub(super) message: Option<String>,
35    pub(super) app_url: Option<String>,
36}
37
38#[derive(Debug, Clone)]
39pub struct CheckoutCallbackResult {
40    pub transaction_id: TransactionId,
41    pub tenant_id: TenantId,
42    pub fly_app_name: Option<String>,
43    pub needs_deploy: bool,
44}
45
46#[derive(Debug, Clone, Copy)]
47#[expect(
48    clippy::struct_field_names,
49    reason = "All three fields are static HTML payloads; the `_html` suffix disambiguates them at \
50              the call site."
51)]
52pub struct CheckoutTemplates {
53    pub success_html: &'static str,
54    pub error_html: &'static str,
55    pub waiting_html: &'static str,
56}
57
58pub(super) struct AppState {
59    pub(super) tx: Arc<Mutex<Option<oneshot::Sender<CloudResult<CheckoutCallbackResult>>>>>,
60    pub(super) api_client: Arc<CloudApiClient>,
61    pub(super) success_template: String,
62    pub(super) error_template: String,
63    pub(super) waiting_template: String,
64}
65
66pub async fn run_checkout_callback_flow(
67    api_client: &CloudApiClient,
68    checkout_url: &str,
69    templates: CheckoutTemplates,
70) -> CloudResult<CheckoutCallbackResult> {
71    let (tx, rx) = oneshot::channel::<CloudResult<CheckoutCallbackResult>>();
72    let tx = Arc::new(Mutex::new(Some(tx)));
73
74    let state = AppState {
75        tx: Arc::clone(&tx),
76        api_client: Arc::new(CloudApiClient::new(
77            api_client.api_url(),
78            api_client.token(),
79        )?),
80        success_template: templates.success_html.to_string(),
81        error_template: templates.error_html.to_string(),
82        waiting_template: templates.waiting_html.to_string(),
83    };
84
85    let app = Router::new()
86        .route("/callback", get(callback_handler))
87        .route("/status/{tenant_id}", get(status_handler))
88        .with_state(Arc::new(state));
89
90    let addr = format!("127.0.0.1:{CALLBACK_PORT}");
91    let listener = tokio::net::TcpListener::bind(&addr).await?;
92
93    CliService::info(&format!(
94        "Starting checkout callback server on http://{addr}"
95    ));
96
97    CliService::info("Opening Paddle checkout in your browser...");
98    CliService::info(&format!("URL: {checkout_url}"));
99
100    if let Err(e) = open::that(checkout_url) {
101        CliService::warning(&format!("Could not open browser automatically: {e}"));
102        CliService::info("Please open this URL manually:");
103        CliService::key_value("URL", checkout_url);
104    }
105
106    CliService::info("Waiting for checkout completion...");
107    CliService::info(&format!("(timeout in {CALLBACK_TIMEOUT_SECS} seconds)"));
108
109    let server = axum::serve(listener, app);
110
111    tokio::select! {
112        result = rx => {
113            result.map_err(|_| CloudError::CheckoutFlow { message: "Checkout cancelled".to_string() })?
114        }
115        _ = server => {
116            Err(CloudError::CheckoutFlow { message: "Server stopped unexpectedly".to_string() })
117        }
118        () = tokio::time::sleep(Duration::from_secs(CALLBACK_TIMEOUT_SECS)) => {
119            Err(CloudError::CheckoutFlow { message: format!("Checkout timed out after {CALLBACK_TIMEOUT_SECS} seconds") })
120        }
121    }
122}