DealDocumentScreening/src/contract_check/core/credits.py

79 lines
2.4 KiB
Python

"""Credits & refund policy — billing invariants.
Thin public wrappers around `CreditsRepository`. The actual SQL lives in one
place (`core.db.repositories.credits`) so that issue 006 can migrate callers
without duplicating the atomic statements.
Reserve-on-enqueue is sacred: a credit moves ONLY on `POST /documents` in the
api, atomically (never below zero). Refund is sacred: idempotent via the
`documents.refunded` flag (a retried/DLQ message can never double-refund).
`refund_credit` honours the runtime policy `REFUND_POLICY`:
- "all" → refund on any terminal failure
- "infra_only" → refund everything EXCEPT user-garbage input (extraction_failed)
See docs/ARCHITECTURE.md §8. Callers own the transaction (commit after these return).
"""
from __future__ import annotations
import uuid
from sqlalchemy.ext.asyncio import AsyncSession
from src.contract_check.core.db.enums import FailureClass, RefundPolicyLike
from src.contract_check.core.db.repositories import CreditsRepository
from src.contract_check.core.db.repositories.credits import (
NON_REFUNDABLE_INFRA_ONLY,
should_refund,
)
__all__ = [
"reserve_credit",
"refund_credit",
"adjust_credits",
"should_refund",
"NON_REFUNDABLE_INFRA_ONLY",
]
async def reserve_credit(
session: AsyncSession,
user_id: uuid.UUID,
*,
document_id: uuid.UUID | None = None,
) -> bool:
"""Atomically decrement credits_left by 1. Returns False if none available."""
return await CreditsRepository(session).reserve(user_id, document_id=document_id)
async def refund_credit(
session: AsyncSession,
document_id: uuid.UUID,
failure_class: FailureClass | str,
policy: RefundPolicyLike,
) -> bool:
"""Refund one credit for a failed document, once. Returns False if skipped."""
return await CreditsRepository(session).refund(document_id, failure_class, policy)
async def adjust_credits(
session: AsyncSession,
user_id: uuid.UUID,
delta: int,
*,
kind: str = "admin_grant",
document_id: uuid.UUID | None = None,
invoice_id: uuid.UUID | None = None,
) -> int:
"""Adjust balance by ``delta`` (clamped at 0) and emit a ledger event.
Default kind is ``admin_grant``; override for refunds/compensation.
Returns the new balance.
"""
return await CreditsRepository(session).adjust(
user_id,
delta,
kind=kind,
document_id=document_id,
invoice_id=invoice_id,
)