mirror of
https://github.com/anikeen-com/id.git
synced 2026-11-04 22:59:41 +00:00
AppSubscription::change() changes price, unit, name, description or discount of a running subscription, prorated right away (APPLY_NOW) or from the next period on (APPLY_PERIOD_END, shown as scheduled_change); cancelScheduledChange() drops a scheduled one. Adds the subscription.updated event type and documents proration_not_possible and credit_exceeds_bill.
263 lines
12 KiB
PHP
263 lines
12 KiB
PHP
<?php
|
|
|
|
namespace Anikeen\Id\Tests;
|
|
|
|
use Anikeen\Id\AnikeenId;
|
|
use Anikeen\Id\Billable;
|
|
use Anikeen\Id\Exceptions\ApiException;
|
|
use Anikeen\Id\Resources\AppSubscription;
|
|
use GuzzleHttp\Exception\ConnectException;
|
|
use GuzzleHttp\Psr7\Request;
|
|
use Illuminate\Database\Eloquent\Model;
|
|
use InvalidArgumentException;
|
|
|
|
class AppSubscriptionsTest extends TestCase
|
|
{
|
|
private function owner(?string $anikeenId = '01JUSER'): Model
|
|
{
|
|
$owner = new class extends Model {
|
|
use Billable;
|
|
|
|
protected $guarded = [];
|
|
};
|
|
$owner->anikeen_id = $anikeenId;
|
|
$owner->anikeen_id_access_token = 'user-token';
|
|
|
|
return $owner;
|
|
}
|
|
|
|
private function subscription(array $overrides = []): array
|
|
{
|
|
return ['data' => ['id' => '01JSUB', 'user_id' => '01JUSER', 'name' => 'Server', 'active' => false, 'cancelled' => false, ...$overrides]];
|
|
}
|
|
|
|
public function test_create_sends_the_user_id_with_the_app_token(): void
|
|
{
|
|
$this->respond(201, $this->subscription());
|
|
|
|
$subscription = $this->owner()->appSubscriptions()->create(['group' => 'team-1', 'name' => 'Server', 'unit' => 'hour', 'price' => '0.0137', 'idempotency_key' => 'server:1']);
|
|
|
|
$request = $this->sent[0]['request'];
|
|
$this->assertSame('POST', $request->getMethod());
|
|
$this->assertSame('/api/v1/client/subscriptions', $request->getUri()->getPath());
|
|
$this->assertSame('Bearer app-token', $request->getHeaderLine('Authorization'));
|
|
$this->assertSame(['group' => 'team-1', 'name' => 'Server', 'unit' => 'hour', 'price' => '0.0137', 'idempotency_key' => 'server:1', 'user_id' => '01JUSER'], $this->sentJson(0));
|
|
$this->assertSame('01JSUB', $subscription->id);
|
|
}
|
|
|
|
public function test_nothing_is_requested_until_a_method_is_called(): void
|
|
{
|
|
$this->owner()->appSubscriptions();
|
|
|
|
$this->assertCount(0, $this->sent);
|
|
}
|
|
|
|
public function test_actions_use_the_app_endpoints(): void
|
|
{
|
|
$this->respond(201, $this->subscription());
|
|
$this->respond(200, $this->subscription(['active' => true]));
|
|
$this->respond(200, $this->subscription(['active' => true, 'autorenew' => false]));
|
|
$this->respond(200, $this->subscription(['active' => true, 'autorenew' => true]));
|
|
$this->respond(200, $this->subscription(['cancelled' => true]));
|
|
|
|
$subscription = $this->owner()->appSubscriptions()->create(['name' => 'Server', 'unit' => 'hour', 'price' => 1]);
|
|
|
|
$this->assertTrue($subscription->checkout()->active);
|
|
$this->assertFalse($subscription->pause()->autorenew);
|
|
$this->assertTrue($subscription->resume()->autorenew);
|
|
$this->assertTrue($subscription->revoke(refund: true)->cancelled);
|
|
|
|
$this->assertSame([
|
|
'POST /api/v1/client/subscriptions',
|
|
'PUT /api/v1/client/subscriptions/01JSUB/checkout',
|
|
'PUT /api/v1/client/subscriptions/01JSUB/pause',
|
|
'PUT /api/v1/client/subscriptions/01JSUB/resume',
|
|
'PUT /api/v1/client/subscriptions/01JSUB/revoke',
|
|
], array_map(fn ($entry) => $entry['request']->getMethod() . ' ' . $entry['request']->getUri()->getPath(), $this->sent));
|
|
$this->assertSame(['refund' => true], $this->sentJson(4));
|
|
}
|
|
|
|
public function test_a_running_service_is_checked_out_from_its_start(): void
|
|
{
|
|
$this->respond(201, $this->subscription());
|
|
$this->respond(200, $this->subscription(['active' => true]));
|
|
|
|
$subscription = $this->owner()->appSubscriptions()->create(['name' => '.de TLD', 'unit' => 'year', 'price' => '5.19']);
|
|
$subscription->checkout(new \DateTimeImmutable('2026-05-19 11:33:58', new \DateTimeZone('UTC')));
|
|
|
|
$this->assertSame(['starts_at' => '2026-05-19T11:33:58+00:00'], $this->sentJson(1));
|
|
}
|
|
|
|
public function test_errors_carry_status_and_error_code(): void
|
|
{
|
|
$this->respond(409, ['message' => 'Product subscription has already been cancelled.', 'error' => 'already_cancelled']);
|
|
|
|
try {
|
|
$this->owner()->appSubscriptions()->create(['name' => 'Server', 'unit' => 'hour', 'price' => 1]);
|
|
$this->fail('Expected an ApiException.');
|
|
} catch (ApiException $e) {
|
|
$this->assertSame(409, $e->status);
|
|
$this->assertSame('already_cancelled', $e->error);
|
|
$this->assertTrue($e->isConflict());
|
|
$this->assertFalse($e->isTemporary());
|
|
}
|
|
}
|
|
|
|
public function test_find_returns_null_for_unknown_subscriptions_and_other_users(): void
|
|
{
|
|
$this->respond(404, ['message' => 'Subscription not found.', 'error' => 'subscription_not_found']);
|
|
$this->respond(200, $this->subscription(['user_id' => '01JSOMEONEELSE']));
|
|
$this->respond(200, $this->subscription());
|
|
|
|
$subscriptions = $this->owner()->appSubscriptions();
|
|
|
|
$this->assertNull($subscriptions->find('01JUNKNOWN'));
|
|
$this->assertNull($subscriptions->find('01JSUB'));
|
|
$this->assertSame('01JSUB', $subscriptions->find('01JSUB')->id);
|
|
}
|
|
|
|
public function test_all_follows_pages_and_filters_by_user(): void
|
|
{
|
|
$this->respond(200, ['data' => [['id' => 'a', 'user_id' => '01JUSER']], 'meta' => ['current_page' => 1, 'last_page' => 2, 'total' => 2], 'links' => ['next' => null]]);
|
|
$this->respond(200, ['data' => [['id' => 'b', 'user_id' => '01JUSER']], 'meta' => ['current_page' => 2, 'last_page' => 2, 'total' => 2], 'links' => ['next' => null]]);
|
|
|
|
$all = $this->owner()->appSubscriptions()->all(['group' => 'team-1', 'active' => true]);
|
|
|
|
$this->assertSame(['a', 'b'], array_map(fn ($s) => $s->id, $all));
|
|
parse_str($this->sent[0]['request']->getUri()->getQuery(), $query);
|
|
$this->assertSame(['group' => 'team-1', 'active' => '1', 'user_id' => '01JUSER', 'per_page' => '100', 'page' => '1'], $query);
|
|
}
|
|
|
|
public function test_a_rejected_app_token_is_renewed_once(): void
|
|
{
|
|
$this->respond(401, ['message' => 'Unauthenticated.']);
|
|
$this->respond(200, ['data' => [], 'meta' => ['last_page' => 1], 'links' => ['next' => null]]);
|
|
|
|
$this->assertSame([], $this->owner()->appSubscriptions()->all());
|
|
|
|
$this->assertSame('Bearer app-token', $this->sent[0]['request']->getHeaderLine('Authorization'));
|
|
$this->assertSame('Bearer fresh-app-token', $this->sent[1]['request']->getHeaderLine('Authorization'));
|
|
}
|
|
|
|
public function test_an_unreachable_service_is_a_temporary_api_exception(): void
|
|
{
|
|
$this->http->append(new ConnectException('Connection refused', new Request('GET', 'v1/client/subscriptions')));
|
|
|
|
try {
|
|
$this->owner()->appSubscriptions()->all();
|
|
$this->fail('Expected an ApiException.');
|
|
} catch (ApiException $e) {
|
|
$this->assertSame(0, $e->status);
|
|
$this->assertTrue($e->isTemporary());
|
|
}
|
|
}
|
|
|
|
public function test_the_shared_client_keeps_the_users_token(): void
|
|
{
|
|
$this->respond(200, ['data' => [], 'meta' => ['last_page' => 1], 'links' => ['next' => null]]);
|
|
app(AnikeenId::class)->withToken('user-token');
|
|
|
|
$this->owner()->appSubscriptions()->all();
|
|
|
|
$this->assertSame('user-token', app(AnikeenId::class)->getToken());
|
|
}
|
|
|
|
public function test_the_user_id_field_is_configurable(): void
|
|
{
|
|
AnikeenId::useUserIdField('id_service_user');
|
|
$owner = $this->owner(null);
|
|
$owner->id_service_user = '01JOTHER';
|
|
|
|
try {
|
|
$this->assertSame('01JOTHER', $owner->appSubscriptions()->userId());
|
|
} finally {
|
|
AnikeenId::useUserIdField('anikeen_id');
|
|
}
|
|
}
|
|
|
|
public function test_a_subscription_starts_with_a_free_trial(): void
|
|
{
|
|
$this->respond(201, $this->subscription());
|
|
$this->respond(200, $this->subscription(['active' => true, 'autorenew' => true, 'status' => 'trial', 'starts_at' => '2026-11-04T10:00:00+00:00', 'trial_ends_at' => date(DATE_ATOM, time() + 14 * 86400)]));
|
|
|
|
$subscription = $this->owner()->appSubscriptions()->create(['group' => 'team-1', 'name' => 'Pro', 'unit' => 'month', 'price' => '9.99', 'trial_days' => 14, 'trial_once' => true]);
|
|
$subscription->checkout();
|
|
|
|
$this->assertSame(['group' => 'team-1', 'name' => 'Pro', 'unit' => 'month', 'price' => '9.99', 'trial_days' => 14, 'trial_once' => true, 'user_id' => '01JUSER'], $this->sentJson(0));
|
|
$this->assertTrue($subscription->isTrial());
|
|
$this->assertTrue($subscription->isActive());
|
|
}
|
|
|
|
public function test_a_used_trial_is_refused_on_checkout(): void
|
|
{
|
|
$this->respond(201, $this->subscription());
|
|
$this->respond(409, ['message' => 'The user already had a free trial.', 'error' => 'trial_already_used']);
|
|
|
|
$subscription = $this->owner()->appSubscriptions()->create(['name' => 'Pro', 'unit' => 'month', 'price' => '9.99', 'trial_ends_at' => '2026-12-01T00:00:00+01:00', 'trial_once' => true]);
|
|
|
|
try {
|
|
$subscription->checkout();
|
|
$this->fail('Expected an ApiException.');
|
|
} catch (ApiException $e) {
|
|
$this->assertTrue($e->isConflict());
|
|
$this->assertSame('trial_already_used', $e->error);
|
|
}
|
|
}
|
|
|
|
public function test_a_subscription_is_created_with_a_discount(): void
|
|
{
|
|
$discount = ['label' => 'SUMMER20', 'percent_off' => 20, 'duration' => 'repeating', 'duration_in_periods' => 3];
|
|
$this->respond(201, $this->subscription(['discount' => [...$discount, 'amount_off' => null, 'periods_remaining' => 3]]));
|
|
|
|
$subscription = $this->owner()->appSubscriptions()->create(['name' => 'Pro', 'unit' => 'month', 'price' => '9.99', 'discount' => $discount]);
|
|
|
|
$this->assertSame($discount, $this->sentJson(0)['discount']);
|
|
$this->assertSame('SUMMER20', $subscription->discount->label);
|
|
$this->assertSame(3, $subscription->discount->periods_remaining);
|
|
}
|
|
|
|
public function test_a_running_subscription_is_changed_now_or_at_the_period_end(): void
|
|
{
|
|
$change = ['applies_at' => '2026-12-01T00:00:00+00:00', 'name' => 'Pro', 'description' => null, 'unit' => 'month', 'price' => 14.99, 'discount' => null, 'created_at' => '2026-11-04T10:00:00+00:00'];
|
|
$this->respond(200, $this->subscription(['active' => true, 'price' => 14.99]));
|
|
$this->respond(200, $this->subscription(['active' => true, 'price' => 14.99, 'scheduled_change' => $change]));
|
|
$this->respond(200, $this->subscription(['active' => true, 'price' => 14.99, 'scheduled_change' => null]));
|
|
|
|
$subscription = new AppSubscription((object) $this->subscription(['active' => true])['data']);
|
|
|
|
$this->assertSame(14.99, $subscription->change(['apply' => AppSubscription::APPLY_NOW, 'price' => '14.99', 'discount' => null])->price);
|
|
$this->assertSame('2026-12-01T00:00:00+00:00', $subscription->change(['apply' => AppSubscription::APPLY_PERIOD_END, 'unit' => 'month', 'name' => 'Pro'])->scheduled_change->applies_at);
|
|
$this->assertNull($subscription->cancelScheduledChange()->scheduled_change);
|
|
|
|
$this->assertSame([
|
|
'PUT /api/v1/client/subscriptions/01JSUB/change Bearer app-token',
|
|
'PUT /api/v1/client/subscriptions/01JSUB/change Bearer app-token',
|
|
'DELETE /api/v1/client/subscriptions/01JSUB/change Bearer app-token',
|
|
], array_map(fn ($entry) => $entry['request']->getMethod() . ' ' . $entry['request']->getUri()->getPath() . ' ' . $entry['request']->getHeaderLine('Authorization'), $this->sent));
|
|
$this->assertSame(['apply' => 'now', 'price' => '14.99', 'discount' => null], $this->sentJson(0));
|
|
$this->assertSame(['apply' => 'period_end', 'unit' => 'month', 'name' => 'Pro'], $this->sentJson(1));
|
|
}
|
|
|
|
public function test_a_refused_change_carries_its_error_code(): void
|
|
{
|
|
$this->respond(409, ['message' => 'The credit would exceed the open bill.', 'error' => 'credit_exceeds_bill']);
|
|
|
|
$subscription = new AppSubscription((object) $this->subscription(['active' => true])['data']);
|
|
|
|
try {
|
|
$subscription->change(['apply' => AppSubscription::APPLY_NOW, 'price' => '1.99']);
|
|
$this->fail('Expected an ApiException.');
|
|
} catch (ApiException $e) {
|
|
$this->assertTrue($e->isConflict());
|
|
$this->assertSame('credit_exceeds_bill', $e->error);
|
|
}
|
|
}
|
|
|
|
public function test_a_missing_user_id_is_reported(): void
|
|
{
|
|
$this->expectException(InvalidArgumentException::class);
|
|
|
|
$this->owner(null)->appSubscriptions()->create(['name' => 'Server', 'unit' => 'hour', 'price' => 1]);
|
|
}
|
|
}
|