change loyalty system

This commit is contained in:
Flatlogic Bot 2026-02-25 18:31:31 +00:00
parent 9eaaf40d0f
commit 2bb3386b5d
7 changed files with 116 additions and 64 deletions

View File

@ -72,11 +72,11 @@ include 'includes/header.php';
<h6 class="card-subtitle mb-2 text-muted text-uppercase small">Current Configuration</h6> <h6 class="card-subtitle mb-2 text-muted text-uppercase small">Current Configuration</h6>
<div class="d-flex justify-content-between align-items-center mt-3"> <div class="d-flex justify-content-between align-items-center mt-3">
<div> <div>
<span class="d-block text-muted small">Points per Order</span> <span class="d-block text-muted small">Points per Loyalty Product</span>
<span class="fs-4 fw-bold text-primary"><?= $settings['points_per_order'] ?> pts</span> <span class="fs-4 fw-bold text-primary"><?= $settings['points_per_order'] ?> pts</span>
</div> </div>
<div class="border-start ps-3"> <div class="border-start ps-3">
<span class="d-block text-muted small">Free Meal Threshold</span> <span class="d-block text-muted small">Free Product Threshold</span>
<span class="fs-4 fw-bold text-success"><?= $settings['points_for_free_meal'] ?> pts</span> <span class="fs-4 fw-bold text-success"><?= $settings['points_for_free_meal'] ?> pts</span>
</div> </div>
</div> </div>
@ -127,7 +127,7 @@ include 'includes/header.php';
<td> <td>
<?php if ($eligible): ?> <?php if ($eligible): ?>
<span class="badge bg-success-subtle text-success border border-success-subtle"> <span class="badge bg-success-subtle text-success border border-success-subtle">
<i class="bi bi-gift-fill me-1"></i> Eligible for Free Meal <i class="bi bi-gift-fill me-1"></i> Eligible for Free Product
</span> </span>
<?php else: ?> <?php else: ?>
<span class="text-muted small"> <span class="text-muted small">
@ -190,14 +190,14 @@ include 'includes/header.php';
</div> </div>
<div class="mb-3"> <div class="mb-3">
<label class="form-label">Points per Order</label> <label class="form-label">Points per Loyalty Product</label>
<input type="number" name="points_per_order" class="form-control" value="<?= $settings['points_per_order'] ?>" min="0" required> <input type="number" name="points_per_order" class="form-control" value="<?= $settings['points_per_order'] ?>" min="0" required>
<div class="form-text">Points awarded for every completed order.</div> <div class="form-text">Points awarded for every loyalty-participating product in the order.</div>
</div> </div>
<div class="mb-3"> <div class="mb-3">
<label class="form-label">Points for Free Meal</label> <label class="form-label">Points for Free Product</label>
<input type="number" name="points_for_free_meal" class="form-control" value="<?= $settings['points_for_free_meal'] ?>" min="0" required> <input type="number" name="points_for_free_meal" class="form-control" value="<?= $settings['points_for_free_meal'] ?>" min="0" required>
<div class="form-text">Threshold points required to redeem a free meal.</div> <div class="form-text">Threshold points required to redeem a free product.</div>
</div> </div>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">

View File

