Update pricing UX, billing flows, and API rules

This commit is contained in:
Dwindi Ramadhana
2026-02-12 00:52:40 +07:00
parent cf065fab1e
commit a905256353
202 changed files with 22348 additions and 301 deletions

View File

@@ -0,0 +1,56 @@
<?php
namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use App\Models\PricingPlan;
use App\Models\Subscription;
use App\Models\User;
use App\Models\UserApiKey;
use App\Models\UserKeyword;
use App\Models\WebhookEvent;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class AdminAnalyticsController extends Controller
{
private function authorizeAdmin(Request $request): ?JsonResponse
{
$token = (string) config('dewemoji.admin.token', '');
$provided = trim((string) $request->header('X-Admin-Token', ''));
if ($token === '' || $provided === '' || !hash_equals($token, $provided)) {
return response()->json(['ok' => false, 'error' => 'unauthorized'], 401);
}
return null;
}
public function overview(Request $request): JsonResponse
{
if ($res = $this->authorizeAdmin($request)) {
return $res;
}
$totalUsers = User::count();
$personalUsers = User::where('tier', 'personal')->count();
$apiKeys = UserApiKey::count();
$keywords = UserKeyword::count();
$subscriptions = Subscription::count();
$activeSubscriptions = Subscription::where('status', 'active')->count();
$pricingPlans = PricingPlan::count();
$webhooks = WebhookEvent::count();
return response()->json([
'ok' => true,
'metrics' => [
'users_total' => $totalUsers,
'users_personal' => $personalUsers,
'api_keys_total' => $apiKeys,
'keywords_total' => $keywords,
'subscriptions_total' => $subscriptions,
'subscriptions_active' => $activeSubscriptions,
'pricing_plans_total' => $pricingPlans,
'webhook_events_total' => $webhooks,
],
]);
}
}

View File

@@ -0,0 +1,141 @@
<?php
namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use App\Models\PricingChange;
use App\Models\PricingPlan;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
class AdminPricingController extends Controller
{
private function authorizeAdmin(Request $request): ?JsonResponse
{
$adminToken = (string) config('dewemoji.admin.token', '');
$provided = trim((string) $request->header('X-Admin-Token', ''));
if ($adminToken === '' || $provided === '' || !hash_equals($adminToken, $provided)) {
return response()->json(['ok' => false, 'error' => 'unauthorized'], 401);
}
return null;
}
public function index(Request $request): JsonResponse
{
if ($res = $this->authorizeAdmin($request)) {
return $res;
}
$plans = PricingPlan::orderBy('id')->get();
return response()->json([
'ok' => true,
'plans' => $plans,
]);
}
public function changes(Request $request): JsonResponse
{
if ($res = $this->authorizeAdmin($request)) {
return $res;
}
$limit = max((int) $request->query('limit', 20), 1);
$items = PricingChange::orderByDesc('id')
->limit($limit)
->get();
return response()->json([
'ok' => true,
'items' => $items,
]);
}
public function update(Request $request): JsonResponse
{
if ($res = $this->authorizeAdmin($request)) {
return $res;
}
$data = $request->validate([
'admin_ref' => 'nullable|string|max:120',
'plans' => 'required|array|min:1',
'plans.*.code' => 'required|string|max:30',
'plans.*.name' => 'required|string|max:50',
'plans.*.currency' => 'required|string|max:10',
'plans.*.amount' => 'required|integer|min:0',
'plans.*.period' => 'nullable|string|max:20',
'plans.*.status' => 'nullable|string|max:20',
'plans.*.meta' => 'nullable|array',
]);
$before = PricingPlan::orderBy('id')->get()->toArray();
DB::transaction(function () use ($data): void {
foreach ($data['plans'] as $plan) {
PricingPlan::updateOrCreate(
['code' => $plan['code']],
[
'name' => $plan['name'],
'currency' => $plan['currency'],
'amount' => $plan['amount'],
'period' => $plan['period'] ?? null,
'status' => $plan['status'] ?? 'active',
'meta' => $plan['meta'] ?? null,
]
);
}
});
$after = PricingPlan::orderBy('id')->get()->toArray();
PricingChange::create([
'admin_ref' => $data['admin_ref'] ?? null,
'before' => $before,
'after' => $after,
]);
return response()->json([
'ok' => true,
'plans' => $after,
]);
}
public function reset(Request $request): JsonResponse
{
if ($res = $this->authorizeAdmin($request)) {
return $res;
}
$defaults = config('dewemoji.pricing.defaults', []);
$before = PricingPlan::orderBy('id')->get()->toArray();
DB::transaction(function () use ($defaults): void {
PricingPlan::query()->delete();
foreach ($defaults as $plan) {
PricingPlan::create([
'code' => $plan['code'],
'name' => $plan['name'],
'currency' => $plan['currency'],
'amount' => $plan['amount'],
'period' => $plan['period'],
'status' => $plan['status'],
'meta' => $plan['meta'] ?? null,
]);
}
});
$after = PricingPlan::orderBy('id')->get()->toArray();
PricingChange::create([
'admin_ref' => (string) $request->header('X-Admin-Ref', ''),
'before' => $before,
'after' => $after,
]);
return response()->json([
'ok' => true,
'plans' => $after,
]);
}
}

View File

@@ -0,0 +1,61 @@
<?php
namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use App\Services\System\SettingsService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class AdminSettingsController extends Controller
{
public function __construct(private readonly SettingsService $settings)
{
}
private function authorizeAdmin(Request $request): ?JsonResponse
{
$token = (string) config('dewemoji.admin.token', '');
$provided = trim((string) $request->header('X-Admin-Token', ''));
if ($token === '' || $provided === '' || !hash_equals($token, $provided)) {
return response()->json(['ok' => false, 'error' => 'unauthorized'], 401);
}
return null;
}
public function index(Request $request): JsonResponse
{
if ($res = $this->authorizeAdmin($request)) {
return $res;
}
$keys = $request->query('keys');
$all = $this->settings->all();
if (is_string($keys) && $keys !== '') {
$filter = array_filter(array_map('trim', explode(',', $keys)));
$all = array_intersect_key($all, array_flip($filter));
}
return response()->json(['ok' => true, 'settings' => $all]);
}
public function update(Request $request): JsonResponse
{
if ($res = $this->authorizeAdmin($request)) {
return $res;
}
$payload = $request->input('settings');
if (!is_array($payload)) {
return response()->json(['ok' => false, 'error' => 'invalid_payload'], 422);
}
$adminRef = (string) $request->header('X-Admin-Ref', '');
$this->settings->setMany($payload, $adminRef !== '' ? $adminRef : null);
return response()->json([
'ok' => true,
'settings' => $this->settings->all(),
]);
}
}

View File

@@ -0,0 +1,158 @@
<?php
namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use App\Models\Subscription;
use App\Models\User;
use App\Models\UserApiKey;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Carbon;
class AdminSubscriptionController extends Controller
{
private function authorizeAdmin(Request $request): ?JsonResponse
{
$token = (string) config('dewemoji.admin.token', '');
$provided = trim((string) $request->header('X-Admin-Token', ''));
if ($token === '' || $provided === '' || !hash_equals($token, $provided)) {
return response()->json(['ok' => false, 'error' => 'unauthorized'], 401);
}
return null;
}
public function index(Request $request): JsonResponse
{
if ($res = $this->authorizeAdmin($request)) {
return $res;
}
$query = Subscription::query()->with('user:id,email,name,tier');
if ($userId = $request->query('user_id')) {
$query->where('user_id', (int) $userId);
}
if ($email = $request->query('email')) {
$query->whereHas('user', fn ($q) => $q->where('email', $email));
}
if ($status = $request->query('status')) {
$query->where('status', (string) $status);
}
$limit = min(max((int) $request->query('limit', 50), 1), 200);
$items = $query->orderByDesc('id')->limit($limit)->get();
return response()->json(['ok' => true, 'items' => $items]);
}
public function grant(Request $request): JsonResponse
{
if ($res = $this->authorizeAdmin($request)) {
return $res;
}
$user = $this->resolveUser($request);
if (!$user) {
return response()->json(['ok' => false, 'error' => 'not_found'], 404);
}
$plan = (string) $request->input('plan', 'personal');
$status = (string) $request->input('status', 'active');
$provider = (string) $request->input('provider', 'admin');
$providerRef = (string) $request->input('provider_ref', '');
$startedAt = $this->parseDate($request->input('started_at')) ?? now();
$expiresAt = $this->parseDate($request->input('expires_at'));
$sub = Subscription::create([
'user_id' => $user->id,
'plan' => $plan,
'status' => $status,
'provider' => $provider,
'provider_ref' => $providerRef !== '' ? $providerRef : null,
'started_at' => $startedAt,
'expires_at' => $expiresAt,
]);
if ($status === 'active') {
$user->update(['tier' => 'personal']);
}
return response()->json(['ok' => true, 'subscription' => $sub]);
}
public function revoke(Request $request): JsonResponse
{
if ($res = $this->authorizeAdmin($request)) {
return $res;
}
$id = (int) $request->input('id', 0);
$now = now();
if ($id > 0) {
$sub = Subscription::find($id);
if (!$sub) {
return response()->json(['ok' => false, 'error' => 'not_found'], 404);
}
$sub->update(['status' => 'revoked', 'expires_at' => $now]);
$this->syncUserTier($sub->user_id);
return response()->json(['ok' => true, 'revoked' => true]);
}
$user = $this->resolveUser($request);
if (!$user) {
return response()->json(['ok' => false, 'error' => 'not_found'], 404);
}
Subscription::where('user_id', $user->id)
->where('status', 'active')
->update(['status' => 'revoked', 'expires_at' => $now]);
$this->syncUserTier($user->id);
return response()->json(['ok' => true, 'revoked' => true]);
}
private function resolveUser(Request $request): ?User
{
if ($userId = $request->input('user_id')) {
return User::find((int) $userId);
}
if ($email = $request->input('email')) {
return User::where('email', (string) $email)->first();
}
return null;
}
private function parseDate(mixed $value): ?Carbon
{
if (!$value) {
return null;
}
try {
return Carbon::parse((string) $value);
} catch (\Throwable) {
return null;
}
}
private function syncUserTier(int $userId): void
{
$active = Subscription::where('user_id', $userId)
->where('status', 'active')
->where(function ($q): void {
$q->whereNull('expires_at')
->orWhere('expires_at', '>', now());
})
->exists();
User::where('id', $userId)->update([
'tier' => $active ? 'personal' : 'free',
]);
if (!$active) {
UserApiKey::where('user_id', $userId)->update(['revoked_at' => now()]);
}
}
}

View File

@@ -0,0 +1,123 @@
<?php
namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use App\Models\User;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class AdminUserController extends Controller
{
private function authorizeAdmin(Request $request): ?JsonResponse
{
$adminToken = (string) config('dewemoji.admin.token', '');
$provided = trim((string) $request->header('X-Admin-Token', ''));
if ($adminToken === '' || $provided === '' || !hash_equals($adminToken, $provided)) {
return response()->json(['ok' => false, 'error' => 'unauthorized'], 401);
}
return null;
}
public function index(Request $request): JsonResponse
{
if ($res = $this->authorizeAdmin($request)) {
return $res;
}
$q = trim((string) $request->query('q', ''));
$limit = max((int) $request->query('limit', 20), 1);
$query = User::query()->orderByDesc('id');
if ($q !== '') {
$query->where(function ($sub) use ($q): void {
$sub->where('email', 'like', '%'.$q.'%')
->orWhere('name', 'like', '%'.$q.'%');
});
}
$items = $query->limit($limit)->get(['id', 'name', 'email', 'tier', 'created_at']);
return response()->json([
'ok' => true,
'items' => $items,
]);
}
public function show(Request $request): JsonResponse
{
if ($res = $this->authorizeAdmin($request)) {
return $res;
}
$email = trim((string) $request->query('email', ''));
$userId = (int) $request->query('user_id', 0);
$query = User::query();
if ($email !== '') {
$query->where('email', $email);
} elseif ($userId > 0) {
$query->where('id', $userId);
} else {
return response()->json(['ok' => false, 'error' => 'missing_target'], 400);
}
/** @var User|null $user */
$user = $query->first();
if (!$user) {
return response()->json(['ok' => false, 'error' => 'not_found'], 404);
}
return response()->json([
'ok' => true,
'user' => [
'id' => $user->id,
'name' => $user->name,
'email' => $user->email,
'tier' => $user->tier,
'created_at' => $user->created_at,
],
]);
}
public function setTier(Request $request): JsonResponse
{
if ($res = $this->authorizeAdmin($request)) {
return $res;
}
$data = $request->validate([
'email' => 'nullable|email|max:255',
'user_id' => 'nullable|integer',
'tier' => 'required|string|in:free,personal',
]);
$query = User::query();
if (!empty($data['email'])) {
$query->where('email', $data['email']);
} elseif (!empty($data['user_id'])) {
$query->where('id', $data['user_id']);
} else {
return response()->json(['ok' => false, 'error' => 'missing_target'], 400);
}
/** @var User|null $user */
$user = $query->first();
if (!$user) {
return response()->json(['ok' => false, 'error' => 'not_found'], 404);
}
$user->tier = $data['tier'];
$user->save();
return response()->json([
'ok' => true,
'user' => [
'id' => $user->id,
'email' => $user->email,
'tier' => $user->tier,
],
]);
}
}

View File

@@ -0,0 +1,92 @@
<?php
namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use App\Models\WebhookEvent;
use App\Services\Billing\PaypalWebhookProcessor;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class AdminWebhookController extends Controller
{
public function __construct(private readonly PaypalWebhookProcessor $processor)
{
}
private function authorizeAdmin(Request $request): ?JsonResponse
{
$token = (string) config('dewemoji.admin.token', '');
$provided = trim((string) $request->header('X-Admin-Token', ''));
if ($token === '' || $provided === '' || !hash_equals($token, $provided)) {
return response()->json(['ok' => false, 'error' => 'unauthorized'], 401);
}
return null;
}
public function index(Request $request): JsonResponse
{
if ($res = $this->authorizeAdmin($request)) {
return $res;
}
$query = WebhookEvent::query();
if ($provider = $request->query('provider')) {
$query->where('provider', (string) $provider);
}
if ($status = $request->query('status')) {
$query->where('status', (string) $status);
}
$limit = min(max((int) $request->query('limit', 50), 1), 200);
$items = $query->orderByDesc('id')->limit($limit)->get();
return response()->json(['ok' => true, 'items' => $items]);
}
public function show(Request $request, int $id): JsonResponse
{
if ($res = $this->authorizeAdmin($request)) {
return $res;
}
$item = WebhookEvent::find($id);
if (!$item) {
return response()->json(['ok' => false, 'error' => 'not_found'], 404);
}
return response()->json(['ok' => true, 'item' => $item]);
}
public function replay(Request $request, int $id): JsonResponse
{
if ($res = $this->authorizeAdmin($request)) {
return $res;
}
$item = WebhookEvent::find($id);
if (!$item) {
return response()->json(['ok' => false, 'error' => 'not_found'], 404);
}
$item->update(['status' => 'pending', 'processed_at' => null, 'error' => null]);
try {
if ($item->provider === 'paypal') {
$this->processor->process((string) ($item->event_type ?? ''), $item->payload ?? []);
}
$item->update([
'status' => 'processed',
'processed_at' => now(),
]);
} catch (\Throwable $e) {
$item->update([
'status' => 'error',
'error' => $e->getMessage(),
'processed_at' => now(),
]);
}
return response()->json(['ok' => true, 'replayed' => true]);
}
}

View File

