Page Menu
Home
Phorge
Search
Configure Global Search
Log In
Files
F117875993
D2239.1775335327.diff
No One
Temporary
Actions
View File
Edit File
Delete File
View Transforms
Subscribe
Flag For Later
Award Token
Authored By
Unknown
Size
57 KB
Referenced Files
None
Subscribers
None
D2239.1775335327.diff
View Options
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
@@ -5,6 +5,7 @@
use App\Http\Controllers\Controller;
use App\Providers\PaymentProvider;
use App\Wallet;
+use App\Payment;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Validator;
@@ -55,6 +56,7 @@
$mandate = [
'currency' => 'CHF',
'description' => \config('app.name') . ' Auto-Payment Setup',
+ 'methodId' => $request->methodId
];
// Normally the auto-payment setup operation is 0, if the balance is below the threshold
@@ -217,8 +219,9 @@
$request = [
'type' => PaymentProvider::TYPE_ONEOFF,
- 'currency' => 'CHF',
+ 'currency' => $request->currency,
'amount' => $amount,
+ 'methodId' => $request->methodId,
'description' => \config('app.name') . ' Payment',
];
@@ -231,6 +234,31 @@
return response()->json($result);
}
+ /**
+ * Delete a pending payment.
+ *
+ * @param \Illuminate\Http\Request $request The API request.
+ *
+ * @return \Illuminate\Http\JsonResponse The response
+ */
+ public function cancel(Request $request)
+ {
+ $current_user = Auth::guard()->user();
+
+ // TODO: Wallet selection
+ $wallet = $current_user->wallets()->first();
+
+ $paymentId = $request->payment;
+
+ $provider = PaymentProvider::factory($wallet);
+
+ $result = $provider->cancel($wallet, $paymentId);
+
+ $result['status'] = 'success';
+
+ return response()->json($result);
+ }
+
/**
* Update payment status (and balance).
*
@@ -325,4 +353,87 @@
return $mandate;
}
+
+
+ /**
+ * List supported payment methods.
+ *
+ * @param \Illuminate\Http\Request $request The API request.
+ *
+ * @return \Illuminate\Http\JsonResponse The response
+ */
+ public static function paymentMethods(Request $request)
+ {
+ $user = Auth::guard()->user();
+
+ // TODO: Wallet selection
+ $wallet = $user->wallets()->first();
+
+ $provider = PaymentProvider::factory($wallet);
+
+ $methods = $provider->paymentMethods($request->type);
+
+ \Log::debug("Provider methods" . var_export(json_encode($methods), true));
+
+ return response()->json($methods);
+ }
+
+
+ /**
+ * List pending payments.
+ *
+ * @param \Illuminate\Http\Request $request The API request.
+ *
+ * @return \Illuminate\Http\JsonResponse The response
+ */
+ public static function payments(Request $request)
+ {
+ $user = Auth::guard()->user();
+
+ // TODO: Wallet selection
+ $wallet = $user->wallets()->first();
+
+ $provider = PaymentProvider::factory($wallet);
+ $pageSize = 10;
+ $page = intval(request()->input('page')) ?: 1;
+ $hasMore = false;
+ $result = Payment::where('wallet_id', $wallet->id)
+ ->where('type', PaymentProvider::TYPE_ONEOFF)
+ ->whereIn('status', [
+ PaymentProvider::STATUS_OPEN,
+ PaymentProvider::STATUS_PENDING,
+ PaymentProvider::STATUS_AUTHORIZED])
+ ->limit($pageSize + 1)
+ ->offset($pageSize * ($page - 1))
+ ->get();
+
+ if (count($result) > $pageSize) {
+ $result->pop();
+ $hasMore = true;
+ }
+
+ $result = $result->map(function ($item) use ($provider) {
+ $payment = $provider->getPayment($item->id);
+ $entry = [
+ 'id' => $item->id,
+ 'createdAt' => $item->created_at->format('Y-m-d H:i'),
+ 'type' => $item->type,
+ 'description' => $item->description,
+ 'amount' => $item->amount,
+ 'status' => $item->status,
+ 'isCancelable' => $payment['isCancelable'],
+ 'checkoutUrl' => $payment['checkoutUrl']
+ ];
+
+ return $entry;
+ });
+
+ return response()->json([
+ 'status' => 'success',
+ 'list' => $result,
+ 'count' => count($result),
+ 'hasMore' => $hasMore,
+ 'page' => $page,
+ ]);
+ }
}
diff --git a/src/app/Payment.php b/src/app/Payment.php
--- a/src/app/Payment.php
+++ b/src/app/Payment.php
@@ -7,11 +7,13 @@
/**
* A payment operation on a wallet.
*
- * @property int $amount Amount of money in cents
+ * @property int $amount Amount of money in cents of CHF
* @property string $description Payment description
* @property string $id Mollie's Payment ID
* @property \App\Wallet $wallet The wallet
* @property string $wallet_id The ID of the wallet
+ * @property int $amountInCurrency Amount of money in cents of $currency
+ * @property string $currency Currency of this payment
*/
class Payment extends Model
{
@@ -30,6 +32,8 @@
'provider',
'status',
'type',
+ 'currency',
+ 'amountInCurrency',
];
/**
diff --git a/src/app/Providers/Payment/Mollie.php b/src/app/Providers/Payment/Mollie.php
--- a/src/app/Providers/Payment/Mollie.php
+++ b/src/app/Providers/Payment/Mollie.php
@@ -66,7 +66,7 @@
'webhookUrl' => Utils::serviceUrl('/api/webhooks/payment/mollie'),
'redirectUrl' => Utils::serviceUrl('/wallet'),
'locale' => 'en_US',
- // 'method' => 'creditcard',
+ 'method' => $payment['methodId']
];
// Create the payment in Mollie
@@ -135,7 +135,8 @@
'id' => $mandate->id,
'isPending' => $mandate->isPending(),
'isValid' => $mandate->isValid(),
- 'method' => self::paymentMethod($mandate, 'Unknown method')
+ 'method' => self::paymentMethod($mandate, 'Unknown method'),
+ 'methodId' => $mandate->method
];
return $result;
@@ -174,20 +175,22 @@
// Register the user in Mollie, if not yet done
$customer_id = self::mollieCustomerId($wallet, true);
- // Note: Required fields: description, amount/currency, amount/value
+ $amount = $payment['amount'] * $this->exchangeRate($wallet->currency, $payment['currency']);
+ $payment['amountInCurrency'] = $amount;
+ // 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),
+ 'value' => sprintf('%.2f', $amount / 100),
],
'customerId' => $customer_id,
'sequenceType' => $payment['type'],
'description' => $payment['description'],
'webhookUrl' => Utils::serviceUrl('/api/webhooks/payment/mollie'),
'locale' => 'en_US',
- // 'method' => 'creditcard',
+ 'method' => $payment['methodId'],
'redirectUrl' => Utils::serviceUrl('/wallet') // required for non-recurring payments
];
@@ -210,6 +213,33 @@
];
}
+
+ /**
+ * Cancel a pending payment.
+ *
+ * @param \App\Wallet $wallet The wallet
+ * @param array $paymentId Payment Id
+ *
+ * @return array Provider payment data:
+ * - id: Operation identifier
+ * - redirectUrl: the location to redirect to
+ */
+ public function cancel(Wallet $wallet, $paymentId): ?array
+ {
+ $response = mollie()->payments()->delete($paymentId);
+
+ $payment['id'] = $response->id;
+ $payment['status'] = $response->status;
+
+ $this->storePayment($payment, $wallet->id);
+
+ return [
+ 'id' => $payment['id'],
+ 'redirectUrl' => Utils::serviceUrl('/wallet')
+ ];
+ }
+
+
/**
* Create a new automatic payment operation.
*
@@ -243,7 +273,7 @@
'description' => $payment['description'],
'webhookUrl' => Utils::serviceUrl('/api/webhooks/payment/mollie'),
'locale' => 'en_US',
- // 'method' => 'creditcard',
+ 'method' => $payment['methodId'],
'mandateId' => $mandate->id
];
@@ -333,7 +363,7 @@
'description' => $refund->description,
'amount' => round(floatval($refund->amount->value) * 100),
'type' => self::TYPE_REFUND,
- // Note: we assume this is the original payment/wallet currency
+ 'currency' => $refund->amount->currency
];
}
}
@@ -348,7 +378,7 @@
'id' => $chargeback->id,
'amount' => round(floatval($chargeback->amount->value) * 100),
'type' => self::TYPE_CHARGEBACK,
- // Note: we assume this is the original payment/wallet currency
+ 'currency' => $chargeback->amount->currency
];
}
}
@@ -488,7 +518,7 @@
// Mollie supports 3 methods here
switch ($object->method) {
- case 'creditcard':
+ case self::METHOD_CREDITCARD:
// If the customer started, but never finished the 'first' payment
// card details will be empty, and mandate will be 'pending'.
if (empty($details->cardNumber)) {
@@ -501,13 +531,91 @@
$details->cardNumber
);
- case 'directdebit':
+ case self::METHOD_DIRECTDEBIT:
return sprintf('Direct Debit (%s)', $details->customerAccount);
- case 'paypal':
+ case self::METHOD_PAYPAL:
return sprintf('PayPal (%s)', $details->consumerAccount);
}
return $default;
}
+
+ /**
+ * List supported payment methods.
+ *
+ * @param string $type type: oneoff/recurring
+ *
+ * @return array Array of array with available payment methods:
+ * - id: id of the method
+ * - name: User readable name of the payment method
+ * - minimumAmount: Minimum amount to be charged in cents
+ * - currency: Currency used for the method
+ * - exchangeRate: The projected exchange rate (actual rate is determined during payment)
+ * - icon: An icon (icon name) representing the method
+ */
+ public function paymentMethods($type): ?array
+ {
+ $availableMethods = [];
+
+ $mapResult = function ($result) use (&$availableMethods) {
+ foreach ($result as $method) {
+ $availableMethods[$method->id] = [
+ 'id' => $method->id,
+ 'name' => $method->description,
+ 'minimumAmount' => round(floatval($method->minimumAmount->value) * 100), // Converted to cents
+ 'currency' => $method->minimumAmount->currency,
+ 'exchangeRate' => $this->exchangeRate('CHF', $method->minimumAmount->currency)
+ ];
+ }
+ };
+
+ // Fallback to EUR methods
+ // TODO enable to unlock payment using methods requiring EUR
+ //$mapResult(mollie()->methods()->allActive(
+ // [
+ // 'sequenceType' => $type,
+ // 'amount' => [
+ // 'value' => '1.00',
+ // 'currency' => 'EUR'
+ // ]
+ // ]
+ //));
+
+ // Prefer CHF methods
+ $mapResult(mollie()->methods()->allActive(
+ [
+ 'sequenceType' => $type,
+ 'amount' => [
+ 'value' => '1.00',
+ 'currency' => 'CHF'
+ ]
+ ]
+ ));
+
+ return $this->applyMethodWhitelist($type, $availableMethods);
+ }
+
+ /**
+ * Get a payment.
+ *
+ * @param string $paymentId Payment identifier
+ *
+ * @return array|null Payment information:
+ * - id: Payment identifier
+ * - status: Payment status
+ * - isCancelable: The payment can be canceled
+ * - chceckoutUrl: The checkout url to complete the payment or null if none
+ */
+ public function getPayment($paymentId): ?array
+ {
+ $payment = mollie()->payments()->get($paymentId);
+
+ return [
+ 'id' => $payment->id,
+ 'status' => $payment->status,
+ 'isCancelable' => $payment->isCancelable,
+ 'checkoutUrl' => $payment->getCheckoutUrl()
+ ];
+ }
}
diff --git a/src/app/Providers/Payment/Stripe.php b/src/app/Providers/Payment/Stripe.php
--- a/src/app/Providers/Payment/Stripe.php
+++ b/src/app/Providers/Payment/Stripe.php
@@ -470,4 +470,76 @@
return $default;
}
+
+ /**
+ * List supported payment methods.
+ *
+ * @param string $type type: oneoff/recurring
+ *
+ * @return array Array of array with available payment methods:
+ * - id: id of the method
+ * - name: User readable name of the payment method
+ * - minimumAmount: Minimum amount to be charged
+ * - currency: Currency used for the method
+ * - image|icon: An image (url) or icon (icon name) representing the method
+ */
+ public function paymentMethods($type): ?array
+ {
+ //TODO get this from the stripe API?
+ $availableMethods = [];
+ switch ($type) {
+ case self::TYPE_ONEOFF:
+ $availableMethods = [
+ self::METHOD_CREDITCARD => [
+ 'id' => self::METHOD_CREDITCARD,
+ 'name' => "Credit Card",
+ 'minimumAmount' => self::MIN_AMOUNT,
+ 'currency' => 'CHF',
+ 'exchangeRate' => 1.0
+ ],
+ self::METHOD_PAYPAL => [
+ 'id' => self::METHOD_PAYPAL,
+ 'name' => "PayPal",
+ 'minimumAmount' => self::MIN_AMOUNT,
+ 'currency' => 'CHF',
+ 'exchangeRate' => 1.0
+ ]
+ ];
+ break;
+ case self::TYPE_RECURRING:
+ $availableMethods = [
+ self::METHOD_CREDITCARD => [
+ 'id' => self::METHOD_CREDITCARD,
+ 'name' => "Credit Card",
+ 'minimumAmount' => self::MIN_AMOUNT, // Converted to cents,
+ 'currency' => 'CHF',
+ 'exchangeRate' => 1.0
+ ]
+ ];
+ break;
+ }
+
+ return $this->applyMethodWhitelist($type, $availableMethods);
+ }
+
+ /**
+ * Get a payment.
+ *
+ * @param string $paymentId Payment identifier
+ *
+ * @return array|null Payment information:
+ * - id: Payment identifier
+ * - status: Payment status
+ * - isCancelable: The payment can be canceled
+ * - chceckoutUrl: The checkout url to complete the payment or null if non
+ */
+ public function getPayment($paymentId): ?array
+ {
+ return [
+ 'id' => $paymentId,
+ 'status' => null,
+ 'isCancelable' => false,
+ 'checkoutUrl' => null
+ ];
+ }
}
diff --git a/src/app/Providers/PaymentProvider.php b/src/app/Providers/PaymentProvider.php
--- a/src/app/Providers/PaymentProvider.php
+++ b/src/app/Providers/PaymentProvider.php
@@ -22,6 +22,11 @@
public const TYPE_REFUND = 'refund';
public const TYPE_CHARGEBACK = 'chargeback';
+ public const METHOD_CREDITCARD = 'creditcard';
+ public const METHOD_PAYPAL = 'paypal';
+ public const METHOD_BANKTRANSFER = 'banktransfer';
+ public const METHOD_DIRECTDEBIT = 'directdebit';
+
/** const int Minimum amount of money in a single payment (in cents) */
public const MIN_AMOUNT = 1000;
@@ -157,6 +162,24 @@
return $db_payment;
}
+ /**
+ * Retrieve an exchange rate.
+ *
+ * @param \App\Wallet $wallet The wallet
+ * @param string $sourceCurrency: Currency from which to convert
+ * @param string $targetCurrency: Currency to convert to
+ *
+ * @return float Exchange rate
+ */
+ protected function exchangeRate(string $sourceCurrency, string $targetCurrency): float
+ {
+ if ($sourceCurrency != $targetCurrency) {
+ throw new \Exception("Currency conversion is not yet implemented.");
+ //FIXME Not yet implemented
+ }
+ return 1.0;
+ }
+
/**
* Deduct an amount of pecunia from the wallet.
* Creates a payment and transaction records for the refund/chargeback operation.
@@ -172,7 +195,14 @@
return;
}
- $wallet->balance -= $refund['amount'];
+ // Preserve originally refunded amount
+ $refund['amountInCurrency'] = $refund['amount'];
+
+ // Convert amount to wallet currency
+ // TODO We should possibly be using the same exchange rate as for the original payment?
+ $amount = round($refund['amount'] * $this->exchangeRate($refund['currency'], $wallet->currency));
+
+ $wallet->balance -= $amount;
$wallet->save();
if ($refund['type'] == self::TYPE_CHARGEBACK) {
@@ -185,13 +215,102 @@
'object_id' => $wallet->id,
'object_type' => Wallet::class,
'type' => $transaction_type,
- 'amount' => $refund['amount'],
+ 'amount' => $amount,
'description' => $refund['description'] ?? '',
]);
$refund['status'] = self::STATUS_PAID;
- $refund['amount'] *= -1;
+ $refund['amount'] = -1 * $amount;
$this->storePayment($refund, $wallet->id);
}
+
+ /**
+ * List supported payment methods.
+ *
+ * @param string $type type: oneoff/recurring
+ *
+ * @return array Array of array with available payment methods:
+ * - id: id of the method
+ * - name: User readable name of the payment method
+ * - minimumAmount: Minimum amount to be charged
+ * - currency: Currency used for the method
+ * - image|icon: An image (url) or icon (icon name) representing the method
+ */
+ abstract public function paymentMethods($type): ?array;
+
+ /**
+ * Get a payment.
+ *
+ * @param string $paymentId Payment identifier
+ *
+ * @return array|null Payment information:
+ * - id: Payment identifier
+ * - status: Payment status
+ * - isCancelable: The payment can be canceled
+ * - chceckoutUrl: The checkout url to complete the payment or null if non
+ */
+ abstract public function getPayment($paymentId): ?array;
+
+ /**
+ * Return an array of whitelisted payment methods with override values.
+ *
+ * @param string $type The payment type for which we require a method.
+ *
+ * @return array Array of methods
+ */
+ protected static function paymentMethodsWhitelist($type): ?array
+ {
+ switch ($type) {
+ case self::TYPE_ONEOFF:
+ return [
+ self::METHOD_CREDITCARD => [
+ 'id' => self::METHOD_CREDITCARD,
+ 'icon' => ['prefix' => 'far', 'name' => 'credit-card']
+ ],
+ self::METHOD_PAYPAL => [
+ 'id' => self::METHOD_PAYPAL,
+ 'icon' => ['prefix' => 'fab', 'name' => 'paypal']
+ ],
+ self::METHOD_BANKTRANSFER => [
+ 'id' => self::METHOD_BANKTRANSFER,
+ 'icon' => ['prefix' => 'fas', 'name' => 'university']
+ ]
+ ];
+ case PaymentProvider::TYPE_RECURRING:
+ return [
+ self::METHOD_CREDITCARD => [
+ 'id' => self::METHOD_CREDITCARD,
+ 'icon' => ['prefix' => 'far', 'name' => 'credit-card']
+ ],
+ self::METHOD_PAYPAL => [
+ 'id' => self::METHOD_PAYPAL,
+ 'icon' => ['prefix' => 'fab', 'name' => 'paypal']
+ ]
+ ];
+ }
+ }
+
+
+ /**
+ * Return an array of whitelisted payment methods with override values.
+ *
+ * @param string $type The payment type for which we require a method.
+ *
+ * @return array Array of methods
+ */
+ protected static function applyMethodWhitelist($type, $availableMethods): ?array
+ {
+ $methods = [];
+
+ // Use only whitelisted methods, and apply values from whitelist (overriding the backend)
+ $whitelistMethods = self::paymentMethodsWhitelist($type);
+ foreach ($whitelistMethods as $id => $whitelistMethod) {
+ if (array_key_exists($id, $availableMethods)) {
+ $methods[] = array_merge($availableMethods[$id], $whitelistMethod);
+ }
+ }
+
+ return $methods;
+ }
}
diff --git a/src/resources/js/fontawesome.js b/src/resources/js/fontawesome.js
--- a/src/resources/js/fontawesome.js
+++ b/src/resources/js/fontawesome.js
@@ -15,6 +15,7 @@
faDownload,
faEnvelope,
faGlobe,
+ faUniversity,
faExclamationCircle,
faInfoCircle,
faLock,
@@ -30,6 +31,10 @@
faWallet
} from '@fortawesome/free-solid-svg-icons'
+import {
+ faPaypal
+} from '@fortawesome/free-brands-svg-icons'
+
// Register only these icons we need
library.add(
faCheck,
@@ -37,6 +42,8 @@
faCheckSquare,
faComments,
faCreditCard,
+ faPaypal,
+ faUniversity,
faDownload,
faEnvelope,
faExclamationCircle,
diff --git a/src/resources/themes/app.scss b/src/resources/themes/app.scss
--- a/src/resources/themes/app.scss
+++ b/src/resources/themes/app.scss
@@ -293,6 +293,27 @@
}
}
+#payment-method-selection {
+ display: flex;
+ flex-wrap: wrap;
+ justify-content: center;
+
+ & > button {
+ padding: 1rem;
+ text-align: center;
+ white-space: nowrap;
+ margin: 0.25rem;
+ text-decoration: none;
+ width: 150px;
+ }
+
+ svg {
+ width: 6rem;
+ height: 6rem;
+ margin: auto;
+ }
+}
+
#logon-form {
flex-basis: auto; // Bootstrap issue? See logon page with width < 992
}
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
@@ -5,8 +5,48 @@
<div class="card-title">Account balance <span :class="wallet.balance < 0 ? 'text-danger' : 'text-success'">{{ $root.price(wallet.balance, wallet.currency) }}</span></div>
<div class="card-text">
<p v-if="wallet.notice" id="wallet-notice">{{ wallet.notice }}</p>
- <p>Add credit to your account or setup an automatic payment by using the button below.</p>
- <button type="button" class="btn btn-primary" @click="paymentDialog()">Add credit</button>
+
+ <div v-if="showPendingPayments" class="alert alert-warning">
+ You have payments that are still in progress. See the payments tab below.
+ </div>
+ <p>
+ <button type="button" class="btn btn-primary" @click="paymentMethodForm('manual')">Add credit</button>
+ </p>
+ <div id="mandate-form" v-if="!mandate.isValid && !mandate.isPending">
+ <template v-if="mandate.id && !mandate.isValid">
+ <div class="alert alert-danger">
+ The setup of automatic payments failed. Restart the process to enable automatic top-ups.
+ </div>
+ <p>
+ <button type="button" class="btn btn-danger" @click="autoPaymentDelete">Cancel auto-payment</button>
+ </p>
+ </template>
+ <button type="button" class="btn btn-primary" @click="paymentMethodForm('auto')">Set up auto-payment</button>
+ </div>
+ <div id="mandate-info" v-else>
+ <div v-if="mandate.isDisabled" class="disabled-mandate alert alert-danger">
+ The configured auto-payment has been disabled. Top up your wallet or
+ raise the auto-payment amount.
+ </div>
+ <template v-else>
+ <p>
+ Auto-payment is <b>set</b> to fill up your account by <b>{{ mandate.amount }} CHF</b>
+ every time your account balance gets under <b>{{ mandate.balance }} CHF</b>.
+ </p>
+ <p>
+ Method of payment: {{ mandate.method }}
+ </p>
+ </template>
+ <div v-if="mandate.isPending" class="alert alert-warning">
+ The setup of the automatic payment is still in progress.
+ </div>
+ <p>
+ <button type="button" class="btn btn-danger" @click="autoPaymentDelete">Cancel auto-payment</button>
+ </p>
+ <p>
+ <button type="button" class="btn btn-primary" @click="autoPaymentChange">Change auto-payment</button>
+ </p>
+ </div>
</div>
</div>
</div>
@@ -22,6 +62,11 @@
History
</a>
</li>
+ <li v-if="showPendingPayments" class="nav-item">
+ <a class="nav-link" id="tab-payments" href="#wallet-payments" role="tab" aria-controls="wallet-payments" aria-selected="false">
+ Pending Payments
+ </a>
+ </li>
</ul>
<div class="tab-content">
<div class="tab-pane show active" id="wallet-receipts" role="tabpanel" aria-labelledby="tab-receipts">
@@ -53,6 +98,11 @@
<transaction-log v-if="walletId && loadTransactions" class="card-text" :wallet-id="walletId"></transaction-log>
</div>
</div>
+ <div class="tab-pane show" id="wallet-payments" role="tabpanel" aria-labelledby="tab-payments">
+ <div class="card-body">
+ <payment-log v-if="walletId && loadPayments" class="card-text" :wallet-id="walletId"></payment-log>
+ </div>
+ </div>
</div>
<div id="payment-dialog" class="modal" tabindex="-1" role="dialog">
@@ -65,7 +115,26 @@
</button>
</div>
<div class="modal-body">
- <div id="payment" v-if="paymentForm == 'init'">
+ <div id="payment-method" v-if="paymentForm == 'method'">
+ <form data-validation-prefix="mandate_">
+ <div id="payment-method-selection">
+ <button :id="method.id" v-for="method in paymentMethods" :key="method.id" @click="selectPaymentMethod(method)" class="card link-profile">
+ <svg-icon v-if="method.icon" :icon="[method.icon.prefix, method.icon.name]" />
+ <img v-if="method.image" v-bind:src="method.image" />
+ <span class="name">{{ method.name }}</span>
+ </button>
+ </div>
+ </form>
+ </div>
+ <div id="manual-payment" v-if="paymentForm == 'manual'">
+ <p v-if="wallet.currency != selectedPaymentMethod.currency">
+ Here is how it works: You specify the amount by which you want to to up your wallet in {{ wallet.currency }}.
+ We will then convert this to {{ selectedPaymentMethod.currency }}, and on the next page you will be provided with the bank-details
+ to transfer the amount in {{ selectedPaymentMethod.currency }}.
+ </p>
+ <p v-if="selectedPaymentMethod.id == 'banktransfer'">
+ Please note that a bank transfer can take several days to complete.
+ </p>
<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">
@@ -76,41 +145,15 @@
</span>
</div>
</div>
+ <div v-if="wallet.currency != selectedPaymentMethod.currency" class="alert alert-warning">
+ You will be charged for {{ amount * selectedPaymentMethod.exchangeRate }} {{ selectedPaymentMethod.currency }}
+ </div>
<div class="w-100 text-center">
<button type="submit" class="btn btn-primary">
<svg-icon :icon="['far', 'credit-card']"></svg-icon> Continue
</button>
</div>
</form>
- <div class="form-separator"><hr><span>or</span></div>
- <div id="mandate-form" v-if="!mandate.isValid && !mandate.isPending">
- <p>Add auto-payment, so you never run out.</p>
- <div v-if="mandate.id && !mandate.isValid" class="alert alert-danger">
- The setup of automatic payments failed. Restart the process to enable automatic top-ups.
- </div>
- <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-else>
- <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>
- <div v-if="mandate.isPending" class="alert alert-warning">
- The setup of the automatic payment is still in progress.
- </div>
- <div v-else-if="mandate.isDisabled" class="disabled-mandate alert alert-danger">
- The configured auto-payment has been disabled. Top up your wallet or
- raise the auto-payment amount.
- </div>
- <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 data-validation-prefix="mandate_">
@@ -173,22 +216,29 @@
<script>
import TransactionLog from './Widgets/TransactionLog'
+ import PaymentLog from './Widgets/PaymentLog'
export default {
components: {
- TransactionLog
+ TransactionLog,
+ PaymentLog
},
data() {
return {
amount: '',
- mandate: { amount: 10, balance: 0 },
+ mandate: { amount: 10, balance: 0, method: null },
paymentDialogTitle: null,
- paymentForm: 'init',
+ paymentForm: null,
+ nextForm: null,
receipts: [],
stripe: null,
loadTransactions: false,
+ loadPayments: false,
+ showPendingPayments: false,
wallet: {},
- walletId: null
+ walletId: null,
+ paymentMethods: [],
+ selectedPaymentMethod: null
}
},
mounted() {
@@ -220,17 +270,28 @@
})
.catch(this.$root.errorHandler)
+ this.loadMandate()
+
+ axios.get('/api/v4/payments/pending')
+ .then(response => {
+ this.showPendingPayments = (response.data.list.length > 0)
+ })
+
+ },
+ updated() {
$(this.$el).find('ul.nav-tabs a').on('click', e => {
e.preventDefault()
$(e.target).tab('show')
if ($(e.target).is('#tab-history')) {
this.loadTransactions = true
}
+ if ($(e.target).is('#tab-payments')) {
+ this.loadPayments = true
+ }
})
},
methods: {
- paymentDialog() {
- const dialog = $('#payment-dialog')
+ loadMandate() {
const mandate_form = $('#mandate-form')
this.$root.removeLoader(mandate_form)
@@ -246,14 +307,20 @@
this.$root.removeLoader(mandate_form)
})
}
+ },
+ selectPaymentMethod(method) {
+ this.formLock = false
+
+ this.selectedPaymentMethod = method
+ this.paymentForm = this.nextForm
+ this.paymentDialogTitle = 'Choose Amount'
this.formLock = false
- this.paymentForm = 'init'
- this.paymentDialogTitle = 'Top up your wallet'
- this.dialog = dialog.on('shown.bs.modal', () => {
- dialog.find('#amount').focus()
- }).modal()
+ setTimeout(() => {
+ this.dialog.find('#mandate_amount').focus()
+ this.dialog.find('#amount').focus()
+ }, 10)
},
payment() {
if (this.formLock) {
@@ -266,7 +333,7 @@
this.$root.clearFormValidation($('#payment-form'))
- axios.post('/api/v4/payments', {amount: this.amount}, { onFinish })
+ axios.post('/api/v4/payments', {amount: this.amount, methodId: this.selectedPaymentMethod.id, currency: this.selectedPaymentMethod.currency}, { onFinish })
.then(response => {
if (response.data.redirectUrl) {
location.href = response.data.redirectUrl
@@ -285,9 +352,15 @@
let onFinish = () => { this.formLock = false }
const method = this.mandate.id && (this.mandate.isValid || this.mandate.isPending) ? 'put' : 'post'
- const post = {
+ let post = {
amount: this.mandate.amount,
- balance: this.mandate.balance
+ balance: this.mandate.balance,
+ }
+
+ // Modifications can't change the method of payment
+ if (this.selectedPaymentMethod) {
+ post['methodId'] = this.selectedPaymentMethod.id;
+ post['currency'] = this.selectedPaymentMethod.currency;
}
this.$root.clearFormValidation($('#auto-payment form'))
@@ -324,10 +397,38 @@
}
})
},
+
+ paymentMethodForm(nextForm) {
+ const dialog = $('#payment-dialog')
+ this.formLock = false
+ this.paymentMethods = []
+
+ this.paymentForm = 'method'
+ this.paymentDialogTitle = 'Payment Method'
+ this.nextForm = nextForm
+
+ const methods = $('#payment-method')
+ this.$root.addLoader(methods)
+ axios.get('/api/v4/payments/methods', {params: {type: nextForm == 'manual' ? 'oneoff' : 'recurring'}})
+ .then(response => {
+ this.$root.removeLoader(methods)
+ this.paymentMethods = response.data
+ })
+ .catch(this.$root.errorHandler)
+
+ this.dialog = dialog.on('shown.bs.modal', () => {}).modal()
+ },
autoPaymentForm(event, title) {
+ const dialog = $('#payment-dialog')
+
this.paymentForm = 'auto'
this.paymentDialogTitle = title || 'Add auto-payment'
this.formLock = false
+
+ this.dialog = dialog.on('shown.bs.modal', () => {
+ dialog.find('#mandate_amount').focus()
+ }).modal()
+
setTimeout(() => { this.dialog.find('#mandate_amount').focus()}, 10)
},
receiptDownload() {
diff --git a/src/resources/vue/Widgets/PaymentLog.vue b/src/resources/vue/Widgets/PaymentLog.vue
new file mode 100644
--- /dev/null
+++ b/src/resources/vue/Widgets/PaymentLog.vue
@@ -0,0 +1,80 @@
+<template>
+ <div>
+ <table class="table table-sm m-0 payments">
+ <thead class="thead-light">
+ <tr>
+ <th scope="col">Date</th>
+ <th scope="col">Description</th>
+ <th scope="col" class="price">Amount</th>
+ <th scope="col">Status</th>
+ <th scope="col"></th>
+ </tr>
+ </thead>
+ <tbody>
+ <tr v-for="payment in payments" :id="'log' + payment.id" :key="payment.id">
+ <td class="datetime">{{ payment.createdAt }}</td>
+ <td class="description">{{ payment.description }}</td>
+ <td :class="'price ' + text-success">{{ amount(payment) }}</td>
+ <td>{{ payment.status }}</td>
+ <td><a v-if="payment.checkoutUrl" v-bind:href="payment.checkoutUrl">Details</a></td>
+ </tr>
+ </tbody>
+ <tfoot class="table-fake-body">
+ <tr>
+ <td colspan="5">There are no payments for this account.</td>
+ </tr>
+ </tfoot>
+ </table>
+ <div class="text-center p-3" id="payments-loader" v-if="hasMore">
+ <button class="btn btn-secondary" @click="loadLog(true)">Load more</button>
+ </div>
+ </div>
+</template>
+
+<script>
+ export default {
+ props: {
+ },
+ data() {
+ return {
+ payments: [],
+ hasMore: false,
+ page: 1
+ }
+ },
+ mounted() {
+ this.loadLog()
+ },
+ methods: {
+ loadLog(more) {
+ let loader = $(this.$el)
+ let param = ''
+
+ if (more) {
+ param = '?page=' + (this.page + 1)
+ loader = $('#payments-loader')
+ }
+
+ this.$root.addLoader(loader)
+ axios.get('/api/v4/payments/pending' + param)
+ .then(response => {
+ this.$root.removeLoader(loader)
+ // Note: In Vue we can't just use .concat()
+ for (let i in response.data.list) {
+ this.$set(this.payments, this.payments.length, response.data.list[i])
+ }
+ this.hasMore = response.data.hasMore
+ this.page = response.data.page || 1
+ })
+ .catch(error => {
+ this.$root.removeLoader(loader)
+ })
+ },
+ amount(payment) {
+ return this.$root.price(payment.amount)
+ }
+
+
+ }
+ }
+</script>
diff --git a/src/routes/api.php b/src/routes/api.php
--- a/src/routes/api.php
+++ b/src/routes/api.php
@@ -76,10 +76,13 @@
Route::get('wallets/{id}/receipts/{receipt}', 'API\V4\WalletsController@receiptDownload');
Route::post('payments', 'API\V4\PaymentsController@store');
+ Route::delete('payments', 'API\V4\PaymentsController@cancel');
Route::get('payments/mandate', 'API\V4\PaymentsController@mandate');
Route::post('payments/mandate', 'API\V4\PaymentsController@mandateCreate');
Route::put('payments/mandate', 'API\V4\PaymentsController@mandateUpdate');
Route::delete('payments/mandate', 'API\V4\PaymentsController@mandateDelete');
+ Route::get('payments/methods', 'API\V4\PaymentsController@paymentMethods');
+ Route::get('payments/pending', 'API\V4\PaymentsController@payments');
Route::get('openvidu/rooms', 'API\V4\OpenViduController@index');
Route::post('openvidu/rooms/{id}/close', 'API\V4\OpenViduController@closeRoom');
diff --git a/src/tests/Browser/PaymentMollieTest.php b/src/tests/Browser/PaymentMollieTest.php
--- a/src/tests/Browser/PaymentMollieTest.php
+++ b/src/tests/Browser/PaymentMollieTest.php
@@ -55,7 +55,12 @@
->assertSeeIn('@main button', 'Add credit')
->click('@main button')
->with(new Dialog('@payment-dialog'), function (Browser $browser) {
- $browser->assertSeeIn('@title', 'Top up your wallet')
+ $browser->assertSeeIn('@title', 'Payment Method')
+ ->waitFor('#payment-method-selection #creditcard')
+ ->click('#creditcard');
+ })
+ ->with(new Dialog('@payment-dialog'), function (Browser $browser) {
+ $browser->assertSeeIn('@title', 'Choose Amount')
->assertFocused('#amount')
->assertSeeIn('@button-cancel', 'Cancel')
->assertSeeIn('@body #payment-form button', 'Continue')
@@ -119,15 +124,15 @@
->on(new Dashboard())
->click('@links .link-wallet')
->on(new WalletPage())
- ->click('@main button')
+ ->assertMissing('@body #mandate-form .alert')
+ ->click('@main #mandate-form button')
->with(new Dialog('@payment-dialog'), function (Browser $browser) {
- $browser->assertSeeIn('@title', 'Top up your wallet')
- ->assertSeeIn('@button-cancel', 'Cancel')
- ->assertSeeIn('@body #mandate-form button', 'Set up auto-payment')
- ->assertSeeIn('@body #mandate-form p', 'Add auto-payment, so you never')
- ->assertMissing('@body #mandate-form .alert')
- ->click('@body #mandate-form button')
- ->assertSeeIn('@title', 'Add auto-payment')
+ $browser->assertSeeIn('@title', 'Payment Method')
+ ->waitFor('#payment-method-selection #creditcard')
+ ->click('#creditcard');
+ })
+ ->with(new Dialog('@payment-dialog'), function (Browser $browser) {
+ $browser->assertSeeIn('@title', 'Choose Amount')
->assertSeeIn('@body label[for="mandate_amount"]', 'Fill up by')
->assertValue('@body #mandate_amount', PaymentProvider::MIN_AMOUNT / 100)
->assertSeeIn('@body label[for="mandate_balance"]', 'when account balance is below') // phpcs:ignore
@@ -164,19 +169,18 @@
->submitValidCreditCard()
->waitForLocation('/wallet')
->visit('/wallet?paymentProvider=mollie')
- ->on(new WalletPage())
- ->click('@main button')
- ->with(new Dialog('@payment-dialog'), function (Browser $browser) {
- $expected = 'Auto-payment is set to fill up your account by 100 CHF every'
- . ' time your account balance gets under 0 CHF. You will be charged'
- . ' via Mastercard (**** **** **** 6787).';
-
- $browser->assertSeeIn('@title', 'Top up your wallet')
- ->waitFor('#mandate-info')
- ->assertSeeIn('#mandate-info p:first-child', $expected)
- ->assertMissing('@body .alert')
- ->click('@button-cancel');
- });
+ ->waitFor('#mandate-info')
+ ->assertPresent('#mandate-info p:first-child')
+ ->assertSeeIn(
+ '#mandate-info p:first-child',
+ 'Auto-payment is set to fill up your account by 100 CHF ' .
+ 'every time your account balance gets under 0 CHF.'
+ )
+ ->assertSeeIn(
+ '#mandate-info p:nth-child(2)',
+ 'Mastercard (**** **** **** 6787)'
+ )
+ ->assertMissing('@body .alert');
$this->assertSame(1, $user->wallets()->first()->payments()->count());
});
@@ -188,73 +192,68 @@
$browser->refresh()
->on(new WalletPage())
- ->click('@main button')
+ ->waitFor('#mandate-info')
+ ->assertSeeIn(
+ '#mandate-info .disabled-mandate',
+ 'The configured auto-payment has been disabled'
+ )
+ ->assertSeeIn('#mandate-info button.btn-primary', 'Change auto-payment')
+ ->click('#mandate-info button.btn-primary')
->with(new Dialog('@payment-dialog'), function (Browser $browser) {
- $browser->waitFor('@body #mandate-info')
- ->assertSeeIn(
- '@body #mandate-info .disabled-mandate',
- 'The configured auto-payment has been disabled'
- )
- ->assertSeeIn('@body #mandate-info button.btn-primary', 'Change auto-payment')
- ->click('@body #mandate-info button.btn-primary')
- ->assertSeeIn('@title', 'Update auto-payment')
- ->assertSeeIn(
- '@body form .disabled-mandate',
- 'The auto-payment is disabled.'
- )
- ->assertValue('@body #mandate_amount', '100')
- ->assertValue('@body #mandate_balance', '0')
- ->assertSeeIn('@button-cancel', 'Cancel')
- ->assertSeeIn('@button-action', 'Submit')
- // Test error handling
- ->type('@body #mandate_amount', 'aaa')
- ->click('@button-action')
- ->assertToast(Toast::TYPE_ERROR, 'Form validation error')
- ->assertVisible('@body #mandate_amount.is-invalid')
- ->assertSeeIn('#mandate_amount + span + .invalid-feedback', 'The amount must be a number.')
- // Submit valid data
- ->type('@body #mandate_amount', '50')
- ->click('@button-action');
+ $browser->assertSeeIn('@title', 'Update auto-payment')
+ ->assertSeeIn(
+ '@body form .disabled-mandate',
+ 'The auto-payment is disabled.'
+ )
+ ->assertValue('@body #mandate_amount', '100')
+ ->assertValue('@body #mandate_balance', '0')
+ ->assertSeeIn('@button-cancel', 'Cancel')
+ ->assertSeeIn('@button-action', 'Submit')
+ // Test error handling
+ ->type('@body #mandate_amount', 'aaa')
+ ->click('@button-action')
+ ->assertToast(Toast::TYPE_ERROR, 'Form validation error')
+ ->assertVisible('@body #mandate_amount.is-invalid')
+ ->assertSeeIn('#mandate_amount + span + .invalid-feedback', 'The amount must be a number.')
+ // Submit valid data
+ ->type('@body #mandate_amount', '50')
+ ->click('@button-action');
})
->waitUntilMissing('#payment-dialog')
->assertToast(Toast::TYPE_SUCCESS, 'The auto-payment has been updated.')
- // Open the dialog again and make sure the "disabled" text isn't there
- ->click('@main button')
- ->with(new Dialog('@payment-dialog'), function (Browser $browser) {
- $browser->assertMissing('@body #mandate-info .disabled-mandate')
- ->click('@body #mandate-info button.btn-primary')
- ->assertMissing('@body form .disabled-mandate')
- ->click('@button-cancel');
- });
+ // make sure the "disabled" text isn't there
+ ->assertMissing('#mandate-info .disabled-mandate')
+ ->click('#mandate-info button.btn-primary')
+ ->assertMissing('form .disabled-mandate')
+ ->click('button.modal-cancel');
});
// Test deleting auto-payment
$this->browse(function (Browser $browser) {
$browser->on(new WalletPage())
- ->click('@main button')
- ->with(new Dialog('@payment-dialog'), function (Browser $browser) {
- $browser->assertSeeIn('@body #mandate-info button.btn-danger', 'Cancel auto-payment')
- ->click('@body #mandate-info button.btn-danger')
- ->assertToast(Toast::TYPE_SUCCESS, 'The auto-payment has been removed.')
- ->assertVisible('@body #mandate-form')
- ->assertMissing('@body #mandate-info')
- ->click('@button-cancel');
- });
+ ->waitFor('#mandate-info')
+ ->assertSeeIn('#mandate-info * button.btn-danger', 'Cancel auto-payment')
+ ->assertVisible('#mandate-info * button.btn-danger')
+ ->click('#mandate-info * button.btn-danger')
+ ->assertToast(Toast::TYPE_SUCCESS, 'The auto-payment has been removed.')
+ ->assertVisible('#mandate-form')
+ ->assertMissing('#mandate-info');
});
// Test pending and failed mandate
$this->browse(function (Browser $browser) {
$browser->on(new WalletPage())
- ->click('@main button')
+ ->assertMissing('@body #mandate-form .alert')
+ ->click('@main #mandate-form button')
->with(new Dialog('@payment-dialog'), function (Browser $browser) {
- $browser->assertSeeIn('@title', 'Top up your wallet')
+ $browser->assertSeeIn('@title', 'Payment Method')
+ ->waitFor('#payment-method-selection #creditcard')
+ ->click('#creditcard');
+ })
+ ->with(new Dialog('@payment-dialog'), function (Browser $browser) {
+ $browser->assertSeeIn('@title', 'Choose Amount')
->assertSeeIn('@button-cancel', 'Cancel')
- ->assertSeeIn('@body #mandate-form button', 'Set up auto-payment')
- ->assertSeeIn('@body #mandate-form p', 'Add auto-payment, so you never')
- ->assertMissing('@body #mandate-form .alert')
- ->click('@body #mandate-form button')
- ->assertSeeIn('@title', 'Add auto-payment')
- ->assertMissing('@body .alert')
+ ->assertSeeIn('@button-action', 'Continue')
// Submit valid data
->type('@body #mandate_amount', '100')
->type('@body #mandate_balance', '0')
@@ -265,29 +264,26 @@
->waitForLocation('/wallet')
->visit('/wallet?paymentProvider=mollie')
->on(new WalletPage())
- ->click('@main button')
- ->with(new Dialog('@payment-dialog'), function (Browser $browser) {
- $expected = 'Auto-payment is set to fill up your account by 100 CHF every'
- . ' time your account balance gets under 0 CHF. You will be charged'
- . ' via Credit Card.';
+ ->assertSeeIn(
+ '#mandate-info .alert-warning',
+ 'The setup of the automatic payment is still in progress.'
+ )
+ // Delete the mandate
+ ->click('#mandate-info * button.btn-danger')
+ ->assertToast(Toast::TYPE_SUCCESS, 'The auto-payment has been removed.')
+ ->assertMissing('@body #mandate-form .alert')
- $browser->assertSeeIn('@title', 'Top up your wallet')
- ->waitFor('#mandate-info')
- ->assertSeeIn('#mandate-info p:first-child', $expected)
- ->assertSeeIn(
- '#mandate-info .alert-warning',
- 'The setup of the automatic payment is still in progress.'
- )
- ->assertSeeIn('@body #mandate-info .btn-danger', 'Cancel auto-payment')
- ->assertSeeIn('@body #mandate-info .btn-primary', 'Change auto-payment')
- // Delete the mandate
- ->click('@body #mandate-info .btn-danger')
- ->assertToast(Toast::TYPE_SUCCESS, 'The auto-payment has been removed.')
- ->assertSeeIn('@body #mandate-form p', 'Add auto-payment, so you never')
- ->assertMissing('@body #mandate-form .alert')
- ->click('@body #mandate-form button')
- ->assertSeeIn('@title', 'Add auto-payment')
- ->assertMissing('@body .alert')
+ // Create a new mandate
+ ->click('@main #mandate-form button')
+ ->with(new Dialog('@payment-dialog'), function (Browser $browser) {
+ $browser->assertSeeIn('@title', 'Payment Method')
+ ->waitFor('#payment-method-selection #creditcard')
+ ->click('#creditcard');
+ })
+ ->with(new Dialog('@payment-dialog'), function (Browser $browser) {
+ $browser->assertSeeIn('@title', 'Choose Amount')
+ ->assertSeeIn('@button-cancel', 'Cancel')
+ ->assertSeeIn('@button-action', 'Continue')
// Submit valid data
->type('@body #mandate_amount', '100')
->type('@body #mandate_balance', '0')
@@ -298,17 +294,15 @@
->waitForLocation('/wallet')
->visit('/wallet?paymentProvider=mollie')
->on(new WalletPage())
+ ->waitFor('#mandate-form .alert-danger')
+ ->assertSeeIn(
+ '#mandate-form .alert-danger',
+ 'The setup of automatic payments failed. Restart the process to enable'
+ )
->click('@main button')
->with(new Dialog('@payment-dialog'), function (Browser $browser) {
$browser->waitFor('#mandate-form')
- ->assertMissing('#mandate-info')
- ->assertSeeIn('#mandate-form p', 'Add auto-payment')
- ->waitFor('#mandate-form .alert-danger')
- ->assertSeeIn(
- '#mandate-form .alert-danger',
- 'The setup of automatic payments failed. Restart the process to enable'
- )
- ->assertSeeIn('@body #mandate-form .btn-primary', 'Set up auto-payment');
+ ->assertMissing('#mandate-info');
});
});
}
File Metadata
Details
Attached
Mime Type
text/plain
Expires
Sat, Apr 4, 8:42 PM (22 h, 30 m)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
18831108
Default Alt Text
D2239.1775335327.diff (57 KB)
Attached To
Mode
D2239: New Payment dialog structure
Attached
Detach File
Event Timeline