From 6c4097b3851f66357e9be033b6ce488ba67853f2 Mon Sep 17 00:00:00 2001 From: James Griffin Date: Mon, 8 Jun 2026 10:24:01 -0300 Subject: [PATCH 1/2] Add payments foundation (e-transfer/comp, Stripe config, receipts) 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 --- README.md | 2 +- docs/features/payments.md | 23 +++- src/AdminMenu.php | 35 +++++- src/Auth/StudentController.php | 18 +++ src/Booking/BookingEndpoint.php | 10 +- src/Booking/BookingRepository.php | 10 ++ src/GroupClass/EnrollmentEndpoint.php | 7 ++ src/GroupClass/EnrollmentRepository.php | 10 ++ src/Payment/BillingMethodResolver.php | 34 +++++ src/Payment/Payment.php | 92 ++++++++++++++ src/Payment/PaymentController.php | 48 +++++++ src/Payment/PaymentEndpoint.php | 48 +++++++ src/Payment/PaymentRepository.php | 114 +++++++++++++++++ src/Payment/PaymentService.php | 103 +++++++++++++++ src/Payment/ReceiptMailer.php | 33 +++++ src/Payment/StudioSettings.php | 69 +++++++++++ src/Plugin.php | 14 ++- src/RestRegistrar.php | 11 +- src/Schema.php | 22 ++++ templates/admin/payments.php | 49 ++++++++ templates/admin/settings.php | 58 +++++++++ templates/admin/student-detail.php | 23 ++++ .../Payment/BillingMethodResolverTest.php | 58 +++++++++ tests/Unit/Payment/PaymentRepositoryTest.php | 107 ++++++++++++++++ tests/Unit/Payment/PaymentServiceTest.php | 117 ++++++++++++++++++ tests/Unit/Payment/PaymentTest.php | 63 ++++++++++ tests/Unit/Payment/ReceiptMailerTest.php | 35 ++++++ 27 files changed, 1201 insertions(+), 12 deletions(-) create mode 100644 src/Payment/BillingMethodResolver.php create mode 100644 src/Payment/Payment.php create mode 100644 src/Payment/PaymentController.php create mode 100644 src/Payment/PaymentEndpoint.php create mode 100644 src/Payment/PaymentRepository.php create mode 100644 src/Payment/PaymentService.php create mode 100644 src/Payment/ReceiptMailer.php create mode 100644 src/Payment/StudioSettings.php create mode 100644 templates/admin/payments.php create mode 100644 templates/admin/settings.php create mode 100644 tests/Unit/Payment/BillingMethodResolverTest.php create mode 100644 tests/Unit/Payment/PaymentRepositoryTest.php create mode 100644 tests/Unit/Payment/PaymentServiceTest.php create mode 100644 tests/Unit/Payment/PaymentTest.php create mode 100644 tests/Unit/Payment/ReceiptMailerTest.php diff --git a/README.md b/README.md index aab3f7f..ad4df21 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ model, REST API, classes, and tests. For contributor/architecture guidance see | Lesson booking (offering → questions → policies) | [lesson-booking.md](docs/features/lesson-booking.md) | ✅ Implemented | | Group classes (capacity-enforced enrolment) | [group-classes.md](docs/features/group-classes.md) | ✅ Implemented | | Student administration (studio-admin view) | [student-administration.md](docs/features/student-administration.md) | ✅ Implemented | -| Payments (Stripe, e-transfer/comp, receipts) | [payments.md](docs/features/payments.md) | 🟡 Planned | +| Payments (e-transfer/comp + receipts; Stripe card charge pending) | [payments.md](docs/features/payments.md) | 🟡 Partial | | Payment reporting (monthly per-instructor + CSV) | [payment-reporting.md](docs/features/payment-reporting.md) | 🟡 Planned | > Payments are deliberately deferred to the end: booking and enrolment ship with a diff --git a/docs/features/payments.md b/docs/features/payments.md index 46fc78f..78723fb 100644 --- a/docs/features/payments.md +++ b/docs/features/payments.md @@ -1,7 +1,22 @@ # Feature: Payments ## Overview -Payment is taken at registration. The default rail is a credit card charged through Stripe, but the studio admin can set any student to pay by e-transfer (recorded, marked paid manually) or to be comped (no charge). Single bookings are charged once; weekly reservations and group classes are charged the full term upfront. A numbered receipt is emailed automatically when a payment is marked paid. +Payment is taken at registration. When Stripe is **not** configured the platform +falls back to **e-transfer** — a pending payment a studio admin marks received — +so everything works without any credentials. When Stripe **is** configured the +default rail becomes the **credit card**. The studio admin can override any +student's method (card / e-transfer / comp). Single bookings are charged once; +weekly reservations and group classes are charged the full term upfront. A +numbered receipt is emailed automatically when a payment is marked paid. + +> **Implemented:** the payment ledger, studio settings, method resolution +> (e-transfer default with no Stripe; card default when configured; per-student +> override), the e-transfer/comp flow with admin confirmation + receipts, and +> integration into booking/enrolment (a registration's `payment_id` is linked; +> comp auto-confirms; e-transfer stays pending until confirmed). +> **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 can be confirmed like an e-transfer. ## Stripe Configuration Stripe credentials live in WordPress options, managed on the **Studio Settings** @@ -16,7 +31,9 @@ page (`manage_billing`, studio admin only): ## Per-Student Billing Method Each student's billing method is stored in user meta `us_payment_method`, set by the -studio admin (default `card`): +studio admin (`Students → student detail → Billing method`). When unset, the studio +default applies — `card` if Stripe is configured, otherwise `etransfer` +(`BillingMethodResolver`): | Method | Behaviour | |------------|-----------------------------------------------------------------------| @@ -33,7 +50,7 @@ studio admin (default `card`): | `instructor_id` | BIGINT UNSIGNED | WordPress user ID (denormalised for reporting) | | `registration_type` | VARCHAR(20) | `lesson` or `enrollment` | | `registration_id` | BIGINT UNSIGNED | FK → `us_lessons.id` or `us_group_enrollments.id` | -| `amount_cents` | INT UNSIGNED | Charged amount in the smallest currency unit | +| `amount` | DECIMAL(10,2) | Charged amount in dollars (matches the offering price) | | `currency` | VARCHAR(3) | ISO 4217, e.g. `CAD` | | `method` | VARCHAR(20) | `card` / `etransfer` / `comp` | | `status` | VARCHAR(20) | `pending` / `paid` / `failed` / `refunded` | diff --git a/src/AdminMenu.php b/src/AdminMenu.php index 8f96c5b..a0b905e 100644 --- a/src/AdminMenu.php +++ b/src/AdminMenu.php @@ -15,6 +15,11 @@ use Unsupervised\Schedular\GroupClass\EnrollmentRepository; use Unsupervised\Schedular\GroupClass\GroupClassController; use Unsupervised\Schedular\Offering\OfferingController; use Unsupervised\Schedular\Offering\OfferingRepository; +use Unsupervised\Schedular\Payment\BillingMethodResolver; +use Unsupervised\Schedular\Payment\PaymentController; +use Unsupervised\Schedular\Payment\PaymentRepository; +use Unsupervised\Schedular\Payment\PaymentService; +use Unsupervised\Schedular\Payment\StudioSettings; use Unsupervised\Schedular\Policy\PolicyController; use Unsupervised\Schedular\Policy\PolicyRepository; use Unsupervised\Schedular\Policy\PolicyService; @@ -32,8 +37,10 @@ class AdminMenu { private RegistrationController $registrationController; private GroupClassController $groupClassController; private StudentController $studentController; + private StudioSettings $settings; + private PaymentController $paymentController; - public function __construct( AvailabilityRepository $availability, BookingRepository $bookings, OfferingRepository $offerings, QuestionRepository $questions, PolicyRepository $policies, PolicyVersionRepository $policyVersions, PolicyService $policyService, InviteRepository $invites, EnrollmentRepository $enrollments ) { + public function __construct( AvailabilityRepository $availability, BookingRepository $bookings, OfferingRepository $offerings, QuestionRepository $questions, PolicyRepository $policies, PolicyVersionRepository $policyVersions, PolicyService $policyService, InviteRepository $invites, EnrollmentRepository $enrollments, StudioSettings $settings, PaymentRepository $payments, PaymentService $paymentService, BillingMethodResolver $resolver ) { $this->availabilityController = new AvailabilityController( $availability, $offerings ); $this->lessonController = new LessonController( $bookings ); $this->offeringController = new OfferingController( $offerings ); @@ -41,7 +48,9 @@ class AdminMenu { $this->policyController = new PolicyController( $policies, $policyVersions, $policyService ); $this->registrationController = new RegistrationController( $invites ); $this->groupClassController = new GroupClassController( $enrollments, $offerings ); - $this->studentController = new StudentController( $bookings, $availability, $offerings, $enrollments ); + $this->studentController = new StudentController( $bookings, $availability, $offerings, $enrollments, $resolver ); + $this->settings = $settings; + $this->paymentController = new PaymentController( $payments, $paymentService ); } public function register(): void { @@ -136,6 +145,28 @@ class AdminMenu { 37 ); + // Studio admin: confirm pending (e-transfer) payments. + add_menu_page( + __( 'Payments', 'unsupervised-schedular' ), + __( 'Payments', 'unsupervised-schedular' ), + RoleManager::CAP_MANAGE_BILLING, + 'us-payments', + [ $this->paymentController, 'renderPage' ], + 'dashicons-money-alt', + 38 + ); + + // Studio admin: Stripe credentials and billing settings. + add_menu_page( + __( 'Studio Settings', 'unsupervised-schedular' ), + __( 'Studio Settings', 'unsupervised-schedular' ), + RoleManager::CAP_MANAGE_BILLING, + 'us-settings', + [ $this->settings, 'renderPage' ], + 'dashicons-admin-settings', + 39 + ); + // Instructor: view their upcoming lessons. add_menu_page( __( 'My Lessons', 'unsupervised-schedular' ), diff --git a/src/Auth/StudentController.php b/src/Auth/StudentController.php index d484904..0e35983 100644 --- a/src/Auth/StudentController.php +++ b/src/Auth/StudentController.php @@ -9,6 +9,8 @@ use Unsupervised\Schedular\Booking\Lesson; use Unsupervised\Schedular\GroupClass\Enrollment; use Unsupervised\Schedular\GroupClass\EnrollmentRepository; use Unsupervised\Schedular\Offering\OfferingRepository; +use Unsupervised\Schedular\Payment\BillingMethodResolver; +use Unsupervised\Schedular\Payment\Payment; class StudentController { @@ -17,6 +19,7 @@ class StudentController { private AvailabilityRepository $availability, private OfferingRepository $offerings, private EnrollmentRepository $enrollments, + private BillingMethodResolver $resolver, ) {} public function renderPage(): void { @@ -56,6 +59,21 @@ class StudentController { } private function renderDetail( \WP_User $student ): void { + $canBilling = current_user_can( RoleManager::CAP_MANAGE_BILLING ); + + if ( $canBilling && isset( $_POST['usc_action'] ) && check_admin_referer( 'usc_student_billing' ) ) { + // phpcs:ignore WordPress.Security.NonceVerification.Missing -- nonce checked above. + $method = sanitize_key( wp_unslash( $_POST['payment_method'] ?? '' ) ); + if ( in_array( $method, Payment::VALID_METHODS, true ) ) { + update_user_meta( (int) $student->ID, BillingMethodResolver::META_METHOD, $method ); + } else { + delete_user_meta( (int) $student->ID, BillingMethodResolver::META_METHOD ); + } + } + + $billingOverride = (string) get_user_meta( (int) $student->ID, BillingMethodResolver::META_METHOD, true ); + $billingDefault = $this->resolver->defaultMethod(); + $now = current_time( 'mysql' ); $rows = array_map( fn( Lesson $lesson ): array => $this->lessonRow( $lesson ), diff --git a/src/Booking/BookingEndpoint.php b/src/Booking/BookingEndpoint.php index b019507..ef58376 100644 --- a/src/Booking/BookingEndpoint.php +++ b/src/Booking/BookingEndpoint.php @@ -6,6 +6,8 @@ namespace Unsupervised\Schedular\Booking; use Unsupervised\Schedular\Availability\AvailabilityRepository; use Unsupervised\Schedular\Auth\RoleManager; use Unsupervised\Schedular\Offering\OfferingRepository; +use Unsupervised\Schedular\Payment\Payment; +use Unsupervised\Schedular\Payment\PaymentService; use Unsupervised\Schedular\Policy\PolicyAcceptance; use Unsupervised\Schedular\Registration\RegistrationGate; @@ -16,6 +18,7 @@ class BookingEndpoint { private BookingRepository $bookings, private OfferingRepository $offerings, private RegistrationGate $gate, + private PaymentService $payments, ) {} public function registerRoutes( string $route_namespace ): void { @@ -109,7 +112,8 @@ class BookingEndpoint { if ( 0 === $offeringId ) { $offeringId = (int) ( $slot->offeringId ?? 0 ); } - if ( $offeringId > 0 && null === $this->offerings->findById( $offeringId ) ) { + $offering = $offeringId > 0 ? $this->offerings->findById( $offeringId ) : null; + if ( $offeringId > 0 && null === $offering ) { return new \WP_Error( 'invalid_offering', __( 'Offering not found.', 'unsupervised-schedular' ), [ 'status' => 400 ] ); } @@ -152,6 +156,10 @@ class BookingEndpoint { $this->gate->record( PolicyAcceptance::REG_LESSON, $anchorId, $studentId, $offeringId, $answers, $acceptedVersionIds, $this->clientIp() ); + if ( null !== $offering && $offering->price > 0.0 ) { + $this->payments->createForRegistration( Payment::REG_LESSON, $anchorId, $studentId, $slot->instructorId, $offering->price, $offering->currency ); + } + return new \WP_REST_Response( [ 'ids' => $ids, diff --git a/src/Booking/BookingRepository.php b/src/Booking/BookingRepository.php index c3718a1..a8477ad 100644 --- a/src/Booking/BookingRepository.php +++ b/src/Booking/BookingRepository.php @@ -170,6 +170,16 @@ class BookingRepository { return array_map( Lesson::fromRow( ... ), $rows ?? [] ); } + public function setPaymentId( int $id, int $paymentId ): bool { + return false !== $this->db->update( + $this->table, + [ 'payment_id' => $paymentId ], + [ 'id' => $id ], + [ '%d' ], + [ '%d' ] + ); + } + public function updateStatus( int $id, string $status ): bool { if ( ! in_array( $status, Lesson::VALID_STATUSES, true ) ) { return false; diff --git a/src/GroupClass/EnrollmentEndpoint.php b/src/GroupClass/EnrollmentEndpoint.php index 2c6d7d3..707a954 100644 --- a/src/GroupClass/EnrollmentEndpoint.php +++ b/src/GroupClass/EnrollmentEndpoint.php @@ -6,6 +6,8 @@ namespace Unsupervised\Schedular\GroupClass; use Unsupervised\Schedular\Auth\RoleManager; use Unsupervised\Schedular\Offering\Offering; use Unsupervised\Schedular\Offering\OfferingRepository; +use Unsupervised\Schedular\Payment\Payment; +use Unsupervised\Schedular\Payment\PaymentService; use Unsupervised\Schedular\Policy\PolicyAcceptance; use Unsupervised\Schedular\Registration\RegistrationGate; @@ -15,6 +17,7 @@ class EnrollmentEndpoint { private EnrollmentRepository $enrollments, private OfferingRepository $offerings, private RegistrationGate $gate, + private PaymentService $payments, ) {} public function registerRoutes( string $route_namespace ): void { @@ -101,6 +104,10 @@ class EnrollmentEndpoint { $this->gate->record( PolicyAcceptance::REG_ENROLLMENT, $id, $studentId, $offeringId, $answers, $acceptedVersionIds, $this->clientIp() ); + if ( $offering->price > 0.0 ) { + $this->payments->createForRegistration( Payment::REG_ENROLLMENT, $id, $studentId, $offering->instructorId, $offering->price, $offering->currency ); + } + return new \WP_REST_Response( [ 'id' => $id, diff --git a/src/GroupClass/EnrollmentRepository.php b/src/GroupClass/EnrollmentRepository.php index bde6c47..c65565a 100644 --- a/src/GroupClass/EnrollmentRepository.php +++ b/src/GroupClass/EnrollmentRepository.php @@ -126,6 +126,16 @@ class EnrollmentRepository { return array_map( Enrollment::fromRow( ... ), $rows ?? [] ); } + public function setPaymentId( int $id, int $paymentId ): bool { + return false !== $this->db->update( + $this->table, + [ 'payment_id' => $paymentId ], + [ 'id' => $id ], + [ '%d' ], + [ '%d' ] + ); + } + public function updateStatus( int $id, string $status ): bool { if ( ! in_array( $status, Enrollment::VALID_STATUSES, true ) ) { return false; diff --git a/src/Payment/BillingMethodResolver.php b/src/Payment/BillingMethodResolver.php new file mode 100644 index 0000000..dac406f --- /dev/null +++ b/src/Payment/BillingMethodResolver.php @@ -0,0 +1,34 @@ +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; + } +} diff --git a/src/Payment/Payment.php b/src/Payment/Payment.php new file mode 100644 index 0000000..b06c04b --- /dev/null +++ b/src/Payment/Payment.php @@ -0,0 +1,92 @@ + + */ + 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 + */ + 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 + */ + 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, + ]; + } +} diff --git a/src/Payment/PaymentController.php b/src/Payment/PaymentController.php new file mode 100644 index 0000000..fee3d60 --- /dev/null +++ b/src/Payment/PaymentController.php @@ -0,0 +1,48 @@ + 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'; + } +} diff --git a/src/Payment/PaymentEndpoint.php b/src/Payment/PaymentEndpoint.php new file mode 100644 index 0000000..f8579dc --- /dev/null +++ b/src/Payment/PaymentEndpoint.php @@ -0,0 +1,48 @@ +\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 ); + } +} diff --git a/src/Payment/PaymentRepository.php b/src/Payment/PaymentRepository.php new file mode 100644 index 0000000..4187bdb --- /dev/null +++ b/src/Payment/PaymentRepository.php @@ -0,0 +1,114 @@ +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 + */ + 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' ] + ); + } +} diff --git a/src/Payment/PaymentService.php b/src/Payment/PaymentService.php new file mode 100644 index 0000000..caa58ee --- /dev/null +++ b/src/Payment/PaymentService.php @@ -0,0 +1,103 @@ +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 ); + } + } +} diff --git a/src/Payment/ReceiptMailer.php b/src/Payment/ReceiptMailer.php new file mode 100644 index 0000000..b34d257 --- /dev/null +++ b/src/Payment/ReceiptMailer.php @@ -0,0 +1,33 @@ +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 ); + } +} diff --git a/src/Payment/StudioSettings.php b/src/Payment/StudioSettings.php new file mode 100644 index 0000000..9ca274e --- /dev/null +++ b/src/Payment/StudioSettings.php @@ -0,0 +1,69 @@ +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 + } +} diff --git a/src/Plugin.php b/src/Plugin.php index 81edbae..a643728 100644 --- a/src/Plugin.php +++ b/src/Plugin.php @@ -9,6 +9,11 @@ use Unsupervised\Schedular\Availability\AvailabilityRepository; use Unsupervised\Schedular\Booking\BookingRepository; use Unsupervised\Schedular\GroupClass\EnrollmentRepository; use Unsupervised\Schedular\Offering\OfferingRepository; +use Unsupervised\Schedular\Payment\BillingMethodResolver; +use Unsupervised\Schedular\Payment\PaymentRepository; +use Unsupervised\Schedular\Payment\PaymentService; +use Unsupervised\Schedular\Payment\ReceiptMailer; +use Unsupervised\Schedular\Payment\StudioSettings; use Unsupervised\Schedular\Policy\AcceptanceRepository; use Unsupervised\Schedular\Policy\PolicyRepository; use Unsupervised\Schedular\Policy\PolicyService; @@ -36,9 +41,14 @@ class Plugin { $enrollments = new EnrollmentRepository( $wpdb ); $registrationGate = new RegistrationGate( $questions, $answers, $policies, $policyVersions, $acceptances ); + $paymentRepo = new PaymentRepository( $wpdb ); + $settings = new StudioSettings(); + $resolver = new BillingMethodResolver( $settings ); + $paymentService = new PaymentService( $paymentRepo, $resolver, new ReceiptMailer(), $bookings, $enrollments ); + ( new RoleManager() )->register(); - ( new AdminMenu( $availability, $bookings, $offerings, $questions, $policies, $policyVersions, $policyService, $invites, $enrollments ) )->register(); - ( new RestRegistrar( $availability, $bookings, $offerings, $questions, $policies, $policyVersions, $policyService, $registrationGate, $enrollments ) )->register(); + ( new AdminMenu( $availability, $bookings, $offerings, $questions, $policies, $policyVersions, $policyService, $invites, $enrollments, $settings, $paymentRepo, $paymentService, $resolver ) )->register(); + ( new RestRegistrar( $availability, $bookings, $offerings, $questions, $policies, $policyVersions, $policyService, $registrationGate, $enrollments, $paymentService ) )->register(); ( new ShortcodeRegistrar( $invites, $policies, $policyVersions, $acceptances ) )->register(); } } diff --git a/src/RestRegistrar.php b/src/RestRegistrar.php index 47cf56a..c1180aa 100644 --- a/src/RestRegistrar.php +++ b/src/RestRegistrar.php @@ -11,6 +11,8 @@ use Unsupervised\Schedular\GroupClass\EnrollmentEndpoint; use Unsupervised\Schedular\GroupClass\EnrollmentRepository; use Unsupervised\Schedular\Offering\OfferingEndpoint; use Unsupervised\Schedular\Offering\OfferingRepository; +use Unsupervised\Schedular\Payment\PaymentEndpoint; +use Unsupervised\Schedular\Payment\PaymentService; use Unsupervised\Schedular\Policy\PolicyEndpoint; use Unsupervised\Schedular\Policy\PolicyRepository; use Unsupervised\Schedular\Policy\PolicyService; @@ -29,14 +31,16 @@ class RestRegistrar { private QuestionEndpoint $questionEndpoint; private PolicyEndpoint $policyEndpoint; private EnrollmentEndpoint $enrollmentEndpoint; + private PaymentEndpoint $paymentEndpoint; - public function __construct( AvailabilityRepository $availability, BookingRepository $bookings, OfferingRepository $offerings, QuestionRepository $questions, PolicyRepository $policies, PolicyVersionRepository $policyVersions, PolicyService $policyService, RegistrationGate $gate, EnrollmentRepository $enrollments ) { + public function __construct( AvailabilityRepository $availability, BookingRepository $bookings, OfferingRepository $offerings, QuestionRepository $questions, PolicyRepository $policies, PolicyVersionRepository $policyVersions, PolicyService $policyService, RegistrationGate $gate, EnrollmentRepository $enrollments, PaymentService $paymentService ) { $this->availabilityEndpoint = new AvailabilityEndpoint( $availability ); - $this->bookingEndpoint = new BookingEndpoint( $availability, $bookings, $offerings, $gate ); + $this->bookingEndpoint = new BookingEndpoint( $availability, $bookings, $offerings, $gate, $paymentService ); $this->offeringEndpoint = new OfferingEndpoint( $offerings ); $this->questionEndpoint = new QuestionEndpoint( $questions, $offerings ); $this->policyEndpoint = new PolicyEndpoint( $policies, $policyVersions, $policyService ); - $this->enrollmentEndpoint = new EnrollmentEndpoint( $enrollments, $offerings, $gate ); + $this->enrollmentEndpoint = new EnrollmentEndpoint( $enrollments, $offerings, $gate, $paymentService ); + $this->paymentEndpoint = new PaymentEndpoint( $paymentService ); } public function register(): void { @@ -50,5 +54,6 @@ class RestRegistrar { $this->questionEndpoint->registerRoutes( self::NAMESPACE ); $this->policyEndpoint->registerRoutes( self::NAMESPACE ); $this->enrollmentEndpoint->registerRoutes( self::NAMESPACE ); + $this->paymentEndpoint->registerRoutes( self::NAMESPACE ); } } diff --git a/src/Schema.php b/src/Schema.php index 4d5d989..bf1a0c6 100644 --- a/src/Schema.php +++ b/src/Schema.php @@ -140,6 +140,28 @@ class Schema { KEY registration (registration_type, registration_id) ) {$charset};", + "CREATE TABLE {$prefix}us_payments ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + student_id BIGINT UNSIGNED NOT NULL, + instructor_id BIGINT UNSIGNED NOT NULL, + registration_type VARCHAR(20) NOT NULL, + registration_id BIGINT UNSIGNED NOT NULL, + amount DECIMAL(10,2) NOT NULL DEFAULT 0, + currency VARCHAR(3) NOT NULL DEFAULT 'CAD', + method VARCHAR(20) NOT NULL DEFAULT 'etransfer', + status VARCHAR(20) NOT NULL DEFAULT 'pending', + stripe_payment_intent_id VARCHAR(255) DEFAULT NULL, + receipt_number VARCHAR(50) DEFAULT NULL, + receipt_sent_at DATETIME DEFAULT NULL, + created_at DATETIME NOT NULL, + paid_at DATETIME DEFAULT NULL, + PRIMARY KEY (id), + KEY student_id (student_id), + KEY instructor_id (instructor_id), + KEY registration (registration_type, registration_id), + KEY status (status) + ) {$charset};", + "CREATE TABLE {$prefix}us_group_enrollments ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, offering_id BIGINT UNSIGNED NOT NULL, diff --git a/templates/admin/payments.php b/templates/admin/payments.php new file mode 100644 index 0000000..301eee7 --- /dev/null +++ b/templates/admin/payments.php @@ -0,0 +1,49 @@ + $rows */ +?> +
+

