diff --git a/composer.json b/composer.json index 68cbe75..fc3c721 100644 --- a/composer.json +++ b/composer.json @@ -25,16 +25,15 @@ }, "require-dev": { "laravel/pint": "^1.0", - "nunomaduro/collision": "^7.0", + "nunomaduro/collision": "^7.8", "nunomaduro/larastan": "^2.0.1", - "orchestra/testbench": "^8.0", + "orchestra/testbench": "^8.8", "pestphp/pest": "^2.0", + "pestphp/pest-plugin-arch": "^2.0", "pestphp/pest-plugin-laravel": "^2.0", "phpstan/extension-installer": "^1.1", "phpstan/phpstan-deprecation-rules": "^1.0", - "phpstan/phpstan-phpunit": "^1.0", - "phpunit/phpunit": "^10.0", - "spatie/laravel-ray": "^1.26" + "phpstan/phpstan-phpunit": "^1.0" }, "autoload": { "psr-4": { @@ -44,14 +43,39 @@ }, "autoload-dev": { "psr-4": { - "Finller\\Invoice\\Tests\\": "tests" + "Finller\\Invoice\\Tests\\": "tests", + "Workbench\\App\\": "workbench/app/", + "Workbench\\Database\\Factories\\": "workbench/database/factories/", + "Workbench\\Database\\Seeders\\": "workbench/database/seeders/" } }, "scripts": { + "post-autoload-dump": [ + "@clear", + "@prepare", + "@composer run prepare" + ], + "clear": "@php vendor/bin/testbench package:purge-skeleton --ansi", + "prepare": "@php vendor/bin/testbench package:discover --ansi", + "build": "@php vendor/bin/testbench workbench:build --ansi", + "start": [ + "Composer\\Config::disableProcessTimeout", + "@composer run build", + "@php vendor/bin/testbench serve" + ], "analyse": "vendor/bin/phpstan analyse", "test": "vendor/bin/pest", "test-coverage": "vendor/bin/pest --coverage", - "format": "vendor/bin/pint" + "format": "vendor/bin/pint", + "serve": [ + "Composer\\Config::disableProcessTimeout", + "@build", + "@php vendor/bin/testbench serve" + ], + "lint": [ + "@php vendor/bin/pint", + "@php vendor/bin/phpstan analyse" + ] }, "config": { "sort-packages": true, @@ -72,4 +96,4 @@ }, "minimum-stability": "dev", "prefer-stable": true -} +} \ No newline at end of file diff --git a/database/factories/InvoiceItemFactory.php b/database/factories/InvoiceItemFactory.php index f6c9cda..2a52b56 100644 --- a/database/factories/InvoiceItemFactory.php +++ b/database/factories/InvoiceItemFactory.php @@ -2,6 +2,7 @@ namespace Finller\Invoice\Database\Factories; +use Brick\Money\Money; use Finller\Invoice\InvoiceItem; use Illuminate\Database\Eloquent\Factories\Factory; @@ -11,17 +12,18 @@ class InvoiceItemFactory extends Factory public function definition() { - $price = fake()->numberBetween(100.00, 100000.00); + $price = Money::ofMinor(fake()->numberBetween(1000, 100000), config('invoices.default_currency')); + $unit_tax = Money::ofMinor(fake()->numberBetween(0, $price->getAmount()->toFloat()), config('invoices.default_currency')); $useTaxPercentage = fake()->boolean(); return [ 'label' => fake()->sentence(), 'description' => fake()->sentence(), - 'unit_price' => $price, - 'unit_tax' => ! $useTaxPercentage ? fake()->numberBetween(0, $price) : null, + 'unit_price' => $price->getMinorAmount()->toInt(), + 'currency' => $price->getCurrency()->getCurrencyCode(), + 'unit_tax' => ! $useTaxPercentage ? $unit_tax : null, 'tax_percentage' => $useTaxPercentage ? fake()->numberBetween(0, 100) : null, - 'currency' => fake()->currencyCode(), 'quantity' => fake()->numberBetween(1, 10), ]; } diff --git a/database/migrations/add_denormalized_columns_to_invoices_table.php.stub b/database/migrations/add_denormalized_columns_to_invoices_table.php.stub new file mode 100644 index 0000000..4c35dc7 --- /dev/null +++ b/database/migrations/add_denormalized_columns_to_invoices_table.php.stub @@ -0,0 +1,36 @@ +bigInteger('subtotal_amount')->nullable(); + $table->bigInteger('discount_amount')->nullable(); + $table->bigInteger('tax_amount')->nullable(); + $table->bigInteger('total_amount')->nullable(); + $table->string('currency')->nullable(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('invoices', function (Blueprint $table) { + $table->dropColumn('subtotal_amount'); + $table->dropColumn('discount_amount'); + $table->dropColumn('tax_amount'); + $table->dropColumn('total_amount'); + $table->dropColumn('currency'); + }); + } +}; diff --git a/database/migrations/add_type_column_to_invoices_table.php.stub b/database/migrations/add_type_column_to_invoices_table.php.stub index 1d2d464..220181b 100644 --- a/database/migrations/add_type_column_to_invoices_table.php.stub +++ b/database/migrations/add_type_column_to_invoices_table.php.stub @@ -13,7 +13,7 @@ return new class extends Migration { Schema::table('invoices', function (Blueprint $table) { $table->string('type')->default('invoice'); - $table->foreignId('parent_id')->nullable() + $table->foreignId('parent_id')->nullable(); }); } diff --git a/src/Commands/DenormalizeInvoicesCommand.php b/src/Commands/DenormalizeInvoicesCommand.php new file mode 100644 index 0000000..cf23a38 --- /dev/null +++ b/src/Commands/DenormalizeInvoicesCommand.php @@ -0,0 +1,44 @@ +argument('ids'); + + $model = config('invoices.model_invoice'); + + /** @var Builder $query */ + $query = $model::query(); + $query + ->with(['items']) + ->when($ids, fn (Builder $q) => $q->whereIn('id', $ids)); + + /** @var int */ + $total = $query->count(); + + $bar = $this->output->createProgressBar($total); + + $query + ->chunk(200, function (Collection $invoices) use ($bar) { + $invoices->each(function (Invoice $invoice) use ($bar) { + $invoice->denormalize()->saveQuietly(); + $bar->advance(); + }); + }); + + $bar->finish(); + + return self::SUCCESS; + } +} diff --git a/src/Invoice.php b/src/Invoice.php index 01f9f19..9e9338e 100644 --- a/src/Invoice.php +++ b/src/Invoice.php @@ -2,8 +2,10 @@ namespace Finller\Invoice; +use Brick\Money\Money; use Carbon\Carbon; use Finller\Invoice\Casts\Discounts; +use Finller\Money\MoneyCast; use Illuminate\Contracts\Mail\Attachable; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Casts\ArrayObject; @@ -24,7 +26,7 @@ * @property ?Invoice $credit * @property InvoiceType $type * @property string $serial_number - * @property ArrayObject $serial_number_details + * @property ?ArrayObject $serial_number_details * @property string $description * @property ?ArrayObject $seller_information * @property ?ArrayObject $buyer_information @@ -47,6 +49,11 @@ * @property Carbon $updated_at * @property null|InvoiceDiscount[] $discounts * @property ?ArrayObject $metadata + * @property ?Money $subtotal_amount + * @property ?Money $discount_amount + * @property ?Money $tax_amount + * @property ?Money $total_amount + * @property ?string $currency */ class Invoice extends Model implements Attachable { @@ -68,6 +75,10 @@ class Invoice extends Model implements Attachable 'metadata' => AsArrayObject::class, 'discounts' => Discounts::class, 'serial_number_details' => AsArrayObject::class, + 'total_amount' => MoneyCast::class.':currency', + 'subtotal_amount' => MoneyCast::class.':currency', + 'discount' => MoneyCast::class.':currency', + 'tax' => MoneyCast::class.':currency', ]; public static function booted() @@ -283,6 +294,22 @@ public function getDiscounts(): ?array return $this->discounts; } + /** + * Denormalize amounts computed from items to the invoice table + * Allowing easier query + */ + public function denormalize(): static + { + $pdfInvoice = $this->toPdfInvoice(); + $this->currency = $pdfInvoice->getCurrency(); + $this->subtotal_amount = $pdfInvoice->subTotalAmount(); + $this->discount_amount = $pdfInvoice->totalDiscountAmount(); + $this->tax_amount = $pdfInvoice->totalTaxAmount(); + $this->total_amount = $pdfInvoice->totalAmount(); + + return $this; + } + public function scopePaid(Builder $query): Builder { return $query->where('state', InvoiceState::Paid); diff --git a/src/InvoiceServiceProvider.php b/src/InvoiceServiceProvider.php index cc4cdbe..5ea0d38 100644 --- a/src/InvoiceServiceProvider.php +++ b/src/InvoiceServiceProvider.php @@ -22,6 +22,7 @@ public function configurePackage(Package $package): void ->hasMigration('create_invoices_table') ->hasMigration('create_invoice_items_table') ->hasMigration('add_discounts_column_to_invoices_table') - ->hasMigration('add_type_column_to_invoices_table'); + ->hasMigration('add_type_column_to_invoices_table') + ->hasMigration('add_denormalized_columns_to_invoices_table'); } } diff --git a/src/PdfInvoice.php b/src/PdfInvoice.php index 594aafb..c554e6e 100644 --- a/src/PdfInvoice.php +++ b/src/PdfInvoice.php @@ -49,6 +49,14 @@ public function getFilename(): string return $this->filename ?? $this->generateFilename(); } + public function getCurrency(): string + { + /** @var ?PdfInvoiceItem $firstItem */ + $firstItem = Arr::first($this->items); + + return $firstItem?->currency ?? config('invoices.default_currency'); + } + public function getLogo(): string { $type = pathinfo($this->logo, PATHINFO_EXTENSION); @@ -62,42 +70,26 @@ public function getLogo(): string */ public function subTotalAmount(): Money { - if (empty($this->items)) { - return Money::ofMinor(0, config('invoices.default_currency')); - } - - $firstItem = Arr::first($this->items); - - $currency = $firstItem->currency; - return array_reduce( $this->items, fn (Money $total, PdfInvoiceItem $item) => $total->plus($item->subTotalAmount()), - Money::of(0, $currency) + Money::of(0, $this->getCurrency()) ); } public function totalTaxAmount(): Money { - if (empty($this->items)) { - return Money::ofMinor(0, config('invoices.default_currency')); - } - - $firstItem = Arr::first($this->items); - - $currency = $firstItem->currency; - return array_reduce( $this->items, fn (Money $total, PdfInvoiceItem $item) => $total->plus($item->totalTaxAmount()), - Money::of(0, $currency) + Money::of(0, $this->getCurrency()) ); } public function totalDiscountAmount(): Money { if (! $this->discounts) { - return Money::of(0, $this->subTotalAmount()->getCurrency()); + return Money::of(0, $this->getCurrency()); } $subtotal = $this->subTotalAmount(); @@ -109,18 +101,10 @@ public function totalDiscountAmount(): Money public function totalAmount(): Money { - if (empty($this->items)) { - return Money::ofMinor(0, config('invoices.default_currency')); - } - - $firstItem = Arr::first($this->items); - - $currency = $firstItem->currency; - $total = array_reduce( $this->items, fn (Money $total, PdfInvoiceItem $item) => $total->plus($item->totalAmount()), - Money::of(0, $currency) + Money::of(0, $this->getCurrency()) ); return $total->minus($this->totalDiscountAmount()); diff --git a/tests/ArchTest.php b/tests/ArchTest.php new file mode 100644 index 0000000..ccc19b2 --- /dev/null +++ b/tests/ArchTest.php @@ -0,0 +1,5 @@ +expect(['dd', 'dump', 'ray']) + ->each->not->toBeUsed(); diff --git a/tests/Feature/InvoiceTest.php b/tests/Feature/InvoiceTest.php index 3b9603a..1e194c2 100644 --- a/tests/Feature/InvoiceTest.php +++ b/tests/Feature/InvoiceTest.php @@ -1,6 +1,7 @@ 1, ]); }); + +it('denormalize amounts in invoice', function () { + /** @var Invoice */ + $invoice = Invoice::factory()->make(); + $invoice->save(); + + $invoice->items()->saveMany(InvoiceItem::factory(2)->make()); + + $invoice->denormalize()->save(); + $pdfInvoice = $invoice->toPdfInvoice(); + + expect($invoice->subtotal_amount->getAmount()->toFloat()) + ->toEqual($pdfInvoice->subTotalAmount()->getAmount()->toFloat()); + + expect($invoice->discount_amount->getAmount()->toFloat()) + ->toEqual($pdfInvoice->totalDiscountAmount()->getAmount()->toFloat()); + + expect($invoice->tax_amount->getAmount()->toFloat()) + ->toEqual($pdfInvoice->totalTaxAmount()->getAmount()->toFloat()); + + expect($invoice->total_amount->getAmount()->toFloat()) + ->toEqual($pdfInvoice->totalAmount()->getAmount()->toFloat()); +}); diff --git a/tests/TestCase.php b/tests/TestCase.php index 277fd42..0cd0364 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -27,8 +27,17 @@ protected function getPackageProviders($app) public function getEnvironmentSetUp($app) { config()->set('database.default', 'testing'); + config()->set('money.default_currency', 'USD'); $migration = include __DIR__.'/../database/migrations/create_invoices_table.php.stub'; $migration->up(); + $migration = include __DIR__.'/../database/migrations/create_invoice_items_table.php.stub'; + $migration->up(); + $migration = include __DIR__.'/../database/migrations/add_type_column_to_invoices_table.php.stub'; + $migration->up(); + $migration = include __DIR__.'/../database/migrations/add_discounts_column_to_invoices_table.php.stub'; + $migration->up(); + $migration = include __DIR__.'/../database/migrations/add_denormalized_columns_to_invoices_table.php.stub'; + $migration->up(); } }