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 }}
+
+
+
+
+
+
+
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