diff --git a/src/.env.example b/src/.env.example --- a/src/.env.example +++ b/src/.env.example @@ -6,6 +6,8 @@ APP_PUBLIC_URL= APP_DOMAIN=kolabnow.com +SUPPORT_URL= + LOG_CHANNEL=stack DB_CONNECTION=mysql diff --git a/src/app/Console/Commands/WalletCharge.php b/src/app/Console/Commands/WalletCharge.php --- a/src/app/Console/Commands/WalletCharge.php +++ b/src/app/Console/Commands/WalletCharge.php @@ -2,11 +2,7 @@ namespace App\Console\Commands; -use App\Domain; -use App\User; -use Carbon\Carbon; use Illuminate\Console\Command; -use Illuminate\Support\Facades\DB; class WalletCharge extends Command { @@ -44,19 +40,15 @@ $wallets = \App\Wallet::all(); foreach ($wallets as $wallet) { - $charge = $wallet->expectedCharges(); + $charge = $wallet->chargeEntitlements(); if ($charge > 0) { $this->info( - "charging wallet {$wallet->id} for user {$wallet->owner->email} with {$charge}" + "Charged wallet {$wallet->id} for user {$wallet->owner->email} with {$charge}" ); - $wallet->chargeEntitlements(); - - if ($wallet->balance < 0) { - // Disabled for now - // \App\Jobs\WalletPayment::dispatch($wallet); - } + // Top-up the wallet if auto-payment enabled for the wallet + \App\Jobs\WalletCharge::dispatch($wallet); } } } diff --git a/src/app/Console/Commands/WalletExpected.php b/src/app/Console/Commands/WalletExpected.php --- a/src/app/Console/Commands/WalletExpected.php +++ b/src/app/Console/Commands/WalletExpected.php @@ -2,11 +2,7 @@ namespace App\Console\Commands; -use App\Domain; -use App\User; -use Carbon\Carbon; use Illuminate\Console\Command; -use Illuminate\Support\Facades\DB; class WalletExpected extends Command { diff --git a/src/app/Console/Commands/WalletMandate.php b/src/app/Console/Commands/WalletMandate.php new file mode 100644 --- /dev/null +++ b/src/app/Console/Commands/WalletMandate.php @@ -0,0 +1,70 @@ +argument('wallet')); + + if (!$wallet) { + return 1; + } + + $mandate = PaymentsController::walletMandate($wallet); + + if (!empty($mandate['id'])) { + $disabled = $mandate['isDisabled'] ? 'Yes' : 'No'; + + if ($this->option('disable') && $disabled == 'No') { + $wallet->setSetting('mandate_disabled', 1); + $disabled = 'Yes'; + } elseif ($this->option('enable') && $disabled == 'Yes') { + $wallet->setSetting('mandate_disabled', null); + $disabled = 'No'; + } + + $this->info("Auto-payment: {$mandate['method']}"); + $this->info(" id: {$mandate['id']}"); + $this->info(" status: " . ($mandate['isPending'] ? 'pending' : 'valid')); + $this->info(" amount: {$mandate['amount']} {$wallet->currency}"); + $this->info(" min-balance: {$mandate['balance']} {$wallet->currency}"); + $this->info(" disabled: $disabled"); + } else { + $this->info("Auto-payment: none"); + } + } +} diff --git a/src/app/Http/Controllers/API/V4/PaymentsController.php b/src/app/Http/Controllers/API/V4/PaymentsController.php --- a/src/app/Http/Controllers/API/V4/PaymentsController.php +++ b/src/app/Http/Controllers/API/V4/PaymentsController.php @@ -65,8 +65,10 @@ return response()->json(['status' => 'error', 'errors' => $errors], 422); } - $wallet->setSetting('mandate_amount', $request->amount); - $wallet->setSetting('mandate_balance', $request->balance); + $wallet->setSettings([ + 'mandate_amount' => $request->amount, + 'mandate_balance' => $request->balance, + ]); $request = [ 'currency' => 'CHF', @@ -99,6 +101,8 @@ $provider->deleteMandate($wallet); + $wallet->setSetting('mandate_disabled', null); + return response()->json([ 'status' => 'success', 'message' => \trans('app.mandate-delete-success'), @@ -142,13 +146,31 @@ return response()->json(['status' => 'error', 'errors' => $errors], 422); } - $wallet->setSetting('mandate_amount', $request->amount); - $wallet->setSetting('mandate_balance', $request->balance); + // If the mandate is disabled the update will trigger + // an auto-payment and the amount must cover the debt + if ($wallet->getSetting('mandate_disabled')) { + if ($wallet->balance < 0 && $wallet->balance + $amount < 0) { + $errors = ['amount' => \trans('validation.minamountdebt')]; + return response()->json(['status' => 'error', 'errors' => $errors], 422); + } - return response()->json([ - 'status' => 'success', - 'message' => \trans('app.mandate-update-success'), + $wallet->setSetting('mandate_disabled', null); + + if ($wallet->balance < intval($request->balance * 100)) { + \App\Jobs\WalletCharge::dispatch($wallet); + } + } + + $wallet->setSettings([ + 'mandate_amount' => $request->amount, + 'mandate_balance' => $request->balance, ]); + + $result = self::walletMandate($wallet); + $result['status'] = 'success'; + $result['message'] = \trans('app.mandate-update-success'); + + return response()->json($result); } /** @@ -222,15 +244,43 @@ } /** - * Charge a wallet with a "recurring" payment. + * Top up a wallet with a "recurring" payment. * * @param \App\Wallet $wallet The wallet to charge - * @param int $amount The amount of money in cents * - * @return bool + * @return bool True if the payment has been initialized */ - public static function directCharge(Wallet $wallet, $amount): bool + public static function topUpWallet(Wallet $wallet): bool { + if ((bool) $wallet->getSetting('mandate_disabled')) { + return false; + } + + $min_balance = (int) (floatval($wallet->getSetting('mandate_balance')) * 100); + $amount = (int) (floatval($wallet->getSetting('mandate_amount')) * 100); + + // The wallet balance is greater than the auto-payment threshold + if ($wallet->balance >= $min_balance) { + // Do nothing + return false; + } + + // The defined top-up amount is not enough + // Disable auto-payment and notify the user + if ($wallet->balance + $amount < 0) { + // Disable (not remove) the mandate + $wallet->setSetting('mandate_disabled', 1); + \App\Jobs\PaymentMandateDisabledEmail::dispatch($wallet); + return false; + } + + $provider = PaymentProvider::factory($wallet); + $mandate = (array) $provider->getMandate($wallet); + + if (empty($mandate['isValid'])) { + return false; + } + $request = [ 'type' => PaymentProvider::TYPE_RECURRING, 'currency' => 'CHF', @@ -238,13 +288,9 @@ 'description' => \config('app.name') . ' Recurring Payment', ]; - $provider = PaymentProvider::factory($wallet); - - if ($result = $provider->payment($wallet, $request)) { - return true; - } + $result = $provider->payment($wallet, $request); - return false; + return !empty($result); } /** @@ -263,6 +309,7 @@ $mandate['amount'] = (int) (PaymentProvider::MIN_AMOUNT / 100); $mandate['balance'] = 0; + $mandate['isDisabled'] = !empty($mandate['id']) && $wallet->getSetting('mandate_disabled'); foreach (['amount', 'balance'] as $key) { if (($value = $wallet->getSetting("mandate_{$key}")) !== null) { diff --git a/src/app/Http/Kernel.php b/src/app/Http/Kernel.php --- a/src/app/Http/Kernel.php +++ b/src/app/Http/Kernel.php @@ -40,7 +40,7 @@ ], 'api' => [ - 'throttle:60,1', + 'throttle:120,1', 'bindings', ], ]; diff --git a/src/app/Jobs/PaymentEmail.php b/src/app/Jobs/PaymentEmail.php new file mode 100644 --- /dev/null +++ b/src/app/Jobs/PaymentEmail.php @@ -0,0 +1,94 @@ +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 @@ -3,6 +3,7 @@ namespace App\Mail; use App\User; +use App\Utils; use App\VerificationCode; use Illuminate\Bus\Queueable; use Illuminate\Mail\Mailable; @@ -37,11 +38,8 @@ */ public function build() { - $href = sprintf( - '%s/login/reset/%s-%s', - \config('app.url'), - $this->code->short_code, - $this->code->code + $href = Utils::serviceUrl( + sprintf('/login/reset/%s-%s', $this->code->short_code, $this->code->code) ); $this->view('emails.password_reset') diff --git a/src/app/Mail/SuspendedDebtor.php b/src/app/Mail/PaymentFailure.php copy from src/app/Mail/SuspendedDebtor.php copy to src/app/Mail/PaymentFailure.php --- a/src/app/Mail/SuspendedDebtor.php +++ b/src/app/Mail/PaymentFailure.php @@ -2,31 +2,37 @@ namespace App\Mail; +use App\Payment; use App\User; use App\Utils; use Illuminate\Bus\Queueable; use Illuminate\Mail\Mailable; use Illuminate\Queue\SerializesModels; -class SuspendedDebtor extends Mailable +class PaymentFailure extends Mailable { use Queueable; use SerializesModels; - /** @var \App\User A suspended user (account) */ - protected $account; + /** @var \App\Payment A payment operation */ + protected $payment; + + /** @var \App\User A wallet controller to whom the email is being send */ + protected $user; /** * Create a new message instance. * - * @param \App\User $account A suspended user (account) + * @param \App\Payment $payment A payment operation + * @param \App\User $user An email recipient * * @return void */ - public function __construct(User $account) + public function __construct(Payment $payment, User $user) { - $this->account = $account; + $this->payment = $payment; + $this->user = $user; } /** @@ -36,26 +42,18 @@ */ public function build() { - $user = $this->account; - - $subject = \trans('mail.suspendeddebtor-subject', ['site' => \config('app.name')]); + $user = $this->user; - $moreInfo = null; - if ($moreInfoUrl = \config('app.kb.account_suspended')) { - $moreInfo = \trans('mail.more-info-html', ['href' => $moreInfoUrl]); - } + $subject = \trans('mail.paymentfailure-subject', ['site' => \config('app.name')]); - $this->view('emails.suspended_debtor') + $this->view('emails.payment_failure') ->subject($subject) ->with([ 'site' => \config('app.name'), 'subject' => $subject, 'username' => $user->name(true), - 'cancelUrl' => \config('app.kb.account_delete'), - 'supportUrl' => \config('app.support_url'), 'walletUrl' => Utils::serviceUrl('/wallet'), - 'moreInfo' => $moreInfo, - 'days' => 14 // TODO: Configurable + 'supportUrl' => \config('app.support_url'), ]); return $this; @@ -68,9 +66,16 @@ */ public static function fakeRender(): string { - $user = new User(); + $payment = new Payment(); + $user = new User([ + 'email' => 'test@' . \config('app.domain'), + ]); + + if (!\config('app.support_url')) { + \config(['app.support_url' => 'https://not-configured-support.url']); + } - $mail = new self($user); + $mail = new self($payment, $user); return $mail->build()->render(); } diff --git a/src/app/Mail/SuspendedDebtor.php b/src/app/Mail/PaymentMandateDisabled.php copy from src/app/Mail/SuspendedDebtor.php copy to src/app/Mail/PaymentMandateDisabled.php --- a/src/app/Mail/SuspendedDebtor.php +++ b/src/app/Mail/PaymentMandateDisabled.php @@ -4,29 +4,35 @@ use App\User; use App\Utils; +use App\Wallet; use Illuminate\Bus\Queueable; use Illuminate\Mail\Mailable; use Illuminate\Queue\SerializesModels; -class SuspendedDebtor extends Mailable +class PaymentMandateDisabled extends Mailable { use Queueable; use SerializesModels; - /** @var \App\User A suspended user (account) */ - protected $account; + /** @var \App\Wallet A wallet for which the mandate has been disabled */ + protected $wallet; + + /** @var \App\User A wallet controller to whom the email is being send */ + protected $user; /** * Create a new message instance. * - * @param \App\User $account A suspended user (account) + * @param \App\Wallet $wallet A wallet that has been charged + * @param \App\User $user An email recipient * * @return void */ - public function __construct(User $account) + public function __construct(Wallet $wallet, User $user) { - $this->account = $account; + $this->wallet = $wallet; + $this->user = $user; } /** @@ -36,26 +42,18 @@ */ public function build() { - $user = $this->account; - - $subject = \trans('mail.suspendeddebtor-subject', ['site' => \config('app.name')]); + $user = $this->user; - $moreInfo = null; - if ($moreInfoUrl = \config('app.kb.account_suspended')) { - $moreInfo = \trans('mail.more-info-html', ['href' => $moreInfoUrl]); - } + $subject = \trans('mail.paymentmandatedisabled-subject', ['site' => \config('app.name')]); - $this->view('emails.suspended_debtor') + $this->view('emails.payment_mandate_disabled') ->subject($subject) ->with([ 'site' => \config('app.name'), 'subject' => $subject, 'username' => $user->name(true), - 'cancelUrl' => \config('app.kb.account_delete'), - 'supportUrl' => \config('app.support_url'), 'walletUrl' => Utils::serviceUrl('/wallet'), - 'moreInfo' => $moreInfo, - 'days' => 14 // TODO: Configurable + 'supportUrl' => \config('app.support_url'), ]); return $this; @@ -68,9 +66,16 @@ */ public static function fakeRender(): string { - $user = new User(); + $wallet = new Wallet(); + $user = new User([ + 'email' => 'test@' . \config('app.domain'), + ]); + + if (!\config('app.support_url')) { + \config(['app.support_url' => 'https://not-configured-support.url']); + } - $mail = new self($user); + $mail = new self($wallet, $user); return $mail->build()->render(); } diff --git a/src/app/Mail/SuspendedDebtor.php b/src/app/Mail/PaymentSuccess.php copy from src/app/Mail/SuspendedDebtor.php copy to src/app/Mail/PaymentSuccess.php --- a/src/app/Mail/SuspendedDebtor.php +++ b/src/app/Mail/PaymentSuccess.php @@ -2,31 +2,37 @@ namespace App\Mail; +use App\Payment; use App\User; use App\Utils; use Illuminate\Bus\Queueable; use Illuminate\Mail\Mailable; use Illuminate\Queue\SerializesModels; -class SuspendedDebtor extends Mailable +class PaymentSuccess extends Mailable { use Queueable; use SerializesModels; - /** @var \App\User A suspended user (account) */ - protected $account; + /** @var \App\Payment A payment operation */ + protected $payment; + + /** @var \App\User A wallet controller to whom the email is being send */ + protected $user; /** * Create a new message instance. * - * @param \App\User $account A suspended user (account) + * @param \App\Payment $payment A payment operation + * @param \App\User $user An email recipient * * @return void */ - public function __construct(User $account) + public function __construct(Payment $payment, User $user) { - $this->account = $account; + $this->payment = $payment; + $this->user = $user; } /** @@ -36,26 +42,18 @@ */ public function build() { - $user = $this->account; - - $subject = \trans('mail.suspendeddebtor-subject', ['site' => \config('app.name')]); + $user = $this->user; - $moreInfo = null; - if ($moreInfoUrl = \config('app.kb.account_suspended')) { - $moreInfo = \trans('mail.more-info-html', ['href' => $moreInfoUrl]); - } + $subject = \trans('mail.paymentsuccess-subject', ['site' => \config('app.name')]); - $this->view('emails.suspended_debtor') + $this->view('emails.payment_success') ->subject($subject) ->with([ 'site' => \config('app.name'), 'subject' => $subject, 'username' => $user->name(true), - 'cancelUrl' => \config('app.kb.account_delete'), - 'supportUrl' => \config('app.support_url'), 'walletUrl' => Utils::serviceUrl('/wallet'), - 'moreInfo' => $moreInfo, - 'days' => 14 // TODO: Configurable + 'supportUrl' => \config('app.support_url'), ]); return $this; @@ -68,9 +66,16 @@ */ public static function fakeRender(): string { - $user = new User(); + $payment = new Payment(); + $user = new User([ + 'email' => 'test@' . \config('app.domain'), + ]); + + if (!\config('app.support_url')) { + \config(['app.support_url' => 'https://not-configured-support.url']); + } - $mail = new self($user); + $mail = new self($payment, $user); return $mail->build()->render(); } diff --git a/src/app/Mail/SignupVerification.php b/src/app/Mail/SignupVerification.php --- a/src/app/Mail/SignupVerification.php +++ b/src/app/Mail/SignupVerification.php @@ -3,6 +3,7 @@ namespace App\Mail; use App\SignupCode; +use App\Utils; use Illuminate\Bus\Queueable; use Illuminate\Mail\Mailable; use Illuminate\Queue\SerializesModels; @@ -36,11 +37,8 @@ */ public function build() { - $href = sprintf( - '%s/signup/%s-%s', - \config('app.url'), - $this->code->short_code, - $this->code->code + $href = Utils::serviceUrl( + sprintf('/signup/%s-%s', $this->code->short_code, $this->code->code) ); $username = $this->code->data['first_name'] ?? ''; diff --git a/src/app/Mail/SuspendedDebtor.php b/src/app/Mail/SuspendedDebtor.php --- a/src/app/Mail/SuspendedDebtor.php +++ b/src/app/Mail/SuspendedDebtor.php @@ -70,6 +70,18 @@ { $user = new User(); + if (!\config('app.support_url')) { + \config(['app.support_url' => 'https://not-configured-support.url']); + } + + if (!\config('app.kb.account_delete')) { + \config(['app.kb.account_delete' => 'https://not-configured-kb.url']); + } + + if (!\config('app.kb.account_suspended')) { + \config(['app.kb.account_suspended' => 'https://not-configured-kb.url']); + } + $mail = new self($user); return $mail->build()->render(); diff --git a/src/app/Providers/Payment/Mollie.php b/src/app/Providers/Payment/Mollie.php --- a/src/app/Providers/Payment/Mollie.php +++ b/src/app/Providers/Payment/Mollie.php @@ -5,6 +5,7 @@ use App\Payment; use App\Utils; use App\Wallet; +use Illuminate\Support\Facades\DB; class Mollie extends \App\Providers\PaymentProvider { @@ -117,42 +118,9 @@ 'id' => $mandate->id, 'isPending' => $mandate->isPending(), 'isValid' => $mandate->isValid(), + 'method' => self::paymentMethod($mandate, 'Unknown method') ]; - $details = $mandate->details; - - // Mollie supports 3 methods here - switch ($mandate->method) { - case 'creditcard': - // If the customer started, but never finished the 'first' payment - // card details will be empty, and mandate will be 'pending'. - if (empty($details->cardNumber)) { - $result['method'] = 'Credit Card'; - } else { - $result['method'] = sprintf( - '%s (**** **** **** %s)', - $details->cardLabel ?: 'Card', // @phpstan-ignore-line - $details->cardNumber - ); - } - break; - - case 'directdebit': - $result['method'] = sprintf( - 'Direct Debit (%s)', - $details->customerAccount - ); - break; - - case 'paypal': - $result['method'] = sprintf('PayPal (%s)', $details->consumerAccount); - break; - - - default: - $result['method'] = 'Unknown method'; - } - return $result; } @@ -182,6 +150,10 @@ */ public function payment(Wallet $wallet, array $payment): ?array { + if ($payment['type'] == self::TYPE_RECURRING) { + return $this->paymentRecurring($wallet, $payment); + } + // Register the user in Mollie, if not yet done $customer_id = self::mollieCustomerId($wallet); @@ -199,26 +171,65 @@ 'webhookUrl' => Utils::serviceUrl('/api/webhooks/payment/mollie'), 'locale' => 'en_US', // 'method' => 'creditcard', + 'redirectUrl' => \url('/wallet') // required for non-recurring payments ]; - if ($payment['type'] == self::TYPE_RECURRING) { - // Check if there's a valid mandate - $mandate = self::mollieMandate($wallet); + // TODO: Additional payment parameters for better fraud protection: + // billingEmail - for bank transfers, Przelewy24, but not creditcard + // billingAddress (it is a structured field not just text) - if (empty($mandate) || !$mandate->isValid() || $mandate->isPending()) { - return null; - } + // Create the payment in Mollie + $response = mollie()->payments()->create($request); + + // Store the payment reference in database + $payment['status'] = $response->status; + $payment['id'] = $response->id; - $request['mandateId'] = $mandate->id; - } else { - // required for non-recurring payments - $request['redirectUrl'] = \url('/wallet'); + $this->storePayment($payment, $wallet->id); - // TODO: Additional payment parameters for better fraud protection: - // billingEmail - for bank transfers, Przelewy24, but not creditcard - // billingAddress (it is a structured field not just text) + return [ + 'id' => $payment['id'], + 'redirectUrl' => $response->getCheckoutUrl(), + ]; + } + + /** + * Create a new automatic payment operation. + * + * @param \App\Wallet $wallet The wallet + * @param array $payment Payment data (see self::payment()) + * + * @return array Provider payment/session data: + * - id: Operation identifier + */ + protected function paymentRecurring(Wallet $wallet, array $payment): ?array + { + // Check if there's a valid mandate + $mandate = self::mollieMandate($wallet); + + if (empty($mandate) || !$mandate->isValid() || $mandate->isPending()) { + return null; } + $customer_id = self::mollieCustomerId($wallet); + + // Note: Required fields: description, amount/currency, amount/value + + $request = [ + 'amount' => [ + 'currency' => $payment['currency'], + // a number with two decimals is required + 'value' => sprintf('%.2f', $payment['amount'] / 100), + ], + 'customerId' => $customer_id, + 'sequenceType' => $payment['type'], + 'description' => $payment['description'], + 'webhookUrl' => Utils::serviceUrl('/api/webhooks/payment/mollie'), + 'locale' => 'en_US', + // 'method' => 'creditcard', + 'mandateId' => $mandate->id + ]; + // Create the payment in Mollie $response = mollie()->payments()->create($request); @@ -226,11 +237,34 @@ $payment['status'] = $response->status; $payment['id'] = $response->id; - self::storePayment($payment, $wallet->id); + DB::beginTransaction(); + + $payment = $this->storePayment($payment, $wallet->id); + + // Mollie can return 'paid' status immediately, so we don't + // have to wait for the webhook. What's more, the webhook would ignore + // the payment because it will be marked as paid before the webhook. + // Let's handle paid status here too. + if ($response->isPaid()) { + self::creditPayment($payment, $response); + $notify = true; + } elseif ($response->isFailed()) { + // Note: I didn't find a way to get any description of the problem with a payment + \Log::info(sprintf('Mollie payment failed (%s)', $response->id)); + + // Disable the mandate + $wallet->setSetting('mandate_disabled', 1); + $notify = true; + } + + DB::commit(); + + if (!empty($notify)) { + \App\Jobs\PaymentEmail::dispatch($payment); + } return [ 'id' => $payment['id'], - 'redirectUrl' => $response->getCheckoutUrl(), ]; } @@ -267,7 +301,8 @@ // The payment is paid and isn't refunded or charged back. // Update the balance, if it wasn't already if ($payment->status != self::STATUS_PAID && $payment->amount > 0) { - $payment->wallet->credit($payment->amount); + $credit = true; + $notify = $payment->type == self::TYPE_RECURRING; } } elseif ($mollie_payment->hasRefunds()) { // The payment has been (partially) refunded. @@ -281,15 +316,35 @@ } elseif ($mollie_payment->isFailed()) { // Note: I didn't find a way to get any description of the problem with a payment \Log::info(sprintf('Mollie payment failed (%s)', $payment->id)); + + // Disable the mandate + if ($payment->type == self::TYPE_RECURRING) { + $notify = true; + $payment->wallet->setSetting('mandate_disabled', 1); + } } + DB::beginTransaction(); + // This is a sanity check, just in case the payment provider api - // sent us open -> paid -> open -> paid. So, we lock the payment after it's paid. - if ($payment->status != self::STATUS_PAID) { + // sent us open -> paid -> open -> paid. So, we lock the payment after + // recivied a "final" state. + $pending_states = [self::STATUS_OPEN, self::STATUS_PENDING, self::STATUS_AUTHORIZED]; + if (in_array($payment->status, $pending_states)) { $payment->status = $mollie_payment->status; $payment->save(); } + if (!empty($credit)) { + self::creditPayment($payment, $mollie_payment); + } + + DB::commit(); + + if (!empty($notify)) { + \App\Jobs\PaymentEmail::dispatch($payment); + } + return 200; } @@ -346,4 +401,56 @@ } */ } + + /** + * Apply the successful payment's pecunia to the wallet + */ + protected static function creditPayment($payment, $mollie_payment) + { + // Extract the payment method for transaction description + $method = self::paymentMethod($mollie_payment, 'Mollie'); + + // TODO: Localization? + $description = $payment->type == self::TYPE_RECURRING ? 'Auto-payment' : 'Payment'; + $description .= " transaction {$payment->id} using {$method}"; + + $payment->wallet->credit($payment->amount, $description); + + // Unlock the disabled auto-payment mandate + if ($payment->wallet->balance >= 0) { + $payment->wallet->setSetting('mandate_disabled', null); + } + } + + /** + * Extract payment method description from Mollie payment/mandate details + */ + protected static function paymentMethod($object, $default = ''): string + { + $details = $object->details; + + // Mollie supports 3 methods here + switch ($object->method) { + case 'creditcard': + // If the customer started, but never finished the 'first' payment + // card details will be empty, and mandate will be 'pending'. + if (empty($details->cardNumber)) { + return 'Credit Card'; + } + + return sprintf( + '%s (**** **** **** %s)', + $details->cardLabel ?: 'Card', // @phpstan-ignore-line + $details->cardNumber + ); + + case 'directdebit': + return sprintf('Direct Debit (%s)', $details->customerAccount); + + case 'paypal': + return sprintf('PayPal (%s)', $details->consumerAccount); + } + + return $default; + } } diff --git a/src/app/Providers/Payment/Stripe.php b/src/app/Providers/Payment/Stripe.php --- a/src/app/Providers/Payment/Stripe.php +++ b/src/app/Providers/Payment/Stripe.php @@ -6,6 +6,8 @@ use App\Utils; use App\Wallet; use App\WalletSetting; +use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Request; use Stripe as StripeAPI; class Stripe extends \App\Providers\PaymentProvider @@ -73,6 +75,13 @@ $session = StripeAPI\Checkout\Session::create($request); + $payment = [ + 'id' => $session->setup_intent, + 'type' => self::TYPE_MANDATE, + ]; + + $this->storePayment($payment, $wallet->id); + return [ 'id' => $session->id, ]; @@ -128,25 +137,9 @@ 'id' => $mandate->id, 'isPending' => $mandate->status != 'succeeded' && $mandate->status != 'canceled', 'isValid' => $mandate->status == 'succeeded', + 'method' => self::paymentMethod($pm, 'Unknown method') ]; - switch ($pm->type) { - case 'card': - // TODO: card number - $result['method'] = \sprintf( - '%s (**** **** **** %s)', - // @phpstan-ignore-next-line - \ucfirst($pm->card->brand) ?: 'Card', - // @phpstan-ignore-next-line - $pm->card->last4 - ); - - break; - - default: - $result['method'] = 'Unknown method'; - } - return $result; } @@ -201,10 +194,9 @@ $session = StripeAPI\Checkout\Session::create($request); // Store the payment reference in database - $payment['status'] = self::STATUS_OPEN; $payment['id'] = $session->payment_intent; - self::storePayment($payment, $wallet->id); + $this->storePayment($payment, $wallet->id); return [ 'id' => $session->id, @@ -233,20 +225,19 @@ 'amount' => $payment['amount'], 'currency' => \strtolower($payment['currency']), 'description' => $payment['description'], - 'locale' => 'en', - 'off_session' => true, 'receipt_email' => $wallet->owner->email, 'customer' => $mandate->customer, 'payment_method' => $mandate->payment_method, + 'off_session' => true, + 'confirm' => true, ]; $intent = StripeAPI\PaymentIntent::create($request); // Store the payment reference in database - $payment['status'] = self::STATUS_OPEN; $payment['id'] = $intent->id; - self::storePayment($payment, $wallet->id); + $this->storePayment($payment, $wallet->id); return [ 'id' => $payment['id'], @@ -260,8 +251,11 @@ */ public function webhook(): int { - $payload = file_get_contents('php://input'); - $sig_header = $_SERVER['HTTP_STRIPE_SIGNATURE']; + // We cannot just use php://input as it's already "emptied" by the framework + // $payload = file_get_contents('php://input'); + $request = Request::instance(); + $payload = $request->getContent(); + $sig_header = $request->header('Stripe-Signature'); // Parse and validate the input try { @@ -270,7 +264,7 @@ $sig_header, \config('services.stripe.webhook_secret') ); - } catch (\UnexpectedValueException $e) { + } catch (\Exception $e) { // Invalid payload return 400; } @@ -282,6 +276,10 @@ $intent = $event->data->object; // @phpstan-ignore-line $payment = Payment::find($intent->id); + if (empty($payment) || $payment->type == self::TYPE_MANDATE) { + return 404; + } + switch ($intent->status) { case StripeAPI\PaymentIntent::STATUS_CANCELED: $status = self::STATUS_CANCELED; @@ -290,43 +288,74 @@ $status = self::STATUS_PAID; break; default: - $status = self::STATUS_PENDING; + $status = self::STATUS_FAILED; } + DB::beginTransaction(); + if ($status == self::STATUS_PAID) { // Update the balance, if it wasn't already if ($payment->status != self::STATUS_PAID) { - $payment->wallet->credit($payment->amount); + $this->creditPayment($payment, $intent); + } + } else { + if (!empty($intent->last_payment_error)) { + // See https://stripe.com/docs/error-codes for more info + \Log::info(sprintf( + 'Stripe payment failed (%s): %s', + $payment->id, + json_encode($intent->last_payment_error) + )); } - } elseif (!empty($intent->last_payment_error)) { - // See https://stripe.com/docs/error-codes for more info - \Log::info(sprintf( - 'Stripe payment failed (%s): %s', - $payment->id, - json_encode($intent->last_payment_error) - )); } if ($payment->status != self::STATUS_PAID) { $payment->status = $status; $payment->save(); + + if ($status != self::STATUS_CANCELED && $payment->type == self::TYPE_RECURRING) { + // Disable the mandate + if ($status == self::STATUS_FAILED) { + $payment->wallet->setSetting('mandate_disabled', 1); + } + + // Notify the user + \App\Jobs\PaymentEmail::dispatch($payment); + } } + DB::commit(); + break; case StripeAPI\Event::SETUP_INTENT_SUCCEEDED: + case StripeAPI\Event::SETUP_INTENT_SETUP_FAILED: + case StripeAPI\Event::SETUP_INTENT_CANCELED: $intent = $event->data->object; // @phpstan-ignore-line + $payment = Payment::find($intent->id); - // Find the wallet - // TODO: This query is potentially slow, we should find another way - // Maybe use payment/transactions table to store the reference - $setting = WalletSetting::where('key', 'stripe_id') - ->where('value', $intent->customer)->first(); + if (empty($payment) || $payment->type != self::TYPE_MANDATE) { + return 404; + } - if ($setting) { - $setting->wallet->setSetting('stripe_mandate_id', $intent->id); + switch ($intent->status) { + case StripeAPI\SetupIntent::STATUS_CANCELED: + $status = self::STATUS_CANCELED; + break; + case StripeAPI\SetupIntent::STATUS_SUCCEEDED: + $status = self::STATUS_PAID; + break; + default: + $status = self::STATUS_FAILED; + } + + if ($status == self::STATUS_PAID) { + $payment->wallet->setSetting('stripe_mandate_id', $intent->id); } + $payment->status = $status; + $payment->save(); + break; default: @@ -382,4 +411,52 @@ } } } + + /** + * Apply the successful payment's pecunia to the wallet + */ + protected static function creditPayment(Payment $payment, $intent) + { + $method = 'Stripe'; + + // Extract the payment method for transaction description + if ( + !empty($intent->charges) + && ($charge = $intent->charges->data[0]) + && ($pm = $charge->payment_method_details) + ) { + $method = self::paymentMethod($pm); + } + + // TODO: Localization? + $description = $payment->type == self::TYPE_RECURRING ? 'Auto-payment' : 'Payment'; + $description .= " transaction {$payment->id} using {$method}"; + + $payment->wallet->credit($payment->amount, $description); + + // Unlock the disabled auto-payment mandate + if ($payment->wallet->balance >= 0) { + $payment->wallet->setSetting('mandate_disabled', null); + } + } + + /** + * Extract payment method description from Stripe payment details + */ + protected static function paymentMethod($details, $default = ''): string + { + switch ($details->type) { + case 'card': + // TODO: card number + return \sprintf( + '%s (**** **** **** %s)', + // @phpstan-ignore-next-line + \ucfirst($details->card->brand) ?: 'Card', + // @phpstan-ignore-next-line + $details->card->last4 + ); + } + + return $default; + } } diff --git a/src/app/Providers/PaymentProvider.php b/src/app/Providers/PaymentProvider.php --- a/src/app/Providers/PaymentProvider.php +++ b/src/app/Providers/PaymentProvider.php @@ -17,6 +17,7 @@ public const TYPE_ONEOFF = 'oneoff'; public const TYPE_RECURRING = 'recurring'; + public const TYPE_MANDATE = 'mandate'; /** const int Minimum amount of money in a single payment (in cents) */ public const MIN_AMOUNT = 1000; @@ -135,15 +136,21 @@ * * @param array $payment Payment information * @param string $wallet_id Wallet ID + * + * @return \App\Payment Payment object */ - protected static function storePayment(array $payment, $wallet_id): void + protected function storePayment(array $payment, $wallet_id): Payment { $db_payment = new Payment(); $db_payment->id = $payment['id']; - $db_payment->description = $payment['description']; - $db_payment->status = $payment['status']; - $db_payment->amount = $payment['amount']; + $db_payment->description = $payment['description'] ?? ''; + $db_payment->status = $payment['status'] ?? self::STATUS_OPEN; + $db_payment->amount = $payment['amount'] ?? 0; + $db_payment->type = $payment['type']; $db_payment->wallet_id = $wallet_id; + $db_payment->provider = $this->name(); $db_payment->save(); + + return $db_payment; } } diff --git a/src/app/Wallet.php b/src/app/Wallet.php --- a/src/app/Wallet.php +++ b/src/app/Wallet.php @@ -117,6 +117,28 @@ } /** + * 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. * * @return \Illuminate\Database\Eloquent\Relations\BelongsToMany @@ -134,11 +156,12 @@ /** * Add an amount of pecunia to this wallet's balance. * - * @param int $amount The amount of pecunia to add (in cents). + * @param int $amount The amount of pecunia to add (in cents). + * @param string $description The transaction description * * @return Wallet Self */ - public function credit(int $amount): Wallet + public function credit(int $amount, string $description = ''): Wallet { $this->balance += $amount; @@ -150,7 +173,8 @@ 'object_id' => $this->id, 'object_type' => \App\Wallet::class, 'type' => \App\Transaction::WALLET_CREDIT, - 'amount' => $amount + 'amount' => $amount, + 'description' => $description ] ); diff --git a/src/config/app.php b/src/config/app.php --- a/src/config/app.php +++ b/src/config/app.php @@ -57,6 +57,8 @@ 'asset_url' => env('ASSET_URL', null), + 'support_url' => env('SUPPORT_URL', null), + /* |-------------------------------------------------------------------------- | Application Domain @@ -145,7 +147,6 @@ */ 'providers' => [ - /* * Laravel Framework Service Providers... */ @@ -198,7 +199,6 @@ */ 'aliases' => [ - 'App' => Illuminate\Support\Facades\App::class, 'Arr' => Illuminate\Support\Arr::class, 'Artisan' => Illuminate\Support\Facades\Artisan::class, @@ -234,7 +234,6 @@ 'URL' => Illuminate\Support\Facades\URL::class, 'Validator' => Illuminate\Support\Facades\Validator::class, 'View' => Illuminate\Support\Facades\View::class, - ], // Locations of knowledge base articles diff --git a/src/database/migrations/2020_03_16_100000_create_payments.php b/src/database/migrations/2020_03_16_100000_create_payments.php --- a/src/database/migrations/2020_03_16_100000_create_payments.php +++ b/src/database/migrations/2020_03_16_100000_create_payments.php @@ -22,6 +22,8 @@ $table->string('status', 16); $table->integer('amount'); $table->text('description'); + $table->string('provider', 16); + $table->string('type', 16); $table->timestamps(); $table->foreign('wallet_id')->references('id')->on('wallets')->onDelete('cascade'); diff --git a/src/phpunit.xml b/src/phpunit.xml --- a/src/phpunit.xml +++ b/src/phpunit.xml @@ -33,7 +33,6 @@ - diff --git a/src/resources/lang/en/mail.php b/src/resources/lang/en/mail.php --- a/src/resources/lang/en/mail.php +++ b/src/resources/lang/en/mail.php @@ -13,6 +13,8 @@ 'header' => "Dear :name,", 'footer' => "Best regards,\nYour :site Team", + 'more-info-html' => "See here for more information.", + 'negativebalance-subject' => ":site Payment Reminder", 'negativebalance-body' => "It has probably skipped your attention that you are behind on paying for your :site account. " . "Consider setting up auto-payment to avoid messages like this in the future.\n\n" @@ -24,6 +26,30 @@ . "You can also click the link below.\n" . "If you did not make such a request, you can either ignore this message or get in touch with us about this incident.", + 'paymentmandatedisabled-subject' => ":site Auto-payment Problem", + 'paymentmandatedisabled-body' => "Your :site account balance is negative " + . "and the configured amount for automatically topping up the balance does not cover " + . "the costs of subscriptions consumed.\n\n" + . "Charging you multiple times for the same amount in short succession " + . "could lead to issues with the payment provider. " + . "In order to not cause any problems, we suspended auto-payment for your account. " + . "To resolve this issue, login to your account settings and adjust your auto-payment amount.", + + 'paymentfailure-subject' => ":site Payment Failed", + 'paymentfailure-body' => "Something went wrong with auto-payment for your :site account.\n" + . "We tried to charge you via your preferred payment method, but the charge did not go through.\n\n" + . "In order to not cause any further issues, we suspended auto-payment for your account. " + . "To resolve this issue, login to your account settings at", + 'paymentfailure-body-rest' => "There you can pay manually for your account and " + . "change your auto-payment settings.", + + 'paymentsuccess-subject' => ":site Payment Succeeded", + 'paymentsuccess-body' => "The auto-payment for your :site account went through without issues. " + . "You can check your new account balance and more details here:", + + 'support' => "Special circumstances? Something wrong with a charge?\n" + . " :site Support is here to help:", + 'signupcode-subject' => ":site Registration", 'signupcode-body' => "This is your verification code for the :site registration process: :code.\n" . "You can also click the link below to continue the registration process:", @@ -34,9 +60,4 @@ 'suspendeddebtor-middle' => "Settle up now to reactivate your account.", 'suspendeddebtor-cancel' => "Don't want to be our customer anymore? " . "Here is how you can cancel your account:", - - 'support' => "Special circumstances? Something wrong with a charge?\n" - . " :site Support is here to help:", - - 'more-info-html' => "See here for more information.", ]; diff --git a/src/resources/lang/en/validation.php b/src/resources/lang/en/validation.php --- a/src/resources/lang/en/validation.php +++ b/src/resources/lang/en/validation.php @@ -133,6 +133,7 @@ 'entryinvalid' => 'The specified :attribute is invalid.', 'entryexists' => 'The specified :attribute is not available.', 'minamount' => 'Minimum amount for a single payment is :amount.', + 'minamountdebt' => 'The specified amount does not cover the balance on the account.', /* |-------------------------------------------------------------------------- diff --git a/src/resources/views/emails/payment_failure.blade.php b/src/resources/views/emails/payment_failure.blade.php new file mode 100644 --- /dev/null +++ b/src/resources/views/emails/payment_failure.blade.php @@ -0,0 +1,20 @@ + + + + + + +

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

+ +

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

+

{{ $walletUrl }}

+

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

+ +@if ($supportUrl) +

{{ __('mail.support', ['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.support', ['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.support', ['site' => $site]) }}

+

{{ $supportUrl }}

+@endif + +

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

+ + diff --git a/src/resources/vue/Admin/User.vue b/src/resources/vue/Admin/User.vue --- a/src/resources/vue/Admin/User.vue +++ b/src/resources/vue/Admin/User.vue @@ -128,7 +128,7 @@ Fill up by {{ wallet.mandate.amount }} CHF when under {{ wallet.mandate.balance }} CHF - using {{ wallet.mandate.method }}. + using {{ wallet.mandate.method }} (disabled). diff --git a/src/resources/vue/Wallet.vue b/src/resources/vue/Wallet.vue --- a/src/resources/vue/Wallet.vue +++ b/src/resources/vue/Wallet.vue @@ -51,6 +51,10 @@ every time your account balance gets under {{ mandate.balance }} CHF. You will be charged via {{ mandate.method }}.

+

+ The configured auto-payment has been disabled. Top up your wallet or + raise the auto-payment amount. +

You can cancel or change the auto-payment at any time.

@@ -88,6 +92,10 @@ Next, you will be redirected to the checkout page, where you can provide your credit card details.

+

+ The auto-payment is disabled. Immediately after you submit new settings we'll + attempt to top up your wallet. +

@@ -181,13 +189,18 @@ axios[method]('/api/v4/payments/mandate', post) .then(response => { - if (response.data.redirectUrl) { - location.href = response.data.redirectUrl - } else if (response.data.id) { - this.stripeCheckout(response.data) + if (method == 'post') { + // a new mandate, redirect to the chackout page + if (response.data.redirectUrl) { + location.href = response.data.redirectUrl + } else if (response.data.id) { + this.stripeCheckout(response.data) + } } else { - this.dialog.modal('hide') + // an update if (response.data.status == 'success') { + this.dialog.modal('hide'); + this.mandate = response.data this.$toast.success(response.data.message) } } diff --git a/src/tests/Browser/Admin/UserTest.php b/src/tests/Browser/Admin/UserTest.php --- a/src/tests/Browser/Admin/UserTest.php +++ b/src/tests/Browser/Admin/UserTest.php @@ -24,12 +24,13 @@ self::useAdminUrl(); $john = $this->getTestUser('john@kolab.org'); - $john->update(['status' => $john->status ^= User::STATUS_SUSPENDED]); $john->setSettings([ 'phone' => '+48123123123', 'external_email' => 'john.doe.external@gmail.com', ]); - + if ($john->isSuspended()) { + User::where('email', $john->email)->update(['status' => $john->status - User::STATUS_SUSPENDED]); + } $wallet = $john->wallets()->first(); $wallet->discount()->dissociate(); $wallet->balance = 0; @@ -42,12 +43,13 @@ public function tearDown(): void { $john = $this->getTestUser('john@kolab.org'); - $john->update(['status' => $john->status ^= User::STATUS_SUSPENDED]); $john->setSettings([ 'phone' => null, 'external_email' => 'john.doe.external@gmail.com', ]); - + if ($john->isSuspended()) { + User::where('email', $john->email)->update(['status' => $john->status - User::STATUS_SUSPENDED]); + } $wallet = $john->wallets()->first(); $wallet->discount()->dissociate(); $wallet->balance = 0; diff --git a/src/tests/Browser/Pages/Home.php b/src/tests/Browser/Pages/Home.php --- a/src/tests/Browser/Pages/Home.php +++ b/src/tests/Browser/Pages/Home.php @@ -26,6 +26,7 @@ public function assert($browser) { $browser->waitForLocation($this->url()) + ->waitUntilMissing('.app-loader') ->assertVisible('form.form-signin'); } diff --git a/src/tests/Browser/Pages/PaymentMollie.php b/src/tests/Browser/Pages/PaymentMollie.php --- a/src/tests/Browser/Pages/PaymentMollie.php +++ b/src/tests/Browser/Pages/PaymentMollie.php @@ -58,7 +58,7 @@ ->waitFor('button.form__button'); } - $browser->click('@status-table input[value="paid"]') + $browser->click('input[value="paid"]') ->click('button.form__button'); } } diff --git a/src/tests/Browser/Pages/PaymentStripe.php b/src/tests/Browser/Pages/PaymentStripe.php --- a/src/tests/Browser/Pages/PaymentStripe.php +++ b/src/tests/Browser/Pages/PaymentStripe.php @@ -59,7 +59,7 @@ public function submitValidCreditCard($browser) { $browser->type('@name-input', 'Test') - ->type('@cardnumber-input', '4242424242424242') + ->typeSlowly('@cardnumber-input', '4242424242424242', 50) ->type('@cardexpiry-input', '12/' . (date('y') + 1)) ->type('@cardcvc-input', '123') ->press('@submit-button'); diff --git a/src/tests/Browser/PaymentMollieTest.php b/src/tests/Browser/PaymentMollieTest.php --- a/src/tests/Browser/PaymentMollieTest.php +++ b/src/tests/Browser/PaymentMollieTest.php @@ -172,12 +172,25 @@ // Test updating auto-payment $this->browse(function (Browser $browser) use ($user) { - $browser->on(new WalletPage()) + $wallet = $user->wallets()->first(); + $wallet->setSetting('mandate_disabled', 1); + + $browser->refresh() + ->on(new WalletPage()) ->click('@main button') - ->with(new Dialog('@payment-dialog'), function (Browser $browser) { - $browser->assertSeeIn('@body #mandate-info button.btn-primary', 'Change auto-payment') + ->with(new Dialog('@payment-dialog'), function (Browser $browser) use ($wallet) { + $browser->waitFor('@body #mandate-info') + ->assertSeeIn( + '@body #mandate-info p.disabled-mandate', + 'The configured auto-payment has been disabled' + ) + ->assertSeeIn('@body #mandate-info button.btn-primary', 'Change auto-payment') ->click('@body #mandate-info button.btn-primary') ->assertSeeIn('@title', 'Update auto-payment') + ->assertSeeIn( + '@body form p.disabled-mandate', + 'The auto-payment is disabled.' + ) ->assertValue('@body #mandate_amount', '100') ->assertValue('@body #mandate_balance', '0') ->assertSeeIn('@button-cancel', 'Cancel') @@ -193,7 +206,15 @@ ->click('@button-action'); }) ->waitUntilMissing('#payment-dialog') - ->assertToast(Toast::TYPE_SUCCESS, 'The auto-payment has been updated.'); + ->assertToast(Toast::TYPE_SUCCESS, 'The auto-payment has been updated.') + // Open the dialog again and make sure the "disabled" text isn't there + ->click('@main button') + ->with(new Dialog('@payment-dialog'), function (Browser $browser) use ($wallet) { + $browser->assertMissing('@body #mandate-info p.disabled-mandate') + ->click('@body #mandate-info button.btn-primary') + ->assertMissing('@body form p.disabled-mandate') + ->click('@button-cancel'); + }); }); // Test deleting auto-payment diff --git a/src/tests/Browser/PaymentStripeTest.php b/src/tests/Browser/PaymentStripeTest.php --- a/src/tests/Browser/PaymentStripeTest.php +++ b/src/tests/Browser/PaymentStripeTest.php @@ -80,7 +80,7 @@ // Looks like in test-mode the webhook is executed before redirect // so we can expect balance updated on the wallet page - $browser->waitForLocation('/wallet', 15) // need more time than default 5 sec. + $browser->waitForLocation('/wallet', 30) // need more time than default 5 sec. ->on(new WalletPage()) ->assertSeeIn('@main .card-text', 'Current account balance is 12,34 CHF'); }); @@ -144,7 +144,7 @@ ->assertMissing('@amount') ->assertValue('@email-input', $user->email) ->submitValidCreditCard() - ->waitForLocation('/wallet', 15) // need more time than default 5 sec. + ->waitForLocation('/wallet', 30) // need more time than default 5 sec. ->visit('/wallet?paymentProvider=stripe') ->on(new WalletPage()) ->click('@main button') diff --git a/src/tests/BrowserAddonTrait.php b/src/tests/BrowserAddonTrait.php new file mode 100644 --- /dev/null +++ b/src/tests/BrowserAddonTrait.php @@ -0,0 +1,82 @@ +driver(); + }, 50); + + $this->browser = new Browser($driver); + + $screenshots_dir = __DIR__ . '/Browser/screenshots/'; + Browser::$storeScreenshotsAt = $screenshots_dir; + if (!file_exists($screenshots_dir)) { + mkdir($screenshots_dir, 0777, true); + } + + return $this->browser; + } + + /** + * (Automatically) stop the browser and driver process + * + * @afterClass + */ + protected function stopBrowser(): void + { + if ($this->browser) { + $this->browser->quit(); + static::stopChromeDriver(); + $this->browser = null; + } + } + + /** + * Initialize and start Chrome driver + */ + protected function driver() + { + static::startChromeDriver(); + + $options = (new ChromeOptions())->addArguments([ + '--lang=en_US', + '--disable-gpu', + '--headless', + ]); + + return RemoteWebDriver::create( + 'http://localhost:9515', + DesiredCapabilities::chrome()->setCapability( + ChromeOptions::CAPABILITY, + $options + ) + ); + } + + /** + * Register an "after class" tear down callback. + * + * @param \Closure $callback + */ + public static function afterClass(\Closure $callback): void + { + // This method is required by SupportsChrome trait + } +} diff --git a/src/tests/Feature/Controller/PaymentsMollieTest.php b/src/tests/Feature/Controller/PaymentsMollieTest.php --- a/src/tests/Feature/Controller/PaymentsMollieTest.php +++ b/src/tests/Feature/Controller/PaymentsMollieTest.php @@ -5,15 +5,19 @@ use App\Http\Controllers\API\V4\PaymentsController; use App\Payment; use App\Providers\PaymentProvider; +use App\Transaction; use App\Wallet; use App\WalletSetting; use GuzzleHttp\Psr7\Response; +use Illuminate\Support\Facades\Bus; use Tests\TestCase; +use Tests\BrowserAddonTrait; use Tests\MollieMocksTrait; class PaymentsMollieTest extends TestCase { use MollieMocksTrait; + use BrowserAddonTrait; /** * {@inheritDoc} @@ -27,10 +31,11 @@ $john = $this->getTestUser('john@kolab.org'); $wallet = $john->wallets()->first(); - $john->setSetting('mollie_id', null); Payment::where('wallet_id', $wallet->id)->delete(); Wallet::where('id', $wallet->id)->update(['balance' => 0]); WalletSetting::where('wallet_id', $wallet->id)->delete(); + Transaction::where('object_id', $wallet->id) + ->where('type', Transaction::WALLET_CREDIT)->delete(); } /** @@ -40,10 +45,11 @@ { $john = $this->getTestUser('john@kolab.org'); $wallet = $john->wallets()->first(); - $john->setSetting('mollie_id', null); Payment::where('wallet_id', $wallet->id)->delete(); Wallet::where('id', $wallet->id)->update(['balance' => 0]); WalletSetting::where('wallet_id', $wallet->id)->delete(); + Transaction::where('object_id', $wallet->id) + ->where('type', Transaction::WALLET_CREDIT)->delete(); parent::tearDown(); } @@ -123,6 +129,7 @@ $this->assertEquals('Credit Card', $json['method']); $this->assertSame(true, $json['isPending']); $this->assertSame(false, $json['isValid']); + $this->assertSame(false, $json['isDisabled']); $mandate_id = $json['id']; @@ -130,7 +137,7 @@ // the mandate validated/completed. Instead, we'll mock the mandate object. $mollie_response = [ 'resource' => 'mandate', - 'id' => $json['id'], + 'id' => $mandate_id, 'status' => 'valid', 'method' => 'creditcard', 'details' => [ @@ -144,6 +151,9 @@ $responseStack = $this->mockMollie(); $responseStack->append(new Response(200, [], json_encode($mollie_response))); + $wallet = $user->wallets()->first(); + $wallet->setSetting('mandate_disabled', 1); + $response = $this->actingAs($user)->get("api/v4/payments/mandate"); $response->assertStatus(200); @@ -154,6 +164,10 @@ $this->assertEquals('Visa (**** **** **** 4242)', $json['method']); $this->assertSame(false, $json['isPending']); $this->assertSame(true, $json['isValid']); + $this->assertSame(true, $json['isDisabled']); + + Bus::fake(); + $wallet->setSetting('mandate_disabled', null); // Test updating mandate details (invalid input) $post = []; @@ -178,6 +192,8 @@ $this->assertSame("Minimum amount for a single payment is {$min}.", $json['errors']['amount']); // Test updating a mandate (valid input) + $responseStack->append(new Response(200, [], json_encode($mollie_response))); + $post = ['amount' => 30.10, 'balance' => 1]; $response = $this->actingAs($user)->put("api/v4/payments/mandate", $post); $response->assertStatus(200); @@ -186,12 +202,50 @@ $this->assertSame('success', $json['status']); $this->assertSame('The auto-payment has been updated.', $json['message']); + $this->assertSame($mandate_id, $json['id']); + $this->assertFalse($json['isDisabled']); $wallet = $user->wallets()->first(); $this->assertEquals(30.10, $wallet->getSetting('mandate_amount')); $this->assertEquals(1, $wallet->getSetting('mandate_balance')); + // Test updating a disabled mandate (invalid input) + $wallet->setSetting('mandate_disabled', 1); + $wallet->balance = -2000; + $wallet->save(); + $user->refresh(); // required so the controller sees the wallet update from above + + $post = ['amount' => 15.10, 'balance' => 1]; + $response = $this->actingAs($user)->put("api/v4/payments/mandate", $post); + $response->assertStatus(422); + + $json = $response->json(); + + $this->assertSame('error', $json['status']); + $this->assertCount(1, $json['errors']); + $this->assertSame('The specified amount does not cover the balance on the account.', $json['errors']['amount']); + + // Test updating a disabled mandate (valid input) + $responseStack->append(new Response(200, [], json_encode($mollie_response))); + + $post = ['amount' => 30, 'balance' => 1]; + $response = $this->actingAs($user)->put("api/v4/payments/mandate", $post); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertSame('success', $json['status']); + $this->assertSame('The auto-payment has been updated.', $json['message']); + $this->assertSame($mandate_id, $json['id']); + $this->assertFalse($json['isDisabled']); + + Bus::assertDispatchedTimes(\App\Jobs\WalletCharge::class, 1); + Bus::assertDispatched(\App\Jobs\WalletCharge::class, function ($job) use ($wallet) { + $job_wallet = $this->getObjectProperty($job, 'wallet'); + return $job_wallet->id === $wallet->id; + }); + $this->unmockMollie(); // Delete mandate @@ -219,6 +273,8 @@ */ public function testStoreAndWebhook(): void { + Bus::fake(); + // Unauth access not allowed $response = $this->post("api/v4/payments", []); $response->assertStatus(401); @@ -277,9 +333,20 @@ $response = $this->post("api/webhooks/payment/mollie", $post); $response->assertStatus(200); - $this->assertSame('paid', $payment->fresh()->status); + $this->assertSame(PaymentProvider::STATUS_PAID, $payment->fresh()->status); $this->assertEquals(1234, $wallet->fresh()->balance); + $transaction = $wallet->transactions()->where('type', Transaction::WALLET_CREDIT)->last(); + $this->assertSame(1234, $transaction->amount); + $this->assertSame( + "Payment transaction {$payment->id} using Mollie", + $transaction->description + ); + + // Assert that email notification job wasn't dispatched, + // it is expected only for recurring payments + Bus::assertDispatchedTimes(\App\Jobs\PaymentEmail::class, 0); + // Verify "paid -> open -> paid" scenario, assert that balance didn't change $mollie_response['status'] = 'open'; unset($mollie_response['paidAt']); @@ -288,7 +355,7 @@ $response = $this->post("api/webhooks/payment/mollie", $post); $response->assertStatus(200); - $this->assertSame('paid', $payment->fresh()->status); + $this->assertSame(PaymentProvider::STATUS_PAID, $payment->fresh()->status); $this->assertEquals(1234, $wallet->fresh()->balance); $mollie_response['status'] = 'paid'; @@ -298,8 +365,38 @@ $response = $this->post("api/webhooks/payment/mollie", $post); $response->assertStatus(200); - $this->assertSame('paid', $payment->fresh()->status); + $this->assertSame(PaymentProvider::STATUS_PAID, $payment->fresh()->status); + $this->assertEquals(1234, $wallet->fresh()->balance); + + // Test for payment failure + Bus::fake(); + + $payment->refresh(); + $payment->status = PaymentProvider::STATUS_OPEN; + $payment->save(); + + $mollie_response = [ + "resource" => "payment", + "id" => $payment->id, + "status" => "failed", + "mode" => "test", + ]; + + // We'll trigger the webhook with payment id and use mocking for + // a request to the Mollie payments API. We cannot force Mollie + // to make the payment status change. + $responseStack = $this->mockMollie(); + $responseStack->append(new Response(200, [], json_encode($mollie_response))); + + $response = $this->post("api/webhooks/payment/mollie", $post); + $response->assertStatus(200); + + $this->assertSame('failed', $payment->fresh()->status); $this->assertEquals(1234, $wallet->fresh()->balance); + + // Assert that email notification job wasn't dispatched, + // it is expected only for recurring payments + Bus::assertDispatchedTimes(\App\Jobs\PaymentEmail::class, 0); } /** @@ -307,20 +404,184 @@ * * @group mollie */ - public function testDirectCharge(): void + public function testTopUp(): void { + Bus::fake(); + $user = $this->getTestUser('john@kolab.org'); $wallet = $user->wallets()->first(); - // Expect false result, as there's no mandate - $result = PaymentsController::directCharge($wallet, 1234); + // Create a valid mandate first + $this->createMandate($wallet, ['amount' => 20.10, 'balance' => 10]); + + // Expect a recurring payment as we have a valid mandate at this point + $result = PaymentsController::topUpWallet($wallet); + $this->assertTrue($result); + + // Check that the payments table contains a new record with proper amount + // There should be two records, one for the first payment and another for + // the recurring payment + $this->assertCount(1, $wallet->payments()->get()); + $payment = $wallet->payments()->first(); + $this->assertSame(2010, $payment->amount); + + // In mollie we don't have to wait for a webhook, the response to + // PaymentIntent already sets the status to 'paid', so we can test + // immediately the balance update + // Assert that email notification job has been dispatched + $this->assertSame(PaymentProvider::STATUS_PAID, $payment->status); + $this->assertEquals(2010, $wallet->fresh()->balance); + $transaction = $wallet->transactions()->where('type', Transaction::WALLET_CREDIT)->last(); + $this->assertSame(2010, $transaction->amount); + $this->assertSame( + "Auto-payment transaction {$payment->id} using Mastercard (**** **** **** 6787)", + $transaction->description + ); + + Bus::assertDispatchedTimes(\App\Jobs\PaymentEmail::class, 1); + Bus::assertDispatched(\App\Jobs\PaymentEmail::class, function ($job) use ($payment) { + $job_payment = $this->getObjectProperty($job, 'payment'); + return $job_payment->id === $payment->id; + }); + + // Expect no payment if the mandate is disabled + $wallet->setSetting('mandate_disabled', 1); + $result = PaymentsController::topUpWallet($wallet); + $this->assertFalse($result); + $this->assertCount(1, $wallet->payments()->get()); + + // Expect no payment if balance is ok + $wallet->setSetting('mandate_disabled', null); + $wallet->balance = 1000; + $wallet->save(); + $result = PaymentsController::topUpWallet($wallet); + $this->assertFalse($result); + $this->assertCount(1, $wallet->payments()->get()); + + // Expect no payment if the top-up amount is not enough + $wallet->setSetting('mandate_disabled', null); + $wallet->balance = -2050; + $wallet->save(); + $result = PaymentsController::topUpWallet($wallet); $this->assertFalse($result); + $this->assertCount(1, $wallet->payments()->get()); + + Bus::assertDispatchedTimes(\App\Jobs\PaymentMandateDisabledEmail::class, 1); + Bus::assertDispatched(\App\Jobs\PaymentMandateDisabledEmail::class, function ($job) use ($wallet) { + $job_wallet = $this->getObjectProperty($job, 'wallet'); + return $job_wallet->id === $wallet->id; + }); + + // Expect no payment if there's no mandate + $wallet->setSetting('mollie_mandate_id', null); + $wallet->balance = 0; + $wallet->save(); + $result = PaymentsController::topUpWallet($wallet); + $this->assertFalse($result); + $this->assertCount(1, $wallet->payments()->get()); + + Bus::assertDispatchedTimes(\App\Jobs\PaymentMandateDisabledEmail::class, 1); + + // Test webhook for recurring payments + + $responseStack = $this->mockMollie(); + Bus::fake(); + + $payment->refresh(); + $payment->status = PaymentProvider::STATUS_OPEN; + $payment->save(); + + $mollie_response = [ + "resource" => "payment", + "id" => $payment->id, + "status" => "paid", + // Status is not enough, paidAt is used to distinguish the state + "paidAt" => date('c'), + "mode" => "test", + ]; + + // We'll trigger the webhook with payment id and use mocking for + // a request to the Mollie payments API. We cannot force Mollie + // to make the payment status change. + $responseStack->append(new Response(200, [], json_encode($mollie_response))); + + $post = ['id' => $payment->id]; + $response = $this->post("api/webhooks/payment/mollie", $post); + $response->assertStatus(200); + + $this->assertSame(PaymentProvider::STATUS_PAID, $payment->fresh()->status); + $this->assertEquals(2010, $wallet->fresh()->balance); + + $transaction = $wallet->transactions()->where('type', Transaction::WALLET_CREDIT)->last(); + $this->assertSame(2010, $transaction->amount); + $this->assertSame( + "Auto-payment transaction {$payment->id} using Mollie", + $transaction->description + ); + + // Assert that email notification job has been dispatched + Bus::assertDispatchedTimes(\App\Jobs\PaymentEmail::class, 1); + Bus::assertDispatched(\App\Jobs\PaymentEmail::class, function ($job) use ($payment) { + $job_payment = $this->getObjectProperty($job, 'payment'); + return $job_payment->id === $payment->id; + }); + + Bus::fake(); + + // Test for payment failure + $payment->refresh(); + $payment->status = PaymentProvider::STATUS_OPEN; + $payment->save(); + + $wallet->setSetting('mollie_mandate_id', 'xxx'); + $wallet->setSetting('mandate_disabled', null); + + $mollie_response = [ + "resource" => "payment", + "id" => $payment->id, + "status" => "failed", + "mode" => "test", + ]; + + $responseStack->append(new Response(200, [], json_encode($mollie_response))); + + $response = $this->post("api/webhooks/payment/mollie", $post); + $response->assertStatus(200); + + $wallet->refresh(); + + $this->assertSame(PaymentProvider::STATUS_FAILED, $payment->fresh()->status); + $this->assertEquals(2010, $wallet->balance); + $this->assertTrue(!empty($wallet->getSetting('mandate_disabled'))); + + // Assert that email notification job has been dispatched + Bus::assertDispatchedTimes(\App\Jobs\PaymentEmail::class, 1); + Bus::assertDispatched(\App\Jobs\PaymentEmail::class, function ($job) use ($payment) { + $job_payment = $this->getObjectProperty($job, 'payment'); + return $job_payment->id === $payment->id; + }); + + $responseStack = $this->unmockMollie(); + } + + /** + * Create Mollie's auto-payment mandate using our API and Chrome browser + */ + protected function createMandate(Wallet $wallet, array $params) + { + // Use the API to create a first payment with a mandate + $response = $this->actingAs($wallet->owner)->post("api/v4/payments/mandate", $params); + $response->assertStatus(200); + $json = $response->json(); - // Problem with this is we need to have a valid mandate - // And there's no easy way to confirm a created mandate. + // There's no easy way to confirm a created mandate. // The only way seems to be to fire up Chrome on checkout page - // and do some actions with use of Dusk browser. + // and do actions with use of Dusk browser. + $this->startBrowser() + ->visit($json['redirectUrl']) + ->click('input[value="paid"]') + ->click('button.form__button'); - $this->markTestIncomplete(); + $this->stopBrowser(); } } diff --git a/src/tests/Feature/Controller/PaymentsStripeTest.php b/src/tests/Feature/Controller/PaymentsStripeTest.php --- a/src/tests/Feature/Controller/PaymentsStripeTest.php +++ b/src/tests/Feature/Controller/PaymentsStripeTest.php @@ -5,9 +5,11 @@ use App\Http\Controllers\API\V4\PaymentsController; use App\Payment; use App\Providers\PaymentProvider; +use App\Transaction; use App\Wallet; use App\WalletSetting; use GuzzleHttp\Psr7\Response; +use Illuminate\Support\Facades\Bus; use Tests\TestCase; use Tests\StripeMocksTrait; @@ -27,10 +29,11 @@ $john = $this->getTestUser('john@kolab.org'); $wallet = $john->wallets()->first(); - $john->setSetting('mollie_id', null); Payment::where('wallet_id', $wallet->id)->delete(); Wallet::where('id', $wallet->id)->update(['balance' => 0]); WalletSetting::where('wallet_id', $wallet->id)->delete(); + Transaction::where('object_id', $wallet->id) + ->where('type', Transaction::WALLET_CREDIT)->delete(); } /** @@ -40,10 +43,11 @@ { $john = $this->getTestUser('john@kolab.org'); $wallet = $john->wallets()->first(); - $john->setSetting('mollie_id', null); Payment::where('wallet_id', $wallet->id)->delete(); Wallet::where('id', $wallet->id)->update(['balance' => 0]); WalletSetting::where('wallet_id', $wallet->id)->delete(); + Transaction::where('object_id', $wallet->id) + ->where('type', Transaction::WALLET_CREDIT)->delete(); parent::tearDown(); } @@ -55,6 +59,8 @@ */ public function testMandates(): void { + Bus::fake(); + // Unauth access not allowed $response = $this->get("api/v4/payments/mandate"); $response->assertStatus(401); @@ -120,6 +126,7 @@ $this->assertEquals(20.10, $json['amount']); $this->assertEquals(0, $json['balance']); + $this->assertSame(false, $json['isDisabled']); // We would have to invoke a browser to accept the "first payment" to make // the mandate validated/completed. Instead, we'll mock the mandate object. @@ -129,7 +136,8 @@ "created": 123456789, "payment_method": "pm_YYY", "status": "succeeded", - "usage": "off_session" + "usage": "off_session", + "customer": null }'; $paymentMethod = '{ @@ -152,6 +160,7 @@ // I.e. we have to fake the mandate id $wallet = $user->wallets()->first(); $wallet->setSetting('stripe_mandate_id', 'AAA'); + $wallet->setSetting('mandate_disabled', 1); $response = $this->actingAs($user)->get("api/v4/payments/mandate"); $response->assertStatus(200); @@ -163,8 +172,11 @@ $this->assertEquals('Visa (**** **** **** 4242)', $json['method']); $this->assertSame(false, $json['isPending']); $this->assertSame(true, $json['isValid']); + $this->assertSame(true, $json['isDisabled']); // Test updating mandate details (invalid input) + $wallet->setSetting('mandate_disabled', null); + $user->refresh(); $post = []; $response = $this->actingAs($user)->put("api/v4/payments/mandate", $post); $response->assertStatus(422); @@ -187,6 +199,9 @@ $this->assertSame("Minimum amount for a single payment is {$min}.", $json['errors']['amount']); // Test updating a mandate (valid input) + $client->addResponse($setupIntent); + $client->addResponse($paymentMethod); + $post = ['amount' => 30.10, 'balance' => 1]; $response = $this->actingAs($user)->put("api/v4/payments/mandate", $post); $response->assertStatus(200); @@ -195,10 +210,48 @@ $this->assertSame('success', $json['status']); $this->assertSame('The auto-payment has been updated.', $json['message']); - - $this->assertEquals(30.10, $wallet->getSetting('mandate_amount')); $this->assertEquals(1, $wallet->getSetting('mandate_balance')); + $this->assertSame('AAA', $json['id']); + $this->assertFalse($json['isDisabled']); + + // Test updating a disabled mandate (invalid input) + $wallet->setSetting('mandate_disabled', 1); + $wallet->balance = -2000; + $wallet->save(); + $user->refresh(); // required so the controller sees the wallet update from above + + $post = ['amount' => 15.10, 'balance' => 1]; + $response = $this->actingAs($user)->put("api/v4/payments/mandate", $post); + $response->assertStatus(422); + + $json = $response->json(); + + $this->assertSame('error', $json['status']); + $this->assertCount(1, $json['errors']); + $this->assertSame('The specified amount does not cover the balance on the account.', $json['errors']['amount']); + + // Test updating a disabled mandate (valid input) + $client->addResponse($setupIntent); + $client->addResponse($paymentMethod); + + $post = ['amount' => 30, 'balance' => 1]; + $response = $this->actingAs($user)->put("api/v4/payments/mandate", $post); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertSame('success', $json['status']); + $this->assertSame('The auto-payment has been updated.', $json['message']); + $this->assertSame('AAA', $json['id']); + $this->assertFalse($json['isDisabled']); + + Bus::assertDispatchedTimes(\App\Jobs\WalletCharge::class, 1); + Bus::assertDispatched(\App\Jobs\WalletCharge::class, function ($job) use ($wallet) { + $job_wallet = $this->getObjectProperty($job, 'wallet'); + return $job_wallet->id === $wallet->id; + }); + $this->unmockStripe(); @@ -212,6 +265,8 @@ */ public function testStoreAndWebhook(): void { + Bus::fake(); + // Unauth access not allowed $response = $this->post("api/v4/payments", []); $response->assertStatus(401); @@ -248,7 +303,149 @@ $this->assertSame('open', $payment->status); $this->assertEquals(0, $wallet->balance); - // TODO: Test the webhook + // Test the webhook + + $post = [ + 'id' => "evt_1GlZ814fj3SIEU8wtxMZ4Nsa", + 'object' => "event", + 'api_version' => "2020-03-02", + 'created' => 1590147209, + 'data' => [ + 'object' => [ + 'id' => $payment->id, + 'object' => "payment_intent", + 'amount' => 1234, + 'amount_capturable' => 0, + 'amount_received' => 1234, + 'capture_method' => "automatic", + 'client_secret' => "pi_1GlZ7w4fj3SIEU8w1RlBpN4l_secret_UYRNDTUUU7nkYHpOLZMb3uf48", + 'confirmation_method' => "automatic", + 'created' => 1590147204, + 'currency' => "chf", + 'customer' => "cus_HKDZ53OsKdlM83", + 'last_payment_error' => null, + 'livemode' => false, + 'metadata' => [], + 'receipt_email' => "payment-test@kolabnow.com", + 'status' => "succeeded" + ] + ], + 'type' => "payment_intent.succeeded" + ]; + + // Test payment succeeded event + $response = $this->webhookRequest($post); + $response->assertStatus(200); + + $this->assertSame(PaymentProvider::STATUS_PAID, $payment->fresh()->status); + $this->assertEquals(1234, $wallet->fresh()->balance); + + $transaction = $wallet->transactions()->where('type', Transaction::WALLET_CREDIT)->last(); + $this->assertSame(1234, $transaction->amount); + $this->assertSame( + "Payment transaction {$payment->id} using Stripe", + $transaction->description + ); + + // Assert that email notification job wasn't dispatched, + // it is expected only for recurring payments + Bus::assertDispatchedTimes(\App\Jobs\PaymentEmail::class, 0); + + // Test that balance didn't change if the same event is posted + $response = $this->webhookRequest($post); + $response->assertStatus(200); + + $this->assertSame(PaymentProvider::STATUS_PAID, $payment->fresh()->status); + $this->assertEquals(1234, $wallet->fresh()->balance); + + // Test for payment failure ('failed' status) + $payment->refresh(); + $payment->status = PaymentProvider::STATUS_OPEN; + $payment->save(); + + $post['type'] = "payment_intent.payment_failed"; + $post['data']['object']['status'] = 'failed'; + + $response = $this->webhookRequest($post); + $response->assertStatus(200); + + $this->assertSame(PaymentProvider::STATUS_FAILED, $payment->fresh()->status); + $this->assertEquals(1234, $wallet->fresh()->balance); + + // Assert that email notification job wasn't dispatched, + // it is expected only for recurring payments + Bus::assertDispatchedTimes(\App\Jobs\PaymentEmail::class, 0); + + // Test for payment failure ('canceled' status) + $payment->refresh(); + $payment->status = PaymentProvider::STATUS_OPEN; + $payment->save(); + + $post['type'] = "payment_intent.canceled"; + $post['data']['object']['status'] = 'canceled'; + + $response = $this->webhookRequest($post); + $response->assertStatus(200); + + $this->assertSame(PaymentProvider::STATUS_CANCELED, $payment->fresh()->status); + $this->assertEquals(1234, $wallet->fresh()->balance); + + // Assert that email notification job wasn't dispatched, + // it is expected only for recurring payments + Bus::assertDispatchedTimes(\App\Jobs\PaymentEmail::class, 0); + } + + /** + * Test receiving webhook request for setup intent + * + * @group stripe + */ + public function testCreateMandateAndWebhook(): void + { + $user = $this->getTestUser('john@kolab.org'); + $wallet = $user->wallets()->first(); + + // Test creating a mandate (valid input) + $post = ['amount' => 20.10, 'balance' => 0]; + $response = $this->actingAs($user)->post("api/v4/payments/mandate", $post); + $response->assertStatus(200); + + $payment = $wallet->payments()->first(); + + $this->assertSame(PaymentProvider::STATUS_OPEN, $payment->status); + $this->assertSame(PaymentProvider::TYPE_MANDATE, $payment->type); + $this->assertSame(0, $payment->amount); + + $post = [ + 'id' => "evt_1GlZ814fj3SIEU8wtxMZ4Nsa", + 'object' => "event", + 'api_version' => "2020-03-02", + 'created' => 1590147209, + 'data' => [ + 'object' => [ + 'id' => $payment->id, + 'object' => "setup_intent", + 'client_secret' => "pi_1GlZ7w4fj3SIEU8w1RlBpN4l_secret_UYRNDTUUU7nkYHpOLZMb3uf48", + 'created' => 1590147204, + 'customer' => "cus_HKDZ53OsKdlM83", + 'last_setup_error' => null, + 'metadata' => [], + 'status' => "succeeded" + ] + ], + 'type' => "setup_intent.succeeded" + ]; + + // Test payment succeeded event + $response = $this->webhookRequest($post); + $response->assertStatus(200); + + $payment->refresh(); + + $this->assertSame(PaymentProvider::STATUS_PAID, $payment->status); + $this->assertSame($payment->id, $wallet->fresh()->getSetting('stripe_mandate_id')); + + // TODO: test other setup_intent.* events } /** @@ -256,20 +453,218 @@ * * @group stripe */ - public function testDirectCharge(): void + public function testTopUpAndWebhook(): void { + Bus::fake(); + $user = $this->getTestUser('john@kolab.org'); $wallet = $user->wallets()->first(); - // Expect false result, as there's no mandate - $result = PaymentsController::directCharge($wallet, 1234); + // Stripe API does not allow us to create a mandate easily + // That's why we we'll mock API responses + // Create a fake mandate + $wallet->setSettings([ + 'mandate_amount' => 20.10, + 'mandate_balance' => 10, + 'stripe_mandate_id' => 'AAA', + ]); + + $setupIntent = json_encode([ + "id" => "AAA", + "object" => "setup_intent", + "created" => 123456789, + "payment_method" => "pm_YYY", + "status" => "succeeded", + "usage" => "off_session", + "customer" => null + ]); + + $paymentMethod = json_encode([ + "id" => "pm_YYY", + "object" => "payment_method", + "card" => [ + "brand" => "visa", + "country" => "US", + "last4" => "4242" + ], + "created" => 123456789, + "type" => "card" + ]); + + $paymentIntent = json_encode([ + "id" => "pi_XX", + "object" => "payment_intent", + "created" => 123456789, + "amount" => 2010, + "currency" => "chf", + "description" => "Kolab Recurring Payment" + ]); + + $client = $this->mockStripe(); + $client->addResponse($setupIntent); + $client->addResponse($paymentMethod); + $client->addResponse($setupIntent); + $client->addResponse($paymentIntent); + + // Expect a recurring payment as we have a valid mandate at this point + $result = PaymentsController::topUpWallet($wallet); + $this->assertTrue($result); + + // Check that the payments table contains a new record with proper amount + // There should be two records, one for the first payment and another for + // the recurring payment + $this->assertCount(1, $wallet->payments()->get()); + $payment = $wallet->payments()->first(); + $this->assertSame(2010, $payment->amount); + $this->assertSame(\config('app.name') . " Recurring Payment", $payment->description); + $this->assertSame("pi_XX", $payment->id); + + // Expect no payment if the mandate is disabled + $wallet->setSetting('mandate_disabled', 1); + $result = PaymentsController::topUpWallet($wallet); $this->assertFalse($result); + $this->assertCount(1, $wallet->payments()->get()); + + // Expect no payment if balance is ok + $wallet->setSetting('mandate_disabled', null); + $wallet->balance = 1000; + $wallet->save(); + $result = PaymentsController::topUpWallet($wallet); + $this->assertFalse($result); + $this->assertCount(1, $wallet->payments()->get()); + + // Expect no payment if the top-up amount is not enough + $wallet->setSetting('mandate_disabled', null); + $wallet->balance = -2050; + $wallet->save(); + $result = PaymentsController::topUpWallet($wallet); + $this->assertFalse($result); + $this->assertCount(1, $wallet->payments()->get()); + + Bus::assertDispatchedTimes(\App\Jobs\PaymentMandateDisabledEmail::class, 1); + Bus::assertDispatched(\App\Jobs\PaymentMandateDisabledEmail::class, function ($job) use ($wallet) { + $job_wallet = $this->getObjectProperty($job, 'wallet'); + return $job_wallet->id === $wallet->id; + }); + + // Expect no payment if there's no mandate + $wallet->setSetting('mollie_mandate_id', null); + $wallet->balance = 0; + $wallet->save(); + $result = PaymentsController::topUpWallet($wallet); + $this->assertFalse($result); + $this->assertCount(1, $wallet->payments()->get()); + + Bus::assertDispatchedTimes(\App\Jobs\PaymentMandateDisabledEmail::class, 1); + + $this->unmockStripe(); + + // Test webhook + + $post = [ + 'id' => "evt_1GlZ814fj3SIEU8wtxMZ4Nsa", + 'object' => "event", + 'api_version' => "2020-03-02", + 'created' => 1590147209, + 'data' => [ + 'object' => [ + 'id' => $payment->id, + 'object' => "payment_intent", + 'amount' => 2010, + 'capture_method' => "automatic", + 'created' => 1590147204, + 'currency' => "chf", + 'customer' => "cus_HKDZ53OsKdlM83", + 'last_payment_error' => null, + 'metadata' => [], + 'receipt_email' => "payment-test@kolabnow.com", + 'status' => "succeeded" + ] + ], + 'type' => "payment_intent.succeeded" + ]; + + // Test payment succeeded event + $response = $this->webhookRequest($post); + $response->assertStatus(200); + + $this->assertSame(PaymentProvider::STATUS_PAID, $payment->fresh()->status); + $this->assertEquals(2010, $wallet->fresh()->balance); + $transaction = $wallet->transactions()->where('type', Transaction::WALLET_CREDIT)->last(); + $this->assertSame(2010, $transaction->amount); + $this->assertSame( + "Auto-payment transaction {$payment->id} using Stripe", + $transaction->description + ); + + // Assert that email notification job has been dispatched + Bus::assertDispatchedTimes(\App\Jobs\PaymentEmail::class, 1); + Bus::assertDispatched(\App\Jobs\PaymentEmail::class, function ($job) use ($payment) { + $job_payment = $this->getObjectProperty($job, 'payment'); + return $job_payment->id === $payment->id; + }); + + Bus::fake(); + + // Test for payment failure ('failed' status) + $payment->refresh(); + $payment->status = PaymentProvider::STATUS_OPEN; + $payment->save(); + + $wallet->setSetting('mandate_disabled', null); + + $post['type'] = "payment_intent.payment_failed"; + $post['data']['object']['status'] = 'failed'; + + $response = $this->webhookRequest($post); + $response->assertStatus(200); + + $wallet->refresh(); + + $this->assertSame(PaymentProvider::STATUS_FAILED, $payment->fresh()->status); + $this->assertEquals(2010, $wallet->balance); + $this->assertTrue(!empty($wallet->getSetting('mandate_disabled'))); + + // Assert that email notification job has been dispatched + Bus::assertDispatchedTimes(\App\Jobs\PaymentEmail::class, 1); + Bus::assertDispatched(\App\Jobs\PaymentEmail::class, function ($job) use ($payment) { + $job_payment = $this->getObjectProperty($job, 'payment'); + return $job_payment->id === $payment->id; + }); + + Bus::fake(); + + // Test for payment failure ('canceled' status) + $payment->refresh(); + $payment->status = PaymentProvider::STATUS_OPEN; + $payment->save(); + + $post['type'] = "payment_intent.canceled"; + $post['data']['object']['status'] = 'canceled'; + + $response = $this->webhookRequest($post); + $response->assertStatus(200); + + $this->assertSame(PaymentProvider::STATUS_CANCELED, $payment->fresh()->status); + $this->assertEquals(2010, $wallet->fresh()->balance); + + // Assert that email notification job wasn't dispatched, + // it is expected only for recurring payments + Bus::assertDispatchedTimes(\App\Jobs\PaymentEmail::class, 0); + } + + /** + * Generate Stripe-Signature header for a webhook payload + */ + protected function webhookRequest($post) + { + $secret = \config('services.stripe.webhook_secret'); + $ts = time(); - // Problem with this is we need to have a valid mandate - // And there's no easy way to confirm a created mandate. - // The only way seems to be to fire up Chrome on checkout page - // and do some actions with use of Dusk browser. + $payload = "$ts." . json_encode($post); + $sig = sprintf('t=%d,v1=%s', $ts, \hash_hmac('sha256', $payload, $secret)); - $this->markTestIncomplete(); + return $this->withHeaders(['Stripe-Signature' => $sig]) + ->json('POST', "api/webhooks/payment/stripe", $post); } } diff --git a/src/tests/Feature/Controller/UsersTest.php b/src/tests/Feature/Controller/UsersTest.php --- a/src/tests/Feature/Controller/UsersTest.php +++ b/src/tests/Feature/Controller/UsersTest.php @@ -311,7 +311,12 @@ $this->assertTrue(empty($json['status'])); $this->assertTrue(empty($json['message'])); - // Now "reboot" the process and verify the user in imap syncronously + // Make sure the domain is confirmed (other test might unset that status) + $domain = $this->getTestDomain('kolab.org'); + $domain->status |= Domain::STATUS_CONFIRMED; + $domain->save(); + + // Now "reboot" the process and verify the user in imap synchronously $response = $this->actingAs($john)->get("/api/v4/users/{$john->id}/status?refresh=1"); $response->assertStatus(200); diff --git a/src/tests/Feature/Jobs/PaymentEmailTest.php b/src/tests/Feature/Jobs/PaymentEmailTest.php new file mode 100644 --- /dev/null +++ b/src/tests/Feature/Jobs/PaymentEmailTest.php @@ -0,0 +1,120 @@ +deleteTestUser('PaymentEmail@UserAccount.com'); + } + + /** + * {@inheritDoc} + * + * @return void + */ + public function tearDown(): void + { + $this->deleteTestUser('PaymentEmail@UserAccount.com'); + + parent::tearDown(); + } + + /** + * Test job handle + * + * @return void + */ + public function testHandle() + { + $user = $this->getTestUser('PaymentEmail@UserAccount.com'); + $user->setSetting('external_email', 'ext@email.tld'); + $wallet = $user->wallets()->first(); + + $payment = new Payment(); + $payment->id = 'test-payment'; + $payment->wallet_id = $wallet->id; + $payment->amount = 100; + $payment->status = PaymentProvider::STATUS_PAID; + $payment->description = 'test'; + $payment->provider = 'stripe'; + $payment->type = PaymentProvider::TYPE_ONEOFF; + $payment->save(); + + Mail::fake(); + + // Assert that no jobs were pushed... + Mail::assertNothingSent(); + + $job = new PaymentEmail($payment); + $job->handle(); + + // Assert the email sending job was pushed once + Mail::assertSent(PaymentSuccess::class, 1); + + // Assert the mail was sent to the user's email + Mail::assertSent(PaymentSuccess::class, function ($mail) use ($user) { + return $mail->hasTo($user->email) && $mail->hasCc('ext@email.tld'); + }); + + $payment->status = PaymentProvider::STATUS_FAILED; + $payment->save(); + + $job = new PaymentEmail($payment); + $job->handle(); + + // Assert the email sending job was pushed once + Mail::assertSent(PaymentFailure::class, 1); + + // Assert the mail was sent to the user's email + Mail::assertSent(PaymentFailure::class, function ($mail) use ($user) { + return $mail->hasTo($user->email) && $mail->hasCc('ext@email.tld'); + }); + + $payment->status = PaymentProvider::STATUS_EXPIRED; + $payment->save(); + + $job = new PaymentEmail($payment); + $job->handle(); + + // Assert the email sending job was pushed twice + Mail::assertSent(PaymentFailure::class, 2); + + // None of statuses below should trigger an email + Mail::fake(); + + $states = [ + PaymentProvider::STATUS_OPEN, + PaymentProvider::STATUS_CANCELED, + PaymentProvider::STATUS_PENDING, + PaymentProvider::STATUS_AUTHORIZED, + ]; + + foreach ($states as $state) { + $payment->status = $state; + $payment->save(); + + $job = new PaymentEmail($payment); + $job->handle(); + } + + // Assert that no mailables were sent... + Mail::assertNothingSent(); + } +} diff --git a/src/tests/Feature/Jobs/PaymentMandateDisabledEmailTest.php b/src/tests/Feature/Jobs/PaymentMandateDisabledEmailTest.php new file mode 100644 --- /dev/null +++ b/src/tests/Feature/Jobs/PaymentMandateDisabledEmailTest.php @@ -0,0 +1,63 @@ +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, "The auto-payment for your $appName account") > 0); + $this->assertTrue(strpos($html, "$appName Team") > 0); + } +} diff --git a/src/tests/Unit/Mail/SignupVerificationTest.php b/src/tests/Unit/Mail/SignupVerificationTest.php --- a/src/tests/Unit/Mail/SignupVerificationTest.php +++ b/src/tests/Unit/Mail/SignupVerificationTest.php @@ -4,6 +4,7 @@ use App\Mail\SignupVerification; use App\SignupCode; +use App\Utils; use Tests\TestCase; class SignupVerificationTest extends TestCase @@ -28,7 +29,7 @@ $mail = new SignupVerification($code); $html = $mail->build()->render(); - $url = \config('app.url') . '/signup/' . $code->short_code . '-' . $code->code; + $url = Utils::serviceUrl('/signup/' . $code->short_code . '-' . $code->code); $link = "$url"; $this->assertSame(\config('app.name') . ' Registration', $mail->subject);