Files
dewemoji/app/app/Services/Billing/LicenseVerificationService.php
2026-02-06 14:04:41 +07:00

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;
}
}