Files
unsupervised-scheduler/tests/Unit/Payment/PaymentServiceTest.php
T
thatguygriffandClaude Fable 5 ff059909a5
CI / No Debug Code (pull_request) Successful in 2s
CI / Coding Standards (pull_request) Successful in 2m50s
CI / Tests (PHP 8.1) (pull_request) Successful in 42s
CI / Tests (PHP 8.2) (pull_request) Successful in 39s
CI / PHPStan (pull_request) Successful in 2m49s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m38s
CI / Build Plugin Zip (pull_request) Skipped
Charge weekly reservations for every claimed occurrence and confirm the whole series
A weekly booking on a per-lesson (one_time) priced offering was creating its
single upfront payment for one week's price while reserving up to 12 weeks,
and settling that payment confirmed only the anchor lesson, leaving the rest
of the series pending forever.

- BookingEndpoint now charges price x claimed occurrences for one_time
  billing; a full_term price is still charged once since it covers the term.
- PaymentService::confirmRegistration resolves the anchor lesson's series and
  confirms every non-cancelled row via the new
  BookingRepository::updateStatusForSeries().

Closes #79

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-22 10:19:53 -03:00

339 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 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]]);
}
}