Files
unsupervised-scheduler/tests/Unit/Payment/PaymentServiceTest.php
T
thatguygriffandClaude Opus 4.8 4328e8fb5f
CI / Tests (PHP 8.2) (pull_request) Successful in 39s
CI / Tests (PHP 8.1) (pull_request) Successful in 1m12s
CI / No Debug Code (pull_request) Successful in 3s
CI / PHPStan (pull_request) Successful in 2m52s
CI / Coding Standards (pull_request) Successful in 2m54s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m39s
CI / Build Plugin Zip (pull_request) Skipped
Add weekly and monthly scheduled billing for offerings
Offerings can now bill weekly (a pending payment 24h before each lesson)
or monthly (one payment on the 1st for that month's lessons), alongside
one-time and full-term. Applies to both private lessons and group classes.

- Offering: new `weekly`/`monthly` billing modes + `isScheduledBilling()`
- Booking/enrolment defer payment for scheduled modes; a single lesson
  booked after its due date has passed (e.g. an add-on in an already-billed
  month) is charged at booking instead
- ScheduledBillingRunner: daily WP-Cron scan generates due payments across
  four cases (private/group × weekly/monthly), deduped via lesson.payment_id
  and payments.period_key
- PaymentDueMailer: one consolidated itemised email per student per scan
- Notice batch: payments emailed together share a reference; the admin
  Payments queue groups them with a lump-sum total for e-transfer reconciliation
- Cancellation never voids a scheduled payment (Payment::isScheduled())
- Schema: us_payments gains due_date, period_key, notice_batch; USC_VERSION 1.2.0

composer test, composer lint, composer cs all pass.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-24 12:06:37 -03:00

350 lines
16 KiB
PHP

<?php
declare(strict_types=1);
namespace Unsupervised\Schedular\Tests\Unit\Payment;
use Brain\Monkey\Functions;
use Mockery;
use Unsupervised\Schedular\Booking\BookingRepository;
use Unsupervised\Schedular\Booking\Lesson;
use Unsupervised\Schedular\GroupClass\EnrollmentRepository;
use Unsupervised\Schedular\Payment\BillingMethodResolver;
use Unsupervised\Schedular\Payment\Payment;
use Unsupervised\Schedular\Payment\PaymentRepository;
use Unsupervised\Schedular\Payment\PaymentService;
use Unsupervised\Schedular\Payment\ReceiptMailer;
use Unsupervised\Schedular\Payment\StripeGateway;
use Unsupervised\Schedular\Payment\StudioSettings;
use Unsupervised\Schedular\Tests\Unit\TestCase;
class PaymentServiceTest extends TestCase
{
private PaymentRepository $payments;
private BillingMethodResolver $resolver;
private ReceiptMailer $mailer;
private BookingRepository $bookings;
private EnrollmentRepository $enrollments;
private StudioSettings $settings;
private StripeGateway $stripe;
private PaymentService $service;
protected function setUp(): void
{
parent::setUp();
$this->payments = Mockery::mock(PaymentRepository::class);
$this->resolver = Mockery::mock(BillingMethodResolver::class);
$this->mailer = Mockery::mock(ReceiptMailer::class);
$this->bookings = Mockery::mock(BookingRepository::class);
$this->enrollments = Mockery::mock(EnrollmentRepository::class);
$this->settings = Mockery::mock(StudioSettings::class);
$this->stripe = Mockery::mock(StripeGateway::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
// lessons (or a lookup miss) fall back to the per-lesson update.
$this->bookings->shouldReceive('findById')->andReturn(null)->byDefault();
$this->service = new PaymentService(
$this->payments,
$this->resolver,
$this->mailer,
$this->bookings,
$this->enrollments,
$this->settings,
$this->stripe
);
Functions\when('get_userdata')->justReturn(false);
}
private function payment(string $method, string $status, int $id): Payment
{
return new Payment(5, 3, Payment::REG_LESSON, 12, 35.00, 'CAD', $method, $status, id: $id);
}
public function testFreeRegistrationCreatesNoPayment(): void
{
self::assertNull($this->service->createForRegistration(Payment::REG_LESSON, 12, 5, 3, 0.0, 'CAD'));
}
public function testVoidPendingMarksPendingPaymentFailed(): void
{
$this->payments->shouldReceive('findById')->with(50)->andReturn($this->payment(Payment::METHOD_ETRANSFER, Payment::STATUS_PENDING, 50));
$this->payments->shouldReceive('updateStatus')->once()->with(50, Payment::STATUS_FAILED)->andReturn(true);
$this->service->voidPending(50);
}
public function testVoidPendingLeavesScheduledPaymentAlone(): void
{
// A scheduled (weekly/monthly) payment can cover several lessons and may be
// collected: cancelling one lesson must never void it or trigger a rebill.
$scheduled = new Payment(5, 3, Payment::REG_LESSON, 12, 35.00, 'CAD', Payment::METHOD_ETRANSFER, Payment::STATUS_PENDING, dueDate: '2026-07-14', id: 60);
$this->payments->shouldReceive('findById')->with(60)->andReturn($scheduled);
$this->payments->shouldNotReceive('updateStatus');
$this->service->voidPending(60);
}
public function testVoidPendingLeavesPaidPaymentAlone(): void
{
// Refunds are manual: cancelling a paid lesson must not touch the ledger.
$this->payments->shouldReceive('findById')->with(50)->andReturn($this->payment(Payment::METHOD_CARD, Payment::STATUS_PAID, 50));
$this->payments->shouldNotReceive('updateStatus');
$this->service->voidPending(50);
}
public function testVoidPendingIgnoresNullPaymentId(): void
{
$this->payments->shouldNotReceive('findById');
$this->service->voidPending(null);
}
public function testEtransferStaysPending(): void
{
$this->resolver->shouldReceive('resolve')->with(5)->andReturn(Payment::METHOD_ETRANSFER);
$this->payments->shouldReceive('insert')
->once()
->with(Mockery::on(static fn (Payment $p): bool => $p->method === Payment::METHOD_ETRANSFER && $p->status === Payment::STATUS_PENDING))
->andReturn(50);
$this->bookings->shouldReceive('setPaymentId')->once()->with(12, 50);
$this->payments->shouldReceive('findById')->with(50)->andReturn($this->payment(Payment::METHOD_ETRANSFER, Payment::STATUS_PENDING, 50));
// No markPaid / confirm for a pending e-transfer.
$result = $this->service->createForRegistration(Payment::REG_LESSON, 12, 5, 3, 35.00, 'CAD');
self::assertSame(Payment::STATUS_PENDING, $result->status);
}
public function testOfferingEtransferEmailIsFrozenOntoPayment(): void
{
$this->resolver->shouldReceive('resolve')->with(5)->andReturn(Payment::METHOD_ETRANSFER);
$this->payments->shouldReceive('insert')
->once()
->with(Mockery::on(static fn (Payment $p): bool => $p->etransferEmail === '[email protected]'))
->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', '[email protected]')
);
}
public function testHstIsComputedAndFrozenOntoPayment(): void
{
$this->settings->shouldReceive('hstRate')->andReturn(13.0);
$this->resolver->shouldReceive('resolve')->with(5)->andReturn(Payment::METHOD_ETRANSFER);
$this->payments->shouldReceive('insert')
->once()
->with(Mockery::on(static fn (Payment $p): bool => $p->taxRate === 13.0 && $p->taxAmount === 13.00 && $p->total() === 113.00))
->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, 100.00, 'CAD'));
}
public function testCompIsNotTaxed(): void
{
$this->settings->shouldReceive('hstRate')->andReturn(13.0);
$this->resolver->shouldReceive('resolve')->with(5)->andReturn(Payment::METHOD_COMP);
$this->payments->shouldReceive('insert')
->once()
->with(Mockery::on(static fn (Payment $p): bool => $p->taxRate === 0.0 && $p->taxAmount === 0.0))
->andReturn(61);
$this->bookings->shouldReceive('setPaymentId')->once()->with(12, 61);
$this->payments->shouldReceive('markPaid')->once()->with(61, 'USC-61')->andReturn(true);
$this->bookings->shouldReceive('updateStatus')->once()->with(12, Lesson::STATUS_CONFIRMED)->andReturn(true);
$this->payments->shouldReceive('findById')->with(61)->andReturn($this->payment(Payment::METHOD_COMP, Payment::STATUS_PAID, 61));
$this->mailer->shouldReceive('send')->andReturn(false);
self::assertNotNull($this->service->createForRegistration(Payment::REG_LESSON, 12, 5, 3, 100.00, 'CAD'));
}
public function testCompIsPaidAndConfirmsImmediately(): void
{
$this->resolver->shouldReceive('resolve')->with(5)->andReturn(Payment::METHOD_COMP);
$this->payments->shouldReceive('insert')
->once()
->with(Mockery::on(static fn (Payment $p): bool => $p->method === Payment::METHOD_COMP && $p->status === Payment::STATUS_PAID))
->andReturn(60);
$this->bookings->shouldReceive('setPaymentId')->once()->with(12, 60);
$this->payments->shouldReceive('markPaid')->once()->with(60, 'USC-60')->andReturn(true);
$this->bookings->shouldReceive('updateStatus')->once()->with(12, Lesson::STATUS_CONFIRMED)->andReturn(true);
$this->payments->shouldReceive('findById')->with(60)->andReturn($this->payment(Payment::METHOD_COMP, Payment::STATUS_PAID, 60));
$this->mailer->shouldReceive('send')->andReturn(false);
$result = $this->service->createForRegistration(Payment::REG_LESSON, 12, 5, 3, 35.00, 'CAD');
self::assertSame(Payment::STATUS_PAID, $result->status);
}
public function testMarkPaidConfirmsAndReturnsTrue(): void
{
$this->payments->shouldReceive('findById')->with(70)->andReturn($this->payment(Payment::METHOD_ETRANSFER, Payment::STATUS_PENDING, 70));
$this->payments->shouldReceive('markPaid')->once()->with(70, 'USC-70')->andReturn(true);
$this->bookings->shouldReceive('updateStatus')->once()->with(12, Lesson::STATUS_CONFIRMED)->andReturn(true);
$this->mailer->shouldReceive('send')->andReturn(false);
self::assertTrue($this->service->markPaid(70));
}
public function testMarkPaidConfirmsEveryLessonInAWeeklySeries(): void
{
$this->payments->shouldReceive('findById')->with(70)->andReturn($this->payment(Payment::METHOD_ETRANSFER, Payment::STATUS_PENDING, 70));
$this->payments->shouldReceive('markPaid')->once()->with(70, 'USC-70')->andReturn(true);
// The anchor lesson (registration_id 12) belongs to series 12: the whole
// series is confirmed, not just the anchor row.
$this->bookings->shouldReceive('findById')->with(12)->andReturn(
new Lesson(slotId: 10, studentId: 5, instructorId: 3, recurrence: Lesson::RECURRENCE_WEEKLY, seriesId: 12, id: 12)
);
$this->bookings->shouldReceive('updateStatusForSeries')->once()->with(12, Lesson::STATUS_CONFIRMED)->andReturn(true);
$this->bookings->shouldNotReceive('updateStatus');
$this->mailer->shouldReceive('send')->andReturn(false);
self::assertTrue($this->service->markPaid(70));
}
public function testMarkPaidReturnsFalseWhenMissing(): void
{
$this->payments->shouldReceive('findById')->with(99)->andReturn(null);
self::assertFalse($this->service->markPaid(99));
}
public function testMarkPaidIdempotentWhenAlreadyPaid(): void
{
$this->payments->shouldReceive('findById')->with(80)->andReturn($this->payment(Payment::METHOD_ETRANSFER, Payment::STATUS_PAID, 80));
// Already paid → no markPaid/confirm calls.
self::assertTrue($this->service->markPaid(80));
}
public function testCreateIntentForCardReturnsClientSecret(): void
{
$this->payments->shouldReceive('findByRegistration')->with(Payment::REG_LESSON, 12)
->andReturn($this->payment(Payment::METHOD_CARD, Payment::STATUS_PENDING, 90));
$intent = \Stripe\PaymentIntent::constructFrom(['id' => 'pi_abc', 'client_secret' => 'pi_abc_secret']);
$this->stripe->shouldReceive('createIntent')->once()
->with(Mockery::on(static fn (Payment $p): bool => $p->id === 90))
->andReturn($intent);
$this->payments->shouldReceive('setStripeIntentId')->once()->with(90, 'pi_abc')->andReturn(true);
$this->settings->shouldReceive('publishableKey')->andReturn('pk_test_123');
$result = $this->service->createIntent(Payment::REG_LESSON, 12, 5);
self::assertSame('card', $result['method']);
self::assertSame('pi_abc_secret', $result['client_secret']);
self::assertSame('pk_test_123', $result['publishable_key']);
}
public function testCreateIntentForEtransferReturnsDisplayDataWithoutStripe(): void
{
$payment = new Payment(5, 3, Payment::REG_LESSON, 12, 35.00, 'CAD', Payment::METHOD_ETRANSFER, Payment::STATUS_PENDING, etransferEmail: '[email protected]', id: 91);
$this->payments->shouldReceive('findByRegistration')->with(Payment::REG_LESSON, 12)->andReturn($payment);
$this->stripe->shouldNotReceive('createIntent');
$result = $this->service->createIntent(Payment::REG_LESSON, 12, 5);
self::assertSame('etransfer', $result['method']);
self::assertSame('[email protected]', $result['etransfer_email']);
self::assertArrayNotHasKey('client_secret', $result);
}
public function testCreateIntentReturnsNullWhenNotOwner(): void
{
$this->payments->shouldReceive('findByRegistration')->with(Payment::REG_LESSON, 12)
->andReturn($this->payment(Payment::METHOD_CARD, Payment::STATUS_PENDING, 90));
// Student 999 does not own payment whose studentId is 5.
self::assertNull($this->service->createIntent(Payment::REG_LESSON, 12, 999));
}
public function testCreateIntentReturnsNullWhenNoPayment(): void
{
$this->payments->shouldReceive('findByRegistration')->with(Payment::REG_LESSON, 12)->andReturn(null);
self::assertNull($this->service->createIntent(Payment::REG_LESSON, 12, 5));
}
public function testCreateIntentReturnsNullWhenStripeFails(): void
{
$this->payments->shouldReceive('findByRegistration')->with(Payment::REG_LESSON, 12)
->andReturn($this->payment(Payment::METHOD_CARD, Payment::STATUS_PENDING, 90));
$this->stripe->shouldReceive('createIntent')->once()->andReturn(null);
self::assertNull($this->service->createIntent(Payment::REG_LESSON, 12, 5));
}
public function testHandleWebhookInvalidSignatureReturnsFalse(): void
{
$this->stripe->shouldReceive('verifyWebhook')->with('{}', 'bad-sig')->andReturn(null);
self::assertFalse($this->service->handleWebhook('{}', 'bad-sig'));
}
public function testHandleWebhookSucceededFinalizesPayment(): void
{
$event = $this->intentEvent('payment_intent.succeeded', 'pi_ok');
$this->stripe->shouldReceive('verifyWebhook')->andReturn($event);
$this->payments->shouldReceive('findByStripeIntentId')->with('pi_ok')
->andReturn($this->payment(Payment::METHOD_CARD, Payment::STATUS_PENDING, 90));
$this->payments->shouldReceive('markPaid')->once()->with(90, 'USC-90')->andReturn(true);
$this->bookings->shouldReceive('updateStatus')->once()->with(12, Lesson::STATUS_CONFIRMED)->andReturn(true);
$this->payments->shouldReceive('findById')->with(90)->andReturn($this->payment(Payment::METHOD_CARD, Payment::STATUS_PAID, 90));
$this->mailer->shouldReceive('send')->andReturn(false);
self::assertTrue($this->service->handleWebhook('{}', 'sig'));
}
public function testHandleWebhookSucceededIdempotentWhenAlreadyPaid(): void
{
$event = $this->intentEvent('payment_intent.succeeded', 'pi_ok');
$this->stripe->shouldReceive('verifyWebhook')->andReturn($event);
$this->payments->shouldReceive('findByStripeIntentId')->with('pi_ok')
->andReturn($this->payment(Payment::METHOD_CARD, Payment::STATUS_PAID, 90));
// Already paid → no markPaid/confirm.
self::assertTrue($this->service->handleWebhook('{}', 'sig'));
}
public function testHandleWebhookFailedMarksFailed(): void
{
$event = $this->intentEvent('payment_intent.payment_failed', 'pi_bad');
$this->stripe->shouldReceive('verifyWebhook')->andReturn($event);
$this->payments->shouldReceive('findByStripeIntentId')->with('pi_bad')
->andReturn($this->payment(Payment::METHOD_CARD, Payment::STATUS_PENDING, 90));
$this->payments->shouldReceive('updateStatus')->once()->with(90, Payment::STATUS_FAILED)->andReturn(true);
self::assertTrue($this->service->handleWebhook('{}', 'sig'));
}
public function testHandleWebhookAcknowledgesUnknownIntent(): void
{
$event = $this->intentEvent('payment_intent.succeeded', 'pi_unknown');
$this->stripe->shouldReceive('verifyWebhook')->andReturn($event);
$this->payments->shouldReceive('findByStripeIntentId')->with('pi_unknown')->andReturn(null);
self::assertTrue($this->service->handleWebhook('{}', 'sig'));
}
private function intentEvent(string $type, string $intentId): \Stripe\Event
{
$intent = \Stripe\PaymentIntent::constructFrom(['id' => $intentId, 'object' => 'payment_intent']);
return \Stripe\Event::constructFrom(['type' => $type, 'data' => ['object' => $intent]]);
}
}