+

+ + +

+ + + + + + + + + + + + + + + + + + + + + + +
+
+ + + + +
+
+ +
diff --git a/templates/admin/settings.php b/templates/admin/settings.php new file mode 100644 index 0000000..002666c --- /dev/null +++ b/templates/admin/settings.php @@ -0,0 +1,58 @@ + +
+

+ +
+

+ + + + + +

+
+ +

+
+ + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
diff --git a/templates/admin/student-detail.php b/templates/admin/student-detail.php index ddfa3dc..d8ddd31 100644 --- a/templates/admin/student-detail.php +++ b/templates/admin/student-detail.php @@ -11,6 +11,9 @@ if (! defined('ABSPATH')) { * @var list $past * @var list $enrolments * @var string $backUrl + * @var bool $canBilling + * @var string $billingOverride + * @var string $billingDefault */ $renderLessons = static function (array $rows): void { @@ -54,6 +57,26 @@ $renderLessons = static function (array $rows): void { user_registered); ?> + +

+
+ + + + +
+ +

diff --git a/tests/Unit/Payment/BillingMethodResolverTest.php b/tests/Unit/Payment/BillingMethodResolverTest.php new file mode 100644 index 0000000..92c1b45 --- /dev/null +++ b/tests/Unit/Payment/BillingMethodResolverTest.php @@ -0,0 +1,58 @@ +justReturn(Payment::METHOD_COMP); + + $settings = Mockery::mock(StudioSettings::class); + $resolver = new BillingMethodResolver($settings); + + self::assertSame(Payment::METHOD_COMP, $resolver->resolve(5)); + } + + public function testDefaultsToCardWhenStripeConfigured(): void + { + Functions\when('get_user_meta')->justReturn(''); + + $settings = Mockery::mock(StudioSettings::class); + $settings->shouldReceive('isStripeConfigured')->andReturn(true); + $resolver = new BillingMethodResolver($settings); + + self::assertSame(Payment::METHOD_CARD, $resolver->resolve(5)); + } + + public function testDefaultsToEtransferWhenStripeNotConfigured(): void + { + Functions\when('get_user_meta')->justReturn(''); + + $settings = Mockery::mock(StudioSettings::class); + $settings->shouldReceive('isStripeConfigured')->andReturn(false); + $resolver = new BillingMethodResolver($settings); + + self::assertSame(Payment::METHOD_ETRANSFER, $resolver->resolve(5)); + self::assertSame(Payment::METHOD_ETRANSFER, $resolver->defaultMethod()); + } + + public function testInvalidOverrideFallsBackToDefault(): void + { + Functions\when('get_user_meta')->justReturn('bogus'); + + $settings = Mockery::mock(StudioSettings::class); + $settings->shouldReceive('isStripeConfigured')->andReturn(true); + $resolver = new BillingMethodResolver($settings); + + self::assertSame(Payment::METHOD_CARD, $resolver->resolve(5)); + } +} diff --git a/tests/Unit/Payment/PaymentRepositoryTest.php b/tests/Unit/Payment/PaymentRepositoryTest.php new file mode 100644 index 0000000..4697ebe --- /dev/null +++ b/tests/Unit/Payment/PaymentRepositoryTest.php @@ -0,0 +1,107 @@ +db = Mockery::mock(\wpdb::class); + $this->db->prefix = 'wp_'; + $this->repo = new PaymentRepository($this->db); + } + + public function testInsertReturnsId(): void + { + Functions\expect('current_time')->with('mysql')->andReturn('2026-06-08 12:00:00'); + + $this->db->shouldReceive('insert') + ->once() + ->with( + 'wp_us_payments', + Mockery::on(static function (array $d): bool { + return $d['student_id'] === 5 + && $d['amount'] === 35.00 + && $d['method'] === Payment::METHOD_ETRANSFER + && $d['status'] === Payment::STATUS_PENDING; + }), + Mockery::type('array') + ); + $this->db->insert_id = 50; + + self::assertSame(50, $this->repo->insert(new Payment(5, 3, Payment::REG_LESSON, 12, 35.00))); + } + + public function testMarkPaidUpdatesStatusAndReceipt(): void + { + Functions\expect('current_time')->with('mysql')->andReturn('2026-06-08 12:00:00'); + + $this->db->shouldReceive('update') + ->once() + ->with( + 'wp_us_payments', + Mockery::on(static fn (array $d): bool => $d['status'] === Payment::STATUS_PAID && $d['receipt_number'] === 'USC-50' && $d['paid_at'] === '2026-06-08 12:00:00'), + ['id' => 50], + ['%s', '%s', '%s'], + ['%d'] + ) + ->andReturn(1); + + self::assertTrue($this->repo->markPaid(50, 'USC-50')); + } + + public function testFindByRegistrationReturnsPayment(): void + { + $this->db->shouldReceive('prepare') + ->once() + ->with(Mockery::pattern('/registration_type = %s AND registration_id = %d/'), Payment::REG_LESSON, 12) + ->andReturn('SELECT ...'); + + $this->db->shouldReceive('get_row')->andReturn($this->row()); + + self::assertInstanceOf(Payment::class, $this->repo->findByRegistration(Payment::REG_LESSON, 12)); + } + + public function testFindPendingMapsRows(): void + { + $this->db->shouldReceive('prepare') + ->once() + ->with(Mockery::pattern('/status = %s/'), Payment::STATUS_PENDING) + ->andReturn('SELECT ...'); + + $this->db->shouldReceive('get_results')->andReturn([$this->row()]); + + self::assertCount(1, $this->repo->findPending()); + } + + private function row(): object + { + return (object) [ + 'id' => '50', + 'student_id' => '5', + 'instructor_id' => '3', + 'registration_type' => Payment::REG_LESSON, + 'registration_id' => '12', + 'amount' => '35.00', + 'currency' => 'CAD', + 'method' => Payment::METHOD_ETRANSFER, + 'status' => Payment::STATUS_PENDING, + 'stripe_payment_intent_id' => null, + 'receipt_number' => null, + 'receipt_sent_at' => null, + 'paid_at' => null, + ]; + } +} diff --git a/tests/Unit/Payment/PaymentServiceTest.php b/tests/Unit/Payment/PaymentServiceTest.php new file mode 100644 index 0000000..f3fe749 --- /dev/null +++ b/tests/Unit/Payment/PaymentServiceTest.php @@ -0,0 +1,117 @@ +payments = Mockery::mock(PaymentRepository::class); + $this->resolver = Mockery::mock(BillingMethodResolver::class); + $this->mailer = Mockery::mock(ReceiptMailer::class); + $this->bookings = Mockery::mock(BookingRepository::class); + $this->enrollments = Mockery::mock(EnrollmentRepository::class); + + $this->service = new PaymentService( + $this->payments, + $this->resolver, + $this->mailer, + $this->bookings, + $this->enrollments + ); + + Functions\when('get_userdata')->justReturn(false); + } + + private function payment(string $method, string $status, int $id): Payment + { + return new Payment(5, 3, Payment::REG_LESSON, 12, 35.00, 'CAD', $method, $status, id: $id); + } + + public function testFreeRegistrationCreatesNoPayment(): void + { + self::assertNull($this->service->createForRegistration(Payment::REG_LESSON, 12, 5, 3, 0.0, 'CAD')); + } + + public function testEtransferStaysPending(): void + { + $this->resolver->shouldReceive('resolve')->with(5)->andReturn(Payment::METHOD_ETRANSFER); + $this->payments->shouldReceive('insert') + ->once() + ->with(Mockery::on(static fn (Payment $p): bool => $p->method === Payment::METHOD_ETRANSFER && $p->status === Payment::STATUS_PENDING)) + ->andReturn(50); + $this->bookings->shouldReceive('setPaymentId')->once()->with(12, 50); + $this->payments->shouldReceive('findById')->with(50)->andReturn($this->payment(Payment::METHOD_ETRANSFER, Payment::STATUS_PENDING, 50)); + + // No markPaid / confirm for a pending e-transfer. + $result = $this->service->createForRegistration(Payment::REG_LESSON, 12, 5, 3, 35.00, 'CAD'); + + self::assertSame(Payment::STATUS_PENDING, $result->status); + } + + public function testCompIsPaidAndConfirmsImmediately(): void + { + $this->resolver->shouldReceive('resolve')->with(5)->andReturn(Payment::METHOD_COMP); + $this->payments->shouldReceive('insert') + ->once() + ->with(Mockery::on(static fn (Payment $p): bool => $p->method === Payment::METHOD_COMP && $p->status === Payment::STATUS_PAID)) + ->andReturn(60); + $this->bookings->shouldReceive('setPaymentId')->once()->with(12, 60); + + $this->payments->shouldReceive('markPaid')->once()->with(60, 'USC-60')->andReturn(true); + $this->bookings->shouldReceive('updateStatus')->once()->with(12, Lesson::STATUS_CONFIRMED)->andReturn(true); + $this->payments->shouldReceive('findById')->with(60)->andReturn($this->payment(Payment::METHOD_COMP, Payment::STATUS_PAID, 60)); + $this->mailer->shouldReceive('send')->andReturn(false); + + $result = $this->service->createForRegistration(Payment::REG_LESSON, 12, 5, 3, 35.00, 'CAD'); + + self::assertSame(Payment::STATUS_PAID, $result->status); + } + + public function testMarkPaidConfirmsAndReturnsTrue(): void + { + $this->payments->shouldReceive('findById')->with(70)->andReturn($this->payment(Payment::METHOD_ETRANSFER, Payment::STATUS_PENDING, 70)); + $this->payments->shouldReceive('markPaid')->once()->with(70, 'USC-70')->andReturn(true); + $this->bookings->shouldReceive('updateStatus')->once()->with(12, Lesson::STATUS_CONFIRMED)->andReturn(true); + $this->mailer->shouldReceive('send')->andReturn(false); + + self::assertTrue($this->service->markPaid(70)); + } + + public function testMarkPaidReturnsFalseWhenMissing(): void + { + $this->payments->shouldReceive('findById')->with(99)->andReturn(null); + + self::assertFalse($this->service->markPaid(99)); + } + + public function testMarkPaidIdempotentWhenAlreadyPaid(): void + { + $this->payments->shouldReceive('findById')->with(80)->andReturn($this->payment(Payment::METHOD_ETRANSFER, Payment::STATUS_PAID, 80)); + + // Already paid → no markPaid/confirm calls. + self::assertTrue($this->service->markPaid(80)); + } +} diff --git a/tests/Unit/Payment/PaymentTest.php b/tests/Unit/Payment/PaymentTest.php new file mode 100644 index 0000000..fccf4d4 --- /dev/null +++ b/tests/Unit/Payment/PaymentTest.php @@ -0,0 +1,63 @@ +currency); + self::assertSame(Payment::METHOD_ETRANSFER, $payment->method); + self::assertSame(Payment::STATUS_PENDING, $payment->status); + self::assertFalse($payment->isPaid()); + self::assertNull($payment->id); + } + + public function testConstants(): void + { + self::assertContains(Payment::METHOD_CARD, Payment::VALID_METHODS); + self::assertContains(Payment::METHOD_ETRANSFER, Payment::VALID_METHODS); + self::assertContains(Payment::METHOD_COMP, Payment::VALID_METHODS); + self::assertContains(Payment::STATUS_PAID, Payment::VALID_STATUSES); + } + + public function testFromRowMapsCorrectly(): void + { + $payment = Payment::fromRow((object) [ + 'id' => '7', + 'student_id' => '5', + 'instructor_id' => '3', + 'registration_type' => Payment::REG_ENROLLMENT, + 'registration_id' => '12', + 'amount' => '120.00', + 'currency' => 'CAD', + 'method' => Payment::METHOD_COMP, + 'status' => Payment::STATUS_PAID, + 'stripe_payment_intent_id' => null, + 'receipt_number' => 'USC-7', + 'receipt_sent_at' => null, + 'paid_at' => '2026-06-08 10:00:00', + ]); + + self::assertSame(7, $payment->id); + self::assertSame(120.00, $payment->amount); + self::assertSame(Payment::METHOD_COMP, $payment->method); + self::assertTrue($payment->isPaid()); + self::assertSame('USC-7', $payment->receiptNumber); + } + + public function testToArrayContainsExpectedKeys(): void + { + $arr = (new Payment(5, 3, Payment::REG_LESSON, 12, 35.00, id: 7))->toArray(); + + foreach (['id', 'student_id', 'amount', 'currency', 'method', 'status', 'receipt_number'] as $key) { + self::assertArrayHasKey($key, $arr); + } + } +} diff --git a/tests/Unit/Payment/ReceiptMailerTest.php b/tests/Unit/Payment/ReceiptMailerTest.php new file mode 100644 index 0000000..9d58d09 --- /dev/null +++ b/tests/Unit/Payment/ReceiptMailerTest.php @@ -0,0 +1,35 @@ +send($payment, null)); + } + + public function testSendsReceiptToStudent(): void + { + Functions\expect('wp_mail') + ->once() + ->with('a@b.test', Mockery::type('string'), Mockery::type('string')) + ->andReturn(true); + + $student = Mockery::mock(\WP_User::class); + $student->user_email = 'a@b.test'; + + $payment = new Payment(5, 3, Payment::REG_LESSON, 12, 35.00, receiptNumber: 'USC-1'); + + self::assertTrue((new ReceiptMailer())->send($payment, $student)); + } +} -- 2.52.0 From 9873cb5e30be67a00c89cad722454a4f7393cb20 Mon Sep 17 00:00:00 2001 From: James Griffin Date: Mon, 8 Jun 2026 10:47:06 -0300 Subject: [PATCH 2/2] Add e-transfer destination email (studio default + offering/booking overrides) The e-transfer destination is resolved at booking time (offering override -> studio default) and frozen onto the payment, so each record keeps where the student was directed. It can then be corrected per booking. - StudioSettings: us_etransfer_email option + a Default e-transfer email field on the Studio Settings page. - Offering: etransfer_email column/field (instructor override) across VO, repo, REST endpoint, admin controller, and form. - Payment: etransfer_email column on the payment (frozen record) + PaymentRepository::updateEtransferEmail; PaymentService freezes it from the offering override or studio default at creation; booking/enrolment pass the offering override. - My Lessons: instructors edit the e-transfer email per pending lesson payment (ownership-checked). - Payments queue: studio admin can correct the email at confirmation (for when a student sends it to the wrong place). - Docs updated. Tests: Payment/Offering rows + PaymentService freezing. composer test (148), cs, and PHPStan level 6 all pass. Refs #7 Co-Authored-By: Claude Opus 4.8 --- docs/features/payments.md | 13 ++++ src/AdminMenu.php | 2 +- src/Booking/BookingEndpoint.php | 2 +- src/Booking/LessonController.php | 65 ++++++++++++++++++- src/GroupClass/EnrollmentEndpoint.php | 2 +- src/Offering/Offering.php | 3 + src/Offering/OfferingController.php | 1 + src/Offering/OfferingEndpoint.php | 8 +++ src/Offering/OfferingRepository.php | 5 +- src/Payment/Payment.php | 3 + src/Payment/PaymentController.php | 17 +++-- src/Payment/PaymentRepository.php | 13 +++- src/Payment/PaymentService.php | 13 +++- src/Payment/StudioSettings.php | 19 ++++-- src/Plugin.php | 2 +- src/Schema.php | 2 + templates/admin/lessons.php | 34 ++++++---- templates/admin/offerings.php | 4 ++ templates/admin/payments.php | 28 ++++---- templates/admin/settings.php | 12 ++++ .../Unit/Offering/OfferingRepositoryTest.php | 1 + tests/Unit/Offering/OfferingTest.php | 1 + tests/Unit/Payment/PaymentRepositoryTest.php | 1 + tests/Unit/Payment/PaymentServiceTest.php | 22 ++++++- tests/Unit/Payment/PaymentTest.php | 1 + 25 files changed, 225 insertions(+), 49 deletions(-) diff --git a/docs/features/payments.md b/docs/features/payments.md index 78723fb..6300c29 100644 --- a/docs/features/payments.md +++ b/docs/features/payments.md @@ -41,6 +41,18 @@ default applies — `card` if Stripe is configured, otherwise `etransfer` | `etransfer`| Payment row created `pending`; admin marks it `paid` when funds arrive | | `comp` | No charge; registration is confirmed immediately, no payment row required | +## E-transfer Destination Email +Where students send e-transfers is resolved and **frozen onto the payment** at +booking time (`us_payments.etransfer_email`), so each record keeps the destination +the student was given. Resolution at creation: + +1. **Offering override** — `us_offerings.etransfer_email`, set by the instructor on the offering. +2. **Studio default** — the `us_etransfer_email` option (Studio Settings, `manage_billing`). + +After booking, the destination on a payment can be corrected per booking: +- **My Lessons** — the instructor edits the e-transfer email for a pending lesson payment. +- **Payments queue** — when marking an e-transfer received, the studio admin can update the email it was actually sent to before confirming. + ## Data Model — `{prefix}us_payments` | Column | Type | Notes | @@ -54,6 +66,7 @@ default applies — `card` if Stripe is configured, otherwise `etransfer` | `currency` | VARCHAR(3) | ISO 4217, e.g. `CAD` | | `method` | VARCHAR(20) | `card` / `etransfer` / `comp` | | `status` | VARCHAR(20) | `pending` / `paid` / `failed` / `refunded` | +| `etransfer_email` | VARCHAR(191) | Frozen e-transfer destination; editable until confirmed | | `stripe_payment_intent_id` | VARCHAR(255) | Stripe PaymentIntent id; NULL for e-transfer / comp | | `receipt_number` | VARCHAR(50) | Sequential receipt id; set when `paid` | | `receipt_sent_at` | DATETIME | When the receipt email was sent; NULL until sent | diff --git a/src/AdminMenu.php b/src/AdminMenu.php index a0b905e..ac757b3 100644 --- a/src/AdminMenu.php +++ b/src/AdminMenu.php @@ -42,7 +42,7 @@ class AdminMenu { public function __construct( AvailabilityRepository $availability, BookingRepository $bookings, OfferingRepository $offerings, QuestionRepository $questions, PolicyRepository $policies, PolicyVersionRepository $policyVersions, PolicyService $policyService, InviteRepository $invites, EnrollmentRepository $enrollments, StudioSettings $settings, PaymentRepository $payments, PaymentService $paymentService, BillingMethodResolver $resolver ) { $this->availabilityController = new AvailabilityController( $availability, $offerings ); - $this->lessonController = new LessonController( $bookings ); + $this->lessonController = new LessonController( $bookings, $payments ); $this->offeringController = new OfferingController( $offerings ); $this->questionController = new QuestionController( $questions, $offerings ); $this->policyController = new PolicyController( $policies, $policyVersions, $policyService ); diff --git a/src/Booking/BookingEndpoint.php b/src/Booking/BookingEndpoint.php index ef58376..959c34c 100644 --- a/src/Booking/BookingEndpoint.php +++ b/src/Booking/BookingEndpoint.php @@ -157,7 +157,7 @@ class BookingEndpoint { $this->gate->record( PolicyAcceptance::REG_LESSON, $anchorId, $studentId, $offeringId, $answers, $acceptedVersionIds, $this->clientIp() ); if ( null !== $offering && $offering->price > 0.0 ) { - $this->payments->createForRegistration( Payment::REG_LESSON, $anchorId, $studentId, $slot->instructorId, $offering->price, $offering->currency ); + $this->payments->createForRegistration( Payment::REG_LESSON, $anchorId, $studentId, $slot->instructorId, $offering->price, $offering->currency, $offering->etransferEmail ); } return new \WP_REST_Response( diff --git a/src/Booking/LessonController.php b/src/Booking/LessonController.php index 774393a..e54400a 100644 --- a/src/Booking/LessonController.php +++ b/src/Booking/LessonController.php @@ -4,17 +4,24 @@ declare(strict_types=1); namespace Unsupervised\Schedular\Booking; use Unsupervised\Schedular\Auth\RoleManager; +use Unsupervised\Schedular\Payment\Payment; +use Unsupervised\Schedular\Payment\PaymentRepository; class LessonController { - public function __construct( private BookingRepository $repository ) {} + public function __construct( + private BookingRepository $repository, + private PaymentRepository $payments, + ) {} public function renderAdminDashboard(): void { if ( ! current_user_can( RoleManager::CAP_VIEW_ALL_LESSONS ) ) { wp_die( esc_html__( 'You do not have permission to view this page.', 'unsupervised-schedular' ) ); } - $lessons = $this->repository->findAllUpcoming(); + $this->handleEtransferUpdate( false ); + + $rows = array_map( fn( Lesson $lesson ): array => $this->row( $lesson ), $this->repository->findAllUpcoming() ); include USC_PLUGIN_DIR . 'templates/admin/lessons.php'; } @@ -24,8 +31,60 @@ class LessonController { wp_die( esc_html__( 'You do not have permission to view lessons.', 'unsupervised-schedular' ) ); } - $lessons = $this->repository->findUpcomingForInstructor( get_current_user_id() ); + $this->handleEtransferUpdate( true ); + + $rows = array_map( fn( Lesson $lesson ): array => $this->row( $lesson ), $this->repository->findUpcomingForInstructor( get_current_user_id() ) ); include USC_PLUGIN_DIR . 'templates/admin/lessons.php'; } + + /** + * Handle a per-lesson e-transfer email override. When $onlyOwn, the payment + * must belong to the current instructor. + */ + private function handleEtransferUpdate( bool $onlyOwn ): void { + if ( ! isset( $_POST['usc_action'] ) || ! check_admin_referer( 'usc_lesson_action' ) ) { + return; + } + + // phpcs:disable WordPress.Security.NonceVerification.Missing -- nonce checked above. + if ( 'set_etransfer' !== sanitize_key( wp_unslash( $_POST['usc_action'] ?? '' ) ) ) { + return; + } + + $paymentId = absint( $_POST['payment_id'] ?? 0 ); + $email = sanitize_email( wp_unslash( $_POST['etransfer_email'] ?? '' ) ); + // phpcs:enable WordPress.Security.NonceVerification.Missing + + if ( $paymentId <= 0 ) { + return; + } + + $payment = $this->payments->findById( $paymentId ); + if ( null !== $payment && ( ! $onlyOwn || get_current_user_id() === $payment->instructorId ) ) { + $this->payments->updateEtransferEmail( $paymentId, '' !== $email ? $email : null ); + } + } + + /** + * Build a display row for a lesson, including its e-transfer payment override. + * + * @return array + */ + private function row( Lesson $lesson ): array { + $student = get_userdata( $lesson->studentId ); + $instructor = get_userdata( $lesson->instructorId ); + $payment = null !== $lesson->paymentId ? $this->payments->findById( $lesson->paymentId ) : null; + + return [ + 'student' => $student ? $student->display_name : (string) $lesson->studentId, + 'instructor' => $instructor ? $instructor->display_name : (string) $lesson->instructorId, + 'slot_id' => (int) $lesson->slotId, + 'status' => $lesson->status, + 'notes' => $lesson->notes ?? '', + 'payment_id' => $payment ? (int) $payment->id : 0, + 'etransfer_email' => $payment ? (string) $payment->etransferEmail : '', + 'etransfer_editable' => null !== $payment && Payment::METHOD_ETRANSFER === $payment->method && ! $payment->isPaid(), + ]; + } } diff --git a/src/GroupClass/EnrollmentEndpoint.php b/src/GroupClass/EnrollmentEndpoint.php index 707a954..8f9e069 100644 --- a/src/GroupClass/EnrollmentEndpoint.php +++ b/src/GroupClass/EnrollmentEndpoint.php @@ -105,7 +105,7 @@ class EnrollmentEndpoint { $this->gate->record( PolicyAcceptance::REG_ENROLLMENT, $id, $studentId, $offeringId, $answers, $acceptedVersionIds, $this->clientIp() ); if ( $offering->price > 0.0 ) { - $this->payments->createForRegistration( Payment::REG_ENROLLMENT, $id, $studentId, $offering->instructorId, $offering->price, $offering->currency ); + $this->payments->createForRegistration( Payment::REG_ENROLLMENT, $id, $studentId, $offering->instructorId, $offering->price, $offering->currency, $offering->etransferEmail ); } return new \WP_REST_Response( diff --git a/src/Offering/Offering.php b/src/Offering/Offering.php index 918c6dc..9a51be8 100644 --- a/src/Offering/Offering.php +++ b/src/Offering/Offering.php @@ -39,6 +39,7 @@ class Offering { public readonly ?string $termStart = null, public readonly ?string $termEnd = null, public readonly ?string $scheduleNote = null, + public readonly ?string $etransferEmail = null, public readonly bool $isActive = true, public readonly ?int $id = null, ) {} @@ -58,6 +59,7 @@ class Offering { termStart: $row->term_start, termEnd: $row->term_end, scheduleNote: $row->schedule_note, + etransferEmail: $row->etransfer_email, isActive: (bool) $row->is_active, id: (int) $row->id, ); @@ -84,6 +86,7 @@ class Offering { 'term_start' => $this->termStart, 'term_end' => $this->termEnd, 'schedule_note' => $this->scheduleNote, + 'etransfer_email' => $this->etransferEmail, 'is_active' => $this->isActive, ]; } diff --git a/src/Offering/OfferingController.php b/src/Offering/OfferingController.php index f187b58..987f95b 100644 --- a/src/Offering/OfferingController.php +++ b/src/Offering/OfferingController.php @@ -77,6 +77,7 @@ class OfferingController { allowWeekly: isset( $_POST['allow_weekly'] ), capacity: $capacity > 0 ? $capacity : null, scheduleNote: $this->nullableText( sanitize_text_field( wp_unslash( $_POST['schedule_note'] ?? '' ) ) ), + etransferEmail: $this->nullableText( sanitize_email( wp_unslash( $_POST['etransfer_email'] ?? '' ) ) ), ) ); // phpcs:enable WordPress.Security.NonceVerification.Missing diff --git a/src/Offering/OfferingEndpoint.php b/src/Offering/OfferingEndpoint.php index a1bcb49..1991ac9 100644 --- a/src/Offering/OfferingEndpoint.php +++ b/src/Offering/OfferingEndpoint.php @@ -95,6 +95,7 @@ class OfferingEndpoint { termStart: $this->nullableText( $request->get_param( 'term_start' ) ), termEnd: $this->nullableText( $request->get_param( 'term_end' ) ), scheduleNote: $this->nullableText( $request->get_param( 'schedule_note' ) ), + etransferEmail: $this->nullableEmail( $request->get_param( 'etransfer_email' ) ), isActive: null === $request->get_param( 'is_active' ) ? true : (bool) $request->get_param( 'is_active' ), ); @@ -139,6 +140,7 @@ class OfferingEndpoint { termStart: $request->has_param( 'term_start' ) ? $this->nullableText( $request->get_param( 'term_start' ) ) : $existing->termStart, termEnd: $request->has_param( 'term_end' ) ? $this->nullableText( $request->get_param( 'term_end' ) ) : $existing->termEnd, scheduleNote: $request->has_param( 'schedule_note' ) ? $this->nullableText( $request->get_param( 'schedule_note' ) ) : $existing->scheduleNote, + etransferEmail: $request->has_param( 'etransfer_email' ) ? $this->nullableEmail( $request->get_param( 'etransfer_email' ) ) : $existing->etransferEmail, isActive: $request->has_param( 'is_active' ) ? (bool) $request->get_param( 'is_active' ) : $existing->isActive, id: $id, ); @@ -186,6 +188,12 @@ class OfferingEndpoint { return max( 0.0, (float) $value ); } + private function nullableEmail( mixed $value ): ?string { + $email = sanitize_email( (string) $value ); + + return '' !== $email ? $email : null; + } + private function nullableInt( mixed $value ): ?int { return ( null === $value || '' === $value ) ? null : (int) $value; } diff --git a/src/Offering/OfferingRepository.php b/src/Offering/OfferingRepository.php index baa3f3a..cfd1f78 100644 --- a/src/Offering/OfferingRepository.php +++ b/src/Offering/OfferingRepository.php @@ -14,11 +14,11 @@ class OfferingRepository { /** * Column formats aligned to {@see columns()} (instructor_id, kind, title, * description, duration_minutes, price, currency, billing_mode, allow_weekly, - * capacity, term_start, term_end, schedule_note, is_active). + * capacity, term_start, term_end, schedule_note, etransfer_email, is_active). * * @var list */ - private const COLUMN_FORMATS = [ '%d', '%s', '%s', '%s', '%d', '%f', '%s', '%s', '%d', '%d', '%s', '%s', '%s', '%d' ]; + private const COLUMN_FORMATS = [ '%d', '%s', '%s', '%s', '%d', '%f', '%s', '%s', '%d', '%d', '%s', '%s', '%s', '%s', '%d' ]; public function insert( Offering $offering ): int { $this->db->insert( @@ -60,6 +60,7 @@ class OfferingRepository { 'term_start' => $offering->termStart, 'term_end' => $offering->termEnd, 'schedule_note' => $offering->scheduleNote, + 'etransfer_email' => $offering->etransferEmail, 'is_active' => $offering->isActive ? 1 : 0, ]; } diff --git a/src/Payment/Payment.php b/src/Payment/Payment.php index b06c04b..40c3aaf 100644 --- a/src/Payment/Payment.php +++ b/src/Payment/Payment.php @@ -40,6 +40,7 @@ class Payment { public readonly string $currency = 'CAD', public readonly string $method = self::METHOD_ETRANSFER, public readonly string $status = self::STATUS_PENDING, + public readonly ?string $etransferEmail = null, public readonly ?string $stripePaymentIntentId = null, public readonly ?string $receiptNumber = null, public readonly ?string $receiptSentAt = null, @@ -57,6 +58,7 @@ class Payment { currency: $row->currency, method: $row->method, status: $row->status, + etransferEmail: $row->etransfer_email, stripePaymentIntentId: $row->stripe_payment_intent_id, receiptNumber: $row->receipt_number, receiptSentAt: $row->receipt_sent_at, @@ -80,6 +82,7 @@ class Payment { 'student_id' => $this->studentId, 'instructor_id' => $this->instructorId, 'registration_type' => $this->registrationType, + 'etransfer_email' => $this->etransferEmail, 'registration_id' => $this->registrationId, 'amount' => $this->amount, 'currency' => $this->currency, diff --git a/src/Payment/PaymentController.php b/src/Payment/PaymentController.php index fee3d60..6871224 100644 --- a/src/Payment/PaymentController.php +++ b/src/Payment/PaymentController.php @@ -20,9 +20,13 @@ 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'] ?? '' ) ) ) { - // phpcs:ignore WordPress.Security.NonceVerification.Missing + // phpcs:disable WordPress.Security.NonceVerification.Missing $paymentId = absint( $_POST['payment_id'] ?? 0 ); + $email = sanitize_email( wp_unslash( $_POST['etransfer_email'] ?? '' ) ); + // phpcs:enable WordPress.Security.NonceVerification.Missing if ( $paymentId > 0 ) { + // Record the destination it was actually sent to before confirming. + $this->payments->updateEtransferEmail( $paymentId, '' !== $email ? $email : null ); $this->service->markPaid( $paymentId ); } } @@ -33,11 +37,12 @@ class PaymentController { $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, + '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, + 'etransfer_email' => (string) $payment->etransferEmail, ]; }, $this->payments->findPending() diff --git a/src/Payment/PaymentRepository.php b/src/Payment/PaymentRepository.php index 4187bdb..66a7d1e 100644 --- a/src/Payment/PaymentRepository.php +++ b/src/Payment/PaymentRepository.php @@ -23,18 +23,29 @@ class PaymentRepository { 'currency' => $payment->currency, 'method' => $payment->method, 'status' => $payment->status, + 'etransfer_email' => $payment->etransferEmail, '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' ] + [ '%d', '%d', '%s', '%d', '%f', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s' ] ); return $this->db->insert_id; } + public function updateEtransferEmail( int $id, ?string $email ): bool { + return false !== $this->db->update( + $this->table, + [ 'etransfer_email' => $email ], + [ 'id' => $id ], + [ '%s' ], + [ '%d' ] + ); + } + public function findById( int $id ): ?Payment { $row = $this->db->get_row( $this->db->prepare( "SELECT * FROM {$this->table} WHERE id = %d", $id ) diff --git a/src/Payment/PaymentService.php b/src/Payment/PaymentService.php index caa58ee..238509c 100644 --- a/src/Payment/PaymentService.php +++ b/src/Payment/PaymentService.php @@ -19,15 +19,17 @@ class PaymentService { private ReceiptMailer $mailer, private BookingRepository $bookings, private EnrollmentRepository $enrollments, + private StudioSettings $settings, ) {} /** * 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. + * (card via Stripe — coming soon; e-transfer confirmed manually). The + * e-transfer destination is frozen now from the offering override or the studio + * default. 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 { + public function createForRegistration( string $type, int $registrationId, int $studentId, int $instructorId, float $amount, string $currency, ?string $offeringEtransferEmail = null ): ?Payment { if ( $amount <= 0.0 ) { return null; } @@ -35,6 +37,10 @@ class PaymentService { $method = $this->resolver->resolve( $studentId ); $status = Payment::METHOD_COMP === $method ? Payment::STATUS_PAID : Payment::STATUS_PENDING; + $etransferEmail = null !== $offeringEtransferEmail && '' !== $offeringEtransferEmail + ? $offeringEtransferEmail + : ( '' !== $this->settings->etransferEmail() ? $this->settings->etransferEmail() : null ); + $id = $this->payments->insert( new Payment( studentId: $studentId, @@ -45,6 +51,7 @@ class PaymentService { currency: $currency, method: $method, status: $status, + etransferEmail: $etransferEmail, ) ); diff --git a/src/Payment/StudioSettings.php b/src/Payment/StudioSettings.php index 9ca274e..089c003 100644 --- a/src/Payment/StudioSettings.php +++ b/src/Payment/StudioSettings.php @@ -7,10 +7,11 @@ 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 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 const OPT_ETRANSFER_EMAIL = 'us_etransfer_email'; public function publishableKey(): string { return (string) get_option( self::OPT_PUBLISHABLE, '' ); @@ -30,6 +31,14 @@ class StudioSettings { return '' !== $currency ? strtoupper( $currency ) : 'CAD'; } + /** + * The studio-default e-transfer destination email (used when an offering has + * no override). + */ + public function etransferEmail(): string { + return (string) get_option( self::OPT_ETRANSFER_EMAIL, '' ); + } + /** * Whether Stripe is configured. When false the platform falls back to * e-transfer billing and card processing is unavailable. @@ -51,6 +60,7 @@ class StudioSettings { $secretKey = $this->secretKey(); $mode = $this->mode(); $currency = $this->currency(); + $etransferEmail = $this->etransferEmail(); $stripeConfigured = $this->isStripeConfigured(); include USC_PLUGIN_DIR . 'templates/admin/settings.php'; @@ -64,6 +74,7 @@ class StudioSettings { 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' ) ) ) ); + update_option( self::OPT_ETRANSFER_EMAIL, sanitize_email( wp_unslash( $_POST['etransfer_email'] ?? '' ) ) ); // phpcs:enable WordPress.Security.NonceVerification.Missing } } diff --git a/src/Plugin.php b/src/Plugin.php index a643728..7ac8790 100644 --- a/src/Plugin.php +++ b/src/Plugin.php @@ -44,7 +44,7 @@ class Plugin { $paymentRepo = new PaymentRepository( $wpdb ); $settings = new StudioSettings(); $resolver = new BillingMethodResolver( $settings ); - $paymentService = new PaymentService( $paymentRepo, $resolver, new ReceiptMailer(), $bookings, $enrollments ); + $paymentService = new PaymentService( $paymentRepo, $resolver, new ReceiptMailer(), $bookings, $enrollments, $settings ); ( new RoleManager() )->register(); ( new AdminMenu( $availability, $bookings, $offerings, $questions, $policies, $policyVersions, $policyService, $invites, $enrollments, $settings, $paymentRepo, $paymentService, $resolver ) )->register(); diff --git a/src/Schema.php b/src/Schema.php index bf1a0c6..9800296 100644 --- a/src/Schema.php +++ b/src/Schema.php @@ -64,6 +64,7 @@ class Schema { term_start DATE DEFAULT NULL, term_end DATE DEFAULT NULL, schedule_note VARCHAR(191) DEFAULT NULL, + etransfer_email VARCHAR(191) DEFAULT NULL, is_active TINYINT(1) NOT NULL DEFAULT 1, created_at DATETIME NOT NULL, PRIMARY KEY (id), @@ -150,6 +151,7 @@ class Schema { currency VARCHAR(3) NOT NULL DEFAULT 'CAD', method VARCHAR(20) NOT NULL DEFAULT 'etransfer', status VARCHAR(20) NOT NULL DEFAULT 'pending', + etransfer_email VARCHAR(191) DEFAULT NULL, stripe_payment_intent_id VARCHAR(255) DEFAULT NULL, receipt_number VARCHAR(50) DEFAULT NULL, receipt_sent_at DATETIME DEFAULT NULL, diff --git a/templates/admin/lessons.php b/templates/admin/lessons.php index 6b1ecf6..c664f30 100644 --- a/templates/admin/lessons.php +++ b/templates/admin/lessons.php @@ -5,12 +5,12 @@ if (! defined('ABSPATH')) { exit; } -/** @var list<\Unsupervised\Schedular\Model\Lesson> $lessons */ +/** @var list $rows */ ?>

- +

@@ -20,21 +20,31 @@ if (! defined('ABSPATH')) { + - - studentId); - $instructor = get_userdata($lesson->instructorId); - ?> + - - - - - + + + + + + diff --git a/templates/admin/offerings.php b/templates/admin/offerings.php index 3e4f5f3..6245f04 100644 --- a/templates/admin/offerings.php +++ b/templates/admin/offerings.php @@ -59,6 +59,10 @@ if (! defined('ABSPATH')) { + + + +
display_name : (string) $lesson->studentId); ?>display_name : (string) $lesson->instructorId); ?>slotId); ?>status); ?>notes ?? ''); ?> + +
+ + + + + +
+ + + +
diff --git a/templates/admin/payments.php b/templates/admin/payments.php index 301eee7..ae5de0c 100644 --- a/templates/admin/payments.php +++ b/templates/admin/payments.php @@ -5,11 +5,11 @@ if (! defined('ABSPATH')) { exit; } -/** @var list $rows */ +/** @var list $rows */ ?>

