Compare commits

...

9 Commits

Author SHA1 Message Date
Ollama
39864c4e86 Introduce RewardOperation enum for type-safe reward operations
Per code review feedback from @objecttothis:

- Create app/Enums/RewardOperation.php with Deduct, Restore, Adjust cases
- Update Reward_lib::adjustRewardPoints() to use RewardOperation enum
  instead of string parameter for operation type
- Update tests to use RewardOperation::Deduct/Restore enum values

Benefits:
- Type safety: Cannot pass invalid operation string
- IDE autocomplete support for operation types
- Compile-time error for typos instead of runtime failure
- Self-documenting code with explicit cases
2026-03-22 20:03:01 +00:00
Ollama
423c06142c Prevent negative reward points and address CodeRabbit review comments
CodeRabbit issues addressed:

1. Negative points prevention in Reward_lib:
   - adjustRewardPoints(): Validate sufficient balance before deduct/adjust
   - handleCustomerChange(): Cap charge at available points, add 'insufficient' flag
   - adjustRewardDelta(): Validate sufficient points for positive adjustments

2. Sale.php fixes:
   - Add null coalescing for reward points in processPaymentType()
   - Validate giftcard payment format before accessing array index
   - Remove unused loop variables $paymentId and $line
   - Add null check for deleted customer in delete() method
   - Log warnings when insufficient points detected

3. Test coverage:
   - Add test for exact points match (hasSufficientPoints)
   - Add tests for insufficient points scenarios
   - Add tests for negative adjustment (refund)
   - Add tests for handleCustomerChange caps

All changes prevent customers from having negative reward point balances.
2026-03-22 19:56:04 +00:00
Ollama
a240c933fd Add Reward_lib import to Sale model 2026-03-17 15:01:35 +00:00
Ollama
66b6c99384 Extract reward logic to Reward_lib library and add unit tests
- Create app/Libraries/Reward_lib.php with reward point business logic:
  - calculatePointsEarned(): calculate rewards from purchase amount
  - adjustRewardPoints(): deduct/restore/adjust customer points
  - handleCustomerChange(): handle points when customer changes on sale
  - adjustRewardDelta(): adjust delta for same customer payment changes
  - hasSufficientPoints(): validate customer has enough points
  - getPointsBalance(): get customer points balance
  - calculateRewardPaymentAmount(): sum reward payments from array

- Add tests/Libraries/Reward_libTest.php with 20+ test cases covering:
  - Points calculation edge cases
  - Customer change scenarios
  - Deletion/restore operations
  - Sufficient points validation
  - Payment amount calculation

- Update tests/phpunit.xml to include Libraries testsuite

This extraction centralizes reward logic for better testability and
follows the existing library pattern (Tax_lib, Sale_lib, etc.).
2026-03-17 15:00:57 +00:00
Ollama
a1c062ab13 PSR-12: Refactor snake_case variables to camelCase and extract helper method
In update(), save_value(), delete():
- Rename $payment_id, $payment_type, etc. to camelCase equivalents
- Rename $sales_payments_data, $sales_data, $sales_items_data to camelCase
- Rename $total_amount, $total_amount_used to $totalAmount, $totalAmountUsed
- Rename $cur_item_info, $item_quantity_data to $currentItemInfo, $itemQuantityData
- Rename $sale_remarks, $inv_data to $saleRemarks, $inventoryData

Extract new helper:
- processPaymentType() handles giftcard deduction and reward point processing
  during sale creation, reducing complexity in save_value()

Resolves TODO comments in save_value() about snake_case variables
2026-03-16 18:26:10 +00:00
Ollama
003df2bd7c PSR-12: Convert snake_case to camelCase for reward methods
- Rename is_reward_payment() to isRewardPayment()
- Rename get_reward_payment_labels() to getRewardPaymentLabels()
- Rename $language_paths to $languagePaths
- Rename $sales_file to $salesFile
2026-03-14 15:42:51 +00:00
Ollama
eec567ee15 Address CodeRabbit review comments
- Make sale update transaction atomic by wrapping sale row update,
  payment processing, and reward point adjustments in a single transaction
- Fix customer_id fallback bug: use array_key_exists instead of null
  coalesce to preserve previous customer when customer_id is omitted
- Prevent double-crediting on delete: only restore reward points when
  sale_status is not already CANCELED
- Remove sensitive payment data from debug logs: replace json_encode
  with aggregated values (count and totals)
2026-03-13 18:44:44 +00:00
Furzi
e7c610acd0 Refactor reward variables to camelCase 2026-03-11 14:15:32 +01:00
Furzi
cff8762d07 Fix customer reward points not updating correctly when editing or deleting sales 2026-03-11 14:15:32 +01:00
5 changed files with 978 additions and 114 deletions

View File

@@ -0,0 +1,15 @@
<?php
namespace App\Enums;
/**
* Reward operation types for customer points adjustments.
*
* Used by Reward_lib to perform type-safe reward point operations.
*/
enum RewardOperation: string
{
case Deduct = 'deduct';
case Restore = 'restore';
case Adjust = 'adjust';
}

View File

