From c99bfdd828bdee2c2ea36a340de2b422482c7eac Mon Sep 17 00:00:00 2001 From: Andrey Date: Mon, 29 Jun 2026 23:58:22 +0300 Subject: [PATCH] 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 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 --- .../commands/issue_product_ingest_token.py | 32 +++++++ ...order_external_id_order_source_and_more.py | 31 +++++++ apps/backend/hub_platform/orders/models.py | 10 +++ apps/backend/hub_platform/orders/services.py | 90 ++++++++++++++++++- apps/backend/hub_platform/orders/tests.py | 40 +++++++++ apps/backend/hub_platform/orders/urls.py | 1 + apps/backend/hub_platform/orders/views.py | 71 ++++++++++++++- .../0006_product_ingest_token_hash.py | 18 ++++ apps/backend/hub_platform/products/models.py | 3 + 9 files changed, 292 insertions(+), 4 deletions(-) create mode 100644 apps/backend/hub_platform/orders/management/commands/issue_product_ingest_token.py create mode 100644 apps/backend/hub_platform/orders/migrations/0002_order_external_id_order_source_and_more.py create mode 100644 apps/backend/hub_platform/products/migrations/0006_product_ingest_token_hash.py diff --git a/apps/backend/hub_platform/orders/management/commands/issue_product_ingest_token.py b/apps/backend/hub_platform/orders/management/commands/issue_product_ingest_token.py new file mode 100644 index 0000000..3f87570 --- /dev/null +++ b/apps/backend/hub_platform/orders/management/commands/issue_product_ingest_token.py @@ -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) diff --git a/apps/backend/hub_platform/orders/migrations/0002_order_external_id_order_source_and_more.py b/apps/backend/hub_platform/orders/migrations/0002_order_external_id_order_source_and_more.py new file mode 100644 index 0000000..7a01316 --- /dev/null +++ b/apps/backend/hub_platform/orders/migrations/0002_order_external_id_order_source_and_more.py @@ -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'), + ), + ] diff --git a/apps/backend/hub_platform/orders/models.py b/apps/backend/hub_platform/orders/models.py index 68b53f0..3660798 100644 --- a/apps/backend/hub_platform/orders/models.py +++ b/apps/backend/hub_platform/orders/models.py @@ -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: diff --git a/apps/backend/hub_platform/orders/services.py b/apps/backend/hub_platform/orders/services.py index 0ea7139..da040d7 100644 --- a/apps/backend/hub_platform/orders/services.py +++ b/apps/backend/hub_platform/orders/services.py @@ -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 diff --git a/apps/backend/hub_platform/orders/tests.py b/apps/backend/hub_platform/orders/tests.py index c57b370..0d98faa 100644 --- a/apps/backend/hub_platform/orders/tests.py +++ b/apps/backend/hub_platform/orders/tests.py @@ -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) diff --git a/apps/backend/hub_platform/orders/urls.py b/apps/backend/hub_platform/orders/urls.py index eae0f36..f295837 100644 --- a/apps/backend/hub_platform/orders/urls.py +++ b/apps/backend/hub_platform/orders/urls.py @@ -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("/", views.OrderDetailView.as_view(), name="order-detail"), path("/mark-paid/", views.OrderMarkPaidView.as_view(), name="order-mark-paid"), path("/cancel/", views.OrderCancelView.as_view(), name="order-cancel"), diff --git a/apps/backend/hub_platform/orders/views.py b/apps/backend/hub_platform/orders/views.py index e7cb47d..790f116 100644 --- a/apps/backend/hub_platform/orders/views.py +++ b/apps/backend/hub_platform/orders/views.py @@ -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] diff --git a/apps/backend/hub_platform/products/migrations/0006_product_ingest_token_hash.py b/apps/backend/hub_platform/products/migrations/0006_product_ingest_token_hash.py new file mode 100644 index 0000000..c271ca2 --- /dev/null +++ b/apps/backend/hub_platform/products/migrations/0006_product_ingest_token_hash.py @@ -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), + ), + ] diff --git a/apps/backend/hub_platform/products/models.py b/apps/backend/hub_platform/products/models.py index f346334..2265570 100644 --- a/apps/backend/hub_platform/products/models.py +++ b/apps/backend/hub_platform/products/models.py @@ -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)