systemprompt_cloud/checkout/client/
mod.rs1mod 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}