Compare commits

..
Author SHA1 Message Date
Maurice PreußandClaude Opus 5.5 5349ee81f6 Ship orders: shipments with delivery notes
$order->shipments()->create([...]) records a delivery of a paid order's goods on Anikeen ID,
which adds its delivery note to the user's order documents and sends order.shipped.
$shipment->deliveryNote() downloads the PDF.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 14:01:45 +01:00
envoyr 60b4b80f17 Merge pull request #8 from anikeen-com/docs/subscription-description
Document changing the description of running app subscriptions
2026-11-01 13:52:30 +01:00
Maurice PreußandClaude Opus 5.5 1f645b50bc Document changing the description of running app subscriptions
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 13:29:30 +01:00
6 changed files with 222 additions and 1 deletions

No files matched your search

+20
View File
@@ -196,6 +196,26 @@ $order = $user->orders()->create([
]);
```
Once a paid order is sent, create a shipment. Anikeen ID adds its delivery note to the user's
order documents (with the tracking link) and sends `order.shipped` to your webhook endpoints.
Without `items`, everything physical that is left is shipped; partial deliveries name the items:
```php
$order = $user->orders()->find($orderId);
$shipment = $order->shipments()->create([
'carrier' => 'DHL',
'tracking_number' => '00340434161234567890',
'tracking_url' => 'https://www.dhl.de/…?piececode=00340434161234567890',
// 'items' => [['order_item_id' => $itemId, 'units' => 1]],
]);
$shipment->number; // LS-2026-0001
$shipment->deliveryNote(); // the delivery note as PDF
```
Only the app that created the order (or a trusted app) can ship it, and only once it is paid.
`checkout()` on such a draft throws `ConfirmationRequiredException`; its `confirmationUrl`
points to the same page. Trusted apps keep checking out directly.
+3 -1
View File
@@ -47,7 +47,9 @@ class AppSubscription implements JsonSerializable
}
/**
* Change a subscription that wasn't checked out yet.
* 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
*/
+13
View File
@@ -101,6 +101,19 @@ class Order extends BaseResource
->request('DELETE', sprintf('v1/orders/%s', $this->id))->success();
}
/**
* Deliveries of this order's goods; `create()` ships them (see Shipments::create()).
*
* @throws Throwable
*/
public function shipments(): Shipments
{
return Shipments::builder(fn() => $this->billable->anikeenId()
->request('GET', sprintf('v1/orders/%s/shipments', $this->id)))
->setBillable($this->billable)
->setParent($this);
}
/**
* Get order items from given order.
*
+38
View File
@@ -0,0 +1,38 @@
<?php
namespace Anikeen\Id\Resources;
use Anikeen\Id\Concerns\HasBillable;
use Anikeen\Id\Concerns\HasParent;
use Throwable;
/**
* @property string $id
* @property string $number delivery note number, e.g. LS-2026-0001
* @property string $order_id
* @property null|string $carrier
* @property null|string $tracking_number
* @property null|string $tracking_url
* @property array $items order_item_id, name, description, units
* @property object $shipping_address
* @property null|string $note
* @property string $shipped_at
*/
class Shipment extends BaseResource
{
use HasBillable;
use HasParent;
/**
* The delivery note as PDF (binary string).
*
* @throws Throwable
*/
public function deliveryNote(): string
{
$result = $this->billable->anikeenId()
->request('GET', sprintf('v1/orders/%s/shipments/%s/delivery-note', $this->order_id, $this->id));
return (string) $result->response?->getBody();
}
}
+60
View File
@@ -0,0 +1,60 @@
<?php
namespace Anikeen\Id\Resources;
use Anikeen\Id\Concerns\HasBillable;
use Anikeen\Id\Concerns\HasParent;
use Throwable;
/**
* Deliveries of an order's physical goods. Each one adds a delivery note to the customer's
* order documents on Anikeen ID.
*/
class Shipments extends BaseCollection
{
use HasBillable;
use HasParent;
/**
* Ship goods of a paid order. Only the app that created the order (or a trusted app) can.
*
* Without `items`, everything physical that hasn't been shipped yet is shipped. Items and
* the shipping address are stored as they are now; the user sees them on the delivery note.
*
* @param array{
* carrier?: string,
* tracking_number?: string,
* tracking_url?: string,
* note?: string,
* shipped_at?: string,
* items?: array<array{order_item_id: string, units?: float|int|string}>
* } $attributes
* - carrier: e.g. "DHL"
* - tracking_url: http(s) link to track the parcel, shown to the user
* - shipped_at: when it was sent (default: now, not in the future)
* - items: physical items of the order and how many of them (default: all left)
*
* Errors (ResourceException with the HTTP status): 409 when the order isn't paid or nothing
* is left to ship, 422 for items that aren't physical items of the order or exceed what's left.
*
* @throws Throwable
*/
public function create(array $attributes = []): Shipment
{
return (new Shipment(fn() => $this->billable->anikeenId()
->request('POST', sprintf('v1/orders/%s/shipments', $this->parent->id), $attributes)))
->setBillable($this->billable)
->setParent($this->parent);
}
/**
* {@inheritDoc}
*/
public function find(string $id): ?Shipment
{
return (new Shipment(fn() => $this->billable->anikeenId()
->request('GET', sprintf('v1/orders/%s/shipments/%s', $this->parent->id, $id))))
->setBillable($this->billable)
->setParent($this->parent);
}
}
+88
View File
@@ -0,0 +1,88 @@
<?php
namespace Anikeen\Id\Tests;
use Anikeen\Id\Billable;
use Anikeen\Id\Exceptions\ResourceException;
use GuzzleHttp\Psr7\Response;
use Illuminate\Database\Eloquent\Model;
class ShipmentsTest extends TestCase
{
private function owner(): Model
{
$owner = new class extends Model {
use Billable;
protected $guarded = [];
};
$owner->anikeen_id = '01JUSER';
$owner->anikeen_id_access_token = 'user-token';
return $owner;
}
private function order(): \Anikeen\Id\Resources\Order
{
$this->respond(200, ['data' => [], 'meta' => ['current_page' => 1, 'last_page' => 1, 'total' => 0], 'links' => ['next' => null]]);
$this->respond(200, ['data' => ['id' => 'ord1', 'status' => 'completed']]);
return $this->owner()->orders()->find('ord1');
}
private function sentCall(int $index): string
{
$request = $this->sent[$index]['request'];
return $request->getMethod() . ' ' . $request->getUri()->getPath() . ' ' . $request->getHeaderLine('Authorization');
}
public function test_an_order_is_shipped_with_carrier_tracking_and_items(): void
{
$order = $this->order();
$this->respond(200, ['data' => []]);
$this->respond(201, ['data' => ['id' => 'shp1', 'number' => 'LS-2026-0001', 'order_id' => 'ord1', 'carrier' => 'DHL']]);
$shipment = $order->shipments()->create([
'carrier' => 'DHL',
'tracking_number' => '0034',
'items' => [['order_item_id' => 'item1', 'units' => 1]],
]);
$this->assertSame('LS-2026-0001', $shipment->number);
$this->assertSame('GET /api/v1/orders/ord1/shipments Bearer user-token', $this->sentCall(2));
$this->assertSame('POST /api/v1/orders/ord1/shipments Bearer user-token', $this->sentCall(3));
$this->assertSame(['carrier' => 'DHL', 'tracking_number' => '0034', 'items' => [['order_item_id' => 'item1', 'units' => 1]]], $this->sentJson(3));
}
public function test_shipments_are_listed_and_the_delivery_note_is_downloaded(): void
{
$order = $this->order();
$this->respond(200, ['data' => [['id' => 'shp1', 'number' => 'LS-2026-0001']]]);
$this->respond(200, ['data' => ['id' => 'shp1', 'number' => 'LS-2026-0001', 'order_id' => 'ord1']]);
$this->http->append(new Response(200, ['Content-Type' => 'application/pdf'], '%PDF-1.7 test'));
$shipments = $order->shipments();
$this->assertSame('LS-2026-0001', $shipments->toArray()[0]->number);
$pdf = $shipments->find('shp1')->deliveryNote();
$this->assertSame('%PDF-1.7 test', $pdf);
$this->assertSame('GET /api/v1/orders/ord1/shipments/shp1/delivery-note Bearer user-token', $this->sentCall(4));
}
public function test_refusals_keep_their_status(): void
{
$order = $this->order();
$this->respond(200, ['data' => []]);
$this->respond(409, ['message' => 'Only paid orders can be shipped.', 'error' => 'order_not_paid']);
try {
$order->shipments()->create();
$this->fail('expected a ResourceException');
} catch (ResourceException $e) {
$this->assertSame(409, $e->getCode());
$this->assertStringContainsString('Only paid orders can be shipped', $e->getMessage());
}
}
}