Files
wowcheckout/api/app/schemas.py
T
jwinterm 7cebe309aa fix(profile): accept 'wownero' for default_qr_logo (was xmrcheckout literal)
ProfileResponse schema's default_qr_logo literal was inherited from
xmrcheckout: Literal["monero", "none", "custom"]. The DB column
(models.py) defaults to "wownero" and main.py's migration backfills
NULL → ... but the backfill also wrote "monero", contradicting the
column default.

End result: new users got default_qr_logo="wownero" (from the
column default), but GET /api/core/profile blew up with a Pydantic
ResponseValidationError on serialization because "wownero" isn't in
the literal. UI showed "Unavailable" for both Store ID and Primary
Address because the entire profile response 500'd, leaving
profileData null on the dashboard. API key + webhook secret rendered
fine because those come from cookies, not profileData.

Fix:
- schemas.py: QrLogoMode now Literal["wownero", "monero", "none",
  "custom"] (keep "monero" for backward compat with any rows from
  before the migration ran)
- schemas.py: ProfileResponse.default_qr_logo default → "wownero"
- main.py: backfill UPDATE → 'wownero' (was 'monero'); migration
  column DEFAULT already correct as 'wownero'
- ui/dashboard/page.tsx: TS type for default_qr_logo widened to
  include "wownero"; fallback default → "wownero"
- ui/dashboard/actions.ts: DefaultQrLogoState.logo type widened;
  validation in updateDefaultQrLogo + initialMetadataPayload now
  accepts "wownero"

Suchshop didn't hit this because their wowcheckout user row was
created back when this UI path wasn't being used (they were
provisioned via direct SQL/API). The new shop.wowne.ro merchant
created via the UI today triggered the column default → exposed
the schema mismatch.
2026-05-18 12:35:55 -04:00

294 lines
9.5 KiB
Python

