Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 0 additions & 30 deletions .github/workflows/changelog.yml

This file was deleted.

16 changes: 8 additions & 8 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,15 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
php-version: ['8.1', '8.2', '8.3', '8.4']
php-version: ['8.1', '8.2', '8.3', '8.4', '8.5']

name: PHP ${{ matrix.php-version }}

steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7.0.1

- name: Setup PHP
uses: shivammathur/setup-php@v2
uses: shivammathur/setup-php@2.37.2
with:
php-version: ${{ matrix.php-version }}
coverage: none
Expand All @@ -29,7 +29,7 @@ jobs:

- name: Cache Composer packages
id: composer-cache
uses: actions/cache@v5
uses: actions/cache@v6.1.0
with:
path: vendor
key: ${{ runner.os }}-php-${{ matrix.php-version }}-${{ hashFiles('**/composer.lock') }}
Expand All @@ -47,10 +47,10 @@ jobs:
name: Static Analysis

steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7.0.1

- name: Setup PHP
uses: shivammathur/setup-php@v2
uses: shivammathur/setup-php@2.37.2
with:
php-version: '8.1'
coverage: none
Expand All @@ -66,10 +66,10 @@ jobs:
name: Code Style

steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7.0.1

- name: Setup PHP
uses: shivammathur/setup-php@v2
uses: shivammathur/setup-php@2.37.2
with:
php-version: '8.1'
coverage: none
Expand Down
49 changes: 0 additions & 49 deletions .github/workflows/pr-summary.yml

This file was deleted.

43 changes: 0 additions & 43 deletions .github/workflows/release.yml

This file was deleted.

16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [2.1.0] - 2026-09-01

### Fixed

- Coerce money fields (`price`, `discount`, `total_price_override`, capture/refund `amount`, `total`, `debt`, `subtotal_override`, `total_tax_override`, `total_discount_override`, `total_override`) to integers before API calls — floating point noise from `ringgit × 100` conversions (e.g. `0.29 * 100 = 28.999999999999996`) no longer produces fractional JSON that the API rejects with 400 "A valid integer is required."
- Values within 1e-9 of an integer are rounded to it; genuine fractional sen (e.g. `108.5`) now throws `InvalidMoneyValueException` instead of being silently truncated by the builder's implicit int cast (previously `28.999…` became `28` sen — a wrong charge amount)

### Added

- `Chip\Support\Money::coerce()` shared money coercion helper
- `Chip\Exception\InvalidMoneyValueException` for money values that cannot be safely sent to the API

### Removed

- Remove auto PR summary workflow and script (`pr-summary.yml`, `generate_pr_summary.py`) — the Ollama endpoint returns 410 Gone and the workflow overwrote PR descriptions with its error output

## [2.0.2] - 2026-05-18

### Added
Expand Down
33 changes: 17 additions & 16 deletions lib/Builder/PurchaseBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
use Chip\Model\Product;
use Chip\Model\Purchase;
use Chip\Model\PurchaseDetails;
use Chip\Support\Money;

