Initial baseline: LaraBlog core with plugin commerce surface.

Captures the current working tree after theme slots, ArticleAccess, and the payment / paid-content plugins so subsequent work has a reviewable git history.
This commit is contained in:
ak
2026-08-12 01:15:38 +08:00
commit 263b98b218
337 changed files with 31393 additions and 0 deletions
@@ -0,0 +1,7 @@
{
"name": "larablog/ai-comment-moderation",
"title": "AI Comment Moderation",
"version": "1.0.0",
"description": "Queue AI moderation for new comments.",
"provider": "Plugins\\Larablog\\AiCommentModeration\\PluginServiceProvider"
}
@@ -0,0 +1,24 @@
<?php
namespace Plugins\Larablog\AiCommentModeration;
use App\Domain\Ai\Jobs\ModerateCommentJob;
use App\Domain\Plugin\Hook;
use App\Models\Comment;
use Illuminate\Support\ServiceProvider;
class PluginServiceProvider extends ServiceProvider
{
public function register(): void
{
//
}
public function boot(): void
{
Hook::listen('comment.created', function (Comment $comment): void {
$comment->update(['moderation_status' => Comment::STATUS_PENDING_AI]);
ModerateCommentJob::dispatch($comment->id);
});
}
}
+7
View File
@@ -0,0 +1,7 @@
{
"name": "larablog/membership",
"title": "Membership",
"version": "1.0.0",
"description": "Membership plugin skeleton.",
"provider": "Plugins\\Larablog\\Membership\\PluginServiceProvider"
}
@@ -0,0 +1,39 @@
<?php
namespace Plugins\Larablog\Membership;
use App\Domain\Plugin\Hook;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\ServiceProvider;
class PluginServiceProvider extends ServiceProvider
{
public function register(): void
{
//
}
public function boot(): void
{
Hook::listen('theme.sidebar', function (string $html): string {
$title = __('admin.plugins.larablog/membership.title');
$desc = __('admin.plugins.larablog/membership.description');
return $html.'<h3>'.e($title).'</h3><p class="note">'.e($desc).' <code>/plugins/membership/status</code></p>';
});
Route::middleware('web')->prefix('plugins/membership')->group(function (): void {
Route::get('/status', function () {
$user = auth()->user();
return response()->json([
'ok' => true,
'plugin' => 'larablog/membership',
'authenticated' => $user !== null,
'roles' => $user?->getRoleNames() ?? [],
'message' => 'Membership skeleton. Billing tiers come in phase 2.',
]);
});
});
}
}
+36
View File
@@ -0,0 +1,36 @@
# Paid Content (`larablog/paid-content`)
Sell individual articles with a teaser (trial) and checkout through `larablog/payment`.
## Prerequisites
1. Enable **Payment** (`larablog/payment`) first — this plugin declares it as a hard dependency.
2. Run migrations so `article_products` (and payment tables) exist:
```bash
php artisan migrate
```
3. Enable **Paid Content** in Admin → Plugins.
## Configure an article
1. Open Admin → Articles → create/edit an article.
2. In the **Paid content** section:
- Turn on paid content
- Set price and currency (default `CNY`)
- Set trial length in characters (default `200`; applied after HTML render)
3. Save.
**Note:** Read password and paid content cannot both be enabled. Use password for private sharing; use paid content for selling.
## Frontend paywall
- Readers without an entitlement see a teaser plus a purchase CTA (`need_purchase`).
- Checkout URL points at `/plugins/payment/checkout` with product metadata; Stub payment (or a real gateway later) grants an entitlement.
- After payment, returning to the article shows full content for that logged-in user.
- Authors and users with the `admin` role always see full content (core `ArticleAccess`).
## Disable
Disable this plugin to remove the Filament paid section / column and the `article.access` paywall. Existing `article_products` rows remain until you clean them up manually.
@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('article_products', function (Blueprint $table): void {
$table->id();
$table->foreignId('article_id')->unique()->constrained('articles')->cascadeOnDelete();
$table->boolean('enabled')->default(false);
$table->decimal('price', 10, 2);
$table->string('currency')->default('CNY');
$table->string('trial_mode')->default('chars');
$table->unsignedInteger('trial_value')->default(200);
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('article_products');
}
};
@@ -0,0 +1,9 @@
{
"name": "larablog/paid-content",
"title": "Paid Content",
"version": "1.0.0",
"description": "Paid articles with teaser and checkout via payment plugin.",
"provider": "Plugins\\Larablog\\PaidContent\\PluginServiceProvider",
"requires": ["larablog/payment"],
"docs": "README.md"
}
@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace Plugins\Larablog\PaidContent\Models;
use App\Models\Article;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class ArticleProduct extends Model
{
protected $table = 'article_products';
protected $fillable = [
'article_id',
'enabled',
'price',
'currency',
'trial_mode',
'trial_value',
];
protected function casts(): array
{
return [
'enabled' => 'boolean',
'price' => 'decimal:2',
'trial_value' => 'integer',
];
}
public function article(): BelongsTo
{
return $this->belongsTo(Article::class);
}
}
@@ -0,0 +1,190 @@
<?php
declare(strict_types=1);
namespace Plugins\Larablog\PaidContent;
use App\Domain\Blog\AccessDecision;
use App\Domain\Blog\HtmlTeaser;
use App\Domain\Plugin\Hook;
use App\Models\Article;
use App\Models\User;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Schemas\Components\Section;
use Filament\Tables\Columns\IconColumn;
use Illuminate\Support\ServiceProvider;
use Illuminate\Validation\ValidationException;
use Plugins\Larablog\PaidContent\Models\ArticleProduct;
use Plugins\Larablog\Payment\Domain\OrderService;
use Plugins\Larablog\Payment\Domain\ProductType;
use Plugins\Larablog\Payment\Models\Entitlement;
class PluginServiceProvider extends ServiceProvider
{
public function register(): void
{
//
}
public function boot(): void
{
$this->loadMigrationsFrom(__DIR__.'/../database/migrations');
Hook::listen('filament.article.form', function (array $components): array {
return [
Section::make(__('admin.settings.paid_content'))
->schema([
Toggle::make('paid_content.enabled')
->label(__('admin.fields.paid_enabled'))
->default(false),
TextInput::make('paid_content.price')
->label(__('admin.fields.price'))
->numeric()
->default(0),
TextInput::make('paid_content.currency')
->label(__('admin.fields.currency'))
->default('CNY'),
TextInput::make('paid_content.trial_value')
->label(__('admin.fields.trial_value'))
->numeric()
->default(200),
])
->collapsible(),
];
});
Hook::listen('filament.article.mutate_before_fill', function (array $data, mixed $record): array {
if (! $record instanceof Article) {
return $data;
}
$product = ArticleProduct::query()->where('article_id', $record->id)->first();
$data['paid_content'] = [
'enabled' => (bool) ($product?->enabled ?? false),
'price' => $product?->price ?? null,
'currency' => $product?->currency ?? 'CNY',
'trial_mode' => $product?->trial_mode ?? 'chars',
'trial_value' => $product?->trial_value ?? 200,
];
return $data;
});
Hook::listen('filament.article.mutate_before_save', function (array $data, mixed $record): array {
$paid = is_array($data['paid_content'] ?? null) ? $data['paid_content'] : [];
$enabled = (bool) ($paid['enabled'] ?? false);
if ($enabled && filled($data['read_password'] ?? null)) {
throw ValidationException::withMessages([
'read_password' => __('admin.messages.paid_password_mutex'),
'paid_content.enabled' => __('admin.messages.paid_password_mutex'),
]);
}
unset($data['paid_content']);
return $data;
});
Hook::listen('filament.article.after_save', function (Article $record, array $data): void {
$paid = $data['paid_content'] ?? null;
if (! is_array($paid)) {
return;
}
$existing = ArticleProduct::query()->where('article_id', $record->id)->first();
ArticleProduct::query()->updateOrCreate(
['article_id' => $record->id],
[
'enabled' => (bool) ($paid['enabled'] ?? false),
'price' => $paid['price'] ?? 0,
'currency' => (string) ($paid['currency'] ?? 'CNY'),
'trial_mode' => (string) ($paid['trial_mode'] ?? $existing?->trial_mode ?? 'chars'),
'trial_value' => (int) ($paid['trial_value'] ?? 200),
],
);
});
Hook::listen('filament.article.table.columns', function (array $columns): array {
return [
IconColumn::make('paid_content_enabled')
->label(__('admin.fields.paid_enabled'))
->boolean()
->getStateUsing(function (Article $record): bool {
return ArticleProduct::query()
->where('article_id', $record->id)
->where('enabled', true)
->exists();
}),
];
});
Hook::listen('article.access', function (AccessDecision $decision, array $context): AccessDecision {
$article = $context['article'] ?? null;
$user = $context['user'] ?? null;
if (! $article instanceof Article) {
return $decision;
}
$product = ArticleProduct::query()
->where('article_id', $article->id)
->where('enabled', true)
->first();
if ($product === null) {
return $decision;
}
if ($user instanceof User && $this->userHasEntitlement($user, (int) $article->id)) {
return $decision;
}
$teaser = $this->buildTeaser($article, $product);
$checkoutUrl = $this->checkoutUrl($article, $product);
return $decision->tightenWith(AccessDecision::needPurchase($teaser, $checkoutUrl));
});
}
protected function userHasEntitlement(User $user, int $articleId): bool
{
if (class_exists(OrderService::class)) {
return app(OrderService::class)->hasEntitlement((int) $user->id, ProductType::ARTICLE, $articleId);
}
return Entitlement::query()
->where('user_id', $user->id)
->where('product_type', ProductType::ARTICLE)
->where('product_id', $articleId)
->whereNull('revoked_at')
->exists();
}
protected function buildTeaser(Article $article, ArticleProduct $product): string
{
if ($product->trial_mode === 'none' || (int) $product->trial_value <= 0) {
return '';
}
$html = $article->renderedHtml();
return app(HtmlTeaser::class)->truncate($html, (int) $product->trial_value);
}
protected function checkoutUrl(Article $article, ArticleProduct $product): string
{
$returnUrl = url('/show-'.$article->id.'.shtml');
$query = http_build_query([
'product_type' => ProductType::ARTICLE,
'product_id' => $article->id,
'return_url' => $returnUrl,
]);
return url('/plugins/payment/checkout').'?'.$query;
}
}
+62
View File
@@ -0,0 +1,62 @@
# larablog/payment
Stub payment foundation: orders, transactions, entitlements, and admin order tools.
## Enable
1. Sync plugins from disk (`php artisan plugins:sync` or Admin → Plugins → Sync).
2. Enable **larablog/payment**.
3. Run migrations so plugin tables exist:
```bash
php artisan migrate
```
Tables: `orders`, `order_items`, `entitlements`, `payment_transactions`.
## Stub checkout flow
Must be logged in.
```text
GET /plugins/payment/checkout
?product_type=article
&product_id=1
&title=Demo%20article
&amount=9.90
&currency=CNY
&return_url=/
```
Flow:
1. Creates a `pending` order (+ one order item), or shows an error if an active entitlement already exists.
2. Redirects to `/plugins/payment/orders/{id}` confirm page.
3. Click **模拟支付成功** / stub pay → `POST .../pay` → marks paid, writes `payment_transactions`, upserts entitlement, dispatches `order.paid`.
4. Redirects to `return_url` (same-origin only) or `/`.
## Admin
With the plugin enabled:
- **Orders** — list / view orders (`admin.nav.orders`).
- **Mark paid** — on a pending order view page (`admin.actions.mark_paid`).
- **Payment settings** — shows this README (`admin.nav.payment_settings`).
## Admin lang keys
| Key | Purpose |
|---|---|
| `admin.nav.orders` | Orders nav |
| `admin.nav.payment_settings` | Settings/docs nav |
| `admin.models.order` / `admin.models.orders` | Resource labels |
| `admin.fields.status/amount/currency/gateway/paid_at` | Table/infolist |
| `admin.actions.mark_paid` | View action |
| `admin.messages.mark_paid_success` / `mark_paid_failed` | Notifications |
| `payment.*` | Stub checkout UI + domain errors (`lang/{locale}/payment.php`) |
## Notes
- Gateway value for this phase is `stub` only.
- `product_type` reserved values: `article`, `theme`, `membership`.
- Real WeChat / Alipay / Stripe gateways are out of scope.
@@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('orders', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained('users')->cascadeOnDelete();
$table->string('status');
$table->decimal('amount', 10, 2);
$table->string('currency')->default('CNY');
$table->string('gateway')->nullable();
$table->timestamp('paid_at')->nullable();
$table->json('meta')->nullable();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('orders');
}
};
@@ -0,0 +1,26 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('order_items', function (Blueprint $table) {
$table->id();
$table->foreignId('order_id')->constrained('orders')->cascadeOnDelete();
$table->string('product_type');
$table->unsignedBigInteger('product_id');
$table->string('title');
$table->decimal('amount', 10, 2);
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('order_items');
}
};
@@ -0,0 +1,29 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('entitlements', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained('users')->cascadeOnDelete();
$table->string('product_type');
$table->unsignedBigInteger('product_id');
$table->unsignedBigInteger('source_order_id')->nullable();
$table->timestamp('granted_at');
$table->timestamp('revoked_at')->nullable();
$table->timestamps();
$table->unique(['user_id', 'product_type', 'product_id']);
});
}
public function down(): void
{
Schema::dropIfExists('entitlements');
}
};
@@ -0,0 +1,26 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('payment_transactions', function (Blueprint $table) {
$table->id();
$table->foreignId('order_id')->constrained('orders')->cascadeOnDelete();
$table->string('gateway');
$table->string('external_id')->nullable();
$table->json('payload')->nullable();
$table->string('status');
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('payment_transactions');
}
};
+8
View File
@@ -0,0 +1,8 @@
{
"name": "larablog/payment",
"title": "Payment",
"version": "1.0.0",
"description": "Orders, Stub gateway checkout, entitlements, and admin order management.",
"provider": "Plugins\\Larablog\\Payment\\PluginServiceProvider",
"docs": "README.md"
}
@@ -0,0 +1,129 @@
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{ __('payment.checkout.title') }} #{{ $order->id }}</title>
<style>
:root {
--bg: #f3f6f4;
--card: #ffffff;
--ink: #14201a;
--muted: #5c6b63;
--line: #d7e0db;
--accent: #0f766e;
--accent-ink: #ffffff;
}
* { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
font-family: "IBM Plex Sans", "Noto Sans SC", sans-serif;
color: var(--ink);
background:
radial-gradient(circle at top left, rgba(15, 118, 110, 0.12), transparent 40%),
linear-gradient(180deg, #eef5f2 0%, var(--bg) 100%);
display: grid;
place-items: center;
padding: 2rem 1rem;
}
.panel {
width: min(32rem, 100%);
background: var(--card);
border: 1px solid var(--line);
border-radius: 1rem;
padding: 1.75rem;
box-shadow: 0 18px 40px rgba(20, 32, 26, 0.08);
}
h1 {
margin: 0 0 0.35rem;
font-size: 1.4rem;
font-family: "Fraunces", "Noto Serif SC", serif;
}
.muted { color: var(--muted); margin: 0 0 1.25rem; }
.row {
display: flex;
justify-content: space-between;
gap: 1rem;
padding: 0.65rem 0;
border-bottom: 1px solid var(--line);
font-size: 0.95rem;
}
.row:last-of-type { border-bottom: 0; margin-bottom: 1.25rem; }
.amount { font-weight: 700; font-size: 1.15rem; }
.actions { display: flex; gap: 0.75rem; flex-wrap: wrap; }
button, .link {
appearance: none;
border: 0;
border-radius: 0.65rem;
padding: 0.75rem 1rem;
font: inherit;
cursor: pointer;
text-decoration: none;
}
button {
background: var(--accent);
color: var(--accent-ink);
}
.link {
background: transparent;
color: var(--muted);
border: 1px solid var(--line);
}
.badge {
display: inline-block;
padding: 0.15rem 0.55rem;
border-radius: 999px;
background: #ecfdf5;
color: #0f766e;
font-size: 0.8rem;
}
.error {
margin-bottom: 1rem;
padding: 0.75rem 0.9rem;
border-radius: 0.65rem;
background: #fef2f2;
color: #991b1b;
}
</style>
</head>
<body>
<main class="panel">
<p class="badge">{{ __('payment.checkout.stub_badge') }}</p>
<h1>{{ __('payment.checkout.title') }}</h1>
<p class="muted">{{ __('payment.checkout.subtitle', ['id' => $order->id]) }}</p>
@if ($errors->any())
<div class="error">{{ $errors->first() }}</div>
@endif
@foreach ($order->items as $item)
<div class="row">
<span>{{ $item->title }}</span>
<span>{{ $item->amount }} {{ $order->currency }}</span>
</div>
@endforeach
<div class="row">
<span>{{ __('payment.checkout.total') }}</span>
<span class="amount">{{ $order->amount }} {{ $order->currency }}</span>
</div>
@if ($order->isPaid())
<p class="muted">{{ __('payment.checkout.already_paid') }}</p>
<div class="actions">
<a class="link" href="{{ $returnUrl }}">{{ __('payment.checkout.back') }}</a>
</div>
@else
<form method="post" action="{{ url('/plugins/payment/orders/'.$order->id.'/pay') }}">
@csrf
<input type="hidden" name="return_url" value="{{ $returnUrl }}">
<div class="actions">
<button type="submit">{{ __('payment.checkout.pay_stub') }}</button>
<a class="link" href="{{ $returnUrl }}">{{ __('payment.checkout.cancel') }}</a>
</div>
</form>
@endif
</main>
</body>
</html>
@@ -0,0 +1,35 @@
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{ __('payment.checkout.error_title') }}</title>
<style>
body {
margin: 0;
min-height: 100vh;
display: grid;
place-items: center;
font-family: "IBM Plex Sans", "Noto Sans SC", sans-serif;
background: linear-gradient(180deg, #f8faf9, #eef3f0);
color: #14201a;
padding: 1.5rem;
}
.panel {
width: min(28rem, 100%);
background: #fff;
border: 1px solid #d7e0db;
border-radius: 1rem;
padding: 1.5rem;
}
a { color: #0f766e; }
</style>
</head>
<body>
<main class="panel">
<h1>{{ __('payment.checkout.error_title') }}</h1>
<p>{{ $message }}</p>
<p><a href="{{ $returnUrl }}">{{ __('payment.checkout.back') }}</a></p>
</main>
</body>
</html>
@@ -0,0 +1,11 @@
<x-filament-panels::page>
<x-filament::section
:heading="__('admin.nav.payment_settings')"
:description="__('admin.plugins.larablog/payment.title')"
icon="heroicon-o-credit-card"
>
<div class="prose prose-sm dark:prose-invert max-w-none whitespace-pre-wrap text-sm leading-6 text-gray-700 dark:text-gray-200">
{{ $readmeExcerpt }}
</div>
</x-filament::section>
</x-filament-panels::page>
@@ -0,0 +1,195 @@
<?php
declare(strict_types=1);
namespace Plugins\Larablog\Payment\Domain;
use App\Domain\Plugin\Hook;
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Plugins\Larablog\Payment\Models\Entitlement;
use Plugins\Larablog\Payment\Models\Order;
use Plugins\Larablog\Payment\Models\OrderItem;
use Plugins\Larablog\Payment\Models\PaymentTransaction;
use RuntimeException;
class OrderService
{
public function createOrder(
User $user,
string $productType,
int $productId,
string $title,
string $amount,
string $currency = 'CNY',
string $gateway = 'stub',
): Order {
return DB::transaction(function () use ($user, $productType, $productId, $title, $amount, $currency, $gateway): Order {
// Re-check inside the transaction: two concurrent checkouts must not
// both end up with a payable order for an already owned product.
if ($this->hasEntitlement((int) $user->id, $productType, $productId)) {
throw new RuntimeException(
__('payment.errors.already_entitled', [
'product_type' => $productType,
'product_id' => $productId,
])
);
}
$pending = $this->pendingOrderFor((int) $user->id, $productType, $productId);
if ($pending !== null) {
// Reuse the order but re-price it, so an unpaid order never locks
// in a stale amount after the seller changes the price.
$pending->forceFill([
'amount' => $amount,
'currency' => $currency,
'gateway' => $gateway,
])->save();
$pending->items()->where('product_type', $productType)
->where('product_id', $productId)
->update(['title' => $title, 'amount' => $amount]);
return $pending->load('items');
}
$order = Order::query()->create([
'user_id' => $user->id,
'status' => Order::STATUS_PENDING,
'amount' => $amount,
'currency' => $currency,
'gateway' => $gateway,
]);
OrderItem::query()->create([
'order_id' => $order->id,
'product_type' => $productType,
'product_id' => $productId,
'title' => $title,
'amount' => $amount,
]);
return $order->load('items');
});
}
/**
* Reuse an existing payable order instead of stacking duplicates.
*/
public function pendingOrderFor(int $userId, string $productType, int $productId): ?Order
{
return Order::query()
->where('user_id', $userId)
->where('status', Order::STATUS_PENDING)
->whereHas('items', function ($query) use ($productType, $productId): void {
$query->where('product_type', $productType)
->where('product_id', $productId);
})
->latest('id')
->first();
}
/**
* @param array<string, mixed> $transactionPayload
*/
public function markPaid(Order $order, array $transactionPayload = []): Order
{
if ($order->isPaid()) {
return $order->loadMissing(['items', 'transactions']);
}
if (! $order->isPending()) {
throw new RuntimeException(
__('payment.errors.order_not_payable', ['status' => $order->status])
);
}
return DB::transaction(function () use ($order, $transactionPayload): Order {
$order->refresh();
if ($order->isPaid()) {
return $order->loadMissing(['items', 'transactions']);
}
if (! $order->isPending()) {
throw new RuntimeException(
__('payment.errors.order_not_payable', ['status' => $order->status])
);
}
$order->loadMissing('items');
// Refuse to charge again for something the buyer already owns from
// another order (duplicate pending orders, replayed callbacks).
foreach ($order->items as $item) {
$ownedElsewhere = Entitlement::query()
->where('user_id', $order->user_id)
->where('product_type', $item->product_type)
->where('product_id', $item->product_id)
->whereNull('revoked_at')
->where(function ($query) use ($order): void {
$query->whereNull('source_order_id')
->orWhere('source_order_id', '!=', $order->id);
})
->exists();
if ($ownedElsewhere) {
throw new RuntimeException(
__('payment.errors.already_entitled', [
'product_type' => $item->product_type,
'product_id' => $item->product_id,
])
);
}
}
$order->forceFill([
'status' => Order::STATUS_PAID,
'paid_at' => now(),
])->save();
PaymentTransaction::query()->create([
'order_id' => $order->id,
'gateway' => (string) ($order->gateway ?: 'stub'),
'external_id' => isset($transactionPayload['external_id'])
? (string) $transactionPayload['external_id']
: null,
'payload' => $transactionPayload !== [] ? $transactionPayload : ['source' => 'stub'],
'status' => 'succeeded',
]);
$order->loadMissing('items');
foreach ($order->items as $item) {
Entitlement::query()->updateOrCreate(
[
'user_id' => $order->user_id,
'product_type' => $item->product_type,
'product_id' => $item->product_id,
],
[
'source_order_id' => $order->id,
'granted_at' => now(),
'revoked_at' => null,
],
);
}
$order->load(['items', 'transactions']);
Hook::dispatch('order.paid', $order);
return $order;
});
}
public function hasEntitlement(int $userId, string $productType, int $productId): bool
{
return Entitlement::query()
->where('user_id', $userId)
->where('product_type', $productType)
->where('product_id', $productId)
->whereNull('revoked_at')
->exists();
}
}
@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace Plugins\Larablog\Payment\Domain;
final class ProductType
{
public const ARTICLE = 'article';
public const THEME = 'theme';
public const MEMBERSHIP = 'membership';
/**
* @return list<string>
*/
public static function all(): array
{
return [
self::ARTICLE,
self::THEME,
self::MEMBERSHIP,
];
}
}
@@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
namespace Plugins\Larablog\Payment\Filament\Pages;
use App\Domain\Plugin\PluginManager;
use BackedEnum;
use Filament\Pages\Page;
use Filament\Support\Icons\Heroicon;
class PaymentSettingsPage extends Page
{
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedDocumentText;
protected static ?int $navigationSort = 101;
protected string $view = 'payment::filament.pages.payment-settings';
public static function getNavigationGroup(): ?string
{
return __('admin.groups.plugins');
}
public static function getNavigationLabel(): string
{
return __('admin.nav.payment_settings');
}
public function getTitle(): string
{
return __('admin.nav.payment_settings');
}
public static function canAccess(): bool
{
return app(PluginManager::class)->isEnabled('larablog/payment');
}
public static function shouldRegisterNavigation(): bool
{
return static::canAccess();
}
/**
* @return array<string, mixed>
*/
public function getViewData(): array
{
$docs = app(PluginManager::class)->readDocs('larablog/payment') ?? '';
return [
'readmeExcerpt' => $docs !== '' ? $docs : __('admin.pages.payment_settings_empty'),
];
}
}
@@ -0,0 +1,147 @@
<?php
declare(strict_types=1);
namespace Plugins\Larablog\Payment\Filament\Resources;
use App\Domain\Plugin\PluginManager;
use BackedEnum;
use Filament\Actions\ViewAction;
use Filament\Infolists\Components\TextEntry;
use Filament\Resources\Resource;
use Filament\Schemas\Schema;
use Filament\Support\Icons\Heroicon;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
use Plugins\Larablog\Payment\Filament\Resources\OrderResource\Pages\ListOrders;
use Plugins\Larablog\Payment\Filament\Resources\OrderResource\Pages\ViewOrder;
use Plugins\Larablog\Payment\Models\Order;
class OrderResource extends Resource
{
protected static ?string $model = Order::class;
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedCreditCard;
protected static ?int $navigationSort = 100;
public static function getNavigationGroup(): ?string
{
return __('admin.groups.plugins');
}
public static function getNavigationLabel(): string
{
return __('admin.nav.orders');
}
public static function getModelLabel(): string
{
return __('admin.models.order');
}
public static function getPluralModelLabel(): string
{
return __('admin.models.orders');
}
public static function canAccess(): bool
{
return static::pluginEnabled();
}
public static function shouldRegisterNavigation(): bool
{
return static::pluginEnabled();
}
public static function canCreate(): bool
{
return false;
}
public static function canEdit($record): bool
{
return false;
}
public static function canDelete($record): bool
{
return false;
}
protected static function pluginEnabled(): bool
{
return app(PluginManager::class)->isEnabled('larablog/payment');
}
public static function form(Schema $schema): Schema
{
return $schema;
}
public static function infolist(Schema $schema): Schema
{
return $schema->components([
TextEntry::make('id')
->label(__('admin.fields.id')),
TextEntry::make('user.name')
->label(__('admin.fields.user')),
TextEntry::make('status')
->label(__('admin.fields.status'))
->badge(),
TextEntry::make('amount')
->label(__('admin.fields.amount')),
TextEntry::make('currency')
->label(__('admin.fields.currency')),
TextEntry::make('gateway')
->label(__('admin.fields.gateway')),
TextEntry::make('paid_at')
->label(__('admin.fields.paid_at'))
->dateTime(),
TextEntry::make('created_at')
->label(__('admin.fields.created_at'))
->dateTime(),
]);
}
public static function table(Table $table): Table
{
return $table
->columns([
TextColumn::make('id')
->label(__('admin.fields.id'))
->sortable(),
TextColumn::make('user.name')
->label(__('admin.fields.user'))
->searchable(),
TextColumn::make('status')
->label(__('admin.fields.status'))
->badge()
->sortable(),
TextColumn::make('amount')
->label(__('admin.fields.amount'))
->sortable(),
TextColumn::make('currency')
->label(__('admin.fields.currency')),
TextColumn::make('gateway')
->label(__('admin.fields.gateway')),
TextColumn::make('paid_at')
->label(__('admin.fields.paid_at'))
->dateTime()
->sortable(),
])
->defaultSort('id', 'desc')
->recordActions([
ViewAction::make(),
]);
}
public static function getPages(): array
{
return [
'index' => ListOrders::route('/'),
'view' => ViewOrder::route('/{record}'),
];
}
}
@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace Plugins\Larablog\Payment\Filament\Resources\OrderResource\Pages;
use Filament\Resources\Pages\ListRecords;
use Plugins\Larablog\Payment\Filament\Resources\OrderResource;
class ListOrders extends ListRecords
{
protected static string $resource = OrderResource::class;
}
@@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
namespace Plugins\Larablog\Payment\Filament\Resources\OrderResource\Pages;
use Filament\Actions\Action;
use Filament\Notifications\Notification;
use Filament\Resources\Pages\ViewRecord;
use Plugins\Larablog\Payment\Domain\OrderService;
use Plugins\Larablog\Payment\Filament\Resources\OrderResource;
use Plugins\Larablog\Payment\Models\Order;
use Throwable;
class ViewOrder extends ViewRecord
{
protected static string $resource = OrderResource::class;
protected function getHeaderActions(): array
{
return [
Action::make('markPaid')
->label(__('admin.actions.mark_paid'))
->icon('heroicon-o-check-circle')
->color('success')
->requiresConfirmation()
->visible(fn (): bool => $this->getRecord() instanceof Order && $this->getRecord()->isPending())
->action(function (OrderService $orders): void {
/** @var Order $order */
$order = $this->getRecord();
try {
$orders->markPaid($order, [
'source' => 'admin',
'marked_by' => auth()->id(),
]);
} catch (Throwable $e) {
Notification::make()
->title(__('admin.messages.mark_paid_failed'))
->body($e->getMessage())
->danger()
->send();
return;
}
$this->record->refresh();
$this->record->loadMissing('user');
Notification::make()
->title(__('admin.messages.mark_paid_success'))
->success()
->send();
}),
];
}
}
@@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
namespace Plugins\Larablog\Payment\Models;
use App\Models\User;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class Entitlement extends Model
{
protected $fillable = [
'user_id',
'product_type',
'product_id',
'source_order_id',
'granted_at',
'revoked_at',
];
protected function casts(): array
{
return [
'product_id' => 'integer',
'source_order_id' => 'integer',
'granted_at' => 'datetime',
'revoked_at' => 'datetime',
];
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function sourceOrder(): BelongsTo
{
return $this->belongsTo(Order::class, 'source_order_id');
}
public function scopeActive(Builder $query): Builder
{
return $query->whereNull('revoked_at');
}
public function isActive(): bool
{
return $this->revoked_at === null;
}
}
@@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
namespace Plugins\Larablog\Payment\Models;
use App\Models\User;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Order extends Model
{
public const STATUS_PENDING = 'pending';
public const STATUS_PAID = 'paid';
public const STATUS_CANCELLED = 'cancelled';
public const STATUS_REFUNDED = 'refunded';
protected $fillable = [
'user_id',
'status',
'amount',
'currency',
'gateway',
'paid_at',
'meta',
];
protected function casts(): array
{
return [
'amount' => 'decimal:2',
'paid_at' => 'datetime',
'meta' => 'array',
];
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function items(): HasMany
{
return $this->hasMany(OrderItem::class);
}
public function transactions(): HasMany
{
return $this->hasMany(PaymentTransaction::class);
}
public function isPending(): bool
{
return $this->status === self::STATUS_PENDING;
}
public function isPaid(): bool
{
return $this->status === self::STATUS_PAID;
}
}
@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace Plugins\Larablog\Payment\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class OrderItem extends Model
{
protected $fillable = [
'order_id',
'product_type',
'product_id',
'title',
'amount',
];
protected function casts(): array
{
return [
'product_id' => 'integer',
'amount' => 'decimal:2',
];
}
public function order(): BelongsTo
{
return $this->belongsTo(Order::class);
}
}
@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace Plugins\Larablog\Payment\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class PaymentTransaction extends Model
{
protected $fillable = [
'order_id',
'gateway',
'external_id',
'payload',
'status',
];
protected function casts(): array
{
return [
'payload' => 'array',
];
}
public function order(): BelongsTo
{
return $this->belongsTo(Order::class);
}
}
@@ -0,0 +1,198 @@
<?php
declare(strict_types=1);
namespace Plugins\Larablog\Payment;
use App\Models\Article;
use App\Models\User;
use Filament\Panel;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\ServiceProvider;
use Plugins\Larablog\Payment\Domain\OrderService;
use Plugins\Larablog\Payment\Domain\ProductType;
use Plugins\Larablog\Payment\Filament\Pages\PaymentSettingsPage;
use Plugins\Larablog\Payment\Filament\Resources\OrderResource;
use Plugins\Larablog\Payment\Models\Order;
use RuntimeException;
use Throwable;
class PluginServiceProvider extends ServiceProvider
{
public function register(): void
{
// Must run in register() so Panel::configureUsing is in place before
// Filament builds the admin panel during package boot.
Panel::configureUsing(function (Panel $panel): void {
if ($panel->getId() !== 'admin') {
return;
}
$panel
->resources([OrderResource::class])
->pages([PaymentSettingsPage::class]);
});
}
public function boot(): void
{
$this->loadMigrationsFrom(__DIR__.'/../database/migrations');
$this->loadViewsFrom(__DIR__.'/../resources/views', 'payment');
Route::middleware(['web', 'auth'])
->prefix('plugins/payment')
->group(function (): void {
Route::get('/checkout', function (Request $request, OrderService $orders) {
$returnUrl = self::safeReturnUrl($request->query('return_url'));
$productType = (string) $request->query('product_type', '');
$productId = (int) $request->query('product_id', 0);
if ($productType === '' || $productId < 1) {
return response()->view('payment::checkout.error', [
'message' => __('payment.errors.invalid_checkout'),
'returnUrl' => $returnUrl,
], 422);
}
try {
[$title, $amount, $currency] = self::resolveCheckoutProduct($productType, $productId, $request);
} catch (RuntimeException $e) {
return response()->view('payment::checkout.error', [
'message' => $e->getMessage(),
'returnUrl' => $returnUrl,
], 422);
}
/** @var User $user */
$user = $request->user();
try {
$order = $orders->createOrder(
$user,
$productType,
$productId,
$title,
$amount,
$currency,
'stub',
);
} catch (RuntimeException $e) {
return response()->view('payment::checkout.error', [
'message' => $e->getMessage(),
'returnUrl' => $returnUrl,
], 409);
} catch (Throwable $e) {
report($e);
return response()->view('payment::checkout.error', [
'message' => __('payment.errors.checkout_failed'),
'returnUrl' => $returnUrl,
], 500);
}
return redirect()->to(
url('/plugins/payment/orders/'.$order->id).'?return_url='.urlencode($returnUrl)
);
})->name('plugins.payment.checkout');
Route::get('/orders/{order}', function (Request $request, Order $order) {
abort_unless((int) $order->user_id === (int) $request->user()?->id, 403);
$order->loadMissing('items');
return view('payment::checkout.confirm', [
'order' => $order,
'returnUrl' => self::safeReturnUrl($request->query('return_url')),
]);
})->name('plugins.payment.orders.show');
Route::post('/orders/{order}/pay', function (Request $request, Order $order, OrderService $orders) {
abort_unless((int) $order->user_id === (int) $request->user()?->id, 403);
$returnUrl = self::safeReturnUrl($request->input('return_url'));
try {
$orders->markPaid($order, [
'source' => 'stub_checkout',
'user_id' => $request->user()?->id,
]);
} catch (RuntimeException $e) {
return redirect()
->to(url('/plugins/payment/orders/'.$order->id).'?return_url='.urlencode($returnUrl))
->withErrors(['pay' => $e->getMessage()]);
}
return redirect()->to($returnUrl);
})->name('plugins.payment.orders.pay');
});
}
/**
* @return array{0: string, 1: string, 2: string}
*/
protected static function resolveCheckoutProduct(string $productType, int $productId, Request $request): array
{
if ($productType === ProductType::ARTICLE) {
$productClass = 'Plugins\\Larablog\\PaidContent\\Models\\ArticleProduct';
if (! class_exists($productClass) || ! Schema::hasTable('article_products')) {
throw new RuntimeException(__('payment.errors.invalid_checkout'));
}
// Only publicly readable articles can be sold; drafts/hidden posts
// must not be purchasable by guessing an id.
$article = Article::query()
->visible()
->published()
->find($productId);
$product = $productClass::query()
->where('article_id', $productId)
->where('enabled', true)
->first();
if ($article === null || $product === null) {
throw new RuntimeException(__('payment.errors.invalid_checkout'));
}
return [
(string) $article->title,
(string) $product->price,
(string) ($product->currency ?: 'CNY'),
];
}
// Non-article stubs may still pass title/amount (theme/membership later).
$title = trim((string) $request->query('title', ''));
$amount = (string) $request->query('amount', '');
$currency = (string) $request->query('currency', 'CNY');
if ($title === '' || $amount === '') {
throw new RuntimeException(__('payment.errors.invalid_checkout'));
}
return [$title, $amount, $currency !== '' ? $currency : 'CNY'];
}
protected static function safeReturnUrl(mixed $value): string
{
$url = is_string($value) ? trim($value) : '';
if ($url === '') {
return url('/');
}
if (str_starts_with($url, '/') && ! str_starts_with($url, '//')) {
return url($url);
}
$appUrl = rtrim((string) config('app.url'), '/');
if ($appUrl !== '' && str_starts_with($url, $appUrl)) {
return $url;
}
return url('/');
}
}
@@ -0,0 +1,7 @@
{
"name": "larablog/plugin-marketplace",
"title": "Plugin Marketplace",
"version": "1.0.0",
"description": "Plugin marketplace skeleton.",
"provider": "Plugins\\Larablog\\PluginMarketplace\\PluginServiceProvider"
}
@@ -0,0 +1,19 @@
<?php
namespace Plugins\Larablog\PluginMarketplace;
use App\Domain\Plugin\Hook;
use Illuminate\Support\ServiceProvider;
class PluginServiceProvider extends ServiceProvider
{
public function boot(): void
{
Hook::listen('theme.sidebar', function (string $html): string {
$title = __('admin.plugins.larablog/plugin-marketplace.title');
$desc = __('admin.plugins.larablog/plugin-marketplace.description');
return $html.'<h3>'.e($title).'</h3><p class="note">'.e($desc).'</p>';
});
}
}
@@ -0,0 +1,7 @@
{
"name": "larablog/theme-marketplace",
"title": "Theme Marketplace",
"version": "1.0.0",
"description": "Theme marketplace skeleton.",
"provider": "Plugins\\Larablog\\ThemeMarketplace\\PluginServiceProvider"
}
@@ -0,0 +1,19 @@
<?php
namespace Plugins\Larablog\ThemeMarketplace;
use App\Domain\Plugin\Hook;
use Illuminate\Support\ServiceProvider;
class PluginServiceProvider extends ServiceProvider
{
public function boot(): void
{
Hook::listen('theme.sidebar', function (string $html): string {
$title = __('admin.plugins.larablog/theme-marketplace.title');
$desc = __('admin.plugins.larablog/theme-marketplace.description');
return $html.'<h3>'.e($title).'</h3><p class="note">'.e($desc).'</p>';
});
}
}