diff --git a/CHANGELOG.md b/CHANGELOG.md index bc56328..ce4fb0b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ each change under the current top section as you work. ### Added - Offerings can now bill on a schedule: **weekly** (a pending payment 24 hours before each lesson) or **monthly** (one payment on the 1st for that month's lessons), alongside the existing one-time and full-term modes. Applies to both private lessons and group classes. A daily job generates due payments, and each student receives one consolidated itemised email per scan; batched payments share a reference so the admin Payments queue groups them with a lump-sum total for e-transfer reconciliation. Cancelling a lesson never voids a scheduled payment. +- Cancelling a lesson that was **already paid for** now credits the student that money instead of leaving it as a manual refund. The credit is one lesson's share of what they paid — the whole amount for a single lesson, or a per-lesson slice of a monthly charge or a full-term series. The daily billing scan automatically applies any available credit against a student's upcoming weekly/monthly charges before emailing their notice, which shows the credit applied and the reduced total due; a charge fully covered by credit is settled and leaves the admin Payments queue. A student's outstanding credit balance is shown on their **student detail** page in the studio admin. Still-pending (unpaid) payments continue to be voided on cancellation as before. - Group classes now carry an **enrolment deadline** the instructor sets on the offering. It defaults to the first day of the class, and once it passes students can no longer enrol — the enrolment page shows the class as closed and the API rejects late enrolments. While enrolment is open, each class card shows an "Enrol by" date. - Instructors can add students to any group class by hand from its details page (**Add students directly**), which now appears for public classes too, not just invite-only ones. This bypasses the enrolment deadline and capacity, so a student can be enrolled as a **late enrolment** after the class has closed to self-enrolment. - Studio admins and instructors can open a **lesson detail view** from the Scheduler and My Lessons lists, showing the offering booked, the policy versions the student accepted (with acceptance time and IP), and their intake answers. On My Lessons an instructor may only open their own lessons; the studio Scheduler may open any. diff --git a/docs/features/credits.md b/docs/features/credits.md new file mode 100644 index 0000000..be077fc --- /dev/null +++ b/docs/features/credits.md @@ -0,0 +1,123 @@ +# Feature: Student Credits (cancelled paid lessons) + +## Overview +When a lesson that has **already been paid for** is cancelled, the student is +credited the amount they paid for *that lesson*. The credit sits on their account +and is automatically applied against their future scheduled-billing charges +(weekly / monthly) before they are asked to pay — so a cancelled-and-paid lesson +becomes money toward the next one rather than a manual refund. + +This complements — it does not replace — the existing cancellation behaviour: a +still-**pending** payment is voided (`PaymentService::voidPending`), and only a +**paid** payment produces a credit. + +## Credit amount — one lesson's share +The credit is one lesson's share of the covering payment's **total (including +tax)**: + +| Covering payment | Lessons it covers | Credit on cancelling one | +|------------------|-------------------|--------------------------| +| Single booking (one-time / full-term single) | 1 | the whole total | +| Weekly **scheduled** lesson | 1 (one payment per lesson) | the whole total | +| Monthly **scheduled** charge | N lessons that month | `total ÷ N` | +| Weekly reservation **series** paid upfront (full-term) | the whole series | `total ÷ series size` | + +The divisor is resolved in `PaymentService::coveredLessonCount`: a weekly series +paid upfront (an *unscheduled* payment on a lesson that has a `series_id`) divides +by the series size (`BookingRepository::countBySeries`); every other case divides +by how many lessons point at the payment (`BookingRepository::countByPaymentId`), +which is 1 for a single or weekly-scheduled lesson and N for a monthly charge. + +The original payment is **left untouched** — the studio keeps the money it +collected; the credit is a forward-looking liability offset against future +billing, never a refund of past revenue. + +### Guards +- Only a **paid** payment credits; an unpaid/pending one is voided instead. +- A lesson is credited **once** — `CreditRepository::existsForLesson` blocks a + second credit if the same lesson is cancelled again after being reinstated. +- A non-anchor lesson in a series (no `payment_id` of its own) is credited through + the series anchor's payment. + +## Applying credit at billing time +The daily scan (`Payment\ScheduledBillingRunner`) generates each student's due +payments, then — before sending the notice — applies their available credit +across those charges oldest-first (`PaymentService::applyCredits`): + +- Each payment's `us_payments.credit_applied` is raised by the amount covered, + reducing what the student owes (`Payment::netDue()`). +- A payment **fully** covered by credit is marked **paid-by-credit** (status + `paid`, registration confirmed) so it drops out of the admin confirmation queue. +- A payment **partially** covered stays `pending` at its reduced net due, shown in + the admin Payments queue and on the notice. +- The credit ledger is drawn down by the total applied + (`CreditRepository::consume`, FIFO), marking each spent credit `consumed`. + +The consolidated notice email (`Payment\PaymentDueMailer`) lists each charge at +its full amount, then an **"Account credit applied: -X"** line and the reduced +**Total due**. When the balance is zero the notice still goes out (so the student +knows their credit covered it) but carries no e-transfer destination or reference. + +## Admin visibility +The studio admin sees a student's credit on their **student detail** page (gated by +`manage_billing`, like the payment history). An **Account credit** section shows the +available balance and a table of every credit — date, reason, original amount, +remaining, and status (`available` / `consumed`). Built by +`Auth\StudentHistory::creditBalance` / `::credits`. + +## Data model — `{prefix}us_credits` + +| Column | Type | Notes | +|---------------------|-----------------|---------------------------------------------------| +| `id` | BIGINT UNSIGNED | Primary key | +| `student_id` | BIGINT UNSIGNED | WordPress user ID | +| `amount` | DECIMAL(10,2) | Original credit amount | +| `remaining` | DECIMAL(10,2) | Unused balance | +| `currency` | VARCHAR(3) | ISO 4217 | +| `source_payment_id` | BIGINT UNSIGNED | Payment that paid for the cancelled lesson | +| `source_lesson_id` | BIGINT UNSIGNED | The cancelled lesson (dedup key) | +| `reason` | VARCHAR(191) | Human-readable note | +| `status` | VARCHAR(20) | `available` / `consumed` | +| `created_at` | DATETIME | Insertion time | +| `updated_at` | DATETIME | Last draw-down; NULL until first consumed | + +A new column on `{prefix}us_payments`: + +| Column | Type | Notes | +|------------------|---------------|-----------------------------------------------------------| +| `credit_applied` | DECIMAL(10,2) | Account credit applied to this payment; `netDue = total − credit_applied` | + +> **Schema change:** `us_credits` and `us_payments.credit_applied` ship as part of +> the (as-yet-unreleased) **1.2.0** — the same release as scheduled billing — so +> `Installer`/`dbDelta` create them when a pre-1.2.0 site upgrades. If you are on a +> 1.2.0 *dev* build that predates this feature, the stored `us_schedular_version` +> already matches `USC_VERSION`, so `Plugin::boot()` will not re-run the installer; +> reactivate the plugin (or bump the version) to pick the new table/column up. + +## Reporting caveat +Credits never touch past revenue and a credit-covered future charge is still +marked `paid`, so `PaymentReport` (which sums `status = paid`) counts the original +paid lesson and the later credit-covered lesson as gross revenue. This mirrors the +design choice to leave the original payment intact rather than represent a partial +refund of a shared payment. + +## Implementation +- Model: `Unsupervised\Schedular\Payment\Credit` +- Repository: `Unsupervised\Schedular\Payment\CreditRepository` +- Issue on cancel: `PaymentService::creditForCancelledLesson` + (called from `Booking\BookingEndpoint::cancel` and `::updateStatus`) +- Apply at billing: `PaymentService::applyCredits`, driven by + `Payment\ScheduledBillingRunner::sendNotices` +- Net due: `Payment::netDue()`, `PaymentRepository::addCreditApplied` +- Lesson counts: `Booking\BookingRepository::countByPaymentId` / `countBySeries` +- Admin view: `Auth\StudentHistory::creditBalance` / `::credits`, rendered in + `templates/admin/student-detail.php` + +## Tests +- `tests/Unit/Payment/CreditRepositoryTest.php` +- `tests/Unit/Payment/PaymentServiceTest.php` (`creditForCancelledLesson`, `applyCredits`) +- `tests/Unit/Payment/ScheduledBillingRunnerTest.php` (credit applied to a run) +- `tests/Unit/Payment/PaymentDueMailerTest.php` (credit line + reduced total) +- `tests/Unit/Payment/PaymentTest.php` (`netDue`) +- `tests/Unit/Booking/BookingEndpointTest.php` (credit issued on cancel) +- `tests/Unit/Auth/StudentHistoryTest.php` (`creditBalance`, `credits`) diff --git a/docs/features/payments.md b/docs/features/payments.md index 172b5e4..d6c4e37 100644 --- a/docs/features/payments.md +++ b/docs/features/payments.md @@ -118,6 +118,11 @@ notice per scan (`Payment\PaymentDueMailer`). Because these payments are schedul monthly charge (and every other lesson it covers) untouched, and never rebills. Full model, dedup, and the four generation cases are documented in `scheduled-billing.md`. +Cancelling a lesson that was **already paid** issues the student an account credit for +that lesson's share of what they paid; the next daily scan applies any available credit +against their due charges (reducing `us_payments.credit_applied` → `Payment::netDue()`) +before emailing the notice. See `credits.md`. + ## REST API | Method | Endpoint | Permission | |---------|---------------------------------------------|-----------------------------| diff --git a/docs/features/scheduled-billing.md b/docs/features/scheduled-billing.md index 078bc80..d7f0b82 100644 --- a/docs/features/scheduled-billing.md +++ b/docs/features/scheduled-billing.md @@ -69,8 +69,12 @@ at-registration payments have no batch and appear on their own. ## Cancellation Scheduled payments are never auto-voided. `PaymentService::voidPending` acts only on legacy at-registration payments (`! Payment::isScheduled()`), so cancelling one lesson -never voids a shared monthly charge, never refunds, and never rebills. Refunds/credits -are a manual, admin-side decision. +never voids a shared monthly charge, never refunds, and never rebills. + +Cancelling a lesson that was **already paid** credits the student one lesson's share +of what they paid (`PaymentService::creditForCancelledLesson`), and the next scan +applies that credit against their due charges before emailing the notice +(`PaymentService::applyCredits`). See `credits.md` for the full model. ## Implementation - Runner: `Unsupervised\Schedular\Payment\ScheduledBillingRunner` diff --git a/docs/features/student-administration.md b/docs/features/student-administration.md index b4a19f8..c523dff 100644 --- a/docs/features/student-administration.md +++ b/docs/features/student-administration.md @@ -33,6 +33,10 @@ No new tables. The views are composed from existing data: and when it was accepted. - **Intake answers** — every registration-question answer, newest first: question label, answer, and the registration it was given for. + - **Account credit** (`manage_billing` only) — the student's available credit + balance plus every credit (date, reason, amount, remaining, status). Credit + comes from cancelled paid lessons and is applied automatically to upcoming + scheduled billing. See `credits.md`. - **Payment history** (`manage_billing` only) — every payment, newest first: date, context, method, status, subtotal, HST, total, and receipt number. @@ -44,7 +48,8 @@ All actions are nonce-protected POSTs handled on the detail page: - **Cancel lesson** — on any non-cancelled upcoming lesson. Uses the same path as student-initiated cancellation: the lesson is marked `cancelled`, the availability slot is freed for rebooking, and a still-pending payment is - voided. Paid lessons keep their payment — refunds stay a manual decision (#72). + voided. A paid lesson is credited back to the student's account (see + `credits.md`) rather than refunded. - **Withdraw** — on an active group-class enrolment: marked `cancelled` (freeing its capacity seat), with the same pending-payment voiding. diff --git a/src/AdminMenu.php b/src/AdminMenu.php index 697ed01..83046ae 100644 --- a/src/AdminMenu.php +++ b/src/AdminMenu.php @@ -25,6 +25,7 @@ use Unsupervised\Schedular\Offering\ClassSlotReconciler; use Unsupervised\Schedular\Offering\OfferingController; use Unsupervised\Schedular\Offering\OfferingRepository; use Unsupervised\Schedular\Payment\BillingMethodResolver; +use Unsupervised\Schedular\Payment\CreditRepository; use Unsupervised\Schedular\Payment\PaymentController; use Unsupervised\Schedular\Payment\PaymentReportController; use Unsupervised\Schedular\Payment\PaymentRepository; @@ -56,7 +57,7 @@ class AdminMenu { private PaymentController $paymentController; private PaymentReportController $paymentReportController; - public function __construct( AvailabilityRepository $availability, BookingRepository $bookings, OfferingRepository $offerings, QuestionRepository $questions, AnswerRepository $answers, PolicyRepository $policies, PolicyVersionRepository $policyVersions, PolicyService $policyService, AcceptanceRepository $acceptances, InviteRepository $invites, EnrollmentRepository $enrollments, GroupAccessRepository $groupAccess, StudioSettings $settings, PaymentRepository $payments, PaymentService $paymentService, BillingMethodResolver $resolver, RegistrationMailer $registrationMailer ) { + public function __construct( AvailabilityRepository $availability, BookingRepository $bookings, OfferingRepository $offerings, QuestionRepository $questions, AnswerRepository $answers, PolicyRepository $policies, PolicyVersionRepository $policyVersions, PolicyService $policyService, AcceptanceRepository $acceptances, InviteRepository $invites, EnrollmentRepository $enrollments, GroupAccessRepository $groupAccess, StudioSettings $settings, PaymentRepository $payments, PaymentService $paymentService, BillingMethodResolver $resolver, RegistrationMailer $registrationMailer, CreditRepository $credits ) { $this->availabilityController = new AvailabilityController( $availability, $offerings ); $this->lessonController = new LessonController( $bookings, $payments, $availability, $offerings, new LessonDetail( $answers, $questions, $acceptances, $policies, $policyVersions ) ); $this->offeringController = new OfferingController( $offerings, new ClassSlotReconciler( $availability ) ); @@ -65,7 +66,7 @@ class AdminMenu { $this->registrationController = new RegistrationController( $invites ); $this->registrationApprovalController = new RegistrationApprovalController( $registrationMailer ); $this->groupClassController = new GroupClassController( $enrollments, $offerings, $payments, $groupAccess, $paymentService, $invites, $registrationMailer ); - $this->studentController = new StudentController( $bookings, $availability, $offerings, $enrollments, $resolver, new StudentHistory( $acceptances, $policies, $policyVersions, $answers, $questions, $payments ), new StudentActions( $bookings, $availability, $enrollments, $paymentService ) ); + $this->studentController = new StudentController( $bookings, $availability, $offerings, $enrollments, $resolver, new StudentHistory( $acceptances, $policies, $policyVersions, $answers, $questions, $payments, $credits ), new StudentActions( $bookings, $availability, $enrollments, $paymentService ) ); $this->instructorController = new InstructorController(); $this->settings = $settings; $this->accessSettings = new AccessSettings(); diff --git a/src/Auth/StudentActions.php b/src/Auth/StudentActions.php index b3718ed..f1b5f24 100644 --- a/src/Auth/StudentActions.php +++ b/src/Auth/StudentActions.php @@ -27,8 +27,9 @@ class StudentActions { /** * Cancel a lesson on the student's behalf: marks it cancelled, frees the - * slot for rebooking, and voids a still-pending payment. Paid lessons keep - * their payment — refunds are a manual, admin-side decision. + * slot for rebooking, and voids a still-pending payment. A paid lesson is + * credited back to the student's account (a per-lesson share of what they + * paid) to offset their future scheduled billing. */ public function cancelLesson( int $lessonId, int $studentId ): bool { $lesson = $this->bookings->findById( $lessonId ); @@ -40,6 +41,7 @@ class StudentActions { $this->bookings->updateStatus( $lessonId, Lesson::STATUS_CANCELLED ); $this->availability->release( $lesson->slotId ); $this->payments->voidPending( $lesson->paymentId ); + $this->payments->creditForCancelledLesson( $lesson ); return true; } diff --git a/src/Auth/StudentController.php b/src/Auth/StudentController.php index 5a2fb3b..b4aa075 100644 --- a/src/Auth/StudentController.php +++ b/src/Auth/StudentController.php @@ -149,11 +149,24 @@ class StudentController { $registrationInfo = $this->history->registrationInfo( (int) $student->ID ); $intake = $this->history->intakeAnswers( (int) $student->ID ); $payments = $canBilling ? $this->history->payments( (int) $student->ID ) : []; + $credits = $canBilling ? $this->history->credits( (int) $student->ID ) : []; + $creditBalance = $canBilling ? $this->history->creditBalance( (int) $student->ID ) : 0.0; + $creditCurrency = $this->creditCurrency( $credits ); $backUrl = admin_url( 'admin.php?page=us-students' ); include USC_PLUGIN_DIR . 'templates/admin/student-detail.php'; } + /** + * Currency to label the credit balance with — taken from the student's credits + * (they share a currency in practice), defaulting to CAD when they have none. + * + * @param list $credits + */ + private function creditCurrency( array $credits ): string { + return [] !== $credits ? (string) $credits[0]['currency'] : 'CAD'; + } + /** * Build a display row for a lesson (slot time, offering, instructor, status). * diff --git a/src/Auth/StudentHistory.php b/src/Auth/StudentHistory.php index acde576..4d8ed83 100644 --- a/src/Auth/StudentHistory.php +++ b/src/Auth/StudentHistory.php @@ -3,6 +3,8 @@ declare(strict_types=1); namespace Unsupervised\Schedular\Auth; +use Unsupervised\Schedular\Payment\Credit; +use Unsupervised\Schedular\Payment\CreditRepository; use Unsupervised\Schedular\Payment\Payment; use Unsupervised\Schedular\Payment\PaymentRepository; use Unsupervised\Schedular\Policy\AcceptanceRepository; @@ -27,6 +29,7 @@ class StudentHistory { private AnswerRepository $answers, private QuestionRepository $questions, private PaymentRepository $payments, + private CreditRepository $credits, ) {} /** @@ -129,6 +132,34 @@ class StudentHistory { ); } + /** + * The student's total unused credit balance (from cancelled paid lessons), + * applied automatically against future scheduled-billing charges. + */ + public function creditBalance( int $studentId ): float { + return $this->credits->availableBalance( $studentId ); + } + + /** + * Every credit the student has been issued, newest first, with the amount, what + * remains, and its state. + * + * @return list + */ + public function credits( int $studentId ): array { + return array_map( + static fn( Credit $credit ): array => [ + 'created_at' => $credit->createdAt ?? '', + 'amount' => $credit->amount, + 'remaining' => $credit->remaining, + 'currency' => $credit->currency, + 'reason' => $credit->reason ?? '—', + 'status' => $credit->status, + ], + $this->credits->findByStudent( $studentId ) + ); + } + /** * Human label for a polymorphic registration target. */ diff --git a/src/Booking/BookingEndpoint.php b/src/Booking/BookingEndpoint.php index 60fe5af..486dc55 100644 --- a/src/Booking/BookingEndpoint.php +++ b/src/Booking/BookingEndpoint.php @@ -343,8 +343,9 @@ class BookingEndpoint { /** * Student-initiated cancellation of their own lesson: marks it cancelled, - * frees the slot for rebooking, and voids any still-pending payment. Paid - * lessons keep their payment — refunds are a manual, admin-side decision. + * frees the slot for rebooking, and voids any still-pending payment. A lesson + * already paid for is credited back to the student's account (a per-lesson + * share of the covering payment) to offset their future scheduled billing. */ public function cancel( \WP_REST_Request $request ): \WP_REST_Response|\WP_Error { $id = absint( Val::int( $request->get_param( 'id' ) ) ); @@ -379,6 +380,7 @@ class BookingEndpoint { $this->bookings->updateStatus( $id, Lesson::STATUS_CANCELLED ); $this->availability->release( $lesson->slotId ); $this->payments->voidPending( $lesson->paymentId ); + $this->payments->creditForCancelledLesson( $lesson ); } return new \WP_REST_Response( @@ -407,6 +409,7 @@ class BookingEndpoint { if ( Lesson::STATUS_CANCELLED === $status && Lesson::STATUS_CANCELLED !== $lesson->status ) { $this->availability->release( $lesson->slotId ); $this->payments->voidPending( $lesson->paymentId ); + $this->payments->creditForCancelledLesson( $lesson ); } elseif ( Lesson::STATUS_CANCELLED === $lesson->status && Lesson::STATUS_CANCELLED !== $status && ! $this->availability->claim( $lesson->slotId ) ) { // Reinstating a cancelled lesson must re-reserve its slot, and // someone else may have booked the freed time in the meantime. diff --git a/src/Booking/BookingRepository.php b/src/Booking/BookingRepository.php index 065f7c9..a3adfe8 100644 --- a/src/Booking/BookingRepository.php +++ b/src/Booking/BookingRepository.php @@ -243,6 +243,37 @@ class BookingRepository { return $rows ?? []; } + /** + * How many lessons a payment covers — every lesson pointed at it, cancelled or + * not, since the payment was billed for all of them. Used to split a paid + * payment's total into a per-lesson share when one covered lesson is cancelled + * and credited. Never below zero. + */ + public function countByPaymentId( int $paymentId ): int { + return (int) $this->db->get_var( + $this->db->prepare( + 'SELECT COUNT(*) FROM %i WHERE payment_id = %d', + $this->table, + $paymentId + ) + ); + } + + /** + * How many lessons belong to a weekly series — the whole reservation an upfront + * (full-term) payment covers, so cancelling one lesson credits its per-lesson + * share. Counts every lesson in the series, cancelled or not. + */ + public function countBySeries( int $seriesId ): int { + return (int) $this->db->get_var( + $this->db->prepare( + 'SELECT COUNT(*) FROM %i WHERE series_id = %d', + $this->table, + $seriesId + ) + ); + } + public function setPaymentId( int $id, int $paymentId ): bool { return false !== $this->db->update( $this->table, diff --git a/src/Payment/Credit.php b/src/Payment/Credit.php new file mode 100644 index 0000000..f2dfc5e --- /dev/null +++ b/src/Payment/Credit.php @@ -0,0 +1,79 @@ + + */ + public const VALID_STATUSES = [ self::STATUS_AVAILABLE, self::STATUS_CONSUMED ]; + + public function __construct( + public readonly int $studentId, + public readonly float $amount, + public readonly float $remaining, + public readonly string $currency = 'CAD', + public readonly ?int $sourcePaymentId = null, + public readonly ?int $sourceLessonId = null, + public readonly ?string $reason = null, + public readonly string $status = self::STATUS_AVAILABLE, + public readonly ?string $createdAt = null, + public readonly ?string $updatedAt = null, + public readonly ?int $id = null, + ) {} + + public static function fromRow( \stdClass $row ): self { + return new self( + studentId: Val::int( $row->student_id ), + amount: Val::float( $row->amount ), + remaining: Val::float( $row->remaining ), + currency: Val::string( $row->currency ), + sourcePaymentId: Val::intOrNull( $row->source_payment_id ?? null ), + sourceLessonId: Val::intOrNull( $row->source_lesson_id ?? null ), + reason: Val::stringOrNull( $row->reason ?? null ), + status: Val::string( $row->status ), + createdAt: Val::stringOrNull( $row->created_at ?? null ), + updatedAt: Val::stringOrNull( $row->updated_at ?? null ), + id: Val::int( $row->id ), + ); + } + + public function isAvailable(): bool { + return self::STATUS_AVAILABLE === $this->status && $this->remaining > 0.0; + } + + /** + * Returns a plain array representation of the credit. + * + * @return array + */ + public function toArray(): array { + return [ + 'id' => $this->id, + 'student_id' => $this->studentId, + 'amount' => $this->amount, + 'remaining' => $this->remaining, + 'currency' => $this->currency, + 'source_payment_id' => $this->sourcePaymentId, + 'source_lesson_id' => $this->sourceLessonId, + 'reason' => $this->reason, + 'status' => $this->status, + 'created_at' => $this->createdAt, + 'updated_at' => $this->updatedAt, + ]; + } +} diff --git a/src/Payment/CreditRepository.php b/src/Payment/CreditRepository.php new file mode 100644 index 0000000..5ca279e --- /dev/null +++ b/src/Payment/CreditRepository.php @@ -0,0 +1,149 @@ +table = $db->prefix . 'us_credits'; + } + + public function insert( Credit $credit ): int { + $this->db->insert( + $this->table, + [ + 'student_id' => $credit->studentId, + 'amount' => $credit->amount, + 'remaining' => $credit->remaining, + 'currency' => $credit->currency, + 'source_payment_id' => $credit->sourcePaymentId, + 'source_lesson_id' => $credit->sourceLessonId, + 'reason' => $credit->reason, + 'status' => $credit->status, + 'created_at' => current_time( 'mysql' ), + ], + [ '%d', '%f', '%f', '%s', '%d', '%d', '%s', '%s', '%s' ] + ); + + return $this->db->insert_id; + } + + public function findById( int $id ): ?Credit { + $row = $this->db->get_row( + $this->db->prepare( 'SELECT * FROM %i WHERE id = %d', $this->table, $id ) + ); + + return $row ? Credit::fromRow( $row ) : null; + } + + /** + * Whether a credit has already been issued for a cancelled lesson, so cancelling + * (or re-cancelling) the same lesson never grants a second credit. + */ + public function existsForLesson( int $lessonId ): bool { + $found = $this->db->get_var( + $this->db->prepare( + 'SELECT id FROM %i WHERE source_lesson_id = %d LIMIT 1', + $this->table, + $lessonId + ) + ); + + return null !== $found; + } + + /** + * A student's total unused credit balance (sum of the remaining amounts of every + * still-available credit). + */ + public function availableBalance( int $studentId ): float { + $total = $this->db->get_var( + $this->db->prepare( + 'SELECT COALESCE( SUM( remaining ), 0 ) FROM %i WHERE student_id = %d AND status = %s', + $this->table, + $studentId, + Credit::STATUS_AVAILABLE + ) + ); + + return round( (float) $total, 2 ); + } + + /** + * A student's still-available credits, oldest first — the FIFO order they are + * consumed in. + * + * @return list + */ + public function findAvailableByStudent( int $studentId ): array { + $rows = $this->db->get_results( + $this->db->prepare( + 'SELECT * FROM %i WHERE student_id = %d AND status = %s AND remaining > 0 ORDER BY created_at ASC, id ASC', + $this->table, + $studentId, + Credit::STATUS_AVAILABLE + ) + ); + + return array_map( Credit::fromRow( ... ), $rows ?? [] ); + } + + /** + * Every credit for a student, newest first (admin history). + * + * @return list + */ + public function findByStudent( int $studentId ): array { + $rows = $this->db->get_results( + $this->db->prepare( + 'SELECT * FROM %i WHERE student_id = %d ORDER BY created_at DESC, id DESC', + $this->table, + $studentId + ) + ); + + return array_map( Credit::fromRow( ... ), $rows ?? [] ); + } + + /** + * Draw down a student's credit balance by $amount, consuming their available + * credits oldest first and marking each fully-spent credit `consumed`. Stops once + * the amount is exhausted; a balance shorter than $amount simply drains to zero. + */ + public function consume( int $studentId, float $amount ): void { + $remaining = round( $amount, 2 ); + if ( $remaining <= 0.0 ) { + return; + } + + foreach ( $this->findAvailableByStudent( $studentId ) as $credit ) { + if ( $remaining <= 0.0 ) { + break; + } + if ( null === $credit->id ) { + continue; + } + + $take = min( $credit->remaining, $remaining ); + $newRemaining = round( $credit->remaining - $take, 2 ); + $status = $newRemaining <= 0.0 ? Credit::STATUS_CONSUMED : Credit::STATUS_AVAILABLE; + + $this->db->update( + $this->table, + [ + 'remaining' => $newRemaining, + 'status' => $status, + 'updated_at' => current_time( 'mysql' ), + ], + [ 'id' => $credit->id ], + [ '%f', '%s', '%s' ], + [ '%d' ] + ); + + $remaining = round( $remaining - $take, 2 ); + } + } +} diff --git a/src/Payment/Payment.php b/src/Payment/Payment.php index 8e28eb9..230b5f0 100644 --- a/src/Payment/Payment.php +++ b/src/Payment/Payment.php @@ -44,6 +44,7 @@ class Payment { public readonly string $status = self::STATUS_PENDING, public readonly float $taxRate = 0.0, public readonly float $taxAmount = 0.0, + public readonly float $creditApplied = 0.0, public readonly ?string $dueDate = null, public readonly ?string $periodKey = null, public readonly ?string $noticeBatch = null, @@ -68,6 +69,7 @@ class Payment { status: Val::string( $row->status ), taxRate: Val::float( $row->tax_rate ), taxAmount: Val::float( $row->tax_amount ), + creditApplied: Val::float( $row->credit_applied ?? 0 ), dueDate: Val::stringOrNull( $row->due_date ?? null ), periodKey: Val::stringOrNull( $row->period_key ?? null ), noticeBatch: Val::stringOrNull( $row->notice_batch ?? null ), @@ -101,6 +103,14 @@ class Payment { return round( $this->amount + $this->taxAmount, 2 ); } + /** + * What the student still owes after any account credit applied to this payment. + * The full `total()` less `creditApplied`, floored at zero. + */ + public function netDue(): float { + return round( max( 0.0, $this->total() - $this->creditApplied ), 2 ); + } + /** * Minimal payment info embedded in registration-creation responses: enough * for the front end to decide whether (and how) to run the payment step. @@ -132,6 +142,8 @@ class Payment { 'tax_rate' => $this->taxRate, 'tax_amount' => $this->taxAmount, 'total' => $this->total(), + 'credit_applied' => $this->creditApplied, + 'net_due' => $this->netDue(), 'currency' => $this->currency, 'method' => $this->method, 'status' => $this->status, diff --git a/src/Payment/PaymentController.php b/src/Payment/PaymentController.php index bd2545d..3377744 100644 --- a/src/Payment/PaymentController.php +++ b/src/Payment/PaymentController.php @@ -65,11 +65,13 @@ class PaymentController { $student = get_userdata( $payment->studentId ); - $groups[ $key ]['total_raw'] += $payment->total(); + // Show what the student still owes — the amount less any account credit + // already applied to this payment. + $groups[ $key ]['total_raw'] += $payment->netDue(); $groups[ $key ]['rows'][] = [ 'id' => (int) $payment->id, 'student' => $student ? $student->display_name : (string) $payment->studentId, - 'amount' => number_format( $payment->amount, 2 ) . ' ' . $payment->currency, + 'amount' => number_format( $payment->netDue(), 2 ) . ' ' . $payment->currency, 'method' => $payment->method, 'for' => $payment->registrationType . ' #' . $payment->registrationId, 'etransfer_email' => (string) $payment->etransferEmail, diff --git a/src/Payment/PaymentDueMailer.php b/src/Payment/PaymentDueMailer.php index 4e64815..c5d34cb 100644 --- a/src/Payment/PaymentDueMailer.php +++ b/src/Payment/PaymentDueMailer.php @@ -17,9 +17,10 @@ class PaymentDueMailer { * on a lump-sum e-transfer so the studio can reconcile it to these payments. * * @param list $items + * @param float $creditApplied Account credit deducted from the total this notice covers. * @return bool False when there is no recipient or nothing to bill. */ - public function send( \WP_User $student, array $items, string $reference = '' ): bool { + public function send( \WP_User $student, array $items, string $reference = '', float $creditApplied = 0.0 ): bool { if ( '' === (string) $student->user_email || [] === $items ) { return false; } @@ -48,16 +49,30 @@ class PaymentDueMailer { } } - $body = __( 'You have upcoming payments due:', 'unsupervised-schedular' ) . "\n\n" - . implode( "\n", $lines ) . "\n\n" - . sprintf( - /* translators: 1: currency, 2: total amount */ - __( 'Total due: %1$s %2$s', 'unsupervised-schedular' ), - $currency, - number_format( $total, 2 ) - ); + // Account credit (from an earlier cancelled paid lesson) offsets the total. + $creditApplied = round( min( $creditApplied, $total ), 2 ); + $dueTotal = round( $total - $creditApplied, 2 ); - if ( [] !== $emails ) { + $body = __( 'You have upcoming payments due:', 'unsupervised-schedular' ) . "\n\n" + . implode( "\n", $lines ); + + if ( $creditApplied > 0.0 ) { + $body .= "\n\n" . sprintf( + /* translators: 1: currency, 2: credit amount */ + __( 'Account credit applied: -%1$s %2$s', 'unsupervised-schedular' ), + $currency, + number_format( $creditApplied, 2 ) + ); + } + + $body .= "\n\n" . sprintf( + /* translators: 1: currency, 2: total amount */ + __( 'Total due: %1$s %2$s', 'unsupervised-schedular' ), + $currency, + number_format( $dueTotal, 2 ) + ); + + if ( $dueTotal > 0.0 && [] !== $emails ) { $body .= "\n\n" . sprintf( /* translators: %s: e-transfer destination email address(es) */ __( 'Please send your e-transfer to: %s', 'unsupervised-schedular' ), diff --git a/src/Payment/PaymentRepository.php b/src/Payment/PaymentRepository.php index 31fc9a1..f55ef3e 100644 --- a/src/Payment/PaymentRepository.php +++ b/src/Payment/PaymentRepository.php @@ -25,6 +25,7 @@ class PaymentRepository { 'status' => $payment->status, 'tax_rate' => $payment->taxRate, 'tax_amount' => $payment->taxAmount, + 'credit_applied' => $payment->creditApplied, 'due_date' => $payment->dueDate, 'period_key' => $payment->periodKey, 'notice_batch' => $payment->noticeBatch, @@ -35,7 +36,7 @@ class PaymentRepository { 'paid_at' => $payment->paidAt, 'created_at' => current_time( 'mysql' ), ], - [ '%d', '%d', '%s', '%d', '%f', '%s', '%s', '%s', '%f', '%f', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s' ] + [ '%d', '%d', '%s', '%d', '%f', '%s', '%s', '%s', '%f', '%f', '%f', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s' ] ); return $this->db->insert_id; @@ -77,6 +78,22 @@ class PaymentRepository { ); } + /** + * Add to the account credit applied against a payment, reducing what the student + * still owes on it (`Payment::netDue()`). Accumulates, so a second application + * adds to the first. + */ + public function addCreditApplied( int $id, float $amount ): bool { + $sql = $this->db->prepare( + 'UPDATE %i SET credit_applied = credit_applied + %f WHERE id = %d', + $this->table, + $amount, + $id + ); + + return null !== $sql && false !== $this->db->query( $sql ); + } + /** * Set a payment's tax rate and recompute the tax amount from its subtotal. */ diff --git a/src/Payment/PaymentService.php b/src/Payment/PaymentService.php index 7f31858..2dbda70 100644 --- a/src/Payment/PaymentService.php +++ b/src/Payment/PaymentService.php @@ -21,6 +21,7 @@ class PaymentService { private EnrollmentRepository $enrollments, private StudioSettings $settings, private StripeGateway $stripe, + private CreditRepository $credits, ) {} /** @@ -134,6 +135,135 @@ class PaymentService { } } + /** + * Credit a student for a cancelled lesson they had already paid for. The credit + * is one lesson's share of the covering payment's total (including tax) — the + * whole total for a single-lesson payment, or `total ÷ lessons covered` for a + * payment that spans several (a monthly scheduled charge, or a weekly series paid + * upfront). The original payment is left untouched; the credit is applied to the + * student's future scheduled-billing charges. Returns null when the lesson was + * never paid, has no covering payment, or was already credited. + */ + public function creditForCancelledLesson( Lesson $lesson ): ?Credit { + if ( null === $lesson->id ) { + return null; + } + + $paymentId = $lesson->paymentId; + if ( null === $paymentId && null !== $lesson->seriesId ) { + // Series lessons other than the anchor carry no payment_id of their own; + // the whole reservation is paid through the anchor's payment. + $anchor = $this->payments->findByRegistration( Payment::REG_LESSON, $lesson->seriesId ); + $paymentId = $anchor?->id; + } + if ( null === $paymentId ) { + return null; + } + + $payment = $this->payments->findById( $paymentId ); + if ( null === $payment || ! $payment->isPaid() ) { + return null; + } + + if ( $this->credits->existsForLesson( $lesson->id ) ) { + return null; + } + + $share = round( $payment->total() / $this->coveredLessonCount( $lesson, $payment ), 2 ); + if ( $share <= 0.0 ) { + return null; + } + + $id = $this->credits->insert( + new Credit( + studentId: $payment->studentId, + amount: $share, + remaining: $share, + currency: $payment->currency, + sourcePaymentId: $payment->id, + sourceLessonId: $lesson->id, + reason: sprintf( + /* translators: %d: cancelled lesson id */ + __( 'Credit for cancelled lesson #%d', 'unsupervised-schedular' ), + $lesson->id + ), + ) + ); + + return $this->credits->findById( $id ); + } + + /** + * How many lessons the covering payment was billed for, so its total can be split + * into a per-lesson credit. A weekly series paid upfront (unscheduled) covers the + * whole series; every other case — a single booking, a weekly scheduled lesson + * (one payment each), or a monthly scheduled charge (payment linked to each + * lesson) — is answered by how many lessons point at the payment. Never below one. + */ + private function coveredLessonCount( Lesson $lesson, Payment $payment ): int { + if ( ! $payment->isScheduled() && null !== $lesson->seriesId ) { + return max( 1, $this->bookings->countBySeries( $lesson->seriesId ) ); + } + + return max( 1, $this->bookings->countByPaymentId( (int) $payment->id ) ); + } + + /** + * Apply a student's available credit balance against a set of freshly-created + * pending payments (the ones a billing scan just generated for them), oldest + * charge first. Each payment's `credit_applied` is raised by the amount covered; + * a payment fully covered is marked paid-by-credit and its registration confirmed + * so it leaves the confirmation queue. The credit ledger is drawn down by the + * total applied. Returns a map of payment id to the credit applied to it, so the + * caller can reflect the reduction on the student's notice. + * + * @param list $payments + * @return array + */ + public function applyCredits( int $studentId, array $payments ): array { + $balance = $this->credits->availableBalance( $studentId ); + if ( $balance <= 0.0 ) { + return []; + } + + $applied = []; + $consumed = 0.0; + + foreach ( $payments as $payment ) { + if ( null === $payment->id || $balance <= 0.0 ) { + continue; + } + + $owing = $payment->netDue(); + if ( $owing <= 0.0 ) { + continue; + } + + $amount = round( min( $balance, $owing ), 2 ); + if ( $amount <= 0.0 ) { + continue; + } + + $this->payments->addCreditApplied( $payment->id, $amount ); + + // Fully covered by credit: settle it so it drops out of the pending queue. + if ( $amount >= $owing ) { + $this->payments->markPaid( $payment->id, 'USC-' . $payment->id ); + $this->confirmRegistration( $payment->registrationType, $payment->registrationId ); + } + + $applied[ $payment->id ] = $amount; + $balance = round( $balance - $amount, 2 ); + $consumed = round( $consumed + $amount, 2 ); + } + + if ( $consumed > 0.0 ) { + $this->credits->consume( $studentId, $consumed ); + } + + return $applied; + } + /** * Resolve the client-side payment step for a freshly created registration. * For a card payment a Stripe PaymentIntent is created (or replayed diff --git a/src/Payment/ScheduledBillingRunner.php b/src/Payment/ScheduledBillingRunner.php index 5a1f941..2c68ab9 100644 --- a/src/Payment/ScheduledBillingRunner.php +++ b/src/Payment/ScheduledBillingRunner.php @@ -44,16 +44,16 @@ class ScheduledBillingRunner { // One notice bucket per student, filled as pending payments are created and // flushed to a single email at the end, so a student billed for several - // lessons on one day is emailed once — never once per lesson. $batchIds - // tracks the payment ids behind each student's bucket so they can be tagged - // with a shared reference for lump-sum e-transfer reconciliation. - $buckets = []; - $batchIds = []; + // lessons on one day is emailed once — never once per lesson. Each entry keeps + // the created payment and its label; credits are applied across the whole + // bucket before the notice is built, so a student's account credit offsets the + // run's charges oldest-first. + $buckets = []; - $this->billPrivateLessons( $now, $buckets, $batchIds ); - $this->billGroupEnrollments( $now, $buckets, $batchIds ); + $this->billPrivateLessons( $now, $buckets ); + $this->billGroupEnrollments( $now, $buckets ); - $this->sendNotices( $buckets, $batchIds ); + $this->sendNotices( $buckets ); } /** @@ -61,10 +61,9 @@ class ScheduledBillingRunner { * are within 24 hours; monthly lessons are grouped per calendar month and billed * one payment for the month once its 1st has arrived. * - * @param array> $buckets - * @param array> $batchIds + * @param array> $buckets */ - private function billPrivateLessons( \DateTimeImmutable $now, array &$buckets, array &$batchIds ): void { + private function billPrivateLessons( \DateTimeImmutable $now, array &$buckets ): void { $today = $now->format( 'Y-m-d' ); $monthly = []; @@ -109,7 +108,6 @@ class ScheduledBillingRunner { $this->bill( $buckets, - $batchIds, Payment::REG_LESSON, $lessonId, $studentId, @@ -123,7 +121,7 @@ class ScheduledBillingRunner { ); } - $this->billMonthlyLessonGroups( $today, $monthly, $buckets, $batchIds ); + $this->billMonthlyLessonGroups( $today, $monthly, $buckets ); } /** @@ -132,10 +130,9 @@ class ScheduledBillingRunner { * lesson in the group; the rest are pointed at it so they are not re-billed. * * @param array> $monthly - * @param array> $buckets - * @param array> $batchIds + * @param array> $buckets */ - private function billMonthlyLessonGroups( string $today, array $monthly, array &$buckets, array &$batchIds ): void { + private function billMonthlyLessonGroups( string $today, array $monthly, array &$buckets ): void { foreach ( $monthly as $group ) { $first = $group[0]['start']; $monthStart = $first->format( 'Y-m-01' ); @@ -151,7 +148,6 @@ class ScheduledBillingRunner { $payment = $this->bill( $buckets, - $batchIds, Payment::REG_LESSON, $anchorId, $group[0]['student_id'], @@ -188,10 +184,9 @@ class ScheduledBillingRunner { * per month (on the 1st) for that month's sessions. Dedup is by `period_key` * since a single enrolment maps to many periodic charges. * - * @param array> $buckets - * @param array> $batchIds + * @param array> $buckets */ - private function billGroupEnrollments( \DateTimeImmutable $now, array &$buckets, array &$batchIds ): void { + private function billGroupEnrollments( \DateTimeImmutable $now, array &$buckets ): void { $today = $now->format( 'Y-m-d' ); $offerings = []; @@ -211,9 +206,9 @@ class ScheduledBillingRunner { } if ( Offering::BILLING_MONTHLY === $offering->billingMode ) { - $this->billGroupMonthly( $now, $today, $enrollment, $offering, $windows, $buckets, $batchIds ); + $this->billGroupMonthly( $now, $today, $enrollment, $offering, $windows, $buckets ); } else { - $this->billGroupWeekly( $now, $enrollment, $offering, $windows, $buckets, $batchIds ); + $this->billGroupWeekly( $now, $enrollment, $offering, $windows, $buckets ); } } } @@ -222,10 +217,9 @@ class ScheduledBillingRunner { * Bill one payment per group-class session that is now within 24 hours. * * @param list $windows - * @param array> $buckets - * @param array> $batchIds + * @param array> $buckets */ - private function billGroupWeekly( \DateTimeImmutable $now, Enrollment $enrollment, Offering $offering, array $windows, array &$buckets, array &$batchIds ): void { + private function billGroupWeekly( \DateTimeImmutable $now, Enrollment $enrollment, Offering $offering, array $windows, array &$buckets ): void { foreach ( $windows as $window ) { $start = new \DateTimeImmutable( $window['start'] ); $due = $start->modify( '-1 day' ); @@ -240,7 +234,6 @@ class ScheduledBillingRunner { $this->bill( $buckets, - $batchIds, Payment::REG_ENROLLMENT, (int) $enrollment->id, $enrollment->studentId, @@ -259,10 +252,9 @@ class ScheduledBillingRunner { * Bill one payment per calendar month of a group class, once its 1st arrives. * * @param list $windows - * @param array> $buckets - * @param array> $batchIds + * @param array> $buckets */ - private function billGroupMonthly( \DateTimeImmutable $now, string $today, Enrollment $enrollment, Offering $offering, array $windows, array &$buckets, array &$batchIds ): void { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found + private function billGroupMonthly( \DateTimeImmutable $now, string $today, Enrollment $enrollment, Offering $offering, array $windows, array &$buckets ): void { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found // Count this enrolment's sessions per calendar month. $months = []; foreach ( $windows as $window ) { @@ -282,7 +274,6 @@ class ScheduledBillingRunner { $this->bill( $buckets, - $batchIds, Payment::REG_ENROLLMENT, (int) $enrollment->id, $enrollment->studentId, @@ -305,46 +296,72 @@ class ScheduledBillingRunner { /** * Create one scheduled payment and, when it is pending (not a comp auto-pay), - * add an itemised line to the student's notice bucket and record its payment id - * for the shared notice batch. Returns the created payment, or null when there - * was nothing to charge. + * add it to the student's notice bucket with the label to show on the notice. + * Credits are applied later, once the whole bucket is known. Returns the created + * payment, or null when there was nothing to charge. * - * @param array> $buckets - * @param array> $batchIds + * @param array> $buckets */ - private function bill( array &$buckets, array &$batchIds, string $type, int $registrationId, int $studentId, int $instructorId, float $amount, string $currency, ?string $etransferEmail, string $dueDate, string $periodKey, string $label ): ?Payment { + private function bill( array &$buckets, string $type, int $registrationId, int $studentId, int $instructorId, float $amount, string $currency, ?string $etransferEmail, string $dueDate, string $periodKey, string $label ): ?Payment { $payment = $this->payments->createForRegistration( $type, $registrationId, $studentId, $instructorId, $amount, $currency, $etransferEmail, $dueDate, $periodKey ); if ( null !== $payment && null !== $payment->id && Payment::STATUS_PENDING === $payment->status ) { - $buckets[ $studentId ][] = [ - 'label' => $label, - 'amount' => $payment->total(), - 'currency' => $payment->currency, - 'due_date' => $payment->dueDate, - 'etransfer_email' => $payment->etransferEmail, + $buckets[ $studentId ][] = [ + 'payment' => $payment, + 'label' => $label, ]; - $batchIds[ $studentId ][] = $payment->id; } return $payment; } /** - * Tag each student's payments with a shared batch reference and email them one - * itemised notice quoting it, so a lump-sum e-transfer can be reconciled to the - * exact pending payments it covers. + * For each student, apply any account credit they hold against the run's charges, + * tag the payments they still owe with a shared batch reference, and email them + * one itemised notice. The notice lists each charge at its full amount, then the + * credit applied and the reduced total due; a charge fully covered by credit is + * already settled and carries no reference. A lump-sum e-transfer for the balance + * reconciles to the reference. * - * @param array> $buckets - * @param array> $batchIds + * @param array> $buckets */ - private function sendNotices( array $buckets, array $batchIds ): void { - foreach ( $buckets as $studentId => $items ) { - $reference = $this->reference(); - $this->payments->assignNoticeBatch( $batchIds[ $studentId ] ?? [], $reference ); + private function sendNotices( array $buckets ): void { + foreach ( $buckets as $studentId => $entries ) { + $payments = array_map( static fn( array $entry ): Payment => $entry['payment'], $entries ); + $applied = $this->payments->applyCredits( $studentId, $payments ); + + $items = []; + $batchIds = []; + $creditTotal = 0.0; + + foreach ( $entries as $entry ) { + $payment = $entry['payment']; + $id = (int) $payment->id; + $credited = $applied[ $id ] ?? 0.0; + + $creditTotal += $credited; + + $items[] = [ + 'label' => $entry['label'], + 'amount' => $payment->total(), + 'currency' => $payment->currency, + 'due_date' => $payment->dueDate, + 'etransfer_email' => $payment->etransferEmail, + ]; + + // A charge still carrying a balance is what a lump-sum e-transfer covers; + // one fully settled by credit needs no reconciliation reference. + if ( round( $payment->total() - $credited, 2 ) > 0.0 ) { + $batchIds[] = $id; + } + } + + $reference = [] !== $batchIds ? $this->reference() : ''; + $this->payments->assignNoticeBatch( $batchIds, $reference ); $user = get_userdata( $studentId ); if ( $user instanceof \WP_User ) { - $this->mailer->send( $user, $items, $reference ); + $this->mailer->send( $user, $items, $reference, round( $creditTotal, 2 ) ); } } } diff --git a/src/Plugin.php b/src/Plugin.php index 8a8da87..773a5b8 100644 --- a/src/Plugin.php +++ b/src/Plugin.php @@ -18,6 +18,7 @@ use Unsupervised\Schedular\GroupClass\GroupAccessRepository; use Unsupervised\Schedular\GroupClass\GroupClassPage; use Unsupervised\Schedular\Offering\OfferingRepository; use Unsupervised\Schedular\Payment\BillingMethodResolver; +use Unsupervised\Schedular\Payment\CreditRepository; use Unsupervised\Schedular\Payment\PaymentRepository; use Unsupervised\Schedular\Payment\PaymentDueMailer; use Unsupervised\Schedular\Payment\PaymentService; @@ -65,10 +66,11 @@ class Plugin { $registrationGate = new RegistrationGate( $questions, $answers, $policies, $policyVersions, $acceptances ); $paymentRepo = new PaymentRepository( $wpdb ); + $creditRepo = new CreditRepository( $wpdb ); $settings = new StudioSettings(); $resolver = new BillingMethodResolver( $settings ); $stripe = new StripeGateway( $settings ); - $paymentService = new PaymentService( $paymentRepo, $resolver, new ReceiptMailer(), $bookings, $enrollments, $settings, $stripe ); + $paymentService = new PaymentService( $paymentRepo, $resolver, new ReceiptMailer(), $bookings, $enrollments, $settings, $stripe, $creditRepo ); // The shortcode and block wrappers share the same page objects so // front-end output is identical whichever way a page embeds them. @@ -85,7 +87,7 @@ class Plugin { ( new RoleManager() )->register(); ( new RegistrationLoginGate() )->register(); ( new EmailConfirmationHandler( $settings, $registrationMailer ) )->register(); - ( new AdminMenu( $availability, $bookings, $offerings, $questions, $answers, $policies, $policyVersions, $policyService, $acceptances, $invites, $enrollments, $groupAccess, $settings, $paymentRepo, $paymentService, $resolver, $registrationMailer ) )->register(); + ( new AdminMenu( $availability, $bookings, $offerings, $questions, $answers, $policies, $policyVersions, $policyService, $acceptances, $invites, $enrollments, $groupAccess, $settings, $paymentRepo, $paymentService, $resolver, $registrationMailer, $creditRepo ) )->register(); ( new RestRegistrar( $availability, $bookings, $offerings, $questions, $policies, $policyVersions, $policyService, $registrationGate, $enrollments, $groupAccess, $paymentService ) )->register(); ( new ShortcodeRegistrar( $bookingPage, $loginPage, $registrationPage, $groupClassPage ) )->register(); ( new BlockRegistrar( $bookingPage, $loginPage, $registrationPage, $groupClassPage ) )->register(); diff --git a/src/Schema.php b/src/Schema.php index e745610..c5c62ba 100644 --- a/src/Schema.php +++ b/src/Schema.php @@ -159,6 +159,7 @@ class Schema { status VARCHAR(20) NOT NULL DEFAULT 'pending', tax_rate DECIMAL(5,2) NOT NULL DEFAULT 0, tax_amount DECIMAL(10,2) NOT NULL DEFAULT 0, + credit_applied DECIMAL(10,2) NOT NULL DEFAULT 0, due_date DATE DEFAULT NULL, period_key VARCHAR(20) DEFAULT NULL, notice_batch VARCHAR(32) DEFAULT NULL, @@ -175,6 +176,24 @@ class Schema { KEY status (status) ) {$charset};", + "CREATE TABLE {$prefix}us_credits ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + student_id BIGINT UNSIGNED NOT NULL, + amount DECIMAL(10,2) NOT NULL DEFAULT 0, + remaining DECIMAL(10,2) NOT NULL DEFAULT 0, + currency VARCHAR(3) NOT NULL DEFAULT 'CAD', + source_payment_id BIGINT UNSIGNED DEFAULT NULL, + source_lesson_id BIGINT UNSIGNED DEFAULT NULL, + reason VARCHAR(191) DEFAULT NULL, + status VARCHAR(20) NOT NULL DEFAULT 'available', + created_at DATETIME NOT NULL, + updated_at DATETIME DEFAULT NULL, + PRIMARY KEY (id), + KEY student_id (student_id), + KEY status (status), + KEY source_lesson_id (source_lesson_id) + ) {$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/student-detail.php b/templates/admin/student-detail.php index 6a845dc..ae3aa86 100644 --- a/templates/admin/student-detail.php +++ b/templates/admin/student-detail.php @@ -14,6 +14,9 @@ if (! defined('ABSPATH')) { * @var list $registrationInfo * @var list $intake * @var list $payments + * @var list $credits + * @var float $creditBalance + * @var string $creditCurrency * @var string $backUrl * @var bool $canBilling * @var string $billingOverride @@ -241,6 +244,42 @@ $renderLessons = static function (array $rows, bool $withActions = false): void +

