work on stripe flow

This commit is contained in:
Casey 2026-01-28 09:31:44 -06:00
parent ab0dced9ec
commit 26f584e01d
14 changed files with 483 additions and 34 deletions

View File

@ -1,6 +0,0 @@
import frappe
@frappe.whitelist(allow_guest=True)
def start_payment(invoice_name: str):
pass

View File

@ -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."

View File

@ -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()

View File

@ -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)

View File

@ -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):

View File

@ -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",

View File

@ -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
from .service_appointment_service import ServiceAppointmentService
from .stripe_service import StripeService

View File

@ -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()

View File

@ -0,0 +1,7 @@
import frappe
class SalesOrderService:
@staticmethod
def apply_advance_payment(sales_order_name: str, payment_entry_doc):
pass

View File

@ -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

View File

@ -0,0 +1,71 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Payment Already Completed</title>
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500;700&display=swap" rel="stylesheet">
<style>
body {
font-family: 'Roboto', sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: #333;
margin: 0;
padding: 0;
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
}
.message-container {
text-align: center;
background-color: #fff;
padding: 50px;
border-radius: 20px;
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.1);
max-width: 500px;
width: 90%;
position: relative;
}
.message-icon {
font-size: 5rem;
color: #74b9ff;
margin-bottom: 30px;
}
.message-title {
font-size: 2.5rem;
margin-bottom: 20px;
color: #333;
font-weight: 700;
}
.message-text {
font-size: 1.2rem;
line-height: 1.6;
margin-bottom: 30px;
color: #666;
font-weight: 400;
}
.contact-info {
background-color: #f8f9fa;
padding: 20px;
border-radius: 10px;
border-left: 5px solid #74b9ff;
text-align: left;
font-size: 1rem;
color: #333;
margin-top: 20px;
}
</style>
</head>
<body>
<div class="message-container">
<div class="message-icon"></div>
<h1 class="message-title">Payment Already Completed</h1>
<p class="message-text">The half down payment for this sales order has already been paid.</p>
<div class="contact-info">
<strong>If you have any questions:</strong><br>
Please contact our sales team for assistance or clarification regarding your order.
</div>
</div>
</body>
</html>

View File

@ -0,0 +1,71 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>No Down Payment Required</title>
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500;700&display=swap" rel="stylesheet">
<style>
body {
font-family: 'Roboto', sans-serif;
background: linear-gradient(135deg, #a8edea 0%, #fed6e3 100%);
color: #333;
margin: 0;
padding: 0;
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
}
.message-container {
text-align: center;
background-color: #fff;
padding: 50px;
border-radius: 20px;
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.1);
max-width: 500px;
width: 90%;
position: relative;
}
.message-icon {
font-size: 5rem;
color: #4ecdc4;
margin-bottom: 30px;
}
.message-title {
font-size: 2.5rem;
margin-bottom: 20px;
color: #333;
font-weight: 700;
}
.message-text {
font-size: 1.2rem;
line-height: 1.6;
margin-bottom: 30px;
color: #666;
font-weight: 400;
}
.contact-info {
background-color: #f8f9fa;
padding: 20px;
border-radius: 10px;
border-left: 5px solid #4ecdc4;
text-align: left;
font-size: 1rem;
color: #333;
margin-top: 20px;
}
</style>
</head>
<body>
<div class="message-container">
<div class="message-icon"></div>
<h1 class="message-title">No Down Payment Required</h1>
<p class="message-text">This sales order does not require any down payment.</p>
<div class="contact-info">
<strong>If you have any questions:</strong><br>
Please contact our sales team for assistance or clarification regarding your order.
</div>
</div>
</body>
</html>

View File

@ -0,0 +1,86 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Error - Something Went Wrong</title>
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500;700&display=swap" rel="stylesheet">
<style>
body {
font-family: 'Roboto', sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: #333;
margin: 0;
padding: 0;
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
}
.error-container {
text-align: center;
background-color: #fff;
padding: 50px;
border-radius: 20px;
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.1);
max-width: 500px;
width: 90%;
position: relative;
}
.error-icon {
font-size: 5rem;
color: #ff6b6b;
margin-bottom: 30px;
animation: bounce 2s infinite;
}
@keyframes bounce {
0%, 20%, 50%, 80%, 100% {
transform: translateY(0);
}
40% {
transform: translateY(-10px);
}
60% {
transform: translateY(-5px);
}
}
.error-title {
font-size: 2.5rem;
margin-bottom: 20px;
color: #333;
font-weight: 700;
}
.error-message {
font-size: 1.2rem;
line-height: 1.6;
margin-bottom: 30px;
color: #666;
font-weight: 400;
}
.error-details {
background-color: #f8f9fa;
padding: 20px;
border-radius: 10px;
border-left: 5px solid #ff6b6b;
text-align: left;
font-family: 'Roboto Mono', monospace;
font-size: 1rem;
color: #333;
margin-top: 20px;
}
</style>
</head>
<body>
<div class="error-container">
<div class="error-icon">⚠️</div>
<h1 class="error-title">Oops, something went wrong!</h1>
<p class="error-message">We're sorry, but an error occurred while processing your request. Please try again later or contact support if the problem persists.</p>
{% if error_message %}
<div class="error-details">
<strong>Error Details:</strong><br>
{{ error_message }}
</div>
{% endif %}
</div>
</body>
</html>