Skip to content

Latest commit

 

History

History
769 lines (618 loc) · 31.5 KB

File metadata and controls

769 lines (618 loc) · 31.5 KB

API & Models Reference

This document provides a technical reference for the Universal Billable Module. It covers the database schema, configuration settings, and REST API specification.

Configuration (settings.py)

The module relies on standard Django settings.

Setting Default Description
INSTALLED_APPS - Must include "billable".
AUTH_USER_MODEL "auth.User" The Django user model to link orders and products to.
BILLABLE_API_TOKEN None Required. Secret token for Bearer authentication in REST API.
BILLABLE_SHOW_DOCS True Include OpenAPI docs at /docs when the API is mounted.
BILLABLE_API_TITLE "Billable Engine API" Title for the OpenAPI schema.
BILLABLE_CURRENCY "USD" Default currency code (optional, depends on implementation).

Database (PostgreSQL, async): When using the module in async mode (ASGI, bots), set CONN_MAX_AGE=0 for the PostgreSQL database in DATABASES. Persistent connections (CONN_MAX_AGE > 0) are not safe across async event loop context and can cause connection reuse issues; CONN_MAX_AGE=0 closes the connection after each request/task.


Usage Guidelines

To avoid AppRegistryNotReady errors during initialization (e.g., in tests or with pytest-django), do not import models directly from the billable package.

Correct way to import models:

from billable.models import ExternalIdentity, Product, Offer

Correct way to import services:

from billable.services import TransactionService, BalanceService, CustomerService

Async context (bots, ASGI): Prefer async methods of the API and services (e.g. TransactionService.acheck_quota, OrderService.acreate_order) so as not to block the event loop.


Integration Examples

This section contains practical examples (sync and async) intended for application-layer integrations.

Welcome Trial (TrialHistory + grant)

from billable.models import Offer, TrialHistory
from billable.services import TransactionService
from asgiref.sync import sync_to_async

class PromotionService:
    @classmethod
    async def claim_welcome_trial(cls, user_id: int, telegram_id: str):
        # 1. Fraud check (using billable tool)
        identities = {"telegram": telegram_id}
        if await TrialHistory.ahas_used_trial(identities=identities):
            return {"success": False, "reason": "trial_already_used"}
        
        # 2. Find trial offer (create in Django admin)
        offer = await Offer.objects.aget(sku="off_welcome_trial")
        
        # 3. Grant using billable engine
        batches = await sync_to_async(TransactionService.grant_offer)(
            user_id=user_id,
            offer=offer,
            source="welcome_bonus"
        )
        
        # 4. Mark as used
        await TrialHistory.objects.acreate(
            identity_type="telegram",
            identity_hash=TrialHistory.generate_identity_hash(telegram_id),
            trial_plan_name="Welcome Trial"
        )
        
        return {"success": True, "batches": batches}

Referral Bonus (signal handler + metadata contract)

from django.dispatch import receiver
from billable.signals import order_confirmed
from billable.services import TransactionService

@receiver(order_confirmed)
async def on_first_purchase(sender, order, **kwargs):
    # Check if this is the first purchase
    if not await Order.objects.filter(user=order.user, status=Order.Status.PAID).exclude(id=order.id).aexists():
        
        # Atomically claim bonus
        referral = await Referral.objects.filter(referee=order.user).afirst()
        
        if referral:
             # Sync wrapper or async equivalent for model method required in async context
             claimed = await sync_to_async(referral.claim_bonus)()
             
             if claimed:
                bonus_offer = await Offer.objects.aget(sku="off_referral_bonus")
                await sync_to_async(TransactionService.grant_offer)(
                    user_id=referral.referrer_id,
                    offer=bonus_offer,
                    source="referral_bonus",
                    metadata={
                        "referee_id": referral.referee_id,  # Required for webhook payload
                        "order_id": order.id,  # Required for webhook payload
                    }
                )

