cardano-checkout-py/cardano_checkout/invoice.py
Kayos dc6378eda6 v0.1.0-dev: initial extraction from TradeCraft + new abstractions
Sulkta Coop's Python SDK for merchant-side Cardano payments +
NFT certificate-of-authenticity minting. Zero-custody by design.

Extracted from TradeCraft's services/cardano_*.py (2,400+ lines of
production Cardano-mainnet code) and restructured as an installable
Python package.

Package layout (cardano_checkout/):
- addresses.py   — lifted verbatim: CIP-1852 HD derivation, pure pycardano
- oracles.py     — lifted from cardano_price.py: Koios ADA/USD feed w/ 5m cache
- monitor.py     — lifted verbatim (SQLAlchemy-coupled; v0.2 refactors to Store)
- scheduler.py   — lifted verbatim (same refactor note)
- invoice.py     — NEW: framework-agnostic Invoice dataclass + lifecycle enum
- store.py       — NEW: InvoiceStore Protocol for pluggable persistence
- mint.py        — NEW: CIP-25 v2 metadata builder (works); tx submission stub for v0.2
- ipfs.py        — NEW: kubo HTTP client with primary-pin + mirror-pin pattern
- txbuild.py     — NEW: v0.2 stub for PyCardano / Ogmios tx construction

Design:
- Consumers provide xpub + InvoiceStore impl. SDK provides everything else.
- IPFS: local kubo for upload + serve, optional mirror pins for archival.
  Chromaticcraft pattern: Rackham kubo primary, Lucy kubo mirror.
- NFT: single native-script policy per merchant studio (CIP-25 v2, not CIP-68
  — full wallet coverage, no mutability needed for static certs). Policy skey
  stays under Sulkta cold-custody (Lucy pattern); signing is an external
  hand-off like ADAMaps payouts.

Tests: pure-module smoke tests pass for invoice, store-protocol, CIP-25
metadata envelope, IPFS client import, txbuild stub module. Address
derivation tests ship but require pycardano + will exercise in CI.

LICENSE: Apache-2.0 (matches upstream Cardano tooling).

Next (v0.2 scope):
- Refactor monitor + scheduler around InvoiceStore (drop SQLAlchemy coupling)
- Wire mint.mint_nft_cert to PyCardano + local Ogmios on Rackham
- txbuild: Ogmios chain-context + cold-signer hand-off shape
- chromaticcraft Phase 2 imports the SDK as its first external consumer
2026-04-23 18:04:00 -07:00

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