feat(platform-orders): 支持退款留痕与支付状态自动推进(meta.refund_receipts)

This commit is contained in:
萝卜
2026-03-10 16:46:00 +00:00
parent 3bc2e20ef7
commit aa7c655043
4 changed files with 294 additions and 0 deletions

View File

@@ -380,6 +380,60 @@ class PlatformOrderController extends Controller
return redirect()->back()->with('success', '已追加支付回执记录(仅用于对账留痕,不自动改状态)。');
}
public function addRefundReceipt(Request $request, PlatformOrder $order): RedirectResponse
{
$admin = $this->ensurePlatformAdmin($request);
$data = $request->validate([
'type' => ['required', 'string', 'max:30'],
'channel' => ['nullable', 'string', 'max:30'],
'amount' => ['required', 'numeric', 'min:0'],
'refunded_at' => ['nullable', 'date'],
'note' => ['nullable', 'string', 'max:2000'],
]);
$now = now();
$meta = (array) ($order->meta ?? []);
$refunds = (array) (data_get($meta, 'refund_receipts', []) ?? []);
$refunds[] = [
'type' => (string) $data['type'],
'channel' => (string) ($data['channel'] ?? ''),
'amount' => (float) $data['amount'],
'refunded_at' => $data['refunded_at'] ? (string) $data['refunded_at'] : null,
'note' => (string) ($data['note'] ?? ''),
'created_at' => $now->toDateTimeString(),
'admin_id' => $admin->id,
];
data_set($meta, 'refund_receipts', $refunds);
// 可治理辅助:自动推进退款标记(仅当订单本身已支付,且退款金额>0 时)
if ($order->payment_status === 'paid' && (float) $data['amount'] > 0) {
$totalRefunded = 0.0;
foreach ($refunds as $r) {
$totalRefunded += (float) (data_get($r, 'amount') ?? 0);
}
$paidAmount = (float) ($order->paid_amount ?? 0);
// 退款总额 >= 已付金额 => 视为已退款;否则视为部分退款
if ($paidAmount > 0 && $totalRefunded >= $paidAmount) {
$order->payment_status = 'refunded';
$order->refunded_at = $order->refunded_at ?: now();
} else {
$order->payment_status = 'partially_refunded';
$order->refunded_at = $order->refunded_at ?: now();
}
}
$order->meta = $meta;
$order->save();
return redirect()->back()->with('success', '已追加退款记录(用于退款轨迹留痕)。');
}
public function markActivated(Request $request, PlatformOrder $order): RedirectResponse
{
$admin = $this->ensurePlatformAdmin($request);
@@ -479,6 +533,10 @@ class PlatformOrderController extends Controller
'最近回执时间',
'最近回执金额',
'最近回执渠道',
'退款记录数',
'最近退款时间',
'最近退款金额',
'最近退款渠道',
];
if ($includeMeta) {
@@ -504,6 +562,10 @@ class PlatformOrderController extends Controller
$receiptCount = count($receipts);
$latestReceipt = $receiptCount > 0 ? end($receipts) : null;
$refunds = (array) (data_get($order->meta, 'refund_receipts', []) ?? []);
$refundCount = count($refunds);
$latestRefund = $refundCount > 0 ? end($refunds) : null;
$row = [
$order->id,
$order->order_no,
@@ -530,6 +592,10 @@ class PlatformOrderController extends Controller
(string) (data_get($latestReceipt, 'paid_at') ?? ''),
(float) (data_get($latestReceipt, 'amount') ?? 0),
(string) (data_get($latestReceipt, 'channel') ?? ''),
$refundCount,
(string) (data_get($latestRefund, 'refunded_at') ?? ''),
(float) (data_get($latestRefund, 'amount') ?? 0),
(string) (data_get($latestRefund, 'channel') ?? ''),
];
if ($includeMeta) {

View File

@@ -87,6 +87,7 @@
$activationError = data_get($order->meta, 'subscription_activation_error');
$audit = (array) (data_get($order->meta, 'audit', []) ?? []);
$paymentReceipts = (array) (data_get($order->meta, 'payment_receipts', []) ?? []);
$refundReceipts = (array) (data_get($order->meta, 'refund_receipts', []) ?? []);
@endphp
@php
@@ -166,6 +167,64 @@
</details>
</div>
<div class="card mb-20">
<h3>退款记录(退款轨迹留痕)</h3>
<p class="muted muted-tight">用于记录退款动作与对账轨迹(先落 meta不引入独立表。追加退款后系统会自动把支付状态推进为“部分退款/已退款”(仅在订单当前为已支付时)。</p>
@if(count($refundReceipts) > 0)
@php $items = array_slice(array_reverse($refundReceipts), 0, 20); @endphp
<table>
<thead>
<tr>
<th style="width:140px;">类型</th>
<th style="width:120px;">渠道</th>
<th style="width:120px;">金额</th>
<th style="width:200px;">退款时间</th>
<th style="width:160px;">记录时间</th>
<th style="width:100px;">管理员</th>
<th>备注</th>
</tr>
</thead>
<tbody>
@foreach($items as $r)
<tr>
<td>{{ data_get($r, 'type') ?: '-' }}</td>
<td>{{ data_get($r, 'channel') ?: '-' }}</td>
<td>¥{{ number_format((float) (data_get($r, 'amount') ?? 0), 2) }}</td>
<td>{{ data_get($r, 'refunded_at') ?: '-' }}</td>
<td>{{ data_get($r, 'created_at') ?: '-' }}</td>
<td>{{ data_get($r, 'admin_id') ?: '-' }}</td>
<td class="muted">{{ data_get($r, 'note') ?: '' }}</td>
</tr>
@endforeach
</tbody>
</table>
@else
<p class="muted">暂无退款记录。</p>
@endif
<details style="margin-top:12px;">
<summary class="muted">追加一条退款记录(会自动推进支付状态)</summary>
<form method="post" action="/admin/platform-orders/{{ $order->id }}/add-refund-receipt" style="margin-top:10px;" onsubmit="return confirm('确认追加退款记录?该操作会写入退款轨迹,并可能推进支付状态为部分退款/已退款');">
@csrf
<div class="grid-3">
<input type="text" name="type" placeholder="类型refund/chargeback/手工退款等" value="refund">
<input type="text" name="channel" placeholder="渠道(可选)" value="{{ (string) ($order->payment_channel ?? '') }}">
<input type="number" step="0.01" name="amount" placeholder="金额" value="0.00">
</div>
<div style="margin-top:10px;">
<input type="text" name="refunded_at" placeholder="退款时间YYYY-mm-dd HH:ii:ss可选" value="{{ optional($order->refunded_at)->format('Y-m-d H:i:s') }}">
</div>
<div style="margin-top:10px;">
<textarea name="note" placeholder="备注(可选,用于退款说明/对账)" rows="3" style="width:100%;"></textarea>
</div>
<div style="margin-top:10px;">
<button type="submit">追加退款记录</button>
</div>
</form>
</details>
</div>
<div class="card mb-20">
<h3>订阅同步记录</h3>
@if($activation)

View File

@@ -110,6 +110,7 @@ Route::prefix('admin')->group(function () {
Route::post('/platform-orders/{order}/activate-subscription', [PlatformOrderController::class, 'activateSubscription']);
Route::post('/platform-orders/{order}/mark-paid-and-activate', [PlatformOrderController::class, 'markPaidAndActivate']);
Route::post('/platform-orders/{order}/add-payment-receipt', [PlatformOrderController::class, 'addPaymentReceipt']);
Route::post('/platform-orders/{order}/add-refund-receipt', [PlatformOrderController::class, 'addRefundReceipt']);
Route::post('/platform-orders/{order}/mark-activated', [PlatformOrderController::class, 'markActivated']);
Route::get('/site-subscriptions', [SiteSubscriptionController::class, 'index']);

View File

@@ -0,0 +1,168 @@
<?php
namespace Tests\Feature;
use App\Models\Merchant;
use App\Models\Plan;
use App\Models\PlatformOrder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class AdminPlatformOrderRefundReceiptTest extends TestCase
{
use RefreshDatabase;
protected function loginAsPlatformAdmin(): void
{
$this->seed();
$this->post('/admin/login', [
'email' => 'platform.admin@demo.local',
'password' => 'Platform@123456',
])->assertRedirect('/admin');
}
public function test_platform_admin_can_add_refund_receipt_and_mark_partially_refunded(): void
{
$this->loginAsPlatformAdmin();
$merchant = Merchant::query()->firstOrFail();
$plan = Plan::query()->create([
'code' => 'refund_test',
'name' => '退款测试(月付)',
'billing_cycle' => 'monthly',
'price' => 30,
'list_price' => 30,
'status' => 'active',
'sort' => 10,
'published_at' => now(),
]);
$order = PlatformOrder::query()->create([
'merchant_id' => $merchant->id,
'plan_id' => $plan->id,
'order_no' => 'PO_REFUND_0001',
'order_type' => 'new_purchase',
'status' => 'activated',
'payment_status' => 'paid',
'plan_name' => $plan->name,
'billing_cycle' => $plan->billing_cycle,
'period_months' => 1,
'quantity' => 1,
'payable_amount' => 30,
'paid_amount' => 30,
'placed_at' => now(),
'paid_at' => now(),
'activated_at' => now(),
]);
$this->post('/admin/platform-orders/' . $order->id . '/add-refund-receipt', [
'type' => 'refund',
'channel' => 'alipay',
'amount' => 10,
'refunded_at' => now()->format('Y-m-d H:i:s'),
'note' => '部分退款',
])->assertRedirect();
$order->refresh();
$refunds = (array) data_get($order->meta, 'refund_receipts', []);
$this->assertCount(1, $refunds);
$this->assertSame('refund', data_get($refunds[0], 'type'));
$this->assertSame('alipay', data_get($refunds[0], 'channel'));
$this->assertSame(10.0, (float) data_get($refunds[0], 'amount'));
$this->assertSame('partially_refunded', $order->payment_status);
$this->assertNotNull($order->refunded_at);
}
public function test_platform_admin_can_add_refund_receipt_and_mark_fully_refunded_when_total_refund_reaches_paid_amount(): void
{
$this->loginAsPlatformAdmin();
$merchant = Merchant::query()->firstOrFail();
$plan = Plan::query()->create([
'code' => 'refund_test_full',
'name' => '退款测试(全额)',
'billing_cycle' => 'monthly',
'price' => 30,
'list_price' => 30,
'status' => 'active',
'sort' => 10,
'published_at' => now(),
]);
$order = PlatformOrder::query()->create([
'merchant_id' => $merchant->id,
'plan_id' => $plan->id,
'order_no' => 'PO_REFUND_0002',
'order_type' => 'new_purchase',
'status' => 'activated',
'payment_status' => 'paid',
'plan_name' => $plan->name,
'billing_cycle' => $plan->billing_cycle,
'period_months' => 1,
'quantity' => 1,
'payable_amount' => 30,
'paid_amount' => 30,
'placed_at' => now(),
'paid_at' => now(),
'activated_at' => now(),
]);
$this->post('/admin/platform-orders/' . $order->id . '/add-refund-receipt', [
'type' => 'refund',
'channel' => 'wechat',
'amount' => 30,
'refunded_at' => now()->format('Y-m-d H:i:s'),
'note' => '全额退款',
])->assertRedirect();
$order->refresh();
$this->assertSame('refunded', $order->payment_status);
$this->assertNotNull($order->refunded_at);
$refunds = (array) data_get($order->meta, 'refund_receipts', []);
$this->assertCount(1, $refunds);
}
public function test_guest_cannot_add_refund_receipt(): void
{
$this->seed();
$merchant = Merchant::query()->firstOrFail();
$plan = Plan::query()->create([
'code' => 'refund_test_guest',
'name' => '退款测试(游客)',
'billing_cycle' => 'monthly',
'price' => 30,
'list_price' => 30,
'status' => 'active',
'sort' => 10,
'published_at' => now(),
]);
$order = PlatformOrder::query()->create([
'merchant_id' => $merchant->id,
'plan_id' => $plan->id,
'order_no' => 'PO_REFUND_0003',
'order_type' => 'new_purchase',
'status' => 'activated',
'payment_status' => 'paid',
'plan_name' => $plan->name,
'billing_cycle' => $plan->billing_cycle,
'period_months' => 1,
'quantity' => 1,
'payable_amount' => 30,
'paid_amount' => 30,
'placed_at' => now(),
'paid_at' => now(),
'activated_at' => now(),
]);
$this->post('/admin/platform-orders/' . $order->id . '/add-refund-receipt', [
'type' => 'refund',
'amount' => 10,
])->assertRedirect('/admin/login');
}
}