From 5349ee81f60d28bdad4bd5c35d166d4426f1d80b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maurice=20Preu=C3=9F?= Date: Sun, 1 Nov 2026 14:01:45 +0100 Subject: [PATCH] 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 --- README.md | 20 ++++++++ src/Id/Resources/Order.php | 13 +++++ src/Id/Resources/Shipment.php | 38 +++++++++++++++ src/Id/Resources/Shipments.php | 60 +++++++++++++++++++++++ tests/Id/ShipmentsTest.php | 88 ++++++++++++++++++++++++++++++++++ 5 files changed, 219 insertions(+) create mode 100644 src/Id/Resources/Shipment.php create mode 100644 src/Id/Resources/Shipments.php create mode 100644 tests/Id/ShipmentsTest.php diff --git a/README.md b/README.md index 7658cae..ef8e7b6 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/src/Id/Resources/Order.php b/src/Id/Resources/Order.php index 53760d2..8ecd5f3 100644 --- a/src/Id/Resources/Order.php +++ b/src/Id/Resources/Order.php @@ -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. * diff --git a/src/Id/Resources/Shipment.php b/src/Id/Resources/Shipment.php new file mode 100644 index 0000000..b822043 --- /dev/null +++ b/src/Id/Resources/Shipment.php @@ -0,0 +1,38 @@ +billable->anikeenId() + ->request('GET', sprintf('v1/orders/%s/shipments/%s/delivery-note', $this->order_id, $this->id)); + + return (string) $result->response?->getBody(); + } +} diff --git a/src/Id/Resources/Shipments.php b/src/Id/Resources/Shipments.php new file mode 100644 index 0000000..492725d --- /dev/null +++ b/src/Id/Resources/Shipments.php @@ -0,0 +1,60 @@ + + * } $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); + } +} diff --git a/tests/Id/ShipmentsTest.php b/tests/Id/ShipmentsTest.php new file mode 100644 index 0000000..193d79a --- /dev/null +++ b/tests/Id/ShipmentsTest.php @@ -0,0 +1,88 @@ +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()); + } + } +}