From afcd9a34880084c960ae5efdac5a9ad2168c375b Mon Sep 17 00:00:00 2001 From: Casey Date: Fri, 6 Feb 2026 13:10:34 -0600 Subject: [PATCH] update Stripe Settings --- custom_ui/api/public/payments.py | 37 ++- custom_ui/events/jobs.py | 6 +- custom_ui/events/sales_invoice.py | 16 ++ custom_ui/events/sales_order.py | 65 +----- custom_ui/fixtures/custom_field.json | 171 ++++++++++++++ custom_ui/fixtures/property_setter.json | 16 ++ custom_ui/services/__init__.py | 4 +- custom_ui/services/email_service.py | 240 ++++++++++++++++++++ custom_ui/services/payment_service.py | 2 +- custom_ui/services/sales_order_service.py | 25 +- custom_ui/services/stripe_service.py | 73 +++++- custom_ui/templates/emails/downpayment.html | 2 +- custom_ui/templates/emails/invoice.html | 152 +++++++++++++ 13 files changed, 718 insertions(+), 91 deletions(-) create mode 100644 custom_ui/events/sales_invoice.py create mode 100644 custom_ui/templates/emails/invoice.html diff --git a/custom_ui/api/public/payments.py b/custom_ui/api/public/payments.py index 7461e6a..a9249da 100644 --- a/custom_ui/api/public/payments.py +++ b/custom_ui/api/public/payments.py @@ -33,20 +33,35 @@ def stripe_webhook(): payload = frappe.request.get_data() sig_header = frappe.request.headers.get('Stripe-Signature') session, metadata = StripeService.get_session_and_metadata(payload, sig_header) - required_keys = ["order_num", "company", "payment_type"] - for key in required_keys: - if not metadata.get(key): - raise frappe.ValidationError(f"Missing required metadata key: {key}") + # Validate required metadata + if not metadata.get("company"): + raise frappe.ValidationError("Missing required metadata key: company") + if not metadata.get("payment_type"): + raise frappe.ValidationError("Missing required metadata key: payment_type") + + # Determine reference document based on payment type + payment_type = metadata.get("payment_type") + reference_doctype = None + reference_doc_name = None + + if payment_type == "advance": + reference_doctype = "Sales Order" + reference_doc_name = metadata.get("sales_order") + if not reference_doc_name: + raise frappe.ValidationError("Missing sales_order in metadata for advance payment") + elif payment_type == "full": + reference_doctype = "Sales Invoice" + reference_doc_name = metadata.get("sales_invoice") + if not reference_doc_name: + raise frappe.ValidationError("Missing sales_invoice in metadata for full payment") + else: + raise frappe.ValidationError(f"Invalid payment type in metadata: {payment_type}") + + # Check if payment already exists if DbService.exists("Payment Entry", {"reference_no": session.id}): raise frappe.ValidationError("Payment Entry already exists for this session.") - reference_doctype = "Sales Invoice" - if metadata.get("payment_type") == "advance": - reference_doctype = "Sales Order" - elif metadata.get("payment_type") != "full": - raise frappe.ValidationError("Invalid payment type in metadata.") - amount_paid = flt(session.amount_total) / 100 # Convert Unix timestamp to date string (YYYY-MM-DD) @@ -63,7 +78,7 @@ def stripe_webhook(): reference_date=reference_date, received_amount=amount_paid, company=metadata.get("company"), - reference_doc_name=metadata.get("order_num") + reference_doc_name=reference_doc_name ) ) pe.flags.ignore_permissions = True diff --git a/custom_ui/events/jobs.py b/custom_ui/events/jobs.py index 3ed0a59..379384a 100644 --- a/custom_ui/events/jobs.py +++ b/custom_ui/events/jobs.py @@ -1,5 +1,5 @@ import frappe -from custom_ui.services import AddressService, ClientService, ServiceAppointmentService, TaskService +from custom_ui.services import SalesOrderService, AddressService, ClientService, ServiceAppointmentService, TaskService from datetime import timedelta import traceback @@ -83,7 +83,9 @@ def after_save(doc, method): print("DEBUG: After Save Triggered for Project:", doc.name) if doc.status == "Completed": print("DEBUG: Project marked as Completed. Generating and sending final invoice.") - + sales_order_status = frappe.get_value("Sales Order", doc.sales_order, "billing_status") + if sales_order_status == "Not Billed": + SalesOrderService.create_sales_invoice_from_sales_order(doc.sales_order) if doc.ready_to_schedule: service_apt_ready_to_schedule = frappe.get_value("Service Address 2", doc.service_appointment, "ready_to_schedule") if not service_apt_ready_to_schedule: diff --git a/custom_ui/events/sales_invoice.py b/custom_ui/events/sales_invoice.py new file mode 100644 index 0000000..3330253 --- /dev/null +++ b/custom_ui/events/sales_invoice.py @@ -0,0 +1,16 @@ +import frappe +from custom_ui.services.email_service import EmailService + +def on_submit(doc, method): + print("DEBUG: On Submit Triggered for Sales Invoice:", doc.name) + + # Send invoice email to customer + try: + print("DEBUG: Preparing to send invoice email for", doc.name) + EmailService.send_invoice_email(doc.name) + print("DEBUG: Invoice email sent successfully for", doc.name) + except Exception as e: + print(f"ERROR: Failed to send invoice email: {str(e)}") + # Don't raise the exception - we don't want to block the invoice submission + frappe.log_error(f"Failed to send invoice email for {doc.name}: {str(e)}", "Invoice Email Error") + \ No newline at end of file diff --git a/custom_ui/events/sales_order.py b/custom_ui/events/sales_order.py index c2ef402..b638135 100644 --- a/custom_ui/events/sales_order.py +++ b/custom_ui/events/sales_order.py @@ -76,68 +76,10 @@ def after_insert(doc, method): if doc.requires_half_payment: try: print("DEBUG: Sales Order requires half payment, preparing to send down payment email") + from custom_ui.services.email_service import EmailService - # Get the customer/lead document to find the billing contact - customer_doc = frappe.get_doc("Customer", doc.customer) - - # Get billing contact email - email = None - if hasattr(customer_doc, 'primary_contact') and customer_doc.primary_contact: - primary_contact = frappe.get_doc("Contact", customer_doc.primary_contact) - email = primary_contact.email_id - - # Fallback to primary contact or first available contact - if not email and hasattr(customer_doc, 'customer_primary_contact') and customer_doc.customer_primary_contact: - primary_contact = frappe.get_doc("Contact", customer_doc.customer_primary_contact) - email = primary_contact.email_id - - # Last resort - try to get any contact from the customer - if not email: - contact_links = frappe.get_all("Dynamic Link", - filters={ - "link_doctype": "Customer", - "link_name": doc.customer, - "parenttype": "Contact" - }, - pluck="parent" - ) - if contact_links: - contact = frappe.get_doc("Contact", contact_links[0]) - email = contact.email_id - - if not email: - print(f"ERROR: No email found for customer {doc.customer}, cannot send down payment email") - return - - # Prepare template context - half_down_amount = doc.custom_halfdown_amount or (doc.grand_total / 2) - base_url = frappe.utils.get_url() - - template_context = { - "company_name": doc.company, - "customer_name": doc.customer_name or doc.customer, - "sales_order_number": doc.name, - "total_amount": frappe.utils.fmt_money(half_down_amount, currency=doc.currency), - "base_url": base_url - } - - # Render the email template - template_path = "custom_ui/templates/emails/downpayment.html" - message = frappe.render_template(template_path, template_context) - subject = f"Down Payment Required - {doc.company} - {doc.name}" - - print(f"DEBUG: Sending down payment email to {email}") - - # Send email - frappe.sendmail( - recipients=email, - subject=subject, - message=message, - doctype="Sales Order", - name=doc.name - ) - - print(f"DEBUG: Down payment email sent to {email} successfully") + # Use EmailService to send the down payment email + EmailService.send_downpayment_email(doc.name) except Exception as e: print(f"ERROR: Failed to send down payment email: {str(e)}") @@ -196,3 +138,4 @@ def create_sales_invoice_from_sales_order(doc, method): # except Exception as e: # print("ERROR creating Sales Invoice from Sales Order:", str(e)) # frappe.log_error(f"Error creating Sales Invoice from Sales Order {doc.name}: {str(e)}", "Sales Order after_submit Error") + diff --git a/custom_ui/fixtures/custom_field.json b/custom_ui/fixtures/custom_field.json index 23060e9..e901109 100644 --- a/custom_ui/fixtures/custom_field.json +++ b/custom_ui/fixtures/custom_field.json @@ -2678,6 +2678,63 @@ "unique": 0, "width": null }, + { + "allow_in_quick_entry": 0, + "allow_on_submit": 0, + "bold": 0, + "collapsible": 0, + "collapsible_depends_on": null, + "columns": 0, + "default": null, + "depends_on": null, + "description": null, + "docstatus": 0, + "doctype": "Custom Field", + "dt": "Stripe Settings", + "fetch_from": null, + "fetch_if_empty": 0, + "fieldname": "custom_webhook_secret", + "fieldtype": "Data", + "hidden": 0, + "hide_border": 0, + "hide_days": 0, + "hide_seconds": 0, + "ignore_user_permissions": 0, + "ignore_xss_filter": 0, + "in_global_search": 0, + "in_list_view": 0, + "in_preview": 0, + "in_standard_filter": 0, + "insert_after": "publishable_key", + "is_system_generated": 0, + "is_virtual": 0, + "label": "Webhook Secret", + "length": 0, + "link_filters": null, + "mandatory_depends_on": null, + "modified": "2026-02-06 07:59:54.465395", + "module": null, + "name": "Stripe Settings-custom_webhook_secret", + "no_copy": 0, + "non_negative": 0, + "options": null, + "permlevel": 0, + "placeholder": null, + "precision": "", + "print_hide": 0, + "print_hide_if_no_value": 0, + "print_width": null, + "read_only": 0, + "read_only_depends_on": null, + "report_hide": 0, + "reqd": 0, + "search_index": 0, + "show_dashboard": 0, + "sort_options": 0, + "translatable": 1, + "unique": 0, + "width": null + }, { "allow_in_quick_entry": 0, "allow_on_submit": 0, @@ -4046,6 +4103,63 @@ "unique": 0, "width": null }, + { + "allow_in_quick_entry": 0, + "allow_on_submit": 0, + "bold": 0, + "collapsible": 0, + "collapsible_depends_on": null, + "columns": 0, + "default": null, + "depends_on": null, + "description": null, + "docstatus": 0, + "doctype": "Custom Field", + "dt": "Stripe Settings", + "fetch_from": null, + "fetch_if_empty": 0, + "fieldname": "custom_company", + "fieldtype": "Link", + "hidden": 0, + "hide_border": 0, + "hide_days": 0, + "hide_seconds": 0, + "ignore_user_permissions": 0, + "ignore_xss_filter": 0, + "in_global_search": 0, + "in_list_view": 0, + "in_preview": 0, + "in_standard_filter": 0, + "insert_after": "secret_key", + "is_system_generated": 0, + "is_virtual": 0, + "label": "Company", + "length": 0, + "link_filters": null, + "mandatory_depends_on": null, + "modified": "2026-02-06 07:59:54.534298", + "module": null, + "name": "Stripe Settings-custom_company", + "no_copy": 0, + "non_negative": 0, + "options": "Company", + "permlevel": 0, + "placeholder": null, + "precision": "", + "print_hide": 0, + "print_hide_if_no_value": 0, + "print_width": null, + "read_only": 0, + "read_only_depends_on": null, + "report_hide": 0, + "reqd": 0, + "search_index": 0, + "show_dashboard": 0, + "sort_options": 0, + "translatable": 0, + "unique": 0, + "width": null + }, { "allow_in_quick_entry": 0, "allow_on_submit": 1, @@ -4502,6 +4616,63 @@ "unique": 0, "width": null }, + { + "allow_in_quick_entry": 0, + "allow_on_submit": 0, + "bold": 0, + "collapsible": 0, + "collapsible_depends_on": null, + "columns": 0, + "default": null, + "depends_on": null, + "description": null, + "docstatus": 0, + "doctype": "Custom Field", + "dt": "Stripe Settings", + "fetch_from": null, + "fetch_if_empty": 0, + "fieldname": "custom_account", + "fieldtype": "Link", + "hidden": 0, + "hide_border": 0, + "hide_days": 0, + "hide_seconds": 0, + "ignore_user_permissions": 0, + "ignore_xss_filter": 0, + "in_global_search": 0, + "in_list_view": 0, + "in_preview": 0, + "in_standard_filter": 0, + "insert_after": "custom_company", + "is_system_generated": 0, + "is_virtual": 0, + "label": "Account", + "length": 0, + "link_filters": null, + "mandatory_depends_on": null, + "modified": "2026-02-06 07:59:54.597448", + "module": null, + "name": "Stripe Settings-custom_account", + "no_copy": 0, + "non_negative": 0, + "options": "Account", + "permlevel": 0, + "placeholder": null, + "precision": "", + "print_hide": 0, + "print_hide_if_no_value": 0, + "print_width": null, + "read_only": 0, + "read_only_depends_on": null, + "report_hide": 0, + "reqd": 0, + "search_index": 0, + "show_dashboard": 0, + "sort_options": 0, + "translatable": 0, + "unique": 0, + "width": null + }, { "allow_in_quick_entry": 0, "allow_on_submit": 0, diff --git a/custom_ui/fixtures/property_setter.json b/custom_ui/fixtures/property_setter.json index b966453..d19a9d3 100644 --- a/custom_ui/fixtures/property_setter.json +++ b/custom_ui/fixtures/property_setter.json @@ -15134,5 +15134,21 @@ "property_type": "Data", "row_name": null, "value": "[\"customer_section\", \"column_break0\", \"custom_installation_address\", \"custom_job_address\", \"requires_half_payment\", \"custom_project_template\", \"custom_requires_halfdown\", \"title\", \"naming_series\", \"customer\", \"customer_name\", \"tax_id\", \"custom_halfdown_is_paid\", \"custom_halfdown_amount\", \"column_break_7\", \"transaction_date\", \"order_type\", \"delivery_date\", \"custom_department_type\", \"custom_project_complete\", \"column_break1\", \"po_no\", \"po_date\", \"company\", \"skip_delivery_note\", \"has_unit_price_items\", \"amended_from\", \"custom_section_break_htf05\", \"custom_workflow_related_custom_fields__landry\", \"custom_coordinator_notification\", \"custom_sales_order_addon\", \"accounting_dimensions_section\", \"cost_center\", \"dimension_col_break\", \"project\", \"currency_and_price_list\", \"currency\", \"conversion_rate\", \"column_break2\", \"selling_price_list\", \"price_list_currency\", \"plc_conversion_rate\", \"ignore_pricing_rule\", \"sec_warehouse\", \"scan_barcode\", \"last_scanned_warehouse\", \"column_break_28\", \"set_warehouse\", \"reserve_stock\", \"items_section\", \"items\", \"section_break_31\", \"total_qty\", \"total_net_weight\", \"column_break_33\", \"base_total\", \"base_net_total\", \"column_break_33a\", \"total\", \"net_total\", \"taxes_section\", \"tax_category\", \"taxes_and_charges\", \"exempt_from_sales_tax\", \"column_break_38\", \"shipping_rule\", \"column_break_49\", \"incoterm\", \"named_place\", \"section_break_40\", \"taxes\", \"section_break_43\", \"base_total_taxes_and_charges\", \"column_break_46\", \"total_taxes_and_charges\", \"totals\", \"base_grand_total\", \"base_rounding_adjustment\", \"base_rounded_total\", \"base_in_words\", \"column_break3\", \"grand_total\", \"rounding_adjustment\", \"rounded_total\", \"in_words\", \"advance_paid\", \"disable_rounded_total\", \"section_break_48\", \"apply_discount_on\", \"base_discount_amount\", \"coupon_code\", \"column_break_50\", \"additional_discount_percentage\", \"discount_amount\", \"sec_tax_breakup\", \"other_charges_calculation\", \"packing_list\", \"packed_items\", \"pricing_rule_details\", \"pricing_rules\", \"contact_info\", \"billing_address_column\", \"customer_address\", \"address_display\", \"customer_group\", \"territory\", \"column_break_84\", \"contact_person\", \"contact_display\", \"contact_phone\", \"contact_mobile\", \"contact_email\", \"shipping_address_column\", \"shipping_address_name\", \"shipping_address\", \"column_break_93\", \"dispatch_address_name\", \"dispatch_address\", \"col_break46\", \"company_address\", \"column_break_92\", \"company_contact_person\", \"company_address_display\", \"payment_schedule_section\", \"payment_terms_section\", \"payment_terms_template\", \"payment_schedule\", \"terms_section_break\", \"tc_name\", \"terms\", \"more_info\", \"section_break_78\", \"status\", \"delivery_status\", \"per_delivered\", \"column_break_81\", \"per_billed\", \"per_picked\", \"billing_status\", \"sales_team_section_break\", \"sales_partner\", \"column_break7\", \"amount_eligible_for_commission\", \"commission_rate\", \"total_commission\", \"section_break1\", \"sales_team\", \"loyalty_points_redemption\", \"loyalty_points\", \"column_break_116\", \"loyalty_amount\", \"subscription_section\", \"from_date\", \"to_date\", \"column_break_108\", \"auto_repeat\", \"update_auto_repeat_reference\", \"printing_details\", \"letter_head\", \"group_same_items\", \"column_break4\", \"select_print_heading\", \"language\", \"additional_info_section\", \"is_internal_customer\", \"represents_company\", \"column_break_152\", \"source\", \"inter_company_order_reference\", \"campaign\", \"party_account_currency\", \"connections_tab\"]" + }, + { + "default_value": null, + "doc_type": "Stripe Settings", + "docstatus": 0, + "doctype": "Property Setter", + "doctype_or_field": "DocType", + "field_name": null, + "is_system_generated": 0, + "modified": "2026-02-06 08:00:17.665416", + "module": null, + "name": "Stripe Settings-main-field_order", + "property": "field_order", + "property_type": "Data", + "row_name": null, + "value": "[\"gateway_name\", \"publishable_key\", \"custom_webhook_secret\", \"column_break_3\", \"secret_key\", \"custom_company\", \"custom_account\", \"section_break_5\", \"header_img\", \"column_break_7\", \"redirect_url\"]" } ] \ No newline at end of file diff --git a/custom_ui/services/__init__.py b/custom_ui/services/__init__.py index 409e0b0..a1fd1a7 100644 --- a/custom_ui/services/__init__.py +++ b/custom_ui/services/__init__.py @@ -9,4 +9,6 @@ from .service_appointment_service import ServiceAppointmentService from .stripe_service import StripeService from .payment_service import PaymentService from .item_service import ItemService -from .project_service import ProjectService \ No newline at end of file +from .project_service import ProjectService +from .sales_order_service import SalesOrderService +from .email_service import EmailService \ No newline at end of file diff --git a/custom_ui/services/email_service.py b/custom_ui/services/email_service.py index e69de29..39a3db3 100644 --- a/custom_ui/services/email_service.py +++ b/custom_ui/services/email_service.py @@ -0,0 +1,240 @@ +import frappe +from frappe.utils import get_url + +class EmailService: + + @staticmethod + def get_customer_email(customer_name: str, doctype: str = "Customer") -> str | None: + """ + Get the primary email for a customer or lead. + + Args: + customer_name: Name of the Customer or Lead + doctype: Either "Customer" or "Lead" + + Returns: + Email address if found, None otherwise + """ + try: + customer_doc = frappe.get_doc(doctype, customer_name) + email = None + + # Try primary_contact field + if hasattr(customer_doc, 'primary_contact') and customer_doc.primary_contact: + try: + primary_contact = frappe.get_doc("Contact", customer_doc.primary_contact) + email = primary_contact.email_id + except Exception as e: + print(f"Warning: Could not get primary_contact: {str(e)}") + + # Fallback to customer_primary_contact + if not email and hasattr(customer_doc, 'customer_primary_contact') and customer_doc.customer_primary_contact: + try: + primary_contact = frappe.get_doc("Contact", customer_doc.customer_primary_contact) + email = primary_contact.email_id + except Exception as e: + print(f"Warning: Could not get customer_primary_contact: {str(e)}") + + # Last resort - get any contact linked to this customer/lead + if not email: + contact_links = frappe.get_all("Dynamic Link", + filters={ + "link_doctype": doctype, + "link_name": customer_name, + "parenttype": "Contact" + }, + pluck="parent" + ) + if contact_links: + try: + contact = frappe.get_doc("Contact", contact_links[0]) + email = contact.email_id + except Exception as e: + print(f"Warning: Could not get contact from dynamic link: {str(e)}") + + return email + + except Exception as e: + print(f"ERROR: Failed to get email for {doctype} {customer_name}: {str(e)}") + return None + + @staticmethod + def send_templated_email( + recipients: str | list, + subject: str, + template_path: str, + template_context: dict, + doctype: str = None, + docname: str = None, + cc: str | list = None, + bcc: str | list = None, + attachments: list = None + ) -> bool: + """ + Send an email using a Jinja2 template. + + Args: + recipients: Email address(es) to send to + subject: Email subject line + template_path: Path to the Jinja2 template (relative to app root) + template_context: Dictionary of variables to pass to template + doctype: Optional doctype to link email to + docname: Optional document name to link email to + cc: Optional CC recipients + bcc: Optional BCC recipients + attachments: Optional list of attachments + + Returns: + True if email sent successfully, False otherwise + """ + try: + # Render the email template + message = frappe.render_template(template_path, template_context) + + # Prepare sendmail arguments + email_args = { + "recipients": recipients, + "subject": subject, + "message": message, + } + + if doctype: + email_args["doctype"] = doctype + if docname: + email_args["name"] = docname + if cc: + email_args["cc"] = cc + if bcc: + email_args["bcc"] = bcc + if attachments: + email_args["attachments"] = attachments + + # Send email + frappe.sendmail(**email_args) + + print(f"DEBUG: Email sent successfully to {recipients}") + return True + + except Exception as e: + print(f"ERROR: Failed to send email: {str(e)}") + frappe.log_error(f"Failed to send email to {recipients}: {str(e)}", "Email Service Error") + return False + + @staticmethod + def send_downpayment_email(sales_order_name: str) -> bool: + """ + Send a down payment email for a Sales Order. + + Args: + sales_order_name: Name of the Sales Order + + Returns: + True if email sent successfully, False otherwise + """ + try: + doc = frappe.get_doc("Sales Order", sales_order_name) + + # Get customer email + email = EmailService.get_customer_email(doc.customer, "Customer") + + if not email: + print(f"ERROR: No email found for customer {doc.customer}, cannot send down payment email") + return False + + # Prepare template context + half_down_amount = doc.custom_halfdown_amount or (doc.grand_total / 2) + base_url = get_url() + + template_context = { + "company_name": doc.company, + "customer_name": doc.customer_name or doc.customer, + "sales_order_number": doc.name, + "total_amount": frappe.utils.fmt_money(half_down_amount, currency=doc.currency), + "base_url": base_url + } + + # Send email + template_path = "custom_ui/templates/emails/downpayment.html" + subject = f"Down Payment Required - {doc.company} - {doc.name}" + + return EmailService.send_templated_email( + recipients=email, + subject=subject, + template_path=template_path, + template_context=template_context, + doctype="Sales Order", + docname=doc.name + ) + + except Exception as e: + print(f"ERROR: Failed to send down payment email for {sales_order_name}: {str(e)}") + frappe.log_error(f"Failed to send down payment email for {sales_order_name}: {str(e)}", "Down Payment Email Error") + return False + + @staticmethod + def send_invoice_email(sales_invoice_name: str) -> bool: + """ + Send an invoice email for a Sales Invoice. + + Args: + sales_invoice_name: Name of the Sales Invoice + + Returns: + True if email sent successfully, False otherwise + """ + try: + doc = frappe.get_doc("Sales Invoice", sales_invoice_name) + + # Get customer email + email = EmailService.get_customer_email(doc.customer, "Customer") + + if not email: + print(f"ERROR: No email found for customer {doc.customer}, cannot send invoice email") + return False + + # Calculate amounts + outstanding_amount = doc.outstanding_amount + paid_amount = doc.grand_total - outstanding_amount + + # Get related Sales Order if available + sales_order = None + if hasattr(doc, 'items') and doc.items: + for item in doc.items: + if item.sales_order: + sales_order = item.sales_order + break + + # Prepare template context + base_url = get_url() + + template_context = { + "company_name": doc.company, + "customer_name": doc.customer_name or doc.customer, + "invoice_number": doc.name, + "invoice_date": doc.posting_date, + "due_date": doc.due_date, + "grand_total": frappe.utils.fmt_money(doc.grand_total, currency=doc.currency), + "outstanding_amount": frappe.utils.fmt_money(outstanding_amount, currency=doc.currency), + "paid_amount": frappe.utils.fmt_money(paid_amount, currency=doc.currency), + "sales_order": sales_order, + "base_url": base_url, + "payment_url": f"{base_url}/api/method/custom_ui.api.public.payments.invoice_stripe_payment?sales_invoice={doc.name}" if outstanding_amount > 0 else None + } + + # Send email + template_path = "custom_ui/templates/emails/invoice.html" + subject = f"Invoice {doc.name} - {doc.company}" + + return EmailService.send_templated_email( + recipients=email, + subject=subject, + template_path=template_path, + template_context=template_context, + doctype="Sales Invoice", + docname=doc.name + ) + + except Exception as e: + print(f"ERROR: Failed to send invoice email for {sales_invoice_name}: {str(e)}") + frappe.log_error(f"Failed to send invoice email for {sales_invoice_name}: {str(e)}", "Invoice Email Error") + return False \ No newline at end of file diff --git a/custom_ui/services/payment_service.py b/custom_ui/services/payment_service.py index b54f83a..9e42869 100644 --- a/custom_ui/services/payment_service.py +++ b/custom_ui/services/payment_service.py @@ -13,7 +13,7 @@ class PaymentService: print(f"DEBUG: Creating Payment Entry for {data.reference_doc_name} with data: {data}") reference_doctype = PaymentService.determine_reference_doctype(data.reference_doc_name) reference_doc = DbService.get_or_throw(reference_doctype, data.reference_doc_name) - account = StripeService.get_stripe_settings(data.company).account + account = StripeService.get_stripe_settings(data.company).custom_account pe = frappe.get_doc({ "doctype": "Payment Entry", "company": data.company, diff --git a/custom_ui/services/sales_order_service.py b/custom_ui/services/sales_order_service.py index 0bdeca3..ff3c2dd 100644 --- a/custom_ui/services/sales_order_service.py +++ b/custom_ui/services/sales_order_service.py @@ -1,2 +1,23 @@ - - \ No newline at end of file +import frappe +from frappe.utils import today +from erpnext.selling.doctype.sales_order.sales_order import make_sales_invoice + +class SalesOrderService: + + @staticmethod + def create_sales_invoice_from_sales_order(sales_order_name): + try: + sales_order_doc = frappe.get_doc("Sales Order", sales_order_name) + sales_invoice = make_sales_invoice(sales_order_doc.name) + sales_invoice.project = sales_order_doc.project + sales_invoice.posting_date = today() + sales_invoice.due_date = today() + sales_invoice.remarks = f"Auto-generated from Sales Order {sales_order_doc.name}" + sales_invoice.job_address = sales_order_doc.custom_job_address + sales_invoice.project_template = sales_order_doc.custom_project_template + sales_invoice.insert() + sales_invoice.submit() + return sales_invoice.name + except Exception as e: + print("ERROR creating Sales Invoice from Sales Order:", str(e)) + return None \ No newline at end of file diff --git a/custom_ui/services/stripe_service.py b/custom_ui/services/stripe_service.py index 6120af3..666a75b 100644 --- a/custom_ui/services/stripe_service.py +++ b/custom_ui/services/stripe_service.py @@ -9,7 +9,7 @@ class StripeService: @staticmethod def get_stripe_settings(company: str): """Fetch Stripe settings for a given company.""" - settings_name = frappe.get_all("Stripe Settings", pluck="name", filters={"company": company}) + settings_name = frappe.get_all("Stripe Settings", pluck="name", filters={"custom_company": company}) if not settings_name: frappe.throw(f"Stripe Settings not found for company: {company}") settings = frappe.get_doc("Stripe Settings", settings_name[0]) if settings_name else None @@ -25,35 +25,84 @@ class StripeService: def get_webhook_secret(company: str) -> str: """Retrieve the Stripe webhook secret for the specified company.""" settings = StripeService.get_stripe_settings(company) - if not settings.webhook_secret: + if not settings.custom_webhook_secret: frappe.throw(f"Stripe Webhook Secret not configured for company: {company}") - return settings.webhook_secret + return settings.custom_webhook_secret @staticmethod - def create_checkout_session(company: str, amount: float, service: str, order_num: str, currency: str = "usd", for_advance_payment: bool = False, line_items: list | None = None) -> stripe.checkout.Session: - """Create a Stripe Checkout Session. order_num is a Sales Order name if for_advance_payment is True, otherwise it is a Sales Invoice name.""" + def create_checkout_session( + company: str, + amount: float, + service: str, + order_num: str, + currency: str = "usd", + for_advance_payment: bool = False, + line_items: list | None = None, + sales_invoice: str = None + ) -> stripe.checkout.Session: + """ + Create a Stripe Checkout Session. + + Args: + company: Company name + amount: Payment amount (should be the outstanding amount for invoices) + service: Service description + order_num: Sales Order name if for_advance_payment is True, otherwise Sales Invoice name + currency: Currency code (default: "usd") + for_advance_payment: True if this is an advance/down payment, False for full invoice payment + line_items: Optional custom line items for the checkout session + sales_invoice: Sales Invoice name (for full payments) + + Returns: + stripe.checkout.Session object + """ stripe.api_key = StripeService.get_api_key(company) + # Determine payment description + if for_advance_payment: + description = f"Advance payment for {company}{' - ' + service if service else ''}" + else: + description = f"Invoice payment for {company}{' - ' + service if service else ''}" + if sales_invoice: + description = f"Invoice {sales_invoice} - {company}" + + # Use custom line items if provided and not an advance payment, otherwise create default line item line_items = line_items if line_items and not for_advance_payment else [{ "price_data": { "currency": currency.lower(), "product_data": { - "name": f"Advance payment for {company}{' - ' + service if service else ''}" + "name": description }, - "unit_amount": int(amount * 100), + "unit_amount": int(amount * 100), # Stripe expects amount in cents }, "quantity": 1, }] + # Prepare metadata + metadata = { + "company": company, + "payment_type": "advance" if for_advance_payment else "full" + } + + # Add appropriate document reference to metadata + if for_advance_payment: + metadata["sales_order"] = order_num + else: + metadata["sales_invoice"] = sales_invoice or order_num + if sales_invoice: + # Check if there's a related sales order + invoice_doc = frappe.get_doc("Sales Invoice", sales_invoice) + if hasattr(invoice_doc, 'items') and invoice_doc.items: + for item in invoice_doc.items: + if item.sales_order: + metadata["sales_order"] = item.sales_order + break + session = stripe.checkout.Session.create( mode="payment", payment_method_types=["card"], line_items=line_items, - metadata={ - "order_num": order_num, - "company": company, - "payment_type": "advance" if for_advance_payment else "full" - }, + metadata=metadata, success_url=f"{get_url()}/payment_success?session_id={{CHECKOUT_SESSION_ID}}", cancel_url=f"{get_url()}/payment_cancelled", ) diff --git a/custom_ui/templates/emails/downpayment.html b/custom_ui/templates/emails/downpayment.html index d9ce712..598266d 100644 --- a/custom_ui/templates/emails/downpayment.html +++ b/custom_ui/templates/emails/downpayment.html @@ -75,7 +75,7 @@