Important: When creating a referral bonus transaction, always include referee_id and order_id in the metadata parameter. This ensures that webhook payloads (e.g., referral_bonus_granted events) can include referee_external_id by looking up the referee's ExternalIdentity record. Without these fields in metadata, the webhook will only contain referrer_external_id and referee_external_id will be null.

Data Models

All database tables are prefixed with billable_. In every model, user (FK to settings.AUTH_USER_MODEL) denotes the Billing account — the entity to which orders and product rights are attributed.

Product (billable_products)

The catalog of available resources.

  • product_key (CharField, PK): Unique string identifier for accounting (e.g., DIAMONDS, VIP_ACCESS). Stored in uppercase (CAPS). Automatically normalized on save.
  • name / description: Display fields.
  • product_type (Choice):
    • PERIOD: Time-based access.
    • QUANTITY: Consumable units.
    • UNLIMITED: Permanent access.
  • is_active: Boolean flag. If False, cannot be used in new offers.
  • is_currency: Boolean flag. If True, this product is treated as a currency (e.g. internal credits).
  • created_at: Timestamp.
  • metadata: Arbitrary JSON data.

Referral (billable_referrals)

Tracks links between inviters and invitees.

  • referrer: FK to User (the inviter).
  • referee: FK to User (the invitee).
  • bonus_granted: Boolean flag.
  • created_at: Timestamp.
  • metadata (JSONField): Stores configuration.
    • Key features: List of feature strings.
  • Methods:
    • claim_bonus() -> bool: Atomically marks bonus_granted=True and bonus_granted_at to now. Returns True if successful, False if already granted. Use this for idempotency in bonus logic.

Note: Product does NOT contain price or quantity. These are defined in Offers.

Offer (billable_offers)

Marketing packages that bundle products.

  • sku (CharField, PK): Commercial identifier. Stored in uppercase (CAPS). Automatically normalized on save.
  • name: Display name (e.g., "Premium Bundle").
  • price / currency: Cost (EUR, USD, XTR, INTERNAL).
  • image / description: UI metadata.
  • is_active: Visibility flag.
  • metadata: Additional configuration (JSON).

OfferItem (billable_offer_items)

Links products to offers with quantity and expiration rules.

  • offer: FK to Offer.
  • product: FK to Product.
  • quantity: How many units of the product.
  • period_value / period_unit: Expiration (DAYS, MONTHS, YEARS, FOREVER).

QuotaBatch (billable_quota_batches)

User's "wallet" of resources. Each batch represents a grant of a specific product.

  • user_id: FK to User.
  • product: FK to Product.
  • source_offer: FK to Offer (nullable, for audit).
  • order_item: FK to OrderItem (nullable, if purchased).
  • initial_quantity: Original amount granted.
  • remaining_quantity: Current balance.
  • valid_from / expires_at: Validity period.
  • state: ACTIVE, EXHAUSTED, EXPIRED, REVOKED.
  • created_at: Timestamp for FIFO ordering.

Why multiple batches? A user's total balance for a product is the sum of remaining_quantity across all ACTIVE batches. The system uses FIFO (First-In, First-Out) logic to consume from the oldest batches first.

Transaction (billable_transactions)

Immutable ledger of all balance changes.

  • user_id: FK to User.
  • quota_batch: FK to QuotaBatch.
  • amount: Quantity changed.
  • direction: CREDIT (grant) or DEBIT (consume).
  • action_type: Source (e.g., "purchase", "trial_activation", "usage").
  • object_id: Optional external reference.
  • metadata: Context (JSON). This field is critical for Rich Transaction History. Apps can store IDs (e.g., message_id) here.
  • created_at: Timestamp.

Rich Transaction History (Metadata)

Use the metadata field to provide context for balance changes. For example, when consuming quota for a vacancy response:

TransactionService.aconsume_quota(
    user_id=user.id,
    product_key="VACANCY_RESPONSE",
    metadata={"vacancy_id": "linkedin:12345", "vacancy_title": "Python Developer"}
)

This allows the frontend to display a detailed log: "Used for response to 'Python Developer' (ID: linkedin:12345)".

Order (billable_orders)

