Recurring subs Phase 6 — cancellation flow (admin + buyer self-serve)
Closes the recurring-subs feature loop: operators can cancel subs from
the admin UI, buyers can self-cancel by submitting their signed
license key. Cancellation is non-destructive — the license stays
valid through end-of-cycle, the renewal worker just stops creating
new invoices because its WHERE filter excludes status='cancelled'.
New API
- GET /v1/admin/subscriptions — list (filter: status=...)
- POST /v1/admin/subscriptions/:id/cancel — operator cancel (audited)
- POST /v1/subscriptions/cancel — buyer self-service; auth
via license_key in body,
verified by signature
Repo helpers (src/subscriptions.rs)
- get_subscription_by_id
- get_subscription_by_license_id (1:1 unique on license_id, used by
buyer self-service)
- list_subscriptions(status_filter, limit)
- cancel_subscription (idempotent UPDATE, returns whether
it actually transitioned)
Behavior details
- Both endpoints fire `subscription.cancelled` webhook with
actor=admin/buyer so operators can distinguish self-service.
- Audit log differentiates by actor_kind: 'admin_api_key' vs
'buyer_license_key'.
- Buyer endpoint returns 401 (not 404) on bad/wrong key so a probe
can't enumerate which licenses have active subs.
- Buyer endpoint returns 401 on revoked or suspended licenses too —
same reason.
- Admin endpoint returns 200 with `{already: <prior_state>}` on
re-cancel (idempotency); 404 on unknown sub.
Tests (+4, total now 57)
- admin_cancel_subscription_happy_path: full flow + DB invariants +
audit row + idempotency
- admin_cancel_unknown_subscription_404s
- buyer_cancel_subscription_via_license_key: full flow + actor_kind
- buyer_cancel_rejects_garbage_key: 401 not 404
Admin UI for the cancel button + subscriptions tab lands in a
follow-up commit (kept this one to the API surface so it's reviewable
in isolation).
This commit is contained in:
@@ -62,6 +62,7 @@ pub mod machines;
|
|||||||
pub mod policies;
|
pub mod policies;
|
||||||
pub mod products;
|
pub mod products;
|
||||||
pub mod purchase;
|
pub mod purchase;
|
||||||
|
pub mod subscriptions;
|
||||||
pub mod buy_page;
|
pub mod buy_page;
|
||||||
pub mod issuer_key;
|
pub mod issuer_key;
|
||||||
pub mod redeem;
|
pub mod redeem;
|
||||||
@@ -333,6 +334,20 @@ pub fn router(state: AppState) -> Router {
|
|||||||
get(policies::list_public_policies),
|
get(policies::list_public_policies),
|
||||||
)
|
)
|
||||||
.route("/v1/admin/tips", get(policies::list_tips))
|
.route("/v1/admin/tips", get(policies::list_tips))
|
||||||
|
// Subscriptions (recurring billing) — admin list + cancel.
|
||||||
|
.route(
|
||||||
|
"/v1/admin/subscriptions",
|
||||||
|
get(subscriptions::admin_list),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/v1/admin/subscriptions/:id/cancel",
|
||||||
|
post(subscriptions::admin_cancel),
|
||||||
|
)
|
||||||
|
// Buyer self-service cancel — auth via license key in the body.
|
||||||
|
.route(
|
||||||
|
"/v1/subscriptions/cancel",
|
||||||
|
post(subscriptions::buyer_cancel),
|
||||||
|
)
|
||||||
// Machines (admin views).
|
// Machines (admin views).
|
||||||
.route("/v1/admin/machines", get(machines::admin_list))
|
.route("/v1/admin/machines", get(machines::admin_list))
|
||||||
.route(
|
.route(
|
||||||
|
|||||||
@@ -0,0 +1,285 @@
|
|||||||
|
//! Subscriptions admin + buyer self-service API.
|
||||||
|
//!
|
||||||
|
//! This is the HTTP surface for the recurring-subscription state machine
|
||||||
|
//! whose underlying schema (migration 0011), helpers (`crate::subscriptions`),
|
||||||
|
//! and renewal worker live elsewhere. v0.2.x ships:
|
||||||
|
//!
|
||||||
|
//! - `GET /v1/admin/subscriptions` — list subscriptions (admin)
|
||||||
|
//! - `POST /v1/admin/subscriptions/:id/cancel` — operator-side cancel (admin)
|
||||||
|
//! - `POST /v1/subscriptions/cancel` — buyer self-service cancel; auth
|
||||||
|
//! is via the buyer's license key
|
||||||
|
//! (no admin token, no cookie).
|
||||||
|
//!
|
||||||
|
//! The cancel paths are deliberately split: admin cancellation is a
|
||||||
|
//! fully-trusted call by the operator (e.g. customer service flow,
|
||||||
|
//! refund follow-through), while the buyer-side endpoint requires the
|
||||||
|
//! caller to prove ownership by sending the signed license key. Both
|
||||||
|
//! paths share the same downstream behavior — flip status to
|
||||||
|
//! `cancelled`, stamp `cancelled_at`, fire a `subscription.cancelled`
|
||||||
|
//! webhook, write an audit row.
|
||||||
|
//!
|
||||||
|
//! Cancellation does NOT immediately revoke the license. The buyer
|
||||||
|
//! keeps access through the end of the current billing cycle (the
|
||||||
|
//! license's `expires_at` is unchanged); the renewal worker simply
|
||||||
|
//! stops creating new invoices because its query filters for
|
||||||
|
//! `status IN ('active', 'past_due')`. This matches industry
|
||||||
|
//! convention (Stripe, Zaprite, etc.) and avoids a UX where the
|
||||||
|
//! buyer cancels mid-month and immediately loses what they paid for.
|
||||||
|
|
||||||
|
use crate::api::admin::{request_context, require_admin};
|
||||||
|
use crate::api::AppState;
|
||||||
|
use crate::error::{AppError, AppResult};
|
||||||
|
use axum::{
|
||||||
|
extract::{Path, Query, State},
|
||||||
|
http::HeaderMap,
|
||||||
|
Json,
|
||||||
|
};
|
||||||
|
use serde::Deserialize;
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct ListQuery {
|
||||||
|
/// Filter on subscription status: 'active' | 'past_due' | 'cancelled' | 'lapsed'.
|
||||||
|
/// Omit to get all.
|
||||||
|
#[serde(default)]
|
||||||
|
pub status: Option<String>,
|
||||||
|
#[serde(default = "default_limit")]
|
||||||
|
pub limit: i64,
|
||||||
|
}
|
||||||
|
fn default_limit() -> i64 {
|
||||||
|
200
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `GET /v1/admin/subscriptions` — list subscriptions for the admin UI.
|
||||||
|
/// Filterable by status. Returned newest-first; renders as a table in
|
||||||
|
/// the SPA's "Subscriptions" tab with action buttons (Cancel / View).
|
||||||
|
pub async fn admin_list(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
Query(q): Query<ListQuery>,
|
||||||
|
) -> AppResult<Json<Value>> {
|
||||||
|
require_admin(&state, &headers)?;
|
||||||
|
if let Some(s) = q.status.as_deref() {
|
||||||
|
if !["active", "past_due", "cancelled", "lapsed"].contains(&s) {
|
||||||
|
return Err(AppError::BadRequest(format!(
|
||||||
|
"unknown status filter '{s}' (allowed: active, past_due, cancelled, lapsed)"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let subs = crate::subscriptions::list_subscriptions(
|
||||||
|
&state.db,
|
||||||
|
q.status.as_deref(),
|
||||||
|
q.limit,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AppError::Internal(e))?;
|
||||||
|
// Hand-shape JSON so we can include cancelled_at consistently and
|
||||||
|
// hide internal fields if any get added later.
|
||||||
|
let payload: Vec<Value> = subs
|
||||||
|
.into_iter()
|
||||||
|
.map(|s| {
|
||||||
|
json!({
|
||||||
|
"id": s.id,
|
||||||
|
"license_id": s.license_id,
|
||||||
|
"policy_id": s.policy_id,
|
||||||
|
"product_id": s.product_id,
|
||||||
|
"period_days": s.period_days,
|
||||||
|
"listed_currency": s.listed_currency,
|
||||||
|
"listed_value": s.listed_value,
|
||||||
|
"status": s.status,
|
||||||
|
"started_at": s.started_at,
|
||||||
|
"next_renewal_at": s.next_renewal_at,
|
||||||
|
"cancelled_at": s.cancelled_at,
|
||||||
|
"consecutive_failures": s.consecutive_failures,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
Ok(Json(json!({ "subscriptions": payload })))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Default)]
|
||||||
|
pub struct CancelReq {
|
||||||
|
/// Optional free-form reason (audit log only — not user-visible).
|
||||||
|
#[serde(default)]
|
||||||
|
pub reason: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `POST /v1/admin/subscriptions/:id/cancel` — admin cancellation.
|
||||||
|
///
|
||||||
|
/// Idempotent: cancelling a sub that's already cancelled (or lapsed)
|
||||||
|
/// returns 200 with `{ok: true, already: <prior_state>}`. Cancelling
|
||||||
|
/// a non-existent sub returns 404.
|
||||||
|
pub async fn admin_cancel(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
Path(id): Path<String>,
|
||||||
|
body: Option<Json<CancelReq>>,
|
||||||
|
) -> AppResult<Json<Value>> {
|
||||||
|
let actor_hash = require_admin(&state, &headers)?;
|
||||||
|
let (ip, ua) = request_context(&headers);
|
||||||
|
let reason = body.and_then(|Json(b)| b.reason).filter(|s| !s.trim().is_empty());
|
||||||
|
|
||||||
|
let sub = crate::subscriptions::get_subscription_by_id(&state.db, &id)
|
||||||
|
.await
|
||||||
|
.map_err(AppError::Internal)?
|
||||||
|
.ok_or_else(|| AppError::NotFound(format!("subscription '{id}'")))?;
|
||||||
|
|
||||||
|
let did_cancel = crate::subscriptions::cancel_subscription(&state.db, &id)
|
||||||
|
.await
|
||||||
|
.map_err(AppError::Internal)?;
|
||||||
|
|
||||||
|
if !did_cancel {
|
||||||
|
// Already in a terminal state.
|
||||||
|
return Ok(Json(json!({
|
||||||
|
"ok": true,
|
||||||
|
"already": sub.status,
|
||||||
|
"subscription_id": id,
|
||||||
|
})));
|
||||||
|
}
|
||||||
|
|
||||||
|
let _ = crate::db::repo::insert_audit(
|
||||||
|
&state.db,
|
||||||
|
"admin_api_key",
|
||||||
|
Some(&actor_hash),
|
||||||
|
"subscription.cancel",
|
||||||
|
Some("subscription"),
|
||||||
|
Some(&id),
|
||||||
|
ip.as_deref(),
|
||||||
|
ua.as_deref(),
|
||||||
|
&json!({
|
||||||
|
"license_id": sub.license_id,
|
||||||
|
"product_id": sub.product_id,
|
||||||
|
"policy_id": sub.policy_id,
|
||||||
|
"reason": reason,
|
||||||
|
"actor": "admin",
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
crate::webhooks::dispatch(
|
||||||
|
&state,
|
||||||
|
"subscription.cancelled",
|
||||||
|
&json!({
|
||||||
|
"subscription_id": id,
|
||||||
|
"license_id": sub.license_id,
|
||||||
|
"product_id": sub.product_id,
|
||||||
|
"policy_id": sub.policy_id,
|
||||||
|
"actor": "admin",
|
||||||
|
"reason": reason,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
Ok(Json(json!({
|
||||||
|
"ok": true,
|
||||||
|
"subscription_id": id,
|
||||||
|
"status": "cancelled",
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct BuyerCancelReq {
|
||||||
|
/// The buyer's full license key (LIC1...). Used as proof-of-ownership
|
||||||
|
/// — we re-validate it (signature + DB row) before honoring the
|
||||||
|
/// cancellation. There is no admin token / no cookie / no email.
|
||||||
|
pub license_key: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub reason: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `POST /v1/subscriptions/cancel` — buyer self-service cancellation.
|
||||||
|
///
|
||||||
|
/// Authentication: the request body carries the full signed license key.
|
||||||
|
/// We decode + verify the signature, look up the license, then resolve
|
||||||
|
/// the subscription tied to that license_id. This means a cancellation
|
||||||
|
/// CAN be initiated by anyone holding the key — which is the same
|
||||||
|
/// trust model as the rest of `/v1/validate`. If the buyer has shared
|
||||||
|
/// their key, that's already a security problem they need to rotate.
|
||||||
|
///
|
||||||
|
/// Returns the same shape as the admin endpoint so SDK code paths can
|
||||||
|
/// share a parser. Fires the `subscription.cancelled` webhook with
|
||||||
|
/// `actor=buyer` so operators can distinguish self-service cancels.
|
||||||
|
pub async fn buyer_cancel(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
Json(body): Json<BuyerCancelReq>,
|
||||||
|
) -> AppResult<Json<Value>> {
|
||||||
|
let (ip, ua) = request_context(&headers);
|
||||||
|
|
||||||
|
// Verify the license key against our pubkey + DB row.
|
||||||
|
// parse_key returns (payload, signature, signed_bytes). verify_payload
|
||||||
|
// takes the raw signed_bytes (NOT a re-serialized payload, because v1
|
||||||
|
// keys round-trip through a different serializer and we'd break the
|
||||||
|
// signature if we re-encoded).
|
||||||
|
let (payload, signature, signed_bytes) =
|
||||||
|
crate::crypto::parse_key(&body.license_key).map_err(|_| AppError::Unauthorized)?;
|
||||||
|
crate::crypto::verify_payload(&state.keypair.verifying, &signed_bytes, &signature)
|
||||||
|
.map_err(|_| AppError::Unauthorized)?;
|
||||||
|
|
||||||
|
let license_id = payload.license_id.to_string();
|
||||||
|
let license = crate::db::repo::get_license_by_id(&state.db, &license_id)
|
||||||
|
.await?
|
||||||
|
.ok_or(AppError::Unauthorized)?;
|
||||||
|
if license.revoked_at.is_some() || license.suspended_at.is_some() {
|
||||||
|
// Don't leak revocation state via a 404; treat as not-authorized.
|
||||||
|
return Err(AppError::Unauthorized);
|
||||||
|
}
|
||||||
|
|
||||||
|
let sub = crate::subscriptions::get_subscription_by_license_id(&state.db, &license_id)
|
||||||
|
.await
|
||||||
|
.map_err(AppError::Internal)?
|
||||||
|
.ok_or_else(|| AppError::NotFound("no subscription tied to this license".into()))?;
|
||||||
|
|
||||||
|
let reason = body.reason.filter(|s| !s.trim().is_empty());
|
||||||
|
|
||||||
|
let did_cancel = crate::subscriptions::cancel_subscription(&state.db, &sub.id)
|
||||||
|
.await
|
||||||
|
.map_err(AppError::Internal)?;
|
||||||
|
|
||||||
|
if !did_cancel {
|
||||||
|
return Ok(Json(json!({
|
||||||
|
"ok": true,
|
||||||
|
"already": sub.status,
|
||||||
|
"subscription_id": sub.id,
|
||||||
|
})));
|
||||||
|
}
|
||||||
|
|
||||||
|
let _ = crate::db::repo::insert_audit(
|
||||||
|
&state.db,
|
||||||
|
"buyer_license_key",
|
||||||
|
Some(&license_id),
|
||||||
|
"subscription.cancel",
|
||||||
|
Some("subscription"),
|
||||||
|
Some(&sub.id),
|
||||||
|
ip.as_deref(),
|
||||||
|
ua.as_deref(),
|
||||||
|
&json!({
|
||||||
|
"license_id": sub.license_id,
|
||||||
|
"product_id": sub.product_id,
|
||||||
|
"policy_id": sub.policy_id,
|
||||||
|
"reason": reason,
|
||||||
|
"actor": "buyer",
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
crate::webhooks::dispatch(
|
||||||
|
&state,
|
||||||
|
"subscription.cancelled",
|
||||||
|
&json!({
|
||||||
|
"subscription_id": sub.id,
|
||||||
|
"license_id": sub.license_id,
|
||||||
|
"product_id": sub.product_id,
|
||||||
|
"policy_id": sub.policy_id,
|
||||||
|
"actor": "buyer",
|
||||||
|
"reason": reason,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
Ok(Json(json!({
|
||||||
|
"ok": true,
|
||||||
|
"subscription_id": sub.id,
|
||||||
|
"status": "cancelled",
|
||||||
|
})))
|
||||||
|
}
|
||||||
@@ -215,6 +215,97 @@ pub async fn find_subscription_for_invoice(
|
|||||||
Ok(row.map(|r| r.get::<String, _>("subscription_id")))
|
Ok(row.map(|r| r.get::<String, _>("subscription_id")))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Look up a subscription by id.
|
||||||
|
pub async fn get_subscription_by_id(
|
||||||
|
pool: &SqlitePool,
|
||||||
|
sub_id: &str,
|
||||||
|
) -> Result<Option<Subscription>> {
|
||||||
|
let row = sqlx::query(&format!(
|
||||||
|
"SELECT {SUB_COLS} FROM subscriptions WHERE id = ?"
|
||||||
|
))
|
||||||
|
.bind(sub_id)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await
|
||||||
|
.context("get_subscription_by_id")?;
|
||||||
|
Ok(row.map(row_to_subscription))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Look up the subscription tied to a given license_id. There's at
|
||||||
|
/// most one (the schema enforces 1:1 via UNIQUE on license_id) — used
|
||||||
|
/// by the buyer self-service cancel endpoint, which authenticates via
|
||||||
|
/// license key, not subscription id.
|
||||||
|
pub async fn get_subscription_by_license_id(
|
||||||
|
pool: &SqlitePool,
|
||||||
|
license_id: &str,
|
||||||
|
) -> Result<Option<Subscription>> {
|
||||||
|
let row = sqlx::query(&format!(
|
||||||
|
"SELECT {SUB_COLS} FROM subscriptions WHERE license_id = ?"
|
||||||
|
))
|
||||||
|
.bind(license_id)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await
|
||||||
|
.context("get_subscription_by_license_id")?;
|
||||||
|
Ok(row.map(row_to_subscription))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// List all subscriptions, optionally filtered by status. Used by the
|
||||||
|
/// admin UI's subscriptions tab. Sorted newest-first by started_at.
|
||||||
|
pub async fn list_subscriptions(
|
||||||
|
pool: &SqlitePool,
|
||||||
|
status_filter: Option<&str>,
|
||||||
|
limit: i64,
|
||||||
|
) -> Result<Vec<Subscription>> {
|
||||||
|
let limit = limit.clamp(1, 1000);
|
||||||
|
let rows = if let Some(s) = status_filter {
|
||||||
|
sqlx::query(&format!(
|
||||||
|
"SELECT {SUB_COLS} FROM subscriptions WHERE status = ? \
|
||||||
|
ORDER BY started_at DESC LIMIT ?"
|
||||||
|
))
|
||||||
|
.bind(s)
|
||||||
|
.bind(limit)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await
|
||||||
|
} else {
|
||||||
|
sqlx::query(&format!(
|
||||||
|
"SELECT {SUB_COLS} FROM subscriptions \
|
||||||
|
ORDER BY started_at DESC LIMIT ?"
|
||||||
|
))
|
||||||
|
.bind(limit)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
.context("list_subscriptions query")?;
|
||||||
|
Ok(rows.into_iter().map(row_to_subscription).collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mark a subscription as cancelled. The license stays valid through
|
||||||
|
/// the end of the current cycle (per design doc — no immediate
|
||||||
|
/// revoke); the renewal worker's `WHERE status IN ('active', 'past_due')`
|
||||||
|
/// filter ensures cancelled subs simply stop renewing. Idempotent —
|
||||||
|
/// re-cancelling an already-cancelled sub is a no-op (returns Ok).
|
||||||
|
pub async fn cancel_subscription(
|
||||||
|
pool: &SqlitePool,
|
||||||
|
sub_id: &str,
|
||||||
|
) -> Result<bool> {
|
||||||
|
let now = Utc::now().to_rfc3339();
|
||||||
|
let rows = sqlx::query(
|
||||||
|
"UPDATE subscriptions \
|
||||||
|
SET status = 'cancelled', cancelled_at = ?, updated_at = ? \
|
||||||
|
WHERE id = ? AND status IN ('active', 'past_due')",
|
||||||
|
)
|
||||||
|
.bind(&now)
|
||||||
|
.bind(&now)
|
||||||
|
.bind(sub_id)
|
||||||
|
.execute(pool)
|
||||||
|
.await
|
||||||
|
.context("cancel_subscription")?
|
||||||
|
.rows_affected();
|
||||||
|
// rows_affected = 0 means the sub was already cancelled, lapsed,
|
||||||
|
// or doesn't exist. Return false so the caller can decide whether
|
||||||
|
// that's a 404 (caller already verified existence) or a no-op.
|
||||||
|
Ok(rows > 0)
|
||||||
|
}
|
||||||
|
|
||||||
/// Atomic creation of a subscription + the first cycle's invoice.
|
/// Atomic creation of a subscription + the first cycle's invoice.
|
||||||
/// Used at purchase time when an operator's policy has
|
/// Used at purchase time when an operator's policy has
|
||||||
/// `is_recurring = 1`. Not invoked by the worker (the worker
|
/// `is_recurring = 1`. Not invoked by the worker (the worker
|
||||||
|
|||||||
@@ -2091,3 +2091,254 @@ async fn edit_policy_to_recurring_respects_tier_gate() {
|
|||||||
"name-only patch on a recurring policy must not re-fire the tier gate"
|
"name-only patch on a recurring policy must not re-fire the tier gate"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// Subscription cancellation (Phase 6)
|
||||||
|
//
|
||||||
|
// Admin cancel: full trust, just needs the bearer token + the sub id.
|
||||||
|
// Buyer cancel: auth via license key in the body. The cancelled state
|
||||||
|
// is terminal — license stays valid through end-of-cycle, renewal
|
||||||
|
// worker stops creating new invoices, webhook fires.
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Helper: seed a license + active subscription tied to it, plus a
|
||||||
|
/// product + recurring policy. Returns (license_id, sub_id, key_string)
|
||||||
|
/// where `key_string` is the signed license key the buyer would have
|
||||||
|
/// in hand (used by the self-service cancel test).
|
||||||
|
async fn seed_subscription(state: &AppState) -> (String, String, String) {
|
||||||
|
let product = repo::create_product(
|
||||||
|
&state.db,
|
||||||
|
"sub-cancel-prod",
|
||||||
|
"Cancel Test",
|
||||||
|
"",
|
||||||
|
25_000,
|
||||||
|
&json!({}),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("create_product");
|
||||||
|
let policy = repo::create_policy(
|
||||||
|
&state.db,
|
||||||
|
&product.id,
|
||||||
|
"Monthly",
|
||||||
|
"monthly",
|
||||||
|
30 * 86_400,
|
||||||
|
0,
|
||||||
|
1,
|
||||||
|
false,
|
||||||
|
None,
|
||||||
|
&[],
|
||||||
|
&json!({}),
|
||||||
|
None,
|
||||||
|
0,
|
||||||
|
None,
|
||||||
|
repo::RecurringConfig {
|
||||||
|
is_recurring: true,
|
||||||
|
renewal_period_days: 30,
|
||||||
|
grace_period_days: 7,
|
||||||
|
trial_days: 0,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("create_policy");
|
||||||
|
|
||||||
|
let license_id = Uuid::new_v4();
|
||||||
|
let issued_at = Utc::now();
|
||||||
|
repo::create_license(
|
||||||
|
&state.db,
|
||||||
|
&license_id.to_string(),
|
||||||
|
&product.id,
|
||||||
|
None,
|
||||||
|
&issued_at.to_rfc3339(),
|
||||||
|
&json!({}),
|
||||||
|
Some(&policy.id),
|
||||||
|
None,
|
||||||
|
0,
|
||||||
|
1,
|
||||||
|
&[],
|
||||||
|
false,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("create_license");
|
||||||
|
|
||||||
|
// Seed a placeholder cycle-1 invoice so the FK on subscription_invoices
|
||||||
|
// is satisfiable — the invoice details don't matter for the cancel
|
||||||
|
// tests, only that a row exists.
|
||||||
|
let invoice_id = Uuid::new_v4().to_string();
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO invoices(id, btcpay_invoice_id, product_id, amount_sats, \
|
||||||
|
checkout_url, status, created_at, updated_at, listed_currency, \
|
||||||
|
listed_value, policy_id) \
|
||||||
|
VALUES(?, ?, ?, 0, ?, 'pending', ?, ?, 'SAT', 0, ?)",
|
||||||
|
)
|
||||||
|
.bind(&invoice_id)
|
||||||
|
.bind(&format!("test-inv-{}", &invoice_id[..8]))
|
||||||
|
.bind(&product.id)
|
||||||
|
.bind("http://test.invalid/inv")
|
||||||
|
.bind(issued_at.to_rfc3339())
|
||||||
|
.bind(issued_at.to_rfc3339())
|
||||||
|
.bind(&policy.id)
|
||||||
|
.execute(&state.db)
|
||||||
|
.await
|
||||||
|
.expect("seed invoice");
|
||||||
|
|
||||||
|
let sub = keysat::subscriptions::create_subscription(
|
||||||
|
&state.db,
|
||||||
|
&license_id.to_string(),
|
||||||
|
&policy.id,
|
||||||
|
&product.id,
|
||||||
|
30,
|
||||||
|
"SAT",
|
||||||
|
25_000,
|
||||||
|
&invoice_id,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("create_subscription");
|
||||||
|
|
||||||
|
// Build a real signed key the buyer-cancel endpoint can verify.
|
||||||
|
let product_uuid = Uuid::parse_str(&product.id).expect("product id is uuid");
|
||||||
|
let payload = LicensePayload {
|
||||||
|
version: 2,
|
||||||
|
flags: 0,
|
||||||
|
product_id: product_uuid,
|
||||||
|
license_id,
|
||||||
|
issued_at: issued_at.timestamp(),
|
||||||
|
expires_at: 0,
|
||||||
|
fingerprint_hash: [0; 32],
|
||||||
|
entitlements: vec![],
|
||||||
|
};
|
||||||
|
let signature = crypto::sign_payload(&state.keypair.signing, &payload);
|
||||||
|
let key_string = crypto::encode_key(&payload, &signature);
|
||||||
|
|
||||||
|
(license_id.to_string(), sub.id, key_string)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn admin_cancel_subscription_happy_path() {
|
||||||
|
let (state, _tmp) = make_test_state().await;
|
||||||
|
let auth = format!("Bearer {}", TEST_ADMIN_KEY);
|
||||||
|
let (_license_id, sub_id, _key) = seed_subscription(&state).await;
|
||||||
|
|
||||||
|
// Cancel.
|
||||||
|
let req = build_request(
|
||||||
|
"POST",
|
||||||
|
&format!("/v1/admin/subscriptions/{}/cancel", sub_id),
|
||||||
|
&[("authorization", &auth)],
|
||||||
|
Some(json!({"reason": "customer requested"})),
|
||||||
|
);
|
||||||
|
let resp = send(&state, req).await;
|
||||||
|
assert_eq!(resp.status(), StatusCode::OK);
|
||||||
|
let body = body_json(resp).await;
|
||||||
|
assert_eq!(body["ok"], true);
|
||||||
|
assert_eq!(body["status"], "cancelled");
|
||||||
|
|
||||||
|
// DB row reflects the new state + cancelled_at is stamped.
|
||||||
|
let (status, cancelled_at): (String, Option<String>) = sqlx::query_as(
|
||||||
|
"SELECT status, cancelled_at FROM subscriptions WHERE id = ?",
|
||||||
|
)
|
||||||
|
.bind(&sub_id)
|
||||||
|
.fetch_one(&state.db)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(status, "cancelled");
|
||||||
|
assert!(cancelled_at.is_some(), "cancelled_at must be stamped");
|
||||||
|
|
||||||
|
// Audit row exists.
|
||||||
|
let n: i64 = sqlx::query_scalar(
|
||||||
|
"SELECT COUNT(*) FROM audit_log WHERE action = 'subscription.cancel' \
|
||||||
|
AND target_id = ?",
|
||||||
|
)
|
||||||
|
.bind(&sub_id)
|
||||||
|
.fetch_one(&state.db)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(n, 1, "exactly one audit row for the cancel");
|
||||||
|
|
||||||
|
// Idempotency: cancelling a cancelled sub returns ok with the prior state.
|
||||||
|
let req = build_request(
|
||||||
|
"POST",
|
||||||
|
&format!("/v1/admin/subscriptions/{}/cancel", sub_id),
|
||||||
|
&[("authorization", &auth)],
|
||||||
|
Some(json!({})),
|
||||||
|
);
|
||||||
|
let resp = send(&state, req).await;
|
||||||
|
assert_eq!(resp.status(), StatusCode::OK);
|
||||||
|
let body = body_json(resp).await;
|
||||||
|
assert_eq!(body["already"], "cancelled");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn admin_cancel_unknown_subscription_404s() {
|
||||||
|
let (state, _tmp) = make_test_state().await;
|
||||||
|
let auth = format!("Bearer {}", TEST_ADMIN_KEY);
|
||||||
|
|
||||||
|
let req = build_request(
|
||||||
|
"POST",
|
||||||
|
"/v1/admin/subscriptions/no-such-sub/cancel",
|
||||||
|
&[("authorization", &auth)],
|
||||||
|
Some(json!({})),
|
||||||
|
);
|
||||||
|
let resp = send(&state, req).await;
|
||||||
|
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn buyer_cancel_subscription_via_license_key() {
|
||||||
|
let (state, _tmp) = make_test_state().await;
|
||||||
|
let (_license_id, sub_id, key_string) = seed_subscription(&state).await;
|
||||||
|
|
||||||
|
// Buyer self-cancels by POSTing the signed key. No admin auth.
|
||||||
|
let req = build_request(
|
||||||
|
"POST",
|
||||||
|
"/v1/subscriptions/cancel",
|
||||||
|
&[],
|
||||||
|
Some(json!({
|
||||||
|
"license_key": key_string,
|
||||||
|
"reason": "no longer needed"
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
let resp = send(&state, req).await;
|
||||||
|
assert_eq!(
|
||||||
|
resp.status(),
|
||||||
|
StatusCode::OK,
|
||||||
|
"buyer cancel should succeed with a valid key"
|
||||||
|
);
|
||||||
|
let body = body_json(resp).await;
|
||||||
|
assert_eq!(body["status"], "cancelled");
|
||||||
|
assert_eq!(body["subscription_id"], sub_id);
|
||||||
|
|
||||||
|
// Audit row carries actor=buyer.
|
||||||
|
let actor: Option<String> = sqlx::query_scalar(
|
||||||
|
"SELECT actor_kind FROM audit_log WHERE target_id = ? \
|
||||||
|
AND action = 'subscription.cancel'",
|
||||||
|
)
|
||||||
|
.bind(&sub_id)
|
||||||
|
.fetch_optional(&state.db)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
actor.as_deref(),
|
||||||
|
Some("buyer_license_key"),
|
||||||
|
"audit must record the buyer-key actor kind"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn buyer_cancel_rejects_garbage_key() {
|
||||||
|
let (state, _tmp) = make_test_state().await;
|
||||||
|
let _ = seed_subscription(&state).await;
|
||||||
|
|
||||||
|
let req = build_request(
|
||||||
|
"POST",
|
||||||
|
"/v1/subscriptions/cancel",
|
||||||
|
&[],
|
||||||
|
Some(json!({"license_key": "not-a-real-key"})),
|
||||||
|
);
|
||||||
|
let resp = send(&state, req).await;
|
||||||
|
assert_eq!(
|
||||||
|
resp.status(),
|
||||||
|
StatusCode::UNAUTHORIZED,
|
||||||
|
"garbage key must be 401, not 404 — don't leak which subs exist"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user