diff --git a/src/app/Backends/OpenExchangeRates.php b/src/app/Backends/OpenExchangeRates.php
new file mode 100644
index 00000000..8ee5d2ff
--- /dev/null
+++ b/src/app/Backends/OpenExchangeRates.php
@@ -0,0 +1,52 @@
+ $apiKey, 'base' => 'USD']);
+ $url = 'https://openexchangerates.org/api/latest.json?' . $query;
+ $html = file_get_contents($url, false);
+ $rates = [];
+
+ if ($html && ($result = json_decode($html, true)) && !empty($result['rates'])) {
+ foreach ($result['rates'] as $code => $rate) {
+ $rates[strtoupper($code)] = $rate;
+ }
+
+ if ($baseCurrency != 'USD') {
+ if ($base = $rates[$baseCurrency]) {
+ foreach ($rates as $code => $rate) {
+ $rates[$code] = $rate / $base;
+ }
+ } else {
+ $rates = [];
+ }
+ }
+
+ foreach ($rates as $code => $rate) {
+ \Log::debug(sprintf("Update %s: %0.8f", $code, $rate));
+ }
+ } else {
+ throw new \Exception("Failed to parse exchange rates");
+ }
+
+ if (count($rates) > 1) {
+ $rates[$baseCurrency] = 1;
+ return $rates;
+ }
+
+ throw new \Exception("Failed to retrieve exchange rates");
+ }
+}
diff --git a/src/app/Console/Commands/Data/Import/ExchangerateCommand.php b/src/app/Console/Commands/Data/Import/ExchangerateCommand.php
new file mode 100644
index 00000000..29bb10b2
--- /dev/null
+++ b/src/app/Console/Commands/Data/Import/ExchangerateCommand.php
@@ -0,0 +1,48 @@
+argument('sourceCurrency'));
+ $rates = \App\Backends\OpenExchangeRates::retrieveRates($sourceCurrency);
+
+ //
+ // export
+ //
+ $file = resource_path("exchangerates-$sourceCurrency.php");
+
+ $out = " $rate) {
+ $out .= sprintf(" '%s' => '%s',\n", $countryCode, $rate);
+ }
+
+ $out .= "];\n";
+
+ file_put_contents($file, $out);
+ }
+}
diff --git a/src/app/Providers/Payment/Mollie.php b/src/app/Providers/Payment/Mollie.php
index bdbf867a..9fe47867 100644
--- a/src/app/Providers/Payment/Mollie.php
+++ b/src/app/Providers/Payment/Mollie.php
@@ -1,622 +1,622 @@
tag
*/
public function customerLink(Wallet $wallet): ?string
{
$customer_id = self::mollieCustomerId($wallet, false);
if (!$customer_id) {
return null;
}
return sprintf(
'%s',
$customer_id,
$customer_id
);
}
/**
* Create a new auto-payment mandate for a wallet.
*
* @param \App\Wallet $wallet The wallet
* @param array $payment Payment data:
* - amount: Value in cents (optional)
* - currency: The operation currency
* - description: Operation desc.
* - methodId: Payment method
*
* @return array Provider payment data:
* - id: Operation identifier
* - redirectUrl: the location to redirect to
*/
public function createMandate(Wallet $wallet, array $payment): ?array
{
// Register the user in Mollie, if not yet done
$customer_id = self::mollieCustomerId($wallet, true);
if (!isset($payment['amount'])) {
$payment['amount'] = 0;
}
$amount = $this->exchange($payment['amount'], $wallet->currency, $payment['currency']);
$payment['currency_amount'] = $amount;
$request = [
'amount' => [
'currency' => $payment['currency'],
'value' => sprintf('%.2f', $amount / 100),
],
'customerId' => $customer_id,
'sequenceType' => 'first',
'description' => $payment['description'],
'webhookUrl' => Utils::serviceUrl('/api/webhooks/payment/mollie'),
'redirectUrl' => Utils::serviceUrl('/wallet'),
'locale' => 'en_US',
'method' => $payment['methodId']
];
// Create the payment in Mollie
$response = mollie()->payments()->create($request);
if ($response->mandateId) {
$wallet->setSetting('mollie_mandate_id', $response->mandateId);
}
// Store the payment reference in database
$payment['status'] = $response->status;
$payment['id'] = $response->id;
$payment['type'] = self::TYPE_MANDATE;
$this->storePayment($payment, $wallet->id);
return [
'id' => $response->id,
'redirectUrl' => $response->getCheckoutUrl(),
];
}
/**
* Revoke the auto-payment mandate for the wallet.
*
* @param \App\Wallet $wallet The wallet
*
* @return bool True on success, False on failure
*/
public function deleteMandate(Wallet $wallet): bool
{
// Get the Mandate info
$mandate = self::mollieMandate($wallet);
// Revoke the mandate on Mollie
if ($mandate) {
$mandate->revoke();
$wallet->setSetting('mollie_mandate_id', null);
}
return true;
}
/**
* Get a auto-payment mandate for the wallet.
*
* @param \App\Wallet $wallet The wallet
*
* @return array|null Mandate information:
* - id: Mandate identifier
* - method: user-friendly payment method desc.
* - methodId: Payment method
* - isPending: the process didn't complete yet
* - isValid: the mandate is valid
*/
public function getMandate(Wallet $wallet): ?array
{
// Get the Mandate info
$mandate = self::mollieMandate($wallet);
if (empty($mandate)) {
return null;
}
$result = [
'id' => $mandate->id,
'isPending' => $mandate->isPending(),
'isValid' => $mandate->isValid(),
'method' => self::paymentMethod($mandate, 'Unknown method'),
'methodId' => $mandate->method
];
return $result;
}
/**
* Get a provider name
*
* @return string Provider name
*/
public function name(): string
{
return 'mollie';
}
/**
* Create a new payment.
*
* @param \App\Wallet $wallet The wallet
* @param array $payment Payment data:
* - amount: Value in cents
* - currency: The operation currency
* - type: oneoff/recurring
* - description: Operation desc.
* - methodId: Payment method
*
* @return array Provider payment data:
* - id: Operation identifier
* - redirectUrl: the location to redirect to
*/
public function payment(Wallet $wallet, array $payment): ?array
{
if ($payment['type'] == self::TYPE_RECURRING) {
return $this->paymentRecurring($wallet, $payment);
}
// Register the user in Mollie, if not yet done
$customer_id = self::mollieCustomerId($wallet, true);
$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 (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' => $payment['methodId'],
'redirectUrl' => Utils::serviceUrl('/wallet') // required for non-recurring payments
];
// TODO: Additional payment parameters for better fraud protection:
// billingEmail - for bank transfers, Przelewy24, but not creditcard
// billingAddress (it is a structured field not just text)
// Create the payment in Mollie
$response = mollie()->payments()->create($request);
// Store the payment reference in database
$payment['status'] = $response->status;
$payment['id'] = $response->id;
$this->storePayment($payment, $wallet->id);
return [
'id' => $payment['id'],
'redirectUrl' => $response->getCheckoutUrl(),
];
}
/**
* 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.
*
* @param \App\Wallet $wallet The wallet
* @param array $payment Payment data (see self::payment())
*
* @return array Provider payment/session data:
* - id: Operation identifier
*/
protected function paymentRecurring(Wallet $wallet, array $payment): ?array
{
// Check if there's a valid mandate
$mandate = self::mollieMandate($wallet);
if (empty($mandate) || !$mandate->isValid() || $mandate->isPending()) {
return null;
}
$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', $amount / 100),
],
'customerId' => $customer_id,
'sequenceType' => $payment['type'],
'description' => $payment['description'],
'webhookUrl' => Utils::serviceUrl('/api/webhooks/payment/mollie'),
'locale' => 'en_US',
'method' => $payment['methodId'],
'mandateId' => $mandate->id
];
// Create the payment in Mollie
$response = mollie()->payments()->create($request);
// Store the payment reference in database
$payment['status'] = $response->status;
$payment['id'] = $response->id;
DB::beginTransaction();
$payment = $this->storePayment($payment, $wallet->id);
// Mollie can return 'paid' status immediately, so we don't
// have to wait for the webhook. What's more, the webhook would ignore
// the payment because it will be marked as paid before the webhook.
// Let's handle paid status here too.
if ($response->isPaid()) {
self::creditPayment($payment, $response);
$notify = true;
} elseif ($response->isFailed()) {
// Note: I didn't find a way to get any description of the problem with a payment
\Log::info(sprintf('Mollie payment failed (%s)', $response->id));
// Disable the mandate
$wallet->setSetting('mandate_disabled', 1);
$notify = true;
}
DB::commit();
if (!empty($notify)) {
\App\Jobs\PaymentEmail::dispatch($payment);
}
return [
'id' => $payment['id'],
];
}
/**
* Update payment status (and balance).
*
* @return int HTTP response code
*/
public function webhook(): int
{
$payment_id = \request()->input('id');
if (empty($payment_id)) {
return 200;
}
$payment = Payment::find($payment_id);
if (empty($payment)) {
// Mollie recommends to return "200 OK" even if the payment does not exist
return 200;
}
// Get the payment details from Mollie
// TODO: Consider https://github.com/mollie/mollie-api-php/issues/502 when it's fixed
$mollie_payment = mollie()->payments()->get($payment_id);
if (empty($mollie_payment)) {
// Mollie recommends to return "200 OK" even if the payment does not exist
return 200;
}
$refunds = [];
if ($mollie_payment->isPaid()) {
// The payment is paid. Update the balance, and notify the user
if ($payment->status != self::STATUS_PAID && $payment->amount > 0) {
$credit = true;
$notify = $payment->type == self::TYPE_RECURRING;
}
// The payment has been (partially) refunded.
// Let's process refunds with status "refunded".
if ($mollie_payment->hasRefunds()) {
foreach ($mollie_payment->refunds() as $refund) {
if ($refund->isTransferred() && $refund->amount->value) {
$refunds[] = [
'id' => $refund->id,
'description' => $refund->description,
'amount' => round(floatval($refund->amount->value) * 100),
'type' => self::TYPE_REFUND,
'currency' => $refund->amount->currency
];
}
}
}
// The payment has been (partially) charged back.
// Let's process chargebacks (they have no states as refunds)
if ($mollie_payment->hasChargebacks()) {
foreach ($mollie_payment->chargebacks() as $chargeback) {
if ($chargeback->amount->value) {
$refunds[] = [
'id' => $chargeback->id,
'amount' => round(floatval($chargeback->amount->value) * 100),
'type' => self::TYPE_CHARGEBACK,
'currency' => $chargeback->amount->currency
];
}
}
}
// In case there were multiple auto-payment setup requests (e.g. caused by a double
// form submission) we end up with multiple payment records and mollie_mandate_id
// pointing to the one from the last payment not the successful one.
// We make sure to use mandate id from the successful "first" payment.
if (
$payment->type == self::TYPE_MANDATE
&& $mollie_payment->mandateId
&& $mollie_payment->sequenceType == Types\SequenceType::SEQUENCETYPE_FIRST
) {
$payment->wallet->setSetting('mollie_mandate_id', $mollie_payment->mandateId);
}
} elseif ($mollie_payment->isFailed()) {
// Note: I didn't find a way to get any description of the problem with a payment
\Log::info(sprintf('Mollie payment failed (%s)', $payment->id));
// Disable the mandate
if ($payment->type == self::TYPE_RECURRING) {
$notify = true;
$payment->wallet->setSetting('mandate_disabled', 1);
}
}
DB::beginTransaction();
// This is a sanity check, just in case the payment provider api
// sent us open -> paid -> open -> paid. So, we lock the payment after
// recivied a "final" state.
$pending_states = [self::STATUS_OPEN, self::STATUS_PENDING, self::STATUS_AUTHORIZED];
if (in_array($payment->status, $pending_states)) {
$payment->status = $mollie_payment->status;
$payment->save();
}
if (!empty($credit)) {
self::creditPayment($payment, $mollie_payment);
}
foreach ($refunds as $refund) {
$this->storeRefund($payment->wallet, $refund);
}
DB::commit();
if (!empty($notify)) {
\App\Jobs\PaymentEmail::dispatch($payment);
}
return 200;
}
/**
* Get Mollie customer identifier for specified wallet.
* Create one if does not exist yet.
*
* @param \App\Wallet $wallet The wallet
* @param bool $create Create the customer if does not exist yet
*
* @return ?string Mollie customer identifier
*/
protected static function mollieCustomerId(Wallet $wallet, bool $create = false): ?string
{
$customer_id = $wallet->getSetting('mollie_id');
// Register the user in Mollie
if (empty($customer_id) && $create) {
$customer = mollie()->customers()->create([
'name' => $wallet->owner->name(),
'email' => $wallet->id . '@private.' . \config('app.domain'),
]);
$customer_id = $customer->id;
$wallet->setSetting('mollie_id', $customer->id);
}
return $customer_id;
}
/**
* Get the active Mollie auto-payment mandate
*/
protected static function mollieMandate(Wallet $wallet)
{
$customer_id = $wallet->getSetting('mollie_id');
$mandate_id = $wallet->getSetting('mollie_mandate_id');
// Get the manadate reference we already have
if ($customer_id && $mandate_id) {
try {
return mollie()->mandates()->getForId($customer_id, $mandate_id);
} catch (ApiException $e) {
// FIXME: What about 404?
if ($e->getCode() == 410) {
// The mandate is gone, remove the reference
$wallet->setSetting('mollie_mandate_id', null);
return null;
}
// TODO: Maybe we shouldn't always throw? It make sense in the job
// but for example when we're just fetching wallet info...
throw $e;
}
}
}
/**
* Apply the successful payment's pecunia to the wallet
*/
protected static function creditPayment($payment, $mollie_payment)
{
// Extract the payment method for transaction description
$method = self::paymentMethod($mollie_payment, 'Mollie');
// TODO: Localization?
$description = $payment->type == self::TYPE_RECURRING ? 'Auto-payment' : 'Payment';
$description .= " transaction {$payment->id} using {$method}";
$payment->wallet->credit($payment->amount, $description);
// Unlock the disabled auto-payment mandate
if ($payment->wallet->balance >= 0) {
$payment->wallet->setSetting('mandate_disabled', null);
}
}
/**
* Extract payment method description from Mollie payment/mandate details
*/
protected static function paymentMethod($object, $default = ''): string
{
$details = $object->details;
// Mollie supports 3 methods here
switch ($object->method) {
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)) {
return 'Credit Card';
}
return sprintf(
'%s (**** **** **** %s)',
$details->cardLabel ?: 'Card', // @phpstan-ignore-line
$details->cardNumber
);
case self::METHOD_DIRECTDEBIT:
return sprintf('Direct Debit (%s)', $details->customerAccount);
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'
- // ]
- // ]
- //),
+ (array) mollie()->methods()->allActive(
+ [
+ 'sequenceType' => $type,
+ 'amount' => [
+ 'value' => '1.00',
+ 'currency' => 'EUR'
+ ]
+ ]
+ ),
// Prefer CHF methods
- (array)mollie()->methods()->allActive(
+ (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/PaymentProvider.php b/src/app/Providers/PaymentProvider.php
index 30d41a8e..03fc2483 100644
--- a/src/app/Providers/PaymentProvider.php
+++ b/src/app/Providers/PaymentProvider.php
@@ -1,388 +1,387 @@
['prefix' => 'far', 'name' => 'credit-card'],
self::METHOD_PAYPAL => ['prefix' => 'fab', 'name' => 'paypal'],
self::METHOD_BANKTRANSFER => ['prefix' => 'fas', 'name' => 'university']
];
/**
* Detect the name of the provider
*
* @param \App\Wallet|string|null $provider_or_wallet
* @return string The name of the provider
*/
private static function providerName($provider_or_wallet = null): string
{
if ($provider_or_wallet instanceof Wallet) {
if ($provider_or_wallet->getSetting('stripe_id')) {
$provider = self::PROVIDER_STRIPE;
} elseif ($provider_or_wallet->getSetting('mollie_id')) {
$provider = self::PROVIDER_MOLLIE;
}
} else {
$provider = $provider_or_wallet;
}
if (empty($provider)) {
$provider = \config('services.payment_provider') ?: self::PROVIDER_MOLLIE;
}
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 self::PROVIDER_MOLLIE:
return new \App\Providers\Payment\Mollie();
default:
throw new \Exception("Invalid payment provider: {$provider_or_wallet}");
}
}
/**
* Create a new auto-payment mandate for a wallet.
*
* @param \App\Wallet $wallet The wallet
* @param array $payment Payment data:
* - amount: Value in cents
* - currency: The operation currency
* - description: Operation desc.
* - methodId: Payment method
*
* @return array Provider payment data:
* - id: Operation identifier
* - redirectUrl: the location to redirect to
*/
abstract public function createMandate(Wallet $wallet, array $payment): ?array;
/**
* Revoke the auto-payment mandate for a wallet.
*
* @param \App\Wallet $wallet The wallet
*
* @return bool True on success, False on failure
*/
abstract public function deleteMandate(Wallet $wallet): bool;
/**
* Get a auto-payment mandate for a wallet.
*
* @param \App\Wallet $wallet The wallet
*
* @return array|null Mandate information:
* - id: Mandate identifier
* - method: user-friendly payment method desc.
* - methodId: Payment method
* - isPending: the process didn't complete yet
* - isValid: the mandate is valid
*/
abstract public function getMandate(Wallet $wallet): ?array;
/**
* Get a link to the customer in the provider's control panel
*
* @param \App\Wallet $wallet The wallet
*
* @return string|null The string representing tag
*/
abstract public function customerLink(Wallet $wallet): ?string;
/**
* Get a provider name
*
* @return string Provider name
*/
abstract public function name(): string;
/**
* Create a new payment.
*
* @param \App\Wallet $wallet The wallet
* @param array $payment Payment data:
* - amount: Value in cents
* - currency: The operation currency
* - type: first/oneoff/recurring
* - description: Operation description
* - methodId: Payment method
*
* @return array Provider payment/session data:
* - id: Operation identifier
* - redirectUrl
*/
abstract public function payment(Wallet $wallet, array $payment): ?array;
/**
* Update payment status (and balance).
*
* @return int HTTP response code
*/
abstract public function webhook(): int;
/**
* Create a payment record in DB
*
* @param array $payment Payment information
* @param string $wallet_id Wallet ID
*
* @return \App\Payment Payment object
*/
protected function storePayment(array $payment, $wallet_id): Payment
{
$db_payment = new Payment();
$db_payment->id = $payment['id'];
$db_payment->description = $payment['description'] ?? '';
$db_payment->status = $payment['status'] ?? self::STATUS_OPEN;
$db_payment->amount = $payment['amount'] ?? 0;
$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 \App\Utils::exchangeRate($sourceCurrency, $targetCurrency);
}
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.
*
* @param \App\Wallet $wallet A wallet object
* @param array $refund A refund or chargeback data (id, type, amount, description)
*
* @return void
*/
protected function storeRefund(Wallet $wallet, array $refund): void
{
if (empty($refund) || empty($refund['amount'])) {
return;
}
// Preserve originally refunded amount
- $refund['currency_amount'] = $refund['amount'];
+ $refund['currency_amount'] = $refund['amount'] * -1;
// 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);
+ // We're not using PaymentProvider::exchange because we have to take the inverse rate of CHF to EUR (we don't have EUR to CHF directrly)
+ $amount = intval(round($refund['amount'] / $this->exchangeRate($wallet->currency, $refund['currency'])));
$wallet->balance -= $amount;
$wallet->save();
if ($refund['type'] == self::TYPE_CHARGEBACK) {
$transaction_type = Transaction::WALLET_CHARGEBACK;
} else {
$transaction_type = Transaction::WALLET_REFUND;
}
Transaction::create([
'object_id' => $wallet->id,
'object_type' => Wallet::class,
'type' => $transaction_type,
'amount' => $amount * -1,
'description' => $refund['description'] ?? '',
]);
$refund['status'] = self::STATUS_PAID;
$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]
- // ]
+ 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/app/Utils.php b/src/app/Utils.php
index d06c0ada..4c961586 100644
--- a/src/app/Utils.php
+++ b/src/app/Utils.php
@@ -1,409 +1,430 @@
= INET_ATON(?)
ORDER BY INET_ATON(net_number), net_mask DESC LIMIT 1
";
} else {
$query = "
SELECT id FROM ip6nets
WHERE INET6_ATON(net_number) <= INET6_ATON(?)
AND INET6_ATON(net_broadcast) >= INET6_ATON(?)
ORDER BY INET6_ATON(net_number), net_mask DESC LIMIT 1
";
}
$nets = \Illuminate\Support\Facades\DB::select($query, [$ip, $ip]);
if (sizeof($nets) > 0) {
return $nets[0]->country;
}
return 'CH';
}
/**
* Return the country ISO code for the current request.
*/
public static function countryForRequest()
{
$request = \request();
$ip = $request->ip();
return self::countryForIP($ip);
}
/**
* Shortcut to creating a progress bar of a particular format with a particular message.
*
* @param \Illuminate\Console\OutputStyle $output Console output object
* @param int $count Number of progress steps
* @param string $message The description
*
* @return \Symfony\Component\Console\Helper\ProgressBar
*/
public static function createProgressBar($output, $count, $message = null)
{
$bar = $output->createProgressBar($count);
$bar->setFormat(
'%current:7s%/%max:7s% [%bar%] %percent:3s%% %elapsed:7s%/%estimated:-7s% %message% '
);
if ($message) {
$bar->setMessage($message . " ...");
}
$bar->start();
return $bar;
}
/**
* Return the number of days in the month prior to this one.
*
* @return int
*/
public static function daysInLastMonth()
{
$start = new Carbon('first day of last month');
$end = new Carbon('last day of last month');
return $start->diffInDays($end) + 1;
}
/**
* Download a file from the interwebz and store it locally.
*
* @param string $source The source location
* @param string $target The target location
* @param bool $force Force the download (and overwrite target)
*
* @return void
*/
public static function downloadFile($source, $target, $force = false)
{
if (is_file($target) && !$force) {
return;
}
\Log::info("Retrieving {$source}");
$fp = fopen($target, 'w');
$curl = curl_init();
curl_setopt($curl, CURLOPT_URL, $source);
curl_setopt($curl, CURLOPT_RETURNTRANSFER, 1);
curl_setopt($curl, CURLOPT_FILE, $fp);
curl_exec($curl);
if (curl_errno($curl)) {
\Log::error("Request error on {$source}: " . curl_error($curl));
curl_close($curl);
fclose($fp);
unlink($target);
return;
}
curl_close($curl);
fclose($fp);
}
/**
* Calculate the broadcast address provided a net number and a prefix.
*
* @param string $net A valid IPv6 network number.
* @param int $prefix The network prefix.
*
* @return string
*/
public static function ip6Broadcast($net, $prefix)
{
$netHex = bin2hex(inet_pton($net));
// Overwriting first address string to make sure notation is optimal
$net = inet_ntop(hex2bin($netHex));
// Calculate the number of 'flexible' bits
$flexbits = 128 - $prefix;
// Build the hexadecimal string of the last address
$lastAddrHex = $netHex;
// We start at the end of the string (which is always 32 characters long)
$pos = 31;
while ($flexbits > 0) {
// Get the character at this position
$orig = substr($lastAddrHex, $pos, 1);
// Convert it to an integer
$origval = hexdec($orig);
// OR it with (2^flexbits)-1, with flexbits limited to 4 at a time
$newval = $origval | (pow(2, min(4, $flexbits)) - 1);
// Convert it back to a hexadecimal character
$new = dechex($newval);
// And put that character back in the string
$lastAddrHex = substr_replace($lastAddrHex, $new, $pos, 1);
// We processed one nibble, move to previous position
$flexbits -= 4;
$pos -= 1;
}
// Convert the hexadecimal string to a binary string
# Using pack() here
# Newer PHP version can use hex2bin()
$lastaddrbin = pack('H*', $lastAddrHex);
// And create an IPv6 address from the binary string
$lastaddrstr = inet_ntop($lastaddrbin);
return $lastaddrstr;
}
/**
* Provide all unique combinations of elements in $input, with order and duplicates irrelevant.
*
* @param array $input The input array of elements.
*
* @return array[]
*/
public static function powerSet(array $input): array
{
$output = [];
for ($x = 0; $x < count($input); $x++) {
self::combine($input, $x + 1, 0, [], 0, $output);
}
return $output;
}
/**
* Returns the current user's email address or null.
*
* @return string
*/
public static function userEmailOrNull(): ?string
{
$user = Auth::user();
if (!$user) {
return null;
}
return $user->email;
}
/**
* Returns a random string consisting of a quantity of segments of a certain length joined.
*
* Example:
*
* ```php
* $roomName = strtolower(\App\Utils::randStr(3, 3, '-');
* // $roomName == '3qb-7cs-cjj'
* ```
*
* @param int $length The length of each segment
* @param int $qty The quantity of segments
* @param string $join The string to use to join the segments
*
* @return string
*/
public static function randStr($length, $qty = 1, $join = '')
{
$chars = env('SHORTCODE_CHARS', self::CHARS);
$randStrs = [];
for ($x = 0; $x < $qty; $x++) {
$randStrs[$x] = [];
for ($y = 0; $y < $length; $y++) {
$randStrs[$x][] = $chars[rand(0, strlen($chars) - 1)];
}
shuffle($randStrs[$x]);
$randStrs[$x] = implode('', $randStrs[$x]);
}
return implode($join, $randStrs);
}
/**
* Returns a UUID in the form of an integer.
*
* @return integer
*/
public static function uuidInt(): int
{
$hex = Uuid::uuid4();
$bin = pack('h*', str_replace('-', '', $hex));
$ids = unpack('L', $bin);
$id = array_shift($ids);
return $id;
}
/**
* Returns a UUID in the form of a string.
*
* @return string
*/
public static function uuidStr(): string
{
return Uuid::uuid4()->toString();
}
private static function combine($input, $r, $index, $data, $i, &$output): void
{
$n = count($input);
// Current cobination is ready
if ($index == $r) {
$output[] = array_slice($data, 0, $r);
return;
}
// When no more elements are there to put in data[]
if ($i >= $n) {
return;
}
// current is included, put next at next location
$data[$index] = $input[$i];
self::combine($input, $r, $index + 1, $data, $i + 1, $output);
// current is excluded, replace it with next (Note that i+1
// is passed, but index is not changed)
self::combine($input, $r, $index, $data, $i + 1, $output);
}
/**
* Create self URL
*
* @param string $route Route/Path
* @todo Move this to App\Http\Controllers\Controller
*
* @return string Full URL
*/
public static function serviceUrl(string $route): string
{
$url = \config('app.public_url');
if (!$url) {
$url = \config('app.url');
}
return rtrim(trim($url, '/') . '/' . ltrim($route, '/'), '/');
}
/**
* Create a configuration/environment data to be passed to
* the UI
*
* @todo Move this to App\Http\Controllers\Controller
*
* @return array Configuration data
*/
public static function uiEnv(): array
{
$countries = include resource_path('countries.php');
$req_domain = preg_replace('/:[0-9]+$/', '', request()->getHttpHost());
$sys_domain = \config('app.domain');
$path = request()->path();
$opts = [
'app.name',
'app.url',
'app.domain',
'app.theme',
'app.webmail_url',
'app.support_email',
'mail.from.address'
];
$env = \app('config')->getMany($opts);
$env['countries'] = $countries ?: [];
$env['view'] = 'root';
$env['jsapp'] = 'user.js';
if ($req_domain == "admin.$sys_domain") {
$env['jsapp'] = 'admin.js';
}
$env['paymentProvider'] = \config('services.payment_provider');
$env['stripePK'] = \config('services.stripe.public_key');
$theme_file = resource_path("themes/{$env['app.theme']}/theme.json");
$menu = [];
if (file_exists($theme_file)) {
$theme = json_decode(file_get_contents($theme_file), true);
if (json_last_error() != JSON_ERROR_NONE) {
\Log::error("Failed to parse $theme_file: " . json_last_error_msg());
} elseif (!empty($theme['menu'])) {
$menu = $theme['menu'];
}
}
$env['menu'] = $menu;
return $env;
}
+
+
+ /**
+ * Retrieve an exchange rate.
+ *
+ * @param string $sourceCurrency: Currency from which to convert
+ * @param string $targetCurrency: Currency to convert to
+ *
+ * @return float Exchange rate
+ */
+ public static function exchangeRate(string $sourceCurrency, string $targetCurrency): float
+ {
+ $rates = include resource_path("exchangerates-$sourceCurrency.php");
+
+ if (!isset($rates[$targetCurrency])) {
+ throw new \Exception("Failed to find exchange rate for " . $targetCurrency);
+ }
+
+ return floatval($rates[$targetCurrency]);
+ }
}
diff --git a/src/config/currency.php b/src/config/currency.php
index 235796c0..554f2145 100644
--- a/src/config/currency.php
+++ b/src/config/currency.php
@@ -1,116 +1,5 @@
'USD',
-
- /*
- |--------------------------------------------------------------------------
- | API Key for OpenExchangeRates.org
- |--------------------------------------------------------------------------
- |
- | Only required if you with to use the Open Exchange Rates api. You can
- | always just use Yahoo, the current default.
- |
- */
-
- 'api_key' => env('OPENEXCHANGERATES_API_KEY', null),
-
- /*
- |--------------------------------------------------------------------------
- | Default Storage Driver
- |--------------------------------------------------------------------------
- |
- | Here you may specify the default storage driver that should be used
- | by the framework.
- |
- | Supported: "database", "filesystem"
- |
- */
-
- 'driver' => 'filesystem',
-
- /*
- |--------------------------------------------------------------------------
- | Default Storage Driver
- |--------------------------------------------------------------------------
- |
- | Here you may specify the default cache driver that should be used
- | by the framework.
- |
- | Supported: all cache drivers supported by Laravel
- |
- */
-
- 'cache_driver' => null,
-
- /*
- |--------------------------------------------------------------------------
- | Storage Specific Configuration
- |--------------------------------------------------------------------------
- |
- | Here you may configure as many storage drivers as you wish.
- |
- */
-
- 'drivers' => [
-
- 'database' => [
- 'class' => \Torann\Currency\Drivers\Database::class,
- 'connection' => null,
- 'table' => 'currencies',
- ],
-
- 'filesystem' => [
- 'class' => \Torann\Currency\Drivers\Filesystem::class,
- 'disk' => null,
- 'path' => 'currencies.json',
- ],
-
- ],
-
- /*
- |--------------------------------------------------------------------------
- | Currency Formatter
- |--------------------------------------------------------------------------
- |
- | Here you may configure a custom formatting of currencies. The reason for
- | this is to help further internationalize the formatting past the basic
- | format column in the table. When set to `null` the package will use the
- | format from storage.
- |
- | More info:
- | http://lyften.com/projects/laravel-currency/doc/formatting.html
- |
- */
-
- 'formatter' => null,
-
- /*
- |--------------------------------------------------------------------------
- | Currency Formatter Specific Configuration
- |--------------------------------------------------------------------------
- |
- | Here you may configure as many currency formatters as you wish.
- |
- */
-
- 'formatters' => [
-
- 'php_intl' => [
- 'class' => \Torann\Currency\Formatters\PHPIntl::class,
- ],
-
- ],
+ 'openexchangerates_api_key' => env('OPENEXCHANGERATES_API_KEY', null),
];
diff --git a/src/resources/exchangerates-CHF.php b/src/resources/exchangerates-CHF.php
new file mode 100644
index 00000000..1d50e310
--- /dev/null
+++ b/src/resources/exchangerates-CHF.php
@@ -0,0 +1,172 @@
+ '3.9552959825289',
+ 'AFN' => '83.613671484215',
+ 'ALL' => '111.47078618895',
+ 'AMD' => '568.26008190657',
+ 'ANG' => '1.9326009913646',
+ 'AOA' => '674.30453178322',
+ 'ARS' => '98.608363602295',
+ 'AUD' => '1.4005248654184',
+ 'AWG' => '1.9383622346732',
+ 'AZN' => '1.8315423225241',
+ 'BAM' => '1.765440939718',
+ 'BBD' => '2.1537358163036',
+ 'BDT' => '91.161248046831',
+ 'BGN' => '1.76640258276',
+ 'BHD' => '0.40598781631649',
+ 'BIF' => '2095.7472160272',
+ 'BMD' => '1.0768679081518',
+ 'BND' => '1.4428166987753',
+ 'BOB' => '7.4343600550926',
+ 'BRL' => '5.9304203338506',
+ 'BSD' => '1.0768679081518',
+ 'BTC' => '1.9792923685602E-5',
+ 'BTN' => '77.837962608992',
+ 'BWP' => '11.786340792079',
+ 'BYN' => '2.7980743448066',
+ 'BZD' => '2.1702905066556',
+ 'CAD' => '1.3518138224611',
+ 'CDF' => '2116.9551990644',
+ 'CHF' => '1',
+ 'CLF' => '0.027974874517967',
+ 'CLP' => '771.89865273056',
+ 'CNH' => '7.0074023900006',
+ 'CNY' => '7.0104100820681',
+ 'COP' => '3842.7175763149',
+ 'CRC' => '657.98680298379',
+ 'CUC' => '1.0768679081518',
+ 'CUP' => '27.729348634908',
+ 'CVE' => '61.047641713125',
+ 'CZK' => '23.665033775962',
+ 'DJF' => '191.67469005049',
+ 'DKK' => '6.7297783052037',
+ 'DOP' => '61.257419889104',
+ 'DZD' => '143.75354477994',
+ 'EGP' => '16.95528521385',
+ 'ERN' => '16.154426088633',
+ 'ETB' => '44.007619917318',
+ 'EUR' => '0.90503424978382',
+ 'FJD' => '2.1941722062547',
+ 'FKP' => '0.77972343878383',
+ 'GBP' => '0.77972343878383',
+ 'GEL' => '3.5805857946047',
+ 'GGP' => '0.77972343878383',
+ 'GHS' => '6.1747045882111',
+ 'GIP' => '0.77972343878383',
+ 'GMD' => '55.189480292779',
+ 'GNF' => '10817.922694883',
+ 'GTQ' => '8.3118318707672',
+ 'GYD' => '225.03202066725',
+ 'HKD' => '8.3636130641307',
+ 'HNL' => '25.899206240665',
+ 'HRK' => '6.85609491083',
+ 'HTG' => '85.509264833048',
+ 'HUF' => '331.7893366386',
+ 'IDR' => '15495.725407298',
+ 'ILS' => '3.5472890388846',
+ 'IMP' => '0.77972343878383',
+ 'INR' => '77.954048969491',
+ 'IQD' => '1570.6716338994',
+ 'IRR' => '45341.523272731',
+ 'ISK' => '135.22230322662',
+ 'JEP' => '0.77972343878383',
+ 'JMD' => '157.70066841191',
+ 'JOD' => '0.76349934687961',
+ 'JPY' => '117.11369248314',
+ 'KES' => '118.29393971047',
+ 'KGS' => '91.318753977681',
+ 'KHR' => '4376.275274359',
+ 'KMF' => '444.07379452714',
+ 'KPW' => '969.1811173366',
+ 'KRW' => '1215.4069376138',
+ 'KWD' => '0.32537563844806',
+ 'KYD' => '0.89714942296033',
+ 'KZT' => '453.27712011062',
+ 'LAK' => '10084.767400839',
+ 'LBP' => '1627.9546875522',
+ 'LKR' => '213.44889346438',
+ 'LRD' => '186.83658529494',
+ 'LSL' => '15.830499914389',
+ 'LYD' => '4.8516237552753',
+ 'MAD' => '9.6543512463131',
+ 'MDL' => '19.238468090789',
+ 'MGA' => '4077.6026098971',
+ 'MKD' => '55.639268634391',
+ 'MMK' => '1518.1264813664',
+ 'MNT' => '3063.4631479649',
+ 'MOP' => '8.6120551054846',
+ 'MRO' => '384.44165798891',
+ 'MRU' => '38.706188436808',
+ 'MUR' => '43.397775621649',
+ 'MVR' => '16.648377860027',
+ 'MWK' => '845.13715851173',
+ 'MXN' => '22.260479270831',
+ 'MYR' => '4.4366957815853',
+ 'MZN' => '77.618485083764',
+ 'NAD' => '15.851495607994',
+ 'NGN' => '443.32165505983',
+ 'NIO' => '37.576054334447',
+ 'NOK' => '9.1987650478829',
+ 'NPR' => '124.54219760741',
+ 'NZD' => '1.5235527164531',
+ 'OMR' => '0.41463183501522',
+ 'PAB' => '1.0768679081518',
+ 'PEN' => '3.9984923849286',
+ 'PGK' => '3.7984060201224',
+ 'PHP' => '52.354625524569',
+ 'PKR' => '167.28288781513',
+ 'PLN' => '4.1605933111427',
+ 'PYG' => '7037.5479933105',
+ 'QAR' => '3.9208760535806',
+ 'RON' => '4.4244625621487',
+ 'RSD' => '106.13422512354',
+ 'RUB' => '81.443654502008',
+ 'RWF' => '1070.2236170055',
+ 'SAR' => '4.0385831002812',
+ 'SBD' => '8.5811651495393',
+ 'SCR' => '22.836425918488',
+ 'SDG' => '409.74823905175',
+ 'SEK' => '9.2106892062299',
+ 'SGD' => '1.4442252419991',
+ 'SHP' => '0.77972343878383',
+ 'SLL' => '11009.305450352',
+ 'SOS' => '622.83776231156',
+ 'SRD' => '15.24198837198',
+ 'SSP' => '140.27281371585',
+ 'STD' => '22204.04937224',
+ 'STN' => '22.291165698742',
+ 'SVC' => '9.4201120157998',
+ 'SYP' => '1086.8852521863',
+ 'SZL' => '15.823320436045',
+ 'THB' => '33.332123292761',
+ 'TJS' => '12.273947657758',
+ 'TMT' => '3.7690376785312',
+ 'TND' => '2.9457721627492',
+ 'TOP' => '2.4455788649597',
+ 'TRY' => '8.3189327377536',
+ 'TTD' => '7.3114183534905',
+ 'TWD' => '30.643246584444',
+ 'TZS' => '2497.256679004',
+ 'UAH' => '29.808460735781',
+ 'UGX' => '3945.9795987375',
+ 'USD' => '1.0768679081518',
+ 'UYU' => '47.707876965688',
+ 'UZS' => '11308.539064999',
+ 'VES' => '1941046.2073111',
+ 'VND' => '24912.524365752',
+ 'VUV' => '116.88367026735',
+ 'WST' => '2.7063122766172',
+ 'XAF' => '593.66326771259',
+ 'XAG' => '0.042055320858178',
+ 'XAU' => '0.0006191236664337',
+ 'XCD' => '2.9102893651756',
+ 'XDR' => '0.75382584246069',
+ 'XOF' => '593.66326771259',
+ 'XPD' => '0.00041475567482466',
+ 'XPF' => '107.99926234548',
+ 'XPT' => '0.00091222557367446',
+ 'YER' => '269.64770697132',
+ 'ZAR' => '15.946586274888',
+ 'ZMW' => '23.740867890922',
+ 'ZWL' => '346.75146642487',
+];
diff --git a/src/tests/Browser/PaymentMollieTest.php b/src/tests/Browser/PaymentMollieTest.php
index 27018d3f..cea49607 100644
--- a/src/tests/Browser/PaymentMollieTest.php
+++ b/src/tests/Browser/PaymentMollieTest.php
@@ -1,313 +1,313 @@
deleteTestUser('payment-test@kolabnow.com');
}
/**
* {@inheritDoc}
*/
public function tearDown(): void
{
$this->deleteTestUser('payment-test@kolabnow.com');
parent::tearDown();
}
/**
* Test the payment process
*
* @group mollie
*/
public function testPayment(): void
{
$user = $this->getTestUser('payment-test@kolabnow.com', [
'password' => 'simple123',
]);
$this->browse(function (Browser $browser) use ($user) {
$browser->visit(new Home())
->submitLogon('payment-test@kolabnow.com', 'simple123', true, ['paymentProvider' => 'mollie'])
->on(new Dashboard())
->click('@links .link-wallet')
->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')
+ ->waitFor('#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('@button-action', 'Continue')
// Test error handling
->type('@body #amount', 'aaa')
->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('@button-action')
->click('@button-action');
})
->on(new PaymentMollie())
->assertSeeIn('@title', \config('app.name') . ' Payment')
->assertSeeIn('@amount', 'CHF 12.34');
$this->assertSame(1, $user->wallets()->first()->payments()->count());
// Looks like the Mollie testing mode is limited.
// We'll select credit card method and mark the payment as paid
// We can't do much more, we have to trust Mollie their page works ;)
// For some reason I don't get the method selection form, it
// immediately jumps to the next step. Let's detect that
if ($browser->element('@methods')) {
$browser->click('@methods button.grid-button-creditcard')
->waitFor('button.form__button');
}
$browser->click('@status-table input[value="paid"]')
->click('button.form__button');
// Now it should redirect back to wallet page and in background
// use the webhook to update payment status (and balance).
// Looks like in test-mode the webhook is executed before redirect
// so we can expect balance updated on the wallet page
$browser->waitForLocation('/wallet')
->on(new WalletPage())
->assertSeeIn('@main .card-title', 'Account balance 12,34 CHF');
});
}
/**
* Test the auto-payment setup process
*
* @group mollie
*/
public function testAutoPaymentSetup(): void
{
$user = $this->getTestUser('payment-test@kolabnow.com', [
'password' => 'simple123',
]);
$this->browse(function (Browser $browser) use ($user) {
$browser->visit(new Home())
->submitLogon('payment-test@kolabnow.com', 'simple123', true, ['paymentProvider' => 'mollie'])
->on(new Dashboard())
->click('@links .link-wallet')
->on(new WalletPage())
->assertMissing('@body #mandate-form .alert')
->click('@main #mandate-form button')
->with(new Dialog('@payment-dialog'), function (Browser $browser) {
$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
->assertValue('@body #mandate_balance', '0')
->assertSeeIn('@button-cancel', 'Cancel')
->assertSeeIn('@button-action', 'Continue')
// Test error handling
->type('@body #mandate_amount', 'aaa')
->type('@body #mandate_balance', '-1')
->click('@button-action')
->assertToast(Toast::TYPE_ERROR, 'Form validation error')
->assertVisible('@body #mandate_amount.is-invalid')
->assertVisible('@body #mandate_balance.is-invalid')
->assertSeeIn('#mandate_amount + span + .invalid-feedback', 'The amount must be a number.')
->assertSeeIn('#mandate_balance + span + .invalid-feedback', 'The balance must be at least 0.')
->type('@body #mandate_amount', 'aaa')
->type('@body #mandate_balance', '0')
->click('@button-action')
->assertToast(Toast::TYPE_ERROR, 'Form validation error')
->assertVisible('@body #mandate_amount.is-invalid')
->assertMissing('@body #mandate_balance.is-invalid')
->assertSeeIn('#mandate_amount + span + .invalid-feedback', 'The amount must be a number.')
->assertMissing('#mandate_balance + span + .invalid-feedback')
// 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 PaymentMollie())
->assertSeeIn('@title', \config('app.name') . ' Auto-Payment Setup')
->assertMissing('@amount')
->submitValidCreditCard()
->waitForLocation('/wallet')
->visit('/wallet?paymentProvider=mollie')
->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());
});
// 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('@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.')
// 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())
->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())
->assertMissing('@body #mandate-form .alert')
->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')
->click('@button-action');
})
->on(new PaymentMollie())
->submitValidCreditCard('open')
->waitForLocation('/wallet')
->visit('/wallet?paymentProvider=mollie')
->on(new WalletPage())
->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')
// 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')
->click('@button-action');
})
->on(new PaymentMollie())
->submitValidCreditCard('failed')
->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');
});
});
}
}
diff --git a/src/tests/Feature/Controller/PaymentsMollieTest.php b/src/tests/Feature/Controller/PaymentsMollieTest.php
index bf053f35..bfdf2683 100644
--- a/src/tests/Feature/Controller/PaymentsMollieTest.php
+++ b/src/tests/Feature/Controller/PaymentsMollieTest.php
@@ -1,920 +1,1060 @@
'mollie']);
$john = $this->getTestUser('john@kolab.org');
$wallet = $john->wallets()->first();
Payment::where('wallet_id', $wallet->id)->delete();
Wallet::where('id', $wallet->id)->update(['balance' => 0]);
WalletSetting::where('wallet_id', $wallet->id)->delete();
$types = [
Transaction::WALLET_CREDIT,
Transaction::WALLET_REFUND,
Transaction::WALLET_CHARGEBACK,
];
Transaction::where('object_id', $wallet->id)->whereIn('type', $types)->delete();
}
/**
* {@inheritDoc}
*/
public function tearDown(): void
{
$john = $this->getTestUser('john@kolab.org');
$wallet = $john->wallets()->first();
Payment::where('wallet_id', $wallet->id)->delete();
Wallet::where('id', $wallet->id)->update(['balance' => 0]);
WalletSetting::where('wallet_id', $wallet->id)->delete();
$types = [
Transaction::WALLET_CREDIT,
Transaction::WALLET_REFUND,
Transaction::WALLET_CHARGEBACK,
];
Transaction::where('object_id', $wallet->id)->whereIn('type', $types)->delete();
parent::tearDown();
}
/**
* Test creating/updating/deleting an outo-payment mandate
*
* @group mollie
*/
public function testMandates(): void
{
// Unauth access not allowed
$response = $this->get("api/v4/payments/mandate");
$response->assertStatus(401);
$response = $this->post("api/v4/payments/mandate", []);
$response->assertStatus(401);
$response = $this->put("api/v4/payments/mandate", []);
$response->assertStatus(401);
$response = $this->delete("api/v4/payments/mandate");
$response->assertStatus(401);
$user = $this->getTestUser('john@kolab.org');
$wallet = $user->wallets()->first();
// Test creating a mandate (invalid input)
$post = [];
$response = $this->actingAs($user)->post("api/v4/payments/mandate", $post);
$response->assertStatus(422);
$json = $response->json();
$this->assertSame('error', $json['status']);
$this->assertCount(2, $json['errors']);
$this->assertSame('The amount field is required.', $json['errors']['amount'][0]);
$this->assertSame('The balance field is required.', $json['errors']['balance'][0]);
// Test creating a mandate (invalid input)
$post = ['amount' => 100, 'balance' => 'a'];
$response = $this->actingAs($user)->post("api/v4/payments/mandate", $post);
$response->assertStatus(422);
$json = $response->json();
$this->assertSame('error', $json['status']);
$this->assertCount(1, $json['errors']);
$this->assertSame('The balance must be a number.', $json['errors']['balance'][0]);
// Test creating a mandate (amount smaller than the minimum value)
$post = ['amount' => -100, 'balance' => 0];
$response = $this->actingAs($user)->post("api/v4/payments/mandate", $post);
$response->assertStatus(422);
$json = $response->json();
$this->assertSame('error', $json['status']);
$this->assertCount(1, $json['errors']);
$min = intval(PaymentProvider::MIN_AMOUNT / 100) . ' CHF';
$this->assertSame("Minimum amount for a single payment is {$min}.", $json['errors']['amount']);
// Test creating a mandate (negative balance, amount too small)
Wallet::where('id', $wallet->id)->update(['balance' => -2000]);
$post = ['amount' => PaymentProvider::MIN_AMOUNT / 100, 'balance' => 0];
$response = $this->actingAs($user)->post("api/v4/payments/mandate", $post);
$response->assertStatus(422);
$json = $response->json();
$this->assertSame('error', $json['status']);
$this->assertCount(1, $json['errors']);
$this->assertSame("The specified amount does not cover the balance on the account.", $json['errors']['amount']);
// Test creating a mandate (valid input)
$post = ['amount' => 20.10, 'balance' => 0];
$response = $this->actingAs($user)->post("api/v4/payments/mandate", $post);
$response->assertStatus(200);
$json = $response->json();
$this->assertSame('success', $json['status']);
$this->assertRegExp('|^https://www.mollie.com|', $json['redirectUrl']);
// Assert the proper payment amount has been used
$payment = Payment::where('id', $json['id'])->first();
$this->assertSame(2010, $payment->amount);
$this->assertSame($wallet->id, $payment->wallet_id);
$this->assertSame(\config('app.name') . " Auto-Payment Setup", $payment->description);
$this->assertSame(PaymentProvider::TYPE_MANDATE, $payment->type);
// Test fetching the mandate information
$response = $this->actingAs($user)->get("api/v4/payments/mandate");
$response->assertStatus(200);
$json = $response->json();
$this->assertEquals(20.10, $json['amount']);
$this->assertEquals(0, $json['balance']);
$this->assertEquals('Credit Card', $json['method']);
$this->assertSame(true, $json['isPending']);
$this->assertSame(false, $json['isValid']);
$this->assertSame(false, $json['isDisabled']);
$mandate_id = $json['id'];
// We would have to invoke a browser to accept the "first payment" to make
// the mandate validated/completed. Instead, we'll mock the mandate object.
$mollie_response = [
'resource' => 'mandate',
'id' => $mandate_id,
'status' => 'valid',
'method' => 'creditcard',
'details' => [
'cardNumber' => '4242',
'cardLabel' => 'Visa',
],
'customerId' => 'cst_GMfxGPt7Gj',
'createdAt' => '2020-04-28T11:09:47+00:00',
];
$responseStack = $this->mockMollie();
$responseStack->append(new Response(200, [], json_encode($mollie_response)));
$wallet = $user->wallets()->first();
$wallet->setSetting('mandate_disabled', 1);
$response = $this->actingAs($user)->get("api/v4/payments/mandate");
$response->assertStatus(200);
$json = $response->json();
$this->assertEquals(20.10, $json['amount']);
$this->assertEquals(0, $json['balance']);
$this->assertEquals('Visa (**** **** **** 4242)', $json['method']);
$this->assertSame(false, $json['isPending']);
$this->assertSame(true, $json['isValid']);
$this->assertSame(true, $json['isDisabled']);
Bus::fake();
$wallet->setSetting('mandate_disabled', null);
$wallet->balance = 1000;
$wallet->save();
// Test updating mandate details (invalid input)
$post = [];
$response = $this->actingAs($user)->put("api/v4/payments/mandate", $post);
$response->assertStatus(422);
$json = $response->json();
$this->assertSame('error', $json['status']);
$this->assertCount(2, $json['errors']);
$this->assertSame('The amount field is required.', $json['errors']['amount'][0]);
$this->assertSame('The balance field is required.', $json['errors']['balance'][0]);
$post = ['amount' => -100, 'balance' => 0];
$response = $this->actingAs($user)->put("api/v4/payments/mandate", $post);
$response->assertStatus(422);
$json = $response->json();
$this->assertSame('error', $json['status']);
$this->assertCount(1, $json['errors']);
$this->assertSame("Minimum amount for a single payment is {$min}.", $json['errors']['amount']);
// Test updating a mandate (valid input)
$responseStack->append(new Response(200, [], json_encode($mollie_response)));
$post = ['amount' => 30.10, 'balance' => 10];
$response = $this->actingAs($user)->put("api/v4/payments/mandate", $post);
$response->assertStatus(200);
$json = $response->json();
$this->assertSame('success', $json['status']);
$this->assertSame('The auto-payment has been updated.', $json['message']);
$this->assertSame($mandate_id, $json['id']);
$this->assertFalse($json['isDisabled']);
$wallet->refresh();
$this->assertEquals(30.10, $wallet->getSetting('mandate_amount'));
$this->assertEquals(10, $wallet->getSetting('mandate_balance'));
Bus::assertDispatchedTimes(\App\Jobs\WalletCharge::class, 0);
// Test updating a disabled mandate (invalid input)
$wallet->setSetting('mandate_disabled', 1);
$wallet->balance = -2000;
$wallet->save();
$user->refresh(); // required so the controller sees the wallet update from above
$post = ['amount' => 15.10, 'balance' => 1];
$response = $this->actingAs($user)->put("api/v4/payments/mandate", $post);
$response->assertStatus(422);
$json = $response->json();
$this->assertSame('error', $json['status']);
$this->assertCount(1, $json['errors']);
$this->assertSame('The specified amount does not cover the balance on the account.', $json['errors']['amount']);
// Test updating a disabled mandate (valid input)
$responseStack->append(new Response(200, [], json_encode($mollie_response)));
$post = ['amount' => 30, 'balance' => 1];
$response = $this->actingAs($user)->put("api/v4/payments/mandate", $post);
$response->assertStatus(200);
$json = $response->json();
$this->assertSame('success', $json['status']);
$this->assertSame('The auto-payment has been updated.', $json['message']);
$this->assertSame($mandate_id, $json['id']);
$this->assertFalse($json['isDisabled']);
Bus::assertDispatchedTimes(\App\Jobs\WalletCharge::class, 1);
Bus::assertDispatched(\App\Jobs\WalletCharge::class, function ($job) use ($wallet) {
$job_wallet = $this->getObjectProperty($job, 'wallet');
return $job_wallet->id === $wallet->id;
});
$this->unmockMollie();
// Delete mandate
$response = $this->actingAs($user)->delete("api/v4/payments/mandate");
$response->assertStatus(200);
$json = $response->json();
$this->assertSame('success', $json['status']);
$this->assertSame('The auto-payment has been removed.', $json['message']);
// Confirm with Mollie the mandate does not exist
$customer_id = $wallet->getSetting('mollie_id');
$this->expectException(\Mollie\Api\Exceptions\ApiException::class);
$this->expectExceptionMessageMatches('/410: Gone/');
$mandate = mollie()->mandates()->getForId($customer_id, $mandate_id);
$this->assertNull($wallet->fresh()->getSetting('mollie_mandate_id'));
// Test Mollie's "410 Gone" response handling when fetching the mandate info
// It is expected to remove the mandate reference
$mollie_response = [
'status' => 410,
'title' => "Gone",
'detail' => "You are trying to access an object, which has previously been deleted",
'_links' => [
'documentation' => [
'href' => "https://docs.mollie.com/errors",
'type' => "text/html"
]
]
];
$responseStack = $this->mockMollie();
$responseStack->append(new Response(410, [], json_encode($mollie_response)));
$wallet->fresh()->setSetting('mollie_mandate_id', '123');
$response = $this->actingAs($user)->get("api/v4/payments/mandate");
$response->assertStatus(200);
$json = $response->json();
$this->assertFalse(array_key_exists('id', $json));
$this->assertFalse(array_key_exists('method', $json));
$this->assertNull($wallet->fresh()->getSetting('mollie_mandate_id'));
}
/**
* Test creating a payment and receiving a status via webhook
*
* @group mollie
*/
public function testStoreAndWebhook(): void
{
Bus::fake();
// Unauth access not allowed
$response = $this->post("api/v4/payments", []);
$response->assertStatus(401);
// Invalid amount
$user = $this->getTestUser('john@kolab.org');
$post = ['amount' => -1];
$response = $this->actingAs($user)->post("api/v4/payments", $post);
$response->assertStatus(422);
$json = $response->json();
$this->assertSame('error', $json['status']);
$this->assertCount(1, $json['errors']);
$min = intval(PaymentProvider::MIN_AMOUNT / 100) . ' CHF';
$this->assertSame("Minimum amount for a single payment is {$min}.", $json['errors']['amount']);
// 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);
$json = $response->json();
$this->assertSame('success', $json['status']);
$this->assertRegExp('|^https://www.mollie.com|', $json['redirectUrl']);
$wallet = $user->wallets()->first();
$payments = Payment::where('wallet_id', $wallet->id)->get();
$this->assertCount(1, $payments);
$payment = $payments[0];
$this->assertSame(1234, $payment->amount);
+ $this->assertSame(1234, $payment->currency_amount);
+ $this->assertSame('CHF', $payment->currency);
$this->assertSame(\config('app.name') . ' Payment', $payment->description);
$this->assertSame('open', $payment->status);
$this->assertEquals(0, $wallet->balance);
// Test the webhook
// Note: Webhook end-point does not require authentication
$mollie_response = [
"resource" => "payment",
"id" => $payment->id,
"status" => "paid",
// Status is not enough, paidAt is used to distinguish the state
"paidAt" => date('c'),
"mode" => "test",
];
// We'll trigger the webhook with payment id and use mocking for
// a request to the Mollie payments API. We cannot force Mollie
// to make the payment status change.
$responseStack = $this->mockMollie();
$responseStack->append(new Response(200, [], json_encode($mollie_response)));
$post = ['id' => $payment->id];
$response = $this->post("api/webhooks/payment/mollie", $post);
$response->assertStatus(200);
$this->assertSame(PaymentProvider::STATUS_PAID, $payment->fresh()->status);
$this->assertEquals(1234, $wallet->fresh()->balance);
$transaction = $wallet->transactions()
->where('type', Transaction::WALLET_CREDIT)->get()->last();
$this->assertSame(1234, $transaction->amount);
$this->assertSame(
"Payment transaction {$payment->id} using Mollie",
$transaction->description
);
// Assert that email notification job wasn't dispatched,
// it is expected only for recurring payments
Bus::assertDispatchedTimes(\App\Jobs\PaymentEmail::class, 0);
// Verify "paid -> open -> paid" scenario, assert that balance didn't change
$mollie_response['status'] = 'open';
unset($mollie_response['paidAt']);
$responseStack->append(new Response(200, [], json_encode($mollie_response)));
$response = $this->post("api/webhooks/payment/mollie", $post);
$response->assertStatus(200);
$this->assertSame(PaymentProvider::STATUS_PAID, $payment->fresh()->status);
$this->assertEquals(1234, $wallet->fresh()->balance);
$mollie_response['status'] = 'paid';
$mollie_response['paidAt'] = date('c');
$responseStack->append(new Response(200, [], json_encode($mollie_response)));
$response = $this->post("api/webhooks/payment/mollie", $post);
$response->assertStatus(200);
$this->assertSame(PaymentProvider::STATUS_PAID, $payment->fresh()->status);
$this->assertEquals(1234, $wallet->fresh()->balance);
// Test for payment failure
Bus::fake();
$payment->refresh();
$payment->status = PaymentProvider::STATUS_OPEN;
$payment->save();
$mollie_response = [
"resource" => "payment",
"id" => $payment->id,
"status" => "failed",
"mode" => "test",
];
// We'll trigger the webhook with payment id and use mocking for
// a request to the Mollie payments API. We cannot force Mollie
// to make the payment status change.
$responseStack = $this->mockMollie();
$responseStack->append(new Response(200, [], json_encode($mollie_response)));
$response = $this->post("api/webhooks/payment/mollie", $post);
$response->assertStatus(200);
$this->assertSame('failed', $payment->fresh()->status);
$this->assertEquals(1234, $wallet->fresh()->balance);
// Assert that email notification job wasn't dispatched,
// it is expected only for recurring payments
Bus::assertDispatchedTimes(\App\Jobs\PaymentEmail::class, 0);
}
+ /**
+ * Test creating a payment and receiving a status via webhook using a foreign currency
+ *
+ * @group mollie
+ */
+ public function testStoreAndWebhookForeignCurrency(): void
+ {
+ Bus::fake();
+
+ $user = $this->getTestUser('john@kolab.org');
+ $wallet = $user->wallets()->first();
+
+ // Successful payment in EUR
+ $post = ['amount' => '12.34', 'currency' => 'EUR', 'methodId' => 'banktransfer'];
+ $response = $this->actingAs($user)->post("api/v4/payments", $post);
+ $response->assertStatus(200);
+
+ $payment = $wallet->payments()
+ ->where('currency', 'EUR')->get()->last();
+
+ $this->assertSame(1234, $payment->amount);
+ $this->assertSame(1117, $payment->currency_amount);
+ $this->assertSame('EUR', $payment->currency);
+ $this->assertEquals(0, $wallet->balance);
+
+ $mollie_response = [
+ "resource" => "payment",
+ "id" => $payment->id,
+ "status" => "paid",
+ // Status is not enough, paidAt is used to distinguish the state
+ "paidAt" => date('c'),
+ "mode" => "test",
+ ];
+
+ $responseStack = $this->mockMollie();
+ $responseStack->append(new Response(200, [], json_encode($mollie_response)));
+
+ $post = ['id' => $payment->id];
+ $response = $this->post("api/webhooks/payment/mollie", $post);
+ $response->assertStatus(200);
+
+ $this->assertSame(PaymentProvider::STATUS_PAID, $payment->fresh()->status);
+ $this->assertEquals(1234, $wallet->fresh()->balance);
+ }
+
/**
* Test automatic payment charges
*
* @group mollie
*/
public function testTopUp(): void
{
Bus::fake();
$user = $this->getTestUser('john@kolab.org');
$wallet = $user->wallets()->first();
// Create a valid mandate first (balance=0, so there's no extra payment yet)
$this->createMandate($wallet, ['amount' => 20.10, 'balance' => 0]);
$wallet->setSetting('mandate_balance', 10);
// Expect a recurring payment as we have a valid mandate at this point
// and the balance is below the threshold
$result = PaymentsController::topUpWallet($wallet);
$this->assertTrue($result);
// Check that the payments table contains a new record with proper amount.
// There should be two records, one for the mandate payment and another for
// the top-up payment
$payments = $wallet->payments()->orderBy('amount')->get();
$this->assertCount(2, $payments);
$this->assertSame(0, $payments[0]->amount);
+ $this->assertSame(0, $payments[0]->currency_amount);
$this->assertSame(2010, $payments[1]->amount);
+ $this->assertSame(2010, $payments[1]->currency_amount);
$payment = $payments[1];
// In mollie we don't have to wait for a webhook, the response to
// PaymentIntent already sets the status to 'paid', so we can test
// immediately the balance update
// Assert that email notification job has been dispatched
$this->assertSame(PaymentProvider::STATUS_PAID, $payment->status);
$this->assertEquals(2010, $wallet->fresh()->balance);
$transaction = $wallet->transactions()
->where('type', Transaction::WALLET_CREDIT)->get()->last();
$this->assertSame(2010, $transaction->amount);
$this->assertSame(
"Auto-payment transaction {$payment->id} using Mastercard (**** **** **** 6787)",
$transaction->description
);
Bus::assertDispatchedTimes(\App\Jobs\PaymentEmail::class, 1);
Bus::assertDispatched(\App\Jobs\PaymentEmail::class, function ($job) use ($payment) {
$job_payment = $this->getObjectProperty($job, 'payment');
return $job_payment->id === $payment->id;
});
// Expect no payment if the mandate is disabled
$wallet->setSetting('mandate_disabled', 1);
$result = PaymentsController::topUpWallet($wallet);
$this->assertFalse($result);
$this->assertCount(2, $wallet->payments()->get());
// Expect no payment if balance is ok
$wallet->setSetting('mandate_disabled', null);
$wallet->balance = 1000;
$wallet->save();
$result = PaymentsController::topUpWallet($wallet);
$this->assertFalse($result);
$this->assertCount(2, $wallet->payments()->get());
// Expect no payment if the top-up amount is not enough
$wallet->setSetting('mandate_disabled', null);
$wallet->balance = -2050;
$wallet->save();
$result = PaymentsController::topUpWallet($wallet);
$this->assertFalse($result);
$this->assertCount(2, $wallet->payments()->get());
Bus::assertDispatchedTimes(\App\Jobs\PaymentMandateDisabledEmail::class, 1);
Bus::assertDispatched(\App\Jobs\PaymentMandateDisabledEmail::class, function ($job) use ($wallet) {
$job_wallet = $this->getObjectProperty($job, 'wallet');
return $job_wallet->id === $wallet->id;
});
// Expect no payment if there's no mandate
$wallet->setSetting('mollie_mandate_id', null);
$wallet->balance = 0;
$wallet->save();
$result = PaymentsController::topUpWallet($wallet);
$this->assertFalse($result);
$this->assertCount(2, $wallet->payments()->get());
Bus::assertDispatchedTimes(\App\Jobs\PaymentMandateDisabledEmail::class, 1);
// Test webhook for recurring payments
$wallet->transactions()->delete();
$responseStack = $this->mockMollie();
Bus::fake();
$payment->refresh();
$payment->status = PaymentProvider::STATUS_OPEN;
$payment->save();
$mollie_response = [
"resource" => "payment",
"id" => $payment->id,
"status" => "paid",
// Status is not enough, paidAt is used to distinguish the state
"paidAt" => date('c'),
"mode" => "test",
];
// We'll trigger the webhook with payment id and use mocking for
// a request to the Mollie payments API. We cannot force Mollie
// to make the payment status change.
$responseStack->append(new Response(200, [], json_encode($mollie_response)));
$post = ['id' => $payment->id];
$response = $this->post("api/webhooks/payment/mollie", $post);
$response->assertStatus(200);
$this->assertSame(PaymentProvider::STATUS_PAID, $payment->fresh()->status);
$this->assertEquals(2010, $wallet->fresh()->balance);
$transaction = $wallet->transactions()
->where('type', Transaction::WALLET_CREDIT)->get()->last();
$this->assertSame(2010, $transaction->amount);
$this->assertSame(
"Auto-payment transaction {$payment->id} using Mollie",
$transaction->description
);
// Assert that email notification job has been dispatched
Bus::assertDispatchedTimes(\App\Jobs\PaymentEmail::class, 1);
Bus::assertDispatched(\App\Jobs\PaymentEmail::class, function ($job) use ($payment) {
$job_payment = $this->getObjectProperty($job, 'payment');
return $job_payment->id === $payment->id;
});
Bus::fake();
// Test for payment failure
$payment->refresh();
$payment->status = PaymentProvider::STATUS_OPEN;
$payment->save();
$wallet->setSetting('mollie_mandate_id', 'xxx');
$wallet->setSetting('mandate_disabled', null);
$mollie_response = [
"resource" => "payment",
"id" => $payment->id,
"status" => "failed",
"mode" => "test",
];
$responseStack->append(new Response(200, [], json_encode($mollie_response)));
$response = $this->post("api/webhooks/payment/mollie", $post);
$response->assertStatus(200);
$wallet->refresh();
$this->assertSame(PaymentProvider::STATUS_FAILED, $payment->fresh()->status);
$this->assertEquals(2010, $wallet->balance);
$this->assertTrue(!empty($wallet->getSetting('mandate_disabled')));
// Assert that email notification job has been dispatched
Bus::assertDispatchedTimes(\App\Jobs\PaymentEmail::class, 1);
Bus::assertDispatched(\App\Jobs\PaymentEmail::class, function ($job) use ($payment) {
$job_payment = $this->getObjectProperty($job, 'payment');
return $job_payment->id === $payment->id;
});
$this->unmockMollie();
}
/**
* Test refund/chargeback handling by the webhook
*
* @group mollie
*/
public function testRefundAndChargeback(): void
{
Bus::fake();
$user = $this->getTestUser('john@kolab.org');
$wallet = $user->wallets()->first();
$wallet->transactions()->delete();
$mollie = PaymentProvider::factory('mollie');
// Create a paid payment
$payment = Payment::create([
'id' => 'tr_123456',
'status' => PaymentProvider::STATUS_PAID,
'amount' => 123,
'currency_amount' => 123,
'currency' => 'CHF',
'type' => PaymentProvider::TYPE_ONEOFF,
'wallet_id' => $wallet->id,
'provider' => 'mollie',
'description' => 'test',
]);
// Test handling a refund by the webhook
$mollie_response1 = [
"resource" => "payment",
"id" => $payment->id,
"status" => "paid",
// Status is not enough, paidAt is used to distinguish the state
"paidAt" => date('c'),
"mode" => "test",
"_links" => [
"refunds" => [
"href" => "https://api.mollie.com/v2/payments/{$payment->id}/refunds",
"type" => "application/hal+json"
]
]
];
$mollie_response2 = [
"count" => 1,
"_links" => [],
"_embedded" => [
"refunds" => [
[
"resource" => "refund",
"id" => "re_123456",
"status" => \Mollie\Api\Types\RefundStatus::STATUS_REFUNDED,
"paymentId" => $payment->id,
"description" => "refund desc",
"amount" => [
"currency" => "CHF",
"value" => "1.01",
],
]
]
]
];
// We'll trigger the webhook with payment id and use mocking for
// requests to the Mollie payments API.
$responseStack = $this->mockMollie();
$responseStack->append(new Response(200, [], json_encode($mollie_response1)));
$responseStack->append(new Response(200, [], json_encode($mollie_response2)));
$post = ['id' => $payment->id];
$response = $this->post("api/webhooks/payment/mollie", $post);
$response->assertStatus(200);
$wallet->refresh();
$this->assertEquals(-101, $wallet->balance);
$transactions = $wallet->transactions()->where('type', Transaction::WALLET_REFUND)->get();
$this->assertCount(1, $transactions);
$this->assertSame(-101, $transactions[0]->amount);
$this->assertSame(Transaction::WALLET_REFUND, $transactions[0]->type);
$this->assertSame("refund desc", $transactions[0]->description);
$payments = $wallet->payments()->where('id', 're_123456')->get();
$this->assertCount(1, $payments);
$this->assertSame(-101, $payments[0]->amount);
+ $this->assertSame(-101, $payments[0]->currency_amount);
$this->assertSame(PaymentProvider::STATUS_PAID, $payments[0]->status);
$this->assertSame(PaymentProvider::TYPE_REFUND, $payments[0]->type);
$this->assertSame("mollie", $payments[0]->provider);
$this->assertSame("refund desc", $payments[0]->description);
// Test handling a chargeback by the webhook
$mollie_response1["_links"] = [
"chargebacks" => [
"href" => "https://api.mollie.com/v2/payments/{$payment->id}/chargebacks",
"type" => "application/hal+json"
]
];
$mollie_response2 = [
"count" => 1,
"_links" => [],
"_embedded" => [
"chargebacks" => [
[
"resource" => "chargeback",
"id" => "chb_123456",
"paymentId" => $payment->id,
"amount" => [
"currency" => "CHF",
"value" => "0.15",
],
]
]
]
];
// We'll trigger the webhook with payment id and use mocking for
// requests to the Mollie payments API.
$responseStack = $this->mockMollie();
$responseStack->append(new Response(200, [], json_encode($mollie_response1)));
$responseStack->append(new Response(200, [], json_encode($mollie_response2)));
$post = ['id' => $payment->id];
$response = $this->post("api/webhooks/payment/mollie", $post);
$response->assertStatus(200);
$wallet->refresh();
$this->assertEquals(-116, $wallet->balance);
$transactions = $wallet->transactions()->where('type', Transaction::WALLET_CHARGEBACK)->get();
$this->assertCount(1, $transactions);
$this->assertSame(-15, $transactions[0]->amount);
$this->assertSame(Transaction::WALLET_CHARGEBACK, $transactions[0]->type);
$this->assertSame('', $transactions[0]->description);
$payments = $wallet->payments()->where('id', 'chb_123456')->get();
$this->assertCount(1, $payments);
$this->assertSame(-15, $payments[0]->amount);
$this->assertSame(PaymentProvider::STATUS_PAID, $payments[0]->status);
$this->assertSame(PaymentProvider::TYPE_CHARGEBACK, $payments[0]->type);
$this->assertSame("mollie", $payments[0]->provider);
$this->assertSame('', $payments[0]->description);
Bus::assertNotDispatched(\App\Jobs\PaymentEmail::class);
$this->unmockMollie();
}
+ /**
+ * Test refund/chargeback handling by the webhook in a foreign currency
+ *
+ * @group mollie
+ */
+ public function testRefundAndChargebackForeignCurrency(): void
+ {
+ Bus::fake();
+
+ $user = $this->getTestUser('john@kolab.org');
+ $wallet = $user->wallets()->first();
+ $wallet->transactions()->delete();
+
+ $mollie = PaymentProvider::factory('mollie');
+
+ // Create a paid payment
+ $payment = Payment::create([
+ 'id' => 'tr_123456',
+ 'status' => PaymentProvider::STATUS_PAID,
+ 'amount' => 1234,
+ 'currency_amount' => 1117,
+ 'currency' => 'EUR',
+ 'type' => PaymentProvider::TYPE_ONEOFF,
+ 'wallet_id' => $wallet->id,
+ 'provider' => 'mollie',
+ 'description' => 'test',
+ ]);
+
+ // Test handling a refund by the webhook
+
+ $mollie_response1 = [
+ "resource" => "payment",
+ "id" => $payment->id,
+ "status" => "paid",
+ // Status is not enough, paidAt is used to distinguish the state
+ "paidAt" => date('c'),
+ "mode" => "test",
+ "_links" => [
+ "refunds" => [
+ "href" => "https://api.mollie.com/v2/payments/{$payment->id}/refunds",
+ "type" => "application/hal+json"
+ ]
+ ]
+ ];
+
+ $mollie_response2 = [
+ "count" => 1,
+ "_links" => [],
+ "_embedded" => [
+ "refunds" => [
+ [
+ "resource" => "refund",
+ "id" => "re_123456",
+ "status" => \Mollie\Api\Types\RefundStatus::STATUS_REFUNDED,
+ "paymentId" => $payment->id,
+ "description" => "refund desc",
+ "amount" => [
+ "currency" => "EUR",
+ "value" => "1.01",
+ ],
+ ]
+ ]
+ ]
+ ];
+
+ // We'll trigger the webhook with payment id and use mocking for
+ // requests to the Mollie payments API.
+ $responseStack = $this->mockMollie();
+ $responseStack->append(new Response(200, [], json_encode($mollie_response1)));
+ $responseStack->append(new Response(200, [], json_encode($mollie_response2)));
+
+ $post = ['id' => $payment->id];
+ $response = $this->post("api/webhooks/payment/mollie", $post);
+ $response->assertStatus(200);
+
+ $wallet->refresh();
+
+ $this->assertEquals(-112, $wallet->balance);
+
+ $payments = $wallet->payments()->where('id', 're_123456')->get();
+
+ $this->assertCount(1, $payments);
+ $this->assertSame(-112, $payments[0]->amount);
+ $this->assertSame(-101, $payments[0]->currency_amount);
+ $this->assertSame('EUR', $payments[0]->currency);
+
+ $this->unmockMollie();
+ }
+
/**
* Create Mollie's auto-payment mandate using our API and Chrome browser
*/
protected function createMandate(Wallet $wallet, array $params)
{
// Use the API to create a first payment with a mandate
$response = $this->actingAs($wallet->owner)->post("api/v4/payments/mandate", $params);
$response->assertStatus(200);
$json = $response->json();
// There's no easy way to confirm a created mandate.
// The only way seems to be to fire up Chrome on checkout page
// and do actions with use of Dusk browser.
$this->startBrowser()
->visit($json['redirectUrl'])
->click('input[value="paid"]')
->click('button.form__button');
$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->assertCount(3, $json);
$this->assertSame('creditcard', $json[0]['id']);
$this->assertSame('paypal', $json[1]['id']);
+ $this->assertSame('banktransfer', $json[2]['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']);
}
}