diff --git a/custom_ui/api/db/estimates.py b/custom_ui/api/db/estimates.py index 678200b..8aadf5a 100644 --- a/custom_ui/api/db/estimates.py +++ b/custom_ui/api/db/estimates.py @@ -4,7 +4,7 @@ from custom_ui.api.db.general import get_doc_history from custom_ui.db_utils import DbUtils, process_query_conditions, build_datatable_dict, get_count_or_filters, build_success_response, build_error_response from werkzeug.wrappers import Response from custom_ui.api.db.clients import check_if_customer, convert_lead_to_customer -from custom_ui.services import DbService, ClientService, AddressService, ContactService +from custom_ui.services import DbService, ClientService, AddressService, ContactService, EstimateService, ItemService # =============================================================================== # ESTIMATES & INVOICES API METHODS @@ -86,11 +86,25 @@ def get_estimate_table_data(filters={}, sortings=[], page=1, page_size=10): @frappe.whitelist() -def get_quotation_items(): +def get_quotation_items(project_template:str = None): """Get all available quotation items.""" try: - items = frappe.get_all("Item", fields=["*"], filters={"item_group": "SNW-S"}) - return build_success_response(items) + filters = EstimateService.map_project_template_to_filter(project_template) + items = frappe.get_all("Item", fields=["item_code", "item_group"], filters=filters) + grouped_item_dicts = {} + for item in items: + item_dict = ItemService.get_full_dict(item.item_code) + if item_dict["bom"]: + if "Packages" not in grouped_item_dicts: + grouped_item_dicts["Packages"] = {} + if item.item_group not in grouped_item_dicts["Packages"]: + grouped_item_dicts["Packages"][item.item_group] = [] + grouped_item_dicts["Packages"][item.item_group].append(item_dict) + else: + if item.item_group not in grouped_item_dicts: + grouped_item_dicts[item.item_group] = [] + grouped_item_dicts[item.item_group].append(item_dict) + return build_success_response(grouped_item_dicts) except Exception as e: return build_error_response(str(e), 500) diff --git a/custom_ui/api/public/payments.py b/custom_ui/api/public/payments.py index b369087..9f4a88a 100644 --- a/custom_ui/api/public/payments.py +++ b/custom_ui/api/public/payments.py @@ -1,7 +1,8 @@ import frappe import json from frappe.utils.data import flt -from custom_ui.services import DbService, StripeService +from custom_ui.services import DbService, StripeService, PaymentService +from custom_ui.models import PaymentData @frappe.whitelist(allow_guest=True) def half_down_stripe_payment(sales_order): @@ -31,37 +32,36 @@ def stripe_webhook(): 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}") + if DbService.exists("Payment Entry", {"reference_no": session.id}): raise frappe.ValidationError("Payment Entry already exists for this session.") - reference_doctype = "Sales Invoice" + + 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, - }) + # stripe_settings = StripeService.get_stripe_settings(metadata.get("company")) - pe.insert() + pe = PaymentService.create_payment_entry( + data=PaymentData( + mode_of_payment="Stripe", + reference_no=session.id, + reference_date=session.created, + received_amount=amount_paid, + company=metadata.get("company"), + reference_doc_name=metadata.get("order_num") + ) + ) pe.submit() return "Payment Entry created and submitted successfully." diff --git a/custom_ui/install.py b/custom_ui/install.py index c4a42ae..450e82d 100644 --- a/custom_ui/install.py +++ b/custom_ui/install.py @@ -24,6 +24,8 @@ def after_install(): create_task_types() # create_tasks() create_bid_meeting_note_form_templates() + create_accounts() + init_stripe_accounts() build_frontend() def after_migrate(): @@ -42,6 +44,8 @@ def after_migrate(): create_task_types() # create_tasks() create_bid_meeting_note_form_templates() + create_accounts() + init_stripe_accounts() # update_address_fields() # build_frontend() @@ -1378,3 +1382,58 @@ def create_bid_meeting_note_form_templates(): ) doc.insert(ignore_permissions=True) + +def create_accounts(): + """Create necessary accounts if they do not exist.""" + print("\nš§ Checking for necessary accounts...") + + accounts = [ + { + "Sprinklers Northwest": [ + { + "account_name": "Stripe Clearing - Sprinklers Northwest", + "account_type": "Bank", + "parent_account": "Bank Accounts - S", + "company": "Sprinklers Northwest" + } + ] + } + ] + + for company_accounts in accounts: + for company, account_list in company_accounts.items(): + for account in account_list: + # Idempotency check + if frappe.db.exists("Account", {"account_name": account["account_name"], "company": account["company"]}): + continue + doc = frappe.get_doc({ + "doctype": "Account", + "account_name": account["account_name"], + "account_type": account["account_type"], + "company": account["company"], + "parent_account": account["parent_account"], + "is_group": 0 + }) + doc.insert(ignore_permissions=True) + + frappe.db.commit() + +def init_stripe_accounts(): + """Initializes the bare configurations for each Stripe Settings doctypes.""" + print("\nš§ Initializing Stripe Settings for companies...") + + companies = ["Sprinklers Northwest"] + + for company in companies: + if not frappe.db.exists("Stripe Settings", {"company": company}): + doc = frappe.get_doc({ + "doctype": "Stripe Settings", + "company": company, + "api_key": "", + "publishable_key": "", + "webhook_secret": "", + "account": f"Stripe Clearing - {company}" + }) + doc.insert(ignore_permissions=True) + + frappe.db.commit() diff --git a/custom_ui/models/__init__.py b/custom_ui/models/__init__.py new file mode 100644 index 0000000..c0134af --- /dev/null +++ b/custom_ui/models/__init__.py @@ -0,0 +1 @@ +from .payments import PaymentData \ No newline at end of file diff --git a/custom_ui/models/payments.py b/custom_ui/models/payments.py new file mode 100644 index 0000000..dfd9bf4 --- /dev/null +++ b/custom_ui/models/payments.py @@ -0,0 +1,10 @@ +from dataclasses import dataclass + +@dataclass +class PaymentData: + mode_of_payment: str + reference_no: str + reference_date: str + received_amount: float + company: str = None + reference_doc_name: str = None \ No newline at end of file diff --git a/custom_ui/services/__init__.py b/custom_ui/services/__init__.py index a1d4631..d5fafc8 100644 --- a/custom_ui/services/__init__.py +++ b/custom_ui/services/__init__.py @@ -6,4 +6,6 @@ from .estimate_service import EstimateService from .onsite_meeting_service import OnSiteMeetingService from .task_service import TaskService from .service_appointment_service import ServiceAppointmentService -from .stripe_service import StripeService \ No newline at end of file +from .stripe_service import StripeService +from .payment_service import PaymentService +from .item_service import ItemService \ No newline at end of file diff --git a/custom_ui/services/estimate_service.py b/custom_ui/services/estimate_service.py index d5e3cf2..ea05a8a 100644 --- a/custom_ui/services/estimate_service.py +++ b/custom_ui/services/estimate_service.py @@ -1,4 +1,5 @@ import frappe +from .item_service import ItemService class EstimateService: @@ -93,4 +94,18 @@ class EstimateService: estimate_doc.customer = customer_name estimate_doc.save(ignore_permissions=True) print(f"DEBUG: Linked Quotation {estimate_doc.name} to {customer_type} {customer_name}") - \ No newline at end of file + + @staticmethod + def map_project_template_to_filter(project_template: str = None) -> dict | None: + """Map a project template to a filter.""" + print(f"DEBUG: Mapping project template {project_template} to quotation category") + if not project_template: + print("DEBUG: No project template provided, defaulting to 'General'") + return None + mapping = { + # SNW Install is both Irrigation and SNW-S categories + "SNW Install": ["in", ["Irrigation", "SNW-S", "Landscaping"]], + } + category = mapping.get(project_template, "General") + print(f"DEBUG: Mapped to quotation category: {category}") + return { "item_group": category } \ No newline at end of file diff --git a/custom_ui/services/item_service.py b/custom_ui/services/item_service.py new file mode 100644 index 0000000..4e4e3de --- /dev/null +++ b/custom_ui/services/item_service.py @@ -0,0 +1,38 @@ +import frappe + +class ItemService: + + @staticmethod + def get_item_category(item_code: str) -> str: + """Retrieve the category of an Item document by item code.""" + print(f"DEBUG: Getting category for Item {item_code}") + category = frappe.db.get_value("Item", item_code, "item_group") + print(f"DEBUG: Retrieved category: {category}") + return category + + @staticmethod + def get_full_dict(item_code: str) -> frappe._dict: + """Retrieve the full Item document by item code.""" + print(f"DEBUG: Getting full document for Item {item_code}") + item_doc = frappe.get_doc("Item", item_code).as_dict() + item_doc["bom"] = ItemService.get_full_bom_dict(item_code) if item_doc.get("default_bom") else None + return item_doc + + @staticmethod + def get_full_bom_dict(item_code: str): + """Retrieve the Bill of Materials (BOM) associated with an Item.""" + print(f"DEBUG: Getting BOM for Item {item_code}") + bom_name = frappe.db.get_value("BOM", {"item": item_code, "is_active": 1}, "name") + bom_dict = frappe.get_doc("BOM", bom_name).as_dict() + for item in bom_dict.get('exploded_items', []): + item_bom_name = frappe.get_value("Item", item["item_name"], "default_bom") + item["bom"] = frappe.get_doc("BOM", item_bom_name).as_dict() if item_bom_name else None + return bom_dict + + @staticmethod + def exists(item_code: str) -> bool: + """Check if an Item document exists by item code.""" + print(f"DEBUG: Checking existence of Item {item_code}") + exists = frappe.db.exists("Item", item_code) is not None + print(f"DEBUG: Item {item_code} exists: {exists}") + return exists \ No newline at end of file diff --git a/custom_ui/services/payment_service.py b/custom_ui/services/payment_service.py index ca447f1..db84f18 100644 --- a/custom_ui/services/payment_service.py +++ b/custom_ui/services/payment_service.py @@ -1,29 +1,51 @@ import frappe -from custom_ui.services import DbService +from custom_ui.services import DbService, StripeService +from dataclasses import dataclass +from custom_ui.models import PaymentData + + class PaymentService: @staticmethod - def create_payment_entry(reference_doctype: str, reference_doc_name: str, data: dict) -> frappe._dict: + def create_payment_entry(data: PaymentData) -> 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) + 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 pe = frappe.get_doc({ "doctype": "Payment Entry", + "company": data.company, "payment_type": "Receive", "party_type": "Customer", - "mode_of_payment": data.get("mode_of_payment", "Stripe"), + "mode_of_payment": data.mode_of_payment or "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, + "paid_to": account, + "reference_no": data.reference_no, + "reference_date": data.reference_date or frappe.utils.nowdate(), + "received_amount": data.received_amount, + "paid_currency": "USD", + "received_currency": "USD", + }).append("references", { + "reference_doctype": reference_doc.doctype, "reference_name": reference_doc.name, - "paid_amount": data.get("paid_amount"), - "paid_currency": data.get("paid_currency"), + "reconcile_effect_on": reference_doc.doctype, + "allocated_amount": data.received_amount, }) pe.insert() print(f"DEBUG: Created Payment Entry with name: {pe.name}") - return pe.as_dict() + return pe + + @staticmethod + def determine_reference_doctype(reference_doc_name: str) -> str: + """Determine the reference doctype based on the document name pattern.""" + print(f"DEBUG: Determining reference doctype for document name: {reference_doc_name}") + if DbService.exists("Sales Order", reference_doc_name): + return "Sales Order" + elif DbService.exists("Sales Invoice", reference_doc_name): + return "Sales Invoice" + else: + frappe.throw("Unable to determine reference doctype from document name.") \ No newline at end of file diff --git a/custom_ui/services/sales_order_service.py b/custom_ui/services/sales_order_service.py index 9730618..0bdeca3 100644 --- a/custom_ui/services/sales_order_service.py +++ b/custom_ui/services/sales_order_service.py @@ -1,7 +1,2 @@ -import frappe -class SalesOrderService: - - @staticmethod - def apply_advance_payment(sales_order_name: str, payment_entry_doc): - pass \ No newline at end of file + \ No newline at end of file diff --git a/custom_ui/www/cancelled_payment.html b/custom_ui/www/cancelled_payment.html index f1a0534..8ada12e 100644 --- a/custom_ui/www/cancelled_payment.html +++ b/custom_ui/www/cancelled_payment.html @@ -1 +1,4 @@ +{% extends "templates/web.html" %} +{% block page_content %}
Payment cancelled.
+{% endblock %} diff --git a/custom_ui/www/successful_payment.html b/custom_ui/www/successful_payment.html index e1006a7..a9e4113 100644 --- a/custom_ui/www/successful_payment.html +++ b/custom_ui/www/successful_payment.html @@ -1 +1,4 @@ +{% extends "templates/web.html" %} +{% block page_content %}Thank you for your payment!
+{% endblock %} diff --git a/docker-compose.local.yaml b/docker-compose.local.yaml new file mode 100644 index 0000000..b99b3de --- /dev/null +++ b/docker-compose.local.yaml @@ -0,0 +1,8 @@ +services: + mailhog: + image: mailhog/mailhog:latest + container_name: mailhog + ports: + - "8025:8025" # MailHog web UI + - "1025:1025" # SMTP server + restart: unless-stopped \ No newline at end of file diff --git a/frontend/src/api.js b/frontend/src/api.js index 01e1baf..a1da164 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -229,8 +229,8 @@ class Api { // ESTIMATE / QUOTATION METHODS // ============================================================================ - static async getQuotationItems() { - return await this.request("custom_ui.api.db.estimates.get_quotation_items"); + static async getQuotationItems(projectTemplate) { + return await this.request("custom_ui.api.db.estimates.get_quotation_items", { projectTemplate }); } static async getEstimateFromAddress(fullAddress) { diff --git a/frontend/src/components/common/ItemSelector.vue b/frontend/src/components/common/ItemSelector.vue new file mode 100644 index 0000000..46ee774 --- /dev/null +++ b/frontend/src/components/common/ItemSelector.vue @@ -0,0 +1,145 @@ + +