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',
];
@@ -232,6 +235,40 @@
}
/**
+ * 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).
*
* @param string $provider Provider name
@@ -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 = $this->exchange($payment['amount'], $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 = $this->exchange($payment['amount'], $wallet->currency, $payment['currency']);
+ $payment['currency_amount'] = $amount;
+ // Note: Required fields: description, amount/currency, amount/value
$request = [
'amount' => [
'currency' => $payment['currency'],
- // a number with two decimals is required
- '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 = $this->exchange($payment['amount'], $wallet->currency, $payment['currency']);
+ $payment['currency_amount'] = $amount;
$request = [
'amount' => [
'currency' => $payment['currency'],
// a number with two decimals is required
- 'value' => sprintf('%.2f', $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
@@ -83,6 +83,7 @@
$session = StripeAPI\Checkout\Session::create($request);
$payment['amount'] = 0;
+ $payment['currency_amount'] = 0;
$payment['id'] = $session->setup_intent;
$payment['type'] = self::TYPE_MANDATE;
@@ -181,6 +182,10 @@
// Register the user in Stripe, if not yet done
$customer_id = self::stripeCustomerId($wallet, true);
+
+ $amount = $this->exchange($payment['amount'], $wallet->currency, $payment['currency']);
+ $payment['currency_amount'] = $amount;
+
$request = [
'customer' => $customer_id,
'cancel_url' => Utils::serviceUrl('/wallet'), // required
@@ -190,7 +195,7 @@
'line_items' => [
[
'name' => $payment['description'],
- 'amount' => $payment['amount'],
+ 'amount' => $amount,
'currency' => \strtolower($payment['currency']),
'quantity' => 1,
]
@@ -227,8 +232,11 @@
return null;
}
+ $amount = $this->exchange($payment['amount'], $wallet->currency, $payment['currency']);
+ $payment['currency_amount'] = $amount;
+
$request = [
- 'amount' => $payment['amount'],
+ 'amount' => $amount,
'currency' => \strtolower($payment['currency']),
'description' => $payment['description'],
'receipt_email' => $wallet->owner->email,
@@ -271,6 +279,7 @@
\config('services.stripe.webhook_secret')
);
} catch (\Exception $e) {
+ \Log::error("Invalid payload: " . $e->getMessage());
// Invalid payload
return 400;
}
@@ -470,4 +479,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,12 +181,45 @@
$db_payment->type = $payment['type'];
$db_payment->wallet_id = $wallet_id;
$db_payment->provider = $this->name();
+ $db_payment->currency = $payment['currency'];
+ $db_payment->currency_amount = $payment['currency_amount'];
$db_payment->save();
return $db_payment;
}
/**
+ * Retrieve an exchange rate.
+ *
+ * @param string $sourceCurrency Currency from which to convert
+ * @param string $targetCurrency Currency to convert to
+ *
+ * @return float Exchange rate
+ */
+ protected function exchangeRate(string $sourceCurrency, string $targetCurrency): float
+ {
+ if (strcasecmp($sourceCurrency, $targetCurrency)) {
+ throw new \Exception("Currency conversion is not yet implemented.");
+ //FIXME Not yet implemented
+ }
+ return 1.0;
+ }
+
+ /**
+ * Convert a value from $sourceCurrency to $targetCurrency
+ *
+ * @param int $amount Amount in cents of $sourceCurrency
+ * @param string $sourceCurrency Currency from which to convert
+ * @param string $targetCurrency Currency to convert to
+ *
+ * @return int Exchanged amount in cents of $targetCurrency
+ */
+ protected function exchange(int $amount, string $sourceCurrency, string $targetCurrency): int
+ {
+ return intval(round($amount * $this->exchangeRate($sourceCurrency, $targetCurrency)));
+ }
+
+ /**
* Deduct an amount of pecunia from the wallet.
* Creates a payment and transaction records for the refund/chargeback operation.
*
@@ -172,7 +234,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 = $this->exchange($refund['amount'], $refund['currency'], $wallet->currency);
+
+ $wallet->balance -= $amount;
$wallet->save();
if ($refund['type'] == self::TYPE_CHARGEBACK) {
@@ -185,13 +254,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 @@
+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 ``
},
// 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 @@
{{ wallet.notice }}
-Add credit to your account or setup an automatic payment by using the button below.
- + ++ +
++ Auto-payment is set to fill up your account by {{ mandate.amount }} CHF + every time your account balance gets under {{ mandate.balance }} CHF. +
++ Method of payment: {{ mandate.method }} +
+ ++ + +
+@@ -48,11 +89,16 @@
+ 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 }}. +
++ Please note that a bank transfer can take several days to complete. +
Choose the amount by which you want to top up your wallet.
Add auto-payment, so you never run out.
-- Auto-payment is set to fill up your account by {{ mandate.amount }} CHF - every time your account balance gets under {{ mandate.balance }} CHF. - You will be charged via {{ mandate.method }}. -
-You can cancel or change the auto-payment at any time.
-