Page Menu
Home
Phorge
Search
Configure Global Search
Log In
Files
F117869611
D1249.1775326230.diff
No One
Temporary
Actions
View File
Edit File
Delete File
View Transforms
Subscribe
Flag For Later
Award Token
Authored By
Unknown
Size
119 KB
Referenced Files
None
Subscribers
None
D1249.1775326230.diff
View Options
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
Details
Attached
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)
Attached To
Mode
D1249: Wallet charge: top-ups and notifications
Attached
Detach File
Event Timeline