@@ -3,6 +3,8 @@
namespace App\Http\Controllers\Api\V1;
use App\Services\Billing\LicenseVerificationService;
use App\Services\Auth\ApiKeyService;
use App\Services\System\SettingsService;
use App\Http\Controllers\Controller;
use Carbon\Carbon;
use Illuminate\Http\JsonResponse;
@@ -21,7 +23,8 @@ class EmojiApiController extends Controller
private static ?array $dataset = null;
public function __construct(
private readonly LicenseVerificationService $verification
private readonly LicenseVerificationService $verification,
private readonly ApiKeyService $apiKeys
) {
}
@@ -41,6 +44,10 @@ class EmojiApiController extends Controller
public function categories(Request $request): JsonResponse
{
if ($blocked = $this->enforcePublicAccess($request)) {
return $blocked;
}
$tier = $this->detectTier($request);
try {
$data = $this->loadData();
@@ -87,6 +94,10 @@ class EmojiApiController extends Controller
public function emojis(Request $request): JsonResponse
{
if ($blocked = $this->enforcePublicAccess($request)) {
return $blocked;
}
$tier = $this->detectTier($request);
try {
$data = $this->loadData();
@@ -109,45 +120,7 @@ class EmojiApiController extends Controller
: max((int) config('dewemoji.pagination.free_max_limit', 20), 1);
$limit = min(max((int) $request->query('limit', $defaultLimit), 1), $maxLimit);
$filtered = array_values(array_filter($items, function (array $item) use ($q, $category, $subSlug): bool {
$itemCategory = trim((string) ($item['category'] ?? ''));
$itemSubcategory = trim((string) ($item['subcategory'] ?? ''));
if ($category !== '' && strcasecmp($itemCategory, $category) !== 0) {
return false;
}
if ($subSlug !== '' && $this->slugify($itemSubcategory) !== $subSlug) {
return false;
}
if ($q === '') {
return true;
}
$haystack = strtolower(implode(' ', [
(string) ($item['emoji'] ?? ''),
(string) ($item['name'] ?? ''),
(string) ($item['slug'] ?? ''),
$itemCategory,
$itemSubcategory,
implode(' ', $item['keywords_en'] ?? []),
implode(' ', $item['keywords_id'] ?? []),
implode(' ', $item['aliases'] ?? []),
implode(' ', $item['shortcodes'] ?? []),
implode(' ', $item['alt_shortcodes'] ?? []),
implode(' ', $item['intent_tags'] ?? []),
]));
$tokens = preg_split('/\s+/', strtolower($q)) ?: [];
foreach ($tokens as $token) {
if ($token === '') {
continue;
}
if (!str_contains($haystack, $token)) {
return false;
}
}
return true;
}));
$filtered = $this->filterItems($items, $q, $category, $subSlug);
$total = count($filtered);
$offset = ($page - 1) * $limit;
@@ -170,44 +143,123 @@ class EmojiApiController extends Controller
]);
}
if ($tier === self::TIER_FREE && $page === 1) {
$usage = $this->trackDailyUsage($request, $q, $category, $subSlug);
if ($usage['blocked']) {
return $this->jsonWithTier($request, [
'ok' => false,
'error' => 'daily_limit_reached',
'message' => 'Daily free limit reached. Upgrade to Pro for unlimited usage.',
'plan' => self::TIER_FREE,
'usage' => $usage['meta'],
], $tier, 429, [
'X-RateLimit-Limit' => (string) $usage['meta']['limit'],
'X-RateLimit-Remaining' => '0',
'X-RateLimit-Reset' => (string) strtotime('tomorrow 00:00:00 UTC'),
'ETag' => $etag,
'Cache-Control' => 'public, max-age=120',
]);
}
$responsePayload['plan'] = self::TIER_FREE;
$responsePayload['usage'] = $usage['meta'];
return $this->jsonWithTier($request, $responsePayload, $tier, 200, [
'X-RateLimit-Limit' => (string) $usage['meta']['limit'],
'X-RateLimit-Remaining' => (string) $usage['meta']['remaining'],
'X-RateLimit-Reset' => (string) strtotime('tomorrow 00:00:00 UTC'),
'ETag' => $etag,
'Cache-Control' => 'public, max-age=120',
]);
}
return $this->jsonWithTier($request, $responsePayload, $tier, 200, [
'ETag' => $etag,
'Cache-Control' => 'public, max-age=120',
]);
}
public function search(Request $request): JsonResponse
{
$private = filter_var($request->query('private', false), FILTER_VALIDATE_BOOL);
if (!$private) {
return $this->emojis($request);
}
$user = $this->apiKeys->resolveUser($request) ?? $request->user();
if (!$user) {
return response()->json(['ok' => false, 'error' => 'unauthorized'], 401);
}
if ((string) $user->tier !== 'personal') {
return response()->json(['ok' => false, 'error' => 'personal_required'], 403);
}
$q = trim((string) ($request->query('q', $request->query('query', ''))));
$category = $this->normalizeCategoryFilter((string) $request->query('category', ''));
$subSlug = $this->slugify((string) $request->query('subcategory', ''));
try {
$data = $this->loadData();
} catch (RuntimeException $e) {
return $this->jsonWithTier($request, [
'error' => 'data_load_failed',
'message' => $e->getMessage(),
], self::TIER_FREE, 500);
}
$items = $data['emojis'] ?? [];
$itemsBySlug = [];
foreach ($items as $item) {
$slug = (string) ($item['slug'] ?? '');
if ($slug !== '') {
$itemsBySlug[$slug] = $item;
}
}
$filtered = $this->filterItems($items, $q, $category, $subSlug);
$publicBySlug = [];
foreach ($filtered as $item) {
$slug = (string) ($item['slug'] ?? '');
if ($slug !== '') {
$publicBySlug[$slug] = $item;
}
}
$privateMatches = [];
if ($q !== '') {
$rows = DB::table('user_keywords')
->where('user_id', $user->id)
->whereRaw('LOWER(keyword) LIKE ?', ['%'.strtolower($q).'%'])
->get(['id', 'emoji_slug', 'keyword', 'lang']);
foreach ($rows as $row) {
$slug = (string) $row->emoji_slug;
if ($slug === '') {
continue;
}
$privateMatches[$slug] = [
'id' => (int) $row->id,
'keyword' => (string) $row->keyword,
'lang' => (string) $row->lang,
];
}
}
$tier = self::TIER_PRO;
$merged = [];
foreach ($privateMatches as $slug => $meta) {
$sourceItem = $publicBySlug[$slug] ?? $itemsBySlug[$slug] ?? null;
if ($sourceItem === null) {
continue;
}
$item = $this->transformItem($sourceItem, $tier);
$item['source'] = 'private';
$item['matched_keyword_id'] = $meta['id'] ?? null;
$item['matched_keyword'] = $meta['keyword'];
$item['matched_lang'] = $meta['lang'];
$merged[$slug] = $item;
}
foreach ($publicBySlug as $slug => $item) {
if (isset($merged[$slug])) {
continue;
}
$row = $this->transformItem($item, $tier);
$row['source'] = 'public';
$merged[$slug] = $row;
}
$mergedItems = array_values($merged);
$page = max((int) $request->query('page', 1), 1);
$limit = max((int) $request->query('limit', 20), 1);
$total = count($mergedItems);
$pageItems = array_slice($mergedItems, ($page - 1) * $limit, $limit);
return response()->json([
'ok' => true,
'items' => $pageItems,
'total' => $total,
'page' => $page,
'limit' => $limit,
'plan' => 'personal',
]);
}
public function emoji(Request $request, ?string $slug = null): JsonResponse
{
if ($blocked = $this->enforcePublicAccess($request)) {
return $blocked;
}
$tier = $this->detectTier($request);
$slug = trim((string) ($slug ?? $request->query('slug', '')));
if ($slug === '') {
@@ -243,6 +295,19 @@ class EmojiApiController extends Controller
}
$payload = $this->transformEmojiDetail($match, $tier);
$includeUserKeywords = filter_var($request->query('include_user_keywords', false), FILTER_VALIDATE_BOOL);
if ($includeUserKeywords) {
$user = $this->apiKeys->resolveUser($request) ?? $request->user();
if ($user && (string) $user->tier === 'personal') {
$payload['user_keywords'] = DB::table('user_keywords')
->where('user_id', $user->id)
->where('emoji_slug', $slug)
->orderByDesc('id')
->get(['id', 'keyword', 'lang']);
} else {
$payload['user_keywords'] = [];
}
}
$etag = '"'.sha1(json_encode([$payload, $tier])).'"';
if ($this->isNotModified($request, $etag)) {
return $this->jsonWithTier($request, [], $tier, 304, [
@@ -277,6 +342,148 @@ class EmojiApiController extends Controller
return self::TIER_FREE;
}
private function enforcePublicAccess(Request $request): ?JsonResponse
{
if ($this->hasApiKeyAuth($request)) {
return null;
}
$config = config('dewemoji.public_access', []);
$settings = app(SettingsService::class);
$maintenanceEnabled = (bool) $settings->get('maintenance_enabled', false);
if ($maintenanceEnabled) {
return $this->jsonWithTier($request, [
'ok' => false,
'error' => 'maintenance',
], self::TIER_FREE, 503);
}
$enforceWhitelist = (bool) $settings->get('public_enforce', $config['enforce_whitelist'] ?? false);
$allowedOrigins = $settings->get('public_origins', $config['allowed_origins'] ?? []);
$extensionIds = $settings->get('public_extension_ids', $config['extension_ids'] ?? []);
$limitOverride = $settings->get('public_hourly_limit', $config['hourly_limit'] ?? 0);
$allowed = $this->isPublicAllowed($request, [
'allowed_origins' => $allowedOrigins,
'extension_ids' => $extensionIds,
]);
if ($enforceWhitelist && !$allowed) {
return $this->jsonWithTier($request, [
'ok' => false,
'error' => 'public_access_denied',
], self::TIER_FREE, 403);
}
if (!$allowed) {
$limit = max((int) $limitOverride, 0);
if ($limit > 0) {
$usage = $this->trackPublicHourlyUsage($request, $limit);
if ($usage['blocked']) {
return $this->jsonWithTier($request, [
'ok' => false,
'error' => 'public_rate_limited',
'usage' => $usage['meta'],
], self::TIER_FREE, 429, [
'X-RateLimit-Limit' => (string) $usage['meta']['limit'],
'X-RateLimit-Remaining' => '0',
'X-RateLimit-Reset' => (string) $usage['meta']['window_ends_at_unix'],
]);
}
}
}
return null;
}
/**
* @param array<string,mixed> $config
*/
private function isPublicAllowed(Request $request, array $config): bool
{
if (app()->environment(['local', 'testing'])) {
return true;
}
$host = trim((string) $request->getHost());
if (in_array($host, ['127.0.0.1', 'localhost'], true)) {
return true;
}
$origin = trim((string) $request->headers->get('Origin', ''));
$allowedOrigins = $config['allowed_origins'] ?? [];
if ($origin !== '' && is_array($allowedOrigins) && in_array($origin, $allowedOrigins, true)) {
return true;
}
$frontendHeader = trim((string) $request->header('X-Dewemoji-Frontend', ''));
$extensionIds = $config['extension_ids'] ?? [];
if ($frontendHeader !== '' && is_array($extensionIds)) {
foreach ($extensionIds as $id) {
if ($id !== '' && str_contains($frontendHeader, $id)) {
return true;
}
}
}
$extensionHeader = trim((string) $request->header('X-Extension-Id', ''));
if ($extensionHeader !== '' && is_array($extensionIds)) {
if (in_array($extensionHeader, $extensionIds, true)) {
return true;
}
}
$userAgent = (string) $request->userAgent();
if ($userAgent !== '' && is_array($extensionIds)) {
foreach ($extensionIds as $id) {
if ($id !== '' && str_contains($userAgent, $id)) {
return true;
}
}
}
return false;
}
private function hasApiKeyAuth(Request $request): bool
{
return $this->apiKeys->resolveUser($request) !== null;
}
/**
* @return array{blocked:bool,meta:array<string,mixed>}
*/
private function trackPublicHourlyUsage(Request $request, int $limit): array
{
$bucket = sha1((string) $request->ip().'|'.(string) $request->userAgent());
$hourKey = Carbon::now('UTC')->format('YmdH');
$cacheKey = 'dw_public_hourly_'.$bucket.'_'.$hourKey;
$seconds = max(60, Carbon::now('UTC')->diffInSeconds(Carbon::now('UTC')->addHour()->startOfHour()));
$current = Cache::get($cacheKey, 0);
if (!is_int($current)) {
$current = (int) $current;
}
$current++;
Cache::put($cacheKey, $current, $seconds);
$blocked = $current > $limit;
$windowEnds = Carbon::now('UTC')->addHour()->startOfHour();
return [
'blocked' => $blocked,
'meta' => [
'used' => min($current, $limit),
'limit' => $limit,
'remaining' => max(0, $limit - $current),
'window' => 'hourly',
'window_ends_at' => $windowEnds->toIso8601String(),
'window_ends_at_unix' => $windowEnds->timestamp,
],
];
}
private function isNotModified(Request $request, string $etag): bool
{
$ifNoneMatch = trim((string) $request->header('If-None-Match', ''));
@@ -329,6 +536,53 @@ class EmojiApiController extends Controller
return trim($value, '-');
}
/**
* @param array<int,array<string,mixed>> $items
* @return array<int,array<string,mixed>>
*/
private function filterItems(array $items, string $q, string $category, string $subSlug): array
{
return array_values(array_filter($items, function (array $item) use ($q, $category, $subSlug): bool {
$itemCategory = trim((string) ($item['category'] ?? ''));
$itemSubcategory = trim((string) ($item['subcategory'] ?? ''));
if ($category !== '' && strcasecmp($itemCategory, $category) !== 0) {
return false;
}
if ($subSlug !== '' && $this->slugify($itemSubcategory) !== $subSlug) {
return false;
}
if ($q === '') {
return true;
}
$haystack = strtolower(implode(' ', [
(string) ($item['emoji'] ?? ''),
(string) ($item['name'] ?? ''),
(string) ($item['slug'] ?? ''),
$itemCategory,
$itemSubcategory,
implode(' ', $item['keywords_en'] ?? []),
implode(' ', $item['keywords_id'] ?? []),
implode(' ', $item['aliases'] ?? []),
implode(' ', $item['shortcodes'] ?? []),
implode(' ', $item['alt_shortcodes'] ?? []),
implode(' ', $item['intent_tags'] ?? []),
]));
$tokens = preg_split('/\s+/', strtolower($q)) ?: [];
foreach ($tokens as $token) {
if ($token === '') {
continue;
}
if (!str_contains($haystack, $token)) {
return false;
}
}
return true;
}));
}
/**
* @param array<string,mixed> $item
* @return array<string,mixed>

View File

@@ -0,0 +1,207 @@
<?php
namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use App\Services\Extension\ExtensionVerificationService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use RuntimeException;
class ExtensionController extends Controller
{
/** @var array<string,mixed>|null */
private static ?array $dataset = null;
public function __construct(private readonly ExtensionVerificationService $verifier)
{
}
public function verify(Request $request): JsonResponse
{
$token = trim((string) $request->header('X-Extension-Token', ''));
$expected = config('dewemoji.public_access.extension_ids', []);
$ok = $this->verifier->verifyToken($token, is_array($expected) ? $expected : []);
return response()->json([
'ok' => $ok,
'verified' => $ok,
], $ok ? 200 : 403);
}
public function search(Request $request): JsonResponse
{
$token = trim((string) $request->header('X-Extension-Token', ''));
$expected = config('dewemoji.public_access.extension_ids', []);
$ok = $this->verifier->verifyToken($token, is_array($expected) ? $expected : []);
if (!$ok) {
return response()->json(['ok' => false, 'error' => 'extension_unverified'], 403);
}
try {
$data = $this->loadData();
} catch (RuntimeException $e) {
return response()->json([
'ok' => false,
'error' => 'data_load_failed',
'message' => $e->getMessage(),
], 500);
}
$items = $data['emojis'] ?? [];
$q = trim((string) ($request->query('q', $request->query('query', ''))));
$category = $this->normalizeCategoryFilter((string) $request->query('category', ''));
$subSlug = $this->slugify((string) $request->query('subcategory', ''));
$page = max((int) $request->query('page', 1), 1);
$defaultLimit = max((int) config('dewemoji.pagination.default_limit', 20), 1);
$maxLimit = max((int) config('dewemoji.pagination.free_max_limit', 20), 1);
$limit = min(max((int) $request->query('limit', $defaultLimit), 1), $maxLimit);
$filtered = $this->filterItems($items, $q, $category, $subSlug);
$total = count($filtered);
$offset = ($page - 1) * $limit;
$pageItems = array_slice($filtered, $offset, $limit);
return response()->json([
'ok' => true,
'items' => array_map(fn (array $item): array => $this->transformItem($item), $pageItems),
'total' => $total,
'page' => $page,
'limit' => $limit,
'plan' => 'free',
]);
}
/**
* @return array<string,mixed>
*/
private function loadData(): array
{
if (self::$dataset !== null) {
return self::$dataset;
}
$path = (string) config('dewemoji.data_path');
if (!is_file($path)) {
throw new RuntimeException('Emoji dataset file was not found at: '.$path);
}
$raw = file_get_contents($path);
if ($raw === false) {
throw new RuntimeException('Emoji dataset file could not be read.');
}
$decoded = json_decode($raw, true);
if (!is_array($decoded)) {
throw new RuntimeException('Emoji dataset JSON is invalid.');
}
self::$dataset = $decoded;
return self::$dataset;
}
private function normalizeCategoryFilter(string $category): string
{
$value = strtolower(trim($category));
if ($value === '' || $value === 'all') {
return '';
}
$map = [
'all' => 'all',
'smileys' => 'Smileys & Emotion',
'people' => 'People & Body',
'animals' => 'Animals & Nature',
'food' => 'Food & Drink',
'travel' => 'Travel & Places',
'activities' => 'Activities',
'objects' => 'Objects',
'symbols' => 'Symbols',
'flags' => 'Flags',
];
return $map[$value] ?? $category;
}
private function slugify(string $text): string
{
$value = strtolower(trim($text));
$value = str_replace('&', 'and', $value);
$value = preg_replace('/[^a-z0-9]+/', '-', $value) ?? '';
return trim($value, '-');
}
/**
* @param array<int,array<string,mixed>> $items
* @return array<int,array<string,mixed>>
*/
private function filterItems(array $items, string $q, string $category, string $subSlug): array
{
return array_values(array_filter($items, function (array $item) use ($q, $category, $subSlug): bool {
$itemCategory = trim((string) ($item['category'] ?? ''));
$itemSubcategory = trim((string) ($item['subcategory'] ?? ''));
if ($category !== '' && strcasecmp($itemCategory, $category) !== 0) {
return false;
}
if ($subSlug !== '' && $this->slugify($itemSubcategory) !== $subSlug) {
return false;
}
if ($q === '') {
return true;
}
$haystack = strtolower(implode(' ', [
(string) ($item['emoji'] ?? ''),
(string) ($item['name'] ?? ''),
(string) ($item['slug'] ?? ''),
$itemCategory,
$itemSubcategory,
implode(' ', $item['keywords_en'] ?? []),
implode(' ', $item['keywords_id'] ?? []),
implode(' ', $item['aliases'] ?? []),
implode(' ', $item['shortcodes'] ?? []),
implode(' ', $item['alt_shortcodes'] ?? []),
implode(' ', $item['intent_tags'] ?? []),
]));
$tokens = preg_split('/\s+/', strtolower($q)) ?: [];
foreach ($tokens as $token) {
if ($token === '') {
continue;
}
if (!str_contains($haystack, $token)) {
return false;
}
}
return true;
}));
}
/**
* @param array<string,mixed> $item
* @return array<string,mixed>
*/
private function transformItem(array $item): array
{
return [
'emoji' => (string) ($item['emoji'] ?? ''),
'name' => (string) ($item['name'] ?? ''),
'slug' => (string) ($item['slug'] ?? ''),
'category' => (string) ($item['category'] ?? ''),
'subcategory' => (string) ($item['subcategory'] ?? ''),
'supports_skin_tone' => (bool) ($item['supports_skin_tone'] ?? false),
'summary' => $this->summary((string) ($item['description'] ?? ''), 150),
];
}
private function summary(string $text, int $max): string
{
$text = trim(preg_replace('/\s+/', ' ', strip_tags($text)) ?? '');
if (mb_strlen($text) <= $max) {
return $text;
}
return rtrim(mb_substr($text, 0, $max - 1), " ,.;:-").'…';
}
}

View File

@@ -0,0 +1,83 @@
<?php
namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use App\Models\WebhookEvent;
use App\Services\Billing\PaypalWebhookProcessor;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class PaypalWebhookController extends Controller
{
public function __construct(private readonly PaypalWebhookProcessor $processor)
{
}
public function handle(Request $request): JsonResponse
{
$payload = $request->all();
$eventType = (string) ($payload['event_type'] ?? '');
$eventId = (string) ($payload['id'] ?? '');
$signatureOk = $this->verifySignature($request);
if ($eventId !== '' && WebhookEvent::where('provider', 'paypal')->where('event_id', $eventId)->exists()) {
return response()->json(['ok' => true, 'duplicate' => true]);
}
$event = WebhookEvent::create([
'provider' => 'paypal',
'event_id' => $eventId !== '' ? $eventId : null,
'event_type' => $eventType !== '' ? $eventType : null,
'status' => $signatureOk ? 'received' : 'error',
'payload' => $payload,
'headers' => $this->extractHeaders($request),
'received_at' => now(),
'error' => $signatureOk ? null : 'signature_unverified',
]);
if (!$signatureOk) {
return response()->json(['ok' => true, 'signature' => 'unverified']);
}
try {
$this->processor->process($eventType, $payload);
$event->update([
'status' => 'processed',
'processed_at' => now(),
]);
} catch (\Throwable $e) {
$event->update([
'status' => 'error',
'error' => $e->getMessage(),
'processed_at' => now(),
]);
}
return response()->json(['ok' => true]);
}
private function verifySignature(Request $request): bool
{
// TODO: Implement PayPal signature verification using transmission headers + webhook ID.
// For now this returns true to allow local testing.
return true;
}
/**
* @return array<string,string>
*/
private function extractHeaders(Request $request): array
{
$headers = [
'paypal-transmission-id' => (string) $request->header('PayPal-Transmission-Id', ''),
'paypal-transmission-sig' => (string) $request->header('PayPal-Transmission-Sig', ''),
'paypal-cert-url' => (string) $request->header('PayPal-Cert-Url', ''),
'paypal-auth-algo' => (string) $request->header('PayPal-Auth-Algo', ''),
'paypal-transmission-time' => (string) $request->header('PayPal-Transmission-Time', ''),
];
return array_filter($headers, fn (string $value): bool => $value !== '');
}
}

View File

@@ -0,0 +1,22 @@
<?php
namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use App\Models\PricingPlan;
use Illuminate\Http\JsonResponse;
class PricingController extends Controller
{
public function index(): JsonResponse
{
$plans = PricingPlan::where('status', 'active')
->orderBy('id')
->get(['code', 'name', 'currency', 'amount', 'period', 'meta']);
return response()->json([
'ok' => true,
'plans' => $plans,
]);
}
}

View File

@@ -0,0 +1,171 @@
<?php
namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use App\Models\User;
use App\Models\UserApiKey;
use App\Services\Auth\ApiKeyService;
use Carbon\Carbon;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
class UserController extends Controller
{
public function __construct(
private readonly ApiKeyService $keys
) {
}
public function register(Request $request): JsonResponse
{
$data = $request->validate([
'email' => 'required|email|max:255|unique:users,email',
'password' => 'required|min:8|max:255',
'name' => 'nullable|string|max:120',
]);
$name = $data['name'] ?? strtok($data['email'], '@');
$user = User::create([
'name' => $name ?: 'User',
'email' => $data['email'],
'password' => Hash::make($data['password']),
'tier' => 'free',
]);
$issued = null;
if ((string) $user->tier === 'personal') {
$issued = $this->keys->issueKey($user, 'default');
}
return response()->json([
'ok' => true,
'user' => [
'id' => $user->id,
'email' => $user->email,
'tier' => $user->tier,
],
'api_key' => $issued['plain'] ?? null,
]);
}
public function login(Request $request): JsonResponse
{
$data = $request->validate([
'email' => 'required|email|max:255',
'password' => 'required|string|max:255',
]);
/** @var User|null $user */
$user = User::where('email', $data['email'])->first();
if (!$user || !Hash::check($data['password'], $user->password)) {
return response()->json([
'ok' => false,
'error' => 'invalid_credentials',
], 401);
}
$issued = null;
if ((string) $user->tier === 'personal') {
$issued = $this->keys->issueKey($user, 'login');
}
return response()->json([
'ok' => true,
'user' => [
'id' => $user->id,
'email' => $user->email,
'tier' => $user->tier,
],
'api_key' => $issued['plain'] ?? null,
]);
}
public function logout(Request $request): JsonResponse
{
$key = $this->keys->parseKey($request);
if ($key === '') {
return response()->json(['ok' => true]);
}
$hash = $this->keys->hashKey($key);
UserApiKey::where('key_hash', $hash)->update([
'revoked_at' => Carbon::now(),
]);
return response()->json(['ok' => true]);
}
public function listApiKeys(Request $request): JsonResponse
{
$user = $this->keys->resolveUser($request);
if (!$user) {
return response()->json(['ok' => false, 'error' => 'unauthorized'], 401);
}
$items = UserApiKey::where('user_id', $user->id)
->orderByDesc('id')
->get()
->map(fn (UserApiKey $key) => [
'id' => $key->id,
'prefix' => $key->key_prefix,
'name' => $key->name,
'created_at' => $key->created_at,
'last_used_at' => $key->last_used_at,
'revoked_at' => $key->revoked_at,
]);
return response()->json([
'ok' => true,
'items' => $items,
]);
}
public function createApiKey(Request $request): JsonResponse
{
$user = $this->keys->resolveUser($request);
if (!$user) {
return response()->json(['ok' => false, 'error' => 'unauthorized'], 401);
}
if ((string) $user->tier !== 'personal') {
return response()->json(['ok' => false, 'error' => 'personal_required'], 403);
}
$data = $request->validate([
'name' => 'nullable|string|max:100',
]);
$issued = $this->keys->issueKey($user, $data['name'] ?? null);
return response()->json([
'ok' => true,
'api_key' => $issued['plain'],
'key' => [
'id' => $issued['record']->id,
'prefix' => $issued['record']->key_prefix,
'name' => $issued['record']->name,
'created_at' => $issued['record']->created_at,
],
]);
}
public function revokeApiKey(Request $request, string $key): JsonResponse
{
$user = $this->keys->resolveUser($request);
if (!$user) {
return response()->json(['ok' => false, 'error' => 'unauthorized'], 401);
}
$hash = $this->keys->hashKey($key);
$updated = UserApiKey::where('user_id', $user->id)
->where('key_hash', $hash)
->whereNull('revoked_at')
->update(['revoked_at' => Carbon::now()]);
return response()->json([
'ok' => true,
'revoked' => $updated > 0,
]);
}
}

View File

@@ -0,0 +1,210 @@
<?php
namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use App\Models\UserKeyword;
use App\Services\Auth\ApiKeyService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class UserKeywordController extends Controller
{
public function __construct(
private readonly ApiKeyService $keys
) {
}
private function ensureUser(Request $request): ?array
{
$user = $this->keys->resolveUser($request);
if (!$user) {
return ['error' => 'unauthorized', 'status' => 401];
}
return ['user' => $user];
}
public function index(Request $request): JsonResponse
{
$check = $this->ensureUser($request);
if (!isset($check['user'])) {
return response()->json(['ok' => false, 'error' => $check['error']], $check['status']);
}
$user = $check['user'];
$items = UserKeyword::where('user_id', $user->id)
->orderByDesc('id')
->get(['id', 'emoji_slug', 'keyword', 'lang', 'created_at', 'updated_at']);
return response()->json(['ok' => true, 'items' => $items]);
}
public function store(Request $request): JsonResponse
{
$check = $this->ensureUser($request);
if (!isset($check['user'])) {
return response()->json(['ok' => false, 'error' => $check['error']], $check['status']);
}
$data = $request->validate([
'emoji_slug' => 'required|string|max:120',
'keyword' => 'required|string|max:200',
'lang' => 'nullable|string|max:10',
]);
$lang = $data['lang'] ?? 'und';
$user = $check['user'];
$limit = $this->keywordLimitFor($user);
if ($limit !== null) {
$exists = UserKeyword::where('user_id', $user->id)
->where('emoji_slug', $data['emoji_slug'])
->where('keyword', $data['keyword'])
->exists();
if (!$exists) {
$count = UserKeyword::where('user_id', $user->id)->count();
if ($count >= $limit) {
return response()->json(['ok' => false, 'error' => 'free_limit_reached', 'limit' => $limit], 403);
}
}
}
$item = UserKeyword::updateOrCreate(
[
'user_id' => $user->id,
'emoji_slug' => $data['emoji_slug'],
'keyword' => $data['keyword'],
],
[
'lang' => $lang,
]
);
return response()->json(['ok' => true, 'item' => $item]);
}
public function destroy(Request $request, int $id): JsonResponse
{
$check = $this->ensureUser($request);
if (!isset($check['user'])) {
return response()->json(['ok' => false, 'error' => $check['error']], $check['status']);
}
$deleted = UserKeyword::where('user_id', $check['user']->id)
->where('id', $id)
->delete();
return response()->json(['ok' => true, 'deleted' => $deleted > 0]);
}
public function update(Request $request, int $id): JsonResponse
{
$check = $this->ensureUser($request);
if (!isset($check['user'])) {
return response()->json(['ok' => false, 'error' => $check['error']], $check['status']);
}
$data = $request->validate([
'emoji_slug' => 'required|string|max:120',
'keyword' => 'required|string|max:200',
'lang' => 'nullable|string|max:10',
]);
$item = UserKeyword::where('user_id', $check['user']->id)
->where('id', $id)
->first();
if (!$item) {
return response()->json(['ok' => false, 'error' => 'not_found'], 404);
}
$duplicate = UserKeyword::where('user_id', $check['user']->id)
->where('emoji_slug', $data['emoji_slug'])
->where('keyword', $data['keyword'])
->first();
if ($duplicate && $duplicate->id !== $item->id) {
$item->delete();
$item = $duplicate;
} else {
$item->update([
'emoji_slug' => $data['emoji_slug'],
'keyword' => $data['keyword'],
'lang' => $data['lang'] ?? 'und',
]);
}
return response()->json(['ok' => true, 'item' => $item]);
}
public function export(Request $request): JsonResponse
{
$check = $this->ensureUser($request);
if (!isset($check['user'])) {
return response()->json(['ok' => false, 'error' => $check['error']], $check['status']);
}
$items = UserKeyword::where('user_id', $check['user']->id)
->orderByDesc('id')
->get(['emoji_slug', 'keyword', 'lang']);
return response()->json(['ok' => true, 'items' => $items]);
}
public function import(Request $request): JsonResponse
{
$check = $this->ensureUser($request);
if (!isset($check['user'])) {
return response()->json(['ok' => false, 'error' => $check['error']], $check['status']);
}
$data = $request->validate([
'items' => 'required|array',
'items.*.emoji_slug' => 'required|string|max:120',
'items.*.keyword' => 'required|string|max:200',
'items.*.lang' => 'nullable|string|max:10',
]);
$count = 0;
$skipped = 0;
$user = $check['user'];
$limit = $this->keywordLimitFor($user);
$current = UserKeyword::where('user_id', $user->id)->count();
foreach ($data['items'] as $row) {
$exists = UserKeyword::where('user_id', $user->id)
->where('emoji_slug', $row['emoji_slug'])
->where('keyword', $row['keyword'])
->exists();
if (!$exists && $limit !== null && $current >= $limit) {
$skipped += 1;
continue;
}
UserKeyword::updateOrCreate(
[
'user_id' => $user->id,
'emoji_slug' => $row['emoji_slug'],
'keyword' => $row['keyword'],
],
[
'lang' => $row['lang'] ?? 'und',
]
);
if (!$exists) {
$current += 1;
}
$count += 1;
}
return response()->json(['ok' => true, 'imported' => $count, 'skipped' => $skipped]);
}
private function keywordLimitFor($user): ?int
{
if ((string) ($user->tier ?? 'free') === 'personal') {
return null;
}
return (int) config('dewemoji.pagination.free_max_limit', 20);
}
}

View File

@@ -0,0 +1,47 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Http\Requests\Auth\LoginRequest;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\View\View;
class AuthenticatedSessionController extends Controller
{
/**
* Display the login view.
*/
public function create(): View
{
return view('auth.login');
}
/**
* Handle an incoming authentication request.
*/
public function store(LoginRequest $request): RedirectResponse
{
$request->authenticate();
$request->session()->regenerate();
return redirect()->intended(route('dashboard.overview', absolute: false));
}
/**
* Destroy an authenticated session.
*/
public function destroy(Request $request): RedirectResponse
{
Auth::guard('web')->logout();
$request->session()->invalidate();
$request->session()->regenerateToken();
return redirect('/');
}
}

View File

@@ -0,0 +1,40 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Validation\ValidationException;
use Illuminate\View\View;
class ConfirmablePasswordController extends Controller
{
/**
* Show the confirm password view.
*/
public function show(): View
{
return view('auth.confirm-password');
}
/**
* Confirm the user's password.
*/
public function store(Request $request): RedirectResponse
{
if (! Auth::guard('web')->validate([
'email' => $request->user()->email,
'password' => $request->password,
])) {
throw ValidationException::withMessages([
'password' => __('auth.password'),
]);
}
$request->session()->put('auth.password_confirmed_at', time());
return redirect()->intended(route('dashboard.overview', absolute: false));
}
}

View File

@@ -0,0 +1,24 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
class EmailVerificationNotificationController extends Controller
{
/**
* Send a new email verification notification.
*/
public function store(Request $request): RedirectResponse
{
if ($request->user()->hasVerifiedEmail()) {
return redirect()->intended(route('dashboard.overview', absolute: false));
}
$request->user()->sendEmailVerificationNotification();
return back()->with('status', 'verification-link-sent');
}
}

View File

@@ -0,0 +1,21 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class EmailVerificationPromptController extends Controller
{
/**
* Display the email verification prompt.
*/
public function __invoke(Request $request): RedirectResponse|View
{
return $request->user()->hasVerifiedEmail()
? redirect()->intended(route('dashboard.overview', absolute: false))
: view('auth.verify-email');
}
}

View File

@@ -0,0 +1,62 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Models\User;
use Illuminate\Auth\Events\PasswordReset;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Password;
use Illuminate\Support\Str;
use Illuminate\Validation\Rules;
use Illuminate\View\View;
class NewPasswordController extends Controller
{
/**
* Display the password reset view.
*/
public function create(Request $request): View
{
return view('auth.reset-password', ['request' => $request]);
}
/**
* Handle an incoming new password request.
*
* @throws \Illuminate\Validation\ValidationException
*/
public function store(Request $request): RedirectResponse
{
$request->validate([
'token' => ['required'],
'email' => ['required', 'email'],
'password' => ['required', 'confirmed', Rules\Password::defaults()],
]);
// Here we will attempt to reset the user's password. If it is successful we
// will update the password on an actual user model and persist it to the
// database. Otherwise we will parse the error and return the response.
$status = Password::reset(
$request->only('email', 'password', 'password_confirmation', 'token'),
function (User $user) use ($request) {
$user->forceFill([
'password' => Hash::make($request->password),
'remember_token' => Str::random(60),
])->save();
event(new PasswordReset($user));
}
);
// If the password was successfully reset, we will redirect the user back to
// the application's home authenticated view. If there is an error we can
// redirect them back to where they came from with their error message.
return $status == Password::PASSWORD_RESET
? redirect()->route('login')->with('status', __($status))
: back()->withInput($request->only('email'))
->withErrors(['email' => __($status)]);
}
}

View File

@@ -0,0 +1,29 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\Rules\Password;
class PasswordController extends Controller
{
/**
* Update the user's password.
*/
public function update(Request $request): RedirectResponse
{
$validated = $request->validateWithBag('updatePassword', [
'current_password' => ['required', 'current_password'],
'password' => ['required', Password::defaults(), 'confirmed'],
]);
$request->user()->update([
'password' => Hash::make($validated['password']),
]);
return back()->with('status', 'password-updated');
}
}

View File

@@ -0,0 +1,44 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Password;
use Illuminate\View\View;
class PasswordResetLinkController extends Controller
{
/**
* Display the password reset link request view.
*/
public function create(): View
{
return view('auth.forgot-password');
}
/**
* Handle an incoming password reset link request.
*
* @throws \Illuminate\Validation\ValidationException
*/
public function store(Request $request): RedirectResponse
{
$request->validate([
'email' => ['required', 'email'],
]);
// We will send the password reset link to this user. Once we have attempted
// to send the link, we will examine the response then see the message we
// need to show to the user. Finally, we'll send out a proper response.
$status = Password::sendResetLink(
$request->only('email')
);
return $status == Password::RESET_LINK_SENT
? back()->with('status', __($status))
: back()->withInput($request->only('email'))
->withErrors(['email' => __($status)]);
}
}

View File

@@ -0,0 +1,54 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Models\User;
use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Auth\Events\Registered;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\Rules;
use Illuminate\View\View;
class RegisteredUserController extends Controller
{
/**
* Display the registration view.
*/
public function create(): View
{
return view('auth.register');
}
/**
* Handle an incoming registration request.
*
* @throws \Illuminate\Validation\ValidationException
*/
public function store(Request $request): RedirectResponse
{
$request->validate([
'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'string', 'lowercase', 'email', 'max:255', 'unique:'.User::class],
'password' => ['required', 'confirmed', Rules\Password::defaults()],
]);
$user = User::create([
'name' => $request->name,
'email' => $request->email,
'password' => Hash::make($request->password),
]);
event(new Registered($user));
if ($user instanceof MustVerifyEmail && ! $user->hasVerifiedEmail()) {
$user->sendEmailVerificationNotification();
}
Auth::login($user);
return redirect(route('dashboard.overview', absolute: false));
}
}

View File

@@ -0,0 +1,27 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use Illuminate\Auth\Events\Verified;
use Illuminate\Foundation\Auth\EmailVerificationRequest;
use Illuminate\Http\RedirectResponse;
class VerifyEmailController extends Controller
{
/**
* Mark the authenticated user's email address as verified.
*/
public function __invoke(EmailVerificationRequest $request): RedirectResponse
{
if ($request->user()->hasVerifiedEmail()) {
return redirect()->intended(route('dashboard.overview', absolute: false).'?verified=1');
}
if ($request->user()->markEmailAsVerified()) {
event(new Verified($request->user()));
}
return redirect()->intended(route('dashboard.overview', absolute: false).'?verified=1');
}
}

View File

@@ -0,0 +1,240 @@
<?php
namespace App\Http\Controllers\Billing;
use App\Http\Controllers\Controller;
use App\Models\Order;
use App\Models\Payment;
use App\Models\PricingPlan;
use App\Models\Subscription;
use App\Models\User;
use App\Models\WebhookEvent;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
class PakasirController extends Controller
{
public function createTransaction(Request $request): JsonResponse
{
$data = $request->validate([
'plan_code' => 'required|string|in:personal_monthly,personal_annual,personal_lifetime',
]);
$user = $request->user();
if (!$user) {
return response()->json(['error' => 'auth_required'], 401);
}
$config = config('dewemoji.billing.providers.pakasir', []);
$enabled = (bool) ($config['enabled'] ?? false);
$apiBase = rtrim((string) ($config['api_base'] ?? ''), '/');
$apiKey = (string) ($config['api_key'] ?? '');
$project = (string) ($config['project'] ?? '');
$timeout = (int) ($config['timeout'] ?? 10);
if (!$enabled || $apiBase === '' || $apiKey === '' || $project === '') {
return response()->json(['error' => 'pakasir_not_configured'], 422);
}
$amountIdr = $this->resolvePlanAmountIdr($data['plan_code']);
if ($amountIdr <= 0) {
return response()->json(['error' => 'invalid_plan_amount'], 422);
}
Subscription::where('user_id', $user->id)
->where('provider', 'pakasir')
->where('status', 'pending')
->update(['status' => 'cancelled']);
Order::where('user_id', $user->id)
->where('provider', 'pakasir')
->where('status', 'pending')
->update(['status' => 'cancelled']);
Payment::where('user_id', $user->id)
->where('provider', 'pakasir')
->where('status', 'pending')
->update(['status' => 'cancelled']);
$order = Order::create([
'user_id' => $user->id,
'plan_code' => $data['plan_code'],
'type' => $data['plan_code'] === 'personal_lifetime' ? 'one_time' : 'subscription',
'currency' => 'IDR',
'amount' => $amountIdr,
'status' => 'pending',
'provider' => 'pakasir',
]);
$orderRef = 'DW-'.$order->id.'-'.now()->format('ymdHis');
$payload = [
'project' => $project,
'order_id' => $orderRef,
'amount' => $amountIdr,
'api_key' => $apiKey,
'description' => 'Dewemoji '.$data['plan_code'].' for '.$user->email,
];
$endpoint = $apiBase.'/api/transactioncreate/qris';
$res = Http::timeout($timeout)->post($endpoint, $payload);
if (!$res->ok()) {
Log::warning('Pakasir create transaction failed', ['body' => $res->body()]);
return response()->json(['error' => 'pakasir_create_failed'], 502);
}
$body = $res->json();
$payment = is_array($body['payment'] ?? null) ? $body['payment'] : $body;
$paymentNumber = (string) ($payment['payment_number'] ?? '');
$status = (string) ($payment['status'] ?? 'pending');
$expiredAt = (string) ($payment['expired_at'] ?? '');
$totalPayment = (int) ($payment['total_payment'] ?? $amountIdr);
$order->update([
'provider_ref' => $orderRef,
'status' => $status === 'success' ? 'pending' : 'pending',
]);
Payment::create([
'user_id' => $user->id,
'order_id' => $order->id,
'provider' => 'pakasir',
'type' => $data['plan_code'] === 'personal_lifetime' ? 'one_time' : 'subscription',
'plan_code' => $data['plan_code'],
'currency' => 'IDR',
'amount' => $amountIdr,
'status' => 'pending',
'provider_ref' => $paymentNumber !== '' ? $paymentNumber : $orderRef,
'raw_payload' => $body,
]);
return response()->json([
'ok' => true,
'order_id' => $orderRef,
'payment_number' => $paymentNumber,
'amount' => $amountIdr,
'total_payment' => $totalPayment,
'expired_at' => $expiredAt,
'status' => $status,
]);
}
public function cancelPending(Request $request): JsonResponse
{
$user = $request->user();
if (!$user) {
return response()->json(['error' => 'auth_required'], 401);
}
$orderRef = (string) $request->input('order_id', '');
$orderQuery = Order::where('user_id', $user->id)
->where('provider', 'pakasir')
->where('status', 'pending');
if ($orderRef !== '') {
$orderQuery->where('provider_ref', $orderRef);
}
$order = $orderQuery->orderByDesc('id')->first();
if (!$order) {
return response()->json(['ok' => true, 'cancelled' => false]);
}
$order->update(['status' => 'cancelled']);
Payment::where('order_id', $order->id)->where('status', 'pending')->update(['status' => 'cancelled']);
Subscription::where('user_id', $user->id)
->where('provider', 'pakasir')
->where('status', 'pending')
->update(['status' => 'cancelled']);
return response()->json(['ok' => true, 'cancelled' => true]);
}
public function webhook(Request $request): JsonResponse
{
$payload = $request->all();
$eventId = (string) ($payload['id'] ?? $payload['event_id'] ?? Str::uuid());
$eventType = (string) ($payload['event_type'] ?? $payload['status'] ?? 'event');
WebhookEvent::create([
'provider' => 'pakasir',
'event_id' => $eventId,
'event_type' => $eventType,
'status' => 'received',
'payload' => $payload,
'headers' => $request->headers->all(),
'received_at' => now(),
]);
$this->handlePakasirPayload($payload);
return response()->json(['ok' => true]);
}
private function handlePakasirPayload(array $payload): void
{
$status = strtolower((string) ($payload['status'] ?? ''));
if (!in_array($status, ['paid', 'success', 'settlement', 'completed'], true)) {
return;
}
$orderId = (string) ($payload['order_id'] ?? '');
if ($orderId === '') {
return;
}
$order = Order::where('provider', 'pakasir')->where('provider_ref', $orderId)->first();
if (!$order) {
return;
}
$order->update(['status' => 'paid']);
Payment::where('order_id', $order->id)->update(['status' => 'paid']);
$user = User::find($order->user_id);
if (!$user) {
return;
}
$expiresAt = null;
if ($order->plan_code === 'personal_monthly') {
$expiresAt = now()->addMonth();
} elseif ($order->plan_code === 'personal_annual') {
$expiresAt = now()->addYear();
}
Subscription::updateOrCreate(
[
'provider' => 'pakasir',
'provider_ref' => $orderId,
],
[
'user_id' => $user->id,
'plan' => $order->plan_code,
'status' => 'active',
'started_at' => now(),
'expires_at' => $expiresAt,
]
);
User::where('id', $user->id)->update(['tier' => 'personal']);
}
private function resolvePlanAmountIdr(string $planCode): int
{
$plan = PricingPlan::where('code', $planCode)->where('status', 'active')->first();
if ($plan) {
return (int) $plan->amount;
}
$defaults = collect(config('dewemoji.pricing.defaults', []))->keyBy('code');
$fallback = $defaults->get($planCode);
if (!$fallback) {
return 0;
}
return (int) ($fallback['amount'] ?? 0);
}
}

View File

@@ -0,0 +1,336 @@
<?php
namespace App\Http\Controllers\Billing;
use App\Http\Controllers\Controller;
use App\Models\Order;
use App\Models\Payment;
use App\Models\PricingPlan;
use App\Models\Subscription;
use App\Models\User;
use App\Models\WebhookEvent;
use App\Services\System\SettingsService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Str;
class PayPalController extends Controller
{
public function createSubscription(Request $request): RedirectResponse|JsonResponse
{
$data = $request->validate([
'plan_code' => 'required|string|in:personal_monthly,personal_annual',
]);
$user = $request->user();
if (!$user) {
return response()->json(['error' => 'auth_required'], 401);
}
$mode = $this->billingMode();
if (!$this->paypalConfigured($mode)) {
return response()->json(['error' => 'paypal_not_configured'], 422);
}
$planId = $this->resolvePlanId($data['plan_code'], $mode);
if (!$planId) {
return response()->json(['error' => 'paypal_plan_missing'], 422);
}
$token = $this->getAccessToken($mode);
if (!$token) {
return response()->json(['error' => 'paypal_auth_failed'], 502);
}
$appUrl = rtrim(config('app.url'), '/');
$payload = [
'plan_id' => $planId,
'subscriber' => [
'name' => [
'given_name' => $user->name ?? 'User',
'surname' => 'Dewemoji',
],
'email_address' => $user->email,
],
'application_context' => [
'brand_name' => 'Dewemoji',
'locale' => 'en-US',
'user_action' => 'SUBSCRIBE_NOW',
'return_url' => $appUrl.'/billing/paypal/return?status=success',
'cancel_url' => $appUrl.'/billing/paypal/return?status=cancel',
],
];
$apiBase = config("dewemoji.billing.providers.paypal.{$mode}.api_base");
$res = Http::withToken($token)
->timeout((int) config('dewemoji.billing.providers.paypal.timeout', 10))
->post(rtrim($apiBase, '/').'/v1/billing/subscriptions', $payload);
$body = $res->json();
$subscriptionId = $body['id'] ?? null;
$approveUrl = collect($body['links'] ?? [])->firstWhere('rel', 'approve')['href'] ?? null;
if (!$subscriptionId || !$approveUrl) {
if (!$res->ok()) {
Log::warning('PayPal create subscription failed', [
'status' => $res->status(),
'body' => $res->body(),
]);
} else {
Log::warning('PayPal create subscription missing approve link', [
'status' => $res->status(),
'body' => $res->body(),
]);
}
return response()->json(['error' => 'paypal_invalid_response'], 502);
}
$amountUsd = $this->resolvePlanAmountUsd($data['plan_code']);
Subscription::where('user_id', $user->id)
->where('provider', 'paypal')
->where('status', 'pending')
->update(['status' => 'cancelled']);
Order::where('user_id', $user->id)
->where('provider', 'paypal')
->where('status', 'pending')
->update(['status' => 'cancelled']);
Payment::where('user_id', $user->id)
->where('provider', 'paypal')
->where('status', 'pending')
->update(['status' => 'cancelled']);
$order = Order::create([
'user_id' => $user->id,
'plan_code' => $data['plan_code'],
'type' => 'subscription',
'currency' => 'USD',
'amount' => $amountUsd,
'status' => 'pending',
'provider' => 'paypal',
'provider_ref' => $subscriptionId,
]);
Payment::create([
'user_id' => $user->id,
'order_id' => $order->id,
'provider' => 'paypal',
'type' => 'subscription',
'plan_code' => $data['plan_code'],
'currency' => 'USD',
'amount' => $amountUsd,
'status' => 'pending',
'provider_ref' => $subscriptionId,
'raw_payload' => $body,
]);
Subscription::firstOrCreate([
'provider' => 'paypal',
'provider_ref' => $subscriptionId,
], [
'user_id' => $user->id,
'plan' => $data['plan_code'],
'status' => 'pending',
'started_at' => now(),
]);
return response()->json(['approve_url' => $approveUrl]);
}
public function return(Request $request): RedirectResponse
{
$status = (string) $request->query('status', 'success');
return redirect()->route('dashboard.billing', ['status' => $status]);
}
public function webhook(Request $request): JsonResponse
{
$payload = $request->all();
$eventId = (string) ($payload['id'] ?? '');
$eventType = (string) ($payload['event_type'] ?? '');
$mode = config('dewemoji.billing.mode', 'sandbox');
$webhookId = config("dewemoji.billing.providers.paypal.webhook_ids.{$mode}");
if ($webhookId) {
$verified = $this->verifySignature($mode, $webhookId, $payload, $request);
if (!$verified) {
return response()->json(['error' => 'invalid_signature'], 401);
}
}
$event = WebhookEvent::create([
'provider' => 'paypal',
'event_id' => $eventId ?: (string) Str::uuid(),
'event_type' => $eventType,
'status' => 'received',
'payload' => $payload,
'headers' => $request->headers->all(),
'received_at' => now(),
]);
try {
$processed = $this->processPayPalEvent($payload);
$event->update([
'status' => $processed ? 'processed' : 'received',
'processed_at' => $processed ? now() : null,
]);
} catch (\Throwable $e) {
$event->update([
'status' => 'error',
'error' => $e->getMessage(),
'processed_at' => now(),
]);
}
return response()->json(['ok' => true]);
}
private function processPayPalEvent(array $payload): bool
{
$type = (string) ($payload['event_type'] ?? '');
$resource = $payload['resource'] ?? [];
$subscriptionId = (string) ($resource['id'] ?? $resource['subscription_id'] ?? '');
if ($subscriptionId === '') {
return false;
}
if ($type === 'BILLING.SUBSCRIPTION.ACTIVATED') {
$sub = Subscription::firstOrNew([
'provider' => 'paypal',
'provider_ref' => $subscriptionId,
]);
if (!$sub->user_id) {
$order = Order::where('provider', 'paypal')->where('provider_ref', $subscriptionId)->first();
if ($order) {
$sub->user_id = $order->user_id;
}
}
$sub->status = 'active';
$sub->started_at = $sub->started_at ?? now();
$sub->next_renewal_at = $resource['billing_info']['next_billing_time'] ?? null;
$sub->save();
if ($sub->user_id) {
User::where('id', $sub->user_id)->update(['tier' => 'personal']);
}
Order::where('provider', 'paypal')->where('provider_ref', $subscriptionId)->update(['status' => 'paid']);
Payment::where('provider', 'paypal')->where('provider_ref', $subscriptionId)->update(['status' => 'paid']);
return true;
}
if (in_array($type, ['BILLING.SUBSCRIPTION.CANCELLED', 'BILLING.SUBSCRIPTION.SUSPENDED'], true)) {
$sub = Subscription::where('provider', 'paypal')->where('provider_ref', $subscriptionId)->first();
if ($sub) {
$sub->status = 'canceled';
$sub->canceled_at = now();
$sub->save();
}
return true;
}
return false;
}
private function resolvePlanId(string $planCode, string $mode): ?string
{
$plan = PricingPlan::where('code', $planCode)->first();
if ($plan) {
$meta = $plan->meta ?? [];
$stored = $meta['paypal'][$mode]['plan']['id'] ?? null;
if ($stored) {
return $stored;
}
}
return config("dewemoji.billing.providers.paypal.plan_ids.{$mode}.{$planCode}") ?: null;
}
private function resolvePlanAmountUsd(string $planCode): int
{
$plan = PricingPlan::where('code', $planCode)->first();
if (!$plan) {
return 0;
}
$rate = (int) config('dewemoji.pricing.usd_rate', 15000);
if ($rate <= 0) {
return 0;
}
return (int) round($plan->amount / $rate);
}
private function getAccessToken(string $mode): ?string
{
$clientId = config("dewemoji.billing.providers.paypal.{$mode}.client_id");
$clientSecret = config("dewemoji.billing.providers.paypal.{$mode}.client_secret");
$apiBase = config("dewemoji.billing.providers.paypal.{$mode}.api_base");
if (!$clientId || !$clientSecret || !$apiBase) {
return null;
}
$res = Http::asForm()
->withBasicAuth($clientId, $clientSecret)
->timeout((int) config('dewemoji.billing.providers.paypal.timeout', 10))
->post(rtrim($apiBase, '/').'/v1/oauth2/token', [
'grant_type' => 'client_credentials',
]);
if (!$res->ok()) {
return null;
}
return $res->json('access_token');
}
private function paypalConfigured(string $mode): bool
{
$enabled = (bool) config('dewemoji.billing.providers.paypal.enabled', false);
$clientId = config("dewemoji.billing.providers.paypal.{$mode}.client_id");
$clientSecret = config("dewemoji.billing.providers.paypal.{$mode}.client_secret");
$apiBase = config("dewemoji.billing.providers.paypal.{$mode}.api_base");
return $enabled && $clientId && $clientSecret && $apiBase;
}
private function billingMode(): string
{
$settings = app(SettingsService::class);
return (string) ($settings->get('billing_mode', config('dewemoji.billing.mode', 'sandbox')) ?: 'sandbox');
}
private function verifySignature(string $mode, string $webhookId, array $payload, Request $request): bool
{
$token = $this->getAccessToken($mode);
if (!$token) {
return false;
}
$apiBase = config("dewemoji.billing.providers.paypal.{$mode}.api_base");
$verifyPayload = [
'auth_algo' => $request->header('paypal-auth-algo'),
'cert_url' => $request->header('paypal-cert-url'),
'transmission_id' => $request->header('paypal-transmission-id'),
'transmission_sig' => $request->header('paypal-transmission-sig'),
'transmission_time' => $request->header('paypal-transmission-time'),
'webhook_id' => $webhookId,
'webhook_event' => $payload,
];
if (collect($verifyPayload)->contains(fn ($v) => empty($v))) {
return false;
}
$res = Http::withToken($token)
->timeout((int) config('dewemoji.billing.providers.paypal.timeout', 10))
->post(rtrim($apiBase, '/').'/v1/notifications/verify-webhook-signature', $verifyPayload);
return $res->ok() && $res->json('verification_status') === 'SUCCESS';
}
}

View File

@@ -0,0 +1,688 @@
<?php
namespace App\Http\Controllers\Dashboard;
use App\Http\Controllers\Controller;
use App\Models\PricingChange;
use App\Models\PricingPlan;
use App\Models\Order;
use App\Models\Payment;
use App\Models\Subscription;
use App\Models\User;
use App\Models\UserApiKey;
use App\Models\UserKeyword;
use App\Models\WebhookEvent;
use App\Models\AdminAuditLog;
use App\Services\Billing\PayPalPlanSyncService;
use App\Services\System\SettingsService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Schema;
use Illuminate\View\View;
use Symfony\Component\HttpFoundation\StreamedResponse;
class AdminDashboardController extends Controller
{
public function __construct(private readonly SettingsService $settings)
{
}
public function users(Request $request): View
{
$q = trim((string) $request->query('q', ''));
$tier = trim((string) $request->query('tier', ''));
$role = trim((string) $request->query('role', ''));
$sort = $this->sanitizeSort($request->query('sort'), ['id', 'name', 'email', 'role', 'tier', 'created_at'], 'id');
$dir = $this->sanitizeDir($request->query('dir'));
$query = User::query();
if ($q !== '') {
$query->where(function ($sub) use ($q): void {
$sub->where('email', 'like', '%'.$q.'%')
->orWhere('name', 'like', '%'.$q.'%');
});
}
if ($tier !== '') {
$query->where('tier', $tier);
}
if ($role !== '') {
$query->where('role', $role);
}
$users = $query->orderBy($sort, $dir)->paginate(20)->withQueryString();
return view('dashboard.admin.users', [
'users' => $users,
'filters' => ['q' => $q, 'tier' => $tier, 'role' => $role],
'sort' => $sort,
'dir' => $dir,
]);
}
public function userDetail(User $user): View
{
$subscriptions = Subscription::where('user_id', $user->id)->orderByDesc('id')->get();
return view('dashboard.admin.user-show', [
'user' => $user,
'subscriptions' => $subscriptions,
]);
}
public function updateUserTier(Request $request): RedirectResponse
{
$data = $request->validate([
'user_id' => 'required|integer',
'tier' => 'required|string|in:free,personal',
]);
User::where('id', $data['user_id'])->update(['tier' => $data['tier']]);
if ($data['tier'] === 'free') {
UserApiKey::where('user_id', $data['user_id'])->update(['revoked_at' => now()]);
}
$this->logAdminAction('user_tier_update', $data);
return back()->with('status', 'User tier updated.');
}
public function createUser(Request $request): RedirectResponse
{
$data = $request->validate([
'name' => 'nullable|string|max:120',
'email' => 'required|email|max:255|unique:users,email',
'password' => 'required|string|min:8|max:255',
'role' => 'required|string|in:admin,user',
'tier' => 'required|string|in:free,personal',
]);
$name = $data['name'] ?: strtok($data['email'], '@');
$user = User::create([
'name' => $name ?: 'User',
'email' => $data['email'],
'password' => Hash::make($data['password']),
'role' => $data['role'],
'tier' => $data['tier'],
]);
$this->logAdminAction('user_created', [
'user_id' => $user->id,
'email' => $user->email,
'role' => $user->role,
'tier' => $user->tier,
]);
return back()->with('status', 'User created.');
}
public function deleteUser(Request $request, User $user): RedirectResponse
{
$admin = $request->user();
if ($admin && $admin->id === $user->id) {
return back()->withErrors(['user' => 'You cannot delete your own account.']);
}
if (($user->role ?? 'user') === 'admin' && User::where('role', 'admin')->count() <= 1) {
return back()->withErrors(['user' => 'Cannot delete the last admin account.']);
}
DB::transaction(function () use ($user): void {
Payment::where('user_id', $user->id)->delete();
Order::where('user_id', $user->id)->delete();
Subscription::where('user_id', $user->id)->delete();
UserApiKey::where('user_id', $user->id)->delete();
UserKeyword::where('user_id', $user->id)->delete();
DB::table('password_reset_tokens')->where('email', $user->email)->delete();
$user->delete();
});
$this->logAdminAction('user_deleted', [
'user_id' => $user->id,
'email' => $user->email,
]);
return back()->with('status', 'User deleted.');
}
public function subscriptions(Request $request): View
{
$query = Subscription::query()->with('user:id,email,name,tier');
$sort = $this->sanitizeSort($request->query('sort'), ['id', 'plan', 'status', 'started_at', 'expires_at', 'created_at'], 'id');
$dir = $this->sanitizeDir($request->query('dir'));
if ($userId = $request->query('user_id')) {
$query->where('user_id', (int) $userId);
}
if ($email = $request->query('email')) {
$query->whereHas('user', fn ($q) => $q->where('email', $email));
}
if ($status = $request->query('status')) {
$query->where('status', (string) $status);
}
$subscriptions = $query->orderBy($sort, $dir)->paginate(20)->withQueryString();
return view('dashboard.admin.subscriptions', [
'subscriptions' => $subscriptions,
'filters' => [
'user_id' => $request->query('user_id'),
'email' => $request->query('email'),
'status' => $request->query('status'),
],
'sort' => $sort,
'dir' => $dir,
]);
}
public function subscriptionDetail(Subscription $subscription): View
{
$subscription->loadMissing('user:id,name,email,tier,role');
return view('dashboard.admin.subscription-show', [
'subscription' => $subscription,
]);
}
public function grantSubscription(Request $request): RedirectResponse
{
$data = $request->validate([
'user_id' => 'nullable|integer',
'email' => 'nullable|email|max:255',
'plan' => 'required|string|max:20',
'status' => 'required|string|in:active,pending,revoked',
'provider' => 'nullable|string|max:20',
'provider_ref' => 'nullable|string|max:100',
'started_at' => 'nullable|date',
'expires_at' => 'nullable|date',
]);
$user = $this->resolveUser($data['user_id'] ?? null, $data['email'] ?? null);
if (!$user) {
return back()->withErrors(['user' => 'User not found.']);
}
$startedAt = $data['started_at'] ? Carbon::parse($data['started_at']) : now();
$expiresAt = $data['expires_at'] ? Carbon::parse($data['expires_at']) : null;
Subscription::create([
'user_id' => $user->id,
'plan' => $data['plan'],
'status' => $data['status'],
'provider' => $data['provider'] ?? 'admin',
'provider_ref' => $data['provider_ref'] ?? null,
'started_at' => $startedAt,
'expires_at' => $expiresAt,
]);
if ($data['status'] === 'active') {
$user->update(['tier' => 'personal']);
}
$this->logAdminAction('subscription_grant', [
'user_id' => $user->id,
'email' => $user->email,
'plan' => $data['plan'],
'status' => $data['status'],
]);
return back()->with('status', 'Subscription granted.');
}
public function revokeSubscription(Request $request): RedirectResponse
{
$data = $request->validate([
'subscription_id' => 'nullable|integer',
'user_id' => 'nullable|integer',
'email' => 'nullable|email|max:255',
]);
if (!empty($data['subscription_id'])) {
$sub = Subscription::find($data['subscription_id']);
if (!$sub) {
return back()->withErrors(['subscription' => 'Subscription not found.']);
}
$sub->update(['status' => 'revoked', 'expires_at' => now()]);
$this->syncUserTier($sub->user_id);
$this->logAdminAction('subscription_revoke', [
'subscription_id' => $sub->id,
'user_id' => $sub->user_id,
]);
return back()->with('status', 'Subscription revoked.');
}
$user = $this->resolveUser($data['user_id'] ?? null, $data['email'] ?? null);
if (!$user) {
return back()->withErrors(['user' => 'User not found.']);
}
Subscription::where('user_id', $user->id)
->where('status', 'active')
->update(['status' => 'revoked', 'expires_at' => now()]);
$this->syncUserTier($user->id);
$this->logAdminAction('subscription_revoke_all', [
'user_id' => $user->id,
'email' => $user->email,
]);
return back()->with('status', 'User subscriptions revoked.');
}
public function pricing(): View
{
$plans = PricingPlan::orderBy('id')->get();
$changes = PricingChange::orderByDesc('id')->limit(5)->get();
return view('dashboard.admin.pricing', [
'plans' => $plans,
'changes' => $changes,
]);
}
public function updatePricing(Request $request): RedirectResponse
{
$data = $request->validate([
'plans' => 'required|array|min:1',
'plans.*.code' => 'required|string|max:30',
'plans.*.name' => 'required|string|max:50',
'plans.*.amount_idr' => 'required|integer|min:0',
'plans.*.amount_usd' => 'nullable|numeric|min:0',
'plans.*.period' => 'nullable|string|max:20',
'plans.*.status' => 'nullable|string|max:20',
]);
$before = PricingPlan::orderBy('id')->get()->toArray();
DB::transaction(function () use ($data): void {
foreach ($data['plans'] as $plan) {
$meta = [
'prices' => [
'IDR' => (int) $plan['amount_idr'],
'USD' => isset($plan['amount_usd']) ? (float) $plan['amount_usd'] : null,
],
];
PricingPlan::updateOrCreate(
['code' => $plan['code']],
[
'name' => $plan['name'],
'currency' => 'IDR',
'amount' => (int) $plan['amount_idr'],
'period' => $plan['period'] ?? null,
'status' => $plan['status'] ?? 'active',
'meta' => $meta,
]
);
}
});
$after = PricingPlan::orderBy('id')->get()->toArray();
PricingChange::create([
'admin_ref' => (string) auth()->user()?->email,
'before' => $before,
'after' => $after,
]);
$this->logAdminAction('pricing_update', ['plans' => count($data['plans'])]);
return back()->with('status', 'Pricing updated.');
}
public function syncPaypalPlans(PayPalPlanSyncService $sync): RedirectResponse
{
$resultSandbox = $sync->sync('sandbox');
$resultLive = $sync->sync('live');
$this->logAdminAction('paypal_plan_sync', [
'sandbox' => $resultSandbox,
'live' => $resultLive,
]);
return back()->with('status', 'PayPal plans synced (sandbox + live).');
}
public function createPricingSnapshot(): RedirectResponse
{
$before = PricingPlan::orderBy('id')->get()->toArray();
PricingChange::create([
'admin_ref' => (string) auth()->user()?->email,
'before' => $before,
'after' => $before,
]);
$this->logAdminAction('pricing_snapshot', ['plans' => count($before)]);
return back()->with('status', 'Pricing snapshot created.');
}
public function resetPricing(): RedirectResponse
{
$defaults = config('dewemoji.pricing.defaults', []);
$before = PricingPlan::orderBy('id')->get()->toArray();
DB::transaction(function () use ($defaults): void {
PricingPlan::query()->delete();
foreach ($defaults as $plan) {
PricingPlan::create([
'code' => $plan['code'],
'name' => $plan['name'],
'currency' => $plan['currency'],
'amount' => $plan['amount'],
'period' => $plan['period'],
'status' => $plan['status'],
'meta' => $plan['meta'] ?? null,
]);
}
});
$after = PricingPlan::orderBy('id')->get()->toArray();
PricingChange::create([
'admin_ref' => (string) auth()->user()?->email,
'before' => $before,
'after' => $after,
]);
$this->logAdminAction('pricing_reset', ['plans' => count($after)]);
return back()->with('status', 'Pricing reset to defaults.');
}
public function webhooks(Request $request): View
{
$query = WebhookEvent::query();
$sort = $this->sanitizeSort($request->query('sort'), ['id', 'provider', 'status', 'received_at', 'created_at'], 'id');
$dir = $this->sanitizeDir($request->query('dir'));
if ($provider = $request->query('provider')) {
$query->where('provider', (string) $provider);
}
if ($status = $request->query('status')) {
$query->where('status', (string) $status);
}
$events = $query->orderBy($sort, $dir)->paginate(20)->withQueryString();
$providers = WebhookEvent::query()->distinct('provider')->orderBy('provider')->pluck('provider');
return view('dashboard.admin.webhooks', [
'events' => $events,
'filters' => [
'provider' => $request->query('provider'),
'status' => $request->query('status'),
],
'providers' => $providers,
'sort' => $sort,
'dir' => $dir,
]);
}
public function webhookDetail(WebhookEvent $event): View
{
return view('dashboard.admin.webhook-show', [
'event' => $event,
]);
}
public function replayWebhook(int $id): RedirectResponse
{
$event = WebhookEvent::find($id);
if (!$event) {
return back()->withErrors(['webhook' => 'Webhook not found.']);
}
$event->update(['status' => 'pending', 'processed_at' => null, 'error' => null]);
$this->logAdminAction('webhook_replay', ['event_id' => $event->id]);
return back()->with('status', 'Webhook queued for replay.');
}
public function replayFailedWebhooks(): RedirectResponse
{
$count = WebhookEvent::where('status', 'error')->count();
WebhookEvent::where('status', 'error')->update([
'status' => 'pending',
'processed_at' => null,
'error' => null,
]);
$this->logAdminAction('webhook_replay_failed', ['count' => $count]);
return back()->with('status', 'Failed webhooks queued for replay.');
}
public function settings(): View
{
$all = $this->settings->all();
return view('dashboard.admin.settings', [
'settings' => $all,
]);
}
public function auditLogs(Request $request): View
{
$q = trim((string) $request->query('q', ''));
$action = trim((string) $request->query('action', ''));
$query = AdminAuditLog::query()->orderByDesc('id');
if ($q !== '') {
$query->where(function ($sub) use ($q): void {
$sub->where('admin_email', 'like', '%'.$q.'%')
->orWhere('action', 'like', '%'.$q.'%');
});
}
if ($action !== '') {
$query->where('action', $action);
}
$actions = AdminAuditLog::query()->distinct('action')->orderBy('action')->pluck('action');
$logs = $query->paginate(25)->withQueryString();
return view('dashboard.admin.audit-logs', [
'logs' => $logs,
'filters' => ['q' => $q, 'action' => $action],
'actions' => $actions,
]);
}
public function updateSettings(Request $request): RedirectResponse
{
$data = $request->validate([
'maintenance_enabled' => 'nullable|boolean',
'public_enforce' => 'nullable|boolean',
'public_origins' => 'nullable|string',
'public_extension_ids' => 'nullable|string',
'public_hourly_limit' => 'nullable|integer|min:0',
'billing_mode' => 'nullable|string|in:sandbox,live',
]);
$payload = [
'maintenance_enabled' => (bool) ($data['maintenance_enabled'] ?? false),
'public_enforce' => (bool) ($data['public_enforce'] ?? false),
'public_origins' => $this->splitCsv($data['public_origins'] ?? ''),
'public_extension_ids' => $this->splitCsv($data['public_extension_ids'] ?? ''),
'public_hourly_limit' => (int) ($data['public_hourly_limit'] ?? 0),
'billing_mode' => $data['billing_mode'] ?? null,
];
$this->settings->setMany($payload, (string) auth()->user()?->email);
$this->logAdminAction('settings_update', $payload);
return back()->with('status', 'Settings updated.');
}
private function resolveUser(?int $userId, ?string $email): ?User
{
if ($userId) {
return User::find($userId);
}
if ($email) {
return User::where('email', $email)->first();
}
return null;
}
private function syncUserTier(int $userId): void
{
$active = Subscription::where('user_id', $userId)
->where('status', 'active')
->where(function ($q): void {
$q->whereNull('expires_at')
->orWhere('expires_at', '>', now());
})
->exists();
User::where('id', $userId)->update([
'tier' => $active ? 'personal' : 'free',
]);
if (!$active) {
UserApiKey::where('user_id', $userId)->update(['revoked_at' => now()]);
}
}
public function exportCsv(Request $request, string $type): StreamedResponse
{
$type = strtolower($type);
$filename = "dewemoji-{$type}-export-".now()->format('Ymd_His').".csv";
return response()->streamDownload(function () use ($type, $request): void {
$out = fopen('php://output', 'w');
if ($type === 'users') {
$q = trim((string) $request->query('q', ''));
$tier = trim((string) $request->query('tier', ''));
$role = trim((string) $request->query('role', ''));
$sort = $this->sanitizeSort($request->query('sort'), ['id', 'name', 'email', 'role', 'tier', 'created_at'], 'id');
$dir = $this->sanitizeDir($request->query('dir'));
$query = User::query();
if ($q !== '') {
$query->where(function ($sub) use ($q): void {
$sub->where('email', 'like', '%'.$q.'%')
->orWhere('name', 'like', '%'.$q.'%');
});
}
if ($tier !== '') {
$query->where('tier', $tier);
}
if ($role !== '') {
$query->where('role', $role);
}
fputcsv($out, ['id', 'name', 'email', 'role', 'tier', 'created_at']);
$query->orderBy($sort, $dir)->chunk(500, function ($rows) use ($out): void {
foreach ($rows as $row) {
fputcsv($out, [
$row->id,
$row->name,
$row->email,
$row->role,
$row->tier,
optional($row->created_at)->toDateTimeString(),
]);
}
});
} elseif ($type === 'subscriptions') {
$userId = $request->query('user_id');
$email = $request->query('email');
$status = $request->query('status');
$sort = $this->sanitizeSort($request->query('sort'), ['id', 'plan', 'status', 'started_at', 'expires_at', 'created_at'], 'id');
$dir = $this->sanitizeDir($request->query('dir'));
$query = Subscription::query()->with('user:id,email');
if ($userId) {
$query->where('user_id', (int) $userId);
}
if ($email) {
$query->whereHas('user', fn ($q) => $q->where('email', $email));
}
if ($status) {
$query->where('status', (string) $status);
}
fputcsv($out, ['id', 'user_id', 'email', 'plan', 'status', 'started_at', 'expires_at']);
$query->orderBy($sort, $dir)->chunk(500, function ($rows) use ($out): void {
foreach ($rows as $row) {
fputcsv($out, [
$row->id,
$row->user_id,
$row->user?->email,
$row->plan,
$row->status,
optional($row->started_at)->toDateTimeString(),
optional($row->expires_at)->toDateTimeString(),
]);
}
});
} elseif ($type === 'webhooks') {
$provider = $request->query('provider');
$status = $request->query('status');
$sort = $this->sanitizeSort($request->query('sort'), ['id', 'provider', 'status', 'received_at', 'created_at'], 'id');
$dir = $this->sanitizeDir($request->query('dir'));
$query = WebhookEvent::query();
if ($provider) {
$query->where('provider', (string) $provider);
}
if ($status) {
$query->where('status', (string) $status);
}
fputcsv($out, ['id', 'provider', 'event_type', 'status', 'received_at', 'processed_at']);
$query->orderBy($sort, $dir)->chunk(500, function ($rows) use ($out): void {
foreach ($rows as $row) {
fputcsv($out, [
$row->id,
$row->provider,
$row->event_type,
$row->status,
optional($row->received_at)->toDateTimeString(),
optional($row->processed_at)->toDateTimeString(),
]);
}
});
} else {
fputcsv($out, ['error']);
fputcsv($out, ['unsupported_export_type']);
}
fclose($out);
}, $filename, [
'Content-Type' => 'text/csv; charset=UTF-8',
]);
}
private function logAdminAction(string $action, array $payload = []): void
{
if (!Schema::hasTable('admin_audit_logs')) {
return;
}
$user = auth()->user();
AdminAuditLog::create([
'admin_id' => $user?->id,
'admin_email' => $user?->email,
'action' => $action,
'payload' => $payload,
'ip_address' => request()->ip(),
]);
}
private function sanitizeSort(mixed $value, array $allowed, string $fallback): string
{
$sort = is_string($value) ? $value : '';
return in_array($sort, $allowed, true) ? $sort : $fallback;
}
private function sanitizeDir(mixed $value): string
{
return $value === 'asc' ? 'asc' : 'desc';
}
/**
* @return array<int,string>
*/
private function splitCsv(string $value): array
{
$items = array_filter(array_map('trim', explode(',', $value)));
return array_values($items);
}
}

View File

@@ -0,0 +1,452 @@
<?php
namespace App\Http\Controllers\Dashboard;
use App\Http\Controllers\Controller;
use App\Models\Order;
use App\Models\Payment;
use App\Models\Subscription;
use App\Models\User;
use App\Models\UserApiKey;
use App\Models\UserKeyword;
use App\Models\WebhookEvent;
use App\Http\Controllers\Api\V1\EmojiApiController;
use App\Services\Auth\ApiKeyService;
use Carbon\Carbon;
use Illuminate\Contracts\View\View;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Gate;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
class UserDashboardController extends Controller
{
public function __construct(
private readonly ApiKeyService $keys
) {
}
public function overview(Request $request): View
{
$user = $request->user();
if (Gate::allows('admin')) {
$days = 7;
$start = now()->subDays($days - 1)->startOfDay();
$labels = [];
$values = [];
$subsValues = [];
$webhookValues = [];
$rawUsers = DB::table('users')
->selectRaw('DATE(created_at) as day, COUNT(*) as total')
->where('created_at', '>=', $start)
->groupBy('day')
->orderBy('day')
->get()
->keyBy('day');
$rawSubs = DB::table('subscriptions')
->selectRaw('DATE(created_at) as day, COUNT(*) as total')
->where('created_at', '>=', $start)
->groupBy('day')
->orderBy('day')
->get()
->keyBy('day');
$rawWebhooks = DB::table('webhook_events')
->selectRaw('DATE(COALESCE(received_at, created_at)) as day, COUNT(*) as total')
->where('created_at', '>=', $start)
->groupBy('day')
->orderBy('day')
->get()
->keyBy('day');
for ($i = $days - 1; $i >= 0; $i--) {
$date = Carbon::now()->subDays($i)->format('Y-m-d');
$labels[] = Carbon::parse($date)->format('M d');
$values[] = (int) ($rawUsers[$date]->total ?? 0);
$subsValues[] = (int) ($rawSubs[$date]->total ?? 0);
$webhookValues[] = (int) ($rawWebhooks[$date]->total ?? 0);
}
$usersTotal = User::count();
$usersPersonal = User::where('tier', 'personal')->count();
$subscriptionsActive = Subscription::where('status', 'active')->count();
$subscriptionsTotal = Subscription::count();
$webhookTotal = WebhookEvent::count();
$webhookErrors = WebhookEvent::where('status', 'error')->count();
return view('dashboard.index', [
'chartLabels' => $labels,
'chartValues' => $values,
'chartSubs' => $subsValues,
'chartWebhooks' => $webhookValues,
'overviewMetrics' => [
'users_total' => $usersTotal,
'users_personal' => $usersPersonal,
'subscriptions_active' => $subscriptionsActive,
'subscriptions_total' => $subscriptionsTotal,
'webhook_total' => $webhookTotal,
'webhook_errors' => $webhookErrors,
],
]);
}
$recentKeywords = UserKeyword::where('user_id', $user?->id)
->orderByDesc('id')
->limit(6)
->get();
$recentWeekCount = UserKeyword::where('user_id', $user?->id)
->where('created_at', '>=', now()->subDays(7))
->count();
$totalKeywords = UserKeyword::where('user_id', $user?->id)->count();
$apiKeyCount = UserApiKey::where('user_id', $user?->id)
->whereNull('revoked_at')
->count();
$activeSubscription = Subscription::where('user_id', $user?->id)
->orderByDesc('started_at')
->first();
return view('dashboard.user.overview', [
'totalKeywords' => $totalKeywords,
'recentKeywords' => $recentKeywords,
'recentWeekCount' => $recentWeekCount,
'apiKeyCount' => $apiKeyCount,
'subscription' => $activeSubscription,
]);
}
public function keywords(Request $request): View
{
$user = $request->user();
$items = UserKeyword::where('user_id', $user?->id)
->orderByDesc('id')
->get();
$emojiLookup = [];
$dataPath = (string) config('dewemoji.data_path');
if (is_file($dataPath)) {
$raw = file_get_contents($dataPath);
$decoded = json_decode((string) $raw, true);
if (is_array($decoded)) {
foreach ($decoded['emojis'] ?? [] as $row) {
$slug = (string) ($row['slug'] ?? '');
if ($slug !== '') {
$emojiLookup[$slug] = [
'emoji' => (string) ($row['emoji'] ?? ''),
'name' => (string) ($row['name'] ?? $slug),
];
}
}
}
}
return view('dashboard.user.keywords', [
'items' => $items,
'user' => $user,
'emojiLookup' => $emojiLookup,
'freeLimit' => $this->keywordLimitFor($user),
]);
}
public function keywordSearch(Request $request, EmojiApiController $emoji): JsonResponse
{
$request->query->set('private', 'true');
return $emoji->search($request);
}
public function storeKeyword(Request $request): RedirectResponse|JsonResponse
{
$user = $request->user();
if (!$user) {
abort(403);
}
$data = $request->validate([
'emoji_slug' => 'required|string|max:120',
'keyword' => 'required|string|max:200',
'lang' => 'nullable|string|max:10',
]);
if ($limit = $this->keywordLimitFor($user)) {
$exists = UserKeyword::where('user_id', $user->id)
->where('emoji_slug', $data['emoji_slug'])
->where('keyword', $data['keyword'])
->exists();
if (!$exists) {
$count = UserKeyword::where('user_id', $user->id)->count();
if ($count >= $limit) {
return $this->rejectKeywordLimit($request, $limit);
}
}
}
$item = UserKeyword::updateOrCreate(
[
'user_id' => $user->id,
'emoji_slug' => $data['emoji_slug'],
'keyword' => $data['keyword'],
],
[
'lang' => $data['lang'] ?? 'und',
]
);
if ($request->expectsJson()) {
return response()->json(['ok' => true, 'item' => $item]);
}
return back()->with('status', 'Keyword saved.');
}
public function updateKeyword(Request $request, UserKeyword $keyword): RedirectResponse|JsonResponse
{
$user = $request->user();
if (!$user) {
abort(403);
}
if ($keyword->user_id !== $user->id) {
abort(403);
}
$data = $request->validate([
'emoji_slug' => 'required|string|max:120',
'keyword' => 'required|string|max:200',
'lang' => 'nullable|string|max:10',
]);
$duplicate = UserKeyword::where('user_id', $user->id)
->where('emoji_slug', $data['emoji_slug'])
->where('keyword', $data['keyword'])
->first();
if ($duplicate && $duplicate->id !== $keyword->id) {
$keyword->delete();
$keyword = $duplicate;
} else {
$keyword->update([
'emoji_slug' => $data['emoji_slug'],
'keyword' => $data['keyword'],
'lang' => $data['lang'] ?? 'und',
]);
}
if ($request->expectsJson()) {
return response()->json(['ok' => true, 'item' => $keyword]);
}
return back()->with('status', 'Keyword updated.');
}
public function deleteKeyword(Request $request, UserKeyword $keyword): RedirectResponse|JsonResponse
{
$user = $request->user();
if (!$user) {
abort(403);
}
if ($keyword->user_id !== $user->id) {
abort(403);
}
$keyword->delete();
if ($request->expectsJson()) {
return response()->json(['ok' => true]);
}
return back()->with('status', 'Keyword removed.');
}
public function importKeywords(Request $request): RedirectResponse
{
$user = $request->user();
if (!$user) {
abort(403);
}
$payload = $request->input('payload');
if ($request->hasFile('file')) {
$payload = $request->file('file')->get();
}
$items = json_decode((string) $payload, true);
if (!is_array($items)) {
return back()->withErrors(['payload' => 'Invalid JSON payload.']);
}
$imported = 0;
$skipped = 0;
$limit = $this->keywordLimitFor($user);
$current = UserKeyword::where('user_id', $user->id)->count();
foreach ($items as $row) {
if (!is_array($row)) {
continue;
}
$emojiSlug = trim((string) ($row['emoji_slug'] ?? ''));
$keyword = trim((string) ($row['keyword'] ?? ''));
$lang = trim((string) ($row['lang'] ?? 'und'));
if ($emojiSlug === '' || $keyword === '') {
continue;
}
$exists = UserKeyword::where('user_id', $user->id)
->where('emoji_slug', $emojiSlug)
->where('keyword', $keyword)
->exists();
if (!$exists && $limit !== null && $current >= $limit) {
$skipped += 1;
continue;
}
UserKeyword::updateOrCreate(
[
'user_id' => $user->id,
'emoji_slug' => $emojiSlug,
'keyword' => $keyword,
],
[
'lang' => $lang !== '' ? $lang : 'und',
]
);
if (!$exists) {
$current += 1;
}
$imported += 1;
}
$message = "Imported {$imported} keywords.";
if ($skipped > 0) {
$message .= " {$skipped} skipped (free limit reached).";
}
return back()->with('status', $message);
}
public function exportKeywords(Request $request): BinaryFileResponse
{
$user = $request->user();
if (!$user) {
abort(403);
}
$items = UserKeyword::where('user_id', $user->id)
->orderByDesc('id')
->get(['emoji_slug', 'keyword', 'lang'])
->values()
->toJson(JSON_PRETTY_PRINT);
$filename = 'dewemoji-keywords-'.$user->id.'-'.now()->format('Ymd-His').'.json';
$path = storage_path('app/'.$filename);
file_put_contents($path, $items);
return response()->download($path, $filename)->deleteFileAfterSend(true);
}
public function apiKeys(Request $request): View
{
$user = $request->user();
$keys = UserApiKey::where('user_id', $user?->id)
->orderByDesc('id')
->get();
$canCreate = $user && (string) $user->tier === 'personal';
return view('dashboard.user.api-keys', [
'user' => $user,
'keys' => $keys,
'canCreate' => $canCreate,
'newKey' => session('new_api_key'),
]);
}
public function createApiKey(Request $request): RedirectResponse
{
$user = $request->user();
if (!$user) {
abort(403);
}
if ((string) $user->tier !== 'personal') {
return back()->withErrors(['api_key' => 'API keys are available on the Personal plan.']);
}
$data = $request->validate([
'name' => 'nullable|string|max:100',
]);
$issued = $this->keys->issueKey($user, $data['name'] ?? null);
return back()->with('new_api_key', $issued['plain']);
}
public function revokeApiKey(Request $request, UserApiKey $key): RedirectResponse
{
$user = $request->user();
if (!$user || $key->user_id !== $user->id) {
abort(403);
}
$key->update(['revoked_at' => Carbon::now()]);
return back()->with('status', 'API key revoked.');
}
public function billing(Request $request): View
{
$user = $request->user();
$subscription = Subscription::where('user_id', $user?->id)
->orderByDesc('started_at')
->first();
$orders = Order::where('user_id', $user?->id)
->orderByDesc('id')
->limit(5)
->get();
$payments = Payment::where('user_id', $user?->id)
->orderByDesc('id')
->limit(5)
->get();
return view('dashboard.user.billing', [
'subscription' => $subscription,
'user' => $user,
'orders' => $orders,
'payments' => $payments,
]);
}
public function preferences(Request $request): View
{
return view('dashboard.user.preferences');
}
private function rejectKeywordLimit(Request $request, int $limit): RedirectResponse|JsonResponse
{
if ($request->expectsJson()) {
return response()->json(['ok' => false, 'error' => 'free_limit_reached', 'limit' => $limit], 403);
}
return back()->withErrors(['tier' => "Free plan limit reached ({$limit} keywords)."]);
}
private function keywordLimitFor(?User $user): ?int
{
if (!$user) {
return null;
}
if ((string) $user->tier === 'personal') {
return null;
}
return (int) config('dewemoji.pagination.free_max_limit', 20);
}
}

View File

@@ -0,0 +1,66 @@
<?php
namespace App\Http\Controllers;
use App\Http\Requests\ProfileUpdateRequest;
use Illuminate\Http\RedirectResponse;
use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Redirect;
use Illuminate\View\View;
class ProfileController extends Controller
{
/**
* Display the user's profile form.
*/
public function edit(Request $request): View
{
return view('dashboard.user.profile', [
'user' => $request->user(),
]);
}
/**
* Update the user's profile information.
*/
public function update(ProfileUpdateRequest $request): RedirectResponse
{
$request->user()->fill($request->validated());
$emailChanged = $request->user()->isDirty('email');
if ($emailChanged) {
$request->user()->email_verified_at = null;
}
$request->user()->save();
if ($emailChanged && $request->user() instanceof MustVerifyEmail) {
$request->user()->sendEmailVerificationNotification();
}
return Redirect::route('profile.edit')->with('status', 'profile-updated');
}
/**
* Delete the user's account.
*/
public function destroy(Request $request): RedirectResponse
{
$request->validateWithBag('userDeletion', [
'password' => ['required', 'current_password'],
]);
$user = $request->user();
Auth::logout();
$user->delete();
$request->session()->invalidate();
$request->session()->regenerateToken();
return Redirect::to('/');
}
}

View File

@@ -3,6 +3,9 @@
namespace App\Http\Controllers\Web;
use App\Http\Controllers\Controller;
use App\Models\PricingPlan;
use App\Models\UserKeyword;
use App\Services\System\SettingsService;
use Illuminate\Contracts\View\View;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
@@ -23,6 +26,12 @@ class SiteController extends Controller
'Flags' => 'flags',
];
private function billingMode(): string
{
$settings = app(SettingsService::class);
return (string) ($settings->get('billing_mode', config('dewemoji.billing.mode', 'sandbox')) ?: 'sandbox');
}
public function home(Request $request): View
{
return view('site.home', [
@@ -30,6 +39,7 @@ class SiteController extends Controller
'initialCategory' => trim((string) $request->query('category', '')),
'initialSubcategory' => trim((string) $request->query('subcategory', '')),
'canonicalPath' => '/',
'userTier' => $request->user()?->tier,
]);
}
@@ -45,19 +55,21 @@ class SiteController extends Controller
'initialCategory' => trim((string) $request->query('category', '')),
'initialSubcategory' => trim((string) $request->query('subcategory', '')),
'canonicalPath' => '/browse',
'userTier' => $request->user()?->tier,
]);
}
public function category(string $categorySlug): View
{
if ($categorySlug === 'all') {
return view('site.home', [
'initialQuery' => '',
'initialCategory' => '',
'initialSubcategory' => '',
'canonicalPath' => '/',
]);
}
return view('site.home', [
'initialQuery' => '',
'initialCategory' => '',
'initialSubcategory' => '',
'canonicalPath' => '/',
'userTier' => request()->user()?->tier,
]);
}
$categoryLabel = $this->categorySlugMap()[$categorySlug] ?? '';
abort_if($categoryLabel === '', 404);
@@ -67,6 +79,7 @@ class SiteController extends Controller
'initialCategory' => $categoryLabel,
'initialSubcategory' => '',
'canonicalPath' => '/'.$categorySlug,
'userTier' => request()->user()?->tier,
]);
}
@@ -84,6 +97,7 @@ class SiteController extends Controller
'initialCategory' => $categoryLabel,
'initialSubcategory' => $subcategorySlug,
'canonicalPath' => '/'.$categorySlug.'/'.$subcategorySlug,
'userTier' => request()->user()?->tier,
]);
}
@@ -94,7 +108,96 @@ class SiteController extends Controller
public function pricing(): View
{
return view('site.pricing');
$currencyPref = strtoupper((string) session('pricing_currency', ''));
if (!in_array($currencyPref, ['IDR', 'USD'], true)) {
$currencyPref = $this->detectPricingCurrency(request());
session(['pricing_currency' => $currencyPref]);
}
$rate = (int) config('dewemoji.pricing.usd_rate', 15000);
$plans = PricingPlan::where('status', 'active')->get()->keyBy('code');
$defaults = config('dewemoji.pricing.defaults', []);
$fallback = collect($defaults)->keyBy('code');
$getPlanAmount = function (string $code) use ($plans, $fallback): int {
$plan = $plans->get($code) ?? $fallback->get($code);
return (int) ($plan['amount'] ?? $plan->amount ?? 0);
};
$pricing = [
'personal_monthly' => [
'idr' => $getPlanAmount('personal_monthly'),
],
'personal_annual' => [
'idr' => $getPlanAmount('personal_annual'),
],
'personal_lifetime' => [
'idr' => $getPlanAmount('personal_lifetime'),
],
];
foreach ($pricing as $key => $row) {
$pricing[$key]['usd'] = $rate > 0 ? round($row['idr'] / $rate, 2) : 0;
}
return view('site.pricing', [
'currencyPref' => $currencyPref,
'usdRate' => $rate,
'pricing' => $pricing,
'payments' => [
'qris_url' => (string) config('dewemoji.payments.qris_url', ''),
'paypal_url' => (string) config('dewemoji.payments.paypal_url', ''),
],
'pakasirEnabled' => (bool) config('dewemoji.billing.providers.pakasir.enabled', false)
&& (string) config('dewemoji.billing.providers.pakasir.api_base', '') !== ''
&& (string) config('dewemoji.billing.providers.pakasir.api_key', '') !== ''
&& (string) config('dewemoji.billing.providers.pakasir.project', '') !== '',
'paypalEnabled' => $this->paypalEnabled($this->billingMode()),
'paypalPlans' => $this->paypalPlanAvailability($this->billingMode()),
]);
}
private function paypalEnabled(string $mode): bool
{
$enabled = (bool) config('dewemoji.billing.providers.paypal.enabled', false);
$clientId = (string) config("dewemoji.billing.providers.paypal.{$mode}.client_id", '');
$clientSecret = (string) config("dewemoji.billing.providers.paypal.{$mode}.client_secret", '');
$apiBase = (string) config("dewemoji.billing.providers.paypal.{$mode}.api_base", '');
return $enabled && $clientId !== '' && $clientSecret !== '' && $apiBase !== '';
}
private function paypalPlanAvailability(string $mode): array
{
$plans = PricingPlan::whereIn('code', ['personal_monthly', 'personal_annual'])->get()->keyBy('code');
$fromDb = function (string $code) use ($plans, $mode): bool {
$plan = $plans->get($code);
if (!$plan) {
return false;
}
$meta = $plan->meta ?? [];
return (string) ($meta['paypal'][$mode]['plan']['id'] ?? '') !== '';
};
$fromEnv = function (string $code) use ($mode): bool {
return (string) config("dewemoji.billing.providers.paypal.plan_ids.{$mode}.{$code}", '') !== '';
};
return [
'personal_monthly' => $fromDb('personal_monthly') || $fromEnv('personal_monthly'),
'personal_annual' => $fromDb('personal_annual') || $fromEnv('personal_annual'),
];
}
public function setPricingCurrency(Request $request): RedirectResponse
{
$data = $request->validate([
'currency' => 'required|string|in:IDR,USD',
]);
session(['pricing_currency' => $data['currency']]);
return back();
}
public function support(): View
@@ -112,6 +215,18 @@ class SiteController extends Controller
return view('site.terms');
}
private function detectPricingCurrency(Request $request): string
{
$country = strtoupper((string) ($request->header('CF-IPCountry')
?? $request->header('X-Country-Code')
?? $request->header('X-Geo-Country')
?? $request->header('X-Appengine-Country')
?? $request->header('CloudFront-Viewer-Country')
?? ''));
return $country === 'ID' ? 'IDR' : 'USD';
}
public function emojiDetail(string $slug): View|Response
{
$dataPath = (string) config('dewemoji.data_path');
@@ -157,10 +272,22 @@ class SiteController extends Controller
];
}
$user = request()->user();
$isPersonal = $user && (string) $user->tier === 'personal';
$userKeywords = [];
if ($isPersonal) {
$userKeywords = UserKeyword::where('user_id', $user->id)
->where('emoji_slug', $slug)
->orderByDesc('id')
->get();
}
return view('site.emoji-detail', [
'emoji' => $match,
'relatedDetails' => $relatedDetails,
'canonicalPath' => '/emoji/'.$slug,
'userKeywords' => $userKeywords,
'userTier' => $user?->tier,
]);
}

