Add payments foundation (e-transfer/comp, Stripe config, receipts)
CI / Tests (PHP 8.1) (pull_request) Successful in 45s
CI / Tests (PHP 8.3) (pull_request) Successful in 50s
CI / No Debug Code (pull_request) Successful in 3s
CI / Coding Standards (pull_request) Successful in 1m2s
CI / Tests (PHP 8.2) (pull_request) Successful in 1m0s
CI / PHPStan (pull_request) Successful in 1m4s
CI / Build Plugin Zip (pull_request) Has been skipped
CI / Tests (PHP 8.1) (pull_request) Successful in 45s
CI / Tests (PHP 8.3) (pull_request) Successful in 50s
CI / No Debug Code (pull_request) Successful in 3s
CI / Coding Standards (pull_request) Successful in 1m2s
CI / Tests (PHP 8.2) (pull_request) Successful in 1m0s
CI / PHPStan (pull_request) Successful in 1m4s
CI / Build Plugin Zip (pull_request) Has been skipped
Implements the payments foundation for #7. Without Stripe credentials everything works on e-transfer (pending payment confirmed by a studio admin); when Stripe keys are configured the default flips to credit card. Per-student override (card/etransfer/comp) is set on the student detail. - Schema: us_payments (amount DECIMAL dollars, method, status, receipt, stripe intent id). - src/Payment/: Payment VO, PaymentRepository, StudioSettings (Stripe options + isStripeConfigured + settings page), BillingMethodResolver (per-student override; default card if configured else etransfer), ReceiptMailer, PaymentService (create at registration, link payment_id, comp->paid+confirm, markPaid->confirm+receipt), PaymentController (e-transfer confirmation queue), PaymentEndpoint (PATCH /payments/{id}). - Booking + enrolment create the payment from the offering price; comp auto-confirms the lesson; setPaymentId on both repositories. - Admin: Studio Settings + Payments menus (manage_billing); per-student billing method on the student detail page. - Docs: payments.md + README updated. Deferred to a follow-up: the live Stripe card charge (PaymentIntent + Stripe.js Elements + webhook + stripe/stripe-php). Until then a card payment is created pending and confirmed like an e-transfer. Tests: tests/Unit/Payment/ (VO, repository, resolver, service, mailer). composer test (147), cs, and PHPStan level 6 all pass. Refs #7 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Unsupervised\Schedular\Payment;
|
||||
|
||||
/**
|
||||
* 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
|
||||
* it is not.
|
||||
*/
|
||||
class BillingMethodResolver {
|
||||
|
||||
public const META_METHOD = 'us_payment_method';
|
||||
|
||||
public function __construct( private StudioSettings $settings ) {}
|
||||
|
||||
public function resolve( int $studentId ): string {
|
||||
$override = (string) get_user_meta( $studentId, self::META_METHOD, true );
|
||||
if ( in_array( $override, Payment::VALID_METHODS, true ) ) {
|
||||
return $override;
|
||||
}
|
||||
|
||||
return $this->defaultMethod();
|
||||
}
|
||||
|
||||
/**
|
||||
* The studio default when a student has no explicit override.
|
||||
*/
|
||||
public function defaultMethod(): string {
|
||||
return $this->settings->isStripeConfigured()
|
||||
? Payment::METHOD_CARD
|
||||
: Payment::METHOD_ETRANSFER;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Unsupervised\Schedular\Payment;
|
||||
|
||||
class Payment {
|
||||
|
||||
public const METHOD_CARD = 'card';
|
||||
public const METHOD_ETRANSFER = 'etransfer';
|
||||
public const METHOD_COMP = 'comp';
|
||||
|
||||
/**
|
||||
* All valid payment methods.
|
||||
*
|
||||
* @var list<string>
|
||||
*/
|
||||
public const VALID_METHODS = [ self::METHOD_CARD, self::METHOD_ETRANSFER, self::METHOD_COMP ];
|
||||
|
||||
public const STATUS_PENDING = 'pending';
|
||||
public const STATUS_PAID = 'paid';
|
||||
public const STATUS_FAILED = 'failed';
|
||||
public const STATUS_REFUNDED = 'refunded';
|
||||
|
||||
/**
|
||||
* All valid payment statuses.
|
||||
*
|
||||
* @var list<string>
|
||||
*/
|
||||
public const VALID_STATUSES = [ self::STATUS_PENDING, self::STATUS_PAID, self::STATUS_FAILED, self::STATUS_REFUNDED ];
|
||||
|
||||
public const REG_LESSON = 'lesson';
|
||||
public const REG_ENROLLMENT = 'enrollment';
|
||||
|
||||
public function __construct(
|
||||
public readonly int $studentId,
|
||||
public readonly int $instructorId,
|
||||
public readonly string $registrationType,
|
||||
public readonly int $registrationId,
|
||||
public readonly float $amount,
|
||||
public readonly string $currency = 'CAD',
|
||||
public readonly string $method = self::METHOD_ETRANSFER,
|
||||
public readonly string $status = self::STATUS_PENDING,
|
||||
public readonly ?string $stripePaymentIntentId = null,
|
||||
public readonly ?string $receiptNumber = null,
|
||||
public readonly ?string $receiptSentAt = null,
|
||||
public readonly ?string $paidAt = null,
|
||||
public readonly ?int $id = null,
|
||||
) {}
|
||||
|
||||
public static function fromRow( object $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,
|
||||
stripePaymentIntentId: $row->stripe_payment_intent_id,
|
||||
receiptNumber: $row->receipt_number,
|
||||
receiptSentAt: $row->receipt_sent_at,
|
||||
paidAt: $row->paid_at,
|
||||
id: (int) $row->id,
|
||||
);
|
||||
}
|
||||
|
||||
public function isPaid(): bool {
|
||||
return self::STATUS_PAID === $this->status;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a plain array representation of the payment.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function toArray(): array {
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'student_id' => $this->studentId,
|
||||
'instructor_id' => $this->instructorId,
|
||||
'registration_type' => $this->registrationType,
|
||||
'registration_id' => $this->registrationId,
|
||||
'amount' => $this->amount,
|
||||
'currency' => $this->currency,
|
||||
'method' => $this->method,
|
||||
'status' => $this->status,
|
||||
'receipt_number' => $this->receiptNumber,
|
||||
'paid_at' => $this->paidAt,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Unsupervised\Schedular\Payment;
|
||||
|
||||
use Unsupervised\Schedular\Auth\RoleManager;
|
||||
|
||||
class PaymentController {
|
||||
|
||||
public function __construct(
|
||||
private PaymentRepository $payments,
|
||||
private PaymentService $service,
|
||||
) {}
|
||||
|
||||
public function renderPage(): void {
|
||||
if ( ! current_user_can( RoleManager::CAP_MANAGE_BILLING ) ) {
|
||||
wp_die( esc_html__( 'You do not have permission to manage payments.', 'unsupervised-schedular' ) );
|
||||
}
|
||||
|
||||
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'] ?? '' ) ) ) {
|
||||
// phpcs:ignore WordPress.Security.NonceVerification.Missing
|
||||
$paymentId = absint( $_POST['payment_id'] ?? 0 );
|
||||
if ( $paymentId > 0 ) {
|
||||
$this->service->markPaid( $paymentId );
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$rows = array_map(
|
||||
static function ( Payment $payment ): array {
|
||||
$student = get_userdata( $payment->studentId );
|
||||
|
||||
return [
|
||||
'id' => (int) $payment->id,
|
||||
'student' => $student ? $student->display_name : (string) $payment->studentId,
|
||||
'amount' => number_format( $payment->amount, 2 ) . ' ' . $payment->currency,
|
||||
'method' => $payment->method,
|
||||
'for' => $payment->registrationType . ' #' . $payment->registrationId,
|
||||
];
|
||||
},
|
||||
$this->payments->findPending()
|
||||
);
|
||||
|
||||
include USC_PLUGIN_DIR . 'templates/admin/payments.php';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Unsupervised\Schedular\Payment;
|
||||
|
||||
use Unsupervised\Schedular\Auth\RoleManager;
|
||||
|
||||
class PaymentEndpoint {
|
||||
|
||||
public function __construct( private PaymentService $service ) {}
|
||||
|
||||
public function registerRoutes( string $route_namespace ): void {
|
||||
register_rest_route(
|
||||
$route_namespace,
|
||||
'/payments/(?P<id>\d+)',
|
||||
[
|
||||
[
|
||||
'methods' => \WP_REST_Server::EDITABLE,
|
||||
'callback' => [ $this, 'markPaid' ],
|
||||
'permission_callback' => [ $this, 'canManage' ],
|
||||
],
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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' ) );
|
||||
|
||||
if ( ! $this->service->markPaid( $id ) ) {
|
||||
return new \WP_Error( 'not_found', __( 'Payment not found.', 'unsupervised-schedular' ), [ 'status' => 404 ] );
|
||||
}
|
||||
|
||||
return new \WP_REST_Response(
|
||||
[
|
||||
'id' => $id,
|
||||
'status' => Payment::STATUS_PAID,
|
||||
],
|
||||
200
|
||||
);
|
||||
}
|
||||
|
||||
public function canManage(): bool {
|
||||
return is_user_logged_in() && current_user_can( RoleManager::CAP_MANAGE_BILLING );
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Unsupervised\Schedular\Payment;
|
||||
|
||||
class PaymentRepository {
|
||||
|
||||
private string $table;
|
||||
|
||||
public function __construct( private \wpdb $db ) {
|
||||
$this->table = $db->prefix . 'us_payments';
|
||||
}
|
||||
|
||||
public function insert( Payment $payment ): int {
|
||||
$this->db->insert(
|
||||
$this->table,
|
||||
[
|
||||
'student_id' => $payment->studentId,
|
||||
'instructor_id' => $payment->instructorId,
|
||||
'registration_type' => $payment->registrationType,
|
||||
'registration_id' => $payment->registrationId,
|
||||
'amount' => $payment->amount,
|
||||
'currency' => $payment->currency,
|
||||
'method' => $payment->method,
|
||||
'status' => $payment->status,
|
||||
'stripe_payment_intent_id' => $payment->stripePaymentIntentId,
|
||||
'receipt_number' => $payment->receiptNumber,
|
||||
'receipt_sent_at' => $payment->receiptSentAt,
|
||||
'paid_at' => $payment->paidAt,
|
||||
'created_at' => current_time( 'mysql' ),
|
||||
],
|
||||
[ '%d', '%d', '%s', '%d', '%f', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s' ]
|
||||
);
|
||||
|
||||
return $this->db->insert_id;
|
||||
}
|
||||
|
||||
public function findById( int $id ): ?Payment {
|
||||
$row = $this->db->get_row(
|
||||
$this->db->prepare( "SELECT * FROM {$this->table} WHERE id = %d", $id )
|
||||
);
|
||||
|
||||
return $row ? Payment::fromRow( $row ) : null;
|
||||
}
|
||||
|
||||
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",
|
||||
$registrationType,
|
||||
$registrationId
|
||||
)
|
||||
);
|
||||
|
||||
return $row ? Payment::fromRow( $row ) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pending payments, newest first (studio-admin confirmation queue).
|
||||
*
|
||||
* @return list<Payment>
|
||||
*/
|
||||
public function findPending(): array {
|
||||
$rows = $this->db->get_results(
|
||||
$this->db->prepare(
|
||||
"SELECT * FROM {$this->table} WHERE status = %s ORDER BY created_at DESC",
|
||||
Payment::STATUS_PENDING
|
||||
)
|
||||
);
|
||||
|
||||
return array_map( Payment::fromRow( ... ), $rows ?? [] );
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a payment paid, stamping the paid time and receipt number.
|
||||
*/
|
||||
public function markPaid( int $id, string $receiptNumber ): bool {
|
||||
return false !== $this->db->update(
|
||||
$this->table,
|
||||
[
|
||||
'status' => Payment::STATUS_PAID,
|
||||
'paid_at' => current_time( 'mysql' ),
|
||||
'receipt_number' => $receiptNumber,
|
||||
],
|
||||
[ 'id' => $id ],
|
||||
[ '%s', '%s', '%s' ],
|
||||
[ '%d' ]
|
||||
);
|
||||
}
|
||||
|
||||
public function markReceiptSent( int $id ): bool {
|
||||
return false !== $this->db->update(
|
||||
$this->table,
|
||||
[ 'receipt_sent_at' => current_time( 'mysql' ) ],
|
||||
[ 'id' => $id ],
|
||||
[ '%s' ],
|
||||
[ '%d' ]
|
||||
);
|
||||
}
|
||||
|
||||
public function updateStatus( int $id, string $status ): bool {
|
||||
if ( ! in_array( $status, Payment::VALID_STATUSES, true ) ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (bool) $this->db->update(
|
||||
$this->table,
|
||||
[ 'status' => $status ],
|
||||
[ 'id' => $id ],
|
||||
[ '%s' ],
|
||||
[ '%d' ]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Unsupervised\Schedular\Payment;
|
||||
|
||||
use Unsupervised\Schedular\Booking\BookingRepository;
|
||||
use Unsupervised\Schedular\Booking\Lesson;
|
||||
use Unsupervised\Schedular\GroupClass\EnrollmentRepository;
|
||||
|
||||
/**
|
||||
* Orchestrates payment creation and confirmation across the payment ledger and
|
||||
* the registrations (lessons / enrolments) they pay for.
|
||||
*/
|
||||
class PaymentService {
|
||||
|
||||
public function __construct(
|
||||
private PaymentRepository $payments,
|
||||
private BillingMethodResolver $resolver,
|
||||
private ReceiptMailer $mailer,
|
||||
private BookingRepository $bookings,
|
||||
private EnrollmentRepository $enrollments,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Create the payment for a new registration and link it. Comped students are
|
||||
* marked paid and confirmed immediately; everyone else gets a pending payment
|
||||
* (card via Stripe — coming soon; e-transfer confirmed manually). Returns null
|
||||
* when the registration has no price to charge.
|
||||
*/
|
||||
public function createForRegistration( string $type, int $registrationId, int $studentId, int $instructorId, float $amount, string $currency ): ?Payment {
|
||||
if ( $amount <= 0.0 ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$method = $this->resolver->resolve( $studentId );
|
||||
$status = Payment::METHOD_COMP === $method ? Payment::STATUS_PAID : Payment::STATUS_PENDING;
|
||||
|
||||
$id = $this->payments->insert(
|
||||
new Payment(
|
||||
studentId: $studentId,
|
||||
instructorId: $instructorId,
|
||||
registrationType: $type,
|
||||
registrationId: $registrationId,
|
||||
amount: $amount,
|
||||
currency: $currency,
|
||||
method: $method,
|
||||
status: $status,
|
||||
)
|
||||
);
|
||||
|
||||
$this->linkPayment( $type, $registrationId, $id );
|
||||
|
||||
if ( Payment::STATUS_PAID === $status ) {
|
||||
$this->finalizePaid( $id, $type, $registrationId, $studentId );
|
||||
}
|
||||
|
||||
return $this->payments->findById( $id );
|
||||
}
|
||||
|
||||
/**
|
||||
* Studio-admin confirmation that a pending payment (e-transfer) was received.
|
||||
* Marks it paid, confirms the registration, and emails the receipt.
|
||||
*/
|
||||
public function markPaid( int $paymentId ): bool {
|
||||
$payment = $this->payments->findById( $paymentId );
|
||||
if ( null === $payment ) {
|
||||
return false;
|
||||
}
|
||||
if ( $payment->isPaid() ) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$this->finalizePaid( $paymentId, $payment->registrationType, $payment->registrationId, $payment->studentId );
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private function finalizePaid( int $paymentId, string $type, int $registrationId, int $studentId ): void {
|
||||
$this->payments->markPaid( $paymentId, 'USC-' . $paymentId );
|
||||
$this->confirmRegistration( $type, $registrationId );
|
||||
|
||||
$paid = $this->payments->findById( $paymentId );
|
||||
$user = get_userdata( $studentId );
|
||||
if ( null !== $paid && $this->mailer->send( $paid, $user instanceof \WP_User ? $user : null ) ) {
|
||||
$this->payments->markReceiptSent( $paymentId );
|
||||
}
|
||||
}
|
||||
|
||||
private function confirmRegistration( string $type, int $registrationId ): void {
|
||||
if ( Payment::REG_LESSON === $type ) {
|
||||
$this->bookings->updateStatus( $registrationId, Lesson::STATUS_CONFIRMED );
|
||||
}
|
||||
// Group enrolments are already `active`; no status change on payment.
|
||||
}
|
||||
|
||||
private function linkPayment( string $type, int $registrationId, int $paymentId ): void {
|
||||
if ( Payment::REG_LESSON === $type ) {
|
||||
$this->bookings->setPaymentId( $registrationId, $paymentId );
|
||||
} elseif ( Payment::REG_ENROLLMENT === $type ) {
|
||||
$this->enrollments->setPaymentId( $registrationId, $paymentId );
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Unsupervised\Schedular\Payment;
|
||||
|
||||
class ReceiptMailer {
|
||||
|
||||
/**
|
||||
* Email a numbered receipt to the student. Returns false when there is no
|
||||
* recipient.
|
||||
*/
|
||||
public function send( Payment $payment, ?\WP_User $student ): bool {
|
||||
if ( null === $student || '' === (string) $student->user_email ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$subject = sprintf(
|
||||
/* translators: %s: receipt number */
|
||||
__( 'Payment receipt %s', 'unsupervised-schedular' ),
|
||||
(string) $payment->receiptNumber
|
||||
);
|
||||
|
||||
$body = sprintf(
|
||||
/* translators: 1: amount, 2: currency, 3: receipt number */
|
||||
__( "Thank you. We have recorded your payment of %1\$s %2\$s.\n\nReceipt: %3\$s", 'unsupervised-schedular' ),
|
||||
number_format( $payment->amount, 2 ),
|
||||
$payment->currency,
|
||||
(string) $payment->receiptNumber
|
||||
);
|
||||
|
||||
return (bool) wp_mail( $student->user_email, $subject, $body );
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Unsupervised\Schedular\Payment;
|
||||
|
||||
use Unsupervised\Schedular\Auth\RoleManager;
|
||||
|
||||
class StudioSettings {
|
||||
|
||||
public const OPT_PUBLISHABLE = 'us_stripe_publishable_key';
|
||||
public const OPT_SECRET = 'us_stripe_secret_key';
|
||||
public const OPT_MODE = 'us_stripe_mode';
|
||||
public const OPT_CURRENCY = 'us_currency';
|
||||
|
||||
public function publishableKey(): string {
|
||||
return (string) get_option( self::OPT_PUBLISHABLE, '' );
|
||||
}
|
||||
|
||||
public function secretKey(): string {
|
||||
return (string) get_option( self::OPT_SECRET, '' );
|
||||
}
|
||||
|
||||
public function mode(): string {
|
||||
return 'live' === get_option( self::OPT_MODE, 'test' ) ? 'live' : 'test';
|
||||
}
|
||||
|
||||
public function currency(): string {
|
||||
$currency = (string) get_option( self::OPT_CURRENCY, 'CAD' );
|
||||
|
||||
return '' !== $currency ? strtoupper( $currency ) : 'CAD';
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether Stripe is configured. When false the platform falls back to
|
||||
* e-transfer billing and card processing is unavailable.
|
||||
*/
|
||||
public function isStripeConfigured(): bool {
|
||||
return '' !== $this->publishableKey() && '' !== $this->secretKey();
|
||||
}
|
||||
|
||||
public function renderPage(): void {
|
||||
if ( ! current_user_can( RoleManager::CAP_MANAGE_BILLING ) ) {
|
||||
wp_die( esc_html__( 'You do not have permission to manage billing settings.', 'unsupervised-schedular' ) );
|
||||
}
|
||||
|
||||
if ( isset( $_POST['usc_action'] ) && check_admin_referer( 'usc_settings_action' ) ) {
|
||||
$this->save();
|
||||
}
|
||||
|
||||
$publishableKey = $this->publishableKey();
|
||||
$secretKey = $this->secretKey();
|
||||
$mode = $this->mode();
|
||||
$currency = $this->currency();
|
||||
$stripeConfigured = $this->isStripeConfigured();
|
||||
|
||||
include USC_PLUGIN_DIR . 'templates/admin/settings.php';
|
||||
}
|
||||
|
||||
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'] ?? '' ) ) );
|
||||
update_option( self::OPT_SECRET, sanitize_text_field( wp_unslash( $_POST['secret_key'] ?? '' ) ) );
|
||||
update_option( self::OPT_MODE, 'live' === $mode ? 'live' : 'test' );
|
||||
update_option( self::OPT_CURRENCY, strtoupper( sanitize_text_field( wp_unslash( $_POST['currency'] ?? 'CAD' ) ) ) );
|
||||
// phpcs:enable WordPress.Security.NonceVerification.Missing
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user