Payment Details

Sales Order Number: {{ sales_order_number }}

-

Down Payment Amount: ${{ total_amount }}

+

Down Payment Amount: {{ total_amount }}

Please click the button below to make your secure payment through our payment processor:

Make Payment diff --git a/custom_ui/templates/emails/invoice.html b/custom_ui/templates/emails/invoice.html new file mode 100644 index 0000000..b10b1b5 --- /dev/null +++ b/custom_ui/templates/emails/invoice.html @@ -0,0 +1,152 @@ + + + + + + Invoice - {{ invoice_number }} + + + +
+
+

Invoice

+
+
+

Dear {{ customer_name }},

+

Thank you for your business with {{ company_name }}. Please find your invoice details below:

+ +
+

Invoice Details

+
+ Invoice Number: + {{ invoice_number }} +
+
+ Invoice Date: + {{ invoice_date }} +
+
+ Due Date: + {{ due_date }} +
+ {% if sales_order %} +
+ Related Sales Order: + {{ sales_order }} +
+ {% endif %} +
+ Invoice Total: + {{ grand_total }} +
+ {% if paid_amount and paid_amount != "$0.00" %} +
+ Amount Paid: + {{ paid_amount }} +
+ {% endif %} +
+ Amount Due: + {{ outstanding_amount }} +
+
+ + {% if payment_url and outstanding_amount != "$0.00" %} +
+ Payment Required: There is an outstanding balance on this invoice. Please click the button below to make a secure payment. +
+ Pay Now + {% else %} +
+ Paid in Full: This invoice has been paid in full. Thank you! +
+ {% endif %} + +

If you have any questions about this invoice, please don't hesitate to contact us.

+

Best regards,
The Team at {{ company_name }}

+
+ +
+ + \ No newline at end of file