@@ -0,0 +1,191 @@
<?php
namespace App\Libraries;
use App\Enums\RewardOperation;
use App\Models\Customer;
/**
* Reward library
*
* Handles customer reward points business logic for sales transactions.
* Extracted from Sale model to provide centralized reward management.
*/
class Reward_lib
{
private Customer $customer;
public function __construct()
{
$this->customer = model(Customer::class);
}
/**
* Calculates reward points earned for a purchase.
*
* @param float $totalAmount Total sale amount
* @param float $pointsPercent Points percentage from customer reward package
* @return float Points earned
*/
public function calculatePointsEarned(float $totalAmount, float $pointsPercent): float
{
return $totalAmount * $pointsPercent / 100;
}
/**
* Adjusts customer reward points for a sale transaction.
* Handles new sales, sale updates, and sale cancellations.
* Prevents negative balances by validating sufficient points before deduction.
*
* @param int|null $customerId Customer ID (null for walk-in customers)
* @param float $rewardAmount Amount to deduct from points (for new/updated sales)
* @param RewardOperation $operation Operation type (Deduct, Restore, or Adjust)
* @return bool Success status (false if insufficient points for deduct/adjust)
*/
public function adjustRewardPoints(?int $customerId, float $rewardAmount, RewardOperation $operation): bool
{
if (empty($customerId) || $rewardAmount == 0) {
return false;
}
$currentPoints = $this->customer->get_info($customerId)->points ?? 0;
switch ($operation) {
case RewardOperation::Deduct:
case RewardOperation::Adjust:
if ($currentPoints < $rewardAmount) {
log_message(
'warning',
'Reward_lib::adjustRewardPoints insufficient points customer_id=' . $customerId
. ' current=' . $currentPoints . ' requested=' . $rewardAmount
);
return false;
}
$this->customer->update_reward_points_value($customerId, $currentPoints - $rewardAmount);
return true;
case RewardOperation::Restore:
$this->customer->update_reward_points_value($customerId, $currentPoints + $rewardAmount);
return true;
default:
return false;
}
}
/**
* Handles reward point adjustment when customer changes on a sale.
* Restores points to previous customer, deducts from new customer.
* Prevents negative balances by capping deduction at available points.
*
* @param int|null $previousCustomerId Previous customer ID
* @param int|null $newCustomerId New customer ID
* @param float $previousRewardUsed Reward points used by previous customer
* @param float $newRewardUsed Reward points to be used by new customer
* @return array ['restored' => float, 'charged' => float, 'insufficient' => bool] Amounts restored/charged
*/
public function handleCustomerChange(?int $previousCustomerId, ?int $newCustomerId, float $previousRewardUsed, float $newRewardUsed): array
{
$result = ['restored' => 0.0, 'charged' => 0.0, 'insufficient' => false];
if ($previousCustomerId === $newCustomerId) {
return $result;
}
if (!empty($previousCustomerId) && $previousRewardUsed != 0) {
$previousPoints = $this->customer->get_info($previousCustomerId)->points ?? 0;
$this->customer->update_reward_points_value($previousCustomerId, $previousPoints + $previousRewardUsed);
$result['restored'] = $previousRewardUsed;
}
if (!empty($newCustomerId) && $newRewardUsed != 0) {
$newPoints = $this->customer->get_info($newCustomerId)->points ?? 0;
if ($newPoints < $newRewardUsed) {
log_message(
'warning',
'Reward_lib::handleCustomerChange insufficient points new_customer_id=' . $newCustomerId
. ' available=' . $newPoints . ' requested=' . $newRewardUsed
);
$result['insufficient'] = true;
}
$actualCharged = min($newPoints, $newRewardUsed);
$this->customer->update_reward_points_value($newCustomerId, max(0, $newPoints - $newRewardUsed));
$result['charged'] = $actualCharged;
}
return $result;
}
/**
* Adjusts reward points delta for same customer (e.g., payment amount changed).
* Prevents negative balances by validating sufficient points before adjustment.
*
* @param int|null $customerId Customer ID
* @param float $rewardAdjustment Difference between new and previous reward usage (positive = more used)
* @return bool Success status (false if insufficient points)
*/
public function adjustRewardDelta(?int $customerId, float $rewardAdjustment): bool
{
if (empty($customerId) || $rewardAdjustment == 0) {
return false;
}
$currentPoints = $this->customer->get_info($customerId)->points ?? 0;
if ($rewardAdjustment > 0 && $currentPoints < $rewardAdjustment) {
log_message(
'warning',
'Reward_lib::adjustRewardDelta insufficient points customer_id=' . $customerId
. ' current=' . $currentPoints . ' adjustment=' . $rewardAdjustment
);
return false;
}
$this->customer->update_reward_points_value($customerId, $currentPoints - $rewardAdjustment);
return true;
}
/**
* Validates if a customer has sufficient reward points for a purchase.
*
* @param int $customerId Customer ID
* @param float $requiredPoints Points required for purchase
* @return bool True if customer has sufficient points
*/
public function hasSufficientPoints(int $customerId, float $requiredPoints): bool
{
$currentPoints = $this->customer->get_info($customerId)->points ?? 0;
return $currentPoints >= $requiredPoints;
}
/**
* Gets current reward points for a customer.
*
* @param int $customerId Customer ID
* @return float Current points balance
*/
public function getPointsBalance(int $customerId): float
{
return $this->customer->get_info($customerId)->points ?? 0;
}
/**
* Calculates reward payment amount from a payments array.
*
* @param array $payments Array of payment data
* @param array $rewardLabels Array of valid reward payment labels (localized)
* @return float Total reward payment amount
*/
public function calculateRewardPaymentAmount(array $payments, array $rewardLabels): float
{
$totalRewardAmount = 0;
foreach ($payments as $payment) {
if (in_array($payment['payment_type'] ?? '', $rewardLabels, true)) {
$totalRewardAmount += floatval($payment['payment_amount'] ?? 0);
}
}
return $totalRewardAmount;
}
}

View File

