Bill elapsed periods on app subscription checkout

This commit is contained in:
envoyr committed 2026-11-06 10:26:51 +00:00
1 parent fb71b172f2
commit 27e868720f
3 files changed
+135 -3

No files matched your search

+19
View File
@@ -3,6 +3,7 @@
namespace Anikeen\Id\Exceptions; namespace Anikeen\Id\Exceptions;
use Anikeen\Id\Result; use Anikeen\Id\Result;
use Psr\Http\Message\ResponseInterface;
use Throwable; 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); 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 public function isNotFound(): bool
{ {
return $this->status === 404; return $this->status === 404;
+35 -3
View File
@@ -6,6 +6,7 @@ use Anikeen\Id\Concerns\HasSubscriptionStatus;
use Anikeen\Id\Exceptions\ApiException; use Anikeen\Id\Exceptions\ApiException;
use Anikeen\Id\Support\AppRequest; use Anikeen\Id\Support\AppRequest;
use DateTimeInterface; use DateTimeInterface;
use InvalidArgumentException;
use JsonSerializable; use JsonSerializable;
/** /**
@@ -73,17 +74,48 @@ class AppSubscription implements JsonSerializable
* $startsAt (in the past) books a service that is already running, e.g. a domain registered * $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. * 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) { if ($startsAt instanceof DateTimeInterface) {
$startsAt = $startsAt->format(DateTimeInterface::ATOM); $startsAt = $startsAt->format(DateTimeInterface::ATOM);
} }
if (!$billElapsedPeriods) {
return $this->send('PUT', '/checkout', $startsAt === null ? null : ['starts_at' => $startsAt]); 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,
]);
}
/** /**
* Cancel the subscription. With $refund (only within 14 days of its start), its not yet * Cancel the subscription. With $refund (only within 14 days of its start), its not yet
* charged usage is credited. * charged usage is credited.
+81
View File
@@ -87,6 +87,87 @@ class AppSubscriptionsTest extends TestCase
$this->assertSame(['starts_at' => '2026-05-19T11:33:58+00:00'], $this->sentJson(1)); $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 public function test_errors_carry_status_and_error_code(): void
{ {
$this->respond(409, ['message' => 'Product subscription has already been cancelled.', 'error' => 'already_cancelled']); $this->respond(409, ['message' => 'Product subscription has already been cancelled.', 'error' => 'already_cancelled']);