v0.1.0-dev: initial public extraction + new abstractions
This commit is contained in:
commit
e38120cf11
17 changed files with 2429 additions and 0 deletions
102
cardano_checkout/invoice.py
Normal file
102
cardano_checkout/invoice.py
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
"""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
|
||||
Loading…
Add table
Add a link
Reference in a new issue