class PurchaseBuilder
{
Expand Down Expand Up @@ -328,21 +329,21 @@ public function clientBankCode(?string $bankCode): self

public function addProduct(
string $name,
int $price,
int|float|string $price,
float|string $quantity = 1.0,
?int $discount = null,
int|float|string|null $discount = null,
?string $taxPercent = null,
?string $category = null,
?int $totalPriceOverride = null
int|float|string|null $totalPriceOverride = null
): self {
$product = new Product();
$product->name = $name;
$product->price = $price;
$product->price = Money::coerce($price);
$product->quantity = is_string($quantity) ? $quantity : (string) $quantity;
$product->discount = $discount;
$product->discount = $discount === null ? null : Money::coerce($discount);
$product->tax_percent = $taxPercent;
$product->category = $category;
$product->total_price_override = $totalPriceOverride;
$product->total_price_override = $totalPriceOverride === null ? null : Money::coerce($totalPriceOverride);

$this->purchase->purchase->products[] = $product;

Expand All @@ -356,37 +357,37 @@ public function notes(string $notes): self
return $this;
}

public function debt(int $debt): self
public function debt(int|float|string $debt): self
{
$this->purchase->purchase->debt = $debt;
$this->purchase->purchase->debt = Money::coerce($debt);

return $this;
}

public function subtotalOverride(int $subtotalOverride): self
public function subtotalOverride(int|float|string $subtotalOverride): self
{
$this->purchase->purchase->subtotal_override = $subtotalOverride;
$this->purchase->purchase->subtotal_override = Money::coerce($subtotalOverride);

return $this;
}

public function totalTaxOverride(int $totalTaxOverride): self
public function totalTaxOverride(int|float|string $totalTaxOverride): self
{
$this->purchase->purchase->total_tax_override = $totalTaxOverride;
$this->purchase->purchase->total_tax_override = Money::coerce($totalTaxOverride);

return $this;
}

public function totalDiscountOverride(int $totalDiscountOverride): self
public function totalDiscountOverride(int|float|string $totalDiscountOverride): self
{
$this->purchase->purchase->total_discount_override = $totalDiscountOverride;
$this->purchase->purchase->total_discount_override = Money::coerce($totalDiscountOverride);

return $this;
}

public function totalOverride(int $totalOverride): self
public function totalOverride(int|float|string $totalOverride): self
{
$this->purchase->purchase->total_override = $totalOverride;
$this->purchase->purchase->total_override = Money::coerce($totalOverride);

return $this;
}
Expand Down
17 changes: 17 additions & 0 deletions lib/Exception/InvalidMoneyValueException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<?php

declare(strict_types=1);

namespace Chip\Exception;

use Exception;

/**
* Thrown when a money field (price, discount, totals, overrides) receives a value
* that cannot be safely sent to the CHIP API — e.g. a float with a genuine
* fractional sen component (108.5) or a non-numeric type. Extends the base
* SDK exception so existing catch blocks keep working.
*/
class InvalidMoneyValueException extends Exception
{
}
26 changes: 22 additions & 4 deletions lib/Model/Product.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@

namespace Chip\Model;

use Chip\Exception\InvalidMoneyValueException;
use Chip\Support\Money;

class Product implements \JsonSerializable
{
/**
Expand Down Expand Up @@ -47,19 +50,34 @@ public static function fromArray(array $data): self
$product = new self();
$product->name = $data['name'] ?? null;
$product->quantity = $data['quantity'] ?? null;
$product->price = $data['price'] ?? null;
$product->discount = $data['discount'] ?? null;
$product->price = isset($data['price']) ? Money::coerce($data['price']) : null;
$product->discount = isset($data['discount']) ? Money::coerce($data['discount']) : null;
$product->tax_percent = $data['tax_percent'] ?? null;
$product->category = $data['category'] ?? null;
$product->total_price_override = $data['total_price_override'] ?? null;
$product->total_price_override = isset($data['total_price_override']) ? Money::coerce($data['total_price_override']) : null;

return $product;
}

#[\ReturnTypeWillChange]
public function jsonSerialize()
{
return array_filter((array) $this, [$this, 'allow_non_null']);
// Coerce at serialization time too: public properties mean callers may
// assign raw values (e.g. 0.29 * 100 float noise) directly without the
// builder. Money fields must reach the API as integers.
$data = (array) $this;

foreach (['price', 'discount', 'total_price_override'] as $moneyField) {
if ($data[$moneyField] !== null) {
try {
$data[$moneyField] = Money::coerce($data[$moneyField]);
} catch (InvalidMoneyValueException $e) {
throw new InvalidMoneyValueException("Product->{$moneyField}: " . $e->getMessage(), 0, $e);
}
}
}

return array_filter($data, [$this, 'allow_non_null']);
}

/**
Expand Down
Loading