Page Menu
Home
Phorge
Search
Configure Global Search
Log In
Files
F117807507
D2239.1775274790.diff
No One
Temporary
Actions
View File
Edit File
Delete File
View Transforms
Subscribe
Flag For Later
Award Token
Authored By
Unknown
Size
91 KB
Referenced Files
None
Subscribers
None
D2239.1775274790.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,40 @@
return response()->json($result);
}
+ /**
+ * Delete a pending payment.
+ *
+ * @param \Illuminate\Http\Request $request The API request.
+ *
+ * @return \Illuminate\Http\JsonResponse The response
+ */
+ // TODO currently unused
+ // public function cancel(Request $request)
+ // {
+ // $current_user = Auth::guard()->user();
+
+ // // TODO: Wallet selection
+ // $wallet = $current_user->wallets()->first();
+
+ // $paymentId = $request->payment;
+
+ // $user_owns_payment = Payment::where('id', $paymentId)
+ // ->where('wallet_id', $wallet->id)
+ // ->exists();
+
+ // if (!$user_owns_payment) {
+ // return $this->errorResponse(404);
+ // }
+
+ // $provider = PaymentProvider::factory($wallet);
+ // if ($provider->cancel($wallet, $paymentId)) {
+ // $result = ['status' => 'success'];
+ // return response()->json($result);
+ // }
+
+ // return $this->errorResponse(404);
+ // }
+
/**
* Update payment status (and balance).
*
@@ -291,6 +328,7 @@
'type' => PaymentProvider::TYPE_RECURRING,
'currency' => 'CHF',
'amount' => $amount,
+ 'methodId' => PaymentProvider::METHOD_CREDITCARD,
'description' => \config('app.name') . ' Recurring Payment',
];
@@ -325,4 +363,113 @@
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();
+
+ $methods = PaymentProvider::paymentMethods($wallet, $request->type);
+
+ \Log::debug("Provider methods" . var_export(json_encode($methods), true));
+
+ return response()->json($methods);
+ }
+
+ /**
+ * Check for pending payments.
+ *
+ * @param \Illuminate\Http\Request $request The API request.
+ *
+ * @return \Illuminate\Http\JsonResponse The response
+ */
+ public static function hasPayments(Request $request)
+ {
+ $user = Auth::guard()->user();
+
+ // TODO: Wallet selection
+ $wallet = $user->wallets()->first();
+
+ $exists = Payment::where('wallet_id', $wallet->id)
+ ->where('type', PaymentProvider::TYPE_ONEOFF)
+ ->whereIn('status', [
+ PaymentProvider::STATUS_OPEN,
+ PaymentProvider::STATUS_PENDING,
+ PaymentProvider::STATUS_AUTHORIZED])
+ ->exists();
+
+ return response()->json([
+ 'status' => 'success',
+ 'hasPending' => $exists
+ ]);
+ }
+
+ /**
+ * 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();
+
+ $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])
+ ->orderBy('created_at', 'desc')
+ ->limit($pageSize + 1)
+ ->offset($pageSize * ($page - 1))
+ ->get();
+
+ if (count($result) > $pageSize) {
+ $result->pop();
+ $hasMore = true;
+ }
+
+ $result = $result->map(function ($item) {
+ $provider = PaymentProvider::factory($item->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 string $currency Currency of this payment
+ * @property int $currency_amount Amount of money in cents of $currency
*/
class Payment extends Model
{
@@ -30,8 +32,19 @@
'provider',
'status',
'type',
+ 'currency',
+ 'currency_amount',
];
+
+ /**
+ * Ensure the currency is appropriately cased.
+ */
+ public function setCurrencyAttribute($currency)
+ {
+ $this->attributes['currency'] = strtoupper($currency);
+ }
+
/**
* The wallet to which this payment belongs.
*
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
@@ -41,6 +41,7 @@
* - amount: Value in cents (optional)
* - currency: The operation currency
* - description: Operation desc.
+ * - methodId: Payment method
*
* @return array Provider payment data:
* - id: Operation identifier
@@ -55,10 +56,13 @@
$payment['amount'] = 0;
}
+ $amount = $payment['amount'] * $this->exchangeRate($wallet->currency, $payment['currency']);
+ $payment['currency_amount'] = $amount;
+
$request = [
'amount' => [
'currency' => $payment['currency'],
- 'value' => sprintf('%.2f', $payment['amount'] / 100),
+ 'value' => sprintf('%.2f', $amount / 100),
],
'customerId' => $customer_id,
'sequenceType' => 'first',
@@ -66,7 +70,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
@@ -119,6 +123,7 @@
* @return array|null Mandate information:
* - id: Mandate identifier
* - method: user-friendly payment method desc.
+ * - methodId: Payment method
* - isPending: the process didn't complete yet
* - isValid: the mandate is valid
*/
@@ -135,7 +140,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;
@@ -160,6 +166,7 @@
* - currency: The operation currency
* - type: oneoff/recurring
* - description: Operation desc.
+ * - methodId: Payment method
*
* @return array Provider payment data:
* - id: Operation identifier
@@ -174,20 +181,23 @@
// 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['currency_amount'] = $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),
+ // a number with two decimals is required (note that JPK and ISK don't require decimals,
+ // but we're not using them currently)
+ 'value' => sprintf('%.2f', $amount / 100),
],
'customerId' => $customer_id,
'sequenceType' => $payment['type'],
'description' => $payment['description'],
'webhookUrl' => Utils::serviceUrl('/api/webhooks/payment/mollie'),
'locale' => 'en_US',
- // 'method' => 'creditcard',
+ 'method' => $payment['methodId'],
'redirectUrl' => Utils::serviceUrl('/wallet') // required for non-recurring payments
];
@@ -210,6 +220,27 @@
];
}
+
+ /**
+ * Cancel a pending payment.
+ *
+ * @param \App\Wallet $wallet The wallet
+ * @param string $paymentId Payment Id
+ *
+ * @return bool True on success, False on failure
+ */
+ public function cancel(Wallet $wallet, $paymentId): bool
+ {
+ $response = mollie()->payments()->delete($paymentId);
+
+ $db_payment = Payment::find($paymentId);
+ $db_payment->status = $response->status;
+ $db_payment->save();
+
+ return true;
+ }
+
+
/**
* Create a new automatic payment operation.
*
@@ -231,19 +262,21 @@
$customer_id = self::mollieCustomerId($wallet, true);
// Note: Required fields: description, amount/currency, amount/value
+ $amount = $payment['amount'] * $this->exchangeRate($wallet->currency, $payment['currency']);
+ $payment['currency_amount'] = $amount;
$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'],
'mandateId' => $mandate->id
];
@@ -333,7 +366,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 +381,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 +521,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 +534,89 @@
$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 The payment type for which we require a method (oneoff/recurring).
+ *
+ * @return array Array of array with available payment methods:
+ * - id: id of the method
+ * - name: User readable name of the payment method
+ * - minimumAmount: Minimum amount to be charged in cents
+ * - currency: Currency used for the method
+ * - exchangeRate: The projected exchange rate (actual rate is determined during payment)
+ * - icon: An icon (icon name) representing the method
+ */
+ public function providerPaymentMethods($type): array
+ {
+
+ $providerMethods = array_merge(
+ // Fallback to EUR methods (later provider methods will override earlier ones)
+ //mollie()->methods()->allActive(
+ // [
+ // 'sequenceType' => $type,
+ // 'amount' => [
+ // 'value' => '1.00',
+ // 'currency' => 'EUR'
+ // ]
+ // ]
+ //),
+ // Prefer CHF methods
+ (array)mollie()->methods()->allActive(
+ [
+ 'sequenceType' => $type,
+ 'amount' => [
+ 'value' => '1.00',
+ 'currency' => 'CHF'
+ ]
+ ]
+ )
+ );
+
+ $availableMethods = [];
+ foreach ($providerMethods as $method) {
+ $availableMethods[$method->id] = [
+ 'id' => $method->id,
+ 'name' => $method->description,
+ 'minimumAmount' => round(floatval($method->minimumAmount->value) * 100), // Converted to cents
+ 'currency' => $method->minimumAmount->currency,
+ 'exchangeRate' => $this->exchangeRate('CHF', $method->minimumAmount->currency)
+ ];
+ }
+
+ return $availableMethods;
+ }
+
+ /**
+ * Get a payment.
+ *
+ * @param string $paymentId Payment identifier
+ *
+ * @return array Payment information:
+ * - id: Payment identifier
+ * - status: Payment status
+ * - isCancelable: The payment can be canceled
+ * - checkoutUrl: The checkout url to complete the payment or null if none
+ */
+ public function getPayment($paymentId): array
+ {
+ $payment = mollie()->payments()->get($paymentId);
+
+ return [
+ 'id' => $payment->id,
+ 'status' => $payment->status,
+ 'isCancelable' => $payment->isCancelable,
+ 'checkoutUrl' => $payment->getCheckoutUrl()
+ ];
+ }
}
diff --git a/src/app/Providers/Payment/Stripe.php b/src/app/Providers/Payment/Stripe.php
--- a/src/app/Providers/Payment/Stripe.php
+++ b/src/app/Providers/Payment/Stripe.php
@@ -271,6 +271,7 @@
\config('services.stripe.webhook_secret')
);
} catch (\Exception $e) {
+ \Log::error("Invalid payload: " . $e->getMessage());
// Invalid payload
return 400;
}
@@ -470,4 +471,80 @@
return $default;
}
+
+ /**
+ * List supported payment methods.
+ *
+ * @param string $type The payment type for which we require a method (oneoff/recurring).
+ *
+ * @return array Array of array with available payment methods:
+ * - id: id of the method
+ * - name: User readable name of the payment method
+ * - minimumAmount: Minimum amount to be charged in cents
+ * - currency: Currency used for the method
+ * - exchangeRate: The projected exchange rate (actual rate is determined during payment)
+ * - icon: An icon (icon name) representing the method
+ */
+ public function providerPaymentMethods($type): array
+ {
+ //TODO get this from the stripe API?
+ $availableMethods = [];
+ switch ($type) {
+ case self::TYPE_ONEOFF:
+ $availableMethods = [
+ self::METHOD_CREDITCARD => [
+ 'id' => self::METHOD_CREDITCARD,
+ 'name' => "Credit Card",
+ 'minimumAmount' => self::MIN_AMOUNT,
+ 'currency' => 'CHF',
+ 'exchangeRate' => 1.0
+ ],
+ self::METHOD_PAYPAL => [
+ 'id' => self::METHOD_PAYPAL,
+ 'name' => "PayPal",
+ 'minimumAmount' => self::MIN_AMOUNT,
+ 'currency' => 'CHF',
+ 'exchangeRate' => 1.0
+ ]
+ ];
+ break;
+ case self::TYPE_RECURRING:
+ $availableMethods = [
+ self::METHOD_CREDITCARD => [
+ 'id' => self::METHOD_CREDITCARD,
+ 'name' => "Credit Card",
+ 'minimumAmount' => self::MIN_AMOUNT, // Converted to cents,
+ 'currency' => 'CHF',
+ 'exchangeRate' => 1.0
+ ]
+ ];
+ break;
+ }
+
+ return $availableMethods;
+ }
+
+ /**
+ * Get a payment.
+ *
+ * @param string $paymentId Payment identifier
+ *
+ * @return array Payment information:
+ * - id: Payment identifier
+ * - status: Payment status
+ * - isCancelable: The payment can be canceled
+ * - checkoutUrl: The checkout url to complete the payment or null if none
+ */
+ public function getPayment($paymentId): array
+ {
+ \Log::info("Stripe::getPayment does not yet retrieve a checkoutUrl.");
+
+ $payment = StripeAPI\PaymentIntent::retrieve($paymentId);
+ return [
+ 'id' => $payment->id,
+ 'status' => $payment->status,
+ 'isCancelable' => false,
+ 'checkoutUrl' => null
+ ];
+ }
}
diff --git a/src/app/Providers/PaymentProvider.php b/src/app/Providers/PaymentProvider.php
--- a/src/app/Providers/PaymentProvider.php
+++ b/src/app/Providers/PaymentProvider.php
@@ -5,6 +5,7 @@
use App\Transaction;
use App\Payment;
use App\Wallet;
+use Illuminate\Support\Facades\Cache;
abstract class PaymentProvider
{
@@ -22,39 +23,64 @@
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';
+
+ public const PROVIDER_MOLLIE = 'mollie';
+ public const PROVIDER_STRIPE = 'stripe';
+
/** const int Minimum amount of money in a single payment (in cents) */
public const MIN_AMOUNT = 1000;
+ private static $paymentMethodIcons = [
+ self::METHOD_CREDITCARD => ['prefix' => 'far', 'name' => 'credit-card'],
+ self::METHOD_PAYPAL => ['prefix' => 'fab', 'name' => 'paypal'],
+ self::METHOD_BANKTRANSFER => ['prefix' => 'fas', 'name' => 'university']
+ ];
+
/**
- * Factory method
+ * Detect the name of the provider
*
* @param \App\Wallet|string|null $provider_or_wallet
+ * @return string The name of the provider
*/
- public static function factory($provider_or_wallet = null)
+ private static function providerName($provider_or_wallet = null): string
{
if ($provider_or_wallet instanceof Wallet) {
if ($provider_or_wallet->getSetting('stripe_id')) {
- $provider = 'stripe';
+ $provider = self::PROVIDER_STRIPE;
} elseif ($provider_or_wallet->getSetting('mollie_id')) {
- $provider = 'mollie';
+ $provider = self::PROVIDER_MOLLIE;
}
} else {
$provider = $provider_or_wallet;
}
if (empty($provider)) {
- $provider = \config('services.payment_provider') ?: 'mollie';
+ $provider = \config('services.payment_provider') ?: self::PROVIDER_MOLLIE;
}
- switch (\strtolower($provider)) {
- case 'stripe':
+ return \strtolower($provider);
+ }
+
+ /**
+ * Factory method
+ *
+ * @param \App\Wallet|string|null $provider_or_wallet
+ */
+ public static function factory($provider_or_wallet = null)
+ {
+ switch (self::providerName($provider_or_wallet)) {
+ case self::PROVIDER_STRIPE:
return new \App\Providers\Payment\Stripe();
- case 'mollie':
+ case self::PROVIDER_MOLLIE:
return new \App\Providers\Payment\Mollie();
default:
- throw new \Exception("Invalid payment provider: {$provider}");
+ throw new \Exception("Invalid payment provider: {$provider_or_wallet}");
}
}
@@ -66,6 +92,7 @@
* - amount: Value in cents
* - currency: The operation currency
* - description: Operation desc.
+ * - methodId: Payment method
*
* @return array Provider payment data:
* - id: Operation identifier
@@ -90,6 +117,7 @@
* @return array|null Mandate information:
* - id: Mandate identifier
* - method: user-friendly payment method desc.
+ * - methodId: Payment method
* - isPending: the process didn't complete yet
* - isValid: the mandate is valid
*/
@@ -120,6 +148,7 @@
* - currency: The operation currency
* - type: first/oneoff/recurring
* - description: Operation description
+ * - methodId: Payment method
*
* @return array Provider payment/session data:
* - id: Operation identifier
@@ -152,11 +181,30 @@
$db_payment->type = $payment['type'];
$db_payment->wallet_id = $wallet_id;
$db_payment->provider = $this->name();
+ $db_payment->currency = $payment['currency'];
+ $db_payment->currency_amount = $payment['amount'];
$db_payment->save();
return $db_payment;
}
+ /**
+ * Retrieve an exchange rate.
+ *
+ * @param string $sourceCurrency: Currency from which to convert
+ * @param string $targetCurrency: Currency to convert to
+ *
+ * @return float Exchange rate
+ */
+ protected function exchangeRate(string $sourceCurrency, string $targetCurrency): float
+ {
+ if (strcasecmp($sourceCurrency, $targetCurrency)) {
+ throw new \Exception("Currency conversion is not yet implemented.");
+ //FIXME Not yet implemented
+ }
+ return 1.0;
+ }
+
/**
* Deduct an amount of pecunia from the wallet.
* Creates a payment and transaction records for the refund/chargeback operation.
@@ -172,7 +220,14 @@
return;
}
- $wallet->balance -= $refund['amount'];
+ // Preserve originally refunded amount
+ $refund['currency_amount'] = $refund['amount'];
+
+ // Convert amount to wallet currency
+ // TODO We should possibly be using the same exchange rate as for the original payment?
+ $amount = intval(round($refund['amount'] * $this->exchangeRate($refund['currency'], $wallet->currency)));
+
+ $wallet->balance -= $amount;
$wallet->save();
if ($refund['type'] == self::TYPE_CHARGEBACK) {
@@ -185,13 +240,135 @@
'object_id' => $wallet->id,
'object_type' => Wallet::class,
'type' => $transaction_type,
- 'amount' => $refund['amount'] * -1,
+ 'amount' => $amount * -1,
'description' => $refund['description'] ?? '',
]);
$refund['status'] = self::STATUS_PAID;
- $refund['amount'] *= -1;
+ $refund['amount'] = -1 * $amount;
$this->storePayment($refund, $wallet->id);
}
+
+ /**
+ * List supported payment methods from this provider
+ *
+ * @param string $type The payment type for which we require a method (oneoff/recurring).
+ *
+ * @return array Array of array with available payment methods:
+ * - id: id of the method
+ * - name: User readable name of the payment method
+ * - minimumAmount: Minimum amount to be charged in cents
+ * - currency: Currency used for the method
+ * - exchangeRate: The projected exchange rate (actual rate is determined during payment)
+ * - icon: An icon (icon name) representing the method
+ */
+ abstract public function providerPaymentMethods($type): array;
+
+ /**
+ * Get a payment.
+ *
+ * @param string $paymentId Payment identifier
+ *
+ * @return array Payment information:
+ * - id: Payment identifier
+ * - status: Payment status
+ * - isCancelable: The payment can be canceled
+ * - checkoutUrl: The checkout url to complete the payment or null if none
+ */
+ abstract public function getPayment($paymentId): array;
+
+ /**
+ * Return an array of whitelisted payment methods with override values.
+ *
+ * @param string $type The payment type for which we require a method.
+ *
+ * @return array Array of methods
+ */
+ protected static function paymentMethodsWhitelist($type): array
+ {
+ switch ($type) {
+ case self::TYPE_ONEOFF:
+ return [
+ self::METHOD_CREDITCARD => [
+ 'id' => self::METHOD_CREDITCARD,
+ 'icon' => self::$paymentMethodIcons[self::METHOD_CREDITCARD]
+ ],
+ self::METHOD_PAYPAL => [
+ 'id' => self::METHOD_PAYPAL,
+ 'icon' => self::$paymentMethodIcons[self::METHOD_PAYPAL]
+ ],
+ // TODO Enable once we're ready to offer them
+ // self::METHOD_BANKTRANSFER => [
+ // 'id' => self::METHOD_BANKTRANSFER,
+ // 'icon' => self::$paymentMethodIcons[self::METHOD_BANKTRANSFER]
+ // ]
+ ];
+ case PaymentProvider::TYPE_RECURRING:
+ return [
+ self::METHOD_CREDITCARD => [
+ 'id' => self::METHOD_CREDITCARD,
+ 'icon' => self::$paymentMethodIcons[self::METHOD_CREDITCARD]
+ ]
+ ];
+ }
+
+ \Log::error("Unknown payment type: " . $type);
+ return [];
+ }
+
+ /**
+ * Return an array of whitelisted payment methods with override values.
+ *
+ * @param string $type The payment type for which we require a method.
+ *
+ * @return array Array of methods
+ */
+ private static function applyMethodWhitelist($type, $availableMethods): array
+ {
+ $methods = [];
+
+ // Use only whitelisted methods, and apply values from whitelist (overriding the backend)
+ $whitelistMethods = self::paymentMethodsWhitelist($type);
+ foreach ($whitelistMethods as $id => $whitelistMethod) {
+ if (array_key_exists($id, $availableMethods)) {
+ $methods[] = array_merge($availableMethods[$id], $whitelistMethod);
+ }
+ }
+
+ return $methods;
+ }
+
+ /**
+ * List supported payment methods for $wallet
+ *
+ * @param \App\Wallet $wallet The wallet
+ * @param string $type The payment type for which we require a method (oneoff/recurring).
+ *
+ * @return array Array of array with available payment methods:
+ * - id: id of the method
+ * - name: User readable name of the payment method
+ * - minimumAmount: Minimum amount to be charged in cents
+ * - currency: Currency used for the method
+ * - exchangeRate: The projected exchange rate (actual rate is determined during payment)
+ * - icon: An icon (icon name) representing the method
+ */
+ public static function paymentMethods(Wallet $wallet, $type): array
+ {
+ $providerName = self::providerName($wallet);
+
+ $cacheKey = "methods-" . $providerName . '-' . $type;
+
+ if ($methods = Cache::get($cacheKey)) {
+ \Log::debug("Using payment method cache" . var_export($methods, true));
+ return $methods;
+ }
+
+ $provider = PaymentProvider::factory($providerName);
+ $methods = self::applyMethodWhitelist($type, $provider->providerPaymentMethods($type));
+
+ Cache::put($cacheKey, $methods, now()->addHours(1));
+
+ return $methods;
+ }
}
diff --git a/src/database/migrations/2021_02_23_084157_payment_table_add_currency_columns.php b/src/database/migrations/2021_02_23_084157_payment_table_add_currency_columns.php
new file mode 100644
--- /dev/null
+++ b/src/database/migrations/2021_02_23_084157_payment_table_add_currency_columns.php
@@ -0,0 +1,55 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+use Illuminate\Support\Facades\DB;
+
+// phpcs:ignore
+class PaymentTableAddCurrencyColumns extends Migration
+{
+ /**
+ * Run the migrations.
+ *
+ * @return void
+ */
+ public function up()
+ {
+ Schema::table(
+ 'payments',
+ function (Blueprint $table) {
+ $table->string('currency')->nullable();
+ $table->integer('currency_amount')->nullable();
+ }
+ );
+
+ DB::table('payments')->update([
+ 'currency' => 'CHF',
+ 'currency_amount' => DB::raw("`amount`")
+ ]);
+
+ Schema::table(
+ 'payments',
+ function (Blueprint $table) {
+ $table->string('currency')->nullable(false)->change();
+ $table->integer('currency_amount')->nullable(false)->change();
+ }
+ );
+ }
+
+ /**
+ * Reverse the migrations.
+ *
+ * @return void
+ */
+ public function down()
+ {
+ Schema::table(
+ 'payments',
+ function (Blueprint $table) {
+ $table->dropColumn('currency');
+ $table->dropColumn('currency_amount');
+ }
+ );
+ }
+}
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
@@ -170,8 +170,8 @@
return `<img src="${src}" alt="${this.appName}">`
},
// Display "loading" overlay inside of the specified element
- addLoader(elem) {
- $(elem).css({position: 'relative'}).append($(loader).addClass('small'))
+ addLoader(elem, small = true) {
+ $(elem).css({position: 'relative'}).append(small ? $(loader).addClass('small') : $(loader))
},
// Remove loader element added in addLoader()
removeLoader(elem) {
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;
+
+ & > a {
+ 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,44 @@
<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 "Pending 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>
+ <button type="button" class="btn btn-danger" @click="autoPaymentDelete">Cancel auto-payment</button>
+ </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>
+ <button type="button" class="btn btn-primary" @click="autoPaymentChange">Change auto-payment</button>
+ </p>
+ </div>
</div>
</div>
</div>
@@ -22,9 +58,14 @@
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">
+ <div class="tab-pane active" id="wallet-receipts" role="tabpanel" aria-labelledby="tab-receipts">
<div class="card-body">
<div class="card-text">
<p v-if="receipts.length">
@@ -48,11 +89,16 @@
</div>
</div>
</div>
- <div class="tab-pane show" id="wallet-history" role="tabpanel" aria-labelledby="tab-history">
+ <div class="tab-pane" id="wallet-history" role="tabpanel" aria-labelledby="tab-history">
<div class="card-body">
<transaction-log v-if="walletId && loadTransactions" class="card-text" :wallet-id="walletId"></transaction-log>
</div>
</div>
+ <div class="tab-pane" 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 +111,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">
+ <a :id="method.id" v-for="method in paymentMethods" :key="method.id" @click="selectPaymentMethod(method)" href="#" 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>
+ </a>
+ </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 +141,10 @@
</span>
</div>
</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 v-if="wallet.currency != selectedPaymentMethod.currency && !isNaN(amount)" class="alert alert-warning">
+ You will be charged for {{ $root.price(amount * selectedPaymentMethod.exchangeRate, selectedPaymentMethod.currency) }}
</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_">
@@ -162,7 +196,13 @@
v-if="paymentForm == 'auto' && !mandate.isValid && !mandate.isPending"
@click="autoPayment"
>
- <svg-icon :icon="['far', 'credit-card']"></svg-icon> Continue
+ <svg-icon icon="check"></svg-icon> Continue
+ </button>
+ <button type="button" class="btn btn-primary modal-action"
+ v-if="paymentForm == 'manual'"
+ @click="payment"
+ >
+ <svg-icon icon="check"></svg-icon> Continue
</button>
</div>
</div>
@@ -173,22 +213,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 +267,28 @@
})
.catch(this.$root.errorHandler)
+ this.loadMandate()
+
+ axios.get('/api/v4/payments/has-pending')
+ .then(response => {
+ this.showPendingPayments = response.data.hasPending
+ })
+
+ },
+ 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 +304,19 @@
this.$root.removeLoader(mandate_form)
})
}
+ },
+ selectPaymentMethod(method) {
+ this.formLock = false
+
+ this.selectedPaymentMethod = method
+ this.paymentForm = this.nextForm
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 +329,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 +348,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 +393,42 @@
}
})
},
+
+ paymentMethodForm(nextForm) {
+ const dialog = $('#payment-dialog')
+ this.formLock = false
+ this.paymentMethods = []
+
+ this.paymentForm = 'method'
+ this.nextForm = nextForm
+ if (nextForm == 'auto') {
+ this.paymentDialogTitle = 'Add auto-payment'
+ } else {
+ this.paymentDialogTitle = 'Top up your wallet'
+ }
+
+ const methods = $('#payment-method')
+ this.$root.addLoader(methods, false)
+ 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.paymentDialogTitle = title
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,76 @@
+<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"></th>
+ <th scope="col" class="price">Amount</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><a v-if="payment.checkoutUrl" v-bind:href="payment.checkoutUrl">Details</a></td>
+ <td class="price text-success">{{ amount(payment) }}</td>
+ </tr>
+ </tbody>
+ <tfoot class="table-fake-body">
+ <tr>
+ <td colspan="4">There are no pending 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,14 @@
Route::get('wallets/{id}/receipts/{receipt}', 'API\V4\WalletsController@receiptDownload');
Route::post('payments', 'API\V4\PaymentsController@store');
+ //Route::delete('payments', 'API\V4\PaymentsController@cancel');
Route::get('payments/mandate', 'API\V4\PaymentsController@mandate');
Route::post('payments/mandate', 'API\V4\PaymentsController@mandateCreate');
Route::put('payments/mandate', 'API\V4\PaymentsController@mandateUpdate');
Route::delete('payments/mandate', 'API\V4\PaymentsController@mandateDelete');
+ Route::get('payments/methods', 'API\V4\PaymentsController@paymentMethods');
+ Route::get('payments/pending', 'API\V4\PaymentsController@payments');
+ Route::get('payments/has-pending', 'API\V4\PaymentsController@hasPayments');
Route::get('openvidu/rooms', 'API\V4\OpenViduController@index');
Route::post('openvidu/rooms/{id}/close', 'API\V4\OpenViduController@closeRoom');
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
@@ -54,21 +54,28 @@
->on(new WalletPage())
->assertSeeIn('@main button', 'Add credit')
->click('@main button')
+ ->with(new Dialog('@payment-dialog'), function (Browser $browser) {
+ $browser->assertSeeIn('@title', 'Top up your wallet')
+ ->waitFor('#payment-method-selection #creditcard')
+ ->waitFor('#payment-method-selection #paypal')
+ ->assertMissing('#payment-method-selection #banktransfer')
+ ->click('#creditcard');
+ })
->with(new Dialog('@payment-dialog'), function (Browser $browser) {
$browser->assertSeeIn('@title', 'Top up your wallet')
->assertFocused('#amount')
->assertSeeIn('@button-cancel', 'Cancel')
- ->assertSeeIn('@body #payment-form button', 'Continue')
+ ->assertSeeIn('@button-action', 'Continue')
// Test error handling
->type('@body #amount', 'aaa')
- ->click('@body #payment-form button')
+ ->click('@button-action')
->assertToast(Toast::TYPE_ERROR, 'Form validation error')
->assertSeeIn('#amount + span + .invalid-feedback', 'The amount must be a number.')
// Submit valid data
->type('@body #amount', '12.34')
// Note we use double click to assert it does not create redundant requests
- ->click('@body #payment-form button')
- ->click('@body #payment-form button');
+ ->click('@button-action')
+ ->click('@button-action');
})
->on(new PaymentMollie())
->assertSeeIn('@title', \config('app.name') . ' Payment')
@@ -119,15 +126,17 @@
->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', 'Add auto-payment')
+ ->waitFor('#payment-method-selection #creditcard')
+ ->assertMissing('#payment-method-selection #paypal')
+ ->assertMissing('#payment-method-selection #banktransfer')
+ ->click('#creditcard');
+ })
+ ->with(new Dialog('@payment-dialog'), function (Browser $browser) {
+ $browser->assertSeeIn('@title', 'Add auto-payment')
->assertSeeIn('@body label[for="mandate_amount"]', 'Fill up by')
->assertValue('@body #mandate_amount', PaymentProvider::MIN_AMOUNT / 100)
->assertSeeIn('@body label[for="mandate_balance"]', 'when account balance is below') // phpcs:ignore
@@ -164,19 +173,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 +196,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', 'Add auto-payment')
+ ->waitFor('#payment-method-selection #creditcard')
+ ->click('#creditcard');
+ })
+ ->with(new Dialog('@payment-dialog'), function (Browser $browser) {
+ $browser->assertSeeIn('@title', 'Add auto-payment')
->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 +268,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', 'Add auto-payment')
+ ->waitFor('#payment-method-selection #creditcard')
+ ->click('#creditcard');
+ })
+ ->with(new Dialog('@payment-dialog'), function (Browser $browser) {
+ $browser->assertSeeIn('@title', 'Add auto-payment')
+ ->assertSeeIn('@button-cancel', 'Cancel')
+ ->assertSeeIn('@button-action', 'Continue')
// Submit valid data
->type('@body #mandate_amount', '100')
->type('@body #mandate_balance', '0')
@@ -298,17 +298,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');
});
});
}
diff --git a/src/tests/Browser/PaymentStripeTest.php b/src/tests/Browser/PaymentStripeTest.php
--- a/src/tests/Browser/PaymentStripeTest.php
+++ b/src/tests/Browser/PaymentStripeTest.php
@@ -54,19 +54,28 @@
->on(new WalletPage())
->assertSeeIn('@main button', 'Add credit')
->click('@main button')
+ ->with(new Dialog('@payment-dialog'), function (Browser $browser) {
+ $browser->assertSeeIn('@title', 'Top up your wallet')
+ ->waitFor('#payment-method-selection #creditcard')
+ ->waitFor('#payment-method-selection #paypal')
+ ->assertMissing('#payment-method-selection #banktransfer')
+ ->click('#creditcard');
+ })
->with(new Dialog('@payment-dialog'), function (Browser $browser) {
$browser->assertSeeIn('@title', 'Top up your wallet')
->assertFocused('#amount')
->assertSeeIn('@button-cancel', 'Cancel')
- ->assertSeeIn('@body #payment-form button', 'Continue')
+ ->assertSeeIn('@button-action', 'Continue')
// Test error handling
->type('@body #amount', 'aaa')
- ->click('@body #payment-form button')
+ ->click('@button-action')
->assertToast(Toast::TYPE_ERROR, 'Form validation error')
->assertSeeIn('#amount + span + .invalid-feedback', 'The amount must be a number.')
// Submit valid data
->type('@body #amount', '12.34')
- ->click('@body #payment-form button');
+ // Note we use double click to assert it does not create redundant requests
+ ->click('@button-action')
+ ->click('@button-action');
})
->on(new PaymentStripe())
->assertSeeIn('@title', \config('app.name') . ' Payment')
@@ -104,13 +113,17 @@
->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')
- ->click('@body #mandate-form button')
- ->assertSeeIn('@title', 'Add auto-payment')
+ $browser->assertSeeIn('@title', 'Add auto-payment')
+ ->waitFor('#payment-method-selection #creditcard')
+ ->assertMissing('#payment-method-selection #paypal')
+ ->assertMissing('#payment-method-selection #banktransfer')
+ ->click('#creditcard');
+ })
+ ->with(new Dialog('@payment-dialog'), function (Browser $browser) {
+ $browser->assertSeeIn('@title', 'Add auto-payment')
->assertSeeIn('@body label[for="mandate_amount"]', 'Fill up by')
->assertValue('@body #mandate_amount', PaymentProvider::MIN_AMOUNT / 100)
->assertSeeIn('@body label[for="mandate_balance"]', 'when account balance is below') // phpcs:ignore
@@ -137,6 +150,8 @@
// Submit valid data
->type('@body #mandate_amount', '100')
->type('@body #mandate_balance', '0')
+ // Note we use double click to assert it does not create redundant requests
+ ->click('@button-action')
->click('@button-action');
})
->on(new PaymentStripe())
@@ -146,57 +161,74 @@
->submitValidCreditCard()
->waitForLocation('/wallet', 30) // need more time than default 5 sec.
->visit('/wallet?paymentProvider=stripe')
- ->on(new WalletPage())
- ->click('@main button')
- ->with(new Dialog('@payment-dialog'), function (Browser $browser) {
- $expected = 'Auto-payment is set to fill up your account by 100 CHF every'
- . ' time your account balance gets under 0 CHF. You will be charged'
- . ' via Visa (**** **** **** 4242).';
-
- $browser->assertSeeIn('@title', 'Top up your wallet')
- ->waitFor('#mandate-info')
- ->assertSeeIn('#mandate-info p:first-child', $expected)
- ->click('@button-cancel');
- });
+ ->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)',
+ 'Visa (**** **** **** 4242)'
+ )
+ ->assertMissing('@body .alert');
});
- // Test updating auto-payment
- $this->browse(function (Browser $browser) {
- $browser->on(new WalletPage())
- ->click('@main button')
+
+ // Test updating (disabled) auto-payment
+ $this->browse(function (Browser $browser) use ($user) {
+ $wallet = $user->wallets()->first();
+ $wallet->setSetting('mandate_disabled', 1);
+
+ $browser->refresh()
+ ->on(new WalletPage())
+ ->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->assertSeeIn('@body #mandate-info button.btn-primary', 'Change auto-payment')
- ->click('@body #mandate-info button.btn-primary')
- ->assertSeeIn('@title', 'Update auto-payment')
- ->assertValue('@body #mandate_amount', '100')
- ->assertValue('@body #mandate_balance', '0')
- ->assertSeeIn('@button-cancel', 'Cancel')
- ->assertSeeIn('@button-action', 'Submit')
- // Test error handling
- ->type('@body #mandate_amount', 'aaa')
- ->click('@button-action')
- ->assertToast(Toast::TYPE_ERROR, 'Form validation error')
- ->assertVisible('@body #mandate_amount.is-invalid')
- ->assertSeeIn('#mandate_amount + span + .invalid-feedback', 'The amount must be a number.')
- // Submit valid data
- ->type('@body #mandate_amount', '50')
- ->click('@button-action');
+ $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.');
+ ->assertToast(Toast::TYPE_SUCCESS, 'The auto-payment has been updated.')
+ // 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');
- });
+ ->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');
});
}
}
diff --git a/src/tests/Feature/Controller/PaymentsMollieTest.php b/src/tests/Feature/Controller/PaymentsMollieTest.php
--- a/src/tests/Feature/Controller/PaymentsMollieTest.php
+++ b/src/tests/Feature/Controller/PaymentsMollieTest.php
@@ -143,7 +143,7 @@
$payment = Payment::where('id', $json['id'])->first();
$this->assertSame(2010, $payment->amount);
$this->assertSame($wallet->id, $payment->wallet_id);
- $this->assertSame("Kolab Now Auto-Payment Setup", $payment->description);
+ $this->assertSame(\config('app.name') . " Auto-Payment Setup", $payment->description);
$this->assertSame(PaymentProvider::TYPE_MANDATE, $payment->type);
// Test fetching the mandate information
@@ -339,6 +339,7 @@
$response = $this->post("api/v4/payments", []);
$response->assertStatus(401);
+ // Invalid amount
$user = $this->getTestUser('john@kolab.org');
$post = ['amount' => -1];
@@ -352,7 +353,13 @@
$min = intval(PaymentProvider::MIN_AMOUNT / 100) . ' CHF';
$this->assertSame("Minimum amount for a single payment is {$min}.", $json['errors']['amount']);
- $post = ['amount' => '12.34'];
+ // Invalid currency
+ $post = ['amount' => '12.34', 'currency' => 'FOO', 'methodId' => 'creditcard'];
+ $response = $this->actingAs($user)->post("api/v4/payments", $post);
+ $response->assertStatus(500);
+
+ // Successful payment
+ $post = ['amount' => '12.34', 'currency' => 'CHF', 'methodId' => 'creditcard'];
$response = $this->actingAs($user)->post("api/v4/payments", $post);
$response->assertStatus(200);
@@ -657,6 +664,8 @@
'id' => 'tr_123456',
'status' => PaymentProvider::STATUS_PAID,
'amount' => 123,
+ 'currency_amount' => 123,
+ 'currency' => 'CHF',
'type' => PaymentProvider::TYPE_ONEOFF,
'wallet_id' => $wallet->id,
'provider' => 'mollie',
@@ -812,4 +821,100 @@
$this->stopBrowser();
}
+
+
+ /**
+ * Test listing a pending payment
+ *
+ * @group mollie
+ */
+ public function testListingPayments(): void
+ {
+ Bus::fake();
+
+ $user = $this->getTestUser('john@kolab.org');
+
+ //Empty response
+ $response = $this->actingAs($user)->get("api/v4/payments/pending");
+ $json = $response->json();
+
+ $this->assertSame('success', $json['status']);
+ $this->assertSame(0, $json['count']);
+ $this->assertSame(1, $json['page']);
+ $this->assertSame(false, $json['hasMore']);
+ $this->assertCount(0, $json['list']);
+
+ $response = $this->actingAs($user)->get("api/v4/payments/has-pending");
+ $json = $response->json();
+ $this->assertSame(false, $json['hasPending']);
+
+ $wallet = $user->wallets()->first();
+
+ // Successful payment
+ $post = ['amount' => '12.34', 'currency' => 'CHF', 'methodId' => 'creditcard'];
+ $response = $this->actingAs($user)->post("api/v4/payments", $post);
+ $response->assertStatus(200);
+
+ //A response
+ $response = $this->actingAs($user)->get("api/v4/payments/pending");
+ $json = $response->json();
+
+ $this->assertSame('success', $json['status']);
+ $this->assertSame(1, $json['count']);
+ $this->assertSame(1, $json['page']);
+ $this->assertSame(false, $json['hasMore']);
+ $this->assertCount(1, $json['list']);
+ $this->assertSame(PaymentProvider::STATUS_OPEN, $json['list'][0]['status']);
+
+ $response = $this->actingAs($user)->get("api/v4/payments/has-pending");
+ $json = $response->json();
+ $this->assertSame(true, $json['hasPending']);
+
+ // Set the payment to paid
+ $payments = Payment::where('wallet_id', $wallet->id)->get();
+
+ $this->assertCount(1, $payments);
+ $payment = $payments[0];
+
+ $payment->status = PaymentProvider::STATUS_PAID;
+ $payment->save();
+
+ // They payment should be gone from the pending list now
+ $response = $this->actingAs($user)->get("api/v4/payments/pending");
+ $json = $response->json();
+ $this->assertSame('success', $json['status']);
+ $this->assertSame(0, $json['count']);
+ $this->assertCount(0, $json['list']);
+
+ $response = $this->actingAs($user)->get("api/v4/payments/has-pending");
+ $json = $response->json();
+ $this->assertSame(false, $json['hasPending']);
+ }
+
+ /**
+ * Test listing payment methods
+ *
+ * @group mollie
+ */
+ public function testListingPaymentMethods(): void
+ {
+ Bus::fake();
+
+ $user = $this->getTestUser('john@kolab.org');
+
+ $response = $this->actingAs($user)->get('api/v4/payments/methods?type=' . PaymentProvider::TYPE_ONEOFF);
+ $response->assertStatus(200);
+ $json = $response->json();
+
+ $this->assertCount(2, $json);
+ $this->assertSame('creditcard', $json[0]['id']);
+ $this->assertSame('paypal', $json[1]['id']);
+
+ $response = $this->actingAs($user)->get('api/v4/payments/methods?type=' . PaymentProvider::TYPE_RECURRING);
+ $response->assertStatus(200);
+ $json = $response->json();
+
+ $this->assertCount(1, $json);
+ $this->assertSame('creditcard', $json[0]['id']);
+ }
}
diff --git a/src/tests/Feature/Controller/PaymentsStripeTest.php b/src/tests/Feature/Controller/PaymentsStripeTest.php
--- a/src/tests/Feature/Controller/PaymentsStripeTest.php
+++ b/src/tests/Feature/Controller/PaymentsStripeTest.php
@@ -135,7 +135,7 @@
// Stripe in 'setup' mode does not allow to set the amount
$payment = Payment::where('wallet_id', $wallet->id)->first();
$this->assertSame(0, $payment->amount);
- $this->assertSame("Kolab Now Auto-Payment Setup", $payment->description);
+ $this->assertSame(\config('app.name') . " Auto-Payment Setup", $payment->description);
$this->assertSame(PaymentProvider::TYPE_MANDATE, $payment->type);
// Test fetching the mandate information
@@ -306,7 +306,14 @@
$min = intval(PaymentProvider::MIN_AMOUNT / 100) . ' CHF';
$this->assertSame("Minimum amount for a single payment is {$min}.", $json['errors']['amount']);
- $post = ['amount' => '12.34'];
+
+ // Invalid currency
+ $post = ['amount' => '12.34', 'currency' => 'FOO', 'methodId' => 'creditcard'];
+ $response = $this->actingAs($user)->post("api/v4/payments", $post);
+ $response->assertStatus(500);
+
+ // Successful payment
+ $post = ['amount' => '12.34', 'currency' => 'CHF', 'methodId' => 'creditcard'];
$response = $this->actingAs($user)->post("api/v4/payments", $post);
$response->assertStatus(200);
@@ -706,4 +713,31 @@
return $this->withHeaders(['Stripe-Signature' => $sig])
->json('POST', "api/webhooks/payment/stripe", $post);
}
+
+ /**
+ * Test listing payment methods
+ *
+ * @group stripe
+ */
+ public function testListingPaymentMethods(): void
+ {
+ Bus::fake();
+
+ $user = $this->getTestUser('john@kolab.org');
+
+ $response = $this->actingAs($user)->get('api/v4/payments/methods?type=' . PaymentProvider::TYPE_ONEOFF);
+ $response->assertStatus(200);
+ $json = $response->json();
+
+ $this->assertCount(2, $json);
+ $this->assertSame('creditcard', $json[0]['id']);
+ $this->assertSame('paypal', $json[1]['id']);
+
+ $response = $this->actingAs($user)->get('api/v4/payments/methods?type=' . PaymentProvider::TYPE_RECURRING);
+ $response->assertStatus(200);
+ $json = $response->json();
+
+ $this->assertCount(1, $json);
+ $this->assertSame('creditcard', $json[0]['id']);
+ }
}
diff --git a/src/tests/Feature/Jobs/PaymentEmailTest.php b/src/tests/Feature/Jobs/PaymentEmailTest.php
--- a/src/tests/Feature/Jobs/PaymentEmailTest.php
+++ b/src/tests/Feature/Jobs/PaymentEmailTest.php
@@ -51,6 +51,8 @@
$payment->id = 'test-payment';
$payment->wallet_id = $wallet->id;
$payment->amount = 100;
+ $payment->currency_amount = 100;
+ $payment->currency = 'CHF';
$payment->status = PaymentProvider::STATUS_PAID;
$payment->description = 'test';
$payment->provider = 'stripe';
File Metadata
Details
Attached
Mime Type
text/plain
Expires
Sat, Apr 4, 3:53 AM (6 h, 48 m)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
18827982
Default Alt Text
D2239.1775274790.diff (91 KB)
Attached To
Mode
D2239: New Payment dialog structure
Attached
Detach File
Event Timeline