Add payments foundation (e-transfer/comp, Stripe config, receipts, e-transfer email) #25

Merged
thatguygriff merged 2 commits from feature/payments into main 2026-06-08 13:49:39 +00:00
25 changed files with 225 additions and 49 deletions
Showing only changes of commit 9873cb5e30 - Show all commits
+13
View File
@@ -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 | | `etransfer`| Payment row created `pending`; admin marks it `paid` when funds arrive |
| `comp` | No charge; registration is confirmed immediately, no payment row required | | `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` ## Data Model — `{prefix}us_payments`
| Column | Type | Notes | | Column | Type | Notes |
@@ -54,6 +66,7 @@ default applies — `card` if Stripe is configured, otherwise `etransfer`
| `currency` | VARCHAR(3) | ISO 4217, e.g. `CAD` | | `currency` | VARCHAR(3) | ISO 4217, e.g. `CAD` |
| `method` | VARCHAR(20) | `card` / `etransfer` / `comp` | | `method` | VARCHAR(20) | `card` / `etransfer` / `comp` |
| `status` | VARCHAR(20) | `pending` / `paid` / `failed` / `refunded` | | `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 | | `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_number` | VARCHAR(50) | Sequential receipt id; set when `paid` |
| `receipt_sent_at` | DATETIME | When the receipt email was sent; NULL until sent | | `receipt_sent_at` | DATETIME | When the receipt email was sent; NULL until sent |
+1 -1
View File
@@ -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 ) { 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->availabilityController = new AvailabilityController( $availability, $offerings );
$this->lessonController = new LessonController( $bookings ); $this->lessonController = new LessonController( $bookings, $payments );
$this->offeringController = new OfferingController( $offerings ); $this->offeringController = new OfferingController( $offerings );
$this->questionController = new QuestionController( $questions, $offerings ); $this->questionController = new QuestionController( $questions, $offerings );
$this->policyController = new PolicyController( $policies, $policyVersions, $policyService ); $this->policyController = new PolicyController( $policies, $policyVersions, $policyService );
+1 -1
View File
@@ -157,7 +157,7 @@ class BookingEndpoint {
$this->gate->record( PolicyAcceptance::REG_LESSON, $anchorId, $studentId, $offeringId, $answers, $acceptedVersionIds, $this->clientIp() ); $this->gate->record( PolicyAcceptance::REG_LESSON, $anchorId, $studentId, $offeringId, $answers, $acceptedVersionIds, $this->clientIp() );
if ( null !== $offering && $offering->price > 0.0 ) { 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( return new \WP_REST_Response(
+62 -3
View File
@@ -4,17 +4,24 @@ declare(strict_types=1);
namespace Unsupervised\Schedular\Booking; namespace Unsupervised\Schedular\Booking;
use Unsupervised\Schedular\Auth\RoleManager; use Unsupervised\Schedular\Auth\RoleManager;
use Unsupervised\Schedular\Payment\Payment;
use Unsupervised\Schedular\Payment\PaymentRepository;
class LessonController { class LessonController {
public function __construct( private BookingRepository $repository ) {} public function __construct(
private BookingRepository $repository,
private PaymentRepository $payments,
) {}
public function renderAdminDashboard(): void { public function renderAdminDashboard(): void {
if ( ! current_user_can( RoleManager::CAP_VIEW_ALL_LESSONS ) ) { if ( ! current_user_can( RoleManager::CAP_VIEW_ALL_LESSONS ) ) {
wp_die( esc_html__( 'You do not have permission to view this page.', 'unsupervised-schedular' ) ); 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'; 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' ) ); 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'; 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<string, mixed>
*/
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(),
];
}
} }
+1 -1
View File
@@ -105,7 +105,7 @@ class EnrollmentEndpoint {
$this->gate->record( PolicyAcceptance::REG_ENROLLMENT, $id, $studentId, $offeringId, $answers, $acceptedVersionIds, $this->clientIp() ); $this->gate->record( PolicyAcceptance::REG_ENROLLMENT, $id, $studentId, $offeringId, $answers, $acceptedVersionIds, $this->clientIp() );
if ( $offering->price > 0.0 ) { 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( return new \WP_REST_Response(
+3
View File
@@ -39,6 +39,7 @@ class Offering {
public readonly ?string $termStart = null, public readonly ?string $termStart = null,
public readonly ?string $termEnd = null, public readonly ?string $termEnd = null,
public readonly ?string $scheduleNote = null, public readonly ?string $scheduleNote = null,
public readonly ?string $etransferEmail = null,
public readonly bool $isActive = true, public readonly bool $isActive = true,
public readonly ?int $id = null, public readonly ?int $id = null,
) {} ) {}
@@ -58,6 +59,7 @@ class Offering {
termStart: $row->term_start, termStart: $row->term_start,
termEnd: $row->term_end, termEnd: $row->term_end,
scheduleNote: $row->schedule_note, scheduleNote: $row->schedule_note,
etransferEmail: $row->etransfer_email,
isActive: (bool) $row->is_active, isActive: (bool) $row->is_active,
id: (int) $row->id, id: (int) $row->id,
); );
@@ -84,6 +86,7 @@ class Offering {
'term_start' => $this->termStart, 'term_start' => $this->termStart,
'term_end' => $this->termEnd, 'term_end' => $this->termEnd,
'schedule_note' => $this->scheduleNote, 'schedule_note' => $this->scheduleNote,
'etransfer_email' => $this->etransferEmail,
'is_active' => $this->isActive, 'is_active' => $this->isActive,
]; ];
} }
+1
View File
@@ -77,6 +77,7 @@ class OfferingController {
allowWeekly: isset( $_POST['allow_weekly'] ), allowWeekly: isset( $_POST['allow_weekly'] ),
capacity: $capacity > 0 ? $capacity : null, capacity: $capacity > 0 ? $capacity : null,
scheduleNote: $this->nullableText( sanitize_text_field( wp_unslash( $_POST['schedule_note'] ?? '' ) ) ), 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 // phpcs:enable WordPress.Security.NonceVerification.Missing
+8
View File
@@ -95,6 +95,7 @@ class OfferingEndpoint {
termStart: $this->nullableText( $request->get_param( 'term_start' ) ), termStart: $this->nullableText( $request->get_param( 'term_start' ) ),
termEnd: $this->nullableText( $request->get_param( 'term_end' ) ), termEnd: $this->nullableText( $request->get_param( 'term_end' ) ),
scheduleNote: $this->nullableText( $request->get_param( 'schedule_note' ) ), 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' ), 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, 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, 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, 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, isActive: $request->has_param( 'is_active' ) ? (bool) $request->get_param( 'is_active' ) : $existing->isActive,
id: $id, id: $id,
); );
@@ -186,6 +188,12 @@ class OfferingEndpoint {
return max( 0.0, (float) $value ); 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 { private function nullableInt( mixed $value ): ?int {
return ( null === $value || '' === $value ) ? null : (int) $value; return ( null === $value || '' === $value ) ? null : (int) $value;
} }
+3 -2
View File
@@ -14,11 +14,11 @@ class OfferingRepository {
/** /**
* Column formats aligned to {@see columns()} (instructor_id, kind, title, * Column formats aligned to {@see columns()} (instructor_id, kind, title,
* description, duration_minutes, price, currency, billing_mode, allow_weekly, * 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<string> * @var list<string>
*/ */
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 { public function insert( Offering $offering ): int {
$this->db->insert( $this->db->insert(
@@ -60,6 +60,7 @@ class OfferingRepository {
'term_start' => $offering->termStart, 'term_start' => $offering->termStart,
'term_end' => $offering->termEnd, 'term_end' => $offering->termEnd,
'schedule_note' => $offering->scheduleNote, 'schedule_note' => $offering->scheduleNote,
'etransfer_email' => $offering->etransferEmail,
'is_active' => $offering->isActive ? 1 : 0, 'is_active' => $offering->isActive ? 1 : 0,
]; ];
} }
+3
View File
@@ -40,6 +40,7 @@ class Payment {
public readonly string $currency = 'CAD', public readonly string $currency = 'CAD',
public readonly string $method = self::METHOD_ETRANSFER, public readonly string $method = self::METHOD_ETRANSFER,
public readonly string $status = self::STATUS_PENDING, public readonly string $status = self::STATUS_PENDING,
public readonly ?string $etransferEmail = null,
public readonly ?string $stripePaymentIntentId = null, public readonly ?string $stripePaymentIntentId = null,
public readonly ?string $receiptNumber = null, public readonly ?string $receiptNumber = null,
public readonly ?string $receiptSentAt = null, public readonly ?string $receiptSentAt = null,
@@ -57,6 +58,7 @@ class Payment {
currency: $row->currency, currency: $row->currency,
method: $row->method, method: $row->method,
status: $row->status, status: $row->status,
etransferEmail: $row->etransfer_email,
stripePaymentIntentId: $row->stripe_payment_intent_id, stripePaymentIntentId: $row->stripe_payment_intent_id,
receiptNumber: $row->receipt_number, receiptNumber: $row->receipt_number,
receiptSentAt: $row->receipt_sent_at, receiptSentAt: $row->receipt_sent_at,
@@ -80,6 +82,7 @@ class Payment {
'student_id' => $this->studentId, 'student_id' => $this->studentId,
'instructor_id' => $this->instructorId, 'instructor_id' => $this->instructorId,
'registration_type' => $this->registrationType, 'registration_type' => $this->registrationType,
'etransfer_email' => $this->etransferEmail,
'registration_id' => $this->registrationId, 'registration_id' => $this->registrationId,
'amount' => $this->amount, 'amount' => $this->amount,
'currency' => $this->currency, 'currency' => $this->currency,
+11 -6
View File
@@ -20,9 +20,13 @@ class PaymentController {
if ( isset( $_POST['usc_action'] ) && check_admin_referer( 'usc_payment_action' ) ) { if ( isset( $_POST['usc_action'] ) && check_admin_referer( 'usc_payment_action' ) ) {
// phpcs:ignore WordPress.Security.NonceVerification.Missing -- nonce checked above. // phpcs:ignore WordPress.Security.NonceVerification.Missing -- nonce checked above.
if ( 'mark_paid' === sanitize_key( wp_unslash( $_POST['usc_action'] ?? '' ) ) ) { 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 ); $paymentId = absint( $_POST['payment_id'] ?? 0 );
$email = sanitize_email( wp_unslash( $_POST['etransfer_email'] ?? '' ) );
// phpcs:enable WordPress.Security.NonceVerification.Missing
if ( $paymentId > 0 ) { if ( $paymentId > 0 ) {
// Record the destination it was actually sent to before confirming.
$this->payments->updateEtransferEmail( $paymentId, '' !== $email ? $email : null );
$this->service->markPaid( $paymentId ); $this->service->markPaid( $paymentId );
} }
} }
@@ -33,11 +37,12 @@ class PaymentController {
$student = get_userdata( $payment->studentId ); $student = get_userdata( $payment->studentId );
return [ return [
'id' => (int) $payment->id, 'id' => (int) $payment->id,
'student' => $student ? $student->display_name : (string) $payment->studentId, 'student' => $student ? $student->display_name : (string) $payment->studentId,
'amount' => number_format( $payment->amount, 2 ) . ' ' . $payment->currency, 'amount' => number_format( $payment->amount, 2 ) . ' ' . $payment->currency,
'method' => $payment->method, 'method' => $payment->method,
'for' => $payment->registrationType . ' #' . $payment->registrationId, 'for' => $payment->registrationType . ' #' . $payment->registrationId,
'etransfer_email' => (string) $payment->etransferEmail,
]; ];
}, },
$this->payments->findPending() $this->payments->findPending()
+12 -1
View File
@@ -23,18 +23,29 @@ class PaymentRepository {
'currency' => $payment->currency, 'currency' => $payment->currency,
'method' => $payment->method, 'method' => $payment->method,
'status' => $payment->status, 'status' => $payment->status,
'etransfer_email' => $payment->etransferEmail,
'stripe_payment_intent_id' => $payment->stripePaymentIntentId, 'stripe_payment_intent_id' => $payment->stripePaymentIntentId,
'receipt_number' => $payment->receiptNumber, 'receipt_number' => $payment->receiptNumber,
'receipt_sent_at' => $payment->receiptSentAt, 'receipt_sent_at' => $payment->receiptSentAt,
'paid_at' => $payment->paidAt, 'paid_at' => $payment->paidAt,
'created_at' => current_time( 'mysql' ), '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; 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 { public function findById( int $id ): ?Payment {
$row = $this->db->get_row( $row = $this->db->get_row(
$this->db->prepare( "SELECT * FROM {$this->table} WHERE id = %d", $id ) $this->db->prepare( "SELECT * FROM {$this->table} WHERE id = %d", $id )
+10 -3
View File
@@ -19,15 +19,17 @@ class PaymentService {
private ReceiptMailer $mailer, private ReceiptMailer $mailer,
private BookingRepository $bookings, private BookingRepository $bookings,
private EnrollmentRepository $enrollments, private EnrollmentRepository $enrollments,
private StudioSettings $settings,
) {} ) {}
/** /**
* Create the payment for a new registration and link it. Comped students are * Create the payment for a new registration and link it. Comped students are
* marked paid and confirmed immediately; everyone else gets a pending payment * marked paid and confirmed immediately; everyone else gets a pending payment
* (card via Stripe — coming soon; e-transfer confirmed manually). Returns null * (card via Stripe — coming soon; e-transfer confirmed manually). The
* when the registration has no price to charge. * 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 ) { if ( $amount <= 0.0 ) {
return null; return null;
} }
@@ -35,6 +37,10 @@ class PaymentService {
$method = $this->resolver->resolve( $studentId ); $method = $this->resolver->resolve( $studentId );
$status = Payment::METHOD_COMP === $method ? Payment::STATUS_PAID : Payment::STATUS_PENDING; $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( $id = $this->payments->insert(
new Payment( new Payment(
studentId: $studentId, studentId: $studentId,
@@ -45,6 +51,7 @@ class PaymentService {
currency: $currency, currency: $currency,
method: $method, method: $method,
status: $status, status: $status,
etransferEmail: $etransferEmail,
) )
); );
+15 -4
View File
@@ -7,10 +7,11 @@ use Unsupervised\Schedular\Auth\RoleManager;
class StudioSettings { class StudioSettings {
public const OPT_PUBLISHABLE = 'us_stripe_publishable_key'; public const OPT_PUBLISHABLE = 'us_stripe_publishable_key';
public const OPT_SECRET = 'us_stripe_secret_key'; public const OPT_SECRET = 'us_stripe_secret_key';
public const OPT_MODE = 'us_stripe_mode'; public const OPT_MODE = 'us_stripe_mode';
public const OPT_CURRENCY = 'us_currency'; public const OPT_CURRENCY = 'us_currency';
public const OPT_ETRANSFER_EMAIL = 'us_etransfer_email';
public function publishableKey(): string { public function publishableKey(): string {
return (string) get_option( self::OPT_PUBLISHABLE, '' ); return (string) get_option( self::OPT_PUBLISHABLE, '' );
@@ -30,6 +31,14 @@ class StudioSettings {
return '' !== $currency ? strtoupper( $currency ) : 'CAD'; 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 * Whether Stripe is configured. When false the platform falls back to
* e-transfer billing and card processing is unavailable. * e-transfer billing and card processing is unavailable.
@@ -51,6 +60,7 @@ class StudioSettings {
$secretKey = $this->secretKey(); $secretKey = $this->secretKey();
$mode = $this->mode(); $mode = $this->mode();
$currency = $this->currency(); $currency = $this->currency();
$etransferEmail = $this->etransferEmail();
$stripeConfigured = $this->isStripeConfigured(); $stripeConfigured = $this->isStripeConfigured();
include USC_PLUGIN_DIR . 'templates/admin/settings.php'; 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_SECRET, sanitize_text_field( wp_unslash( $_POST['secret_key'] ?? '' ) ) );
update_option( self::OPT_MODE, 'live' === $mode ? 'live' : 'test' ); 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_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 // phpcs:enable WordPress.Security.NonceVerification.Missing
} }
} }
+1 -1
View File
@@ -44,7 +44,7 @@ class Plugin {
$paymentRepo = new PaymentRepository( $wpdb ); $paymentRepo = new PaymentRepository( $wpdb );
$settings = new StudioSettings(); $settings = new StudioSettings();
$resolver = new BillingMethodResolver( $settings ); $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 RoleManager() )->register();
( new AdminMenu( $availability, $bookings, $offerings, $questions, $policies, $policyVersions, $policyService, $invites, $enrollments, $settings, $paymentRepo, $paymentService, $resolver ) )->register(); ( new AdminMenu( $availability, $bookings, $offerings, $questions, $policies, $policyVersions, $policyService, $invites, $enrollments, $settings, $paymentRepo, $paymentService, $resolver ) )->register();
+2
View File
@@ -64,6 +64,7 @@ class Schema {
term_start DATE DEFAULT NULL, term_start DATE DEFAULT NULL,
term_end DATE DEFAULT NULL, term_end DATE DEFAULT NULL,
schedule_note VARCHAR(191) DEFAULT NULL, schedule_note VARCHAR(191) DEFAULT NULL,
etransfer_email VARCHAR(191) DEFAULT NULL,
is_active TINYINT(1) NOT NULL DEFAULT 1, is_active TINYINT(1) NOT NULL DEFAULT 1,
created_at DATETIME NOT NULL, created_at DATETIME NOT NULL,
PRIMARY KEY (id), PRIMARY KEY (id),
@@ -150,6 +151,7 @@ class Schema {
currency VARCHAR(3) NOT NULL DEFAULT 'CAD', currency VARCHAR(3) NOT NULL DEFAULT 'CAD',
method VARCHAR(20) NOT NULL DEFAULT 'etransfer', method VARCHAR(20) NOT NULL DEFAULT 'etransfer',
status VARCHAR(20) NOT NULL DEFAULT 'pending', status VARCHAR(20) NOT NULL DEFAULT 'pending',
etransfer_email VARCHAR(191) DEFAULT NULL,
stripe_payment_intent_id VARCHAR(255) DEFAULT NULL, stripe_payment_intent_id VARCHAR(255) DEFAULT NULL,
receipt_number VARCHAR(50) DEFAULT NULL, receipt_number VARCHAR(50) DEFAULT NULL,
receipt_sent_at DATETIME DEFAULT NULL, receipt_sent_at DATETIME DEFAULT NULL,
+22 -12
View File
@@ -5,12 +5,12 @@ if (! defined('ABSPATH')) {
exit; exit;
} }
/** @var list<\Unsupervised\Schedular\Model\Lesson> $lessons */ /** @var list<array{student: string, instructor: string, slot_id: int, status: string, notes: string, payment_id: int, etransfer_email: string, etransfer_editable: bool}> $rows */
?> ?>
<div class="wrap"> <div class="wrap">
<h1><?php esc_html_e('Lessons', 'unsupervised-schedular'); ?></h1> <h1><?php esc_html_e('Lessons', 'unsupervised-schedular'); ?></h1>
<?php if (empty($lessons)) : ?> <?php if (empty($rows)) : ?>
<p><?php esc_html_e('No upcoming lessons.', 'unsupervised-schedular'); ?></p> <p><?php esc_html_e('No upcoming lessons.', 'unsupervised-schedular'); ?></p>
<?php else : ?> <?php else : ?>
<table class="wp-list-table widefat fixed striped"> <table class="wp-list-table widefat fixed striped">
@@ -20,21 +20,31 @@ if (! defined('ABSPATH')) {
<th><?php esc_html_e('Instructor', 'unsupervised-schedular'); ?></th> <th><?php esc_html_e('Instructor', 'unsupervised-schedular'); ?></th>
<th><?php esc_html_e('Slot ID', 'unsupervised-schedular'); ?></th> <th><?php esc_html_e('Slot ID', 'unsupervised-schedular'); ?></th>
<th><?php esc_html_e('Status', 'unsupervised-schedular'); ?></th> <th><?php esc_html_e('Status', 'unsupervised-schedular'); ?></th>
<th><?php esc_html_e('E-transfer email', 'unsupervised-schedular'); ?></th>
<th><?php esc_html_e('Notes', 'unsupervised-schedular'); ?></th> <th><?php esc_html_e('Notes', 'unsupervised-schedular'); ?></th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<?php foreach ($lessons as $lesson) : ?> <?php foreach ($rows as $row) : ?>
<?php
$student = get_userdata($lesson->studentId);
$instructor = get_userdata($lesson->instructorId);
?>
<tr> <tr>
<td><?php echo esc_html($student ? $student->display_name : (string) $lesson->studentId); ?></td> <td><?php echo esc_html($row['student']); ?></td>
<td><?php echo esc_html($instructor ? $instructor->display_name : (string) $lesson->instructorId); ?></td> <td><?php echo esc_html($row['instructor']); ?></td>
<td><?php echo esc_html((string) $lesson->slotId); ?></td> <td><?php echo esc_html((string) $row['slot_id']); ?></td>
<td><?php echo esc_html($lesson->status); ?></td> <td><?php echo esc_html($row['status']); ?></td>
<td><?php echo esc_html($lesson->notes ?? ''); ?></td> <td>
<?php if ($row['etransfer_editable']) : ?>
<form method="post" style="display:flex; gap:4px;">
<?php wp_nonce_field('usc_lesson_action'); ?>
<input type="hidden" name="usc_action" value="set_etransfer">
<input type="hidden" name="payment_id" value="<?php echo esc_attr((string) $row['payment_id']); ?>">
<input type="email" name="etransfer_email" value="<?php echo esc_attr($row['etransfer_email']); ?>">
<button type="submit" class="button button-small"><?php esc_html_e('Save', 'unsupervised-schedular'); ?></button>
</form>
<?php else : ?>
<?php echo esc_html('' !== $row['etransfer_email'] ? $row['etransfer_email'] : '—'); ?>
<?php endif; ?>
</td>
<td><?php echo esc_html($row['notes']); ?></td>
</tr> </tr>
<?php endforeach; ?> <?php endforeach; ?>
</tbody> </tbody>
+4
View File
@@ -59,6 +59,10 @@ if (! defined('ABSPATH')) {
<th><label for="schedule_note"><?php esc_html_e('Schedule note', 'unsupervised-schedular'); ?></label></th> <th><label for="schedule_note"><?php esc_html_e('Schedule note', 'unsupervised-schedular'); ?></label></th>
<td><input type="text" name="schedule_note" id="schedule_note" class="regular-text" placeholder="<?php esc_attr_e('e.g. Tuesdays 4:00pm', 'unsupervised-schedular'); ?>"></td> <td><input type="text" name="schedule_note" id="schedule_note" class="regular-text" placeholder="<?php esc_attr_e('e.g. Tuesdays 4:00pm', 'unsupervised-schedular'); ?>"></td>
</tr> </tr>
<tr>
<th><label for="etransfer_email"><?php esc_html_e('E-transfer email', 'unsupervised-schedular'); ?></label></th>
<td><input type="email" name="etransfer_email" id="etransfer_email" class="regular-text" placeholder="<?php esc_attr_e('Overrides the studio default', 'unsupervised-schedular'); ?>"></td>
</tr>
</table> </table>
<?php submit_button(esc_html__('Add Offering', 'unsupervised-schedular')); ?> <?php submit_button(esc_html__('Add Offering', 'unsupervised-schedular')); ?>
</form> </form>
+15 -13
View File
@@ -5,11 +5,11 @@ if (! defined('ABSPATH')) {
exit; exit;
} }
/** @var list<array{id: int, student: string, amount: string, method: string, for: string}> $rows */ /** @var list<array{id: int, student: string, amount: string, method: string, for: string, etransfer_email: string}> $rows */
?> ?>
<div class="wrap"> <div class="wrap">
<h1><?php esc_html_e('Payments', 'unsupervised-schedular'); ?></h1> <h1><?php esc_html_e('Payments', 'unsupervised-schedular'); ?></h1>
<p class="description"><?php esc_html_e('Pending payments awaiting confirmation. Marking one received confirms the booking and emails a receipt.', 'unsupervised-schedular'); ?></p> <p class="description"><?php esc_html_e('Pending payments awaiting confirmation. Marking one received confirms the booking and emails a receipt. You can correct the e-transfer email here if the student sent it elsewhere.', 'unsupervised-schedular'); ?></p>
<?php if (empty($rows)) : ?> <?php if (empty($rows)) : ?>
<p><?php esc_html_e('No pending payments.', 'unsupervised-schedular'); ?></p> <p><?php esc_html_e('No pending payments.', 'unsupervised-schedular'); ?></p>
@@ -21,26 +21,28 @@ if (! defined('ABSPATH')) {
<th><?php esc_html_e('For', 'unsupervised-schedular'); ?></th> <th><?php esc_html_e('For', 'unsupervised-schedular'); ?></th>
<th><?php esc_html_e('Amount', 'unsupervised-schedular'); ?></th> <th><?php esc_html_e('Amount', 'unsupervised-schedular'); ?></th>
<th><?php esc_html_e('Method', 'unsupervised-schedular'); ?></th> <th><?php esc_html_e('Method', 'unsupervised-schedular'); ?></th>
<th><?php esc_html_e('E-transfer email', 'unsupervised-schedular'); ?></th>
<th><?php esc_html_e('Actions', 'unsupervised-schedular'); ?></th> <th><?php esc_html_e('Actions', 'unsupervised-schedular'); ?></th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<?php foreach ($rows as $row) : ?> <?php foreach ($rows as $row) : ?>
<tr> <tr>
<td><?php echo esc_html($row['student']); ?></td> <form method="post">
<td><?php echo esc_html($row['for']); ?></td> <?php wp_nonce_field('usc_payment_action'); ?>
<td><?php echo esc_html($row['amount']); ?></td> <input type="hidden" name="usc_action" value="mark_paid">
<td><?php echo esc_html($row['method']); ?></td> <input type="hidden" name="payment_id" value="<?php echo esc_attr((string) $row['id']); ?>">
<td> <td><?php echo esc_html($row['student']); ?></td>
<form method="post" style="display:inline;"> <td><?php echo esc_html($row['for']); ?></td>
<?php wp_nonce_field('usc_payment_action'); ?> <td><?php echo esc_html($row['amount']); ?></td>
<input type="hidden" name="usc_action" value="mark_paid"> <td><?php echo esc_html($row['method']); ?></td>
<input type="hidden" name="payment_id" value="<?php echo esc_attr((string) $row['id']); ?>"> <td><input type="email" name="etransfer_email" class="regular-text" value="<?php echo esc_attr($row['etransfer_email']); ?>"></td>
<td>
<button type="submit" class="button button-small button-primary"> <button type="submit" class="button button-small button-primary">
<?php esc_html_e('Mark received', 'unsupervised-schedular'); ?> <?php esc_html_e('Mark received', 'unsupervised-schedular'); ?>
</button> </button>
</form> </td>
</td> </form>
</tr> </tr>
<?php endforeach; ?> <?php endforeach; ?>
</tbody> </tbody>
+12
View File
@@ -10,6 +10,7 @@ if (! defined('ABSPATH')) {
* @var string $secretKey * @var string $secretKey
* @var string $mode * @var string $mode
* @var string $currency * @var string $currency
* @var string $etransferEmail
* @var bool $stripeConfigured * @var bool $stripeConfigured
*/ */
?> ?>
@@ -53,6 +54,17 @@ if (! defined('ABSPATH')) {
<td><input type="text" name="currency" id="currency" class="small-text" maxlength="3" value="<?php echo esc_attr($currency); ?>"></td> <td><input type="text" name="currency" id="currency" class="small-text" maxlength="3" value="<?php echo esc_attr($currency); ?>"></td>
</tr> </tr>
</table> </table>
<h2><?php esc_html_e('E-transfer', 'unsupervised-schedular'); ?></h2>
<table class="form-table">
<tr>
<th><label for="etransfer_email"><?php esc_html_e('Default e-transfer email', 'unsupervised-schedular'); ?></label></th>
<td>
<input type="email" name="etransfer_email" id="etransfer_email" class="regular-text" value="<?php echo esc_attr($etransferEmail); ?>">
<p class="description"><?php esc_html_e('Where students send e-transfers, unless an offering overrides it. Instructors can also override per offering or per booking.', 'unsupervised-schedular'); ?></p>
</td>
</tr>
</table>
<?php submit_button(esc_html__('Save Settings', 'unsupervised-schedular')); ?> <?php submit_button(esc_html__('Save Settings', 'unsupervised-schedular')); ?>
</form> </form>
</div> </div>
@@ -176,6 +176,7 @@ class OfferingRepositoryTest extends TestCase
'term_start' => null, 'term_start' => null,
'term_end' => null, 'term_end' => null,
'schedule_note' => null, 'schedule_note' => null,
'etransfer_email' => null,
'is_active' => '1', 'is_active' => '1',
]; ];
} }
+1
View File
@@ -59,6 +59,7 @@ class OfferingTest extends TestCase
'term_start' => '2026-09-01', 'term_start' => '2026-09-01',
'term_end' => '2027-06-30', 'term_end' => '2027-06-30',
'schedule_note' => 'Tuesdays 4:00pm', 'schedule_note' => 'Tuesdays 4:00pm',
'etransfer_email' => null,
'is_active' => '1', 'is_active' => '1',
]; ];
@@ -98,6 +98,7 @@ class PaymentRepositoryTest extends TestCase
'currency' => 'CAD', 'currency' => 'CAD',
'method' => Payment::METHOD_ETRANSFER, 'method' => Payment::METHOD_ETRANSFER,
'status' => Payment::STATUS_PENDING, 'status' => Payment::STATUS_PENDING,
'etransfer_email' => null,
'stripe_payment_intent_id' => null, 'stripe_payment_intent_id' => null,
'receipt_number' => null, 'receipt_number' => null,
'receipt_sent_at' => null, 'receipt_sent_at' => null,
+21 -1
View File
@@ -13,6 +13,7 @@ use Unsupervised\Schedular\Payment\Payment;
use Unsupervised\Schedular\Payment\PaymentRepository; use Unsupervised\Schedular\Payment\PaymentRepository;
use Unsupervised\Schedular\Payment\PaymentService; use Unsupervised\Schedular\Payment\PaymentService;
use Unsupervised\Schedular\Payment\ReceiptMailer; use Unsupervised\Schedular\Payment\ReceiptMailer;
use Unsupervised\Schedular\Payment\StudioSettings;
use Unsupervised\Schedular\Tests\Unit\TestCase; use Unsupervised\Schedular\Tests\Unit\TestCase;
class PaymentServiceTest extends TestCase class PaymentServiceTest extends TestCase
@@ -22,6 +23,7 @@ class PaymentServiceTest extends TestCase
private ReceiptMailer $mailer; private ReceiptMailer $mailer;
private BookingRepository $bookings; private BookingRepository $bookings;
private EnrollmentRepository $enrollments; private EnrollmentRepository $enrollments;
private StudioSettings $settings;
private PaymentService $service; private PaymentService $service;
protected function setUp(): void protected function setUp(): void
@@ -33,13 +35,16 @@ class PaymentServiceTest extends TestCase
$this->mailer = Mockery::mock(ReceiptMailer::class); $this->mailer = Mockery::mock(ReceiptMailer::class);
$this->bookings = Mockery::mock(BookingRepository::class); $this->bookings = Mockery::mock(BookingRepository::class);
$this->enrollments = Mockery::mock(EnrollmentRepository::class); $this->enrollments = Mockery::mock(EnrollmentRepository::class);
$this->settings = Mockery::mock(StudioSettings::class);
$this->settings->shouldReceive('etransferEmail')->andReturn('');
$this->service = new PaymentService( $this->service = new PaymentService(
$this->payments, $this->payments,
$this->resolver, $this->resolver,
$this->mailer, $this->mailer,
$this->bookings, $this->bookings,
$this->enrollments $this->enrollments,
$this->settings
); );
Functions\when('get_userdata')->justReturn(false); Functions\when('get_userdata')->justReturn(false);
@@ -71,6 +76,21 @@ class PaymentServiceTest extends TestCase
self::assertSame(Payment::STATUS_PENDING, $result->status); 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 public function testCompIsPaidAndConfirmsImmediately(): void
{ {
$this->resolver->shouldReceive('resolve')->with(5)->andReturn(Payment::METHOD_COMP); $this->resolver->shouldReceive('resolve')->with(5)->andReturn(Payment::METHOD_COMP);
+1
View File
@@ -39,6 +39,7 @@ class PaymentTest extends TestCase
'currency' => 'CAD', 'currency' => 'CAD',
'method' => Payment::METHOD_COMP, 'method' => Payment::METHOD_COMP,
'status' => Payment::STATUS_PAID, 'status' => Payment::STATUS_PAID,
'etransfer_email' => null,
'stripe_payment_intent_id' => null, 'stripe_payment_intent_id' => null,
'receipt_number' => 'USC-7', 'receipt_number' => 'USC-7',
'receipt_sent_at' => null, 'receipt_sent_at' => null,