Wire product→merchant-profile write path
Multi-profile resolution shipped in :52 but nothing wrote products.merchant_profile_id, so it was non-functional end to end. Add merchant_profile_id to the Product model + all four product SELECTs, a set_product_merchant_profile writer (validates the target profile exists, returning 404 instead of a raw FK-violation 500), and thread an optional field through CreateProductReq (post-write) and UpdateProductReq (double-Option; Some(None) clears to default). The admin SPA product form shows a profile picker only when >1 profile exists. Mirrors the entitlements-catalog post-write pattern. Tests: repo round-trip (attach/resolve/clear/bad-id) + HTTP handler arms. api suite 54→56, full suite green.
This commit is contained in:
@@ -107,6 +107,11 @@ pub struct CreateProductReq {
|
|||||||
/// policies can carry any entitlement string.
|
/// policies can carry any entitlement string.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub entitlements_catalog: Option<Vec<crate::models::EntitlementDef>>,
|
pub entitlements_catalog: Option<Vec<crate::models::EntitlementDef>>,
|
||||||
|
/// Merchant profile to attach the product to (migration 0020).
|
||||||
|
/// Omit / null to resolve to the default profile. Only meaningful
|
||||||
|
/// when the operator runs more than one profile.
|
||||||
|
#[serde(default)]
|
||||||
|
pub merchant_profile_id: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Currencies the admin endpoints accept. Whitelist enforced here so
|
/// Currencies the admin endpoints accept. Whitelist enforced here so
|
||||||
@@ -212,6 +217,17 @@ pub async fn create_product(
|
|||||||
} else {
|
} else {
|
||||||
product
|
product
|
||||||
};
|
};
|
||||||
|
// Attach to a merchant profile if the operator picked one (same
|
||||||
|
// post-write pattern as the entitlements catalog). Omitted = NULL =
|
||||||
|
// resolves to the default profile. A bad profile id 404s here AFTER
|
||||||
|
// the row exists, leaving it with a NULL profile — benign (resolves
|
||||||
|
// to default; reattach or delete). The admin UI only offers existing
|
||||||
|
// profiles, so this is an API-direct edge only.
|
||||||
|
let product = if let Some(profile_id) = req.merchant_profile_id.as_deref() {
|
||||||
|
repo::set_product_merchant_profile(&state.db, &product.id, Some(profile_id)).await?
|
||||||
|
} else {
|
||||||
|
product
|
||||||
|
};
|
||||||
let _ = repo::insert_audit(
|
let _ = repo::insert_audit(
|
||||||
&state.db,
|
&state.db,
|
||||||
"admin_api_key",
|
"admin_api_key",
|
||||||
@@ -437,6 +453,20 @@ pub struct UpdateProductReq {
|
|||||||
/// string until the catalog is set again.
|
/// string until the catalog is set again.
|
||||||
#[serde(default, deserialize_with = "deser_double_option_catalog", skip_serializing_if = "Option::is_none")]
|
#[serde(default, deserialize_with = "deser_double_option_catalog", skip_serializing_if = "Option::is_none")]
|
||||||
pub entitlements_catalog: Option<Option<Vec<crate::models::EntitlementDef>>>,
|
pub entitlements_catalog: Option<Option<Vec<crate::models::EntitlementDef>>>,
|
||||||
|
/// Reassign the product's merchant profile (migration 0020).
|
||||||
|
/// `Some(Some(id))` attaches, `Some(None)` clears it back to
|
||||||
|
/// default-resolution, omit / absent leaves it unchanged.
|
||||||
|
#[serde(default, deserialize_with = "deser_double_option_profile", skip_serializing_if = "Option::is_none")]
|
||||||
|
pub merchant_profile_id: Option<Option<String>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Serde adapter for the nullable merchant-profile patch — same
|
||||||
|
/// "omitted vs null vs value" three-way distinction as the catalog.
|
||||||
|
fn deser_double_option_profile<'de, D>(de: D) -> Result<Option<Option<String>>, D::Error>
|
||||||
|
where
|
||||||
|
D: serde::Deserializer<'de>,
|
||||||
|
{
|
||||||
|
Option::<String>::deserialize(de).map(Some)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Serde adapter — distinguishes "field omitted" (None) from
|
/// Serde adapter — distinguishes "field omitted" (None) from
|
||||||
@@ -533,6 +563,16 @@ pub async fn update_product(
|
|||||||
}
|
}
|
||||||
None => updated,
|
None => updated,
|
||||||
};
|
};
|
||||||
|
// Merchant-profile reassignment, same three-way patch as the
|
||||||
|
// catalog: Some(Some) attaches, Some(None) clears to default, None
|
||||||
|
// leaves it untouched.
|
||||||
|
let updated = match &req.merchant_profile_id {
|
||||||
|
Some(Some(profile_id)) => {
|
||||||
|
repo::set_product_merchant_profile(&state.db, &id, Some(profile_id.as_str())).await?
|
||||||
|
}
|
||||||
|
Some(None) => repo::set_product_merchant_profile(&state.db, &id, None).await?,
|
||||||
|
None => updated,
|
||||||
|
};
|
||||||
let _ = repo::insert_audit(
|
let _ = repo::insert_audit(
|
||||||
&state.db,
|
&state.db,
|
||||||
"admin_api_key",
|
"admin_api_key",
|
||||||
|
|||||||
@@ -14,10 +14,10 @@ use uuid::Uuid;
|
|||||||
|
|
||||||
pub async fn list_products(pool: &SqlitePool, only_active: bool) -> AppResult<Vec<Product>> {
|
pub async fn list_products(pool: &SqlitePool, only_active: bool) -> AppResult<Vec<Product>> {
|
||||||
let q = if only_active {
|
let q = if only_active {
|
||||||
"SELECT id, slug, name, description, price_sats, price_currency, price_value, active, metadata_json, entitlements_catalog_json, created_at, updated_at
|
"SELECT id, slug, name, description, price_sats, price_currency, price_value, active, metadata_json, entitlements_catalog_json, merchant_profile_id, created_at, updated_at
|
||||||
FROM products WHERE active = 1 ORDER BY name"
|
FROM products WHERE active = 1 ORDER BY name"
|
||||||
} else {
|
} else {
|
||||||
"SELECT id, slug, name, description, price_sats, price_currency, price_value, active, metadata_json, entitlements_catalog_json, created_at, updated_at
|
"SELECT id, slug, name, description, price_sats, price_currency, price_value, active, metadata_json, entitlements_catalog_json, merchant_profile_id, created_at, updated_at
|
||||||
FROM products ORDER BY name"
|
FROM products ORDER BY name"
|
||||||
};
|
};
|
||||||
let rows = sqlx::query(q).fetch_all(pool).await?;
|
let rows = sqlx::query(q).fetch_all(pool).await?;
|
||||||
@@ -26,7 +26,7 @@ pub async fn list_products(pool: &SqlitePool, only_active: bool) -> AppResult<Ve
|
|||||||
|
|
||||||
pub async fn get_product_by_slug(pool: &SqlitePool, slug: &str) -> AppResult<Option<Product>> {
|
pub async fn get_product_by_slug(pool: &SqlitePool, slug: &str) -> AppResult<Option<Product>> {
|
||||||
let row = sqlx::query(
|
let row = sqlx::query(
|
||||||
"SELECT id, slug, name, description, price_sats, price_currency, price_value, active, metadata_json, entitlements_catalog_json, created_at, updated_at
|
"SELECT id, slug, name, description, price_sats, price_currency, price_value, active, metadata_json, entitlements_catalog_json, merchant_profile_id, created_at, updated_at
|
||||||
FROM products WHERE slug = ?",
|
FROM products WHERE slug = ?",
|
||||||
)
|
)
|
||||||
.bind(slug)
|
.bind(slug)
|
||||||
@@ -37,7 +37,7 @@ pub async fn get_product_by_slug(pool: &SqlitePool, slug: &str) -> AppResult<Opt
|
|||||||
|
|
||||||
pub async fn get_product_by_id(pool: &SqlitePool, id: &str) -> AppResult<Option<Product>> {
|
pub async fn get_product_by_id(pool: &SqlitePool, id: &str) -> AppResult<Option<Product>> {
|
||||||
let row = sqlx::query(
|
let row = sqlx::query(
|
||||||
"SELECT id, slug, name, description, price_sats, price_currency, price_value, active, metadata_json, entitlements_catalog_json, created_at, updated_at
|
"SELECT id, slug, name, description, price_sats, price_currency, price_value, active, metadata_json, entitlements_catalog_json, merchant_profile_id, created_at, updated_at
|
||||||
FROM products WHERE id = ?",
|
FROM products WHERE id = ?",
|
||||||
)
|
)
|
||||||
.bind(id)
|
.bind(id)
|
||||||
@@ -301,6 +301,41 @@ pub async fn set_product_entitlements_catalog(
|
|||||||
.ok_or_else(|| AppError::NotFound(format!("product {product_id}")))
|
.ok_or_else(|| AppError::NotFound(format!("product {product_id}")))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Attach a product to a merchant profile (migration 0020). Pass
|
||||||
|
/// `Some(profile_id)` to set it, `None` to clear it (the product then
|
||||||
|
/// resolves to the default profile). The target profile is validated to
|
||||||
|
/// exist first so a bad id returns a clean 404 rather than surfacing as
|
||||||
|
/// a raw foreign-key-violation 500.
|
||||||
|
pub async fn set_product_merchant_profile(
|
||||||
|
pool: &SqlitePool,
|
||||||
|
product_id: &str,
|
||||||
|
merchant_profile_id: Option<&str>,
|
||||||
|
) -> AppResult<Product> {
|
||||||
|
if let Some(profile_id) = merchant_profile_id {
|
||||||
|
if get_merchant_profile_by_id(pool, profile_id).await?.is_none() {
|
||||||
|
return Err(AppError::NotFound(format!(
|
||||||
|
"merchant profile {profile_id}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let now = Utc::now().to_rfc3339();
|
||||||
|
let rows = sqlx::query(
|
||||||
|
"UPDATE products SET merchant_profile_id = ?, updated_at = ? WHERE id = ?",
|
||||||
|
)
|
||||||
|
.bind(merchant_profile_id)
|
||||||
|
.bind(&now)
|
||||||
|
.bind(product_id)
|
||||||
|
.execute(pool)
|
||||||
|
.await?
|
||||||
|
.rows_affected();
|
||||||
|
if rows == 0 {
|
||||||
|
return Err(AppError::NotFound(format!("product {product_id}")));
|
||||||
|
}
|
||||||
|
get_product_by_id(pool, product_id)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| AppError::NotFound(format!("product {product_id}")))
|
||||||
|
}
|
||||||
|
|
||||||
fn row_to_product(row: sqlx::sqlite::SqliteRow) -> AppResult<Product> {
|
fn row_to_product(row: sqlx::sqlite::SqliteRow) -> AppResult<Product> {
|
||||||
let metadata_json: String = row.try_get("metadata_json")?;
|
let metadata_json: String = row.try_get("metadata_json")?;
|
||||||
let metadata: serde_json::Value = serde_json::from_str(&metadata_json).unwrap_or_default();
|
let metadata: serde_json::Value = serde_json::from_str(&metadata_json).unwrap_or_default();
|
||||||
@@ -326,6 +361,13 @@ fn row_to_product(row: sqlx::sqlite::SqliteRow) -> AppResult<Product> {
|
|||||||
.flatten()
|
.flatten()
|
||||||
.and_then(|s| serde_json::from_str::<Vec<crate::models::EntitlementDef>>(&s).ok())
|
.and_then(|s| serde_json::from_str::<Vec<crate::models::EntitlementDef>>(&s).ok())
|
||||||
.filter(|v| !v.is_empty());
|
.filter(|v| !v.is_empty());
|
||||||
|
// merchant_profile_id lands in migration 0020. NULL = resolves to
|
||||||
|
// the default profile (back-compat); try_get is tolerant of older
|
||||||
|
// rows / SELECTs that predate the column.
|
||||||
|
let merchant_profile_id: Option<String> = row
|
||||||
|
.try_get::<Option<String>, _>("merchant_profile_id")
|
||||||
|
.ok()
|
||||||
|
.flatten();
|
||||||
Ok(Product {
|
Ok(Product {
|
||||||
id: row.try_get("id")?,
|
id: row.try_get("id")?,
|
||||||
slug: row.try_get("slug")?,
|
slug: row.try_get("slug")?,
|
||||||
@@ -337,6 +379,7 @@ fn row_to_product(row: sqlx::sqlite::SqliteRow) -> AppResult<Product> {
|
|||||||
active: active_int != 0,
|
active: active_int != 0,
|
||||||
metadata,
|
metadata,
|
||||||
entitlements_catalog,
|
entitlements_catalog,
|
||||||
|
merchant_profile_id,
|
||||||
created_at: row.try_get("created_at")?,
|
created_at: row.try_get("created_at")?,
|
||||||
updated_at: row.try_get("updated_at")?,
|
updated_at: row.try_get("updated_at")?,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -34,6 +34,12 @@ pub struct Product {
|
|||||||
/// behavior); operators can opt-in by adding rows.
|
/// behavior); operators can opt-in by adding rows.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub entitlements_catalog: Option<Vec<EntitlementDef>>,
|
pub entitlements_catalog: Option<Vec<EntitlementDef>>,
|
||||||
|
/// Merchant profile this product belongs to (migration 0020). None
|
||||||
|
/// resolves to the default profile (back-compat for rows created
|
||||||
|
/// before the operator ran more than one profile). Set via the admin
|
||||||
|
/// product form when >1 profile exists.
|
||||||
|
#[serde(default)]
|
||||||
|
pub merchant_profile_id: Option<String>,
|
||||||
pub created_at: String,
|
pub created_at: String,
|
||||||
pub updated_at: String,
|
pub updated_at: String,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3685,3 +3685,155 @@ async fn merchant_profile_provider_resolution_queries_round_trip() {
|
|||||||
assert_eq!(row_fallback.id, prov);
|
assert_eq!(row_fallback.id, prov);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The product → merchant-profile write path. The resolver
|
||||||
|
/// (`get_merchant_profile_for_product`) already reads
|
||||||
|
/// `products.merchant_profile_id`, but nothing wrote it until
|
||||||
|
/// `set_product_merchant_profile` landed. Drives create (NULL → default),
|
||||||
|
/// attach (resolves to the chosen profile), and clear (back to default),
|
||||||
|
/// plus the bad-id guard.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn product_merchant_profile_write_path_round_trips() {
|
||||||
|
let (state, _tmp) = make_test_state().await;
|
||||||
|
let now = "2026-06-15T00:00:00Z";
|
||||||
|
|
||||||
|
let default = repo::get_default_merchant_profile(&state.db)
|
||||||
|
.await
|
||||||
|
.expect("get_default_merchant_profile")
|
||||||
|
.expect("a default profile exists post-migration");
|
||||||
|
|
||||||
|
// Fresh product: no profile id set. The repo read returns None (the
|
||||||
|
// column is NULL); the production resolver `for_product` applies the
|
||||||
|
// default-profile fallback.
|
||||||
|
let product = repo::create_product(&state.db, "profile-write", "Profile Write", "", 1_000, &json!({}))
|
||||||
|
.await
|
||||||
|
.expect("create_product");
|
||||||
|
assert_eq!(product.merchant_profile_id, None);
|
||||||
|
assert!(
|
||||||
|
repo::get_merchant_profile_for_product(&state.db, &product.id)
|
||||||
|
.await
|
||||||
|
.expect("get_merchant_profile_for_product")
|
||||||
|
.is_none(),
|
||||||
|
"a NULL-profile product yields no direct match"
|
||||||
|
);
|
||||||
|
let resolved = keysat::merchant_profiles::for_product(&state, &product.id)
|
||||||
|
.await
|
||||||
|
.expect("for_product falls back to default");
|
||||||
|
assert_eq!(resolved.id, default.id);
|
||||||
|
|
||||||
|
// Attach to a second profile → reads back + resolves to that profile.
|
||||||
|
let p2 = Uuid::new_v4().to_string();
|
||||||
|
repo::create_merchant_profile(
|
||||||
|
&state.db, &p2, "Second Biz", None, None, None, None, None, false, now,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("create_merchant_profile");
|
||||||
|
let attached = repo::set_product_merchant_profile(&state.db, &product.id, Some(&p2))
|
||||||
|
.await
|
||||||
|
.expect("set_product_merchant_profile attach");
|
||||||
|
assert_eq!(attached.merchant_profile_id.as_deref(), Some(p2.as_str()));
|
||||||
|
let resolved = keysat::merchant_profiles::for_product(&state, &product.id)
|
||||||
|
.await
|
||||||
|
.expect("for_product resolves to attached profile");
|
||||||
|
assert_eq!(resolved.id, p2);
|
||||||
|
|
||||||
|
// Clear back to NULL → resolver falls back to the default again.
|
||||||
|
let cleared = repo::set_product_merchant_profile(&state.db, &product.id, None)
|
||||||
|
.await
|
||||||
|
.expect("set_product_merchant_profile clear");
|
||||||
|
assert_eq!(cleared.merchant_profile_id, None);
|
||||||
|
let resolved = keysat::merchant_profiles::for_product(&state, &product.id)
|
||||||
|
.await
|
||||||
|
.expect("for_product falls back to default after clear");
|
||||||
|
assert_eq!(resolved.id, default.id);
|
||||||
|
|
||||||
|
// Bad profile id is rejected with NotFound, not an FK-violation 500.
|
||||||
|
let err = repo::set_product_merchant_profile(&state.db, &product.id, Some("does-not-exist"))
|
||||||
|
.await
|
||||||
|
.expect_err("bad profile id is rejected");
|
||||||
|
assert!(matches!(err, keysat::error::AppError::NotFound(_)), "got {err:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// HTTP-layer coverage for the product → merchant-profile wiring: the
|
||||||
|
/// thin create/update handler arms (Some / Some(None) / Some(Some(bad)))
|
||||||
|
/// that the repo-level round-trip test above can't reach. Runtime-prepared
|
||||||
|
/// SQL means a typo in those arms only surfaces at execution.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn admin_product_merchant_profile_endpoints() {
|
||||||
|
let (state, _tmp) = make_test_state().await;
|
||||||
|
let auth = format!("Bearer {}", TEST_ADMIN_KEY);
|
||||||
|
|
||||||
|
// Second profile (repo-direct bypasses the Creator tier cap).
|
||||||
|
let p2 = Uuid::new_v4().to_string();
|
||||||
|
repo::create_merchant_profile(
|
||||||
|
&state.db, &p2, "Second Biz", None, None, None, None, None, false,
|
||||||
|
"2026-06-15T00:00:00Z",
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("create_merchant_profile");
|
||||||
|
|
||||||
|
// Create with a valid profile id → 200, echoed back.
|
||||||
|
let req = build_request(
|
||||||
|
"POST",
|
||||||
|
"/v1/admin/products",
|
||||||
|
&[("authorization", &auth)],
|
||||||
|
Some(json!({"slug": "mp-create", "name": "MP Create", "price_sats": 1000, "merchant_profile_id": p2})),
|
||||||
|
);
|
||||||
|
let resp = send(&state, req).await;
|
||||||
|
assert_eq!(resp.status(), StatusCode::OK);
|
||||||
|
let body = body_json(resp).await;
|
||||||
|
assert_eq!(body["merchant_profile_id"], json!(p2));
|
||||||
|
let created_id = body["id"].as_str().expect("product id").to_string();
|
||||||
|
|
||||||
|
// PATCH clear (merchant_profile_id: null) → 200, field cleared.
|
||||||
|
let req = build_request(
|
||||||
|
"PATCH",
|
||||||
|
&format!("/v1/admin/products/{created_id}"),
|
||||||
|
&[("authorization", &auth)],
|
||||||
|
Some(json!({"merchant_profile_id": null})),
|
||||||
|
);
|
||||||
|
let resp = send(&state, req).await;
|
||||||
|
assert_eq!(resp.status(), StatusCode::OK);
|
||||||
|
let body = body_json(resp).await;
|
||||||
|
assert_eq!(body["merchant_profile_id"], json!(null));
|
||||||
|
|
||||||
|
// PATCH set back to the valid profile → 200.
|
||||||
|
let req = build_request(
|
||||||
|
"PATCH",
|
||||||
|
&format!("/v1/admin/products/{created_id}"),
|
||||||
|
&[("authorization", &auth)],
|
||||||
|
Some(json!({"merchant_profile_id": p2})),
|
||||||
|
);
|
||||||
|
let resp = send(&state, req).await;
|
||||||
|
assert_eq!(resp.status(), StatusCode::OK);
|
||||||
|
let body = body_json(resp).await;
|
||||||
|
assert_eq!(body["merchant_profile_id"], json!(p2));
|
||||||
|
|
||||||
|
// PATCH to a nonexistent profile → 404.
|
||||||
|
let req = build_request(
|
||||||
|
"PATCH",
|
||||||
|
&format!("/v1/admin/products/{created_id}"),
|
||||||
|
&[("authorization", &auth)],
|
||||||
|
Some(json!({"merchant_profile_id": "nope"})),
|
||||||
|
);
|
||||||
|
let resp = send(&state, req).await;
|
||||||
|
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
|
||||||
|
|
||||||
|
// Create with a nonexistent profile → 404. The product row is created
|
||||||
|
// before the profile is attached (same post-write order as the
|
||||||
|
// entitlements catalog), so it persists with a NULL profile — benign:
|
||||||
|
// it resolves to the default and the operator can reattach or delete.
|
||||||
|
let req = build_request(
|
||||||
|
"POST",
|
||||||
|
"/v1/admin/products",
|
||||||
|
&[("authorization", &auth)],
|
||||||
|
Some(json!({"slug": "mp-bad", "name": "MP Bad", "price_sats": 1000, "merchant_profile_id": "nope"})),
|
||||||
|
);
|
||||||
|
let resp = send(&state, req).await;
|
||||||
|
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
|
||||||
|
let orphan = repo::get_product_by_slug(&state.db, "mp-bad")
|
||||||
|
.await
|
||||||
|
.expect("get_product_by_slug")
|
||||||
|
.expect("product persisted despite the profile-404");
|
||||||
|
assert_eq!(orphan.merchant_profile_id, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1574,11 +1574,32 @@ hr.div { border:none; border-top:1px solid var(--border-1); margin:18px 0; }
|
|||||||
}
|
}
|
||||||
|
|
||||||
// -------- Products --------
|
// -------- Products --------
|
||||||
|
// Merchant-profile picker for the product create/edit forms. Returns
|
||||||
|
// null when the operator runs 0 or 1 profile — there's nothing to
|
||||||
|
// choose, and the product resolves to the default profile. With >1
|
||||||
|
// profile it returns { element, value() }; `selectedId` pre-selects a
|
||||||
|
// profile, falling back to the default when null.
|
||||||
|
function profileSelectField(profiles, selectedId) {
|
||||||
|
if (!profiles || profiles.length <= 1) return null
|
||||||
|
const sel = el('select', { class: 'input', name: 'p_merchant_profile' },
|
||||||
|
profiles.map((pr) => el('option', { value: pr.id },
|
||||||
|
pr.name + (pr.is_default ? ' (default)' : ''))))
|
||||||
|
const fallback = (profiles.find((pr) => pr.is_default) || profiles[0]).id
|
||||||
|
sel.value = selectedId || fallback
|
||||||
|
const element = el('div', { style: 'margin-top:18px; padding-top:14px; border-top:1px dashed var(--border-1)' }, [
|
||||||
|
el('label', { style: 'display:block; font-weight:600; font-size:13px; margin:0 0 4px' }, 'Merchant profile'),
|
||||||
|
sel,
|
||||||
|
el('div', { class: 'muted', style: 'font-size:12px; margin-top:4px' },
|
||||||
|
'Which business this product sells under — sets the payment provider and branding buyers see at checkout.'),
|
||||||
|
])
|
||||||
|
return { element, value: () => sel.value }
|
||||||
|
}
|
||||||
|
|
||||||
// Edit-product modal. Opens when the operator clicks Edit on a product
|
// Edit-product modal. Opens when the operator clicks Edit on a product
|
||||||
// row. Mutable: name, description, price (currency + value). Slug is
|
// row. Mutable: name, description, price (currency + value), merchant
|
||||||
// intentionally not editable (it's part of the public buy URL —
|
// profile. Slug is intentionally not editable (it's part of the public
|
||||||
// changing it would break bookmarks).
|
// buy URL — changing it would break bookmarks).
|
||||||
function openEditProduct(p) {
|
function openEditProduct(p, profiles) {
|
||||||
const overlay = el('div', {
|
const overlay = el('div', {
|
||||||
style: 'position:fixed; inset:0; background:rgba(14,31,51,0.55); z-index:9999; ' +
|
style: 'position:fixed; inset:0; background:rgba(14,31,51,0.55); z-index:9999; ' +
|
||||||
'display:flex; align-items:center; justify-content:center; padding:20px;',
|
'display:flex; align-items:center; justify-content:center; padding:20px;',
|
||||||
@@ -1586,6 +1607,7 @@ hr.div { border:none; border-top:1px solid var(--border-1); margin:18px 0; }
|
|||||||
const nameField = formInput('e_p_name', 'Display name', { value: p.name || '', required: true })
|
const nameField = formInput('e_p_name', 'Display name', { value: p.name || '', required: true })
|
||||||
const descField = formInput('e_p_description', 'Description', { textarea: true, value: p.description || '' })
|
const descField = formInput('e_p_description', 'Description', { textarea: true, value: p.description || '' })
|
||||||
const editCatalog = catalogEditor(p.entitlements_catalog || null)
|
const editCatalog = catalogEditor(p.entitlements_catalog || null)
|
||||||
|
const editProfile = profileSelectField(profiles, p.merchant_profile_id || null)
|
||||||
|
|
||||||
// Currency-aware price inputs. For SAT-currency products, show
|
// Currency-aware price inputs. For SAT-currency products, show
|
||||||
// the integer sat amount. For USD/EUR, render the cents value
|
// the integer sat amount. For USD/EUR, render the cents value
|
||||||
@@ -1643,6 +1665,7 @@ hr.div { border:none; border-top:1px solid var(--border-1); margin:18px 0; }
|
|||||||
el('div', { style: 'margin-top:18px; padding-top:14px; border-top:1px dashed var(--border-1)' }, [
|
el('div', { style: 'margin-top:18px; padding-top:14px; border-top:1px dashed var(--border-1)' }, [
|
||||||
editCatalog.element,
|
editCatalog.element,
|
||||||
]),
|
]),
|
||||||
|
editProfile && editProfile.element,
|
||||||
status,
|
status,
|
||||||
el('div', { style: 'display:flex; gap:10px; margin-top:14px;' }, [
|
el('div', { style: 'display:flex; gap:10px; margin-top:14px;' }, [
|
||||||
el('button', { class: 'btn primary', onclick: async function () {
|
el('button', { class: 'btn primary', onclick: async function () {
|
||||||
@@ -1663,6 +1686,9 @@ hr.div { border:none; border-top:1px solid var(--border-1); margin:18px 0; }
|
|||||||
// the server treats null as "set to NULL", absent as
|
// the server treats null as "set to NULL", absent as
|
||||||
// "leave alone".
|
// "leave alone".
|
||||||
body.entitlements_catalog = editCatalog.read()
|
body.entitlements_catalog = editCatalog.read()
|
||||||
|
// Only present when >1 profile; always a concrete id when
|
||||||
|
// shown, so this is a Some(Some(id)) reassignment server-side.
|
||||||
|
if (editProfile) body.merchant_profile_id = editProfile.value()
|
||||||
await api('/v1/admin/products/' + p.id, { method: 'PATCH', body })
|
await api('/v1/admin/products/' + p.id, { method: 'PATCH', body })
|
||||||
overlay.remove()
|
overlay.remove()
|
||||||
routes.products()
|
routes.products()
|
||||||
@@ -1691,6 +1717,16 @@ hr.div { border:none; border-top:1px solid var(--border-1); margin:18px 0; }
|
|||||||
const gfBanner = grandfatherBanner(tierStatus, 'products', 'products')
|
const gfBanner = grandfatherBanner(tierStatus, 'products', 'products')
|
||||||
if (gfBanner) target.appendChild(gfBanner)
|
if (gfBanner) target.appendChild(gfBanner)
|
||||||
|
|
||||||
|
// Merchant profiles drive the optional profile picker on the create
|
||||||
|
// + edit forms (rendered only when >1 profile exists). Non-fatal on
|
||||||
|
// error: an empty list just hides the picker, and products resolve
|
||||||
|
// to the default profile.
|
||||||
|
let profiles = []
|
||||||
|
try {
|
||||||
|
const pj = await api('/v1/admin/merchant-profiles')
|
||||||
|
profiles = (pj && pj.profiles) || []
|
||||||
|
} catch (e) { profiles = [] }
|
||||||
|
|
||||||
// Create form. Currency picker swaps the price-input units in
|
// Create form. Currency picker swaps the price-input units in
|
||||||
// place: SAT → integer sats, USD/EUR → dollar/euro amount which
|
// place: SAT → integer sats, USD/EUR → dollar/euro amount which
|
||||||
// we convert to cents on the way out (the backend stores
|
// we convert to cents on the way out (the backend stores
|
||||||
@@ -1721,6 +1757,7 @@ hr.div { border:none; border-top:1px solid var(--border-1); margin:18px 0; }
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
const createCatalog = catalogEditor(null)
|
const createCatalog = catalogEditor(null)
|
||||||
|
const createProfile = profileSelectField(profiles, null)
|
||||||
const create = el('details', { class: 'disclosure' }, [
|
const create = el('details', { class: 'disclosure' }, [
|
||||||
el('summary', null, 'Create a new product'),
|
el('summary', null, 'Create a new product'),
|
||||||
el('div', { class: 'body' }, [
|
el('div', { class: 'body' }, [
|
||||||
@@ -1750,6 +1787,7 @@ hr.div { border:none; border-top:1px solid var(--border-1); margin:18px 0; }
|
|||||||
el('div', { style: 'margin-top:18px; padding-top:14px; border-top:1px dashed var(--border-1)' }, [
|
el('div', { style: 'margin-top:18px; padding-top:14px; border-top:1px dashed var(--border-1)' }, [
|
||||||
createCatalog.element,
|
createCatalog.element,
|
||||||
]),
|
]),
|
||||||
|
createProfile && createProfile.element,
|
||||||
// Pre-check warning when the operator is at cap-1 (or already
|
// Pre-check warning when the operator is at cap-1 (or already
|
||||||
// over) for products. Renders inline above the submit so they
|
// over) for products. Renders inline above the submit so they
|
||||||
// know what to expect before clicking.
|
// know what to expect before clicking.
|
||||||
@@ -1781,6 +1819,7 @@ hr.div { border:none; border-top:1px solid var(--border-1); margin:18px 0; }
|
|||||||
metadata: {},
|
metadata: {},
|
||||||
}
|
}
|
||||||
if (catalog) body.entitlements_catalog = catalog
|
if (catalog) body.entitlements_catalog = catalog
|
||||||
|
if (createProfile) body.merchant_profile_id = createProfile.value()
|
||||||
await api('/v1/admin/products', { method: 'POST', body })
|
await api('/v1/admin/products', { method: 'POST', body })
|
||||||
status.replaceWith(ok('Created. Reloading…'))
|
status.replaceWith(ok('Created. Reloading…'))
|
||||||
setTimeout(routes.products, 600)
|
setTimeout(routes.products, 600)
|
||||||
@@ -1838,7 +1877,7 @@ hr.div { border:none; border-top:1px solid var(--border-1); margin:18px 0; }
|
|||||||
el('td', null, el('div', { class: 'actions-row' }, [
|
el('td', null, el('div', { class: 'actions-row' }, [
|
||||||
el('button', {
|
el('button', {
|
||||||
class: 'btn sm secondary',
|
class: 'btn sm secondary',
|
||||||
onclick: function () { openEditProduct(p) },
|
onclick: function () { openEditProduct(p, profiles) },
|
||||||
}, 'Edit'),
|
}, 'Edit'),
|
||||||
el('button', {
|
el('button', {
|
||||||
class: 'btn sm danger',
|
class: 'btn sm danger',
|
||||||
|
|||||||
Reference in New Issue
Block a user