Represents a financial transaction intent.

  • user_id: FK to User.
  • status (Choice): PENDING, PAID, CANCELLED, REFUNDED.
  • total_amount / currency: Financial totals.
  • payment_method: String identifier (e.g., stripe, telegram_payments).
  • payment_id: External transaction ID (for idempotency).
  • created_at / paid_at: Timestamps.
  • metadata: Application-specific IDs (e.g., {"report_id": 123}).

OrderItem (billable_order_items)

Individual lines within an order.

  • order: FK to Order.
  • offer: FK to Offer.
  • quantity: Number of offers purchased.
  • price: Price per offer at the moment of purchase.

TrialHistory (billable_trial_history)

Fraud prevention tool. Does NOT enforce trial logic — your application layer should check this before granting.

  • identity_hash (CharField, indexed): SHA-256 hash of the user's external ID. The ID is normalized to lowercase before hashing for maximum compatibility.
  • identity_type: Type of ID hashed (e.g., telegram, email).
  • trial_plan_name: The specific trial name used.
  • Methods:
    • ahas_used_trial(identities: dict): Async check if any identity has used a trial.
    • generate_identity_hash(value): Static method to hash identities.

Identity Normalization & Hashing

To prevent trial abuse across different platforms, follow the provider:external_id pattern.

  1. Normalize: Convert to lowercase and strip whitespace.
  2. Hash: Use TrialHistory.generate_identity_hash().

Pattern: linkedin:12345, tg:5454776146, email:user@example.com.

# Recommended normalization before hashing
raw_id = " LINKEDIN:12345 "
normalized = raw_id.strip().lower() # "linkedin:12345"
identity_hash = TrialHistory.generate_identity_hash(normalized)

ExternalIdentity (billable_external_identities)

External identity mapping for integrations.

  • provider (CharField, indexed, default="default"): Identity source (e.g., telegram, n8n).
  • external_id (CharField, indexed): Stable external identifier.
  • user (FK, nullable): Optional link to settings.AUTH_USER_MODEL.
  • metadata (JSONField): Provider-specific payload. Use this to store project-specific data (e.g., bot_id, slug, category) to avoid extending the library tables.
  • Uniqueness: (provider, external_id).
  • Methods:
    • get_user_by_identity(external_id, provider="default"): Synchronously retrieves a User by their external identity.
    • aget_user_by_identity(external_id, provider="default"): Asynchronously retrieves a User by their external identity.
    • get_external_id_for_user(user, provider="default"): Synchronously retrieves external_id for a user by provider.
    • aget_external_id_for_user(user, provider="default"): Asynchronously retrieves external_id for a user by provider.

Management Commands

migrate_identities

Creates ExternalIdentity records from the values of a field on the User (or Custom User) model. Used for one-off migration of existing identifiers (e.g. telegram_id, chat_id, stripe_id) into the identities table. Idempotent: does not create duplicates for (provider, external_id).

Syntax:

python manage.py migrate_identities <field> <provider> [--dry-run] [--limit N]
Argument / option Description
field Name of the field on the User model that holds the identifier (e.g. chat_id, telegram_id, stripe_id).
provider Provider name for ExternalIdentity (e.g. telegram, stripe).
--dry-run Print the plan without writing to the database.
--limit N Process at most N users (0 = no limit).

Behaviour: For each user with a non-empty field value, an ExternalIdentity(provider=..., external_id=str(value), user=user) record is created if that (provider, external_id) pair does not already exist. Users with an empty or None value for the field are skipped. If the field does not exist on the model, the command writes an error to stderr and exits without creating any records.

Examples:

python manage.py migrate_identities telegram_id telegram
python manage.py migrate_identities chat_id telegram --dry-run
python manage.py migrate_identities stripe_id stripe --limit 100

You can run the command multiple times with different fields and providers for the same user; that user will have multiple ExternalIdentity records (one per provider/external_id pair).


TransactionService API Reference

The TransactionService is the primary interface for managing balances.

Methods Summary

