Fix loyalty system: load tiers from DB, award points on payment

- LoyaltyProgram now loads tiers from loyalty_tiers DB table in constructor
  with fallback to hardcoded defaults if table is empty
- awardPoints() accepts order_id param with duplicate-prevention check so
  points cannot be double-awarded for the same order
- Inserts balance_after into loyalty_transactions for accurate history
- payment-status.php: award points after Stripe checkout session or
  PaymentIntent confirmed as paid
- create-checkout-session.php: award points in demo mode payment path

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-14 20:58:37 +00:00
parent b6d0319be7
commit f89362528a
3 changed files with 95 additions and 73 deletions
+11 -1
View File
@@ -6,6 +6,7 @@
require_once __DIR__ . '/../includes/functions.php'; require_once __DIR__ . '/../includes/functions.php';
require_once __DIR__ . '/../includes/stripe.php'; require_once __DIR__ . '/../includes/stripe.php';
require_once __DIR__ . '/../includes/loyalty.php';
header('Content-Type: application/json'); header('Content-Type: application/json');
@@ -52,7 +53,16 @@ if (!isStripeConfigured()) {
'order_id = :id', 'order_id = :id',
['id' => $orderId] ['id' => $orderId]
); );
if (!empty($order['customer_id'])) {
loyalty()->awardPoints(
$order['customer_id'],
(float) $order['total'],
'Order #' . $order['order_number'],
$orderId
);
}
jsonResponse([ jsonResponse([
'demo_mode' => true, 'demo_mode' => true,
'message' => 'Payment simulated (Stripe not configured)', 'message' => 'Payment simulated (Stripe not configured)',
+23 -2
View File
@@ -6,6 +6,7 @@
require_once __DIR__ . '/../includes/functions.php'; require_once __DIR__ . '/../includes/functions.php';
require_once __DIR__ . '/../includes/stripe.php'; require_once __DIR__ . '/../includes/stripe.php';
require_once __DIR__ . '/../includes/loyalty.php';
header('Content-Type: application/json'); header('Content-Type: application/json');
@@ -75,7 +76,17 @@ try {
'order_id = :id', 'order_id = :id',
['id' => $order['order_id']] ['id' => $order['order_id']]
); );
// Award loyalty points
if (!empty($order['customer_id'])) {
loyalty()->awardPoints(
$order['customer_id'],
(float) $order['total'],
'Order #' . $order['order_number'],
$order['order_id']
);
}
jsonResponse([ jsonResponse([
'status' => 'complete', 'status' => 'complete',
'payment_status' => 'paid', 'payment_status' => 'paid',
@@ -104,7 +115,17 @@ try {
'order_id = :id', 'order_id = :id',
['id' => $order['order_id']] ['id' => $order['order_id']]
); );
// Award loyalty points
if (!empty($order['customer_id'])) {
loyalty()->awardPoints(
$order['customer_id'],
(float) $order['total'],
'Order #' . $order['order_number'],
$order['order_id']
);
}
jsonResponse([ jsonResponse([
'status' => 'complete', 'status' => 'complete',
'payment_status' => 'paid', 'payment_status' => 'paid',
+61 -70
View File
@@ -6,64 +6,37 @@
*/ */
class LoyaltyProgram { class LoyaltyProgram {
// Loyalty tier definitions private array $tiers = [];
private array $tiers = [
'bronze' => [ public function __construct() {
'name' => 'Bronze Bean', $this->loadTiers();
'min_points' => 0, }
'multiplier' => 1.0,
'benefits' => [ private function loadTiers(): void {
'Earn 1 point per $1 spent', $rows = db()->fetchAll("SELECT * FROM loyalty_tiers ORDER BY min_points ASC");
'Birthday reward', foreach ($rows as $row) {
'Member-only offers' $key = strtolower($row['name']);
], $benefits = json_decode($row['benefits'] ?? '[]', true) ?? [];
'color' => '#CD7F32', $this->tiers[$key] = [
'icon' => 'fa-coffee' 'name' => $row['name'],
], 'min_points' => (int) $row['min_points'],
'silver' => [ 'multiplier' => (float) $row['multiplier'],
'name' => 'Silver Roast', 'benefits' => $benefits,
'min_points' => 500, 'color' => $row['color'] ?? '#888',
'multiplier' => 1.25, 'icon' => 'fa-coffee',
'benefits' => [ ];
'Earn 1.25 points per $1 spent', }
'Free shipping on orders $25+', // Fallback if DB is empty
'Early access to new products', if (empty($this->tiers)) {
'Double points weekends' $this->tiers = [
], 'bronze' => ['name'=>'Bronze', 'min_points'=>0, 'multiplier'=>1.0, 'benefits'=>[], 'color'=>'#CD7F32', 'icon'=>'fa-coffee'],
'color' => '#C0C0C0', 'silver' => ['name'=>'Silver', 'min_points'=>500, 'multiplier'=>1.5, 'benefits'=>[], 'color'=>'#C0C0C0', 'icon'=>'fa-mug-hot'],
'icon' => 'fa-mug-hot' 'gold' => ['name'=>'Gold', 'min_points'=>1500, 'multiplier'=>2.0, 'benefits'=>[], 'color'=>'#FFD700', 'icon'=>'fa-crown'],
], 'platinum' => ['name'=>'Platinum', 'min_points'=>3000, 'multiplier'=>3.0, 'benefits'=>[], 'color'=>'#E5E4E2', 'icon'=>'fa-gem'],
'gold' => [ ];
'name' => 'Gold Blend', }
'min_points' => 1500, }
'multiplier' => 1.5,
'benefits' => [
'Earn 1.5 points per $1 spent',
'Free shipping on all orders',
'Exclusive Gold-only products',
'Priority customer support',
'Quarterly free coffee sample'
],
'color' => '#FFD700',
'icon' => 'fa-crown'
],
'platinum' => [
'name' => 'Platinum Reserve',
'min_points' => 5000,
'multiplier' => 2.0,
'benefits' => [
'Earn 2 points per $1 spent',
'Free express shipping',
'VIP early access to everything',
'Annual free bag of premium coffee',
'Dedicated account manager',
'Exclusive tasting events'
],
'color' => '#E5E4E2',
'icon' => 'fa-gem'
]
];
// Points redemption rates // Points redemption rates
private float $pointsToValue = 0.01; // 1 point = $0.01 (100 points = $1) private float $pointsToValue = 0.01; // 1 point = $0.01 (100 points = $1)
@@ -153,33 +126,51 @@ class LoyaltyProgram {
/** /**
* Award points for a purchase * Award points for a purchase
*/ */
public function awardPoints(string $customerId, float $amount, string $description = 'Purchase'): array { public function awardPoints(string $customerId, float $amount, string $description = 'Purchase', string $orderId = ''): array {
// Prevent duplicate awards for the same order
if ($orderId) {
$already = db()->fetch(
"SELECT id FROM loyalty_transactions WHERE order_id = :oid AND type = 'earn' LIMIT 1",
['oid' => $orderId]
);
if ($already) {
return ['points_earned' => 0, 'already_awarded' => true];
}
}
$customerTier = $this->getCustomerTier($customerId); $customerTier = $this->getCustomerTier($customerId);
$multiplier = $customerTier['info']['multiplier']; $multiplier = $customerTier['info']['multiplier'];
// Calculate points (base: 1 point per dollar) // Calculate points (base: 1 point per dollar)
$basePoints = floor($amount); $basePoints = (int) floor($amount);
$bonusPoints = floor($basePoints * ($multiplier - 1)); $bonusPoints = (int) floor($basePoints * ($multiplier - 1));
$totalPoints = $basePoints + $bonusPoints; $totalPoints = $basePoints + $bonusPoints;
// Update customer points // Update customer points
db()->query( db()->query(
"UPDATE customers SET "UPDATE customers SET
reward_points = reward_points + :points, reward_points = reward_points + :points,
lifetime_points = COALESCE(lifetime_points, 0) + :points, lifetime_points = COALESCE(lifetime_points, 0) + :points,
updated_at = NOW() updated_at = NOW()
WHERE customer_id = :id", WHERE customer_id = :id",
['points' => $totalPoints, 'id' => $customerId] ['points' => $totalPoints, 'id' => $customerId]
); );
$newBalance = db()->fetch(
"SELECT reward_points FROM customers WHERE customer_id = :id",
['id' => $customerId]
)['reward_points'] ?? $totalPoints;
// Log the transaction // Log the transaction
db()->insert('loyalty_transactions', [ db()->insert('loyalty_transactions', [
'transaction_id' => generateId('lt_'), 'transaction_id' => generateId('lt_'),
'customer_id' => $customerId, 'customer_id' => $customerId,
'points' => $totalPoints, 'points' => $totalPoints,
'type' => 'earn', 'balance_after' => $newBalance,
'description' => $description . ($bonusPoints > 0 ? " (+{$bonusPoints} bonus)" : ''), 'type' => 'earn',
'reference_amount' => $amount 'description' => $description . ($bonusPoints > 0 ? " (+{$bonusPoints} bonus)" : ''),
'reference_amount' => $amount,
'order_id' => $orderId ?: null,
]); ]);
// Check for tier upgrade // Check for tier upgrade