From 26f584e01df83dffa26dfc565a5a841e926ed6ac Mon Sep 17 00:00:00 2001 From: Casey Date: Wed, 28 Jan 2026 09:31:44 -0600 Subject: [PATCH] work on stripe flow --- custom_ui/api/payments.py | 6 -- custom_ui/api/public/payments.py | 72 ++++++++++++++ custom_ui/events/estimate.py | 15 +-- custom_ui/events/jobs.py | 4 +- custom_ui/events/sales_order.py | 53 +++++++---- custom_ui/hooks.py | 7 +- custom_ui/services/__init__.py | 3 +- custom_ui/services/payment_service.py | 29 ++++++ custom_ui/services/sales_order_service.py | 7 ++ custom_ui/services/stripe_service.py | 93 +++++++++++++++++++ .../templates/already-paid-half-payment.html | 71 ++++++++++++++ .../templates/email}/downpayment.html | 0 custom_ui/templates/no-half-payment.html | 71 ++++++++++++++ custom_ui/templates/public-error.html | 86 +++++++++++++++++ 14 files changed, 483 insertions(+), 34 deletions(-) delete mode 100644 custom_ui/api/payments.py create mode 100644 custom_ui/api/public/payments.py create mode 100644 custom_ui/services/payment_service.py create mode 100644 custom_ui/services/sales_order_service.py create mode 100644 custom_ui/services/stripe_service.py create mode 100644 custom_ui/templates/already-paid-half-payment.html rename {templates/downpayment => custom_ui/templates/email}/downpayment.html (100%) create mode 100644 custom_ui/templates/no-half-payment.html create mode 100644 custom_ui/templates/public-error.html 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..746913d 100644 --- a/custom_ui/events/sales_order.py +++ b/custom_ui/events/sales_order.py @@ -2,32 +2,41 @@ 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) @@ -61,6 +70,14 @@ def after_insert(doc, method): ClientService.append_link_v2( 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): 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/templates/downpayment/downpayment.html b/custom_ui/templates/email/downpayment.html similarity index 100% rename from templates/downpayment/downpayment.html rename to custom_ui/templates/email/downpayment.html 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