main.py 36 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184
  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. event = None
  595. payload = await request.body()
  596. sig_header = request.headers["stripe-signature"]
  597. try:
  598. if os.getenv("ENV") == "production":
  599. endpoint_secret = os.getenv("STRIPE_ENDPOINT_SECRET")
  600. else:
  601. endpoint_secret = os.getenv("STRIPE_TEST_ENDPOINT_SECRET")
  602. event = stripe.Webhook.construct_event(payload, sig_header, endpoint_secret)
  603. except ValueError as e:
  604. main_logger.error(
  605. f"stripe-webhook error: Webhook error while parsing basic request. {e}"
  606. )
  607. raise HTTPException(status_code=400, detail="Stripe payment error.")
  608. except stripe.error.SignatureVerificationError as e:
  609. main_logger.error(
  610. f"stripe-webhook error: Webhook signature verification failed. {e}"
  611. )
  612. raise HTTPException(status_code=400, detail="Stripe payment error.")
  613. if event:
  614. checkout = event["data"]["object"]
  615. if "client_reference_id" in checkout:
  616. status_res = update_status_order(
  617. event["type"],
  618. checkout["client_reference_id"],
  619. settings.git_repo,
  620. settings.local_db.filepath,
  621. )
  622. if status_res is False:
  623. main_logger.error("stripe-webhook error: order could not be updated.")
  624. raise HTTPException(
  625. status_code=400, detail="Order could not be updated."
  626. )
  627. if event["type"] in [
  628. "checkout.session.completed",
  629. "checkout.session.async_payment_succeeded",
  630. ]:
  631. order_id = checkout["client_reference_id"]
  632. product_list = fetch_order_product_list(
  633. order_id, settings.git_repo, settings.local_db.filepath
  634. )
  635. if product_list:
  636. for product_info in product_list:
  637. update_product_inventory(
  638. product_info.filename,
  639. product_info.quantity,
  640. product_info.product_variation_id,
  641. settings,
  642. )
  643. # -- send email
  644. email_settings = {
  645. "git_repo": settings.git_repo,
  646. "document_match": settings.document_match,
  647. "block_types": settings.block_types,
  648. }
  649. order = prepare_email_order(order_id, email_settings)
  650. await send_email(email_settings, order, background_tasks)
  651. elif event["type"] == "checkout.session.async_payment_failed":
  652. main_logger.error(
  653. f"stripe-webhook error: Stripe payment error. {event['type']}"
  654. )
  655. raise HTTPException(status_code=400, detail="Stripe payment error.")
  656. else:
  657. main_logger.error(f"stripe-webhook error: Unhandled event type {event['type']}")
  658. raise HTTPException(status_code=400, detail="Stripe payment error.")
  659. # -- return 200
  660. return {"success": True}
  661. @app.post("/create-now-payments", response_class=RedirectResponse, status_code=303)
  662. async def create_now_payments(
  663. request: Request,
  664. order_id: str = Form(...),
  665. weight: int = Form(...),
  666. email: EmailStr = Form(...),
  667. first_name: str | None = Form(None),
  668. last_name: str = Form(...),
  669. address: str = Form(...),
  670. address_no: str = Form(...),
  671. address_extra: str | None = Form(None),
  672. postal_code: str = Form(...),
  673. city: str = Form(...),
  674. country: str = Form(...),
  675. phone_number: str = Form(None),
  676. note: str | None = Form(None),
  677. ):
  678. """NOWPayments checkout session. User selects with which currency to pay.
  679. Sort of a custom NOW Payments Invoice page.
  680. """
  681. # -- initial form-data validation
  682. form_data = await request.form()
  683. try:
  684. form = form_validation_checkout_session(form_data)
  685. except ValidationError as e:
  686. for error in e.errors():
  687. main_logger.error(f"form-validation-stripe-checkout-session => {error}")
  688. # -- check if order-id is unique
  689. orders_settings = {
  690. "git_repo": settings.git_repo,
  691. "document_match": settings.document_match,
  692. "block_types": settings.block_types,
  693. }
  694. if not is_order_id_open(orders_settings, form.order_id):
  695. main_logger.error("now-payments-checkout error: Order ID is wrong.")
  696. raise HTTPException(status_code=422, detail="Order ID is wrong.")
  697. shipping_info = {
  698. "email": form.email,
  699. "first_name": form.first_name,
  700. "last_name": form.last_name,
  701. "address": form.address,
  702. "address_no": form.address_no,
  703. "address_extra": form.address_extra,
  704. "postal_code": form.postal_code,
  705. "city": form.city,
  706. "country": form.country,
  707. "phone_number": form.phone_number,
  708. "note": form.note,
  709. }
  710. shipping_info = ShippingInfo(**shipping_info)
  711. session_data = request.session
  712. initialize_cart(session_data, settings.currency.default)
  713. if "cart" not in session_data and len(session_data["cart"].keys()) == 0:
  714. main_logger.error("now-payments-checkout error: Cart is empty.")
  715. raise HTTPException(status_code=403, detail="Cart is empty.")
  716. cart = CartSession(**request.session["cart"])
  717. data = {
  718. "price_amount": cart.meta.total,
  719. "price_currency": settings.currency.now_payments,
  720. "order_id": form.order_id,
  721. "ipn_callback_url": f"{request.base_url}now-webhook",
  722. "success_url": f"{request.base_url}checkout/success",
  723. "cancel_url": f"{request.base_url}checkout",
  724. }
  725. data = NowPaymentsInvoice(**data)
  726. payment_url, np_order_id = await generate_payment_link(data)
  727. product_list = create_product_list(settings, cart.items)
  728. # -- save transaction to local-db
  729. customer_order = prepare_customer_order_data(
  730. settings.git_repo,
  731. settings.document_match,
  732. settings.document_exclude,
  733. settings.block_types,
  734. np_order_id,
  735. product_list,
  736. "NOW Payments",
  737. data.order_id,
  738. cart,
  739. shipping_info,
  740. )
  741. save_shipping_info(customer_order, settings.git_repo, settings.local_db.filepath)
  742. # -- redirect to NOWPayments Checkout page
  743. return payment_url
  744. @app.post("/now-webhook")
  745. async def now_webhook(request: Request, background_tasks: BackgroundTasks):
  746. """NOW Payments IPN (Instant Payment Notification) webhook.
  747. Send email after successful payment confirmation.
  748. """
  749. main_logger.info("now-webhook...")
  750. payload = await request.json()
  751. sig_header = request.headers["x-nowpayments-sig"]
  752. if os.getenv("ENV") == "production":
  753. ipn_secret_key = os.getenv("NOWPAYMENTS_IPN")
  754. else:
  755. ipn_secret_key = os.getenv("NOWPAYMENTS_SANDBOX_IPN")
  756. is_verified = ipn_signature_check(ipn_secret_key, sig_header, payload)
  757. if is_verified:
  758. # check payment status, update order if
  759. # status has changed (finished, expired, failed)
  760. # send email if status: finished.
  761. event_type = payload["payment_status"]
  762. if event_type in ["finished", "expired", "failed"]:
  763. if "payment_id" in payload:
  764. order_id = payload["order_id"]
  765. status_res = update_status_order(
  766. event_type,
  767. order_id,
  768. settings.git_repo,
  769. settings.local_db.filepath,
  770. )
  771. if status_res is False:
  772. raise HTTPException(
  773. status_code=400, detail="Order could not be updated."
  774. )
  775. if event_type == "finished":
  776. product_list = fetch_order_product_list(
  777. order_id, settings.git_repo, settings.local_db.filepath
  778. )
  779. if product_list:
  780. for product_info in product_list:
  781. update_product_inventory(
  782. product_info.filename,
  783. product_info.quantity,
  784. product_info.product_variation_id,
  785. settings,
  786. )
  787. # -- send email
  788. email_settings = {
  789. "git_repo": settings.git_repo,
  790. "document_match": settings.document_match,
  791. "block_types": settings.block_types,
  792. }
  793. order = prepare_email_order(order_id, email_settings)
  794. await send_email(email_settings, order, background_tasks)
  795. elif event_type == "failed":
  796. main_logger.error(
  797. f"(NowPayments) Unhandled event type {event_type}"
  798. )
  799. raise HTTPException(
  800. status_code=400, detail="NOWPayments payment error."
  801. )
  802. else:
  803. main_logger.error("(NowPayments) payment_id not in payload")
  804. raise HTTPException(
  805. status_code=400, detail="NOWPayments payment error."
  806. )
  807. @app.get("/checkout/success", response_class=HTMLResponse)
  808. async def checkout_success(request: Request):
  809. """View that confirms to the user that the checkout procedure was successful."""
  810. session_data = request.session
  811. # if there's no cart session, redirect the user to `/`
  812. if "cart" not in session_data:
  813. clear_cart(session_data, settings.currency.default)
  814. return RedirectResponse(f"{request.base_url}", status_code=303)
  815. cart = CartSession(**request.session["cart"])
  816. filename = "orders"
  817. orders = read_file(
  818. settings.git_repo, filename, settings.document_match, settings.block_types
  819. )
  820. customer_order = [
  821. order for order in orders.blocks if order.order_id == cart.meta.order_id
  822. ]
  823. # if cart.meta.order_id does not match any order, redirect the user to `/`
  824. if len(customer_order) == 0:
  825. clear_cart(session_data, settings.currency.default)
  826. return RedirectResponse(f"{request.base_url}", status_code=303)
  827. # -- save data and clear cart, return view
  828. data = {
  829. "title": "Checkout Success",
  830. "order_id": cart.meta.order_id,
  831. "customer_email": customer_order[0].shipping_info.email,
  832. }
  833. # -- prepare order to xlsx format
  834. product_list = fetch_order_product_list(
  835. cart.meta.order_id, settings.git_repo, settings.local_db.filepath
  836. )
  837. if not product_list:
  838. raise HTTPException(status_code=403, detail="Order checkout is missing information.")
  839. products_shipping_meta = []
  840. for product_info in product_list:
  841. product = read_file(settings.git_repo, f"products/{product_info.filename}", settings.document_match, settings.block_types)
  842. products_shipping_meta.append(
  843. ProductShippingMeta(
  844. weight=product.meta.weight,
  845. customs_tariff_code=product.meta.customs_tariff_code,
  846. origin_country_iso=product.meta.origin_country_iso,
  847. )
  848. )
  849. email_from = os.getenv("MAIL_FROM")
  850. export_order_to_xlsx(
  851. customer_order[0],
  852. products_shipping_meta,
  853. settings.order_upload.filepath,
  854. settings.currency.default,
  855. settings.git_repo,
  856. email_from,
  857. )
  858. clear_cart(session_data, settings.currency.default)
  859. cart = CartSession(**request.session["cart"])
  860. site = read_file(
  861. settings.git_repo,
  862. "site",
  863. settings.document_match,
  864. settings.block_types,
  865. )
  866. return templates.TemplateResponse(
  867. request,
  868. "checkout-success.html",
  869. {"request": request, "site": site, "data": data, "cart": cart},
  870. )
  871. @app.get("/checkout/error", response_class=HTMLResponse)
  872. async def checkout_error(request: Request):
  873. """View for when the checkout procedure fails."""
  874. cart = CartSession(**request.session["cart"])
  875. site = read_file(
  876. settings.git_repo,
  877. "site",
  878. settings.document_match,
  879. settings.block_types,
  880. )
  881. data = {
  882. "title": "Checkout",
  883. }
  884. return templates.TemplateResponse(
  885. request,
  886. "checkout-error.html",
  887. {"request": request, "site": site, "data": data, "cart": cart},
  888. )
  889. @app.post("/cart/clear")
  890. async def cart_clear(
  891. request: Request,
  892. js: bool = False,
  893. ):
  894. """Clear cart."""
  895. request.session.clear()
  896. return RedirectResponse(request.base_url, status_code=303)
  897. # -- set custom HTTP errors
  898. @app.exception_handler(RequestValidationError)
  899. async def validation_exception_handler(request, exc):
  900. """Handle invalid data exception by returning an HTML response
  901. rather than a JSON one.
  902. """
  903. site = read_file(
  904. settings.git_repo,
  905. "site",
  906. settings.document_match,
  907. settings.block_types,
  908. )
  909. message = "We can't process your request."
  910. main_logger.error(exc)
  911. t = templates.TemplateResponse(
  912. request,
  913. "error.html",
  914. {
  915. "request": request,
  916. "site": site,
  917. "data": {
  918. "title": "Error",
  919. "error": message,
  920. "status_code": 422,
  921. },
  922. "cart": None,
  923. },
  924. )
  925. return HTMLResponse(content=t.body, status_code=422)
  926. @app.exception_handler(StarletteHTTPException)
  927. async def http_exception_handler(request, exc):
  928. """Custom handling of HTTP exception (400-500).
  929. Prepare an appropriate user-facing message and pass it
  930. to the error.html template.
  931. """
  932. message = ""
  933. main_logger.error(f"http-exception: error => {exc}")
  934. if exc.status_code == 404:
  935. message = "Nothing was found here."
  936. elif exc.status_code == 500:
  937. message = "Server error."
  938. elif 400 <= exc.status_code <= 499:
  939. message = "Something went wrong."
  940. if exc.detail:
  941. message = exc.detail
  942. site = read_file(
  943. settings.git_repo,
  944. "site",
  945. settings.document_match,
  946. settings.block_types,
  947. )
  948. data = {
  949. "title": "Error",
  950. "error": message,
  951. "status_code": exc.status_code,
  952. }
  953. t = templates.TemplateResponse(
  954. request,
  955. "error.html",
  956. {
  957. "request": request,
  958. "site": site,
  959. "data": data,
  960. "cart": None,
  961. },
  962. )
  963. return HTMLResponse(content=t.body, status_code=exc.status_code)