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