View File

@@ -0,0 +1,85 @@
<?php
namespace App\Http\Requests\Auth;
use Illuminate\Auth\Events\Lockout;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
class LoginRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'email' => ['required', 'string', 'email'],
'password' => ['required', 'string'],
];
}
/**
* Attempt to authenticate the request's credentials.
*
* @throws \Illuminate\Validation\ValidationException
*/
public function authenticate(): void
{
$this->ensureIsNotRateLimited();
if (! Auth::attempt($this->only('email', 'password'), $this->boolean('remember'))) {
RateLimiter::hit($this->throttleKey());
throw ValidationException::withMessages([
'email' => trans('auth.failed'),
]);
}
RateLimiter::clear($this->throttleKey());
}
/**
* Ensure the login request is not rate limited.
*
* @throws \Illuminate\Validation\ValidationException
*/
public function ensureIsNotRateLimited(): void
{
if (! RateLimiter::tooManyAttempts($this->throttleKey(), 5)) {
return;
}
event(new Lockout($this));
$seconds = RateLimiter::availableIn($this->throttleKey());
throw ValidationException::withMessages([
'email' => trans('auth.throttle', [
'seconds' => $seconds,
'minutes' => ceil($seconds / 60),
]),
]);
}
/**
* Get the rate limiting throttle key for the request.
*/
public function throttleKey(): string
{
return Str::transliterate(Str::lower($this->string('email')).'|'.$this->ip());
}
}

