diff --git a/src/app/Http/Controllers/API/V4/Admin/WalletsController.php b/src/app/Http/Controllers/API/V4/Admin/WalletsController.php index 10164a85..586cc146 100644 --- a/src/app/Http/Controllers/API/V4/Admin/WalletsController.php +++ b/src/app/Http/Controllers/API/V4/Admin/WalletsController.php @@ -1,158 +1,159 @@ user()->canRead($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); + $result['notice'] = $this->getWalletNotice($wallet); // for resellers return response()->json($result); } /** * Award/penalize a wallet. * * @param \Illuminate\Http\Request $request The API request. * @param string $id Wallet identifier * * @return \Illuminate\Http\JsonResponse The response */ public function oneOff(Request $request, $id) { $wallet = Wallet::find($id); $user = Auth::guard()->user(); if (empty($wallet) || !$user->canRead($wallet)) { return $this->errorResponse(404); } // Check required fields $v = Validator::make( $request->all(), [ 'amount' => 'required|numeric', 'description' => 'required|string|max:1024', ] ); if ($v->fails()) { return response()->json(['status' => 'error', 'errors' => $v->errors()], 422); } $amount = (int) ($request->amount * 100); $type = $amount > 0 ? Transaction::WALLET_AWARD : Transaction::WALLET_PENALTY; DB::beginTransaction(); $wallet->balance += $amount; $wallet->save(); Transaction::create( [ 'user_email' => \App\Utils::userEmailOrNull(), 'object_id' => $wallet->id, 'object_type' => Wallet::class, 'type' => $type, 'amount' => $amount, 'description' => $request->description ] ); if ($user->role == 'reseller') { if ($user->tenant && ($tenant_wallet = $user->tenant->wallet())) { $desc = ($amount > 0 ? 'Awarded' : 'Penalized') . " user {$wallet->owner->email}"; $method = $amount > 0 ? 'debit' : 'credit'; $tenant_wallet->{$method}(abs($amount), $desc); } } DB::commit(); $response = [ 'status' => 'success', 'message' => \trans("app.wallet-{$type}-success"), 'balance' => $wallet->balance ]; return response()->json($response); } /** * Update wallet data. * * @param \Illuminate\Http\Request $request The API request. * @param string $id Wallet identifier * * @return \Illuminate\Http\JsonResponse The response */ public function update(Request $request, $id) { $wallet = Wallet::find($id); if (empty($wallet) || !Auth::guard()->user()->canRead($wallet)) { return $this->errorResponse(404); } if (array_key_exists('discount', $request->input())) { if (empty($request->discount)) { $wallet->discount()->dissociate(); $wallet->save(); } elseif ($discount = Discount::withEnvTenant()->find($request->discount)) { $wallet->discount()->associate($discount); $wallet->save(); } } $response = $wallet->toArray(); if ($wallet->discount) { $response['discount'] = $wallet->discount->discount; $response['discount_description'] = $wallet->discount->description; } $response['status'] = 'success'; $response['message'] = \trans('app.wallet-update-success'); return response()->json($response); } } diff --git a/src/app/Http/Controllers/API/V4/Reseller/PaymentsController.php b/src/app/Http/Controllers/API/V4/Reseller/PaymentsController.php new file mode 100644 index 00000000..b7729813 --- /dev/null +++ b/src/app/Http/Controllers/API/V4/Reseller/PaymentsController.php @@ -0,0 +1,7 @@ + tag */ public function customerLink(Wallet $wallet): ?string { $customer_id = self::mollieCustomerId($wallet, false); if (!$customer_id) { return null; } 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 (optional) * - currency: The operation currency * - description: Operation desc. * - methodId: Payment method * * @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, true); if (!isset($payment['amount'])) { $payment['amount'] = 0; } $amount = $this->exchange($payment['amount'], $wallet->currency, $payment['currency']); $payment['currency_amount'] = $amount; $request = [ 'amount' => [ 'currency' => $payment['currency'], 'value' => sprintf('%.2f', $amount / 100), ], 'customerId' => $customer_id, 'sequenceType' => 'first', 'description' => $payment['description'], 'webhookUrl' => Utils::serviceUrl('/api/webhooks/payment/mollie'), - 'redirectUrl' => Utils::serviceUrl('/wallet'), + 'redirectUrl' => self::redirectUrl(), 'locale' => 'en_US', 'method' => $payment['methodId'] ]; // Create the payment in Mollie $response = mollie()->payments()->create($request); if ($response->mandateId) { $wallet->setSetting('mollie_mandate_id', $response->mandateId); } // Store the payment reference in database $payment['status'] = $response->status; $payment['id'] = $response->id; $payment['type'] = self::TYPE_MANDATE; $this->storePayment($payment, $wallet->id); 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. * - methodId: Payment method * - 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(), 'method' => self::paymentMethod($mandate, 'Unknown method'), 'methodId' => $mandate->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. * - methodId: Payment method * * @return array Provider payment data: * - id: Operation identifier * - redirectUrl: the location to redirect to */ public function payment(Wallet $wallet, array $payment): ?array { if ($payment['type'] == self::TYPE_RECURRING) { return $this->paymentRecurring($wallet, $payment); } // Register the user in Mollie, if not yet done $customer_id = self::mollieCustomerId($wallet, true); $amount = $this->exchange($payment['amount'], $wallet->currency, $payment['currency']); $payment['currency_amount'] = $amount; // Note: Required fields: description, amount/currency, amount/value $request = [ 'amount' => [ 'currency' => $payment['currency'], // a number with two decimals is required (note that JPK and ISK don't require decimals, // but we're not using them currently) 'value' => sprintf('%.2f', $amount / 100), ], 'customerId' => $customer_id, 'sequenceType' => $payment['type'], 'description' => $payment['description'], 'webhookUrl' => Utils::serviceUrl('/api/webhooks/payment/mollie'), 'locale' => 'en_US', 'method' => $payment['methodId'], - 'redirectUrl' => Utils::serviceUrl('/wallet') // required for non-recurring payments + 'redirectUrl' => self::redirectUrl() // required for non-recurring payments ]; // 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; $this->storePayment($payment, $wallet->id); return [ 'id' => $payment['id'], 'redirectUrl' => $response->getCheckoutUrl(), ]; } /** * Cancel a pending payment. * * @param \App\Wallet $wallet The wallet * @param string $paymentId Payment Id * * @return bool True on success, False on failure */ public function cancel(Wallet $wallet, $paymentId): bool { $response = mollie()->payments()->delete($paymentId); $db_payment = Payment::find($paymentId); $db_payment->status = $response->status; $db_payment->save(); return true; } /** * Create a new automatic payment operation. * * @param \App\Wallet $wallet The wallet * @param array $payment Payment data (see self::payment()) * * @return array Provider payment/session data: * - id: Operation identifier */ protected function paymentRecurring(Wallet $wallet, array $payment): ?array { // Check if there's a valid mandate $mandate = self::mollieMandate($wallet); if (empty($mandate) || !$mandate->isValid() || $mandate->isPending()) { return null; } $customer_id = self::mollieCustomerId($wallet, true); // Note: Required fields: description, amount/currency, amount/value $amount = $this->exchange($payment['amount'], $wallet->currency, $payment['currency']); $payment['currency_amount'] = $amount; $request = [ 'amount' => [ 'currency' => $payment['currency'], // a number with two decimals is required 'value' => sprintf('%.2f', $amount / 100), ], 'customerId' => $customer_id, 'sequenceType' => $payment['type'], 'description' => $payment['description'], 'webhookUrl' => Utils::serviceUrl('/api/webhooks/payment/mollie'), 'locale' => 'en_US', 'method' => $payment['methodId'], 'mandateId' => $mandate->id ]; // Create the payment in Mollie $response = mollie()->payments()->create($request); // Store the payment reference in database $payment['status'] = $response->status; $payment['id'] = $response->id; DB::beginTransaction(); $payment = $this->storePayment($payment, $wallet->id); // Mollie can return 'paid' status immediately, so we don't // have to wait for the webhook. What's more, the webhook would ignore // the payment because it will be marked as paid before the webhook. // Let's handle paid status here too. if ($response->isPaid()) { self::creditPayment($payment, $response); $notify = true; } elseif ($response->isFailed()) { // Note: I didn't find a way to get any description of the problem with a payment \Log::info(sprintf('Mollie payment failed (%s)', $response->id)); // Disable the mandate $wallet->setSetting('mandate_disabled', 1); $notify = true; } DB::commit(); if (!empty($notify)) { \App\Jobs\PaymentEmail::dispatch($payment); } return [ 'id' => $payment['id'], ]; } /** * 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 // TODO: Consider https://github.com/mollie/mollie-api-php/issues/502 when it's fixed $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; } $refunds = []; if ($mollie_payment->isPaid()) { // The payment is paid. Update the balance, and notify the user if ($payment->status != self::STATUS_PAID && $payment->amount > 0) { $credit = true; $notify = $payment->type == self::TYPE_RECURRING; } // The payment has been (partially) refunded. // Let's process refunds with status "refunded". if ($mollie_payment->hasRefunds()) { foreach ($mollie_payment->refunds() as $refund) { if ($refund->isTransferred() && $refund->amount->value) { $refunds[] = [ 'id' => $refund->id, 'description' => $refund->description, 'amount' => round(floatval($refund->amount->value) * 100), 'type' => self::TYPE_REFUND, 'currency' => $refund->amount->currency ]; } } } // The payment has been (partially) charged back. // Let's process chargebacks (they have no states as refunds) if ($mollie_payment->hasChargebacks()) { foreach ($mollie_payment->chargebacks() as $chargeback) { if ($chargeback->amount->value) { $refunds[] = [ 'id' => $chargeback->id, 'amount' => round(floatval($chargeback->amount->value) * 100), 'type' => self::TYPE_CHARGEBACK, 'currency' => $chargeback->amount->currency ]; } } } // In case there were multiple auto-payment setup requests (e.g. caused by a double // form submission) we end up with multiple payment records and mollie_mandate_id // pointing to the one from the last payment not the successful one. // We make sure to use mandate id from the successful "first" payment. if ( $payment->type == self::TYPE_MANDATE && $mollie_payment->mandateId && $mollie_payment->sequenceType == Types\SequenceType::SEQUENCETYPE_FIRST ) { $payment->wallet->setSetting('mollie_mandate_id', $mollie_payment->mandateId); } } elseif ($mollie_payment->isFailed()) { // Note: I didn't find a way to get any description of the problem with a payment \Log::info(sprintf('Mollie payment failed (%s)', $payment->id)); // Disable the mandate if ($payment->type == self::TYPE_RECURRING) { $notify = true; $payment->wallet->setSetting('mandate_disabled', 1); } } DB::beginTransaction(); // This is a sanity check, just in case the payment provider api // sent us open -> paid -> open -> paid. So, we lock the payment after // recivied a "final" state. $pending_states = [self::STATUS_OPEN, self::STATUS_PENDING, self::STATUS_AUTHORIZED]; if (in_array($payment->status, $pending_states)) { $payment->status = $mollie_payment->status; $payment->save(); } if (!empty($credit)) { self::creditPayment($payment, $mollie_payment); } foreach ($refunds as $refund) { $this->storeRefund($payment->wallet, $refund); } DB::commit(); if (!empty($notify)) { \App\Jobs\PaymentEmail::dispatch($payment); } return 200; } /** * Get Mollie customer identifier for specified wallet. * Create one if does not exist yet. * * @param \App\Wallet $wallet The wallet * @param bool $create Create the customer if does not exist yet * * @return ?string Mollie customer identifier */ protected static function mollieCustomerId(Wallet $wallet, bool $create = false): ?string { $customer_id = $wallet->getSetting('mollie_id'); // Register the user in Mollie if (empty($customer_id) && $create) { $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) { try { return mollie()->mandates()->getForId($customer_id, $mandate_id); } catch (ApiException $e) { // FIXME: What about 404? if ($e->getCode() == 410) { // The mandate is gone, remove the reference $wallet->setSetting('mollie_mandate_id', null); return null; } // TODO: Maybe we shouldn't always throw? It make sense in the job // but for example when we're just fetching wallet info... throw $e; } } } /** * Apply the successful payment's pecunia to the wallet */ protected static function creditPayment($payment, $mollie_payment) { // Extract the payment method for transaction description $method = self::paymentMethod($mollie_payment, 'Mollie'); // TODO: Localization? $description = $payment->type == self::TYPE_RECURRING ? 'Auto-payment' : 'Payment'; $description .= " transaction {$payment->id} using {$method}"; $payment->wallet->credit($payment->amount, $description); // Unlock the disabled auto-payment mandate if ($payment->wallet->balance >= 0) { $payment->wallet->setSetting('mandate_disabled', null); } } /** * Extract payment method description from Mollie payment/mandate details */ protected static function paymentMethod($object, $default = ''): string { $details = $object->details; // Mollie supports 3 methods here switch ($object->method) { case self::METHOD_CREDITCARD: // If the customer started, but never finished the 'first' payment // card details will be empty, and mandate will be 'pending'. if (empty($details->cardNumber)) { return 'Credit Card'; } return sprintf( '%s (**** **** **** %s)', $details->cardLabel ?: 'Card', // @phpstan-ignore-line $details->cardNumber ); case self::METHOD_DIRECTDEBIT: return sprintf('Direct Debit (%s)', $details->customerAccount); case self::METHOD_PAYPAL: return sprintf('PayPal (%s)', $details->consumerAccount); } return $default; } /** * List supported payment methods. * * @param string $type The payment type for which we require a method (oneoff/recurring). * * @return array Array of array with available payment methods: * - id: id of the method * - name: User readable name of the payment method * - minimumAmount: Minimum amount to be charged in cents * - currency: Currency used for the method * - exchangeRate: The projected exchange rate (actual rate is determined during payment) * - icon: An icon (icon name) representing the method */ public function providerPaymentMethods($type): array { $providerMethods = array_merge( // Fallback to EUR methods (later provider methods will override earlier ones) //mollie()->methods()->allActive( // [ // 'sequenceType' => $type, // 'amount' => [ // 'value' => '1.00', // 'currency' => 'EUR' // ] // ] //), // Prefer CHF methods (array)mollie()->methods()->allActive( [ 'sequenceType' => $type, 'amount' => [ 'value' => '1.00', 'currency' => 'CHF' ] ] ) ); $availableMethods = []; foreach ($providerMethods as $method) { $availableMethods[$method->id] = [ 'id' => $method->id, 'name' => $method->description, 'minimumAmount' => round(floatval($method->minimumAmount->value) * 100), // Converted to cents 'currency' => $method->minimumAmount->currency, 'exchangeRate' => $this->exchangeRate('CHF', $method->minimumAmount->currency) ]; } return $availableMethods; } /** * Get a payment. * * @param string $paymentId Payment identifier * * @return array Payment information: * - id: Payment identifier * - status: Payment status * - isCancelable: The payment can be canceled * - checkoutUrl: The checkout url to complete the payment or null if none */ public function getPayment($paymentId): array { $payment = mollie()->payments()->get($paymentId); return [ 'id' => $payment->id, 'status' => $payment->status, 'isCancelable' => $payment->isCancelable, 'checkoutUrl' => $payment->getCheckoutUrl() ]; } } diff --git a/src/app/Providers/Payment/Stripe.php b/src/app/Providers/Payment/Stripe.php index 6c20642d..d96f2af6 100644 --- a/src/app/Providers/Payment/Stripe.php +++ b/src/app/Providers/Payment/Stripe.php @@ -1,558 +1,558 @@ tag */ public function customerLink(Wallet $wallet): ?string { $customer_id = self::stripeCustomerId($wallet, false); if (!$customer_id) { return null; } $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 (not used) * - 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, true); $request = [ 'customer' => $customer_id, - 'cancel_url' => Utils::serviceUrl('/wallet'), // required - 'success_url' => Utils::serviceUrl('/wallet'), // required + 'cancel_url' => self::redirectUrl(), // required + 'success_url' => self::redirectUrl(), // required 'payment_method_types' => ['card'], // required 'locale' => 'en', 'mode' => 'setup', ]; // Note: Stripe does not allow to set amount for 'setup' operation // We'll dispatch WalletCharge job when we receive a webhook request $session = StripeAPI\Checkout\Session::create($request); $payment['amount'] = 0; $payment['currency_amount'] = 0; $payment['id'] = $session->setup_intent; $payment['type'] = self::TYPE_MANDATE; $this->storePayment($payment, $wallet->id); 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', 'method' => self::paymentMethod($pm, '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, true); $amount = $this->exchange($payment['amount'], $wallet->currency, $payment['currency']); $payment['currency_amount'] = $amount; $request = [ 'customer' => $customer_id, - 'cancel_url' => Utils::serviceUrl('/wallet'), // required - 'success_url' => Utils::serviceUrl('/wallet'), // required + 'cancel_url' => self::redirectUrl(), // required + 'success_url' => self::redirectUrl(), // required 'payment_method_types' => ['card'], // required 'locale' => 'en', 'line_items' => [ [ 'name' => $payment['description'], 'amount' => $amount, 'currency' => \strtolower($payment['currency']), 'quantity' => 1, ] ] ]; $session = StripeAPI\Checkout\Session::create($request); // Store the payment reference in database $payment['id'] = $session->payment_intent; $this->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; } $amount = $this->exchange($payment['amount'], $wallet->currency, $payment['currency']); $payment['currency_amount'] = $amount; $request = [ 'amount' => $amount, 'currency' => \strtolower($payment['currency']), 'description' => $payment['description'], 'receipt_email' => $wallet->owner->email, 'customer' => $mandate->customer, 'payment_method' => $mandate->payment_method, 'off_session' => true, 'confirm' => true, ]; $intent = StripeAPI\PaymentIntent::create($request); // Store the payment reference in database $payment['id'] = $intent->id; $this->storePayment($payment, $wallet->id); return [ 'id' => $payment['id'], ]; } /** * Update payment status (and balance). * * @return int HTTP response code */ public function webhook(): int { // We cannot just use php://input as it's already "emptied" by the framework // $payload = file_get_contents('php://input'); $request = Request::instance(); $payload = $request->getContent(); $sig_header = $request->header('Stripe-Signature'); // Parse and validate the input try { $event = StripeAPI\Webhook::constructEvent( $payload, $sig_header, \config('services.stripe.webhook_secret') ); } catch (\Exception $e) { \Log::error("Invalid payload: " . $e->getMessage()); // 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); if (empty($payment) || $payment->type == self::TYPE_MANDATE) { return 404; } 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_FAILED; } DB::beginTransaction(); if ($status == self::STATUS_PAID) { // Update the balance, if it wasn't already if ($payment->status != self::STATUS_PAID) { $this->creditPayment($payment, $intent); } } else { if (!empty($intent->last_payment_error)) { // See https://stripe.com/docs/error-codes for more info \Log::info(sprintf( 'Stripe payment failed (%s): %s', $payment->id, json_encode($intent->last_payment_error) )); } } if ($payment->status != self::STATUS_PAID) { $payment->status = $status; $payment->save(); if ($status != self::STATUS_CANCELED && $payment->type == self::TYPE_RECURRING) { // Disable the mandate if ($status == self::STATUS_FAILED) { $payment->wallet->setSetting('mandate_disabled', 1); } // Notify the user \App\Jobs\PaymentEmail::dispatch($payment); } } DB::commit(); break; case StripeAPI\Event::SETUP_INTENT_SUCCEEDED: case StripeAPI\Event::SETUP_INTENT_SETUP_FAILED: case StripeAPI\Event::SETUP_INTENT_CANCELED: $intent = $event->data->object; // @phpstan-ignore-line $payment = Payment::find($intent->id); if (empty($payment) || $payment->type != self::TYPE_MANDATE) { return 404; } switch ($intent->status) { case StripeAPI\SetupIntent::STATUS_CANCELED: $status = self::STATUS_CANCELED; break; case StripeAPI\SetupIntent::STATUS_SUCCEEDED: $status = self::STATUS_PAID; break; default: $status = self::STATUS_FAILED; } if ($status == self::STATUS_PAID) { $payment->wallet->setSetting('stripe_mandate_id', $intent->id); $threshold = intval((float) $payment->wallet->getSetting('mandate_balance') * 100); // Top-up the wallet if balance is below the threshold if ($payment->wallet->balance < $threshold && $payment->status != self::STATUS_PAID) { \App\Jobs\WalletCharge::dispatch($payment->wallet); } } $payment->status = $status; $payment->save(); break; default: \Log::debug("Unhandled Stripe event: " . var_export($payload, true)); break; } return 200; } /** * Get Stripe customer identifier for specified wallet. * Create one if does not exist yet. * * @param \App\Wallet $wallet The wallet * @param bool $create Create the customer if does not exist yet * * @return string|null Stripe customer identifier */ protected static function stripeCustomerId(Wallet $wallet, bool $create = false): ?string { $customer_id = $wallet->getSetting('stripe_id'); // Register the user in Stripe if (empty($customer_id) && $create) { $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; } } } /** * Apply the successful payment's pecunia to the wallet */ protected static function creditPayment(Payment $payment, $intent) { $method = 'Stripe'; // Extract the payment method for transaction description if ( !empty($intent->charges) && ($charge = $intent->charges->data[0]) && ($pm = $charge->payment_method_details) ) { $method = self::paymentMethod($pm); } // TODO: Localization? $description = $payment->type == self::TYPE_RECURRING ? 'Auto-payment' : 'Payment'; $description .= " transaction {$payment->id} using {$method}"; $payment->wallet->credit($payment->amount, $description); // Unlock the disabled auto-payment mandate if ($payment->wallet->balance >= 0) { $payment->wallet->setSetting('mandate_disabled', null); } } /** * Extract payment method description from Stripe payment details */ protected static function paymentMethod($details, $default = ''): string { switch ($details->type) { case 'card': // TODO: card number return \sprintf( '%s (**** **** **** %s)', \ucfirst($details->card->brand) ?: 'Card', $details->card->last4 ); } return $default; } /** * List supported payment methods. * * @param string $type The payment type for which we require a method (oneoff/recurring). * * @return array Array of array with available payment methods: * - id: id of the method * - name: User readable name of the payment method * - minimumAmount: Minimum amount to be charged in cents * - currency: Currency used for the method * - exchangeRate: The projected exchange rate (actual rate is determined during payment) * - icon: An icon (icon name) representing the method */ public function providerPaymentMethods($type): array { //TODO get this from the stripe API? $availableMethods = []; switch ($type) { case self::TYPE_ONEOFF: $availableMethods = [ self::METHOD_CREDITCARD => [ 'id' => self::METHOD_CREDITCARD, 'name' => "Credit Card", 'minimumAmount' => self::MIN_AMOUNT, 'currency' => 'CHF', 'exchangeRate' => 1.0 ], self::METHOD_PAYPAL => [ 'id' => self::METHOD_PAYPAL, 'name' => "PayPal", 'minimumAmount' => self::MIN_AMOUNT, 'currency' => 'CHF', 'exchangeRate' => 1.0 ] ]; break; case self::TYPE_RECURRING: $availableMethods = [ self::METHOD_CREDITCARD => [ 'id' => self::METHOD_CREDITCARD, 'name' => "Credit Card", 'minimumAmount' => self::MIN_AMOUNT, // Converted to cents, 'currency' => 'CHF', 'exchangeRate' => 1.0 ] ]; break; } return $availableMethods; } /** * Get a payment. * * @param string $paymentId Payment identifier * * @return array Payment information: * - id: Payment identifier * - status: Payment status * - isCancelable: The payment can be canceled * - checkoutUrl: The checkout url to complete the payment or null if none */ public function getPayment($paymentId): array { \Log::info("Stripe::getPayment does not yet retrieve a checkoutUrl."); $payment = StripeAPI\PaymentIntent::retrieve($paymentId); return [ 'id' => $payment->id, 'status' => $payment->status, 'isCancelable' => false, 'checkoutUrl' => null ]; } } diff --git a/src/app/Providers/PaymentProvider.php b/src/app/Providers/PaymentProvider.php index 188c3811..b4f1558e 100644 --- a/src/app/Providers/PaymentProvider.php +++ b/src/app/Providers/PaymentProvider.php @@ -1,390 +1,408 @@ ['prefix' => 'far', 'name' => 'credit-card'], self::METHOD_PAYPAL => ['prefix' => 'fab', 'name' => 'paypal'], self::METHOD_BANKTRANSFER => ['prefix' => 'fas', 'name' => 'university'] ]; /** * Detect the name of the provider * * @param \App\Wallet|string|null $provider_or_wallet * @return string The name of the provider */ private static function providerName($provider_or_wallet = null): string { if ($provider_or_wallet instanceof Wallet) { if ($provider_or_wallet->getSetting('stripe_id')) { $provider = self::PROVIDER_STRIPE; } elseif ($provider_or_wallet->getSetting('mollie_id')) { $provider = self::PROVIDER_MOLLIE; } } else { $provider = $provider_or_wallet; } if (empty($provider)) { $provider = \config('services.payment_provider') ?: self::PROVIDER_MOLLIE; } return \strtolower($provider); } /** * Factory method * * @param \App\Wallet|string|null $provider_or_wallet */ public static function factory($provider_or_wallet = null) { switch (self::providerName($provider_or_wallet)) { case self::PROVIDER_STRIPE: return new \App\Providers\Payment\Stripe(); case self::PROVIDER_MOLLIE: return new \App\Providers\Payment\Mollie(); default: throw new \Exception("Invalid payment provider: {$provider_or_wallet}"); } } /** * 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. * - methodId: Payment method * * @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. * - methodId: Payment method * - 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 * - methodId: Payment method * * @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 * * @return \App\Payment Payment object */ protected function storePayment(array $payment, $wallet_id): Payment { $db_payment = new Payment(); $db_payment->id = $payment['id']; $db_payment->description = $payment['description'] ?? ''; $db_payment->status = $payment['status'] ?? self::STATUS_OPEN; $db_payment->amount = $payment['amount'] ?? 0; $db_payment->type = $payment['type']; $db_payment->wallet_id = $wallet_id; $db_payment->provider = $this->name(); $db_payment->currency = $payment['currency']; $db_payment->currency_amount = $payment['currency_amount']; $db_payment->save(); return $db_payment; } /** * Retrieve an exchange rate. * * @param string $sourceCurrency Currency from which to convert * @param string $targetCurrency Currency to convert to * * @return float Exchange rate */ protected function exchangeRate(string $sourceCurrency, string $targetCurrency): float { if (strcasecmp($sourceCurrency, $targetCurrency)) { throw new \Exception("Currency conversion is not yet implemented."); //FIXME Not yet implemented } return 1.0; } /** * Convert a value from $sourceCurrency to $targetCurrency * * @param int $amount Amount in cents of $sourceCurrency * @param string $sourceCurrency Currency from which to convert * @param string $targetCurrency Currency to convert to * * @return int Exchanged amount in cents of $targetCurrency */ protected function exchange(int $amount, string $sourceCurrency, string $targetCurrency): int { return intval(round($amount * $this->exchangeRate($sourceCurrency, $targetCurrency))); } /** * Deduct an amount of pecunia from the wallet. * Creates a payment and transaction records for the refund/chargeback operation. * * @param \App\Wallet $wallet A wallet object * @param array $refund A refund or chargeback data (id, type, amount, description) * * @return void */ protected function storeRefund(Wallet $wallet, array $refund): void { if (empty($refund) || empty($refund['amount'])) { return; } // Preserve originally refunded amount $refund['currency_amount'] = $refund['amount']; // Convert amount to wallet currency // TODO We should possibly be using the same exchange rate as for the original payment? $amount = $this->exchange($refund['amount'], $refund['currency'], $wallet->currency); $wallet->balance -= $amount; $wallet->save(); if ($refund['type'] == self::TYPE_CHARGEBACK) { $transaction_type = Transaction::WALLET_CHARGEBACK; } else { $transaction_type = Transaction::WALLET_REFUND; } Transaction::create([ 'object_id' => $wallet->id, 'object_type' => Wallet::class, 'type' => $transaction_type, 'amount' => $amount * -1, 'description' => $refund['description'] ?? '', ]); $refund['status'] = self::STATUS_PAID; $refund['amount'] = -1 * $amount; // FIXME: Refunds/chargebacks are out of the reseller comissioning for now $this->storePayment($refund, $wallet->id); } /** * List supported payment methods from this provider * * @param string $type The payment type for which we require a method (oneoff/recurring). * * @return array Array of array with available payment methods: * - id: id of the method * - name: User readable name of the payment method * - minimumAmount: Minimum amount to be charged in cents * - currency: Currency used for the method * - exchangeRate: The projected exchange rate (actual rate is determined during payment) * - icon: An icon (icon name) representing the method */ abstract public function providerPaymentMethods($type): array; /** * Get a payment. * * @param string $paymentId Payment identifier * * @return array Payment information: * - id: Payment identifier * - status: Payment status * - isCancelable: The payment can be canceled * - checkoutUrl: The checkout url to complete the payment or null if none */ abstract public function getPayment($paymentId): array; /** * Return an array of whitelisted payment methods with override values. * * @param string $type The payment type for which we require a method. * * @return array Array of methods */ protected static function paymentMethodsWhitelist($type): array { switch ($type) { case self::TYPE_ONEOFF: return [ self::METHOD_CREDITCARD => [ 'id' => self::METHOD_CREDITCARD, 'icon' => self::$paymentMethodIcons[self::METHOD_CREDITCARD] ], self::METHOD_PAYPAL => [ 'id' => self::METHOD_PAYPAL, 'icon' => self::$paymentMethodIcons[self::METHOD_PAYPAL] ], // TODO Enable once we're ready to offer them // self::METHOD_BANKTRANSFER => [ // 'id' => self::METHOD_BANKTRANSFER, // 'icon' => self::$paymentMethodIcons[self::METHOD_BANKTRANSFER] // ] ]; case PaymentProvider::TYPE_RECURRING: return [ self::METHOD_CREDITCARD => [ 'id' => self::METHOD_CREDITCARD, 'icon' => self::$paymentMethodIcons[self::METHOD_CREDITCARD] ] ]; } \Log::error("Unknown payment type: " . $type); return []; } /** * Return an array of whitelisted payment methods with override values. * * @param string $type The payment type for which we require a method. * * @return array Array of methods */ private static function applyMethodWhitelist($type, $availableMethods): array { $methods = []; // Use only whitelisted methods, and apply values from whitelist (overriding the backend) $whitelistMethods = self::paymentMethodsWhitelist($type); foreach ($whitelistMethods as $id => $whitelistMethod) { if (array_key_exists($id, $availableMethods)) { $methods[] = array_merge($availableMethods[$id], $whitelistMethod); } } return $methods; } /** * List supported payment methods for $wallet * * @param \App\Wallet $wallet The wallet * @param string $type The payment type for which we require a method (oneoff/recurring). * * @return array Array of array with available payment methods: * - id: id of the method * - name: User readable name of the payment method * - minimumAmount: Minimum amount to be charged in cents * - currency: Currency used for the method * - exchangeRate: The projected exchange rate (actual rate is determined during payment) * - icon: An icon (icon name) representing the method */ public static function paymentMethods(Wallet $wallet, $type): array { $providerName = self::providerName($wallet); $cacheKey = "methods-" . $providerName . '-' . $type; if ($methods = Cache::get($cacheKey)) { \Log::debug("Using payment method cache" . var_export($methods, true)); return $methods; } $provider = PaymentProvider::factory($providerName); $methods = self::applyMethodWhitelist($type, $provider->providerPaymentMethods($type)); Cache::put($cacheKey, $methods, now()->addHours(1)); return $methods; } + + /** + * Returns the full URL for the wallet page, used when returning from an external payment page. + * Depending on the request origin it will return a URL for the User or Reseller UI. + * + * @return string The redirect URL + */ + public static function redirectUrl(): string + { + $url = \App\Utils::serviceUrl('/wallet'); + $domain = preg_replace('/:[0-9]+$/', '', request()->getHttpHost()); + + if (strpos($domain, 'reseller') === 0) { + $url = preg_replace('|^(https?://)([^/]+)|', '\\1' . $domain, $url); + } + + return $url; + } } diff --git a/src/resources/js/reseller/routes.js b/src/resources/js/reseller/routes.js index c43dcf25..d0296f0b 100644 --- a/src/resources/js/reseller/routes.js +++ b/src/resources/js/reseller/routes.js @@ -1,69 +1,76 @@ import DashboardComponent from '../../vue/Reseller/Dashboard' import DistlistComponent from '../../vue/Admin/Distlist' import DomainComponent from '../../vue/Admin/Domain' import InvitationsComponent from '../../vue/Reseller/Invitations' import LoginComponent from '../../vue/Login' import LogoutComponent from '../../vue/Logout' import PageComponent from '../../vue/Page' import StatsComponent from '../../vue/Reseller/Stats' import UserComponent from '../../vue/Admin/User' +import WalletComponent from '../../vue/Wallet' const routes = [ { path: '/', redirect: { name: 'dashboard' } }, { path: '/dashboard', name: 'dashboard', component: DashboardComponent, meta: { requiresAuth: true } }, { path: '/distlist/:list', name: 'distlist', component: DistlistComponent, meta: { requiresAuth: true } }, { path: '/domain/:domain', name: 'domain', component: DomainComponent, meta: { requiresAuth: true } }, { path: '/login', name: 'login', component: LoginComponent }, { path: '/logout', name: 'logout', component: LogoutComponent }, { path: '/invitations', name: 'invitations', component: InvitationsComponent, meta: { requiresAuth: true } }, { path: '/stats', name: 'stats', component: StatsComponent, meta: { requiresAuth: true } }, { path: '/user/:user', name: 'user', component: UserComponent, meta: { requiresAuth: true } }, + { + path: '/wallet', + name: 'wallet', + component: WalletComponent, + meta: { requiresAuth: true } + }, { name: '404', path: '*', component: PageComponent } ] export default routes diff --git a/src/resources/vue/Reseller/Dashboard.vue b/src/resources/vue/Reseller/Dashboard.vue index 0b1d4484..95791e1c 100644 --- a/src/resources/vue/Reseller/Dashboard.vue +++ b/src/resources/vue/Reseller/Dashboard.vue @@ -1,27 +1,51 @@ diff --git a/src/routes/api.php b/src/routes/api.php index d586e94f..f23ff8d1 100644 --- a/src/routes/api.php +++ b/src/routes/api.php @@ -1,205 +1,217 @@ 'api', 'prefix' => $prefix . 'api/auth' ], function ($router) { Route::post('login', 'API\AuthController@login'); Route::group( ['middleware' => 'auth:api'], function ($router) { Route::get('info', 'API\AuthController@info'); Route::post('logout', 'API\AuthController@logout'); Route::post('refresh', 'API\AuthController@refresh'); } ); } ); Route::group( [ 'domain' => \config('app.domain'), 'middleware' => 'api', 'prefix' => $prefix . 'api/auth' ], function ($router) { Route::post('password-reset/init', 'API\PasswordResetController@init'); Route::post('password-reset/verify', 'API\PasswordResetController@verify'); Route::post('password-reset', 'API\PasswordResetController@reset'); Route::post('signup/init', 'API\SignupController@init'); Route::get('signup/invitations/{id}', 'API\SignupController@invitation'); Route::get('signup/plans', 'API\SignupController@plans'); Route::post('signup/verify', 'API\SignupController@verify'); Route::post('signup', 'API\SignupController@signup'); } ); Route::group( [ 'domain' => \config('app.domain'), 'middleware' => 'auth:api', 'prefix' => $prefix . 'api/v4' ], function () { Route::apiResource('domains', API\V4\DomainsController::class); Route::get('domains/{id}/confirm', 'API\V4\DomainsController@confirm'); Route::get('domains/{id}/status', 'API\V4\DomainsController@status'); Route::apiResource('groups', API\V4\GroupsController::class); Route::get('groups/{id}/status', 'API\V4\GroupsController@status'); Route::apiResource('packages', API\V4\PackagesController::class); Route::apiResource('skus', API\V4\SkusController::class); Route::apiResource('users', API\V4\UsersController::class); Route::get('users/{id}/skus', 'API\V4\SkusController@userSkus'); Route::get('users/{id}/status', 'API\V4\UsersController@status'); Route::apiResource('wallets', API\V4\WalletsController::class); Route::get('wallets/{id}/transactions', 'API\V4\WalletsController@transactions'); Route::get('wallets/{id}/receipts', 'API\V4\WalletsController@receipts'); Route::get('wallets/{id}/receipts/{receipt}', 'API\V4\WalletsController@receiptDownload'); Route::post('payments', 'API\V4\PaymentsController@store'); //Route::delete('payments', 'API\V4\PaymentsController@cancel'); 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'); Route::get('payments/methods', 'API\V4\PaymentsController@paymentMethods'); Route::get('payments/pending', 'API\V4\PaymentsController@payments'); Route::get('payments/has-pending', 'API\V4\PaymentsController@hasPayments'); Route::get('openvidu/rooms', 'API\V4\OpenViduController@index'); Route::post('openvidu/rooms/{id}/close', 'API\V4\OpenViduController@closeRoom'); Route::post('openvidu/rooms/{id}/config', 'API\V4\OpenViduController@setRoomConfig'); // FIXME: I'm not sure about this one, should we use DELETE request maybe? Route::post('openvidu/rooms/{id}/connections/{conn}/dismiss', 'API\V4\OpenViduController@dismissConnection'); Route::put('openvidu/rooms/{id}/connections/{conn}', 'API\V4\OpenViduController@updateConnection'); Route::post('openvidu/rooms/{id}/request/{reqid}/accept', 'API\V4\OpenViduController@acceptJoinRequest'); Route::post('openvidu/rooms/{id}/request/{reqid}/deny', 'API\V4\OpenViduController@denyJoinRequest'); } ); // Note: In Laravel 7.x we could just use withoutMiddleware() instead of a separate group Route::group( [ 'domain' => \config('app.domain'), 'prefix' => $prefix . 'api/v4' ], function () { Route::post('openvidu/rooms/{id}', 'API\V4\OpenViduController@joinRoom'); Route::post('openvidu/rooms/{id}/connections', 'API\V4\OpenViduController@createConnection'); // FIXME: I'm not sure about this one, should we use DELETE request maybe? Route::post('openvidu/rooms/{id}/connections/{conn}/dismiss', 'API\V4\OpenViduController@dismissConnection'); Route::put('openvidu/rooms/{id}/connections/{conn}', 'API\V4\OpenViduController@updateConnection'); Route::post('openvidu/rooms/{id}/request/{reqid}/accept', 'API\V4\OpenViduController@acceptJoinRequest'); Route::post('openvidu/rooms/{id}/request/{reqid}/deny', 'API\V4\OpenViduController@denyJoinRequest'); } ); Route::group( [ 'domain' => \config('app.domain'), 'middleware' => 'api', 'prefix' => $prefix . 'api/v4' ], function ($router) { Route::post('support/request', 'API\V4\SupportController@request'); } ); Route::group( [ 'domain' => \config('app.domain'), 'prefix' => $prefix . 'api/webhooks', ], function () { Route::post('payment/{provider}', 'API\V4\PaymentsController@webhook'); Route::post('meet/openvidu', 'API\V4\OpenViduController@webhook'); } ); Route::group( [ 'domain' => 'admin.' . \config('app.domain'), 'middleware' => ['auth:api', 'admin'], 'prefix' => $prefix . 'api/v4', ], function () { Route::apiResource('domains', API\V4\Admin\DomainsController::class); Route::post('domains/{id}/suspend', 'API\V4\Admin\DomainsController@suspend'); Route::post('domains/{id}/unsuspend', 'API\V4\Admin\DomainsController@unsuspend'); Route::apiResource('groups', API\V4\Admin\GroupsController::class); Route::post('groups/{id}/suspend', 'API\V4\Admin\GroupsController@suspend'); Route::post('groups/{id}/unsuspend', 'API\V4\Admin\GroupsController@unsuspend'); Route::apiResource('packages', API\V4\Admin\PackagesController::class); Route::apiResource('skus', API\V4\Admin\SkusController::class); Route::apiResource('users', API\V4\Admin\UsersController::class); Route::post('users/{id}/reset2FA', 'API\V4\Admin\UsersController@reset2FA'); Route::get('users/{id}/skus', 'API\V4\Admin\SkusController@userSkus'); Route::post('users/{id}/suspend', 'API\V4\Admin\UsersController@suspend'); Route::post('users/{id}/unsuspend', 'API\V4\Admin\UsersController@unsuspend'); Route::apiResource('wallets', API\V4\Admin\WalletsController::class); Route::post('wallets/{id}/one-off', 'API\V4\Admin\WalletsController@oneOff'); Route::get('wallets/{id}/transactions', 'API\V4\Admin\WalletsController@transactions'); Route::apiResource('discounts', API\V4\Admin\DiscountsController::class); Route::get('stats/chart/{chart}', 'API\V4\Admin\StatsController@chart'); } ); Route::group( [ 'domain' => 'reseller.' . \config('app.domain'), 'middleware' => ['auth:api', 'reseller'], 'prefix' => $prefix . 'api/v4', ], function () { Route::apiResource('domains', API\V4\Reseller\DomainsController::class); Route::post('domains/{id}/suspend', 'API\V4\Reseller\DomainsController@suspend'); Route::post('domains/{id}/unsuspend', 'API\V4\Reseller\DomainsController@unsuspend'); Route::apiResource('groups', API\V4\Reseller\GroupsController::class); Route::post('groups/{id}/suspend', 'API\V4\Reseller\GroupsController@suspend'); Route::post('groups/{id}/unsuspend', 'API\V4\Reseller\GroupsController@unsuspend'); Route::apiResource('invitations', API\V4\Reseller\InvitationsController::class); Route::post('invitations/{id}/resend', 'API\V4\Reseller\InvitationsController@resend'); Route::apiResource('packages', API\V4\Reseller\PackagesController::class); + + Route::post('payments', 'API\V4\Reseller\PaymentsController@store'); + Route::get('payments/mandate', 'API\V4\Reseller\PaymentsController@mandate'); + Route::post('payments/mandate', 'API\V4\Reseller\PaymentsController@mandateCreate'); + Route::put('payments/mandate', 'API\V4\Reseller\PaymentsController@mandateUpdate'); + Route::delete('payments/mandate', 'API\V4\Reseller\PaymentsController@mandateDelete'); + Route::get('payments/methods', 'API\V4\Reseller\PaymentsController@paymentMethods'); + Route::get('payments/pending', 'API\V4\Reseller\PaymentsController@payments'); + Route::get('payments/has-pending', 'API\V4\Reseller\PaymentsController@hasPayments'); + Route::apiResource('skus', API\V4\Reseller\SkusController::class); Route::apiResource('users', API\V4\Reseller\UsersController::class); Route::post('users/{id}/reset2FA', 'API\V4\Reseller\UsersController@reset2FA'); Route::get('users/{id}/skus', 'API\V4\Reseller\SkusController@userSkus'); Route::post('users/{id}/suspend', 'API\V4\Reseller\UsersController@suspend'); Route::post('users/{id}/unsuspend', 'API\V4\Reseller\UsersController@unsuspend'); Route::apiResource('wallets', API\V4\Reseller\WalletsController::class); Route::post('wallets/{id}/one-off', 'API\V4\Reseller\WalletsController@oneOff'); + Route::get('wallets/{id}/receipts', 'API\V4\Reseller\WalletsController@receipts'); + Route::get('wallets/{id}/receipts/{receipt}', 'API\V4\Reseller\WalletsController@receiptDownload'); Route::get('wallets/{id}/transactions', 'API\V4\Reseller\WalletsController@transactions'); Route::apiResource('discounts', API\V4\Reseller\DiscountsController::class); Route::get('stats/chart/{chart}', 'API\V4\Reseller\StatsController@chart'); } ); diff --git a/src/tests/Browser/Pages/Home.php b/src/tests/Browser/Pages/Home.php index 0cc87564..2a600fd4 100644 --- a/src/tests/Browser/Pages/Home.php +++ b/src/tests/Browser/Pages/Home.php @@ -1,87 +1,88 @@ waitForLocation($this->url()) ->waitUntilMissing('.app-loader') ->assertVisible('form.form-signin'); } /** * Get the element shortcuts for the page. * * @return array */ public function elements() { return [ '@app' => '#app', '@email-input' => '#inputEmail', '@password-input' => '#inputPassword', '@second-factor-input' => '#secondfactor', '@logon-button' => '#logon-form button.btn-primary' ]; } /** * Submit logon form. * - * @param \Laravel\Dusk\Browser $browser The browser object - * @param string $username User name - * @param string $password User password - * @param bool $wait_for_dashboard - * @param array $config Client-site config + * @param \Tests\Browser $browser The browser object + * @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, $config = [] ) { - $browser->type('@email-input', $username) + $browser->clearToasts() + ->type('@email-input', $username) ->type('@password-input', $password); if ($username == 'ned@kolab.org') { $code = \App\Auth\SecondFactor::code('ned@kolab.org'); $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) { $browser->waitForLocation('/dashboard'); } } } diff --git a/src/tests/Browser/Reseller/LogonTest.php b/src/tests/Browser/Reseller/LogonTest.php index 966e0278..656904e7 100644 --- a/src/tests/Browser/Reseller/LogonTest.php +++ b/src/tests/Browser/Reseller/LogonTest.php @@ -1,144 +1,144 @@ browse(function (Browser $browser) { $browser->visit(new Home()) ->with(new Menu(), function ($browser) { - $browser->assertMenuItems(['explore', 'blog', 'support', 'login']); + $browser->assertMenuItems(['explore', 'blog', 'support', 'login', 'lang']); }) ->assertMissing('@second-factor-input') ->assertMissing('@forgot-password'); }); } /** * Test redirect to /login if user is unauthenticated */ public function testLogonRedirect(): void { $this->browse(function (Browser $browser) { $browser->visit('/dashboard'); // Checks if we're really on the login page $browser->waitForLocation('/login') ->on(new Home()); }); } /** * Logon with wrong password/user test */ public function testLogonWrongCredentials(): void { $this->browse(function (Browser $browser) { $browser->visit(new Home()) ->submitLogon('reseller@reseller.com', 'wrong') // Error message ->assertToast(Toast::TYPE_ERROR, 'Invalid username or password.') // Checks if we're still on the logon page ->on(new Home()); }); } /** * Successful logon test */ public function testLogonSuccessful(): void { $this->browse(function (Browser $browser) { $browser->visit(new Home()) ->submitLogon('reseller@reseller.com', 'reseller', true); // Checks if we're really on Dashboard page $browser->on(new Dashboard()) ->within(new Menu(), function ($browser) { - $browser->assertMenuItems(['explore', 'blog', 'support', 'dashboard', 'logout']); + $browser->assertMenuItems(['explore', 'blog', 'support', 'dashboard', 'logout', 'lang']); }) ->assertUser('reseller@reseller.com'); // Test that visiting '/' with logged in user does not open logon form // but "redirects" to the dashboard $browser->visit('/')->on(new Dashboard()); }); } /** * Logout test * * @depends testLogonSuccessful */ public function testLogout(): void { $this->browse(function (Browser $browser) { $browser->on(new Dashboard()); // Click the Logout button $browser->within(new Menu(), function ($browser) { $browser->clickMenuItem('logout'); }); // We expect the logon page $browser->waitForLocation('/login') ->on(new Home()); // with default menu $browser->within(new Menu(), function ($browser) { - $browser->assertMenuItems(['explore', 'blog', 'support', 'login']); + $browser->assertMenuItems(['explore', 'blog', 'support', 'login', 'lang']); }); // Success toast message $browser->assertToast(Toast::TYPE_SUCCESS, 'Successfully logged out'); }); } /** * Logout by URL test */ public function testLogoutByURL(): void { $this->browse(function (Browser $browser) { $browser->visit(new Home()) ->submitLogon('reseller@reseller.com', 'reseller', true); // Checks if we're really on Dashboard page $browser->on(new Dashboard()); // Use /logout url, and expect the logon page $browser->visit('/logout') ->waitForLocation('/login') ->on(new Home()); // with default menu $browser->within(new Menu(), function ($browser) { - $browser->assertMenuItems(['explore', 'blog', 'support', 'login']); + $browser->assertMenuItems(['explore', 'blog', 'support', 'login', 'lang']); }); // Success toast message $browser->assertToast(Toast::TYPE_SUCCESS, 'Successfully logged out'); }); } } diff --git a/src/tests/Browser/Reseller/PaymentMollieTest.php b/src/tests/Browser/Reseller/PaymentMollieTest.php new file mode 100644 index 00000000..7bb9ef29 --- /dev/null +++ b/src/tests/Browser/Reseller/PaymentMollieTest.php @@ -0,0 +1,116 @@ +getTestUser('reseller@kolabnow.com'); + $wallet = $user->wallets()->first(); + $wallet->payments()->delete(); + $wallet->balance = 0; + $wallet->save(); + + parent::tearDown(); + } + + /** + * Test the payment process + * + * @group mollie + */ + public function testPayment(): void + { + $this->browse(function (Browser $browser) { + $user = $this->getTestUser('reseller@kolabnow.com'); + $wallet = $user->wallets()->first(); + $wallet->payments()->delete(); + $wallet->balance = 0; + $wallet->save(); + + $browser->visit(new Home()) + ->submitLogon($user->email, 'reseller', 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') + ->waitFor('#payment-method-selection #creditcard') + ->waitFor('#payment-method-selection #paypal') + ->assertMissing('#payment-method-selection #banktransfer') + ->click('#creditcard'); + }) + ->with(new Dialog('@payment-dialog'), function (Browser $browser) { + $browser->assertSeeIn('@title', 'Top up your wallet') + ->assertFocused('#amount') + ->assertSeeIn('@button-cancel', 'Cancel') + ->assertSeeIn('@button-action', 'Continue') + // Test error handling + ->type('@body #amount', 'aaa') + ->click('@button-action') + ->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') + // Note we use double click to assert it does not create redundant requests + ->click('@button-action') + ->click('@button-action'); + }) + ->on(new PaymentMollie()) + ->assertSeeIn('@title', \config('app.name') . ' Payment') + ->assertSeeIn('@amount', 'CHF 12.34'); + + $this->assertSame(1, $wallet->payments()->count()); + + // 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-title', 'Account balance 12,34 CHF'); + }); + } +} diff --git a/src/tests/Browser/Reseller/WalletTest.php b/src/tests/Browser/Reseller/WalletTest.php new file mode 100644 index 00000000..4fc4f2ac --- /dev/null +++ b/src/tests/Browser/Reseller/WalletTest.php @@ -0,0 +1,248 @@ +getTestUser('reseller@kolabnow.com'); + $wallet = $reseller->wallets()->first(); + $wallet->balance = 0; + $wallet->save(); + $wallet->payments()->delete(); + $wallet->transactions()->delete(); + + parent::tearDown(); + } + + /** + * Test wallet page (unauthenticated) + */ + public function testWalletUnauth(): void + { + // Test that the page requires authentication + $this->browse(function (Browser $browser) { + $browser->visit('/wallet')->on(new Home()); + }); + } + + /** + * Test wallet "box" on Dashboard + */ + public function testDashboard(): void + { + $reseller = $this->getTestUser('reseller@kolabnow.com'); + Wallet::where('user_id', $reseller->id)->update(['balance' => 125]); + + // Positive balance + $this->browse(function (Browser $browser) { + $browser->visit(new Home()) + ->submitLogon('reseller@kolabnow.com', 'reseller', true) + ->on(new Dashboard()) + ->assertSeeIn('@links .link-wallet .name', 'Wallet') + ->assertSeeIn('@links .link-wallet .badge-success', '1,25 CHF'); + }); + + Wallet::where('user_id', $reseller->id)->update(['balance' => -1234]); + + // Negative balance + $this->browse(function (Browser $browser) { + $browser->visit(new Dashboard()) + ->assertSeeIn('@links .link-wallet .name', 'Wallet') + ->assertSeeIn('@links .link-wallet .badge-danger', '-12,34 CHF'); + }); + } + + /** + * Test wallet page + * + * @depends testDashboard + */ + public function testWallet(): void + { + $reseller = $this->getTestUser('reseller@kolabnow.com'); + Wallet::where('user_id', $reseller->id)->update(['balance' => -1234]); + + $this->browse(function (Browser $browser) { + $browser->click('@links .link-wallet') + ->on(new WalletPage()) + ->assertSeeIn('#wallet .card-title', 'Account balance -12,34 CHF') + ->assertSeeIn('#wallet .card-title .text-danger', '-12,34 CHF') + ->assertSeeIn('#wallet .card-text', 'You are out of credit'); + }); + } + + /** + * Test Receipts tab + * + * @depends testWallet + */ + public function testReceipts(): void + { + $user = $this->getTestUser('reseller@kolabnow.com'); + $wallet = $user->wallets()->first(); + $wallet->payments()->delete(); + + // Assert Receipts tab content when there's no receipts available + $this->browse(function (Browser $browser) { + $browser->visit(new WalletPage()) + ->assertSeeIn('#wallet .card-title', 'Account balance 0,00 CHF') + ->assertSeeIn('#wallet .card-title .text-success', '0,00 CHF') + ->assertSeeIn('#wallet .card-text', 'You are in your free trial period.') // TODO + ->assertSeeIn('@nav #tab-receipts', 'Receipts') + ->with('@receipts-tab', function (Browser $browser) { + $browser->waitUntilMissing('.app-loader') + ->assertSeeIn('p', 'There are no receipts for payments') + ->assertDontSeeIn('p', 'Here you can download') + ->assertMissing('select') + ->assertMissing('button'); + }); + }); + + // Create some sample payments + $receipts = []; + $date = Carbon::create(intval(date('Y')) - 1, 3, 30); + $payment = Payment::create([ + 'id' => 'AAA1', + 'status' => PaymentProvider::STATUS_PAID, + 'type' => PaymentProvider::TYPE_ONEOFF, + 'description' => 'Paid in March', + 'wallet_id' => $wallet->id, + 'provider' => 'stripe', + 'amount' => 1111, + 'currency_amount' => 1111, + 'currency' => 'CHF', + ]); + $payment->updated_at = $date; + $payment->save(); + $receipts[] = $date->format('Y-m'); + + $date = Carbon::create(intval(date('Y')) - 1, 4, 30); + $payment = Payment::create([ + 'id' => 'AAA2', + 'status' => PaymentProvider::STATUS_PAID, + 'type' => PaymentProvider::TYPE_ONEOFF, + 'description' => 'Paid in April', + 'wallet_id' => $wallet->id, + 'provider' => 'stripe', + 'amount' => 1111, + 'currency_amount' => 1111, + 'currency' => 'CHF', + ]); + $payment->updated_at = $date; + $payment->save(); + $receipts[] = $date->format('Y-m'); + + // Assert Receipts tab with receipts available + $this->browse(function (Browser $browser) use ($receipts) { + $browser->refresh() + ->on(new WalletPage()) + ->assertSeeIn('@nav #tab-receipts', 'Receipts') + ->with('@receipts-tab', function (Browser $browser) use ($receipts) { + $browser->waitUntilMissing('.app-loader') + ->assertDontSeeIn('p', 'There are no receipts for payments') + ->assertSeeIn('p', 'Here you can download') + ->assertSeeIn('button', 'Download') + ->assertElementsCount('select > option', 2) + ->assertSeeIn('select > option:nth-child(1)', $receipts[1]) + ->assertSeeIn('select > option:nth-child(2)', $receipts[0]); + + // Download a receipt file + $browser->select('select', $receipts[0]) + ->click('button') + ->pause(2000); + + $files = glob(__DIR__ . '/../downloads/*.pdf'); + + $filename = pathinfo($files[0], PATHINFO_BASENAME); + $this->assertTrue(strpos($filename, $receipts[0]) !== false); + + $content = $browser->readDownloadedFile($filename, 0); + $this->assertStringStartsWith("%PDF-1.", $content); + + $browser->removeDownloadedFile($filename); + }); + }); + } + + /** + * Test History tab + * + * @depends testWallet + */ + public function testHistory(): void + { + $user = $this->getTestUser('reseller@kolabnow.com'); + $wallet = $user->wallets()->first(); + $wallet->transactions()->delete(); + + // Create some sample transactions + $transactions = $this->createTestTransactions($wallet); + $transactions = array_reverse($transactions); + $pages = array_chunk($transactions, 10 /* page size*/); + + $this->browse(function (Browser $browser) use ($pages) { + $browser->on(new WalletPage()) + ->assertSeeIn('@nav #tab-history', 'History') + ->click('@nav #tab-history') + ->with('@history-tab', function (Browser $browser) use ($pages) { + $browser->waitUntilMissing('.app-loader') + ->assertElementsCount('table tbody tr', 10) + ->assertMissing('table td.email') + ->assertSeeIn('#transactions-loader button', 'Load more'); + + foreach ($pages[0] as $idx => $transaction) { + $selector = 'table tbody tr:nth-child(' . ($idx + 1) . ')'; + $priceStyle = $transaction->type == Transaction::WALLET_AWARD ? 'text-success' : 'text-danger'; + $browser->assertSeeIn("$selector td.description", $transaction->shortDescription()) + ->assertMissing("$selector td.selection button") + ->assertVisible("$selector td.price.{$priceStyle}"); + // TODO: Test more transaction details + } + + // Load the next page + $browser->click('#transactions-loader button') + ->waitUntilMissing('.app-loader') + ->assertElementsCount('table tbody tr', 12) + ->assertMissing('#transactions-loader button'); + + $debitEntry = null; + foreach ($pages[1] as $idx => $transaction) { + $selector = 'table tbody tr:nth-child(' . ($idx + 1 + 10) . ')'; + $priceStyle = $transaction->type == Transaction::WALLET_CREDIT ? 'text-success' : 'text-danger'; + $browser->assertSeeIn("$selector td.description", $transaction->shortDescription()); + + if ($transaction->type == Transaction::WALLET_DEBIT) { + $debitEntry = $selector; + } else { + $browser->assertMissing("$selector td.selection button"); + } + } + }); + }); + } +} diff --git a/src/tests/Feature/Controller/Reseller/PaymentsMollieTest.php b/src/tests/Feature/Controller/Reseller/PaymentsMollieTest.php new file mode 100644 index 00000000..8488de99 --- /dev/null +++ b/src/tests/Feature/Controller/Reseller/PaymentsMollieTest.php @@ -0,0 +1,257 @@ + 'mollie']); + + $reseller = $this->getTestUser('reseller@kolabnow.com'); + $wallet = $reseller->wallets()->first(); + Payment::where('wallet_id', $wallet->id)->delete(); + Wallet::where('id', $wallet->id)->update(['balance' => 0]); + WalletSetting::where('wallet_id', $wallet->id)->delete(); + Transaction::where('object_id', $wallet->id)->delete(); + } + + /** + * {@inheritDoc} + */ + public function tearDown(): void + { + $reseller = $this->getTestUser('reseller@kolabnow.com'); + $wallet = $reseller->wallets()->first(); + Payment::where('wallet_id', $wallet->id)->delete(); + Wallet::where('id', $wallet->id)->update(['balance' => 0]); + WalletSetting::where('wallet_id', $wallet->id)->delete(); + Transaction::where('object_id', $wallet->id)->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); + + $reseller = $this->getTestUser('reseller@kolabnow.com'); + $wallet = $reseller->wallets()->first(); + $wallet->balance = -10; + $wallet->save(); + + // Test creating a mandate (valid input) + $post = ['amount' => 20.10, 'balance' => 0]; + $response = $this->actingAs($reseller)->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']); + + // Assert the proper payment amount has been used + $payment = Payment::where('id', $json['id'])->first(); + + $this->assertSame(2010, $payment->amount); + $this->assertSame($wallet->id, $payment->wallet_id); + $this->assertSame(\config('app.name') . " Auto-Payment Setup", $payment->description); + $this->assertSame(PaymentProvider::TYPE_MANDATE, $payment->type); + + // Test fetching the mandate information + $response = $this->actingAs($reseller)->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']); + $this->assertSame(false, $json['isDisabled']); + + $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' => $mandate_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))); + + $wallet = $reseller->wallets()->first(); + $wallet->setSetting('mandate_disabled', 1); + + $response = $this->actingAs($reseller)->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']); + $this->assertSame(true, $json['isDisabled']); + + Bus::fake(); + $wallet->setSetting('mandate_disabled', null); + $wallet->balance = 1000; + $wallet->save(); + + // Test updating a mandate (valid input) + $responseStack->append(new Response(200, [], json_encode($mollie_response))); + + $post = ['amount' => 30.10, 'balance' => 10]; + $response = $this->actingAs($reseller)->put("api/v4/payments/mandate", $post); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertSame('success', $json['status']); + $this->assertSame('The auto-payment has been updated.', $json['message']); + $this->assertSame($mandate_id, $json['id']); + $this->assertFalse($json['isDisabled']); + + $wallet->refresh(); + + $this->assertEquals(30.10, $wallet->getSetting('mandate_amount')); + $this->assertEquals(10, $wallet->getSetting('mandate_balance')); + + Bus::assertDispatchedTimes(\App\Jobs\WalletCharge::class, 0); + + $this->unmockMollie(); + + // Delete mandate + $response = $this->actingAs($reseller)->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']); + } + + /** + * Test creating a payment + * + * @group mollie + */ + public function testStore(): void + { + Bus::fake(); + + // Unauth access not allowed + $response = $this->post("api/v4/payments", []); + $response->assertStatus(401); + + $reseller = $this->getTestUser('reseller@kolabnow.com'); + + // Successful payment + $post = ['amount' => '12.34', 'currency' => 'CHF', 'methodId' => 'creditcard']; + $response = $this->actingAs($reseller)->post("api/v4/payments", $post); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertSame('success', $json['status']); + $this->assertRegExp('|^https://www.mollie.com|', $json['redirectUrl']); + } + + /** + * Test listing a pending payment + * + * @group mollie + */ + public function testListingPayments(): void + { + Bus::fake(); + + $reseller = $this->getTestUser('reseller@kolabnow.com'); + + // Empty response + $response = $this->actingAs($reseller)->get("api/v4/payments/pending"); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertSame('success', $json['status']); + $this->assertSame(0, $json['count']); + $this->assertSame(1, $json['page']); + $this->assertSame(false, $json['hasMore']); + $this->assertCount(0, $json['list']); + + $response = $this->actingAs($reseller)->get("api/v4/payments/has-pending"); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertSame(false, $json['hasPending']); + } + + /** + * Test listing payment methods + * + * @group mollie + */ + public function testListingPaymentMethods(): void + { + Bus::fake(); + + $reseller = $this->getTestUser('reseller@kolabnow.com'); + + $response = $this->actingAs($reseller)->get('api/v4/payments/methods?type=' . PaymentProvider::TYPE_ONEOFF); + $response->assertStatus(200); + $json = $response->json(); + + $this->assertCount(2, $json); + $this->assertSame('creditcard', $json[0]['id']); + $this->assertSame('paypal', $json[1]['id']); + } +} diff --git a/src/tests/Feature/Controller/Reseller/WalletsTest.php b/src/tests/Feature/Controller/Reseller/WalletsTest.php index a48bd408..202fe7bf 100644 --- a/src/tests/Feature/Controller/Reseller/WalletsTest.php +++ b/src/tests/Feature/Controller/Reseller/WalletsTest.php @@ -1,311 +1,312 @@ 1]); } /** * {@inheritDoc} */ public function tearDown(): void { \config(['app.tenant_id' => 1]); parent::tearDown(); } /** * 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'); $reseller1 = $this->getTestUser('reseller@kolabnow.com'); $reseller2 = $this->getTestUser('reseller@reseller.com'); $wallet = $user->wallets()->first(); $wallet->discount_id = null; $wallet->save(); // 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(403); // Reseller from a different tenant $response = $this->actingAs($reseller2)->get("api/v4/wallets/{$wallet->id}"); $response->assertStatus(403); // Reseller $response = $this->actingAs($reseller1)->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($wallet->balance, $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'])); + $this->assertTrue(!empty($json['notice'])); // Reseller from a different tenant \config(['app.tenant_id' => 2]); $response = $this->actingAs($reseller2)->get("api/v4/wallets/{$wallet->id}"); $response->assertStatus(404); } /** * Test awarding/penalizing a wallet (POST /api/v4/wallets/:id/one-off) */ public function testOneOff(): void { $user = $this->getTestUser('john@kolab.org'); $admin = $this->getTestUser('jeroen@jeroen.jeroen'); $reseller1 = $this->getTestUser('reseller@kolabnow.com'); $reseller2 = $this->getTestUser('reseller@reseller.com'); $wallet = $user->wallets()->first(); $reseller1_wallet = $reseller1->wallets()->first(); $balance = $wallet->balance; $reseller1_balance = $reseller1_wallet->balance; Transaction::where('object_id', $wallet->id) ->whereIn('type', [Transaction::WALLET_AWARD, Transaction::WALLET_PENALTY]) ->delete(); Transaction::where('object_id', $reseller1_wallet->id)->delete(); // Non-admin user $response = $this->actingAs($user)->post("api/v4/wallets/{$wallet->id}/one-off", []); $response->assertStatus(403); // Admin user $response = $this->actingAs($admin)->post("api/v4/wallets/{$wallet->id}/one-off", []); $response->assertStatus(403); // Reseller from a different tenant $response = $this->actingAs($reseller2)->post("api/v4/wallets/{$wallet->id}/one-off", []); $response->assertStatus(403); // Admin user - invalid input $post = ['amount' => 'aaaa']; $response = $this->actingAs($reseller1)->post("api/v4/wallets/{$wallet->id}/one-off", $post); $response->assertStatus(422); $json = $response->json(); $this->assertSame('error', $json['status']); $this->assertSame('The amount must be a number.', $json['errors']['amount'][0]); $this->assertSame('The description field is required.', $json['errors']['description'][0]); $this->assertCount(2, $json); $this->assertCount(2, $json['errors']); // A valid bonus $post = ['amount' => '50', 'description' => 'A bonus']; $response = $this->actingAs($reseller1)->post("api/v4/wallets/{$wallet->id}/one-off", $post); $response->assertStatus(200); $json = $response->json(); $this->assertSame('success', $json['status']); $this->assertSame('The bonus has been added to the wallet successfully.', $json['message']); $this->assertSame($balance += 5000, $json['balance']); $this->assertSame($balance, $wallet->fresh()->balance); $this->assertSame($reseller1_balance -= 5000, $reseller1_wallet->fresh()->balance); $transaction = Transaction::where('object_id', $wallet->id) ->where('type', Transaction::WALLET_AWARD)->first(); $this->assertSame($post['description'], $transaction->description); $this->assertSame(5000, $transaction->amount); $this->assertSame($reseller1->email, $transaction->user_email); $transaction = Transaction::where('object_id', $reseller1_wallet->id) ->where('type', Transaction::WALLET_DEBIT)->first(); $this->assertSame("Awarded user {$user->email}", $transaction->description); $this->assertSame(-5000, $transaction->amount); $this->assertSame($reseller1->email, $transaction->user_email); // A valid penalty $post = ['amount' => '-40', 'description' => 'A penalty']; $response = $this->actingAs($reseller1)->post("api/v4/wallets/{$wallet->id}/one-off", $post); $response->assertStatus(200); $json = $response->json(); $this->assertSame('success', $json['status']); $this->assertSame('The penalty has been added to the wallet successfully.', $json['message']); $this->assertSame($balance -= 4000, $json['balance']); $this->assertSame($balance, $wallet->fresh()->balance); $this->assertSame($reseller1_balance += 4000, $reseller1_wallet->fresh()->balance); $transaction = Transaction::where('object_id', $wallet->id) ->where('type', Transaction::WALLET_PENALTY)->first(); $this->assertSame($post['description'], $transaction->description); $this->assertSame(-4000, $transaction->amount); $this->assertSame($reseller1->email, $transaction->user_email); $transaction = Transaction::where('object_id', $reseller1_wallet->id) ->where('type', Transaction::WALLET_CREDIT)->first(); $this->assertSame("Penalized user {$user->email}", $transaction->description); $this->assertSame(4000, $transaction->amount); $this->assertSame($reseller1->email, $transaction->user_email); // Reseller from a different tenant \config(['app.tenant_id' => 2]); $response = $this->actingAs($reseller2)->post("api/v4/wallets/{$wallet->id}/one-off", []); $response->assertStatus(404); } /** * Test fetching wallet transactions (GET /api/v4/wallets/:id/transactions) */ public function testTransactions(): void { // Note: Here we're testing only that the end-point works, // and admin can get the transaction log, response details // are tested in Feature/Controller/WalletsTest.php $this->deleteTestUser('wallets-controller@kolabnow.com'); $user = $this->getTestUser('wallets-controller@kolabnow.com'); $wallet = $user->wallets()->first(); $admin = $this->getTestUser('jeroen@jeroen.jeroen'); $reseller1 = $this->getTestUser('reseller@kolabnow.com'); $reseller2 = $this->getTestUser('reseller@reseller.com'); // Non-admin $response = $this->actingAs($user)->get("api/v4/wallets/{$wallet->id}/transactions"); $response->assertStatus(403); // Admin $response = $this->actingAs($admin)->get("api/v4/wallets/{$wallet->id}/transactions"); $response->assertStatus(403); // Reseller from a different tenant $response = $this->actingAs($reseller2)->get("api/v4/wallets/{$wallet->id}/transactions"); $response->assertStatus(403); // Create some sample transactions $transactions = $this->createTestTransactions($wallet); $transactions = array_reverse($transactions); $pages = array_chunk($transactions, 10 /* page size*/); // Get the 2nd page $response = $this->actingAs($reseller1)->get("api/v4/wallets/{$wallet->id}/transactions?page=2"); $response->assertStatus(200); $json = $response->json(); $this->assertCount(5, $json); $this->assertSame('success', $json['status']); $this->assertSame(2, $json['page']); $this->assertSame(2, $json['count']); $this->assertSame(false, $json['hasMore']); $this->assertCount(2, $json['list']); foreach ($pages[1] as $idx => $transaction) { $this->assertSame($transaction->id, $json['list'][$idx]['id']); $this->assertSame($transaction->type, $json['list'][$idx]['type']); $this->assertSame($transaction->shortDescription(), $json['list'][$idx]['description']); $this->assertFalse($json['list'][$idx]['hasDetails']); } // The 'user' key is set only on the admin/reseller end-point // FIXME: Should we hide this for resellers? $this->assertSame('jeroen@jeroen.jeroen', $json['list'][1]['user']); // Reseller from a different tenant \config(['app.tenant_id' => 2]); $response = $this->actingAs($reseller2)->get("api/v4/wallets/{$wallet->id}/transactions"); $response->assertStatus(403); } /** * Test updating a wallet (PUT /api/v4/wallets/:id) */ public function testUpdate(): void { $admin = $this->getTestUser('jeroen@jeroen.jeroen'); $reseller1 = $this->getTestUser('reseller@kolabnow.com'); $reseller2 = $this->getTestUser('reseller@reseller.com'); $user = $this->getTestUser('john@kolab.org'); $wallet = $user->wallets()->first(); $discount = Discount::where('code', 'TEST')->first(); // Non-admin user $response = $this->actingAs($user)->put("api/v4/wallets/{$wallet->id}", []); $response->assertStatus(403); // Admin $response = $this->actingAs($admin)->put("api/v4/wallets/{$wallet->id}", []); $response->assertStatus(403); // Reseller from another tenant $response = $this->actingAs($reseller2)->put("api/v4/wallets/{$wallet->id}", []); $response->assertStatus(403); // Admin user - setting a discount $post = ['discount' => $discount->id]; $response = $this->actingAs($reseller1)->put("api/v4/wallets/{$wallet->id}", $post); $response->assertStatus(200); $json = $response->json(); $this->assertSame('success', $json['status']); $this->assertSame('User wallet updated successfully.', $json['message']); $this->assertSame($wallet->id, $json['id']); $this->assertSame($discount->discount, $json['discount']); $this->assertSame($discount->id, $json['discount_id']); $this->assertSame($discount->description, $json['discount_description']); $this->assertSame($discount->id, $wallet->fresh()->discount->id); // Admin user - removing a discount $post = ['discount' => null]; $response = $this->actingAs($reseller1)->put("api/v4/wallets/{$wallet->id}", $post); $response->assertStatus(200); $json = $response->json(); $this->assertSame('success', $json['status']); $this->assertSame('User wallet updated successfully.', $json['message']); $this->assertSame($wallet->id, $json['id']); $this->assertSame(null, $json['discount_id']); $this->assertTrue(empty($json['discount_description'])); $this->assertSame(null, $wallet->fresh()->discount); // Reseller from a different tenant \config(['app.tenant_id' => 2]); $response = $this->actingAs($reseller2)->put("api/v4/wallets/{$wallet->id}", []); $response->assertStatus(404); } }