main.py 36 KB

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