diff --git a/src/Id/Exceptions/ApiException.php b/src/Id/Exceptions/ApiException.php index 5654568..237cf16 100644 --- a/src/Id/Exceptions/ApiException.php +++ b/src/Id/Exceptions/ApiException.php @@ -3,6 +3,7 @@ namespace Anikeen\Id\Exceptions; use Anikeen\Id\Result; +use Psr\Http\Message\ResponseInterface; use Throwable; /** @@ -44,6 +45,24 @@ class ApiException extends ResourceException return new static(sprintf('Anikeen ID is not reachable: %s', $previous->getMessage()), 0, null, $previous); } + /** + * The JSON body of Anikeen ID's error response, null without one. Some errors carry details, + * e.g. `net_amount` of "elapsed_amount_exceeded". + */ + public function body(): ?object + { + $previous = $this->getPrevious(); + $response = $previous !== null && method_exists($previous, 'getResponse') ? $previous->getResponse() : null; + + if (!$response instanceof ResponseInterface) { + return null; + } + + $body = json_decode((string) $response->getBody()); + + return is_object($body) ? $body : null; + } + public function isNotFound(): bool { return $this->status === 404; diff --git a/src/Id/Resources/AppSubscription.php b/src/Id/Resources/AppSubscription.php index 351f120..f4f58aa 100644 --- a/src/Id/Resources/AppSubscription.php +++ b/src/Id/Resources/AppSubscription.php @@ -6,6 +6,7 @@ use Anikeen\Id\Concerns\HasSubscriptionStatus; use Anikeen\Id\Exceptions\ApiException; use Anikeen\Id\Support\AppRequest; use DateTimeInterface; +use InvalidArgumentException; use JsonSerializable; /** @@ -73,15 +74,46 @@ class AppSubscription implements JsonSerializable * $startsAt (in the past) books a service that is already running, e.g. a domain registered * earlier: the first period runs from $startsAt, is billed now and renews when it ends. * - * @throws ApiException 409 "already_active", "billing_address_missing", "period_already_ended", … + * A $startsAt older than one period is refused ("period_already_ended") unless + * $billElapsedPeriods is set: then the periods that have already ended are billed too, together + * with the running one, as long as they come to at most $maxNetAmount (net, up to two + * decimals) and $startsAt lies within the maximum age Anikeen ID accepts (about three years). + * The lines land on the current month's collective order, which is charged on the 1st of the + * next month. $billElapsedPeriods needs both $startsAt and $maxNetAmount. + * + * @throws ApiException 409 "already_active", "billing_address_missing", "period_already_ended", + * "elapsed_periods_not_allowed" (the elapsed periods can't be billed for this subscription), + * "elapsed_amount_exceeded" (they come to more than $maxNetAmount; the response body has + * the booked `net_amount`), …; 422 invalid input (e.g. a start older than the maximum age) + * @throws InvalidArgumentException $billElapsedPeriods without $startsAt or $maxNetAmount */ - public function checkout(DateTimeInterface|string|null $startsAt = null): self - { + public function checkout( + DateTimeInterface|string|null $startsAt = null, + bool $billElapsedPeriods = false, + float|string|null $maxNetAmount = null, + ): self { if ($startsAt instanceof DateTimeInterface) { $startsAt = $startsAt->format(DateTimeInterface::ATOM); } - return $this->send('PUT', '/checkout', $startsAt === null ? null : ['starts_at' => $startsAt]); + if (!$billElapsedPeriods) { + return $this->send('PUT', '/checkout', $startsAt === null ? null : ['starts_at' => $startsAt]); + } + + if ($startsAt === null || $startsAt === '') { + throw new InvalidArgumentException('Billing elapsed periods needs a start date ($startsAt).'); + } + + if ($maxNetAmount === null || $maxNetAmount === '') { + throw new InvalidArgumentException('Billing elapsed periods needs a maximum net amount ($maxNetAmount).'); + } + + return $this->send('PUT', '/checkout', [ + 'starts_at' => $startsAt, + 'bill_elapsed_periods' => true, + // floats from calculations may carry binary noise (e.g. 59.970000000000006) + 'max_net_amount' => is_float($maxNetAmount) ? round($maxNetAmount, 2) : $maxNetAmount, + ]); } /** diff --git a/tests/Id/AppSubscriptionsTest.php b/tests/Id/AppSubscriptionsTest.php index 53e41ef..8003bc9 100644 --- a/tests/Id/AppSubscriptionsTest.php +++ b/tests/Id/AppSubscriptionsTest.php @@ -87,6 +87,87 @@ class AppSubscriptionsTest extends TestCase $this->assertSame(['starts_at' => '2026-05-19T11:33:58+00:00'], $this->sentJson(1)); } + public function test_a_checkout_without_arguments_sends_no_body(): void + { + $this->respond(201, $this->subscription()); + $this->respond(200, $this->subscription(['active' => true])); + + $this->owner()->appSubscriptions()->create(['name' => 'Server', 'unit' => 'hour', 'price' => 1])->checkout(); + + $request = $this->sent[1]['request']; + $this->assertSame('PUT /api/v1/client/subscriptions/01JSUB/checkout', $request->getMethod() . ' ' . $request->getUri()->getPath()); + $this->assertSame('', (string) $request->getBody()); + $this->assertFalse($request->hasHeader('Content-Type')); + } + + public function test_a_checkout_from_a_date_sends_only_the_start(): void + { + $this->respond(201, $this->subscription()); + $this->respond(200, $this->subscription(['active' => true])); + + $this->owner()->appSubscriptions()->create(['name' => '.de TLD', 'unit' => 'year', 'price' => '5.19']) + ->checkout('2026-05-19T11:33:58+00:00'); + + $this->assertSame('{"starts_at":"2026-05-19T11:33:58+00:00"}', (string) $this->sent[1]['request']->getBody()); + } + + public function test_elapsed_periods_are_billed_up_to_the_ceiling(): void + { + $this->respond(201, $this->subscription()); + $this->respond(200, $this->subscription(['active' => true])); + + $this->owner()->appSubscriptions()->create(['name' => '.de TLD', 'unit' => 'year', 'price' => '5.19']) + ->checkout(new \DateTimeImmutable('2024-05-19 11:33:58', new \DateTimeZone('UTC')), billElapsedPeriods: true, maxNetAmount: '15.57'); + + $request = $this->sent[1]['request']; + $this->assertSame('PUT /api/v1/client/subscriptions/01JSUB/checkout', $request->getMethod() . ' ' . $request->getUri()->getPath()); + $this->assertSame(['starts_at' => '2024-05-19T11:33:58+00:00', 'bill_elapsed_periods' => true, 'max_net_amount' => '15.57'], $this->sentJson(1)); + } + + public function test_a_float_ceiling_is_sent_with_two_decimals(): void + { + $this->respond(201, $this->subscription()); + $this->respond(200, $this->subscription(['active' => true])); + + $this->owner()->appSubscriptions()->create(['name' => '.de TLD', 'unit' => 'year', 'price' => 19.99]) + ->checkout('2024-05-19', true, 19.99 * 3); + + $this->assertSame(['starts_at' => '2024-05-19', 'bill_elapsed_periods' => true, 'max_net_amount' => 59.97], $this->sentJson(1)); + } + + public function test_billing_elapsed_periods_needs_a_start_and_a_ceiling(): void + { + $this->respond(201, $this->subscription()); + $subscription = $this->owner()->appSubscriptions()->create(['name' => '.de TLD', 'unit' => 'year', 'price' => '5.19']); + + foreach ([[null, '10.00'], ['2024-05-19', null], ['', '10.00'], ['2024-05-19', '']] as [$startsAt, $maxNetAmount]) { + try { + $subscription->checkout($startsAt, billElapsedPeriods: true, maxNetAmount: $maxNetAmount); + $this->fail('Expected an InvalidArgumentException.'); + } catch (InvalidArgumentException) { + } + } + + $this->assertCount(1, $this->sent); + } + + public function test_an_exceeded_ceiling_carries_the_net_amount(): void + { + $this->respond(201, $this->subscription()); + $this->respond(409, ['message' => 'The elapsed periods come to 20.76 net, more than 15.57.', 'error' => 'elapsed_amount_exceeded', 'net_amount' => '20.76']); + + $subscription = $this->owner()->appSubscriptions()->create(['name' => '.de TLD', 'unit' => 'year', 'price' => '5.19']); + + try { + $subscription->checkout('2024-05-19', billElapsedPeriods: true, maxNetAmount: '15.57'); + $this->fail('Expected an ApiException.'); + } catch (ApiException $e) { + $this->assertTrue($e->isConflict()); + $this->assertSame('elapsed_amount_exceeded', $e->error); + $this->assertSame('20.76', $e->body()->net_amount); + } + } + public function test_errors_carry_status_and_error_code(): void { $this->respond(409, ['message' => 'Product subscription has already been cancelled.', 'error' => 'already_cancelled']);