Method Sync / Async Idempotency Support Description
check_quota Both No Returns whether user has enough balance.
consume_quota / aconsume_quota Both Yes (via idempotency_key) Debits balance using FIFO. Returns usage ID and metadata.
grant_offer Both No* Grants products from an Offer.
exchange Both No* Swaps internal currency for an Offer.

* Note: Credits (grants) do not have a built-in idempotency key. See Migration Best Practices for implementation patterns.

API Signatures

Quota Check

# Sync
res = TransactionService.check_quota(user_id=1, product_key="CREDITS")
# Async
res = await TransactionService.acheck_quota(user_id=1, product_key="CREDITS")
# Returns: {"can_use": bool, "remaining": int, "message": str}

Consumption

# Sync
res = TransactionService.consume_quota(
    user_id=user.id,
    product_key="CREDITS",
    amount=1,
    action_type="usage",
    idempotency_key="request_uuid_123",
    metadata={"vacancy_id": "linkedin:12345", "vacancy_title": "Senior Python Developer"}
)

# Async
res = await TransactionService.aconsume_quota(
    user_id=user.id,
    product_key="CREDITS",
    amount=1,
    action_type="usage",
    idempotency_key="request_uuid_123",
    metadata={"vacancy_id": "linkedin:12345", "vacancy_title": "Senior Python Developer"}
)

Granting (Purchase/Bonus)

# Sync
batches = TransactionService.grant_offer(
    user_id=user.id,
    offer=offer_obj,
    source="bonus",
    metadata={"campaign": "early_bird"}
)

# Async
batches = await TransactionService.agrant_offer(
    user_id=user.id,
    offer=offer_obj,
    source="bonus",
    metadata={"campaign": "early_bird"}
)

Idempotency Matrix (Important)

  • check_quota / acheck_quota: no idempotency key.
  • consume_quota / aconsume_quota: supports idempotency_key; repeated calls with the same key return the previously created usage.
  • grant_offer / agrant_offer: no built-in idempotency key.
  • exchange / aexchange: no dedicated idempotency key argument; idempotency must be ensured at the integration layer if required.

Rich Transaction History (Metadata)

Use transaction metadata to bind each debit/credit to a concrete business object (vacancy, report, campaign, migration record). This is the primary way to build a human-readable ledger in UI.

Example: aconsume_quota linked to vacancy

# Sync
TransactionService.consume_quota(
    user_id=user.id,
    product_key="VACANCY_RESPONSE",
    action_type="usage",
    idempotency_key="vacancy-response:linkedin:12345:user:42",
    metadata={"vacancy_id": "linkedin:12345", "vacancy_title": "Senior Python Developer"}
)

# Async
await TransactionService.aconsume_quota(
    user_id=user.id,
    product_key="VACANCY_RESPONSE",
    action_type="usage",
    idempotency_key="vacancy-response:linkedin:12345:user:42",
    metadata={"vacancy_id": "linkedin:12345", "vacancy_title": "Senior Python Developer"}
)

Frontend can use metadata.vacancy_title and metadata.vacancy_id from transaction history endpoint (GET /wallet/transactions) to render records like:

  • "Debited for response to vacancy 'Senior Python Developer'"
  • "Source: linkedin:12345"

Migration Best Practices

When migrating balances from legacy systems, use TransactionService.grant_offer with protective metadata and explicit deduplication check.

Pattern: Idempotent Migration

Since grant_offer / agrant_offer don't have idempotency_key, use metadata.migration_source_id as your integration-level idempotency marker.

  1. Check: Before granting, check if a transaction with migration_source_id already exists.
  2. Grant: If not found, grant the offer and store the source ID in metadata.
  3. Key format: Prefer provider-scoped keys like legacy_system:record_id to avoid collisions between migration sources.
