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..6300c29 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 | |------------|-----------------------------------------------------------------------| @@ -24,6 +41,18 @@ studio admin (default `card`): | `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 | @@ -33,10 +62,11 @@ 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` | +| `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 8f96c5b..ac757b3 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,16 +37,20 @@ 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->lessonController = new LessonController( $bookings, $payments ); $this->offeringController = new OfferingController( $offerings ); $this->questionController = new QuestionController( $questions, $offerings ); $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..959c34c 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, $offering->etransferEmail ); + } + 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/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 2c6d7d3..8f9e069 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, $offering->etransferEmail ); + } + 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/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/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..40c3aaf --- /dev/null +++ b/src/Payment/Payment.php @@ -0,0 +1,95 @@ + + */ + 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 $etransferEmail = null, + 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, + etransferEmail: $row->etransfer_email, + stripePaymentIntentId: $row->stripe_payment_intent_id, + receiptNumber: $row->receipt_number, + receiptSentAt: $row->receipt_sent_at, + paidAt: $row->paid_at, + id: (int) $row->id, + ); + } + + 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, + 'etransfer_email' => $this->etransferEmail, + '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..6871224 --- /dev/null +++ b/src/Payment/PaymentController.php @@ -0,0 +1,53 @@ + 0 ) { + // Record the destination it was actually sent to before confirming. + $this->payments->updateEtransferEmail( $paymentId, '' !== $email ? $email : null ); + $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, + 'etransfer_email' => (string) $payment->etransferEmail, + ]; + }, + $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..66a7d1e --- /dev/null +++ b/src/Payment/PaymentRepository.php @@ -0,0 +1,125 @@ +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, + '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', '%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 ) + ); + + 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..238509c --- /dev/null +++ b/src/Payment/PaymentService.php @@ -0,0 +1,110 @@ +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, + instructorId: $instructorId, + registrationType: $type, + registrationId: $registrationId, + amount: $amount, + currency: $currency, + method: $method, + status: $status, + etransferEmail: $etransferEmail, + ) + ); + + $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..089c003 --- /dev/null +++ b/src/Payment/StudioSettings.php @@ -0,0 +1,80 @@ +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(); + $etransferEmail = $this->etransferEmail(); + $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' ) ) ) ); + 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 81edbae..7ac8790 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, $settings ); + ( 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..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), @@ -140,6 +141,29 @@ 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', + 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, + 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/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 new file mode 100644 index 0000000..ae5de0c --- /dev/null +++ b/templates/admin/payments.php @@ -0,0 +1,51 @@ + $rows */ +?> +
+

+

+ + +

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

+ +
+

+ + + + + +

+
+ +

+
+ + + + + + + + + + + + + + + + + + + +
+ +
+ +

+ + + + + +
+ +

+
+ +
+
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/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/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..dd94a2a --- /dev/null +++ b/tests/Unit/Payment/PaymentRepositoryTest.php @@ -0,0 +1,108 @@ +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, + 'etransfer_email' => null, + '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..dca51f1 --- /dev/null +++ b/tests/Unit/Payment/PaymentServiceTest.php @@ -0,0 +1,137 @@ +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->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->settings + ); + + 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 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); + $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..74b9bd4 --- /dev/null +++ b/tests/Unit/Payment/PaymentTest.php @@ -0,0 +1,64 @@ +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, + 'etransfer_email' => null, + '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)); + } +}