diff --git a/src/app/Backends/OpenExchangeRates.php b/src/app/Backends/OpenExchangeRates.php new file mode 100644 --- /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 --- /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 --- a/src/app/Providers/Payment/Mollie.php +++ b/src/app/Providers/Payment/Mollie.php @@ -562,17 +562,17 @@ $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' => [ 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 @@ -189,23 +189,6 @@ } /** - * 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 @@ -216,7 +199,7 @@ */ protected function exchange(int $amount, string $sourceCurrency, string $targetCurrency): int { - return intval(round($amount * $this->exchangeRate($sourceCurrency, $targetCurrency))); + return intval(round($amount * \App\Utils::exchangeRate($sourceCurrency, $targetCurrency))); } /** @@ -235,7 +218,7 @@ } // 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? @@ -303,34 +286,19 @@ */ protected static function paymentMethodsWhitelist($type): array { + $methods = []; 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] - // ] - ]; + $methods = explode(',', \config('app.payment.methods_oneoff')); + break; case PaymentProvider::TYPE_RECURRING: - return [ - self::METHOD_CREDITCARD => [ - 'id' => self::METHOD_CREDITCARD, - 'icon' => self::$paymentMethodIcons[self::METHOD_CREDITCARD] - ] - ]; + $methods = explode(',', \config('app.payment.methods_recurring')); + break; + default: + \Log::error("Unknown payment type: " . $type); } - - \Log::error("Unknown payment type: " . $type); - return []; + $methods = array_map('strtolower', array_map('trim', $methods)); + return $methods; } /** @@ -346,9 +314,11 @@ // Use only whitelisted methods, and apply values from whitelist (overriding the backend) $whitelistMethods = self::paymentMethodsWhitelist($type); - foreach ($whitelistMethods as $id => $whitelistMethod) { + foreach ($whitelistMethods as $id) { if (array_key_exists($id, $availableMethods)) { - $methods[] = array_merge($availableMethods[$id], $whitelistMethod); + $method = $availableMethods[$id]; + $method['icon'] = self::$paymentMethodIcons[$id]; + $methods[] = $method; } } @@ -383,6 +353,8 @@ $provider = PaymentProvider::factory($providerName); $methods = self::applyMethodWhitelist($type, $provider->providerPaymentMethods($type)); + \Log::debug("Loaded payment methods" . var_export($methods, true)); + Cache::put($cacheKey, $methods, now()->addHours(1)); return $methods; diff --git a/src/app/Utils.php b/src/app/Utils.php --- a/src/app/Utils.php +++ b/src/app/Utils.php @@ -5,6 +5,7 @@ use Carbon\Carbon; use Illuminate\Support\Facades\Auth; use Ramsey\Uuid\Uuid; +use Illuminate\Support\Facades\Cache; /** * Small utility functions for App. @@ -393,4 +394,37 @@ 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 + { + if (strcasecmp($sourceCurrency, $targetCurrency) == 0) { + return 1.0; + } + + $currencyFile = resource_path("exchangerates-$sourceCurrency.php"); + + //Attempt to find the reverse exchange rate, if we don't have the file for the source currency + if (!file_exists($currencyFile)) { + $rates = include resource_path("exchangerates-$targetCurrency.php"); + if (!isset($rates[$sourceCurrency])) { + throw new \Exception("Failed to find the reverse exchange rate for " . $sourceCurrency); + } + return 1.0 / floatval($rates[$sourceCurrency]); + } + + $rates = include $currencyFile; + if (!isset($rates[$targetCurrency])) { + throw new \Exception("Failed to find exchange rate for " . $targetCurrency); + } + + return floatval($rates[$targetCurrency]); + } } diff --git a/src/config/app.php b/src/config/app.php --- a/src/config/app.php +++ b/src/config/app.php @@ -268,4 +268,9 @@ 'countries' => env('VAT_COUNTRIES'), 'rate' => (float) env('VAT_RATE'), ], + + 'payment' => [ + 'methods_oneoff' => env('PAYMENT_METHODS_ONEOFF', "creditcard,paypal,banktransfer"), + 'methods_recurring' => env('PAYMENT_METHODS_RECURRING', "creditcard"), + ], ]; diff --git a/src/config/currency.php b/src/config/currency.php deleted file mode 100644 --- a/src/config/currency.php +++ /dev/null @@ -1,116 +0,0 @@ - '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, - ], - - ], -]; diff --git a/src/config/services.php b/src/config/services.php --- a/src/config/services.php +++ b/src/config/services.php @@ -46,4 +46,9 @@ 'webhook_secret' => env('STRIPE_WEBHOOK_SECRET'), ], + + '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 --- /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 --- a/src/tests/Browser/PaymentMollieTest.php +++ b/src/tests/Browser/PaymentMollieTest.php @@ -58,7 +58,7 @@ $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) { diff --git a/src/tests/Feature/Controller/PaymentsMollieTest.php b/src/tests/Feature/Controller/PaymentsMollieTest.php --- a/src/tests/Feature/Controller/PaymentsMollieTest.php +++ b/src/tests/Feature/Controller/PaymentsMollieTest.php @@ -374,6 +374,8 @@ $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); @@ -469,6 +471,51 @@ } /** + * 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 @@ -496,7 +543,9 @@ $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 @@ -734,6 +783,7 @@ $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); @@ -802,6 +852,95 @@ } /** + * 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) @@ -906,9 +1045,10 @@ $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); diff --git a/src/tests/Unit/UtilsTest.php b/src/tests/Unit/UtilsTest.php --- a/src/tests/Unit/UtilsTest.php +++ b/src/tests/Unit/UtilsTest.php @@ -101,4 +101,18 @@ $this->assertTrue(strlen($result) === 36); $this->assertTrue(preg_match('/[^a-f0-9-]/i', $result) === 0); } + + /** + * Test for Utils::exchangeRate() + */ + public function testExchangeRate(): void + { + $this->assertSame(1.0, Utils::exchangeRate("DUMMY", "dummy")); + $this->assertEqualsWithDelta(0.90503424978382, Utils::exchangeRate("CHF", "EUR"), PHP_FLOAT_EPSILON); + $this->assertEqualsWithDelta(1.1049305595217682, Utils::exchangeRate("EUR", "CHF"), PHP_FLOAT_EPSILON); + $this->expectException(\Exception::class); + $this->assertSame(1.0, Utils::exchangeRate("CHF", "FOO")); + $this->expectException(\Exception::class); + $this->assertSame(1.0, Utils::exchangeRate("FOO", "CHF")); + } }