View File

@@ -0,0 +1,30 @@
<?php
namespace App\Http\Requests;
use App\Models\User;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class ProfileUpdateRequest extends FormRequest
{
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:255'],
'email' => [
'required',
'string',
'lowercase',
'email',
'max:255',
Rule::unique(User::class)->ignore($this->user()->id),
],
];
}
}

View File

@@ -0,0 +1,88 @@
<?php
namespace App\Mail;
use Illuminate\Support\Facades\Http;
use Symfony\Component\Mailer\Exception\TransportException;
use Symfony\Component\Mailer\SentMessage;
use Symfony\Component\Mailer\Transport\AbstractTransport;
use Symfony\Component\Mime\Email;
class MailketingTransport extends AbstractTransport
{
public function __construct(
protected string $apiToken,
protected string $endpoint,
protected ?string $defaultFromName = null,
protected ?string $defaultFromEmail = null,
protected int $timeoutSeconds = 10,
) {
parent::__construct();
}
public function __toString(): string
{
return 'mailketing';
}
protected function doSend(SentMessage $message): void
{
$email = $message->getOriginalMessage();
if (! $email instanceof Email) {
throw new TransportException('Mailketing transport only supports Symfony Email messages.');
}
if ($email->getAttachments()) {
throw new TransportException('Mailketing transport does not support attachments without URL references.');
}
$from = $email->getFrom()[0] ?? null;
$fromName = $from?->getName() ?: $this->defaultFromName ?: '';
$fromEmail = $from?->getAddress() ?: $this->defaultFromEmail ?: '';
if ($fromEmail === '') {
throw new TransportException('Mailketing transport requires a from email address.');
}
$subject = $email->getSubject() ?? '';
$content = $email->getHtmlBody() ?? $email->getTextBody() ?? '';
$recipients = array_merge(
$email->getTo(),
$email->getCc(),
$email->getBcc(),
);
if ($recipients === []) {
throw new TransportException('Mailketing transport requires at least one recipient.');
}
foreach ($recipients as $recipient) {
$response = Http::asForm()
->timeout($this->timeoutSeconds)
->post($this->endpoint, [
'api_token' => $this->apiToken,
'from_name' => $fromName,
'from_email' => $fromEmail,
'recipient' => $recipient->getAddress(),
'subject' => $subject,
'content' => $content,
]);
if (! $response->ok()) {
throw new TransportException(sprintf(
'Mailketing request failed (%s): %s',
$response->status(),
$response->body()
));
}
$payload = $response->json();
if (is_array($payload) && (($payload['status'] ?? null) !== 'success')) {
$detail = $payload['response'] ?? $payload['message'] ?? 'unknown';
throw new TransportException('Mailketing responded with an error: '.$detail);
}
}
}
}

