Page MenuHomePhorge

D1231.1775364479.diff
No OneTemporary

Authored By
Unknown
Size
68 KB
Referenced Files
None
Subscribers
None

D1231.1775364479.diff

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 @@
+<?php
+
+namespace App\Console\Commands;
+
+use App\User;
+use Illuminate\Console\Command;
+
+class MollieInfo extends Command
+{
+ /**
+ * The name and signature of the console command.
+ *
+ * @var string
+ */
+ protected $signature = 'mollie:info {user?}';
+
+ /**
+ * The console command description.
+ *
+ * @var string
+ */
+ protected $description = 'Mollie information';
+
+ /**
+ * Execute the console command.
+ *
+ * @return mixed
+ */
+ public function handle()
+ {
+ if ($this->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 @@
+<?php
+
+namespace App\Console\Commands;
+
+use App\Providers\PaymentProvider;
+use App\User;
+use Illuminate\Console\Command;
+use Stripe as StripeAPI;
+
+class StripeInfo extends Command
+{
+ /**
+ * The name and signature of the console command.
+ *
+ * @var string
+ */
+ protected $signature = 'stripe:info {user?}';
+
+ /**
+ * The console command description.
+ *
+ * @var string
+ */
+ protected $description = 'Stripe information';
+
+ /**
+ * Execute the console command.
+ *
+ * @return mixed
+ */
+ public function handle()
+ {
+ if ($this->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. <wallet-id>@<user-domain>) 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 @@
+<?php
+
+namespace App\Providers\Payment;
+
+use App\Payment;
+use App\Utils;
+use App\Wallet;
+
+class Mollie extends \App\Providers\PaymentProvider
+{
+ /**
+ * Create a new auto-payment mandate for a wallet.
+ *
+ * @param \App\Wallet $wallet The wallet
+ * @param array $payment Payment data:
+ * - amount: Value in cents
+ * - currency: The operation currency
+ * - description: Operation desc.
+ *
+ * @return array Provider payment data:
+ * - id: Operation identifier
+ * - redirectUrl: the location to redirect to
+ */
+ public function createMandate(Wallet $wallet, array $payment): ?array
+ {
+ // Register the user in Mollie, if not yet done
+ $customer_id = self::mollieCustomerId($wallet);
+
+ $request = [
+ 'amount' => [
+ 'currency' => $payment['currency'],
+ 'value' => '0.00',
+ ],
+ 'customerId' => $customer_id,
+ 'sequenceType' => 'first',
+ 'description' => $payment['description'],
+ 'webhookUrl' => Utils::serviceUrl('/api/webhooks/payment/mollie'),
+ 'redirectUrl' => \url('/wallet'),
+ 'locale' => 'en_US',
+ // 'method' => 'creditcard',
+ ];
+
+ // Create the payment in Mollie
+ $response = mollie()->payments()->create($request);
+
+ // 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 @@
+<?php
+
+namespace App\Providers\Payment;
+
+use App\Payment;
+use App\Utils;
+use App\Wallet;
+use App\WalletSetting;
+use Stripe as StripeAPI;
+
+class Stripe extends \App\Providers\PaymentProvider
+{
+ /**
+ * Class constructor.
+ */
+ public function __construct()
+ {
+ StripeAPI\Stripe::setApiKey(\env('STRIPE_KEY'));
+ }
+
+ /**
+ * Create a new auto-payment mandate for a wallet.
+ *
+ * @param \App\Wallet $wallet The wallet
+ * @param array $payment Payment data:
+ * - amount: Value in cents
+ * - currency: The operation currency
+ * - description: Operation desc.
+ *
+ * @return array Provider payment/session data:
+ * - id: Session identifier
+ */
+ public function createMandate(Wallet $wallet, array $payment): ?array
+ {
+ // Register the user in Stripe, if not yet done
+ $customer_id = self::stripeCustomerId($wallet);
+
+ $request = [
+ 'customer' => $customer_id,
+ 'cancel_url' => \url('/wallet'), // required
+ 'success_url' => \url('/wallet'), // required
+ 'payment_method_types' => ['card'], // required
+ 'locale' => 'en',
+ 'mode' => 'setup',
+ ];
+
+ $session = StripeAPI\Checkout\Session::create($request);
+
+ return [
+ 'id' => $session->id,
+ ];
+ }
+
+ /**
+ * Revoke the auto-payment mandate.
+ *
+ * @param \App\Wallet $wallet The wallet
+ *
+ * @return bool True on success, False on failure
+ */
+ public function deleteMandate(Wallet $wallet): bool
+ {
+ // Get the Mandate info
+ $mandate = self::stripeMandate($wallet);
+
+ if ($mandate) {
+ // Remove the reference
+ $wallet->setSetting('stripe_mandate_id', null);
+
+ // Detach the payment method on Stripe
+ $pm = StripeAPI\PaymentMethod::retrieve($mandate->payment_method);
+ $pm->detach();
+ }
+
+ return true;
+ }
+
+ /**
+ * Get a auto-payment mandate for a wallet.
+ *
+ * @param \App\Wallet $wallet The wallet
+ *
+ * @return array|null Mandate information:
+ * - id: Mandate identifier
+ * - method: user-friendly payment method desc.
+ * - isPending: the process didn't complete yet
+ * - isValid: the mandate is valid
+ */
+ public function getMandate(Wallet $wallet): ?array
+ {
+ // Get the Mandate info
+ $mandate = self::stripeMandate($wallet);
+
+ if (empty($mandate)) {
+ return null;
+ }
+
+ $pm = StripeAPI\PaymentMethod::retrieve($mandate->payment_method);
+
+ $result = [
+ 'id' => $mandate->id,
+ 'isPending' => $mandate->status != 'succeeded' && $mandate->status != 'canceled',
+ 'isValid' => $mandate->status == 'succeeded',
+ ];
+
+ switch ($pm->type) {
+ case 'card':
+ // TODO: card number
+ $result['method'] = \sprintf(
+ '%s (**** **** **** %s)',
+ \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 @@
+<?php
+
+namespace App\Providers;
+
+use App\Payment;
+use App\Wallet;
+
+abstract class PaymentProvider
+{
+ public const STATUS_OPEN = 'open';
+ public const STATUS_CANCELED = 'canceled';
+ public const STATUS_PENDING = 'pending';
+ public const STATUS_AUTHORIZED = 'authorized';
+ public const STATUS_EXPIRED = 'expired';
+ public const STATUS_FAILED = 'failed';
+ public const STATUS_PAID = 'paid';
+
+ public const TYPE_ONEOFF = 'oneoff';
+ public const TYPE_RECURRING = 'recurring';
+
+ /** const int Minimum amount of money in a single payment (in cents) */
+ public const MIN_AMOUNT = 1000;
+
+ /**
+ * Factory method
+ */
+ public static function factory(string $provider = null)
+ {
+ if (!$provider) {
+ $provider = \env('PAYMENT_PROVIDER') ?: 'mollie';
+ }
+
+ switch ($provider) {
+ case 'stripe':
+ return new \App\Providers\Payment\Stripe();
+
+ case 'mollie':
+ return new \App\Providers\Payment\Mollie();
+
+ default:
+ throw new \Exception("Invalid payment provider: {$provider}");
+ }
+ }
+
+ /**
+ * Create a new auto-payment mandate for a wallet.
+ *
+ * @param \App\Wallet $wallet The wallet
+ * @param array $payment Payment data:
+ * - amount: Value in cents
+ * - currency: The operation currency
+ * - description: Operation desc.
+ *
+ * @return array Provider payment data:
+ * - id: Operation identifier
+ * - redirectUrl: the location to redirect to
+ */
+ abstract public function createMandate(Wallet $wallet, array $payment): ?array;
+
+ /**
+ * Revoke the auto-payment mandate for a wallet.
+ *
+ * @param \App\Wallet $wallet The wallet
+ *
+ * @return bool True on success, False on failure
+ */
+ abstract public function deleteMandate(Wallet $wallet): bool;
+
+ /**
+ * Get a auto-payment mandate for a wallet.
+ *
+ * @param \App\Wallet $wallet The wallet
+ *
+ * @return array|null Mandate information:
+ * - id: Mandate identifier
+ * - method: user-friendly payment method desc.
+ * - isPending: the process didn't complete yet
+ * - isValid: the mandate is valid
+ */
+ abstract public function getMandate(Wallet $wallet): ?array;
+
+ /**
+ * Create a new payment.
+ *
+ * @param \App\Wallet $wallet The wallet
+ * @param array $payment Payment data:
+ * - amount: Value in cents
+ * - currency: The operation currency
+ * - type: first/oneoff/recurring
+ * - description: Operation description
+ *
+ * @return array Provider payment/session data:
+ * - id: Operation identifier
+ * - redirectUrl
+ */
+ abstract public function payment(Wallet $wallet, array $payment): ?array;
+
+ /**
+ * Update payment status (and balance).
+ *
+ * @return int HTTP response code
+ */
+ abstract public function webhook(): int;
+
+ /**
+ * Create a payment record in DB
+ *
+ * @param array $payment Payment information
+ * @param string $wallet_id Wallet ID
+ */
+ protected static function storePayment(array $payment, $wallet_id): void
+ {
+ $db_payment = new Payment();
+ $db_payment->id = $payment['id'];
+ $db_payment->description = $payment['description'];
+ $db_payment->status = $payment['status'];
+ $db_payment->amount = $payment['amount'];
+ $db_payment->wallet_id = $wallet_id;
+ $db_payment->save();
+ }
+}
diff --git a/src/app/Traits/UserSettingsTrait.php b/src/app/Traits/SettingsTrait.php
rename from src/app/Traits/UserSettingsTrait.php
rename to src/app/Traits/SettingsTrait.php
--- a/src/app/Traits/UserSettingsTrait.php
+++ b/src/app/Traits/SettingsTrait.php
@@ -2,10 +2,9 @@
namespace App\Traits;
-use App\UserSetting;
use Illuminate\Support\Facades\Cache;
-trait UserSettingsTrait
+trait SettingsTrait
{
/**
* Obtain the value for a setting.
@@ -17,9 +16,9 @@
* $locale = $user->getSetting('locale');
* ```
*
- * @param string $key Lookup key
+ * @param string $key Setting name
*
- * @return string|null
+ * @return string|null Setting value
*/
public function getSetting(string $key)
{
@@ -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 @@
+<?php
+
+namespace App;
+
+use Illuminate\Database\Eloquent\Model;
+
+/**
+ * A collection of settings for a Wallet.
+ *
+ * @property int $id
+ * @property string $wallet_id
+ * @property string $key
+ * @property string $value
+ */
+class WalletSetting extends Model
+{
+ protected $fillable = [
+ 'wallet_id', 'key', 'value'
+ ];
+
+ /**
+ * The wallet to which this setting belongs.
+ *
+ * @return \Illuminate\Database\Eloquent\Relations\BelongsTo
+ */
+ public function wallet()
+ {
+ return $this->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 = '<div class="app-loader"><div class="spinner-border" role="status"><span class="sr-only">Loading</span></div></div>'
+
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($('<div class="app-loader"><div class="spinner-border" role="status"><span class="sr-only">Loading</span></div></div>'))
+ $('#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 @@
<p>Current account balance is
<span :class="balance < 0 ? 'text-danger' : 'text-success'"><strong>{{ $root.price(balance) }}</strong></span>
</p>
- <button type="button" class="btn btn-primary" @click="payment()">Add 10 bucks to my wallet</button>
+ <button type="button" class="btn btn-primary" @click="paymentDialog()">Add credit</button>
+ </div>
+ </div>
+ </div>
+
+ <div id="payment-dialog" class="modal" tabindex="-1" role="dialog">
+ <div class="modal-dialog" role="document">
+ <div class="modal-content">
+ <div class="modal-header">
+ <h5 class="modal-title">{{ paymentDialogTitle }}</h5>
+ <button type="button" class="close" data-dismiss="modal" aria-label="Close">
+ <span aria-hidden="true">&times;</span>
+ </button>
+ </div>
+ <div class="modal-body">
+ <div id="payment" v-if="paymentForm == 'init'">
+ <p>Choose the amount by which you want to top up your wallet.</p>
+ <form id="payment-form" @submit.prevent="payment">
+ <div class="form-group">
+ <div class="input-group">
+ <input type="text" class="form-control" id="amount" v-model="amount" required>
+ <span class="input-group-append">
+ <span class="input-group-text">{{ wallet_currency }}</span>
+ </span>
+ </div>
+ </div>
+ <div class="w-100 text-center">
+ <button type="submit" class="btn btn-primary">Continue</button>
+ </div>
+ </form>
+ <div class="form-separator"><hr><span>or</span></div>
+ <div id="mandate-form" v-if="!mandate.id">
+ <p>Add auto-payment, so you never run out.</p>
+ <div class="w-100 text-center">
+ <button type="button" class="btn btn-primary" @click="autoPaymentForm">Set up auto-payment</button>
+ </div>
+ </div>
+ <div id="mandate-info" v-if="mandate.id">
+ <p>Auto-payment is set to fill up your account by <b>{{ mandate.amount }} CHF</b>
+ every time your account balance gets under <b>{{ mandate.balance }} CHF</b>.
+ You will be charged via {{ mandate.method }}.
+ </p>
+ <p>You can cancel or change the auto-payment at any time.</p>
+ <div class="form-group d-flex justify-content-around">
+ <button type="button" class="btn btn-danger" @click="autoPaymentDelete">Cancel auto-payment</button>
+ <button type="button" class="btn btn-primary" @click="autoPaymentChange">Change auto-payment</button>
+ </div>
+ </div>
+ </div>
+ <div id="auto-payment" v-if="paymentForm == 'auto'">
+ <form id="payment-form" @submit.prevent="autoPayment">
+ <p>Here is how it works: Every time your account runs low,
+ we will charge your preferred payment method for an amount you choose.
+ You can cancel or change the auto-payment option at any time.
+ </p>
+ <div class="form-group row">
+ <label for="mandate_amount" class="col-sm-6 col-form-label">Fill up by</label>
+ <div class="input-group col-sm-6">
+ <input type="text" class="form-control" id="mandate_amount" v-model="mandate.amount" required>
+ <span class="input-group-append">
+ <span class="input-group-text">{{ wallet_currency }}</span>
+ </span>
+ </div>
+ </div>
+ <div class="form-group row">
+ <label for="mandate_balance" class="col-sm-6 col-form-label">when account balance is below</label>
+ <div class="col-sm-6">
+ <div class="input-group">
+ <input type="text" class="form-control" id="mandate_balance" v-model="mandate.balance" required>
+ <span class="input-group-append">
+ <span class="input-group-text">{{ wallet_currency }}</span>
+ </span>
+ </div>
+ </div>
+ </div>
+ <p v-if="!mandate.id">
+ Next, you will be redirected to the checkout page, where you can provide
+ your credit card details.
+ </p>
+ <div class="w-100 text-center">
+ <button type="submit" class="btn btn-primary">
+ {{ mandate.id ? 'Submit' : 'Continue' }}
+ </button>
+ </div>
+ </form>
+ </div>
+ </div>
+ <div class="modal-footer">
+ <button type="button" class="btn btn-secondary modal-cancel" data-dismiss="modal">Cancel</button>
+ </div>
</div>
</div>
</div>
@@ -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);

File Metadata

Mime Type
text/plain
Expires
Sun, Apr 5, 4:47 AM (16 h, 35 m ago)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
18832369
Default Alt Text
D1231.1775364479.diff (68 KB)

Event Timeline