mirror of
https://github.com/Such-Software/wowcheckout.git
synced 2026-10-17 05:51:45 +02:00
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.
294 lines
9.5 KiB
Python
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
|