From b088bfc0624792f0c8c03f70658e7acac36a7df3 Mon Sep 17 00:00:00 2001 From: Grant Date: Mon, 15 Jun 2026 21:38:24 -0500 Subject: [PATCH] =?UTF-8?q?Wire=20product=E2=86=92merchant-profile=20write?= =?UTF-8?q?=20path?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- licensing-service/src/api/admin.rs | 40 ++++++++ licensing-service/src/db/repo.rs | 51 +++++++++- licensing-service/src/models.rs | 6 ++ licensing-service/tests/api.rs | 152 +++++++++++++++++++++++++++++ licensing-service/web/index.html | 49 +++++++++- 5 files changed, 289 insertions(+), 9 deletions(-) diff --git a/licensing-service/src/api/admin.rs b/licensing-service/src/api/admin.rs index 62e110f..1fffefe 100644 --- a/licensing-service/src/api/admin.rs +++ b/licensing-service/src/api/admin.rs @@ -107,6 +107,11 @@ pub struct CreateProductReq { /// policies can carry any entitlement string. #[serde(default)] pub entitlements_catalog: Option>, + /// 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, } /// Currencies the admin endpoints accept. Whitelist enforced here so @@ -212,6 +217,17 @@ pub async fn create_product( } else { 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( &state.db, "admin_api_key", @@ -437,6 +453,20 @@ pub struct UpdateProductReq { /// string until the catalog is set again. #[serde(default, deserialize_with = "deser_double_option_catalog", skip_serializing_if = "Option::is_none")] pub entitlements_catalog: Option>>, + /// 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>, +} + +/// 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>, D::Error> +where + D: serde::Deserializer<'de>, +{ + Option::::deserialize(de).map(Some) } /// Serde adapter — distinguishes "field omitted" (None) from @@ -533,6 +563,16 @@ pub async fn update_product( } 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( &state.db, "admin_api_key", diff --git a/licensing-service/src/db/repo.rs b/licensing-service/src/db/repo.rs index 1e7aa44..4302faa 100644 --- a/licensing-service/src/db/repo.rs +++ b/licensing-service/src/db/repo.rs @@ -14,10 +14,10 @@ use uuid::Uuid; pub async fn list_products(pool: &SqlitePool, only_active: bool) -> AppResult> { 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" } 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" }; let rows = sqlx::query(q).fetch_all(pool).await?; @@ -26,7 +26,7 @@ pub async fn list_products(pool: &SqlitePool, only_active: bool) -> AppResult AppResult> { 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 = ?", ) .bind(slug) @@ -37,7 +37,7 @@ pub async fn get_product_by_slug(pool: &SqlitePool, slug: &str) -> AppResult AppResult> { 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 = ?", ) .bind(id) @@ -301,6 +301,41 @@ pub async fn set_product_entitlements_catalog( .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 { + 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 { let metadata_json: String = row.try_get("metadata_json")?; 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 { .flatten() .and_then(|s| serde_json::from_str::>(&s).ok()) .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 = row + .try_get::, _>("merchant_profile_id") + .ok() + .flatten(); Ok(Product { id: row.try_get("id")?, slug: row.try_get("slug")?, @@ -337,6 +379,7 @@ fn row_to_product(row: sqlx::sqlite::SqliteRow) -> AppResult { active: active_int != 0, metadata, entitlements_catalog, + merchant_profile_id, created_at: row.try_get("created_at")?, updated_at: row.try_get("updated_at")?, }) diff --git a/licensing-service/src/models.rs b/licensing-service/src/models.rs index 0f2d71d..91eef0d 100644 --- a/licensing-service/src/models.rs +++ b/licensing-service/src/models.rs @@ -34,6 +34,12 @@ pub struct Product { /// behavior); operators can opt-in by adding rows. #[serde(default)] pub entitlements_catalog: Option>, + /// 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, pub created_at: String, pub updated_at: String, } diff --git a/licensing-service/tests/api.rs b/licensing-service/tests/api.rs index e7b725b..03afe77 100644 --- a/licensing-service/tests/api.rs +++ b/licensing-service/tests/api.rs @@ -3685,3 +3685,155 @@ async fn merchant_profile_provider_resolution_queries_round_trip() { 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); +} + diff --git a/licensing-service/web/index.html b/licensing-service/web/index.html index fa45d8d..cdd3c66 100644 --- a/licensing-service/web/index.html +++ b/licensing-service/web/index.html @@ -1574,11 +1574,32 @@ hr.div { border:none; border-top:1px solid var(--border-1); margin:18px 0; } } // -------- 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 - // row. Mutable: name, description, price (currency + value). Slug is - // intentionally not editable (it's part of the public buy URL — - // changing it would break bookmarks). - function openEditProduct(p) { + // row. Mutable: name, description, price (currency + value), merchant + // profile. Slug is intentionally not editable (it's part of the public + // buy URL — changing it would break bookmarks). + function openEditProduct(p, profiles) { const overlay = el('div', { 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;', @@ -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 descField = formInput('e_p_description', 'Description', { textarea: true, value: p.description || '' }) 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 // 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)' }, [ editCatalog.element, ]), + editProfile && editProfile.element, status, el('div', { style: 'display:flex; gap:10px; margin-top:14px;' }, [ 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 // "leave alone". 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 }) overlay.remove() 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') 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 // place: SAT → integer sats, USD/EUR → dollar/euro amount which // 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 createProfile = profileSelectField(profiles, null) const create = el('details', { class: 'disclosure' }, [ el('summary', null, 'Create a new product'), 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)' }, [ createCatalog.element, ]), + createProfile && createProfile.element, // Pre-check warning when the operator is at cap-1 (or already // over) for products. Renders inline above the submit so they // 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: {}, } if (catalog) body.entitlements_catalog = catalog + if (createProfile) body.merchant_profile_id = createProfile.value() await api('/v1/admin/products', { method: 'POST', body }) status.replaceWith(ok('Created. Reloading…')) 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('button', { class: 'btn sm secondary', - onclick: function () { openEditProduct(p) }, + onclick: function () { openEditProduct(p, profiles) }, }, 'Edit'), el('button', { class: 'btn sm danger',