diff --git a/src/.env.example b/src/.env.example --- a/src/.env.example +++ b/src/.env.example @@ -60,7 +60,11 @@ SWOOLE_HTTP_HOST=127.0.0.1 SWOOLE_HTTP_PORT=8000 +PAYMENT_PROVIDER= MOLLIE_KEY= +STRIPE_KEY= +STRIPE_PUBLIC_KEY= +STRIPE_WEBHOOK_SECRET= MAIL_DRIVER=smtp MAIL_HOST=smtp.mailtrap.io diff --git a/src/app/Console/Commands/MollieInfo.php b/src/app/Console/Commands/MollieInfo.php new file mode 100644 --- /dev/null +++ b/src/app/Console/Commands/MollieInfo.php @@ -0,0 +1,78 @@ +argument('user')) { + $user = User::where('email', $this->argument('user'))->first(); + + if (!$user) { + return 1; + } + + $this->info("Found user: {$user->id}"); + + $wallet = $user->wallets->first(); + $provider = new \App\Providers\Payment\Mollie(); + + if ($mandate = $provider->getMandate($wallet)) { + $amount = $wallet->getSetting('mandate_amount'); + $balance = $wallet->getSetting('mandate_balance') ?: 0; + + $this->info("Auto-payment: {$mandate['method']}"); + $this->info(" id: {$mandate['id']}"); + $this->info(" status: " . ($mandate['isPending'] ? 'pending' : 'valid')); + $this->info(" amount: {$amount} {$wallet->currency}"); + $this->info(" min-balance: {$balance} {$wallet->currency}"); + } else { + $this->info("Auto-payment: none"); + } + + // TODO: List user payments history + } else { + $this->info("Available payment methods:"); + + foreach (mollie()->methods()->all() as $method) { + $this->info("- {$method->description} ({$method->id}):"); + $this->info(" status: {$method->status}"); + $this->info(sprintf( + " min: %s %s", + $method->minimumAmount->value, + $method->minimumAmount->currency + )); + if (!empty($method->maximumAmount)) { + $this->info(sprintf( + " max: %s %s", + $method->maximumAmount->value, + $method->maximumAmount->currency + )); + } + } + } + } +} diff --git a/src/app/Console/Commands/StripeInfo.php b/src/app/Console/Commands/StripeInfo.php new file mode 100644 --- /dev/null +++ b/src/app/Console/Commands/StripeInfo.php @@ -0,0 +1,63 @@ +argument('user')) { + $user = User::where('email', $this->argument('user'))->first(); + + if (!$user) { + return 1; + } + + $this->info("Found user: {$user->id}"); + + $wallet = $user->wallets->first(); + $provider = PaymentProvider::factory('stripe'); + + if ($mandate = $provider->getMandate($wallet)) { + $amount = $wallet->getSetting('mandate_amount'); + $balance = $wallet->getSetting('mandate_balance') ?: 0; + + $this->info("Auto-payment: {$mandate['method']}"); + $this->info(" id: {$mandate['id']}"); + $this->info(" status: " . ($mandate['isPending'] ? 'pending' : 'valid')); + $this->info(" amount: {$amount} {$wallet->currency}"); + $this->info(" min-balance: {$balance} {$wallet->currency}"); + } else { + $this->info("Auto-payment: none"); + } + + // TODO: List user payments history + } else { + // TODO: Fetch some info/stats from Stripe + } + } +} diff --git a/src/app/Http/Controllers/API/AuthController.php b/src/app/Http/Controllers/API/AuthController.php --- a/src/app/Http/Controllers/API/AuthController.php +++ b/src/app/Http/Controllers/API/AuthController.php @@ -31,6 +31,7 @@ */ public static function logonResponse(User $user) { + // @phpstan-ignore-next-line $token = Auth::guard()->login($user); return response()->json([ @@ -101,6 +102,7 @@ */ public function refresh() { + // @phpstan-ignore-next-line return $this->respondWithToken(Auth::guard()->refresh()); } diff --git a/src/app/Http/Controllers/API/V4/Admin/WalletsController.php b/src/app/Http/Controllers/API/V4/Admin/WalletsController.php --- a/src/app/Http/Controllers/API/V4/Admin/WalletsController.php +++ b/src/app/Http/Controllers/API/V4/Admin/WalletsController.php @@ -3,12 +3,49 @@ namespace App\Http\Controllers\API\V4\Admin; use App\Discount; +use App\Http\Controllers\API\V4\PaymentsController; +use App\Providers\PaymentProvider; use App\Wallet; use Illuminate\Http\Request; class WalletsController extends \App\Http\Controllers\API\V4\WalletsController { /** + * Return data of the specified wallet. + * + * @param string $id A wallet identifier + * + * @return \Illuminate\Http\JsonResponse The response + */ + public function show($id) + { + $wallet = Wallet::find($id); + + if (empty($wallet)) { + return $this->errorResponse(404); + } + + $result = $wallet->toArray(); + + $result['discount'] = 0; + $result['discount_description'] = ''; + + if ($wallet->discount) { + $result['discount'] = $wallet->discount->discount; + $result['discount_description'] = $wallet->discount->description; + } + + $result['mandate'] = PaymentsController::walletMandate($wallet); + + $provider = PaymentProvider::factory($wallet); + + $result['provider'] = $provider->name(); + $result['providerLink'] = $provider->customerLink($wallet); + + return $result; + } + + /** * Update wallet data. * * @param \Illuminate\Http\Request $request The API request. 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 @@ -2,9 +2,9 @@ namespace App\Http\Controllers\API\V4; -use App\Payment; -use App\Wallet; use App\Http\Controllers\Controller; +use App\Providers\PaymentProvider; +use App\Wallet; use Illuminate\Http\Request; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Validator; @@ -12,209 +12,264 @@ class PaymentsController extends Controller { /** - * Create a new payment. + * Get the auto-payment mandate info. + * + * @return \Illuminate\Http\JsonResponse The response + */ + public function mandate() + { + $user = Auth::guard()->user(); + + // TODO: Wallet selection + $wallet = $user->wallets->first(); + + $mandate = self::walletMandate($wallet); + + return response()->json($mandate); + } + + /** + * Create a new auto-payment mandate. * * @param \Illuminate\Http\Request $request The API request. * * @return \Illuminate\Http\JsonResponse The response */ - public function store(Request $request) + public function mandateCreate(Request $request) { $current_user = Auth::guard()->user(); // TODO: Wallet selection - $wallet = $current_user->wallets()->first(); + $wallet = $current_user->wallets->first(); + + $rules = [ + 'amount' => 'required|numeric', + 'balance' => 'required|numeric|min:0', + ]; // Check required fields - $v = Validator::make( - $request->all(), - [ - 'amount' => 'required|int|min:1', - ] - ); + $v = Validator::make($request->all(), $rules); + + // TODO: allow comma as a decimal point? if ($v->fails()) { return response()->json(['status' => 'error', 'errors' => $v->errors()], 422); } - // Register the user in Mollie, if not yet done - // FIXME: Maybe Mollie ID should be bound to a wallet, but then - // The same customer could technicly have multiple - // Mollie IDs, then we'd need to use some "virtual" email - // address (e.g. @) instead of the user email address - $customer_id = $current_user->getSetting('mollie_id'); - $seq_type = 'oneoff'; - - if (empty($customer_id)) { - $customer = mollie()->customers()->create([ - 'name' => $current_user->name(), - 'email' => $current_user->email, - ]); - - $seq_type = 'first'; - $customer_id = $customer->id; - $current_user->setSetting('mollie_id', $customer_id); + $amount = (int) ($request->amount * 100); + + // Validate the minimum value + if ($amount < PaymentProvider::MIN_AMOUNT) { + $min = intval(PaymentProvider::MIN_AMOUNT / 100) . ' CHF'; + $errors = ['amount' => \trans('validation.minamount', ['amount' => $min])]; + return response()->json(['status' => 'error', 'errors' => $errors], 422); } - $payment_request = [ - 'amount' => [ - 'currency' => 'CHF', - // a number with two decimals is required - 'value' => sprintf('%.2f', $request->amount / 100), - ], - 'customerId' => $customer_id, - 'sequenceType' => $seq_type, // 'first' / 'oneoff' / 'recurring' - 'description' => 'Kolab Now Payment', // required - 'redirectUrl' => \url('/wallet'), // required for non-recurring payments - 'webhookUrl' => self::serviceUrl('/api/webhooks/payment/mollie'), - 'locale' => 'en_US', + $wallet->setSetting('mandate_amount', $request->amount); + $wallet->setSetting('mandate_balance', $request->balance); + + $request = [ + 'currency' => 'CHF', + 'amount' => $amount, + 'description' => \config('app.name') . ' Auto-Payment Setup', ]; - // Create the payment in Mollie - $payment = mollie()->payments()->create($payment_request); + $provider = PaymentProvider::factory($wallet); + + $result = $provider->createMandate($wallet, $request); + + $result['status'] = 'success'; + + return response()->json($result); + } + + /** + * Revoke the auto-payment mandate. + * + * @return \Illuminate\Http\JsonResponse The response + */ + public function mandateDelete() + { + $user = Auth::guard()->user(); + + // TODO: Wallet selection + $wallet = $user->wallets->first(); + + $provider = PaymentProvider::factory($wallet); - // Store the payment reference in database - self::storePayment($payment, $wallet->id, $request->amount); + $provider->deleteMandate($wallet); return response()->json([ 'status' => 'success', - 'redirectUrl' => $payment->getCheckoutUrl(), + 'message' => \trans('app.mandate-delete-success'), ]); } /** - * Update payment status (and balance). + * Update a new auto-payment mandate. * * @param \Illuminate\Http\Request $request The API request. * - * @return \Illuminate\Http\Response The response + * @return \Illuminate\Http\JsonResponse The response */ - public function webhook(Request $request) + public function mandateUpdate(Request $request) { - $db_payment = Payment::find($request->id); + $current_user = Auth::guard()->user(); - // Mollie recommends to return "200 OK" even if the payment does not exist - if (empty($db_payment)) { - return response('Success', 200); - } + // TODO: Wallet selection + $wallet = $current_user->wallets->first(); - // Get the payment details from Mollie - $payment = mollie()->payments()->get($request->id); + $rules = [ + 'amount' => 'required|numeric', + 'balance' => 'required|numeric|min:0', + ]; - if (empty($payment)) { - return response('Success', 200); - } + // Check required fields + $v = Validator::make($request->all(), $rules); - if ($payment->isPaid()) { - if (!$payment->hasRefunds() && !$payment->hasChargebacks()) { - // The payment is paid and isn't refunded or charged back. - // Update the balance, if it wasn't already - if ($db_payment->status != 'paid') { - $db_payment->wallet->credit($db_payment->amount); - } - } elseif ($payment->hasRefunds()) { - // The payment has been (partially) refunded. - // The status of the payment is still "paid" - // TODO: Update balance - } elseif ($payment->hasChargebacks()) { - // The payment has been (partially) charged back. - // The status of the payment is still "paid" - // TODO: Update balance - } + // TODO: allow comma as a decimal point? + + if ($v->fails()) { + return response()->json(['status' => 'error', 'errors' => $v->errors()], 422); } - // 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 ($db_payment->status != 'paid') { - $db_payment->status = $payment->status; - $db_payment->save(); + $amount = (int) ($request->amount * 100); + + // Validate the minimum value + if ($amount < PaymentProvider::MIN_AMOUNT) { + $min = intval(PaymentProvider::MIN_AMOUNT / 100) . ' CHF'; + $errors = ['amount' => \trans('validation.minamount', ['amount' => $min])]; + return response()->json(['status' => 'error', 'errors' => $errors], 422); } - return response('Success', 200); + $wallet->setSetting('mandate_amount', $request->amount); + $wallet->setSetting('mandate_balance', $request->balance); + + return response()->json([ + 'status' => 'success', + 'message' => \trans('app.mandate-update-success'), + ]); } /** - * Charge a wallet with a "recurring" payment. + * Create a new payment. * - * @param \App\Wallet $wallet The wallet to charge - * @param int $amount The amount of money in cents + * @param \Illuminate\Http\Request $request The API request. * - * @return bool + * @return \Illuminate\Http\JsonResponse The response */ - public static function directCharge(Wallet $wallet, $amount): bool + public function store(Request $request) { - $customer_id = $wallet->owner->getSetting('mollie_id'); + $current_user = Auth::guard()->user(); + + // TODO: Wallet selection + $wallet = $current_user->wallets->first(); + + $rules = [ + 'amount' => 'required|numeric', + ]; + + // Check required fields + $v = Validator::make($request->all(), $rules); + + // TODO: allow comma as a decimal point? - if (empty($customer_id)) { - return false; + if ($v->fails()) { + return response()->json(['status' => 'error', 'errors' => $v->errors()], 422); } - // Check if there's at least one valid mandate - $mandates = mollie()->mandates()->listFor($customer_id)->filter(function ($mandate) { - return $mandate->isValid(); - }); + $amount = (int) ($request->amount * 100); - if (empty($mandates)) { - return false; + // Validate the minimum value + if ($amount < PaymentProvider::MIN_AMOUNT) { + $min = intval(PaymentProvider::MIN_AMOUNT / 100) . ' CHF'; + $errors = ['amount' => \trans('validation.minamount', ['amount' => $min])]; + return response()->json(['status' => 'error', 'errors' => $errors], 422); } - $payment_request = [ - 'amount' => [ - 'currency' => 'CHF', - // a number with two decimals is required - 'value' => sprintf('%.2f', $amount / 100), - ], - 'customerId' => $customer_id, - 'sequenceType' => 'recurring', - 'description' => 'Kolab Now Recurring Payment', - 'webhookUrl' => self::serviceUrl('/api/webhooks/payment/mollie'), + $request = [ + 'type' => PaymentProvider::TYPE_ONEOFF, + 'currency' => 'CHF', + 'amount' => $amount, + 'description' => \config('app.name') . ' Payment', ]; - // Create the payment in Mollie - $payment = mollie()->payments()->create($payment_request); + $provider = PaymentProvider::factory($wallet); + + $result = $provider->payment($wallet, $request); + + $result['status'] = 'success'; - // Store the payment reference in database - self::storePayment($payment, $wallet->id, $amount); + return response()->json($result); + } + + /** + * Update payment status (and balance). + * + * @param string $provider Provider name + * + * @return \Illuminate\Http\Response The response + */ + public function webhook($provider) + { + $code = 200; - return true; + if ($provider = PaymentProvider::factory($provider)) { + $code = $provider->webhook(); + } + + return response($code < 400 ? 'Success' : 'Server error', $code); } /** - * Create self URL + * Charge a wallet with a "recurring" payment. * - * @param string $route Route/Path + * @param \App\Wallet $wallet The wallet to charge + * @param int $amount The amount of money in cents * - * @return string Full URL + * @return bool */ - protected static function serviceUrl(string $route): string + public static function directCharge(Wallet $wallet, $amount): bool { - $url = \url($route); + $request = [ + 'type' => PaymentProvider::TYPE_RECURRING, + 'currency' => 'CHF', + 'amount' => $amount, + 'description' => \config('app.name') . ' Recurring Payment', + ]; - $app_url = trim(\config('app.url'), '/'); - $pub_url = trim(\config('app.public_url'), '/'); + $provider = PaymentProvider::factory($wallet); - if ($pub_url != $app_url) { - $url = str_replace($app_url, $pub_url, $url); + if ($result = $provider->payment($wallet, $request)) { + return true; } - return $url; + return false; } /** - * Create a payment record in DB + * Returns auto-payment mandate info for the specified wallet + * + * @param \App\Wallet $wallet A wallet object * - * @param object $payment Mollie payment - * @param string $wallet_id Wallet ID - * @param int $amount Amount of money in cents + * @return array A mandate metadata */ - protected static function storePayment($payment, $wallet_id, $amount): void + public static function walletMandate(Wallet $wallet): array { - $db_payment = new Payment(); - $db_payment->id = $payment->id; - $db_payment->description = $payment->description; - $db_payment->status = $payment->status; - $db_payment->amount = $amount; - $db_payment->wallet_id = $wallet_id; - $db_payment->save(); + $provider = PaymentProvider::factory($wallet); + + // Get the Mandate info + $mandate = (array) $provider->getMandate($wallet); + + $mandate['amount'] = (int) (PaymentProvider::MIN_AMOUNT / 100); + $mandate['balance'] = 0; + + foreach (['amount', 'balance'] as $key) { + if (($value = $wallet->getSetting("mandate_{$key}")) !== null) { + $mandate[$key] = $value; + } + } + + return $mandate; } } diff --git a/src/app/Http/Controllers/API/V4/UsersController.php b/src/app/Http/Controllers/API/V4/UsersController.php --- a/src/app/Http/Controllers/API/V4/UsersController.php +++ b/src/app/Http/Controllers/API/V4/UsersController.php @@ -426,7 +426,7 @@ $response = array_merge($response, self::userStatuses($user)); - // Add discount info to wallet object output + // Add more info to the wallet object output $map_func = function ($wallet) use ($user) { $result = $wallet->toArray(); @@ -439,6 +439,9 @@ $result['user_email'] = $wallet->owner->email; } + $provider = \App\Providers\PaymentProvider::factory($wallet); + $result['provider'] = $provider->name(); + return $result; }; 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 @@ -19,7 +19,8 @@ \App\Http\Middleware\CheckForMaintenanceMode::class, \Illuminate\Foundation\Http\Middleware\ValidatePostSize::class, \App\Http\Middleware\TrimStrings::class, - \Illuminate\Foundation\Http\Middleware\ConvertEmptyStringsToNull::class + \Illuminate\Foundation\Http\Middleware\ConvertEmptyStringsToNull::class, + \App\Http\Middleware\DevelConfig::class, ]; /** diff --git a/src/app/Http/Middleware/DevelConfig.php b/src/app/Http/Middleware/DevelConfig.php new file mode 100644 --- /dev/null +++ b/src/app/Http/Middleware/DevelConfig.php @@ -0,0 +1,38 @@ +getMethod() == 'GET' && isset($request->paymentProvider)) { + $provider = $request->paymentProvider; + } else { + $provider = $request->headers->get('X-TEST-PAYMENT-PROVIDER'); + } + + if (!empty($provider)) { + \config(['services.payment_provider' => $provider]); + } + } + + return $next($request); + } +} diff --git a/src/app/Providers/Payment/Mollie.php b/src/app/Providers/Payment/Mollie.php new file mode 100644 --- /dev/null +++ b/src/app/Providers/Payment/Mollie.php @@ -0,0 +1,349 @@ + tag + */ + public function customerLink(Wallet $wallet): ?string + { + $customer_id = self::mollieCustomerId($wallet); + + return sprintf( + '%s', + $customer_id, + $customer_id + ); + } + + /** + * Create a new auto-payment mandate for a wallet. + * + * @param \App\Wallet $wallet The wallet + * @param array $payment Payment data: + * - amount: Value in cents + * - currency: The operation currency + * - description: Operation desc. + * + * @return array Provider payment data: + * - id: Operation identifier + * - redirectUrl: the location to redirect to + */ + public function createMandate(Wallet $wallet, array $payment): ?array + { + // Register the user in Mollie, if not yet done + $customer_id = self::mollieCustomerId($wallet); + + $request = [ + 'amount' => [ + 'currency' => $payment['currency'], + 'value' => '0.00', + ], + 'customerId' => $customer_id, + 'sequenceType' => 'first', + 'description' => $payment['description'], + 'webhookUrl' => Utils::serviceUrl('/api/webhooks/payment/mollie'), + 'redirectUrl' => \url('/wallet'), + 'locale' => 'en_US', + // 'method' => 'creditcard', + ]; + + // Create the payment in Mollie + $response = mollie()->payments()->create($request); + + if ($response->mandateId) { + $wallet->setSetting('mollie_mandate_id', $response->mandateId); + } + + return [ + 'id' => $response->id, + 'redirectUrl' => $response->getCheckoutUrl(), + ]; + } + + /** + * Revoke the auto-payment mandate for the wallet. + * + * @param \App\Wallet $wallet The wallet + * + * @return bool True on success, False on failure + */ + public function deleteMandate(Wallet $wallet): bool + { + // Get the Mandate info + $mandate = self::mollieMandate($wallet); + + // Revoke the mandate on Mollie + if ($mandate) { + $mandate->revoke(); + + $wallet->setSetting('mollie_mandate_id', null); + } + + return true; + } + + /** + * Get a auto-payment mandate for the wallet. + * + * @param \App\Wallet $wallet The wallet + * + * @return array|null Mandate information: + * - id: Mandate identifier + * - method: user-friendly payment method desc. + * - isPending: the process didn't complete yet + * - isValid: the mandate is valid + */ + public function getMandate(Wallet $wallet): ?array + { + // Get the Mandate info + $mandate = self::mollieMandate($wallet); + + if (empty($mandate)) { + return null; + } + + $result = [ + 'id' => $mandate->id, + 'isPending' => $mandate->isPending(), + 'isValid' => $mandate->isValid(), + ]; + + $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; + } + + /** + * Get a provider name + * + * @return string Provider name + */ + public function name(): string + { + return 'mollie'; + } + + /** + * Create a new payment. + * + * @param \App\Wallet $wallet The wallet + * @param array $payment Payment data: + * - amount: Value in cents + * - currency: The operation currency + * - type: oneoff/recurring + * - description: Operation desc. + * + * @return array Provider payment data: + * - id: Operation identifier + * - redirectUrl: the location to redirect to + */ + public function payment(Wallet $wallet, array $payment): ?array + { + // Register the user in Mollie, if not yet done + $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', + ]; + + if ($payment['type'] == self::TYPE_RECURRING) { + // Check if there's a valid mandate + $mandate = self::mollieMandate($wallet); + + if (empty($mandate) || !$mandate->isValid() || $mandate->isPending()) { + return null; + } + + $request['mandateId'] = $mandate->id; + } else { + // required for non-recurring payments + $request['redirectUrl'] = \url('/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) + } + + // Create the payment in Mollie + $response = mollie()->payments()->create($request); + + // Store the payment reference in database + $payment['status'] = $response->status; + $payment['id'] = $response->id; + + self::storePayment($payment, $wallet->id); + + return [ + 'id' => $payment['id'], + 'redirectUrl' => $response->getCheckoutUrl(), + ]; + } + + /** + * Update payment status (and balance). + * + * @return int HTTP response code + */ + public function webhook(): int + { + $payment_id = \request()->input('id'); + + if (empty($payment_id)) { + return 200; + } + + $payment = Payment::find($payment_id); + + if (empty($payment)) { + // Mollie recommends to return "200 OK" even if the payment does not exist + return 200; + } + + // Get the payment details from Mollie + $mollie_payment = mollie()->payments()->get($payment_id); + + if (empty($mollie_payment)) { + // Mollie recommends to return "200 OK" even if the payment does not exist + return 200; + } + + if ($mollie_payment->isPaid()) { + if (!$mollie_payment->hasRefunds() && !$mollie_payment->hasChargebacks()) { + // 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); + } + } elseif ($mollie_payment->hasRefunds()) { + // The payment has been (partially) refunded. + // The status of the payment is still "paid" + // TODO: Update balance + } elseif ($mollie_payment->hasChargebacks()) { + // The payment has been (partially) charged back. + // The status of the payment is still "paid" + // TODO: Update balance + } + } 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)); + } + + // 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) { + $payment->status = $mollie_payment->status; + $payment->save(); + } + + return 200; + } + + /** + * Get Mollie customer identifier for specified wallet. + * Create one if does not exist yet. + * + * @param \App\Wallet $wallet The wallet + * + * @return string Mollie customer identifier + */ + protected static function mollieCustomerId(Wallet $wallet): string + { + $customer_id = $wallet->getSetting('mollie_id'); + + // Register the user in Mollie + if (empty($customer_id)) { + $customer = mollie()->customers()->create([ + 'name' => $wallet->owner->name(), + 'email' => $wallet->id . '@private.' . \config('app.domain'), + ]); + + $customer_id = $customer->id; + + $wallet->setSetting('mollie_id', $customer->id); + } + + return $customer_id; + } + + /** + * Get the active Mollie auto-payment mandate + */ + protected static function mollieMandate(Wallet $wallet) + { + $customer_id = $wallet->getSetting('mollie_id'); + $mandate_id = $wallet->getSetting('mollie_mandate_id'); + + // Get the manadate reference we already have + if ($customer_id && $mandate_id) { + $mandate = mollie()->mandates()->getForId($customer_id, $mandate_id); + if ($mandate) {// && ($mandate->isValid() || $mandate->isPending())) { + return $mandate; + } + } + + // Get all mandates from Mollie and find the active one + /* + foreach ($customer->mandates() as $mandate) { + if ($mandate->isValid() || $mandate->isPending()) { + $wallet->setSetting('mollie_mandate_id', $mandate->id); + return $mandate; + } + } + */ + } +} diff --git a/src/app/Providers/Payment/Stripe.php b/src/app/Providers/Payment/Stripe.php new file mode 100644 --- /dev/null +++ b/src/app/Providers/Payment/Stripe.php @@ -0,0 +1,381 @@ + tag + */ + public function customerLink(Wallet $wallet): ?string + { + $customer_id = self::stripeCustomerId($wallet); + + $location = 'https://dashboard.stripe.com'; + + $key = \config('services.stripe.key'); + + if (strpos($key, 'sk_test_') === 0) { + $location .= '/test'; + } + + return sprintf( + '%s', + $location, + $customer_id, + $customer_id + ); + } + + /** + * Create a new auto-payment mandate for a wallet. + * + * @param \App\Wallet $wallet The wallet + * @param array $payment Payment data: + * - amount: Value in cents + * - currency: The operation currency + * - description: Operation desc. + * + * @return array Provider payment/session data: + * - id: Session identifier + */ + public function createMandate(Wallet $wallet, array $payment): ?array + { + // Register the user in Stripe, if not yet done + $customer_id = self::stripeCustomerId($wallet); + + $request = [ + 'customer' => $customer_id, + 'cancel_url' => \url('/wallet'), // required + 'success_url' => \url('/wallet'), // required + 'payment_method_types' => ['card'], // required + 'locale' => 'en', + 'mode' => 'setup', + ]; + + $session = StripeAPI\Checkout\Session::create($request); + + return [ + 'id' => $session->id, + ]; + } + + /** + * Revoke the auto-payment mandate. + * + * @param \App\Wallet $wallet The wallet + * + * @return bool True on success, False on failure + */ + public function deleteMandate(Wallet $wallet): bool + { + // Get the Mandate info + $mandate = self::stripeMandate($wallet); + + if ($mandate) { + // Remove the reference + $wallet->setSetting('stripe_mandate_id', null); + + // Detach the payment method on Stripe + $pm = StripeAPI\PaymentMethod::retrieve($mandate->payment_method); + $pm->detach(); + } + + return true; + } + + /** + * Get a auto-payment mandate for a wallet. + * + * @param \App\Wallet $wallet The wallet + * + * @return array|null Mandate information: + * - id: Mandate identifier + * - method: user-friendly payment method desc. + * - isPending: the process didn't complete yet + * - isValid: the mandate is valid + */ + public function getMandate(Wallet $wallet): ?array + { + // Get the Mandate info + $mandate = self::stripeMandate($wallet); + + if (empty($mandate)) { + return null; + } + + $pm = StripeAPI\PaymentMethod::retrieve($mandate->payment_method); + + $result = [ + 'id' => $mandate->id, + 'isPending' => $mandate->status != 'succeeded' && $mandate->status != 'canceled', + 'isValid' => $mandate->status == 'succeeded', + ]; + + 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; + } + + /** + * Get a provider name + * + * @return string Provider name + */ + public function name(): string + { + return 'stripe'; + } + + /** + * Create a new payment. + * + * @param \App\Wallet $wallet The wallet + * @param array $payment Payment data: + * - amount: Value in cents + * - currency: The operation currency + * - type: first/oneoff/recurring + * - description: Operation desc. + * + * @return array Provider payment/session data: + * - id: Session identifier + */ + public function payment(Wallet $wallet, array $payment): ?array + { + if ($payment['type'] == self::TYPE_RECURRING) { + return $this->paymentRecurring($wallet, $payment); + } + + // Register the user in Stripe, if not yet done + $customer_id = self::stripeCustomerId($wallet); + + $request = [ + 'customer' => $customer_id, + 'cancel_url' => \url('/wallet'), // required + 'success_url' => \url('/wallet'), // required + 'payment_method_types' => ['card'], // required + 'locale' => 'en', + 'line_items' => [ + [ + 'name' => $payment['description'], + 'amount' => $payment['amount'], + 'currency' => \strtolower($payment['currency']), + 'quantity' => 1, + ] + ] + ]; + + $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); + + return [ + 'id' => $session->id, + ]; + } + + /** + * 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: Session identifier + */ + protected function paymentRecurring(Wallet $wallet, array $payment): ?array + { + // Check if there's a valid mandate + $mandate = self::stripeMandate($wallet); + + if (empty($mandate)) { + return null; + } + + $request = [ + '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, + ]; + + $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); + + return [ + 'id' => $payment['id'], + ]; + } + + /** + * Update payment status (and balance). + * + * @return int HTTP response code + */ + public function webhook(): int + { + $payload = file_get_contents('php://input'); + $sig_header = $_SERVER['HTTP_STRIPE_SIGNATURE']; + + // Parse and validate the input + try { + $event = StripeAPI\Webhook::constructEvent( + $payload, + $sig_header, + \config('services.stripe.webhook_secret') + ); + } catch (\UnexpectedValueException $e) { + // Invalid payload + return 400; + } + + switch ($event->type) { + case StripeAPI\Event::PAYMENT_INTENT_CANCELED: + case StripeAPI\Event::PAYMENT_INTENT_PAYMENT_FAILED: + case StripeAPI\Event::PAYMENT_INTENT_SUCCEEDED: + $intent = $event->data->object; // @phpstan-ignore-line + $payment = Payment::find($intent->id); + + switch ($intent->status) { + case StripeAPI\PaymentIntent::STATUS_CANCELED: + $status = self::STATUS_CANCELED; + break; + case StripeAPI\PaymentIntent::STATUS_SUCCEEDED: + $status = self::STATUS_PAID; + break; + default: + $status = self::STATUS_PENDING; + } + + if ($status == self::STATUS_PAID) { + // Update the balance, if it wasn't already + if ($payment->status != self::STATUS_PAID) { + $payment->wallet->credit($payment->amount); + } + } elseif (!empty($intent->last_payment_error)) { + // See https://stripe.com/docs/error-codes for more info + \Log::info(sprintf( + 'Stripe payment failed (%s): %s', + $payment->id, + json_encode($intent->last_payment_error) + )); + } + + if ($payment->status != self::STATUS_PAID) { + $payment->status = $status; + $payment->save(); + } + + break; + + case StripeAPI\Event::SETUP_INTENT_SUCCEEDED: + $intent = $event->data->object; // @phpstan-ignore-line + + // 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 ($setting) { + $setting->wallet->setSetting('stripe_mandate_id', $intent->id); + } + + break; + } + + return 200; + } + + /** + * Get Stripe customer identifier for specified wallet. + * Create one if does not exist yet. + * + * @param \App\Wallet $wallet The wallet + * + * @return string Stripe customer identifier + */ + protected static function stripeCustomerId(Wallet $wallet): string + { + $customer_id = $wallet->getSetting('stripe_id'); + + // Register the user in Stripe + if (empty($customer_id)) { + $customer = StripeAPI\Customer::create([ + 'name' => $wallet->owner->name(), + // Stripe will display the email on Checkout page, editable, + // and use it to send the receipt (?), use the user email here + // 'email' => $wallet->id . '@private.' . \config('app.domain'), + 'email' => $wallet->owner->email, + ]); + + $customer_id = $customer->id; + + $wallet->setSetting('stripe_id', $customer->id); + } + + return $customer_id; + } + + /** + * Get the active Stripe auto-payment mandate (Setup Intent) + */ + protected static function stripeMandate(Wallet $wallet) + { + // Note: Stripe also has 'Mandate' objects, but we do not use these + + if ($mandate_id = $wallet->getSetting('stripe_mandate_id')) { + $mandate = StripeAPI\SetupIntent::retrieve($mandate_id); + // @phpstan-ignore-next-line + if ($mandate && $mandate->status != 'canceled') { + return $mandate; + } + } + } +} diff --git a/src/app/Providers/PaymentProvider.php b/src/app/Providers/PaymentProvider.php new file mode 100644 --- /dev/null +++ b/src/app/Providers/PaymentProvider.php @@ -0,0 +1,149 @@ +getSetting('stripe_id')) { + $provider = 'stripe'; + } elseif ($provider_or_wallet->getSetting('mollie_id')) { + $provider = 'mollie'; + } + } else { + $provider = $provider_or_wallet; + } + + if (empty($provider)) { + $provider = \config('services.payment_provider') ?: 'mollie'; + } + + switch (\strtolower($provider)) { + case 'stripe': + return new \App\Providers\Payment\Stripe(); + + case 'mollie': + return new \App\Providers\Payment\Mollie(); + + default: + throw new \Exception("Invalid payment provider: {$provider}"); + } + } + + /** + * Create a new auto-payment mandate for a wallet. + * + * @param \App\Wallet $wallet The wallet + * @param array $payment Payment data: + * - amount: Value in cents + * - currency: The operation currency + * - description: Operation desc. + * + * @return array Provider payment data: + * - id: Operation identifier + * - redirectUrl: the location to redirect to + */ + abstract public function createMandate(Wallet $wallet, array $payment): ?array; + + /** + * Revoke the auto-payment mandate for a wallet. + * + * @param \App\Wallet $wallet The wallet + * + * @return bool True on success, False on failure + */ + abstract public function deleteMandate(Wallet $wallet): bool; + + /** + * Get a auto-payment mandate for a wallet. + * + * @param \App\Wallet $wallet The wallet + * + * @return array|null Mandate information: + * - id: Mandate identifier + * - method: user-friendly payment method desc. + * - isPending: the process didn't complete yet + * - isValid: the mandate is valid + */ + abstract public function getMandate(Wallet $wallet): ?array; + + /** + * Get a link to the customer in the provider's control panel + * + * @param \App\Wallet $wallet The wallet + * + * @return string|null The string representing tag + */ + abstract public function customerLink(Wallet $wallet): ?string; + + /** + * Get a provider name + * + * @return string Provider name + */ + abstract public function name(): string; + + /** + * Create a new payment. + * + * @param \App\Wallet $wallet The wallet + * @param array $payment Payment data: + * - amount: Value in cents + * - currency: The operation currency + * - type: first/oneoff/recurring + * - description: Operation description + * + * @return array Provider payment/session data: + * - id: Operation identifier + * - redirectUrl + */ + abstract public function payment(Wallet $wallet, array $payment): ?array; + + /** + * Update payment status (and balance). + * + * @return int HTTP response code + */ + abstract public function webhook(): int; + + /** + * Create a payment record in DB + * + * @param array $payment Payment information + * @param string $wallet_id Wallet ID + */ + protected static function storePayment(array $payment, $wallet_id): void + { + $db_payment = new Payment(); + $db_payment->id = $payment['id']; + $db_payment->description = $payment['description']; + $db_payment->status = $payment['status']; + $db_payment->amount = $payment['amount']; + $db_payment->wallet_id = $wallet_id; + $db_payment->save(); + } +} diff --git a/src/app/Traits/UserSettingsTrait.php b/src/app/Traits/SettingsTrait.php rename from src/app/Traits/UserSettingsTrait.php rename to src/app/Traits/SettingsTrait.php --- a/src/app/Traits/UserSettingsTrait.php +++ b/src/app/Traits/SettingsTrait.php @@ -2,10 +2,9 @@ namespace App\Traits; -use App\UserSetting; use Illuminate\Support\Facades\Cache; -trait UserSettingsTrait +trait SettingsTrait { /** * Obtain the value for a setting. @@ -17,9 +16,9 @@ * $locale = $user->getSetting('locale'); * ``` * - * @param string $key Lookup key + * @param string $key Setting name * - * @return string|null + * @return string|null Setting value */ public function getSetting(string $key) { @@ -35,6 +34,25 @@ } /** + * Remove a setting. + * + * Example Usage: + * + * ```php + * $user = User::firstOrCreate(['email' => 'some@other.erg']); + * $user->removeSetting('locale'); + * ``` + * + * @param string $key Setting name + * + * @return void + */ + public function removeSetting(string $key): void + { + $this->setSetting($key, null); + } + + /** * Create or update a setting. * * Example Usage: @@ -49,7 +67,7 @@ * * @return void */ - public function setSetting(string $key, $value) + public function setSetting(string $key, $value): void { $this->storeSetting($key, $value); $this->setCache(); @@ -69,7 +87,7 @@ * * @return void */ - public function setSettings(array $data = []) + public function setSettings(array $data = []): void { foreach ($data as $key => $value) { $this->storeSetting($key, $value); @@ -81,12 +99,13 @@ private function storeSetting(string $key, $value): void { if ($value === null || $value === '') { - if ($setting = UserSetting::where(['user_id' => $this->id, 'key' => $key])->first()) { + // Note: We're selecting the record first, so observers can act + if ($setting = $this->settings()->where('key', $key)->first()) { $setting->delete(); } } else { - UserSetting::updateOrCreate( - ['user_id' => $this->id, 'key' => $key], + $this->settings()->updateOrCreate( + ['key' => $key], ['value' => $value] ); } @@ -94,8 +113,10 @@ private function getCache() { - if (Cache::has('user_settings_' . $this->id)) { - return Cache::get('user_settings_' . $this->id); + $model = \strtolower(get_class($this)); + + if (Cache::has("{$model}_settings_{$this->id}")) { + return Cache::get("{$model}_settings_{$this->id}"); } return $this->setCache(); @@ -103,8 +124,10 @@ private function setCache() { - if (Cache::has('user_settings_' . $this->id)) { - Cache::forget('user_settings_' . $this->id); + $model = \strtolower(get_class($this)); + + if (Cache::has("{$model}_settings_{$this->id}")) { + Cache::forget("{$model}_settings_{$this->id}"); } $cached = []; @@ -114,7 +137,7 @@ } } - Cache::forever('user_settings_' . $this->id, $cached); + Cache::forever("{$model}_settings_{$this->id}", $cached); return $this->getCache(); } diff --git a/src/app/User.php b/src/app/User.php --- a/src/app/User.php +++ b/src/app/User.php @@ -5,7 +5,7 @@ use App\Entitlement; use App\UserAlias; use App\Traits\UserAliasesTrait; -use App\Traits\UserSettingsTrait; +use App\Traits\SettingsTrait; use App\Wallet; use Illuminate\Notifications\Notifiable; use Illuminate\Database\Eloquent\SoftDeletes; @@ -26,7 +26,7 @@ use Notifiable; use NullableFields; use UserAliasesTrait; - use UserSettingsTrait; + use SettingsTrait; use SoftDeletes; // a new user, default on creation diff --git a/src/app/Utils.php b/src/app/Utils.php --- a/src/app/Utils.php +++ b/src/app/Utils.php @@ -98,10 +98,34 @@ $isAdmin = strpos(request()->getHttpHost(), 'admin.') === 0; $env['jsapp'] = $isAdmin ? 'admin.js' : 'user.js'; + $env['paymentProvider'] = \config('services.payment_provider'); + $env['stripePK'] = \config('services.stripe.public_key'); + return $env; } /** + * Create self URL + * + * @param string $route Route/Path + * + * @return string Full URL + */ + public static function serviceUrl(string $route): string + { + $url = \url($route); + + $app_url = trim(\config('app.url'), '/'); + $pub_url = trim(\config('app.public_url'), '/'); + + if ($pub_url != $app_url) { + $url = str_replace($app_url, $pub_url, $url); + } + + return $url; + } + + /** * Email address (login or alias) validation * * @param string $email Email address diff --git a/src/app/Wallet.php b/src/app/Wallet.php --- a/src/app/Wallet.php +++ b/src/app/Wallet.php @@ -3,6 +3,7 @@ namespace App; use App\User; +use App\Traits\SettingsTrait; use Carbon\Carbon; use Iatstuti\Database\Support\NullableFields; use Illuminate\Database\Eloquent\Model; @@ -17,6 +18,7 @@ class Wallet extends Model { use NullableFields; + use SettingsTrait; public $incrementing = false; protected $keyType = 'string'; @@ -208,4 +210,14 @@ { return $this->hasMany('App\Payment'); } + + /** + * Any (additional) properties of this wallet. + * + * @return \Illuminate\Database\Eloquent\Relations\HasMany + */ + public function settings() + { + return $this->hasMany('App\WalletSetting'); + } } diff --git a/src/app/WalletSetting.php b/src/app/WalletSetting.php new file mode 100644 --- /dev/null +++ b/src/app/WalletSetting.php @@ -0,0 +1,34 @@ +belongsTo( + '\App\Wallet', + 'wallet_id', /* local */ + 'id' /* remote */ + ); + } +} diff --git a/src/composer.json b/src/composer.json --- a/src/composer.json +++ b/src/composer.json @@ -27,6 +27,7 @@ "silviolleite/laravelpwa": "^1.0", "spatie/laravel-translatable": "^4.2", "spomky-labs/otphp": "~4.0.0", + "stripe/stripe-php": "^7.29", "swooletw/laravel-swoole": "^2.6", "torann/currency": "^1.0", "torann/geoip": "^1.0", diff --git a/src/config/services.php b/src/config/services.php --- a/src/config/services.php +++ b/src/config/services.php @@ -34,4 +34,16 @@ 'secret' => env('SPARKPOST_SECRET'), ], + 'payment_provider' => env('PAYMENT_PROVIDER', 'mollie'), + + 'mollie' => [ + 'key' => env('MOLLIE_KEY'), + ], + + 'stripe' => [ + 'key' => env('STRIPE_KEY'), + 'public_key' => env('STRIPE_PUBLIC_KEY'), + 'webhook_secret' => env('STRIPE_WEBHOOK_SECRET'), + ], + ]; 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 @@ -16,7 +16,7 @@ Schema::create( 'payments', function (Blueprint $table) { - $table->string('id', 16)->primary(); + $table->string('id', 32)->primary(); $table->string('wallet_id', 36); $table->string('status', 16); $table->integer('amount'); diff --git a/src/database/migrations/2020_03_16_100000_create_payments.php b/src/database/migrations/2020_04_21_100000_create_wallet_settings.php copy from src/database/migrations/2020_03_16_100000_create_payments.php copy to src/database/migrations/2020_04_21_100000_create_wallet_settings.php --- a/src/database/migrations/2020_03_16_100000_create_payments.php +++ b/src/database/migrations/2020_04_21_100000_create_wallet_settings.php @@ -4,7 +4,7 @@ use Illuminate\Database\Schema\Blueprint; use Illuminate\Database\Migrations\Migration; -class CreatePayments extends Migration +class CreateWalletSettings extends Migration { /** * Run the migrations. @@ -14,16 +14,16 @@ public function up() { Schema::create( - 'payments', + 'wallet_settings', function (Blueprint $table) { - $table->string('id', 16)->primary(); - $table->string('wallet_id', 36); - $table->string('status', 16); - $table->integer('amount'); - $table->text('description'); + $table->bigIncrements('id'); + $table->string('wallet_id'); + $table->string('key'); + $table->string('value'); $table->timestamps(); $table->foreign('wallet_id')->references('id')->on('wallets')->onDelete('cascade'); + $table->unique(['wallet_id', 'key']); } ); } @@ -35,6 +35,6 @@ */ public function down() { - Schema::dropIfExists('payments'); + Schema::dropIfExists('wallet_settings'); } } diff --git a/src/resources/js/app.js b/src/resources/js/app.js --- a/src/resources/js/app.js +++ b/src/resources/js/app.js @@ -10,6 +10,8 @@ import MenuComponent from '../vue/Widgets/Menu' import store from './store' +const loader = '
Loading
' + const app = new Vue({ el: '#app', components: { @@ -67,13 +69,20 @@ delete axios.defaults.headers.common.Authorization this.$router.push({ name: 'login' }) }, - // Display "loading" overlay (to be used by route components) + // Display "loading" overlay inside of the specified element + addLoader(elem) { + $(elem).css({position: 'relative'}).append($(loader).addClass('small')) + }, + // Remove loader element added in addLoader() + removeLoader(elem) { + $(elem).find('.app-loader').remove() + }, startLoading() { this.isLoading = true // Lock the UI with the 'loading...' element let loading = $('#app > .app-loader').show() if (!loading.length) { - $('#app').append($('
Loading
')) + $('#app').append($(loader)) } }, // Hide "loading" overlay @@ -193,6 +202,21 @@ } }) +// Add a axios request interceptor +window.axios.interceptors.request.use( + config => { + // This is the only way I found to change configuration options + // on a running application. We need this for browser testing. + config.headers['X-Test-Payment-Provider'] = window.config.paymentProvider + + return config + }, + error => { + // Do something with request error + return Promise.reject(error) + } +) + // Add a axios response interceptor for general/validation error handler window.axios.interceptors.response.use( response => { @@ -212,7 +236,7 @@ form = $(form) $.each(error.response.data.errors || {}, (idx, msg) => { - const input_name = (form.data('validation-prefix') || '') + idx + const input_name = (form.data('validation-prefix') || form.find('form').first().data('validation-prefix') || '') + idx let input = form.find('#' + input_name) if (!input.length) { diff --git a/src/resources/js/fontawesome.js b/src/resources/js/fontawesome.js --- a/src/resources/js/fontawesome.js +++ b/src/resources/js/fontawesome.js @@ -4,6 +4,7 @@ //import { } from '@fortawesome/free-brands-svg-icons' import { faCheckSquare, + faCreditCard, faSquare, } from '@fortawesome/free-regular-svg-icons' @@ -31,6 +32,7 @@ faCheck, faCheckCircle, faCheckSquare, + faCreditCard, faExclamationCircle, faGlobe, faInfoCircle, diff --git a/src/resources/lang/en/app.php b/src/resources/lang/en/app.php --- a/src/resources/lang/en/app.php +++ b/src/resources/lang/en/app.php @@ -10,6 +10,9 @@ | The following language lines are used in the application. */ + 'mandate-delete-success' => 'The auto-payment has been removed.', + 'mandate-update-success' => 'The auto-payment has been updated.', + 'planbutton' => 'Choose :plan', 'process-user-new' => 'Registering a user...', 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 @@ -117,7 +117,6 @@ 'url' => 'The :attribute format is invalid.', 'uuid' => 'The :attribute must be a valid UUID.', - '2fareq' => 'Second factor code is required.', '2fainvalid' => 'Second factor code is invalid.', 'emailinvalid' => 'The specified email address is invalid.', @@ -133,6 +132,7 @@ 'noextemail' => 'This user has no external email address.', 'entryinvalid' => 'The specified :attribute is invalid.', 'entryexists' => 'The specified :attribute is not available.', + 'minamount' => 'Minimum amount for a single payment is :amount.', /* |-------------------------------------------------------------------------- diff --git a/src/resources/sass/app.scss b/src/resources/sass/app.scss --- a/src/resources/sass/app.scss +++ b/src/resources/sass/app.scss @@ -84,6 +84,12 @@ border-width: 15px; color: #b2aa99; } + + &.small .spinner-border { + width: 25px; + height: 25px; + border-width: 3px; + } } pre { @@ -215,6 +221,27 @@ } } +.form-separator { + position: relative; + margin: 1em 0; + display: flex; + justify-content: center; + + hr { + border-color: #999; + margin: 0; + position: absolute; + top: .75em; + width: 100%; + } + + span { + background: #fff; + padding: 0 1em; + z-index: 1; + } +} + // Bootstrap style fix .btn-link { border: 0; diff --git a/src/resources/sass/forms.scss b/src/resources/sass/forms.scss --- a/src/resources/sass/forms.scss +++ b/src/resources/sass/forms.scss @@ -32,3 +32,13 @@ margin-right: 0.5em; } } + +.form-control-plaintext .btn-sm { + margin-top: -0.25rem; +} + +form.read-only { + .row { + margin-bottom: 0; + } +} 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 @@ -4,8 +4,8 @@
{{ user.email }}
-
-
+ +
@@ -13,7 +13,7 @@
-
+
@@ -21,7 +21,7 @@
-
+
@@ -29,31 +29,31 @@
-
+
{{ user.first_name }}
-
+
{{ user.last_name }}
-
+
{{ user.organization }}
-
+
{{ user.phone }}
-
+
@@ -62,13 +62,13 @@
-
+
{{ user.billing_address }}
-
+
{{ user.country }} @@ -108,18 +108,34 @@
-
Account balance {{ $root.price(balance) }}
+
Account balance {{ $root.price(wallet.balance) }}
- -
- -
+ +
+ +
- {{ wallet_discount ? (wallet_discount + '% - ' + wallet_discount_description) : 'none' }} + {{ wallet.discount ? (wallet.discount + '% - ' + wallet.discount_description) : 'none' }}
+
+ +
+ + Fill up by {{ wallet.mandate.amount }} CHF + when under {{ wallet.mandate.balance }} CHF + using {{ wallet.mandate.method }}. + +
+
+
+ +
+ +
+
@@ -241,7 +257,7 @@
+
+
+ + @@ -18,7 +110,14 @@ export default { data() { return { + amount: '', balance: 0, + mandate: { amount: 10, balance: 0 }, + paymentDialogTitle: null, + paymentForm: 'init', + provider: window.config.paymentProvider, + stripe: null, + wallet_currency: 'CHF' } }, mounted() { @@ -26,16 +125,123 @@ // TODO: currencies, multi-wallets, accounts this.$store.state.authInfo.wallets.forEach(wallet => { this.balance += wallet.balance + this.provider = wallet.provider }) + + if (this.provider == 'stripe') { + this.stripeInit() + } }, methods: { + paymentDialog() { + const dialog = $('#payment-dialog') + const mandate_form = $('#mandate-form') + + this.$root.removeLoader(mandate_form) + + if (!this.mandate.id) { + this.$root.addLoader(mandate_form) + axios.get('/api/v4/payments/mandate') + .then(response => { + this.$root.removeLoader(mandate_form) + this.mandate = response.data + }) + .catch(error => { + this.$root.removeLoader(mandate_form) + }) + } + + this.paymentForm = 'init' + this.paymentDialogTitle = 'Top up your wallet' + + this.dialog = dialog.on('shown.bs.modal', () => { + dialog.find('#amount').focus() + }).modal() + }, payment() { - axios.post('/api/v4/payments', {amount: 1000}) + this.$root.clearFormValidation($('#payment-form')) + + axios.post('/api/v4/payments', {amount: this.amount}) + .then(response => { + if (response.data.redirectUrl) { + location.href = response.data.redirectUrl + } else { + this.stripeCheckout(response.data) + } + }) + }, + autoPayment() { + const method = this.mandate.id ? 'put' : 'post' + const post = { + amount: this.mandate.amount, + balance: this.mandate.balance + } + + this.$root.clearFormValidation($('#auto-payment form')) + + 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) + } else { + this.dialog.modal('hide') + if (response.data.status == 'success') { + this.$toast.success(response.data.message) + } } }) + }, + autoPaymentChange(event) { + this.autoPaymentForm(event, 'Update auto-payment') + }, + autoPaymentDelete() { + axios.delete('/api/v4/payments/mandate') + .then(response => { + this.mandate = { amount: 10, balance: 0 } + if (response.data.status == 'success') { + this.$toast.success(response.data.message) + } + }) + }, + autoPaymentForm(event, title) { + this.paymentForm = 'auto' + this.paymentDialogTitle = title || 'Add auto-payment' + setTimeout(() => { this.dialog.find('#mandate_amount').focus()}, 10) + }, + stripeInit() { + let script = $('#stripe-script') + + if (!script.length) { + script = document.createElement('script') + + script.onload = () => { + this.stripe = Stripe(window.config.stripePK) + } + + script.id = 'stripe-script' + script.src = 'https://js.stripe.com/v3/' + + document.getElementsByTagName('head')[0].appendChild(script) + } else { + this.stripe = Stripe(window.config.stripePK) + } + }, + stripeCheckout(data) { + if (!this.stripe) { + return + } + + this.stripe.redirectToCheckout({ + sessionId: data.id + }).then(result => { + // If it fails due to a browser or network error, + // display the localized error message to the user + if (result.error) { + this.$toast.error(result.error.message) + } + }) } } } diff --git a/src/routes/api.php b/src/routes/api.php --- a/src/routes/api.php +++ b/src/routes/api.php @@ -70,6 +70,10 @@ Route::apiResource('wallets', API\V4\WalletsController::class); Route::post('payments', 'API\V4\PaymentsController@store'); + Route::get('payments/mandate', 'API\V4\PaymentsController@mandate'); + Route::post('payments/mandate', 'API\V4\PaymentsController@mandateCreate'); + Route::put('payments/mandate', 'API\V4\PaymentsController@mandateUpdate'); + Route::delete('payments/mandate', 'API\V4\PaymentsController@mandateDelete'); } ); @@ -78,7 +82,7 @@ 'domain' => \config('app.domain'), ], function () { - Route::post('webhooks/payment/mollie', 'API\V4\PaymentsController@webhook'); + Route::post('webhooks/payment/{provider}', 'API\V4\PaymentsController@webhook'); } ); diff --git a/src/tests/Browser.php b/src/tests/Browser.php --- a/src/tests/Browser.php +++ b/src/tests/Browser.php @@ -28,7 +28,7 @@ } } - Assert::assertEquals($expected_count, $count, "Count of [$selector] elements is not $count"); + Assert::assertEquals($expected_count, $count, "Count of [$selector] elements is not $expected_count"); return $this; } 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 @@ -107,12 +107,16 @@ // Assert Finances tab $browser->assertSeeIn('@nav #tab-finances', 'Finances') ->with('@user-finances', function (Browser $browser) { - $browser->assertSeeIn('.card-title', 'Account balance') + $browser->waitUntilMissing('.app-loader') + ->assertSeeIn('.card-title', 'Account balance') ->assertSeeIn('.card-title .text-success', '0,00 CHF') ->with('form', function (Browser $browser) { - $browser->assertElementsCount('.row', 1) + $payment_provider = ucfirst(\config('services.payment_provider')); + $browser->assertElementsCount('.row', 2) ->assertSeeIn('.row:nth-child(1) label', 'Discount') - ->assertSeeIn('.row:nth-child(1) #discount span', 'none'); + ->assertSeeIn('.row:nth-child(1) #discount span', 'none') + ->assertSeeIn('.row:nth-child(2) label', $payment_provider . ' ID') + ->assertVisible('.row:nth-child(2) a'); }); }); @@ -211,10 +215,11 @@ // Assert Finances tab $browser->assertSeeIn('@nav #tab-finances', 'Finances') ->with('@user-finances', function (Browser $browser) { - $browser->assertSeeIn('.card-title', 'Account balance') + $browser->waitUntilMissing('.app-loader') + ->assertSeeIn('.card-title', 'Account balance') ->assertSeeIn('.card-title .text-danger', '-20,10 CHF') ->with('form', function (Browser $browser) { - $browser->assertElementsCount('.row', 1) + $browser->assertElementsCount('.row', 2) ->assertSeeIn('.row:nth-child(1) label', 'Discount') ->assertSeeIn('.row:nth-child(1) #discount span', '10% - Test voucher'); }); @@ -291,10 +296,11 @@ // Assert Finances tab $browser->assertSeeIn('@nav #tab-finances', 'Finances') ->with('@user-finances', function (Browser $browser) { - $browser->assertSeeIn('.card-title', 'Account balance') + $browser->waitUntilMissing('.app-loader') + ->assertSeeIn('.card-title', 'Account balance') ->assertSeeIn('.card-title .text-success', '0,00 CHF') ->with('form', function (Browser $browser) { - $browser->assertElementsCount('.row', 1) + $browser->assertElementsCount('.row', 2) ->assertSeeIn('.row:nth-child(1) label', 'Discount') ->assertSeeIn('.row:nth-child(1) #discount span', 'none'); }); @@ -412,6 +418,7 @@ $browser->visit(new UserPage($john->id)) ->pause(100) + ->waitUntilMissing('@user-finances .app-loader') ->click('@user-finances #discount button') // Test dialog content, and closing it with Cancel button ->with(new Dialog('#discount-dialog'), function (Browser $browser) { diff --git a/src/tests/Browser/Pages/DomainInfo.php b/src/tests/Browser/Pages/DomainInfo.php --- a/src/tests/Browser/Pages/DomainInfo.php +++ b/src/tests/Browser/Pages/DomainInfo.php @@ -25,8 +25,7 @@ */ public function assert($browser) { - $browser->waitUntilMissing('@app .app-loader') - ->assertPresent('@config,@verify'); + $browser->waitUntilMissing('@app .app-loader'); } /** 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 @@ -51,11 +51,17 @@ * @param string $username User name * @param string $password User password * @param bool $wait_for_dashboard + * @param array $config Client-site config * * @return void */ - public function submitLogon($browser, $username, $password, $wait_for_dashboard = false) - { + public function submitLogon( + $browser, + $username, + $password, + $wait_for_dashboard = false, + $config = [] + ) { $browser->type('@email-input', $username) ->type('@password-input', $password); @@ -64,6 +70,12 @@ $browser->type('@second-factor-input', $code); } + if (!empty($config)) { + $browser->script( + sprintf('Object.assign(window.config, %s)', \json_encode($config)) + ); + } + $browser->press('form button'); if ($wait_for_dashboard) { 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 @@ -43,4 +43,22 @@ '@status-table' => 'table.table--select-status', ]; } + + /** + * Submit payment form. + * + * @param \Laravel\Dusk\Browser $browser The browser object + * + * @return void + */ + public function submitValidCreditCard($browser) + { + if ($browser->element('@methods')) { + $browser->click('@methods button.grid-button-creditcard') + ->waitFor('button.form__button'); + } + + $browser->click('@status-table input[value="paid"]') + ->click('button.form__button'); + } } diff --git a/src/tests/Browser/Pages/PaymentStripe.php b/src/tests/Browser/Pages/PaymentStripe.php new file mode 100644 --- /dev/null +++ b/src/tests/Browser/Pages/PaymentStripe.php @@ -0,0 +1,67 @@ +waitFor('.App-Payment'); + } + + /** + * Get the element shortcuts for the page. + * + * @return array + */ + public function elements(): array + { + return [ + '@form' => '.App-Payment > form', + '@title' => '.App-Overview .ProductSummary-Info .Text', + '@amount' => '#ProductSummary-TotalAmount', + '@description' => '#ProductSummary-Description', + '@email-input' => '.App-Payment #email', + '@cardnumber-input' => '.App-Payment #cardNumber', + '@cardexpiry-input' => '.App-Payment #cardExpiry', + '@cardcvc-input' => '.App-Payment #cardCvc', + '@name-input' => '.App-Payment #billingName', + '@submit-button' => '.App-Payment form button.SubmitButton', + ]; + } + + /** + * Submit payment form. + * + * @param \Laravel\Dusk\Browser $browser The browser object + * + * @return void + */ + public function submitValidCreditCard($browser) + { + $browser->type('@name-input', 'Test') + ->type('@cardnumber-input', '4242424242424242') + ->type('@cardexpiry-input', '12/' . (date('y') + 1)) + ->type('@cardcvc-input', '123') + ->press('@submit-button'); + } +} diff --git a/src/tests/Browser/Pages/Wallet.php b/src/tests/Browser/Pages/Wallet.php --- a/src/tests/Browser/Pages/Wallet.php +++ b/src/tests/Browser/Pages/Wallet.php @@ -39,7 +39,8 @@ { return [ '@app' => '#app', - '@main' => '#wallet' + '@main' => '#wallet', + '@payment-dialog' => '#payment-dialog', ]; } } diff --git a/src/tests/Browser/PaymentMollieTest.php b/src/tests/Browser/PaymentMollieTest.php new file mode 100644 --- /dev/null +++ b/src/tests/Browser/PaymentMollieTest.php @@ -0,0 +1,212 @@ +deleteTestUser('payment-test@kolabnow.com'); + } + + /** + * {@inheritDoc} + */ + public function tearDown(): void + { + $this->deleteTestUser('payment-test@kolabnow.com'); + + parent::tearDown(); + } + + /** + * Test the payment process + * + * @group mollie + */ + public function testPayment(): void + { + $user = $this->getTestUser('payment-test@kolabnow.com', [ + 'password' => 'simple123', + ]); + + $this->browse(function (Browser $browser) { + $browser->visit(new Home()) + ->submitLogon('payment-test@kolabnow.com', 'simple123', true, ['paymentProvider' => 'mollie']) + ->on(new Dashboard()) + ->click('@links .link-wallet') + ->on(new WalletPage()) + ->assertSeeIn('@main button', 'Add credit') + ->click('@main button') + ->with(new Dialog('@payment-dialog'), function (Browser $browser) { + $browser->assertSeeIn('@title', 'Top up your wallet') + ->assertFocused('#amount') + ->assertSeeIn('@button-cancel', 'Cancel') + ->assertSeeIn('@body #payment-form button', 'Continue') + // Test error handling + ->type('@body #amount', 'aaa') + ->click('@body #payment-form button') + ->assertToast(Toast::TYPE_ERROR, 'Form validation error') + ->assertSeeIn('#amount + span + .invalid-feedback', 'The amount must be a number.') + // Submit valid data + ->type('@body #amount', '12.34') + ->click('@body #payment-form button'); + }) + ->on(new PaymentMollie()) + ->assertSeeIn('@title', \config('app.name') . ' Payment') + ->assertSeeIn('@amount', 'CHF 12.34'); + + // Looks like the Mollie testing mode is limited. + // We'll select credit card method and mark the payment as paid + // We can't do much more, we have to trust Mollie their page works ;) + + // For some reason I don't get the method selection form, it + // immediately jumps to the next step. Let's detect that + if ($browser->element('@methods')) { + $browser->click('@methods button.grid-button-creditcard') + ->waitFor('button.form__button'); + } + + $browser->click('@status-table input[value="paid"]') + ->click('button.form__button'); + + // Now it should redirect back to wallet page and in background + // use the webhook to update payment status (and balance). + + // Looks like in test-mode the webhook is executed before redirect + // so we can expect balance updated on the wallet page + + $browser->waitForLocation('/wallet') + ->on(new WalletPage()) + ->assertSeeIn('@main .card-text', 'Current account balance is 12,34 CHF'); + }); + } + + /** + * Test the auto-payment setup process + * + * @group mollie + */ + public function testAutoPaymentSetup(): void + { + $user = $this->getTestUser('payment-test@kolabnow.com', [ + 'password' => 'simple123', + ]); + + $this->browse(function (Browser $browser) { + $browser->visit(new Home()) + ->submitLogon('payment-test@kolabnow.com', 'simple123', true, ['paymentProvider' => 'mollie']) + ->on(new Dashboard()) + ->click('@links .link-wallet') + ->on(new WalletPage()) + ->click('@main button') + ->with(new Dialog('@payment-dialog'), function (Browser $browser) { + $browser->assertSeeIn('@title', 'Top up your wallet') + ->assertSeeIn('@button-cancel', 'Cancel') + ->assertSeeIn('@body #mandate-form button', 'Set up auto-payment') + ->click('@body #mandate-form button') + ->assertSeeIn('@title', 'Add auto-payment') + ->assertSeeIn('@body label[for="mandate_amount"]', 'Fill up by') + ->assertValue('@body #mandate_amount', PaymentProvider::MIN_AMOUNT / 100) + ->assertSeeIn('@body label[for="mandate_balance"]', 'when account balance is below') // phpcs:ignore + ->assertValue('@body #mandate_balance', '0') + ->assertSeeIn('@button-cancel', 'Cancel') + ->assertSeeIn('@button-action', 'Continue') + // Test error handling + ->type('@body #mandate_amount', 'aaa') + ->type('@body #mandate_balance', '-1') + ->click('@button-action') + ->assertToast(Toast::TYPE_ERROR, 'Form validation error') + ->assertVisible('@body #mandate_amount.is-invalid') + ->assertVisible('@body #mandate_balance.is-invalid') + ->assertSeeIn('#mandate_amount + span + .invalid-feedback', 'The amount must be a number.') + ->assertSeeIn('#mandate_balance + span + .invalid-feedback', 'The balance must be at least 0.') + ->type('@body #mandate_amount', 'aaa') + ->type('@body #mandate_balance', '0') + ->click('@button-action') + ->assertToast(Toast::TYPE_ERROR, 'Form validation error') + ->assertVisible('@body #mandate_amount.is-invalid') + ->assertMissing('@body #mandate_balance.is-invalid') + ->assertSeeIn('#mandate_amount + span + .invalid-feedback', 'The amount must be a number.') + ->assertMissing('#mandate_balance + span + .invalid-feedback') + // Submit valid data + ->type('@body #mandate_amount', '100') + ->type('@body #mandate_balance', '0') + ->click('@button-action'); + }) + ->on(new PaymentMollie()) + ->assertSeeIn('@title', \config('app.name') . ' Auto-Payment Setup') + ->assertMissing('@amount') + ->submitValidCreditCard() + ->waitForLocation('/wallet') + ->visit('/wallet?paymentProvider=mollie') + ->on(new WalletPage()) + ->click('@main button') + ->with(new Dialog('@payment-dialog'), function (Browser $browser) { + $expected = 'Auto-payment is set to fill up your account by 100 CHF every' + . ' time your account balance gets under 0 CHF. You will be charged' + . ' via Mastercard (**** **** **** 6787).'; + + $browser->assertSeeIn('@title', 'Top up your wallet') + ->waitFor('#mandate-info') + ->assertSeeIn('#mandate-info p:first-child', $expected) + ->click('@button-cancel'); + }); + }); + + // Test updating auto-payment + $this->browse(function (Browser $browser) use ($user) { + $browser->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') + ->click('@body #mandate-info button.btn-primary') + ->assertSeeIn('@title', 'Update auto-payment') + ->assertValue('@body #mandate_amount', '100') + ->assertValue('@body #mandate_balance', '0') + ->assertSeeIn('@button-cancel', 'Cancel') + ->assertSeeIn('@button-action', 'Submit') + // Test error handling + ->type('@body #mandate_amount', 'aaa') + ->click('@button-action') + ->assertToast(Toast::TYPE_ERROR, 'Form validation error') + ->assertVisible('@body #mandate_amount.is-invalid') + ->assertSeeIn('#mandate_amount + span + .invalid-feedback', 'The amount must be a number.') + // Submit valid data + ->type('@body #mandate_amount', '50') + ->click('@button-action'); + }) + ->waitUntilMissing('#payment-dialog') + ->assertToast(Toast::TYPE_SUCCESS, 'The auto-payment has been updated.'); + }); + + // Test deleting auto-payment + $this->browse(function (Browser $browser) use ($user) { + $browser->on(new WalletPage()) + ->click('@main button') + ->with(new Dialog('@payment-dialog'), function (Browser $browser) { + $browser->assertSeeIn('@body #mandate-info button.btn-danger', 'Cancel auto-payment') + ->click('@body #mandate-info button.btn-danger') + ->assertToast(Toast::TYPE_SUCCESS, 'The auto-payment has been removed.') + ->assertVisible('@body #mandate-form') + ->assertMissing('@body #mandate-info'); + }); + }); + } +} diff --git a/src/tests/Browser/PaymentStripeTest.php b/src/tests/Browser/PaymentStripeTest.php new file mode 100644 --- /dev/null +++ b/src/tests/Browser/PaymentStripeTest.php @@ -0,0 +1,202 @@ +deleteTestUser('payment-test@kolabnow.com'); + } + + /** + * {@inheritDoc} + */ + public function tearDown(): void + { + $this->deleteTestUser('payment-test@kolabnow.com'); + + parent::tearDown(); + } + + /** + * Test the payment process + * + * @group stripe + */ + public function testPayment(): void + { + $user = $this->getTestUser('payment-test@kolabnow.com', [ + 'password' => 'simple123', + ]); + + $this->browse(function (Browser $browser) use ($user) { + $browser->visit(new Home()) + ->submitLogon('payment-test@kolabnow.com', 'simple123', true, ['paymentProvider' => 'stripe']) + ->on(new Dashboard()) + ->click('@links .link-wallet') + ->on(new WalletPage()) + ->assertSeeIn('@main button', 'Add credit') + ->click('@main button') + ->with(new Dialog('@payment-dialog'), function (Browser $browser) { + $browser->assertSeeIn('@title', 'Top up your wallet') + ->assertFocused('#amount') + ->assertSeeIn('@button-cancel', 'Cancel') + ->assertSeeIn('@body #payment-form button', 'Continue') + // Test error handling + ->type('@body #amount', 'aaa') + ->click('@body #payment-form button') + ->assertToast(Toast::TYPE_ERROR, 'Form validation error') + ->assertSeeIn('#amount + span + .invalid-feedback', 'The amount must be a number.') + // Submit valid data + ->type('@body #amount', '12.34') + ->click('@body #payment-form button'); + }) + ->on(new PaymentStripe()) + ->assertSeeIn('@title', \config('app.name') . ' Payment') + ->assertSeeIn('@amount', 'CHF 12.34') + ->assertValue('@email-input', $user->email) + ->submitValidCreditCard(); + + // Now it should redirect back to wallet page and in background + // use the webhook to update payment status (and balance). + + // 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. + ->on(new WalletPage()) + ->assertSeeIn('@main .card-text', 'Current account balance is 12,34 CHF'); + }); + } + + /** + * Test the auto-payment setup process + * + * @group stripe + */ + public function testAutoPaymentSetup(): void + { + $user = $this->getTestUser('payment-test@kolabnow.com', [ + 'password' => 'simple123', + ]); + + // Test creating auto-payment + $this->browse(function (Browser $browser) use ($user) { + $browser->visit(new Home()) + ->submitLogon('payment-test@kolabnow.com', 'simple123', true, ['paymentProvider' => 'stripe']) + ->on(new Dashboard()) + ->click('@links .link-wallet') + ->on(new WalletPage()) + ->click('@main button') + ->with(new Dialog('@payment-dialog'), function (Browser $browser) { + $browser->assertSeeIn('@title', 'Top up your wallet') + ->assertSeeIn('@button-cancel', 'Cancel') + ->assertSeeIn('@body #mandate-form button', 'Set up auto-payment') + ->click('@body #mandate-form button') + ->assertSeeIn('@title', 'Add auto-payment') + ->assertSeeIn('@body label[for="mandate_amount"]', 'Fill up by') + ->assertValue('@body #mandate_amount', PaymentProvider::MIN_AMOUNT / 100) + ->assertSeeIn('@body label[for="mandate_balance"]', 'when account balance is below') // phpcs:ignore + ->assertValue('@body #mandate_balance', '0') + ->assertSeeIn('@button-cancel', 'Cancel') + ->assertSeeIn('@button-action', 'Continue') + // Test error handling + ->type('@body #mandate_amount', 'aaa') + ->type('@body #mandate_balance', '-1') + ->click('@button-action') + ->assertToast(Toast::TYPE_ERROR, 'Form validation error') + ->assertVisible('@body #mandate_amount.is-invalid') + ->assertVisible('@body #mandate_balance.is-invalid') + ->assertSeeIn('#mandate_amount + span + .invalid-feedback', 'The amount must be a number.') + ->assertSeeIn('#mandate_balance + span + .invalid-feedback', 'The balance must be at least 0.') + ->type('@body #mandate_amount', 'aaa') + ->type('@body #mandate_balance', '0') + ->click('@button-action') + ->assertToast(Toast::TYPE_ERROR, 'Form validation error') + ->assertVisible('@body #mandate_amount.is-invalid') + ->assertMissing('@body #mandate_balance.is-invalid') + ->assertSeeIn('#mandate_amount + span + .invalid-feedback', 'The amount must be a number.') + ->assertMissing('#mandate_balance + span + .invalid-feedback') + // Submit valid data + ->type('@body #mandate_amount', '100') + ->type('@body #mandate_balance', '0') + ->click('@button-action'); + }) + ->on(new PaymentStripe()) + ->assertMissing('@title') + ->assertMissing('@amount') + ->assertValue('@email-input', $user->email) + ->submitValidCreditCard() + ->waitForLocation('/wallet', 15) // need more time than default 5 sec. + ->visit('/wallet?paymentProvider=stripe') + ->on(new WalletPage()) + ->click('@main button') + ->with(new Dialog('@payment-dialog'), function (Browser $browser) { + $expected = 'Auto-payment is set to fill up your account by 100 CHF every' + . ' time your account balance gets under 0 CHF. You will be charged' + . ' via Visa (**** **** **** 4242).'; + + $browser->assertSeeIn('@title', 'Top up your wallet') + ->waitFor('#mandate-info') + ->assertSeeIn('#mandate-info p:first-child', $expected) + ->click('@button-cancel'); + }); + }); + + // Test updating auto-payment + $this->browse(function (Browser $browser) use ($user) { + $browser->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') + ->click('@body #mandate-info button.btn-primary') + ->assertSeeIn('@title', 'Update auto-payment') + ->assertValue('@body #mandate_amount', '100') + ->assertValue('@body #mandate_balance', '0') + ->assertSeeIn('@button-cancel', 'Cancel') + ->assertSeeIn('@button-action', 'Submit') + // Test error handling + ->type('@body #mandate_amount', 'aaa') + ->click('@button-action') + ->assertToast(Toast::TYPE_ERROR, 'Form validation error') + ->assertVisible('@body #mandate_amount.is-invalid') + ->assertSeeIn('#mandate_amount + span + .invalid-feedback', 'The amount must be a number.') + // Submit valid data + ->type('@body #mandate_amount', '50') + ->click('@button-action'); + }) + ->waitUntilMissing('#payment-dialog') + ->assertToast(Toast::TYPE_SUCCESS, 'The auto-payment has been updated.'); + }); + + // Test deleting auto-payment + $this->browse(function (Browser $browser) use ($user) { + $browser->on(new WalletPage()) + ->click('@main button') + ->with(new Dialog('@payment-dialog'), function (Browser $browser) { + $browser->assertSeeIn('@body #mandate-info button.btn-danger', 'Cancel auto-payment') + ->click('@body #mandate-info button.btn-danger') + ->assertToast(Toast::TYPE_SUCCESS, 'The auto-payment has been removed.') + ->assertVisible('@body #mandate-form') + ->assertMissing('@body #mandate-info'); + }); + }); + } +} diff --git a/src/tests/Browser/PaymentTest.php b/src/tests/Browser/PaymentTest.php deleted file mode 100644 --- a/src/tests/Browser/PaymentTest.php +++ /dev/null @@ -1,82 +0,0 @@ -deleteTestUser('payment-test@kolabnow.com'); - } - - /** - * {@inheritDoc} - */ - public function tearDown(): void - { - $this->deleteTestUser('payment-test@kolabnow.com'); - - parent::tearDown(); - } - - /** - * Test a payment process - * - * @group mollie - */ - public function testPayment(): void - { - $user = $this->getTestUser('payment-test@kolabnow.com', [ - 'password' => 'simple123', - ]); - - $this->browse(function (Browser $browser) { - $browser->visit(new Home()) - ->submitLogon('payment-test@kolabnow.com', 'simple123', true) - ->on(new Dashboard()) - ->click('@links .link-wallet') - ->on(new WalletPage()) - ->click('@main button') - ->on(new PaymentMollie()) - ->assertSeeIn('@title', 'Kolab Now Payment') - ->assertSeeIn('@amount', 'CHF 10.00'); - - // Looks like the Mollie testing mode is limited. - // We'll select credit card method and mark the payment as paid - // We can't do much more, we have to trust Mollie their page works ;) - - // For some reason I don't get the method selection form, it - // immediately jumps to the next step. Let's detect that - if ($browser->element('@methods')) { - $browser->click('@methods button.grid-button-creditcard') - ->waitFor('button.form__button'); - } - - $browser->click('@status-table input[value="paid"]') - ->click('button.form__button'); - - // Now it should redirect back to wallet page and in background - // use the webhook to update payment status (and balance). - - // Looks like in test-mode the webhook is executed before redirect - // so we can expect balance updated on the wallet page - - $browser->waitForLocation('/wallet') - ->on(new WalletPage()) - ->assertSeeIn('@main .card-text', 'Current account balance is 10,00 CHF'); - }); - } -} diff --git a/src/tests/Feature/Backends/LDAPTest.php b/src/tests/Feature/Backends/LDAPTest.php --- a/src/tests/Feature/Backends/LDAPTest.php +++ b/src/tests/Feature/Backends/LDAPTest.php @@ -108,7 +108,6 @@ $expected['inetuserstatus'] = $user->status; $expected['mailquota'] = 2097152; $expected['nsroledn'] = null; - // TODO: country? dn $ldap_user = LDAP::getUser($user->email); diff --git a/src/tests/Feature/Controller/Admin/WalletsTest.php b/src/tests/Feature/Controller/Admin/WalletsTest.php --- a/src/tests/Feature/Controller/Admin/WalletsTest.php +++ b/src/tests/Feature/Controller/Admin/WalletsTest.php @@ -25,6 +25,46 @@ } /** + * Test fetching a wallet (GET /api/v4/wallets/:id) + * + * @group stripe + */ + public function testShow(): void + { + \config(['services.payment_provider' => 'stripe']); + + $user = $this->getTestUser('john@kolab.org'); + $admin = $this->getTestUser('jeroen@jeroen.jeroen'); + $wallet = $user->wallets()->first(); + + // Make sure there's no stripe/mollie identifiers + $wallet->setSetting('stripe_id', null); + $wallet->setSetting('stripe_mandate_id', null); + $wallet->setSetting('mollie_id', null); + $wallet->setSetting('mollie_mandate_id', null); + + // Non-admin user + $response = $this->actingAs($user)->get("api/v4/wallets/{$wallet->id}"); + $response->assertStatus(403); + + // Admin user + $response = $this->actingAs($admin)->get("api/v4/wallets/{$wallet->id}"); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertSame($wallet->id, $json['id']); + $this->assertSame('CHF', $json['currency']); + $this->assertSame(0, $json['balance']); + $this->assertSame(0, $json['discount']); + $this->assertTrue(empty($json['description'])); + $this->assertTrue(empty($json['discount_description'])); + $this->assertTrue(!empty($json['provider'])); + $this->assertTrue(!empty($json['providerLink'])); + $this->assertTrue(!empty($json['mandate'])); + } + + /** * Test updating a wallet (PUT /api/v4/wallets/:id) */ public function testUpdate(): void diff --git a/src/tests/Feature/Controller/PaymentsMollieTest.php b/src/tests/Feature/Controller/PaymentsMollieTest.php new file mode 100644 --- /dev/null +++ b/src/tests/Feature/Controller/PaymentsMollieTest.php @@ -0,0 +1,325 @@ + 'mollie']); + + $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(); + } + + /** + * {@inheritDoc} + */ + public function tearDown(): void + { + $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(); + + parent::tearDown(); + } + + /** + * Test creating/updating/deleting an outo-payment mandate + * + * @group mollie + */ + public function testMandates(): void + { + // Unauth access not allowed + $response = $this->get("api/v4/payments/mandate"); + $response->assertStatus(401); + $response = $this->post("api/v4/payments/mandate", []); + $response->assertStatus(401); + $response = $this->put("api/v4/payments/mandate", []); + $response->assertStatus(401); + $response = $this->delete("api/v4/payments/mandate"); + $response->assertStatus(401); + + $user = $this->getTestUser('john@kolab.org'); + + // Test creating a mandate (invalid input) + $post = []; + $response = $this->actingAs($user)->post("api/v4/payments/mandate", $post); + $response->assertStatus(422); + + $json = $response->json(); + + $this->assertSame('error', $json['status']); + $this->assertCount(2, $json['errors']); + $this->assertSame('The amount field is required.', $json['errors']['amount'][0]); + $this->assertSame('The balance field is required.', $json['errors']['balance'][0]); + + // Test creating a mandate (invalid input) + $post = ['amount' => 100, 'balance' => 'a']; + $response = $this->actingAs($user)->post("api/v4/payments/mandate", $post); + $response->assertStatus(422); + + $json = $response->json(); + + $this->assertSame('error', $json['status']); + $this->assertCount(1, $json['errors']); + $this->assertSame('The balance must be a number.', $json['errors']['balance'][0]); + + // Test creating a mandate (invalid input) + $post = ['amount' => -100, 'balance' => 0]; + $response = $this->actingAs($user)->post("api/v4/payments/mandate", $post); + $response->assertStatus(422); + + $json = $response->json(); + + $this->assertSame('error', $json['status']); + $this->assertCount(1, $json['errors']); + $min = intval(PaymentProvider::MIN_AMOUNT / 100) . ' CHF'; + $this->assertSame("Minimum amount for a single payment is {$min}.", $json['errors']['amount']); + + // 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); + + $json = $response->json(); + + $this->assertSame('success', $json['status']); + $this->assertRegExp('|^https://www.mollie.com|', $json['redirectUrl']); + + // Test fetching the mandate information + $response = $this->actingAs($user)->get("api/v4/payments/mandate"); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertEquals(20.10, $json['amount']); + $this->assertEquals(0, $json['balance']); + $this->assertEquals('Credit Card', $json['method']); + $this->assertSame(true, $json['isPending']); + $this->assertSame(false, $json['isValid']); + + $mandate_id = $json['id']; + + // 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. + $mollie_response = [ + 'resource' => 'mandate', + 'id' => $json['id'], + 'status' => 'valid', + 'method' => 'creditcard', + 'details' => [ + 'cardNumber' => '4242', + 'cardLabel' => 'Visa', + ], + 'customerId' => 'cst_GMfxGPt7Gj', + 'createdAt' => '2020-04-28T11:09:47+00:00', + ]; + + $responseStack = $this->mockMollie(); + $responseStack->append(new Response(200, [], json_encode($mollie_response))); + + $response = $this->actingAs($user)->get("api/v4/payments/mandate"); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertEquals(20.10, $json['amount']); + $this->assertEquals(0, $json['balance']); + $this->assertEquals('Visa (**** **** **** 4242)', $json['method']); + $this->assertSame(false, $json['isPending']); + $this->assertSame(true, $json['isValid']); + + // Test updating mandate details (invalid input) + $post = []; + $response = $this->actingAs($user)->put("api/v4/payments/mandate", $post); + $response->assertStatus(422); + + $json = $response->json(); + + $this->assertSame('error', $json['status']); + $this->assertCount(2, $json['errors']); + $this->assertSame('The amount field is required.', $json['errors']['amount'][0]); + $this->assertSame('The balance field is required.', $json['errors']['balance'][0]); + + $post = ['amount' => -100, 'balance' => 0]; + $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("Minimum amount for a single payment is {$min}.", $json['errors']['amount']); + + // Test updating a mandate (valid input) + $post = ['amount' => 30.10, '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']); + + $wallet = $user->wallets()->first(); + + $this->assertEquals(30.10, $wallet->getSetting('mandate_amount')); + $this->assertEquals(1, $wallet->getSetting('mandate_balance')); + + $this->unmockMollie(); + + // Delete mandate + $response = $this->actingAs($user)->delete("api/v4/payments/mandate"); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertSame('success', $json['status']); + $this->assertSame('The auto-payment has been removed.', $json['message']); + + // Confirm with Mollie the mandate does not exist + $customer_id = $wallet->getSetting('mollie_id'); + $this->expectException(\Mollie\Api\Exceptions\ApiException::class); + $this->expectExceptionMessageMatches('/410: Gone/'); + $mandate = mollie()->mandates()->getForId($customer_id, $mandate_id); + + $this->assertNull($wallet->fresh()->getSetting('mollie_mandate_id')); + } + + /** + * Test creating a payment and receiving a status via webhook + * + * @group mollie + */ + public function testStoreAndWebhook(): void + { + // Unauth access not allowed + $response = $this->post("api/v4/payments", []); + $response->assertStatus(401); + + $user = $this->getTestUser('john@kolab.org'); + + $post = ['amount' => -1]; + $response = $this->actingAs($user)->post("api/v4/payments", $post); + $response->assertStatus(422); + + $json = $response->json(); + + $this->assertSame('error', $json['status']); + $this->assertCount(1, $json['errors']); + $min = intval(PaymentProvider::MIN_AMOUNT / 100) . ' CHF'; + $this->assertSame("Minimum amount for a single payment is {$min}.", $json['errors']['amount']); + + $post = ['amount' => '12.34']; + $response = $this->actingAs($user)->post("api/v4/payments", $post); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertSame('success', $json['status']); + $this->assertRegExp('|^https://www.mollie.com|', $json['redirectUrl']); + + $wallet = $user->wallets()->first(); + $payments = Payment::where('wallet_id', $wallet->id)->get(); + + $this->assertCount(1, $payments); + $payment = $payments[0]; + $this->assertSame(1234, $payment->amount); + $this->assertSame(\config('app.name') . ' Payment', $payment->description); + $this->assertSame('open', $payment->status); + $this->assertEquals(0, $wallet->balance); + + // Test the webhook + // Note: Webhook end-point does not require authentication + + $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 = $this->mockMollie(); + $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('paid', $payment->fresh()->status); + $this->assertEquals(1234, $wallet->fresh()->balance); + + // Verify "paid -> open -> paid" scenario, assert that balance didn't change + $mollie_response['status'] = 'open'; + unset($mollie_response['paidAt']); + $responseStack->append(new Response(200, [], json_encode($mollie_response))); + + $response = $this->post("api/webhooks/payment/mollie", $post); + $response->assertStatus(200); + + $this->assertSame('paid', $payment->fresh()->status); + $this->assertEquals(1234, $wallet->fresh()->balance); + + $mollie_response['status'] = 'paid'; + $mollie_response['paidAt'] = date('c'); + $responseStack->append(new Response(200, [], json_encode($mollie_response))); + + $response = $this->post("api/webhooks/payment/mollie", $post); + $response->assertStatus(200); + + $this->assertSame('paid', $payment->fresh()->status); + $this->assertEquals(1234, $wallet->fresh()->balance); + } + + /** + * Test automatic payment charges + * + * @group mollie + */ + public function testDirectCharge(): void + { + $user = $this->getTestUser('john@kolab.org'); + $wallet = $user->wallets()->first(); + + // Expect false result, as there's no mandate + $result = PaymentsController::directCharge($wallet, 1234); + $this->assertFalse($result); + + // 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. + + $this->markTestIncomplete(); + } +} diff --git a/src/tests/Feature/Controller/PaymentsStripeTest.php b/src/tests/Feature/Controller/PaymentsStripeTest.php new file mode 100644 --- /dev/null +++ b/src/tests/Feature/Controller/PaymentsStripeTest.php @@ -0,0 +1,274 @@ + 'stripe']); + + $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(); + } + + /** + * {@inheritDoc} + */ + public function tearDown(): void + { + $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(); + + parent::tearDown(); + } + + /** + * Test creating/updating/deleting an outo-payment mandate + * + * @group stripe + */ + public function testMandates(): void + { + // Unauth access not allowed + $response = $this->get("api/v4/payments/mandate"); + $response->assertStatus(401); + $response = $this->post("api/v4/payments/mandate", []); + $response->assertStatus(401); + $response = $this->put("api/v4/payments/mandate", []); + $response->assertStatus(401); + $response = $this->delete("api/v4/payments/mandate"); + $response->assertStatus(401); + + $user = $this->getTestUser('john@kolab.org'); + + // Test creating a mandate (invalid input) + $post = []; + $response = $this->actingAs($user)->post("api/v4/payments/mandate", $post); + $response->assertStatus(422); + + $json = $response->json(); + + $this->assertSame('error', $json['status']); + $this->assertCount(2, $json['errors']); + $this->assertSame('The amount field is required.', $json['errors']['amount'][0]); + $this->assertSame('The balance field is required.', $json['errors']['balance'][0]); + + // Test creating a mandate (invalid input) + $post = ['amount' => 100, 'balance' => 'a']; + $response = $this->actingAs($user)->post("api/v4/payments/mandate", $post); + $response->assertStatus(422); + + $json = $response->json(); + + $this->assertSame('error', $json['status']); + $this->assertCount(1, $json['errors']); + $this->assertSame('The balance must be a number.', $json['errors']['balance'][0]); + + // Test creating a mandate (invalid input) + $post = ['amount' => -100, 'balance' => 0]; + $response = $this->actingAs($user)->post("api/v4/payments/mandate", $post); + $response->assertStatus(422); + + $json = $response->json(); + + $this->assertSame('error', $json['status']); + $this->assertCount(1, $json['errors']); + $min = intval(PaymentProvider::MIN_AMOUNT / 100) . ' CHF'; + $this->assertSame("Minimum amount for a single payment is {$min}.", $json['errors']['amount']); + + // 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); + + $json = $response->json(); + + $this->assertSame('success', $json['status']); + $this->assertRegExp('|^cs_test_|', $json['id']); + + // Test fetching the mandate information + $response = $this->actingAs($user)->get("api/v4/payments/mandate"); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertEquals(20.10, $json['amount']); + $this->assertEquals(0, $json['balance']); + + // 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. + $setupIntent = '{ + "id": "AAA", + "object": "setup_intent", + "created": 123456789, + "payment_method": "pm_YYY", + "status": "succeeded", + "usage": "off_session" + }'; + + $paymentMethod = '{ + "id": "pm_YYY", + "object": "payment_method", + "card": { + "brand": "visa", + "country": "US", + "last4": "4242" + }, + "created": 123456789, + "type": "card" + }'; + + $client = $this->mockStripe(); + $client->addResponse($setupIntent); + $client->addResponse($paymentMethod); + + // As we do not use checkout page, we do not receive a webworker request + // I.e. we have to fake the mandate id + $wallet = $user->wallets()->first(); + $wallet->setSetting('stripe_mandate_id', 'AAA'); + + $response = $this->actingAs($user)->get("api/v4/payments/mandate"); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertEquals(20.10, $json['amount']); + $this->assertEquals(0, $json['balance']); + $this->assertEquals('Visa (**** **** **** 4242)', $json['method']); + $this->assertSame(false, $json['isPending']); + $this->assertSame(true, $json['isValid']); + + // Test updating mandate details (invalid input) + $post = []; + $response = $this->actingAs($user)->put("api/v4/payments/mandate", $post); + $response->assertStatus(422); + + $json = $response->json(); + + $this->assertSame('error', $json['status']); + $this->assertCount(2, $json['errors']); + $this->assertSame('The amount field is required.', $json['errors']['amount'][0]); + $this->assertSame('The balance field is required.', $json['errors']['balance'][0]); + + $post = ['amount' => -100, 'balance' => 0]; + $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("Minimum amount for a single payment is {$min}.", $json['errors']['amount']); + + // Test updating a mandate (valid input) + $post = ['amount' => 30.10, '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->assertEquals(30.10, $wallet->getSetting('mandate_amount')); + $this->assertEquals(1, $wallet->getSetting('mandate_balance')); + + $this->unmockStripe(); + + // TODO: Delete mandate + } + + /** + * Test creating a payment and receiving a status via webhook + * + * @group stripe + */ + public function testStoreAndWebhook(): void + { + // Unauth access not allowed + $response = $this->post("api/v4/payments", []); + $response->assertStatus(401); + + $user = $this->getTestUser('john@kolab.org'); + + $post = ['amount' => -1]; + $response = $this->actingAs($user)->post("api/v4/payments", $post); + $response->assertStatus(422); + + $json = $response->json(); + + $this->assertSame('error', $json['status']); + $this->assertCount(1, $json['errors']); + $min = intval(PaymentProvider::MIN_AMOUNT / 100) . ' CHF'; + $this->assertSame("Minimum amount for a single payment is {$min}.", $json['errors']['amount']); + + $post = ['amount' => '12.34']; + $response = $this->actingAs($user)->post("api/v4/payments", $post); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertSame('success', $json['status']); + $this->assertRegExp('|^cs_test_|', $json['id']); + + $wallet = $user->wallets()->first(); + $payments = Payment::where('wallet_id', $wallet->id)->get(); + + $this->assertCount(1, $payments); + $payment = $payments[0]; + $this->assertSame(1234, $payment->amount); + $this->assertSame(\config('app.name') . ' Payment', $payment->description); + $this->assertSame('open', $payment->status); + $this->assertEquals(0, $wallet->balance); + + // TODO: Test the webhook + } + + /** + * Test automatic payment charges + * + * @group stripe + */ + public function testDirectCharge(): void + { + $user = $this->getTestUser('john@kolab.org'); + $wallet = $user->wallets()->first(); + + // Expect false result, as there's no mandate + $result = PaymentsController::directCharge($wallet, 1234); + $this->assertFalse($result); + + // 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. + + $this->markTestIncomplete(); + } +} diff --git a/src/tests/Feature/Controller/PaymentsTest.php b/src/tests/Feature/Controller/PaymentsTest.php deleted file mode 100644 --- a/src/tests/Feature/Controller/PaymentsTest.php +++ /dev/null @@ -1,156 +0,0 @@ -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]); - } - - /** - * {@inheritDoc} - */ - public function tearDown(): void - { - $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]); - - parent::tearDown(); - } - - /** - * Test creating a payment and receiving a status via webhook) - * - * @group mollie - */ - public function testStoreAndWebhook(): void - { - // Unauth access not allowed - $response = $this->post("api/v4/payments", []); - $response->assertStatus(401); - - $user = $this->getTestUser('john@kolab.org'); - - $post = ['amount' => -1]; - $response = $this->actingAs($user)->post("api/v4/payments", $post); - $response->assertStatus(422); - - $json = $response->json(); - - $this->assertSame('error', $json['status']); - $this->assertCount(1, $json['errors']); - $this->assertSame('The amount must be at least 1.', $json['errors']['amount'][0]); - - $post = ['amount' => 1234]; - $response = $this->actingAs($user)->post("api/v4/payments", $post); - $response->assertStatus(200); - - $json = $response->json(); - - $this->assertSame('success', $json['status']); - $this->assertRegExp('|^https://www.mollie.com|', $json['redirectUrl']); - - $wallet = $user->wallets()->first(); - $payments = Payment::where('wallet_id', $wallet->id)->get(); - - $this->assertCount(1, $payments); - $payment = $payments[0]; - $this->assertSame(1234, $payment->amount); - $this->assertSame('Kolab Now Payment', $payment->description); - $this->assertSame('open', $payment->status); - $this->assertEquals(0, $wallet->balance); - - // Test the webhook - // Note: Webhook end-point does not require authentication - - $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", -/* - "createdAt" => "2018-03-20T13:13:37+00:00", - "amount" => { - "value" => "10.00", - "currency" => "EUR" - }, - "description" => "Order #12345", - "method" => null, - "metadata" => { - "order_id" => "12345" - }, - "isCancelable" => false, - "locale" => "nl_NL", - "restrictPaymentMethodsToCountry" => "NL", - "expiresAt" => "2018-03-20T13:28:37+00:00", - "details" => null, - "profileId" => "pfl_QkEhN94Ba", - "sequenceType" => "oneoff", - "redirectUrl" => "https://webshop.example.org/order/12345/", - "webhookUrl" => "https://webshop.example.org/payments/webhook/", -*/ - ]; - - // 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))); - - $post = ['id' => $payment->id]; - $response = $this->post("api/webhooks/payment/mollie", $post); - $response->assertStatus(200); - - $this->assertSame('paid', $payment->fresh()->status); - $this->assertEquals(1234, $wallet->fresh()->balance); - - // Verify "paid -> open -> paid" scenario, assert that balance didn't change - $mollie_response['status'] = 'open'; - unset($mollie_response['paidAt']); - $responseStack->append(new Response(200, [], json_encode($mollie_response))); - - $response = $this->post("api/webhooks/payment/mollie", $post); - $response->assertStatus(200); - - $this->assertSame('paid', $payment->fresh()->status); - $this->assertEquals(1234, $wallet->fresh()->balance); - - $mollie_response['status'] = 'paid'; - $mollie_response['paidAt'] = date('c'); - $responseStack->append(new Response(200, [], json_encode($mollie_response))); - - $response = $this->post("api/webhooks/payment/mollie", $post); - $response->assertStatus(200); - - $this->assertSame('paid', $payment->fresh()->status); - $this->assertEquals(1234, $wallet->fresh()->balance); - } - - public function testDirectCharge(): void - { - $this->markTestIncomplete(); - } -} 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 @@ -33,6 +33,7 @@ $user = $this->getTestUser('john@kolab.org'); $wallet = $user->wallets()->first(); $wallet->discount()->dissociate(); + $wallet->settings()->whereIn('key', ['mollie_id', 'stripe_id'])->delete(); $wallet->save(); $user->status |= User::STATUS_IMAP_READY; $user->save(); @@ -53,6 +54,7 @@ $user = $this->getTestUser('john@kolab.org'); $wallet = $user->wallets()->first(); $wallet->discount()->dissociate(); + $wallet->settings()->whereIn('key', ['mollie_id', 'stripe_id'])->delete(); $wallet->save(); $user->status |= User::STATUS_IMAP_READY; $user->save(); @@ -316,16 +318,12 @@ $json = $response->json(); $this->assertTrue($json['isImapReady']); - $this->assertFalse($json['isReady']); + $this->assertTrue($json['isReady']); $this->assertCount(7, $json['process']); $this->assertSame('user-imap-ready', $json['process'][2]['label']); $this->assertSame(true, $json['process'][2]['state']); - $this->assertSame('domain-confirmed', $json['process'][6]['label']); - $this->assertSame(false, $json['process'][6]['state']); - $this->assertSame('error', $json['status']); - $this->assertSame('Failed to verify an ownership of a domain.', $json['message']); - - // TODO: Test completing all process steps + $this->assertSame('success', $json['status']); + $this->assertSame('Setup process finished successfully.', $json['message']); } /** @@ -728,6 +726,7 @@ */ public function testUserResponse(): void { + $provider = \config('payment_provider') ?: 'mollie'; $user = $this->getTestUser('john@kolab.org'); $wallet = $user->wallets()->first(); $result = $this->invokeMethod(new UsersController(), 'userResponse', [$user]); @@ -765,11 +764,15 @@ $this->assertSame($wallet->id, $result['wallet']['id']); $this->assertSame($wallet->id, $result['accounts'][0]['id']); $this->assertSame($ned_wallet->id, $result['wallets'][0]['id']); + $this->assertSame($provider, $result['wallet']['provider']); + $this->assertSame($provider, $result['wallets'][0]['provider']); // Test discount in a response $discount = Discount::where('code', 'TEST')->first(); $wallet->discount()->associate($discount); $wallet->save(); + $mod_provider = $provider == 'mollie' ? 'stripe' : 'mollie'; + $wallet->setSetting($mod_provider . '_id', 123); $user->refresh(); $result = $this->invokeMethod(new UsersController(), 'userResponse', [$user]); @@ -778,9 +781,11 @@ $this->assertSame($discount->id, $result['wallet']['discount_id']); $this->assertSame($discount->discount, $result['wallet']['discount']); $this->assertSame($discount->description, $result['wallet']['discount_description']); + $this->assertSame($mod_provider, $result['wallet']['provider']); $this->assertSame($discount->id, $result['wallets'][0]['discount_id']); $this->assertSame($discount->discount, $result['wallets'][0]['discount']); $this->assertSame($discount->description, $result['wallets'][0]['discount_description']); + $this->assertSame($mod_provider, $result['wallets'][0]['provider']); } /** diff --git a/src/tests/MollieMocksTrait.php b/src/tests/MollieMocksTrait.php --- a/src/tests/MollieMocksTrait.php +++ b/src/tests/MollieMocksTrait.php @@ -31,9 +31,9 @@ $guzzle = new Client(['handler' => $handler]); - $this->app->forgetInstance('mollie.api.client'); - $this->app->forgetInstance('mollie.api'); - $this->app->forgetInstance('mollie'); + $this->app->forgetInstance('mollie.api.client'); // @phpstan-ignore-line + $this->app->forgetInstance('mollie.api'); // @phpstan-ignore-line + $this->app->forgetInstance('mollie'); // @phpstan-ignore-line $this->app->singleton('mollie.api.client', function () use ($guzzle) { return new MollieApiClient($guzzle); @@ -44,8 +44,14 @@ public function unmockMollie() { - $this->app->forgetInstance('mollie.api.client'); - $this->app->forgetInstance('mollie.api'); - $this->app->forgetInstance('mollie'); + $this->app->forgetInstance('mollie.api.client'); // @phpstan-ignore-line + $this->app->forgetInstance('mollie.api'); // @phpstan-ignore-line + $this->app->forgetInstance('mollie'); // @phpstan-ignore-line + + $guzzle = new Client(); + + $this->app->singleton('mollie.api.client', function () use ($guzzle) { + return new MollieApiClient($guzzle); + }); } } diff --git a/src/tests/StripeMockClient.php b/src/tests/StripeMockClient.php new file mode 100644 --- /dev/null +++ b/src/tests/StripeMockClient.php @@ -0,0 +1,22 @@ +responses); + + return $response; + } + + public function addResponse($body, $code = 200, $headers = []) + { + $this->responses[] = [$body, $code, $headers]; + } +} diff --git a/src/tests/StripeMocksTrait.php b/src/tests/StripeMocksTrait.php new file mode 100644 --- /dev/null +++ b/src/tests/StripeMocksTrait.php @@ -0,0 +1,27 @@ + str_replace('//', '//admin.', \config('app.url'))]); - url()->forceRootUrl(config('app.url')); + url()->forceRootUrl(config('app.url')); // @phpstan-ignore-line } }