Skip to content

Commit

Permalink
Add refund page with advanced refund process form
Browse files Browse the repository at this point in the history
The page allows to execute complex refund process
extendable by widgets and data providers. With
actual refund it:

- Stops or changes the subscription linked to the payment.
- Stops recurrent payment linked to the payment.
- Displays information about payment, subscription, and
their owner.

Dropdown list of payment statuses (used to change
the status) is extracted to widget and is now reused
across the different places. Refund selection doesn't
change the status immediately, but redirects user
to the refund page.

remp/crm#2960
  • Loading branch information
Patrik Hudák authored and rootpd committed Mar 13, 2024
1 parent 846e800 commit 687e57d
Show file tree
Hide file tree
Showing 20 changed files with 931 additions and 55 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<?php

namespace Crm\PaymentsModule\Components\PaymentStatusDropdownMenuWidget;

use Crm\ApplicationModule\Models\Widget\BaseLazyWidget;
use Nette\Database\Table\ActiveRow;

class PaymentStatusDropdownMenuWidget extends BaseLazyWidget
{
private string $templateName = 'payment_status_dropdown_menu_widget.latte';

public function identifier(): string
{
return 'paymentstatusdropdownmenuwidget';
}

public function render(ActiveRow $payment): void
{
$this->template->payment = $payment;
$this->template->setFile(__DIR__ . DIRECTORY_SEPARATOR . $this->templateName);
$this->template->render();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
{var $btn_class = 'btn-default'}
{if $payment->status == \Crm\PaymentsModule\Repositories\PaymentsRepository::STATUS_PAID}
{var $btn_class = 'btn-success'}
{elseif $payment->status == \Crm\PaymentsModule\Repositories\PaymentsRepository::STATUS_FORM}
{var $btn_class = 'btn-info'}
{elseif $payment->status == \Crm\PaymentsModule\Repositories\PaymentsRepository::STATUS_FAIL || $payment->status == \Crm\PaymentsModule\Repositories\PaymentsRepository::STATUS_TIMEOUT}
{var $btn_class = 'btn-danger'}
{/if}

<div class="dropdown clearfix">
<button class="btn {$btn_class} btn-sm dropdown-toggle" type="button" id="dropdownMenu1" data-toggle="dropdown" aria-expanded="true">
{$payment->status|firstUpper}
<span class="caret"></span>
</button>
<ul class="dropdown-menu" role="menu" aria-labelledby="dropdownMenu1">
<li role="presentation"><a role="menuitem" tabindex="-1" href="{plink :Payments:PaymentsAdmin:changeStatus status => \Crm\PaymentsModule\Repositories\PaymentsRepository::STATUS_FORM, payment => $payment->id}">Form</a></li>
<li role="presentation"><a role="menuitem" tabindex="-1" href="#" data-toggle="modal" data-target="#change-status-modal-{$payment->id}">Paid</a></li>
<li role="presentation"><a role="menuitem" tabindex="-1" href="{plink :Payments:PaymentsAdmin:changeStatus status => \Crm\PaymentsModule\Repositories\PaymentsRepository::STATUS_PREPAID, payment => $payment->id}">Prepaid</a></li>
<li role="presentation"><a role="menuitem" tabindex="-1" href="{plink :Payments:PaymentsAdmin:changeStatus status => \Crm\PaymentsModule\Repositories\PaymentsRepository::STATUS_FAIL, payment => $payment->id}">Fail</a></li>
<li role="presentation"><a role="menuitem" tabindex="-1" href="{plink :Payments:PaymentsAdmin:changeStatus status => \Crm\PaymentsModule\Repositories\PaymentsRepository::STATUS_TIMEOUT, payment => $payment->id}">Timeout</a></li>
<li role="presentation"><a role="menuitem" tabindex="-1" href="{plink :Payments:PaymentsRefundAdmin:default $payment->id}">Refund</a></li>
<li role="presentation"><a role="menuitem" tabindex="-1" href="#">Imported</a></li>
</ul>
</div>
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
<?php

namespace Crm\PaymentsModule\Components\RefundPaymentItemsListWidget;

use Crm\ApplicationModule\Models\Widget\BaseLazyWidget;

class RefundPaymentItemsListWidget extends BaseLazyWidget
{
private string $templateName = 'refund_payment_items_list_widget.latte';

public function identifier(): string
{
return 'refundpaymentitemslistwidget';
}

public function render(array $params): void
{
/* @var Nette\Database\Table\ActiveRow $payment */
$payment = $params['payment'];

$this->template->paymentItems = $payment->related('payment_items');

$this->template->setFile(__DIR__ . DIRECTORY_SEPARATOR . $this->templateName);
$this->template->render();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
<div class="panel panel-default">
<div class="panel-heading">
{_payments.admin.payments.show.payment_items}
</div>
<div class="panel-body">
<table class="table table-responsive table-striped">
<thead>
<tr>
<th>
{_payments.admin.payments.show.payment_item}
</th>
<th>
{_payments.admin.payments.show.payment_item_type}
</th>
<th>
{_payments.admin.payments.count}
</th>
<th>
{_payments.admin.payments.short_unit_price}
</th>
<th class="text-right">
{_payments.admin.payments.amount}
</th>
</tr>
</thead>
<tbody>
{var $totalAmount = 0}
{var $totalAmountWithoutVat = 0}
{foreach $paymentItems as $paymentItem}
<tr>
<td class="truncate-text">{$paymentItem->name}</td>
<td class="truncate-text">
<span class="label label-default">{$paymentItem->type}</span>
</td>
<td>{$paymentItem->count}</td>
<td>{$paymentItem->amount|price}</td>
<td class="text-right">{($paymentItem->amount * $paymentItem->count)|price}</td>
</tr>

{php $totalAmount += $paymentItem->amount * $paymentItem->count}
{php $totalAmountWithoutVat += $paymentItem->amount_without_vat * $paymentItem->count}
{/foreach}
</tbody>
</table>

<hr />

<div class="row">
<div class="col-sm-6 col-sm-offset-6 col-xs-12">
<table class="table table-clear table-left-label">
<tr class="text-right" style="font-size: 1.2em;">
<td>{_payments.admin.payments.amount}</td>
<td><b>{$totalAmount|price}</b></td>
</tr>
<tr class="text-right">
<td>{_payments.admin.payments.amount_without_vat}</td>
<td>{$totalAmountWithoutVat|price}</td>
</tr>
<tr class="text-right">
<td>{_payments.admin.payments.vat_rate}</td>
<td>{($totalAmount-$totalAmountWithoutVat)|price}</td>
</tr>
</table>
</div>
</div>
</div>
</div>
26 changes: 1 addition & 25 deletions src/Components/UserPaymentsListing/user_payments_listing.latte
Original file line number Diff line number Diff line change
Expand Up @@ -54,31 +54,7 @@
</td>

<td>
<div class="dropdown clearfix">
{var $btn_class = 'btn-default'}
{if $payment->status == \Crm\PaymentsModule\Repositories\PaymentsRepository::STATUS_PAID || $payment->status == \Crm\PaymentsModule\Repositories\PaymentsRepository::STATUS_IMPORTED}
{var $btn_class = 'btn-success'}
{elseif $payment->status == \Crm\PaymentsModule\Repositories\PaymentsRepository::STATUS_FORM}
{var $btn_class = 'btn-info'}
{elseif $payment->status == \Crm\PaymentsModule\Repositories\PaymentsRepository::STATUS_FAIL || $payment->status == \Crm\PaymentsModule\Repositories\PaymentsRepository::STATUS_TIMEOUT}
{var $btn_class = 'btn-danger'}
{/if}
<button class="btn {$btn_class} btn-sm dropdown-toggle" type="button" id="dropdownMenu1" data-toggle="dropdown" aria-expanded="true">
{$payment->status|firstUpper}
<span class="caret"></span>
</button>
<ul n:inner-foreach="$paymentStatuses as $paymentStatus" class="dropdown-menu" role="menu" aria-labelledby="dropdownMenu1">
{if $paymentStatus === \Crm\PaymentsModule\Repositories\PaymentsRepository::STATUS_PAID}
<li role="presentation">
<a role="menuitem" tabindex="-1" href="#" data-toggle="modal" data-target="#change-status-modal-{$payment->id}">{$paymentStatus|firstUpper}</a>
</li>
{else}
<li role="presentation">
<a role="menuitem" tabindex="-1" href="{plink :Payments:PaymentsAdmin:changeStatus status => $paymentStatus, payment => $payment->id}">{$paymentStatus|firstUpper}</a>
</li>
{/if}
</ul>
</div>
{control simpleWidget 'admin.payment.status.dropdown_menu', $payment}
</td>

<td>
Expand Down
16 changes: 16 additions & 0 deletions src/DataProviders/PaymentRefundFormDataProviderInterface.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<?php

namespace Crm\PaymentsModule\DataProviders;

use Crm\ApplicationModule\Models\DataProvider\DataProviderInterface;
use Nette\Application\UI\Form;
use Nette\Utils\ArrayHash;

interface PaymentRefundFormDataProviderInterface extends DataProviderInterface
{
const PATH = 'admin.dataprovider.payment_refund';

public function provide(array $params): Form;

public function formSucceeded(Form $form, ArrayHash $values): array;
}
199 changes: 199 additions & 0 deletions src/Forms/PaymentRefundFormFactory.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
<?php

namespace Crm\PaymentsModule\Forms;

use Crm\ApplicationModule\Models\DataProvider\DataProviderException;
use Crm\ApplicationModule\Models\DataProvider\DataProviderManager;
use Crm\PaymentsModule\DataProviders\PaymentRefundFormDataProviderInterface;
use Crm\PaymentsModule\Repositories\PaymentsRepository;
use Crm\PaymentsModule\Repositories\RecurrentPaymentsRepository;
use Crm\SubscriptionsModule\Events\SubscriptionShortenedEvent;
use Crm\SubscriptionsModule\Models\Subscription\StopSubscriptionHandler;
use Crm\SubscriptionsModule\Repositories\SubscriptionsRepository;
use DateTime;
use League\Event\Emitter;
use Nette\Application\UI\Form;
use Nette\Database\Table\ActiveRow;
use Nette\Localization\Translator;
use Nette\Utils\ArrayHash;
use Tomaj\Form\Renderer\BootstrapRenderer;

class PaymentRefundFormFactory
{
const PAYMENT_ID_KEY = 'payment_id';
const SUBSCRIPTION_ENDS_AT_KEY = 'subscription_ends_at';
const NEW_PAYMENT_STATUS = 'new_payment_status';
const STOP_RECURRENT_CHARGE_KEY = 'stop_recurrent_charge';

/** @var callable */
public $onSave;

public function __construct(
private DataProviderManager $dataProviderManager,
private PaymentsRepository $paymentsRepository,
private StopSubscriptionHandler $stopSubscriptionHandler,
private SubscriptionsRepository $subscriptionsRepository,
private RecurrentPaymentsRepository $recurrentPaymentsRepository,
private Translator $translator,
private Emitter $emitter,
) {
}

/**
* @throws DataProviderException
*/
public function create(int $paymentId): Form
{
$form = new Form();
$form->addProtection();
$form->setTranslator($this->translator);
$form->setRenderer(new BootstrapRenderer());

$payment = $this->paymentsRepository->find($paymentId);

$form->addHidden(self::PAYMENT_ID_KEY)->setRequired()->setDefaultValue($payment->id);

// For case, if you want to change final payment status via dataProvider
$form->addHidden(self::NEW_PAYMENT_STATUS)
->setRequired()
->setDefaultValue(PaymentsRepository::STATUS_REFUND);

$now = new DateTime();
if ($payment->subscription && $payment->subscription->end_time > $now) {
if ($payment->status != PaymentsRepository::STATUS_REFUND) {
$form->addText(
self::SUBSCRIPTION_ENDS_AT_KEY,
'payments.admin.payment_refund.form.cancel_subscription_date'
)
->setRequired('payments.admin.payment_refund.form.required.subscription_ends_at')
->setHtmlAttribute('class', 'flatpickr')
->setHtmlAttribute('flatpickr_datetime', "1")
->setHtmlAttribute('flatpickr_datetime_seconds', "1")
->setHtmlAttribute('flatpickr_mindate', $now->format('d.m.Y H:i:s'))
->setDefaultValue($now->format('Y-m-d H:i:s'));
}

$form->addHidden('subscription_default_ends_at')
->setRequired()
->setHtmlId('subscription_default_ends_at')
->setDefaultValue($payment->subscription->end_time);

$form->addHidden('subscription_starts_at')
->setRequired()
->setHtmlId('subscription_starts_at')
->setDefaultValue($payment->subscription->start_time);
}

if ($this->recurrentPaymentCanBeStoppedInRefund($payment) && $payment->status != PaymentsRepository::STATUS_REFUND) {
$form->addCheckbox(
self::STOP_RECURRENT_CHARGE_KEY,
'payments.admin.payment_refund.form.stop_recurrent_charge'
)
->setDisabled()
->setDefaultValue(true);
}

if ($payment->status != PaymentsRepository::STATUS_REFUND) {
$form->addSubmit('submit', 'payments.admin.payment_refund.confirm_refund')
->getControlPrototype()
->setName('button')
->setAttribute('class', 'btn btn-danger');
}

/** @var PaymentRefundFormDataProviderInterface[] $providers */
$providers = $this->dataProviderManager->getProviders(
PaymentRefundFormDataProviderInterface::PATH,
PaymentRefundFormDataProviderInterface::class
);
foreach ($providers as $provider) {
$form = $provider->provide(['form' => $form]);
}

$form->onSuccess[] = [$this, 'formSucceeded'];

return $form;
}

public function formSucceeded(Form $form, ArrayHash $values): void
{
$payment = $this->paymentsRepository->find($values[self::PAYMENT_ID_KEY]);
$newEndTime = $values[self::SUBSCRIPTION_ENDS_AT_KEY] ?? null;
$newPaymentStatus = $values[self::NEW_PAYMENT_STATUS] ?? $payment->status;
$stopRecurrentPayment = $values[self::STOP_RECURRENT_CHARGE_KEY] ?? true;

if ($newEndTime && $payment->subscription_id) {
$this->updateSubscriptionOnSuccess($payment->subscription, new DateTime($newEndTime));
}

if ($stopRecurrentPayment && $this->recurrentPaymentCanBeStoppedInRefund($payment)) {
$this->stopRecurrentChargeInRefundedPayment($payment);
}

$this->paymentsRepository->update($payment, ['status' => $newPaymentStatus]);

/** @var PaymentRefundFormDataProviderInterface[] $providers */
$providers = $this->dataProviderManager->getProviders(
PaymentRefundFormDataProviderInterface::PATH,
PaymentRefundFormDataProviderInterface::class
);
foreach ($providers as $provider) {
[$form, $values] = $provider->formSucceeded($form, $values);
}

if (isset($this->onSave)) {
($this->onSave)($values[self::PAYMENT_ID_KEY]);
}
}

protected function updateSubscriptionOnSuccess(ActiveRow $subscription, DateTime $newEndTime): void
{
if ($newEndTime <= new DateTime()) {
$this->stopSubscriptionHandler->stopSubscription($subscription, true);
} else {
$this->shortenSubscription($subscription, $newEndTime);
}
}

protected function recurrentPaymentCanBeStoppedInRefund(ActiveRow $payment): bool
{
$recurrentPayment = $this->recurrentPaymentsRepository->recurrent($payment);

if (!$recurrentPayment) {
return false;
}

$lastRecurrentPayment = $this->recurrentPaymentsRepository->getLastWithState(
$recurrentPayment,
RecurrentPaymentsRepository::STATE_ACTIVE,
);

return $lastRecurrentPayment
&& $this->recurrentPaymentsRepository->canBeStopped($lastRecurrentPayment);
}

protected function stopRecurrentChargeInRefundedPayment(ActiveRow $payment): void
{
$recurrentPayment = $this->recurrentPaymentsRepository->recurrent($payment);
$lastRecurrentPayment = $this->recurrentPaymentsRepository->getLastWithState(
$recurrentPayment,
RecurrentPaymentsRepository::STATE_ACTIVE,
);

$this->recurrentPaymentsRepository->stoppedByAdmin($lastRecurrentPayment);
}

protected function shortenSubscription(ActiveRow $subscription, DateTime $newEndTime): void
{
$note = '[Admin shortened] From ' . $subscription->end_time->format('Y-m-d H:i:s') . ' to ' . $newEndTime->format('Y-m-d H:i:s');
if (!empty($subscription->note)) {
$note = $subscription->note . "\n" . $note;
}

$this->subscriptionsRepository->update($subscription, [
'end_time' => $newEndTime,
'note' => $note,
]);

$this->emitter->emit(new SubscriptionShortenedEvent($subscription, $newEndTime));
}
}
Loading

0 comments on commit 687e57d

Please sign in to comment.