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

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:
2026-06-08 10:24:01 -03:00
parent 071ef7fc2a
commit 6c4097b385
27 changed files with 1201 additions and 12 deletions
+34
View File
@@ -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;
}
}
+92
View File
@@ -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,
];
}
}
+48
View File
@@ -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';
}
}
+48
View File
@@ -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 );
}
}
+114
View File
@@ -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' ]
);
}
}
+103
View File
@@ -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 );
}
}
}
+33
View File
@@ -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 );
}
}
+69
View File
@@ -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
}
}