from datetime import datetime
from decimal import Decimal
from typing import Any, Literal
from uuid import UUID
from pydantic import (
AnyUrl,
BaseModel,
ConfigDict,
Field,
conint,
field_serializer,
field_validator,
model_validator,
)
from .formatting import format_xmr_amount
InvoiceStatus = Literal["pending", "payment_detected", "confirmed", "expired", "invalid"]
BtcpayCheckoutStyle = Literal["standard", "btcpay_classic"]
# "wownero" is the wowcheckout default; "monero" kept as an accepted
# value for backward compat with any rows written before the
# fork-rename. New rows are created with "wownero" (see models.py
# server_default + main.py migration backfill).
QrLogoMode = Literal["wownero", "monero", "none", "custom"]
class InvoiceCreate(BaseModel):
amount_xmr: Decimal | None = Field(default=None, gt=0)
amount_fiat: Decimal | None = Field(default=None, gt=0)
currency: str | None = None
confirmation_target: conint(ge=0, le=10) = 1
checkout_continue_url: AnyUrl | None = Field(
default=None,
description="Optional: after the invoice is confirmed, the hosted invoice page can offer a Continue button that navigates to this merchant URL.",
)
metadata: dict[str, Any] | None = None
expires_at: datetime | None = Field(
default=None,
description="Defaults to 60 minutes from creation if omitted.",
)
@field_validator("currency")
@classmethod
def _normalize_currency(cls, value: str | None) -> str | None:
if value is None:
return None
return value.strip().upper()
@model_validator(mode="after")
def _validate_amounts(self) -> "InvoiceCreate":
if self.amount_xmr is None and self.amount_fiat is None:
raise ValueError("amount_xmr or amount_fiat is required")
if self.amount_xmr is not None and self.amount_fiat is not None:
raise ValueError("Provide either amount_xmr or amount_fiat, not both")
if self.amount_fiat is not None and not self.currency:
raise ValueError("currency is required when amount_fiat is provided")
return self
@field_validator("checkout_continue_url")
@classmethod
def _validate_checkout_continue_url(cls, value: AnyUrl | None) -> AnyUrl | None:
if value is None:
return None
if value.scheme == "https":
return value
if value.scheme == "http" and value.host in ("localhost", "127.0.0.1"):
return value
raise ValueError("checkout_continue_url must use https (http is allowed for localhost)")
class DonationCreate(BaseModel):
amount_xmr: Decimal = Field(gt=0)
confirmation_target: conint(ge=0, le=10) = 1
class InvoiceResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: UUID
address: str
subaddress_index: int | None = None
amount_xmr: Decimal
status: InvoiceStatus
confirmation_target: int
confirmations: int = 0
paid_after_expiry: bool = False
paid_after_expiry_at: datetime | None = None
qr_logo: QrLogoMode | None = None
qr_logo_data_url: str | None = None
metadata: dict[str, Any] | None = Field(default=None, validation_alias="metadata_json")
warnings: list[str] | None = None
quote: dict[str, Any] | None = None
invoice_url: str | None = None
qr_url: str | None = None
created_at: datetime
archived_at: datetime | None = None
expires_at: datetime | None
detected_at: datetime | None
confirmed_at: datetime | None
@field_serializer("amount_xmr")
def _serialize_amount_xmr(self, value: Decimal) -> str:
return format_xmr_amount(value)
class InvoiceCreateUser(BaseModel):
amount_xmr: Decimal | None = Field(default=None, gt=0)
amount_fiat: Decimal | None = Field(default=None, gt=0)
currency: str | None = None
confirmation_target: conint(ge=0, le=10) = 1
checkout_continue_url: AnyUrl | None = Field(
default=None,
description="Optional: after the invoice is confirmed, the hosted invoice page can offer a Continue button that navigates to this merchant URL.",
)
metadata: dict[str, Any] | None = None
expires_at: datetime | None = Field(
default=None,
description="Defaults to 60 minutes from creation if omitted.",
)
@field_validator("currency")
@classmethod
def _normalize_currency(cls, value: str | None) -> str | None:
if value is None:
return None
return value.strip().upper()
@model_validator(mode="after")
def _validate_amounts(self) -> "InvoiceCreateUser":
if self.amount_xmr is None and self.amount_fiat is None:
raise ValueError("amount_xmr or amount_fiat is required")
if self.amount_xmr is not None and self.amount_fiat is not None:
raise ValueError("Provide either amount_xmr or amount_fiat, not both")
if self.amount_fiat is not None and not self.currency:
raise ValueError("currency is required when amount_fiat is provided")
return self
@field_validator("checkout_continue_url")
@classmethod
def _validate_checkout_continue_url(cls, value: AnyUrl | None) -> AnyUrl | None:
if value is None:
return None
if value.scheme == "https":
return value
if value.scheme == "http" and value.host in ("localhost", "127.0.0.1"):
return value
raise ValueError("checkout_continue_url must use https (http is allowed for localhost)")
class InvoiceListResponse(BaseModel):
items: list[InvoiceResponse]
total: int
class InvoiceStatusResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: UUID
address: str
subaddress_index: int | None = None
amount_xmr: Decimal
amount_paid_xmr: str | None = None
status: InvoiceStatus
confirmation_target: int
confirmations: int = 0
paid_after_expiry: bool = False
paid_after_expiry_at: datetime | None = None
qr_logo: QrLogoMode | None = None
qr_logo_data_url: str | None = None
created_at: datetime
expires_at: datetime | None
detected_at: datetime | None
confirmed_at: datetime | None
btcpay_amount: str | None = None
btcpay_currency: str | None = None
btcpay_checkout_style: BtcpayCheckoutStyle | None = None
btcpay_redirect_url: str | None = None
btcpay_redirect_automatically: bool | None = None
btcpay_order_id: str | None = None
btcpay_order_number: str | None = None
checkout_continue_available: bool = False
quote: dict[str, Any] | None = None
qr_url: str | None = None
@field_serializer("amount_xmr")
def _serialize_amount_xmr(self, value: Decimal) -> str:
return format_xmr_amount(value)
class SystemStatusResponse(BaseModel):
wallet_rpc: Literal["ok", "unreachable"]
daemon: Literal["ok", "unreachable", "unknown"]
daemon_height: int | None = None
invoice_reconcile_interval_seconds: int
last_reconcile_started_at: datetime | None = None
last_reconcile_completed_at: datetime | None = None
last_reconcile_error: str | None = None
class ProfileResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: UUID
payment_address: str
default_confirmation_target: conint(ge=0, le=10) = 1
default_qr_logo: QrLogoMode = "wownero"
default_qr_logo_data_url: str | None = None
btcpay_checkout_style: BtcpayCheckoutStyle = "btcpay_classic"
created_at: datetime
class ProfileUpdate(BaseModel):
btcpay_checkout_style: BtcpayCheckoutStyle | None = None
default_confirmation_target: conint(ge=0, le=10) | None = None
default_qr_logo: QrLogoMode | None = None
default_qr_logo_data_url: str | None = None
class WebhookCreate(BaseModel):
url: AnyUrl | None = None
events: list[
Literal[
"invoice.created",
"invoice.payment_detected",
"invoice.confirmed",
"invoice.expired",
]
] | None = None
event_urls: dict[
Literal[
"invoice.created",
"invoice.payment_detected",
"invoice.confirmed",
"invoice.expired",
],
AnyUrl,
] | None = None
class WebhookResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: UUID
url: AnyUrl | None = None
events: list[str]
event_urls: dict[str, AnyUrl] | None = None
active: bool
created_at: datetime
class WebhookDeliveryResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: UUID
webhook_id: UUID | None = None
event: str
url: str
invoice_id: UUID | None = None
invoice_address: str | None = None
invoice_subaddress_index: int | None = None
invoice_amount_xmr: Decimal | None = None
invoice_status: InvoiceStatus | None = None
http_status: int | None = None
error_message: str | None = None
created_at: datetime
@field_serializer("invoice_amount_xmr")
def _serialize_invoice_amount_xmr(self, value: Decimal | None) -> str | None:
if value is None:
return None
return format_xmr_amount(value)
class LoginRequest(BaseModel):
payment_address: str = Field(min_length=1)
view_key: str = Field(min_length=1, max_length=256)
class LoginResponse(BaseModel):
api_key: str
webhook_secret: str
store_id: UUID
class ApiCredentialsResetRequest(BaseModel):
reset_api_key: bool = False
reset_webhook_secret: bool = False
class ApiCredentialsResetResponse(BaseModel):
api_key: str | None = None
webhook_secret: str | None = None