Page MenuHomePhorge

D1249.1775326230.diff
No OneTemporary

Authored By
Unknown
Size
119 KB
Referenced Files
None
Subscribers
None

D1249.1775326230.diff

diff --git a/src/.env.example b/src/.env.example
--- a/src/.env.example
+++ b/src/.env.example
@@ -6,6 +6,8 @@
APP_PUBLIC_URL=
APP_DOMAIN=kolabnow.com
+SUPPORT_URL=
+
LOG_CHANNEL=stack
DB_CONNECTION=mysql
diff --git a/src/app/Console/Commands/WalletCharge.php b/src/app/Console/Commands/WalletCharge.php
--- a/src/app/Console/Commands/WalletCharge.php
+++ b/src/app/Console/Commands/WalletCharge.php
@@ -2,11 +2,7 @@
namespace App\Console\Commands;
-use App\Domain;
-use App\User;
-use Carbon\Carbon;
use Illuminate\Console\Command;
-use Illuminate\Support\Facades\DB;
class WalletCharge extends Command
{
@@ -44,19 +40,15 @@
$wallets = \App\Wallet::all();
foreach ($wallets as $wallet) {
- $charge = $wallet->expectedCharges();
+ $charge = $wallet->chargeEntitlements();
if ($charge > 0) {
$this->info(
- "charging wallet {$wallet->id} for user {$wallet->owner->email} with {$charge}"
+ "Charged wallet {$wallet->id} for user {$wallet->owner->email} with {$charge}"
);
- $wallet->chargeEntitlements();
-
- if ($wallet->balance < 0) {
- // Disabled for now
- // \App\Jobs\WalletPayment::dispatch($wallet);
- }
+ // Top-up the wallet if auto-payment enabled for the wallet
+ \App\Jobs\WalletCharge::dispatch($wallet);
}
}
}
diff --git a/src/app/Console/Commands/WalletExpected.php b/src/app/Console/Commands/WalletExpected.php
--- a/src/app/Console/Commands/WalletExpected.php
+++ b/src/app/Console/Commands/WalletExpected.php
@@ -2,11 +2,7 @@
namespace App\Console\Commands;
-use App\Domain;
-use App\User;
-use Carbon\Carbon;
use Illuminate\Console\Command;
-use Illuminate\Support\Facades\DB;
class WalletExpected extends Command
{
diff --git a/src/app/Console/Commands/WalletMandate.php b/src/app/Console/Commands/WalletMandate.php
new file mode 100644
--- /dev/null
+++ b/src/app/Console/Commands/WalletMandate.php
@@ -0,0 +1,70 @@
+<?php
+
+namespace App\Console\Commands;
+
+use App\Http\Controllers\API\V4\PaymentsController;
+use Illuminate\Console\Command;
+
+class WalletMandate extends Command
+{
+ /**
+ * The name and signature of the console command.
+ *
+ * @var string
+ */
+ protected $signature = 'wallet:mandate {wallet} {--disable}{--enable}';
+
+ /**
+ * The console command description.
+ *
+ * @var string
+ */
+ protected $description = 'Show expected charges to wallets';
+
+ /**
+ * Create a new command instance.
+ *
+ * @return void
+ */
+ public function __construct()
+ {
+ parent::__construct();
+ }
+
+ /**
+ * Execute the console command.
+ *
+ * @return mixed
+ */
+ public function handle()
+ {
+ $wallet = \App\Wallet::find($this->argument('wallet'));
+
+ if (!$wallet) {
+ return 1;
+ }
+
+ $mandate = PaymentsController::walletMandate($wallet);
+
+ if (!empty($mandate['id'])) {
+ $disabled = $mandate['isDisabled'] ? 'Yes' : 'No';
+
+ if ($this->option('disable') && $disabled == 'No') {
+ $wallet->setSetting('mandate_disabled', 1);
+ $disabled = 'Yes';
+ } elseif ($this->option('enable') && $disabled == 'Yes') {
+ $wallet->setSetting('mandate_disabled', null);
+ $disabled = 'No';
+ }
+
+ $this->info("Auto-payment: {$mandate['method']}");
+ $this->info(" id: {$mandate['id']}");
+ $this->info(" status: " . ($mandate['isPending'] ? 'pending' : 'valid'));
+ $this->info(" amount: {$mandate['amount']} {$wallet->currency}");
+ $this->info(" min-balance: {$mandate['balance']} {$wallet->currency}");
+ $this->info(" disabled: $disabled");
+ } else {
+ $this->info("Auto-payment: none");
+ }
+ }
+}
diff --git a/src/app/Http/Controllers/API/V4/PaymentsController.php b/src/app/Http/Controllers/API/V4/PaymentsController.php
--- a/src/app/Http/Controllers/API/V4/PaymentsController.php
+++ b/src/app/Http/Controllers/API/V4/PaymentsController.php
@@ -65,8 +65,10 @@
return response()->json(['status' => 'error', 'errors' => $errors], 422);
}
- $wallet->setSetting('mandate_amount', $request->amount);
- $wallet->setSetting('mandate_balance', $request->balance);
+ $wallet->setSettings([
+ 'mandate_amount' => $request->amount,
+ 'mandate_balance' => $request->balance,
+ ]);
$request = [
'currency' => 'CHF',
@@ -99,6 +101,8 @@
$provider->deleteMandate($wallet);
+ $wallet->setSetting('mandate_disabled', null);
+
return response()->json([
'status' => 'success',
'message' => \trans('app.mandate-delete-success'),
@@ -142,13 +146,31 @@
return response()->json(['status' => 'error', 'errors' => $errors], 422);
}
- $wallet->setSetting('mandate_amount', $request->amount);
- $wallet->setSetting('mandate_balance', $request->balance);
+ // If the mandate is disabled the update will trigger
+ // an auto-payment and the amount must cover the debt
+ if ($wallet->getSetting('mandate_disabled')) {
+ if ($wallet->balance < 0 && $wallet->balance + $amount < 0) {
+ $errors = ['amount' => \trans('validation.minamountdebt')];
+ return response()->json(['status' => 'error', 'errors' => $errors], 422);
+ }
- return response()->json([
- 'status' => 'success',
- 'message' => \trans('app.mandate-update-success'),
+ $wallet->setSetting('mandate_disabled', null);
+
+ if ($wallet->balance < intval($request->balance * 100)) {
+ \App\Jobs\WalletCharge::dispatch($wallet);
+ }
+ }
+
+ $wallet->setSettings([
+ 'mandate_amount' => $request->amount,
+ 'mandate_balance' => $request->balance,
]);
+
+ $result = self::walletMandate($wallet);
+ $result['status'] = 'success';
+ $result['message'] = \trans('app.mandate-update-success');
+
+ return response()->json($result);
}
/**
@@ -222,15 +244,43 @@
}
/**
- * Charge a wallet with a "recurring" payment.
+ * Top up a wallet with a "recurring" payment.
*
* @param \App\Wallet $wallet The wallet to charge
- * @param int $amount The amount of money in cents
*
- * @return bool
+ * @return bool True if the payment has been initialized
*/
- public static function directCharge(Wallet $wallet, $amount): bool
+ public static function topUpWallet(Wallet $wallet): bool
{
+ if ((bool) $wallet->getSetting('mandate_disabled')) {
+ return false;
+ }
+
+ $min_balance = (int) (floatval($wallet->getSetting('mandate_balance')) * 100);
+ $amount = (int) (floatval($wallet->getSetting('mandate_amount')) * 100);
+
+ // The wallet balance is greater than the auto-payment threshold
+ if ($wallet->balance >= $min_balance) {
+ // Do nothing
+ return false;
+ }
+
+ // The defined top-up amount is not enough
+ // Disable auto-payment and notify the user
+ if ($wallet->balance + $amount < 0) {
+ // Disable (not remove) the mandate
+ $wallet->setSetting('mandate_disabled', 1);
+ \App\Jobs\PaymentMandateDisabledEmail::dispatch($wallet);
+ return false;
+ }
+
+ $provider = PaymentProvider::factory($wallet);
+ $mandate = (array) $provider->getMandate($wallet);
+
+ if (empty($mandate['isValid'])) {
+ return false;
+ }
+
$request = [
'type' => PaymentProvider::TYPE_RECURRING,
'currency' => 'CHF',
@@ -238,13 +288,9 @@
'description' => \config('app.name') . ' Recurring Payment',
];
- $provider = PaymentProvider::factory($wallet);
-
- if ($result = $provider->payment($wallet, $request)) {
- return true;
- }
+ $result = $provider->payment($wallet, $request);
- return false;
+ return !empty($result);
}
/**
@@ -263,6 +309,7 @@
$mandate['amount'] = (int) (PaymentProvider::MIN_AMOUNT / 100);
$mandate['balance'] = 0;
+ $mandate['isDisabled'] = !empty($mandate['id']) && $wallet->getSetting('mandate_disabled');
foreach (['amount', 'balance'] as $key) {
if (($value = $wallet->getSetting("mandate_{$key}")) !== null) {
diff --git a/src/app/Http/Kernel.php b/src/app/Http/Kernel.php
--- a/src/app/Http/Kernel.php
+++ b/src/app/Http/Kernel.php
@@ -40,7 +40,7 @@
],
'api' => [
- 'throttle:60,1',
+ 'throttle:120,1',
'bindings',
],
];
diff --git a/src/app/Jobs/PaymentEmail.php b/src/app/Jobs/PaymentEmail.php
new file mode 100644
--- /dev/null
+++ b/src/app/Jobs/PaymentEmail.php
@@ -0,0 +1,94 @@
+<?php
+
+namespace App\Jobs;
+
+use App\Payment;
+use App\Providers\PaymentProvider;
+use App\User;
+use Illuminate\Bus\Queueable;
+use Illuminate\Contracts\Queue\ShouldQueue;
+use Illuminate\Foundation\Bus\Dispatchable;
+use Illuminate\Support\Facades\Mail;
+use Illuminate\Queue\SerializesModels;
+use Illuminate\Queue\InteractsWithQueue;
+
+class PaymentEmail implements ShouldQueue
+{
+ use Dispatchable;
+ use InteractsWithQueue;
+ use Queueable;
+ use SerializesModels;
+
+ /** @var int The number of times the job may be attempted. */
+ public $tries = 2;
+
+ /** @var int The number of seconds to wait before retrying the job. */
+ public $retryAfter = 10;
+
+ /** @var bool Delete the job if the wallet no longer exist. */
+ public $deleteWhenMissingModels = true;
+
+ /** @var \App\Payment A payment object */
+ protected $payment;
+
+ /** @var \App\User A wallet controller */
+ protected $controller;
+
+
+ /**
+ * Create a new job instance.
+ *
+ * @param \App\Payment $payment A payment object
+ * @param \App\User $controller A wallet controller
+ *
+ * @return void
+ */
+ public function __construct(Payment $payment, User $controller = null)
+ {
+ $this->payment = $payment;
+ $this->controller = $controller;
+ }
+
+ /**
+ * Execute the job.
+ *
+ * @return void
+ */
+ public function handle()
+ {
+ $wallet = $this->payment->wallet;
+
+ if (empty($this->controller)) {
+ $this->controller = $wallet->owner;
+ }
+
+ $ext_email = $this->controller->getSetting('external_email');
+ $cc = [];
+
+ if ($ext_email && $ext_email != $this->controller->email) {
+ $cc[] = $ext_email;
+ }
+
+ if ($this->payment->status == PaymentProvider::STATUS_PAID) {
+ $mail = new \App\Mail\PaymentSuccess($this->payment, $this->controller);
+ } elseif (
+ $this->payment->status == PaymentProvider::STATUS_EXPIRED
+ || $this->payment->status == PaymentProvider::STATUS_FAILED
+ ) {
+ $mail = new \App\Mail\PaymentFailure($this->payment, $this->controller);
+ } else {
+ return;
+ }
+
+ Mail::to($this->controller->email)->cc($cc)->send($mail);
+
+ /*
+ // Send the email to all wallet controllers too
+ if ($wallet->owner->id == $this->controller->id) {
+ $this->wallet->controllers->each(function ($controller) {
+ self::dispatch($this->payment, $controller);
+ }
+ });
+ */
+ }
+}
diff --git a/src/app/Jobs/PaymentMandateDisabledEmail.php b/src/app/Jobs/PaymentMandateDisabledEmail.php
new file mode 100644
--- /dev/null
+++ b/src/app/Jobs/PaymentMandateDisabledEmail.php
@@ -0,0 +1,83 @@
+<?php
+
+namespace App\Jobs;
+
+use App\Mail\PaymentMandateDisabled;
+use App\User;
+use App\Wallet;
+use Illuminate\Bus\Queueable;
+use Illuminate\Contracts\Queue\ShouldQueue;
+use Illuminate\Foundation\Bus\Dispatchable;
+use Illuminate\Support\Facades\Mail;
+use Illuminate\Queue\SerializesModels;
+use Illuminate\Queue\InteractsWithQueue;
+
+class PaymentMandateDisabledEmail implements ShouldQueue
+{
+ use Dispatchable;
+ use InteractsWithQueue;
+ use Queueable;
+ use SerializesModels;
+
+ /** @var int The number of times the job may be attempted. */
+ public $tries = 2;
+
+ /** @var int The number of seconds to wait before retrying the job. */
+ public $retryAfter = 10;
+
+ /** @var bool Delete the job if the wallet no longer exist. */
+ public $deleteWhenMissingModels = true;
+
+ /** @var \App\Wallet A wallet object */
+ protected $wallet;
+
+ /** @var \App\User A wallet controller */
+ protected $controller;
+
+
+ /**
+ * Create a new job instance.
+ *
+ * @param \App\Wallet $wallet A wallet object
+ * @param \App\User $controller An email recipient (wallet controller)
+ *
+ * @return void
+ */
+ public function __construct(Wallet $wallet, User $controller = null)
+ {
+ $this->wallet = $wallet;
+ $this->controller = $controller;
+ }
+
+ /**
+ * Execute the job.
+ *
+ * @return void
+ */
+ public function handle()
+ {
+ if (empty($this->controller)) {
+ $this->controller = $this->wallet->owner;
+ }
+
+ $ext_email = $this->controller->getSetting('external_email');
+ $cc = [];
+
+ if ($ext_email && $ext_email != $this->controller->email) {
+ $cc[] = $ext_email;
+ }
+
+ $mail = new PaymentMandateDisabled($this->wallet, $this->controller);
+
+ Mail::to($this->controller->email)->cc($cc)->send($mail);
+
+ /*
+ // Send the email to all controllers too
+ if ($this->controller->id == $this->wallet->owner->id) {
+ $this->wallet->controllers->each(function ($controller) {
+ self::dispatch($this->wallet, $controller);
+ }
+ });
+ */
+ }
+}
diff --git a/src/app/Jobs/WalletPayment.php b/src/app/Jobs/WalletCharge.php
rename from src/app/Jobs/WalletPayment.php
rename to src/app/Jobs/WalletCharge.php
--- a/src/app/Jobs/WalletPayment.php
+++ b/src/app/Jobs/WalletCharge.php
@@ -10,25 +10,30 @@
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
-class WalletPayment implements ShouldQueue
+class WalletCharge implements ShouldQueue
{
use Dispatchable;
use InteractsWithQueue;
use Queueable;
use SerializesModels;
+ /** @var \App\Wallet A wallet object */
protected $wallet;
+ /** @var int The number of seconds to wait before retrying the job. */
+ public $retryAfter = 10;
+
+ /** @var int How many times retry the job if it fails. */
public $tries = 5;
- /** @var bool Delete the job if its models no longer exist. */
+ /** @var bool Delete the job if the wallet no longer exist. */
public $deleteWhenMissingModels = true;
/**
* Create a new job instance.
*
- * @param \App\Wallet $wallet The wallet to charge.
+ * @param \App\Wallet $wallet The wallet that has been charged.
*
* @return void
*/
@@ -44,8 +49,6 @@
*/
public function handle()
{
- if ($this->wallet->balance < 0) {
- PaymentsController::directCharge($this->wallet, $this->wallet->balance * -1);
- }
+ PaymentsController::topUpWallet($this->wallet);
}
}
diff --git a/src/app/Mail/PasswordReset.php b/src/app/Mail/PasswordReset.php
--- a/src/app/Mail/PasswordReset.php
+++ b/src/app/Mail/PasswordReset.php
@@ -3,6 +3,7 @@
namespace App\Mail;
use App\User;
+use App\Utils;
use App\VerificationCode;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
@@ -37,11 +38,8 @@
*/
public function build()
{
- $href = sprintf(
- '%s/login/reset/%s-%s',
- \config('app.url'),
- $this->code->short_code,
- $this->code->code
+ $href = Utils::serviceUrl(
+ sprintf('/login/reset/%s-%s', $this->code->short_code, $this->code->code)
);
$this->view('emails.password_reset')
diff --git a/src/app/Mail/SuspendedDebtor.php b/src/app/Mail/PaymentFailure.php
copy from src/app/Mail/SuspendedDebtor.php
copy to src/app/Mail/PaymentFailure.php
--- a/src/app/Mail/SuspendedDebtor.php
+++ b/src/app/Mail/PaymentFailure.php
@@ -2,31 +2,37 @@
namespace App\Mail;
+use App\Payment;
use App\User;
use App\Utils;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Queue\SerializesModels;
-class SuspendedDebtor extends Mailable
+class PaymentFailure extends Mailable
{
use Queueable;
use SerializesModels;
- /** @var \App\User A suspended user (account) */
- protected $account;
+ /** @var \App\Payment A payment operation */
+ protected $payment;
+
+ /** @var \App\User A wallet controller to whom the email is being send */
+ protected $user;
/**
* Create a new message instance.
*
- * @param \App\User $account A suspended user (account)
+ * @param \App\Payment $payment A payment operation
+ * @param \App\User $user An email recipient
*
* @return void
*/
- public function __construct(User $account)
+ public function __construct(Payment $payment, User $user)
{
- $this->account = $account;
+ $this->payment = $payment;
+ $this->user = $user;
}
/**
@@ -36,26 +42,18 @@
*/
public function build()
{
- $user = $this->account;
-
- $subject = \trans('mail.suspendeddebtor-subject', ['site' => \config('app.name')]);
+ $user = $this->user;
- $moreInfo = null;
- if ($moreInfoUrl = \config('app.kb.account_suspended')) {
- $moreInfo = \trans('mail.more-info-html', ['href' => $moreInfoUrl]);
- }
+ $subject = \trans('mail.paymentfailure-subject', ['site' => \config('app.name')]);
- $this->view('emails.suspended_debtor')
+ $this->view('emails.payment_failure')
->subject($subject)
->with([
'site' => \config('app.name'),
'subject' => $subject,
'username' => $user->name(true),
- 'cancelUrl' => \config('app.kb.account_delete'),
- 'supportUrl' => \config('app.support_url'),
'walletUrl' => Utils::serviceUrl('/wallet'),
- 'moreInfo' => $moreInfo,
- 'days' => 14 // TODO: Configurable
+ 'supportUrl' => \config('app.support_url'),
]);
return $this;
@@ -68,9 +66,16 @@
*/
public static function fakeRender(): string
{
- $user = new User();
+ $payment = new Payment();
+ $user = new User([
+ 'email' => 'test@' . \config('app.domain'),
+ ]);
+
+ if (!\config('app.support_url')) {
+ \config(['app.support_url' => 'https://not-configured-support.url']);
+ }
- $mail = new self($user);
+ $mail = new self($payment, $user);
return $mail->build()->render();
}
diff --git a/src/app/Mail/SuspendedDebtor.php b/src/app/Mail/PaymentMandateDisabled.php
copy from src/app/Mail/SuspendedDebtor.php
copy to src/app/Mail/PaymentMandateDisabled.php
--- a/src/app/Mail/SuspendedDebtor.php
+++ b/src/app/Mail/PaymentMandateDisabled.php
@@ -4,29 +4,35 @@
use App\User;
use App\Utils;
+use App\Wallet;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Queue\SerializesModels;
-class SuspendedDebtor extends Mailable
+class PaymentMandateDisabled extends Mailable
{
use Queueable;
use SerializesModels;
- /** @var \App\User A suspended user (account) */
- protected $account;
+ /** @var \App\Wallet A wallet for which the mandate has been disabled */
+ protected $wallet;
+
+ /** @var \App\User A wallet controller to whom the email is being send */
+ protected $user;
/**
* Create a new message instance.
*
- * @param \App\User $account A suspended user (account)
+ * @param \App\Wallet $wallet A wallet that has been charged
+ * @param \App\User $user An email recipient
*
* @return void
*/
- public function __construct(User $account)
+ public function __construct(Wallet $wallet, User $user)
{
- $this->account = $account;
+ $this->wallet = $wallet;
+ $this->user = $user;
}
/**
@@ -36,26 +42,18 @@
*/
public function build()
{
- $user = $this->account;
-
- $subject = \trans('mail.suspendeddebtor-subject', ['site' => \config('app.name')]);
+ $user = $this->user;
- $moreInfo = null;
- if ($moreInfoUrl = \config('app.kb.account_suspended')) {
- $moreInfo = \trans('mail.more-info-html', ['href' => $moreInfoUrl]);
- }
+ $subject = \trans('mail.paymentmandatedisabled-subject', ['site' => \config('app.name')]);
- $this->view('emails.suspended_debtor')
+ $this->view('emails.payment_mandate_disabled')
->subject($subject)
->with([
'site' => \config('app.name'),
'subject' => $subject,
'username' => $user->name(true),
- 'cancelUrl' => \config('app.kb.account_delete'),
- 'supportUrl' => \config('app.support_url'),
'walletUrl' => Utils::serviceUrl('/wallet'),
- 'moreInfo' => $moreInfo,
- 'days' => 14 // TODO: Configurable
+ 'supportUrl' => \config('app.support_url'),
]);
return $this;
@@ -68,9 +66,16 @@
*/
public static function fakeRender(): string
{
- $user = new User();
+ $wallet = new Wallet();
+ $user = new User([
+ 'email' => 'test@' . \config('app.domain'),
+ ]);
+
+ if (!\config('app.support_url')) {
+ \config(['app.support_url' => 'https://not-configured-support.url']);
+ }
- $mail = new self($user);
+ $mail = new self($wallet, $user);
return $mail->build()->render();
}
diff --git a/src/app/Mail/SuspendedDebtor.php b/src/app/Mail/PaymentSuccess.php
copy from src/app/Mail/SuspendedDebtor.php
copy to src/app/Mail/PaymentSuccess.php
--- a/src/app/Mail/SuspendedDebtor.php
+++ b/src/app/Mail/PaymentSuccess.php
@@ -2,31 +2,37 @@
namespace App\Mail;
+use App\Payment;
use App\User;
use App\Utils;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Queue\SerializesModels;
-class SuspendedDebtor extends Mailable
+class PaymentSuccess extends Mailable
{
use Queueable;
use SerializesModels;
- /** @var \App\User A suspended user (account) */
- protected $account;
+ /** @var \App\Payment A payment operation */
+ protected $payment;
+
+ /** @var \App\User A wallet controller to whom the email is being send */
+ protected $user;
/**
* Create a new message instance.
*
- * @param \App\User $account A suspended user (account)
+ * @param \App\Payment $payment A payment operation
+ * @param \App\User $user An email recipient
*
* @return void
*/
- public function __construct(User $account)
+ public function __construct(Payment $payment, User $user)
{
- $this->account = $account;
+ $this->payment = $payment;
+ $this->user = $user;
}
/**
@@ -36,26 +42,18 @@
*/
public function build()
{
- $user = $this->account;
-
- $subject = \trans('mail.suspendeddebtor-subject', ['site' => \config('app.name')]);
+ $user = $this->user;
- $moreInfo = null;
- if ($moreInfoUrl = \config('app.kb.account_suspended')) {
- $moreInfo = \trans('mail.more-info-html', ['href' => $moreInfoUrl]);
- }
+ $subject = \trans('mail.paymentsuccess-subject', ['site' => \config('app.name')]);
- $this->view('emails.suspended_debtor')
+ $this->view('emails.payment_success')
->subject($subject)
->with([
'site' => \config('app.name'),
'subject' => $subject,
'username' => $user->name(true),
- 'cancelUrl' => \config('app.kb.account_delete'),
- 'supportUrl' => \config('app.support_url'),
'walletUrl' => Utils::serviceUrl('/wallet'),
- 'moreInfo' => $moreInfo,
- 'days' => 14 // TODO: Configurable
+ 'supportUrl' => \config('app.support_url'),
]);
return $this;
@@ -68,9 +66,16 @@
*/
public static function fakeRender(): string
{
- $user = new User();
+ $payment = new Payment();
+ $user = new User([
+ 'email' => 'test@' . \config('app.domain'),
+ ]);
+
+ if (!\config('app.support_url')) {
+ \config(['app.support_url' => 'https://not-configured-support.url']);
+ }
- $mail = new self($user);
+ $mail = new self($payment, $user);
return $mail->build()->render();
}
diff --git a/src/app/Mail/SignupVerification.php b/src/app/Mail/SignupVerification.php
--- a/src/app/Mail/SignupVerification.php
+++ b/src/app/Mail/SignupVerification.php
@@ -3,6 +3,7 @@
namespace App\Mail;
use App\SignupCode;
+use App\Utils;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Queue\SerializesModels;
@@ -36,11 +37,8 @@
*/
public function build()
{
- $href = sprintf(
- '%s/signup/%s-%s',
- \config('app.url'),
- $this->code->short_code,
- $this->code->code
+ $href = Utils::serviceUrl(
+ sprintf('/signup/%s-%s', $this->code->short_code, $this->code->code)
);
$username = $this->code->data['first_name'] ?? '';
diff --git a/src/app/Mail/SuspendedDebtor.php b/src/app/Mail/SuspendedDebtor.php
--- a/src/app/Mail/SuspendedDebtor.php
+++ b/src/app/Mail/SuspendedDebtor.php
@@ -70,6 +70,18 @@
{
$user = new User();
+ if (!\config('app.support_url')) {
+ \config(['app.support_url' => 'https://not-configured-support.url']);
+ }
+
+ if (!\config('app.kb.account_delete')) {
+ \config(['app.kb.account_delete' => 'https://not-configured-kb.url']);
+ }
+
+ if (!\config('app.kb.account_suspended')) {
+ \config(['app.kb.account_suspended' => 'https://not-configured-kb.url']);
+ }
+
$mail = new self($user);
return $mail->build()->render();
diff --git a/src/app/Providers/Payment/Mollie.php b/src/app/Providers/Payment/Mollie.php
--- a/src/app/Providers/Payment/Mollie.php
+++ b/src/app/Providers/Payment/Mollie.php
@@ -5,6 +5,7 @@
use App\Payment;
use App\Utils;
use App\Wallet;
+use Illuminate\Support\Facades\DB;
class Mollie extends \App\Providers\PaymentProvider
{
@@ -117,42 +118,9 @@
'id' => $mandate->id,
'isPending' => $mandate->isPending(),
'isValid' => $mandate->isValid(),
+ 'method' => self::paymentMethod($mandate, 'Unknown method')
];
- $details = $mandate->details;
-
- // Mollie supports 3 methods here
- switch ($mandate->method) {
- case 'creditcard':
- // If the customer started, but never finished the 'first' payment
- // card details will be empty, and mandate will be 'pending'.
- if (empty($details->cardNumber)) {
- $result['method'] = 'Credit Card';
- } else {
- $result['method'] = sprintf(
- '%s (**** **** **** %s)',
- $details->cardLabel ?: 'Card', // @phpstan-ignore-line
- $details->cardNumber
- );
- }
- break;
-
- case 'directdebit':
- $result['method'] = sprintf(
- 'Direct Debit (%s)',
- $details->customerAccount
- );
- break;
-
- case 'paypal':
- $result['method'] = sprintf('PayPal (%s)', $details->consumerAccount);
- break;
-
-
- default:
- $result['method'] = 'Unknown method';
- }
-
return $result;
}
@@ -182,6 +150,10 @@
*/
public function payment(Wallet $wallet, array $payment): ?array
{
+ if ($payment['type'] == self::TYPE_RECURRING) {
+ return $this->paymentRecurring($wallet, $payment);
+ }
+
// Register the user in Mollie, if not yet done
$customer_id = self::mollieCustomerId($wallet);
@@ -199,26 +171,65 @@
'webhookUrl' => Utils::serviceUrl('/api/webhooks/payment/mollie'),
'locale' => 'en_US',
// 'method' => 'creditcard',
+ 'redirectUrl' => \url('/wallet') // required for non-recurring payments
];
- if ($payment['type'] == self::TYPE_RECURRING) {
- // Check if there's a valid mandate
- $mandate = self::mollieMandate($wallet);
+ // TODO: Additional payment parameters for better fraud protection:
+ // billingEmail - for bank transfers, Przelewy24, but not creditcard
+ // billingAddress (it is a structured field not just text)
- if (empty($mandate) || !$mandate->isValid() || $mandate->isPending()) {
- return null;
- }
+ // Create the payment in Mollie
+ $response = mollie()->payments()->create($request);
+
+ // Store the payment reference in database
+ $payment['status'] = $response->status;
+ $payment['id'] = $response->id;
- $request['mandateId'] = $mandate->id;
- } else {
- // required for non-recurring payments
- $request['redirectUrl'] = \url('/wallet');
+ $this->storePayment($payment, $wallet->id);
- // TODO: Additional payment parameters for better fraud protection:
- // billingEmail - for bank transfers, Przelewy24, but not creditcard
- // billingAddress (it is a structured field not just text)
+ return [
+ 'id' => $payment['id'],
+ 'redirectUrl' => $response->getCheckoutUrl(),
+ ];
+ }
+
+ /**
+ * Create a new automatic payment operation.
+ *
+ * @param \App\Wallet $wallet The wallet
+ * @param array $payment Payment data (see self::payment())
+ *
+ * @return array Provider payment/session data:
+ * - id: Operation identifier
+ */
+ protected function paymentRecurring(Wallet $wallet, array $payment): ?array
+ {
+ // Check if there's a valid mandate
+ $mandate = self::mollieMandate($wallet);
+
+ if (empty($mandate) || !$mandate->isValid() || $mandate->isPending()) {
+ return null;
}
+ $customer_id = self::mollieCustomerId($wallet);
+
+ // Note: Required fields: description, amount/currency, amount/value
+
+ $request = [
+ 'amount' => [
+ 'currency' => $payment['currency'],
+ // a number with two decimals is required
+ 'value' => sprintf('%.2f', $payment['amount'] / 100),
+ ],
+ 'customerId' => $customer_id,
+ 'sequenceType' => $payment['type'],
+ 'description' => $payment['description'],
+ 'webhookUrl' => Utils::serviceUrl('/api/webhooks/payment/mollie'),
+ 'locale' => 'en_US',
+ // 'method' => 'creditcard',
+ 'mandateId' => $mandate->id
+ ];
+
// Create the payment in Mollie
$response = mollie()->payments()->create($request);
@@ -226,11 +237,34 @@
$payment['status'] = $response->status;
$payment['id'] = $response->id;
- self::storePayment($payment, $wallet->id);
+ DB::beginTransaction();
+
+ $payment = $this->storePayment($payment, $wallet->id);
+
+ // Mollie can return 'paid' status immediately, so we don't
+ // have to wait for the webhook. What's more, the webhook would ignore
+ // the payment because it will be marked as paid before the webhook.
+ // Let's handle paid status here too.
+ if ($response->isPaid()) {
+ self::creditPayment($payment, $response);
+ $notify = true;
+ } elseif ($respoonse->isFailed()) {
+ // Note: I didn't find a way to get any description of the problem with a payment
+ \Log::info(sprintf('Mollie payment failed (%s)', $response->id));
+
+ // Disable the mandate
+ $wallet->setSetting('mandate_disabled', 1);
+ $notify = true;
+ }
+
+ DB::commit();
+
+ if (!empty($notify)) {
+ \App\Jobs\PaymentEmail::dispatch($payment);
+ }
return [
'id' => $payment['id'],
- 'redirectUrl' => $response->getCheckoutUrl(),
];
}
@@ -267,7 +301,8 @@
// The payment is paid and isn't refunded or charged back.
// Update the balance, if it wasn't already
if ($payment->status != self::STATUS_PAID && $payment->amount > 0) {
- $payment->wallet->credit($payment->amount);
+ $credit = true;
+ $notify = $payment->type == self::TYPE_RECURRING;
}
} elseif ($mollie_payment->hasRefunds()) {
// The payment has been (partially) refunded.
@@ -281,15 +316,35 @@
} elseif ($mollie_payment->isFailed()) {
// Note: I didn't find a way to get any description of the problem with a payment
\Log::info(sprintf('Mollie payment failed (%s)', $payment->id));
+
+ // Disable the mandate
+ if ($payment->type == self::TYPE_RECURRING) {
+ $notify = true;
+ $payment->wallet->setSetting('mandate_disabled', 1);
+ }
}
+ DB::beginTransaction();
+
// This is a sanity check, just in case the payment provider api
- // sent us open -> paid -> open -> paid. So, we lock the payment after it's paid.
- if ($payment->status != self::STATUS_PAID) {
+ // sent us open -> paid -> open -> paid. So, we lock the payment after
+ // recivied a "final" state.
+ $pending_states = [self::STATUS_OPEN, self::STATUS_PENDING, self::STATUS_AUTHORIZED];
+ if (in_array($payment->status, $pending_states)) {
$payment->status = $mollie_payment->status;
$payment->save();
}
+ if (!empty($credit)) {
+ self::creditPayment($payment, $mollie_payment);
+ }
+
+ DB::commit();
+
+ if (!empty($notify)) {
+ \App\Jobs\PaymentEmail::dispatch($payment);
+ }
+
return 200;
}
@@ -346,4 +401,56 @@
}
*/
}
+
+ /**
+ * Apply the successful payment's pecunia to the wallet
+ */
+ protected static function creditPayment($payment, $mollie_payment)
+ {
+ // Extract the payment method for transaction description
+ $method = self::paymentMethod($mollie_payment, 'Mollie');
+
+ // TODO: Localization?
+ $description = $payment->type == self::TYPE_RECURRING ? 'Auto-payment' : 'Payment';
+ $description .= " transaction {$payment->id} using {$method}";
+
+ $payment->wallet->credit($payment->amount, $description);
+
+ // Unlock the disabled auto-payment mandate
+ if ($payment->wallet->balance >= 0) {
+ $payment->wallet->setSetting('mandate_disabled', null);
+ }
+ }
+
+ /**
+ * Extract payment method description from Mollie payment/mandate details
+ */
+ protected static function paymentMethod($object, $default = ''): string
+ {
+ $details = $object->details;
+
+ // Mollie supports 3 methods here
+ switch ($object->method) {
+ case 'creditcard':
+ // If the customer started, but never finished the 'first' payment
+ // card details will be empty, and mandate will be 'pending'.
+ if (empty($details->cardNumber)) {
+ return 'Credit Card';
+ }
+
+ return sprintf(
+ '%s (**** **** **** %s)',
+ $details->cardLabel ?: 'Card', // @phpstan-ignore-line
+ $details->cardNumber
+ );
+
+ case 'directdebit':
+ return sprintf('Direct Debit (%s)', $details->customerAccount);
+
+ case 'paypal':
+ return sprintf('PayPal (%s)', $details->consumerAccount);
+ }
+
+ return $default;
+ }
}
diff --git a/src/app/Providers/Payment/Stripe.php b/src/app/Providers/Payment/Stripe.php
--- a/src/app/Providers/Payment/Stripe.php
+++ b/src/app/Providers/Payment/Stripe.php
@@ -6,6 +6,8 @@
use App\Utils;
use App\Wallet;
use App\WalletSetting;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Request;
use Stripe as StripeAPI;
class Stripe extends \App\Providers\PaymentProvider
@@ -73,6 +75,13 @@
$session = StripeAPI\Checkout\Session::create($request);
+ $payment = [
+ 'id' => $session->setup_intent,
+ 'type' => self::TYPE_MANDATE,
+ ];
+
+ $this->storePayment($payment, $wallet->id);
+
return [
'id' => $session->id,
];
@@ -128,25 +137,9 @@
'id' => $mandate->id,
'isPending' => $mandate->status != 'succeeded' && $mandate->status != 'canceled',
'isValid' => $mandate->status == 'succeeded',
+ 'method' => self::paymentMethod($pm, 'Unknown method')
];
- switch ($pm->type) {
- case 'card':
- // TODO: card number
- $result['method'] = \sprintf(
- '%s (**** **** **** %s)',
- // @phpstan-ignore-next-line
- \ucfirst($pm->card->brand) ?: 'Card',
- // @phpstan-ignore-next-line
- $pm->card->last4
- );
-
- break;
-
- default:
- $result['method'] = 'Unknown method';
- }
-
return $result;
}
@@ -201,10 +194,9 @@
$session = StripeAPI\Checkout\Session::create($request);
// Store the payment reference in database
- $payment['status'] = self::STATUS_OPEN;
$payment['id'] = $session->payment_intent;
- self::storePayment($payment, $wallet->id);
+ $this->storePayment($payment, $wallet->id);
return [
'id' => $session->id,
@@ -233,20 +225,19 @@
'amount' => $payment['amount'],
'currency' => \strtolower($payment['currency']),
'description' => $payment['description'],
- 'locale' => 'en',
- 'off_session' => true,
'receipt_email' => $wallet->owner->email,
'customer' => $mandate->customer,
'payment_method' => $mandate->payment_method,
+ 'off_session' => true,
+ 'confirm' => true,
];
$intent = StripeAPI\PaymentIntent::create($request);
// Store the payment reference in database
- $payment['status'] = self::STATUS_OPEN;
$payment['id'] = $intent->id;
- self::storePayment($payment, $wallet->id);
+ $this->storePayment($payment, $wallet->id);
return [
'id' => $payment['id'],
@@ -260,8 +251,11 @@
*/
public function webhook(): int
{
- $payload = file_get_contents('php://input');
- $sig_header = $_SERVER['HTTP_STRIPE_SIGNATURE'];
+ // We cannot just use php://input as it's already "emptied" by the framework
+ // $payload = file_get_contents('php://input');
+ $request = Request::instance();
+ $payload = $request->getContent();
+ $sig_header = $request->header('Stripe-Signature');
// Parse and validate the input
try {
@@ -270,7 +264,7 @@
$sig_header,
\config('services.stripe.webhook_secret')
);
- } catch (\UnexpectedValueException $e) {
+ } catch (\Exception $e) {
// Invalid payload
return 400;
}
@@ -282,6 +276,10 @@
$intent = $event->data->object; // @phpstan-ignore-line
$payment = Payment::find($intent->id);
+ if (empty($payment) || $payment->type == self::TYPE_MANDATE) {
+ return 404;
+ }
+
switch ($intent->status) {
case StripeAPI\PaymentIntent::STATUS_CANCELED:
$status = self::STATUS_CANCELED;
@@ -290,43 +288,74 @@
$status = self::STATUS_PAID;
break;
default:
- $status = self::STATUS_PENDING;
+ $status = self::STATUS_FAILED;
}
+ DB::beginTransaction();
+
if ($status == self::STATUS_PAID) {
// Update the balance, if it wasn't already
if ($payment->status != self::STATUS_PAID) {
- $payment->wallet->credit($payment->amount);
+ $this->creditPayment($payment, $intent);
+ }
+ } else {
+ if (!empty($intent->last_payment_error)) {
+ // See https://stripe.com/docs/error-codes for more info
+ \Log::info(sprintf(
+ 'Stripe payment failed (%s): %s',
+ $payment->id,
+ json_encode($intent->last_payment_error)
+ ));
}
- } elseif (!empty($intent->last_payment_error)) {
- // See https://stripe.com/docs/error-codes for more info
- \Log::info(sprintf(
- 'Stripe payment failed (%s): %s',
- $payment->id,
- json_encode($intent->last_payment_error)
- ));
}
if ($payment->status != self::STATUS_PAID) {
$payment->status = $status;
$payment->save();
+
+ if ($status != self::STATUS_CANCELED && $payment->type == self::TYPE_RECURRING) {
+ // Disable the mandate
+ if ($status == self::STATUS_FAILED) {
+ $payment->wallet->setSetting('mandate_disabled', 1);
+ }
+
+ // Notify the user
+ \App\Jobs\PaymentEmail::dispatch($payment);
+ }
}
+ DB::commit();
+
break;
case StripeAPI\Event::SETUP_INTENT_SUCCEEDED:
+ case StripeAPI\Event::SETUP_INTENT_SETUP_FAILED:
+ case StripeAPI\Event::SETUP_INTENT_CANCELED:
$intent = $event->data->object; // @phpstan-ignore-line
+ $payment = Payment::find($intent->id);
- // Find the wallet
- // TODO: This query is potentially slow, we should find another way
- // Maybe use payment/transactions table to store the reference
- $setting = WalletSetting::where('key', 'stripe_id')
- ->where('value', $intent->customer)->first();
+ if (empty($payment) || $payment->type != self::TYPE_MANDATE) {
+ return 404;
+ }
- if ($setting) {
- $setting->wallet->setSetting('stripe_mandate_id', $intent->id);
+ switch ($intent->status) {
+ case StripeAPI\SetupIntent::STATUS_CANCELED:
+ $status = self::STATUS_CANCELED;
+ break;
+ case StripeAPI\SetupIntent::STATUS_SUCCEEDED:
+ $status = self::STATUS_PAID;
+ break;
+ default:
+ $status = self::STATUS_FAILED;
+ }
+
+ if ($status == self::STATUS_PAID) {
+ $payment->wallet->setSetting('stripe_mandate_id', $intent->id);
}
+ $payment->status = $status;
+ $payment->save();
+
break;
default:
@@ -382,4 +411,52 @@
}
}
}
+
+ /**
+ * Apply the successful payment's pecunia to the wallet
+ */
+ protected static function creditPayment(Payment $payment, $intent)
+ {
+ $method = 'Stripe';
+
+ // Extract the payment method for transaction description
+ if (
+ !empty($intent->charges)
+ && ($charge = $intent->charges->data[0])
+ && ($pm = $charge->payment_method_details)
+ ) {
+ $method = self::paymentMethod($pm);
+ }
+
+ // TODO: Localization?
+ $description = $payment->type == self::TYPE_RECURRING ? 'Auto-payment' : 'Payment';
+ $description .= " transaction {$payment->id} using {$method}";
+
+ $payment->wallet->credit($payment->amount, $description);
+
+ // Unlock the disabled auto-payment mandate
+ if ($payment->wallet->balance >= 0) {
+ $payment->wallet->setSetting('mandate_disabled', null);
+ }
+ }
+
+ /**
+ * Extract payment method description from Stripe payment details
+ */
+ protected static function paymentMethod($details, $default = ''): string
+ {
+ switch ($details->type) {
+ case 'card':
+ // TODO: card number
+ return \sprintf(
+ '%s (**** **** **** %s)',
+ // @phpstan-ignore-next-line
+ \ucfirst($details->card->brand) ?: 'Card',
+ // @phpstan-ignore-next-line
+ $details->card->last4
+ );
+ }
+
+ return $default;
+ }
}
diff --git a/src/app/Providers/PaymentProvider.php b/src/app/Providers/PaymentProvider.php
--- a/src/app/Providers/PaymentProvider.php
+++ b/src/app/Providers/PaymentProvider.php
@@ -17,6 +17,7 @@
public const TYPE_ONEOFF = 'oneoff';
public const TYPE_RECURRING = 'recurring';
+ public const TYPE_MANDATE = 'mandate';
/** const int Minimum amount of money in a single payment (in cents) */
public const MIN_AMOUNT = 1000;
@@ -135,15 +136,21 @@
*
* @param array $payment Payment information
* @param string $wallet_id Wallet ID
+ *
+ * @return \App\Payment Payment object
*/
- protected static function storePayment(array $payment, $wallet_id): void
+ protected function storePayment(array $payment, $wallet_id): Payment
{
$db_payment = new Payment();
$db_payment->id = $payment['id'];
- $db_payment->description = $payment['description'];
- $db_payment->status = $payment['status'];
- $db_payment->amount = $payment['amount'];
+ $db_payment->description = $payment['description'] ?? '';
+ $db_payment->status = $payment['status'] ?? self::STATUS_OPEN;
+ $db_payment->amount = $payment['amount'] ?? 0;
+ $db_payment->type = $payment['type'];
$db_payment->wallet_id = $wallet_id;
+ $db_payment->provider = $this->name();
$db_payment->save();
+
+ return $db_payment;
}
}
diff --git a/src/app/Wallet.php b/src/app/Wallet.php
--- a/src/app/Wallet.php
+++ b/src/app/Wallet.php
@@ -116,6 +116,28 @@
return $charges;
}
+ /**
+ * A helper to display human-readable amount of money using
+ * the wallet currency and specified locale.
+ *
+ * @param int $amount A amount of money (in cents)
+ * @param string $locale A locale for the output
+ *
+ * @return string String representation, e.g. "9.99 CHF"
+ */
+ public function money(int $amount, $locale = 'de_DE')
+ {
+ $amount = round($amount / 100, 2);
+
+ // Prefer intl extension's number formatter
+ if (class_exists('NumberFormatter')) {
+ $nf = new \NumberFormatter($locale, \NumberFormatter::DECIMAL);
+ return $nf->formatCurrency($amount, $this->currency);
+ }
+
+ return sprintf('%.2f %s', $amount, $this->currency);
+ }
+
/**
* Controllers of this wallet.
*
@@ -134,11 +156,12 @@
/**
* Add an amount of pecunia to this wallet's balance.
*
- * @param int $amount The amount of pecunia to add (in cents).
+ * @param int $amount The amount of pecunia to add (in cents).
+ * @param string $description The transaction description
*
* @return Wallet Self
*/
- public function credit(int $amount): Wallet
+ public function credit(int $amount, string $description = ''): Wallet
{
$this->balance += $amount;
@@ -150,7 +173,8 @@
'object_id' => $this->id,
'object_type' => \App\Wallet::class,
'type' => \App\Transaction::WALLET_CREDIT,
- 'amount' => $amount
+ 'amount' => $amount,
+ 'description' => $description
]
);
diff --git a/src/config/app.php b/src/config/app.php
--- a/src/config/app.php
+++ b/src/config/app.php
@@ -57,6 +57,8 @@
'asset_url' => env('ASSET_URL', null),
+ 'support_url' => env('SUPPORT_URL', null),
+
/*
|--------------------------------------------------------------------------
| Application Domain
@@ -145,7 +147,6 @@
*/
'providers' => [
-
/*
* Laravel Framework Service Providers...
*/
@@ -198,7 +199,6 @@
*/
'aliases' => [
-
'App' => Illuminate\Support\Facades\App::class,
'Arr' => Illuminate\Support\Arr::class,
'Artisan' => Illuminate\Support\Facades\Artisan::class,
@@ -234,7 +234,6 @@
'URL' => Illuminate\Support\Facades\URL::class,
'Validator' => Illuminate\Support\Facades\Validator::class,
'View' => Illuminate\Support\Facades\View::class,
-
],
// Locations of knowledge base articles
diff --git a/src/database/migrations/2020_03_16_100000_create_payments.php b/src/database/migrations/2020_03_16_100000_create_payments.php
--- a/src/database/migrations/2020_03_16_100000_create_payments.php
+++ b/src/database/migrations/2020_03_16_100000_create_payments.php
@@ -22,6 +22,8 @@
$table->string('status', 16);
$table->integer('amount');
$table->text('description');
+ $table->string('provider', 16);
+ $table->string('type', 16);
$table->timestamps();
$table->foreign('wallet_id')->references('id')->on('wallets')->onDelete('cascade');
diff --git a/src/phpunit.xml b/src/phpunit.xml
--- a/src/phpunit.xml
+++ b/src/phpunit.xml
@@ -33,7 +33,6 @@
<server name="APP_ENV" value="testing"/>
<server name="APP_DEBUG" value="true"/>
<server name="BCRYPT_ROUNDS" value="4"/>
- <server name="CACHE_DRIVER" value="array"/>
<server name="MAIL_DRIVER" value="array"/>
<server name="QUEUE_CONNECTION" value="sync"/>
<server name="SESSION_DRIVER" value="array"/>
diff --git a/src/resources/lang/en/mail.php b/src/resources/lang/en/mail.php
--- a/src/resources/lang/en/mail.php
+++ b/src/resources/lang/en/mail.php
@@ -13,6 +13,8 @@
'header' => "Dear :name,",
'footer' => "Best regards,\nYour :site Team",
+ 'more-info-html' => "See <a href=\":href\">here</a> for more information.",
+
'negativebalance-subject' => ":site Payment Reminder",
'negativebalance-body' => "It has probably skipped your attention that you are behind on paying for your :site account. "
. "Consider setting up auto-payment to avoid messages like this in the future.\n\n"
@@ -24,6 +26,30 @@
. "You can also click the link below.\n"
. "If you did not make such a request, you can either ignore this message or get in touch with us about this incident.",
+ 'paymentmandatedisabled-subject' => ":site Auto-payment Problem",
+ 'paymentmandatedisabled-body' => "Your :site account balance is negative "
+ . "and the configured amount for automatically topping up the balance does not cover "
+ . "the costs of subscriptions consumed.\n\n"
+ . "Charging you multiple times for the same amount in short succession "
+ . "could lead to issues with the payment provider. "
+ . "In order to not cause any problems, we suspended auto-payment for your account. "
+ . "To resolve this issue, login to your account settings and adjust your auto-payment amount.",
+
+ 'paymentfailure-subject' => ":site Payment Failed",
+ 'paymentfailure-body' => "Something went wrong with auto-payment for your :site account.\n"
+ . "We tried to charge you via your preferred payment method, but the charge did not go through.\n\n"
+ . "In order to not cause any further issues, we suspended auto-payment for your account. "
+ . "To resolve this issue, login to your account settings at",
+ 'paymentfailure-body-rest' => "There you can pay manually for your account and "
+ . "change your auto-payment settings.",
+
+ 'paymentsuccess-subject' => ":site Payment Succeeded",
+ 'paymentsuccess-body' => "The auto-payment for your :site account went through without issues. "
+ . "You can check your new account balance and more details here:",
+
+ 'support' => "Special circumstances? Something wrong with a charge?\n"
+ . " :site Support is here to help:",
+
'signupcode-subject' => ":site Registration",
'signupcode-body' => "This is your verification code for the :site registration process: :code.\n"
. "You can also click the link below to continue the registration process:",
@@ -34,9 +60,4 @@
'suspendeddebtor-middle' => "Settle up now to reactivate your account.",
'suspendeddebtor-cancel' => "Don't want to be our customer anymore? "
. "Here is how you can cancel your account:",
-
- 'support' => "Special circumstances? Something wrong with a charge?\n"
- . " :site Support is here to help:",
-
- 'more-info-html' => "See <a href=\":href\">here</a> for more information.",
];
diff --git a/src/resources/lang/en/validation.php b/src/resources/lang/en/validation.php
--- a/src/resources/lang/en/validation.php
+++ b/src/resources/lang/en/validation.php
@@ -133,6 +133,7 @@
'entryinvalid' => 'The specified :attribute is invalid.',
'entryexists' => 'The specified :attribute is not available.',
'minamount' => 'Minimum amount for a single payment is :amount.',
+ 'minamountdebt' => 'The specified amount does not cover the balance on the account.',
/*
|--------------------------------------------------------------------------
diff --git a/src/resources/views/emails/payment_failure.blade.php b/src/resources/views/emails/payment_failure.blade.php
new file mode 100644
--- /dev/null
+++ b/src/resources/views/emails/payment_failure.blade.php
@@ -0,0 +1,20 @@
+<!DOCTYPE html>
+<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
+ <head>
+ <meta charset="utf-8">
+ </head>
+ <body>
+ <p>{{ __('mail.header', ['name' => $username]) }}</p>
+
+ <p>{{ __('mail.paymentfailure-body', ['site' => $site]) }}</p>
+ <p><a href="{{ $walletUrl }}">{{ $walletUrl }}</a></p>
+ <p>{{ __('mail.paymentfailure-body-rest', ['site' => $site]) }}</p>
+
+@if ($supportUrl)
+ <p>{{ __('mail.support', ['site' => $site]) }}</p>
+ <p><a href="{{ $supportUrl }}">{{ $supportUrl }}</a></p>
+@endif
+
+ <p>{{ __('mail.footer', ['site' => $site, 'appurl' => config('app.url')]) }}</p>
+ </body>
+</html>
diff --git a/src/resources/views/emails/payment_mandate_disabled.blade.php b/src/resources/views/emails/payment_mandate_disabled.blade.php
new file mode 100644
--- /dev/null
+++ b/src/resources/views/emails/payment_mandate_disabled.blade.php
@@ -0,0 +1,20 @@
+<!DOCTYPE html>
+<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
+ <head>
+ <meta charset="utf-8">
+ </head>
+ <body>
+ <p>{{ __('mail.header', ['name' => $username]) }}</p>
+
+ <p>{{ __('mail.paymentmandatedisabled-body', ['site' => $site]) }}</p>
+ <p><a href="{{ $walletUrl }}">{{ $walletUrl }}</a></p>
+ <p>{{ __('mail.paymentfailure-body-rest', ['site' => $site]) }}</p>
+
+@if ($supportUrl)
+ <p>{{ __('mail.support', ['site' => $site]) }}</p>
+ <p><a href="{{ $supportUrl }}">{{ $supportUrl }}</a></p>
+@endif
+
+ <p>{{ __('mail.footer', ['site' => $site, 'appurl' => config('app.url')]) }}</p>
+ </body>
+</html>
diff --git a/src/resources/views/emails/payment_success.blade.php b/src/resources/views/emails/payment_success.blade.php
new file mode 100644
--- /dev/null
+++ b/src/resources/views/emails/payment_success.blade.php
@@ -0,0 +1,19 @@
+<!DOCTYPE html>
+<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
+ <head>
+ <meta charset="utf-8">
+ </head>
+ <body>
+ <p>{{ __('mail.header', ['name' => $username]) }}</p>
+
+ <p>{{ __('mail.paymentsuccess-body', ['site' => $site]) }}</p>
+ <p><a href="{{ $walletUrl }}">{{ $walletUrl }}</a></p>
+
+@if ($supportUrl)
+ <p>{{ __('mail.support', ['site' => $site]) }}</p>
+ <p><a href="{{ $supportUrl }}">{{ $supportUrl }}</a></p>
+@endif
+
+ <p>{{ __('mail.footer', ['site' => $site, 'appurl' => config('app.url')]) }}</p>
+ </body>
+</html>
diff --git a/src/resources/vue/Admin/User.vue b/src/resources/vue/Admin/User.vue
--- a/src/resources/vue/Admin/User.vue
+++ b/src/resources/vue/Admin/User.vue
@@ -128,7 +128,7 @@
<span class="form-control-plaintext" id="autopayment">
Fill up by <b>{{ wallet.mandate.amount }} CHF</b>
when under <b>{{ wallet.mandate.balance }} CHF</b>
- using {{ wallet.mandate.method }}.</span>
+ using {{ wallet.mandate.method }}<span v-if="wallet.mandate.isDisabled"> (disabled)</span>.
</span>
</div>
</div>
diff --git a/src/resources/vue/Wallet.vue b/src/resources/vue/Wallet.vue
--- a/src/resources/vue/Wallet.vue
+++ b/src/resources/vue/Wallet.vue
@@ -51,6 +51,10 @@
every time your account balance gets under <b>{{ mandate.balance }} CHF</b>.
You will be charged via {{ mandate.method }}.
</p>
+ <p v-if="mandate.isDisabled" class="disabled-mandate text-danger">
+ The configured auto-payment has been disabled. Top up your wallet or
+ raise the auto-payment amount.
+ </p>
<p>You can cancel or change the auto-payment at any time.</p>
<div class="form-group d-flex justify-content-around">
<button type="button" class="btn btn-danger" @click="autoPaymentDelete">Cancel auto-payment</button>
@@ -88,6 +92,10 @@
Next, you will be redirected to the checkout page, where you can provide
your credit card details.
</p>
+ <p v-if="mandate.isDisabled" class="disabled-mandate text-danger">
+ The auto-payment is disabled. Immediately after you submit new settings we'll
+ attempt to top up your wallet.
+ </p>
</form>
</div>
</div>
@@ -181,13 +189,18 @@
axios[method]('/api/v4/payments/mandate', post)
.then(response => {
- if (response.data.redirectUrl) {
- location.href = response.data.redirectUrl
- } else if (response.data.id) {
- this.stripeCheckout(response.data)
+ if (method == 'post') {
+ // a new mandate, redirect to the chackout page
+ if (response.data.redirectUrl) {
+ location.href = response.data.redirectUrl
+ } else if (response.data.id) {
+ this.stripeCheckout(response.data)
+ }
} else {
- this.dialog.modal('hide')
+ // an update
if (response.data.status == 'success') {
+ this.dialog.modal('hide');
+ this.mandate = response.data
this.$toast.success(response.data.message)
}
}
diff --git a/src/tests/Browser/Admin/UserTest.php b/src/tests/Browser/Admin/UserTest.php
--- a/src/tests/Browser/Admin/UserTest.php
+++ b/src/tests/Browser/Admin/UserTest.php
@@ -24,12 +24,13 @@
self::useAdminUrl();
$john = $this->getTestUser('john@kolab.org');
- $john->update(['status' => $john->status ^= User::STATUS_SUSPENDED]);
$john->setSettings([
'phone' => '+48123123123',
'external_email' => 'john.doe.external@gmail.com',
]);
-
+ if ($john->isSuspended()) {
+ User::where('email', $john->email)->update(['status' => $john->status - User::STATUS_SUSPENDED]);
+ }
$wallet = $john->wallets()->first();
$wallet->discount()->dissociate();
$wallet->balance = 0;
@@ -42,12 +43,13 @@
public function tearDown(): void
{
$john = $this->getTestUser('john@kolab.org');
- $john->update(['status' => $john->status ^= User::STATUS_SUSPENDED]);
$john->setSettings([
'phone' => null,
'external_email' => 'john.doe.external@gmail.com',
]);
-
+ if ($john->isSuspended()) {
+ User::where('email', $john->email)->update(['status' => $john->status - User::STATUS_SUSPENDED]);
+ }
$wallet = $john->wallets()->first();
$wallet->discount()->dissociate();
$wallet->balance = 0;
diff --git a/src/tests/Browser/Pages/Home.php b/src/tests/Browser/Pages/Home.php
--- a/src/tests/Browser/Pages/Home.php
+++ b/src/tests/Browser/Pages/Home.php
@@ -26,6 +26,7 @@
public function assert($browser)
{
$browser->waitForLocation($this->url())
+ ->waitUntilMissing('.app-loader')
->assertVisible('form.form-signin');
}
diff --git a/src/tests/Browser/Pages/PaymentMollie.php b/src/tests/Browser/Pages/PaymentMollie.php
--- a/src/tests/Browser/Pages/PaymentMollie.php
+++ b/src/tests/Browser/Pages/PaymentMollie.php
@@ -58,7 +58,7 @@
->waitFor('button.form__button');
}
- $browser->click('@status-table input[value="paid"]')
+ $browser->click('input[value="paid"]')
->click('button.form__button');
}
}
diff --git a/src/tests/Browser/Pages/PaymentStripe.php b/src/tests/Browser/Pages/PaymentStripe.php
--- a/src/tests/Browser/Pages/PaymentStripe.php
+++ b/src/tests/Browser/Pages/PaymentStripe.php
@@ -59,7 +59,7 @@
public function submitValidCreditCard($browser)
{
$browser->type('@name-input', 'Test')
- ->type('@cardnumber-input', '4242424242424242')
+ ->typeSlowly('@cardnumber-input', '4242424242424242', 50)
->type('@cardexpiry-input', '12/' . (date('y') + 1))
->type('@cardcvc-input', '123')
->press('@submit-button');
diff --git a/src/tests/Browser/PaymentMollieTest.php b/src/tests/Browser/PaymentMollieTest.php
--- a/src/tests/Browser/PaymentMollieTest.php
+++ b/src/tests/Browser/PaymentMollieTest.php
@@ -172,12 +172,25 @@
// Test updating auto-payment
$this->browse(function (Browser $browser) use ($user) {
- $browser->on(new WalletPage())
+ $wallet = $user->wallets()->first();
+ $wallet->setSetting('mandate_disabled', 1);
+
+ $browser->refresh()
+ ->on(new WalletPage())
->click('@main button')
- ->with(new Dialog('@payment-dialog'), function (Browser $browser) {
- $browser->assertSeeIn('@body #mandate-info button.btn-primary', 'Change auto-payment')
+ ->with(new Dialog('@payment-dialog'), function (Browser $browser) use ($wallet) {
+ $browser->waitFor('@body #mandate-info')
+ ->assertSeeIn(
+ '@body #mandate-info p.disabled-mandate',
+ 'The configured auto-payment has been disabled'
+ )
+ ->assertSeeIn('@body #mandate-info button.btn-primary', 'Change auto-payment')
->click('@body #mandate-info button.btn-primary')
->assertSeeIn('@title', 'Update auto-payment')
+ ->assertSeeIn(
+ '@body form p.disabled-mandate',
+ 'The auto-payment is disabled.'
+ )
->assertValue('@body #mandate_amount', '100')
->assertValue('@body #mandate_balance', '0')
->assertSeeIn('@button-cancel', 'Cancel')
@@ -193,7 +206,15 @@
->click('@button-action');
})
->waitUntilMissing('#payment-dialog')
- ->assertToast(Toast::TYPE_SUCCESS, 'The auto-payment has been updated.');
+ ->assertToast(Toast::TYPE_SUCCESS, 'The auto-payment has been updated.')
+ // Open the dialog again and make sure the "disabled" text isn't there
+ ->click('@main button')
+ ->with(new Dialog('@payment-dialog'), function (Browser $browser) use ($wallet) {
+ $browser->assertMissing('@body #mandate-info p.disabled-mandate')
+ ->click('@body #mandate-info button.btn-primary')
+ ->assertMissing('@body form p.disabled-mandate')
+ ->click('@button-cancel');
+ });
});
// Test deleting auto-payment
diff --git a/src/tests/Browser/PaymentStripeTest.php b/src/tests/Browser/PaymentStripeTest.php
--- a/src/tests/Browser/PaymentStripeTest.php
+++ b/src/tests/Browser/PaymentStripeTest.php
@@ -80,7 +80,7 @@
// Looks like in test-mode the webhook is executed before redirect
// so we can expect balance updated on the wallet page
- $browser->waitForLocation('/wallet', 15) // need more time than default 5 sec.
+ $browser->waitForLocation('/wallet', 30) // need more time than default 5 sec.
->on(new WalletPage())
->assertSeeIn('@main .card-text', 'Current account balance is 12,34 CHF');
});
@@ -144,7 +144,7 @@
->assertMissing('@amount')
->assertValue('@email-input', $user->email)
->submitValidCreditCard()
- ->waitForLocation('/wallet', 15) // need more time than default 5 sec.
+ ->waitForLocation('/wallet', 30) // need more time than default 5 sec.
->visit('/wallet?paymentProvider=stripe')
->on(new WalletPage())
->click('@main button')
diff --git a/src/tests/BrowserAddonTrait.php b/src/tests/BrowserAddonTrait.php
new file mode 100644
--- /dev/null
+++ b/src/tests/BrowserAddonTrait.php
@@ -0,0 +1,82 @@
+<?php
+
+namespace Tests;
+
+use Facebook\WebDriver\Chrome\ChromeOptions;
+use Facebook\WebDriver\Remote\RemoteWebDriver;
+use Facebook\WebDriver\Remote\DesiredCapabilities;
+use Laravel\Dusk\Chrome\SupportsChrome;
+
+trait BrowserAddonTrait
+{
+ use SupportsChrome;
+
+
+ /**
+ * Initialize and start Chrome driver and browser
+ *
+ * @returns Browser The browser
+ */
+ protected function startBrowser(): Browser
+ {
+ $driver = retry(5, function () {
+ return $this->driver();
+ }, 50);
+
+ $this->browser = new Browser($driver);
+
+ $screenshots_dir = __DIR__ . '/Browser/screenshots/';
+ Browser::$storeScreenshotsAt = $screenshots_dir;
+ if (!file_exists($screenshots_dir)) {
+ mkdir($screenshots_dir, 0777, true);
+ }
+
+ return $this->browser;
+ }
+
+ /**
+ * (Automatically) stop the browser and driver process
+ *
+ * @afterClass
+ */
+ protected function stopBrowser(): void
+ {
+ if ($this->browser) {
+ $this->browser->quit();
+ static::stopChromeDriver();
+ $this->browser = null;
+ }
+ }
+
+ /**
+ * Initialize and start Chrome driver
+ */
+ protected function driver()
+ {
+ static::startChromeDriver();
+
+ $options = (new ChromeOptions())->addArguments([
+ '--lang=en_US',
+ '--disable-gpu',
+ '--headless',
+ ]);
+
+ return RemoteWebDriver::create(
+ 'http://localhost:9515',
+ DesiredCapabilities::chrome()->setCapability(
+ ChromeOptions::CAPABILITY,
+ $options
+ )
+ );
+ }
+
+ /**
+ * Register an "after class" tear down callback.
+ *
+ * @param \Closure $callback
+ */
+ public static function afterClass(\Closure $callback): void
+ {
+ // This method is required by SupportsChrome trait
+ }
+}
diff --git a/src/tests/Feature/Controller/PaymentsMollieTest.php b/src/tests/Feature/Controller/PaymentsMollieTest.php
--- a/src/tests/Feature/Controller/PaymentsMollieTest.php
+++ b/src/tests/Feature/Controller/PaymentsMollieTest.php
@@ -5,15 +5,19 @@
use App\Http\Controllers\API\V4\PaymentsController;
use App\Payment;
use App\Providers\PaymentProvider;
+use App\Transaction;
use App\Wallet;
use App\WalletSetting;
use GuzzleHttp\Psr7\Response;
+use Illuminate\Support\Facades\Bus;
use Tests\TestCase;
+use Tests\BrowserAddonTrait;
use Tests\MollieMocksTrait;
class PaymentsMollieTest extends TestCase
{
use MollieMocksTrait;
+ use BrowserAddonTrait;
/**
* {@inheritDoc}
@@ -27,10 +31,11 @@
$john = $this->getTestUser('john@kolab.org');
$wallet = $john->wallets()->first();
- $john->setSetting('mollie_id', null);
Payment::where('wallet_id', $wallet->id)->delete();
Wallet::where('id', $wallet->id)->update(['balance' => 0]);
WalletSetting::where('wallet_id', $wallet->id)->delete();
+ Transaction::where('object_id', $wallet->id)
+ ->where('type', Transaction::WALLET_CREDIT)->delete();
}
/**
@@ -40,10 +45,11 @@
{
$john = $this->getTestUser('john@kolab.org');
$wallet = $john->wallets()->first();
- $john->setSetting('mollie_id', null);
Payment::where('wallet_id', $wallet->id)->delete();
Wallet::where('id', $wallet->id)->update(['balance' => 0]);
WalletSetting::where('wallet_id', $wallet->id)->delete();
+ Transaction::where('object_id', $wallet->id)
+ ->where('type', Transaction::WALLET_CREDIT)->delete();
parent::tearDown();
}
@@ -123,6 +129,7 @@
$this->assertEquals('Credit Card', $json['method']);
$this->assertSame(true, $json['isPending']);
$this->assertSame(false, $json['isValid']);
+ $this->assertSame(false, $json['isDisabled']);
$mandate_id = $json['id'];
@@ -130,7 +137,7 @@
// the mandate validated/completed. Instead, we'll mock the mandate object.
$mollie_response = [
'resource' => 'mandate',
- 'id' => $json['id'],
+ 'id' => $mandate_id,
'status' => 'valid',
'method' => 'creditcard',
'details' => [
@@ -144,6 +151,9 @@
$responseStack = $this->mockMollie();
$responseStack->append(new Response(200, [], json_encode($mollie_response)));
+ $wallet = $user->wallets()->first();
+ $wallet->setSetting('mandate_disabled', 1);
+
$response = $this->actingAs($user)->get("api/v4/payments/mandate");
$response->assertStatus(200);
@@ -154,6 +164,10 @@
$this->assertEquals('Visa (**** **** **** 4242)', $json['method']);
$this->assertSame(false, $json['isPending']);
$this->assertSame(true, $json['isValid']);
+ $this->assertSame(true, $json['isDisabled']);
+
+ Bus::fake();
+ $wallet->setSetting('mandate_disabled', null);
// Test updating mandate details (invalid input)
$post = [];
@@ -178,6 +192,8 @@
$this->assertSame("Minimum amount for a single payment is {$min}.", $json['errors']['amount']);
// Test updating a mandate (valid input)
+ $responseStack->append(new Response(200, [], json_encode($mollie_response)));
+
$post = ['amount' => 30.10, 'balance' => 1];
$response = $this->actingAs($user)->put("api/v4/payments/mandate", $post);
$response->assertStatus(200);
@@ -186,12 +202,50 @@
$this->assertSame('success', $json['status']);
$this->assertSame('The auto-payment has been updated.', $json['message']);
+ $this->assertSame($mandate_id, $json['id']);
+ $this->assertFalse($json['isDisabled']);
$wallet = $user->wallets()->first();
$this->assertEquals(30.10, $wallet->getSetting('mandate_amount'));
$this->assertEquals(1, $wallet->getSetting('mandate_balance'));
+ // Test updating a disabled mandate (invalid input)
+ $wallet->setSetting('mandate_disabled', 1);
+ $wallet->balance = -2000;
+ $wallet->save();
+ $user->refresh(); // required so the controller sees the wallet update from above
+
+ $post = ['amount' => 15.10, 'balance' => 1];
+ $response = $this->actingAs($user)->put("api/v4/payments/mandate", $post);
+ $response->assertStatus(422);
+
+ $json = $response->json();
+
+ $this->assertSame('error', $json['status']);
+ $this->assertCount(1, $json['errors']);
+ $this->assertSame('The specified amount does not cover the balance on the account.', $json['errors']['amount']);
+
+ // Test updating a disabled mandate (valid input)
+ $responseStack->append(new Response(200, [], json_encode($mollie_response)));
+
+ $post = ['amount' => 30, 'balance' => 1];
+ $response = $this->actingAs($user)->put("api/v4/payments/mandate", $post);
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertSame('success', $json['status']);
+ $this->assertSame('The auto-payment has been updated.', $json['message']);
+ $this->assertSame($mandate_id, $json['id']);
+ $this->assertFalse($json['isDisabled']);
+
+ Bus::assertDispatchedTimes(\App\Jobs\WalletCharge::class, 1);
+ Bus::assertDispatched(\App\Jobs\WalletCharge::class, function ($job) use ($wallet) {
+ $job_wallet = $this->getObjectProperty($job, 'wallet');
+ return $job_wallet->id === $wallet->id;
+ });
+
$this->unmockMollie();
// Delete mandate
@@ -219,6 +273,8 @@
*/
public function testStoreAndWebhook(): void
{
+ Bus::fake();
+
// Unauth access not allowed
$response = $this->post("api/v4/payments", []);
$response->assertStatus(401);
@@ -277,9 +333,20 @@
$response = $this->post("api/webhooks/payment/mollie", $post);
$response->assertStatus(200);
- $this->assertSame('paid', $payment->fresh()->status);
+ $this->assertSame(PaymentProvider::STATUS_PAID, $payment->fresh()->status);
$this->assertEquals(1234, $wallet->fresh()->balance);
+ $transaction = $wallet->transactions()->where('type', Transaction::WALLET_CREDIT)->last();
+ $this->assertSame(1234, $transaction->amount);
+ $this->assertSame(
+ "Payment transaction {$payment->id} using Mollie",
+ $transaction->description
+ );
+
+ // Assert that email notification job wasn't dispatched,
+ // it is expected only for recurring payments
+ Bus::assertDispatchedTimes(\App\Jobs\PaymentEmail::class, 0);
+
// Verify "paid -> open -> paid" scenario, assert that balance didn't change
$mollie_response['status'] = 'open';
unset($mollie_response['paidAt']);
@@ -288,7 +355,7 @@
$response = $this->post("api/webhooks/payment/mollie", $post);
$response->assertStatus(200);
- $this->assertSame('paid', $payment->fresh()->status);
+ $this->assertSame(PaymentProvider::STATUS_PAID, $payment->fresh()->status);
$this->assertEquals(1234, $wallet->fresh()->balance);
$mollie_response['status'] = 'paid';
@@ -298,8 +365,38 @@
$response = $this->post("api/webhooks/payment/mollie", $post);
$response->assertStatus(200);
- $this->assertSame('paid', $payment->fresh()->status);
+ $this->assertSame(PaymentProvider::STATUS_PAID, $payment->fresh()->status);
+ $this->assertEquals(1234, $wallet->fresh()->balance);
+
+ // Test for payment failure
+ Bus::fake();
+
+ $payment->refresh();
+ $payment->status = PaymentProvider::STATUS_OPEN;
+ $payment->save();
+
+ $mollie_response = [
+ "resource" => "payment",
+ "id" => $payment->id,
+ "status" => "failed",
+ "mode" => "test",
+ ];
+
+ // We'll trigger the webhook with payment id and use mocking for
+ // a request to the Mollie payments API. We cannot force Mollie
+ // to make the payment status change.
+ $responseStack = $this->mockMollie();
+ $responseStack->append(new Response(200, [], json_encode($mollie_response)));
+
+ $response = $this->post("api/webhooks/payment/mollie", $post);
+ $response->assertStatus(200);
+
+ $this->assertSame('failed', $payment->fresh()->status);
$this->assertEquals(1234, $wallet->fresh()->balance);
+
+ // Assert that email notification job wasn't dispatched,
+ // it is expected only for recurring payments
+ Bus::assertDispatchedTimes(\App\Jobs\PaymentEmail::class, 0);
}
/**
@@ -307,20 +404,184 @@
*
* @group mollie
*/
- public function testDirectCharge(): void
+ public function testTopUp(): void
{
+ Bus::fake();
+
$user = $this->getTestUser('john@kolab.org');
$wallet = $user->wallets()->first();
- // Expect false result, as there's no mandate
- $result = PaymentsController::directCharge($wallet, 1234);
+ // Create a valid mandate first
+ $this->createMandate($wallet, ['amount' => 20.10, 'balance' => 10]);
+
+ // Expect a recurring payment as we have a valid mandate at this point
+ $result = PaymentsController::topUpWallet($wallet);
+ $this->assertTrue($result);
+
+ // Check that the payments table contains a new record with proper amount
+ // There should be two records, one for the first payment and another for
+ // the recurring payment
+ $this->assertCount(1, $wallet->payments()->get());
+ $payment = $wallet->payments()->first();
+ $this->assertSame(2010, $payment->amount);
+
+ // In mollie we don't have to wait for a webhook, the response to
+ // PaymentIntent already sets the status to 'paid', so we can test
+ // immediately the balance update
+ // Assert that email notification job has been dispatched
+ $this->assertSame(PaymentProvider::STATUS_PAID, $payment->status);
+ $this->assertEquals(2010, $wallet->fresh()->balance);
+ $transaction = $wallet->transactions()->where('type', Transaction::WALLET_CREDIT)->last();
+ $this->assertSame(2010, $transaction->amount);
+ $this->assertSame(
+ "Auto-payment transaction {$payment->id} using Mastercard (**** **** **** 6787)",
+ $transaction->description
+ );
+
+ Bus::assertDispatchedTimes(\App\Jobs\PaymentEmail::class, 1);
+ Bus::assertDispatched(\App\Jobs\PaymentEmail::class, function ($job) use ($payment) {
+ $job_payment = $this->getObjectProperty($job, 'payment');
+ return $job_payment->id === $payment->id;
+ });
+
+ // Expect no payment if the mandate is disabled
+ $wallet->setSetting('mandate_disabled', 1);
+ $result = PaymentsController::topUpWallet($wallet);
+ $this->assertFalse($result);
+ $this->assertCount(1, $wallet->payments()->get());
+
+ // Expect no payment if balance is ok
+ $wallet->setSetting('mandate_disabled', null);
+ $wallet->balance = 1000;
+ $wallet->save();
+ $result = PaymentsController::topUpWallet($wallet);
+ $this->assertFalse($result);
+ $this->assertCount(1, $wallet->payments()->get());
+
+ // Expect no payment if the top-up amount is not enough
+ $wallet->setSetting('mandate_disabled', null);
+ $wallet->balance = -2050;
+ $wallet->save();
+ $result = PaymentsController::topUpWallet($wallet);
$this->assertFalse($result);
+ $this->assertCount(1, $wallet->payments()->get());
+
+ Bus::assertDispatchedTimes(\App\Jobs\PaymentMandateDisabledEmail::class, 1);
+ Bus::assertDispatched(\App\Jobs\PaymentMandateDisabledEmail::class, function ($job) use ($wallet) {
+ $job_wallet = $this->getObjectProperty($job, 'wallet');
+ return $job_wallet->id === $wallet->id;
+ });
+
+ // Expect no payment if there's no mandate
+ $wallet->setSetting('mollie_mandate_id', null);
+ $wallet->balance = 0;
+ $wallet->save();
+ $result = PaymentsController::topUpWallet($wallet);
+ $this->assertFalse($result);
+ $this->assertCount(1, $wallet->payments()->get());
+
+ Bus::assertDispatchedTimes(\App\Jobs\PaymentMandateDisabledEmail::class, 1);
+
+ // Test webhook for recurring payments
+
+ $responseStack = $this->mockMollie();
+ Bus::fake();
+
+ $payment->refresh();
+ $payment->status = PaymentProvider::STATUS_OPEN;
+ $payment->save();
+
+ $mollie_response = [
+ "resource" => "payment",
+ "id" => $payment->id,
+ "status" => "paid",
+ // Status is not enough, paidAt is used to distinguish the state
+ "paidAt" => date('c'),
+ "mode" => "test",
+ ];
+
+ // We'll trigger the webhook with payment id and use mocking for
+ // a request to the Mollie payments API. We cannot force Mollie
+ // to make the payment status change.
+ $responseStack->append(new Response(200, [], json_encode($mollie_response)));
+
+ $post = ['id' => $payment->id];
+ $response = $this->post("api/webhooks/payment/mollie", $post);
+ $response->assertStatus(200);
+
+ $this->assertSame(PaymentProvider::STATUS_PAID, $payment->fresh()->status);
+ $this->assertEquals(2010, $wallet->fresh()->balance);
+
+ $transaction = $wallet->transactions()->where('type', Transaction::WALLET_CREDIT)->last();
+ $this->assertSame(2010, $transaction->amount);
+ $this->assertSame(
+ "Auto-payment transaction {$payment->id} using Mollie",
+ $transaction->description
+ );
+
+ // Assert that email notification job has been dispatched
+ Bus::assertDispatchedTimes(\App\Jobs\PaymentEmail::class, 1);
+ Bus::assertDispatched(\App\Jobs\PaymentEmail::class, function ($job) use ($payment) {
+ $job_payment = $this->getObjectProperty($job, 'payment');
+ return $job_payment->id === $payment->id;
+ });
+
+ Bus::fake();
+
+ // Test for payment failure
+ $payment->refresh();
+ $payment->status = PaymentProvider::STATUS_OPEN;
+ $payment->save();
+
+ $wallet->setSetting('mollie_mandate_id', 'xxx');
+ $wallet->setSetting('mandate_disabled', null);
+
+ $mollie_response = [
+ "resource" => "payment",
+ "id" => $payment->id,
+ "status" => "failed",
+ "mode" => "test",
+ ];
+
+ $responseStack->append(new Response(200, [], json_encode($mollie_response)));
+
+ $response = $this->post("api/webhooks/payment/mollie", $post);
+ $response->assertStatus(200);
+
+ $wallet->refresh();
+
+ $this->assertSame(PaymentProvider::STATUS_FAILED, $payment->fresh()->status);
+ $this->assertEquals(2010, $wallet->balance);
+ $this->assertTrue(!empty($wallet->getSetting('mandate_disabled')));
+
+ // Assert that email notification job has been dispatched
+ Bus::assertDispatchedTimes(\App\Jobs\PaymentEmail::class, 1);
+ Bus::assertDispatched(\App\Jobs\PaymentEmail::class, function ($job) use ($payment) {
+ $job_payment = $this->getObjectProperty($job, 'payment');
+ return $job_payment->id === $payment->id;
+ });
+
+ $responseStack = $this->unmockMollie();
+ }
+
+ /**
+ * Create Mollie's auto-payment mandate using our API and Chrome browser
+ */
+ protected function createMandate(Wallet $wallet, array $params)
+ {
+ // Use the API to create a first payment with a mandate
+ $response = $this->actingAs($wallet->owner)->post("api/v4/payments/mandate", $params);
+ $response->assertStatus(200);
+ $json = $response->json();
- // Problem with this is we need to have a valid mandate
- // And there's no easy way to confirm a created mandate.
+ // There's no easy way to confirm a created mandate.
// The only way seems to be to fire up Chrome on checkout page
- // and do some actions with use of Dusk browser.
+ // and do actions with use of Dusk browser.
+ $this->startBrowser()
+ ->visit($json['redirectUrl'])
+ ->click('input[value="paid"]')
+ ->click('button.form__button');
- $this->markTestIncomplete();
+ $this->stopBrowser();
}
}
diff --git a/src/tests/Feature/Controller/PaymentsStripeTest.php b/src/tests/Feature/Controller/PaymentsStripeTest.php
--- a/src/tests/Feature/Controller/PaymentsStripeTest.php
+++ b/src/tests/Feature/Controller/PaymentsStripeTest.php
@@ -5,9 +5,11 @@
use App\Http\Controllers\API\V4\PaymentsController;
use App\Payment;
use App\Providers\PaymentProvider;
+use App\Transaction;
use App\Wallet;
use App\WalletSetting;
use GuzzleHttp\Psr7\Response;
+use Illuminate\Support\Facades\Bus;
use Tests\TestCase;
use Tests\StripeMocksTrait;
@@ -27,10 +29,11 @@
$john = $this->getTestUser('john@kolab.org');
$wallet = $john->wallets()->first();
- $john->setSetting('mollie_id', null);
Payment::where('wallet_id', $wallet->id)->delete();
Wallet::where('id', $wallet->id)->update(['balance' => 0]);
WalletSetting::where('wallet_id', $wallet->id)->delete();
+ Transaction::where('object_id', $wallet->id)
+ ->where('type', Transaction::WALLET_CREDIT)->delete();
}
/**
@@ -40,10 +43,11 @@
{
$john = $this->getTestUser('john@kolab.org');
$wallet = $john->wallets()->first();
- $john->setSetting('mollie_id', null);
Payment::where('wallet_id', $wallet->id)->delete();
Wallet::where('id', $wallet->id)->update(['balance' => 0]);
WalletSetting::where('wallet_id', $wallet->id)->delete();
+ Transaction::where('object_id', $wallet->id)
+ ->where('type', Transaction::WALLET_CREDIT)->delete();
parent::tearDown();
}
@@ -55,6 +59,8 @@
*/
public function testMandates(): void
{
+ Bus::fake();
+
// Unauth access not allowed
$response = $this->get("api/v4/payments/mandate");
$response->assertStatus(401);
@@ -120,6 +126,7 @@
$this->assertEquals(20.10, $json['amount']);
$this->assertEquals(0, $json['balance']);
+ $this->assertSame(false, $json['isDisabled']);
// We would have to invoke a browser to accept the "first payment" to make
// the mandate validated/completed. Instead, we'll mock the mandate object.
@@ -129,7 +136,8 @@
"created": 123456789,
"payment_method": "pm_YYY",
"status": "succeeded",
- "usage": "off_session"
+ "usage": "off_session",
+ "customer": null
}';
$paymentMethod = '{
@@ -152,6 +160,7 @@
// I.e. we have to fake the mandate id
$wallet = $user->wallets()->first();
$wallet->setSetting('stripe_mandate_id', 'AAA');
+ $wallet->setSetting('mandate_disabled', 1);
$response = $this->actingAs($user)->get("api/v4/payments/mandate");
$response->assertStatus(200);
@@ -163,8 +172,11 @@
$this->assertEquals('Visa (**** **** **** 4242)', $json['method']);
$this->assertSame(false, $json['isPending']);
$this->assertSame(true, $json['isValid']);
+ $this->assertSame(true, $json['isDisabled']);
// Test updating mandate details (invalid input)
+ $wallet->setSetting('mandate_disabled', null);
+ $user->refresh();
$post = [];
$response = $this->actingAs($user)->put("api/v4/payments/mandate", $post);
$response->assertStatus(422);
@@ -187,6 +199,9 @@
$this->assertSame("Minimum amount for a single payment is {$min}.", $json['errors']['amount']);
// Test updating a mandate (valid input)
+ $client->addResponse($setupIntent);
+ $client->addResponse($paymentMethod);
+
$post = ['amount' => 30.10, 'balance' => 1];
$response = $this->actingAs($user)->put("api/v4/payments/mandate", $post);
$response->assertStatus(200);
@@ -195,10 +210,48 @@
$this->assertSame('success', $json['status']);
$this->assertSame('The auto-payment has been updated.', $json['message']);
-
-
$this->assertEquals(30.10, $wallet->getSetting('mandate_amount'));
$this->assertEquals(1, $wallet->getSetting('mandate_balance'));
+ $this->assertSame('AAA', $json['id']);
+ $this->assertFalse($json['isDisabled']);
+
+ // Test updating a disabled mandate (invalid input)
+ $wallet->setSetting('mandate_disabled', 1);
+ $wallet->balance = -2000;
+ $wallet->save();
+ $user->refresh(); // required so the controller sees the wallet update from above
+
+ $post = ['amount' => 15.10, 'balance' => 1];
+ $response = $this->actingAs($user)->put("api/v4/payments/mandate", $post);
+ $response->assertStatus(422);
+
+ $json = $response->json();
+
+ $this->assertSame('error', $json['status']);
+ $this->assertCount(1, $json['errors']);
+ $this->assertSame('The specified amount does not cover the balance on the account.', $json['errors']['amount']);
+
+ // Test updating a disabled mandate (valid input)
+ $client->addResponse($setupIntent);
+ $client->addResponse($paymentMethod);
+
+ $post = ['amount' => 30, 'balance' => 1];
+ $response = $this->actingAs($user)->put("api/v4/payments/mandate", $post);
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertSame('success', $json['status']);
+ $this->assertSame('The auto-payment has been updated.', $json['message']);
+ $this->assertSame('AAA', $json['id']);
+ $this->assertFalse($json['isDisabled']);
+
+ Bus::assertDispatchedTimes(\App\Jobs\WalletCharge::class, 1);
+ Bus::assertDispatched(\App\Jobs\WalletCharge::class, function ($job) use ($wallet) {
+ $job_wallet = $this->getObjectProperty($job, 'wallet');
+ return $job_wallet->id === $wallet->id;
+ });
+
$this->unmockStripe();
@@ -212,6 +265,8 @@
*/
public function testStoreAndWebhook(): void
{
+ Bus::fake();
+
// Unauth access not allowed
$response = $this->post("api/v4/payments", []);
$response->assertStatus(401);
@@ -248,7 +303,149 @@
$this->assertSame('open', $payment->status);
$this->assertEquals(0, $wallet->balance);
- // TODO: Test the webhook
+ // Test the webhook
+
+ $post = [
+ 'id' => "evt_1GlZ814fj3SIEU8wtxMZ4Nsa",
+ 'object' => "event",
+ 'api_version' => "2020-03-02",
+ 'created' => 1590147209,
+ 'data' => [
+ 'object' => [
+ 'id' => $payment->id,
+ 'object' => "payment_intent",
+ 'amount' => 1234,
+ 'amount_capturable' => 0,
+ 'amount_received' => 1234,
+ 'capture_method' => "automatic",
+ 'client_secret' => "pi_1GlZ7w4fj3SIEU8w1RlBpN4l_secret_UYRNDTUUU7nkYHpOLZMb3uf48",
+ 'confirmation_method' => "automatic",
+ 'created' => 1590147204,
+ 'currency' => "chf",
+ 'customer' => "cus_HKDZ53OsKdlM83",
+ 'last_payment_error' => null,
+ 'livemode' => false,
+ 'metadata' => [],
+ 'receipt_email' => "payment-test@kolabnow.com",
+ 'status' => "succeeded"
+ ]
+ ],
+ 'type' => "payment_intent.succeeded"
+ ];
+
+ // Test payment succeeded event
+ $response = $this->webhookRequest($post);
+ $response->assertStatus(200);
+
+ $this->assertSame(PaymentProvider::STATUS_PAID, $payment->fresh()->status);
+ $this->assertEquals(1234, $wallet->fresh()->balance);
+
+ $transaction = $wallet->transactions()->where('type', Transaction::WALLET_CREDIT)->last();
+ $this->assertSame(1234, $transaction->amount);
+ $this->assertSame(
+ "Payment transaction {$payment->id} using Stripe",
+ $transaction->description
+ );
+
+ // Assert that email notification job wasn't dispatched,
+ // it is expected only for recurring payments
+ Bus::assertDispatchedTimes(\App\Jobs\PaymentEmail::class, 0);
+
+ // Test that balance didn't change if the same event is posted
+ $response = $this->webhookRequest($post);
+ $response->assertStatus(200);
+
+ $this->assertSame(PaymentProvider::STATUS_PAID, $payment->fresh()->status);
+ $this->assertEquals(1234, $wallet->fresh()->balance);
+
+ // Test for payment failure ('failed' status)
+ $payment->refresh();
+ $payment->status = PaymentProvider::STATUS_OPEN;
+ $payment->save();
+
+ $post['type'] = "payment_intent.payment_failed";
+ $post['data']['object']['status'] = 'failed';
+
+ $response = $this->webhookRequest($post);
+ $response->assertStatus(200);
+
+ $this->assertSame(PaymentProvider::STATUS_FAILED, $payment->fresh()->status);
+ $this->assertEquals(1234, $wallet->fresh()->balance);
+
+ // Assert that email notification job wasn't dispatched,
+ // it is expected only for recurring payments
+ Bus::assertDispatchedTimes(\App\Jobs\PaymentEmail::class, 0);
+
+ // Test for payment failure ('canceled' status)
+ $payment->refresh();
+ $payment->status = PaymentProvider::STATUS_OPEN;
+ $payment->save();
+
+ $post['type'] = "payment_intent.canceled";
+ $post['data']['object']['status'] = 'canceled';
+
+ $response = $this->webhookRequest($post);
+ $response->assertStatus(200);
+
+ $this->assertSame(PaymentProvider::STATUS_CANCELED, $payment->fresh()->status);
+ $this->assertEquals(1234, $wallet->fresh()->balance);
+
+ // Assert that email notification job wasn't dispatched,
+ // it is expected only for recurring payments
+ Bus::assertDispatchedTimes(\App\Jobs\PaymentEmail::class, 0);
+ }
+
+ /**
+ * Test receiving webhook request for setup intent
+ *
+ * @group stripe
+ */
+ public function testCreateMandateAndWebhook(): void
+ {
+ $user = $this->getTestUser('john@kolab.org');
+ $wallet = $user->wallets()->first();
+
+ // Test creating a mandate (valid input)
+ $post = ['amount' => 20.10, 'balance' => 0];
+ $response = $this->actingAs($user)->post("api/v4/payments/mandate", $post);
+ $response->assertStatus(200);
+
+ $payment = $wallet->payments()->first();
+
+ $this->assertSame(PaymentProvider::STATUS_OPEN, $payment->status);
+ $this->assertSame(PaymentProvider::TYPE_MANDATE, $payment->type);
+ $this->assertSame(0, $payment->amount);
+
+ $post = [
+ 'id' => "evt_1GlZ814fj3SIEU8wtxMZ4Nsa",
+ 'object' => "event",
+ 'api_version' => "2020-03-02",
+ 'created' => 1590147209,
+ 'data' => [
+ 'object' => [
+ 'id' => $payment->id,
+ 'object' => "setup_intent",
+ 'client_secret' => "pi_1GlZ7w4fj3SIEU8w1RlBpN4l_secret_UYRNDTUUU7nkYHpOLZMb3uf48",
+ 'created' => 1590147204,
+ 'customer' => "cus_HKDZ53OsKdlM83",
+ 'last_setup_error' => null,
+ 'metadata' => [],
+ 'status' => "succeeded"
+ ]
+ ],
+ 'type' => "setup_intent.succeeded"
+ ];
+
+ // Test payment succeeded event
+ $response = $this->webhookRequest($post);
+ $response->assertStatus(200);
+
+ $payment->refresh();
+
+ $this->assertSame(PaymentProvider::STATUS_PAID, $payment->status);
+ $this->assertSame($payment->id, $wallet->fresh()->getSetting('stripe_mandate_id'));
+
+ // TODO: test other setup_intent.* events
}
/**
@@ -256,20 +453,218 @@
*
* @group stripe
*/
- public function testDirectCharge(): void
+ public function testTopUpAndWebhook(): void
{
+ Bus::fake();
+
$user = $this->getTestUser('john@kolab.org');
$wallet = $user->wallets()->first();
- // Expect false result, as there's no mandate
- $result = PaymentsController::directCharge($wallet, 1234);
+ // Stripe API does not allow us to create a mandate easily
+ // That's why we we'll mock API responses
+ // Create a fake mandate
+ $wallet->setSettings([
+ 'mandate_amount' => 20.10,
+ 'mandate_balance' => 10,
+ 'stripe_mandate_id' => 'AAA',
+ ]);
+
+ $setupIntent = json_encode([
+ "id" => "AAA",
+ "object" => "setup_intent",
+ "created" => 123456789,
+ "payment_method" => "pm_YYY",
+ "status" => "succeeded",
+ "usage" => "off_session",
+ "customer" => null
+ ]);
+
+ $paymentMethod = json_encode([
+ "id" => "pm_YYY",
+ "object" => "payment_method",
+ "card" => [
+ "brand" => "visa",
+ "country" => "US",
+ "last4" => "4242"
+ ],
+ "created" => 123456789,
+ "type" => "card"
+ ]);
+
+ $paymentIntent = json_encode([
+ "id" => "pi_XX",
+ "object" => "payment_intent",
+ "created" => 123456789,
+ "amount" => 2010,
+ "currency" => "chf",
+ "description" => "Kolab Recurring Payment"
+ ]);
+
+ $client = $this->mockStripe();
+ $client->addResponse($setupIntent);
+ $client->addResponse($paymentMethod);
+ $client->addResponse($setupIntent);
+ $client->addResponse($paymentIntent);
+
+ // Expect a recurring payment as we have a valid mandate at this point
+ $result = PaymentsController::topUpWallet($wallet);
+ $this->assertTrue($result);
+
+ // Check that the payments table contains a new record with proper amount
+ // There should be two records, one for the first payment and another for
+ // the recurring payment
+ $this->assertCount(1, $wallet->payments()->get());
+ $payment = $wallet->payments()->first();
+ $this->assertSame(2010, $payment->amount);
+ $this->assertSame(\config('app.name') . " Recurring Payment", $payment->description);
+ $this->assertSame("pi_XX", $payment->id);
+
+ // Expect no payment if the mandate is disabled
+ $wallet->setSetting('mandate_disabled', 1);
+ $result = PaymentsController::topUpWallet($wallet);
$this->assertFalse($result);
+ $this->assertCount(1, $wallet->payments()->get());
+
+ // Expect no payment if balance is ok
+ $wallet->setSetting('mandate_disabled', null);
+ $wallet->balance = 1000;
+ $wallet->save();
+ $result = PaymentsController::topUpWallet($wallet);
+ $this->assertFalse($result);
+ $this->assertCount(1, $wallet->payments()->get());
+
+ // Expect no payment if the top-up amount is not enough
+ $wallet->setSetting('mandate_disabled', null);
+ $wallet->balance = -2050;
+ $wallet->save();
+ $result = PaymentsController::topUpWallet($wallet);
+ $this->assertFalse($result);
+ $this->assertCount(1, $wallet->payments()->get());
+
+ Bus::assertDispatchedTimes(\App\Jobs\PaymentMandateDisabledEmail::class, 1);
+ Bus::assertDispatched(\App\Jobs\PaymentMandateDisabledEmail::class, function ($job) use ($wallet) {
+ $job_wallet = $this->getObjectProperty($job, 'wallet');
+ return $job_wallet->id === $wallet->id;
+ });
+
+ // Expect no payment if there's no mandate
+ $wallet->setSetting('mollie_mandate_id', null);
+ $wallet->balance = 0;
+ $wallet->save();
+ $result = PaymentsController::topUpWallet($wallet);
+ $this->assertFalse($result);
+ $this->assertCount(1, $wallet->payments()->get());
+
+ Bus::assertDispatchedTimes(\App\Jobs\PaymentMandateDisabledEmail::class, 1);
+
+ $this->unmockStripe();
+
+ // Test webhook
+
+ $post = [
+ 'id' => "evt_1GlZ814fj3SIEU8wtxMZ4Nsa",
+ 'object' => "event",
+ 'api_version' => "2020-03-02",
+ 'created' => 1590147209,
+ 'data' => [
+ 'object' => [
+ 'id' => $payment->id,
+ 'object' => "payment_intent",
+ 'amount' => 2010,
+ 'capture_method' => "automatic",
+ 'created' => 1590147204,
+ 'currency' => "chf",
+ 'customer' => "cus_HKDZ53OsKdlM83",
+ 'last_payment_error' => null,
+ 'metadata' => [],
+ 'receipt_email' => "payment-test@kolabnow.com",
+ 'status' => "succeeded"
+ ]
+ ],
+ 'type' => "payment_intent.succeeded"
+ ];
+
+ // Test payment succeeded event
+ $response = $this->webhookRequest($post);
+ $response->assertStatus(200);
+
+ $this->assertSame(PaymentProvider::STATUS_PAID, $payment->fresh()->status);
+ $this->assertEquals(2010, $wallet->fresh()->balance);
+ $transaction = $wallet->transactions()->where('type', Transaction::WALLET_CREDIT)->last();
+ $this->assertSame(2010, $transaction->amount);
+ $this->assertSame(
+ "Auto-payment transaction {$payment->id} using Stripe",
+ $transaction->description
+ );
+
+ // Assert that email notification job has been dispatched
+ Bus::assertDispatchedTimes(\App\Jobs\PaymentEmail::class, 1);
+ Bus::assertDispatched(\App\Jobs\PaymentEmail::class, function ($job) use ($payment) {
+ $job_payment = $this->getObjectProperty($job, 'payment');
+ return $job_payment->id === $payment->id;
+ });
+
+ Bus::fake();
+
+ // Test for payment failure ('failed' status)
+ $payment->refresh();
+ $payment->status = PaymentProvider::STATUS_OPEN;
+ $payment->save();
+
+ $wallet->setSetting('mandate_disabled', null);
+
+ $post['type'] = "payment_intent.payment_failed";
+ $post['data']['object']['status'] = 'failed';
+
+ $response = $this->webhookRequest($post);
+ $response->assertStatus(200);
+
+ $wallet->refresh();
+
+ $this->assertSame(PaymentProvider::STATUS_FAILED, $payment->fresh()->status);
+ $this->assertEquals(2010, $wallet->balance);
+ $this->assertTrue(!empty($wallet->getSetting('mandate_disabled')));
+
+ // Assert that email notification job has been dispatched
+ Bus::assertDispatchedTimes(\App\Jobs\PaymentEmail::class, 1);
+ Bus::assertDispatched(\App\Jobs\PaymentEmail::class, function ($job) use ($payment) {
+ $job_payment = $this->getObjectProperty($job, 'payment');
+ return $job_payment->id === $payment->id;
+ });
+
+ Bus::fake();
+
+ // Test for payment failure ('canceled' status)
+ $payment->refresh();
+ $payment->status = PaymentProvider::STATUS_OPEN;
+ $payment->save();
+
+ $post['type'] = "payment_intent.canceled";
+ $post['data']['object']['status'] = 'canceled';
+
+ $response = $this->webhookRequest($post);
+ $response->assertStatus(200);
+
+ $this->assertSame(PaymentProvider::STATUS_CANCELED, $payment->fresh()->status);
+ $this->assertEquals(2010, $wallet->fresh()->balance);
+
+ // Assert that email notification job wasn't dispatched,
+ // it is expected only for recurring payments
+ Bus::assertDispatchedTimes(\App\Jobs\PaymentEmail::class, 0);
+ }
+
+ /**
+ * Generate Stripe-Signature header for a webhook payload
+ */
+ protected function webhookRequest($post)
+ {
+ $secret = \config('services.stripe.webhook_secret');
+ $ts = time();
- // Problem with this is we need to have a valid mandate
- // And there's no easy way to confirm a created mandate.
- // The only way seems to be to fire up Chrome on checkout page
- // and do some actions with use of Dusk browser.
+ $payload = "$ts." . json_encode($post);
+ $sig = sprintf('t=%d,v1=%s', $ts, \hash_hmac('sha256', $payload, $secret));
- $this->markTestIncomplete();
+ return $this->withHeaders(['Stripe-Signature' => $sig])
+ ->json('POST', "api/webhooks/payment/stripe", $post);
}
}
diff --git a/src/tests/Feature/Controller/UsersTest.php b/src/tests/Feature/Controller/UsersTest.php
--- a/src/tests/Feature/Controller/UsersTest.php
+++ b/src/tests/Feature/Controller/UsersTest.php
@@ -311,7 +311,12 @@
$this->assertTrue(empty($json['status']));
$this->assertTrue(empty($json['message']));
- // Now "reboot" the process and verify the user in imap syncronously
+ // Make sure the domain is confirmed (other test might unset that status)
+ $domain = $this->getTestDomain('kolab.org');
+ $domain->status |= Domain::STATUS_CONFIRMED;
+ $domain->save();
+
+ // Now "reboot" the process and verify the user in imap synchronously
$response = $this->actingAs($john)->get("/api/v4/users/{$john->id}/status?refresh=1");
$response->assertStatus(200);
diff --git a/src/tests/Feature/Jobs/PaymentEmailTest.php b/src/tests/Feature/Jobs/PaymentEmailTest.php
new file mode 100644
--- /dev/null
+++ b/src/tests/Feature/Jobs/PaymentEmailTest.php
@@ -0,0 +1,120 @@
+<?php
+
+namespace Tests\Feature\Jobs;
+
+use App\Jobs\PaymentEmail;
+use App\Mail\PaymentFailure;
+use App\Mail\PaymentSuccess;
+use App\Payment;
+use App\Providers\PaymentProvider;
+use Illuminate\Support\Facades\Mail;
+use Tests\TestCase;
+
+class PaymentEmailTest extends TestCase
+{
+ /**
+ * {@inheritDoc}
+ *
+ * @return void
+ */
+ public function setUp(): void
+ {
+ parent::setUp();
+
+ $this->deleteTestUser('PaymentEmail@UserAccount.com');
+ }
+
+ /**
+ * {@inheritDoc}
+ *
+ * @return void
+ */
+ public function tearDown(): void
+ {
+ $this->deleteTestUser('PaymentEmail@UserAccount.com');
+
+ parent::tearDown();
+ }
+
+ /**
+ * Test job handle
+ *
+ * @return void
+ */
+ public function testHandle()
+ {
+ $user = $this->getTestUser('PaymentEmail@UserAccount.com');
+ $user->setSetting('external_email', 'ext@email.tld');
+ $wallet = $user->wallets()->first();
+
+ $payment = new Payment();
+ $payment->id = 'test-payment';
+ $payment->wallet_id = $wallet->id;
+ $payment->amount = 100;
+ $payment->status = PaymentProvider::STATUS_PAID;
+ $payment->description = 'test';
+ $payment->provider = 'stripe';
+ $payment->type = PaymentProvider::TYPE_ONEOFF;
+ $payment->save();
+
+ Mail::fake();
+
+ // Assert that no jobs were pushed...
+ Mail::assertNothingSent();
+
+ $job = new PaymentEmail($payment);
+ $job->handle();
+
+ // Assert the email sending job was pushed once
+ Mail::assertSent(PaymentSuccess::class, 1);
+
+ // Assert the mail was sent to the user's email
+ Mail::assertSent(PaymentSuccess::class, function ($mail) use ($user) {
+ return $mail->hasTo($user->email) && $mail->hasCc('ext@email.tld');
+ });
+
+ $payment->status = PaymentProvider::STATUS_FAILED;
+ $payment->save();
+
+ $job = new PaymentEmail($payment);
+ $job->handle();
+
+ // Assert the email sending job was pushed once
+ Mail::assertSent(PaymentFailure::class, 1);
+
+ // Assert the mail was sent to the user's email
+ Mail::assertSent(PaymentFailure::class, function ($mail) use ($user) {
+ return $mail->hasTo($user->email) && $mail->hasCc('ext@email.tld');
+ });
+
+ $payment->status = PaymentProvider::STATUS_EXPIRED;
+ $payment->save();
+
+ $job = new PaymentEmail($payment);
+ $job->handle();
+
+ // Assert the email sending job was pushed twice
+ Mail::assertSent(PaymentFailure::class, 2);
+
+ // None of statuses below should trigger an email
+ Mail::fake();
+
+ $states = [
+ PaymentProvider::STATUS_OPEN,
+ PaymentProvider::STATUS_CANCELED,
+ PaymentProvider::STATUS_PENDING,
+ PaymentProvider::STATUS_AUTHORIZED,
+ ];
+
+ foreach ($states as $state) {
+ $payment->status = $state;
+ $payment->save();
+
+ $job = new PaymentEmail($payment);
+ $job->handle();
+ }
+
+ // Assert that no mailables were sent...
+ Mail::assertNothingSent();
+ }
+}
diff --git a/src/tests/Feature/Jobs/PaymentMandateDisabledEmailTest.php b/src/tests/Feature/Jobs/PaymentMandateDisabledEmailTest.php
new file mode 100644
--- /dev/null
+++ b/src/tests/Feature/Jobs/PaymentMandateDisabledEmailTest.php
@@ -0,0 +1,63 @@
+<?php
+
+namespace Tests\Feature\Jobs;
+
+use App\Jobs\PaymentMandateDisabledEmail;
+use App\Mail\PaymentMandateDisabled;
+use Illuminate\Support\Facades\Mail;
+use Tests\TestCase;
+
+class PaymentMandateDisabledEmailTest extends TestCase
+{
+ /**
+ * {@inheritDoc}
+ *
+ * @return void
+ */
+ public function setUp(): void
+ {
+ parent::setUp();
+
+ $this->deleteTestUser('PaymentEmail@UserAccount.com');
+ }
+
+ /**
+ * {@inheritDoc}
+ *
+ * @return void
+ */
+ public function tearDown(): void
+ {
+ $this->deleteTestUser('PaymentEmail@UserAccount.com');
+
+ parent::tearDown();
+ }
+
+ /**
+ * Test job handle
+ *
+ * @return void
+ */
+ public function testHandle()
+ {
+ $user = $this->getTestUser('PaymentEmail@UserAccount.com');
+ $user->setSetting('external_email', 'ext@email.tld');
+ $wallet = $user->wallets()->first();
+
+ Mail::fake();
+
+ // Assert that no jobs were pushed...
+ Mail::assertNothingSent();
+
+ $job = new PaymentMandateDisabledEmail($wallet);
+ $job->handle();
+
+ // Assert the email sending job was pushed once
+ Mail::assertSent(PaymentMandateDisabled::class, 1);
+
+ // Assert the mail was sent to the user's email
+ Mail::assertSent(PaymentMandateDisabled::class, function ($mail) use ($user) {
+ return $mail->hasTo($user->email) && $mail->hasCc('ext@email.tld');
+ });
+ }
+}
diff --git a/src/tests/Unit/Mail/PasswordResetTest.php b/src/tests/Unit/Mail/PasswordResetTest.php
--- a/src/tests/Unit/Mail/PasswordResetTest.php
+++ b/src/tests/Unit/Mail/PasswordResetTest.php
@@ -4,6 +4,7 @@
use App\Mail\PasswordReset;
use App\User;
+use App\Utils;
use App\VerificationCode;
use Tests\TestCase;
@@ -31,7 +32,7 @@
$mail = new PasswordReset($code);
$html = $mail->build()->render();
- $url = \config('app.url') . '/login/reset/' . $code->short_code . '-' . $code->code;
+ $url = Utils::serviceUrl('/login/reset/' . $code->short_code . '-' . $code->code);
$link = "<a href=\"$url\">$url</a>";
$this->assertSame(\config('app.name') . ' Password Reset', $mail->subject);
diff --git a/src/tests/Unit/Mail/PaymentFailureTest.php b/src/tests/Unit/Mail/PaymentFailureTest.php
new file mode 100644
--- /dev/null
+++ b/src/tests/Unit/Mail/PaymentFailureTest.php
@@ -0,0 +1,44 @@
+<?php
+
+namespace Tests\Unit\Mail;
+
+use App\Mail\PaymentFailure;
+use App\Payment;
+use App\User;
+use Tests\TestCase;
+
+class PaymentFailureTest extends TestCase
+{
+ /**
+ * Test email content
+ *
+ * @return void
+ */
+ public function testBuild()
+ {
+ // @phpstan-ignore-next-line
+ $user = new User();
+ $payment = new Payment();
+ $payment->amount = 123;
+
+ \config(['app.support_url' => 'https://kolab.org/support']);
+
+ $mail = new PaymentFailure($payment, $user);
+ $html = $mail->build()->render();
+
+ $walletUrl = \App\Utils::serviceUrl('/wallet');
+ $walletLink = sprintf('<a href="%s">%s</a>', $walletUrl, $walletUrl);
+ $supportUrl = \config('app.support_url');
+ $supportLink = sprintf('<a href="%s">%s</a>', $supportUrl, $supportUrl);
+ $appName = \config('app.name');
+
+ $this->assertSame("$appName Payment Failed", $mail->subject);
+ $this->assertStringStartsWith('<!DOCTYPE html>', $html);
+ $this->assertTrue(strpos($html, $user->name(true)) > 0);
+ $this->assertTrue(strpos($html, $walletLink) > 0);
+ $this->assertTrue(strpos($html, $supportLink) > 0);
+ $this->assertTrue(strpos($html, "$appName Support") > 0);
+ $this->assertTrue(strpos($html, "Something went wrong with auto-payment for your $appName account") > 0);
+ $this->assertTrue(strpos($html, "$appName Team") > 0);
+ }
+}
diff --git a/src/tests/Unit/Mail/PaymentMandateDisabledTest.php b/src/tests/Unit/Mail/PaymentMandateDisabledTest.php
new file mode 100644
--- /dev/null
+++ b/src/tests/Unit/Mail/PaymentMandateDisabledTest.php
@@ -0,0 +1,43 @@
+<?php
+
+namespace Tests\Unit\Mail;
+
+use App\Mail\PaymentMandateDisabled;
+use App\Wallet;
+use App\User;
+use Tests\TestCase;
+
+class PaymentMandateDisabledTest extends TestCase
+{
+ /**
+ * Test email content
+ *
+ * @return void
+ */
+ public function testBuild()
+ {
+ // @phpstan-ignore-next-line
+ $user = new User();
+ $wallet = new Wallet();
+
+ \config(['app.support_url' => 'https://kolab.org/support']);
+
+ $mail = new PaymentMandateDisabled($wallet, $user);
+ $html = $mail->build()->render();
+
+ $walletUrl = \App\Utils::serviceUrl('/wallet');
+ $walletLink = sprintf('<a href="%s">%s</a>', $walletUrl, $walletUrl);
+ $supportUrl = \config('app.support_url');
+ $supportLink = sprintf('<a href="%s">%s</a>', $supportUrl, $supportUrl);
+ $appName = \config('app.name');
+
+ $this->assertSame("$appName Auto-payment Problem", $mail->subject);
+ $this->assertStringStartsWith('<!DOCTYPE html>', $html);
+ $this->assertTrue(strpos($html, $user->name(true)) > 0);
+ $this->assertTrue(strpos($html, $walletLink) > 0);
+ $this->assertTrue(strpos($html, $supportLink) > 0);
+ $this->assertTrue(strpos($html, "$appName Support") > 0);
+ $this->assertTrue(strpos($html, "Your $appName account balance") > 0);
+ $this->assertTrue(strpos($html, "$appName Team") > 0);
+ }
+}
diff --git a/src/tests/Unit/Mail/PaymentSuccessTest.php b/src/tests/Unit/Mail/PaymentSuccessTest.php
new file mode 100644
--- /dev/null
+++ b/src/tests/Unit/Mail/PaymentSuccessTest.php
@@ -0,0 +1,44 @@
+<?php
+
+namespace Tests\Unit\Mail;
+
+use App\Mail\PaymentSuccess;
+use App\Payment;
+use App\User;
+use Tests\TestCase;
+
+class PaymentSuccessTest extends TestCase
+{
+ /**
+ * Test email content
+ *
+ * @return void
+ */
+ public function testBuild()
+ {
+ // @phpstan-ignore-next-line
+ $user = new User();
+ $payment = new Payment();
+ $payment->amount = 123;
+
+ \config(['app.support_url' => 'https://kolab.org/support']);
+
+ $mail = new PaymentSuccess($payment, $user);
+ $html = $mail->build()->render();
+
+ $walletUrl = \App\Utils::serviceUrl('/wallet');
+ $walletLink = sprintf('<a href="%s">%s</a>', $walletUrl, $walletUrl);
+ $supportUrl = \config('app.support_url');
+ $supportLink = sprintf('<a href="%s">%s</a>', $supportUrl, $supportUrl);
+ $appName = \config('app.name');
+
+ $this->assertSame("$appName Payment Succeeded", $mail->subject);
+ $this->assertStringStartsWith('<!DOCTYPE html>', $html);
+ $this->assertTrue(strpos($html, $user->name(true)) > 0);
+ $this->assertTrue(strpos($html, $walletLink) > 0);
+ $this->assertTrue(strpos($html, $supportLink) > 0);
+ $this->assertTrue(strpos($html, "$appName Support") > 0);
+ $this->assertTrue(strpos($html, "The auto-payment for your $appName account") > 0);
+ $this->assertTrue(strpos($html, "$appName Team") > 0);
+ }
+}
diff --git a/src/tests/Unit/Mail/SignupVerificationTest.php b/src/tests/Unit/Mail/SignupVerificationTest.php
--- a/src/tests/Unit/Mail/SignupVerificationTest.php
+++ b/src/tests/Unit/Mail/SignupVerificationTest.php
@@ -4,6 +4,7 @@
use App\Mail\SignupVerification;
use App\SignupCode;
+use App\Utils;
use Tests\TestCase;
class SignupVerificationTest extends TestCase
@@ -28,7 +29,7 @@
$mail = new SignupVerification($code);
$html = $mail->build()->render();
- $url = \config('app.url') . '/signup/' . $code->short_code . '-' . $code->code;
+ $url = Utils::serviceUrl('/signup/' . $code->short_code . '-' . $code->code);
$link = "<a href=\"$url\">$url</a>";
$this->assertSame(\config('app.name') . ' Registration', $mail->subject);

File Metadata

Mime Type
text/plain
Expires
Sat, Apr 4, 6:10 PM (10 h, 14 m)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
18830642
Default Alt Text
D1249.1775326230.diff (119 KB)

Event Timeline