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:
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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.',
|
||||
]);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
+29
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
¤cy=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.
|
||||
+28
@@ -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');
|
||||
}
|
||||
};
|
||||
+26
@@ -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');
|
||||
}
|
||||
};
|
||||
+29
@@ -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');
|
||||
}
|
||||
};
|
||||
+26
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -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>';
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user