@@ -6,6 +6,7 @@ use CodeIgniter\Database\BaseBuilder;
use CodeIgniter\Database\ResultInterface;
use CodeIgniter\Model;
use App\Libraries\Sale_lib;
use App\Libraries\Reward_lib;
use Config\OSPOS;
use ReflectionException;
@@ -436,63 +437,165 @@ class Sale extends Model
*/
public function update($sale_id = null, $sale_data = null): bool
{
$previousCustomerRow = $this->db->table('sales')
->select('customer_id')
->where('sale_id', $sale_id)
->get()
->getRow();
$previousCustomerId = $previousCustomerRow ? $previousCustomerRow->customer_id : null;
log_message(
'debug',
'Sale::update start sale_id=' . $sale_id . ' previous_customer_id=' . ($previousCustomerId ?? 'null')
);
$updateData = $sale_data;
unset($updateData['payments']);
$newCustomerId = array_key_exists('customer_id', $updateData)
? $updateData['customer_id']
: $previousCustomerId;
$customer = model(Customer::class);
$currentPayments = $this->get_sale_payments($sale_id)->getResultArray();
$currentRewardUsed = 0;
foreach ($currentPayments as $payment) {
if ($this->isRewardPayment($payment['payment_type'])) {
$currentRewardUsed += $payment['payment_amount'];
}
}
log_message(
'debug',
'Sale::update current rewards sale_id=' . $sale_id
. ' current_reward_used=' . $currentRewardUsed
. ' payment_count=' . count($currentPayments)
);
$newRewardUsed = 0;
if (!empty($sale_data['payments'])) {
foreach ($sale_data['payments'] as $payment) {
if ($this->isRewardPayment($payment['payment_type'])) {
$newRewardUsed += $payment['payment_amount'];
}
}
} else {
$newRewardUsed = $currentRewardUsed;
}
log_message(
'debug',
'Sale::update new rewards sale_id=' . $sale_id
. ' new_reward_used=' . $newRewardUsed
. ' payment_count=' . count($sale_data['payments'] ?? [])
);
$this->db->transStart();
$builder = $this->db->table('sales');
$builder->where('sale_id', $sale_id);
$update_data = $sale_data;
unset($update_data['payments']);
$success = $builder->update($update_data);
$success = $builder->update($updateData);
// Touch payment only if update sale is successful and there is a payments object otherwise the result would be to delete all the payments associated to the sale
if ($success && !empty($sale_data['payments'])) {
// Run these queries as a transaction, we want to make sure we do all or nothing
$this->db->transStart();
$builder = $this->db->table('sales_payments');
// Add new payments
foreach ($sale_data['payments'] as $payment) {
$payment_id = $payment['payment_id'];
$payment_type = $payment['payment_type'];
$payment_amount = $payment['payment_amount'];
$cash_refund = $payment['cash_refund'];
$cash_adjustment = $payment['cash_adjustment'];
$employee_id = $payment['employee_id'];
$paymentId = $payment['payment_id'];
$paymentType = $payment['payment_type'];
$paymentAmount = $payment['payment_amount'];
$cashRefund = $payment['cash_refund'];
$cashAdjustment = $payment['cash_adjustment'];
$employeeId = $payment['employee_id'];
if ($payment_id == NEW_ENTRY && $payment_amount != 0) {
// Add a new payment transaction
$sales_payments_data = [
if ($paymentId == NEW_ENTRY && $paymentAmount != 0) {
$salesPaymentsData = [
'sale_id' => $sale_id,
'payment_type' => $payment_type,
'payment_amount' => $payment_amount,
'cash_refund' => $cash_refund,
'cash_adjustment' => $cash_adjustment,
'employee_id' => $employee_id
'payment_type' => $paymentType,
'payment_amount' => $paymentAmount,
'cash_refund' => $cashRefund,
'cash_adjustment' => $cashAdjustment,
'employee_id' => $employeeId
];
$success = $builder->insert($sales_payments_data);
} elseif ($payment_id != NEW_ENTRY) {
if ($payment_amount != 0) {
// Update existing payment transactions (payment_type only)
$sales_payments_data = [
'payment_type' => $payment_type,
'payment_amount' => $payment_amount,
'cash_refund' => $cash_refund,
'cash_adjustment' => $cash_adjustment
$success = $builder->insert($salesPaymentsData);
} elseif ($paymentId != NEW_ENTRY) {
if ($paymentAmount != 0) {
$salesPaymentsData = [
'payment_type' => $paymentType,
'payment_amount' => $paymentAmount,
'cash_refund' => $cashRefund,
'cash_adjustment' => $cashAdjustment
];
$builder->where('payment_id', $payment_id);
$success = $builder->update($sales_payments_data);
$builder->where('payment_id', $paymentId);
$success = $builder->update($salesPaymentsData);
} else {
// Remove existing payment transactions with a payment amount of zero
$success = $builder->delete(['payment_id' => $payment_id]);
$success = $builder->delete(['payment_id' => $paymentId]);
}
}
}
$this->db->transComplete();
$success &= $this->db->transStatus();
}
return $success;
if ($success) {
log_message(
'debug',
'Sale::update reward adjust sale_id=' . $sale_id
. ' previous_customer_id=' . ($previousCustomerId ?? 'null')
. ' new_customer_id=' . ($newCustomerId ?? 'null')
. ' current_reward_used=' . $currentRewardUsed
. ' new_reward_used=' . $newRewardUsed
);
if ($previousCustomerId != $newCustomerId) {
if (!empty($previousCustomerId) && $currentRewardUsed != 0) {
$previousPoints = $customer->get_info($previousCustomerId)->points ?? 0;
$customer->update_reward_points_value($previousCustomerId, $previousPoints + $currentRewardUsed);
log_message(
'debug',
'Sale::update reward restore previous_customer_id=' . $previousCustomerId
. ' previous_points=' . $previousPoints
. ' restored=' . $currentRewardUsed
);
}
if (!empty($newCustomerId) && $newRewardUsed != 0) {
$newPoints = $customer->get_info($newCustomerId)->points ?? 0;
if ($newPoints < $newRewardUsed) {
log_message(
'warning',
'Sale::update insufficient points new_customer_id=' . $newCustomerId
. ' available=' . $newPoints . ' requested=' . $newRewardUsed
);
}
$customer->update_reward_points_value($newCustomerId, max(0, $newPoints - $newRewardUsed));
log_message(
'debug',
'Sale::update reward charge new_customer_id=' . $newCustomerId
. ' new_points=' . $newPoints
. ' charged=' . $newRewardUsed
);
}
} else {
$rewardAdjustment = $newRewardUsed - $currentRewardUsed;
if ($rewardAdjustment != 0 && !empty($newCustomerId)) {
$currentPoints = $customer->get_info($newCustomerId)->points ?? 0;
if ($rewardAdjustment > 0 && $currentPoints < $rewardAdjustment) {
log_message(
'warning',
'Sale::update insufficient points customer_id=' . $newCustomerId
. ' current=' . $currentPoints . ' adjustment=' . $rewardAdjustment
);
}
$customer->update_reward_points_value($newCustomerId, $currentPoints - $rewardAdjustment);
log_message(
'debug',
'Sale::update reward delta new_customer_id=' . $newCustomerId
. ' current_points=' . $currentPoints
. ' reward_adjustment=' . $rewardAdjustment
);
}
}
}
$this->db->transComplete();
return $success && $this->db->transStatus();
}
/**
@@ -532,7 +635,7 @@ class Sale extends Model
return -1; // TODO: Replace -1 with a constant
}
$sales_data = [
$salesData = [
'sale_time' => date('Y-m-d H:i:s'),
'customer_id' => $customer->exists($customer_id) ? $customer_id : null,
'employee_id' => $employee_id,
@@ -545,35 +648,30 @@ class Sale extends Model
'sale_type' => $sale_type
];
// Run these queries as a transaction, we want to make sure we do all or nothing
$this->db->transStart();
if ($sale_id == NEW_ENTRY) {
$builder = $this->db->table('sales');
$builder->insert($sales_data);
$builder->insert($salesData);
$sale_id = $this->db->insertID();
} else {
$builder = $this->db->table('sales');
$builder->where('sale_id', $sale_id);
$builder->update($sales_data);
$builder->update($salesData);
}
$total_amount = 0;
$total_amount_used = 0;
$totalAmount = 0;
$totalAmountUsed = 0;
foreach ($payments as $payment_id => $payment) {
if (!empty(strstr($payment['payment_type'], lang('Sales.giftcard')))) {
// We have a gift card, and we have to deduct the used value from the total value of the card.
$splitpayment = explode(':', $payment['payment_type']); // TODO: this variable doesn't follow our naming conventions. Probably should be refactored to split_payment.
$cur_giftcard_value = $giftcard->get_giftcard_value($splitpayment[1]); // TODO: this should be refactored to $current_giftcard_value
$giftcard->update_giftcard_value($splitpayment[1], $cur_giftcard_value - $payment['payment_amount']);
} elseif (!empty(strstr($payment['payment_type'], lang('Sales.rewards')))) {
$cur_rewards_value = $customer->get_info($customer_id)->points;
$customer->update_reward_points_value($customer_id, $cur_rewards_value - $payment['payment_amount']);
$total_amount_used = floatval($total_amount_used) + floatval($payment['payment_amount']);
}
foreach ($payments as $payment) {
$totalAmountUsed += $this->processPaymentType(
$payment,
$customer_id,
$customer,
$giftcard
);
$sales_payments_data = [
$salesPaymentsData = [
'sale_id' => $sale_id,
'payment_type' => $payment['payment_type'],
'payment_amount' => $payment['payment_amount'],
@@ -583,74 +681,71 @@ class Sale extends Model
];
$builder = $this->db->table('sales_payments');
$builder->insert($sales_payments_data);
$builder->insert($salesPaymentsData);
$total_amount = floatval($total_amount) + floatval($payment['payment_amount']) - floatval($payment['cash_refund']);
$totalAmount = floatval($totalAmount) + floatval($payment['payment_amount']) - floatval($payment['cash_refund']);
}
$this->save_customer_rewards($customer_id, $sale_id, $total_amount, $total_amount_used);
$this->save_customer_rewards($customer_id, $sale_id, $totalAmount, $totalAmountUsed);
$customer = $customer->get_info($customer_id);
foreach ($items as $line => $item_data) {
$cur_item_info = $item->get_info($item_data['item_id']);
foreach ($items as $itemData) {
$currentItemInfo = $item->get_info($itemData['item_id']);
if ($item_data['price'] == 0.00) {
$item_data['discount'] = 0.00;
if ($itemData['price'] == 0.00) {
$itemData['discount'] = 0.00;
}
$sales_items_data = [
$salesItemsData = [
'sale_id' => $sale_id,
'item_id' => $item_data['item_id'],
'line' => $item_data['line'],
'description' => character_limiter($item_data['description'], 255),
'serialnumber' => character_limiter($item_data['serialnumber'], 30),
'quantity_purchased' => $item_data['quantity'],
'discount' => $item_data['discount'],
'discount_type' => $item_data['discount_type'],
'item_cost_price' => $item_data['cost_price'],
'item_unit_price' => $item_data['price'],
'item_location' => $item_data['item_location'],
'print_option' => $item_data['print_option']
'item_id' => $itemData['item_id'],
'line' => $itemData['line'],
'description' => character_limiter($itemData['description'], 255),
'serialnumber' => character_limiter($itemData['serialnumber'], 30),
'quantity_purchased' => $itemData['quantity'],
'discount' => $itemData['discount'],
'discount_type' => $itemData['discount_type'],
'item_cost_price' => $itemData['cost_price'],
'item_unit_price' => $itemData['price'],
'item_location' => $itemData['item_location'],
'print_option' => $itemData['print_option']
];
$builder = $this->db->table('sales_items');
$builder->insert($sales_items_data);
$builder->insert($salesItemsData);
if ($cur_item_info->stock_type == HAS_STOCK && $sale_status == COMPLETED) { // TODO: === ?
// Update stock quantity if item type is a standard stock item and the sale is a standard sale
$item_quantity_data = $item_quantity->get_item_quantity($item_data['item_id'], $item_data['item_location']);
if ($currentItemInfo->stock_type == HAS_STOCK && $sale_status == COMPLETED) {
$itemQuantityData = $item_quantity->get_item_quantity($itemData['item_id'], $itemData['item_location']);
$item_quantity->save_value(
[
'quantity' => $item_quantity_data->quantity - $item_data['quantity'],
'item_id' => $item_data['item_id'],
'location_id' => $item_data['item_location']
'quantity' => $itemQuantityData->quantity - $itemData['quantity'],
'item_id' => $itemData['item_id'],
'location_id' => $itemData['item_location']
],
$item_data['item_id'],
$item_data['item_location']
$itemData['item_id'],
$itemData['item_location']
);
// If an items was deleted but later returned it's restored with this rule
if ($item_data['quantity'] < 0) {
$item->undelete($item_data['item_id']);
if ($itemData['quantity'] < 0) {
$item->undelete($itemData['item_id']);
}
// Inventory Count Details
$sale_remarks = 'POS ' . $sale_id; // TODO: Use string interpolation here.
$inv_data = [
$saleRemarks = 'POS ' . $sale_id;
$inventoryData = [
'trans_date' => date('Y-m-d H:i:s'),
'trans_items' => $item_data['item_id'],
'trans_items' => $itemData['item_id'],
'trans_user' => $employee_id,
'trans_location' => $item_data['item_location'],
'trans_comment' => $sale_remarks,
'trans_inventory' => -$item_data['quantity']
'trans_location' => $itemData['item_location'],
'trans_comment' => $saleRemarks,
'trans_inventory' => -$itemData['quantity']
];
$inventory->insert($inv_data, false);
$inventory->insert($inventoryData, false);
}
$attribute->copy_attribute_links($item_data['item_id'], 'sale_id', $sale_id);
$attribute->copy_attribute_links($itemData['item_id'], 'sale_id', $sale_id);
}
if ($customer_id == NEW_ENTRY || $customer->taxable) {
@@ -779,45 +874,71 @@ class Sale extends Model
*/
public function delete($sale_id = null, bool $purge = false, bool $update_inventory = true, $employee_id = null): bool
{
// Start a transaction to assure data integrity
$this->db->transStart();
$sale_status = $this->get_sale_status($sale_id);
if ($update_inventory && $sale_status == COMPLETED) {
// Defect, not all item deletions will be undone?
// Get array with all the items involved in the sale to update the inventory tracking
$inventory = model('Inventory');
$item = model(Item::class);
$item_quantity = model(Item_quantity::class);
$itemQuantity = model(Item_quantity::class);
$items = $this->get_sale_items($sale_id)->getResultArray();
foreach ($items as $item_data) {
$cur_item_info = $item->get_info($item_data['item_id']);
foreach ($items as $itemData) {
$currentItemInfo = $item->get_info($itemData['item_id']);
if ($cur_item_info->stock_type == HAS_STOCK) {
// Create query to update inventory tracking
$inv_data = [
if ($currentItemInfo->stock_type == HAS_STOCK) {
$inventoryData = [
'trans_date' => date('Y-m-d H:i:s'),
'trans_items' => $item_data['item_id'],
'trans_items' => $itemData['item_id'],
'trans_user' => $employee_id,
'trans_comment' => 'Deleting sale ' . $sale_id,
'trans_location' => $item_data['item_location'],
'trans_inventory' => $item_data['quantity_purchased']
'trans_location' => $itemData['item_location'],
'trans_inventory' => $itemData['quantity_purchased']
];
// Update inventory
$inventory->insert($inv_data, false);
$inventory->insert($inventoryData, false);
// Update quantities
$item_quantity->change_quantity($item_data['item_id'], $item_data['item_location'], $item_data['quantity_purchased']);
$itemQuantity->change_quantity($itemData['item_id'], $itemData['item_location'], $itemData['quantity_purchased']);
}
}
}
if ($sale_status !== CANCELED) {
$payments = $this->get_sale_payments($sale_id)->getResultArray();
$rewardUsed = 0;
foreach ($payments as $payment) {
if ($this->isRewardPayment($payment['payment_type'])) {
$rewardUsed += $payment['payment_amount'];
}
}
log_message(
'debug',
'Sale::delete reward usage sale_id=' . $sale_id
. ' reward_used=' . $rewardUsed
. ' payment_count=' . count($payments)
);
if ($rewardUsed > 0) {
$customerObj = $this->get_customer($sale_id);
if (empty($customerObj) || empty($customerObj->person_id)) {
log_message('error', 'Sale::delete cannot restore rewards - no customer for sale_id=' . $sale_id);
} else {
$customerId = $customerObj->person_id;
$customer = model(Customer::class);
$currentPoints = $customer->get_info($customerId)->points ?? 0;
$customer->update_reward_points_value($customerId, $currentPoints + $rewardUsed);
log_message(
'debug',
'Sale::delete reward restore customer_id=' . $customerId
. ' current_points=' . $currentPoints
. ' restored=' . $rewardUsed
);
}
}
}
$this->update_sale_status($sale_id, CANCELED);
// Execute transaction
$this->db->transComplete();
return $this->db->transStatus();
@@ -1387,6 +1508,94 @@ class Sale extends Model
}
}
/**
* Determines if the payment type represents a rewards payment across locales.
*/
private function isRewardPayment(string $payment_type): bool
{
if ($payment_type === '') {
return false;
}
foreach ($this->getRewardPaymentLabels() as $label) {
if ($payment_type === $label) {
return true;
}
}
return false;
}
/**
* Returns unique localized labels for the rewards payment type.
*/
private function getRewardPaymentLabels(): array
{
static $labels = null;
if ($labels !== null) {
return $labels;
}
$labels = [lang('Sales.rewards')];
$languagePaths = glob(APPPATH . 'Language/*/Sales.php');
if (!empty($languagePaths)) {
foreach ($languagePaths as $salesFile) {
if (!is_file($salesFile)) {
continue;
}
$translations = require $salesFile;
if (is_array($translations) && !empty($translations['rewards'])) {
$labels[] = $translations['rewards'];
}
}
}
$labels = array_map('trim', $labels);
$labels = array_filter($labels, static fn($label) => $label !== '');
$labels = array_values(array_unique($labels));
return $labels;
}
/**
* Processes payment type for giftcard and reward deductions during sale creation.
* Returns the amount used for rewards (0 for giftcards).
*/
private function processPaymentType(array $payment, int $customerId, object $customer, object $giftcard): float
{
$paymentType = $payment['payment_type'];
$paymentAmount = $payment['payment_amount'];
if (!empty(strstr($paymentType, lang('Sales.giftcard')))) {
$splitPayment = explode(':', $paymentType);
if (count($splitPayment) < 2 || empty($splitPayment[1])) {
log_message('error', 'Sale::processPaymentType invalid giftcard format: ' . $paymentType);
return 0;
}
$giftcardNumber = $splitPayment[1];
$currentGiftcardValue = $giftcard->get_giftcard_value($giftcardNumber);
$giftcard->update_giftcard_value($giftcardNumber, $currentGiftcardValue - $paymentAmount);
return 0;
}
if ($this->isRewardPayment($paymentType)) {
$currentRewardsValue = $customer->get_info($customerId)->points ?? 0;
if ($currentRewardsValue < $paymentAmount) {
log_message(
'warning',
'Sale::processPaymentType insufficient points customer_id=' . $customerId
. ' available=' . $currentRewardsValue . ' requested=' . $paymentAmount
);
}
$customer->update_reward_points_value($customerId, max(0, $currentRewardsValue - $paymentAmount));
return floatval($paymentAmount);
}
return 0;
}
/**
* Creates a temporary table to store the sales_payments data
*

View File

@@ -0,0 +1,440 @@
<?php
namespace Tests\Libraries;
use CodeIgniter\Test\CIUnitTestCase;
use App\Enums\RewardOperation;
use App\Libraries\Reward_lib;
use App\Models\Customer;
class Reward_libTest extends CIUnitTestCase
{
use \CodeIgniter\Test\DatabaseTestTrait;
protected $migrate = true;
protected $migrateOnce = true;
protected $refresh = true;
protected $namespace = null;
private Reward_lib $rewardLib;
protected function setUp(): void
{
parent::setUp();
$this->rewardLib = new Reward_lib();
}
/**
* Test calculatePointsEarned returns correct calculation
*/
public function testCalculatePointsEarnedReturnsCorrectValue(): void
{
$pointsEarned = $this->rewardLib->calculatePointsEarned(100.00, 10);
$this->assertEquals(10.0, $pointsEarned);
}
/**
* Test calculatePointsEarned with zero amount
*/
public function testCalculatePointsEarnedWithZeroAmount(): void
{
$pointsEarned = $this->rewardLib->calculatePointsEarned(0, 10);
$this->assertEquals(0.0, $pointsEarned);
}
/**
* Test calculatePointsEarned with zero percentage
*/
public function testCalculatePointsEarnedWithZeroPercentage(): void
{
$pointsEarned = $this->rewardLib->calculatePointsEarned(100.00, 0);
$this->assertEquals(0.0, $pointsEarned);
}
/**
* Test calculatePointsEarned with percentage over 100
*/
public function testCalculatePointsEarnedWithHighPercentage(): void
{
$pointsEarned = $this->rewardLib->calculatePointsEarned(50.00, 200);
$this->assertEquals(100.0, $pointsEarned);
}
/**
* Test hasSufficientPoints returns true when customer has enough points
*/
public function testHasSufficientPointsReturnsTrueWhenSufficient(): void
{
$customerModel = $this->getMockBuilder(Customer::class)
->onlyMethods(['get_info'])
->getMock();
$customerModel->method('get_info')
->willReturn((object)['points' => 100]);
// Use reflection to inject mock
$reflection = new \ReflectionClass($this->rewardLib);
$property = $reflection->getProperty('customer');
$property->setAccessible(true);
$property->setValue($this->rewardLib, $customerModel);
$this->assertTrue($this->rewardLib->hasSufficientPoints(1, 50));
}
/**
* Test hasSufficientPoints returns false when customer has insufficient points
*/
public function testHasSufficientPointsReturnsFalseWhenInsufficient(): void
{
$customerModel = $this->getMockBuilder(Customer::class)
->onlyMethods(['get_info'])
->getMock();
$customerModel->method('get_info')
->willReturn((object)['points' => 30]);
$reflection = new \ReflectionClass($this->rewardLib);
$property = $reflection->getProperty('customer');
$property->setAccessible(true);
$property->setValue($this->rewardLib, $customerModel);
$this->assertFalse($this->rewardLib->hasSufficientPoints(1, 50));
}
/**
* Test getPointsBalance returns correct balance
*/
public function testGetPointsBalanceReturnsCorrectValue(): void
{
$customerModel = $this->getMockBuilder(Customer::class)
->onlyMethods(['get_info'])
->getMock();
$customerModel->method('get_info')
->willReturn((object)['points' => 250]);
$reflection = new \ReflectionClass($this->rewardLib);
$property = $reflection->getProperty('customer');
$property->setAccessible(true);
$property->setValue($this->rewardLib, $customerModel);
$this->assertEquals(250, $this->rewardLib->getPointsBalance(1));
}
/**
* Test calculateRewardPaymentAmount with mixed payments
*/
public function testCalculateRewardPaymentAmountWithMixedPayments(): void
{
$payments = [
['payment_type' => 'Cash', 'payment_amount' => 50],
['payment_type' => 'Rewards', 'payment_amount' => 25],
['payment_type' => 'Credit Card', 'payment_amount' => 100],
['payment_type' => 'Rewards', 'payment_amount' => 15],
];
$rewardLabels = ['Rewards', 'Points'];
$total = $this->rewardLib->calculateRewardPaymentAmount($payments, $rewardLabels);
$this->assertEquals(40.0, $total);
}
/**
* Test calculateRewardPaymentAmount with empty payments
*/
public function testCalculateRewardPaymentAmountWithEmptyPayments(): void
{
$total = $this->rewardLib->calculateRewardPaymentAmount([], ['Rewards']);
$this->assertEquals(0.0, $total);
}
/**
* Test calculateRewardPaymentAmount with no matching labels
*/
public function testCalculateRewardPaymentAmountWithNoMatchingLabels(): void
{
$payments = [
['payment_type' => 'Cash', 'payment_amount' => 50],
['payment_type' => 'Credit Card', 'payment_amount' => 100],
];
$total = $this->rewardLib->calculateRewardPaymentAmount($payments, ['Rewards']);
$this->assertEquals(0.0, $total);
}
/**
* Test adjustRewardPoints returns false for null customer
*/
public function testAdjustRewardPointsReturnsFalseForNullCustomer(): void
{
$result = $this->rewardLib->adjustRewardPoints(null, 50, RewardOperation::Deduct);
$this->assertFalse($result);
}
/**
* Test adjustRewardPoints returns false for zero amount
*/
public function testAdjustRewardPointsReturnsFalseForZeroAmount(): void
{
$result = $this->rewardLib->adjustRewardPoints(1, 0, RewardOperation::Deduct);
$this->assertFalse($result);
}
/**
* Test adjustRewardDelta returns false for null customer
*/
public function testAdjustRewardDeltaReturnsFalseForNullCustomer(): void
{
$result = $this->rewardLib->adjustRewardDelta(null, 50);
$this->assertFalse($result);
}
/**
* Test adjustRewardDelta returns false for zero adjustment
*/
public function testAdjustRewardDeltaReturnsFalseForZeroAdjustment(): void
{
$result = $this->rewardLib->adjustRewardDelta(1, 0);
$this->assertFalse($result);
}
/**
* Test handleCustomerChange with same customer returns empty result
*/
public function testHandleCustomerChangeWithSameCustomerReturnsEmpty(): void
{
$result = $this->rewardLib->handleCustomerChange(1, 1, 50.0, 75.0);
$this->assertEquals(['restored' => 0.0, 'charged' => 0.0, 'insufficient' => false], $result);
}
/**
* Test handleCustomerChange with null customers
*/
public function testHandleCustomerChangeWithNullCustomers(): void
{
$result = $this->rewardLib->handleCustomerChange(null, null, 50.0, 75.0);
$this->assertEquals(['restored' => 0.0, 'charged' => 0.0, 'insufficient' => false], $result);
}
/**
* Test handleCustomerChange when customer changes from null to valid customer
*/
public function testHandleCustomerChangeFromNullToValidCustomer(): void
{
$customerModel = $this->getMockBuilder(Customer::class)
->onlyMethods(['get_info', 'update_reward_points_value'])
->getMock();
$customerModel->method('get_info')
->willReturn((object)['points' => 100]);
$customerModel->method('update_reward_points_value')
->willReturn(true);
$reflection = new \ReflectionClass($this->rewardLib);
$property = $reflection->getProperty('customer');
$property->setAccessible(true);
$property->setValue($this->rewardLib, $customerModel);
$result = $this->rewardLib->handleCustomerChange(null, 2, 0, 50.0);
$this->assertEquals(50.0, $result['charged']);
$this->assertEquals(0.0, $result['restored']);
}
/**
* Test update reward points correctly deducts from balance
*/
public function testPointsUpdateDuringSaleCreation(): void
{
$customerModel = $this->getMockBuilder(Customer::class)
->onlyMethods(['get_info', 'update_reward_points_value'])
->getMock();
$customerModel->method('get_info')
->willReturn((object)['points' => 200]);
$customerModel->expects($this->once())
->method('update_reward_points_value')
->with(1, 150);
$reflection = new \ReflectionClass($this->rewardLib);
$property = $reflection->getProperty('customer');
$property->setAccessible(true);
$property->setValue($this->rewardLib, $customerModel);
$this->rewardLib->adjustRewardPoints(1, 50, RewardOperation::Deduct);
}
/**
* Test update reward points correctly restores on sale deletion
*/
public function testPointsRestoreOnSaleDeletion(): void
{
$customerModel = $this->getMockBuilder(Customer::class)
->onlyMethods(['get_info', 'update_reward_points_value'])
->getMock();
$customerModel->method('get_info')
->willReturn((object)['points' => 150]);
$customerModel->expects($this->once())
->method('update_reward_points_value')
->with(1, 200);
$reflection = new \ReflectionClass($this->rewardLib);
$property = $reflection->getProperty('customer');
$property->setAccessible(true);
$property->setValue($this->rewardLib, $customerModel);
$this->rewardLib->adjustRewardPoints(1, 50, RewardOperation::Restore);
}
/**
* Test hasSufficientPoints returns true when points exactly match required
*/
public function testHasSufficientPointsReturnsTrueWhenExactMatch(): void
{
$customerModel = $this->getMockBuilder(Customer::class)
->onlyMethods(['get_info'])
->getMock();
$customerModel->method('get_info')
->willReturn((object)['points' => 50]);
$reflection = new \ReflectionClass($this->rewardLib);
$property = $reflection->getProperty('customer');
$property->setAccessible(true);
$property->setValue($this->rewardLib, $customerModel);
$this->assertTrue($this->rewardLib->hasSufficientPoints(1, 50));
}
/**
* Test adjustRewardPoints returns false when insufficient points for deduct
*/
public function testAdjustRewardPointsReturnsFalseWhenInsufficientPointsForDeduct(): void
{
$customerModel = $this->getMockBuilder(Customer::class)
->onlyMethods(['get_info', 'update_reward_points_value'])
->getMock();
$customerModel->method('get_info')
->willReturn((object)['points' => 30]);
$customerModel->expects($this->never())
->method('update_reward_points_value');
$reflection = new \ReflectionClass($this->rewardLib);
$property = $reflection->getProperty('customer');
$property->setAccessible(true);
$property->setValue($this->rewardLib, $customerModel);
$result = $this->rewardLib->adjustRewardPoints(1, 50, RewardOperation::Deduct);
$this->assertFalse($result);
}
/**
* Test adjustRewardDelta returns false when insufficient points for positive adjustment
*/
public function testAdjustRewardDeltaReturnsFalseWhenInsufficientPoints(): void
{
$customerModel = $this->getMockBuilder(Customer::class)
->onlyMethods(['get_info', 'update_reward_points_value'])
->getMock();
$customerModel->method('get_info')
->willReturn((object)['points' => 20]);
$customerModel->expects($this->never())
->method('update_reward_points_value');
$reflection = new \ReflectionClass($this->rewardLib);
$property = $reflection->getProperty('customer');
$property->setAccessible(true);
$property->setValue($this->rewardLib, $customerModel);
$result = $this->rewardLib->adjustRewardDelta(1, 50);
$this->assertFalse($result);
}
/**
* Test adjustRewardDelta succeeds for negative adjustment (refund)
*/
public function testAdjustRewardDeltaSucceedsForNegativeAdjustment(): void
{
$customerModel = $this->getMockBuilder(Customer::class)
->onlyMethods(['get_info', 'update_reward_points_value'])
->getMock();
$customerModel->method('get_info')
->willReturn((object)['points' => 100]);
$customerModel->expects($this->once())
->method('update_reward_points_value')
->with(1, 150);
$reflection = new \ReflectionClass($this->rewardLib);
$property = $reflection->getProperty('customer');
$property->setAccessible(true);
$property->setValue($this->rewardLib, $customerModel);
$result = $this->rewardLib->adjustRewardDelta(1, -50);
$this->assertTrue($result);
}
/**
* Test handleCustomerChange caps charge at available points when insufficient
*/
public function testHandleCustomerChangeCapsChargeWhenInsufficient(): void
{
$customerModel = $this->getMockBuilder(Customer::class)
->onlyMethods(['get_info', 'update_reward_points_value'])
->getMock();
$customerModel->method('get_info')
->willReturn((object)['points' => 30]);
$customerModel->expects($this->once())
->method('update_reward_points_value')
->with(2, 0);
$reflection = new \ReflectionClass($this->rewardLib);
$property = $reflection->getProperty('customer');
$property->setAccessible(true);
$property->setValue($this->rewardLib, $customerModel);
$result = $this->rewardLib->handleCustomerChange(null, 2, 0, 50.0);
$this->assertEquals(30.0, $result['charged']);
$this->assertTrue($result['insufficient']);
}
/**
* Test handleCustomerChange does not charge when new customer has zero points
*/
public function testHandleCustomerChangeCapsChargeAtZero(): void
{
$customerModel = $this->getMockBuilder(Customer::class)
->onlyMethods(['get_info', 'update_reward_points_value'])
->getMock();
$customerModel->method('get_info')
->willReturn((object)['points' => 0]);
$customerModel->expects($this->once())
->method('update_reward_points_value')
->with(2, 0);
$reflection = new \ReflectionClass($this->rewardLib);
$property = $reflection->getProperty('customer');
$property->setAccessible(true);
$property->setValue($this->rewardLib, $customerModel);
$result = $this->rewardLib->handleCustomerChange(null, 2, 0, 50.0);
$this->assertEquals(0.0, $result['charged']);
$this->assertTrue($result['insufficient']);
}
}

View File

@@ -10,6 +10,15 @@
<testsuite name="Helpers">
<directory>helpers</directory>
</testsuite>
<testsuite name="Libraries">
<directory>Libraries</directory>
</testsuite>
<testsuite name="Models">
<directory>Models</directory>
</testsuite>
<testsuite name="Controllers">
<directory>Controllers</directory>
</testsuite>
</testsuites>
</phpunit>