From 9eb957d951d26107a8e63577bee08bed293ec829 Mon Sep 17 00:00:00 2001 From: Dries Vints Date: Fri, 14 Jun 2019 21:19:10 +0200 Subject: [PATCH] Refactor Invoices These changes utilize the Money library to format money values from the invoice and invoice line objects. It makes use of the invoice's currency to make sure there's no currency conflict when trying to display the values. New Composer requirements: - moneyphp/money: used for formatting money values - symfony/intl: used for the above library to properly format money values - (optional) ext-intl: used for more locales when formatting money values This brings along some changes as well: - Removed the $currencySymbol setting on the Cashier object along with the useCurrencySymbol and usesCurrencySymbol methods - Removed the $symbol parameter from the useCurrency method on the Cashier object and its guessCurrencySymbol method - Refactored the formatAmount method on the Cashier object to accept an optional $currency parameter. By default it'll use the current set Currency. Also refactored its internals to use the Money library to format the value. - The starting balance is now no longer subtracted from the subtotal of an invoice - The rawTotal method now returns an integer instead of a float - A new tax() method is added to the Invoice object which returns the invoice tax formatted with its currency And finally the invoice pdf got a make over: - Subtotal is now displayed below the amount of each row and a total of all rows combined - Discount and Tax are displayed below subtotal - Starting balance is shown right above the total If there's no discount, tax or starting balance then subtotal isn't shown. The new position of the subtotal, discount, tax and balance also make it much more clear on what tax is calculated. --- composer.json | 7 +- resources/views/receipt.blade.php | 68 ++++++------- src/Cashier.php | 75 +++++--------- src/Invoice.php | 44 +++++---- src/InvoiceItem.php | 4 +- tests/Unit/InvoiceTest.php | 159 ++++++++++++++++++++++++++++++ 6 files changed, 249 insertions(+), 108 deletions(-) create mode 100644 tests/Unit/InvoiceTest.php diff --git a/composer.json b/composer.json index 9a2dc68c..af35c0c5 100644 --- a/composer.json +++ b/composer.json @@ -23,15 +23,20 @@ "illuminate/routing": "~5.8.0|~5.9.0", "illuminate/support": "~5.8.0|~5.9.0", "illuminate/view": "~5.8.0|~5.9.0", + "moneyphp/money": "^3.2", "nesbot/carbon": "^1.26.3|^2.0", "stripe/stripe-php": "^6.0", - "symfony/http-kernel": "^4.2" + "symfony/http-kernel": "^4.2", + "symfony/intl": "^4.3" }, "require-dev": { "mockery/mockery": "^1.0", "orchestra/testbench": "^3.8", "phpunit/phpunit": "^7.5" }, + "suggest": { + "ext-intl": "Allows for more locales besides the default \"en\" when formatting money values." + }, "autoload": { "psr-4": { "Laravel\\Cashier\\": "src/" diff --git a/resources/views/receipt.blade.php b/resources/views/receipt.blade.php index 8824201c..f96a5d4d 100644 --- a/resources/views/receipt.blade.php +++ b/resources/views/receipt.blade.php @@ -2,10 +2,10 @@ + Invoice - -
@@ -121,16 +114,9 @@ - - - - - - - @foreach ($invoice->invoiceItems() as $item) - + @@ -138,7 +124,7 @@ @foreach ($invoice->subscriptions() as $subscription) - + @endforeach + + @if ($invoice->hasDiscount() || $invoice->tax_percent || $invoice->hasStartingBalance()) + + + + + @endif + @if ($invoice->hasDiscount()) - @if ($invoice->discountIsPercentage()) - - @else - - @endif - + + @endif @@ -164,16 +160,22 @@ @if ($invoice->tax_percent) - - - + + + + @endif + + + @if ($invoice->hasStartingBalance()) + + + @endif - - - + +
Amount
Starting Balance {{ $invoice->startingBalance() }}
{{ $item->description }} {{ $item->total() }}
Subscription ({{ $subscription->quantity }}) {{ $subscription->startDateAsCarbon()->formatLocalized('%B %e, %Y') }} - @@ -148,15 +134,25 @@
Subtotal{{ $invoice->subtotal() }}
{{ $invoice->coupon() }} ({{ $invoice->percentOff() }}% Off){{ $invoice->coupon() }} ({{ $invoice->amountOff() }} Off)  + @if ($invoice->discountIsPercentage()) + {{ $invoice->coupon() }} ({{ $invoice->percentOff() }}% Off) + @else + {{ $invoice->coupon() }} ({{ $invoice->amountOff() }} Off) + @endif + -{{ $invoice->discount() }}
Tax ({{ $invoice->tax_percent }}%) {{ Laravel\Cashier\Cashier::formatAmount($invoice->tax) }}Tax ({{ $invoice->tax_percent }}%){{ $invoice->tax() }}
Customer Balance{{ $invoice->startingBalance() }}
 Total
