mirror of
https://github.com/anikeen-com/id.git
synced 2026-11-06 23:58:35 +00:00
270 lines
12 KiB
PHP
270 lines
12 KiB
PHP
<?php
|
|
|
|
namespace Anikeen\Id\Tests;
|
|
|
|
use Anikeen\Id\AnikeenId;
|
|
use Anikeen\Id\Exceptions\ApiException;
|
|
use Anikeen\Id\Resources\AppCharge;
|
|
use Anikeen\Id\Resources\BillingLimit;
|
|
use DateTimeImmutable;
|
|
|
|
class AppChargesTest extends TestCase
|
|
{
|
|
private function sentCall(int $index): string
|
|
{
|
|
$request = $this->sent[$index]['request'];
|
|
|
|
return $request->getMethod() . ' ' . $request->getUri()->getPath() . ' ' . $request->getHeaderLine('Authorization');
|
|
}
|
|
|
|
private function charge(array $overrides = []): array
|
|
{
|
|
return ['data' => [
|
|
'object' => 'charge', 'id' => '01JCHARGE', 'user_id' => '01JUSER', 'status' => 'open',
|
|
'idempotency_key' => 'shipment-123', 'name' => 'Signed card', 'description' => null,
|
|
'type' => 'physical', 'quantity' => 2, 'unit' => 'piece', 'unit_price' => '4.000000',
|
|
'net_amount' => '8.00', 'gross_amount' => '9.52', 'currency_iso' => 'EUR', 'group' => null,
|
|
'occurred_at' => '2026-11-06T10:00:00+00:00', 'reference' => 'order-77', 'metadata' => ['parcel' => 'p-1'],
|
|
'order_id' => '01JORDER', 'voided_at' => null, 'void_reason' => null, 'created_at' => '2026-11-06T10:00:01+00:00',
|
|
...$overrides,
|
|
]];
|
|
}
|
|
|
|
private function limit(array $overrides = []): array
|
|
{
|
|
return ['data' => [
|
|
'object' => 'billing_limit', 'user_id' => '01JUSER', 'currency' => 'EUR', 'basis' => 'gross',
|
|
'limit' => '100.00', 'used' => '82.40', 'used_by_app' => '60.00', 'remaining' => '17.60',
|
|
'remaining_net' => '14.78', 'vat_rate' => 19, 'reached' => false,
|
|
'resets_at' => '2026-12-01T02:00:00Z', 'version' => 7,
|
|
...$overrides,
|
|
]];
|
|
}
|
|
|
|
public function test_a_charge_is_booked_with_the_app_token(): void
|
|
{
|
|
$this->respond(201, $this->charge());
|
|
|
|
$attributes = [
|
|
'idempotency_key' => 'shipment-123',
|
|
'name' => 'Signed card',
|
|
'type' => 'physical',
|
|
'quantity' => 2,
|
|
'unit_price' => '4.00',
|
|
'reference' => 'order-77',
|
|
'metadata' => ['parcel' => 'p-1'],
|
|
];
|
|
$charge = app(AnikeenId::class)->createChargeForUser('01JUSER', $attributes);
|
|
|
|
$this->assertSame('POST /api/v1/client/users/01JUSER/charges Bearer app-token', $this->sentCall(0));
|
|
$this->assertSame($attributes, $this->sentJson(0));
|
|
$this->assertInstanceOf(AppCharge::class, $charge);
|
|
$this->assertSame('01JCHARGE', $charge->id());
|
|
$this->assertSame('open', $charge->status());
|
|
$this->assertTrue($charge->isOpen());
|
|
$this->assertFalse($charge->isBilled());
|
|
$this->assertFalse($charge->isVoided());
|
|
$this->assertSame('8.00', $charge->netAmount());
|
|
$this->assertSame('9.52', $charge->grossAmount());
|
|
$this->assertSame('01JORDER', $charge->orderId());
|
|
$this->assertSame(['parcel' => 'p-1'], $charge->metadata());
|
|
$this->assertEquals(new DateTimeImmutable('2026-11-06T10:00:00+00:00'), $charge->occurredAt());
|
|
$this->assertSame(2, $charge->quantity);
|
|
$this->assertSame('order-77', $charge->reference);
|
|
$this->assertNull($charge->voided_at);
|
|
$this->assertSame('Signed card', $charge->jsonSerialize()['name']);
|
|
}
|
|
|
|
public function test_empty_metadata_is_an_empty_array(): void
|
|
{
|
|
$this->respond(200, $this->charge(['metadata' => new \stdClass()]));
|
|
|
|
$this->assertSame([], app(AnikeenId::class)->findChargeForUser('01JUSER', '01JCHARGE')->metadata());
|
|
}
|
|
|
|
public function test_a_charge_is_found_with_the_app_token(): void
|
|
{
|
|
$this->respond(200, $this->charge(['status' => 'billed']));
|
|
|
|
$charge = app(AnikeenId::class)->findChargeForUser('01JUSER', '01JCHARGE');
|
|
|
|
$this->assertSame('GET /api/v1/client/users/01JUSER/charges/01JCHARGE Bearer app-token', $this->sentCall(0));
|
|
$this->assertTrue($charge->isBilled());
|
|
}
|
|
|
|
public function test_a_charge_is_voided_with_and_without_a_reason(): void
|
|
{
|
|
$voided = ['status' => 'voided', 'order_id' => null, 'voided_at' => '2026-11-06T11:00:00+00:00', 'void_reason' => 'Parcel lost'];
|
|
$this->respond(200, $this->charge($voided));
|
|
$this->respond(200, $this->charge($voided));
|
|
|
|
$charge = app(AnikeenId::class)->voidChargeForUser('01JUSER', '01JCHARGE', 'Parcel lost');
|
|
app(AnikeenId::class)->voidChargeForUser('01JUSER', '01JCHARGE');
|
|
|
|
$this->assertSame('POST /api/v1/client/users/01JUSER/charges/01JCHARGE/void Bearer app-token', $this->sentCall(0));
|
|
$this->assertSame(['reason' => 'Parcel lost'], $this->sentJson(0));
|
|
$this->assertSame('POST /api/v1/client/users/01JUSER/charges/01JCHARGE/void Bearer app-token', $this->sentCall(1));
|
|
$this->assertSame([], $this->sentJson(1));
|
|
$this->assertTrue($charge->isVoided());
|
|
$this->assertNull($charge->orderId());
|
|
$this->assertSame('Parcel lost', $charge->void_reason);
|
|
}
|
|
|
|
public function test_path_segments_are_encoded(): void
|
|
{
|
|
$this->respond(200, $this->charge());
|
|
|
|
app(AnikeenId::class)->findChargeForUser('01J/USER', '01J CHARGE');
|
|
|
|
$this->assertSame('/api/v1/client/users/01J%2FUSER/charges/01J%20CHARGE', $this->sent[0]['request']->getUri()->getPath());
|
|
}
|
|
|
|
public function test_a_charge_over_the_billing_limit_carries_the_details(): void
|
|
{
|
|
$this->respond(409, [
|
|
'message' => 'The customer\'s billing limit is reached.', 'error' => 'billing_limit_reached',
|
|
'limit' => '100.00', 'used' => '97.40', 'remaining' => '2.60', 'remaining_net' => '2.18',
|
|
'required' => '4.76', 'currency' => 'EUR', 'resets_at' => '2026-12-01T02:00:00Z',
|
|
]);
|
|
|
|
try {
|
|
app(AnikeenId::class)->createChargeForUser('01JUSER', ['idempotency_key' => 'k', 'name' => 'Card', 'quantity' => 1, 'unit_price' => '4.00']);
|
|
$this->fail('Expected an ApiException.');
|
|
} catch (ApiException $e) {
|
|
$this->assertTrue($e->isBillingLimitReached());
|
|
$this->assertTrue($e->isConflict());
|
|
$this->assertSame(409, $e->status);
|
|
$this->assertSame('2.60', $e->body()->remaining);
|
|
$this->assertSame('4.76', $e->body()->required);
|
|
$this->assertSame('2026-12-01T02:00:00Z', $e->body()->resets_at);
|
|
}
|
|
}
|
|
|
|
public function test_other_conflicts_are_not_the_billing_limit(): void
|
|
{
|
|
$this->respond(409, ['message' => 'The charge is more than this app may charge at once.', 'error' => 'charge_exceeds_maximum', 'max_charge_net' => '50.00']);
|
|
|
|
try {
|
|
app(AnikeenId::class)->createChargeForUser('01JUSER', ['idempotency_key' => 'k', 'name' => 'Card', 'quantity' => 1, 'unit_price' => '60.00']);
|
|
$this->fail('Expected an ApiException.');
|
|
} catch (ApiException $e) {
|
|
$this->assertFalse($e->isBillingLimitReached());
|
|
$this->assertSame('charge_exceeds_maximum', $e->error);
|
|
$this->assertSame('50.00', $e->body()->max_charge_net);
|
|
}
|
|
}
|
|
|
|
public function test_an_already_billed_charge_cannot_be_voided(): void
|
|
{
|
|
$this->respond(409, ['message' => 'The charge is on a closed bill; only a refund of that order credits it.', 'error' => 'charge_already_billed', 'charge_id' => '01JCHARGE']);
|
|
|
|
try {
|
|
app(AnikeenId::class)->voidChargeForUser('01JUSER', '01JCHARGE');
|
|
$this->fail('Expected an ApiException.');
|
|
} catch (ApiException $e) {
|
|
$this->assertSame('charge_already_billed', $e->error);
|
|
$this->assertSame('01JCHARGE', $e->body()->charge_id);
|
|
}
|
|
}
|
|
|
|
public function test_an_unknown_charge_is_not_found(): void
|
|
{
|
|
$this->respond(404, ['message' => 'Charge not found.', 'error' => 'charge_not_found']);
|
|
|
|
try {
|
|
app(AnikeenId::class)->findChargeForUser('01JUSER', '01JOTHER');
|
|
$this->fail('Expected an ApiException.');
|
|
} catch (ApiException $e) {
|
|
$this->assertTrue($e->isNotFound());
|
|
$this->assertSame('charge_not_found', $e->error);
|
|
}
|
|
}
|
|
|
|
public function test_the_billing_limit_is_read_with_the_app_token(): void
|
|
{
|
|
$this->respond(200, $this->limit());
|
|
|
|
$limit = app(AnikeenId::class)->billingLimitForUser('01JUSER');
|
|
|
|
$this->assertSame('GET /api/v1/client/users/01JUSER/billing-limit Bearer app-token', $this->sentCall(0));
|
|
$this->assertInstanceOf(BillingLimit::class, $limit);
|
|
$this->assertTrue($limit->hasLimit());
|
|
$this->assertSame('100.00', $limit->limit());
|
|
$this->assertSame('82.40', $limit->used());
|
|
$this->assertSame('60.00', $limit->usedByApp());
|
|
$this->assertSame('17.60', $limit->remaining());
|
|
$this->assertSame('14.78', $limit->remainingNet());
|
|
$this->assertSame(19.0, $limit->vatRate());
|
|
$this->assertSame('EUR', $limit->currency());
|
|
$this->assertFalse($limit->isReached());
|
|
$this->assertEquals(new DateTimeImmutable('2026-12-01T02:00:00Z'), $limit->resetsAt());
|
|
$this->assertSame(7, $limit->version());
|
|
$this->assertSame('gross', $limit->basis);
|
|
}
|
|
|
|
public function test_without_a_limit_the_app_sees_only_its_own_share(): void
|
|
{
|
|
$this->respond(200, $this->limit(['limit' => null, 'used' => null, 'remaining' => null, 'remaining_net' => null, 'vat_rate' => null]));
|
|
|
|
$limit = app(AnikeenId::class)->billingLimitForUser('01JUSER');
|
|
|
|
$this->assertFalse($limit->hasLimit());
|
|
$this->assertNull($limit->limit());
|
|
$this->assertNull($limit->used());
|
|
$this->assertNull($limit->remaining());
|
|
$this->assertNull($limit->remainingNet());
|
|
$this->assertNull($limit->vatRate());
|
|
$this->assertSame('60.00', $limit->usedByApp());
|
|
$this->assertTrue($limit->allows('100000.00'));
|
|
}
|
|
|
|
public function test_allows_compares_with_the_remaining_amount(): void
|
|
{
|
|
$limit = new BillingLimit((object) $this->limit()['data']);
|
|
|
|
$this->assertTrue($limit->allows('17.60'));
|
|
$this->assertTrue($limit->allows('17.6'));
|
|
$this->assertTrue($limit->allows('4.76'));
|
|
$this->assertTrue($limit->allows(17.6));
|
|
$this->assertTrue($limit->allows(17));
|
|
$this->assertFalse($limit->allows('17.61'));
|
|
$this->assertFalse($limit->allows('17.600001'));
|
|
$this->assertFalse($limit->allows('100.00'));
|
|
$this->assertFalse($limit->allows(1000));
|
|
}
|
|
|
|
public function test_a_reached_or_zero_limit_allows_nothing(): void
|
|
{
|
|
$reached = new BillingLimit((object) $this->limit(['used' => '100.00', 'remaining' => '0.00', 'remaining_net' => '0.00', 'reached' => true])['data']);
|
|
$zero = new BillingLimit((object) $this->limit(['limit' => '0.00', 'used' => '0.00', 'remaining' => '0.00', 'reached' => true])['data']);
|
|
|
|
$this->assertTrue($reached->isReached());
|
|
$this->assertFalse($reached->allows('0.01'));
|
|
$this->assertTrue($zero->hasLimit());
|
|
$this->assertFalse($zero->allows('0.01'));
|
|
}
|
|
|
|
public function test_the_user_s_own_limit_has_no_app_share(): void
|
|
{
|
|
$limit = new BillingLimit((object) ['object' => 'billing_limit', 'user_id' => '01JUSER', 'currency' => 'EUR', 'basis' => 'gross', 'limit' => null, 'used' => '12.00', 'remaining' => null, 'remaining_net' => null, 'vat_rate' => 19.0, 'reached' => false, 'resets_at' => '2026-12-01T02:00:00Z', 'version' => 0]);
|
|
|
|
$this->assertSame('12.00', $limit->used());
|
|
$this->assertNull($limit->usedByApp());
|
|
$this->assertSame(0, $limit->version());
|
|
}
|
|
|
|
public function test_a_refused_limit_read_is_an_api_exception(): void
|
|
{
|
|
$this->respond(403, ['message' => 'This app may not read customers\' billing limits.', 'error' => 'billing_limit_not_enabled']);
|
|
|
|
try {
|
|
app(AnikeenId::class)->billingLimitForUser('01JUSER');
|
|
$this->fail('Expected an ApiException.');
|
|
} catch (ApiException $e) {
|
|
$this->assertSame(403, $e->status);
|
|
$this->assertSame('billing_limit_not_enabled', $e->error);
|
|
$this->assertFalse($e->isBillingLimitReached());
|
|
}
|
|
}
|
|
}
|