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:
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user