Money¶
Every amount Kessai takes or answers with is a Drupal\kessai\Money: a figure
and the ISO 4217 currency it is in. A payment's amount, what was captured,
released and refunded, what is still held, the balance, and what each movement
moved are all one.
use Drupal\kessai\Money;
use Drupal\kessai\PaymentRequest;
$payment = $payment_client->create(new PaymentRequest(
amount: new Money('1.234', 'KWD'),
account_uuid: $account_uuid,
kind: 'booking',
));
$payment->getAmount()->value; // '1.234'
$payment->getAmount()->currency; // 'KWD'
$payment->getBalance()->value; // '0.000'
What an amount can be¶
The figure is a decimal string of zero or above. It has as many decimals as its currency has, and no more:
| Written | Currency | Held as | Why |
|---|---|---|---|
'12.3' |
EUR | '12.30' |
A euro has cents. |
'1000.00' |
JPY | '1000' |
A trailing zero is not a decimal. |
'1.234' |
KWD | '1.234' |
A dinar has fils, a thousandth. |
'150000000' |
VND | '150000000' |
A dong has no decimals. |
'10.50' |
JPY | refused | There is no half yen. |
'-10.00' |
EUR | refused | Money is never negative. |
'10,00' |
EUR | refused | A figure is written with a point. |
The constructor throws \InvalidArgumentException for each refusal, so a
mistake is caught where the amount is built, before anything reaches a payment
or a provider.
How many decimals a currency has is ISO 4217's exponent, and Money holds the
one table of the currencies whose exponent is not 2. getExponent() reads it.
PHP's intl extension is not used: it answers with CLDR's display digits, which
say 0 for HUF, IDR and COP where ISO 4217 says 2.
(new Money('1', 'JPY'))->getExponent(); // 0
Money::getCurrencyExponent('KWD'); // 3
Arithmetic¶
add(), subtract(), compare(), isZero() and isPositive() work at the
currency's own decimals, with bcmath, and never through a float.
$held = new Money('150.00', 'EUR');
$claimed = new Money('10.00', 'EUR');
$held->subtract($claimed)->value; // '140.00'
$held->compare($claimed); // 1
$claimed->isPositive(); // TRUE
format() writes an amount for a person, in a message or a log line:
'140.00 EUR'. A key, a column or the wire takes value and currency
apart instead.
Two currencies never meet. Adding, subtracting or comparing an amount in
another currency throws \LogicException, and so does a subtraction that would
end below zero.
$held->add(new Money('10', 'JPY'));
// LogicException: Cannot mix JPY with EUR.
Moving money against a payment¶
The amount given to capture(), refund() and release() has to be in the
payment's currency. Every figure on a payment is in its currency, and so is
every movement against it.
// $payment holds 1.234 KWD.
$payment_client->capture($payment, new Money('0.617', 'KWD'), FALSE);
$payment_client->release($payment, new Money('617', 'JPY'));
// InvalidArgumentException: the payment is in KWD.
A payment with a stored payment method names its currency the same way:
$payment_client->chargeStoredPaymentMethod(new Money('30.00', 'EUR'), $account_uuid, 'fee', $subject);
Storage¶
A payment's amount, captured, released and refunded, and a movement's
amount, are kessai_amount fields: decimal columns of precision 19 and
scale 4, beside one three-letter currency column per record. Fifteen digits
before the point and four after fit every ISO 4217 currency, CLF and UYW
included. A stored value means what it says: 12.3400 in a EUR row is 12.34 EUR.
The currency is kept once per record rather than once per amount, because every amount on a payment, and every movement against it, is in the payment's currency. A currency per amount would be columns that have to agree.
The largest amount a payment holds is the column's, 999999999999999.9999, and
a larger one is refused with \InvalidArgumentException when the payment is
created or the money is moved.
SQLite holds less. It stores a decimal column as a float, and PHP reads a float back with 14 significant digits. On SQLite the largest amount is 9999999999.9999: ten digits before the point and four after. A larger one is refused the same way, so no figure is ever read back rounded.
Only the entity classes touch those columns. Everything else sets and reads
a Money through RecordEntityInterface::setAmount(), getAmount() and the
payment's other getters and setters. A guard test,
AmountColumnsStayBehindTheEntityTest, fails when code anywhere else names an
amount column. Moving to integer minor units one day is then a change to the
field definitions, the entity accessors and the formatter, and nothing else.
kessai_amount is a field type of its own rather than core's decimal. Core's
DecimalItem::preSave() rounds the value through a float, and PHP writes a
float with 14 significant digits, so 99999999999.9999 would be stored as
100000000000. AmountItem writes the figure with bcmath and never turns it
into a float. Views treats it as it treats a decimal: the column is numeric.
Display¶
The four shipped views show each amount with the kessai_amount formatter
("Amount in its currency"), which writes the figure with as many decimals as its
currency has: 1.234 for a dinar, 1000 for yen. Core's decimal formatter
shows a fixed number of decimals for every currency.
On the wire¶
An amount crosses between two Kessai as one object:
"amount": {"value": "12.34", "currency": "EUR"}
See Remote.