payment.py 5.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199
  1. import hashlib
  2. import hmac
  3. import json
  4. import logging
  5. import os
  6. from typing import NoReturn
  7. from pathlib import Path
  8. import httpx
  9. from fastapi import HTTPException
  10. from fastapi.encoders import jsonable_encoder
  11. from pydantic import ValidationError
  12. from app.schema import CartSession, NowPaymentsInvoice, ShippingCostEntry, ShippingRate
  13. logger = logging.getLogger(__name__)
  14. def prepare_stripe_data(cart: CartSession) -> list[dict]:
  15. """Returns list of items ready for Stripe to be processed.
  16. Each item is in the form of {price_data: {...}} or {price: <price_id>, quantity: <amount>}
  17. When using product IDs, we need to use price_data to create prices inline:
  18. list_items = [
  19. {
  20. 'price_data': {
  21. 'currency': 'usd',
  22. 'product': 'prod_XYZ',
  23. 'unit_amount': 2000 # amount in cents
  24. },
  25. 'quantity': 1
  26. }
  27. ]
  28. When using price IDs:
  29. list_items = [
  30. {
  31. 'price': 'price_XYZ',
  32. 'quantity': 1
  33. }
  34. ]
  35. """
  36. list_items = []
  37. for item in cart.items.values():
  38. # Create inline price data using the product ID
  39. item_data = {
  40. "price_data": {
  41. "currency": cart.meta.currency.lower(),
  42. "product": item.product_id,
  43. "unit_amount": int(item.price * 100 / item.quantity), # Convert to cents
  44. },
  45. "quantity": item.quantity,
  46. }
  47. list_items.append(item_data)
  48. return list_items
  49. def is_country_in_europe(country: str) -> bool | NoReturn:
  50. """
  51. Check if given country is part of a list of countries in Europe.
  52. """
  53. dir_path = Path(__file__).resolve().parent
  54. europe_db_filepath = Path(f"{dir_path}/europe.json")
  55. try:
  56. with open(europe_db_filepath, encoding='utf-8') as file:
  57. europe_db = json.load(file)
  58. if country in europe_db['countries']:
  59. return True
  60. return False
  61. except FileNotFoundError as error:
  62. logger.error(f"is-country-in-europe => {error}")
  63. def calculate_shipping_cost(country: str, weight: int, currency: str):
  64. """Returns appropriate shipping price based on given Country and product's Weight."""
  65. dir_path = Path(__file__).resolve().parent
  66. shipping_cost_db_filepath = Path(f"{dir_path}/shipping-cost.json")
  67. try:
  68. with open(shipping_cost_db_filepath, encoding='utf-8') as f:
  69. shipping_cost_db = json.load(f)
  70. area = None
  71. if country == "Switzerland":
  72. area = country
  73. elif is_country_in_europe(country):
  74. area = "Europe"
  75. else:
  76. area = "International"
  77. shipping_rate = None
  78. for entry in shipping_cost_db['areas'][area]:
  79. try:
  80. entry = ShippingCostEntry(**entry)
  81. if (
  82. entry.max is None and weight >= entry.min or
  83. entry.min <= weight <= entry.max
  84. ):
  85. shipping_rate = ShippingRate(price=entry.price, area=area)
  86. except ValidationError as e:
  87. logger.error("pydantic validation errors =>")
  88. for e in e.errors():
  89. logger.error(f"error => {e}")
  90. return shipping_rate
  91. except FileNotFoundError as error:
  92. logger.error(f"calculate-shipping-cost => {error}")
  93. def prepare_shipping_rate(country: str, weight: int, currency: str):
  94. """
  95. """
  96. shipping_rate = calculate_shipping_cost(country, weight, currency)
  97. # Stripe needs shipping price set in cents as an integer
  98. shipping_rate.price = int(shipping_rate.price * 100)
  99. return {
  100. "shipping_rate_data": {
  101. "type": "fixed_amount",
  102. "fixed_amount": {"amount": shipping_rate.price, "currency": currency},
  103. "display_name": shipping_rate.area,
  104. }
  105. }
  106. async def generate_payment_link(data: NowPaymentsInvoice) -> tuple[str, str] | NoReturn:
  107. """Generate payment link for NOW Payments crypto payment service.
  108. See <https://documenter.getpostman.com/view/7907941/2s93JusNJt#f5e4e645-dce2-4b06-b2ca-2a29aaa5e845>.
  109. """
  110. environment = os.getenv("ENV")
  111. API_URL = "https://api.nowpayments.io/v1/"
  112. NOW_API_KEY = os.getenv("NOWPAYMENTS_API_KEY")
  113. if environment != "production":
  114. API_URL = "https://api-sandbox.nowpayments.io/v1/"
  115. NOW_API_KEY = os.getenv("NOWPAYMENTS_SANDBOX_API_KEY")
  116. ENDPOINT = "invoice"
  117. headers = {"x-api-key": NOW_API_KEY}
  118. async with httpx.AsyncClient(headers=headers) as client:
  119. resp = await client.post(f"{API_URL}{ENDPOINT}", json=jsonable_encoder(data))
  120. try:
  121. resp.raise_for_status()
  122. if resp.status_code == 200:
  123. data = resp.json()
  124. return data["invoice_url"], data["id"]
  125. except httpx.HTTPStatusError as exc:
  126. logger.error(f"Error {exc.response.status_code}: {exc.response.text}")
  127. raise HTTPException(
  128. status_code=400, detail="NOWPayments link cannot be generated."
  129. )
  130. def ipn_signature_check(
  131. np_secret_key: str, np_x_signature: str, payload: dict[str]
  132. ) -> bool:
  133. """Signature verification function as part of the NOW Paymnents's
  134. Instant Payment Notifications (IPN) webhook workflow.
  135. See <https://documenter.getpostman.com/view/7907941/2s93JusNJt>.
  136. """
  137. sorted_msg = json.dumps(payload, separators=(",", ":"), sort_keys=True)
  138. digest = hmac.new(
  139. str(np_secret_key).encode(), f"{sorted_msg}".encode(), hashlib.sha512
  140. )
  141. signature = digest.hexdigest()
  142. if signature == np_x_signature:
  143. return True
  144. else:
  145. return False