Browse Source

first commit after cleaning, working version

sylhaf 2 years ago
commit
eb18bcc72b
8 changed files with 563 additions and 0 deletions
  1. 8 0
      .gitignore
  2. 49 0
      app_logging.py
  3. 411 0
      main.py
  4. BIN
      msedgedriver.exe
  5. 19 0
      process.txt
  6. 41 0
      readme.md
  7. 11 0
      references_example.conf
  8. 24 0
      requirements.txt

+ 8 - 0
.gitignore

@@ -0,0 +1,8 @@
+logs
+logs/**
+logs\**
+__pycache__
+__pycache__/**
+__pycache__\**
+references.conf
+test_stripe.py

+ 49 - 0
app_logging.py

@@ -0,0 +1,49 @@
+from distutils.debug import DEBUG
+import logging
+from logging.handlers import RotatingFileHandler
+
+global logger_name
+logger_name = "slash-dolistripe"
+__is_init__ = False
+
+
+
+
+
+def init() :
+
+    global __is_init__
+    if __is_init__ :
+        return
+    #create log folder at execution path"
+    import os
+    if not os.path.exists('logs'):
+        os.makedirs('logs')
+
+    # Create a custom logger
+    logger = logging.getLogger(logger_name)
+
+    #TODO create Categories here
+    # of this style : logger = logging.getLogger(logger_name + ".persistence")
+
+    # Create handlers
+    c_handler = logging.StreamHandler()
+    f_handler = RotatingFileHandler('logs/slash-dolistripe.log',encoding = "UTF-8",backupCount=5,maxBytes=20000000) # 2mo per log
+    c_handler.setLevel(logging.DEBUG)
+    f_handler.setLevel(logging.DEBUG)
+    f_handler.doRollover()
+
+    # Create formatters and add it to handlers
+    c_format = logging.Formatter('[%(asctime)s] - %(name)s - %(levelname)s - %(message)s')
+    f_format = logging.Formatter('[%(asctime)s] - %(name)s - %(levelname)s - %(message)s')
+    c_handler.setFormatter(c_format)
+    f_handler.setFormatter(f_format)
+
+    # Add handlers to the logger
+    logger.addHandler(c_handler)
+    logger.addHandler(f_handler)
+
+    logger.setLevel(logging.DEBUG)
+
+    logger.debug("logging engine started")
+    __is_init__ = True

+ 411 - 0
main.py

@@ -0,0 +1,411 @@
+
+from asyncio.windows_events import NULL
+from time import sleep
+from app_logging import logger_name, init as init_logging
+import logging
+logger = logging.getLogger(logger_name)
+import requests,json
+import datetime
+import argparse
+
+
+init_logging()
+
+logger.debug(__name__)
+
+
+logger.info("")
+logger.info("")
+logger.info("")
+logger.info("-------         welcome to Slash-Dolistripe        -------")
+
+# Method to read config file settings
+logger.info(" --- -------------------------------------------------------------------------------- ---")
+logger.info(" --- ---------------------------------- ARG PHASE ----------------------------------- ---")
+logger.info(" --- -------------------------------------------------------------------------------- ---")
+
+
+parser = argparse.ArgumentParser()
+parser.add_argument("-v","--verbosity", help="increase output verbosity",action="store_true")
+parser.add_argument("-d","--dry", help="perform a dry run",action="store_true")
+parser.add_argument("-m","--mail", help="send invoice per mail to client",action="store_true")
+parser.add_argument("-p","--planned", help="trigger planned work to ",action="store_true")
+
+args = parser.parse_args()
+if args.verbosity:
+    logger.info("Args : Debug Verbose Enabled")
+    logger.setLevel(logging.DEBUG)
+else :
+    logger.setLevel(logging.INFO)
+
+args = parser.parse_args()
+if args.dry:
+    logger.info("Args : Dry Run Enabled")
+
+args = parser.parse_args()
+if args.mail:
+    logger.info("Args : send invoice per Mails Enabled")
+
+
+logger.info(" --- -------------------------------------------------------------------------------- ---")
+logger.info(" --- ---------------------------------- CONF PHASE ---------------------------------- ---")
+logger.info(" --- -------------------------------------------------------------------------------- ---")
+logger.info("Reading Reference ConfIguration File")
+import configparser
+config = configparser.ConfigParser()
+config.optionxform = str # to make the read Case Sensitive 
+config.read('references.conf')
+
+references_dict = config["list"]
+stripe_api_key = config["credentials"]["stripe_api_key"]
+dolibarr_username = config["credentials"]["dolibarr_username"]
+dolibarr_password = config["credentials"]["dolibarr_password"]
+
+if args.planned :
+    dolibarr_planned_work_key = config["credentials"]["planned_work_key"]
+    dolibarr_planned_work_cron_job_id = config["credentials"]["cron_job_id"]
+
+contact_mail = None
+
+if config.has_option("credentials", "contact_mail") :
+    contact_mail = config["credentials"]["contact_mail"]
+
+logger.debug("contact mail in configuration is : " + contact_mail)
+
+logger.debug("Reading Stripe subscriptions references with CRM link to  subscription contract")
+for reference in  references_dict:
+    logger.debug("link reference found : " + reference  + " -> " + references_dict[reference])
+
+
+
+logger.info(" --- -------------------------------------------------------------------------------- ---")
+logger.info(" --- --------------------------------- STRIPE PHASE --------------------------------- ---")
+logger.info(" --- -------------------------------------------------------------------------------- ---")
+
+logger.info("testing connection to stripe...")
+import requests
+from requests.structures import CaseInsensitiveDict
+
+headers = CaseInsensitiveDict()
+headers["Accept"] = "application/json"
+headers["Authorization"] = "Bearer "+ stripe_api_key
+
+logger.debug("retrieving balance for test")
+r = requests.get("https://api.stripe.com/v1/balance", headers=headers)
+json_content = json.loads(r.text)
+logger.debug(json.dumps(json_content, indent=4 , ensure_ascii=False))
+assert r.status_code == 200
+logger.info("Stripe OK")
+
+
+logger.info("----------- Validating Stripe references and retrieving data -----------")
+
+class crm_linked_invoice : 
+    url    : str
+    date   : datetime
+    amount : float
+    unpaid : bool = False
+
+class invoice_link :
+
+    stripe_subscription_reference : str
+    contract_number               : int
+
+    stripe_customer_ref    : str
+    stripe_customer_name   : str
+    stripe_customer_mail   : str
+    stripe_paid_amount     : float
+    stripe_epoch_date      : int
+    stripe_invoice_url     : str
+    stripe_invoice_number  : str
+
+
+    crm_contract_activated     : bool = False
+    crm_needs_new_invoice      : bool = False
+    crm_needs_update           : bool = False
+    crm_target_invoice         : crm_linked_invoice
+    
+
+
+invoice_links = set()
+
+
+for reference in  references_dict:
+
+    logger.info("retrieving Subscription data of " + reference)
+    url = "https://api.stripe.com/v1/subscriptions/" + reference
+    logger.debug("testing url : '" + url + "'")
+    r = requests.get(url, headers=headers)
+ 
+    assert r.status_code == 200
+    subscription_json_data = json.loads(r.text)
+    logger.debug("Subscription Data found : ")
+    logger.debug(json.dumps(subscription_json_data, indent=4 , ensure_ascii=False))
+
+    if subscription_json_data["status"] != "active" :
+        logger.info("subscription not active, going to next")
+        continue
+
+    #retrieving customer data 
+    latest_invoice_reference = subscription_json_data["latest_invoice"]
+    logger.debug("latest invoice reference found : " + latest_invoice_reference)
+    url = "https://api.stripe.com/v1/invoices/" + latest_invoice_reference
+    r = requests.get(url, headers=headers)
+    assert r.status_code == 200
+
+    latest_invoice_json_data = json.loads(r.text)
+    logger.debug("latest invoice Data found : ")
+    logger.debug(json.dumps(latest_invoice_json_data, indent=4, ensure_ascii=False))
+
+    #TODO verfiy invoice status to "paid"
+
+    link = invoice_link()
+
+
+    link.stripe_subscription_reference = reference
+    link.contract_number        = references_dict[reference]
+    link.stripe_paid_amount     = float(float(latest_invoice_json_data["amount_paid"]) / 100 )
+    link.stripe_epoch_date      = latest_invoice_json_data["created"]
+    link.stripe_customer_name   = latest_invoice_json_data["customer_name"]
+    link.stripe_customer_mail   = latest_invoice_json_data["customer_email"]
+    link.stripe_invoice_url     = latest_invoice_json_data["hosted_invoice_url"]
+    link.stripe_invoice_number  = latest_invoice_json_data["number"]
+
+
+    logger.info("latest invoice customer info : " + link.stripe_customer_name + " - " + link.stripe_customer_mail)
+    logger.info("latest invoice paid amount   : " + str(link.stripe_paid_amount) + latest_invoice_json_data["currency"])
+    logger.info("latest invoice payment date  : " + str(datetime.datetime.fromtimestamp(int(link.stripe_epoch_date))))
+    logger.info("latest invoice url : " + link.stripe_invoice_url )
+
+    # subscription reference - dolibarr contract reference - name - mail - amount - date of payment. 
+
+
+
+
+    invoice_links.add(link)
+
+
+    logger.info(" ----------- Subscription Data OK")
+    
+
+logger.info("Stripe Phase OK")
+
+
+logger.info(" --- -------------------------------------------------------------------------------- ---")
+logger.info(" --- -------------------------------- READ CRM PHASE -------------------------------- ---")
+logger.info(" --- -------------------------------------------------------------------------------- ---")
+
+from selenium import webdriver
+from selenium.webdriver.common.keys import Keys
+from selenium.webdriver.common.by import By
+import logging
+from selenium.webdriver.remote.remote_connection import LOGGER
+LOGGER.setLevel(logging.CRITICAL)
+
+
+logger.info("CRM Login")
+driver = webdriver.Edge()
+driver.get("https://crm.slashthd.fr/index.php")
+assert "Identifiant" in driver.title
+
+
+driver.find_element(By.NAME,"username").send_keys(str(dolibarr_username))
+pass_field = driver.find_element(By.NAME,"password")
+pass_field.send_keys(str(dolibarr_password))
+pass_field.send_keys(Keys.RETURN)
+logger.info(driver.title)
+assert "Accueil" in driver.title
+
+logger.info("login successful")
+
+current_date = datetime.datetime.now()
+
+if args.planned : 
+    logger.info("preparation phase : trigger planned work in CRM")
+    work_url = "https://crm.slashthd.fr/public/cron/cron_run_jobs_by_url.php?securitykey=" + \
+        dolibarr_planned_work_key + "&userlogin=" + dolibarr_username + "&id=" + str(dolibarr_planned_work_cron_job_id)
+    driver.get(work_url)
+    logger.info("temporization 1 sec....")
+    sleep(1)
+
+# iterating over links63
+logger.info("iterating over invoice links")
+link : invoice_link
+for link in invoice_links :
+    link.crm_contract_activated == False
+    logger.debug("treating contract : " + link.contract_number)
+    driver.get("https://crm.slashthd.fr/contrat/card.php?id=" + str(link.contract_number))
+    logger.debug("testing if services are activated")
+    #iterating service 
+
+
+    contract_lines = driver.find_elements(By.XPATH,"//div[contains(@id,'contrat-lines-container')]/div")
+    logger.info(str(len(contract_lines)) + " service line found")
+    for line in contract_lines : 
+        service_name   = line.find_element(By.CLASS_NAME,"classfortooltip").text
+        service_status = line.find_element(By.CLASS_NAME,"badge-status").text
+
+        logger.info(service_name)
+        logger.info(service_status)
+
+        if "En service" in service_status :
+            logger.debug("crm contract have at least one service activated")
+            link.crm_contract_activated = True
+
+
+    if link.crm_contract_activated == False:
+        logger.info("the contract with  number " + link.contract_number + " have all services disabled, SKIPPING CONTRACT")
+        continue
+
+    contract_links_table = driver.find_element(By.XPATH,'//table[@data-block="showLinkedObject"]')
+    contract_links_elements = driver.find_elements(By.XPATH,'//tr[@data-element="facture"]')
+   
+    link.crm_needs_new_invoice = True
+
+    if len(contract_links_elements) == 0  or contract_links_elements == None:
+         link.crm_needs_new_invoice = False
+    
+    if not link.crm_contract_activated : 
+        link.crm_needs_new_invoice = False
+
+    for element in contract_links_elements : 
+
+        current_invoice = crm_linked_invoice()
+        current_invoice.url = element.find_element(By.CLASS_NAME, "linkedcol-name").find_element(By.TAG_NAME,"a").get_attribute("href")
+        current_invoice.amount = float(element.find_element(By.CLASS_NAME, "linkedcol-amount").text.replace(',', "."))
+        current_invoice.date = datetime.datetime.strptime(element.find_element(By.CLASS_NAME, "linkedcol-date").text, '%d/%m/%Y')
+
+        if element.find_element(By.CLASS_NAME, "linkedcol-statut").find_element(By.TAG_NAME,"span").get_attribute("title") == "Impayée" : 
+            logger.debug("invoice is unpaid")
+            current_invoice.unpaid = True
+    
+        logger.info("-----")
+        logger.info("CRM linked invoice for customer : " + link.stripe_customer_name + " - " + link.stripe_customer_mail)
+        logger.info("CRM linked invoice url : " + current_invoice.url)
+        logger.info("CRM linked invoice date : " + str(current_invoice.amount))
+        logger.info("CRM linked invoice amount : " + str(current_invoice.date))
+        logger.info("CRM linked invoice unpaid ?  : " + str(current_invoice.unpaid))
+        logger.info("-----")
+    
+
+        stripe_date  = datetime.datetime.fromtimestamp(link.stripe_epoch_date)
+
+        #checking if new invoice is needed for the month
+        if current_invoice.date.year == current_date.year :
+            if current_invoice.date.month == current_date.month :
+                logger.debug("invoice link does not need a new invoice for this month")
+                link.crm_needs_new_invoice = False #TODO check all month since last invoice
+
+        #checking if invoice is eligible to update
+        if current_invoice.date.year == stripe_date.year :
+            if current_invoice.date.month == stripe_date.month :
+                if current_invoice.date.day <= stripe_date.day :
+
+                    if current_invoice.unpaid : 
+                        
+                        if link.stripe_paid_amount == current_invoice.amount :
+
+                            logger.info("Current crm invoice is unpaid, and corresponding to same month and amount as stripe payment")
+                            logger.info(" ## Target invoice FOUND !  ##")
+                            link.crm_needs_update = True
+                            link.crm_target_invoice = current_invoice
+            
+
+
+logger.info(" --- -------------------------------------------------------------------------------- ---")
+logger.info(" --- -------------------------------- SUMMARY PHASE --------------------------------- ---")
+logger.info(" --- -------------------------------------------------------------------------------- ---")
+
+logger.info("summary of actions")
+
+if len(invoice_links) == 0 :
+    logger.info("## no action pending detected ##")
+
+action = False
+
+link : invoice_link
+for link in invoice_links :
+
+    if link.crm_contract_activated : 
+        if link.crm_needs_update :
+            action = True
+            logger.info(" ## ------------------------------- ##")
+            logger.info("@@ INVOICE UPDATE PENDING : ")
+            logger.info("stripe invoice customer info : " + link.stripe_customer_name + " - " + link.stripe_customer_mail)
+            logger.info("stripe invoice paid amount   : " + str(link.stripe_paid_amount) + " " + latest_invoice_json_data["currency"])
+            logger.info("stripe invoice payment date  : " + str(datetime.datetime.fromtimestamp(int(link.stripe_epoch_date))))
+            logger.info("CRM linked invoice url : " + link.crm_target_invoice.url)
+            logger.info("CRM linked invoice date : " + str(link.crm_target_invoice.amount))
+            logger.info("CRM linked invoice amount : " + str(link.crm_target_invoice.date))
+            logger.info("CRM linked invoice unpaid ?  : " + str(link.crm_target_invoice.unpaid))
+            logger.info(" ## ------------------------------- ##")
+        
+        if link.crm_needs_new_invoice :
+            Action = True
+            logger.info(" ## ------------------------------- ##")
+            logger.info("New Invoice Generation Pending")
+            logger.info("invoice customer info : " + link.stripe_customer_name + " - " + link.stripe_customer_mail)
+            date_string = format(current_date,'01/%m/%Y')
+            logger.info("planned date : " + date_string)
+            logger.info(" ## ------------------------------- ##")
+        
+if not action :
+    logger.info("## no action pending detected ##")
+
+
+if args.dry:
+    driver.close()
+    logger.info("dry run enabled, exiting here...")
+    exit(0)
+
+
+logger.info(" --- -------------------------------------------------------------------------------- ---")
+logger.info(" --- -------------------------------- ACTION PHASE ---------------------------------- ---")
+logger.info(" --- -------------------------------------------------------------------------------- ---")
+
+
+for link in invoice_links :
+
+    if link.crm_contract_activated : 
+        if link.crm_needs_update :
+
+            logger.info(" ## ------ Creating Payment ------ ##")
+            logger.info("stripe invoice customer info : " + link.stripe_customer_name + " - " + link.stripe_customer_mail)
+            logger.info("stripe invoice paid amount   : " + str(link.stripe_paid_amount) + " " + latest_invoice_json_data["currency"])
+            logger.info("stripe invoice payment date  : " + str(datetime.datetime.fromtimestamp(int(link.stripe_epoch_date))))
+            logger.info("CRM linked invoice url : " + link.crm_target_invoice.url)
+            logger.info("CRM linked invoice date : " + str(link.crm_target_invoice.amount))
+            logger.info("CRM linked invoice amount : " + str(link.crm_target_invoice.date))
+            logger.info("CRM linked invoice unpaid ?  : " + str(link.crm_target_invoice.unpaid))
+
+ 
+
+            driver.get(link.crm_target_invoice.url)
+            pay_url = driver.find_element(By.XPATH, "//*[text()='Saisir règlement']").get_attribute("href")
+            driver.get(pay_url)
+            stripe_payment_date = datetime.datetime.fromtimestamp(int(link.stripe_epoch_date))
+            date_string = format(stripe_payment_date,'{%d/%m/%Y}')
+            driver.find_element(By.ID, "re").send_keys(date_string)
+            driver.find_element(By.NAME, "comment").send_keys("Payment treated by automation - Slash-DoliStripe \n stripe invoice URL : " + link.stripe_invoice_url )
+            driver.find_element(By.CLASS_NAME,'AutoFillAmout').click()
+            driver.find_element(By.NAME, "num_paiement").send_keys(link.stripe_invoice_number)
+            driver.find_element(By.XPATH,'//input[@value="Payer"]').click()
+            driver.find_element(By.XPATH,'//input[@value="Valider"]').click()
+
+            if args.mail:
+                logger.info("sending email to client...")
+                driver.find_element(By.XPATH, "//*[text()='Envoyer email']").click()
+                driver.find_element(By.ID, "sendto").send_keys(link.stripe_customer_mail)
+                if contact_mail is not None : 
+                    driver.find_element(By.ID, "sendtocc").send_keys(contact_mail)
+                driver.find_element(By.ID, "sendmail").click()
+                
+
+
+logger.info(" --- -------------------------------------------------------------------------------- ---")
+logger.info(" --- -------------------------------- CLEANING PHASE -------------------------------- ---")
+logger.info(" --- -------------------------------------------------------------------------------- ---")
+
+
+driver.close()
+exit(0)

BIN
msedgedriver.exe


+ 19 - 0
process.txt

@@ -0,0 +1,19 @@
+Process : 
+
+
+1. Démarchage Client
+2. Installation chez client
+3. creation adhérent chalant + Fiche client CRM + Contrat Air Fiber Access 
+4. Activation Service dans contrat CRM 
+5. Date de premiere facture contrat = date de mise en service + 1 mois arrondi , exemple 22/09/2022 -> 01/11/2022
+6. Lien de paiement envoyée au client
+
+check periodique
+
+
+7. client qui paie -> suggestion d'association entre Client Stripe et Client CRM en commentaire 
+Association manuelle entre référence client Stripe et celle du CRM 
+Association instance abonnement au contrat dans CRM 
+Association Produit CRM et Produit Stripe
+
+8. generation Facture 

+ 41 - 0
readme.md

@@ -0,0 +1,41 @@
+
+
+This project is meant to have a link between Stripe Subscriptions and Dolibar contract with reccuring invoice. 
+
+I use it for my association who is an internet provider : Slash - [https://slashthd.fr](https://slashthd.fr)
+
+The main goal of this script is to mark the dolibarr invoice as "paid" when the Stripe automatic payment occurs.
+
+thanks [https://www.mistergeek.net](https://www.mistergeek.net) for the visibility. 
+
+Manual : 
+
+1. get a dolibarr account that can read/write invoices/contracts/clients and get an read Stripe API KEY, put both in a reference.conf file.  an exemple of conf is in the file references_example.conf.
+2. Create your contract addociated to your client in dolibarr and get his ID in its URL : https://dolibarr_instance.com/contrat/card.php?id=13 -> here the ID is 13
+3. you have to wait the first payment of your client in Stripe, when it's done get the subscription ID, it looks like something like that : 
+sub_1Lsdhjqsdlkqh2367sdhqjTx
+
+put the two reference in the reference.conf
+
+the script is using selenium and request. 
+
+Console Option of the Script : 
+
+usage: main.py [-h] [-v] [-d] [-m] [-p]
+
+options:
+  -h, --help       show this help message and exit
+  -v, --verbosity  increase output verbosity
+  -d, --dry        perform a dry run
+  -m, --mail       send invoice per mail to client (to add copy mail to a mail check contact mail in refenrence example file)
+  -p, --planned    trigger planned work to
+  
+  
+  In order of execution the script will : 
+  
+  
+  1. verify connection to Stripe
+  2. Test All Stripe Subscription référence and gather Data about it ( last payment) 
+  3. Connect to dolibarr via Selenium to Gather contracts last invoices data and their status.
+  4. make a summary in console of what action will be perfomed
+  5. perform actions : mark corresponding invoices as "paid" and if option is activated send a mail to the client registered in dolibarr. (it uses the Stripe Mail) 

+ 11 - 0
references_example.conf

@@ -0,0 +1,11 @@
+[credentials]
+stripe_api_key = example_key_2349872349874
+dolibarr_username = user
+dolibarr_password = pass
+#optionnal  : 
+planned_work_key = key
+cron_job_id = 2
+contact_mail = contact@entity.com
+[list]
+sub_123456789092373927398279 = 456
+sub_123456789092373927398276 = 768

+ 24 - 0
requirements.txt

@@ -0,0 +1,24 @@
+async-generator==1.10
+attrs==22.1.0
+certifi==2022.6.15
+cffi==1.15.1
+charset-normalizer==2.1.0
+dolibarr==0.1.20
+dolipy==0.1.2
+h11==0.13.0
+idna==3.3
+outcome==1.2.0
+pycparser==2.21
+pydantic==1.9.2
+PySocks==1.7.1
+python-dotenv==0.15.0
+requests==2.28.1
+selenium==4.4.3
+sniffio==1.2.0
+sortedcontainers==2.4.0
+stripe==4.1.0
+trio==0.21.0
+trio-websocket==0.9.2
+typing_extensions==4.3.0
+urllib3==1.26.11
+wsproto==1.1.0