feat(platform-orders): 支持退款留痕与支付状态自动推进(meta.refund_receipts)
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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']);
|
||||
|
||||
168
tests/Feature/AdminPlatformOrderRefundReceiptTest.php
Normal file
168
tests/Feature/AdminPlatformOrderRefundReceiptTest.php
Normal 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');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user