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>
This commit is contained in:
AndreyandClaude Opus 4.8 committed 2026-06-29 23:58:22 +03:00
1 parent 2b67305a4c
commit c99bfdd828
9 files changed
+292 -4

No files matched your search

@@ -0,0 +1,32 @@
"""Issue (generate + store hash of) an order-ingest token for a product backend.
The plaintext token is printed ONCE — store it in the product backend's config.
Re-running rotates the token (invalidates the old one). Run deliberately.
"""
from __future__ import annotations
import secrets
from django.core.management.base import BaseCommand, CommandError
from hub_platform.orders.services import hash_ingest_token
from hub_platform.products.models import Product
class Command(BaseCommand):
help = "Generate an order-ingest token for a product (prints the token once)."
def add_arguments(self, parser) -> None:
parser.add_argument("product_code")
def handle(self, *args: object, **options: object) -> None:
code = str(options["product_code"])
product = Product.objects.filter(code=code).first()
if product is None:
raise CommandError(f"product '{code}' not found")
token = secrets.token_urlsafe(32)
product.ingest_token_hash = hash_ingest_token(token)
product.save(update_fields=["ingest_token_hash", "updated_at"])
self.stdout.write(self.style.SUCCESS(f"ingest token for {code} (store it now, shown once):"))
self.stdout.write(token)
@@ -0,0 +1,31 @@
# Generated by Django 5.2.15 on 2026-06-29 20:53
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('channels', '0002_channel_system_prompt'),
('conversations', '0001_initial'),
('identity', '0006_alter_employeeprofile_totp_secret'),
('orders', '0001_initial'),
('products', '0006_product_ingest_token_hash'),
]
operations = [
migrations.AddField(
model_name='order',
name='external_id',
field=models.CharField(blank=True, max_length=128),
),
migrations.AddField(
model_name='order',
name='source',
field=models.CharField(blank=True, max_length=64),
),
migrations.AddConstraint(
model_name='order',
constraint=models.UniqueConstraint(condition=models.Q(('external_id', ''), _negated=True), fields=('organization', 'source', 'external_id'), name='uniq_order_org_source_external'),
),
]
@@ -29,6 +29,9 @@ class Order(models.Model):
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)
@@ -36,6 +39,13 @@ class Order(models.Model):
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:
+89 -1
View File
@@ -1,3 +1,4 @@
import hashlib
from dataclasses import dataclass
from django.core.exceptions import ValidationError
@@ -6,7 +7,11 @@ from django.utils import timezone
from hub_platform.conversations.models import Contact, Conversation
from hub_platform.orders.models import FulfillmentStatus, Order, OrderItem, PaymentStatus
from hub_platform.products.models import Offer, Price
from hub_platform.products.models import Offer, Price, Product
def hash_ingest_token(token: str) -> str:
return hashlib.sha256(token.encode("utf-8")).hexdigest()
@dataclass(frozen=True)
@@ -90,3 +95,86 @@ def set_fulfillment(*, order: Order, status: str) -> Order:
order.fulfillment_status = status
order.save(update_fields=["fulfillment_status", "updated_at"])
return order
# --- Ingest from a product backend (webhook, ADR-HUB-0018) ---
@dataclass(frozen=True)
class IngestItemInput:
offer_code: str
quantity: int = 1
def resolve_product_by_token(token: str) -> Product | None:
if not token:
return None
return Product.objects.filter(ingest_token_hash=hash_ingest_token(token)).first()
@transaction.atomic
def ingest_order(
*,
product: Product,
external_id: str,
items: list[IngestItemInput],
payment_status: str,
currency: str = "RUB",
amount_minor: int | None = None,
conversation: Conversation | None = None,
contact_name: str = "",
) -> tuple[Order, bool]:
if payment_status not in PaymentStatus.values:
raise ValidationError({"paymentStatus": "Unknown status"})
if not items:
raise ValidationError({"items": "Order needs at least one item"})
organization = product.organization
# Идемпотентность по (организация, продукт, внешний id).
if external_id:
existing = Order.objects.filter(organization=organization, source=product.code, external_id=external_id).first()
if existing is not None:
return existing, False
resolved: list[tuple[Offer, Price | None, int, int]] = []
total = 0
for item in items:
try:
offer = Offer.objects.get(product=product, code=item.offer_code)
except Offer.DoesNotExist as error:
raise ValidationError({"items": f"Offer {item.offer_code} not found in {product.code}"}) from error
price = _active_price(offer)
quantity = max(1, int(item.quantity))
unit = price.amount_minor if price else 0
if price:
currency = price.currency
total += unit * quantity
resolved.append((offer, price, quantity, unit * quantity))
if conversation is not None:
contact = conversation.contact
else:
contact = Contact.objects.create(organization=organization, name=contact_name or "Клиент")
is_paid = payment_status == PaymentStatus.PAID
order = Order.objects.create(
organization=organization,
contact=contact,
conversation=conversation,
product=product,
channel=conversation.channel if conversation else None,
payment_status=payment_status,
fulfillment_status=FulfillmentStatus.PENDING if is_paid else FulfillmentStatus.NONE,
amount_minor=amount_minor if amount_minor is not None else total,
currency=currency,
source=product.code,
external_id=external_id,
paid_at=timezone.now() if is_paid else None,
)
OrderItem.objects.bulk_create(
[
OrderItem(order=order, offer=offer, price=price, title=offer.name, quantity=quantity, amount_minor=amount, currency=currency)
for offer, price, quantity, amount in resolved
]
)
return order, True
+40
View File
@@ -86,3 +86,43 @@ class OrderApiTests(OrdersTestBase):
response = self.client.get(f"/api/v1/orders/{order.id}/")
self.assertEqual(response.status_code, 404)
class OrderIngestTests(OrdersTestBase):
def setUp(self) -> None:
super().setUp()
from hub_platform.orders.services import hash_ingest_token
self.token = "secret-product-token"
product = Product.objects.get(code="firepage")
product.ingest_token_hash = hash_ingest_token(self.token)
product.save(update_fields=["ingest_token_hash"])
# Вебхук без сессии.
self.webhook = APIClient()
def _post(self, body: dict, token: str | None = "secret-product-token"):
headers = {"HTTP_X_PRODUCT_TOKEN": token} if token is not None else {}
return self.webhook.post("/api/v1/orders/ingest/", data=json.dumps(body), content_type="application/json", **headers)
def test_ingest_creates_paid_order(self) -> None:
response = self._post({"externalId": "fp-1001", "paymentStatus": "PAID", "contactName": "Гость", "items": [{"offerCode": "box"}]})
self.assertEqual(response.status_code, 201)
order = Order.objects.get(source="firepage", external_id="fp-1001")
self.assertEqual(order.payment_status, PaymentStatus.PAID)
self.assertEqual(order.amount_minor, 490_000)
self.assertIsNotNone(order.paid_at)
def test_ingest_is_idempotent(self) -> None:
first = self._post({"externalId": "fp-1002", "paymentStatus": "PAID", "items": [{"offerCode": "box"}]})
second = self._post({"externalId": "fp-1002", "paymentStatus": "PAID", "items": [{"offerCode": "box"}]})
self.assertEqual(first.status_code, 201)
self.assertEqual(second.status_code, 200)
self.assertEqual(Order.objects.filter(external_id="fp-1002").count(), 1)
def test_ingest_rejects_bad_token(self) -> None:
response = self._post({"externalId": "x", "items": [{"offerCode": "box"}]}, token="nope")
self.assertEqual(response.status_code, 401)
def test_ingest_requires_token(self) -> None:
response = self._post({"externalId": "x", "items": [{"offerCode": "box"}]}, token=None)
self.assertEqual(response.status_code, 401)
+1
View File
@@ -4,6 +4,7 @@ from hub_platform.orders import views
urlpatterns = [
path("", views.OrderListCreateView.as_view(), name="order-list"),
path("ingest/", views.OrderIngestView.as_view(), name="order-ingest"),
path("<int:order_id>/", views.OrderDetailView.as_view(), name="order-detail"),
path("<int:order_id>/mark-paid/", views.OrderMarkPaidView.as_view(), name="order-mark-paid"),
path("<int:order_id>/cancel/", views.OrderCancelView.as_view(), name="order-cancel"),
+68 -3
View File
@@ -1,16 +1,25 @@
from django.core.exceptions import ValidationError
from rest_framework.permissions import IsAuthenticated
from rest_framework.permissions import AllowAny, IsAuthenticated
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.views import APIView
from hub_platform.api.permissions import IsOwner
from hub_platform.conversations.models import Contact
from hub_platform.conversations.models import Contact, Conversation
from hub_platform.identity.audit import record_audit_event
from hub_platform.orders.models import Order
from hub_platform.orders.selectors import order_for_organization, orders_for_organization
from hub_platform.orders.serializers import order_payload
from hub_platform.orders.services import OrderItemInput, cancel_order, create_order, mark_paid, set_fulfillment
from hub_platform.orders.services import (
IngestItemInput,
OrderItemInput,
cancel_order,
create_order,
ingest_order,
mark_paid,
resolve_product_by_token,
set_fulfillment,
)
def _validation_error(error: ValidationError) -> Response:
@@ -71,6 +80,62 @@ class OrderListCreateView(_Base):
return Response({"order": order_payload(order, with_items=True)}, status=201)
class OrderIngestView(APIView):
# Вебхук бэкенда продукта (ADR-HUB-0018). Аутентификация — токеном продукта,
# без пользовательской сессии. Идемпотентно по externalId.
permission_classes = [AllowAny]
def post(self, request: Request) -> Response:
token = request.headers.get("X-Product-Token", "")
product = resolve_product_by_token(token)
if product is None:
return Response({"detail": "Invalid product token"}, status=401)
raw_items = request.data.get("items")
if not isinstance(raw_items, list) or not raw_items:
return Response({"detail": "items must be a non-empty list"}, status=400)
try:
items = [IngestItemInput(offer_code=str(item["offerCode"]), quantity=int(item.get("quantity", 1))) for item in raw_items]
except (KeyError, TypeError, ValueError):
return Response({"detail": "Each item needs offerCode"}, status=400)
conversation = None
conversation_id = request.data.get("conversationId")
if conversation_id is not None:
conversation = Conversation.objects.filter(id=conversation_id, organization=product.organization).first()
if conversation is None:
return Response({"detail": "Conversation not found"}, status=400)
amount_raw = request.data.get("amountMinor")
try:
amount_minor = int(amount_raw) if amount_raw is not None else None
except (TypeError, ValueError):
return Response({"detail": "amountMinor must be an integer"}, status=400)
try:
order, created = ingest_order(
product=product,
external_id=str(request.data.get("externalId", "")).strip(),
items=items,
payment_status=str(request.data.get("paymentStatus", "PAID")),
currency=str(request.data.get("currency", "RUB")),
amount_minor=amount_minor,
conversation=conversation,
contact_name=str(request.data.get("contactName", "")).strip(),
)
except ValidationError as error:
return _validation_error(error)
record_audit_event(
action="orders.ingested" if created else "orders.ingest_duplicate",
actor=None,
organization=product.organization,
object_type="Order",
object_id=str(order.id),
request=request,
)
return Response({"order": order_payload(order, with_items=True)}, status=201 if created else 200)
class OrderDetailView(_Base):
permission_classes = [IsAuthenticated]
@@ -0,0 +1,18 @@
# Generated by Django 5.2.15 on 2026-06-29 20:53
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('products', '0005_remove_price_price_amount_positive_and_more'),
]
operations = [
migrations.AddField(
model_name='product',
name='ingest_token_hash',
field=models.CharField(blank=True, db_index=True, max_length=64),
),
]
@@ -18,6 +18,9 @@ class Product(models.Model):
site_url = models.URLField(blank=True)
summary = models.CharField(max_length=500, blank=True)
sales_description = models.TextField(blank=True)
# SHA-256 токена бэкенда продукта для вебхука заказов (ADR-HUB-0018). Сам токен
# не хранится — выдаётся один раз командой issue_product_ingest_token.
ingest_token_hash = models.CharField(max_length=64, blank=True, db_index=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)