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/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,247 @@ 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(); + + $provider = PaymentProvider::factory(); + + // 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 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(); + $rules = [ + 'mandate_amount' => 'required|numeric|min:0', + 'mandate_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->mandate_amount * 100); + + // Validate the minimum value + if ($amount < PaymentProvider::MIN_AMOUNT) { + $errors = ['mandate_amount' => \trans('validation.minamount')]; + 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->mandate_amount); + $wallet->setSetting('mandate_balance', $request->mandate_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(); + + $result = $provider->createMandate($wallet, $request); + + $result['status'] = 'success'; + + return response()->json($result); + } - // Store the payment reference in database - self::storePayment($payment, $wallet->id, $request->amount); + /** + * 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(); + + $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(); + + $rules = [ + 'mandate_amount' => 'required|numeric|min:0', + 'mandate_balance' => 'required|numeric|min:0', + ]; - // Get the payment details from Mollie - $payment = mollie()->payments()->get($request->id); + // Check required fields + $v = Validator::make($request->all(), $rules); - if (empty($payment)) { - return response('Success', 200); - } + // TODO: allow comma as a decimal point? - 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 - } + 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->mandate_amount * 100); + + // Validate the minimum value + if ($amount < PaymentProvider::MIN_AMOUNT) { + $errors = ['mandate_amount' => \trans('validation.minamount')]; + return response()->json(['status' => 'error', 'errors' => $errors], 422); } - return response('Success', 200); + $wallet->setSetting('mandate_amount', $request->mandate_amount); + $wallet->setSetting('mandate_balance', $request->mandate_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|min:0', + ]; + + // 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) { + $errors = ['amount' => \trans('validation.minamount')]; + 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(); + + $result = $provider->payment($wallet, $request); - // Store the payment reference in database - self::storePayment($payment, $wallet->id, $amount); + $result['status'] = 'success'; - return true; + return response()->json($result); } /** - * Create self URL + * Update payment status (and balance). * - * @param string $route Route/Path + * @param string $provider Provider name * - * @return string Full URL + * @return \Illuminate\Http\Response The response */ - protected static function serviceUrl(string $route): string + public function webhook($provider) { - $url = \url($route); - - $app_url = trim(\config('app.url'), '/'); - $pub_url = trim(\config('app.public_url'), '/'); + $code = 200; - if ($pub_url != $app_url) { - $url = str_replace($app_url, $pub_url, $url); + if ($provider = PaymentProvider::factory($provider)) { + $code = $provider->webhook(); } - return $url; + return response($code < 400 ? 'Success' : 'Server error', $code); } /** - * Create a payment record in DB + * Charge a wallet with a "recurring" payment. + * + * @param \App\Wallet $wallet The wallet to charge + * @param int $amount The amount of money in cents * - * @param object $payment Mollie payment - * @param string $wallet_id Wallet ID - * @param int $amount Amount of money in cents + * @return bool */ - protected static function storePayment($payment, $wallet_id, $amount): void + public static function directCharge(Wallet $wallet, $amount): bool { - $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(); + $request = [ + 'type' => PaymentProvider::TYPE_RECURRING, + 'currency' => 'CHF', + 'amount' => $amount, + 'description' => \config('app.name') . ' Recurring Payment', + ]; + + $provider = PaymentProvider::factory(); + + if ($result = $provider->payment($wallet, $request)) { + return true; + } + + return false; } } 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,316 @@ + [ + '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); + + // Store the payment reference in database + // TODO: This is not really needed as the amount=0 for this + // operation, and we don't really need to track it. + $payment['status'] = $response->status; + $payment['id'] = $response->id; + $payment['amount'] = 0; + + self::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. + * - 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', + $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; + } + + /** + * 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::getMandate($wallet); + + if (empty($mandate)) { + 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 = Payment::find($_POST['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 + } + } + + // 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 = self::mollieCustomerId($wallet); + + $customer = mollie()->customers()->get($customer_id); + + // Get the manadate reference we already have + if ($mandate_id = $wallet->getSetting('mollie_mandate_id')) { + $mandate = $customer->getMandate($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,334 @@ + $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)', + \ucfirst($pm->card->brand) ?: 'Card', + $pm->card->last4 + ); + + break; + + default: + $result['method'] = 'Unknown method'; + } + + return $result; + } + + /** + * 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, + \env('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; + $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); + } + } + + if ($payment->status != self::STATUS_PAID) { + $payment->status = $status; + $payment->save(); + } + + break; + + case StripeAPI\Event::SETUP_INTENT_SUCCEEDED: + $intent = $event->data->object; + + // 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're not use these + + if ($mandate_id = $wallet->getSetting('stripe_mandate_id')) { + $mandate = StripeAPI\SetupIntent::retrieve($mandate_id); + 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,121 @@ +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) { @@ -34,6 +33,25 @@ return empty($value) ? null : $value; } + /** + * 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. * @@ -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,9 +98,36 @@ $isAdmin = strpos(request()->getHttpHost(), 'admin.') === 0; $env['jsapp'] = $isAdmin ? 'admin.js' : 'user.js'; + $env['paymentProvider'] = \env('PAYMENT_PROVIDER'); + + if ($env['paymentProvider'] == 'stripe') { + $env['stripePK'] = \env('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 * 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/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 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,8 @@ | The following language lines are used in the application. */ + 'mandate-delete-success' => 'The auto-payment has been removed.', + 'planbutton' => 'Choose :plan', 'process-user-new' => 'Registering a user...', 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 @@ -83,6 +83,12 @@ border-width: 15px; color: #b2aa99; } + + &.small .spinner-border { + width: 25px; + height: 25px; + border-width: 3px; + } } pre { @@ -212,3 +218,24 @@ margin-bottom: 0; } } + +.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; + } +} diff --git a/src/resources/vue/Wallet.vue b/src/resources/vue/Wallet.vue --- a/src/resources/vue/Wallet.vue +++ b/src/resources/vue/Wallet.vue @@ -7,7 +7,96 @@

Current account balance is {{ $root.price(balance) }}

- + + + + + + @@ -18,7 +107,13 @@ export default { data() { return { + amount: '', balance: 0, + mandate: { amount: 10, balance: 0 }, + paymentDialogTitle: null, + paymentForm: 'init', + stripe: null, + wallet_currency: 'CHF' } }, mounted() { @@ -27,15 +122,117 @@ this.$store.state.authInfo.wallets.forEach(wallet => { this.balance += wallet.balance }) + + if (window.config.stripePK) { + 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}) + 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 = { + mandate_amount: this.mandate.amount, + mandate_balance: this.mandate.balance + } + + 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' + this.dialog.find('#mandate_amount').focus() + }, + 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/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);