diff --git a/custom_ui/api/db/addresses.py b/custom_ui/api/db/addresses.py
index 3d3fe96..bb99ff7 100644
--- a/custom_ui/api/db/addresses.py
+++ b/custom_ui/api/db/addresses.py
@@ -1,7 +1,7 @@
import frappe
import json
from custom_ui.db_utils import build_error_response, build_success_response
-from custom_ui.services import ClientService, AddressService
+from custom_ui.services import ClientService, AddressService, ContactService
@frappe.whitelist()
def get_address_by_full_address(full_address):
@@ -35,6 +35,33 @@ def get_address(address_name):
# except Exception as e:
# return build_error_response(str(e), 500)
+@frappe.whitelist()
+def create_address(address_data, company, customer_name):
+ """Create a new address."""
+ print(f"DEBUG: create_address called with address_data: {address_data}, company: {company}, customer_name: {customer_name}")
+ if isinstance(address_data, str):
+ address_data = json.loads(address_data)
+ customer_doctype = ClientService.get_client_doctype(customer_name)
+ address_data["customer_name"] = customer_name
+ address_data["customer_type"] = customer_doctype
+ address_data["address_title"] = AddressService.build_address_title(customer_name, address_data)
+ address_data["address_type"] = "Service"
+ address_data["custom_billing_address"] = 0
+ address_data["is_service_address"] = 1
+ address_data["country"] = "United States"
+ address_data["companies"] = [{ "company": company }]
+ print(f"DEBUG: Final address_data before creation: {address_data}")
+ try:
+ address_doc = AddressService.create_address(address_data)
+ for contact in address_data.get("contacts", []):
+ AddressService.link_address_to_contact(address_doc, contact)
+ contact_doc = ContactService.get_or_throw(contact)
+ ContactService.link_contact_to_address(contact_doc, address_doc)
+ ClientService.append_link_v2(customer_name, "properties", {"address": address_doc.name})
+ return build_success_response(address_doc.as_dict())
+ except Exception as e:
+ return build_error_response(str(e), 500)
+
@frappe.whitelist()
def get_addresses(fields=["*"], filters={}):
"""Get addresses with optional filtering."""
diff --git a/custom_ui/api/db/service_appointments.py b/custom_ui/api/db/service_appointments.py
index 6cb864c..2ac4b63 100644
--- a/custom_ui/api/db/service_appointments.py
+++ b/custom_ui/api/db/service_appointments.py
@@ -20,6 +20,9 @@ def get_service_appointments(companies, filters={}):
ServiceAppointmentService.get_full_dict(name)
for name in service_appointment_names
]
+
+ "is_half_down_paid"
+
return build_success_response(service_appointments)
except Exception as e:
return build_error_response(str(e), 500)
diff --git a/custom_ui/api/payments.py b/custom_ui/api/payments.py
deleted file mode 100644
index 476dfe6..0000000
--- a/custom_ui/api/payments.py
+++ /dev/null
@@ -1,6 +0,0 @@
-import frappe
-
-@frappe.whitelist(allow_guest=True)
-def start_payment(invoice_name: str):
-
- pass
\ No newline at end of file
diff --git a/custom_ui/api/public/payments.py b/custom_ui/api/public/payments.py
new file mode 100644
index 0000000..b369087
--- /dev/null
+++ b/custom_ui/api/public/payments.py
@@ -0,0 +1,72 @@
+import frappe
+import json
+from frappe.utils.data import flt
+from custom_ui.services import DbService, StripeService
+
+@frappe.whitelist(allow_guest=True)
+def half_down_stripe_payment(sales_order):
+ """Public endpoint for initiating a half-down advance payment for a sales order."""
+ if not DbService.exists("Sales Order", sales_order):
+ frappe.throw("Sales Order does not exist.")
+ so = DbService.get_or_throw("Sales Order", sales_order)
+ if not so.requires_half_payment:
+ frappe.throw("This sales order does not require a half-down payment.")
+ if so.docstatus != 1:
+ frappe.throw("Sales Order must be submitted to proceed with payment.")
+ if so.custom_halfdown_is_paid or so.advanced_paid >= so.custom_halfdown_amount:
+ frappe.throw("Half-down payment has already been made for this sales order.")
+ stripe_session = StripeService.create_checkout_session(
+ company=so.company,
+ amount=so.custom_halfdown_amount,
+ service=so.custom_project_template,
+ sales_order=so.name,
+ for_advance_payment=True
+ )
+ return frappe.redirect(stripe_session.url)
+
+@frappe.whitelist(allow_guest=True)
+def stripe_webhook():
+ """Endpoint to handle Stripe webhooks."""
+ payload = frappe.request.get_data()
+ sig_header = frappe.request.headers.get('Stripe-Signature')
+ session, metadata = StripeService.get_session_and_metadata(payload, sig_header)
+
+ 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
+ currency = session.currency.upper()
+ reference_doc = frappe.get_doc(reference_doctype, metadata.get("order_num"))
+
+ pe = frappe.get_doc({
+ "doctype": "Payment Entry",
+ "payment_type": "Receive",
+ "party_type": "Customer",
+ "mode_of_payment": "Stripe",
+ "party": reference_doc.customer,
+ "party_name": reference_doc.customer,
+ "paid_to": metadata.get("company"),
+ "reference_no": session.id,
+ "reference_date": frappe.utils.nowdate(),
+ "reference_doctype": reference_doctype,
+ "reference_name": reference_doc.name,
+ "paid_amount": amount_paid,
+ "paid_currency": currency,
+ })
+
+ pe.insert()
+ pe.submit()
+ return "Payment Entry created and submitted successfully."
+
+
+
+
+
+
\ No newline at end of file
diff --git a/custom_ui/events/estimate.py b/custom_ui/events/estimate.py
index cfae092..d6465a1 100644
--- a/custom_ui/events/estimate.py
+++ b/custom_ui/events/estimate.py
@@ -31,6 +31,7 @@ def before_insert(doc, method):
print("DEBUG: CHECKING CUSTOMER NAME")
print(doc.actual_customer_name)
print("Quotation_to:", doc.quotation_to)
+ doc.customer_address = frappe.get_value(doc.customer_type, doc.actual_customer_name, "customer_billing_address")
# print("Party_type:", doc.party_type)
if doc.custom_project_template == "SNW Install":
print("DEBUG: Quotation uses SNW Install template, making sure no duplicate linked estimates.")
@@ -53,25 +54,27 @@ def before_submit(doc, method):
def on_update_after_submit(doc, method):
print("DEBUG: on_update_after_submit hook triggered for Quotation:", doc.name)
print("DEBUG: Current custom_current_status:", doc.custom_current_status)
+ if doc.custom_current_status == "Won":
+ print("DEBUG: Quotation is already marked as Won, no action needed.")
+ return
if doc.custom_current_status == "Estimate Accepted":
doc.custom_current_status = "Won"
print("DEBUG: Quotation marked as Won, updating current status.")
if doc.customer_type == "Lead":
print("DEBUG: Customer is a Lead, converting to Customer and updating Quotation.")
new_customer = ClientService.convert_lead_to_customer(doc.actual_customer_name)
- doc.actual_customer_name = new_customer.name
doc.customer_type = "Customer"
+ doc.actual_customer_name = new_customer.name
new_customer.reload()
- ClientService.append_link_v2(
- new_customer.name, "quotations", {"quotation": doc.name}
- )
doc.save()
print("DEBUG: Creating Sales Order from accepted Estimate")
new_sales_order = make_sales_order(doc.name)
- new_sales_order.custom_requires_half_payment = doc.requires_half_payment
+ new_sales_order.requires_half_payment = doc.requires_half_payment
new_sales_order.customer = doc.actual_customer_name
+ new_sales_order.customer_name = doc.actual_customer_name
+ new_sales_order.customer_address = doc.customer_address
# new_sales_order.custom_installation_address = doc.custom_installation_address
- # new_sales_order.custom_job_address = doc.custom_job_address
+ new_sales_order.custom_job_address = doc.custom_job_address
new_sales_order.payment_schedule = []
print("DEBUG: Setting payment schedule for Sales Order")
new_sales_order.set_payment_schedule()
diff --git a/custom_ui/events/jobs.py b/custom_ui/events/jobs.py
index 378018e..de0d9ff 100644
--- a/custom_ui/events/jobs.py
+++ b/custom_ui/events/jobs.py
@@ -56,7 +56,9 @@ def after_insert(doc, method):
def before_insert(doc, method):
# This is where we will add logic to set tasks and other properties of a job based on it's project_template
- pass
+ if doc.requires_half_payment:
+ print("DEBUG: Project requires half payment, setting flag.")
+ doc.ready_to_schedule = 0
def before_save(doc, method):
print("DEBUG: Before Save Triggered for Project:", doc.name)
diff --git a/custom_ui/events/sales_order.py b/custom_ui/events/sales_order.py
index 1fffdb4..d8d659f 100644
--- a/custom_ui/events/sales_order.py
+++ b/custom_ui/events/sales_order.py
@@ -2,32 +2,42 @@ import frappe
from custom_ui.services import DbService, AddressService, ClientService
-def on_save(doc, method):
- print("DEBUG: on_save hook triggered for Sales Order", doc.name)
- if doc.advance_paid >= doc.grand_total/2:
- if doc.project and doc.half_down_required:
- print("DEBUG: Advance payments exceed required threshold of half down, setting project half down paid.")
- project = frappe.get_doc("Project", doc.project)
- project.is_half_down_paid = True
+def before_save(doc, method):
+ print("DEBUG: before_save hook triggered for Sales Order", doc.name)
+ if doc.docstatus == 1:
+ if doc.requires_half_payment:
+ half_down_is_paid = doc.custom_halfdown_is_paid or doc.advance_paid >= doc.custom_halfdown_amount or doc.advance_paid >= doc.grand_total / 2
+ if half_down_is_paid and not doc.custom_halfdown_is_paid:
+ doc.custom_halfdown_is_paid = 1
def before_insert(doc, method):
+ print("DEBUG: Before Insert triggered for Sales Order: ", doc.name)
+ if doc.custom_project_template == "SNW Install":
+ print("DEBUG: Sales Order uses SNW Install template, checking for duplicates.")
+ address_doc = AddressService.get_or_throw(doc.custom_job_address)
+ for link in address_doc.sales_orders:
+ if link.project_template == "SNW Install":
+ raise frappe.ValidationError("A Sales Order with project template 'SNW Install' is already linked to this address.")
print("DEBUG: before_insert hook triggered for Sales Order")
- # if doc.custom_project_template == "SNW Install":
- # print("DEBUG: Sales Order uses SNW Install template, checking for duplicate linked sales orders.")
- # address_doc = AddressService.get_or_throw(doc.custom_job_address)
- # if "SNW Install" in [link.project_template for link in address_doc.sales_orders]:
- # raise frappe.ValidationError("A Sales Order with project template 'SNW Install' is already linked to this address.")
+ if doc.requires_half_payment:
+ print("DEBUG: Sales Order requires half payment, calculating half-down amount.")
+ half_down_amount = doc.grand_total / 2
+ doc.custom_halfdown_amount = half_down_amount
+ print("DEBUG: Half-down amount set to:", half_down_amount)
+
def on_submit(doc, method):
+ print("DEBUG: on_submit hook triggered for Sales Order:", doc.name)
print("DEBUG: Info from Sales Order")
- print(doc.custom_installation_address)
- print(doc.company)
- print(doc.transaction_date)
- print(doc.customer)
- print(doc.custom_job_address)
- print(doc.custom_project_template)
+ print(f"Sales Order Name: {doc.name}")
+ print(f"Grand Total: {doc.grand_total}")
+ print(f"Company: {doc.company}")
+ print(f"Requires Half Payment: {doc.requires_half_payment}")
+ print(f"Customer: {doc.customer}")
+ print(f"Job Address: {doc.custom_job_address}")
+ print(f"Project Template: {doc.custom_project_template}")
# Create Invoice and Project from Sales Order
try:
print("Creating Project from Sales Order", doc.name)
@@ -62,6 +72,15 @@ def after_insert(doc, method):
doc.customer, "sales_orders", {"sales_order": doc.name, "project_template": doc.custom_project_template}
)
+def on_update_after_submit(doc, method):
+ print("DEBUG: on_update_after_submit hook triggered for Sales Order:", doc.name)
+ if doc.requires_half_payment and doc.custom_halfdown_is_paid:
+ project_is_scheduable = frappe.get_value("Project", doc.project, "ready_to_schedule")
+ if not project_is_scheduable:
+ print("DEBUG: Half-down payment made, setting Project to ready to schedule.")
+ frappe.set_value("Project", doc.project, "ready_to_schedule", 1)
+
+
def create_sales_invoice_from_sales_order(doc, method):
pass
diff --git a/custom_ui/hooks.py b/custom_ui/hooks.py
index 9bd60cf..6cfcf77 100644
--- a/custom_ui/hooks.py
+++ b/custom_ui/hooks.py
@@ -179,15 +179,18 @@ doc_events = {
"on_update_after_submit": "custom_ui.events.estimate.on_update_after_submit"
},
"Sales Order": {
+ "before_save": "custom_ui.events.sales_order.before_save",
"before_insert": "custom_ui.events.sales_order.before_insert",
"after_insert": "custom_ui.events.sales_order.after_insert",
- "on_submit": "custom_ui.events.sales_order.on_submit"
+ "on_submit": "custom_ui.events.sales_order.on_submit",
+ "on_update_after_submit": "custom_ui.events.sales_order.on_update_after_submit"
},
"Project": {
"before_insert": "custom_ui.events.jobs.before_insert",
"after_insert": "custom_ui.events.jobs.after_insert",
"before_save": "custom_ui.events.jobs.before_save",
- "on_update": "custom_ui.events.jobs.after_save"
+ "on_update": "custom_ui.events.jobs.after_save",
+ "after_save": "custom_ui.events.jobs.after_save"
},
"Task": {
"before_insert": "custom_ui.events.task.before_insert",
diff --git a/custom_ui/services/__init__.py b/custom_ui/services/__init__.py
index 1fb0a17..a1d4631 100644
--- a/custom_ui/services/__init__.py
+++ b/custom_ui/services/__init__.py
@@ -5,4 +5,5 @@ from .client_service import ClientService
from .estimate_service import EstimateService
from .onsite_meeting_service import OnSiteMeetingService
from .task_service import TaskService
-from .service_appointment_service import ServiceAppointmentService
\ No newline at end of file
+from .service_appointment_service import ServiceAppointmentService
+from .stripe_service import StripeService
\ No newline at end of file
diff --git a/custom_ui/services/payment_service.py b/custom_ui/services/payment_service.py
new file mode 100644
index 0000000..ca447f1
--- /dev/null
+++ b/custom_ui/services/payment_service.py
@@ -0,0 +1,29 @@
+import frappe
+from custom_ui.services import DbService
+
+class PaymentService:
+
+ @staticmethod
+ def create_payment_entry(reference_doctype: str, reference_doc_name: str, data: dict) -> frappe._dict:
+ """Create a Payment Entry document based on the reference document."""
+ print(f"DEBUG: Creating Payment Entry for {reference_doctype} {reference_doc_name} with data: {data}")
+ reference_doc = DbService.get_or_throw(reference_doctype, reference_doc_name)
+ pe = frappe.get_doc({
+ "doctype": "Payment Entry",
+ "payment_type": "Receive",
+ "party_type": "Customer",
+ "mode_of_payment": data.get("mode_of_payment", "Stripe"),
+ "party": reference_doc.customer,
+ "party_name": reference_doc.customer,
+ "paid_to": data.get("paid_to"),
+ "reference_no": data.get("reference_no"),
+ "reference_date": data.get("reference_date", frappe.utils.nowdate()),
+ "reference_doctype": reference_doctype,
+ "reference_name": reference_doc.name,
+ "paid_amount": data.get("paid_amount"),
+ "paid_currency": data.get("paid_currency"),
+ })
+ pe.insert()
+ print(f"DEBUG: Created Payment Entry with name: {pe.name}")
+ return pe.as_dict()
+
\ No newline at end of file
diff --git a/custom_ui/services/sales_order_service.py b/custom_ui/services/sales_order_service.py
new file mode 100644
index 0000000..9730618
--- /dev/null
+++ b/custom_ui/services/sales_order_service.py
@@ -0,0 +1,7 @@
+import frappe
+
+class SalesOrderService:
+
+ @staticmethod
+ def apply_advance_payment(sales_order_name: str, payment_entry_doc):
+ pass
\ No newline at end of file
diff --git a/custom_ui/services/stripe_service.py b/custom_ui/services/stripe_service.py
new file mode 100644
index 0000000..7f02c57
--- /dev/null
+++ b/custom_ui/services/stripe_service.py
@@ -0,0 +1,93 @@
+import frappe
+import stripe
+import json
+from custom_ui.services import DbService
+from frappe.utils import get_url
+
+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})
+ 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
+ return settings
+
+ @staticmethod
+ def get_api_key(company: str) -> str:
+ """Retrieve the Stripe API key for the specified company."""
+ settings = StripeService.get_stripe_settings(company)
+ return settings.secret_key
+
+ @staticmethod
+ 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:
+ frappe.throw(f"Stripe Webhook Secret not configured for company: {company}")
+ return settings.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."""
+ stripe.api_key = StripeService.get_api_key(company)
+
+ 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 ''}"
+ },
+ "unit_amount": int(amount * 100),
+ },
+ "quantity": 1,
+ }]
+
+ 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"
+ },
+ success_url=f"{get_url()}/payment-success?session_id={{CHECKOUT_SESSION_ID}}",
+ cancel_url=f"{get_url()}/payment-cancelled",
+ )
+
+ return session
+
+ @staticmethod
+ def get_event(payload: bytes, sig_header: str, company: str = None) -> stripe.Event:
+ company = company if company else json.loads(payload).get("data", {}).get("object", {}).get("metadata", {}).get("company")
+ if not company:
+ frappe.throw("Company information missing in webhook payload.")
+ try:
+ event = stripe.Webhook.construct_event(
+ payload=payload,
+ sig_header=sig_header,
+ secret=StripeService.get_webhook_secret(company),
+ api_key=StripeService.get_api_key(company)
+ )
+ except ValueError as e:
+ frappe.throw(f"Invalid payload: {str(e)}")
+ except stripe.error.SignatureVerificationError as e:
+ frappe.throw(f"Invalid signature: {str(e)}")
+
+ return event
+
+ @staticmethod
+ def get_session_and_metadata(payload: bytes, sig_header: str, company: str = None) -> tuple[stripe.checkout.Session, dict]:
+ """Retrieve the Stripe Checkout Session and its metadata from a webhook payload."""
+ event = StripeService.get_event(payload, sig_header, company)
+ if event.type != "checkout.session.completed":
+ frappe.throw(f"Unhandled event type: {event.type}")
+ session = event.data.object
+ metadata = session["metadata"] if "metadata" in session else {}
+
+ return session, metadata
+
+
\ No newline at end of file
diff --git a/custom_ui/templates/already-paid-half-payment.html b/custom_ui/templates/already-paid-half-payment.html
new file mode 100644
index 0000000..68876c6
--- /dev/null
+++ b/custom_ui/templates/already-paid-half-payment.html
@@ -0,0 +1,71 @@
+
+
+
+
+
+ Payment Already Completed
+
+
+
+
+
+
ℹ️
+
Payment Already Completed
+
The half down payment for this sales order has already been paid.
+
+ If you have any questions:
+ Please contact our sales team for assistance or clarification regarding your order.
+
+
+
+
\ No newline at end of file
diff --git a/custom_ui/templates/email/downpayment.html b/custom_ui/templates/email/downpayment.html
new file mode 100644
index 0000000..136424c
--- /dev/null
+++ b/custom_ui/templates/email/downpayment.html
@@ -0,0 +1,90 @@
+
+
+
+
+
+ Down Payment Required
+
+
+
+
+
+
Thank You for Confirming Your Quote
+
+
+
Dear Valued Customer,
+
Thank you for accepting our quote for services at {{ company_name }}. We're excited to work with you and appreciate your trust in our team.
+
To proceed with scheduling your service, a half down payment is required. This helps us secure the necessary resources and ensures everything is prepared for your appointment.
+
+
Payment Details
+
Sales Order Number: {{ sales_order_number }}
+
Down Payment Amount: ${{ total_amount }}
+
+
Please click the button below to make your secure payment through our payment processor:
If you have any questions or need assistance, feel free to contact us. We're here to help!
+
Best regards, The Team at {{ company_name }}
+
+
+
+
+
\ No newline at end of file
diff --git a/custom_ui/templates/no-half-payment.html b/custom_ui/templates/no-half-payment.html
new file mode 100644
index 0000000..632ba49
--- /dev/null
+++ b/custom_ui/templates/no-half-payment.html
@@ -0,0 +1,71 @@
+
+
+
+
+
+ No Down Payment Required
+
+
+
+
+
+
✅
+
No Down Payment Required
+
This sales order does not require any down payment.
+
+ If you have any questions:
+ Please contact our sales team for assistance or clarification regarding your order.
+
+
+
+
\ No newline at end of file
diff --git a/custom_ui/templates/public-error.html b/custom_ui/templates/public-error.html
new file mode 100644
index 0000000..47cef95
--- /dev/null
+++ b/custom_ui/templates/public-error.html
@@ -0,0 +1,86 @@
+
+
+
+
+
+ Error - Something Went Wrong
+
+
+
+
+
+
⚠️
+
Oops, something went wrong!
+
We're sorry, but an error occurred while processing your request. Please try again later or contact support if the problem persists.
+ {% if error_message %}
+
+ Error Details:
+ {{ error_message }}
+
+ {% endif %}
+
+
+
\ No newline at end of file
diff --git a/frontend/src/components/clientView/GeneralClientInfo.vue b/frontend/src/components/clientView/GeneralClientInfo.vue
index 2505590..2b27689 100644
--- a/frontend/src/components/clientView/GeneralClientInfo.vue
+++ b/frontend/src/components/clientView/GeneralClientInfo.vue
@@ -4,6 +4,26 @@