Total {{ $invoice->total() }}
diff --git a/src/Cashier.php b/src/Cashier.php index ea0d0f7d..d83dab5a 100644 --- a/src/Cashier.php +++ b/src/Cashier.php @@ -2,8 +2,11 @@ namespace Laravel\Cashier; -use Exception; -use Illuminate\Support\Str; +use Money\Money; +use Money\Currency; +use NumberFormatter; +use Money\Currencies\ISOCurrencies; +use Money\Formatter\IntlMoneyFormatter; class Cashier { @@ -36,11 +39,14 @@ class Cashier protected static $currency = 'usd'; /** - * The current currency symbol. + * The locale used to format money values. + * + * To use more locales besides the default "en" locale, make + * sure you have the ext-intl installed on your environment. * * @var string */ - protected static $currencySymbol = '$'; + protected static $currencyLocale = 'en'; /** * The custom currency formatter. @@ -142,38 +148,11 @@ public static function stripeModel() * Set the currency to be used when billing Stripe models. * * @param string $currency - * @param string|null $symbol * @return void - * @throws \Exception */ - public static function useCurrency($currency, $symbol = null) + public static function useCurrency($currency) { static::$currency = $currency; - - static::useCurrencySymbol($symbol ?: static::guessCurrencySymbol($currency)); - } - - /** - * Guess the currency symbol for the given currency. - * - * @param string $currency - * @return string - * @throws \Exception - */ - protected static function guessCurrencySymbol($currency) - { - switch (strtolower($currency)) { - case 'usd': - case 'aud': - case 'cad': - return '$'; - case 'eur': - return '€'; - case 'gbp': - return '£'; - default: - throw new Exception('Unable to guess symbol for currency. Please explicitly specify it.'); - } } /** @@ -187,24 +166,14 @@ public static function usesCurrency() } /** - * Set the currency symbol to be used when formatting currency. + * Set the currency locale to format money. * - * @param string $symbol + * @param string $currencyLocale * @return void */ - public static function useCurrencySymbol($symbol) + public static function useCurrencyLocale($currencyLocale) { - static::$currencySymbol = $symbol; - } - - /** - * Get the currency symbol currently in use. - * - * @return string - */ - public static function usesCurrencySymbol() - { - return static::$currencySymbol; + static::$currencyLocale = $currencyLocale; } /** @@ -222,21 +191,21 @@ public static function formatCurrencyUsing(callable $callback) * Format the given amount into a displayable currency. * * @param int $amount + * @param string|null $currency * @return string */ - public static function formatAmount($amount) + public static function formatAmount($amount, $currency = null) { if (static::$formatCurrencyUsing) { - return call_user_func(static::$formatCurrencyUsing, $amount); + return call_user_func(static::$formatCurrencyUsing, $amount, $currency); } - $amount = number_format($amount / 100, 2); + $money = new Money($amount, new Currency(strtoupper($currency ?? static::usesCurrency()))); - if (Str::startsWith($amount, '-')) { - return '-'.static::usesCurrencySymbol().ltrim($amount, '-'); - } + $numberFormatter = new NumberFormatter(static::$currencyLocale, NumberFormatter::CURRENCY); + $moneyFormatter = new IntlMoneyFormatter($numberFormatter, new ISOCurrencies()); - return static::usesCurrencySymbol().$amount; + return $moneyFormatter->format($money); } /** diff --git a/src/Invoice.php b/src/Invoice.php index bf3de5a5..e1f97a11 100644 --- a/src/Invoice.php +++ b/src/Invoice.php @@ -63,11 +63,11 @@ public function total() /** * Get the raw total amount that was paid (or will be paid). * - * @return float + * @return int */ public function rawTotal() { - return max(0, $this->invoice->total - ($this->rawStartingBalance() * -1)); + return $this->invoice->total + $this->rawStartingBalance(); } /** @@ -77,9 +77,7 @@ public function rawTotal() */ public function subtotal() { - return $this->formatAmount( - max(0, $this->invoice->subtotal - ($this->rawStartingBalance() * -1)) - ); + return $this->formatAmount($this->invoice->subtotal); } /** @@ -102,6 +100,16 @@ public function startingBalance() return $this->formatAmount($this->rawStartingBalance()); } + /** + * Get the raw starting balance for the invoice. + * + * @return int + */ + public function rawStartingBalance() + { + return $this->invoice->starting_balance ?? 0; + } + /** * Determine if the invoice has a discount. * @@ -174,6 +182,16 @@ public function amountOff() return $this->formatAmount(0); } + /** + * Get the tax total amount. + * + * @return string + */ + public function tax() + { + return $this->formatAmount($this->invoice->tax); + } + /** * Get all of the "invoice item" line items. * @@ -216,14 +234,14 @@ public function invoiceItemsByType($type) } /** - * Format the given amount into a string based on the Stripe model's preferences. + * Format the given amount into a displayable currency. * * @param int $amount * @return string */ protected function formatAmount($amount) { - return Cashier::formatAmount($amount); + return Cashier::formatAmount($amount, $this->invoice->currency); } /** @@ -278,18 +296,6 @@ public function download(array $data) ]); } - /** - * Get the raw starting balance for the invoice. - * - * @return float - */ - public function rawStartingBalance() - { - return isset($this->invoice->starting_balance) - ? $this->invoice->starting_balance - : 0; - } - /** * Get the Stripe invoice instance. * diff --git a/src/InvoiceItem.php b/src/InvoiceItem.php index bcb6a5cd..11e8efe0 100644 --- a/src/InvoiceItem.php +++ b/src/InvoiceItem.php @@ -102,14 +102,14 @@ public function isSubscription() } /** - * Format the given amount into a string based on the owner model's preferences. + * Format the given amount into a displayable currency. * * @param int $amount * @return string */ protected function formatAmount($amount) { - return Cashier::formatAmount($amount); + return Cashier::formatAmount($amount, $this->item->currency); } /** diff --git a/tests/Unit/InvoiceTest.php b/tests/Unit/InvoiceTest.php new file mode 100644 index 00000000..f7aec7ea --- /dev/null +++ b/tests/Unit/InvoiceTest.php @@ -0,0 +1,159 @@ +created = 1560541724; + $invoice = new Invoice(new User(), $stripeInvoice); + + $date = $invoice->date(); + + $this->assertInstanceOf(Carbon::class, $date); + $this->assertEquals(1560541724, $date->unix()); + } + + public function test_it_can_return_the_invoice_date_with_a_timezone() + { + $stripeInvoice = new StripeInvoice(); + $stripeInvoice->created = 1560541724; + $invoice = new Invoice(new User(), $stripeInvoice); + + $date = $invoice->date('CET'); + + $this->assertInstanceOf(CarbonTimeZone::class, $timezone = $date->getTimezone()); + $this->assertEquals('CET', $timezone->getName()); + } + + public function test_it_can_return_its_total() + { + $stripeInvoice = new StripeInvoice(); + $stripeInvoice->total = 1000; + $stripeInvoice->currency = 'USD'; + $invoice = new Invoice(new User(), $stripeInvoice); + + $total = $invoice->total(); + + $this->assertEquals('$10.00', $total); + } + + public function test_it_can_return_its_raw_total() + { + $stripeInvoice = new StripeInvoice(); + $stripeInvoice->total = 1000; + $stripeInvoice->currency = 'USD'; + $invoice = new Invoice(new User(), $stripeInvoice); + + $total = $invoice->rawTotal(); + + $this->assertEquals(1000, $total); + } + + public function test_it_returns_a_lower_total_when_there_was_a_starting_balance() + { + $stripeInvoice = new StripeInvoice(); + $stripeInvoice->total = 1000; + $stripeInvoice->currency = 'USD'; + $stripeInvoice->starting_balance = -450; + $invoice = new Invoice(new User(), $stripeInvoice); + + $total = $invoice->total(); + + $this->assertEquals('$5.50', $total); + } + + public function test_it_can_return_its_subtotal() + { + $stripeInvoice = new StripeInvoice(); + $stripeInvoice->subtotal = 500; + $stripeInvoice->currency = 'USD'; + $invoice = new Invoice(new User(), $stripeInvoice); + + $subtotal = $invoice->subtotal(); + + $this->assertEquals('$5.00', $subtotal); + } + + public function test_it_can_determine_when_the_customer_has_a_starting_balance() + { + $stripeInvoice = new StripeInvoice(); + $stripeInvoice->starting_balance = -450; + $invoice = new Invoice(new User(), $stripeInvoice); + + $this->assertTrue($invoice->hasStartingBalance()); + } + + public function test_it_can_determine_when_the_customer_does_not_have_a_starting_balance() + { + $stripeInvoice = new StripeInvoice(); + $stripeInvoice->starting_balance = 0; + $invoice = new Invoice(new User(), $stripeInvoice); + + $this->assertFalse($invoice->hasStartingBalance()); + } + + public function test_it_can_return_its_starting_balance() + { + $stripeInvoice = new StripeInvoice(); + $stripeInvoice->starting_balance = -450; + $stripeInvoice->currency = 'USD'; + $invoice = new Invoice(new User(), $stripeInvoice); + + $startingBalance = $invoice->startingBalance(); + + $this->assertEquals('-$4.50', $startingBalance); + } + + public function test_it_can_return_its_raw_starting_balance() + { + $stripeInvoice = new StripeInvoice(); + $stripeInvoice->starting_balance = -450; + $invoice = new Invoice(new User(), $stripeInvoice); + + $startingBalance = $invoice->rawStartingBalance(); + + $this->assertEquals(-450, $startingBalance); + } + + public function test_it_can_determine_if_it_has_a_discount_applied() + { + $stripeInvoice = new StripeInvoice(); + $stripeInvoice->subtotal = 450; + $stripeInvoice->total = 500; + $stripeInvoice->discount = new Discount(); + $invoice = new Invoice(new User(), $stripeInvoice); + + $this->assertTrue($invoice->hasDiscount()); + } + + public function test_it_can_return_its_tax() + { + $stripeInvoice = new StripeInvoice(); + $stripeInvoice->tax = 50; + $stripeInvoice->currency = 'USD'; + $invoice = new Invoice(new User(), $stripeInvoice); + + $tax = $invoice->tax(); + + $this->assertEquals('$0.50', $tax); + } +}