def safe_migrate_balance(user_id, legacy_amount, migration_id):
    # 1. Idempotency check via metadata
    exists = Transaction.objects.filter(
        user_id=user_id,
        metadata__migration_source_id=migration_id
    ).exists()
    
    if exists:
        return
        
    # 2. Grant (e.g., using a special "Migration" offer)
    offer = Offer.objects.get(sku="OFF_MIGRATION_CREDITS")
    TransactionService.grant_offer(
        user_id=user_id,
        offer=offer,
        source="migration",
        metadata={"migration_source_id": migration_id, "original_amount": legacy_amount}
    )
async def asafe_migrate_balance(user_id, migration_id):
    exists = await Transaction.objects.filter(
        user_id=user_id,
        metadata__migration_source_id=migration_id
    ).aexists()
    if exists:
        return

    offer = await Offer.objects.aget(sku="OFF_MIGRATION_CREDITS")
    await TransactionService.agrant_offer(
        user_id=user_id,
        offer=offer,
        source="migration",
        metadata={"migration_source_id": migration_id}
    )

CustomerService

Service for managing customer-related operations, particularly merging accounts.

from billable.services import CustomerService

# Merge source_user data into target_user
# Moves all orders, quota batches, transactions, identities, and referrals.
stats = CustomerService.merge_customers(target_user_id=101, source_user_id=102)
# Returns: {'moved_orders': 2, 'moved_batches': 5, ...}

# Async version
stats = await CustomerService.amerge_customers(target_user_id=101, source_user_id=102)

Admin Interface

Billable provides enhanced Django Admin interfaces for managing products and analyzing customer usage.

Customer Product Report

A hierarchical report view available in the Customer admin. It groups transactions by QuotaBatch (the source of funds), showing:

  1. Inflows: How the product was acquired (Order, Offer, or Manual Grant).
  2. Spending: How the product was consumed (Transactions).
  3. Running Balance: The balance history calculated chronologically.

This view helps support agents audit complex usage scenarios where a user has multiple active quotas for the same product.

Webhooks

When integrating Billable with external systems (e.g., n8n, Zapier), you may need to implement specific webhook contracts.

Referral Bonus Granted

