Files
chatballs/apps/backend/hub_platform/orders/models.py
T
AndreyandClaude Opus 4.8 c99bfdd828 feat(orders): product-backend order ingest webhook (ADR-HUB-0018)
Orders originate in the product backends (FoxRay/FirePage); Hub records
them. Added an authenticated ingest webhook instead of manual order entry:

- POST /api/v1/orders/ingest/ (AllowAny + product-token auth via
  X-Product-Token; token stored only as SHA-256). Idempotent by
  (organization, source=product.code, externalId). Resolves the contact
  from conversationId when given, else creates one from contactName;
  resolves items by offerCode within the product; PAID stamps paid_at +
  fulfillment PENDING. Audited (orders.ingested / ingest_duplicate).
- Order gains source/external_id with a partial-unique constraint;
  Product gains ingest_token_hash. Migrations included.
- issue_product_ingest_token <code> management command generates + prints
  the token once (user-run; rotates on re-run).

Tests: 10 orders tests (incl. ingest auth + idempotency); full suite 95/95.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 23:58:22 +03:00

69 lines
3.4 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from django.db import models
# Коммерческий заказ (ADR-HUB-0018): каталог и факт продажи живут в Хабе,
# само исполнение (выдача доступа) — на стороне бэкенда продукта. Позиции
# хранят снимок offer/цены, чтобы запись не «плыла» при изменении каталога.
class PaymentStatus(models.TextChoices):
PENDING = "PENDING", "Ожидает оплаты"
PAID = "PAID", "Оплачен"
CANCELLED = "CANCELLED", "Отменён"
REFUNDED = "REFUNDED", "Возврат"
class FulfillmentStatus(models.TextChoices):
NONE = "NONE", "Не требуется"
PENDING = "PENDING", "В процессе"
DELIVERED = "DELIVERED", "Исполнен"
FAILED = "FAILED", "Ошибка"
class Order(models.Model):
organization = models.ForeignKey("identity.Organization", on_delete=models.PROTECT, related_name="orders")
contact = models.ForeignKey("conversations.Contact", on_delete=models.PROTECT, related_name="orders")
conversation = models.ForeignKey("conversations.Conversation", on_delete=models.SET_NULL, null=True, blank=True, related_name="orders")
product = models.ForeignKey("products.Product", on_delete=models.PROTECT, related_name="orders", null=True, blank=True)
channel = models.ForeignKey("channels.Channel", on_delete=models.SET_NULL, null=True, blank=True, related_name="orders")
payment_status = models.CharField(max_length=16, choices=PaymentStatus.choices, default=PaymentStatus.PENDING)
fulfillment_status = models.CharField(max_length=16, choices=FulfillmentStatus.choices, default=FulfillmentStatus.NONE)
amount_minor = models.PositiveBigIntegerField(default=0)
currency = models.CharField(max_length=3, default="RUB")
# Происхождение для вебхука из бэкенда продукта (идемпотентность).
source = models.CharField(max_length=64, blank=True)
external_id = models.CharField(max_length=128, blank=True)
created_at = models.DateTimeField(auto_now_add=True, db_index=True)
paid_at = models.DateTimeField(null=True, blank=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
ordering = ["-created_at"]
indexes = [models.Index(fields=["organization", "payment_status"])]
constraints = [
models.UniqueConstraint(
fields=["organization", "source", "external_id"],
condition=~models.Q(external_id=""),
name="uniq_order_org_source_external",
)
]
@property
def code(self) -> str:
return f"ORD-{self.id}"
def __str__(self) -> str:
return f"{self.code}/{self.payment_status}"
class OrderItem(models.Model):
order = models.ForeignKey(Order, on_delete=models.CASCADE, related_name="items")
offer = models.ForeignKey("products.Offer", on_delete=models.PROTECT, related_name="order_items")
price = models.ForeignKey("products.Price", on_delete=models.PROTECT, null=True, blank=True, related_name="order_items")
title = models.CharField(max_length=255)
quantity = models.PositiveIntegerField(default=1)
amount_minor = models.PositiveBigIntegerField(default=0)
currency = models.CharField(max_length=3, default="RUB")
def __str__(self) -> str:
return f"{self.title} x{self.quantity}"