main.py 40 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255
  1. import logging
  2. import os
  3. from contextlib import asynccontextmanager
  4. from pathlib import Path
  5. from typing import Literal
  6. from urllib.parse import urljoin, urlparse
  7. import stripe
  8. from asgi_csrf import asgi_csrf
  9. from dotenv import load_dotenv
  10. from fastapi import BackgroundTasks, FastAPI, Form, HTTPException, Request
  11. from fastapi.encoders import jsonable_encoder
  12. from fastapi.exceptions import RequestValidationError
  13. from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
  14. from fastapi.staticfiles import StaticFiles
  15. from fastapi.templating import Jinja2Templates
  16. from starlette.exceptions import HTTPException as StarletteHTTPException
  17. from starsessions import CookieStore, SessionAutoloadMiddleware, SessionMiddleware
  18. from pydantic import ValidationError, EmailStr
  19. import pycountry
  20. from app.cart import (
  21. calculate_quantity_requested_by_user,
  22. clear_cart,
  23. initialize_cart,
  24. product_exist_in_cart,
  25. update_cart,
  26. update_cart_meta,
  27. create_product_list,
  28. )
  29. from app.db import (
  30. check_product_availability,
  31. export_order_to_xlsx,
  32. fetch_order_product_list,
  33. get_products_by_category,
  34. is_order_id_open,
  35. prepare_customer_order_data,
  36. save_shipping_info,
  37. update_product_inventory,
  38. update_status_order,
  39. write_variation_id,
  40. get_variation_id,
  41. write_product_id_db,
  42. get_products_id_db,
  43. )
  44. from app.email import send_email
  45. from app.parser import md, read_dir, read_file
  46. from app.payment import (
  47. calculate_shipping_cost,
  48. prepare_shipping_rate,
  49. generate_payment_link,
  50. ipn_signature_check,
  51. prepare_stripe_data,
  52. )
  53. from app.read_settings import read_settings
  54. from app.schema import CartSession, CartUpdate, NowPaymentsInvoice, ShippingInfo, ProductShippingMeta
  55. from app.template import (
  56. make_product_selected,
  57. prepare_checkout,
  58. prepare_email_order,
  59. prepare_product,
  60. prepare_related_products,
  61. prepare_sets_for_menu,
  62. to_srcset,
  63. assets_hashing,
  64. make_slugify
  65. )
  66. from app.form_validation import form_validation_checkout_session
  67. load_dotenv(".env")
  68. logging_level = logging.INFO
  69. main_logger = logging.getLogger()
  70. main_logger.setLevel(logging_level)
  71. formatter = logging.Formatter("%(asctime)s %(levelname)s: %(name)s => %(message)s")
  72. # Set up a stream handler to log to the console
  73. stream_handler = logging.StreamHandler()
  74. stream_handler.setLevel(logging_level)
  75. stream_handler.setFormatter(formatter)
  76. file_handler = logging.FileHandler("logs.log")
  77. file_handler.setLevel(logging_level)
  78. file_handler.setFormatter(formatter)
  79. # Add handlers to logger
  80. main_logger.addHandler(stream_handler)
  81. main_logger.addHandler(file_handler)
  82. @asynccontextmanager
  83. async def lifespan(app: FastAPI):
  84. """
  85. On startup of application we check that all the product variations
  86. have their unique IDs
  87. """
  88. products_list = read_dir(
  89. settings.git_repo,
  90. settings.document_match,
  91. settings.document_exclude,
  92. settings.block_types,
  93. exclude_doc="products",
  94. tree="products",
  95. )
  96. products_data = get_products_id_db(settings)
  97. for single_product in products_list:
  98. write_variation_id(single_product, settings)
  99. write_product_id_db(single_product, settings, products_data)
  100. yield
  101. app = FastAPI(docs_url=None, redoc_url=None, lifespan=lifespan)
  102. # -- read settings
  103. settings = read_settings("settings.toml")
  104. # -- mount static files
  105. app.mount(
  106. "/static",
  107. StaticFiles(directory=Path(__file__).parent.parent / "static"),
  108. name="static",
  109. )
  110. # mount media folder from git content repo
  111. app.mount("/media", StaticFiles(directory=settings.git_repo), name="media")
  112. # -- setup templates
  113. templates = Jinja2Templates(directory=Path(__file__).parent / "templates")
  114. templates.env.filters["md"] = md
  115. templates.env.filters["to_srcset"] = to_srcset
  116. templates.env.filters["assets_hashing"] = assets_hashing
  117. templates.env.filters["slugify"] = make_slugify
  118. templates.env.globals.update(CACHE=os.getenv('CACHE'))
  119. # -- add middlewares for CSRF and Session
  120. app.add_middleware(
  121. asgi_csrf,
  122. signing_secret=os.getenv("CSRF_SECRET_KEY"),
  123. always_protect={"/", "/products", "/checkout"},
  124. )
  125. session_store = CookieStore(secret_key=os.getenv("SESSION_SECRET_KEY"))
  126. app.add_middleware(SessionAutoloadMiddleware)
  127. app.add_middleware(
  128. SessionMiddleware,
  129. store=session_store,
  130. lifetime=3600 * 24 * 14,
  131. cookie_https_only=os.getenv("ENV") == "dev",
  132. )
  133. # -- load Stripe and set API key
  134. if os.getenv("ENV") == "production":
  135. stripe.api_key = os.getenv("STRIPE_SECRET_KEY")
  136. else:
  137. stripe.api_key = os.getenv("STRIPE_TEST_SECRET_KEY")
  138. @app.get("/", response_class=HTMLResponse)
  139. async def root(request: Request):
  140. """The Main view of the website. It gives access to all the pages."""
  141. session_data = request.session
  142. initialize_cart(session_data, settings.currency.default)
  143. cart = CartSession(**request.session["cart"])
  144. doc = read_file(
  145. settings.git_repo,
  146. "index",
  147. settings.document_match,
  148. settings.block_types,
  149. "",
  150. )
  151. categories = get_products_by_category(settings)
  152. sets = prepare_sets_for_menu(settings)
  153. site = read_file(
  154. settings.git_repo,
  155. "site",
  156. settings.document_match,
  157. settings.block_types,
  158. )
  159. response = templates.TemplateResponse(
  160. request,
  161. "index.html",
  162. {
  163. "request": request,
  164. "nav": None,
  165. "doc": doc,
  166. "categories": categories,
  167. "sets": sets,
  168. "site": site,
  169. "cart": cart,
  170. },
  171. )
  172. return response
  173. @app.get("/products", response_class=RedirectResponse, status_code=301)
  174. async def products():
  175. """
  176. The products view does not exist, so we return the user to the main view.
  177. """
  178. return "/"
  179. @app.get("/products/{product_id}", response_class=HTMLResponse)
  180. async def product(
  181. request: Request,
  182. product_id: str,
  183. js: bool = False,
  184. size: str | None = None,
  185. style: str | None = None,
  186. quantity: int = 1,
  187. ):
  188. """The product view displaying the content of the selected product PRODUCT ID.
  189. By default we check if the product exists in quantity = 1, unless
  190. a different value is explicitly pass to the GET request.
  191. """
  192. session_data = request.session
  193. initialize_cart(session_data, settings.currency.default)
  194. cart = CartSession(**request.session["cart"])
  195. tree = "products"
  196. doc = read_file(
  197. settings.git_repo,
  198. product_id,
  199. settings.document_match,
  200. settings.block_types,
  201. tree,
  202. )
  203. # we set quantity=0 because we are in a GET view and are not
  204. # adding anything to the cart yet, just want to see if we could
  205. # add 1 more item to it.
  206. quantity_requested_by_user = calculate_quantity_requested_by_user(
  207. session_data["cart"]["items"], doc.meta.product_id, quantity=0,
  208. )
  209. triplet = check_product_availability(
  210. doc, product_id, quantity_requested_by_user, size, style, settings
  211. )
  212. is_product_available, is_product_selectable, inventory_amount = triplet
  213. if js:
  214. product_selected = make_product_selected(size, style, quantity)
  215. json_response = {
  216. "is_product_available": is_product_available,
  217. "is_product_selectable": is_product_selectable,
  218. "product_selected": product_selected,
  219. }
  220. return JSONResponse(content=json_response, status_code=200)
  221. else:
  222. doc.blocks = prepare_product(doc)
  223. size_guide = None
  224. if doc.meta.options:
  225. size_guide = read_file(
  226. settings.git_repo,
  227. "products",
  228. settings.document_match,
  229. settings.block_types,
  230. None,
  231. )
  232. products = read_dir(
  233. settings.git_repo,
  234. settings.document_match,
  235. settings.document_exclude,
  236. settings.block_types,
  237. exclude_doc=doc.meta.path,
  238. tree="products",
  239. )
  240. related_products = prepare_related_products(products, product_id)
  241. site = read_file(
  242. settings.git_repo,
  243. "site",
  244. settings.document_match,
  245. settings.block_types,
  246. )
  247. response = templates.TemplateResponse(
  248. request,
  249. "product.html",
  250. {
  251. "request": request,
  252. "nav": None,
  253. "doc": doc,
  254. "size_guide": size_guide,
  255. "related_products": related_products,
  256. "is_product_available": is_product_available,
  257. "is_product_selectable": is_product_selectable,
  258. "product_form": {
  259. "size": size,
  260. "style": style,
  261. "quantity": quantity,
  262. },
  263. "site": site,
  264. "cart": cart,
  265. },
  266. )
  267. return response
  268. @app.get("/pages", response_class=RedirectResponse, status_code=301)
  269. async def products():
  270. """
  271. The pages view does not exist, so we return the user to the main view.
  272. """
  273. return "/"
  274. @app.get("/pages/{page_id}", response_class=HTMLResponse)
  275. async def page(request: Request, page_id: str):
  276. """The Page view displaying the content of the selected PAGE ID."""
  277. tree = "pages"
  278. doc = read_file(
  279. settings.git_repo,
  280. page_id,
  281. settings.document_match,
  282. settings.block_types,
  283. tree,
  284. )
  285. site = read_file(
  286. settings.git_repo,
  287. "site",
  288. settings.document_match,
  289. settings.block_types,
  290. )
  291. response = templates.TemplateResponse(
  292. request,
  293. "page.html",
  294. {
  295. "request": request,
  296. "nav": None,
  297. "doc": doc,
  298. "site": site,
  299. },
  300. )
  301. return response
  302. @app.get("/checkout", response_class=HTMLResponse)
  303. async def checkout(request: Request):
  304. """The Checkout view (GET, read-only)."""
  305. filename = request.url.path[1:]
  306. doc = read_file(
  307. settings.git_repo, filename, settings.document_match, settings.block_types
  308. )
  309. session_data = request.session
  310. initialize_cart(session_data, settings.currency.default)
  311. update_cart_meta(
  312. session_data["cart"], session_data["cart"]["meta"]["shipping"], settings
  313. )
  314. cart = CartSession(**request.session["cart"])
  315. checkout_data = prepare_checkout(cart, settings, doc)
  316. site = read_file(
  317. settings.git_repo,
  318. "site",
  319. settings.document_match,
  320. settings.block_types,
  321. )
  322. response = templates.TemplateResponse(
  323. request,
  324. "checkout.html",
  325. {
  326. "request": request,
  327. "nav": None,
  328. "data": checkout_data,
  329. "site": site,
  330. "cart": cart,
  331. },
  332. )
  333. return response
  334. @app.post("/checkout")
  335. async def checkout_update(
  336. request: Request,
  337. js: bool = False,
  338. page: Literal["product", "checkout"] = Form(...),
  339. redirect_with_items: bool | None = Form(None),
  340. operation: Literal["add", "remove", "delete", "manual"] = Form(...),
  341. product_id: str = Form(...),
  342. product_path: str = Form(...),
  343. price: int = Form(...),
  344. quantity: int = Form(...),
  345. size: Literal["s", "m", "l", "xl", "xxl"] | None = Form(None),
  346. style: str | None = Form(None),
  347. ):
  348. """The Checkout view, (POST, read-write). This view updates (add /
  349. remove) the user's session cart. If the item is already present in
  350. the cart, it increases or decreases the item quantity. Else, it
  351. will be created or removed accordingly. It will also check if any
  352. selected item in the cart is still available or it has sold out
  353. meanwhile. Morever, it displays the Checkout view.
  354. """
  355. session_data = request.session
  356. initialize_cart(session_data, settings.currency.default)
  357. shipping_amount = session_data["cart"]["meta"]["shipping"]
  358. if operation == "manual":
  359. selected_product_id = session_data["cart"]["items"][product_id]
  360. price = quantity * selected_product_id["price"]
  361. tree = "products"
  362. product_filename = Path(product_path).stem
  363. selected_product = read_file(
  364. settings.git_repo,
  365. product_filename,
  366. settings.document_match,
  367. settings.block_types,
  368. tree,
  369. )
  370. product_variation_id = get_variation_id(selected_product, size, style)
  371. form = CartUpdate(
  372. operation=operation,
  373. product_id=product_id,
  374. options={"size": size,
  375. "style": style,
  376. "product_variation_id": product_variation_id,
  377. "weight": selected_product.meta.weight,
  378. },
  379. price=price,
  380. )
  381. quantity_requested_by_user = calculate_quantity_requested_by_user(
  382. session_data["cart"]["items"], product_id, quantity
  383. )
  384. triplet = check_product_availability(
  385. selected_product,
  386. product_path,
  387. quantity_requested_by_user,
  388. size,
  389. style,
  390. settings,
  391. )
  392. is_product_available, is_product_selectable, inventory_amount = triplet
  393. product_exists = product_exist_in_cart(
  394. product_id, size, style, product_variation_id, session_data["cart"]["items"]
  395. )
  396. update_cart(
  397. session_data["cart"],
  398. form,
  399. product_exists,
  400. shipping_amount,
  401. quantity,
  402. inventory_amount,
  403. settings,
  404. )
  405. cart = CartSession(**request.session["cart"])
  406. if js:
  407. # -- return JSON res for js-handled form update
  408. if page == "product":
  409. cart_breakdown = jsonable_encoder(cart.meta)
  410. product_selected = make_product_selected(size, style, quantity)
  411. json_response = {
  412. "cart_breakdown": cart_breakdown,
  413. "is_product_available": is_product_available,
  414. "is_product_selectable": is_product_selectable,
  415. "product_selected": product_selected,
  416. }
  417. elif page == "checkout":
  418. filename = request.url.path[1:]
  419. doc = read_file(
  420. settings.git_repo,
  421. filename,
  422. settings.document_match,
  423. settings.block_types,
  424. )
  425. checkout_data = prepare_checkout(
  426. cart, settings, doc, js_update=False
  427. )
  428. cart_items = [
  429. jsonable_encoder(item) for item in checkout_data["checkout_items"]
  430. ]
  431. cart_breakdown = jsonable_encoder(cart.meta)
  432. json_response = {
  433. "cart_items": cart_items,
  434. "cart_breakdown": cart_breakdown,
  435. }
  436. return JSONResponse(content=json_response, status_code=200)
  437. else:
  438. redirect_URL = urljoin(
  439. request.headers["referer"], urlparse(request.headers["referer"]).path
  440. )
  441. redirect_URL = f"{redirect_URL}"
  442. if redirect_with_items:
  443. URI_options = []
  444. param_options = {"size": size, "style": style, "quantity": quantity}
  445. for idx, option in enumerate(param_options.keys()):
  446. if param_options[option]:
  447. param = f"{option}={param_options[option]}"
  448. URI_options.append(param)
  449. if len(URI_options) > 0:
  450. URI_params = "&".join(URI_options)
  451. redirect_URL = f"{redirect_URL}?{URI_params}"
  452. else:
  453. redirect_URL = f"{redirect_URL}"
  454. return RedirectResponse(redirect_URL, status_code=303)
  455. @app.post("/checkout/shipping")
  456. async def checkout_shipping(request: Request,
  457. js: bool = False,
  458. country: str = Form(...),
  459. weight: int = Form(...)):
  460. """
  461. This view returns the region to which the given submitted country belongs to.
  462. This is used in /checkout to update shipping costs in real time.
  463. """
  464. countries = [country.name for country in pycountry.countries]
  465. if (
  466. country not in countries
  467. or weight <= 0
  468. ):
  469. raise HTTPException(status_code=403, detail="Shipping info are incorrect.")
  470. shipping_rate = calculate_shipping_cost(country, weight, settings.currency.default)
  471. if shipping_rate:
  472. # update cart with new shipping costs
  473. session_data = request.session
  474. initialize_cart(session_data, settings.currency.default)
  475. update_cart_meta(session_data["cart"], shipping_rate.price, settings)
  476. cart = CartSession(**request.session['cart'])
  477. if js:
  478. return cart.meta
  479. else:
  480. return RedirectResponse(f"{request.base_url}checkout", status_code=303)
  481. raise HTTPException(status_code=403, detail="Shipping info are missing.")
  482. @app.post("/stripe-checkout-session", response_class=RedirectResponse, status_code=303)
  483. async def create_checkout_session(
  484. request: Request,
  485. order_id: str = Form(...),
  486. weight: int = Form(...),
  487. email: EmailStr = Form(...),
  488. first_name: str | None = Form(None),
  489. last_name: str = Form(...),
  490. address: str = Form(...),
  491. address_no: str = Form(...),
  492. address_extra: str | None = Form(None),
  493. postal_code: str = Form(...),
  494. city: str = Form(...),
  495. country: str = Form(...),
  496. phone_number: str = Form(None),
  497. note: str | None = Form(None),
  498. ):
  499. """Stripe-based checkout session. Prepare order data and create a new
  500. Stripe Checkout session. Handle gracefully in case of errors, or
  501. if order exists already in local-db and has not `status: Open`.
  502. """
  503. # -- initial form-data validation
  504. form_data = await request.form()
  505. try:
  506. form = form_validation_checkout_session(form_data)
  507. except ValidationError as e:
  508. for error in e.errors():
  509. main_logger.error(f"form-validation-stripe-checkout-session => {error}")
  510. # -- check if order-id is unique
  511. orders_settings = {
  512. "git_repo": settings.git_repo,
  513. "document_match": settings.document_match,
  514. "block_types": settings.block_types,
  515. }
  516. if not is_order_id_open(orders_settings, form.order_id):
  517. main_logger.error("stripe-checkout error: Order ID is wrong.")
  518. raise HTTPException(status_code=422, detail="Order ID is wrong.")
  519. shipping_info = {
  520. "email": form.email,
  521. "first_name": form.first_name,
  522. "last_name": form.last_name,
  523. "address": form.address,
  524. "address_no": form.address_no,
  525. "address_extra": form.address_extra,
  526. "postal_code": form.postal_code,
  527. "city": form.city,
  528. "country": form.country,
  529. "phone_number": form.phone_number,
  530. "note": form.note,
  531. }
  532. shipping_info = ShippingInfo(**shipping_info)
  533. session_data = request.session
  534. initialize_cart(session_data, settings.currency.default)
  535. if "cart" not in session_data and len(session_data["cart"].keys()) == 0:
  536. main_logger.error("stripe-checkout error: Cart is empty.")
  537. raise HTTPException(status_code=403, detail="Cart is empty.")
  538. cart = CartSession(**request.session["cart"])
  539. try:
  540. line_items = prepare_stripe_data(cart)
  541. shipping_rate = prepare_shipping_rate(shipping_info.country, form.weight, settings.currency.default)
  542. if shipping_rate is None:
  543. main_logger.error("stripe-checkout error: Shipping info are missing.")
  544. raise HTTPException(status_code=403, detail="Shipping info are missing.")
  545. checkout_session = stripe.checkout.Session.create(
  546. mode="payment",
  547. line_items=line_items,
  548. shipping_options=[shipping_rate],
  549. customer_email=shipping_info.email,
  550. client_reference_id=form.order_id,
  551. success_url=f"{request.base_url}checkout/success",
  552. cancel_url=f"{request.base_url}checkout",
  553. )
  554. product_list = create_product_list(settings, cart.items)
  555. # -- save transaction to local-db
  556. customer_order = prepare_customer_order_data(
  557. settings.git_repo,
  558. settings.document_match,
  559. settings.document_exclude,
  560. settings.block_types,
  561. checkout_session["id"],
  562. product_list,
  563. "Stripe",
  564. form.order_id,
  565. cart,
  566. shipping_info,
  567. )
  568. save_shipping_info(
  569. customer_order, settings.git_repo, settings.local_db.filepath
  570. )
  571. # -- redirect to Stripe Checkout page
  572. return checkout_session.url
  573. except stripe.error.CardError as e:
  574. main_logger.error(
  575. f"stripe-checkout error: A payment error occurred: {e.user_message}"
  576. )
  577. raise HTTPException(status_code=400, detail="Stripe payment error.")
  578. except stripe.error.InvalidRequestError as e:
  579. main_logger.error(f"stripe-checkout error: An invalid request occurred. => {e}")
  580. raise HTTPException(
  581. status_code=400, detail="Stripe payment error (invalid request)."
  582. )
  583. except Exception as e:
  584. main_logger.error(
  585. f"stripe-checkout error: Another problem occurred, maybe unrelated to Stripe. {e}"
  586. )
  587. raise HTTPException(status_code=400, detail="Stripe payment error.")
  588. @app.post("/stripe-webhook", response_class=JSONResponse)
  589. async def stripe_webhook(request: Request, background_tasks: BackgroundTasks):
  590. """Stripe webhook endpoint to receive updates from Stripe's checkout
  591. operations. Send email after successful order confirmation.
  592. """
  593. main_logger.info("stripe-webhook => ...")
  594. try:
  595. event = None
  596. payload = await request.body()
  597. sig_header = request.headers["stripe-signature"]
  598. try:
  599. if os.getenv("ENV") == "production":
  600. endpoint_secret = os.getenv("STRIPE_ENDPOINT_SECRET")
  601. else:
  602. endpoint_secret = os.getenv("STRIPE_TEST_ENDPOINT_SECRET")
  603. event = stripe.Webhook.construct_event(payload, sig_header, endpoint_secret)
  604. except ValueError as e:
  605. main_logger.error(
  606. f"stripe-webhook error: Webhook error while parsing basic request. {e}"
  607. )
  608. raise HTTPException(status_code=400, detail="Stripe payment error.")
  609. except stripe.error.SignatureVerificationError as e:
  610. main_logger.error(
  611. f"stripe-webhook error: Webhook signature verification failed. {e}"
  612. )
  613. raise HTTPException(status_code=400, detail="Stripe payment error.")
  614. if event:
  615. checkout = event["data"]["object"]
  616. if "client_reference_id" in checkout:
  617. try:
  618. status_res = update_status_order(
  619. event["type"],
  620. checkout["client_reference_id"],
  621. settings.git_repo,
  622. settings.local_db.filepath,
  623. )
  624. if status_res is False:
  625. main_logger.error("stripe-webhook error: order could not be updated.")
  626. # Don't raise an exception, just log and continue
  627. #raise HTTPException(
  628. # status_code=400, detail="Order could not be updated."
  629. #)
  630. # We still want to try updating inventory and sending email
  631. except KeyError as e:
  632. main_logger.error(f"stripe-webhook error: Missing key in order data: {e}")
  633. # Continue processing - we want to still try to send the email
  634. except Exception as e:
  635. main_logger.error(f"stripe-webhook error: Unexpected error updating order: {e}")
  636. # Continue processing
  637. if event["type"] in [
  638. "checkout.session.completed",
  639. "checkout.session.async_payment_succeeded",
  640. ]:
  641. order_id = checkout["client_reference_id"]
  642. product_list = fetch_order_product_list(
  643. order_id, settings.git_repo, settings.local_db.filepath
  644. )
  645. if product_list:
  646. for product_info in product_list:
  647. # We need to read the product file and get the variation ID
  648. try:
  649. product = read_file(
  650. settings.git_repo,
  651. f"products/{product_info.filename}",
  652. settings.document_match,
  653. settings.block_types,
  654. )
  655. # Get the variation ID based on size and style
  656. product_variation_id = get_variation_id(product, product_info.size, product_info.style)
  657. update_product_inventory(
  658. product_info.filename,
  659. product_info.quantity,
  660. product_variation_id,
  661. settings,
  662. )
  663. except Exception as e:
  664. main_logger.error(f"stripe-webhook error: Failed to update inventory: {e}")
  665. # Continue processing other products
  666. # -- send email
  667. try:
  668. email_settings = {
  669. "git_repo": settings.git_repo,
  670. "document_match": settings.document_match,
  671. "block_types": settings.block_types,
  672. }
  673. # It's possible the order was just created and not all data is available yet
  674. # The prepare_email_order function should handle this gracefully
  675. try:
  676. order = prepare_email_order(order_id, email_settings)
  677. await send_email(email_settings, order, background_tasks)
  678. except TypeError as e:
  679. # Specifically catch the 'NoneType' object is not iterable error
  680. main_logger.warning(f"stripe-webhook warning: Email preparation issue, possibly due to timing: {e}")
  681. # Consider implementing a retry mechanism here if needed
  682. # For example, add the email task to a queue to try again later
  683. except Exception as e:
  684. main_logger.error(f"stripe-webhook error: Failed to send email: {e}")
  685. # Continue processing - we've already done inventory updates
  686. elif event["type"] == "checkout.session.async_payment_failed":
  687. main_logger.error(
  688. f"stripe-webhook error: Stripe payment error. {event['type']}"
  689. )
  690. raise HTTPException(status_code=400, detail="Stripe payment error.")
  691. else:
  692. main_logger.error(f"stripe-webhook error: Unhandled event type {event['type']}")
  693. raise HTTPException(status_code=400, detail="Stripe payment error.")
  694. except Exception as e:
  695. main_logger.error(f"stripe-webhook error: Unhandled exception: {e}")
  696. import traceback
  697. main_logger.error(traceback.format_exc())
  698. # Still return success to prevent Stripe from retrying the webhook
  699. # -- return 200
  700. return {"success": True}
  701. @app.post("/create-now-payments", response_class=RedirectResponse, status_code=303)
  702. async def create_now_payments(
  703. request: Request,
  704. order_id: str = Form(...),
  705. weight: int = Form(...),
  706. email: EmailStr = Form(...),
  707. first_name: str | None = Form(None),
  708. last_name: str = Form(...),
  709. address: str = Form(...),
  710. address_no: str = Form(...),
  711. address_extra: str | None = Form(None),
  712. postal_code: str = Form(...),
  713. city: str = Form(...),
  714. country: str = Form(...),
  715. phone_number: str = Form(None),
  716. note: str | None = Form(None),
  717. ):
  718. """NOWPayments checkout session. User selects with which currency to pay.
  719. Sort of a custom NOW Payments Invoice page.
  720. """
  721. # -- initial form-data validation
  722. form_data = await request.form()
  723. try:
  724. form = form_validation_checkout_session(form_data)
  725. except ValidationError as e:
  726. for error in e.errors():
  727. main_logger.error(f"form-validation-stripe-checkout-session => {error}")
  728. # -- check if order-id is unique
  729. orders_settings = {
  730. "git_repo": settings.git_repo,
  731. "document_match": settings.document_match,
  732. "block_types": settings.block_types,
  733. }
  734. if not is_order_id_open(orders_settings, form.order_id):
  735. main_logger.error("now-payments-checkout error: Order ID is wrong.")
  736. raise HTTPException(status_code=422, detail="Order ID is wrong.")
  737. shipping_info = {
  738. "email": form.email,
  739. "first_name": form.first_name,
  740. "last_name": form.last_name,
  741. "address": form.address,
  742. "address_no": form.address_no,
  743. "address_extra": form.address_extra,
  744. "postal_code": form.postal_code,
  745. "city": form.city,
  746. "country": form.country,
  747. "phone_number": form.phone_number,
  748. "note": form.note,
  749. }
  750. shipping_info = ShippingInfo(**shipping_info)
  751. session_data = request.session
  752. initialize_cart(session_data, settings.currency.default)
  753. if "cart" not in session_data and len(session_data["cart"].keys()) == 0:
  754. main_logger.error("now-payments-checkout error: Cart is empty.")
  755. raise HTTPException(status_code=403, detail="Cart is empty.")
  756. cart = CartSession(**request.session["cart"])
  757. data = {
  758. "price_amount": cart.meta.total,
  759. "price_currency": settings.currency.now_payments,
  760. "order_id": form.order_id,
  761. "ipn_callback_url": f"{request.base_url}now-webhook",
  762. "success_url": f"{request.base_url}checkout/success",
  763. "cancel_url": f"{request.base_url}checkout",
  764. }
  765. data = NowPaymentsInvoice(**data)
  766. payment_url, np_order_id = await generate_payment_link(data)
  767. product_list = create_product_list(settings, cart.items)
  768. # -- save transaction to local-db
  769. customer_order = prepare_customer_order_data(
  770. settings.git_repo,
  771. settings.document_match,
  772. settings.document_exclude,
  773. settings.block_types,
  774. np_order_id,
  775. product_list,
  776. "NOW Payments",
  777. data.order_id,
  778. cart,
  779. shipping_info,
  780. )
  781. save_shipping_info(customer_order, settings.git_repo, settings.local_db.filepath)
  782. # -- redirect to NOWPayments Checkout page
  783. return payment_url
  784. @app.post("/now-webhook")
  785. async def now_webhook(request: Request, background_tasks: BackgroundTasks):
  786. """NOW Payments IPN (Instant Payment Notification) webhook.
  787. Send email after successful payment confirmation.
  788. """
  789. main_logger.info("now-webhook...")
  790. payload = await request.json()
  791. sig_header = request.headers["x-nowpayments-sig"]
  792. if os.getenv("ENV") == "production":
  793. ipn_secret_key = os.getenv("NOWPAYMENTS_IPN")
  794. else:
  795. ipn_secret_key = os.getenv("NOWPAYMENTS_SANDBOX_IPN")
  796. is_verified = ipn_signature_check(ipn_secret_key, sig_header, payload)
  797. if is_verified:
  798. # check payment status, update order if
  799. # status has changed (finished, expired, failed)
  800. # send email if status: finished.
  801. event_type = payload["payment_status"]
  802. if event_type in ["finished", "expired", "failed"]:
  803. if "payment_id" in payload:
  804. order_id = payload["order_id"]
  805. status_res = update_status_order(
  806. event_type,
  807. order_id,
  808. settings.git_repo,
  809. settings.local_db.filepath,
  810. )
  811. if status_res is False:
  812. raise HTTPException(
  813. status_code=400, detail="Order could not be updated."
  814. )
  815. if event_type == "finished":
  816. product_list = fetch_order_product_list(
  817. order_id, settings.git_repo, settings.local_db.filepath
  818. )
  819. if product_list:
  820. for product_info in product_list:
  821. try:
  822. product = read_file(
  823. settings.git_repo,
  824. f"products/{product_info.filename}",
  825. settings.document_match,
  826. settings.block_types,
  827. )
  828. # Get the variation ID based on size and style
  829. product_variation_id = get_variation_id(product,
  830. product_info.size,
  831. product_info.style)
  832. update_product_inventory(
  833. product_info.filename,
  834. product_info.quantity,
  835. product_variation_id,
  836. settings,
  837. )
  838. except Exception as e:
  839. main_logger.error(f"now-webhook error: Failed to update inventory: {e}")
  840. # Continue processing other products
  841. # -- send email
  842. try:
  843. email_settings = {
  844. "git_repo": settings.git_repo,
  845. "document_match": settings.document_match,
  846. "block_types": settings.block_types,
  847. }
  848. try:
  849. order = prepare_email_order(order_id, email_settings)
  850. await send_email(email_settings, order, background_tasks)
  851. except TypeError as e:
  852. main_logger.warning(f"now-webhook warning: Email preparation issue: {e}")
  853. except Exception as e:
  854. main_logger.error(f"now-webhook error: Failed to send email: {e}")
  855. elif event_type == "failed":
  856. main_logger.error(
  857. f"(NowPayments) Unhandled event type {event_type}"
  858. )
  859. raise HTTPException(
  860. status_code=400, detail="NOWPayments payment error."
  861. )
  862. else:
  863. main_logger.error("(NowPayments) payment_id not in payload")
  864. raise HTTPException(
  865. status_code=400, detail="NOWPayments payment error."
  866. )
  867. @app.get("/checkout/success", response_class=HTMLResponse)
  868. async def checkout_success(request: Request):
  869. """View that confirms to the user that the checkout procedure was successful."""
  870. session_data = request.session
  871. # if there's no cart session, redirect the user to `/`
  872. if "cart" not in session_data:
  873. clear_cart(session_data, settings.currency.default)
  874. return RedirectResponse(f"{request.base_url}", status_code=303)
  875. cart = CartSession(**request.session["cart"])
  876. filename = "orders"
  877. orders = read_file(
  878. settings.git_repo, filename, settings.document_match, settings.block_types
  879. )
  880. customer_order = [
  881. order for order in orders.blocks if order.order_id == cart.meta.order_id
  882. ]
  883. # if cart.meta.order_id does not match any order, redirect the user to `/`
  884. if len(customer_order) == 0:
  885. clear_cart(session_data, settings.currency.default)
  886. return RedirectResponse(f"{request.base_url}", status_code=303)
  887. # -- save data and clear cart, return view
  888. data = {
  889. "title": "Checkout Success",
  890. "order_id": cart.meta.order_id,
  891. "customer_email": customer_order[0].shipping_info.email,
  892. }
  893. # -- prepare order to xlsx format
  894. product_list = fetch_order_product_list(
  895. cart.meta.order_id, settings.git_repo, settings.local_db.filepath
  896. )
  897. if not product_list:
  898. raise HTTPException(status_code=403, detail="Order checkout is missing information.")
  899. products_shipping_meta = []
  900. for product_info in product_list:
  901. product = read_file(settings.git_repo, f"products/{product_info.filename}", settings.document_match, settings.block_types)
  902. products_shipping_meta.append(
  903. ProductShippingMeta(
  904. weight=product.meta.weight,
  905. customs_tariff_code=product.meta.customs_tariff_code,
  906. origin_country_iso=product.meta.origin_country_iso,
  907. )
  908. )
  909. email_from = os.getenv("MAIL_FROM")
  910. export_order_to_xlsx(
  911. customer_order[0],
  912. products_shipping_meta,
  913. settings.order_upload.filepath,
  914. settings.currency.default,
  915. settings.git_repo,
  916. email_from,
  917. )
  918. clear_cart(session_data, settings.currency.default)
  919. cart = CartSession(**request.session["cart"])
  920. site = read_file(
  921. settings.git_repo,
  922. "site",
  923. settings.document_match,
  924. settings.block_types,
  925. )
  926. return templates.TemplateResponse(
  927. request,
  928. "checkout-success.html",
  929. {"request": request, "site": site, "data": data, "cart": cart},
  930. )
  931. @app.get("/checkout/error", response_class=HTMLResponse)
  932. async def checkout_error(request: Request):
  933. """View for when the checkout procedure fails."""
  934. cart = CartSession(**request.session["cart"])
  935. site = read_file(
  936. settings.git_repo,
  937. "site",
  938. settings.document_match,
  939. settings.block_types,
  940. )
  941. data = {
  942. "title": "Checkout",
  943. }
  944. return templates.TemplateResponse(
  945. request,
  946. "checkout-error.html",
  947. {"request": request, "site": site, "data": data, "cart": cart},
  948. )
  949. @app.post("/cart/clear")
  950. async def cart_clear(
  951. request: Request,
  952. js: bool = False,
  953. ):
  954. """Clear cart."""
  955. request.session.clear()
  956. return RedirectResponse(request.base_url, status_code=303)
  957. # -- set custom HTTP errors
  958. @app.exception_handler(RequestValidationError)
  959. async def validation_exception_handler(request, exc):
  960. """Handle invalid data exception by returning an HTML response
  961. rather than a JSON one.
  962. """
  963. site = read_file(
  964. settings.git_repo,
  965. "site",
  966. settings.document_match,
  967. settings.block_types,
  968. )
  969. message = "We can't process your request."
  970. main_logger.error(exc)
  971. t = templates.TemplateResponse(
  972. request,
  973. "error.html",
  974. {
  975. "request": request,
  976. "site": site,
  977. "data": {
  978. "title": "Error",
  979. "error": message,
  980. "status_code": 422,
  981. },
  982. "cart": None,
  983. },
  984. )
  985. return HTMLResponse(content=t.body, status_code=422)
  986. @app.exception_handler(StarletteHTTPException)
  987. async def http_exception_handler(request, exc):
  988. """Custom handling of HTTP exception (400-500).
  989. Prepare an appropriate user-facing message and pass it
  990. to the error.html template.
  991. """
  992. message = ""
  993. main_logger.error(f"http-exception: error => {exc}")
  994. if exc.status_code == 404:
  995. message = "Nothing was found here."
  996. elif exc.status_code == 500:
  997. message = "Server error."
  998. elif 400 <= exc.status_code <= 499:
  999. message = "Something went wrong."
  1000. if exc.detail:
  1001. message = exc.detail
  1002. site = read_file(
  1003. settings.git_repo,
  1004. "site",
  1005. settings.document_match,
  1006. settings.block_types,
  1007. )
  1008. data = {
  1009. "title": "Error",
  1010. "error": message,
  1011. "status_code": exc.status_code,
  1012. }
  1013. t = templates.TemplateResponse(
  1014. request,
  1015. "error.html",
  1016. {
  1017. "request": request,
  1018. "site": site,
  1019. "data": data,
  1020. "cart": None,
  1021. },
  1022. )
  1023. return HTMLResponse(content=t.body, status_code=exc.status_code)