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