Metadata-Version: 2.4
Name: altissimo-shippo-tracking
Version: 0.2.1
Summary: Shippo shipping & tracking client with optional Firestore persistence.
License-Expression: Apache-2.0
License-File: LICENSE
Author: Lukas Karlsson
Author-email: karlsson@altissimo.io
Requires-Python: >=3.11,<4.0
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Developers
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Classifier: Typing :: Typed
Provides-Extra: firestore
Requires-Dist: altissimo-firedantic (>=0.22.4,<0.23) ; extra == "firestore"
Requires-Dist: google-cloud-firestore (>=2.0) ; extra == "firestore"
Requires-Dist: pydantic (>=2.0)
Requires-Dist: requests (>=2.28)
Project-URL: Changelog, https://github.com/altissimo-hq/shippo-tracking/blob/main/CHANGELOG.md
Project-URL: Homepage, https://github.com/altissimo-hq/shippo-tracking
Project-URL: Repository, https://github.com/altissimo-hq/shippo-tracking
Description-Content-Type: text/markdown

# shippo-tracking

Shippo shipping & tracking client with optional Firestore persistence.

## Installation

Published on PyPI as `altissimo-shippo-tracking`; the import name is
`altissimo.shippo_tracking`.

```bash
# Basic (API client + models only)
pip install altissimo-shippo-tracking

# With Firestore persistence
pip install "altissimo-shippo-tracking[firestore]"
```

The `firestore` extra installs [`altissimo-firedantic`](https://pypi.org/project/altissimo-firedantic/),
Altissimo's maintained fork of firedantic. It is still imported as `firedantic`, so don't
install the upstream `firedantic` package alongside it.

## Quick Start

```python
from altissimo.shippo_tracking import ShippoClient

client = ShippoClient()  # reads SHIPPO_API_KEY from env
status = client.get_tracking_status("usps", "9400111899223456789012")
print(status.tracking_status.status)  # "TRANSIT", "DELIVERED", etc.
```

## With Firestore Persistence

```python
from altissimo.shippo_tracking import ShippoService

service = ShippoService()

# Fetch from API, save to Firestore, and register for webhooks if this
# tracking number hasn't been seen before
detail = service.save_tracking_detail("usps", "9400111899223456789012", register_if_new=True)

# List records, optionally filtered by status (case-insensitive)
in_flight = service.list_tracking_details(exclude_status="DELIVERED")
delivered = service.list_tracking_details(status="DELIVERED")
```

The Shippo GET tracking endpoint does **not** subscribe a shipment to
`track_updated` webhooks — only `register_tracking()` does. Pass
`register_if_new=True` so new tracking numbers are registered automatically;
without it, saving a new tracking number logs a warning.

## Delivery Callback Hook

Inject your own logic for when a package is delivered:

```python
from altissimo.shippo_tracking import ShippoService, ShippoTrackingDetail

def handle_delivery(detail: ShippoTrackingDetail):
    print(f"Package {detail.tracking_number} delivered!")
    # Send email, update order, notify team, etc.

service = ShippoService(on_delivery=handle_delivery)
```

## Status Downgrade Protection

The service guards against **status regression**. Tracking statuses have a natural
lifecycle ordering:

```text
UNKNOWN → PRE_TRANSIT → TRANSIT → DELIVERED → RETURNED → FAILURE
```

If a persisted record already has a status further along in this progression (e.g.
`DELIVERED`), and the Shippo API or a webhook returns a lower status (e.g.
`PRE_TRANSIT` — common when USPS purges old tracking data), the update is
**silently skipped** and the existing record is preserved.

This applies to both `save_tracking_detail()` and incoming webhook events.

## FastAPI Webhook Router

```python
import os

from fastapi import FastAPI
from altissimo.shippo_tracking.router import create_shippo_router

app = FastAPI()

# Simple — no delivery callback
app.include_router(create_shippo_router())

# With delivery callback
app.include_router(create_shippo_router(on_delivery=handle_delivery))

# With a self-generated URL token (401 on missing/wrong ?token=)
app.include_router(create_shippo_router(webhook_token=os.environ["SHIPPO_WEBHOOK_TOKEN"]))

# With HMAC signature verification (401 on missing/invalid signature)
app.include_router(create_shippo_router(webhook_secret=os.environ["SHIPPO_WEBHOOK_SECRET"]))
```

### Webhook authentication

[Shippo offers three ways](https://docs.goshippo.com/tracking/webhook-security)
to secure webhooks; this router supports the two
that are checked per request, and they can be combined.

- **Self-generated token** (`webhook_token`): in the Shippo dashboard
  (Settings → API → Webhooks), append `?token=<your token>` to the webhook URL.
  Shippo echoes it back on every POST and the router compares it in constant time.
- **HMAC** (`webhook_secret`): requires Shippo's solutions team to provision a
  secret (up to ~10 business days).
- **IP allowlist**: enforce at your load balancer/firewall; not handled here.

When `webhook_secret` is set, requests must carry a
`Shippo-Auth-Signature: t=<timestamp>,v1=<hex HMAC-SHA256(secret, "<timestamp>.<raw body>")>`
header. `signature_tolerance_seconds` optionally rejects stale timestamps.
To send signed test payloads, use
`altissimo.shippo_tracking.webhook.compute_signature_header(secret, body)`.

## Testing

```bash
poetry install --with dev
poetry run pytest -v
```

All unit tests use in-memory fakes — no Firestore or network access required.

Integration tests in `tests/test_repo_integration.py` exercise `ShippoRepo`
against the Firestore emulator and are skipped unless `FIRESTORE_EMULATOR_HOST`
is set. To run them (requires the Firebase CLI and Java):

```bash
poetry install --with dev --all-extras
firebase emulators:exec --only firestore --project demo-shippo-tracking "poetry run pytest -m integration -v"
```

