From 9873cb5e30be67a00c89cad722454a4f7393cb20 Mon Sep 17 00:00:00 2001 From: James Griffin Date: Mon, 8 Jun 2026 10:47:06 -0300 Subject: [PATCH] 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,