update estimate page, add stripe script and docker compose for mail server
This commit is contained in:
parent
678eb18583
commit
9a7e3fe740
@ -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)
|
||||
|
||||
|
||||
@ -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."
|
||||
|
||||
|
||||
@ -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()
|
||||
|
||||
1
custom_ui/models/__init__.py
Normal file
1
custom_ui/models/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
from .payments import PaymentData
|
||||
10
custom_ui/models/payments.py
Normal file
10
custom_ui/models/payments.py
Normal file
@ -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
|
||||
@ -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
|
||||
from .stripe_service import StripeService
|
||||
from .payment_service import PaymentService
|
||||
from .item_service import ItemService
|
||||
@ -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}")
|
||||
|
||||
|
||||
@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 }
|
||||
38
custom_ui/services/item_service.py
Normal file
38
custom_ui/services/item_service.py
Normal file
@ -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
|
||||
@ -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.")
|
||||
|
||||
@ -1,7 +1,2 @@
|
||||
import frappe
|
||||
|
||||
class SalesOrderService:
|
||||
|
||||
@staticmethod
|
||||
def apply_advance_payment(sales_order_name: str, payment_entry_doc):
|
||||
pass
|
||||
|
||||
8
docker-compose.local.yaml
Normal file
8
docker-compose.local.yaml
Normal file
@ -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
|
||||
@ -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) {
|
||||
|
||||
145
frontend/src/components/common/ItemSelector.vue
Normal file
145
frontend/src/components/common/ItemSelector.vue
Normal file
@ -0,0 +1,145 @@
|
||||
<template>
|
||||
<div class="items-container">
|
||||
<div v-if="items.length === 0" class="no-items-message">
|
||||
<i class="pi pi-inbox"></i>
|
||||
<p>{{ emptyMessage }}</p>
|
||||
</div>
|
||||
<div v-else v-for="item in items" :key="item.itemCode" class="item-card" :class="{ 'item-selected': item._selected }" @click="handleItemClick(item, $event)">
|
||||
<div class="item-card-header">
|
||||
<span class="item-code">{{ item.itemCode }}</span>
|
||||
<span class="item-name">{{ item.itemName }}</span>
|
||||
<span class="item-price">${{ item.standardRate?.toFixed(2) || '0.00' }}</span>
|
||||
<Button
|
||||
:label="item._selected ? 'Selected' : 'Select'"
|
||||
:icon="item._selected ? 'pi pi-check' : 'pi pi-plus'"
|
||||
@click.stop="handleItemClick(item, $event)"
|
||||
size="small"
|
||||
:severity="item._selected ? 'success' : 'secondary'"
|
||||
class="select-item-button"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="item.description" class="item-description">
|
||||
{{ item.description }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from "vue";
|
||||
import Button from "primevue/button";
|
||||
|
||||
const props = defineProps({
|
||||
items: {
|
||||
type: Array,
|
||||
required: true,
|
||||
default: () => []
|
||||
},
|
||||
emptyMessage: {
|
||||
type: String,
|
||||
default: "No items found in this category"
|
||||
}
|
||||
});
|
||||
|
||||
const emit = defineEmits(['select']);
|
||||
|
||||
const selectedItems = ref([]);
|
||||
|
||||
const handleItemClick = (item, event) => {
|
||||
// Always multi-select mode - toggle item in selection
|
||||
const index = selectedItems.value.findIndex(i => i.itemCode === item.itemCode);
|
||||
if (index >= 0) {
|
||||
selectedItems.value.splice(index, 1);
|
||||
} else {
|
||||
selectedItems.value.push(item);
|
||||
}
|
||||
// Emit the entire selection array
|
||||
emit('select', [...selectedItems.value]);
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.items-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
height: 100%;
|
||||
overflow-y: scroll;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.no-items-message {
|
||||
text-align: center;
|
||||
padding: 3rem 2rem;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.no-items-message i {
|
||||
font-size: 3em;
|
||||
color: #ccc;
|
||||
margin-bottom: 1rem;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.no-items-message p {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.item-card {
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 6px;
|
||||
padding: 0.75rem;
|
||||
background-color: #fafafa;
|
||||
transition: all 0.2s ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.item-card:hover {
|
||||
background-color: #f0f0f0;
|
||||
border-color: #2196f3;
|
||||
}
|
||||
|
||||
.item-selected {
|
||||
background-color: #e3f2fd;
|
||||
border-color: #2196f3;
|
||||
border-width: 2px;
|
||||
}
|
||||
|
||||
.item-card-header {
|
||||
display: grid;
|
||||
grid-template-columns: 150px 1fr 120px 100px;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.item-code {
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
font-family: monospace;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.item-name {
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.item-price {
|
||||
font-weight: 600;
|
||||
color: #2196f3;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.select-item-button {
|
||||
justify-self: end;
|
||||
}
|
||||
|
||||
.item-description {
|
||||
margin-top: 0.5rem;
|
||||
padding-top: 0.5rem;
|
||||
border-top: 1px solid #e0e0e0;
|
||||
color: #666;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
</style>
|
||||
526
frontend/src/components/modals/AddItemModal.vue
Normal file
526
frontend/src/components/modals/AddItemModal.vue
Normal file
@ -0,0 +1,526 @@
|
||||
<template>
|
||||
<Modal
|
||||
:visible="visible"
|
||||
@update:visible="$emit('update:visible', $event)"
|
||||
@close="handleClose"
|
||||
:options="{ showActions: false, maxWidth: '90vw', width: '1350px' }"
|
||||
class="add-item-modal"
|
||||
>
|
||||
<template #title>
|
||||
<div class="modal-title-container">
|
||||
<span>Add Item</span>
|
||||
<span v-if="selectedItemsCount > 0" class="selection-badge">{{ selectedItemsCount }} selected</span>
|
||||
</div>
|
||||
</template>
|
||||
<div class="modal-content items-modal-content">
|
||||
<div class="search-section">
|
||||
<label for="item-search" class="field-label">Search Items</label>
|
||||
<InputText
|
||||
id="item-search"
|
||||
v-model="searchTerm"
|
||||
placeholder="Search by item code or name..."
|
||||
fluid
|
||||
/>
|
||||
</div>
|
||||
<div class="tabs-container">
|
||||
<Tabs v-model="activeItemTab" v-if="itemGroups.length > 0 || packageGroups.length > 0">
|
||||
<TabList>
|
||||
<Tab v-for="group in itemGroups" :key="group" :value="group">{{ group }}</Tab>
|
||||
<Tab v-if="packageGroups.length > 0" value="Packages">Packages</Tab>
|
||||
</TabList>
|
||||
<TabPanels>
|
||||
<!-- Regular category tabs -->
|
||||
<TabPanel v-for="group in itemGroups" :key="group" :value="group">
|
||||
<ItemSelector :items="getFilteredItemsForGroup(group)" @select="handleItemSelection" />
|
||||
</TabPanel>
|
||||
|
||||
<!-- Packages tab with nested sub-tabs -->
|
||||
<TabPanel v-if="packageGroups.length > 0" value="Packages">
|
||||
<Tabs v-model="activePackageTab" class="nested-tabs">
|
||||
<TabList>
|
||||
<Tab v-for="packageGroup in packageGroups" :key="packageGroup" :value="packageGroup">{{ packageGroup }}</Tab>
|
||||
</TabList>
|
||||
<TabPanels>
|
||||
<TabPanel v-for="packageGroup in packageGroups" :key="packageGroup" :value="packageGroup">
|
||||
<div class="package-items-container">
|
||||
<div v-for="item in getFilteredPackageItemsForGroup(packageGroup)" :key="item.itemCode" class="package-item" :class="{ 'package-item-selected': item._selected }">
|
||||
<div class="package-item-header">
|
||||
<Button
|
||||
:icon="isPackageExpanded(item.itemCode) ? 'pi pi-chevron-down' : 'pi pi-chevron-right'"
|
||||
@click="togglePackageExpansion(item.itemCode)"
|
||||
text
|
||||
rounded
|
||||
class="expand-button"
|
||||
/>
|
||||
<span class="package-item-code">{{ item.itemCode }}</span>
|
||||
<span class="package-item-name">{{ item.itemName }}</span>
|
||||
<span class="package-item-price">${{ item.standardRate?.toFixed(2) || '0.00' }}</span>
|
||||
<Button
|
||||
:label="item._selected ? 'Selected' : 'Select'"
|
||||
:icon="item._selected ? 'pi pi-check' : 'pi pi-plus'"
|
||||
@click="handleItemSelection(item)"
|
||||
size="small"
|
||||
:severity="item._selected ? 'success' : 'secondary'"
|
||||
class="add-package-button"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="isPackageExpanded(item.itemCode) && item.bom" class="bom-details">
|
||||
<div class="bom-header">Bill of Materials:</div>
|
||||
<div v-for="bomItem in (item.bom.items || [])" :key="bomItem.itemCode" class="bom-item">
|
||||
<span class="bom-item-code">{{ bomItem.itemCode }}</span>
|
||||
<span class="bom-item-name">{{ bomItem.itemName }}</span>
|
||||
<span class="bom-item-qty">Qty: {{ bomItem.qty }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TabPanel>
|
||||
</TabPanels>
|
||||
</Tabs>
|
||||
</TabPanel>
|
||||
</TabPanels>
|
||||
</Tabs>
|
||||
<!-- Fallback if no categories -->
|
||||
<ItemSelector v-else :items="[]" empty-message="No items available. Please select a Project Template first." />
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<Button
|
||||
label="Clear Selection"
|
||||
@click="clearSelection"
|
||||
severity="secondary"
|
||||
:disabled="selectedItemsCount === 0"
|
||||
/>
|
||||
<Button
|
||||
:label="`Add ${selectedItemsCount} Item${selectedItemsCount !== 1 ? 's' : ''}`"
|
||||
@click="addItems"
|
||||
icon="pi pi-plus"
|
||||
:disabled="selectedItemsCount === 0"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch } from "vue";
|
||||
import Modal from "../common/Modal.vue";
|
||||
import ItemSelector from "../common/ItemSelector.vue";
|
||||
import InputText from "primevue/inputtext";
|
||||
import Button from "primevue/button";
|
||||
import Tabs from "primevue/tabs";
|
||||
import TabList from "primevue/tablist";
|
||||
import Tab from "primevue/tab";
|
||||
import TabPanels from "primevue/tabpanels";
|
||||
import TabPanel from "primevue/tabpanel";
|
||||
|
||||
const props = defineProps({
|
||||
visible: {
|
||||
type: Boolean,
|
||||
required: true
|
||||
},
|
||||
quotationItems: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
}
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:visible', 'add-items']);
|
||||
|
||||
const searchTerm = ref("");
|
||||
const expandedPackageItems = ref(new Set());
|
||||
const selectedItemsInModal = ref(new Set());
|
||||
|
||||
const itemGroups = computed(() => {
|
||||
if (!props.quotationItems || typeof props.quotationItems !== 'object') return [];
|
||||
// Get all keys except 'Packages'
|
||||
const groups = Object.keys(props.quotationItems).filter(key => key !== 'Packages').sort();
|
||||
return groups;
|
||||
});
|
||||
|
||||
const packageGroups = computed(() => {
|
||||
if (!props.quotationItems?.Packages || typeof props.quotationItems.Packages !== 'object') return [];
|
||||
return Object.keys(props.quotationItems.Packages).sort();
|
||||
});
|
||||
|
||||
// Active tabs with default to first category
|
||||
const activeItemTab = computed({
|
||||
get: () => _activeItemTab.value || itemGroups.value[0] || "",
|
||||
set: (val) => { _activeItemTab.value = val; }
|
||||
});
|
||||
|
||||
const activePackageTab = computed({
|
||||
get: () => _activePackageTab.value || packageGroups.value[0] || "",
|
||||
set: (val) => { _activePackageTab.value = val; }
|
||||
});
|
||||
|
||||
const _activeItemTab = ref("");
|
||||
const _activePackageTab = ref("");
|
||||
|
||||
const getFilteredItemsForGroup = (group) => {
|
||||
if (!props.quotationItems || typeof props.quotationItems !== 'object') return [];
|
||||
|
||||
let items = [];
|
||||
|
||||
// Get items from the specified group
|
||||
if (group && props.quotationItems[group]) {
|
||||
items = [...props.quotationItems[group]];
|
||||
}
|
||||
|
||||
// Filter by search term
|
||||
if (searchTerm.value.trim()) {
|
||||
const term = searchTerm.value.toLowerCase();
|
||||
items = items.filter(
|
||||
(item) =>
|
||||
item.itemCode?.toLowerCase().includes(term) ||
|
||||
item.itemName?.toLowerCase().includes(term),
|
||||
);
|
||||
}
|
||||
|
||||
return items.map((item) => ({
|
||||
...item,
|
||||
id: item.itemCode,
|
||||
_selected: selectedItemsInModal.value.has(item.itemCode)
|
||||
}));
|
||||
};
|
||||
|
||||
const getFilteredPackageItemsForGroup = (packageGroup) => {
|
||||
if (!props.quotationItems?.Packages || typeof props.quotationItems.Packages !== 'object') return [];
|
||||
|
||||
let items = [];
|
||||
|
||||
// Get items from the specified package group
|
||||
if (packageGroup && props.quotationItems.Packages[packageGroup]) {
|
||||
items = [...props.quotationItems.Packages[packageGroup]];
|
||||
}
|
||||
|
||||
// Filter by search term
|
||||
if (searchTerm.value.trim()) {
|
||||
const term = searchTerm.value.toLowerCase();
|
||||
items = items.filter(
|
||||
(item) =>
|
||||
item.itemCode?.toLowerCase().includes(term) ||
|
||||
item.itemName?.toLowerCase().includes(term),
|
||||
);
|
||||
}
|
||||
|
||||
return items.map((item) => ({
|
||||
...item,
|
||||
id: item.itemCode,
|
||||
_selected: selectedItemsInModal.value.has(item.itemCode)
|
||||
}));
|
||||
};
|
||||
|
||||
const selectedItemsCount = computed(() => selectedItemsInModal.value.size);
|
||||
|
||||
const togglePackageExpansion = (itemCode) => {
|
||||
if (expandedPackageItems.value.has(itemCode)) {
|
||||
expandedPackageItems.value.delete(itemCode);
|
||||
} else {
|
||||
expandedPackageItems.value.add(itemCode);
|
||||
}
|
||||
// Force reactivity
|
||||
expandedPackageItems.value = new Set(expandedPackageItems.value);
|
||||
};
|
||||
|
||||
const isPackageExpanded = (itemCode) => {
|
||||
return expandedPackageItems.value.has(itemCode);
|
||||
};
|
||||
|
||||
const handleItemSelection = (itemOrRows) => {
|
||||
// Handle both single item (from package cards) and array (from DataTable)
|
||||
if (Array.isArray(itemOrRows)) {
|
||||
// From DataTable - replace selection with new array
|
||||
selectedItemsInModal.value = new Set(itemOrRows.map(row => row.itemCode));
|
||||
} else {
|
||||
// From package card - toggle single item
|
||||
if (selectedItemsInModal.value.has(itemOrRows.itemCode)) {
|
||||
selectedItemsInModal.value.delete(itemOrRows.itemCode);
|
||||
} else {
|
||||
selectedItemsInModal.value.add(itemOrRows.itemCode);
|
||||
}
|
||||
// Force reactivity
|
||||
selectedItemsInModal.value = new Set(selectedItemsInModal.value);
|
||||
}
|
||||
};
|
||||
|
||||
const clearSelection = () => {
|
||||
selectedItemsInModal.value = new Set();
|
||||
};
|
||||
|
||||
const addItems = () => {
|
||||
// Get all selected items from all categories
|
||||
const allItems = [];
|
||||
|
||||
// Collect from regular categories
|
||||
if (props.quotationItems && typeof props.quotationItems === 'object') {
|
||||
Object.keys(props.quotationItems).forEach(key => {
|
||||
if (key !== 'Packages' && Array.isArray(props.quotationItems[key])) {
|
||||
props.quotationItems[key].forEach(item => {
|
||||
if (selectedItemsInModal.value.has(item.itemCode)) {
|
||||
allItems.push(item);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Collect from Packages sub-categories
|
||||
if (props.quotationItems.Packages && typeof props.quotationItems.Packages === 'object') {
|
||||
Object.keys(props.quotationItems.Packages).forEach(subKey => {
|
||||
if (Array.isArray(props.quotationItems.Packages[subKey])) {
|
||||
props.quotationItems.Packages[subKey].forEach(item => {
|
||||
if (selectedItemsInModal.value.has(item.itemCode)) {
|
||||
allItems.push(item);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (allItems.length > 0) {
|
||||
emit('add-items', allItems);
|
||||
selectedItemsInModal.value = new Set();
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
selectedItemsInModal.value = new Set();
|
||||
searchTerm.value = "";
|
||||
emit('update:visible', false);
|
||||
};
|
||||
|
||||
// Watch modal visibility to reset state when closing
|
||||
watch(() => props.visible, (newVal) => {
|
||||
if (newVal) {
|
||||
// Modal is opening - reset to first tabs
|
||||
_activeItemTab.value = "";
|
||||
_activePackageTab.value = "";
|
||||
} else {
|
||||
// Modal is closing - reset state
|
||||
selectedItemsInModal.value = new Set();
|
||||
searchTerm.value = "";
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.items-modal-content {
|
||||
height: 70vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tabs-container {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.tabs-container :deep(.p-tabs) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.tabs-container :deep(.p-tabpanels) {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tabs-container :deep(.p-tabpanel) {
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.search-section {
|
||||
/* margin removed - parent gap handles spacing */
|
||||
}
|
||||
|
||||
.field-label {
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.tip-section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem;
|
||||
background-color: #e3f2fd;
|
||||
border: 1px solid #2196f3;
|
||||
border-radius: 4px;
|
||||
color: #1565c0;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.tip-section i {
|
||||
color: #2196f3;
|
||||
}
|
||||
|
||||
.tip-section kbd {
|
||||
background-color: #fff;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 3px;
|
||||
padding: 2px 6px;
|
||||
font-family: monospace;
|
||||
font-size: 0.85em;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.modal-title-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.selection-badge {
|
||||
background-color: #2196f3;
|
||||
color: white;
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 12px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.75rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid #e0e0e0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.nested-tabs {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.nested-tabs :deep(.p-tabs) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.nested-tabs :deep(.p-tabpanels) {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.nested-tabs :deep(.p-tabpanel) {
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.package-items-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
height: 100%;
|
||||
overflow-y: scroll;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.package-item {
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 6px;
|
||||
padding: 0.75rem;
|
||||
background-color: #fafafa;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.package-item:hover {
|
||||
background-color: #f0f0f0;
|
||||
border-color: #2196f3;
|
||||
}
|
||||
|
||||
.package-item-selected {
|
||||
background-color: #e3f2fd;
|
||||
border-color: #2196f3;
|
||||
border-width: 2px;
|
||||
}
|
||||
|
||||
.package-item-header {
|
||||
display: grid;
|
||||
grid-template-columns: 40px 120px 1fr 100px 80px;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.expand-button {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
}
|
||||
|
||||
.package-item-code {
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.package-item-name {
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.package-item-price {
|
||||
font-weight: 600;
|
||||
color: #2196f3;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.add-package-button {
|
||||
justify-self: end;
|
||||
}
|
||||
|
||||
.bom-details {
|
||||
margin-top: 0.75rem;
|
||||
padding: 0.75rem;
|
||||
background-color: #fff;
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.bom-header {
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.5rem;
|
||||
color: #666;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.bom-item {
|
||||
display: grid;
|
||||
grid-template-columns: 120px 1fr 100px;
|
||||
gap: 1rem;
|
||||
padding: 0.5rem;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.bom-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.bom-item-code {
|
||||
font-family: monospace;
|
||||
color: #666;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.bom-item-name {
|
||||
color: #555;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.bom-item-qty {
|
||||
color: #888;
|
||||
font-size: 0.85rem;
|
||||
text-align: right;
|
||||
}
|
||||
</style>
|
||||
@ -20,24 +20,27 @@
|
||||
|
||||
<div v-else-if="formConfig" class="form-container">
|
||||
<Button @click="debugLog" label="Debug" severity="secondary" size="small" class="debug-button" />
|
||||
<template v-for="row in groupedFields" :key="`row-${row.rowIndex}`">
|
||||
<div class="form-row">
|
||||
<div
|
||||
v-for="field in row.fields"
|
||||
:key="field.name"
|
||||
:class="`form-column-${Math.min(Math.max(field.columns || 12, 1), 12)}`"
|
||||
>
|
||||
<div class="form-field">
|
||||
<!-- Field Label -->
|
||||
<label :for="field.name" class="field-label">
|
||||
{{ field.label }}
|
||||
<span v-if="field.required" class="required-indicator">*</span>
|
||||
</label>
|
||||
<div
|
||||
v-for="row in groupedFields"
|
||||
:key="`row-${row.rowIndex}`"
|
||||
class="form-row"
|
||||
>
|
||||
<div
|
||||
v-for="field in row.fields"
|
||||
:key="field.name"
|
||||
:class="`form-column-${Math.min(Math.max(field.columns || 12, 1), 12)}`"
|
||||
>
|
||||
<div class="form-field">
|
||||
<!-- Field Label -->
|
||||
<label :for="field.name" class="field-label">
|
||||
{{ field.label }}
|
||||
<span v-if="field.required" class="required-indicator">*</span>
|
||||
</label>
|
||||
|
||||
<!-- Help Text -->
|
||||
<small v-if="field.helpText" class="field-help-text">
|
||||
{{ field.helpText }}
|
||||
</small>
|
||||
<!-- Help Text -->
|
||||
<small v-if="field.helpText" class="field-help-text">
|
||||
{{ field.helpText }}
|
||||
</small>
|
||||
|
||||
<!-- Data/Text Field -->
|
||||
<template v-if="field.type === 'Data' || field.type === 'Text'">
|
||||
@ -211,10 +214,9 @@
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="error-container">
|
||||
@ -573,7 +575,7 @@ const loadDoctypeOptions = async () => {
|
||||
for (const field of fieldsWithDoctype) {
|
||||
try {
|
||||
// Use the new API method for fetching docs
|
||||
let docs = await Api.getQuotationItems();
|
||||
let docs = await Api.getQuotationItems(props.projectTemplate);
|
||||
|
||||
// Deduplicate by value field
|
||||
const valueField = field.doctypeValueField || 'name';
|
||||
|
||||
115
stripe-init-webhook.sh
Normal file
115
stripe-init-webhook.sh
Normal file
@ -0,0 +1,115 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Script to initialize and run Stripe CLI webhook forwarding
|
||||
# Usage: ./stripe-init-webhook.sh --site <site> --port <port>
|
||||
|
||||
set -e
|
||||
|
||||
# Default values
|
||||
SITE=""
|
||||
PORT="8000"
|
||||
|
||||
# Parse command line arguments
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case $1 in
|
||||
--site)
|
||||
SITE="$2"
|
||||
shift 2
|
||||
;;
|
||||
--port)
|
||||
PORT="$2"
|
||||
shift 2
|
||||
;;
|
||||
-h|--help)
|
||||
echo "Usage: $0 --site <site> [--port <port>]"
|
||||
echo ""
|
||||
echo "Options:"
|
||||
echo " --site Required. The site domain (e.g., erp.local)"
|
||||
echo " --port Optional. The port number (default: 8000)"
|
||||
echo ""
|
||||
echo "Example:"
|
||||
echo " $0 --site erp.local --port 8000"
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "Unknown option: $1"
|
||||
echo "Use --help for usage information"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Check if required flag is provided
|
||||
if [ -z "$SITE" ]; then
|
||||
echo "Error: --site flag is required"
|
||||
echo "Usage: $0 --site <site> [--port <port>]"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Checking Stripe CLI installation..."
|
||||
|
||||
# Check if Stripe CLI is installed
|
||||
if ! command -v stripe &> /dev/null; then
|
||||
echo "Stripe CLI is not installed."
|
||||
read -p "Would you like to install it now? (y/n) " -n 1 -r
|
||||
echo
|
||||
|
||||
if [[ $REPLY =~ ^[Yy]$ ]]; then
|
||||
echo "Installing Stripe CLI..."
|
||||
|
||||
# Add GPG key
|
||||
echo "Adding Stripe GPG key..."
|
||||
curl -s https://packages.stripe.dev/api/security/keypair/stripe-cli-gpg/public | gpg --dearmor | sudo tee /usr/share/keyrings/stripe.gpg > /dev/null
|
||||
|
||||
# Add repository
|
||||
echo "Adding Stripe repository..."
|
||||
echo "deb [signed-by=/usr/share/keyrings/stripe.gpg] https://packages.stripe.dev/stripe-cli-debian-local stable main" | sudo tee -a /etc/apt/sources.list.d/stripe.list
|
||||
|
||||
# Update and install
|
||||
echo "Updating package list..."
|
||||
sudo apt update
|
||||
|
||||
echo "Installing Stripe CLI..."
|
||||
sudo apt install stripe -y
|
||||
|
||||
echo "Stripe CLI installed successfully!"
|
||||
else
|
||||
echo "Installation cancelled. Exiting."
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
echo "Stripe CLI is already installed."
|
||||
fi
|
||||
|
||||
# Check if Stripe CLI is authenticated
|
||||
echo "Checking authentication status..."
|
||||
if ! stripe config --list &> /dev/null; then
|
||||
echo "Stripe CLI is not authenticated."
|
||||
echo "Please log in to your Stripe account..."
|
||||
stripe login --interactive
|
||||
else
|
||||
# Try to verify authentication by running a simple command
|
||||
if stripe config --list | grep -q "test_mode_api_key"; then
|
||||
echo "Stripe CLI is authenticated."
|
||||
else
|
||||
echo "Stripe CLI authentication may be invalid."
|
||||
echo "Please log in to your Stripe account..."
|
||||
stripe login --interactive
|
||||
fi
|
||||
fi
|
||||
|
||||
# Start Docker containers
|
||||
echo ""
|
||||
echo "Starting Docker containers..."
|
||||
docker compose -f docker-compose.local.yaml up -d
|
||||
|
||||
# Start listening for webhooks
|
||||
WEBHOOK_URL="http://${SITE}:${PORT}/api/method/custom_ui.api.public.payments.stripe_webhook"
|
||||
echo ""
|
||||
echo "Starting Stripe webhook listener..."
|
||||
echo "Forwarding to: $WEBHOOK_URL"
|
||||
echo ""
|
||||
echo "Press Ctrl+C to stop"
|
||||
echo ""
|
||||
|
||||
stripe listen --forward-to "$WEBHOOK_URL"
|
||||
150
stripe-local-init.sh
Executable file
150
stripe-local-init.sh
Executable file
@ -0,0 +1,150 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Script to initialize and run Stripe CLI webhook forwarding
|
||||
# Usage: ./stripe-init-webhook.sh --site <site> --port <port>
|
||||
|
||||
set -e
|
||||
|
||||
# Colors for output
|
||||
BLUE='\033[0;34m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
RED='\033[0;31m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
# Display banner
|
||||
echo ""
|
||||
echo -e "${BLUE}+-----------------------------------------------+${NC}"
|
||||
echo -e "${BLUE} ██████╗ ██╗ ██╗██╗██╗ ██████╗ ██╗ ██╗${NC}"
|
||||
echo -e "${BLUE} ██╔════╝ ██║ ██║██║██║ ██╔═══██╗██║ ██║${NC}"
|
||||
echo -e "${BLUE} ╚█████╗ ███████║██║██║ ██║ ██║███████║${NC}"
|
||||
echo -e "${BLUE} ╚═══██╗ ██╔══██║██║██║ ██║ ██║██╔══██║${NC}"
|
||||
echo -e "${BLUE} ██████╔╝ ██║ ██║██║███████╗╚██████╔╝██║ ██║${NC}"
|
||||
echo -e "${BLUE} ╚═════╝ ╚═╝ ╚═╝╚═╝╚══════╝ ╚═════╝ ╚═╝ ╚═╝${NC}"
|
||||
echo -e "${BLUE}+-----------------------------------------------+${NC}"
|
||||
echo -e "${YELLOW} 🚀 Automated Local Stripe Environment${NC}"
|
||||
echo -e "${YELLOW} 💼 For ERPNext Development${NC}"
|
||||
echo -e "${BLUE}+-----------------------------------------------+${NC}"
|
||||
echo ""
|
||||
|
||||
# Default values
|
||||
SITE=""
|
||||
PORT="8000"
|
||||
|
||||
# Parse command line arguments
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case $1 in
|
||||
--site)
|
||||
SITE="$2"
|
||||
shift 2
|
||||
;;
|
||||
--port)
|
||||
PORT="$2"
|
||||
shift 2
|
||||
;;
|
||||
-h|--help)
|
||||
echo "Usage: $0 --site <site> [--port <port>]"
|
||||
echo ""
|
||||
echo "Options:"
|
||||
echo " --site Required. The site domain (e.g., erp.local)"
|
||||
echo " --port Optional. The port number (default: 8000)"
|
||||
echo ""
|
||||
echo "Example:"
|
||||
echo " $0 --site erp.local --port 8000"
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "Unknown option: $1"
|
||||
echo "Use --help for usage information"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Check if required flag is provided
|
||||
if [ -z "$SITE" ]; then
|
||||
echo -e "${RED}❌ Error: --site flag is required${NC}"
|
||||
echo "Usage: $0 --site <site> [--port <port>]"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo -e "${BLUE}🔍 Checking Stripe CLI installation...${NC}"
|
||||
|
||||
# Check if Stripe CLI is installed
|
||||
if ! command -v stripe &> /dev/null; then
|
||||
echo -e "${YELLOW}⚠️ Stripe CLI is not installed.${NC}"
|
||||
read -p "Would you like to install it now? (y/n) " -n 1 -r
|
||||
echo
|
||||
|
||||
if [[ $REPLY =~ ^[Yy]$ ]]; then
|
||||
echo -e "${BLUE}📦 Installing Stripe CLI...${NC}"
|
||||
|
||||
# Add GPG key
|
||||
echo -e "${BLUE}🔑 Adding Stripe GPG key...${NC}"
|
||||
curl -s https://packages.stripe.dev/api/security/keypair/stripe-cli-gpg/public | gpg --dearmor | sudo tee /usr/share/keyrings/stripe.gpg > /dev/null
|
||||
|
||||
# Add repository
|
||||
echo -e "${BLUE}📚 Adding Stripe repository...${NC}"
|
||||
echo "deb [signed-by=/usr/share/keyrings/stripe.gpg] https://packages.stripe.dev/stripe-cli-debian-local stable main" | sudo tee -a /etc/apt/sources.list.d/stripe.list
|
||||
|
||||
# Update and install
|
||||
echo -e "${BLUE}🔄 Updating package list...${NC}"
|
||||
sudo apt update
|
||||
|
||||
echo -e "${BLUE}⬇️ Installing Stripe CLI...${NC}"
|
||||
sudo apt install stripe -y
|
||||
|
||||
echo -e "${GREEN}✅ Stripe CLI installed successfully!${NC}"
|
||||
else
|
||||
echo -e "${RED}❌ Installation cancelled. Exiting.${NC}"
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
echo -e "${GREEN}✅ Stripe CLI is already installed.${NC}"
|
||||
fi
|
||||
|
||||
# Check if Stripe CLI is authenticated
|
||||
echo -e "${BLUE}🔐 Checking authentication status...${NC}"
|
||||
if ! stripe config --list &> /dev/null; then
|
||||
echo -e "${YELLOW}⚠️ Stripe CLI is not authenticated.${NC}"
|
||||
echo -e "${BLUE}🔑 Please log in to your Stripe account...${NC}"
|
||||
stripe login --interactive
|
||||
else
|
||||
# Try to verify authentication by running a simple command
|
||||
if stripe config --list | grep -q "test_mode_api_key"; then
|
||||
echo -e "${GREEN}✅ Stripe CLI is authenticated.${NC}"
|
||||
else
|
||||
echo -e "${YELLOW}⚠️ Stripe CLI authentication may be invalid.${NC}"
|
||||
echo -e "${BLUE}🔑 Please log in to your Stripe account...${NC}"
|
||||
stripe login --interactive
|
||||
fi
|
||||
fi
|
||||
|
||||
# Start Docker containers
|
||||
echo ""
|
||||
echo -e "${BLUE}🐳 Starting Docker containers...${NC}"
|
||||
docker compose -f docker-compose.local.yaml up -d
|
||||
|
||||
# Cleanup function to run on exit
|
||||
cleanup() {
|
||||
echo ""
|
||||
echo -e "${YELLOW}🛑 Shutting down...${NC}"
|
||||
echo -e "${BLUE}🐳 Stopping Docker containers...${NC}"
|
||||
docker compose -f docker-compose.local.yaml down
|
||||
echo -e "${GREEN}✨ Cleanup complete. Goodbye!${NC}"
|
||||
exit 0
|
||||
}
|
||||
|
||||
# Trap Ctrl+C (SIGINT) and termination signals
|
||||
trap cleanup SIGINT SIGTERM EXIT
|
||||
|
||||
# Start listening for webhooks
|
||||
WEBHOOK_URL="http://${SITE}:${PORT}/api/method/custom_ui.api.public.payments.stripe_webhook"
|
||||
echo ""
|
||||
echo -e "${GREEN}🎧 Starting Stripe webhook listener...${NC}"
|
||||
echo -e "${BLUE}📡 Forwarding to: ${YELLOW}$WEBHOOK_URL${NC}"
|
||||
echo ""
|
||||
echo -e "${YELLOW}⌨️ Press Ctrl+C to stop${NC}"
|
||||
echo ""
|
||||
|
||||
stripe listen --forward-to "$WEBHOOK_URL"
|
||||
Loading…
x
Reference in New Issue
Block a user