Extract provision_merchant() service for shared use
Some checks failed
ci.yml / Extract provision_merchant() service for shared use (pull_request) Failing after 0s

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 '<username>'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) <noreply@anthropic.com>
This commit is contained in:
Padreug 2026-05-03 16:19:52 +02:00
commit 16e50d67f9
2 changed files with 86 additions and 52 deletions

View file

@ -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)

View file

@ -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 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}"
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,
)
await resubscribe_to_all_merchants()
await nostr_client.merchant_temp_subscription(public_key)
await nostr_client.merchant_temp_subscription(account.pubkey)
return merchant