-
-
Notifications
You must be signed in to change notification settings - Fork 272
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Test setup for Paddle currency detection
- Loading branch information
1 parent
9488ad9
commit 0767b93
Showing
5 changed files
with
346 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -5,5 +5,6 @@ | |
'cdn' => [ | ||
'domain' => 'https://assets.getkirby.com', | ||
], | ||
'cloudflare' => true, | ||
'debug' => false | ||
]; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,124 @@ | ||
<?php | ||
|
||
namespace Buy; | ||
|
||
use Exception; | ||
use Kirby\Http\Remote; | ||
use Throwable; | ||
|
||
class Paddle | ||
{ | ||
// cache | ||
protected static Visitor $visitor; | ||
|
||
/** | ||
* Generates a custom checkout link and returns the checkout URL | ||
* | ||
* @throws \Exception On request errors or Paddle API errors | ||
*/ | ||
public static function checkout(string $product, array $payload = []): string | ||
{ | ||
$data = [ | ||
'vendor_id' => option('keys.paddle.id'), | ||
'vendor_auth_code' => option('keys.paddle.auth'), | ||
'product_id' => $product, | ||
'expires' => date('Y-m-d', strtotime('+1 day')), | ||
'quantity_variable' => false, | ||
'quantity' => 1, | ||
...$payload | ||
]; | ||
|
||
$response = static::request('POST', 'vendors', 'product/generate_pay_link', compact('data')); | ||
return $response['url']; | ||
} | ||
|
||
/** | ||
* Performs a request to the Paddle API | ||
* | ||
* @param 'GET'|'POST' $method HTTP method | ||
* | ||
* @throws \Exception On request errors or Paddle API errors | ||
*/ | ||
public static function request(string $method, string $subdomain, string $endpoint, array $options = []): array | ||
{ | ||
// GET requests need the data as query params | ||
$method = strtoupper($method); | ||
$query = ''; | ||
if ($method === 'GET' && isset($options['data']) === true) { | ||
$query = '?' . http_build_query($options['data']); | ||
} | ||
|
||
$response = new Remote( | ||
'https://' . $subdomain . '.paddle.com/api/2.0/' . $endpoint . $query, | ||
[ | ||
'method' => $method, | ||
...$options | ||
] | ||
); | ||
|
||
$data = $response->json(); | ||
|
||
if (isset($data['success']) === true && $data['success'] === true) { | ||
return $data['response']; | ||
} | ||
|
||
throw new Exception($data['error']['message'] ?? 'Unknown error'); | ||
} | ||
|
||
/** | ||
* Determines the country, currency and conversion rate information | ||
* for the visitor via the Paddle price API | ||
* | ||
* @param string|null $country Override for a country code (used for testing) | ||
*/ | ||
public static function visitor(string|null $country = null): Visitor | ||
{ | ||
// cache for the entire request as the IP won't change | ||
if (isset(static::$visitor) === true) { | ||
return static::$visitor; | ||
} | ||
|
||
try { | ||
$product = Product::Basic; | ||
|
||
$options = [ | ||
'data' => [ | ||
'product_ids' => $product->productId() | ||
], | ||
|
||
// fast timeout to avoid letting the user wait too long | ||
'timeout' => 1 | ||
]; | ||
|
||
if ($country !== null) { | ||
$options['data']['customer_country'] = $country; | ||
} else { | ||
$ip = Visitor::ip(); | ||
|
||
// if we only have a local IP, don't bother | ||
// requesting dynamic information from Paddle | ||
if ($ip === null) { | ||
return static::$visitor = Visitor::createFromError('No visitor IP available'); | ||
} | ||
|
||
$options['data']['customer_ip'] = $ip; | ||
} | ||
|
||
$response = static::request('GET', 'checkout', 'prices', $options); | ||
$paddleProduct = $response['products'][0]; | ||
|
||
return static::$visitor = Visitor::create( | ||
country: $response['customer_country'], | ||
currency: $paddleProduct['currency'], | ||
|
||
// calculate conversion rate from the EUR price; | ||
// requires that the EUR price matches between the site and Paddle admin | ||
conversionRate: $paddleProduct['list_price']['net'] / $product->rawPrice() | ||
); | ||
} catch (Throwable $e) { | ||
// on any kind of error, use the EUR prices as a fallback | ||
// to avoid a broken buy page | ||
return static::$visitor = Visitor::createFromError($e->getMessage()); | ||
} | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,200 @@ | ||
<?php | ||
|
||
namespace Buy; | ||
|
||
use Exception; | ||
use Kirby\Cms\App; | ||
use Kirby\Toolkit\Str; | ||
|
||
class Visitor | ||
{ | ||
/** | ||
* Supported currencies with their currency sign (prefixed to the amount); | ||
* currencies not listed here will automatically fall back to EUR | ||
*/ | ||
const CURRENCIES = [ | ||
'ARS' => 'ARS ', | ||
'AUD' => 'A$', | ||
'BRL' => 'R$', | ||
'CAD' => 'CA$', | ||
'CHF' => 'CHF ', | ||
'CNY' => 'CN¥', | ||
'COP' => 'COP ', | ||
'CZK' => 'CZK ', | ||
'DKK' => 'DKK ', | ||
'EUR' => '€', | ||
'GBP' => '£', | ||
'HKD' => 'HK$', | ||
'HUF' => 'HUF ', | ||
'ILS' => '₪', | ||
'INR' => '₹', | ||
'JPY' => '¥', | ||
'KRW' => '₩', | ||
'MXN' => 'MX$', | ||
'NOK' => 'NOK ', | ||
'NZD' => 'NZ$', | ||
'PLN' => 'PLN ', | ||
'SEK' => 'SEK ', | ||
'SGD' => 'SGD ', | ||
'THB' => 'THB ', | ||
'TRY' => 'TRY ', | ||
'TWD' => 'NT$', | ||
'UAH' => 'UAH ', | ||
'USD' => '$', | ||
'ZAR' => 'ZAR ', | ||
]; | ||
|
||
protected function __construct( | ||
protected string $currency, | ||
protected float $conversionRate, | ||
protected string|null $country = null, | ||
protected string|null $error = null | ||
) { | ||
} | ||
|
||
/** | ||
* Creates a new instance (with validation) | ||
* | ||
* @param string $currency Currency code | ||
* @param float $conversionRate Currency conversion rate from EUR | ||
* @param string|null $country Two-character ISO country code if available | ||
* @param string|null $error Error message if an error occurred during currency detection | ||
*/ | ||
public static function create( | ||
string $currency, | ||
float $conversionRate, | ||
string|null $country = null, | ||
string|null $error = null | ||
): static { | ||
// fall back to EUR if the user currency is not supported | ||
if (isset(static::CURRENCIES[$currency]) !== true) { | ||
$error = 'Invalid currency "' . $currency . '"'; | ||
$currency = 'EUR'; | ||
$conversionRate = 1.0; | ||
} | ||
|
||
// the conversion rate of EUR always needs to be 1 | ||
if ($currency === 'EUR' && $conversionRate !== 1.0) { | ||
$conversionRate = 1.0; | ||
$error = 'Invalid conversion rate "' . $conversionRate . '" for currency EUR'; | ||
} | ||
|
||
return new static($currency, $conversionRate, $country, $error); | ||
} | ||
|
||
/** | ||
* Creates a fallback EUR instance if an error occurred | ||
* during currency detection | ||
*/ | ||
public static function createFromError(string $error): static | ||
{ | ||
return static::create( | ||
currency: 'EUR', | ||
conversionRate: 1.0, | ||
error: $error | ||
); | ||
} | ||
|
||
/** | ||
* Returns the dynamic conversion rate from EUR based | ||
* on the chosen user currency | ||
*/ | ||
public function conversionRate(): float | ||
{ | ||
return $this->conversionRate; | ||
} | ||
|
||
/** | ||
* Returns the user's two-character ISO country code if available | ||
*/ | ||
public function country(): string|null | ||
{ | ||
return $this->country; | ||
} | ||
|
||
/** | ||
* Returns the currency code chosen for the user | ||
*/ | ||
public function currency(): string | ||
{ | ||
return $this->currency; | ||
} | ||
|
||
/** | ||
* Returns the currency sign prefix chosen for the user | ||
*/ | ||
public function currencySign(): string | ||
{ | ||
return static::CURRENCIES[$this->currency]; | ||
} | ||
|
||
/** | ||
* If there was an error during currency detection, | ||
* it will be returned here | ||
*/ | ||
public function error(): string|null | ||
{ | ||
return $this->error; | ||
} | ||
|
||
/** | ||
* Determines the user IP address depending on the setup | ||
* @internal | ||
*/ | ||
public static function ip(): string|null | ||
{ | ||
$env = App::instance()->environment(); | ||
|
||
// if the site is served via Cloudflare, use the proxied IP header | ||
if (option('cloudflare', false) === true) { | ||
return $env->get('CF_CONNECTING_IP', ''); | ||
} | ||
|
||
// otherwise use the direct IP header | ||
$ip = $env->get('REMOTE_ADDR', ''); | ||
|
||
// ignore local IPs as we cannot determine the country from them | ||
if ( | ||
Str::startsWith($ip, '0.') === true || | ||
Str::startsWith($ip, '10.') === true || | ||
Str::startsWith($ip, '127.') === true || | ||
Str::startsWith($ip, '192.') === true || | ||
Str::startsWith($ip, 'fe80::') === true || | ||
$ip === '::1' | ||
) { | ||
return null; | ||
} | ||
|
||
return $ip; | ||
} | ||
|
||
/** | ||
* Returns the formatted approximate revenue limit | ||
* in the user's currency | ||
* | ||
* @param int $revenueLimit Limit in EUR to convert | ||
*/ | ||
public function revenueLimit(int $revenueLimit): string | ||
{ | ||
$converted = $revenueLimit * $this->conversionRate; | ||
|
||
// shorten to three digits with K/M/B suffix | ||
$suffix = ''; | ||
if ($converted >= 1000000000) { | ||
$converted /= 1000000000; | ||
$suffix = 'B'; | ||
} elseif ($converted >= 1000000) { | ||
$converted /= 1000000; | ||
$suffix = 'M'; | ||
} elseif ($converted >= 1000) { | ||
$converted /= 1000; | ||
$suffix = 'K'; | ||
} | ||
|
||
// use two significant digits because it's just an approximation | ||
$digits = strlen(round($converted)); | ||
$converted = round($converted, -$digits + 2); | ||
|
||
return '~ ' . $this->currencySign() . $converted . $suffix; | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters