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/Admin/WalletsController.php b/src/app/Http/Controllers/API/V4/Admin/WalletsController.php --- a/src/app/Http/Controllers/API/V4/Admin/WalletsController.php +++ b/src/app/Http/Controllers/API/V4/Admin/WalletsController.php @@ -42,7 +42,7 @@ $result['provider'] = $provider->name(); $result['providerLink'] = $provider->customerLink($wallet); - return $result; + return response()->json($result); } /** 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/Http/Controllers/API/V4/WalletsController.php b/src/app/Http/Controllers/API/V4/WalletsController.php --- a/src/app/Http/Controllers/API/V4/WalletsController.php +++ b/src/app/Http/Controllers/API/V4/WalletsController.php @@ -45,7 +45,7 @@ /** * Display the specified resource. * - * @param int $id + * @param string $id * * @return \Illuminate\Http\JsonResponse */ 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 @@ +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 @@ +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 @@ -2,6 +2,7 @@ namespace App\Mail; +use App\Utils; use App\VerificationCode; use Illuminate\Bus\Queueable; use Illuminate\Mail\Mailable; @@ -35,11 +36,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/PaymentFailure.php b/src/app/Mail/PaymentFailure.php new file mode 100644 --- /dev/null +++ b/src/app/Mail/PaymentFailure.php @@ -0,0 +1,61 @@ +payment = $payment; + $this->user = $user; + } + + /** + * Build the message. + * + * @return $this + */ + public function build() + { + $user = $this->user; + + $subject = \trans('mail.paymentfailure-subject', ['site' => \config('app.name')]); + + $this->view('emails.payment_failure') + ->subject($subject) + ->with([ + 'site' => \config('app.name'), + 'subject' => $subject, + 'username' => $user->name(true), + 'walletUrl' => Utils::serviceUrl('/wallet'), + 'supportUrl' => \config('app.support_url'), + ]); + + return $this; + } +} diff --git a/src/app/Mail/PaymentMandateDisabled.php b/src/app/Mail/PaymentMandateDisabled.php new file mode 100644 --- /dev/null +++ b/src/app/Mail/PaymentMandateDisabled.php @@ -0,0 +1,61 @@ +wallet = $wallet; + $this->user = $user; + } + + /** + * Build the message. + * + * @return $this + */ + public function build() + { + $user = $this->user; + + $subject = \trans('mail.paymentmandatedisabled-subject', ['site' => \config('app.name')]); + + $this->view('emails.payment_mandate_disabled') + ->subject($subject) + ->with([ + 'site' => \config('app.name'), + 'subject' => $subject, + 'username' => $user->name(true), + 'walletUrl' => Utils::serviceUrl('/wallet'), + 'supportUrl' => \config('app.support_url'), + ]); + + return $this; + } +} diff --git a/src/app/Mail/PaymentSuccess.php b/src/app/Mail/PaymentSuccess.php new file mode 100644 --- /dev/null +++ b/src/app/Mail/PaymentSuccess.php @@ -0,0 +1,61 @@ +payment = $payment; + $this->user = $user; + } + + /** + * Build the message. + * + * @return $this + */ + public function build() + { + $user = $this->user; + + $subject = \trans('mail.paymentsuccess-subject', ['site' => \config('app.name')]); + + $this->view('emails.payment_success') + ->subject($subject) + ->with([ + 'site' => \config('app.name'), + 'subject' => $subject, + 'username' => $user->name(true), + 'walletUrl' => Utils::serviceUrl('/wallet'), + 'supportUrl' => \config('app.support_url'), + ]); + + return $this; + } +} 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; @@ -35,11 +36,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) ); $this->view('emails.signup_code') 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; } 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 @@ -120,6 +120,28 @@ return $this->chargeEntitlements(false); } + /** + * 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); + } + /** * Remove a controller from 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,6 +234,5 @@ 'URL' => Illuminate\Support\Facades\URL::class, 'Validator' => Illuminate\Support\Facades\Validator::class, 'View' => Illuminate\Support\Facades\View::class, - ], ]; 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 @@ -21,6 +21,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 @@ -22,4 +22,28 @@ 'signupcode-subject' => ":site Registration", 'signupcode-body' => "This is your verification code for the :site registration process: :code.\n" . "You can also click the link below to continue the registration process:", + + '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' => "An auto-payment for your :site account went through without issues. " + . "You can check your new account balance and more details here:", + + 'payment-support-body' => "Something wrong with a charge? Any other issues?\n" + . " :site Support is here to help:", ]; 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 @@ + + + + + + +

{{ __('mail.header', ['name' => $username]) }}

+ +

{{ __('mail.paymentfailure-body', ['site' => $site]) }}

+

{{ $walletUrl }}

+

{{ __('mail.paymentfailure-body-rest', ['site' => $site]) }}

+ +@if ($supportUrl) +

{{ __('mail.payment-support-body', ['site' => $site]) }}

+

{{ $supportUrl }}

+@endif + +

{{ __('mail.footer', ['site' => $site, 'appurl' => config('app.url')]) }}

+ + 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 @@ + + + + + + +

{{ __('mail.header', ['name' => $username]) }}

+ +

{{ __('mail.paymentmandatedisabled-body', ['site' => $site]) }}

+

{{ $walletUrl }}

+

{{ __('mail.paymentfailure-body-rest', ['site' => $site]) }}

+ +@if ($supportUrl) +

{{ __('mail.payment-support-body', ['site' => $site]) }}

+

{{ $supportUrl }}

+@endif + +

{{ __('mail.footer', ['site' => $site, 'appurl' => config('app.url')]) }}

+ + 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 @@ + + + + + + +

{{ __('mail.header', ['name' => $username]) }}

+ +

{{ __('mail.paymentsuccess-body', ['site' => $site]) }}

+

{{ $walletUrl }}

+ +@if ($supportUrl) +

{{ __('mail.payment-support-body', ['site' => $site]) }}

+

{{ $supportUrl }}

+@endif + +

{{ __('mail.footer', ['site' => $site, 'appurl' => config('app.url')]) }}

+ + 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 @@ +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,11 +8,13 @@ use App\Wallet; use App\WalletSetting; use GuzzleHttp\Psr7\Response; +use Illuminate\Support\Facades\Bus; use Tests\TestCase; class PaymentsMollieTest extends TestCase { use \Tests\MollieMocksTrait; + use \Tests\BrowserAddonTrait; /** * {@inheritDoc} @@ -26,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(); @@ -39,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(); @@ -122,6 +122,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']; @@ -143,6 +144,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); @@ -153,6 +157,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 = []; @@ -218,6 +223,8 @@ */ public function testStoreAndWebhook(): void { + Bus::fake(); + // Unauth access not allowed $response = $this->post("api/v4/payments", []); $response->assertStatus(401); @@ -276,9 +283,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']); @@ -287,7 +298,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'; @@ -297,8 +308,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); } /** @@ -306,20 +347,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; class PaymentsStripeTest extends TestCase @@ -26,7 +27,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(); @@ -39,7 +39,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(); @@ -119,6 +118,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. @@ -128,7 +128,8 @@ "created": 123456789, "payment_method": "pm_YYY", "status": "succeeded", - "usage": "off_session" + "usage": "off_session", + "customer": null }'; $paymentMethod = '{ @@ -151,6 +152,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); @@ -162,6 +164,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 = []; @@ -211,6 +214,8 @@ */ public function testStoreAndWebhook(): void { + Bus::fake(); + // Unauth access not allowed $response = $this->post("api/v4/payments", []); $response->assertStatus(401); @@ -247,7 +252,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 } /** @@ -255,20 +395,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 @@ +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 @@ +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 = "$url"; $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 @@ +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('%s', $walletUrl, $walletUrl); + $supportUrl = \config('app.support_url'); + $supportLink = sprintf('%s', $supportUrl, $supportUrl); + $appName = \config('app.name'); + + $this->assertSame("$appName Payment Failed", $mail->subject); + $this->assertStringStartsWith('', $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 @@ + 'https://kolab.org/support']); + + $mail = new PaymentMandateDisabled($wallet, $user); + $html = $mail->build()->render(); + + $walletUrl = \App\Utils::serviceUrl('/wallet'); + $walletLink = sprintf('%s', $walletUrl, $walletUrl); + $supportUrl = \config('app.support_url'); + $supportLink = sprintf('%s', $supportUrl, $supportUrl); + $appName = \config('app.name'); + + $this->assertSame("$appName Auto-payment Problem", $mail->subject); + $this->assertStringStartsWith('', $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 @@ +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('%s', $walletUrl, $walletUrl); + $supportUrl = \config('app.support_url'); + $supportLink = sprintf('%s', $supportUrl, $supportUrl); + $appName = \config('app.name'); + + $this->assertSame("$appName Payment Succeeded", $mail->subject); + $this->assertStringStartsWith('', $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 @@ -27,7 +28,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 = "$url"; $this->assertSame(\config('app.name') . ' Registration', $mail->subject);