cardano-checkout-py/cardano_checkout/invoice.py

102 lines
4 KiB
Python

"""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.
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