diff --git a/app/Http/Controllers/DashboardController.php b/app/Http/Controllers/DashboardController.php index d67ab55..23365a3 100644 --- a/app/Http/Controllers/DashboardController.php +++ b/app/Http/Controllers/DashboardController.php @@ -47,11 +47,11 @@ class DashboardController extends Controller if ($isAdmin && filled($shippingConfig['client_id'] ?? null) && filled($shippingConfig['client_secret'] ?? null) && filled($shippingConfig['auth_url'] ?? null)) { try { $parcel2Go->verifyCredentials(); - if ($parcel2Go->hasScope('my-profile')) { + try { $shipments = array_slice($parcel2Go->fetch('me/orders/detail')['Orders'] ?? [], 0, 4); $shippingStatus = 'Live account'; - } else { - $shippingStatus = 'Quotes available'; + } catch (Throwable $exception) { + $shippingStatus = 'History access needed'; } } catch (Throwable $exception) { report($exception); diff --git a/app/Http/Controllers/EtsySettingsController.php b/app/Http/Controllers/EtsySettingsController.php index 173ce8d..96655d8 100644 --- a/app/Http/Controllers/EtsySettingsController.php +++ b/app/Http/Controllers/EtsySettingsController.php @@ -36,6 +36,7 @@ class EtsySettingsController extends Controller 'auth_url' => $parcel2go['auth_url'] ?? '', 'base_url' => $parcel2go['base_url'] ?? '', 'scope' => $parcel2go['scope'] ?? 'public-api', + 'collection_postcode' => $parcel2go['collection_postcode'] ?? '', ], 'bambuddy' => [ 'stored' => in_array('bambuddy', $storedIntegrations, true), diff --git a/app/Http/Controllers/IntegrationSettingsController.php b/app/Http/Controllers/IntegrationSettingsController.php index 788b053..063788e 100644 --- a/app/Http/Controllers/IntegrationSettingsController.php +++ b/app/Http/Controllers/IntegrationSettingsController.php @@ -21,6 +21,7 @@ class IntegrationSettingsController extends Controller 'auth_url' => ['nullable', 'url:https', 'max:2048'], 'base_url' => ['nullable', 'url:https', 'max:2048'], 'scope' => ['nullable', 'string', 'in:public-api,public-api my-profile,public-api payment,public-api payment my-profile'], + 'collection_postcode' => ['nullable', 'string', 'max:12'], ], default => [ 'url' => ['nullable', 'url:https', 'max:2048'], diff --git a/app/Http/Controllers/ShippingController.php b/app/Http/Controllers/ShippingController.php index 84a3b3e..c470f08 100644 --- a/app/Http/Controllers/ShippingController.php +++ b/app/Http/Controllers/ShippingController.php @@ -2,7 +2,11 @@ namespace App\Http\Controllers; +use App\Models\EtsyConnection; +use App\Services\Etsy; +use App\Services\IntegrationSettings; use App\Services\Parcel2Go; +use Illuminate\Http\Client\RequestException; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\Http\Response; @@ -11,82 +15,105 @@ use Throwable; class ShippingController extends Controller { - public function index(Request $request, Parcel2Go $parcel2Go): View + public function index(Request $request, Parcel2Go $parcel2Go, Etsy $etsy, IntegrationSettings $settings): View { abort_unless($request->user()->role === 'admin', 403); + $pendingOrders = []; + $etsyNotice = null; + try { + $pendingOrders = $this->pendingOrders($etsy); + if ($pendingOrders === [] && (! $etsy->configured() || ! EtsyConnection::query()->exists())) { + $etsyNotice = 'Connect your Etsy shop to see pending orders.'; + } + } catch (Throwable $exception) { + report($exception); + $etsyNotice = 'Etsy pending orders are temporarily unavailable.'; + } + + $account = []; + $balance = []; + $orders = []; + $profileNotice = null; + $integrationError = null; + $historyAvailable = false; try { $parcel2Go->verifyCredentials(); - $account = []; - $balance = []; - $orders = []; - $profileAvailable = $parcel2Go->hasScope('my-profile'); - $profileNotice = null; - - if ($profileAvailable) { + try { + $orders = $parcel2Go->fetch('me/orders/detail'); + $historyAvailable = true; + } catch (Throwable $exception) { + $profileNotice = $exception instanceof RequestException && in_array($exception->response->status(), [401, 403], true) + ? 'Parcel2Go denied shipment history for this client. Request my-profile access from Parcel2Go API support.' + : 'Parcel2Go shipment history is temporarily unavailable.'; + } + if ($parcel2Go->hasScope('my-profile')) { try { $account = $parcel2Go->fetch('me/detail'); $balance = $parcel2Go->fetch('me/prepay/balance'); - $orders = $parcel2Go->fetch('me/orders/detail'); } catch (Throwable $exception) { report($exception); - $profileNotice = 'Parcel2Go connected, but account history is unavailable. Check that this client has my-profile access.'; } - } else { - $profileNotice = $parcel2Go->scopeWasReduced() - ? 'Parcel2Go rejected the selected API access. Basic shipping access works, but account history and labels need my-profile permission for this client.' - : 'Parcel2Go connected for quotes and collections. Add my-profile access in Parcel2Go to show account history and labels.'; } - - return view('shipping', compact('account', 'balance', 'orders', 'profileAvailable', 'profileNotice') + [ - 'quotes' => $request->session()->get('shipping_quotes', []), - 'collectionDates' => $request->session()->get('shipping_collection_dates', []), - 'selectedService' => $request->session()->get('shipping_selected_service'), - 'bookingResult' => $request->session()->get('shipping_booking_result'), - 'bookingPending' => $request->session()->get('shipping_booking_pending', false), - ]); } catch (Throwable $exception) { report($exception); - - return view('shipping', [ - 'account' => [], 'balance' => [], 'orders' => [], - 'profileAvailable' => false, - 'profileNotice' => null, - 'quotes' => $request->session()->get('shipping_quotes', []), - 'collectionDates' => $request->session()->get('shipping_collection_dates', []), - 'selectedService' => $request->session()->get('shipping_selected_service'), - 'bookingResult' => $request->session()->get('shipping_booking_result'), - 'bookingPending' => $request->session()->get('shipping_booking_pending', false), - 'integrationError' => 'Parcel2Go is unavailable. Check API credentials and connectivity.', - ]); + $integrationError = 'Parcel2Go is unavailable. Check API credentials and connectivity.'; } + + return view('shipping', [ + 'account' => $account, 'balance' => $balance, 'orders' => $orders, + 'historyAvailable' => $historyAvailable, 'profileNotice' => $profileNotice, + 'integrationError' => $integrationError, 'pendingOrders' => $pendingOrders, + 'etsyNotice' => $etsyNotice, + 'collectionPostcode' => $settings->for('parcel2go')['collection_postcode'] ?? '', + 'selectedReceipt' => collect($pendingOrders)->first(fn (array $receipt): bool => (string) ($receipt['receipt_id'] ?? '') === (string) $request->session()->get('shipping_receipt_id')), + 'quotes' => $request->session()->get('shipping_quotes', []), + 'collectionDates' => $request->session()->get('shipping_collection_dates', []), + 'selectedService' => $request->session()->get('shipping_selected_service'), + 'bookingResult' => $request->session()->get('shipping_booking_result'), + 'bookingPending' => $request->session()->get('shipping_booking_pending', false), + ]); } - public function quote(Request $request, Parcel2Go $parcel2Go): RedirectResponse + public function quote(Request $request, Parcel2Go $parcel2Go, Etsy $etsy, IntegrationSettings $settings): RedirectResponse { abort_unless($request->user()->role === 'admin', 403); if ($request->session()->get('shipping_booking_pending')) { return redirect()->route('shipping')->withErrors(['collection' => 'A booking outcome is uncertain. Check your Parcel2Go account before trying again.']); } $details = $request->validate([ - 'collection_postcode' => ['required', 'string', 'max:12'], - 'delivery_postcode' => ['required', 'string', 'max:12'], + 'receipt_id' => ['required', 'integer', 'min:1'], 'weight' => ['required', 'numeric', 'gt:0', 'lte:30'], 'length' => ['required', 'numeric', 'gt:0', 'lte:200'], 'width' => ['required', 'numeric', 'gt:0', 'lte:200'], 'height' => ['required', 'numeric', 'gt:0', 'lte:200'], - 'value' => ['required', 'numeric', 'gte:0', 'lte:10000'], ]); try { + $receipt = collect($this->pendingOrders($etsy))->first(fn (array $order): bool => (string) ($order['receipt_id'] ?? '') === (string) $details['receipt_id']); + if (! $receipt) { + return redirect()->route('shipping')->withErrors(['quote' => 'This Etsy order is no longer pending. Refresh the order list.']); + } + $collectionPostcode = $settings->for('parcel2go')['collection_postcode'] ?? null; + if (blank($collectionPostcode) || blank($receipt['zip'] ?? null) || ($receipt['country_iso'] ?? 'GB') !== 'GB' || ($receipt['grandtotal']['currency_code'] ?? 'GBP') !== 'GBP') { + return redirect()->route('shipping')->withErrors(['quote' => 'A UK collection postcode and a UK Etsy delivery address priced in GBP are required.']); + } + $total = $receipt['grandtotal'] ?? []; + $value = (float) ($total['amount'] ?? 0) / max(1, (int) ($total['divisor'] ?? 1)); + $details = array_merge($details, [ + 'collection_postcode' => $collectionPostcode, + 'delivery_postcode' => $receipt['zip'], + 'value' => min(10000, max(0, $value)), + ]); $quotes = $parcel2Go->quote($details)['Quotes'] ?? []; $request->session()->put('shipping_quote', $details); + $request->session()->put('shipping_receipt_id', $receipt['receipt_id']); $request->session()->put('shipping_quotes', $quotes); - $request->session()->forget(['shipping_collection_dates', 'shipping_selected_service']); + $request->session()->forget(['shipping_collection_dates', 'shipping_selected_service', 'shipping_sender_location']); return redirect()->route('shipping')->with('status', count($quotes).' services available.'); } catch (Throwable $exception) { report($exception); - return redirect()->route('shipping')->withErrors(['quote' => 'Parcel2Go could not quote this parcel. Check the postcodes and parcel details.']); + return redirect()->route('shipping')->withErrors(['quote' => 'Could not quote this Etsy order. Check its delivery address and parcel details.']); } } @@ -118,7 +145,7 @@ class ShippingController extends Controller } } - public function bookCollection(Request $request, Parcel2Go $parcel2Go): RedirectResponse + public function bookCollection(Request $request, Parcel2Go $parcel2Go, Etsy $etsy): RedirectResponse { abort_unless($request->user()->role === 'admin', 403); if ($request->session()->get('shipping_booking_pending')) { @@ -152,13 +179,24 @@ class ShippingController extends Controller return redirect()->route('shipping')->withErrors(['collection' => 'Choose an available collection date for the quoted address.']); } + try { + $receipt = collect($this->pendingOrders($etsy))->first(fn (array $order): bool => (string) ($order['receipt_id'] ?? '') === (string) $request->session()->get('shipping_receipt_id')); + if (! $receipt || ($receipt['zip'] ?? null) !== $details['delivery_postcode']) { + return redirect()->route('shipping')->withErrors(['collection' => 'The Etsy order or delivery postcode changed. Request a fresh quote.']); + } + } catch (Throwable $exception) { + report($exception); + + return redirect()->route('shipping')->withErrors(['collection' => 'Could not verify the Etsy order before booking. Try again.']); + } + try { $request->session()->put('shipping_booking_pending', true); $result = $parcel2Go->bookCollection($details, $service, $booking); if (empty($result['OrderId'])) { throw new \RuntimeException('Parcel2Go did not return an order reference.'); } - $request->session()->forget(['shipping_quote', 'shipping_quotes', 'shipping_collection_dates', 'shipping_selected_service', 'shipping_sender_location', 'shipping_booking_pending']); + $request->session()->forget(['shipping_quote', 'shipping_receipt_id', 'shipping_quotes', 'shipping_collection_dates', 'shipping_selected_service', 'shipping_sender_location', 'shipping_booking_pending']); return redirect()->route('shipping')->with('shipping_booking_result', $result)->with('status', 'Collection order created. Complete payment with Parcel2Go.'); } catch (Throwable $exception) { @@ -172,7 +210,7 @@ class ShippingController extends Controller { abort_unless($request->user()->role === 'admin', 403); $request->validate(['checked_account' => ['accepted']]); - $request->session()->forget(['shipping_quote', 'shipping_quotes', 'shipping_collection_dates', 'shipping_selected_service', 'shipping_sender_location', 'shipping_booking_pending']); + $request->session()->forget(['shipping_quote', 'shipping_receipt_id', 'shipping_quotes', 'shipping_collection_dates', 'shipping_selected_service', 'shipping_sender_location', 'shipping_booking_pending']); return redirect()->route('shipping')->with('status', 'Booking workflow reset. Start with a fresh quote.'); } @@ -192,4 +230,12 @@ class ShippingController extends Controller 'Cache-Control' => 'private, no-store', ]); } + + /** @return array> */ + private function pendingOrders(Etsy $etsy): array + { + $connection = EtsyConnection::first(); + + return $connection && $etsy->configured() ? ($etsy->receipts($connection)['results'] ?? []) : []; + } } diff --git a/resources/views/settings/index.blade.php b/resources/views/settings/index.blade.php index 0c7bc13..131bb0a 100644 --- a/resources/views/settings/index.blade.php +++ b/resources/views/settings/index.blade.php @@ -55,6 +55,7 @@ + -

Choose account history only if Parcel2Go granted the my-profile scope to this client. An ungranted scope prevents sign in.

+

To request account history, sign in to Parcel2Go My Account → Settings → API and check this client's permissions. If my-profile is unavailable, email apihelp@parcel2go.com with your account email and Client ID. Ask them to enable my-profile for your live client. Never email the Client secret. After confirmation, select “Shipping and account history” here.

diff --git a/resources/views/shipping.blade.php b/resources/views/shipping.blade.php index 9e07de1..15c0742 100644 --- a/resources/views/shipping.blade.php +++ b/resources/views/shipping.blade.php @@ -1,23 +1,41 @@ @extends('layouts.app') @section('title', 'Shipping') @section('content') -

Dispatch

Parcel2Go

Compare services, arrange a collection and download labels.

+

Dispatch

Orders and shipping

Prepare pending Etsy orders and follow Parcel2Go shipments.

@isset($integrationError)@endisset -@if($profileNotice)
{{ $profileNotice }}
@endif +@if($etsyNotice)
{{ $etsyNotice }} Settings
@endif +@if($profileNotice)
{{ $profileNotice }} See the access steps in Settings.
@endif @if($bookingPending)@endif @if($bookingResult) @php $paymentUrl = $bookingResult['Links']['payment'] ?? null; $paymentHost = is_string($paymentUrl) ? parse_url($paymentUrl, PHP_URL_HOST) : null; @endphp

Collection order #{{ $bookingResult['OrderId'] ?? '—' }} created

Total: £{{ number_format((float) ($bookingResult['TotalPrice'] ?? 0), 2) }}. Payment and collection confirmation are completed with Parcel2Go.

@if($paymentHost && ($paymentHost === 'parcel2go.com' || str_ends_with($paymentHost, '.parcel2go.com')))Complete payment ↗@endif
@endif -@if($profileAvailable)

Account

{{ trim(($account['Forename'] ?? '').' '.($account['Surname'] ?? '')) ?: 'Unavailable' }}

{{ $account['Email'] ?? '' }}

Prepay balance

{{ isset($balance['Balance']) ? '£'.number_format((float) $balance['Balance'], 2) : 'Unavailable' }}

@endif -

1. Compare services

Enter a UK parcel. Dimensions are centimetres and weight is kilograms.

@csrf - - - @foreach([['weight','Weight (kg)','0.1'],['length','Length (cm)','1'],['width','Width (cm)','1'],['height','Height (cm)','1'],['value','Parcel value (£)','0.01']] as [$field,$label,$step])@endforeach -
-
-@if(count($quotes))

2. Choose a collection service

@foreach($quotes as $quote)@php $service = $quote['Service'] ?? []; @endphp

{{ $service['Name'] ?? 'Courier service' }}

{{ $service['CourierName'] ?? '' }} · {{ $service['CollectionType'] ?? 'Unknown' }}

{{ isset($quote['TotalPrice']) ? '£'.number_format((float) $quote['TotalPrice'], 2) : 'See details' }}

@if(($service['CollectionType'] ?? null) === 'Collection' && ! empty($service['Slug']))
@csrf
@endif
@endforeach
@endif -@if(count($collectionDates) && $selectedService)

3. Arrange collection

Creating the order does not take payment. Parcel2Go will provide a payment link.

@csrf -
Sender and collection@foreach([['sender_name','Name'],['sender_email','Email'],['sender_phone','Phone'],['sender_property','Property'],['sender_street','Street'],['sender_town','Town']] as [$field,$label])@endforeach
Recipient and delivery@foreach([['recipient_name','Name'],['recipient_email','Email'],['recipient_phone','Phone'],['recipient_property','Property'],['recipient_street','Street'],['recipient_town','Town']] as [$field,$label])@endforeach
@endif -

Recent shipments and labels

@forelse(($orders['Orders'] ?? []) as $order)@empty@endforelse
OrderCourierServiceStatusRecipientLabel
{{ $order['OrderLineId'] ?? '—' }}{{ $order['Courier'] ?? '—' }}{{ $order['Service'] ?? '—' }}{{ ($order['Tracking']['Delivered'] ?? false) ? 'Delivered' : 'In transit' }}{{ $order['DeliveryAddress']['ContactName'] ?? '—' }}@if(! empty($order['Hash']) && ! empty($order['OrderLineId']))Download PDF@elseUnavailable@endif
{{ $profileAvailable ? 'No shipments available.' : 'Account history requires my-profile access from Parcel2Go.' }}
-@endsection +@if($account || $balance)

Account

{{ trim(($account['Forename'] ?? '').' '.($account['Surname'] ?? '')) ?: 'Unavailable' }}

{{ $account['Email'] ?? '' }}

Prepay balance

{{ isset($balance['Balance']) ? '£'.number_format((float) $balance['Balance'], 2) : 'Unavailable' }}

@endif +
+

Pending Etsy orders

Quotes use the Etsy delivery postcode and order value. Enter the packed parcel size and weight.

{{ count($pendingOrders) }} pending
+ @if(blank($collectionPostcode))

Set your collection postcode once in Parcel2Go settings to quote these orders.

@endif +
+ @forelse($pendingOrders as $receipt) + @php $canQuote = filled($collectionPostcode) && filled($receipt['zip'] ?? null) && ($receipt['country_iso'] ?? 'GB') === 'GB' && ($receipt['grandtotal']['currency_code'] ?? 'GBP') === 'GBP'; @endphp +
+

Order #{{ $receipt['receipt_id'] ?? '—' }}

{{ $receipt['name'] ?? 'Buyer' }} · {{ $receipt['city'] ?? 'City unavailable' }} · {{ $receipt['zip'] ?? 'Postcode unavailable' }}

@if(isset($receipt['grandtotal']['amount']))

{{ $receipt['grandtotal']['currency_code'] ?? 'GBP' }} {{ number_format((float) $receipt['grandtotal']['amount'] / max(1, (int) ($receipt['grandtotal']['divisor'] ?? 1)), 2) }}

@endif
+ @if($canQuote) +
@csrf + @foreach([['weight','Weight (kg)','0.1'],['length','Length (cm)','1'],['width','Width (cm)','1'],['height','Height (cm)','1']] as [$field,$label,$step])@endforeach +
+
+ @else

{{ blank($collectionPostcode) ? 'Add a collection postcode in Settings.' : 'A UK delivery postcode and GBP order value are needed from Etsy before this order can be quoted.' }}

@endif +
+ @empty

No paid, unshipped Etsy orders to prepare.

@endforelse +
+
@if(count($quotes) && $selectedReceipt)

Collection services for Etsy order #{{ $selectedReceipt['receipt_id'] }}

@foreach($quotes as $quote)@php $service = $quote['Service'] ?? []; @endphp

{{ $service['Name'] ?? 'Courier service' }}

{{ $service['CourierName'] ?? '' }} · {{ $service['CollectionType'] ?? 'Unknown' }}

{{ isset($quote['TotalPrice']) ? '£'.number_format((float) $quote['TotalPrice'], 2) : 'See details' }}

@if(($service['CollectionType'] ?? null) === 'Collection' && ! empty($service['Slug']))
@csrf
@endif
@endforeach
@endif +@if(count($collectionDates) && $selectedService && $selectedReceipt)

Arrange collection for Etsy order #{{ $selectedReceipt['receipt_id'] }}

Creating the order does not take payment. Parcel2Go will provide a payment link.

@csrf +
Sender and collection@foreach([['sender_name','Name'],['sender_email','Email'],['sender_phone','Phone'],['sender_property','Property'],['sender_street','Street'],['sender_town','Town']] as [$field,$label])@endforeach
Recipient and delivery@foreach([['recipient_name','Name'],['recipient_email','Email'],['recipient_phone','Phone'],['recipient_property','Property'],['recipient_street','Street'],['recipient_town','Town']] as [$field,$label])@endforeach
@endif +
+

Existing shipments

Current Parcel2Go progress and labels from your account history.

+ + @forelse(($orders['Orders'] ?? []) as $order) + + @empty@endforelse +
OrderCourierServiceProgressEstimated deliveryRecipientLabel
{{ $order['OrderLineId'] ?? '—' }}{{ $order['Courier'] ?? '—' }}{{ $order['Service'] ?? '—' }} $order['Tracking']['Delivered'] ?? false, 'bg-sky-500/10 text-sky-300' => ! ($order['Tracking']['Delivered'] ?? false)])>{{ ($order['Tracking']['Delivered'] ?? false) ? 'Delivered' : 'In transit' }}{{ $order['EstimatedDeliveryDate'] ?? '—' }}{{ $order['DeliveryAddress']['ContactName'] ?? '—' }}@if(! empty($order['Hash']) && ! empty($order['OrderLineId']))Download PDF@elseUnavailable@endif
{{ $historyAvailable ? 'No shipments in Parcel2Go account history.' : 'Shipment history is unavailable until Parcel2Go grants access to this client.' }}
+
@endsection diff --git a/tests/Feature/IntegrationSettingsTest.php b/tests/Feature/IntegrationSettingsTest.php index 9c859c2..a894d14 100644 --- a/tests/Feature/IntegrationSettingsTest.php +++ b/tests/Feature/IntegrationSettingsTest.php @@ -108,8 +108,9 @@ class IntegrationSettingsTest extends TestCase $this->assertDatabaseCount('integration_settings', 0); $this->actingAs($admin)->put(route('settings.integrations.update', 'parcel2go'), [ - 'scope' => 'public-api my-profile', + 'scope' => 'public-api my-profile', 'collection_postcode' => 'SW1A 1AA', ])->assertRedirect(route('settings.index')); $this->assertSame('public-api my-profile', IntegrationSetting::findOrFail('parcel2go')->settings['scope']); + $this->assertSame('SW1A 1AA', IntegrationSetting::findOrFail('parcel2go')->settings['collection_postcode']); } } diff --git a/tests/Feature/IntegrationsTest.php b/tests/Feature/IntegrationsTest.php index 9e1c78b..1bb4fd7 100644 --- a/tests/Feature/IntegrationsTest.php +++ b/tests/Feature/IntegrationsTest.php @@ -3,6 +3,7 @@ namespace Tests\Feature; use App\Models\EtsyConnection; +use App\Models\IntegrationSetting; use App\Models\User; use Illuminate\Foundation\Testing\LazilyRefreshDatabase; use Illuminate\Support\Facades\Http; @@ -65,6 +66,7 @@ class IntegrationsTest extends TestCase public function test_shipping_quote_sends_parcel_to_parcel2go(): void { + $this->pendingEtsyOrder(); Http::preventStrayRequests(); Http::fake([ 'https://auth.example.test/token' => Http::response(['access_token' => 'test-token']), @@ -75,11 +77,11 @@ class IntegrationsTest extends TestCase ]); config()->set('services.parcel2go', ['client_id' => 'id', 'client_secret' => 'secret', 'auth_url' => 'https://auth.example.test/token', 'base_url' => 'https://shipping.example.test/api']); $admin = User::factory()->create(['role' => 'admin']); - $details = ['collection_postcode' => 'SW1A 1AA', 'delivery_postcode' => 'M1 1AE', 'weight' => 2, 'length' => 10, 'width' => 10, 'height' => 10, 'value' => 30]; + $details = ['receipt_id' => 77, 'delivery_postcode' => 'SW1A 1AA', 'weight' => 2, 'length' => 10, 'width' => 10, 'height' => 10]; $this->actingAs($admin)->post(route('shipping.quote'), $details)->assertRedirect(route('shipping')); - $this->actingAs($admin)->get(route('shipping'))->assertOk()->assertSee('Evri')->assertSee('£5.50'); - Http::assertSent(fn ($request) => $request->url() === 'https://shipping.example.test/api/quotes' && $request['Parcels'][0]['Weight'] === 2); + $this->actingAs($admin)->get(route('shipping'))->assertOk()->assertSee('Evri')->assertSee('£5.50')->assertDontSee('name="delivery_postcode"', false); + Http::assertSent(fn ($request) => $request->url() === 'https://shipping.example.test/api/quotes' && $request['Parcels'][0]['Weight'] === 2 && $request['DeliveryAddress']['Postcode'] === 'M1 1AE' && $request['CollectionAddress']['Postcode'] === 'SW1A 1AA'); } public function test_shipping_page_works_with_public_api_scope_without_profile_access(): void @@ -89,7 +91,7 @@ class IntegrationsTest extends TestCase return $request['scope'] === 'public-api' ? Http::response(['access_token' => 'test-token']) : Http::response(['error' => 'invalid_scope'], 400); - }]); + }, 'https://shipping.example.test/api/me/orders/detail' => Http::response([], 403)]); config()->set('services.parcel2go', [ 'client_id' => 'id', 'client_secret' => 'secret', 'auth_url' => 'https://auth.example.test/token', @@ -98,8 +100,45 @@ class IntegrationsTest extends TestCase ]); $this->actingAs(User::factory()->create(['role' => 'admin']))->get(route('shipping')) - ->assertOk()->assertSee('Parcel2Go connected for quotes and collections') + ->assertOk()->assertSee('Pending Etsy orders')->assertSee('Parcel2Go denied shipment history') ->assertDontSee('Parcel2Go is unavailable'); + Http::assertSentCount(2); + } + + public function test_shipping_shows_existing_shipments_when_basic_access_can_read_history(): void + { + Http::preventStrayRequests(); + Http::fake([ + 'https://auth.example.test/token' => Http::response(['access_token' => 'test-token']), + 'https://shipping.example.test/api/me/orders/detail' => Http::response(['Orders' => [[ + 'OrderLineId' => 123, 'Courier' => 'Evri', 'Service' => 'Standard', + 'Tracking' => ['Delivered' => true], 'DeliveryAddress' => ['ContactName' => 'Buyer'], + 'EstimatedDeliveryDate' => '2026-10-15', 'Hash' => 'hash', + ]]]), + ]); + config()->set('services.parcel2go', [ + 'client_id' => 'id', 'client_secret' => 'secret', + 'auth_url' => 'https://auth.example.test/token', + 'base_url' => 'https://shipping.example.test/api', + 'scope' => 'public-api', + ]); + + $this->actingAs(User::factory()->create(['role' => 'admin']))->get(route('shipping')) + ->assertOk()->assertSee('Existing shipments')->assertSee('Delivered')->assertSee('Buyer') + ->assertDontSee('Parcel2Go denied shipment history'); + Http::assertSentCount(2); + } + + public function test_shipping_rejects_a_quote_for_an_order_that_is_not_pending(): void + { + $this->pendingEtsyOrder(); + Http::preventStrayRequests(); + $admin = User::factory()->create(['role' => 'admin']); + + $this->actingAs($admin)->post(route('shipping.quote'), [ + 'receipt_id' => 999, 'weight' => 2, 'length' => 10, 'width' => 10, 'height' => 10, + 'delivery_postcode' => 'SW1A 1AA', + ])->assertRedirect(route('shipping'))->assertSessionHasErrors('quote'); Http::assertSentCount(1); } @@ -110,7 +149,7 @@ class IntegrationsTest extends TestCase return $request['scope'] === 'public-api' ? Http::response(['access_token' => 'test-token']) : Http::response(['error' => 'invalid_scope'], 400); - }]); + }, 'https://shipping.example.test/api/me/orders/detail' => Http::response([], 403)]); config()->set('services.parcel2go', [ 'client_id' => 'id', 'client_secret' => 'secret', 'auth_url' => 'https://auth.example.test/token', @@ -119,10 +158,9 @@ class IntegrationsTest extends TestCase ]); $this->actingAs(User::factory()->create(['role' => 'admin']))->get(route('shipping')) - ->assertOk()->assertSee('Parcel2Go rejected the selected API access') - ->assertDontSee('Parcel2Go connected, but account history is unavailable') + ->assertOk()->assertSee('Parcel2Go denied shipment history') ->assertDontSee('Parcel2Go is unavailable'); - Http::assertSentCount(2); + Http::assertSentCount(3); } public function test_shipping_label_is_downloaded_for_an_order_in_account_history(): void @@ -142,6 +180,7 @@ class IntegrationsTest extends TestCase public function test_collection_booking_uses_quoted_service_and_available_date(): void { + $this->pendingEtsyOrder(); Http::preventStrayRequests(); Http::fake([ 'https://auth.example.test/token' => Http::response(['access_token' => 'test-token']), @@ -153,6 +192,7 @@ class IntegrationsTest extends TestCase config()->set('services.parcel2go', ['client_id' => 'id', 'client_secret' => 'secret', 'auth_url' => 'https://auth.example.test/token', 'base_url' => 'https://shipping.example.test/api']); $session = [ 'shipping_quote' => ['collection_postcode' => 'SW1A 1AA', 'delivery_postcode' => 'M1 1AE', 'weight' => 2, 'length' => 10, 'width' => 10, 'height' => 10, 'value' => 30], + 'shipping_receipt_id' => 77, 'shipping_selected_service' => 'evri-collection', 'shipping_quotes' => [['Service' => ['Slug' => 'evri-collection', 'CollectionType' => 'Collection']]], 'shipping_collection_dates' => [['CollectionDate' => '2026-10-15T10:00:00Z']], @@ -249,4 +289,24 @@ class IntegrationsTest extends TestCase ])->assertUnprocessable()->assertJsonValidationErrors('targetTemperature'); Http::assertNothingSent(); } + + private function pendingEtsyOrder(): void + { + config()->set('services.etsy', ['keystring' => 'key', 'shared_secret' => 'secret']); + EtsyConnection::create([ + 'shop_id' => 42, 'shop_name' => 'HickLab3D', + 'access_token' => '123.token', 'refresh_token' => '123.refresh', + 'expires_at' => now()->addHour(), + ]); + IntegrationSetting::create([ + 'provider' => 'parcel2go', + 'settings' => ['collection_postcode' => 'SW1A 1AA'], + ]); + Http::fake([ + 'https://api.etsy.com/v3/application/shops/42/receipts*' => Http::response(['results' => [[ + 'receipt_id' => 77, 'name' => 'Buyer', 'zip' => 'M1 1AE', 'city' => 'Manchester', 'country_iso' => 'GB', + 'grandtotal' => ['amount' => 3000, 'divisor' => 100, 'currency_code' => 'GBP'], + ]]]), + ]); + } }