@ -47,16 +47,16 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['action'])) {
if (!has_permission('products_edit')) { if (!has_permission('products_edit')) {
$message = '<div class="alert alert-danger">Access Denied: You do not have permission to edit products.</div>'; $message = '<div class="alert alert-danger">Access Denied: You do not have permission to edit products.</div>';
} else { } else {
$stmt = $pdo->prepare("UPDATE products SET name = ?, name_ar = ?, category_id = ?, price = ?, cost_price = ?, stock_quantity = ?, description = ?, image_url = ?, promo_discount_percent = ?, promo_date_from = ?, promo_date_to = ? WHERE id = ?"); $stmt = $pdo->prepare("UPDATE products SET name = ?, name_ar = ?, category_id = ?, price = ?, cost_price = ?, stock_quantity = ?, description = ?, image_url = ?, promo_discount_percent = ?, promo_date_from = ?, promo_date_to = ?, is_loyalty = ? WHERE id = ?");
$stmt->execute([$name, $name_ar, $category_id, $price, $cost_price, $stock_quantity, $description, $image_url, $promo_discount_percent, $promo_date_from, $promo_date_to, $id]); $stmt->execute([$name, $name_ar, $category_id, $price, $cost_price, $stock_quantity, $description, $image_url, $promo_discount_percent, $promo_date_from, $promo_date_to, $is_loyalty, $id]);
$message = '<div class="alert alert-success">Product updated successfully!</div>'; $message = '<div class="alert alert-success">Product updated successfully!</div>';
} }
} elseif ($action === 'add_product') { } elseif ($action === 'add_product') {
if (!has_permission('products_add')) { if (!has_permission('products_add')) {
$message = '<div class="alert alert-danger">Access Denied: You do not have permission to add products.</div>'; $message = '<div class="alert alert-danger">Access Denied: You do not have permission to add products.</div>';
} else { } else {
$stmt = $pdo->prepare("INSERT INTO products (name, name_ar, category_id, price, cost_price, stock_quantity, description, image_url, promo_discount_percent, promo_date_from, promo_date_to) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"); $stmt = $pdo->prepare("INSERT INTO products (name, name_ar, category_id, price, cost_price, stock_quantity, description, image_url, promo_discount_percent, promo_date_from, promo_date_to, is_loyalty) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)");
$stmt->execute([$name, $name_ar, $category_id, $price, $cost_price, $stock_quantity, $description, $image_url, $promo_discount_percent, $promo_date_from, $promo_date_to]); $stmt->execute([$name, $name_ar, $category_id, $price, $cost_price, $stock_quantity, $description, $image_url, $promo_discount_percent, $promo_date_from, $promo_date_to, $is_loyalty]);
$message = '<div class="alert alert-success">Product created successfully!</div>'; $message = '<div class="alert alert-success">Product created successfully!</div>';
} }
} }
@ -339,6 +339,15 @@ include 'includes/header.php';
</div> </div>
<div class="col-12 mt-4"> <div class="col-12 mt-4">
<div class="col-12 mt-2">
<div class="form-check form-switch p-3 bg-light rounded-3 border d-flex justify-content-between align-items-center">
<div class="ms-1">
<label class="form-check-label fw-bold text-dark mb-0" for="productIsLoyalty">Loyalty System Participation</label>
<div class="small text-muted">Include this product in earning and redeeming loyalty points</div>
</div>
<input class="form-check-input ms-0" type="checkbox" name="is_loyalty" id="productIsLoyalty" style="width: 2.5rem; height: 1.25rem;">
</div>
</div>
<h6 class="fw-bold border-bottom pb-2 mb-3"><i class="bi bi-percent me-1"></i> Promotion Settings</h6> <h6 class="fw-bold border-bottom pb-2 mb-3"><i class="bi bi-percent me-1"></i> Promotion Settings</h6>
<div class="row g-3"> <div class="row g-3">
<div class="col-md-4"> <div class="col-md-4">

View File