View File

@@ -0,0 +1,23 @@
<?php
namespace App\Mail;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Queue\SerializesModels;
class TestMailketing extends Mailable
{
use Queueable, SerializesModels;
public function build(): self
{
$actionUrl = config('app.url', 'https://dewemoji.com');
return $this
->subject('Dewemoji test email')
->view('emails.test', [
'actionUrl' => $actionUrl,
]);
}
}

View File

@@ -0,0 +1,20 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class AdminAuditLog extends Model
{
protected $fillable = [
'admin_id',
'admin_email',
'action',
'payload',
'ip_address',
];
protected $casts = [
'payload' => 'array',
];
}

31
app/app/Models/Order.php Normal file
View File

@@ -0,0 +1,31 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Order extends Model
{
protected $fillable = [
'user_id',
'plan_code',
'type',
'currency',
'amount',
'status',
'provider',
'provider_ref',
];
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function payments(): HasMany
{
return $this->hasMany(Payment::class);
}
}

View File

@@ -0,0 +1,36 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class Payment extends Model
{
protected $fillable = [
'user_id',
'order_id',
'provider',
'type',
'plan_code',
'currency',
'amount',
'status',
'provider_ref',
'raw_payload',
];
protected $casts = [
'raw_payload' => 'array',
];
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function order(): BelongsTo
{
return $this->belongsTo(Order::class);
}
}

