update Stripe Settings

This commit is contained in:
Casey 2026-02-06 13:10:34 -06:00
parent 7b40c86b40
commit afcd9a3488
13 changed files with 718 additions and 91 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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\"]"
}
]

View File

@ -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
from .project_service import ProjectService
from .sales_order_service import SalesOrderService
from .email_service import EmailService

View File

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

View File

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

View File

@ -1,2 +1,23 @@
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

View File

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

View File

@ -75,7 +75,7 @@
<div class="payment-details">
<h2>Payment Details</h2>
<p><strong>Sales Order Number:</strong> {{ sales_order_number }}</p>
<p><strong>Down Payment Amount:</strong> ${{ total_amount }}</p>
<p><strong>Down Payment Amount:</strong> {{ total_amount }}</p>
</div>
<p>Please click the button below to make your secure payment through our payment processor:</p>
<a href="{{ base_url }}/api/method/custom_ui.api.public.payments.half_down_stripe_payment?sales_order={{ sales_order_number }}" class="cta-button">Make Payment</a>

View File

@ -0,0 +1,152 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Invoice - {{ invoice_number }}</title>
<style>
body {
font-family: Arial, sans-serif;
line-height: 1.6;
color: #333;
background-color: #f4f4f4;
margin: 0;
padding: 20px;
}
.container {
max-width: 600px;
margin: 0 auto;
background-color: #ffffff;
padding: 20px;
border-radius: 8px;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
}
.header {
text-align: center;
padding-bottom: 20px;
border-bottom: 1px solid #eee;
}
.header h1 {
color: #2c3e50;
margin: 0;
}
.content {
padding: 20px 0;
}
.invoice-details {
background-color: #ecf0f1;
padding: 15px;
border-radius: 5px;
margin: 20px 0;
}
.invoice-details h2 {
margin-top: 0;
color: #3498db;
}
.detail-row {
display: flex;
justify-content: space-between;
padding: 5px 0;
border-bottom: 1px solid #ddd;
}
.detail-row:last-child {
border-bottom: none;
font-weight: bold;
margin-top: 10px;
padding-top: 10px;
border-top: 2px solid #3498db;
}
.detail-label {
font-weight: bold;
}
.cta-button {
display: inline-block;
background-color: #27ae60;
color: #ffffff;
padding: 12px 24px;
text-decoration: none;
border-radius: 5px;
font-weight: bold;
text-align: center;
margin: 20px 0;
}
.footer {
text-align: center;
padding-top: 20px;
border-top: 1px solid #eee;
color: #7f8c8d;
font-size: 14px;
}
.note {
background-color: #fff3cd;
border-left: 4px solid #ffc107;
padding: 10px;
margin: 15px 0;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>Invoice</h1>
</div>
<div class="content">
<p>Dear {{ customer_name }},</p>
<p>Thank you for your business with {{ company_name }}. Please find your invoice details below:</p>
<div class="invoice-details">
<h2>Invoice Details</h2>
<div class="detail-row">
<span class="detail-label">Invoice Number:</span>
<span>{{ invoice_number }}</span>
</div>
<div class="detail-row">
<span class="detail-label">Invoice Date:</span>
<span>{{ invoice_date }}</span>
</div>
<div class="detail-row">
<span class="detail-label">Due Date:</span>
<span>{{ due_date }}</span>
</div>
{% if sales_order %}
<div class="detail-row">
<span class="detail-label">Related Sales Order:</span>
<span>{{ sales_order }}</span>
</div>
{% endif %}
<div class="detail-row">
<span class="detail-label">Invoice Total:</span>
<span>{{ grand_total }}</span>
</div>
{% if paid_amount and paid_amount != "$0.00" %}
<div class="detail-row">
<span class="detail-label">Amount Paid:</span>
<span>{{ paid_amount }}</span>
</div>
{% endif %}
<div class="detail-row">
<span class="detail-label">Amount Due:</span>
<span>{{ outstanding_amount }}</span>
</div>
</div>
{% if payment_url and outstanding_amount != "$0.00" %}
<div class="note">
<strong>Payment Required:</strong> There is an outstanding balance on this invoice. Please click the button below to make a secure payment.
</div>
<a href="{{ payment_url }}" class="cta-button">Pay Now</a>
{% else %}
<div class="note">
<strong>Paid in Full:</strong> This invoice has been paid in full. Thank you!
</div>
{% endif %}
<p>If you have any questions about this invoice, please don't hesitate to contact us.</p>
<p>Best regards,<br>The Team at {{ company_name }}</p>
</div>
<div class="footer">
<p>This is an automated email. Please do not reply directly.</p>
</div>
</div>
</body>
</html>