diff --git a/config/simple-commerce.php b/config/simple-commerce.php index 61e50c969..26e4a9140 100644 --- a/config/simple-commerce.php +++ b/config/simple-commerce.php @@ -26,7 +26,7 @@ Calculator\ApplyCouponDiscounts::class, Calculator\ApplyShipping::class, Calculator\CalculateTaxes::class, - Calculator\CalculateGrandTotal::class, + Calculator\CalculateTotals::class, ], ], @@ -59,17 +59,17 @@ 'dummy' => [ // ], +// +// 'stripe' => [ +// 'key' => env('STRIPE_KEY'), +// 'secret' => env('STRIPE_SECRET'), +// 'webhook_secret' => env('STRIPE_WEBHOOK_SECRET'), +// ], - 'stripe' => [ - 'key' => env('STRIPE_KEY'), - 'secret' => env('STRIPE_SECRET'), - 'webhook_secret' => env('STRIPE_WEBHOOK_SECRET'), + 'mollie' => [ + 'api_key' => env('MOLLIE_KEY'), + 'profile_id' => env('MOLLIE_PROFILE_ID'), ], - -// 'mollie' => [ -// 'api_key' => env('MOLLIE_KEY'), -// 'profile_id' => env('MOLLIE_PROFILE_ID'), -// ], ], ], diff --git a/resources/js/components/fieldtypes/OrderReceipt/LineItem.vue b/resources/js/components/fieldtypes/OrderReceipt/LineItem.vue index c7757fa16..ce17fb9bc 100644 --- a/resources/js/components/fieldtypes/OrderReceipt/LineItem.vue +++ b/resources/js/components/fieldtypes/OrderReceipt/LineItem.vue @@ -10,7 +10,7 @@
{{ lineItem.unit_price }}
{{ lineItem.quantity }}
-
{{ lineItem.total }}
+
{{ lineItem.sub_total }}
grandTotal( - (($cart->subTotal() + $cart->taxTotal()) - $cart->discountTotal()) + $cart->shippingTotal() - ); - - return $next($cart); - } -} diff --git a/src/Cart/Calculator/CalculateLineItems.php b/src/Cart/Calculator/CalculateLineItems.php index 2dd1df3f5..83a41fca1 100644 --- a/src/Cart/Calculator/CalculateLineItems.php +++ b/src/Cart/Calculator/CalculateLineItems.php @@ -23,13 +23,12 @@ public function handle(Cart $cart, Closure $next) }; $lineItem->unitPrice($price); - $lineItem->total($price * $lineItem->quantity()); + $lineItem->subTotal($price * $lineItem->quantity()); + $lineItem->total($lineItem->subTotal()); return $lineItem; }); - $cart->subTotal($cart->lineItems()->map->total()->sum()); - return $next($cart); } } diff --git a/src/Cart/Calculator/CalculateTotals.php b/src/Cart/Calculator/CalculateTotals.php new file mode 100644 index 000000000..dfeaa55ab --- /dev/null +++ b/src/Cart/Calculator/CalculateTotals.php @@ -0,0 +1,34 @@ +subTotal($cart->lineItems()->map->subTotal()->sum()); + + // Calculate the total (subtotal + taxes if they aren't included in the prices) + $total = $cart->subTotal(); + + if (! $pricesIncludeTax) { + $total += $cart->lineItems()->map->taxTotal()->sum(); + } + + // Apply any discounts to the total before adding shipping. + $total = $total - $cart->discountTotal(); + + // Add shipping costs to the total + $total += $cart->shippingTotal(); + + $cart->grandTotal($total); + + return $next($cart); + } +} diff --git a/src/Cart/Calculator/ResetTotals.php b/src/Cart/Calculator/ResetTotals.php index 1ab226bb9..8156cfb3c 100644 --- a/src/Cart/Calculator/ResetTotals.php +++ b/src/Cart/Calculator/ResetTotals.php @@ -20,9 +20,10 @@ public function handle(Cart $cart, Closure $next) $cart->remove('shipping_tax_breakdown'); $cart->lineItems()->transform(function (LineItem $lineItem) { - $lineItem->total(0); $lineItem->unitPrice(0); $lineItem->taxTotal(0); + $lineItem->subTotal(0); + $lineItem->total(0); $lineItem->remove('tax_breakdown'); diff --git a/src/Cart/Cart.php b/src/Cart/Cart.php index 6f3e98608..33ae8772c 100644 --- a/src/Cart/Cart.php +++ b/src/Cart/Cart.php @@ -265,7 +265,7 @@ public function fingerprint(): string 'taxable_state' => $this->taxableAddress()?->state, 'taxable_country' => $this->taxableAddress()?->country, 'taxable_post_code' => $this->taxableAddress()?->postcode, - 'config' => config('statamic.simple-commerce.taxes'), + 'tax_config' => config('statamic.simple-commerce.taxes'), ]; return sha1(json_encode($payload)); diff --git a/src/Fieldtypes/OrderReceiptFieldtype.php b/src/Fieldtypes/OrderReceiptFieldtype.php index d2bd797ce..08ca10dc7 100644 --- a/src/Fieldtypes/OrderReceiptFieldtype.php +++ b/src/Fieldtypes/OrderReceiptFieldtype.php @@ -35,6 +35,7 @@ public function preProcess($data) ] : null, 'unit_price' => Money::format($lineItem->unitPrice(), Site::selected()), 'quantity' => $lineItem->quantity(), + 'sub_total' => Money::format($lineItem->subTotal(), Site::selected()), 'total' => Money::format($lineItem->total(), Site::selected()), ])->all(), 'coupon' => $order->coupon() ? [ diff --git a/src/Orders/AugmentedLineItem.php b/src/Orders/AugmentedLineItem.php index 69e1139cd..f0ba6837c 100644 --- a/src/Orders/AugmentedLineItem.php +++ b/src/Orders/AugmentedLineItem.php @@ -2,9 +2,9 @@ namespace DuncanMcClean\SimpleCommerce\Orders; +use DuncanMcClean\SimpleCommerce\Fieldtypes\MoneyFieldtype; use Statamic\Data\AbstractAugmented; use Statamic\Fields\Value; -use Statamic\Support\Arr; use Statamic\Support\Str; class AugmentedLineItem extends AbstractAugmented @@ -35,7 +35,7 @@ public function get($handle): Value { // These fields have methods on the LineItem class. However, we don't want to call those methods, // we want to use the underlying properties. - if (in_array($handle, ['product', 'quantity', 'unit_price', 'total', 'tax_total'])) { + if (in_array($handle, ['product', 'quantity', 'unit_price', 'sub_total', 'tax_total', 'total'])) { $value = new Value( fn () => $this->data->{Str::camel($handle)}, $handle, diff --git a/src/Orders/AugmentedOrder.php b/src/Orders/AugmentedOrder.php index 8046673db..82bbd5895 100644 --- a/src/Orders/AugmentedOrder.php +++ b/src/Orders/AugmentedOrder.php @@ -32,6 +32,7 @@ private function commonKeys(): array 'customer', 'coupon', 'shipping_method', + 'shipping_option', 'tax_breakdown', ]; @@ -68,6 +69,15 @@ public function shippingMethod() ]; } + public function shippingOption() + { + if (! $this->data->shippingOption()) { + return null; + } + + return $this->data->shippingOption()->toAugmentedArray(); + } + public function status() { if (! $this->data instanceof Order) { diff --git a/src/Orders/LineItem.php b/src/Orders/LineItem.php index f9a1552ce..1d7b1b5d0 100644 --- a/src/Orders/LineItem.php +++ b/src/Orders/LineItem.php @@ -20,8 +20,9 @@ class LineItem public $variant; public $quantity; public $unitPrice; - public $total; + public $subTotal; public $taxTotal; + public $total; public function __construct() { @@ -91,10 +92,10 @@ public function unitPrice() ->args(func_get_args()); } - public function total($total = null) + public function subTotal() { return $this - ->fluentlyGetOrSet('total') + ->fluentlyGetOrSet('subTotal') ->args(func_get_args()); } @@ -105,6 +106,13 @@ public function taxTotal($taxTotal = null) ->args(func_get_args()); } + public function total($total = null) + { + return $this + ->fluentlyGetOrSet('total') + ->args(func_get_args()); + } + public function defaultAugmentedArrayKeys() { return []; @@ -112,7 +120,7 @@ public function defaultAugmentedArrayKeys() public function shallowAugmentedArrayKeys() { - return ['id', 'product', 'variant', 'quantity', 'unit_price', 'total', 'tax_total']; + return ['id', 'product', 'variant', 'quantity', 'unit_price', 'sub_total', 'tax_total', 'total']; } public function newAugmentedInstance(): Augmented @@ -133,8 +141,9 @@ public function fileData(): array 'variant' => $this->variant, 'quantity' => $this->quantity, 'unit_price' => $this->unitPrice, - 'total' => $this->total, + 'sub_total' => $this->subTotal, 'tax_total' => $this->taxTotal, + 'total' => $this->total, ])->filter()->all(); } } diff --git a/src/Orders/LineItemBlueprint.php b/src/Orders/LineItemBlueprint.php index 5a2c46941..b821658b9 100644 --- a/src/Orders/LineItemBlueprint.php +++ b/src/Orders/LineItemBlueprint.php @@ -14,6 +14,8 @@ public function __invoke(): StatamicBlueprint 'variant' => ['type' => 'text'], 'quantity' => ['type' => 'integer'], 'unit_price' => ['type' => 'money', 'save_zero_value' => true], + 'sub_total' => ['type' => 'money', 'save_zero_value' => true], + 'tax_total' => ['type' => 'money', 'save_zero_value' => true], 'total' => ['type' => 'money', 'save_zero_value' => true], ])->setHandle('line_item'); } diff --git a/src/Orders/LineItems.php b/src/Orders/LineItems.php index 213822ec5..f4ddd5ca2 100644 --- a/src/Orders/LineItems.php +++ b/src/Orders/LineItems.php @@ -16,8 +16,9 @@ public function create(array $data): self ->variant(Arr::pull($data, 'variant')) ->quantity((int) Arr::pull($data, 'quantity')) ->unitPrice(Arr::pull($data, 'unit_price')) - ->total(Arr::pull($data, 'total', 0)) + ->subTotal(Arr::pull($data, 'sub_total', 0)) ->taxTotal(Arr::pull($data, 'tax_total', 0)) + ->total(Arr::pull($data, 'total', 0)) ->data(collect($data)); $this->push($lineItem); diff --git a/src/Payments/Gateways/Mollie.php b/src/Payments/Gateways/Mollie.php index e7184eea4..ab7a44b86 100644 --- a/src/Payments/Gateways/Mollie.php +++ b/src/Payments/Gateways/Mollie.php @@ -5,6 +5,7 @@ use DuncanMcClean\SimpleCommerce\Contracts\Cart\Cart; use DuncanMcClean\SimpleCommerce\Contracts\Orders\Order; use DuncanMcClean\SimpleCommerce\Orders\LineItem; +use DuncanMcClean\SimpleCommerce\Shipping\ShippingOption; use DuncanMcClean\SimpleCommerce\SimpleCommerce; use Illuminate\Http\Request; use Illuminate\Http\Response; @@ -38,25 +39,31 @@ public function __construct() public function setup(Cart $cart): array { // todo: ensure the existing payment has the correct totals, if not, they should be updated. - // if ($cart->get('mollie_payment_id')) { - // $payment = $this->mollie->payments->get($cart->get('mollie_payment_id')); - // - // return ['checkout_url' => $payment->getCheckoutUrl()]; - // } +// if ($cart->get('mollie_payment_id')) { +// $payment = $this->mollie->payments->get($cart->get('mollie_payment_id')); +// +// return ['checkout_url' => $payment->getCheckoutUrl()]; +// } + +// dd($cart); +// $payment = $this->mollie->payments->create([ - 'description' => config('app.name').' '.$cart->id(), // todo: this is visible to the customer, but the order doesn't exist yet, so we have to use the cart ID + 'description' => "Pending Order: {$cart->id()}", 'amount' => $this->formatAmount(site: $cart->site(), amount: $cart->grandTotal()), 'redirectUrl' => $this->checkoutUrl(), // 'webhookUrl' => $this->webhookUrl(), 'lines' => $cart->lineItems() ->map(function (LineItem $lineItem) use ($cart) { + // Mollie expects the unit price to include taxes. However, we only apply taxes to the line item total. + // So, we need to do some calculations to figure out what the unit price would be including tax. + $unitPrice = ($lineItem->total() + $lineItem->get('discount_amount', 0)) / $lineItem->quantity(); + return [ - 'type' => 'physical', // todo: digital products + 'type' => 'physical', 'description' => $lineItem->product()->get('title'), 'quantity' => $lineItem->quantity(), - // todo: make sure this amount is INCLUDING taxes - 'unitPrice' => $this->formatAmount(site: $cart->site(), amount: $lineItem->unitPrice()), + 'unitPrice' => $this->formatAmount(site: $cart->site(), amount: $unitPrice), 'discountAmount' => $lineItem->has('discount_amount') ? $this->formatAmount(site: $cart->site(), amount: $lineItem->get('discount_amount')) : null, @@ -66,33 +73,32 @@ public function setup(Cart $cart): array 'productUrl' => $lineItem->product()->absoluteUrl(), ]; }) - ->when($cart->shippingOption(), function ($lines, $shippingOption) use ($cart) { - // todo: handle shipping taxes here + ->when($cart->shippingOption(), function ($lines, ShippingOption $shippingOption) use ($cart) { return $lines->push([ 'type' => 'shipping_fee', 'description' => $shippingOption->name(), 'quantity' => 1, - 'unitPrice' => $this->formatAmount(site: $cart->site(), amount: $shippingOption->price()), - 'totalAmount' => $this->formatAmount(site: $cart->site(), amount: $shippingOption->price()), - // 'vatRate' => 0, - // 'vatAmount' => $this->formatAmount(site: $cart->site(), amount: 0), + 'unitPrice' => $this->formatAmount(site: $cart->site(), amount: $cart->shippingTotal()), + 'totalAmount' => $this->formatAmount(site: $cart->site(), amount: $cart->shippingTotal()), + 'vatRate' => collect($cart->get('shipping_tax_breakdown'))->sum('rate'), + 'vatAmount' => $this->formatAmount(site: $cart->site(), amount: $cart->get('shipping_tax_total', 0)), ]); }) ->values()->all(), - 'billingAddress' => array_filter([ + 'billingAddress' => $cart->hasBillingAddress() ? array_filter([ 'streetAndNumber' => $cart->billingAddress()?->line1, 'streetAdditional' => $cart->billingAddress()?->line2, 'postalCode' => $cart->billingAddress()?->postcode, 'city' => $cart->billingAddress()?->city, 'country' => Arr::get($cart->billingAddress()?->country()?->data(), 'iso2'), - ]), - 'shippingAddress' => array_filter([ + ]) : null, + 'shippingAddress' => $cart->hasShippingAddress() ? array_filter([ 'streetAndNumber' => $cart->shippingAddress()?->line1, 'streetAdditional' => $cart->shippingAddress()?->line2, 'postalCode' => $cart->shippingAddress()?->postcode, 'city' => $cart->shippingAddress()?->city, 'country' => Arr::get($cart->shippingAddress()?->country()?->data(), 'iso2'), - ]), + ]) : null, 'locale' => $cart->site()->locale(), 'metadata' => [ 'cart_id' => $cart->id(), @@ -105,13 +111,6 @@ public function setup(Cart $cart): array return ['checkout_url' => $payment->getCheckoutUrl()]; } - public function afterRecalculating(Cart $cart): void - { - if ($cart->get('mollie_payment_id')) { - $this->setup($cart); - } - } - public function process(Order $order): void { $order->set('payment_gateway', static::handle())->save(); @@ -149,7 +148,7 @@ private function formatAmount(Site $site, int $amount): array { return [ 'currency' => Str::upper($site->attribute('currency')), - 'value' => (string) substr_replace($amount, '.', -2, 0), + 'value' => (string) number_format($amount / 100, 2, '.', ''), ]; } } diff --git a/src/Payments/Gateways/PaymentGateway.php b/src/Payments/Gateways/PaymentGateway.php index 8a11aeb28..b2c60355a 100644 --- a/src/Payments/Gateways/PaymentGateway.php +++ b/src/Payments/Gateways/PaymentGateway.php @@ -18,11 +18,6 @@ abstract class PaymentGateway abstract public function setup(Cart $cart): array; - public function afterRecalculating(Cart $cart): void - { - // - } - abstract public function process(Order $order): void; abstract public function capture(Order $order): void; diff --git a/src/Payments/Gateways/Stripe.php b/src/Payments/Gateways/Stripe.php index 64a3ccd5f..60e53a903 100644 --- a/src/Payments/Gateways/Stripe.php +++ b/src/Payments/Gateways/Stripe.php @@ -85,13 +85,6 @@ public function setup(Cart $cart): array ]; } - public function afterRecalculating(Cart $cart): void - { - if ($cart->get('stripe_payment_intent')) { - $this->setup($cart); - } - } - public function process(Order $order): void { PaymentIntent::update($order->get('stripe_payment_intent'), [ diff --git a/src/Payments/PaymentServiceProvider.php b/src/Payments/PaymentServiceProvider.php index ee0ddd38b..24848817c 100644 --- a/src/Payments/PaymentServiceProvider.php +++ b/src/Payments/PaymentServiceProvider.php @@ -2,9 +2,6 @@ namespace DuncanMcClean\SimpleCommerce\Payments; -use DuncanMcClean\SimpleCommerce\Events\CartRecalculated; -use DuncanMcClean\SimpleCommerce\Facades\PaymentGateway; -use Illuminate\Support\Facades\Event; use Statamic\Providers\AddonServiceProvider; class PaymentServiceProvider extends AddonServiceProvider @@ -20,13 +17,5 @@ public function bootAddon() foreach ($this->paymentGateways as $paymentGateway) { $paymentGateway::register(); } - - Event::listen(CartRecalculated::class, function ($event) { - if ($event->cart->isFree()) { - return; - } - - PaymentGateway::all()->each(fn ($gateway) => $gateway->afterRecalculating($event->cart)); - }); } -} \ No newline at end of file +} diff --git a/tests/Feature/Cart/CanCalculateGrandTotalTest.php b/tests/Feature/Cart/CanCalculateGrandTotalTest.php deleted file mode 100644 index cd888f85c..000000000 --- a/tests/Feature/Cart/CanCalculateGrandTotalTest.php +++ /dev/null @@ -1,25 +0,0 @@ -subTotal(5000) - ->taxTotal(1000) - ->discountTotal(500) - ->shippingTotal(500); - - $cart = app(CalculateGrandTotal::class)->handle($cart, fn ($cart) => $cart); - - $this->assertEquals(6000, $cart->grandTotal()); - } -} diff --git a/tests/Feature/Cart/CanCalculateTotalsTest.php b/tests/Feature/Cart/CanCalculateTotalsTest.php new file mode 100644 index 000000000..89e627ad9 --- /dev/null +++ b/tests/Feature/Cart/CanCalculateTotalsTest.php @@ -0,0 +1,103 @@ +save(); + Entry::make()->id('product-id')->collection('products')->data(['price' => 500])->save(); + } + + #[Test] + public function calculates_grand_total_correctly_when_prices_include_tax() + { + config()->set('statamic.simple-commerce.taxes.price_includes_tax', true); + + $cart = Cart::make() + ->lineItems([ + [ + 'product' => 'product-id', + 'quantity' => 1, + 'unit_price' => 500, + 'sub_total' => 500, + 'tax_total' => 20, + 'total' => 500, + ], + ]) + ->subTotal(500) + ->shippingTotal(500) + ->set('shipping_tax_total', 20) + ->taxTotal(40); + + $cart = app(CalculateTotals::class)->handle($cart, fn ($cart) => $cart); + + $this->assertEquals(1000, $cart->grandTotal()); + } + + #[Test] + public function calculates_grand_total_correctly_when_prices_exclude_tax() + { + config()->set('statamic.simple-commerce.taxes.price_includes_tax', false); + + $cart = Cart::make() + ->lineItems([ + [ + 'product' => 'product-id', + 'quantity' => 1, + 'unit_price' => 500, + 'sub_total' => 500, + 'tax_total' => 20, + 'total' => 520, + ], + ]) + ->subTotal(500) + ->shippingTotal(520) + ->set('shipping_tax_total', 20) + ->taxTotal(40); + + $cart = app(CalculateTotals::class)->handle($cart, fn ($cart) => $cart); + + $this->assertEquals(1040, $cart->grandTotal()); + } + + #[Test] + public function discount_total_is_subtracted_from_grand_total() + { + config()->set('statamic.simple-commerce.taxes.price_includes_tax', true); + + $cart = Cart::make() + ->lineItems([ + [ + 'product' => 'product-id', + 'quantity' => 1, + 'unit_price' => 500, + 'sub_total' => 500, + 'tax_total' => 20, + 'total' => 500, + ], + ]) + ->subTotal(500) + ->shippingTotal(500) + ->set('shipping_tax_total', 20) + ->taxTotal(40) + ->discountTotal(400); + + $cart = app(CalculateTotals::class)->handle($cart, fn ($cart) => $cart); + + $this->assertEquals(600, $cart->grandTotal()); + } +} diff --git a/tests/Feature/Customers/GuestCustomerTest.php b/tests/Feature/Customers/GuestCustomerTest.php index d28beaef6..04287f4a2 100644 --- a/tests/Feature/Customers/GuestCustomerTest.php +++ b/tests/Feature/Customers/GuestCustomerTest.php @@ -23,7 +23,7 @@ public function it_can_convert_a_guest_customer_to_a_user(): void $this ->actingAs(User::make()->makeSuper()->save()) - ->post(cp_route('simple-commerce.convert-guest-to-user'), [ + ->post(cp_route('simple-commerce.fieldtypes.convert-guest-customer'), [ 'email' => 'cj.cregg@example.com', 'order_id' => $orderA->id(), ]) @@ -45,7 +45,7 @@ public function it_can_convert_a_guest_customer_to_a_user_when_user_already_exis $this ->actingAs(User::make()->makeSuper()->save()) - ->post(cp_route('simple-commerce.convert-guest-to-user'), [ + ->post(cp_route('simple-commerce.fieldtypes.convert-guest-customer'), [ 'email' => 'cj.cregg@example.com', 'order_id' => $order->id(), ]) diff --git a/tests/Feature/Payments/StripeTest.php b/tests/Feature/Payments/StripeTest.php index 37670bf09..3376c1dc8 100644 --- a/tests/Feature/Payments/StripeTest.php +++ b/tests/Feature/Payments/StripeTest.php @@ -101,25 +101,6 @@ public function setup_returns_existing_payment_intent_if_one_exists() $this->assertEquals($stripePaymentIntent->id, $cart->get('stripe_payment_intent')); } - #[Test] - public function payment_intent_amount_is_updated_after_totals_are_recalculated() - { - $stripePaymentIntent = PaymentIntent::create(['amount' => 1000, 'currency' => 'gbp']); - - $cart = $this->makeCartWithGuestCustomer(); - $cart->set('stripe_payment_intent', $stripePaymentIntent->id)->save(); - - $cart->grandTotal(2000)->saveWithoutRecalculating(); - - (new Stripe)->afterRecalculating($cart); - - $cart->fresh(); - $this->assertEquals($stripePaymentIntent->id, $cart->get('stripe_payment_intent')); - - $stripePaymentIntent = PaymentIntent::retrieve($cart->get('stripe_payment_intent')); - $this->assertEquals(2000, $stripePaymentIntent->amount); - } - #[Test] public function it_can_process_a_payment() { diff --git a/tests/__fixtures__/content/collections/products/product-id.md b/tests/__fixtures__/content/collections/products/product-id.md index 0e5a30ffe..ea9188dd1 100644 --- a/tests/__fixtures__/content/collections/products/product-id.md +++ b/tests/__fixtures__/content/collections/products/product-id.md @@ -1,5 +1,5 @@ --- id: product-id blueprint: product -price: 1000 +price: 500 ---