View File

@@ -0,0 +1,19 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class PricingChange extends Model
{
protected $fillable = [
'admin_ref',
'before',
'after',
];
protected $casts = [
'before' => 'array',
'after' => 'array',
];
}

View File

@@ -0,0 +1,23 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class PricingPlan extends Model
{
protected $fillable = [
'code',
'name',
'currency',
'amount',
'period',
'status',
'meta',
];
protected $casts = [
'amount' => 'integer',
'meta' => 'array',
];
}

View File

@@ -0,0 +1,18 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class Setting extends Model
{
protected $fillable = [
'key',
'value',
'updated_by',
];
protected $casts = [
'value' => 'array',
];
}

View File

@@ -0,0 +1,33 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class Subscription extends Model
{
protected $fillable = [
'user_id',
'plan',
'status',
'provider',
'provider_ref',
'started_at',
'expires_at',
'canceled_at',
'next_renewal_at',
];
protected $casts = [
'started_at' => 'datetime',
'expires_at' => 'datetime',
'canceled_at' => 'datetime',
'next_renewal_at' => 'datetime',
];
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}

View File

@@ -2,15 +2,18 @@
namespace App\Models;
// use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Contracts\Auth\MustVerifyEmail as MustVerifyEmailContract;
use Illuminate\Auth\MustVerifyEmail;
use App\Notifications\ResetPasswordNotification;
use App\Notifications\VerifyEmailNotification;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
class User extends Authenticatable
class User extends Authenticatable implements MustVerifyEmailContract
{
/** @use HasFactory<\Database\Factories\UserFactory> */
use HasFactory, Notifiable;
use HasFactory, Notifiable, MustVerifyEmail;
/**
* The attributes that are mass assignable.
@@ -21,6 +24,8 @@ class User extends Authenticatable
'name',
'email',
'password',
'role',
'tier',
];
/**
@@ -45,4 +50,19 @@ class User extends Authenticatable
'password' => 'hashed',
];
}
public function isAdmin(): bool
{
return $this->role === 'admin';
}
public function sendEmailVerificationNotification(): void
{
$this->notify(new VerifyEmailNotification());
}
public function sendPasswordResetNotification($token): void
{
$this->notify(new ResetPasswordNotification($token));
}
}

View File

@@ -0,0 +1,28 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class UserApiKey extends Model
{
protected $fillable = [
'user_id',
'key_hash',
'key_prefix',
'name',
'last_used_at',
'revoked_at',
];
protected $casts = [
'last_used_at' => 'datetime',
'revoked_at' => 'datetime',
];
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}

View File

@@ -0,0 +1,21 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class UserKeyword extends Model
{
protected $fillable = [
'user_id',
'emoji_slug',
'keyword',
'lang',
];
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}

View File

@@ -0,0 +1,27 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class WebhookEvent extends Model
{
protected $fillable = [
'provider',
'event_id',
'event_type',
'status',
'payload',
'headers',
'error',
'received_at',
'processed_at',
];
protected $casts = [
'payload' => 'array',
'headers' => 'array',
'received_at' => 'datetime',
'processed_at' => 'datetime',
];
}

View File

@@ -0,0 +1,20 @@
<?php
namespace App\Notifications;
use Illuminate\Auth\Notifications\ResetPassword;
use Illuminate\Notifications\Messages\MailMessage;
class ResetPasswordNotification extends ResetPassword
{
public function toMail($notifiable): MailMessage
{
$resetUrl = $this->resetUrl($notifiable);
return (new MailMessage)
->subject('Reset your password')
->view('emails.reset-password', [
'resetUrl' => $resetUrl,
]);
}
}

View File

@@ -0,0 +1,20 @@
<?php
namespace App\Notifications;
use Illuminate\Auth\Notifications\VerifyEmail;
use Illuminate\Notifications\Messages\MailMessage;
class VerifyEmailNotification extends VerifyEmail
{
public function toMail($notifiable): MailMessage
{
$verificationUrl = $this->verificationUrl($notifiable);
return (new MailMessage)
->subject('Verify your email')
->view('emails.verify-email', [
'verificationUrl' => $verificationUrl,
]);
}
}

View File

@@ -3,6 +3,9 @@
namespace App\Providers;
use Illuminate\Support\ServiceProvider;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Mail;
use App\Mail\MailketingTransport;
class AppServiceProvider extends ServiceProvider
{
@@ -19,6 +22,18 @@ class AppServiceProvider extends ServiceProvider
*/
public function boot(): void
{
//
Gate::define('admin', function ($user) {
return $user && method_exists($user, 'isAdmin') ? $user->isAdmin() : false;
});
Mail::extend('mailketing', function (array $config) {
return new MailketingTransport(
apiToken: (string) ($config['token'] ?? ''),
endpoint: (string) ($config['endpoint'] ?? 'https://api.mailketing.co.id/api/v1/send'),
defaultFromName: config('mail.from.name'),
defaultFromEmail: config('mail.from.address'),
timeoutSeconds: (int) ($config['timeout'] ?? 10),
);
});
}
}

