From 16e50d67f9479b7084e23a1081619d38cc41fafb Mon Sep 17 00:00:00 2001 From: Padreug Date: Sun, 3 May 2026 16:19:52 +0200 Subject: [PATCH] Extract provision_merchant() service for shared use MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both _auto_create_merchant (lazy GET fallback in views_api) and LNbits' _create_default_merchant (eager signup hook) used to reimplement merchant + zone + stall creation independently. Moves the canonical implementation to services.provision_merchant() so both call sites stay in lockstep — future changes (NIP-17 kind 10050 relay list, additional default zones, etc.) only happen in one place. - services.provision_merchant(user_id, wallet_id, public_key, private_key, display_name, config): creates merchant if absent, default 'Online' zone, default ''s Store' stall, and publishes the kind 30017 stall event. Idempotent on the merchant pubkey: returns the existing merchant unchanged if one exists. - views_api._auto_create_merchant: now a 10-line wrapper that loads the account, generates fallback keys if missing, then delegates. The LNbits-side hook (lnbits/core/services/users.py:_create_default_merchant) will be updated in a companion commit to also call this service. Co-Authored-By: Claude Opus 4.6 (1M context) --- services.py | 71 ++++++++++++++++++++++++++++++++++++++++++++++++++++ views_api.py | 67 +++++++++++-------------------------------------- 2 files changed, 86 insertions(+), 52 deletions(-) diff --git a/services.py b/services.py index 2a7159e..f57788c 100644 --- a/services.py +++ b/services.py @@ -12,9 +12,11 @@ from .crud import ( CustomerProfile, create_customer, create_direct_message, + create_merchant, create_order, create_product, create_stall, + create_zone, get_customer, get_last_direct_messages_created_at, get_last_product_update_time, @@ -42,6 +44,7 @@ from .models import ( DirectMessage, DirectMessageType, Merchant, + MerchantConfig, Nostrable, Order, OrderContact, @@ -49,11 +52,13 @@ from .models import ( OrderItem, OrderStatusUpdate, PartialDirectMessage, + PartialMerchant, PartialOrder, PaymentOption, PaymentRequest, Product, Stall, + Zone, ) from .nostr.event import NostrEvent from .nostr.nip59 import unwrap_message, wrap_message @@ -180,6 +185,72 @@ async def sign_and_send_to_nostr( return event +async def provision_merchant( + user_id: str, + wallet_id: str, + public_key: str, + private_key: str, + display_name: Optional[str] = None, + config: Optional[MerchantConfig] = None, +) -> Merchant: + """ + Provision a merchant with a default shipping zone and default stall, + and publish the stall to Nostr relays. + + Single source of truth used by: + - LNbits user-creation hook (eager, on signup) — see + lnbits/core/services/users.py:_create_default_merchant + - nostrmarket views_api._auto_create_merchant (lazy, on first GET + /api/v1/merchant when a merchant is missing). + + Idempotent on the merchant: if a merchant with this pubkey already + exists, returns it without recreating zone/stall. + """ + existing = await get_merchant_by_pubkey(public_key) + if existing: + return existing + + partial_merchant = PartialMerchant( + private_key=private_key, + public_key=public_key, + config=config or MerchantConfig(), + ) + merchant = await create_merchant(user_id, partial_merchant) + + online_zone = Zone( + id=f"online-{merchant.public_key}", + name="Online", + currency="sat", + cost=0, + countries=["Free (digital)"], + ) + await create_zone(merchant.id, online_zone) + + name = display_name or "My" + default_stall = Stall( + wallet=wallet_id, + name=f"{name}'s Store", + currency="sat", + shipping_zones=[online_zone], + ) + default_stall = await create_stall(merchant.id, default_stall) + + # Publish the kind 30017 stall event so customers' clients can resolve + # the stall name when they fetch products. Non-fatal on failure: a + # later product publish (or webapp self-heal) will retry. + try: + stall_event = await sign_and_send_to_nostr(merchant, default_stall) + default_stall.event_id = stall_event.id + await update_stall(merchant.id, default_stall) + except Exception as ex: + logger.warning( + f"[NOSTRMARKET] Failed to publish default stall for " + f"merchant {merchant.id}: {ex}" + ) + + return merchant + + async def handle_order_paid(order_id: str, merchant_pubkey: str): try: order = await update_order_paid_status(order_id, True) diff --git a/views_api.py b/views_api.py index 8241162..0e78bc3 100644 --- a/views_api.py +++ b/views_api.py @@ -83,6 +83,7 @@ from .models import ( from .services import ( build_order_with_payment, create_or_update_order_from_dm, + provision_merchant, reply_to_structured_dm, resubscribe_to_all_merchants, send_dm, @@ -99,72 +100,34 @@ async def _auto_create_merchant( config: MerchantConfig | None = None, ) -> Merchant: """ - Provision a merchant record from the user's account keypair. - Called automatically on first GET or explicitly via POST. + Lazy fallback: provision a merchant from the user's account keypair when + the LNbits-side eager provisioning didn't run (e.g., older accounts, or + upstream LNbits without our signup hook). - Also creates a default "Online" shipping zone and a default stall named - after the user, then publishes the stall to relays so that any product - the user creates references a stall the customer-facing client can find. + Delegates to services.provision_merchant — the canonical implementation. """ account = await get_account(wallet.wallet.user) assert account, "User account not found" - # In our fork, accounts always have keypairs. - # Generate as fallback only if somehow missing. + # In our fork, accounts always have keypairs. Generate as fallback only + # if somehow missing (e.g., upstream LNbits where this isn't auto-set). if not account.pubkey or not account.prvkey: private_key, public_key = generate_keypair() account.pubkey = public_key account.prvkey = private_key await update_account(account) - else: - public_key = account.pubkey - private_key = account.prvkey - existing_merchant = await get_merchant_by_pubkey(public_key) - assert existing_merchant is None, "A merchant already uses this public key" - - partial_merchant = PartialMerchant( - private_key=private_key, - public_key=public_key, - config=config or MerchantConfig(), + merchant = await provision_merchant( + user_id=wallet.wallet.user, + wallet_id=wallet.wallet.id, + public_key=account.pubkey, + private_key=account.prvkey, + display_name=account.username, + config=config, ) - merchant = await create_merchant(wallet.wallet.user, partial_merchant) - - online_zone = Zone( - id=f"online-{merchant.public_key}", - name="Online", - currency="sat", - cost=0, - countries=["Free (digital)"], - ) - await create_zone(merchant.id, online_zone) - - # Create + publish a default stall so products created through the UI - # always have a published parent. Without this, a product publish lands - # on relays referencing a stall_id that no relay has seen, and the - # customer client renders "Unknown Stall". - display_name = account.username or "My" - default_stall = Stall( - wallet=wallet.wallet.id, - name=f"{display_name}'s Store", - currency="sat", - shipping_zones=[online_zone], - ) - default_stall = await create_stall(merchant.id, default_stall) - try: - stall_event = await sign_and_send_to_nostr(merchant, default_stall) - default_stall.event_id = stall_event.id - await update_stall(merchant.id, default_stall) - except Exception as ex: - # Non-fatal: merchant is usable; a product publish (or self-heal) - # will republish the stall later. - logger.warning( - f"[NOSTRMARKET] Failed to publish default stall for {merchant.id}: {ex}" - ) - await resubscribe_to_all_merchants() - await nostr_client.merchant_temp_subscription(public_key) + await nostr_client.merchant_temp_subscription(account.pubkey) return merchant