"""Invoice state machine for Cardano-native merchant payments. An Invoice represents one payment intent: a unique receive address derived from the merchant's xpub, an expected amount in lovelace, a USD-denominated label, and a lifecycle state that transitions as the chain confirms payment. The Invoice is deliberately framework-agnostic — persistence is delegated to an :class:`InvoiceStore` (see :mod:`cardano_checkout.store`). """ from __future__ import annotations from dataclasses import dataclass, field from datetime import datetime, timezone from enum import Enum from typing import Optional class InvoiceStatus(str, Enum): """Lifecycle states for a Cardano checkout invoice. Valid transitions:: PENDING ──► MATCHED ──► CONFIRMED │ │ │ └──► UNDERPAID │ └──► OVERPAID (still moves to CONFIRMED but flagged) │ ├──► EXPIRED (no payment within window) └──► CANCELLED (merchant-initiated) CONFIRMED is terminal success; EXPIRED / CANCELLED are terminal failures. UNDERPAID is recoverable if the customer sends the delta. """ PENDING = "pending" MATCHED = "matched" # at least one UTxO landed, not yet k-confirmed CONFIRMED = "confirmed" # enough confirmations, payment final UNDERPAID = "underpaid" # delta < expected by more than tolerance OVERPAID = "overpaid" # delta > expected by more than tolerance (non-fatal) EXPIRED = "expired" CANCELLED = "cancelled" @dataclass class Invoice: """One Cardano payment intent. Attributes: id: Stable per-merchant identifier (UUID or monotonic; caller's choice). merchant_id: Opaque merchant namespace — the SDK never interprets it. derivation_index: BIP-44 receive-chain index used to derive receive_address. receive_address: Bech32 address the customer pays to. Derived from the merchant's xpub at ``derivation_index``. expected_lovelace: Target amount. Set at creation time from the USD ↔ ADA oracle snapshot; does NOT float with market price once set. usd_amount: Human-readable label for what the customer owes. status: Current lifecycle state. See :class:`InvoiceStatus`. created_at: UTC timestamp when the invoice was created. expires_at: UTC timestamp when this invoice stops accepting payment. Typically created_at + 15 minutes for live-price quotes. tx_hashes: All observed inbound tx hashes. Empty until first UTxO lands. received_lovelace: Sum of inbound UTxOs at ``receive_address``. confirmed_at: UTC timestamp when status became CONFIRMED. metadata: Arbitrary merchant-defined payload (order id, sku, etc.). """ id: str merchant_id: str derivation_index: int receive_address: str expected_lovelace: int usd_amount: float status: InvoiceStatus = InvoiceStatus.PENDING created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) expires_at: Optional[datetime] = None tx_hashes: list[str] = field(default_factory=list) received_lovelace: int = 0 confirmed_at: Optional[datetime] = None metadata: dict = field(default_factory=dict) @property def ada_amount(self) -> float: """Expected amount in ADA (lovelace / 1_000_000).""" return self.expected_lovelace / 1_000_000 @property def is_terminal(self) -> bool: """True if the invoice has reached a state it can't leave.""" return self.status in { InvoiceStatus.CONFIRMED, InvoiceStatus.EXPIRED, InvoiceStatus.CANCELLED, } def is_expired(self, now: Optional[datetime] = None) -> bool: """True if wall-clock is past expires_at and status is still PENDING.""" if self.expires_at is None: return False current = now or datetime.now(timezone.utc) return current >= self.expires_at and self.status == InvoiceStatus.PENDING