-

+

@@ -21,26 +21,28 @@ if (! defined('ABSPATH')) { + - - - - - -
- - - + + + + + + + + + + -
- + + diff --git a/templates/admin/settings.php b/templates/admin/settings.php index 002666c..adcee45 100644 --- a/templates/admin/settings.php +++ b/templates/admin/settings.php @@ -10,6 +10,7 @@ if (! defined('ABSPATH')) { * @var string $secretKey * @var string $mode * @var string $currency + * @var string $etransferEmail * @var bool $stripeConfigured */ ?> @@ -53,6 +54,17 @@ if (! defined('ABSPATH')) { + +

+ + + + + +
+ +

+
diff --git a/tests/Unit/Offering/OfferingRepositoryTest.php b/tests/Unit/Offering/OfferingRepositoryTest.php index cf0a18b..b0eb71f 100644 --- a/tests/Unit/Offering/OfferingRepositoryTest.php +++ b/tests/Unit/Offering/OfferingRepositoryTest.php @@ -176,6 +176,7 @@ class OfferingRepositoryTest extends TestCase 'term_start' => null, 'term_end' => null, 'schedule_note' => null, + 'etransfer_email' => null, 'is_active' => '1', ]; } diff --git a/tests/Unit/Offering/OfferingTest.php b/tests/Unit/Offering/OfferingTest.php index c90bfb2..f837eb0 100644 --- a/tests/Unit/Offering/OfferingTest.php +++ b/tests/Unit/Offering/OfferingTest.php @@ -59,6 +59,7 @@ class OfferingTest extends TestCase 'term_start' => '2026-09-01', 'term_end' => '2027-06-30', 'schedule_note' => 'Tuesdays 4:00pm', + 'etransfer_email' => null, 'is_active' => '1', ]; diff --git a/tests/Unit/Payment/PaymentRepositoryTest.php b/tests/Unit/Payment/PaymentRepositoryTest.php index 4697ebe..dd94a2a 100644 --- a/tests/Unit/Payment/PaymentRepositoryTest.php +++ b/tests/Unit/Payment/PaymentRepositoryTest.php @@ -98,6 +98,7 @@ class PaymentRepositoryTest extends TestCase 'currency' => 'CAD', 'method' => Payment::METHOD_ETRANSFER, 'status' => Payment::STATUS_PENDING, + 'etransfer_email' => null, 'stripe_payment_intent_id' => null, 'receipt_number' => null, 'receipt_sent_at' => null, diff --git a/tests/Unit/Payment/PaymentServiceTest.php b/tests/Unit/Payment/PaymentServiceTest.php index f3fe749..dca51f1 100644 --- a/tests/Unit/Payment/PaymentServiceTest.php +++ b/tests/Unit/Payment/PaymentServiceTest.php @@ -13,6 +13,7 @@ use Unsupervised\Schedular\Payment\Payment; use Unsupervised\Schedular\Payment\PaymentRepository; use Unsupervised\Schedular\Payment\PaymentService; use Unsupervised\Schedular\Payment\ReceiptMailer; +use Unsupervised\Schedular\Payment\StudioSettings; use Unsupervised\Schedular\Tests\Unit\TestCase; class PaymentServiceTest extends TestCase @@ -22,6 +23,7 @@ class PaymentServiceTest extends TestCase private ReceiptMailer $mailer; private BookingRepository $bookings; private EnrollmentRepository $enrollments; + private StudioSettings $settings; private PaymentService $service; protected function setUp(): void @@ -33,13 +35,16 @@ class PaymentServiceTest extends TestCase $this->mailer = Mockery::mock(ReceiptMailer::class); $this->bookings = Mockery::mock(BookingRepository::class); $this->enrollments = Mockery::mock(EnrollmentRepository::class); + $this->settings = Mockery::mock(StudioSettings::class); + $this->settings->shouldReceive('etransferEmail')->andReturn(''); $this->service = new PaymentService( $this->payments, $this->resolver, $this->mailer, $this->bookings, - $this->enrollments + $this->enrollments, + $this->settings ); Functions\when('get_userdata')->justReturn(false); @@ -71,6 +76,21 @@ class PaymentServiceTest extends TestCase self::assertSame(Payment::STATUS_PENDING, $result->status); } + public function testOfferingEtransferEmailIsFrozenOntoPayment(): void + { + $this->resolver->shouldReceive('resolve')->with(5)->andReturn(Payment::METHOD_ETRANSFER); + $this->payments->shouldReceive('insert') + ->once() + ->with(Mockery::on(static fn (Payment $p): bool => $p->etransferEmail === 'pay@studio.test')) + ->andReturn(50); + $this->bookings->shouldReceive('setPaymentId')->once()->with(12, 50); + $this->payments->shouldReceive('findById')->with(50)->andReturn($this->payment(Payment::METHOD_ETRANSFER, Payment::STATUS_PENDING, 50)); + + self::assertNotNull( + $this->service->createForRegistration(Payment::REG_LESSON, 12, 5, 3, 35.00, 'CAD', 'pay@studio.test') + ); + } + public function testCompIsPaidAndConfirmsImmediately(): void { $this->resolver->shouldReceive('resolve')->with(5)->andReturn(Payment::METHOD_COMP); diff --git a/tests/Unit/Payment/PaymentTest.php b/tests/Unit/Payment/PaymentTest.php index fccf4d4..74b9bd4 100644 --- a/tests/Unit/Payment/PaymentTest.php +++ b/tests/Unit/Payment/PaymentTest.php @@ -39,6 +39,7 @@ class PaymentTest extends TestCase 'currency' => 'CAD', 'method' => Payment::METHOD_COMP, 'status' => Payment::STATUS_PAID, + 'etransfer_email' => null, 'stripe_payment_intent_id' => null, 'receipt_number' => 'USC-7', 'receipt_sent_at' => null, -- 2.52.0