Sent by the application layer when a referral bonus is successfully claimed (typically after the referee's first paid order).

Method: POST
Expected Payload (JSON):

{
  "event": "referral_bonus_granted",
  "referrer_external_id": "123456789", // e.g. Telegram Chat ID
  "referee_external_id": "987654321",
  "order_id": 55,                      // ID of the order that triggered the bonus
  "idempotency_key": "referral_bonus:10:order:55" // Unique key for deduplication
}

REST API Specification

The API is built with Django Ninja. Base URL: /api/v1/billing (typical configuration). Authentication: Header Authorization: Bearer <BILLABLE_API_TOKEN>.

Normalization Note: All endpoints are case-insensitive for sku and product_key inputs. The system automatically converts these fields to uppercase before processing.

1. Quota & Balance

GET /balance

Get current quotas for the authenticated user.

  • Response: List of active product_key and remaining limits.

GET /user-products

List active quota batches, optionally filtered by product_key.

  • Query params:
    • user_id (int, optional): Local user id.
    • product_key (str, optional): Resource key to filter by.
    • external_id (str, optional): External identifier.
    • provider (str, optional): Identity provider.
  • Response (200): List[ActiveBatch]
  • Response (404): If external_id is provided but user does not exist.

GET /wallet

Get aggregated balance for all active products.

  • Query params: user_id or (external_id + provider).
  • Response (200): {"user_id": int, "balances": {"PRODUCT_KEY": int, ...}}
  • Response (404): If user not found (lookup only).

GET /wallet/batches

Detailed list of all active quota batches (with expires_at).

  • Query params: user_id or (external_id + provider).
  • Response (200): List[QuotaBatchSchema]
  • Response (404): If user not found (lookup only).

GET /wallet/transactions

Transaction history (ledger) for the user.

  • Query params: user_id or (external_id + provider), product_key, action_type, date_from.
  • Response (200): List[TransactionSchema] (up to 100 recent items).
  • Response (404): If user not found (lookup only).

POST /identify

Identify an external identity and ensure a local User exists (create and link if missing).

  • Body:
    {
      "provider": "telegram",
      "external_id": "123456789",
      "profile": {
        "telegram_username": "alice",
        "first_name": "Alice"
      }
    }
  • Notes:
    • If provider is omitted, "default" is used.
    • User is always created or resolved; the response always includes user_id.

POST /wallet/consume

Consume quota for a specific product (admin / server-to-server).

  • Body: user_id or (external_id + provider), product_key, action_type, optional action_id, idempotency_key, optional metadata (JSON). The metadata is stored on the created DEBIT transaction.
  • Note: Automatically creates a local User if missing.
  • Response (200): {"success": true, "message": "...", "data": {"usage_id": "...", "remaining": N, "metadata": {...}}}. The data.metadata is the stored transaction metadata (on idempotent replay, the existing transaction's metadata is returned).

POST /demo/trial-grant

(Demo/Reference implementation) Grant a trial offer with abuse protection.

  • Body:
    {
      "sku": "off_trial_pack",
      "user_id": 123,
      "metadata": { "campaign_id": "winter2024" }
    }
    Optional metadata (JSON) is merged with internal data (e.g. identities) and stored on the created CREDIT transaction(s); the same merged metadata is returned in the response.
  • Notes:
    • Uses TrialHistory to prevent double-granting.
    • Automatically creates a local User if external_id + provider is used and user is missing.
    • This is a reference implementation; move logic to PromotionService in production.
  • Response (200): {"success": true, "message": "Trial granted", "data": {"products": [...], "metadata": {...}}}

2. Commercial Flows

POST /exchange

Exchange internal currency for an offer (spend product_key, grant sku).

  • Entry point for internal currency purchases.
  • Logic: Atomically consumes internal balance and grants the offer via TransactionService.grant_offer(source="exchange"). Optional request metadata is merged with internal data (e.g. price) and stored on the created Transaction; the same metadata is returned in the response.
  • Body: Send the JSON object directly as the request body (no top-level "data" wrapper). Content-Type: application/json. Optional field: metadata (JSON).
  • By user ID:
    {
      "sku": "off_premium_pack",
      "user_id": 123,
      "metadata": { "source": "telegram_menu" }
    }
  • By external identity (e.g. Telegram): provide external_id and provider instead of user_id. User is resolved via ExternalIdentity.
    {
      "sku": "off_premium_pack",
      "external_id": "322056265",
      "provider": "telegram",
      "metadata": { "source": "telegram_menu" }
    }
  • Notes: sku is required. Either user_id or (external_id + provider) must be present. If provider is omitted when using external identity, "default" is used.
  • Auto-creation: If a new external_id + provider is used, the system automatically creates a local User before processing the exchange.
  • Response (200): {"success": true, "message": "Exchange successful", "data": {"success": true, "message": "Exchanged", "metadata": {...}}}. The data.metadata is the stored transaction metadata (includes at least price; plus any request metadata if provided).

3. Orders

POST /orders

Create a new order (financial intent).

  • Body:
    {
      "user_id": 123,
      "items": [
        {"sku": "off_diamonds_100", "quantity": 1}
      ]
    }
  • Auto-creation: If a new external_id + provider is used (instead of user_id), the system automatically creates a local User.

POST /orders/{order_id}/confirm

Confirm payment for an order and grant products.

  • Entry point for real money purchases (RUB, USD, XTR). Webhooks from Stripe, YooKassa, or Telegram Payments are handled in your application: the app receives the webhook, extracts order_id and payment_id, then calls this endpoint. Billable does not expose a built-in webhook URL.
  • Logic: Transitions order to PAID and calls TransactionService.grant_offer(source="purchase"). Reprocessing the same payment_id does not create duplicate batches or transactions.
  • Body:
    {
      "payment_id": "tx_abc_123",
      "payment_method": "stripe"
    }

POST /orders/{order_id}/refund

Refund a paid order and revoke associated products.

  • Logic: Transitions order to REFUNDED and creates DEBIT transactions for any remaining quantity in the batches granted by this order. Batches are marked as REVOKED.
  • Body:
    {
      "reason": "Customer request"
    }

4. Referrals & Stats

POST /referrals

Create a referral link between referrer and referee. Supports two input modes.

  • By user IDs — body: referrer_id, referee_id (int), optional metadata.
  • By external identity — body: provider, referrer_external_id, referee_external_id (str), optional metadata. Only existing ExternalIdentity records are used; if either identity is missing, returns 400 without creating the referral.
  • Response (200): {"success": true, "message": "Referral assigned", "data": {"created": bool, "referral_id": int, "metadata": {...}}}. The metadata is the stored Referral metadata (from the request or existing record).

GET /referrals/stats

Referral statistics (e.g. count of invited users) for the referrer.

  • Query params:
    • user_id (int, optional): Local user id.
    • external_id (str, optional): External identifier (used if user_id is not provided).
    • provider (str, optional): Identity provider for external_id. Defaults to "default".
  • Response (200): {"success": true, "message": "Stats retrieved", "data": {"count": N}}
  • Response (404): If external_id is provided but user does not exist.

POST /customers/merge

Merge two customers: move all data from source_user to target_user.

  • Body:
    {
      "target_user_id": 1,
      "source_user_id": 2
    }
  • Response (200): CustomerMergeResponse (success flag, message, and counts of moved items).
  • Notes: Atomically moves orders, batches, transactions, identities, and referrals.

5. Catalog

GET /catalog

List all active offers (catalog) with nested offer items and products.

  • Logic: Returns offers with is_active=True, prefetched items and products.
  • Response fields: sku, name, price, currency, description, image, is_active, items, metadata.
  • Query params (optional):
    • sku (str, repeatable): Filter by SKU list. Example: ?sku=off_a&sku=off_b. Preserves input order; returns only found offers. If omitted, returns full catalog.
  • Response (200): List[OfferSchema]

GET /catalog/{sku}

Get a single active offer by SKU.

  • Path: sku — unique offer identifier (e.g. off_credits_100).
  • Logic: Exact match on sku and is_active=True. Prefetches items__product.
  • Response fields: sku, name, price, currency, description, image, is_active, items, metadata.
  • Response (200): OfferSchema
  • Response (404): CommonResponse{"success": false, "message": "Offer not found"} if offer does not exist or is inactive.

Response Contract (OfferSchema):

Field Type Description
sku string Commercial identifier (e.g., off_diamonds_100, pack_premium).
name string Display name of the offer.
price number (Decimal) Price per unit.
currency string Currency code: EUR, USD, XTR, INTERNAL, etc.
description string Description for UI.
image string | null Image URL or null.
is_active boolean Visibility in catalog.
items array List of offer items (products and quantities).
metadata object Additional configuration (JSON).

Structure of items element (OfferItemSchema):

Field Type Description
product object Product in the position (see below).
quantity integer Quantity of units of the product in the offer.
period_unit string Period unit: DAYS, MONTHS, YEARS, FOREVER.
period_value integer | null Period value; null for FOREVER.

Nested product object (ProductSchema):

Field Type Description
id integer Product primary key.
product_key string | null Identifier for accounting (e.g., diamonds, vip_access).
name string Display name of the product.
description string Text description.
product_type string PERIOD, QUANTITY, UNLIMITED.
is_active boolean Availability in new offers.
metadata object JSON configuration.
created_at string (datetime) Creation timestamp.

Response Example (fragment):

[
  {
    "sku": "pack_premium",
    "name": "Premium Bundle",
    "price": "9.99",
    "currency": "USD",
    "description": "Monthly premium access",
    "image": null,
    "is_active": true,
    "items": [
      {
        "product": {
          "id": 1,
          "product_key": "vip_access",
          "name": "VIP Access",
          "description": "",
          "product_type": "PERIOD",
          "is_active": true,
          "metadata": {},
          "created_at": "2025-01-01T00:00:00"
        },
        "quantity": 1,
        "period_unit": "MONTHS",
        "period_value": 1
      }
    ],
    "metadata": {}
  }
]