Coverage for atlas_ecommerce/routers/shopify.py: 93%

45 statements  

« prev     ^ index     » next       coverage.py v7.16.0, created at 2026-09-25 16:36 +0000

1"""Shopify webhook intake. 

2 

3Shopify allows one second to connect and five for the whole request, and deletes 

4the subscription after eight consecutive failures. So this route only verifies, 

5records and acknowledges; account provisioning happens in a worker. A delivery we 

6have already stored is acknowledged again without re-processing, because Shopify 

7redelivers freely and at-least-once is the only guarantee on offer. 

8""" 

9 

10import json 

11from logging import getLogger 

12 

13from fastapi import APIRouter, Depends, Request, Response, status 

14from pydantic import ValidationError 

15from pymongo.errors import DuplicateKeyError 

16 

17from atlas_ecommerce.config import Settings, get_settings 

18from atlas_ecommerce.models import OrderDb, ShopifyOrder 

19from atlas_ecommerce.security.shopify_hmac import ( 

20 HMAC_HEADER, 

21 SHOP_DOMAIN_HEADER, 

22 TOPIC_HEADER, 

23 WEBHOOK_ID_HEADER, 

24 verify_shopify_hmac, 

25) 

26 

27logger = getLogger(__name__) 

28 

29router = APIRouter(prefix="/webhooks/shopify", tags=["shopify"]) 

30 

31 

32@router.post("/orders", status_code=status.HTTP_200_OK) 

33async def receive_order( 

34 request: Request, 

35 response: Response, 

36 settings: Settings = Depends(get_settings), 

37) -> dict[str, str]: 

38 # Must be the unparsed bytes: re-serialising parsed JSON never matches. 

39 raw_body = await request.body() 

40 secret = request.app.state.secrets.shopify_webhook_secret.get_secret_value() 

41 

42 if not verify_shopify_hmac(raw_body, request.headers.get(HMAC_HEADER), secret): 

43 logger.warning("Rejected Shopify delivery with an invalid signature") 

44 response.status_code = status.HTTP_401_UNAUTHORIZED 

45 return {"status": "invalid_signature"} 

46 

47 shop_domain = request.headers.get(SHOP_DOMAIN_HEADER) 

48 accepted = settings.shopify_shop_domains 

49 if accepted and (shop_domain or "").lower() not in accepted: 

50 logger.warning("Rejected signed delivery from unexpected shop %s", shop_domain) 

51 response.status_code = status.HTTP_401_UNAUTHORIZED 

52 return {"status": "unexpected_shop"} 

53 

54 webhook_id = request.headers.get(WEBHOOK_ID_HEADER) 

55 topic = request.headers.get(TOPIC_HEADER) 

56 

57 try: 

58 payload = json.loads(raw_body) 

59 order = ShopifyOrder.model_validate(payload) 

60 except (ValidationError, json.JSONDecodeError): 

61 # Signed by Shopify but not a shape we understand. Retrying cannot fix 

62 # that, so acknowledge and keep the subscription alive. 

63 logger.exception("Signed Shopify delivery %s failed to parse", webhook_id) 

64 return {"status": "unparseable"} 

65 

66 existing = await OrderDb.find_one({"shopify_order_id": order.id}) 

67 if existing is not None: 

68 logger.info("Ignoring duplicate delivery for order %s", order.id) 

69 return {"status": "duplicate"} 

70 

71 customer = order.customer 

72 record = OrderDb( 

73 shopify_order_id=order.id, 

74 webhook_id=webhook_id, 

75 topic=topic, 

76 shop_domain=shop_domain, 

77 email=order.contact_email, 

78 first_name=customer.first_name if customer else None, 

79 last_name=customer.last_name if customer else None, 

80 order_number=order.order_number, 

81 financial_status=order.financial_status, 

82 raw=payload, 

83 ) 

84 

85 try: 

86 await record.insert() 

87 except DuplicateKeyError: 

88 # Concurrent redelivery beat us to the insert. Both are the same order. 

89 logger.info("Concurrent duplicate delivery for order %s", order.id) 

90 return {"status": "duplicate"} 

91 

92 logger.info("Accepted Shopify order %s for provisioning", order.id) 

93 return {"status": "accepted"}