Migrate purchase::start onto PaymentProvider trait + paid-purchase test

Drops the legacy compat path. `purchase::start` now calls
`state.payment_provider().await?.create_invoice(CreateInvoiceParams {
...})` instead of `state.btcpay_client().await?.create_invoice(...)`.
Provider-specific concerns (BTCPay's checkout-URL rewriting from the
internal Docker hostname to the public domain, metadata enrichment
with `orderId` / `source`) move inside the BtcpayProvider impl where
they belong; the same code path now serves any future provider
(Zaprite, etc.) without fork/copy.

URL rewriting is removed from the caller (no longer needs to know
which provider's URLs to rewrite or how). The
`crate::payment::btcpay::rewrite_to_public` function stays on the
provider impl; pubpath unchanged.

Adds `paid_purchase_creates_invoice_via_provider` integration test —
previously deferred per :42's release notes because the compat path
prevented MockPaymentProvider from substituting. Now the mock works
through the same call site as production. Verifies:
  - daemon delegates invoice creation to the provider
  - returned provider_invoice_id is stamped on the local invoice row
  - checkout_url is what the provider returned
  - no license issued at this stage (that's the webhook's job)

Test count: 22 (9 unit + 4 migration + 9 API).
This commit is contained in:
Grant
2026-05-08 09:35:41 -05:00
parent 34704bfa03
commit e2b296ce29
2 changed files with 99 additions and 56 deletions
+27 -40
View File
@@ -8,10 +8,10 @@
//! present, then stores it locally. //! present, then stores it locally.
use crate::api::AppState; use crate::api::AppState;
use crate::btcpay::client::BtcpayClient;
use crate::crypto::{encode_key, sign_payload, LicensePayload, FLAG_TRIAL, KEY_VERSION_V2}; use crate::crypto::{encode_key, sign_payload, LicensePayload, FLAG_TRIAL, KEY_VERSION_V2};
use crate::db::repo; use crate::db::repo;
use crate::error::{AppError, AppResult}; use crate::error::{AppError, AppResult};
use crate::payment::{CreateInvoiceParams, Money};
use axum::{ use axum::{
extract::{Path, State}, extract::{Path, State},
Json, Json,
@@ -291,59 +291,46 @@ pub async fn start(
.filter(|s| !s.is_empty()) .filter(|s| !s.is_empty())
.unwrap_or(&default_redirect); .unwrap_or(&default_redirect);
let metadata = BtcpayClient::invoice_metadata(&product.id, &internal_id); // Step C: provider-agnostic invoice creation. The trait method
let btcpay = match state.btcpay_client().await { // handles provider-specific concerns (HMAC-headered request, URL
Ok(c) => c, // 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) => { Err(e) => {
// Release the reserved slot if we have one — BTCPay isn't ready.
if let Some(code) = &reservation { if let Some(code) = &reservation {
let _ = repo::release_code_slot(&state.db, &code.id).await; let _ = repo::release_code_slot(&state.db, &code.id).await;
} }
return Err(e); return Err(e);
} }
}; };
let created = match provider
// Step C: BTCPay invoice. On failure, release the slot and bail. .create_invoice(CreateInvoiceParams {
let created = match btcpay amount: Money::sats(final_price),
.create_invoice(final_price, metadata, Some(redirect_url)) 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 .await
{ {
Ok(c) => c, Ok(handle) => handle,
Err(e) => { Err(e) => {
if let Some(code) = &reservation { if let Some(code) = &reservation {
let _ = repo::release_code_slot(&state.db, &code.id).await; let _ = repo::release_code_slot(&state.db, &code.id).await;
} }
return Err(AppError::Upstream(format!( return Err(AppError::Upstream(format!(
"BTCPay invoice create failed: {e}" "payment provider create-invoice failed: {e}"
))); )));
} }
}; };
let checkout_url = created.checkout_url.clone();
// 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()
}
};
// Step D: persist local invoice. On failure, release the slot. // Step D: persist local invoice. On failure, release the slot.
// Use internal_id we pre-generated (and baked into the BTCPay // 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( let invoice = match repo::create_invoice(
&state.db, &state.db,
&internal_id, &internal_id,
&created.id, &created.provider_invoice_id,
&product.id, &product.id,
final_price, final_price,
&checkout_url, &checkout_url,
@@ -394,7 +381,7 @@ pub async fn start(
and invalidating local invoice" and invalidating local invoice"
); );
let _ = repo::release_code_slot(&state.db, &code.id).await; 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); return Err(e);
} }
} }
@@ -403,7 +390,7 @@ pub async fn start(
Ok(Json(StartPurchaseResp { Ok(Json(StartPurchaseResp {
invoice_id: invoice.id, invoice_id: invoice.id,
btcpay_invoice_id: created.id, btcpay_invoice_id: created.provider_invoice_id,
checkout_url, checkout_url,
amount_sats: final_price, amount_sats: final_price,
base_price_sats: base_price, base_price_sats: base_price,
+72 -16
View File
@@ -543,22 +543,78 @@ async fn free_purchase_issues_license_inline() {
); );
} }
// Note on the missing paid-purchase test: /// Paid purchase end-to-end through the trait. v0.1.0:43 migrated
// /// `purchase::start` off the legacy `state.btcpay_client()` compat
// `purchase::start` still uses the legacy compat accessor /// accessor onto the abstract `state.payment_provider()` trait
// `state.btcpay_client()`, which downcasts the active provider /// surface, which means a `MockPaymentProvider` can drive the path
// specifically to the concrete `BtcpayProvider` type rather than /// without a real BTCPay roundtrip.
// going through the `PaymentProvider` trait. A `MockPaymentProvider` ///
// can't satisfy that downcast — it'd need to BE a `BtcpayProvider`, /// Verifies:
// which requires a working HTTP client. /// - the daemon delegates invoice creation to the provider
// /// - the returned `provider_invoice_id` is stamped onto the local
// The fix is a small refactor of `purchase::start` to use /// invoice row's `btcpay_invoice_id` column
// `state.payment_provider().await?.create_invoice(...)` instead of /// - the buyer-facing `checkout_url` is whatever the provider
// the compat path. That's already on the v0.3 backlog (see /// returned (mock returns a deterministic stub URL; production
// `src/payment/mod.rs` "Why a trait" doc comment). Once it lands, a /// BtcpayProvider rewrites the host inside its impl)
// `paid_purchase_creates_invoice_via_provider` test slots right in. /// - no license is issued at this stage (that's the webhook's job)
// For now we test the webhook handler — which IS already on the #[tokio::test]
// trait surface — directly against a fixture invoice. 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 /// The settle webhook: provider POSTs an InvoiceSettled event, daemon
/// flips the invoice status and issues a license. Re-POSTing the same /// flips the invoice status and issues a license. Re-POSTing the same