Apply account credit on same-month rebook; show total credit on student page #202
@@ -184,6 +184,7 @@ class LessonBooker {
|
|||||||
? $offering->price
|
? $offering->price
|
||||||
: $offering->price * count( $ids );
|
: $offering->price * count( $ids );
|
||||||
|
|
||||||
|
$payerId = $this->guardians->payerFor( $studentId );
|
||||||
$payment = $this->payments->createForRegistration(
|
$payment = $this->payments->createForRegistration(
|
||||||
Payment::REG_LESSON,
|
Payment::REG_LESSON,
|
||||||
$anchorId,
|
$anchorId,
|
||||||
@@ -192,9 +193,23 @@ class LessonBooker {
|
|||||||
$amount,
|
$amount,
|
||||||
$offering->currency,
|
$offering->currency,
|
||||||
$offering->etransferEmail,
|
$offering->etransferEmail,
|
||||||
payerId: $this->guardians->payerFor( $studentId )
|
payerId: $payerId
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// A scheduled lesson charged at booking — the extra lesson added to (or a
|
||||||
|
// cancelled one rebooked within) an already-billed month — skips the daily
|
||||||
|
// scan, which is the only place credit is otherwise applied. Apply the
|
||||||
|
// payer's account credit here too, so a cancellation credit settles the
|
||||||
|
// rebooking instead of the family being charged twice for the same slot. A
|
||||||
|
// pending payment fully covered by credit is settled and its lesson
|
||||||
|
// confirmed; applyCredits is a no-op when the payer holds no balance. The
|
||||||
|
// one-time pay-now flow is left alone: its payment step already fronts the
|
||||||
|
// student, and credit there is a manual studio decision.
|
||||||
|
if ( $offering->isScheduledBilling() && null !== $payment && null !== $payment->id && Payment::STATUS_PENDING === $payment->status ) {
|
||||||
|
$this->payments->applyCredits( $payerId, [ $payment ] );
|
||||||
|
$payment = $this->payments->findPayment( (int) $payment->id ) ?? $payment;
|
||||||
|
}
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'status' => null !== $payment && $payment->isPaid() ? Lesson::STATUS_CONFIRMED : Lesson::STATUS_PENDING,
|
'status' => null !== $payment && $payment->isPaid() ? Lesson::STATUS_CONFIRMED : Lesson::STATUS_PENDING,
|
||||||
'payment' => $payment,
|
'payment' => $payment,
|
||||||
|
|||||||
@@ -116,6 +116,15 @@ class PaymentService {
|
|||||||
return $this->payments->markNoticed( $paymentId );
|
return $this->payments->markNoticed( $paymentId );
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-read a payment from the ledger — the caller's way to pick up a status or
|
||||||
|
* credit change {@see applyCredits} wrote straight to the row, since the
|
||||||
|
* Payment object it holds is immutable. Delegates to the ledger.
|
||||||
|
*/
|
||||||
|
public function findPayment( int $paymentId ): ?Payment {
|
||||||
|
return $this->payments->findById( $paymentId );
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Studio-admin confirmation that a pending payment (e-transfer) was received.
|
* Studio-admin confirmation that a pending payment (e-transfer) was received.
|
||||||
* Marks it paid, confirms the registration, and emails the receipt.
|
* Marks it paid, confirms the registration, and emails the receipt.
|
||||||
|
|||||||
@@ -114,6 +114,39 @@ $renderLessons = static function (array $rows, bool $withActions = false): void
|
|||||||
<div class="notice notice-error is-dismissible"><p><?php echo esc_html($error); ?></p></div>
|
<div class="notice notice-error is-dismissible"><p><?php echo esc_html($error); ?></p></div>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<?php
|
||||||
|
/*
|
||||||
|
* Surface the credit balance up top the moment there is one, so the studio
|
||||||
|
* sees at a glance that this student is owed against future billing without
|
||||||
|
* scrolling to the Account credit section. Only shown when there is credit to
|
||||||
|
* report — a zero balance is not news. The full breakdown stays below.
|
||||||
|
*/
|
||||||
|
?>
|
||||||
|
<?php if ($canBilling && $creditBalance > 0) : ?>
|
||||||
|
<div class="notice notice-info inline">
|
||||||
|
<p>
|
||||||
|
<?php
|
||||||
|
printf(
|
||||||
|
/* translators: %s: total available credit, e.g. "45.00 CAD" */
|
||||||
|
esc_html__('Total account credit: %s', 'unsupervised-schedular'),
|
||||||
|
'<strong>' . esc_html(number_format_i18n($creditBalance, 2) . ' ' . $creditCurrency) . '</strong>'
|
||||||
|
);
|
||||||
|
?>
|
||||||
|
<?php if ($payer['id'] !== (int) $student->ID) : ?>
|
||||||
|
<span class="description">
|
||||||
|
<?php
|
||||||
|
printf(
|
||||||
|
/* translators: %s: name of the parent/guardian whose account holds the balance. */
|
||||||
|
esc_html__('Held on %s’s account.', 'unsupervised-schedular'),
|
||||||
|
esc_html($payer['name'])
|
||||||
|
);
|
||||||
|
?>
|
||||||
|
</span>
|
||||||
|
<?php endif; ?>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
<?php $detailUrl = static fn(int $id): string => add_query_arg(['page' => $pageSlug, 'student_id' => $id], admin_url('admin.php')); ?>
|
<?php $detailUrl = static fn(int $id): string => add_query_arg(['page' => $pageSlug, 'student_id' => $id], admin_url('admin.php')); ?>
|
||||||
|
|
||||||
<h2><?php esc_html_e('Account', 'unsupervised-schedular'); ?></h2>
|
<h2><?php esc_html_e('Account', 'unsupervised-schedular'); ?></h2>
|
||||||
|
|||||||
@@ -57,6 +57,13 @@ class BookingEndpointTest extends TestCase
|
|||||||
// Crediting a cancelled paid lesson is exercised in dedicated tests; other
|
// Crediting a cancelled paid lesson is exercised in dedicated tests; other
|
||||||
// cancellation paths simply allow the call.
|
// cancellation paths simply allow the call.
|
||||||
$this->payments->shouldReceive('creditForCancelledLesson')->andReturn(null)->byDefault();
|
$this->payments->shouldReceive('creditForCancelledLesson')->andReturn(null)->byDefault();
|
||||||
|
// A charge-at-booking scheduled lesson (an add-on to an already-billed month)
|
||||||
|
// applies the payer's credit before it settles. The default holds no balance
|
||||||
|
// and re-reads the same payment; the same-month-rebook test overrides both.
|
||||||
|
$this->payments->shouldReceive('applyCredits')->andReturn([])->byDefault();
|
||||||
|
$this->payments->shouldReceive('findPayment')->andReturnUsing(
|
||||||
|
static fn (int $id): ?Payment => null
|
||||||
|
)->byDefault();
|
||||||
|
|
||||||
$this->guardians = Mockery::mock(GuardianService::class);
|
$this->guardians = Mockery::mock(GuardianService::class);
|
||||||
// The default account books only for itself: no guardian link anywhere.
|
// The default account books only for itself: no guardian link anywhere.
|
||||||
@@ -463,6 +470,42 @@ class BookingEndpointTest extends TestCase
|
|||||||
self::assertNotNull($result->get_data()['payment']);
|
self::assertNotNull($result->get_data()['payment']);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function testMonthlyRebookInBilledMonthAppliesAccountCredit(): void
|
||||||
|
{
|
||||||
|
// Rebooking a cancelled monthly lesson inside an already-billed month is
|
||||||
|
// charged at booking. The payer holds a cancellation credit that must be
|
||||||
|
// applied to that charge — otherwise the family is billed twice for the same
|
||||||
|
// slot. A credit that fully covers it settles the payment and confirms the
|
||||||
|
// lesson, so the front end runs no payment step.
|
||||||
|
$this->availability->shouldReceive('findById')->with(10)->andReturn(
|
||||||
|
new AvailabilitySlot(instructorId: 3, startDt: '2026-06-20 10:00:00', endDt: '2026-06-20 11:00:00', offeringId: null, id: 10)
|
||||||
|
);
|
||||||
|
$this->offerings->shouldReceive('findById')->with(8)->andReturn(
|
||||||
|
new Offering(instructorId: 3, kind: Offering::KIND_PRIVATE_LESSON, title: 'Lesson', price: 45.0, billingMode: Offering::BILLING_MONTHLY, id: 8)
|
||||||
|
);
|
||||||
|
$this->gate->shouldReceive('validate')->andReturn(null);
|
||||||
|
$this->availability->shouldReceive('claim')->with(10)->once()->andReturn(true);
|
||||||
|
$this->bookings->shouldReceive('insert')->once()->andReturn(77);
|
||||||
|
$this->gate->shouldReceive('record')->once();
|
||||||
|
|
||||||
|
$pending = new Payment(5, 3, Payment::REG_LESSON, 77, 45.0, currency: 'CAD', method: Payment::METHOD_ETRANSFER, status: Payment::STATUS_PENDING, id: 12);
|
||||||
|
$this->payments->shouldReceive('createForRegistration')
|
||||||
|
->once()
|
||||||
|
->with(Payment::REG_LESSON, 77, 5, 3, 45.0, 'CAD', null, null, null, 5)
|
||||||
|
->andReturn($pending);
|
||||||
|
|
||||||
|
// Credit is applied against the fresh charge for the same payer.
|
||||||
|
$this->payments->shouldReceive('applyCredits')->once()->with(5, [$pending])->andReturn([12 => 45.0]);
|
||||||
|
// applyCredits settled the row; the re-read reflects it as paid.
|
||||||
|
$paid = new Payment(5, 3, Payment::REG_LESSON, 77, 45.0, currency: 'CAD', method: Payment::METHOD_ETRANSFER, status: Payment::STATUS_PAID, creditApplied: 45.0, id: 12);
|
||||||
|
$this->payments->shouldReceive('findPayment')->with(12)->andReturn($paid);
|
||||||
|
|
||||||
|
$result = $this->endpoint->book(new \WP_REST_Request(['slot_id' => 10, 'offering_id' => 8]));
|
||||||
|
|
||||||
|
self::assertInstanceOf(\WP_REST_Response::class, $result);
|
||||||
|
self::assertSame(Lesson::STATUS_CONFIRMED, $result->get_data()['status']);
|
||||||
|
}
|
||||||
|
|
||||||
public function testMonthlyLessonBeforeBillingDateDefersPayment(): void
|
public function testMonthlyLessonBeforeBillingDateDefersPayment(): void
|
||||||
{
|
{
|
||||||
// "now" is 2026-06-01; a monthly lesson for July is booked before July's 1st,
|
// "now" is 2026-06-01; a monthly lesson for July is booked before July's 1st,
|
||||||
|
|||||||
Reference in New Issue
Block a user