diff --git a/models.py b/models.py index 346741e..0d8e3dc 100644 --- a/models.py +++ b/models.py @@ -2,8 +2,8 @@ from sqlite3 import Row from typing import List, Optional, Tuple -from fastapi.param_functions import Query from lnbits.db import FilterModel, FromRowModel +from lnbits.utils.exchange_rates import fiat_amount_as_satoshis from pydantic import BaseModel from .helpers import format_amount, is_ws_url, normalize_identifier, validate_pub_key @@ -13,6 +13,64 @@ class CustomCost(BaseModel): bracket: int amount: float + def validate_data(self): + assert self.bracket >= 0, "Bracket must be positive." + assert self.amount >= 0, "Custom cost must be positive." + + +class PriceData(BaseModel): + currency: str + price: float + discount: float = 0 + referer_bonus: float = 0 + + reason: str + + async def price_sats(self) -> float: + if self.price == 0: + return 0 + if self.currency == "sats": + return self.price + return await fiat_amount_as_satoshis(self.price, self.currency) + + async def discount_sats(self) -> float: + if self.discount == 0: + return 0 + if self.currency == "sats": + return self.discount + return await fiat_amount_as_satoshis(self.discount, self.currency) + + async def referer_bonus_sats(self) -> float: + if self.referer_bonus == 0: + return 0 + if self.currency == "sats": + return self.referer_bonus + return await fiat_amount_as_satoshis(self.referer_bonus, self.currency) + + +class Promotion(BaseModel): + code: str = "" + buyer_discount_percent: float + referer_bonus_percent: float + selected_referer: Optional[str] = None + + def validate_data(self): + assert ( + 0 <= self.buyer_discount_percent <= 100 + ), f"Discount percent for '{self.code}' must be between 0 and 100." + assert ( + 0 <= self.referer_bonus_percent <= 100 + ), f"Referer percent for '{self.code}' must be between 0 and 100." + assert self.buyer_discount_percent + self.referer_bonus_percent <= 100, ( + f"Discount and Referer for '{self.code}'" " must be less than 100%." + ) + + +class PromoCodeStatus(BaseModel): + buyer_discount: Optional[float] = None + allow_referer: bool = False + referer: Optional[str] = None + class RotateAddressData(BaseModel): secret: str @@ -45,30 +103,127 @@ class CreateAddressData(BaseModel): pubkey: str = "" years: int = 1 relays: Optional[List[str]] = None + promo_code: Optional[str] = None + referer: Optional[str] = None create_invoice: bool = False + def normalize(self): + self.local_part = self.local_part.strip() + self.pubkey = self.pubkey.strip() + if self.relays: + self.relays = [r.strip() for r in self.relays] + + if self.promo_code: + self.promo_code = self.promo_code.strip() + if "@" in self.promo_code: + elements = self.promo_code.rsplit("@") + self.promo_code = elements[0] + self.referer = elements[1] + + if self.referer: + self.referer = self.referer.strip() + class DomainCostConfig(BaseModel): max_years: int = 1 - enable_custom_cost: bool = False char_count_cost: List[CustomCost] = [] rank_cost: List[CustomCost] = [] + promotions: List[Promotion] = [] + + def apply_promo_code( + self, amount: float, promo_code: Optional[str] = None + ) -> Tuple[float, float]: + if promo_code is None: + return 0, 0 + promotion = next((p for p in self.promotions if p.code == promo_code), None) + if not promotion: + return 0, 0 + + discount = amount * (promotion.buyer_discount_percent / 100) + referer_bonus = amount * (promotion.referer_bonus_percent / 100) + return round(discount, 2), round(referer_bonus, 2) + + def get_promotion(self, promo_code: Optional[str] = None) -> Optional[Promotion]: + if promo_code is None: + return None + return next((p for p in self.promotions if p.code == promo_code), None) + + def promo_code_buyer_discount(self, promo_code: Optional[str] = None) -> float: + promotion = self.get_promotion(promo_code) + if not promotion: + return 0 + return promotion.buyer_discount_percent + + def promo_code_referer( + self, promo_code: Optional[str] = None, default_referer: Optional[str] = None + ) -> Optional[str]: + promotion = self.get_promotion(promo_code) + if not promotion: + return None + if promotion.referer_bonus_percent == 0: + return None + if promotion.selected_referer: + return promotion.selected_referer + + return default_referer + + def promo_code_allows_referer(self, promo_code: Optional[str] = None) -> bool: + promotion = self.get_promotion(promo_code) + if not promotion: + return False + + return promotion.referer_bonus_percent > 0 and not promotion.selected_referer + + def promo_code_status(self, promo_code: Optional[str] = None) -> PromoCodeStatus: + return PromoCodeStatus( + buyer_discount=self.promo_code_buyer_discount(promo_code), + allow_referer=self.promo_code_allows_referer(promo_code), + referer=self.promo_code_referer(promo_code), + ) + + def validate_data(self): + for cost in self.char_count_cost: + cost.validate_data() + + for cost in self.rank_cost: + cost.validate_data() + + assert ( + 1 < self.max_years < 100 + ), "Maximum allowed years must be between 1 and 100." + promo_codes = [] + for promo in self.promotions: + promo.validate_data() + assert ( + promo.code not in promo_codes + ), f"Duplicate promo code: '{promo.code}'." + promo_codes.append(promo.code) class CreateDomainData(BaseModel): wallet: str currency: str - cost: float = Query(..., ge=0.01) + cost: float domain: str cost_config: Optional[DomainCostConfig] = None + def validate_data(self): + assert self.cost >= 0, "Domain cost must be positive." + if self.cost_config: + self.cost_config.validate_data() + class EditDomainData(BaseModel): id: str currency: str - cost: float = Query(..., ge=0.01) + cost: float cost_config: Optional[DomainCostConfig] = None + def validate_data(self): + assert self.cost >= 0, "Domain cost must be positive." + if self.cost_config: + self.cost_config.validate_data() + @classmethod def from_row(cls, row: Row) -> "EditDomainData": return cls(**dict(row)) @@ -99,33 +254,43 @@ class Domain(PublicDomain): cost_config: DomainCostConfig = DomainCostConfig() time: int - def price_for_identifier( - self, identifier: str, years: int, rank: Optional[int] = None - ) -> Tuple[float, str]: + async def price_for_identifier( + self, + identifier: str, + years: int, + rank: Optional[int] = None, + promo_code: Optional[str] = None, + ) -> PriceData: assert ( 1 <= years <= self.cost_config.max_years ), f"Number of years must be between '1' and '{self.cost_config.max_years}'." identifier = normalize_identifier(identifier) - max_amount = self.cost - reason = "" - if not self.cost_config.enable_custom_cost: - return max_amount * years, reason + max_amount, reason = self.cost, "" for char_cost in self.cost_config.char_count_cost: if len(identifier) <= char_cost.bracket and max_amount < char_cost.amount: max_amount = char_cost.amount reason = f"{len(identifier)} characters" - if not rank: - return max_amount * years, reason - - for rank_cost in self.cost_config.rank_cost: - if rank <= rank_cost.bracket and max_amount < rank_cost.amount: - max_amount = rank_cost.amount - reason = f"Top {rank_cost.bracket} identifier" - - return max_amount * years, reason + if rank: + for rank_cost in self.cost_config.rank_cost: + if rank <= rank_cost.bracket and max_amount < rank_cost.amount: + max_amount = rank_cost.amount + reason = f"Top {rank_cost.bracket} identifier" + + full_price = max_amount * years + discount, referer_bonus = self.cost_config.apply_promo_code( + full_price, promo_code + ) + + return PriceData( + currency=self.currency, + price=full_price - discount, + discount=discount, + referer_bonus=referer_bonus, + reason=reason, + ) def public_data(self): data = dict(PublicDomain(**dict(self))) @@ -156,6 +321,8 @@ class AddressConfig(BaseModel): reimburse_payment_hash: Optional[str] = None activated_by_owner: bool = False years: int = 1 + promo_code: Optional[str] = None + referer: Optional[str] = None max_years: int = 1 relays: List[str] = [] @@ -175,6 +342,8 @@ class Address(FromRowModel): config: AddressConfig = AddressConfig() + promo_code_status: PromoCodeStatus = PromoCodeStatus() + @property def has_pubkey(self): return self.pubkey != "" diff --git a/services.py b/services.py index 4fb780f..b0f973b 100644 --- a/services.py +++ b/services.py @@ -1,10 +1,9 @@ -from typing import List, Optional +from typing import List, Optional, Tuple import httpx from lnbits.core.crud import get_standalone_payment, get_user -from lnbits.core.services import create_invoice +from lnbits.core.services import create_invoice, pay_invoice from lnbits.db import Filters, Page -from lnbits.utils.exchange_rates import fiat_amount_as_satoshis from loguru import logger from .crud import ( @@ -36,6 +35,7 @@ AddressStatus, CreateAddressData, Domain, + PriceData, ) @@ -82,68 +82,62 @@ async def get_user_addresses_paginated( async def get_identifier_status( - domain: Domain, identifier: str, years: int + domain: Domain, identifier: str, years: int, promo_code: Optional[str] = None ) -> AddressStatus: identifier = normalize_identifier(identifier) address = await get_active_address_by_local_part(domain.id, identifier) if address: return AddressStatus(identifier=identifier, available=False) - rank = None - if domain.cost_config.enable_custom_cost: - identifier_ranking = await get_identifier_ranking(identifier) - rank = identifier_ranking.rank if identifier_ranking else None + price_data = await get_identifier_price_data(domain, identifier, years, promo_code) - if rank == 0: + if not price_data: return AddressStatus(identifier=identifier, available=False) - price, reason = domain.price_for_identifier(identifier, years, rank) - - price_in_sats = ( - price - if domain.currency == "sats" - else await fiat_amount_as_satoshis(price, domain.currency) - ) - return AddressStatus( identifier=identifier, available=True, - price=price, - price_in_sats=price_in_sats, - price_reason=reason, + price=price_data.price, + price_in_sats=await price_data.price_sats(), + price_reason=price_data.reason, currency=domain.currency, ) +async def get_identifier_price_data( + domain: Domain, identifier: str, years: int, promo_code: Optional[str] = None +) -> Optional[PriceData]: + identifier_ranking = await get_identifier_ranking(identifier) + rank = identifier_ranking.rank if identifier_ranking else None + + if rank == 0: + return None + + return await domain.price_for_identifier(identifier, years, rank, promo_code) + + async def request_user_address( domain: Domain, address_data: CreateAddressData, wallet_id: str, user_id: str, ): - address = await create_address(domain, address_data, wallet_id, user_id) + address = await create_address( + domain, address_data, wallet_id, user_id, address_data.promo_code + ) assert ( address.config.price_in_sats ), f"Cannot compute price for '{address_data.local_part}'." + payment_hash, payment_request = None, None if address_data.create_invoice: - # in case the user pays, but the identifier is no longer available - payment_hash, payment_request = await create_invoice( - wallet_id=domain.wallet, - amount=int(address.config.price_in_sats), - memo=f"Payment of {address.config.price} {address.config.currency} " - f"for NIP-05 for {address_data.local_part}@{domain.domain}", - extra={ - "tag": "nostrnip5", - "domain_id": domain.id, - "address_id": address.id, - "action": "activate", - "reimburse_wallet_id": wallet_id, - }, + payment_hash, payment_request = await create_invoice_for_identifier( + domain, address, wallet_id ) - else: - payment_hash, payment_request = None, None + address.promo_code_status = domain.cost_config.promo_code_status( + address_data.promo_code + ) resp = { "payment_hash": payment_hash, "payment_request": payment_request, @@ -153,11 +147,44 @@ async def request_user_address( return resp +async def create_invoice_for_identifier( + domain: Domain, + address: Address, + reimburse_wallet_id: str, +) -> Tuple[str, str]: + price_data = await get_identifier_price_data( + domain, address.local_part, address.config.years, address.config.promo_code + ) + assert price_data, f"Cannot compute price for '{address.local_part}'." + price_in_sats = await price_data.price_sats() + discount_sats = await price_data.discount_sats() + referer_bonus_sats = await price_data.referer_bonus_sats() + + payment_hash, payment_request = await create_invoice( + wallet_id=domain.wallet, + amount=int(price_in_sats), + memo=f"Payment of {address.config.price} {address.config.currency} " + f"for NIP-05 {address.local_part}@{domain.domain}", + extra={ + "tag": "nostrnip5", + "domain_id": domain.id, + "address_id": address.id, + "action": "activate", + "reimburse_wallet_id": reimburse_wallet_id, + "discount_sats": int(discount_sats), + "referer": address.config.referer, + "referer_bonus_sats": int(referer_bonus_sats), + }, + ) + return payment_hash, payment_request + + async def create_address( domain: Domain, data: CreateAddressData, wallet_id: Optional[str] = None, user_id: Optional[str] = None, + promo_code: Optional[str] = None, ) -> Address: identifier = normalize_identifier(data.local_part) @@ -165,26 +192,24 @@ async def create_address( if data.pubkey != "": data.pubkey = validate_pub_key(data.pubkey) - identifier_status = await get_identifier_status(domain, identifier, data.years) - - assert identifier_status.available, f"Identifier '{identifier}' not available." - assert identifier_status.price, f"Cannot compute price for '{identifier}'." + owner_id = owner_id_from_user_id(user_id) + addresss = await get_address_for_owner(owner_id, domain.id, identifier) - price_in_sats = ( - identifier_status.price - if domain.currency == "sats" - else await fiat_amount_as_satoshis(identifier_status.price, domain.currency) + promo_code = promo_code or (addresss.config.promo_code if addresss else None) + identifier_status = await get_identifier_status( + domain, identifier, data.years, promo_code ) - assert price_in_sats, f"Cannot compute price for '{identifier}'." - owner_id = owner_id_from_user_id(user_id) - addresss = await get_address_for_owner(owner_id, domain.id, identifier) + assert identifier_status.available, f"Identifier '{identifier}' not available." + assert identifier_status.price, f"Cannot compute price for '{identifier}'." config = addresss.config if addresss else AddressConfig() config.price = identifier_status.price - config.price_in_sats = price_in_sats + config.price_in_sats = identifier_status.price_in_sats config.currency = domain.currency config.years = data.years + config.promo_code = data.promo_code + config.referer = domain.cost_config.promo_code_referer(promo_code, data.referer) config.max_years = domain.cost_config.max_years config.ln_address.wallet = wallet_id or "" @@ -231,7 +256,7 @@ async def get_valid_addresses_for_owner( if not domain: continue status = await get_identifier_status( - domain, address.local_part, address.config.years + domain, address.local_part, address.config.years, address.config.promo_code ) if status.available: @@ -243,11 +268,48 @@ async def get_valid_addresses_for_owner( continue address.config.currency = domain.currency + address.promo_code_status = domain.cost_config.promo_code_status( + address.config.promo_code + ) valid_addresses.append(address) return valid_addresses +async def pay_referer_for_promo_code(address: Address, referer: str, bonus_sats: int): + try: + assert bonus_sats > 0, f"Bonus amount negative: '{bonus_sats}'." + + domain = await get_domain_by_id(address.domain_id) + assert domain, f"Missing domain for '{address.local_part}'." + + referer_address = await get_active_address_by_local_part( + address.domain_id, referer + ) + assert referer_address, f"Missing address for referer '{referer}'." + referer_wallet = referer_address.config.ln_address.wallet + assert referer_wallet, f"Missing wallet for referer '{referer}'." + + _, payment_request = await create_invoice( + wallet_id=referer_wallet, + amount=bonus_sats, + memo=f"Referer bonus of {bonus_sats} sats to '{referer}' " + f"from NIP-05 {address.local_part}@{domain.domain}", + extra={ + "tag": "nostrnip5", + "domain_id": domain.id, + "address_id": address.id, + "action": "referer_bonus", + }, + ) + + await pay_invoice(wallet_id=domain.wallet, payment_request=payment_request) + + except Exception as exc: + logger.warning(f"Failed to pay referer for '{referer}'.") + logger.warning(exc) + + async def check_address_payment(domain_id: str, payment_hash: str) -> bool: payment = await get_standalone_payment(payment_hash, incoming=True) if not payment: diff --git a/tasks.py b/tasks.py index 938aed4..fce2720 100644 --- a/tasks.py +++ b/tasks.py @@ -6,7 +6,7 @@ from .crud import get_address, update_address from .models import Address -from .services import activate_address, update_ln_address +from .services import activate_address, pay_referer_for_promo_code, update_ln_address async def wait_for_paid_invoices(): @@ -61,6 +61,7 @@ async def _activate_address(payment: Payment, address: Address): ) if activated: await _create_ln_address(payment, address) + await _pay_promo_code(payment, address) else: await _update_reimburse_data(payment, address) @@ -83,8 +84,25 @@ async def _create_ln_address(payment: Payment, address: Address): f" '{address.local_part} ({address.id}')." ) return - address.config.ln_address.wallet = wallet - await update_ln_address(address) + try: + address.config.ln_address.wallet = wallet + await update_ln_address(address) + except Exception as exc: + logger.warning(exc) + + +async def _pay_promo_code(payment: Payment, address: Address): + referer = payment.extra.get("referer") + if not referer: + return + referer_bonus_sats = payment.extra.get("referer_bonus_sats") + if not referer_bonus_sats or not isinstance(referer_bonus_sats, int): + logger.warning( + f"Found referer but no bonus specified for '{address.local_part}'." + ) + return + + await pay_referer_for_promo_code(address, referer, int(referer_bonus_sats)) async def _reimburse_payment(address: Address): diff --git a/templates/nostrnip5/domain.html b/templates/nostrnip5/domain.html new file mode 100644 index 0000000..26bc4e6 --- /dev/null +++ b/templates/nostrnip5/domain.html @@ -0,0 +1,537 @@ +{% extends "public.html" %} {% block toolbar_title %} Edit domain "{{ +domain.domain }}" {% endblock %} {% from "macros.jinja" import window_vars with +context %} {% block page %} + +