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_trial_can_be_confirmed_without_a_payment_method_and_end_cancelled(): void { $this->respond(201, $this->subscription(['trial_days' => 14, 'trial_payment_method' => 'optional'])); $this->respond(200, $this->subscription(['active' => false, 'cancelled' => true, 'status' => 'cancelled', 'starts_at' => '2026-11-04T10:00:00+00:00', 'trial_ends_at' => '2026-11-18T10:00:00+00:00', 'cancels_at' => '2026-11-18T10:00:00+00:00', 'trial_payment_method' => 'optional', 'cancellation_reason' => 'trial_ended_without_payment_method'])); $subscription = $this->owner()->appSubscriptions()->create(['name' => 'Pro', 'unit' => 'month', 'price' => '9.99', 'trial_days' => 14, 'trial_payment_method' => TrialPaymentMethod::OPTIONAL]); $this->assertSame('optional', $this->sentJson(0)['trial_payment_method']); $this->assertSame(TrialPaymentMethod::OPTIONAL, $subscription->trial_payment_method); $ended = $this->owner()->appSubscriptions()->find('01JSUB'); $this->assertTrue($ended->isCancelled()); $this->assertSame(CancellationReason::TRIAL_ENDED_WITHOUT_PAYMENT_METHOD, $ended->cancellation_reason); } 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]); } }