Upgrade PHPStan to 2.x and raise analysis level from 6 to 10
CI / No Debug Code (pull_request) Successful in 3s
CI / Tests (PHP 8.2) (pull_request) Successful in 48s
CI / Tests (PHP 8.3) (pull_request) Successful in 52s
CI / Coding Standards (pull_request) Successful in 57s
CI / Tests (PHP 8.1) (pull_request) Successful in 1m1s
CI / PHPStan (pull_request) Successful in 1m11s
CI / Build Plugin Zip (pull_request) Has been skipped
CI / No Debug Code (pull_request) Successful in 3s
CI / Tests (PHP 8.2) (pull_request) Successful in 48s
CI / Tests (PHP 8.3) (pull_request) Successful in 52s
CI / Coding Standards (pull_request) Successful in 57s
CI / Tests (PHP 8.1) (pull_request) Successful in 1m1s
CI / PHPStan (pull_request) Successful in 1m11s
CI / Build Plugin Zip (pull_request) Has been skipped
- Bump phpstan/phpstan ^2.0 and szepeviktor/phpstan-wordpress ^2.0 - Move the analysis level into phpstan.neon (single source) and raise it to 10 - Add Val, a runtime coercion helper that narrows untyped WordPress boundary values (wpdb rows, REST params, superglobals, options) with explicit checks instead of blind casts, plus unit tests - Type value-object fromRow() params as stdClass (what wpdb returns) and map columns through Val so unexpected shapes degrade safely - Use %i identifier placeholders for table names in all wpdb::prepare() calls so every query string is a literal and identifiers are escaped by WordPress; raises the minimum WordPress version to 6.2 where %i was introduced - Guard wpdb::prepare() null result before wpdb::query() in updateTax() - Fix nullable get_permalink()/strtotime() handling, list types at REST and capability call sites, dead null-coalescing on checked superglobals, and narrow get_users() results before mapping - Register Val method names with the ValidatedSanitizedInput sniff so it validates the real sanitizer around each superglobal read - Update repository unit tests for the %i placeholder arguments Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -3,6 +3,8 @@ declare(strict_types=1);
|
||||
|
||||
namespace Unsupervised\Schedular\Payment;
|
||||
|
||||
use Unsupervised\Schedular\Val;
|
||||
|
||||
/**
|
||||
* Resolves the billing method for a student: a per-student override if set,
|
||||
* otherwise the studio default — card when Stripe is configured, e-transfer when
|
||||
@@ -15,7 +17,7 @@ class BillingMethodResolver {
|
||||
public function __construct( private StudioSettings $settings ) {}
|
||||
|
||||
public function resolve( int $studentId ): string {
|
||||
$override = (string) get_user_meta( $studentId, self::META_METHOD, true );
|
||||
$override = Val::string( get_user_meta( $studentId, self::META_METHOD, true ) );
|
||||
if ( in_array( $override, Payment::VALID_METHODS, true ) ) {
|
||||
return $override;
|
||||
}
|
||||
|
||||
+19
-17
@@ -3,6 +3,8 @@ declare(strict_types=1);
|
||||
|
||||
namespace Unsupervised\Schedular\Payment;
|
||||
|
||||
use Unsupervised\Schedular\Val;
|
||||
|
||||
class Payment {
|
||||
|
||||
public const METHOD_CARD = 'card';
|
||||
@@ -50,24 +52,24 @@ class Payment {
|
||||
public readonly ?int $id = null,
|
||||
) {}
|
||||
|
||||
public static function fromRow( object $row ): self {
|
||||
public static function fromRow( \stdClass $row ): self {
|
||||
return new self(
|
||||
studentId: (int) $row->student_id,
|
||||
instructorId: (int) $row->instructor_id,
|
||||
registrationType: $row->registration_type,
|
||||
registrationId: (int) $row->registration_id,
|
||||
amount: (float) $row->amount,
|
||||
currency: $row->currency,
|
||||
method: $row->method,
|
||||
status: $row->status,
|
||||
taxRate: (float) $row->tax_rate,
|
||||
taxAmount: (float) $row->tax_amount,
|
||||
etransferEmail: $row->etransfer_email,
|
||||
stripePaymentIntentId: $row->stripe_payment_intent_id,
|
||||
receiptNumber: $row->receipt_number,
|
||||
receiptSentAt: $row->receipt_sent_at,
|
||||
paidAt: $row->paid_at,
|
||||
id: (int) $row->id,
|
||||
studentId: Val::int( $row->student_id ),
|
||||
instructorId: Val::int( $row->instructor_id ),
|
||||
registrationType: Val::string( $row->registration_type ),
|
||||
registrationId: Val::int( $row->registration_id ),
|
||||
amount: Val::float( $row->amount ),
|
||||
currency: Val::string( $row->currency ),
|
||||
method: Val::string( $row->method ),
|
||||
status: Val::string( $row->status ),
|
||||
taxRate: Val::float( $row->tax_rate ),
|
||||
taxAmount: Val::float( $row->tax_amount ),
|
||||
etransferEmail: Val::stringOrNull( $row->etransfer_email ),
|
||||
stripePaymentIntentId: Val::stringOrNull( $row->stripe_payment_intent_id ),
|
||||
receiptNumber: Val::stringOrNull( $row->receipt_number ),
|
||||
receiptSentAt: Val::stringOrNull( $row->receipt_sent_at ),
|
||||
paidAt: Val::stringOrNull( $row->paid_at ),
|
||||
id: Val::int( $row->id ),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
namespace Unsupervised\Schedular\Payment;
|
||||
|
||||
use Unsupervised\Schedular\Auth\RoleManager;
|
||||
use Unsupervised\Schedular\Val;
|
||||
|
||||
class PaymentController {
|
||||
|
||||
@@ -19,10 +20,10 @@ class PaymentController {
|
||||
|
||||
if ( isset( $_POST['usc_action'] ) && check_admin_referer( 'usc_payment_action' ) ) {
|
||||
// phpcs:ignore WordPress.Security.NonceVerification.Missing -- nonce checked above.
|
||||
if ( 'mark_paid' === sanitize_key( wp_unslash( $_POST['usc_action'] ?? '' ) ) ) {
|
||||
if ( 'mark_paid' === sanitize_key( Val::string( wp_unslash( $_POST['usc_action'] ) ) ) ) {
|
||||
// phpcs:disable WordPress.Security.NonceVerification.Missing
|
||||
$paymentId = absint( $_POST['payment_id'] ?? 0 );
|
||||
$email = sanitize_email( wp_unslash( $_POST['etransfer_email'] ?? '' ) );
|
||||
$paymentId = absint( Val::int( $_POST['payment_id'] ?? 0 ) );
|
||||
$email = sanitize_email( Val::string( wp_unslash( $_POST['etransfer_email'] ?? '' ) ) );
|
||||
// phpcs:enable WordPress.Security.NonceVerification.Missing
|
||||
if ( $paymentId > 0 ) {
|
||||
// Record the destination it was actually sent to before confirming.
|
||||
|
||||
@@ -4,11 +4,17 @@ declare(strict_types=1);
|
||||
namespace Unsupervised\Schedular\Payment;
|
||||
|
||||
use Unsupervised\Schedular\Auth\RoleManager;
|
||||
use Unsupervised\Schedular\Val;
|
||||
|
||||
class PaymentEndpoint {
|
||||
|
||||
public function __construct( private PaymentService $service ) {}
|
||||
|
||||
/**
|
||||
* Registers this endpoint's REST routes.
|
||||
*
|
||||
* @param non-falsy-string $route_namespace REST namespace the routes are registered under (e.g. `us-scheduler/v1`).
|
||||
*/
|
||||
public function registerRoutes( string $route_namespace ): void {
|
||||
register_rest_route(
|
||||
$route_namespace,
|
||||
@@ -64,8 +70,8 @@ class PaymentEndpoint {
|
||||
* (Stripe client secret for card; display data for e-transfer/comp).
|
||||
*/
|
||||
public function createIntent( \WP_REST_Request $request ): \WP_REST_Response|\WP_Error {
|
||||
$type = (string) $request->get_param( 'registration_type' );
|
||||
$registrationId = absint( $request->get_param( 'registration_id' ) );
|
||||
$type = Val::string( $request->get_param( 'registration_type' ) );
|
||||
$registrationId = absint( Val::int( $request->get_param( 'registration_id' ) ) );
|
||||
|
||||
$result = $this->service->createIntent( $type, $registrationId, get_current_user_id() );
|
||||
if ( null === $result ) {
|
||||
@@ -99,7 +105,7 @@ class PaymentEndpoint {
|
||||
* Studio admin marks a pending payment (e-transfer) received.
|
||||
*/
|
||||
public function markPaid( \WP_REST_Request $request ): \WP_REST_Response|\WP_Error {
|
||||
$id = absint( $request->get_param( 'id' ) );
|
||||
$id = absint( Val::int( $request->get_param( 'id' ) ) );
|
||||
|
||||
if ( ! $this->service->markPaid( $id ) ) {
|
||||
return new \WP_Error( 'not_found', __( 'Payment not found.', 'unsupervised-schedular' ), [ 'status' => 404 ] );
|
||||
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
namespace Unsupervised\Schedular\Payment;
|
||||
|
||||
use Unsupervised\Schedular\Auth\RoleManager;
|
||||
use Unsupervised\Schedular\Val;
|
||||
|
||||
class PaymentReportController {
|
||||
|
||||
@@ -21,8 +22,8 @@ class PaymentReportController {
|
||||
}
|
||||
|
||||
// phpcs:disable WordPress.Security.NonceVerification.Recommended -- read-only report filters, no state change.
|
||||
$month = $this->sanitizeMonth( isset( $_GET['month'] ) ? sanitize_text_field( wp_unslash( $_GET['month'] ) ) : '' );
|
||||
$instructorId = isset( $_GET['instructor_id'] ) ? absint( $_GET['instructor_id'] ) : 0;
|
||||
$month = $this->sanitizeMonth( isset( $_GET['month'] ) ? sanitize_text_field( Val::string( wp_unslash( $_GET['month'] ) ) ) : '' );
|
||||
$instructorId = isset( $_GET['instructor_id'] ) ? absint( Val::int( $_GET['instructor_id'] ) ) : 0;
|
||||
// phpcs:enable WordPress.Security.NonceVerification.Recommended
|
||||
|
||||
$instructorId = $this->scopeInstructor( $instructorId );
|
||||
@@ -58,8 +59,8 @@ class PaymentReportController {
|
||||
check_admin_referer( self::EXPORT_ACTION );
|
||||
|
||||
// phpcs:disable WordPress.Security.NonceVerification.Recommended -- nonce checked above.
|
||||
$month = $this->sanitizeMonth( isset( $_GET['month'] ) ? sanitize_text_field( wp_unslash( $_GET['month'] ) ) : '' );
|
||||
$instructorId = isset( $_GET['instructor_id'] ) ? absint( $_GET['instructor_id'] ) : 0;
|
||||
$month = $this->sanitizeMonth( isset( $_GET['month'] ) ? sanitize_text_field( Val::string( wp_unslash( $_GET['month'] ) ) ) : '' );
|
||||
$instructorId = isset( $_GET['instructor_id'] ) ? absint( Val::int( $_GET['instructor_id'] ) ) : 0;
|
||||
// phpcs:enable WordPress.Security.NonceVerification.Recommended
|
||||
|
||||
$instructorId = $this->scopeInstructor( $instructorId );
|
||||
@@ -92,7 +93,8 @@ class PaymentReportController {
|
||||
*/
|
||||
private function buildReport( string $month, int $instructorId ): PaymentReport {
|
||||
$start = $month . '-01 00:00:00';
|
||||
$end = gmdate( 'Y-m-d H:i:s', strtotime( $month . '-01 00:00:00 +1 month' ) );
|
||||
$endTs = strtotime( $month . '-01 00:00:00 +1 month' );
|
||||
$end = false === $endTs ? $start : gmdate( 'Y-m-d H:i:s', $endTs );
|
||||
|
||||
$rows = array_map(
|
||||
static function ( Payment $payment ): array {
|
||||
|
||||
@@ -55,7 +55,8 @@ class PaymentRepository {
|
||||
public function findByStripeIntentId( string $intentId ): ?Payment {
|
||||
$row = $this->db->get_row(
|
||||
$this->db->prepare(
|
||||
"SELECT * FROM {$this->table} WHERE stripe_payment_intent_id = %s ORDER BY id DESC LIMIT 1",
|
||||
'SELECT * FROM %i WHERE stripe_payment_intent_id = %s ORDER BY id DESC LIMIT 1',
|
||||
$this->table,
|
||||
$intentId
|
||||
)
|
||||
);
|
||||
@@ -77,14 +78,15 @@ class PaymentRepository {
|
||||
* Set a payment's tax rate and recompute the tax amount from its subtotal.
|
||||
*/
|
||||
public function updateTax( int $id, float $rate ): bool {
|
||||
return false !== $this->db->query(
|
||||
$this->db->prepare(
|
||||
"UPDATE {$this->table} SET tax_rate = %f, tax_amount = ROUND( amount * %f / 100, 2 ) WHERE id = %d",
|
||||
$rate,
|
||||
$rate,
|
||||
$id
|
||||
)
|
||||
$sql = $this->db->prepare(
|
||||
'UPDATE %i SET tax_rate = %f, tax_amount = ROUND( amount * %f / 100, 2 ) WHERE id = %d',
|
||||
$this->table,
|
||||
$rate,
|
||||
$rate,
|
||||
$id
|
||||
);
|
||||
|
||||
return null !== $sql && false !== $this->db->query( $sql );
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -94,8 +96,8 @@ class PaymentRepository {
|
||||
* @return list<Payment>
|
||||
*/
|
||||
public function findPaidBetween( string $from, string $to, int $instructorId = 0 ): array {
|
||||
$sql = "SELECT * FROM {$this->table} WHERE status = %s AND paid_at >= %s AND paid_at < %s";
|
||||
$params = [ Payment::STATUS_PAID, $from, $to ];
|
||||
$sql = 'SELECT * FROM %i WHERE status = %s AND paid_at >= %s AND paid_at < %s';
|
||||
$params = [ $this->table, Payment::STATUS_PAID, $from, $to ];
|
||||
|
||||
if ( $instructorId > 0 ) {
|
||||
$sql .= ' AND instructor_id = %d';
|
||||
@@ -111,7 +113,7 @@ class PaymentRepository {
|
||||
|
||||
public function findById( int $id ): ?Payment {
|
||||
$row = $this->db->get_row(
|
||||
$this->db->prepare( "SELECT * FROM {$this->table} WHERE id = %d", $id )
|
||||
$this->db->prepare( 'SELECT * FROM %i WHERE id = %d', $this->table, $id )
|
||||
);
|
||||
|
||||
return $row ? Payment::fromRow( $row ) : null;
|
||||
@@ -120,7 +122,8 @@ class PaymentRepository {
|
||||
public function findByRegistration( string $registrationType, int $registrationId ): ?Payment {
|
||||
$row = $this->db->get_row(
|
||||
$this->db->prepare(
|
||||
"SELECT * FROM {$this->table} WHERE registration_type = %s AND registration_id = %d ORDER BY id DESC LIMIT 1",
|
||||
'SELECT * FROM %i WHERE registration_type = %s AND registration_id = %d ORDER BY id DESC LIMIT 1',
|
||||
$this->table,
|
||||
$registrationType,
|
||||
$registrationId
|
||||
)
|
||||
@@ -137,7 +140,8 @@ class PaymentRepository {
|
||||
public function findPending(): array {
|
||||
$rows = $this->db->get_results(
|
||||
$this->db->prepare(
|
||||
"SELECT * FROM {$this->table} WHERE status = %s ORDER BY created_at DESC",
|
||||
'SELECT * FROM %i WHERE status = %s ORDER BY created_at DESC',
|
||||
$this->table,
|
||||
Payment::STATUS_PENDING
|
||||
)
|
||||
);
|
||||
|
||||
@@ -67,8 +67,8 @@ class StripeGateway {
|
||||
* Seam around the Stripe PaymentIntents create call so tests can stub the
|
||||
* network request.
|
||||
*
|
||||
* @param array<string, mixed> $params
|
||||
* @param array<string, mixed> $options
|
||||
* @param array{amount: int, currency: string, metadata: array<string, string>, description: string} $params
|
||||
* @param array{idempotency_key?: string} $options
|
||||
*/
|
||||
protected function paymentIntentsCreate( array $params, array $options ): PaymentIntent {
|
||||
return $this->client()->paymentIntents->create( $params, $options );
|
||||
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
namespace Unsupervised\Schedular\Payment;
|
||||
|
||||
use Unsupervised\Schedular\Auth\RoleManager;
|
||||
use Unsupervised\Schedular\Val;
|
||||
|
||||
class StudioSettings {
|
||||
|
||||
@@ -16,11 +17,11 @@ class StudioSettings {
|
||||
public const OPT_HST_RATE = 'us_hst_rate';
|
||||
|
||||
public function publishableKey(): string {
|
||||
return (string) get_option( self::OPT_PUBLISHABLE, '' );
|
||||
return Val::string( get_option( self::OPT_PUBLISHABLE, '' ) );
|
||||
}
|
||||
|
||||
public function secretKey(): string {
|
||||
return (string) get_option( self::OPT_SECRET, '' );
|
||||
return Val::string( get_option( self::OPT_SECRET, '' ) );
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -28,7 +29,7 @@ class StudioSettings {
|
||||
* webhook requests genuinely came from Stripe. Empty until configured.
|
||||
*/
|
||||
public function webhookSecret(): string {
|
||||
return (string) get_option( self::OPT_WEBHOOK_SECRET, '' );
|
||||
return Val::string( get_option( self::OPT_WEBHOOK_SECRET, '' ) );
|
||||
}
|
||||
|
||||
public function mode(): string {
|
||||
@@ -36,7 +37,7 @@ class StudioSettings {
|
||||
}
|
||||
|
||||
public function currency(): string {
|
||||
$currency = (string) get_option( self::OPT_CURRENCY, 'CAD' );
|
||||
$currency = Val::string( get_option( self::OPT_CURRENCY, 'CAD' ) );
|
||||
|
||||
return '' !== $currency ? strtoupper( $currency ) : 'CAD';
|
||||
}
|
||||
@@ -46,14 +47,14 @@ class StudioSettings {
|
||||
* no override).
|
||||
*/
|
||||
public function etransferEmail(): string {
|
||||
return (string) get_option( self::OPT_ETRANSFER_EMAIL, '' );
|
||||
return Val::string( get_option( self::OPT_ETRANSFER_EMAIL, '' ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Default HST/tax rate as a percentage (e.g. 13.0). 0 means no tax.
|
||||
*/
|
||||
public function hstRate(): float {
|
||||
return max( 0.0, (float) get_option( self::OPT_HST_RATE, 0 ) );
|
||||
return max( 0.0, Val::float( get_option( self::OPT_HST_RATE, 0 ) ) );
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -92,22 +93,23 @@ class StudioSettings {
|
||||
private function save(): void {
|
||||
// Nonce is verified by the caller (renderPage) before this method runs.
|
||||
// phpcs:disable WordPress.Security.NonceVerification.Missing
|
||||
$mode = sanitize_key( wp_unslash( $_POST['mode'] ?? 'test' ) );
|
||||
update_option( self::OPT_PUBLISHABLE, sanitize_text_field( wp_unslash( $_POST['publishable_key'] ?? '' ) ) );
|
||||
$mode = sanitize_key( Val::string( wp_unslash( $_POST['mode'] ?? 'test' ) ) );
|
||||
update_option( self::OPT_PUBLISHABLE, sanitize_text_field( Val::string( wp_unslash( $_POST['publishable_key'] ?? '' ) ) ) );
|
||||
// Secret fields are write-only: a blank submission keeps the stored secret,
|
||||
// so an admin saving other settings never wipes the keys.
|
||||
$secretKey = sanitize_text_field( wp_unslash( $_POST['secret_key'] ?? '' ) );
|
||||
$secretKey = sanitize_text_field( Val::string( wp_unslash( $_POST['secret_key'] ?? '' ) ) );
|
||||
if ( '' !== $secretKey ) {
|
||||
update_option( self::OPT_SECRET, $secretKey );
|
||||
}
|
||||
$webhookSecret = sanitize_text_field( wp_unslash( $_POST['webhook_secret'] ?? '' ) );
|
||||
$webhookSecret = sanitize_text_field( Val::string( wp_unslash( $_POST['webhook_secret'] ?? '' ) ) );
|
||||
if ( '' !== $webhookSecret ) {
|
||||
update_option( self::OPT_WEBHOOK_SECRET, $webhookSecret );
|
||||
}
|
||||
update_option( self::OPT_MODE, 'live' === $mode ? 'live' : 'test' );
|
||||
update_option( self::OPT_CURRENCY, strtoupper( sanitize_text_field( wp_unslash( $_POST['currency'] ?? 'CAD' ) ) ) );
|
||||
update_option( self::OPT_ETRANSFER_EMAIL, sanitize_email( wp_unslash( $_POST['etransfer_email'] ?? '' ) ) );
|
||||
$hstRate = isset( $_POST['hst_rate'] ) ? (float) $_POST['hst_rate'] : 0.0;
|
||||
update_option( self::OPT_CURRENCY, strtoupper( sanitize_text_field( Val::string( wp_unslash( $_POST['currency'] ?? 'CAD' ) ) ) ) );
|
||||
update_option( self::OPT_ETRANSFER_EMAIL, sanitize_email( Val::string( wp_unslash( $_POST['etransfer_email'] ?? '' ) ) ) );
|
||||
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Val::float() coerces to float; slashes cannot survive numeric coercion.
|
||||
$hstRate = isset( $_POST['hst_rate'] ) ? Val::float( $_POST['hst_rate'] ) : 0.0;
|
||||
update_option( self::OPT_HST_RATE, max( 0.0, $hstRate ) );
|
||||
// phpcs:enable WordPress.Security.NonceVerification.Missing
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user