Skip to content

Commit

Permalink
Test setup for Paddle currency detection
Browse files Browse the repository at this point in the history
  • Loading branch information
lukasbestle committed Nov 29, 2023
1 parent 9488ad9 commit 0767b93
Show file tree
Hide file tree
Showing 5 changed files with 346 additions and 0 deletions.
1 change: 1 addition & 0 deletions site/config/config.getkirby.com.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,6 @@
'cdn' => [
'domain' => 'https://assets.getkirby.com',
],
'cloudflare' => true,
'debug' => false
];
19 changes: 19 additions & 0 deletions site/config/routes.php
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,25 @@
]);
}
],
[
// TODO: Temporary test, remove this route again
'pattern' => 'buy/currency-test',
'action' => function () {
$visitor = \Buy\Paddle::visitor();
$rates = Data::read(dirname(__DIR__) . '/plugins/buy/rates.json');

$body = 'Detected IP: ' . (\Buy\Visitor::ip() ?? 'N/A') . "\n" .
'Detected country: ' . ($visitor->country() ?? 'N/A') . "\n" .
'Detected currency: ' . $visitor->currency() . "\n" .
'Detected rate: ' . $visitor->conversionRate() . ' (hardcoded for this currency: ' . $rates[$visitor->currency()] . ")\n" .
'Status: ' . ($visitor->error() ?? 'OK') . "\n\n" .
'Example price: ' . $visitor->currencySign() . "123\n" .
'Revenue limit: ' . $visitor->revenueLimit(1000000);


return new \Kirby\Http\Response($body, 'text/plain');
}
],
[
'pattern' => 'buy/(:any)/(:any?)',
'action' => function (string $product, string $currency = 'EUR') {
Expand Down
124 changes: 124 additions & 0 deletions site/plugins/buy/Paddle.php
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());
}
}
}
200 changes: 200 additions & 0 deletions site/plugins/buy/Visitor.php
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;
}
}
2 changes: 2 additions & 0 deletions site/plugins/buy/index.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,12 @@
use Kirby\Cms\App;

load([
'Buy\Paddle' => __DIR__ . '/Paddle.php',
'Buy\Price' => __DIR__ . '/Price.php',
'Buy\Product' => __DIR__ . '/Product.php',
'Buy\Sale' => __DIR__ . '/Sale.php',
'Buy\Upgrade' => __DIR__ . '/Upgrade.php',
'Buy\Visitor' => __DIR__ . '/Visitor.php',
]);

App::plugin('getkirby/buy', []);

0 comments on commit 0767b93

Please sign in to comment.