mirror of
https://github.com/dartdavros/chatballs.git
synced 2026-10-05 17:14:59 +03:00
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:
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)
|
||||
+31
@@ -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:
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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"),
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in new issue
Block a user