Metadata-Version: 2.5
Name: fleet-costs-sdk
Version: 0.4.0
Summary: Official Python SDK for the Fleet Costs landed-cost / duty / VAT / freight pricing API.
Project-URL: Homepage, https://getfleet.dev
Project-URL: Documentation, https://docs.getfleet.dev/guides/python-sdk
Project-URL: API reference, https://docs.getfleet.dev
Project-URL: Support, https://getfleet.dev/contact
Author-email: Fleet <support@getfleet.dev>
Maintainer-email: Fleet <support@getfleet.dev>
License-Expression: MIT
License-File: LICENSE
Keywords: cross-border,customs,duty,fleet,hs-code,hts,import,landed-cost,sdk,vat
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Office/Business :: Financial
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Classifier: Typing :: Typed
Requires-Python: >=3.10
Requires-Dist: httpx>=0.25
Provides-Extra: dev
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
Requires-Dist: pytest>=8; extra == 'dev'
Requires-Dist: respx>=0.21; extra == 'dev'
Description-Content-Type: text/markdown

# fleet-costs-sdk

Official Python SDK for the [Fleet Costs](https://getfleet.dev) landed-cost /
duty / VAT / freight pricing API.

```bash
pip install fleet-costs-sdk
```

```python
from fleet import FleetClient

client = FleetClient(
    base_url="https://api.getfleet.dev",
    api_key="ck_live_...",
)

quote = client.create_quote({
    "origin": "US",
    "dest": "DE",
    "itemValue": {"amount": 100, "currency": "USD"},
    "dimsCm": {"l": 20, "w": 15, "h": 5},
    "weightKg": 0.5,
    "categoryKey": "electronics.smartphone",
    "mode": "air",
    "hs6": "851712",  # optional: supply a 6-, 8- or 10-digit commodity code
})

print(quote["total"])
```

## Features

- Synchronous client built on [httpx](https://www.python-httpx.org/).
- Automatic retries on transient failures (429, 500, 502, 503, 504) with
  exponential backoff.
- Idempotency key support on mutating endpoints — safe to retry writes.
- Context manager support (`with FleetClient(...) as client: ...`).
- Typed method surface covering quotes, classify, manifests, and lookups.

## Documentation

- API reference: https://docs.getfleet.dev
- Python SDK guide: https://docs.getfleet.dev/guides/python-sdk
- Support: https://getfleet.dev/contact or support@getfleet.dev

## License

MIT — see the `LICENSE` file included in this package.

## Collection assessment

Quote methods preserve the API's `assessment` dictionary. Before collecting import charges,
require `quote["assessment"]["collection"]["eligible"] is True`. Its `outcome` is
`complete`, `estimated`, or `refused`; display the `reasons` and their `message` and `action`
alongside any estimate. A maximum or confidence score is not collection permission.
Category-only quotes remain estimates. Missing assessment on an older saved response requires
a fresh quote with a new idempotency key before collection.

## Retry and recovery keys

Mutating methods return a `FleetResponse`: the usual response dictionary, plus an
`idempotency_key` attribute containing the key actually sent. The attribute is SDK
metadata and is not added to the API JSON fields.

```python
import httpx
from fleet import FleetAPIError

try:
    quote = client.create_quote(body)
    recovery_key = quote.idempotency_key
except FleetAPIError as error:
    recovery_key = error.idempotency_key
    server_code = error.code
except httpx.RequestError as error:
    recovery_key = error.idempotency_key
except ValueError as error:
    recovery_key = error.idempotency_key
```

For a timed-out quote, use `client.get_quote_by_key(recovery_key)` to retrieve a
result that may have completed server-side. A missing result is not evidence that
it is safe to create the same order under another key.

The SDK keeps the same key on network errors, ordinary transient HTTP failures,
and `409 IDEMPOTENCY_KEY_PROCESSING`. It generates a replacement only after
`409 IDEMPOTENCY_KEY_FAILED` explicitly confirms rollback, and only when the SDK
created the key. A caller's `idempotency_key` is never replaced. Unrelated conflicts
are not retried. Retries share the configured `max_retries` budget; HTTP and
transport failures expose the final attempted key even when that budget is zero.
Invalid JSON and HTTPX response-decoding failures also retain the key on the
original exception; non-transport decoding failures are not retried.

`create_quote` and `create_quote_batch` require decoded JSON success responses to
be objects. JSON `null`, arrays and scalars raise `ValueError` with the final
`idempotency_key`, without retrying the write. This checks only the top-level
shape: empty objects and nested nullable fields are retained unchanged. Invalid
JSON still raises its original parsing exception with the final recovery key.

## Complete consignments and batch quotes

Use `client.create_quote_batch(items)` for 1–50 quote inputs. All items are sent in
one request, in their original order, including non-adjacent members of a declared
consignment. Supply the complete membership and the canonical declaration facts;
the SDK does not invent grouping, split oversized batches, or change delivery and
freight inputs. An explicit zero `deliveryCharge` is different from an omitted value.

`insurance` is the premium actually paid and not already in `itemValue`. It is handled as
freight is: part of `components.CIF` and `total` everywhere, and of the duty base only
where duty is valued CIF (not the US). For a CN destination, omit it when no insurance
was paid and the API applies the
customs presumption of 3 per mille of goods plus freight (`cn_insurance_presumed`); send
`{"amount": 0, ...}` when the insurance is already included in `itemValue`. In the
response, `insurance` is the declared premium inside `components.CIF` and `total`, and
`presumedInsurance` is the CN presumption: duty and VAT are computed on it, but it is not
part of `components.CIF` or `total`, since nobody pays it. The SDK sends whichever of the
three states you give it unchanged.

Inspect `results`, `succeeded` and `failed`: an HTTP success may contain per-item
errors or estimates, and `status == "ok"` does not grant collection permission.
Assessments and allocation details are retained unchanged.

The batch response exposes `result.idempotency_key`. To recover a timed-out batch,
repeat `create_quote_batch` with the **identical full items** and that final key.
`get_quote_by_key` reads single quotes and cannot replay a batch key.

## Dispatch and customs status

Quote inputs are dictionaries. Single and batch methods preserve the canonical
`declaration.movement` object without adding defaults or validating customs facts:

```python
body = {
    "origin": "CN",  # country of manufacture
    "dispatchCountry": "DE",  # actual ship-from country
    "dest": "FR",
    "itemValue": {"amount": 200, "currency": "EUR"},
    "dimsCm": {"l": 10, "w": 10, "h": 10},
    "weightKg": 2,
    "categoryKey": "apparel.tshirt",
    "hs6": "610910",
    "mode": "air",
    "freight": {"amount": 25, "currency": "EUR"},
    "deliveryCharge": {"amount": 0, "currency": "EUR"},
    "declaration": {"movement": {"customsStatus": "free_circulation"}},
}
quote = client.create_quote(body)
```

The status describes the goods **at this sale in the dispatch customs territory**.
`free_circulation` means qualifying goods are already in free circulation before
the sale, with no release/entry lodged to fulfil this order. Use
`not_in_free_circulation` when a customs debt or conditional procedure remains,
including bonded stock that will be released to fulfil this order. This is the
merchant's assertion from their records; Fleet does not verify release evidence.
Do not derive it from manufacture, warehouse country, IOSS registration or an unset
Boolean. Supply actual `dispatchCountry` with a known status; omit `movement` when
unknown. An empty movement object or a made-up status is not an unknown value.

Movement is per line: items sharing a complete consignment may have different
statuses. Preserve those facts; do not split a consignment to change its treatment.
On a no-border lane, a required release produces `customs_release_not_modelled`
and a refused assessment with a null maximum. Other qualifications remain
independent, including `vat_buyer_status_unresolved`. Neither declared free
circulation nor a successful batch item establishes collection permission, buyer
VAT entitlement, seller registration or the correct remittance jurisdiction.

`explainability.duty.customsStatusSource` records `request` for a merchant declaration
and `assumed` for an inferred status on applicable no-border quotes. It is absent
on border quotes and can be absent on historical records, including records with
no `explainability` block. Preserve that absence; never fill it with `assumed` or
interpret `request` as verified. The response dictionary retains complete reasons,
warnings, missing components and nullable ceilings alongside this provenance.

## Distance-sales attestation

A seller established in an EU member state, with no OSS registration, can attest
that it is under that state's distance-selling threshold (EUR 10,000 in the euro
area, the national figure elsewhere). State the total in the threshold's currency;
`client.get_distance_sales_threshold("PL")` says which. Quotes whose
request declares `dispatchCountry` equal to the attested state can then price
consumer sales at the dispatch state's rate:

```python
profile = client.set_distance_sales_attestation({
    "statementVersion": 1,
    "establishmentCountry": "NL",
    "sellsOnOwnAccount": True,
    "establishedOnlyThere": True,
    "dispatchesOnlyFromThere": True,
    "noDestinationTaxationOption": True,
    "noSmallEnterpriseExemption": True,
    "priorYearWithinThreshold": True,
    "currentYearTotal": {"amount": 4200, "currency": "EUR"},
    "currentYearTotalAsOf": "2026-09-20",
    "exclusionsAcknowledged": True,
})
profile["distanceSalesAttestation"]["inEffect"]

# As soon as the current-year total passes the threshold:
client.withdraw_distance_sales_attestation()
```

Every statement is a literal `True`: send the attestation only if the seller can
affirm each one. It needs a live key with `self:write`; Test-mode keys are refused,
and the API does not check who holds the key.

`explainability.vat.supplyVatSchemeSource` names what decided where a consumer sale is taxed (`oss_registration`,
`destination_registration`, `declared_destination`, `attestation` or `assumed`) on a cross-border intra-EU supply
priced OSS or domestic. `declared_destination` means the seller states destination taxation with no OSS or
destination VAT registration on file; such an OSS-priced quote cannot be collected.
It is absent on reverse charge, domestic movements, imports and older saved quotes.
`explainability.vat.thresholdAttestation` is present only where the engine consulted an attestation on file (no OSS
registration, a domestic-scheme price): `applied`, or `not_applied` with a `reason`.
Preserve both absences; do not fill them in.

## Buyer VAT numbers

Send the buyer's VAT number, country prefix included, as
`declaration.buyer.vatNumber`, and `declaration.buyer.taxStatus:
"vat_registered_business"` only where your checkout captured it. Never derive the
status from the presence of a number.

Where an EU number could decide an intra-EU supply, Fleet checks it against VIES and
reports the result in `explainability.vat.buyerVatCheck`: `status` (`valid`, `invalid`, `unavailable`, or
`not_checked` with a `reason`), `numberCountry`, `requesterCountry`, `checkedAt` and
`requestIdentifier`, the VIES consultation number to keep with the invoice. The
number itself is never echoed.

A check needs your VAT registration for the dispatch state recorded in Fleet as the
requester. Without it the result is `not_checked` with reason
`no_requester_registration`, test keys included. Live keys in production are not
checked until the switch-on (#2369). Test keys get a simulator that sends nothing
anywhere: national part `100` is valid and `200` invalid. GB numbers are not checked
yet.

What the result changes depends on the lane, so read each quote's warnings rather than
these notes:

- **A number from another member state** (an exempt supply). A `valid` number beside
  `vat_registered_business` replaces `vat_reverse_charge_conditional` with the
  information code `vat_intra_eu_exemption_number_verified`, and the quote can
  complete. VIES confirms a registration, not who is buying, so a valid number without
  that status does not. VAT confidence stays capped at `estimated` and
  `guaranteedMax` keeps its reserve either way, because the exemption also rests on
  your own recapitulative statement. `unavailable` and `not_checked` leave the quote
  as it would be without a check.
- **A number from the dispatch state** never makes the supply exempt. A valid one
  beside `vat_registered_business` prices dispatch-state VAT and discloses it with
  `vat_reverse_charge_not_available`.
- **An `invalid` number**, from either, is priced as absent. It blocks collection with
  `vat_buyer_vat_number_invalid` only where that could change the rate; where it
  cannot, such as equal rates or an applied distance-sales attestation, the quote
  discloses it with `vat_reverse_charge_not_available`.

On a GB business import of GBP 135 or less, a GB number gives a conditional zero
(`vat_reverse_charge_conditional`) with no `guaranteedMax`. Rows from `export_quotes` carry the same `buyerVatCheck`, so
the consultation number stays linked to its quote.

For the separate [organization data export](https://docs.getfleet.dev/api/data-retention),
use `GET /v1/data-retention/export` with `self:read` scope as an organization owner or admin.
Retained consultation records appear in `data.viesConsultations`, newest first, up to 10,000.
`completeness.viesConsultationsIncluded` gives the returned count;
`completeness.viesConsultationsTruncated` says whether more records exist.
These records are separate from per-quote `buyerVatCheck`. This organization export excludes
single-quote history, manifest quotes and manifest documents; it is not a complete quote-history export.

### Great Britain and Northern Ireland

For quote and manifest requests with destination `GB` (or `UK`), explicitly send
`gbDeliveryTerritory: "great_britain"` or `"northern_ireland"` for the actual delivery.
No SDK supplies a default. Omission returns HTTP 422
`GB_DELIVERY_TERRITORY_REQUIRED`; Northern Ireland returns HTTP 422
`TERRITORY_NOT_SUPPORTED`. Do not derive Great Britain from the country code alone.

## Gateway headers

Use the public `extra_headers` option when your chosen API gateway requires
additional headers. For example, a Cloudflare Access protected origin can use:

```python
import os
from fleet import FleetClient

with FleetClient(
    base_url=os.environ["FLEET_API_URL"],
    api_key=os.environ["FLEET_API_KEY"],
    extra_headers={
        "CF-Access-Client-Id": os.environ["CF_ACCESS_CLIENT_ID"],
        "CF-Access-Client-Secret": os.environ["CF_ACCESS_CLIENT_SECRET"],
    },
) as client:
    quote = client.create_quote(body)
```

The mapping is copied at construction. Header names are case-insensitive;
invalid/duplicate names, non-ASCII/control-character values and SDK-controlled
authentication, idempotency, host, framing and hop-by-hop headers raise `ValueError`.
Keep the API key in `api_key` and operation keys in `idempotency_key`.
Redirects are not followed, so gateway credentials are not forwarded to a
redirect destination. Use a trusted HTTPS origin for real credentials.
