root/fMoney.php

Revision 577, 19.5 kB (checked in by wbond, 10 months ago)

Updated documentation to indicate when methods will throw the exception in an @throws tag, changed some magic methods ( methods) to @internal

LineHide Line Numbers
1 <?php
2 /**
3  * Represents a monetary value - USD are supported by default and others can be added via ::defineCurrency()
4  *
5  * @copyright  Copyright (c) 2008-2009 Will Bond
6  * @author     Will Bond [wb] <will@flourishlib.com>
7  * @license    http://flourishlib.com/license
8  *
9  * @package    Flourish
10  * @link       http://flourishlib.com/fMoney
11  *
12  * @version    1.0.0b2
13  * @changes    1.0.0b2  Fixed a bug with calling ::format() when a format callback is set, fixed `NULL` `$element` handling in ::getCurrencyInfo() [wb, 2009-03-24]
14  * @changes    1.0.0b   The initial implementation [wb, 2008-08-10]
15  */
16 class fMoney
17 {
18     // The following constants allow for nice looking callbacks to static methods
19     const defineCurrency           = 'fMoney::defineCurrency';
20     const getCurrencies            = 'fMoney::getCurrencies';
21     const getCurrencyInfo          = 'fMoney::getCurrencyInfo';
22     const getDefaultCurrency       = 'fMoney::getDefaultCurrency';
23     const registerFormatCallback   = 'fMoney::registerFormatCallback';
24     const registerUnformatCallback = 'fMoney::registerUnformatCallback';
25     const reset                    = 'fMoney::reset';
26     const setDefaultCurrency       = 'fMoney::setDefaultCurrency';
27    
28    
29     /**
30     * The number of decimal places to use for values
31     *
32     * @var integer
33     */
34     static private $currencies = array(
35         'USD' => array(
36             'name'      => 'United States Dollar',
37             'symbol'    => '$',
38             'precision' => 2,
39             'value'     => '1.00000000'
40         )
41     );
42    
43     /**
44     * The ISO code (three letters, e.g. 'USD') for the default currency
45     *
46     * @var string
47     */
48     static private $default_currency = NULL;
49    
50     /**
51     * A callback to process all money values through
52     *
53     * @var callback
54     */
55     static private $format_callback = NULL;
56    
57     /**
58     * A callback to remove money formatting and return a decimal number
59     *
60     * @var callback
61     */
62     static private $unformat_callback = NULL;
63    
64    
65     /**
66     * Allows adding a new currency, or modifying an existing one
67     *
68     * @param string  $iso_code   The ISO code (three letters, e.g. `'USD'`) for the currency
69     * @param string  $name       The name of the currency
70     * @param string  $symbol     The symbol for the currency
71     * @param integer $precision  The number of digits after the decimal separator to store
72     * @param string  $value      The value of the currency relative to some common standard between all currencies
73     * @return void
74     */
75     static public function defineCurrency($iso_code, $name, $symbol, $precision, $value)
76     {
77         self::$currencies[$iso_code] = array(
78             'name'      => $name,
79             'symbol'    => $symbol,
80             'precision' => $precision,
81             'value'     => $value
82         );
83     }
84    
85    
86     /**
87     * Lists all of the defined currencies
88     *
89     * @return array  The 3 letter ISO codes for all of the defined currencies
90     */
91     static public function getCurrencies()
92     {
93         return array_keys(self::$currencies);
94     }
95    
96    
97     /**
98     * Allows retrieving information about a currency
99     *
100     * @param string  $iso_code  The ISO code (three letters, e.g. `'USD'`) for the currency
101     * @param string  $element   The element to retrieve: `'name'`, `'symbol'`, `'precision'`, `'value'`
102     * @return mixed  An associative array of the currency info, or the element specified
103     */
104     static public function getCurrencyInfo($iso_code, $element=NULL)
105     {
106         if (!isset(self::$currencies[$iso_code])) {
107             throw new fProgrammerException(
108                 'The currency specified, %1$s, is not a valid currency. Must be one of: %2$s.',
109                 $iso_code,
110                 join(', ', array_keys(self::$currencies))
111             );
112         }
113        
114         if ($element === NULL) {
115             return self::$currencies[$iso_code];
116         }
117        
118         if (!isset(self::$currencies[$iso_code][$element])) {
119             throw new fProgrammerException(
120                 'The element specified, %1$s, is not valid. Must be one of: %2$s.',
121                 $element,
122                 join(', ', array_keys(self::$currencies[$iso_code]))
123             );
124         }
125        
126         return self::$currencies[$iso_code][$element];
127     }
128    
129    
130     /**
131     * Gets the default currency
132     *
133     * @return string  The ISO code of the default currency
134     */
135     static public function getDefaultCurrency()
136     {
137         return self::$default_currency;
138     }
139    
140    
141     /**
142     * Allows setting a callback to translate or modify any return values from ::format()
143     *
144     * @param  callback $callback  The callback to pass all fNumber objects to. Should accept an fNumber object and a string currency abbreviation and return a formatted string.
145     * @return void
146     */
147     static public function registerFormatCallback($callback)
148     {
149         if (is_string($callback) && strpos($callback, '::') !== FALSE) {
150             $callback = explode('::', $callback);   
151         }
152         self::$format_callback = $callback;
153     }
154    
155    
156     /**
157     * Allows setting a callback to clean any formatted values so they can be passed to fNumber
158     *
159     * @param  callback $callback  The callback to pass formatted strings to. Should accept a formatted string and a currency code and return a string suitable to passing to the fNumber constructor.
160     * @return void
161     */
162     static public function registerUnformatCallback($callback)
163     {
164         if (is_string($callback) && strpos($callback, '::') !== FALSE) {
165             $callback = explode('::', $callback);   
166         }
167         self::$unformat_callback = $callback;
168     }
169    
170    
171     /**
172     * Resets the configuration of the class
173     *
174     * @internal
175      *
176     * @return void
177     */
178     static public function reset()
179     {
180         self::$currencies = array(
181             'USD' => array(
182                 'name'      => 'United States Dollar',
183                 'symbol'    => '$',
184                 'precision' => 2,
185                 'value'     => '1.00000000'
186             )
187         );
188         self::$default_currency  = NULL;
189         self::$format_callback   = NULL;
190         self::$unformat_callback = NULL;   
191     }
192    
193    
194     /**
195     * Sets the default currency to use when creating fMoney objects
196     *
197     * @param string  $iso_code  The ISO code (three letters, e.g. `'USD'`) for the new default currency
198     * @return void
199     */
200     static public function setDefaultCurrency($iso_code)
201     {
202         if (!isset(self::$currencies[$iso_code])) {
203             throw new fProgrammerException(
204                 'The currency specified, %1$s, is not a valid currency. Must be one of: %2$s.',
205                 $iso_code,
206                 join(', ', array_keys(self::$currencies))
207             );
208         }
209        
210         self::$default_currency = $iso_code;
211     }
212    
213    
214     /**
215     * The raw monetary value
216     *
217     * @var fNumber
218     */
219     private $amount;
220    
221     /**
222     * The ISO code or the currency of this value
223     *
224     * @var string
225     */
226     private $currency;
227    
228    
229     /**
230     * Creates the monetary to represent, with an optional currency
231     *
232     * @throws fValidationException  When `$amount` is not a valid number/monetary value
233     *
234     * @param  fNumber|string $amount    The monetary value to represent, should never be a float since those are imprecise
235     * @param  string         $currency  The currency ISO code (three letters, e.g. `'USD'`) for this value
236     * @return fMoney
237     */
238     public function __construct($amount, $currency=NULL)
239     {
240         if ($currency !== NULL && !isset(self::$currencies[$currency])) {
241             throw new fProgrammerException(
242                 'The currency specified, %1$s, is not a valid currency. Must be one of: %2$s.',
243                 $abbreviation,
244                 join(', ', array_keys(self::$currencies))
245             );
246         }
247        
248         if ($currency === NULL && self::$default_currency === NULL) {
249             throw new fProgrammerException(
250                 'No currency was specified and no default currency has been set'
251             );
252         }
253        
254         $this->currency = ($currency !== NULL) ? $currency : self::$default_currency;
255        
256         $precision = self::getCurrencyInfo($this->currency, 'precision');
257        
258         // Unformat any money value
259         if (self::$unformat_callback !== NULL) {
260             $amount = call_user_func(self::$unformat_callback, $amount, $this->currency);
261         } else {
262             $amount = str_replace(
263                 array(
264                     self::getCurrencyInfo($this->currency, 'symbol'),
265                     ','
266                 ),
267                 '',
268                 ($amount instanceof fNumber) ? $amount->__toString() : $amount
269             );
270         }
271        
272         $this->amount = new fNumber($amount, $precision);
273     }
274    
275    
276     /**
277     * All requests that hit this method should be requests for callbacks
278     *
279     * @internal
280      *
281     * @param  string $method  The method to create a callback for
282     * @return callback  The callback for the method requested
283     */
284     public function __get($method)
285     {
286         return array($this, $method);       
287     }
288    
289    
290     /**
291     * Returns the monetary value without a currency symbol or thousand separator (e.g. `2000.12`)
292     *
293     * @return string  The monetary value without currency symbol or thousands separator
294     */
295     public function __toString()
296     {
297         return $this->amount->__toString();
298     }
299    
300    
301     /**
302     * Adds the passed monetary value to the current one
303     *
304     * @throws fValidationException  When `$addend` is not a valid number/monetary value
305     *
306     * @param  fMoney|string|integer $addend  The money object to add - a string or integer will be converted to the default currency (if defined)
307     * @return fMoney  The sum of the monetary values in this currency
308     */
309     public function add($addend)
310     {
311         $addend           = $this->makeMoney($addend);
312         $converted_addend = $addend->convert($this->currency)->amount;
313         $precision        = self::getCurrencyInfo($this->currency, 'precision');
314         $new_amount       = $this->amount->add($converted_addend, $precision+1)->round($precision);
315         return new fMoney($new_amount, $this->currency);
316     }
317    
318    
319     /**
320     * Splits the current value into multiple parts ensuring that the sum of the results is exactly equal to this amount
321     *
322     * This method takes two or more parameters. The parameters should each be
323     * fractions that when added together equal 1.
324     *
325     * @throws fValidationException  When one of the ratios is not a number
326     *
327     * @param  fNumber|string $ratio1  The ratio of the first amount to this amount
328     * @param  fNumber|string $ratio2  The ratio of the second amount to this amount
329     * @param  fNumber|string ...
330     * @return array  fMoney objects each with the appropriate ratio of the current amount
331     */
332     public function allocate($ratio1, $ratio2)
333     {
334         $ratios = func_get_args();
335        
336         $total = new fNumber('0', 10);
337         foreach ($ratios as $ratio) {
338             $total = $total->add($ratio);
339         }
340        
341         if (!$total->eq('1.0')) {
342             $ratio_values = array();
343             foreach ($ratios as $ratio) {
344                 $ratio_values[] = ($ratio instanceof fNumber) ? $ratio->__toString() : (string) $ratio;
345             }
346            
347             throw new fProgrammerException(
348                 'The ratios specified (%s) combined are not equal to 1',
349                 join(', ', $ratio_values)
350             );
351         }
352        
353         $precision = self::getCurrencyInfo($this->currency, 'precision');
354        
355         if ($precision == 0) {
356             $smallest_amount = new fNumber('1');
357         } else {
358             $smallest_amount = new fNumber('0.' . str_pad('', $precision-1, '0') . '1');
359         }
360         $smallest_money = new fMoney($smallest_amount, $this->currency);
361        
362         $monies = array();
363         $sum    = new fNumber('0', $precision);
364        
365         foreach ($ratios as $ratio) {
366             $new_amount = $this->amount->mul($ratio)->trunc($precision);
367             $sum        = $sum->add($new_amount, $precision+1)->round($precision);
368             $monies[] = new fMoney($new_amount, $this->currency);
369         }
370        
371         while ($sum->lt($this->amount)) {
372             foreach ($monies as &$money) {
373                 if ($sum->eq($this->amount)) {
374                     break 2;
375                 }
376                 $money = $money->add($smallest_money);
377                 $sum   = $sum->add($smallest_amount, $precision+1)->round($precision);
378             }
379         }
380        
381         return $monies;
382     }
383    
384    
385     /**
386     * Converts this money amount to another currency
387     *
388     * @param  string $new_currency  The ISO code (three letters, e.g. `'USD'`) for the new currency
389     * @return fMoney  A new fMoney object representing this amount in the new currency
390     */
391     public function convert($new_currency)
392     {
393         if ($new_currency == $this->currency) {
394             return $this;
395         }
396        
397         if (!isset(self::$currencies[$new_currency])) {
398             throw new fProgrammerException(
399                 'The currency specified, %1$s, is not a valid currency. Must be one of: %2$s.',
400                 $new_currency,
401                 join(', ', array_keys(self::$currencies))
402             );
403         }
404        
405         $currency_value     = self::getCurrencyInfo($this->currency, 'value');
406         $new_currency_value = self::getCurrencyInfo($new_currency, 'value');
407         $new_precision      = self::getCurrencyInfo($new_currency, 'precision');
408        
409         $new_amount = $this->amount->mul($currency_value, 8)->div($new_currency_value, $new_precision+1)->round($new_precision);
410          
411         return new fMoney($new_amount, $new_currency);
412     }
413    
414    
415     /**
416     * Checks to see if two monetary values are equal
417     *
418     * @throws fValidationException  When `$money` is not a valid number/monetary value
419     *
420     * @param  fMoney|string|integer $money  The money object to compare to - a string or integer will be converted to the default currency (if defined)
421     * @return boolean  If the monetary values are equal
422     */
423     public function eq($money)
424     {
425         $money = $this->makeMoney($money);
426         return $this->amount->eq($money->convert($this->currency)->amount);
427     }
428    
429    
430     /**
431     * Formats the amount by preceeding the amount with the currency symbol and adding thousands separators
432     *
433     * @return string  The formatted (and possibly converted) value
434     */
435     public function format()
436     {
437         if (self::$format_callback !== NULL) {
438             return call_user_func(self::$format_callback, $this->amount, $this->currency);
439         }
440        
441         // We can't use number_format() since it takes a float and we have a
442         // string that can not be losslessly converted to a float
443         $number   = $this->__toString();
444         $parts    = explode('.', $number);
445        
446         $integer  = $parts[0];
447         $fraction = (!isset($parts[1])) ? '' : $parts[1];
448        
449         $sign     = '';
450         if ($integer[0] == '-') {
451             $sign    = '-';
452             $integer = substr($integer, 1);
453         }
454        
455         $int_sections = array();
456         for ($i = strlen($integer)-3; $i > 0; $i -= 3) {
457             array_unshift($int_sections, substr($integer, $i, 3));
458         }
459         array_unshift($int_sections, substr($integer, 0, $i+3));
460        
461         $symbol   = self::getCurrencyInfo($this->currency, 'symbol');
462         $integer  = join(',', $int_sections);
463         $fraction = (strlen($fraction)) ? '.' . $fraction : '';
464        
465         return $sign . $symbol . $integer . $fraction;
466     }
467    
468    
469     /**
470     * Returns the fNumber object representing the amount
471     *
472     * @return fNumber  The amount of this monetary value
473     */
474     public function getAmount()
475     {
476         return $this->amount;
477     }
478    
479    
480     /**
481     * Returns the currency ISO code
482     *
483     * @return string  The currency ISO code (three letters, e.g. `'USD'`)
484     */
485     public function getCurrency()
486     {
487         return $this->currency;
488     }
489    
490    
491     /**
492     * Checks to see if this value is greater than the one passed
493     *
494     * @throws fValidationException  When `$money` is not a valid number/monetary value
495     *
496     * @param  fMoney|string|integer $money  The money object to compare to - a string or integer will be converted to the default currency (if defined)
497     * @return boolean  If this value is greater than the one passed
498     */
499     public function gt($money)
500     {
501         $money = $this->makeMoney($money);
502         return $this->amount->gt($money->convert($this->currency)->amount);
503     }
504    
505    
506     /**
507     * Checks to see if this value is greater than or equal to the one passed
508     *
509     * @throws fValidationException  When `$money` is not a valid number/monetary value
510     *
511     * @param  fMoney|string|integer $money  The money object to compare to - a string or integer will be converted to the default currency (if defined)
512     * @return boolean  If this value is greater than or equal to the one passed
513     */
514     public function gte($money)
515     {
516         $money = $this->makeMoney($money);
517         return $this->amount->gte($money->convert($this->currency)->amount);
518     }
519    
520    
521     /**
522     * Checks to see if this value is less than the one passed
523     *
524     * @throws fValidationException  When `$money` is not a valid number/monetary value
525     *
526     * @param  fMoney|string|integer $money  The money object to compare to - a string or integer will be converted to the default currency (if defined)
527     * @return boolean  If this value is less than the one passed
528     */
529     public function lt($money)
530     {
531         $money = $this->makeMoney($money);
532         return $this->amount->lt($money->convert($this->currency)->amount);
533     }
534    
535    
536     /**
537     * Checks to see if this value is less than or equal to the one passed
538     *
539     * @throws fValidationException  When `$money` is not a valid number/monetary value
540     *
541     * @param  fMoney|string|integer $money  The money object to compare to - a string or integer will be converted to the default currency (if defined)
542     * @return boolean  If this value is less than or equal to the one passed
543     */
544     public function lte($money)
545     {
546         $money = $this->makeMoney($money);
547         return $this->amount->lte($money->convert($this->currency)->amount);
548     }
549    
550    
551     /**
552     * Turns a string into an fMoney object if a default currency is defined
553     *
554     * @throws fValidationException  When `$money` is not a valid number/monetary value
555     *
556     * @param  fMoney|string|integer $money  The value to convert to an fMoney object
557     * @return fMoney  The converted value
558     */
559     private function makeMoney($money)
560     {
561         if ($money instanceof fMoney) {
562             return $money;
563         }   
564        
565         if (is_object($money) && is_callable(array($money, '__toString'))) {
566             $money = $money->__toString();   
567         } elseif (is_numeric($money) || is_object($money)) {
568             $money = (string) $money;   
569         }
570        
571         if (!is_string($money)) {
572             throw new fProgrammerException(
573                 'The money value specified, %s, is not an fMoney object, integer or string and is thus is invalid for this operation',
574                 $money
575             );   
576         }
577        
578         if (!self::$default_currency) {
579             throw new fProgrammerException(
580                 'A default currency must be set in order to convert strings or integers to fMoney objects on the fly'
581             );       
582         }
583        
584         return new fMoney($money);
585     }
586    
587    
588     /**
589     * Mupltiplies this monetary value times the number passed
590     *
591     * @throws fValidationException  When `$multiplicand` is not a valid number
592     *
593     * @param  fNumber|string|integer $multiplicand  The number of times to multiply this ammount - don't use a float since they are imprecise
594     * @return fMoney  The product of the monetary value and the multiplicand passed
595     */
596     public function mul($multiplicand)
597     {
598         $precision  = self::getCurrencyInfo($this->currency, 'precision');
599         $new_amount = $this->amount->mul($multiplicand, $precision+1)->round($precision);
600         return new fMoney($new_amount, $this->currency);
601     }
602    
603    
604     /**
605     * Subtracts the passed monetary value from the current one
606     *
607     * @throws fValidationException  When `$subtrahend` is not a valid number/monetary value
608     *
609     * @param  fMoney|string|integer $subtrahend  The money object to subtract - a string or integer will be converted to the default currency (if defined)
610     * @return fMoney  The difference of the monetary values in this currency
611     */
612     public function sub($subtrahend)
613     {
614         $subtrahend           = $this->makeMoney($subtrahend);
615         $converted_subtrahend = $subtrahend->convert($this->currency)->amount;
616         $precision            = self::getCurrencyInfo($this->currency, 'precision');
617         $new_amount           = $this->amount->sub($converted_subtrahend, $precision+1)->round($precision);
618         return new fMoney($new_amount, $this->currency);
619     }
620 }
621  
622  
623  
624 /**
625  * Copyright (c) 2008-2009 Will Bond <will@flourishlib.com>
626  *
627  * Permission is hereby granted, free of charge, to any person obtaining a copy
628  * of this software and associated documentation files (the "Software"), to deal
629  * in the Software without restriction, including without limitation the rights
630  * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
631  * copies of the Software, and to permit persons to whom the Software is
632  * furnished to do so, subject to the following conditions:
633  *
634  * The above copyright notice and this permission notice shall be included in
635  * all copies or substantial portions of the Software.
636  *
637  * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
638  * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
639  * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
640  * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
641  * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
642  * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
643  * THE SOFTWARE.
644  */