diff --git a/licensing-service/src/api/purchase.rs b/licensing-service/src/api/purchase.rs index 0b1c2dc..4ff0be4 100644 --- a/licensing-service/src/api/purchase.rs +++ b/licensing-service/src/api/purchase.rs @@ -8,10 +8,10 @@ //! present, then stores it locally. use crate::api::AppState; -use crate::btcpay::client::BtcpayClient; use crate::crypto::{encode_key, sign_payload, LicensePayload, FLAG_TRIAL, KEY_VERSION_V2}; use crate::db::repo; use crate::error::{AppError, AppResult}; +use crate::payment::{CreateInvoiceParams, Money}; use axum::{ extract::{Path, State}, Json, @@ -291,59 +291,46 @@ pub async fn start( .filter(|s| !s.is_empty()) .unwrap_or(&default_redirect); - let metadata = BtcpayClient::invoice_metadata(&product.id, &internal_id); - let btcpay = match state.btcpay_client().await { - Ok(c) => c, + // Step C: provider-agnostic invoice creation. The trait method + // handles provider-specific concerns (HMAC-headered request, URL + // rewriting from internal hostname to public, metadata enrichment + // with `orderId`/`source`) inside its impl, so this code path is + // identical for any future provider (Zaprite, etc.). On failure, + // release the slot and bail. + let provider = match state.payment_provider().await { + Ok(p) => p, Err(e) => { - // Release the reserved slot if we have one — BTCPay isn't ready. if let Some(code) = &reservation { let _ = repo::release_code_slot(&state.db, &code.id).await; } return Err(e); } }; - - // Step C: BTCPay invoice. On failure, release the slot and bail. - let created = match btcpay - .create_invoice(final_price, metadata, Some(redirect_url)) + let created = match provider + .create_invoice(CreateInvoiceParams { + amount: Money::sats(final_price), + redirect_url, + // We pass `productId` through for any provider that exposes + // it on its dashboard / receipt. The trait's enrichment + // adds `orderId` (= our internal_id) and `source` so + // webhooks can be correlated to the local invoice row. + metadata: json!({ "productId": product.id }), + external_order_id: &internal_id, + buyer_email: req.buyer_email.as_deref(), + }) .await { - Ok(c) => c, + Ok(handle) => handle, Err(e) => { if let Some(code) = &reservation { let _ = repo::release_code_slot(&state.db, &code.id).await; } return Err(AppError::Upstream(format!( - "BTCPay invoice create failed: {e}" + "payment provider create-invoice failed: {e}" ))); } }; - - // BTCPay returns a checkout URL using whatever URL we called its - // API at — for us, the internal Docker hostname (fast). Rewrite - // the host to the configured public URL so the buyer actually - // gets a link they can open. Falls through unchanged if no public - // URL is configured (test/dev only). - let checkout_url = match &state.config.btcpay_public_url { - Some(public_base) => { - let rewritten = - crate::payment::btcpay::rewrite_to_public(&created.checkout_link, public_base); - tracing::info!( - original = %created.checkout_link, - rewritten = %rewritten, - public_base = %public_base, - "purchase: checkout URL rewritten for buyer" - ); - rewritten - } - None => { - tracing::warn!( - original = %created.checkout_link, - "purchase: checkout URL NOT rewritten — btcpay_public_url is None" - ); - created.checkout_link.clone() - } - }; + let checkout_url = created.checkout_url.clone(); // Step D: persist local invoice. On failure, release the slot. // Use internal_id we pre-generated (and baked into the BTCPay @@ -352,7 +339,7 @@ pub async fn start( let invoice = match repo::create_invoice( &state.db, &internal_id, - &created.id, + &created.provider_invoice_id, &product.id, final_price, &checkout_url, @@ -394,7 +381,7 @@ pub async fn start( and invalidating local invoice" ); let _ = repo::release_code_slot(&state.db, &code.id).await; - let _ = repo::update_invoice_status(&state.db, &created.id, "invalid").await; + let _ = repo::update_invoice_status(&state.db, &created.provider_invoice_id, "invalid").await; return Err(e); } } @@ -403,7 +390,7 @@ pub async fn start( Ok(Json(StartPurchaseResp { invoice_id: invoice.id, - btcpay_invoice_id: created.id, + btcpay_invoice_id: created.provider_invoice_id, checkout_url, amount_sats: final_price, base_price_sats: base_price, diff --git a/licensing-service/tests/api.rs b/licensing-service/tests/api.rs index 5ad3d93..67fbdfa 100644 --- a/licensing-service/tests/api.rs +++ b/licensing-service/tests/api.rs @@ -543,22 +543,78 @@ async fn free_purchase_issues_license_inline() { ); } -// Note on the missing paid-purchase test: -// -// `purchase::start` still uses the legacy compat accessor -// `state.btcpay_client()`, which downcasts the active provider -// specifically to the concrete `BtcpayProvider` type rather than -// going through the `PaymentProvider` trait. A `MockPaymentProvider` -// can't satisfy that downcast — it'd need to BE a `BtcpayProvider`, -// which requires a working HTTP client. -// -// The fix is a small refactor of `purchase::start` to use -// `state.payment_provider().await?.create_invoice(...)` instead of -// the compat path. That's already on the v0.3 backlog (see -// `src/payment/mod.rs` "Why a trait" doc comment). Once it lands, a -// `paid_purchase_creates_invoice_via_provider` test slots right in. -// For now we test the webhook handler — which IS already on the -// trait surface — directly against a fixture invoice. +/// Paid purchase end-to-end through the trait. v0.1.0:43 migrated +/// `purchase::start` off the legacy `state.btcpay_client()` compat +/// accessor onto the abstract `state.payment_provider()` trait +/// surface, which means a `MockPaymentProvider` can drive the path +/// without a real BTCPay roundtrip. +/// +/// Verifies: +/// - the daemon delegates invoice creation to the provider +/// - the returned `provider_invoice_id` is stamped onto the local +/// invoice row's `btcpay_invoice_id` column +/// - the buyer-facing `checkout_url` is whatever the provider +/// returned (mock returns a deterministic stub URL; production +/// BtcpayProvider rewrites the host inside its impl) +/// - no license is issued at this stage (that's the webhook's job) +#[tokio::test] +async fn paid_purchase_creates_invoice_via_provider() { + let (state, _tmp) = make_test_state_with_mock_provider().await; + + repo::create_product( + &state.db, + "paid-test", + "Paid Test", + "", + 10_000, + &json!({}), + ) + .await + .expect("create_product"); + + let req = build_request( + "POST", + "/v1/purchase", + &[], + Some(json!({"product": "paid-test"})), + ); + let resp = send(&state, req).await; + assert_eq!( + resp.status(), + StatusCode::OK, + "paid purchase should succeed against the mock provider" + ); + + let body = body_json(resp).await; + assert_eq!(body["amount_sats"], 10_000); + assert_eq!(body["btcpay_invoice_id"], "mock-inv-1"); + assert!( + body["checkout_url"] + .as_str() + .map_or(false, |s| s.starts_with("http://mock-checkout.test/")), + "checkout_url should pass through from the provider: {body:?}" + ); + assert!( + body["license_key"].is_null(), + "no license should be issued before the settle webhook fires" + ); + + // Pending invoice row exists with the provider's id stamped on it. + let invoice_status: String = sqlx::query_scalar( + "SELECT status FROM invoices WHERE btcpay_invoice_id = 'mock-inv-1'", + ) + .fetch_one(&state.db) + .await + .unwrap(); + assert_eq!(invoice_status, "pending"); + + // No license yet. + let licenses: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM licenses") + .fetch_one(&state.db) + .await + .unwrap(); + assert_eq!(licenses, 0); +} /// The settle webhook: provider POSTs an InvoiceSettled event, daemon /// flips the invoice status and issues a license. Re-POSTing the same