@ -74,7 +74,6 @@ try {
$settingsStmt = $pdo->query("SELECT is_enabled, points_per_order, points_for_free_meal FROM loyalty_settings WHERE id = 1"); $settingsStmt = $pdo->query("SELECT is_enabled, points_per_order, points_for_free_meal FROM loyalty_settings WHERE id = 1");
$loyaltySettings = $settingsStmt->fetch(PDO::FETCH_ASSOC); $loyaltySettings = $settingsStmt->fetch(PDO::FETCH_ASSOC);
$loyalty_enabled = $loyaltySettings ? (bool)$loyaltySettings['is_enabled'] : true; $loyalty_enabled = $loyaltySettings ? (bool)$loyaltySettings['is_enabled'] : true;
$points_per_order = $loyaltySettings ? intval($loyaltySettings['points_per_order']) : 10;
$points_threshold = $loyaltySettings ? intval($loyaltySettings['points_for_free_meal']) : 70; $points_threshold = $loyaltySettings ? intval($loyaltySettings['points_for_free_meal']) : 70;
$current_points = 0; $current_points = 0;
@ -96,58 +95,21 @@ try {
} }
} }
// Loyalty Redemption Logic // Loyalty Redemption Logic (initial check)
$redeem_loyalty = !empty($data['redeem_loyalty']) && $loyalty_enabled; $redeem_loyalty = !empty($data['redeem_loyalty']) && $loyalty_enabled;
if ($redeem_loyalty && $customer_id) { if ($redeem_loyalty && $customer_id) {
if ($current_points < $points_threshold) { if ($current_points < $points_threshold) {
throw new Exception("Insufficient loyalty points for redemption."); throw new Exception("Insufficient loyalty points for redemption.");
} }
// Deduct points
$deductStmt = $pdo->prepare("UPDATE customers SET points = points - ? WHERE id = ?");
$pdo->prepare("UPDATE customers SET loyalty_redemptions_count = loyalty_redemptions_count + 1 WHERE id = ?")->execute([$customer_id]);
$deductStmt->execute([$points_threshold, $customer_id]);
$points_deducted = $points_threshold;
// Record Loyalty History (Deduction)
$historyStmt = $pdo->prepare("INSERT INTO loyalty_points_history (customer_id, points_change, reason) VALUES (?, ?, 'Redeemed Free Meal')");
$historyStmt->execute([$customer_id, -$points_threshold]);
$redeem_history_id = $pdo->lastInsertId();
// --- OVERRIDE PAYMENT TYPE ---
$ptStmt = $pdo->prepare("SELECT id FROM payment_types WHERE name = 'Loyalty Redeem' LIMIT 1");
$ptStmt->execute();
$loyaltyPt = $ptStmt->fetchColumn();
if ($loyaltyPt) {
$data['payment_type_id'] = $loyaltyPt;
}
} }
// Award Points (ONLY IF NOT REDEEMING)
if ($customer_id && $loyalty_enabled && !$redeem_loyalty) {
$awardStmt = $pdo->prepare("UPDATE customers SET points = points + ? WHERE id = ?");
$awardStmt->execute([$points_per_order, $customer_id]);
$points_awarded = $points_per_order;
// Record Loyalty History (Award)
$historyStmt = $pdo->prepare("INSERT INTO loyalty_points_history (customer_id, points_change, reason) VALUES (?, ?, 'Earned from Order')");
$historyStmt->execute([$customer_id, $points_per_order]);
$award_history_id = $pdo->lastInsertId();
}
// User/Payment info
$user = get_logged_user();
$user_id = $user ? $user['id'] : null;
$payment_type_id = !empty($data['payment_type_id']) ? intval($data['payment_type_id']) : null;
// VAT vs Discount: We repurpose the 'discount' column in the database to store VAT value.
// If it's a positive value, it's VAT. If negative, it acts as a discount (loyalty).
$vat = isset($data['vat']) ? floatval($data['vat']) : 0.00;
// Total amount will be recalculated on server to be safe // Total amount will be recalculated on server to be safe
$calculated_total = 0; $calculated_total = 0;
// First, process items to calculate real total // First, process items to calculate real total and handle loyalty
$processed_items = []; $processed_items = [];
$loyalty_items_indices = [];
if (!empty($data['items']) && is_array($data['items'])) { if (!empty($data['items']) && is_array($data['items'])) {
foreach ($data['items'] as $item) { foreach ($data['items'] as $item) {
$pid = $item['product_id'] ?? ($item['id'] ?? null); $pid = $item['product_id'] ?? ($item['id'] ?? null);
@ -176,8 +138,9 @@ try {
} }
} }
$item_total = $unit_price * $qty; if ($product['is_loyalty']) {
$calculated_total += $item_total; $loyalty_items_indices[] = count($processed_items);
}
$processed_items[] = [ $processed_items[] = [
'product_id' => $pid, 'product_id' => $pid,
@ -185,13 +148,91 @@ try {
'variant_id' => $vid, 'variant_id' => $vid,
'variant_name' => $variant_name, 'variant_name' => $variant_name,
'quantity' => $qty, 'quantity' => $qty,
'unit_price' => $unit_price 'unit_price' => $unit_price,
'is_loyalty' => (bool)$product['is_loyalty']
]; ];
} }
} }
// Handle Loyalty Redemption (Make one loyalty item free)
if ($redeem_loyalty && $customer_id) {
if (empty($loyalty_items_indices)) {
throw new Exception("No loyalty-eligible products in the order to redeem.");
}
// Find the most expensive loyalty item
$max_price = -1;
$max_index = -1;
foreach ($loyalty_items_indices as $idx) {
if ($processed_items[$idx]['unit_price'] > $max_price) {
$max_price = $processed_items[$idx]['unit_price'];
$max_index = $idx;
}
}
if ($max_index !== -1) {
// Deduct points from customer
$deductStmt = $pdo->prepare("UPDATE customers SET points = points - ? WHERE id = ?");
$pdo->prepare("UPDATE customers SET loyalty_redemptions_count = loyalty_redemptions_count + 1 WHERE id = ?")->execute([$customer_id]);
$deductStmt->execute([$points_threshold, $customer_id]);
$points_deducted = $points_threshold;
// Record Loyalty History (Deduction)
$historyStmt = $pdo->prepare("INSERT INTO loyalty_points_history (customer_id, points_change, reason) VALUES (?, ?, 'Redeemed Free Product')");
$historyStmt->execute([$customer_id, -$points_threshold]);
$redeem_history_id = $pdo->lastInsertId();
// Make ONE unit of the most expensive loyalty product free
if ($processed_items[$max_index]['quantity'] > 1) {
$processed_items[$max_index]['quantity']--;
$free_item = $processed_items[$max_index];
$free_item['quantity'] = 1;
$free_item['unit_price'] = 0;
$processed_items[] = $free_item;
} else {
$processed_items[$max_index]['unit_price'] = 0;
}
// --- OVERRIDE PAYMENT TYPE ---
$ptStmt = $pdo->prepare("SELECT id FROM payment_types WHERE name = 'Loyalty Redeem' LIMIT 1");
$ptStmt->execute();
$loyaltyPt = $ptStmt->fetchColumn();
if ($loyaltyPt) {
$data['payment_type_id'] = $loyaltyPt;
}
}
}
// Recalculate Subtotal and Earned Points
foreach ($processed_items as $pi) {
$calculated_total += $pi['unit_price'] * $pi['quantity'];
// Award points for PAID loyalty items (10 points each)
if ($pi['is_loyalty'] && $pi['unit_price'] > 0) {
$points_awarded += $pi['quantity'] * 10;
}
}
// Award Points (ONLY IF NOT REDEEMING or we can award for remaining items)
// User said "calculate these products multiply 10 points and will be calculated to the customer"
// Usually points are earned on the whole order, but here it's specifically per product.
if ($customer_id && $loyalty_enabled && $points_awarded > 0) {
$awardStmt = $pdo->prepare("UPDATE customers SET points = points + ? WHERE id = ?");
$awardStmt->execute([$points_awarded, $customer_id]);
// Record Loyalty History (Award)
$historyStmt = $pdo->prepare("INSERT INTO loyalty_points_history (customer_id, points_change, reason) VALUES (?, ?, 'Earned from Products')");
$historyStmt->execute([$customer_id, $points_awarded]);
$award_history_id = $pdo->lastInsertId();
}
$vat = isset($data['vat']) ? floatval($data['vat']) : 0.00;
$final_total = max(0, $calculated_total + $vat); $final_total = max(0, $calculated_total + $vat);
// User/Payment info
$user = get_logged_user();
$user_id = $user ? $user['id'] : null;
$payment_type_id = !empty($data['payment_type_id']) ? intval($data['payment_type_id']) : null;
// Commission Calculation // Commission Calculation
$commission_amount = 0; $commission_amount = 0;
$companySettings = get_company_settings(); $companySettings = get_company_settings();
@ -200,7 +241,6 @@ try {
$userStmt->execute([$user_id]); $userStmt->execute([$user_id]);
$commission_rate = (float)$userStmt->fetchColumn(); $commission_rate = (float)$userStmt->fetchColumn();
if ($commission_rate > 0) { if ($commission_rate > 0) {
// Commission is usually calculated on the subtotal (before tax/VAT)
$commission_amount = $calculated_total * ($commission_rate / 100); $commission_amount = $calculated_total * ($commission_rate / 100);
} }
} }
@ -220,7 +260,6 @@ try {
} }
if ($is_update) { if ($is_update) {
// UPDATE Existing Order
$stmt = $pdo->prepare("UPDATE orders SET $stmt = $pdo->prepare("UPDATE orders SET
outlet_id = ?, table_id = ?, table_number = ?, order_type = ?, outlet_id = ?, table_id = ?, table_number = ?, order_type = ?,
customer_id = ?, customer_name = ?, customer_phone = ?, customer_id = ?, customer_name = ?, customer_phone = ?,
@ -237,7 +276,6 @@ try {
$delStmt = $pdo->prepare("DELETE FROM order_items WHERE order_id = ?"); $delStmt = $pdo->prepare("DELETE FROM order_items WHERE order_id = ?");
$delStmt->execute([$order_id]); $delStmt->execute([$order_id]);
} else { } else {
// INSERT New Order
$stmt = $pdo->prepare("INSERT INTO orders (outlet_id, table_id, table_number, order_type, customer_id, customer_name, customer_phone, payment_type_id, total_amount, discount, user_id, commission_amount, status) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending')"); $stmt = $pdo->prepare("INSERT INTO orders (outlet_id, table_id, table_number, order_type, customer_id, customer_name, customer_phone, payment_type_id, total_amount, discount, user_id, commission_amount, status) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending')");
$stmt->execute([$outlet_id, $table_id, $table_number, $order_type, $customer_id, $customer_name, $customer_phone, $payment_type_id, $final_total, $vat, $user_id, $commission_amount]); $stmt->execute([$outlet_id, $table_id, $table_number, $order_type, $customer_id, $customer_name, $customer_phone, $payment_type_id, $final_total, $vat, $user_id, $commission_amount]);
$order_id = $pdo->lastInsertId(); $order_id = $pdo->lastInsertId();

View File

@ -266,7 +266,8 @@ document.addEventListener('DOMContentLoaded', () => {
loyaltySection.classList.remove('d-none'); loyaltySection.classList.remove('d-none');
if (loyaltyPointsDisplay) loyaltyPointsDisplay.textContent = cust.points + ' pts'; if (loyaltyPointsDisplay) loyaltyPointsDisplay.textContent = cust.points + ' pts';
if (redeemLoyaltyBtn) { if (redeemLoyaltyBtn) {
redeemLoyaltyBtn.disabled = !cust.eligible_for_free_meal; const hasLoyaltyItem = cart.some(item => item.is_loyalty);
redeemLoyaltyBtn.disabled = !cust.eligible_for_free_meal || !hasLoyaltyItem;
if (loyaltyMessage) { if (loyaltyMessage) {
if (cust.eligible_for_free_meal) loyaltyMessage.innerHTML = '<span class="text-success fw-bold">Eligible for Free Meal!</span>'; if (cust.eligible_for_free_meal) loyaltyMessage.innerHTML = '<span class="text-success fw-bold">Eligible for Free Meal!</span>';
else loyaltyMessage.textContent = `${cust.points_needed || 0} pts away from a free meal.`; else loyaltyMessage.textContent = `${cust.points_needed || 0} pts away from a free meal.`;
@ -476,7 +477,7 @@ document.addEventListener('DOMContentLoaded', () => {
addToCart({ addToCart({
id: product.id, name: product.name, name_ar: product.name_ar || "", id: product.id, name: product.name, name_ar: product.name_ar || "",
price: parseFloat(product.price), base_price: parseFloat(product.price), price: parseFloat(product.price), base_price: parseFloat(product.price),
hasVariants: false, quantity: 1, variant_id: null, variant_name: null hasVariants: false, quantity: 1, variant_id: null, variant_name: null, is_loyalty: parseInt(product.is_loyalty) === 1
}); });
} }
}; };
@ -498,7 +499,7 @@ document.addEventListener('DOMContentLoaded', () => {
addToCart({ addToCart({
id: product.id, name: product.name, name_ar: product.name_ar || "", id: product.id, name: product.name, name_ar: product.name_ar || "",
price: finalPrice, base_price: parseFloat(product.price), price: finalPrice, base_price: parseFloat(product.price),
hasVariants: true, quantity: 1, variant_id: v.id, variant_name: v.name hasVariants: true, quantity: 1, variant_id: v.id, variant_name: v.name, is_loyalty: parseInt(product.is_loyalty) === 1
}); });
variantSelectionModal.hide(); variantSelectionModal.hide();
}; };
@ -539,6 +540,7 @@ document.addEventListener('DOMContentLoaded', () => {
function updateCart() { function updateCart() {
if (!cartItemsContainer) return; if (!cartItemsContainer) return;
if (cart.length === 0) { if (cart.length === 0) {
if (redeemLoyaltyBtn) redeemLoyaltyBtn.disabled = true;
cartItemsContainer.innerHTML = `<div class="text-center text-muted mt-5"><i class="bi bi-basket3 fs-1 text-light"></i><p class="mt-2">${_t('cart_empty')}</p></div>`; cartItemsContainer.innerHTML = `<div class="text-center text-muted mt-5"><i class="bi bi-basket3 fs-1 text-light"></i><p class="mt-2">${_t('cart_empty')}</p></div>`;
if (cartSubtotal) cartSubtotal.innerText = formatCurrency(0); if (cartSubtotal) cartSubtotal.innerText = formatCurrency(0);
if (cartTotalPrice) cartTotalPrice.innerText = formatCurrency(0); if (cartTotalPrice) cartTotalPrice.innerText = formatCurrency(0);

View File

@ -0,0 +1,2 @@
-- Add is_loyalty column to products table
ALTER TABLE products ADD COLUMN is_loyalty TINYINT(1) DEFAULT 0;

View File

@ -18,6 +18,7 @@ CREATE TABLE IF NOT EXISTS products (
description TEXT, description TEXT,
price DECIMAL(10, 2) NOT NULL, price DECIMAL(10, 2) NOT NULL,
image_url VARCHAR(255), image_url VARCHAR(255),
is_loyalty TINYINT(1) DEFAULT 0,
FOREIGN KEY (category_id) REFERENCES categories(id) FOREIGN KEY (category_id) REFERENCES categories(id)
); );

View File

@ -310,7 +310,7 @@ if (!$loyalty_settings) {
</div> </div>
</div> </div>
<div id="loyalty-message" class="small text-muted mb-2" style="font-size: 0.75rem;"></div> <div id="loyalty-message" class="small text-muted mb-2" style="font-size: 0.75rem;"></div>
<button type="button" class="btn btn-primary btn-sm w-100 fw-bold rounded-3" id="redeem-loyalty-btn" disabled>Redeem Free Meal</button> <button type="button" class="btn btn-primary btn-sm w-100 fw-bold rounded-3" id="redeem-loyalty-btn" disabled>Redeem Free Product</button>
</div> </div>
</div> </div>