+

+ ' . esc_html(number_format_i18n($creditBalance, 2) . ' ' . $creditCurrency) . '' + ); + ?> + +

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

diff --git a/tests/Unit/Auth/StudentActionsTest.php b/tests/Unit/Auth/StudentActionsTest.php index ea55113..dd969ed 100644 --- a/tests/Unit/Auth/StudentActionsTest.php +++ b/tests/Unit/Auth/StudentActionsTest.php @@ -42,6 +42,10 @@ class StudentActionsTest extends TestCase $this->bookings->shouldReceive('updateStatus')->once()->with(12, Lesson::STATUS_CANCELLED)->andReturn(true); $this->availability->shouldReceive('release')->once()->with(7)->andReturn(true); $this->payments->shouldReceive('voidPending')->once()->with(40); + // A paid lesson is credited; the cancelled lesson value object is handed over. + $this->payments->shouldReceive('creditForCancelledLesson') + ->once() + ->with(Mockery::on(static fn (Lesson $l): bool => $l->id === 12 && $l->paymentId === 40)); self::assertTrue($this->actions->cancelLesson(12, 5)); } diff --git a/tests/Unit/Auth/StudentHistoryTest.php b/tests/Unit/Auth/StudentHistoryTest.php index 98dde2b..ecc33c7 100644 --- a/tests/Unit/Auth/StudentHistoryTest.php +++ b/tests/Unit/Auth/StudentHistoryTest.php @@ -5,6 +5,8 @@ namespace Unsupervised\Schedular\Tests\Unit\Auth; use Mockery; use Unsupervised\Schedular\Auth\StudentHistory; +use Unsupervised\Schedular\Payment\Credit; +use Unsupervised\Schedular\Payment\CreditRepository; use Unsupervised\Schedular\Payment\Payment; use Unsupervised\Schedular\Payment\PaymentRepository; use Unsupervised\Schedular\Policy\AcceptanceRepository; @@ -27,6 +29,7 @@ class StudentHistoryTest extends TestCase private AnswerRepository&Mockery\MockInterface $answers; private QuestionRepository&Mockery\MockInterface $questions; private PaymentRepository&Mockery\MockInterface $payments; + private CreditRepository&Mockery\MockInterface $credits; private StudentHistory $history; protected function setUp(): void @@ -39,6 +42,7 @@ class StudentHistoryTest extends TestCase $this->answers = Mockery::mock(AnswerRepository::class); $this->questions = Mockery::mock(QuestionRepository::class); $this->payments = Mockery::mock(PaymentRepository::class); + $this->credits = Mockery::mock(CreditRepository::class); $this->history = new StudentHistory( $this->acceptances, @@ -46,7 +50,8 @@ class StudentHistoryTest extends TestCase $this->policyVersions, $this->answers, $this->questions, - $this->payments + $this->payments, + $this->credits ); } @@ -215,4 +220,34 @@ class StudentHistoryTest extends TestCase self::assertSame('Enrolment #3', $rows[0]['context']); self::assertSame('—', $rows[0]['receipt']); } + + public function testCreditBalanceDelegatesToRepository(): void + { + $this->credits->shouldReceive('availableBalance')->once()->with(5)->andReturn(45.0); + + self::assertSame(45.0, $this->history->creditBalance(5)); + } + + public function testCreditsBuildDisplayRows(): void + { + $this->credits->shouldReceive('findByStudent')->once()->with(5)->andReturn([ + new Credit(5, 33.00, 13.00, 'CAD', 12, 77, 'Credit for cancelled lesson #77', Credit::STATUS_AVAILABLE, '2026-07-01 09:00:00', id: 300), + ]); + + $rows = $this->history->credits(5); + + self::assertSame( + [ + [ + 'created_at' => '2026-07-01 09:00:00', + 'amount' => 33.00, + 'remaining' => 13.00, + 'currency' => 'CAD', + 'reason' => 'Credit for cancelled lesson #77', + 'status' => Credit::STATUS_AVAILABLE, + ], + ], + $rows + ); + } } diff --git a/tests/Unit/Booking/BookingEndpointTest.php b/tests/Unit/Booking/BookingEndpointTest.php index a785e92..a551aeb 100644 --- a/tests/Unit/Booking/BookingEndpointTest.php +++ b/tests/Unit/Booking/BookingEndpointTest.php @@ -48,6 +48,9 @@ class BookingEndpointTest extends TestCase $this->payments = Mockery::mock(PaymentService::class); $this->settings = Mockery::mock(StudioSettings::class); $this->settings->shouldReceive('cancellationCutoffHours')->andReturn(24)->byDefault(); + // Crediting a cancelled paid lesson is exercised in dedicated tests; other + // cancellation paths simply allow the call. + $this->payments->shouldReceive('creditForCancelledLesson')->andReturn(null)->byDefault(); $this->endpoint = new BookingEndpoint( $this->availability, @@ -472,6 +475,24 @@ class BookingEndpointTest extends TestCase self::assertSame(Lesson::STATUS_CANCELLED, $result->get_data()['status']); } + public function testCancelCreditsThePaidLesson(): void + { + $lesson = new Lesson(slotId: 10, studentId: 5, instructorId: 3, status: Lesson::STATUS_PENDING, paymentId: 12, id: 77); + $this->bookings->shouldReceive('findById')->with(77)->andReturn($lesson); + $this->availability->shouldReceive('findById')->with(10)->andReturn($this->slot(10, 3, null)); + $this->bookings->shouldReceive('updateStatus')->with(77, Lesson::STATUS_CANCELLED)->once()->andReturn(true); + $this->availability->shouldReceive('release')->with(10)->once()->andReturn(true); + $this->payments->shouldReceive('voidPending')->with(12)->once(); + + // The cancelled lesson (the value object, so its payment_id is intact) is + // handed to the credit path. + $this->payments->shouldReceive('creditForCancelledLesson') + ->once() + ->with(Mockery::on(static fn (Lesson $l): bool => $l->id === 77 && $l->paymentId === 12)); + + $this->endpoint->cancel(new \WP_REST_Request(['id' => 77])); + } + public function testCancelWithinStudioCutoffIsRejected(): void { // Now (2026-06-01 10:00) is only 24h before a slot at 2026-06-02 10:00, diff --git a/tests/Unit/Payment/CreditRepositoryTest.php b/tests/Unit/Payment/CreditRepositoryTest.php new file mode 100644 index 0000000..7579611 --- /dev/null +++ b/tests/Unit/Payment/CreditRepositoryTest.php @@ -0,0 +1,111 @@ +db = Mockery::mock(\wpdb::class); + $this->db->prefix = 'wp_'; + $this->repo = new CreditRepository($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_credits', + Mockery::on(static function (array $d): bool { + return $d['student_id'] === 5 + && $d['amount'] === 33.0 + && $d['remaining'] === 33.0 + && $d['source_lesson_id'] === 77 + && $d['status'] === Credit::STATUS_AVAILABLE; + }), + Mockery::type('array') + ); + $this->db->insert_id = 300; + + $credit = new Credit(5, 33.0, 33.0, 'CAD', 12, 77, 'Credit for cancelled lesson #77'); + self::assertSame(300, $this->repo->insert($credit)); + } + + public function testExistsForLessonReturnsTrueWhenRowFound(): void + { + $this->db->shouldReceive('prepare') + ->once() + ->with(Mockery::pattern('/source_lesson_id = %d/'), 'wp_us_credits', 77) + ->andReturn('SELECT ...'); + $this->db->shouldReceive('get_var')->once()->with('SELECT ...')->andReturn('300'); + + self::assertTrue($this->repo->existsForLesson(77)); + } + + public function testExistsForLessonReturnsFalseWhenNone(): void + { + $this->db->shouldReceive('prepare')->andReturn('SELECT ...'); + $this->db->shouldReceive('get_var')->once()->andReturn(null); + + self::assertFalse($this->repo->existsForLesson(77)); + } + + public function testAvailableBalanceSumsRemaining(): void + { + $this->db->shouldReceive('prepare') + ->once() + ->with(Mockery::pattern('/SUM\( remaining \)/'), 'wp_us_credits', 5, Credit::STATUS_AVAILABLE) + ->andReturn('SELECT ...'); + $this->db->shouldReceive('get_var')->once()->with('SELECT ...')->andReturn('45.00'); + + self::assertSame(45.0, $this->repo->availableBalance(5)); + } + + public function testConsumeDrawsDownOldestFirstAndMarksSpentConsumed(): void + { + // Two available credits ($20 then $30); consuming $35 empties the first and + // takes $15 from the second, leaving it $15 and still available. + $rows = [ + (object) ['id' => '1', 'student_id' => '5', 'amount' => '20.00', 'remaining' => '20.00', 'currency' => 'CAD', 'source_payment_id' => null, 'source_lesson_id' => null, 'reason' => null, 'status' => Credit::STATUS_AVAILABLE, 'created_at' => '2026-06-01 09:00:00', 'updated_at' => null], + (object) ['id' => '2', 'student_id' => '5', 'amount' => '30.00', 'remaining' => '30.00', 'currency' => 'CAD', 'source_payment_id' => null, 'source_lesson_id' => null, 'reason' => null, 'status' => Credit::STATUS_AVAILABLE, 'created_at' => '2026-06-02 09:00:00', 'updated_at' => null], + ]; + + Functions\expect('current_time')->with('mysql')->andReturn('2026-07-15 12:00:00'); + $this->db->shouldReceive('prepare')->andReturn('SELECT ...'); + $this->db->shouldReceive('get_results')->once()->with('SELECT ...')->andReturn($rows); + + // First credit fully spent -> consumed. + $this->db->shouldReceive('update') + ->once() + ->with('wp_us_credits', Mockery::on(static fn (array $d): bool => $d['remaining'] === 0.0 && $d['status'] === Credit::STATUS_CONSUMED), ['id' => 1], Mockery::type('array'), Mockery::type('array')); + // Second credit partly spent -> stays available with $15 remaining. + $this->db->shouldReceive('update') + ->once() + ->with('wp_us_credits', Mockery::on(static fn (array $d): bool => $d['remaining'] === 15.0 && $d['status'] === Credit::STATUS_AVAILABLE), ['id' => 2], Mockery::type('array'), Mockery::type('array')); + + $this->repo->consume(5, 35.0); + } + + public function testConsumeIgnoresNonPositiveAmount(): void + { + $this->db->shouldNotReceive('get_results'); + $this->db->shouldNotReceive('update'); + + $this->repo->consume(5, 0.0); + } +} diff --git a/tests/Unit/Payment/PaymentDueMailerTest.php b/tests/Unit/Payment/PaymentDueMailerTest.php index 796100d..adbe979 100644 --- a/tests/Unit/Payment/PaymentDueMailerTest.php +++ b/tests/Unit/Payment/PaymentDueMailerTest.php @@ -74,6 +74,46 @@ class PaymentDueMailerTest extends TestCase self::assertTrue((new PaymentDueMailer())->send($this->student('a@b.test'), $items, 'REF12345')); } + public function testCreditReducesTheTotalDue(): void + { + Functions\expect('wp_mail') + ->once() + ->with( + 'a@b.test', + Mockery::type('string'), + Mockery::on(static function (string $body): bool { + // Line shows the full 35.00; credit line shows -20.00; total due 15.00. + return str_contains($body, '35.00') + && str_contains($body, '-CAD 20.00') + && str_contains($body, 'Total due: CAD 15.00'); + }) + ) + ->andReturn(true); + + $items = [[ 'label' => 'Piano', 'amount' => 35.0, 'currency' => 'CAD', 'due_date' => '2026-07-15', 'etransfer_email' => 'pay@studio.test' ]]; + + self::assertTrue((new PaymentDueMailer())->send($this->student('a@b.test'), $items, 'REF1', 20.0)); + } + + public function testCreditCoveringEverythingLeavesZeroDueAndNoEtransferLine(): void + { + Functions\expect('wp_mail') + ->once() + ->with( + 'a@b.test', + Mockery::type('string'), + Mockery::on(static function (string $body): bool { + return str_contains($body, 'Total due: CAD 0.00') + && ! str_contains($body, 'pay@studio.test'); + }) + ) + ->andReturn(true); + + $items = [[ 'label' => 'Piano', 'amount' => 35.0, 'currency' => 'CAD', 'due_date' => '2026-07-15', 'etransfer_email' => 'pay@studio.test' ]]; + + self::assertTrue((new PaymentDueMailer())->send($this->student('a@b.test'), $items, '', 35.0)); + } + public function testIncludesEtransferDestination(): void { Functions\expect('wp_mail') diff --git a/tests/Unit/Payment/PaymentServiceTest.php b/tests/Unit/Payment/PaymentServiceTest.php index 9bfed65..301a7cc 100644 --- a/tests/Unit/Payment/PaymentServiceTest.php +++ b/tests/Unit/Payment/PaymentServiceTest.php @@ -9,6 +9,8 @@ use Unsupervised\Schedular\Booking\BookingRepository; use Unsupervised\Schedular\Booking\Lesson; use Unsupervised\Schedular\GroupClass\EnrollmentRepository; use Unsupervised\Schedular\Payment\BillingMethodResolver; +use Unsupervised\Schedular\Payment\Credit; +use Unsupervised\Schedular\Payment\CreditRepository; use Unsupervised\Schedular\Payment\Payment; use Unsupervised\Schedular\Payment\PaymentRepository; use Unsupervised\Schedular\Payment\PaymentService; @@ -26,6 +28,7 @@ class PaymentServiceTest extends TestCase private EnrollmentRepository $enrollments; private StudioSettings $settings; private StripeGateway $stripe; + private CreditRepository $credits; private PaymentService $service; protected function setUp(): void @@ -39,6 +42,7 @@ class PaymentServiceTest extends TestCase $this->enrollments = Mockery::mock(EnrollmentRepository::class); $this->settings = Mockery::mock(StudioSettings::class); $this->stripe = Mockery::mock(StripeGateway::class); + $this->credits = Mockery::mock(CreditRepository::class); $this->settings->shouldReceive('etransferEmail')->andReturn(''); $this->settings->shouldReceive('hstRate')->andReturn(0.0)->byDefault(); // Confirming a lesson looks it up to detect a weekly series; single @@ -52,7 +56,8 @@ class PaymentServiceTest extends TestCase $this->bookings, $this->enrollments, $this->settings, - $this->stripe + $this->stripe, + $this->credits ); Functions\when('get_userdata')->justReturn(false); @@ -340,6 +345,151 @@ class PaymentServiceTest extends TestCase self::assertTrue($this->service->handleWebhook('{}', 'sig')); } + public function testCreditForCancelledLessonCreditsWholeTotalOfSingleLessonPayment(): void + { + // A paid single-lesson payment: the whole total (incl. tax) is credited. + $paid = new Payment(5, 3, Payment::REG_LESSON, 77, 30.00, 'CAD', Payment::METHOD_ETRANSFER, Payment::STATUS_PAID, taxRate: 10.0, taxAmount: 3.00, id: 12); + $this->payments->shouldReceive('findById')->with(12)->andReturn($paid); + $this->bookings->shouldReceive('countByPaymentId')->with(12)->andReturn(1); + $this->credits->shouldReceive('existsForLesson')->with(77)->andReturn(false); + + $this->credits->shouldReceive('insert') + ->once() + ->with(Mockery::on(static fn (Credit $c): bool => $c->studentId === 5 + && $c->amount === 33.00 + && $c->remaining === 33.00 + && $c->sourceLessonId === 77)) + ->andReturn(300); + $this->credits->shouldReceive('findById')->with(300)->andReturn( + new Credit(5, 33.00, 33.00, 'CAD', 12, 77, id: 300) + ); + + $lesson = new Lesson(slotId: 10, studentId: 5, instructorId: 3, status: Lesson::STATUS_CANCELLED, paymentId: 12, id: 77); + self::assertNotNull($this->service->creditForCancelledLesson($lesson)); + } + + public function testCreditForCancelledLessonSplitsSharedMonthlyPayment(): void + { + // A monthly scheduled charge covering 3 lessons: one cancellation credits a third. + $paid = new Payment(5, 3, Payment::REG_LESSON, 201, 90.00, 'CAD', Payment::METHOD_ETRANSFER, Payment::STATUS_PAID, dueDate: '2026-07-01', id: 12); + $this->payments->shouldReceive('findById')->with(12)->andReturn($paid); + $this->bookings->shouldReceive('countByPaymentId')->with(12)->andReturn(3); + $this->credits->shouldReceive('existsForLesson')->with(202)->andReturn(false); + + $this->credits->shouldReceive('insert') + ->once() + ->with(Mockery::on(static fn (Credit $c): bool => $c->amount === 30.00)) + ->andReturn(301); + $this->credits->shouldReceive('findById')->with(301)->andReturn(new Credit(5, 30.00, 30.00, 'CAD', 12, 202, id: 301)); + + $lesson = new Lesson(slotId: 10, studentId: 5, instructorId: 3, status: Lesson::STATUS_CANCELLED, paymentId: 12, id: 202); + self::assertNotNull($this->service->creditForCancelledLesson($lesson)); + } + + public function testCreditForCancelledLessonSkipsUnpaidPayment(): void + { + $pending = new Payment(5, 3, Payment::REG_LESSON, 77, 30.00, 'CAD', Payment::METHOD_ETRANSFER, Payment::STATUS_PENDING, id: 12); + $this->payments->shouldReceive('findById')->with(12)->andReturn($pending); + $this->credits->shouldNotReceive('insert'); + + $lesson = new Lesson(slotId: 10, studentId: 5, instructorId: 3, paymentId: 12, id: 77); + self::assertNull($this->service->creditForCancelledLesson($lesson)); + } + + public function testCreditForCancelledLessonSkipsWhenNoPayment(): void + { + $this->credits->shouldNotReceive('insert'); + + $lesson = new Lesson(slotId: 10, studentId: 5, instructorId: 3, id: 77); + self::assertNull($this->service->creditForCancelledLesson($lesson)); + } + + public function testCreditForCancelledLessonSkipsAlreadyCredited(): void + { + $paid = new Payment(5, 3, Payment::REG_LESSON, 77, 30.00, 'CAD', Payment::METHOD_ETRANSFER, Payment::STATUS_PAID, id: 12); + $this->payments->shouldReceive('findById')->with(12)->andReturn($paid); + $this->bookings->shouldReceive('countByPaymentId')->with(12)->andReturn(1); + $this->credits->shouldReceive('existsForLesson')->with(77)->andReturn(true); + $this->credits->shouldNotReceive('insert'); + + $lesson = new Lesson(slotId: 10, studentId: 5, instructorId: 3, paymentId: 12, id: 77); + self::assertNull($this->service->creditForCancelledLesson($lesson)); + } + + public function testCreditForCancelledLessonUsesSeriesSizeForUpfrontSeries(): void + { + // A non-anchor series lesson has no payment_id of its own; the anchor's + // upfront (unscheduled) payment covers the whole 4-lesson series. + $anchorPayment = new Payment(5, 3, Payment::REG_LESSON, 40, 120.00, 'CAD', Payment::METHOD_ETRANSFER, Payment::STATUS_PAID, id: 12); + $this->payments->shouldReceive('findByRegistration')->with(Payment::REG_LESSON, 40)->andReturn($anchorPayment); + $this->payments->shouldReceive('findById')->with(12)->andReturn($anchorPayment); + $this->bookings->shouldReceive('countBySeries')->with(40)->andReturn(4); + $this->credits->shouldReceive('existsForLesson')->with(43)->andReturn(false); + + $this->credits->shouldReceive('insert') + ->once() + ->with(Mockery::on(static fn (Credit $c): bool => $c->amount === 30.00)) + ->andReturn(302); + $this->credits->shouldReceive('findById')->with(302)->andReturn(new Credit(5, 30.00, 30.00, 'CAD', 12, 43, id: 302)); + + $lesson = new Lesson(slotId: 10, studentId: 5, instructorId: 3, recurrence: Lesson::RECURRENCE_WEEKLY, seriesId: 40, paymentId: null, id: 43); + self::assertNotNull($this->service->creditForCancelledLesson($lesson)); + } + + public function testApplyCreditsReturnsEmptyWhenNoBalance(): void + { + $this->credits->shouldReceive('availableBalance')->with(5)->andReturn(0.0); + + self::assertSame([], $this->service->applyCredits(5, [$this->pending(500, 40.00)])); + } + + public function testApplyCreditsPartiallyCoversWithoutMarkingPaid(): void + { + // $30 credit against a $40 charge: applied but still owing, so it stays pending. + $this->credits->shouldReceive('availableBalance')->with(5)->andReturn(30.0); + $this->payments->shouldReceive('addCreditApplied')->once()->with(500, 30.0)->andReturn(true); + $this->payments->shouldNotReceive('markPaid'); + $this->credits->shouldReceive('consume')->once()->with(5, 30.0); + + $applied = $this->service->applyCredits(5, [$this->pending(500, 40.00)]); + + self::assertSame([500 => 30.0], $applied); + } + + public function testApplyCreditsFullyCoversMarksPaidByCreditAndConfirms(): void + { + // $50 credit against a $40 charge: fully covered -> settled + registration confirmed. + $this->credits->shouldReceive('availableBalance')->with(5)->andReturn(50.0); + $this->payments->shouldReceive('addCreditApplied')->once()->with(500, 40.0)->andReturn(true); + $this->payments->shouldReceive('markPaid')->once()->with(500, 'USC-500')->andReturn(true); + $this->bookings->shouldReceive('updateStatus')->once()->with(12, Lesson::STATUS_CONFIRMED)->andReturn(true); + $this->credits->shouldReceive('consume')->once()->with(5, 40.0); + + $applied = $this->service->applyCredits(5, [$this->pending(500, 40.00)]); + + self::assertSame([500 => 40.0], $applied); + } + + public function testApplyCreditsSpreadsAcrossChargesOldestFirst(): void + { + // $50 balance across two $40 charges: first fully covered, second partly. + $this->credits->shouldReceive('availableBalance')->with(5)->andReturn(50.0); + $this->payments->shouldReceive('addCreditApplied')->once()->with(500, 40.0)->andReturn(true); + $this->payments->shouldReceive('markPaid')->once()->with(500, 'USC-500')->andReturn(true); + $this->bookings->shouldReceive('updateStatus')->once()->with(12, Lesson::STATUS_CONFIRMED)->andReturn(true); + $this->payments->shouldReceive('addCreditApplied')->once()->with(501, 10.0)->andReturn(true); + $this->credits->shouldReceive('consume')->once()->with(5, 50.0); + + $applied = $this->service->applyCredits(5, [$this->pending(500, 40.00), $this->pending(501, 40.00)]); + + self::assertSame([500 => 40.0, 501 => 10.0], $applied); + } + + private function pending(int $id, float $amount): Payment + { + return new Payment(5, 3, Payment::REG_LESSON, 12, $amount, 'CAD', Payment::METHOD_ETRANSFER, Payment::STATUS_PENDING, dueDate: '2026-07-14', id: $id); + } + private function intentEvent(string $type, string $intentId): \Stripe\Event { $intent = \Stripe\PaymentIntent::constructFrom(['id' => $intentId, 'object' => 'payment_intent']); diff --git a/tests/Unit/Payment/PaymentTest.php b/tests/Unit/Payment/PaymentTest.php index 3548b71..9a07c03 100644 --- a/tests/Unit/Payment/PaymentTest.php +++ b/tests/Unit/Payment/PaymentTest.php @@ -73,6 +73,28 @@ class PaymentTest extends TestCase self::assertSame(100.00, $payment->total()); } + public function testNetDueSubtractsAppliedCredit(): void + { + $payment = new Payment(5, 3, Payment::REG_LESSON, 12, 100.00, taxRate: 13.0, taxAmount: 13.00, creditApplied: 40.00); + + self::assertSame(113.00, $payment->total()); + self::assertSame(73.00, $payment->netDue()); + } + + public function testNetDueFloorsAtZeroWhenCreditExceedsTotal(): void + { + $payment = new Payment(5, 3, Payment::REG_LESSON, 12, 30.00, creditApplied: 50.00); + + self::assertSame(0.0, $payment->netDue()); + } + + public function testNetDueEqualsTotalWithoutCredit(): void + { + $payment = new Payment(5, 3, Payment::REG_LESSON, 12, 30.00); + + self::assertSame(30.00, $payment->netDue()); + } + public function testToSummaryArrayContainsOnlyClientFacingFields(): void { $summary = (new Payment(5, 3, Payment::REG_LESSON, 12, 35.00, id: 7))->toSummaryArray(); diff --git a/tests/Unit/Payment/ScheduledBillingRunnerTest.php b/tests/Unit/Payment/ScheduledBillingRunnerTest.php index b15ba45..f7764ff 100644 --- a/tests/Unit/Payment/ScheduledBillingRunnerTest.php +++ b/tests/Unit/Payment/ScheduledBillingRunnerTest.php @@ -40,6 +40,8 @@ class ScheduledBillingRunnerTest extends TestCase $this->enrollments->shouldReceive('findActiveByBillingModes')->andReturn([])->byDefault(); $this->mailer->shouldReceive('send')->andReturn(true)->byDefault(); $this->payments->shouldReceive('assignNoticeBatch')->byDefault(); + // No account credit unless a test says otherwise. + $this->payments->shouldReceive('applyCredits')->andReturn([])->byDefault(); Functions\when('wp_generate_uuid4')->justReturn('abcdef12-3456-7890-abcd-ef1234567890'); @@ -238,7 +240,52 @@ class ScheduledBillingRunnerTest extends TestCase ->with(Mockery::on(static fn (array $ids): bool => count($ids) === 2), Mockery::type('string')); $this->mailer->shouldReceive('send') ->once() - ->with(Mockery::type(\WP_User::class), Mockery::on(static fn (array $items): bool => count($items) === 2), Mockery::type('string')); + ->with(Mockery::type(\WP_User::class), Mockery::on(static fn (array $items): bool => count($items) === 2), Mockery::type('string'), 0.0); + + $this->runner->run(); + } + + public function testAppliesAccountCreditToTheRun(): void + { + $this->now('2026-07-15 09:00:00'); + $this->bookings->shouldReceive('findUnbilledScheduledLessons') + ->andReturn([ $this->lessonRow(101, Offering::BILLING_WEEKLY, '2026-07-15 18:00:00', 35.0) ]); + + $payment = $this->pending(500, '2026-07-14'); + $this->payments->shouldReceive('createForRegistration')->once()->andReturn($payment); + + // Student holds $20 credit, applied to the one $35 charge — still $15 owing, + // so the payment stays in the notice batch and the notice quotes the credit. + $this->payments->shouldReceive('applyCredits') + ->once() + ->with(5, Mockery::on(static fn (array $p): bool => count($p) === 1)) + ->andReturn([500 => 20.0]); + $this->payments->shouldReceive('assignNoticeBatch') + ->once() + ->with([500], Mockery::type('string')); + $this->mailer->shouldReceive('send') + ->once() + ->with(Mockery::type(\WP_User::class), Mockery::type('array'), Mockery::type('string'), 20.0); + + $this->runner->run(); + } + + public function testCreditFullyCoveringAChargeLeavesItOutOfTheBatch(): void + { + $this->now('2026-07-15 09:00:00'); + $this->bookings->shouldReceive('findUnbilledScheduledLessons') + ->andReturn([ $this->lessonRow(101, Offering::BILLING_WEEKLY, '2026-07-15 18:00:00', 35.0) ]); + + $payment = $this->pending(500, '2026-07-14'); + $this->payments->shouldReceive('createForRegistration')->once()->andReturn($payment); + + // Credit covers the whole $35 charge: nothing owing, so no reconciliation + // batch and no reference on the (zero-balance) notice. + $this->payments->shouldReceive('applyCredits')->once()->andReturn([500 => 35.0]); + $this->payments->shouldReceive('assignNoticeBatch')->once()->with([], ''); + $this->mailer->shouldReceive('send') + ->once() + ->with(Mockery::type(\WP_User::class), Mockery::type('array'), '', 35.0); $this->runner->run(); }