Page MenuHomePhorge

D1249.1775311440.diff
No OneTemporary

Authored By
Unknown
Size
81 KB
Referenced Files
None
Subscribers
None

D1249.1775311440.diff

diff --git a/src/.env.example b/src/.env.example
--- a/src/.env.example
+++ b/src/.env.example
@@ -6,6 +6,8 @@
APP_PUBLIC_URL=
APP_DOMAIN=kolabnow.com
+SUPPORT_URL=
+
LOG_CHANNEL=stack
DB_CONNECTION=mysql
diff --git a/src/app/Console/Commands/WalletCharge.php b/src/app/Console/Commands/WalletCharge.php
--- a/src/app/Console/Commands/WalletCharge.php
+++ b/src/app/Console/Commands/WalletCharge.php
@@ -2,11 +2,7 @@
namespace App\Console\Commands;
-use App\Domain;
-use App\User;
-use Carbon\Carbon;
use Illuminate\Console\Command;
-use Illuminate\Support\Facades\DB;
class WalletCharge extends Command
{
@@ -44,19 +40,15 @@
$wallets = \App\Wallet::all();
foreach ($wallets as $wallet) {
- $charge = $wallet->expectedCharges();
+ $charge = $wallet->chargeEntitlements();
if ($charge > 0) {
$this->info(
- "charging wallet {$wallet->id} for user {$wallet->owner->email} with {$charge}"
+ "Charged wallet {$wallet->id} for user {$wallet->owner->email} with {$charge}"
);
- $wallet->chargeEntitlements();
-
- if ($wallet->balance < 0) {
- // Disabled for now
- // \App\Jobs\WalletPayment::dispatch($wallet);
- }
+ // Top-up the wallet if auto-payment enabled for the wallet
+ \App\Jobs\WalletCharge::dispatch($wallet);
}
}
}
diff --git a/src/app/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,11 @@
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,
+ 'mandate_disabled' => null
+ ]);
$request = [
'currency' => 'CHF',
@@ -142,8 +145,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,
+ ]);
return response()->json([
'status' => 'success',
@@ -222,15 +227,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 +271,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 +292,7 @@
$mandate['amount'] = (int) (PaymentProvider::MIN_AMOUNT / 100);
$mandate['balance'] = 0;
+ $mandate['isDisabled'] = (bool) $wallet->getSetting('mandate_disabled');
foreach (['amount', 'balance'] as $key) {
if (($value = $wallet->getSetting("mandate_{$key}")) !== null) {
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
@@ -226,7 +226,7 @@
$payment['status'] = $response->status;
$payment['id'] = $response->id;
- self::storePayment($payment, $wallet->id);
+ $this->storePayment($payment, $wallet->id);
return [
'id' => $payment['id'],
@@ -268,6 +268,7 @@
// Update the balance, if it wasn't already
if ($payment->status != self::STATUS_PAID && $payment->amount > 0) {
$payment->wallet->credit($payment->amount);
+ $notify = $payment->type == self::TYPE_RECURRING;
}
} elseif ($mollie_payment->hasRefunds()) {
// The payment has been (partially) refunded.
@@ -281,15 +282,27 @@
} 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);
+ }
}
// 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($notify)) {
+ \App\Jobs\PaymentEmail::dispatch($payment);
+ }
+
return 200;
}
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,7 @@
use App\Utils;
use App\Wallet;
use App\WalletSetting;
+use Illuminate\Support\Facades\Request;
use Stripe as StripeAPI;
class Stripe extends \App\Providers\PaymentProvider
@@ -73,6 +74,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,
];
@@ -201,10 +209,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,
@@ -243,10 +250,9 @@
$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 +266,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 +279,7 @@
$sig_header,
\config('services.stripe.webhook_secret')
);
- } catch (\UnexpectedValueException $e) {
+ } catch (\Exception $e) {
// Invalid payload
return 400;
}
@@ -282,6 +291,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,7 +303,7 @@
$status = self::STATUS_PAID;
break;
default:
- $status = self::STATUS_PENDING;
+ $status = self::STATUS_FAILED;
}
if ($status == self::STATUS_PAID) {
@@ -298,35 +311,62 @@
if ($payment->status != self::STATUS_PAID) {
$payment->wallet->credit($payment->amount);
}
- } 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)
- ));
+ } 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)
+ ));
+ }
}
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);
+ }
}
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:
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;
@@ -136,14 +137,16 @@
* @param array $payment Payment information
* @param string $wallet_id Wallet ID
*/
- protected static function storePayment(array $payment, $wallet_id): void
+ protected function storePayment(array $payment, $wallet_id): void
{
$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();
}
}
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.
*
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/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
@@ -39,4 +39,25 @@
. " :site Support is here to help:",
'more-info-html' => "See <a href=\":href\">here</a> for more information.",
+
+ '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 "
+ . "re-enable or change your auto-payment options.",
+
+ '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:",
];
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/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/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
@@ -8,12 +8,15 @@
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,7 +30,6 @@
$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();
@@ -40,7 +42,6 @@
{
$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();
@@ -123,6 +124,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'];
@@ -144,6 +146,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 +159,7 @@
$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)
$post = [];
@@ -219,6 +225,8 @@
*/
public function testStoreAndWebhook(): void
{
+ Bus::fake();
+
// Unauth access not allowed
$response = $this->post("api/v4/payments", []);
$response->assertStatus(401);
@@ -277,9 +285,13 @@
$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);
+ // 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 +300,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 +310,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 +349,158 @@
*
* @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);
+
+ // 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);
+
+ // 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
@@ -8,6 +8,7 @@
use App\Wallet;
use App\WalletSetting;
use GuzzleHttp\Psr7\Response;
+use Illuminate\Support\Facades\Bus;
use Tests\TestCase;
use Tests\StripeMocksTrait;
@@ -27,7 +28,6 @@
$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();
@@ -40,7 +40,6 @@
{
$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();
@@ -120,6 +119,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 +129,8 @@
"created": 123456789,
"payment_method": "pm_YYY",
"status": "succeeded",
- "usage": "off_session"
+ "usage": "off_session",
+ "customer": null
}';
$paymentMethod = '{
@@ -152,6 +153,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,6 +165,7 @@
$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)
$post = [];
@@ -212,6 +215,8 @@
*/
public function testStoreAndWebhook(): void
{
+ Bus::fake();
+
// Unauth access not allowed
$response = $this->post("api/v4/payments", []);
$response->assertStatus(401);
@@ -248,7 +253,142 @@
$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);
+
+ // 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 +396,212 @@
*
* @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("Kolab 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);
+
+ // 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/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,118 @@
+<?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->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, "An auto-payment for your $appName account") > 0);
+ $this->assertTrue(strpos($html, "$appName Team") > 0);
+ }
+}
diff --git a/src/tests/Unit/Mail/SignupVerificationTest.php b/src/tests/Unit/Mail/SignupVerificationTest.php
--- a/src/tests/Unit/Mail/SignupVerificationTest.php
+++ b/src/tests/Unit/Mail/SignupVerificationTest.php
@@ -4,6 +4,7 @@
use App\Mail\SignupVerification;
use App\SignupCode;
+use App\Utils;
use Tests\TestCase;
class SignupVerificationTest extends TestCase
@@ -28,7 +29,7 @@
$mail = new SignupVerification($code);
$html = $mail->build()->render();
- $url = \config('app.url') . '/signup/' . $code->short_code . '-' . $code->code;
+ $url = Utils::serviceUrl('/signup/' . $code->short_code . '-' . $code->code);
$link = "<a href=\"$url\">$url</a>";
$this->assertSame(\config('app.name') . ' Registration', $mail->subject);

File Metadata

Mime Type
text/plain
Expires
Sat, Apr 4, 2:04 PM (14 h, 29 m)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
18829789
Default Alt Text
D1249.1775311440.diff (81 KB)

Event Timeline