Skip to content

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.