Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 59 additions & 0 deletions docs/docs/payment-gateways.md
Original file line number Diff line number Diff line change
Expand Up @@ -336,6 +336,65 @@ You can workaround this by setting up a tunneling service, like [Expose](https:/

You will need to update the `APP_URL` key in your `.env` while your tunnel is active, so the gateway points towards the tunnel.

## Pay on delivery
In some markets, you may wish to offer "Pay on delivery" instead of requiring payment upfront. To do this, simply add the built-in `pay_on_delivery` payment gateway to your gateways array:

```php
// config/statamic/cargo.php

'gateways' => [
'pay_on_delivery' => [], // [tl! add]
],
```

:::tip note
The "Pay on delivery" gateway will only be shown to customers when their selected shipping option supports it.

```php
// app/ShippingMethods/LocalPostageService.php

public function options(Cart $cart): Collection
{
return collect([
ShippingOption::make($this)
->name(__('Standard Shipping'))
->price(499)
->acceptsPaymentOnDelivery(true), // [tl! add]
]);
}
```
:::

When orders are placed using "Pay on delivery":
- They'll start with a status of "Payment Pending"
- When you ship the order, update the status to "Shipped"
- Once the delivery company has collected payment and confirmed delivery, update the status to "Payment Received"

Depending on the delivery company, you may be able to automate the final status update via a custom API integration.

### Payment Form

:::tip note
You don't need to copy this into your project if you're using the [built-in checkout flow](/frontend/checkout/prebuilt), as you'll already have it.
:::

To use the Pay on delivery gateway, copy and paste this template into your checkout flow:

::tabs
::tab antlers
```antlers
<form x-data="{ busy: false }" action="{{ checkout_url }}" method="POST" @submit="busy = true">
<button>Place Order</button>
</form>
```
::tab blade
```blade
<form x-data="{ busy: false }" action="{{ $checkout_url }}" method="POST" @submit="busy = true">
<button>Place Order</button>
</form>
```
::

## Build your own
If you need to use a payment processor that Cargo doesn't support out-of-the-box, it's pretty easy to build your own payment gateway.

Expand Down
2 changes: 2 additions & 0 deletions docs/docs/shipping.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ class RoyalMail extends ShippingMethod

The `options` method should return a collection of `ShippingOption` objects. The name and the price are displayed to the customer during checkout.

If you want to accept [payment on delivery](/docs/payment-gateways#pay-on-delivery), you'll also need to chain `->acceptsPaymentOnDelivery(true)` when creating shipping options.

You can optionally provide a `fieldtypeDetails` method to your shipping method, allowing you to display information about the shipment in the Control Panel, under the "Shipping" tab:

```php
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<form x-data="{ busy: false }" action="{{ checkout_url }}" method="POST" @submit="busy = true">
<div>
{{ partial:checkout/components/button label="Place Order" type="submit" }}
</div>
</form>
3 changes: 2 additions & 1 deletion src/Cart/Cart.php
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,8 @@ public function shippingOption(): ?ShippingOption
return ShippingOption::make($this->shippingMethod())
->name(Arr::get($this->get('shipping_option'), 'name'))
->handle(Arr::get($this->get('shipping_option'), 'handle'))
->price(Arr::get($this->get('shipping_option'), 'price'));
->price(Arr::get($this->get('shipping_option'), 'price'))
->acceptsPaymentOnDelivery(Arr::get($this->get('shipping_option'), 'accepts_payment_on_delivery'));
}

public function paymentGateway(): ?PaymentGateway
Expand Down
1 change: 1 addition & 0 deletions src/Http/Controllers/CartPaymentGatewaysController.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ public function __invoke()
$cart = CartFacade::current();

return Facades\PaymentGateway::all()
->filter(fn (PaymentGateway $paymentGateway) => $paymentGateway->isAvailable($cart))
->map(function (PaymentGateway $paymentGateway) use ($cart) {
$setup = $cart->isFree() ? [] : $paymentGateway->setup($cart);

Expand Down
74 changes: 74 additions & 0 deletions src/Payments/Gateways/PayOnDelivery.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
<?php

namespace DuncanMcClean\Cargo\Payments\Gateways;

use DuncanMcClean\Cargo\Cargo;
use DuncanMcClean\Cargo\Contracts\Cart\Cart;
use DuncanMcClean\Cargo\Contracts\Orders\Order;
use DuncanMcClean\Cargo\Orders\TimelineEvent;
use DuncanMcClean\Cargo\Orders\TimelineEventTypes\OrderStatusChanged;
use DuncanMcClean\Cargo\Support\Money;
use Illuminate\Http\Request;
use Illuminate\Http\Response;

class PayOnDelivery extends PaymentGateway
{
protected static $title = 'Pay on delivery';

public function isAvailable(Cart $cart): bool
{
return $cart->shippingOption()?->acceptsPaymentOnDelivery() ?? false;
}

public function setup(Cart $cart): array
{
return [];
}

public function process(Order $order): void
{
//
}

public function capture(Order $order): void
{
//
}

public function cancel(Cart $cart): void
{
//
}

public function webhook(Request $request): Response
{
return response();
}

public function refund(Order $order, int $amount): void
{
$order->set('amount_refunded', $amount)->save();
}

public function logo(): ?string
{
return Cargo::svg('cargo-mark');
}

public function fieldtypeDetails(Order $order): array
{
$paymentReceivedEvent = $order->timelineEvents()
->filter(function (TimelineEvent $timelineEvent): bool {
return get_class($timelineEvent->type()) === OrderStatusChanged::class
&& $timelineEvent->metadata()->get('New Status') === 'payment_received';
})
->first();

return [
__('Amount') => Money::format($order->grandTotal(), $order->site()),
__('Payment Date') => $paymentReceivedEvent
? __(':datetime UTC', ['datetime' => $paymentReceivedEvent->datetime()->format('Y-m-d H:i:s')])
: __('Awaiting Payment'),
];
}
}
5 changes: 5 additions & 0 deletions src/Payments/Gateways/PaymentGateway.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,11 @@ abstract class PaymentGateway
{
use HasHandle, HasTitle, RegistersItself;

public function isAvailable(Cart $cart): bool
{
return true;
}

abstract public function setup(Cart $cart): array;

abstract public function process(Order $order): void;
Expand Down
1 change: 1 addition & 0 deletions src/Payments/PaymentServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ class PaymentServiceProvider extends AddonServiceProvider
protected array $paymentGateways = [
Gateways\Dummy::class,
Gateways\Mollie::class,
Gateways\PayOnDelivery::class,
Gateways\Stripe::class,
];

Expand Down
3 changes: 2 additions & 1 deletion src/Shipping/FreeShipping.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@ public function options(Cart $cart): Collection
return collect([
ShippingOption::make($this)
->name(__('Free Shipping'))
->price(0),
->price(0)
->acceptsPaymentOnDelivery(true),
]);
}

Expand Down
8 changes: 8 additions & 0 deletions src/Shipping/ShippingOption.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ class ShippingOption implements Augmentable, Purchasable
public $name;
public $handle;
public $price;
public $acceptsPaymentOnDelivery = false;
public $shippingMethod;

public static function make(ShippingMethod $shippingMethod): self
Expand Down Expand Up @@ -51,6 +52,11 @@ public function price($price = null)
return $this->fluentlyGetOrSet('price')->args(func_get_args());
}

public function acceptsPaymentOnDelivery($acceptsPaymentOnDelivery = null)
{
return $this->fluentlyGetOrSet('acceptsPaymentOnDelivery')->args(func_get_args());
}

public function shippingMethod($shippingMethod = null)
{
return $this->fluentlyGetOrSet('shippingMethod')
Expand Down Expand Up @@ -105,6 +111,7 @@ public function augmentedArrayData(): array
'name' => $this->name(),
'handle' => $this->handle(),
'price' => $this->price(),
'accepts_payment_on_delivery' => $this->acceptsPaymentOnDelivery(),
'shipping_method' => $this->shippingMethod(),
];
}
Expand All @@ -115,6 +122,7 @@ public function toArray(): array
'name' => $this->name(),
'handle' => $this->handle(),
'price' => $this->price(),
'accepts_payment_on_delivery' => $this->acceptsPaymentOnDelivery(),
];
}
}
1 change: 1 addition & 0 deletions src/Tags/PaymentGateways.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ public function index()

if (! Blink::has(self::BLINK_KEY)) {
$paymentGateways = Facades\PaymentGateway::all()
->filter(fn (PaymentGateway $paymentGateway) => $paymentGateway->isAvailable($cart))
->map(function (PaymentGateway $paymentGateway) use ($cart) {
$setup = $cart->isFree() ? [] : $paymentGateway->setup($cart);

Expand Down
86 changes: 86 additions & 0 deletions tests/Payments/PayOnDeliveryTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
<?php

namespace Payments;

use DuncanMcClean\Cargo\Contracts\Orders\Order as OrderContract;
use DuncanMcClean\Cargo\Facades\Cart;
use DuncanMcClean\Cargo\Facades\Order;
use DuncanMcClean\Cargo\Orders\OrderStatus;
use DuncanMcClean\Cargo\Payments\Gateways\Dummy;
use DuncanMcClean\Cargo\Payments\Gateways\PayOnDelivery;
use PHPUnit\Framework\Attributes\Group;
use PHPUnit\Framework\Attributes\Test;
use Statamic\Facades\Collection;
use Statamic\Facades\Entry;
use Tests\Fixtures\ShippingMethods\FakeShippingMethod;
use Tests\TestCase;

#[Group('payments')]
class PayOnDeliveryTest extends TestCase
{
#[Test]
public function it_can_determine_availability()
{
FakeShippingMethod::register();
config()->set('statamic.cargo.shipping.methods', ['fake_shipping_method' => []]);

$cart = $this->makeCartWithGuestCustomer();

// No shipping option selected.
$this->assertFalse((new PayOnDelivery)->isAvailable($cart));

// Shipping option selected, doesn't accept payment on delivery.
$cart
->set('shipping_method', 'fake_shipping_method')
->set('shipping_option', 'standard_shipping');

$this->assertFalse((new PayOnDelivery)->isAvailable($cart));

// Shipping option selected, accepts payment on delivery.
$cart
->set('shipping_method', 'fake_shipping_method')
->set('shipping_option', 'pay_on_delivery');

$this->assertTrue((new PayOnDelivery)->isAvailable($cart));
}

#[Test]
public function it_can_refund_an_order()
{
$order = $this->makeOrder();

(new Dummy)->refund($order, 500);

$this->assertEquals(500, $order->fresh()->get('amount_refunded'));
}

private function makeOrder(): OrderContract
{
$order = Order::make()
->status(OrderStatus::PaymentPending)
->grandTotal(1000)
->lineItems([
['product' => 'product-id', 'quantity' => 1, 'total' => 1000],
]);

$order->save();

return $order;
}

private function makeCartWithGuestCustomer()
{
Collection::make('products')->save();
Entry::make()->id('product-id')->collection('products')->data(['price' => 1000])->save();

$cart = Cart::make()
->lineItems([
['product' => 'product-id', 'quantity' => 1, 'total' => 1000],
])
->customer(['name' => 'David Hasselhoff', 'email' => '[email protected]']);

$cart->save();

return $cart;
}
}
2 changes: 1 addition & 1 deletion tests/Shipping/ShippingTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ public function shipping_method_can_return_multiple_options()
$options = $shippingMethod->options(Facades\Cart::make());

$this->assertInstanceOf(Collection::class, $options);
$this->assertCount(3, $options);
$this->assertCount(4, $options);

$this->assertInstanceOf(ShippingOption::class, $options->first());
$this->assertEquals('In-Store Pickup', $options->first()->name());
Expand Down
2 changes: 2 additions & 0 deletions tests/Tags/PaymentGatewayTagTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ public function it_outputs_available_payment_gateways()
{
Config::set('statamic.cargo.payments.gateways', [
'dummy' => [],
'pay_on_delivery' => [],
]);

$cart = tap(Cart::make()->grandTotal(1000))->saveWithoutRecalculating();
Expand All @@ -34,6 +35,7 @@ public function it_outputs_available_payment_gateways()
$output = $this->tag('{{ payment_gateways }}<option>{{ name }}</option>{{ /payment_gateways }}');

$this->assertStringContainsString('<option>Dummy</option>', $output);
$this->assertStringNotContainsString('<option>Pay on delivery</option>', $output);
}

private function tag($tag, $variables = [])
Expand Down
5 changes: 5 additions & 0 deletions tests/__fixtures__/app/ShippingMethods/FakeShippingMethod.php
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,11 @@ public function options(Cart $cart): Collection
ShippingOption::make($this)
->name('Express Shipping')
->price(1000),

ShippingOption::make($this)
->name('Pay On Delivery')
->price(1500)
->acceptsPaymentOnDelivery(true),
]);
}
}