View File

@@ -0,0 +1,76 @@
<?php
namespace App\Services\Auth;
use App\Models\User;
use App\Models\UserApiKey;
use Carbon\Carbon;
use Illuminate\Http\Request;
class ApiKeyService
{
public function parseKey(Request $request): string
{
$auth = trim((string) $request->header('Authorization', ''));
if (str_starts_with($auth, 'Bearer ')) {
return trim(substr($auth, 7));
}
return trim((string) $request->header('X-Api-Key', ''));
}
public function hashKey(string $key): string
{
return hash('sha256', $key);
}
public function prefix(string $key): string
{
return substr($key, 0, 8);
}
public function resolveUser(Request $request): ?User
{
$key = $this->parseKey($request);
if ($key === '') {
return null;
}
$record = UserApiKey::where('key_hash', $this->hashKey($key))
->whereNull('revoked_at')
->first();
if (!$record) {
return null;
}
$record->last_used_at = Carbon::now();
$record->save();
$user = $record->user;
if (!$user || (string) $user->tier !== 'personal') {
return null;
}
return $user;
}
public function issueKey(User $user, ?string $name = null): array
{
$plain = 'dew_'.bin2hex(random_bytes(16));
$hash = $this->hashKey($plain);
$prefix = $this->prefix($plain);
$record = UserApiKey::create([
'user_id' => $user->id,
'key_hash' => $hash,
'key_prefix' => $prefix,
'name' => $name,
]);
return [
'plain' => $plain,
'record' => $record,
];
}
}

