409 lines
13 KiB
PHP
409 lines
13 KiB
PHP
<?php
|
|
|
|
namespace App\Services\Billing;
|
|
|
|
use Illuminate\Support\Facades\Cache;
|
|
use Illuminate\Support\Facades\Http;
|
|
|
|
class LicenseVerificationService
|
|
{
|
|
/**
|
|
* @return array{
|
|
* ok:bool,
|
|
* tier:string,
|
|
* source:string,
|
|
* error:?string,
|
|
* plan:string,
|
|
* product_id:?string,
|
|
* expires_at:?string,
|
|
* meta:array<string,mixed>,
|
|
* details:array<string,mixed>
|
|
* }
|
|
*/
|
|
public function verify(string $key, bool $fresh = false): array
|
|
{
|
|
$key = trim($key);
|
|
if ($key === '') {
|
|
return $this->invalid('missing_key');
|
|
}
|
|
|
|
$ttl = max((int) config('dewemoji.billing.verify_cache_ttl', 300), 0);
|
|
if ($fresh || $ttl === 0) {
|
|
return $this->verifyNow($key);
|
|
}
|
|
|
|
$cacheKey = 'dw_license_verify_'.sha1($key);
|
|
|
|
/** @var array{
|
|
* ok:bool,
|
|
* tier:string,
|
|
* source:string,
|
|
* error:?string,
|
|
* plan:string,
|
|
* product_id:?string,
|
|
* expires_at:?string,
|
|
* meta:array<string,mixed>,
|
|
* details:array<string,mixed>
|
|
* } $result */
|
|
$result = Cache::remember($cacheKey, now()->addSeconds($ttl), fn (): array => $this->verifyNow($key));
|
|
return $result;
|
|
}
|
|
|
|
public function isPro(string $key): bool
|
|
{
|
|
$result = $this->verify($key);
|
|
return (bool) ($result['ok'] ?? false);
|
|
}
|
|
|
|
/**
|
|
* @return array{
|
|
* ok:bool,
|
|
* tier:string,
|
|
* source:string,
|
|
* error:?string,
|
|
* plan:string,
|
|
* product_id:?string,
|
|
* expires_at:?string,
|
|
* meta:array<string,mixed>,
|
|
* details:array<string,mixed>
|
|
* }
|
|
*/
|
|
private function verifyNow(string $key): array
|
|
{
|
|
$mode = strtolower((string) config('dewemoji.billing.mode', 'sandbox'));
|
|
|
|
if ($mode === 'sandbox') {
|
|
return $this->ok('sandbox', 'pro', null, null, ['mode' => 'sandbox']);
|
|
}
|
|
|
|
if ((bool) config('dewemoji.license.accept_all', false)) {
|
|
return $this->ok('accept_all', 'pro', null, null, ['mode' => 'accept_all']);
|
|
}
|
|
|
|
$validKeys = config('dewemoji.license.pro_keys', []);
|
|
if (is_array($validKeys) && in_array($key, $validKeys, true)) {
|
|
return $this->ok('key_list', 'pro', null, null, ['mode' => 'key_list']);
|
|
}
|
|
|
|
$gum = $this->verifyWithGumroad($key);
|
|
if ($gum['ok']) {
|
|
return $this->ok(
|
|
'gumroad',
|
|
(string) ($gum['plan'] ?? 'pro'),
|
|
$gum['product_id'] ?? null,
|
|
$gum['expires_at'] ?? null,
|
|
is_array($gum['meta'] ?? null) ? $gum['meta'] : []
|
|
);
|
|
}
|
|
|
|
$may = $this->verifyWithMayar($key);
|
|
if ($may['ok']) {
|
|
return $this->ok(
|
|
'mayar',
|
|
(string) ($may['plan'] ?? 'pro'),
|
|
$may['product_id'] ?? null,
|
|
$may['expires_at'] ?? null,
|
|
is_array($may['meta'] ?? null) ? $may['meta'] : []
|
|
);
|
|
}
|
|
|
|
return $this->invalid('invalid_license', [
|
|
'gumroad' => $gum['err'] ?? 'not_checked',
|
|
'mayar' => $may['err'] ?? 'not_checked',
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* @return array{
|
|
* ok:bool,
|
|
* err?:string,
|
|
* plan?:string,
|
|
* product_id?:?string,
|
|
* expires_at?:?string,
|
|
* meta?:array<string,mixed>
|
|
* }
|
|
*/
|
|
private function verifyWithGumroad(string $key): array
|
|
{
|
|
if (!(bool) config('dewemoji.billing.providers.gumroad.enabled', false)) {
|
|
return ['ok' => false, 'err' => 'gumroad_disabled'];
|
|
}
|
|
|
|
$stubKeys = config('dewemoji.billing.providers.gumroad.test_keys', []);
|
|
if (is_array($stubKeys) && in_array($key, $stubKeys, true)) {
|
|
return [
|
|
'ok' => true,
|
|
'plan' => 'pro',
|
|
'product_id' => null,
|
|
'expires_at' => null,
|
|
'meta' => ['stub' => true],
|
|
];
|
|
}
|
|
|
|
$url = trim((string) config('dewemoji.billing.providers.gumroad.verify_url', ''));
|
|
$productIds = config('dewemoji.billing.providers.gumroad.product_ids', []);
|
|
if ($url === '') {
|
|
return ['ok' => false, 'err' => 'gumroad_missing_url'];
|
|
}
|
|
if (!is_array($productIds)) {
|
|
$productIds = [];
|
|
}
|
|
|
|
try {
|
|
$timeout = max((int) config('dewemoji.billing.providers.gumroad.timeout', 8), 1);
|
|
$idsToTry = count($productIds) > 0 ? $productIds : [null];
|
|
foreach ($idsToTry as $pid) {
|
|
$payload = [
|
|
'license_key' => $key,
|
|
'increment_uses_count' => false,
|
|
];
|
|
if (is_string($pid) && trim($pid) !== '') {
|
|
$payload['product_id'] = trim($pid);
|
|
}
|
|
|
|
$response = Http::asForm()
|
|
->timeout($timeout)
|
|
->post($url, $payload);
|
|
|
|
if (!$response->successful()) {
|
|
continue;
|
|
}
|
|
|
|
$json = $response->json();
|
|
if (!is_array($json) || (($json['success'] ?? false) !== true)) {
|
|
continue;
|
|
}
|
|
|
|
$purchase = is_array($json['purchase'] ?? null) ? $json['purchase'] : [];
|
|
if (!$this->isTruthy($purchase['is_valid'] ?? true)) {
|
|
continue;
|
|
}
|
|
if ($this->isTruthy($purchase['refunded'] ?? false)) {
|
|
continue;
|
|
}
|
|
if ($this->isTruthy($purchase['chargebacked'] ?? false)) {
|
|
continue;
|
|
}
|
|
$isRecurring = !empty($purchase['recurrence']);
|
|
|
|
return [
|
|
'ok' => true,
|
|
'plan' => 'pro',
|
|
'product_id' => (string) ($purchase['product_id'] ?? ($payload['product_id'] ?? '')) ?: null,
|
|
'expires_at' => null,
|
|
'meta' => [
|
|
'plan_type' => $isRecurring ? 'subscription' : 'lifetime',
|
|
],
|
|
];
|
|
}
|
|
|
|
return ['ok' => false, 'err' => 'gumroad_no_match'];
|
|
} catch (\Throwable) {
|
|
return ['ok' => false, 'err' => 'gumroad_verify_failed'];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @return array{
|
|
* ok:bool,
|
|
* err?:string,
|
|
* plan?:string,
|
|
* product_id?:?string,
|
|
* expires_at?:?string,
|
|
* meta?:array<string,mixed>
|
|
* }
|
|
*/
|
|
private function verifyWithMayar(string $key): array
|
|
{
|
|
if (!(bool) config('dewemoji.billing.providers.mayar.enabled', false)) {
|
|
return ['ok' => false, 'err' => 'mayar_disabled'];
|
|
}
|
|
|
|
$stubKeys = config('dewemoji.billing.providers.mayar.test_keys', []);
|
|
if (is_array($stubKeys) && in_array($key, $stubKeys, true)) {
|
|
return [
|
|
'ok' => true,
|
|
'plan' => 'pro',
|
|
'product_id' => null,
|
|
'expires_at' => null,
|
|
'meta' => ['stub' => true],
|
|
];
|
|
}
|
|
|
|
$url = trim((string) config('dewemoji.billing.providers.mayar.verify_url', ''));
|
|
$apiBase = rtrim((string) config('dewemoji.billing.providers.mayar.api_base', ''), '/');
|
|
$verifyEndpoint = '/'.ltrim((string) config('dewemoji.billing.providers.mayar.endpoint_verify', '/v1/license/verify'), '/');
|
|
if ($url === '' && $apiBase !== '') {
|
|
$url = $apiBase.$verifyEndpoint;
|
|
}
|
|
$apiKey = trim((string) config('dewemoji.billing.providers.mayar.api_key', ''));
|
|
if ($apiKey === '') {
|
|
$apiKey = trim((string) config('dewemoji.billing.providers.mayar.secret_key', ''));
|
|
}
|
|
$productIds = config('dewemoji.billing.providers.mayar.product_ids', []);
|
|
if (!is_array($productIds)) {
|
|
$productIds = [];
|
|
}
|
|
if ($url === '') {
|
|
return ['ok' => false, 'err' => 'mayar_missing_url'];
|
|
}
|
|
|
|
try {
|
|
$timeout = max((int) config('dewemoji.billing.providers.mayar.timeout', 8), 1);
|
|
$request = Http::timeout($timeout)
|
|
->withHeaders(['Accept' => 'application/json']);
|
|
if ($apiKey !== '') {
|
|
$request = $request->withToken($apiKey)
|
|
->withHeaders(['X-API-Key' => $apiKey]);
|
|
}
|
|
|
|
$response = $request->post($url, [
|
|
'license_key' => $key,
|
|
'license' => $key,
|
|
'key' => $key,
|
|
]);
|
|
|
|
if (!$response->successful()) {
|
|
return ['ok' => false, 'err' => 'mayar_http_'.$response->status()];
|
|
}
|
|
|
|
$json = $response->json();
|
|
if (!is_array($json)) {
|
|
return ['ok' => false, 'err' => 'mayar_invalid_json'];
|
|
}
|
|
|
|
$data = is_array($json['data'] ?? null) ? $json['data'] : [];
|
|
$status = strtolower((string) ($data['status'] ?? $json['status'] ?? ''));
|
|
$valid = (($json['success'] ?? false) === true)
|
|
|| (($json['valid'] ?? false) === true)
|
|
|| (($data['valid'] ?? false) === true)
|
|
|| in_array($status, ['active', 'paid', 'completed', 'valid'], true);
|
|
if (!$valid) {
|
|
return ['ok' => false, 'err' => 'mayar_invalid'];
|
|
}
|
|
|
|
$productId = $this->firstString([
|
|
$data['product_id'] ?? null,
|
|
$data['productId'] ?? null,
|
|
$data['product_code'] ?? null,
|
|
$json['product_id'] ?? null,
|
|
]);
|
|
if (!empty($productIds) && ($productId === null || !in_array($productId, $productIds, true))) {
|
|
return ['ok' => false, 'err' => 'mayar_no_match'];
|
|
}
|
|
|
|
$planType = strtolower((string) ($data['type'] ?? 'lifetime'));
|
|
$expiresAt = $this->firstString([
|
|
$data['expires_at'] ?? null,
|
|
$data['expired_at'] ?? null,
|
|
$data['expiry_date'] ?? null,
|
|
$data['valid_until'] ?? null,
|
|
$json['expires_at'] ?? null,
|
|
]);
|
|
return [
|
|
'ok' => true,
|
|
'plan' => 'pro',
|
|
'product_id' => $productId,
|
|
'expires_at' => $expiresAt,
|
|
'meta' => [
|
|
'plan_type' => $planType,
|
|
],
|
|
];
|
|
} catch (\Throwable) {
|
|
return ['ok' => false, 'err' => 'mayar_verify_failed'];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param array<string,mixed> $meta
|
|
* @return array{
|
|
* ok:bool,
|
|
* tier:string,
|
|
* source:string,
|
|
* error:?string,
|
|
* plan:string,
|
|
* product_id:?string,
|
|
* expires_at:?string,
|
|
* meta:array<string,mixed>,
|
|
* details:array<string,mixed>
|
|
* }
|
|
*/
|
|
private function ok(string $source, string $plan, ?string $productId, ?string $expiresAt, array $meta): array
|
|
{
|
|
return [
|
|
'ok' => true,
|
|
'tier' => 'pro',
|
|
'source' => $source,
|
|
'error' => null,
|
|
'plan' => $plan,
|
|
'product_id' => $productId,
|
|
'expires_at' => $expiresAt,
|
|
'meta' => $meta,
|
|
'details' => [],
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param array<string,mixed> $details
|
|
* @return array{
|
|
* ok:bool,
|
|
* tier:string,
|
|
* source:string,
|
|
* error:?string,
|
|
* plan:string,
|
|
* product_id:?string,
|
|
* expires_at:?string,
|
|
* meta:array<string,mixed>,
|
|
* details:array<string,mixed>
|
|
* }
|
|
*/
|
|
private function invalid(string $error, array $details = []): array
|
|
{
|
|
return [
|
|
'ok' => false,
|
|
'tier' => 'free',
|
|
'source' => 'none',
|
|
'error' => $error,
|
|
'plan' => 'free',
|
|
'product_id' => null,
|
|
'expires_at' => null,
|
|
'meta' => [],
|
|
'details' => $details,
|
|
];
|
|
}
|
|
|
|
private function isTruthy(mixed $value): bool
|
|
{
|
|
if (is_bool($value)) {
|
|
return $value;
|
|
}
|
|
if (is_numeric($value)) {
|
|
return (int) $value === 1;
|
|
}
|
|
if (is_string($value)) {
|
|
$normalized = strtolower(trim($value));
|
|
return in_array($normalized, ['1', 'true', 'yes', 'y', 'on'], true);
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* @param array<int,mixed> $values
|
|
*/
|
|
private function firstString(array $values): ?string
|
|
{
|
|
foreach ($values as $value) {
|
|
if (!is_string($value)) {
|
|
continue;
|
|
}
|
|
$value = trim($value);
|
|
if ($value !== '') {
|
|
return $value;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
}
|