main.py 43 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289
  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. quantity_in_cart = calculate_quantity_requested_by_user(
  204. session_data["cart"]["items"], doc.meta.product_id, size=size, style=style
  205. )
  206. # For the product page, we want to check if adding 1 more would exceed stock
  207. quantity_requested_by_user = quantity_in_cart + 1
  208. triplet = check_product_availability(
  209. doc, product_id, quantity_requested_by_user, size, style, settings
  210. )
  211. is_product_available, is_product_selectable, inventory_amount = triplet
  212. if js:
  213. product_selected = make_product_selected(size, style, quantity)
  214. json_response = {
  215. "is_product_available": is_product_available,
  216. "is_product_selectable": is_product_selectable,
  217. "product_selected": product_selected,
  218. }
  219. return JSONResponse(content=json_response, status_code=200)
  220. else:
  221. doc.blocks = prepare_product(doc)
  222. size_guide = None
  223. if doc.meta.options:
  224. size_guide = read_file(
  225. settings.git_repo,
  226. "products",
  227. settings.document_match,
  228. settings.block_types,
  229. None,
  230. )
  231. products = read_dir(
  232. settings.git_repo,
  233. settings.document_match,
  234. settings.document_exclude,
  235. settings.block_types,
  236. exclude_doc=doc.meta.path,
  237. tree="products",
  238. )
  239. related_products = prepare_related_products(products, product_id)
  240. site = read_file(
  241. settings.git_repo,
  242. "site",
  243. settings.document_match,
  244. settings.block_types,
  245. )
  246. response = templates.TemplateResponse(
  247. request,
  248. "product.html",
  249. {
  250. "request": request,
  251. "nav": None,
  252. "doc": doc,
  253. "size_guide": size_guide,
  254. "related_products": related_products,
  255. "is_product_available": is_product_available,
  256. "is_product_selectable": is_product_selectable,
  257. "product_form": {
  258. "size": size,
  259. "style": style,
  260. "quantity": quantity,
  261. },
  262. "site": site,
  263. "cart": cart,
  264. },
  265. )
  266. return response
  267. @app.get("/pages", response_class=RedirectResponse, status_code=301)
  268. async def products():
  269. """
  270. The pages view does not exist, so we return the user to the main view.
  271. """
  272. return "/"
  273. @app.get("/pages/{page_id}", response_class=HTMLResponse)
  274. async def page(request: Request, page_id: str):
  275. """The Page view displaying the content of the selected PAGE ID."""
  276. tree = "pages"
  277. doc = read_file(
  278. settings.git_repo,
  279. page_id,
  280. settings.document_match,
  281. settings.block_types,
  282. tree,
  283. )
  284. site = read_file(
  285. settings.git_repo,
  286. "site",
  287. settings.document_match,
  288. settings.block_types,
  289. )
  290. response = templates.TemplateResponse(
  291. request,
  292. "page.html",
  293. {
  294. "request": request,
  295. "nav": None,
  296. "doc": doc,
  297. "site": site,
  298. },
  299. )
  300. return response
  301. @app.get("/checkout", response_class=HTMLResponse)
  302. async def checkout(request: Request):
  303. """The Checkout view (GET, read-only)."""
  304. filename = request.url.path[1:]
  305. doc = read_file(
  306. settings.git_repo, filename, settings.document_match, settings.block_types
  307. )
  308. session_data = request.session
  309. initialize_cart(session_data, settings.currency.default)
  310. update_cart_meta(
  311. session_data["cart"], session_data["cart"]["meta"]["shipping"], settings
  312. )
  313. cart = CartSession(**request.session["cart"])
  314. checkout_data = prepare_checkout(cart, settings, doc)
  315. site = read_file(
  316. settings.git_repo,
  317. "site",
  318. settings.document_match,
  319. settings.block_types,
  320. )
  321. response = templates.TemplateResponse(
  322. request,
  323. "checkout.html",
  324. {
  325. "request": request,
  326. "nav": None,
  327. "data": checkout_data,
  328. "site": site,
  329. "cart": cart,
  330. },
  331. )
  332. return response
  333. @app.post("/checkout")
  334. async def checkout_update(
  335. request: Request,
  336. js: bool = False,
  337. page: Literal["product", "checkout"] = Form(...),
  338. redirect_with_items: bool | None = Form(None),
  339. operation: Literal["add", "remove", "delete", "manual"] = Form(...),
  340. product_id: str = Form(...),
  341. product_path: str = Form(...),
  342. price: int = Form(...),
  343. quantity: int = Form(...),
  344. size: Literal["s", "m", "l", "xl", "xxl"] | None = Form(None),
  345. style: str | None = Form(None),
  346. ):
  347. """The Checkout view, (POST, read-write). This view updates (add /
  348. remove) the user's session cart. If the item is already present in
  349. the cart, it increases or decreases the item quantity. Else, it
  350. will be created or removed accordingly. It will also check if any
  351. selected item in the cart is still available or it has sold out
  352. meanwhile. Morever, it displays the Checkout view.
  353. """
  354. main_logger.info(f"POST /checkout: js={js}, page={page}, product_id={product_id}, size={size}, style={style}, quantity={quantity}")
  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_in_cart = calculate_quantity_requested_by_user(
  382. session_data["cart"]["items"], product_id, size=size, style=style
  383. )
  384. quantity_requested_by_user = quantity_in_cart + quantity
  385. main_logger.info(f"Calling check_product_availability with: product_id={product_id}, quantity_requested_by_user={quantity_requested_by_user}, size={size}, style={style}")
  386. triplet = check_product_availability(
  387. selected_product,
  388. product_path,
  389. quantity_requested_by_user,
  390. size,
  391. style,
  392. settings,
  393. )
  394. is_product_available, is_product_selectable, inventory_amount = triplet
  395. main_logger.info(f"check_product_availability returned: is_product_available={is_product_available}, is_product_selectable={is_product_selectable}, inventory_amount={inventory_amount}")
  396. product_exists = product_exist_in_cart(
  397. product_id, size, style, product_variation_id, session_data["cart"]["items"]
  398. )
  399. update_cart(
  400. session_data["cart"],
  401. form,
  402. product_exists,
  403. shipping_amount,
  404. quantity,
  405. inventory_amount,
  406. settings,
  407. )
  408. cart = CartSession(**request.session["cart"])
  409. if js:
  410. # -- return JSON res for js-handled form update
  411. if page == "product":
  412. cart_breakdown = jsonable_encoder(cart.meta)
  413. product_selected = make_product_selected(size, style, quantity)
  414. json_response = {
  415. "cart_breakdown": cart_breakdown,
  416. "is_product_available": is_product_available,
  417. "is_product_selectable": is_product_selectable,
  418. "product_selected": product_selected,
  419. }
  420. elif page == "checkout":
  421. filename = request.url.path[1:]
  422. doc = read_file(
  423. settings.git_repo,
  424. filename,
  425. settings.document_match,
  426. settings.block_types,
  427. )
  428. checkout_data = prepare_checkout(
  429. cart, settings, doc, js_update=False
  430. )
  431. cart_items = [
  432. jsonable_encoder(item) for item in checkout_data["checkout_items"]
  433. ]
  434. cart_breakdown = jsonable_encoder(cart.meta)
  435. json_response = {
  436. "cart_items": cart_items,
  437. "cart_breakdown": cart_breakdown,
  438. }
  439. main_logger.info(f"AJAX response: {json_response}")
  440. return JSONResponse(content=json_response, status_code=200)
  441. else:
  442. redirect_URL = urljoin(
  443. request.headers["referer"], urlparse(request.headers["referer"]).path
  444. )
  445. redirect_URL = f"{redirect_URL}"
  446. if redirect_with_items:
  447. URI_options = []
  448. param_options = {"size": size, "style": style, "quantity": quantity}
  449. for idx, option in enumerate(param_options.keys()):
  450. if param_options[option]:
  451. param = f"{option}={param_options[option]}"
  452. URI_options.append(param)
  453. if len(URI_options) > 0:
  454. URI_params = "&".join(URI_options)
  455. redirect_URL = f"{redirect_URL}?{URI_params}"
  456. else:
  457. redirect_URL = f"{redirect_URL}"
  458. return RedirectResponse(redirect_URL, status_code=303)
  459. @app.post("/checkout/shipping")
  460. async def checkout_shipping(request: Request,
  461. js: bool = False,
  462. country: str = Form(...),
  463. weight: int = Form(...)):
  464. """
  465. This view returns the region to which the given submitted country belongs to.
  466. This is used in /checkout to update shipping costs in real time.
  467. """
  468. countries = [country.name for country in pycountry.countries]
  469. if (
  470. country not in countries
  471. or weight <= 0
  472. ):
  473. raise HTTPException(status_code=403, detail="Shipping info are incorrect.")
  474. shipping_rate = calculate_shipping_cost(country, weight, settings.currency.default)
  475. if shipping_rate:
  476. # update cart with new shipping costs
  477. session_data = request.session
  478. initialize_cart(session_data, settings.currency.default)
  479. update_cart_meta(session_data["cart"], shipping_rate.price, settings)
  480. cart = CartSession(**request.session['cart'])
  481. if js:
  482. return cart.meta
  483. else:
  484. return RedirectResponse(f"{request.base_url}checkout", status_code=303)
  485. raise HTTPException(status_code=403, detail="Shipping info are missing.")
  486. @app.post("/stripe-checkout-session", response_class=RedirectResponse, status_code=303)
  487. async def create_checkout_session(
  488. request: Request,
  489. order_id: str = Form(...),
  490. weight: int = Form(...),
  491. email: EmailStr = Form(...),
  492. first_name: str | None = Form(None),
  493. last_name: str = Form(...),
  494. address: str = Form(...),
  495. address_no: str = Form(...),
  496. address_extra: str | None = Form(None),
  497. postal_code: str = Form(...),
  498. city: str = Form(...),
  499. country: str = Form(...),
  500. phone_number: str = Form(None),
  501. note: str | None = Form(None),
  502. ):
  503. """Stripe-based checkout session. Prepare order data and create a new
  504. Stripe Checkout session. Handle gracefully in case of errors, or
  505. if order exists already in local-db and has not `status: Open`.
  506. """
  507. # -- initial form-data validation
  508. form_data = await request.form()
  509. try:
  510. form = form_validation_checkout_session(form_data)
  511. except ValidationError as e:
  512. for error in e.errors():
  513. main_logger.error(f"form-validation-stripe-checkout-session => {error}")
  514. # -- check if order-id is unique
  515. orders_settings = {
  516. "git_repo": settings.git_repo,
  517. "document_match": settings.document_match,
  518. "block_types": settings.block_types,
  519. }
  520. if not is_order_id_open(orders_settings, form.order_id):
  521. main_logger.error("stripe-checkout error: Order ID is wrong.")
  522. raise HTTPException(status_code=422, detail="Order ID is wrong.")
  523. shipping_info = {
  524. "email": form.email,
  525. "first_name": form.first_name,
  526. "last_name": form.last_name,
  527. "address": form.address,
  528. "address_no": form.address_no,
  529. "address_extra": form.address_extra,
  530. "postal_code": form.postal_code,
  531. "city": form.city,
  532. "country": form.country,
  533. "phone_number": form.phone_number,
  534. "note": form.note,
  535. }
  536. shipping_info = ShippingInfo(**shipping_info)
  537. session_data = request.session
  538. initialize_cart(session_data, settings.currency.default)
  539. if "cart" not in session_data and len(session_data["cart"].keys()) == 0:
  540. main_logger.error("stripe-checkout error: Cart is empty.")
  541. raise HTTPException(status_code=403, detail="Cart is empty.")
  542. cart = CartSession(**request.session["cart"])
  543. try:
  544. line_items = prepare_stripe_data(cart)
  545. shipping_rate = prepare_shipping_rate(shipping_info.country, form.weight, settings.currency.default)
  546. if shipping_rate is None:
  547. main_logger.error("stripe-checkout error: Shipping info are missing.")
  548. raise HTTPException(status_code=403, detail="Shipping info are missing.")
  549. checkout_session = stripe.checkout.Session.create(
  550. mode="payment",
  551. line_items=line_items,
  552. shipping_options=[shipping_rate],
  553. customer_email=shipping_info.email,
  554. client_reference_id=form.order_id,
  555. success_url=f"{request.base_url}checkout/success",
  556. cancel_url=f"{request.base_url}checkout",
  557. )
  558. product_list = create_product_list(settings, cart.items)
  559. # -- save transaction to local-db
  560. customer_order = prepare_customer_order_data(
  561. settings.git_repo,
  562. settings.document_match,
  563. settings.document_exclude,
  564. settings.block_types,
  565. checkout_session["id"],
  566. product_list,
  567. "Stripe",
  568. form.order_id,
  569. cart,
  570. shipping_info,
  571. )
  572. save_shipping_info(
  573. customer_order, settings.git_repo, settings.local_db.filepath
  574. )
  575. # -- redirect to Stripe Checkout page
  576. return checkout_session.url
  577. except stripe.error.CardError as e:
  578. main_logger.error(
  579. f"stripe-checkout error: A payment error occurred: {e.user_message}"
  580. )
  581. raise HTTPException(status_code=400, detail="Stripe payment error.")
  582. except stripe.error.InvalidRequestError as e:
  583. main_logger.error(f"stripe-checkout error: An invalid request occurred. => {e}")
  584. raise HTTPException(
  585. status_code=400, detail="Stripe payment error (invalid request)."
  586. )
  587. except Exception as e:
  588. main_logger.error(
  589. f"stripe-checkout error: Another problem occurred, maybe unrelated to Stripe. {e}"
  590. )
  591. raise HTTPException(status_code=400, detail="Stripe payment error.")
  592. @app.post("/stripe-webhook", response_class=JSONResponse)
  593. async def stripe_webhook(request: Request, background_tasks: BackgroundTasks):
  594. """Stripe webhook endpoint to receive updates from Stripe's checkout
  595. operations. Send email after successful order confirmation.
  596. """
  597. main_logger.info("stripe-webhook => ...")
  598. try:
  599. event = None
  600. payload = await request.body()
  601. sig_header = request.headers["stripe-signature"]
  602. try:
  603. if os.getenv("ENV") == "production":
  604. endpoint_secret = os.getenv("STRIPE_ENDPOINT_SECRET")
  605. else:
  606. endpoint_secret = os.getenv("STRIPE_TEST_ENDPOINT_SECRET")
  607. event = stripe.Webhook.construct_event(payload, sig_header, endpoint_secret)
  608. except ValueError as e:
  609. main_logger.error(
  610. f"stripe-webhook error: Webhook error while parsing basic request. {e}"
  611. )
  612. raise HTTPException(status_code=400, detail="Stripe payment error.")
  613. except stripe.error.SignatureVerificationError as e:
  614. main_logger.error(
  615. f"stripe-webhook error: Webhook signature verification failed. {e}"
  616. )
  617. raise HTTPException(status_code=400, detail="Stripe payment error.")
  618. if event:
  619. checkout = event["data"]["object"]
  620. if "client_reference_id" in checkout:
  621. try:
  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. # Don't raise an exception, just log and continue
  631. #raise HTTPException(
  632. # status_code=400, detail="Order could not be updated."
  633. #)
  634. # We still want to try updating inventory and sending email
  635. except KeyError as e:
  636. main_logger.error(f"stripe-webhook error: Missing key in order data: {e}")
  637. # Continue processing - we want to still try to send the email
  638. except Exception as e:
  639. main_logger.error(f"stripe-webhook error: Unexpected error updating order: {e}")
  640. # Continue processing
  641. if event["type"] in [
  642. "checkout.session.completed",
  643. "checkout.session.async_payment_succeeded",
  644. ]:
  645. order_id = checkout["client_reference_id"]
  646. product_list = fetch_order_product_list(
  647. order_id, settings.git_repo, settings.local_db.filepath
  648. )
  649. if product_list:
  650. for product_info in product_list:
  651. # We need to read the product file and get the variation ID
  652. try:
  653. product = read_file(
  654. settings.git_repo,
  655. f"products/{product_info.filename}",
  656. settings.document_match,
  657. settings.block_types,
  658. )
  659. # Get the variation ID based on size and style
  660. product_variation_id = get_variation_id(product, product_info.size, product_info.style)
  661. update_product_inventory(
  662. product_info.filename,
  663. product_info.quantity,
  664. product_variation_id,
  665. settings,
  666. )
  667. except Exception as e:
  668. main_logger.error(f"stripe-webhook error: Failed to update inventory: {e}")
  669. # Continue processing other products
  670. # -- send email
  671. try:
  672. email_settings = {
  673. "git_repo": settings.git_repo,
  674. "document_match": settings.document_match,
  675. "block_types": settings.block_types,
  676. }
  677. # It's possible the order was just created and not all data is available yet
  678. # The prepare_email_order function should handle this gracefully
  679. try:
  680. order = prepare_email_order(order_id, email_settings)
  681. await send_email(email_settings, order, background_tasks)
  682. except TypeError as e:
  683. # Specifically catch the 'NoneType' object is not iterable error
  684. main_logger.warning(f"stripe-webhook warning: Email preparation issue, possibly due to timing: {e}")
  685. # Consider implementing a retry mechanism here if needed
  686. # For example, add the email task to a queue to try again later
  687. except Exception as e:
  688. main_logger.error(f"stripe-webhook error: Failed to send email: {e}")
  689. # Continue processing - we've already done inventory updates
  690. elif event["type"] == "checkout.session.async_payment_failed":
  691. main_logger.error(
  692. f"stripe-webhook error: Stripe payment error. {event['type']}"
  693. )
  694. raise HTTPException(status_code=400, detail="Stripe payment error.")
  695. else:
  696. main_logger.error(f"stripe-webhook error: Unhandled event type {event['type']}")
  697. raise HTTPException(status_code=400, detail="Stripe payment error.")
  698. except Exception as e:
  699. main_logger.error(f"stripe-webhook error: Unhandled exception: {e}")
  700. import traceback
  701. main_logger.error(traceback.format_exc())
  702. # Still return success to prevent Stripe from retrying the webhook
  703. # -- return 200
  704. return {"success": True}
  705. @app.post("/create-now-payments", response_class=RedirectResponse, status_code=303)
  706. async def create_now_payments(
  707. request: Request,
  708. order_id: str = Form(...),
  709. weight: int = Form(...),
  710. email: EmailStr = Form(...),
  711. first_name: str | None = Form(None),
  712. last_name: str = Form(...),
  713. address: str = Form(...),
  714. address_no: str = Form(...),
  715. address_extra: str | None = Form(None),
  716. postal_code: str = Form(...),
  717. city: str = Form(...),
  718. country: str = Form(...),
  719. phone_number: str = Form(None),
  720. note: str | None = Form(None),
  721. ):
  722. """NOWPayments checkout session. User selects with which currency to pay.
  723. Sort of a custom NOW Payments Invoice page.
  724. """
  725. # -- initial form-data validation
  726. form_data = await request.form()
  727. try:
  728. form = form_validation_checkout_session(form_data)
  729. except ValidationError as e:
  730. for error in e.errors():
  731. main_logger.error(f"form-validation-stripe-checkout-session => {error}")
  732. # -- check if order-id is unique
  733. orders_settings = {
  734. "git_repo": settings.git_repo,
  735. "document_match": settings.document_match,
  736. "block_types": settings.block_types,
  737. }
  738. if not is_order_id_open(orders_settings, form.order_id):
  739. main_logger.error("now-payments-checkout error: Order ID is wrong.")
  740. raise HTTPException(status_code=422, detail="Order ID is wrong.")
  741. shipping_info = {
  742. "email": form.email,
  743. "first_name": form.first_name,
  744. "last_name": form.last_name,
  745. "address": form.address,
  746. "address_no": form.address_no,
  747. "address_extra": form.address_extra,
  748. "postal_code": form.postal_code,
  749. "city": form.city,
  750. "country": form.country,
  751. "phone_number": form.phone_number,
  752. "note": form.note,
  753. }
  754. shipping_info = ShippingInfo(**shipping_info)
  755. session_data = request.session
  756. initialize_cart(session_data, settings.currency.default)
  757. if "cart" not in session_data and len(session_data["cart"].keys()) == 0:
  758. main_logger.error("now-payments-checkout error: Cart is empty.")
  759. raise HTTPException(status_code=403, detail="Cart is empty.")
  760. cart = CartSession(**request.session["cart"])
  761. data = {
  762. "price_amount": cart.meta.total,
  763. "price_currency": settings.currency.now_payments,
  764. "order_id": form.order_id,
  765. "ipn_callback_url": f"{request.base_url}now-webhook",
  766. "success_url": f"{request.base_url}checkout/success",
  767. "cancel_url": f"{request.base_url}checkout",
  768. }
  769. data = NowPaymentsInvoice(**data)
  770. payment_url, np_order_id = await generate_payment_link(data)
  771. product_list = create_product_list(settings, cart.items)
  772. # -- save transaction to local-db
  773. customer_order = prepare_customer_order_data(
  774. settings.git_repo,
  775. settings.document_match,
  776. settings.document_exclude,
  777. settings.block_types,
  778. np_order_id,
  779. product_list,
  780. "NOW Payments",
  781. data.order_id,
  782. cart,
  783. shipping_info,
  784. )
  785. save_shipping_info(customer_order, settings.git_repo, settings.local_db.filepath)
  786. # -- redirect to NOWPayments Checkout page
  787. return payment_url
  788. @app.post("/now-webhook")
  789. async def now_webhook(request: Request, background_tasks: BackgroundTasks):
  790. """NOW Payments IPN (Instant Payment Notification) webhook.
  791. Send email after successful payment confirmation.
  792. """
  793. main_logger.info("now-webhook: Starting processing")
  794. try:
  795. payload = await request.json()
  796. main_logger.info(f"now-webhook: Received payload with status: {payload.get('payment_status', 'unknown')}")
  797. sig_header = request.headers["x-nowpayments-sig"]
  798. if os.getenv("ENV") == "production":
  799. ipn_secret_key = os.getenv("NOWPAYMENTS_IPN")
  800. else:
  801. ipn_secret_key = os.getenv("NOWPAYMENTS_SANDBOX_IPN")
  802. is_verified = ipn_signature_check(ipn_secret_key, sig_header, payload)
  803. if is_verified:
  804. # check payment status, update order if
  805. # status has changed (finished, expired, failed)
  806. # send email if status: finished.
  807. event_type = payload["payment_status"]
  808. main_logger.info(f"now-webhook: Processing verified event type: {event_type}")
  809. if event_type in ["finished", "expired", "failed"]:
  810. if "payment_id" in payload:
  811. order_id = payload["order_id"]
  812. main_logger.info(f"now-webhook: Processing order: {order_id}")
  813. status_res = update_status_order(
  814. event_type,
  815. order_id,
  816. settings.git_repo,
  817. settings.local_db.filepath,
  818. )
  819. if status_res is False:
  820. main_logger.error(f"now-webhook: Failed to update order status for {order_id}")
  821. return JSONResponse(content={"success": False, "error": "Order could not be updated."}, status_code=400)
  822. if event_type == "finished":
  823. product_list = fetch_order_product_list(
  824. order_id, settings.git_repo, settings.local_db.filepath
  825. )
  826. main_logger.info(f"now-webhook: Found {len(product_list) if product_list else 0} products to update")
  827. if product_list:
  828. updated_products = 0
  829. for product_info in product_list:
  830. try:
  831. product = read_file(
  832. settings.git_repo,
  833. f"products/{product_info.filename}",
  834. settings.document_match,
  835. settings.block_types,
  836. )
  837. # Get the variation ID based on size and style
  838. product_variation_id = get_variation_id(product,
  839. product_info.size,
  840. product_info.style)
  841. main_logger.info(f"now-webhook: Updating inventory for {product_info.filename}, variation: {product_variation_id}, quantity: {product_info.quantity}")
  842. update_product_inventory(
  843. product_info.filename,
  844. product_info.quantity,
  845. product_variation_id,
  846. settings,
  847. )
  848. updated_products += 1
  849. except Exception as e:
  850. main_logger.error(f"now-webhook error: Failed to update inventory for {product_info.filename}: {e}")
  851. import traceback
  852. main_logger.error(traceback.format_exc())
  853. # Continue processing other products
  854. main_logger.info(f"now-webhook: Successfully updated {updated_products} out of {len(product_list)} products")
  855. # -- send email
  856. try:
  857. email_settings = {
  858. "git_repo": settings.git_repo,
  859. "document_match": settings.document_match,
  860. "block_types": settings.block_types,
  861. }
  862. try:
  863. main_logger.info(f"now-webhook: Preparing email for order {order_id}")
  864. order = prepare_email_order(order_id, email_settings)
  865. main_logger.info(f"now-webhook: Sending email for order {order_id}")
  866. await send_email(email_settings, order, background_tasks)
  867. main_logger.info(f"now-webhook: Email queued successfully")
  868. except TypeError as e:
  869. main_logger.warning(f"now-webhook warning: Email preparation issue: {e}")
  870. import traceback
  871. main_logger.error(traceback.format_exc())
  872. except Exception as e:
  873. main_logger.error(f"now-webhook error: Failed to send email: {e}")
  874. import traceback
  875. main_logger.error(traceback.format_exc())
  876. elif event_type == "failed":
  877. main_logger.error(f"now-webhook: Payment failed for order {order_id}")
  878. # We don't want to raise an exception here since we need to return a success response
  879. # Just log the error
  880. else:
  881. main_logger.error("now-webhook: payment_id not in payload")
  882. return JSONResponse(content={"success": False, "error": "Payment ID missing"}, status_code=400)
  883. else:
  884. main_logger.info(f"now-webhook: Event type {event_type} not processed (not in finished/expired/failed)")
  885. else:
  886. main_logger.error("now-webhook: Signature verification failed")
  887. return JSONResponse(content={"success": False, "error": "Signature verification failed"}, status_code=400)
  888. except Exception as e:
  889. main_logger.error(f"now-webhook error: Unhandled exception: {e}")
  890. import traceback
  891. main_logger.error(traceback.format_exc())
  892. # Return success anyway to prevent NOW Payments from retrying constantly
  893. main_logger.info("now-webhook: Processing completed")
  894. return JSONResponse(content={"success": True}, status_code=200)
  895. @app.get("/checkout/success", response_class=HTMLResponse)
  896. async def checkout_success(request: Request):
  897. """View that confirms to the user that the checkout procedure was successful."""
  898. session_data = request.session
  899. # if there's no cart session, redirect the user to `/`
  900. if "cart" not in session_data:
  901. clear_cart(session_data, settings.currency.default)
  902. return RedirectResponse(f"{request.base_url}", status_code=303)
  903. cart = CartSession(**request.session["cart"])
  904. filename = "orders"
  905. orders = read_file(
  906. settings.git_repo, filename, settings.document_match, settings.block_types
  907. )
  908. customer_order = [
  909. order for order in orders.blocks if order.order_id == cart.meta.order_id
  910. ]
  911. # if cart.meta.order_id does not match any order, redirect the user to `/`
  912. if len(customer_order) == 0:
  913. clear_cart(session_data, settings.currency.default)
  914. return RedirectResponse(f"{request.base_url}", status_code=303)
  915. # -- save data and clear cart, return view
  916. data = {
  917. "title": "Checkout Success",
  918. "order_id": cart.meta.order_id,
  919. "customer_email": customer_order[0].shipping_info.email,
  920. }
  921. # -- prepare order to xlsx format
  922. product_list = fetch_order_product_list(
  923. cart.meta.order_id, settings.git_repo, settings.local_db.filepath
  924. )
  925. if not product_list:
  926. raise HTTPException(status_code=403, detail="Order checkout is missing information.")
  927. products_shipping_meta = []
  928. for product_info in product_list:
  929. product = read_file(settings.git_repo, f"products/{product_info.filename}", settings.document_match, settings.block_types)
  930. products_shipping_meta.append(
  931. ProductShippingMeta(
  932. weight=product.meta.weight,
  933. customs_tariff_code=product.meta.customs_tariff_code,
  934. origin_country_iso=product.meta.origin_country_iso,
  935. )
  936. )
  937. email_from = os.getenv("MAIL_FROM")
  938. export_order_to_xlsx(
  939. customer_order[0],
  940. products_shipping_meta,
  941. settings.order_upload.filepath,
  942. settings.currency.default,
  943. settings.git_repo,
  944. email_from,
  945. )
  946. clear_cart(session_data, settings.currency.default)
  947. cart = CartSession(**request.session["cart"])
  948. site = read_file(
  949. settings.git_repo,
  950. "site",
  951. settings.document_match,
  952. settings.block_types,
  953. )
  954. return templates.TemplateResponse(
  955. request,
  956. "checkout-success.html",
  957. {"request": request, "site": site, "data": data, "cart": cart},
  958. )
  959. @app.get("/checkout/error", response_class=HTMLResponse)
  960. async def checkout_error(request: Request):
  961. """View for when the checkout procedure fails."""
  962. cart = CartSession(**request.session["cart"])
  963. site = read_file(
  964. settings.git_repo,
  965. "site",
  966. settings.document_match,
  967. settings.block_types,
  968. )
  969. data = {
  970. "title": "Checkout",
  971. }
  972. return templates.TemplateResponse(
  973. request,
  974. "checkout-error.html",
  975. {"request": request, "site": site, "data": data, "cart": cart},
  976. )
  977. @app.post("/cart/clear")
  978. async def cart_clear(
  979. request: Request,
  980. js: bool = False,
  981. ):
  982. """Clear cart."""
  983. request.session.clear()
  984. return RedirectResponse(request.base_url, status_code=303)
  985. # -- set custom HTTP errors
  986. @app.exception_handler(RequestValidationError)
  987. async def validation_exception_handler(request, exc):
  988. """Handle invalid data exception by returning an HTML response
  989. rather than a JSON one.
  990. """
  991. site = read_file(
  992. settings.git_repo,
  993. "site",
  994. settings.document_match,
  995. settings.block_types,
  996. )
  997. message = "We can't process your request."
  998. main_logger.error(exc)
  999. t = templates.TemplateResponse(
  1000. request,
  1001. "error.html",
  1002. {
  1003. "request": request,
  1004. "site": site,
  1005. "data": {
  1006. "title": "Error",
  1007. "error": message,
  1008. "status_code": 422,
  1009. },
  1010. "cart": None,
  1011. },
  1012. )
  1013. return HTMLResponse(content=t.body, status_code=422)
  1014. @app.exception_handler(StarletteHTTPException)
  1015. async def http_exception_handler(request, exc):
  1016. """Custom handling of HTTP exception (400-500).
  1017. Prepare an appropriate user-facing message and pass it
  1018. to the error.html template.
  1019. """
  1020. message = ""
  1021. main_logger.error(f"http-exception: error => {exc}")
  1022. if exc.status_code == 404:
  1023. message = "Nothing was found here."
  1024. elif exc.status_code == 500:
  1025. message = "Server error."
  1026. elif 400 <= exc.status_code <= 499:
  1027. message = "Something went wrong."
  1028. if exc.detail:
  1029. message = exc.detail
  1030. site = read_file(
  1031. settings.git_repo,
  1032. "site",
  1033. settings.document_match,
  1034. settings.block_types,
  1035. )
  1036. data = {
  1037. "title": "Error",
  1038. "error": message,
  1039. "status_code": exc.status_code,
  1040. }
  1041. t = templates.TemplateResponse(
  1042. request,
  1043. "error.html",
  1044. {
  1045. "request": request,
  1046. "site": site,
  1047. "data": data,
  1048. "cart": None,
  1049. },
  1050. )
  1051. return HTMLResponse(content=t.body, status_code=exc.status_code)