View File

@@ -0,0 +1,319 @@
<?php
namespace App\Services\Billing;
use App\Models\PricingPlan;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class PayPalPlanSyncService
{
/**
* @return array{created:int,updated:int,deactivated:int,skipped:int}
*/
public function sync(string $mode = 'sandbox'): array
{
$result = [
'created' => 0,
'updated' => 0,
'deactivated' => 0,
'skipped' => 0,
];
$token = $this->getAccessToken($mode);
if (!$token) {
Log::warning('PayPal plan sync aborted: missing access token', ['mode' => $mode]);
return $result;
}
$productId = $this->ensureProduct($mode, $token);
if (!$productId) {
Log::warning('PayPal plan sync aborted: missing product id', ['mode' => $mode]);
return $result;
}
$plans = PricingPlan::whereIn('code', ['personal_monthly', 'personal_annual'])->get();
$keepIds = [];
foreach ($plans as $plan) {
$currentMeta = $plan->meta ?? [];
$currentPlanId = $currentMeta['paypal'][$mode]['plan']['id'] ?? null;
$history = $currentMeta['paypal'][$mode]['plan']['history'] ?? [];
if ($currentPlanId) {
$history = array_values(array_unique(array_merge([$currentPlanId], $history)));
}
$amountUsd = $this->toUsdAmount($plan->amount);
$existing = $currentPlanId ? $this->getPlan($mode, $token, $currentPlanId) : null;
$existingAmount = $existing['billing_cycles'][0]['pricing_scheme']['fixed_price']['value'] ?? null;
if ($existing && $existingAmount !== null && (float) $existingAmount === (float) $amountUsd) {
$currentMeta['paypal'][$mode]['plan'] = [
'id' => $currentPlanId,
'amount_usd' => $amountUsd,
'history' => $history,
'synced_at' => now()->toIso8601String(),
];
$plan->update(['meta' => $currentMeta]);
if ($currentPlanId) {
$keepIds[] = $currentPlanId;
}
$result['skipped']++;
continue;
}
$newPlanId = $this->createPlan($mode, $token, $productId, $plan->code, $plan->name, $amountUsd, $plan->period);
if (!$newPlanId) {
$result['skipped']++;
continue;
}
$currentMeta['paypal'][$mode]['plan'] = [
'id' => $newPlanId,
'amount_usd' => $amountUsd,
'history' => $history,
'synced_at' => now()->toIso8601String(),
];
$plan->update(['meta' => $currentMeta]);
if ($currentPlanId) {
$result['updated']++;
} else {
$result['created']++;
}
$keepIds[] = $newPlanId;
if ($this->canDeactivate($plan->code)) {
foreach ($history as $oldId) {
if ($oldId === $newPlanId) {
continue;
}
if ($this->deactivatePlan($mode, $token, $oldId)) {
$result['deactivated']++;
}
}
}
}
// Deactivate any other active plans under this product to avoid confusion
$deactivated = $this->deactivateOtherPlansForProduct($mode, $token, $productId, $keepIds);
$result['deactivated'] += $deactivated;
return $result;
}
private function toUsdAmount(int $idrAmount): string
{
$rate = (int) config('dewemoji.pricing.usd_rate', 15000);
$usd = $rate > 0 ? $idrAmount / $rate : 0;
return number_format(max($usd, 1), 2, '.', '');
}
private function canDeactivate(string $planCode): bool
{
$activeCount = \App\Models\Subscription::query()
->where('provider', 'paypal')
->where('status', 'active')
->where('plan', $planCode)
->count();
return $activeCount === 0;
}
private function getAccessToken(string $mode): ?string
{
$clientId = config("dewemoji.billing.providers.paypal.{$mode}.client_id");
$clientSecret = config("dewemoji.billing.providers.paypal.{$mode}.client_secret");
$apiBase = config("dewemoji.billing.providers.paypal.{$mode}.api_base");
if (!$clientId || !$clientSecret || !$apiBase) {
return null;
}
$res = Http::asForm()
->withBasicAuth($clientId, $clientSecret)
->timeout((int) config('dewemoji.billing.providers.paypal.timeout', 10))
->post(rtrim($apiBase, '/').'/v1/oauth2/token', [
'grant_type' => 'client_credentials',
]);
if (!$res->ok()) {
Log::warning('PayPal auth failed', ['body' => $res->body()]);
return null;
}
return $res->json('access_token');
}
private function ensureProduct(string $mode, string $token): ?string
{
$apiBase = config("dewemoji.billing.providers.paypal.{$mode}.api_base");
$list = Http::withToken($token)
->timeout((int) config('dewemoji.billing.providers.paypal.timeout', 10))
->get(rtrim($apiBase, '/').'/v1/catalogs/products', [
'page_size' => 20,
'page' => 1,
]);
if ($list->ok()) {
$items = $list->json('products') ?? [];
foreach ($items as $item) {
if (($item['custom_id'] ?? '') === 'dewemoji-personal') {
return $item['id'] ?? null;
}
}
}
$payload = [
'name' => 'Dewemoji Personal',
'type' => 'SERVICE',
'category' => 'SOFTWARE',
'custom_id' => 'dewemoji-personal',
];
$create = Http::withToken($token)
->timeout((int) config('dewemoji.billing.providers.paypal.timeout', 10))
->post(rtrim($apiBase, '/').'/v1/catalogs/products', $payload);
$createdId = $create->json('id');
if ($createdId) {
if (!$create->ok()) {
Log::warning('PayPal product create returned non-OK but provided id', [
'status' => $create->status(),
'body' => $create->body(),
]);
}
return $createdId;
}
Log::warning('PayPal product create failed', [
'status' => $create->status(),
'body' => $create->body(),
]);
return null;
}
private function createPlan(
string $mode,
string $token,
string $productId,
string $code,
string $name,
string $amountUsd,
?string $period
): ?string {
$apiBase = config("dewemoji.billing.providers.paypal.{$mode}.api_base");
$intervalUnit = $period === 'year' ? 'YEAR' : 'MONTH';
$payload = [
'product_id' => $productId,
'name' => $name,
'description' => "{$name} subscription",
'billing_cycles' => [
[
'frequency' => [
'interval_unit' => $intervalUnit,
'interval_count' => 1,
],
'tenure_type' => 'REGULAR',
'sequence' => 1,
'total_cycles' => 0,
'pricing_scheme' => [
'fixed_price' => [
'value' => $amountUsd,
'currency_code' => 'USD',
],
],
],
],
'payment_preferences' => [
'auto_bill_outstanding' => true,
'setup_fee_failure_action' => 'CONTINUE',
'payment_failure_threshold' => 3,
],
];
$res = Http::withToken($token)
->timeout((int) config('dewemoji.billing.providers.paypal.timeout', 10))
->post(rtrim($apiBase, '/').'/v1/billing/plans', $payload);
$planId = $res->json('id');
if ($planId) {
if (!$res->ok()) {
Log::warning('PayPal plan create returned non-OK but provided id', [
'code' => $code,
'status' => $res->status(),
'body' => $res->body(),
]);
}
return $planId;
}
Log::warning('PayPal plan create failed', [
'code' => $code,
'status' => $res->status(),
'body' => $res->body(),
]);
return null;
}
private function getPlan(string $mode, string $token, string $planId): ?array
{
$apiBase = config("dewemoji.billing.providers.paypal.{$mode}.api_base");
$res = Http::withToken($token)
->timeout((int) config('dewemoji.billing.providers.paypal.timeout', 10))
->get(rtrim($apiBase, '/').'/v1/billing/plans/'.$planId);
if (!$res->ok()) {
return null;
}
return $res->json();
}
private function deactivatePlan(string $mode, string $token, string $planId): bool
{
$apiBase = config("dewemoji.billing.providers.paypal.{$mode}.api_base");
$res = Http::withToken($token)
->timeout((int) config('dewemoji.billing.providers.paypal.timeout', 10))
->post(rtrim($apiBase, '/').'/v1/billing/plans/'.$planId.'/deactivate');
return $res->ok();
}
private function deactivateOtherPlansForProduct(string $mode, string $token, string $productId, array $keepIds): int
{
$apiBase = config("dewemoji.billing.providers.paypal.{$mode}.api_base");
$res = Http::withToken($token)
->timeout((int) config('dewemoji.billing.providers.paypal.timeout', 10))
->get(rtrim($apiBase, '/').'/v1/billing/plans', [
'product_id' => $productId,
'page_size' => 20,
'page' => 1,
]);
if (!$res->ok()) {
return 0;
}
$items = $res->json('plans') ?? [];
if (!is_array($items)) {
return 0;
}
$count = 0;
foreach ($items as $plan) {
$id = $plan['id'] ?? null;
$status = strtoupper((string) ($plan['status'] ?? ''));
if (!$id || in_array($id, $keepIds, true)) {
continue;
}
if ($status !== 'ACTIVE') {
continue;
}
if ($this->deactivatePlan($mode, $token, $id)) {
$count++;
}
}
return $count;
}
}

View File

@@ -0,0 +1,78 @@
<?php
namespace App\Services\Billing;
use App\Models\Subscription;
use App\Models\User;
use App\Models\UserApiKey;
class PaypalWebhookProcessor
{
/**
* @param array<string,mixed> $payload
*/
public function process(string $eventType, array $payload): void
{
$resource = $payload['resource'] ?? [];
if (!is_array($resource)) {
return;
}
$subscriptionId = (string) ($resource['id'] ?? '');
$email = (string) ($resource['subscriber']['email_address'] ?? '');
if ($subscriptionId === '' || $email === '') {
return;
}
$user = User::where('email', $email)->first();
if (!$user) {
throw new \RuntimeException('User not found for webhook email.');
}
if ($eventType === 'BILLING.SUBSCRIPTION.ACTIVATED') {
Subscription::create([
'user_id' => $user->id,
'plan' => 'personal',
'status' => 'active',
'provider' => 'paypal',
'provider_ref' => $subscriptionId,
'started_at' => now(),
'expires_at' => null,
]);
$user->update(['tier' => 'personal']);
return;
}
if ($eventType === 'BILLING.SUBSCRIPTION.CANCELLED' || $eventType === 'BILLING.SUBSCRIPTION.SUSPENDED') {
Subscription::where('user_id', $user->id)
->where('provider', 'paypal')
->where('provider_ref', $subscriptionId)
->where('status', 'active')
->update([
'status' => $eventType === 'BILLING.SUBSCRIPTION.CANCELLED' ? 'cancelled' : 'suspended',
'expires_at' => now(),
]);
$this->syncUserTier($user->id);
}
}
private function syncUserTier(int $userId): void
{
$active = Subscription::where('user_id', $userId)
->where('status', 'active')
->where(function ($q): void {
$q->whereNull('expires_at')
->orWhere('expires_at', '>', now());
})
->exists();
User::where('id', $userId)->update([
'tier' => $active ? 'personal' : 'free',
]);
if (!$active) {
UserApiKey::where('user_id', $userId)->update(['revoked_at' => now()]);
}
}
}

View File

@@ -0,0 +1,60 @@
<?php
namespace App\Services\Extension;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
class ExtensionVerificationService
{
/**
* @param array<string> $expectedExtensionIds
*/
public function verifyToken(string $token, array $expectedExtensionIds): bool
{
if ($token === '') {
return false;
}
$config = config('dewemoji.extension_verification', []);
if (!(bool) ($config['enabled'] ?? true)) {
return true;
}
$projectId = (string) ($config['project_id'] ?? '');
$serverKey = (string) ($config['server_key'] ?? '');
if ($projectId === '' || $serverKey === '') {
return false;
}
$cacheKey = 'dw_ext_verify_'.sha1($token);
$ttl = max((int) ($config['cache_ttl'] ?? 3600), 60);
return Cache::remember($cacheKey, $ttl, function () use ($token, $serverKey, $expectedExtensionIds): bool {
$url = 'https://iid.googleapis.com/iid/info/'.$token.'?details=true';
$response = Http::withHeaders([
'Authorization' => 'key='.$serverKey,
])->get($url);
if (!$response->ok()) {
return false;
}
$data = $response->json();
if (!is_array($data)) {
return false;
}
$application = (string) ($data['application'] ?? '');
if ($application === '') {
return false;
}
if (count($expectedExtensionIds) === 0) {
return true;
}
return in_array($application, $expectedExtensionIds, true);
});
}
}

View File

@@ -0,0 +1,46 @@
<?php
namespace App\Services\System;
use App\Models\Setting;
use Illuminate\Support\Facades\Cache;
class SettingsService
{
private const CACHE_KEY = 'dw_settings_all';
private const CACHE_TTL = 30;
/**
* @return array<string,mixed>
*/
public function all(): array
{
return Cache::remember(self::CACHE_KEY, self::CACHE_TTL, function (): array {
$out = [];
foreach (Setting::all(['key', 'value']) as $setting) {
$out[$setting->key] = $setting->value;
}
return $out;
});
}
public function get(string $key, mixed $default = null): mixed
{
$all = $this->all();
return array_key_exists($key, $all) ? $all[$key] : $default;
}
/**
* @param array<string,mixed> $values
*/
public function setMany(array $values, ?string $updatedBy = null): void
{
foreach ($values as $key => $value) {
Setting::updateOrCreate(
['key' => (string) $key],
['value' => $value, 'updated_by' => $updatedBy]
);
}
Cache::forget(self::CACHE_KEY);
}
}

View File

@@ -0,0 +1,17 @@
<?php
namespace App\View\Components;
use Illuminate\View\Component;
use Illuminate\View\View;
class AppLayout extends Component
{
/**
* Get the view / contents that represents the component.
*/
public function render(): View
{
return view('layouts.app');
}
}

View File

@@ -0,0 +1,17 @@
<?php
namespace App\View\Components;
use Illuminate\View\Component;
use Illuminate\View\View;
class GuestLayout extends Component
{
/**
* Get the view / contents that represents the component.
*/
public function render(): View
{
return view('layouts.guest');
}
}