Files
id/src/Id/Resources/AppSubscription.php
T

178 lines
6.1 KiB
PHP

<?php
namespace Anikeen\Id\Resources;
use Anikeen\Id\Concerns\HasSubscriptionStatus;
use Anikeen\Id\Exceptions\ApiException;
use Anikeen\Id\Support\AppRequest;
use DateTimeInterface;
use InvalidArgumentException;
use JsonSerializable;
/**
* A subscription managed by the app itself (client credentials), see AppSubscriptions.
*
* @property string $id
* @property string $user_id
* @property ?string $group
* @property string $name
* @property ?string $description
* @property string $unit
* @property float $price
* @property ?float $monthly_cap net, at most this per calendar month (hour and day units)
* @property ?float $expected_monthly_price gross estimate for a month (720 hours, at most the monthly cap)
* @property ?string $predecessor_id the subscription this one replaced (a resize); they share the monthly cap
* @property ?float $vat_rate
* @property bool $active
* @property bool $autorenew
* @property bool $cancelled
* @property ?string $status draft, trial, active, paused, cancelling, expired or cancelled, see status()
* @property ?string $cancels_at when a scheduled cancellation takes effect
* @property ?string $trial_ends_at
* @property ?string $starts_at
* @property ?string $ends_at
* @property ?string $accounting_at
* @property bool $is_past_due
* @property int $failed_renewal_retries
* @property ?array $payload
* @property ?string $idempotency_key
* @property ?string $checkout_session_id the checkout session the user confirms it in, if any
*/
class AppSubscription implements JsonSerializable
{
use HasSubscriptionStatus;
public function __construct(protected object $data)
{
}
public function __get(string $name): mixed
{
return $this->data->{$name} ?? null;
}
public function __isset(string $name): bool
{
return isset($this->data->{$name});
}
/**
* Change a subscription (send only what changes). Before checkout everything can change;
* afterwards only the description, which names what the customer pays for and is shown
* on their bills, e.g. `$subscription->update(['description' => 'web-01.example.com'])`.
*
* @throws ApiException
*/
public function update(array $attributes): self
{
return $this->send('PUT', '', $attributes);
}
/**
* Activate the subscription and bill its first period.
*
* $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.
*
* 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,
bool $billElapsedPeriods = false,
float|string|null $maxNetAmount = null,
): self {
if ($startsAt instanceof DateTimeInterface) {
$startsAt = $startsAt->format(DateTimeInterface::ATOM);
}
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,
]);
}
/**
* Cancel the subscription. With $refund (only within 14 days of its start), its not yet
* charged usage is credited.
*
* @throws ApiException 409 "already_cancelled", 404 "subscription_not_found", …
*/
public function revoke(bool $refund = false): self
{
return $this->send('PUT', '/revoke', ['refund' => $refund]);
}
/**
* Stop renewing; the subscription expires when its paid period ends.
*
* @throws ApiException
*/
public function pause(): self
{
return $this->send('PUT', '/pause');
}
/**
* Renew again (an expired subscription starts a new period).
*
* @throws ApiException
*/
public function resume(): self
{
return $this->send('PUT', '/resume');
}
/**
* @throws ApiException
*/
public function refresh(): self
{
return $this->send('GET', '');
}
public function toArray(): array
{
return (array) $this->data;
}
public function jsonSerialize(): array
{
return $this->toArray();
}
/**
* @throws ApiException
*/
private function send(string $method, string $action, ?array $payload = null): self
{
$this->data = AppRequest::send($method, sprintf('v1/client/subscriptions/%s%s', $this->id, $action), $payload)->data;
return $this;
}
}