Phase 6: security & ops hardening (T6.1-T6.5)

- T6.1: named rate limiters (api-read/api-write/api-auth/api-webhooks),
  applied per module route group with tighter limits on booking/payment
  writes and the KBZ webhook than read-only catalog/routing endpoints.
- T6.2: install spatie/laravel-activitylog; LogsActivity on Booking/
  Payment/Refund status transitions and catalog/pricing admin CRUD
  (EvCompany, Destination, DepartureTimeSlot, EvRoute, RoutePricing).
  New IdentityPlugin with a read-only AuditLogResource gated by
  view_audit_log.
- T6.3: JSON error envelope for api/* in bootstrap/app.php (401/403/404/
  405/429/500 fallback), plus PaymentGatewayException (422 declined /
  502 unavailable).
- T6.4: feature tests proving the FastAPI agent token gets 403 on
  refund/cancel-not-owned and 405 (no write handler) on catalog/routing
  writes.
- T6.5: install gboquizosanchez/filament-log-viewer with a custom
  Filament admin theme (required for its views' Tailwind classes to
  compile), LOG_CHANNEL/FILAMENT_LOG_VIEWER_DRIVER=daily, registered
  under Operations in the sidebar.

252 tests passing.
This commit is contained in:
Nyan Lin Paing
2026-08-09 20:59:00 +07:00
parent e2e7902307
commit 46f9b8d5a3
43 changed files with 1176 additions and 25 deletions
@@ -0,0 +1,57 @@
<?php
namespace Modules\Payment\Exceptions;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use RuntimeException;
/**
* Carries a gateway's own error code/message for an unexpected failure that
* isn't already captured as a typed Failed result (e.g. a malformed
* response the gateway strategy can't parse into a PaymentResultData/
* RefundResultData). Never allowed to surface as a raw 500 (domain.md §6,
* T6.3): a declined/rejected call from the gateway itself is a 422 (client
* can retry/fix), an unreachable/misbehaving gateway is a 502.
*/
class PaymentGatewayException extends RuntimeException
{
private function __construct(
string $message,
private readonly int $statusCode,
private readonly ?string $gatewayCode = null,
) {
parent::__construct($message);
}
/**
* The gateway responded but rejected/declined the request surfaced as
* 422 since it's a business outcome the caller can act on.
*/
public static function declined(string $message, ?string $gatewayCode = null): self
{
return new self($message, 422, $gatewayCode);
}
/**
* The gateway didn't respond usefully at all (unreachable, malformed
* payload, unexpected HTTP status) surfaced as 502, our fault for
* depending on it, not the caller's.
*/
public static function unavailable(string $message, ?string $gatewayCode = null): self
{
return new self($message, 502, $gatewayCode);
}
public function render(Request $request): ?JsonResponse
{
if ($request->expectsJson()) {
return response()->json([
'message' => $this->getMessage(),
'gateway_code' => $this->gatewayCode,
], $this->statusCode);
}
return null;
}
}
+15 -1
View File
@@ -10,6 +10,8 @@ use Modules\Booking\Models\Booking;
use Modules\Payment\Database\Factories\PaymentFactory;
use Modules\Payment\Enums\PaymentMethod;
use Modules\Payment\Enums\PaymentStatus;
use Spatie\Activitylog\Models\Concerns\LogsActivity;
use Spatie\Activitylog\Support\LogOptions;
/**
* One attempt to pay for a Booking through a gateway a Booking can have
@@ -19,7 +21,19 @@ use Modules\Payment\Enums\PaymentStatus;
class Payment extends Model
{
/** @use HasFactory<PaymentFactory> */
use HasFactory;
use HasFactory, LogsActivity;
/**
* Audit trail on status transitions only (domain.md §6; T6.2).
*/
public function getActivitylogOptions(): LogOptions
{
return LogOptions::defaults()
->logOnly(['status'])
->logOnlyDirty()
->dontLogEmptyChanges()
->useLogName('payment');
}
/**
* @var list<string>
+15 -1
View File
@@ -8,6 +8,8 @@ use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Modules\Payment\Database\Factories\RefundFactory;
use Modules\Payment\Enums\RefundStatus;
use Spatie\Activitylog\Models\Concerns\LogsActivity;
use Spatie\Activitylog\Support\LogOptions;
/**
* A reversal against a specific successful Payment (not against the Booking
@@ -17,7 +19,19 @@ use Modules\Payment\Enums\RefundStatus;
class Refund extends Model
{
/** @use HasFactory<RefundFactory> */
use HasFactory;
use HasFactory, LogsActivity;
/**
* Audit trail on status transitions only (domain.md §6; T6.2).
*/
public function getActivitylogOptions(): LogOptions
{
return LogOptions::defaults()
->logOnly(['status'])
->logOnlyDirty()
->dontLogEmptyChanges()
->useLogName('refund');
}
/**
* @var list<string>