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