external_shipment_id was at the top level of the POST /v2/labels payload in both
_create_dummy_outbound_label() and create_return_label_from_dummy() - ShipStation's
schema only supports it nested under shipment, so it was silently ignored and
auto-generated ("SEAuto-...") on every label this flow ever created. Moved it inside
the shipment dict in both functions. Confirmed end-to-end on a real production
ticket (AR-166098): Order # now correctly shows the real ticket number, and
ship_from/ship_to are correct for a return.
Also: store_id is now only required in production - ShipStation's sandbox
environment cannot have stores/Order Sources at all (confirmed in the real sandbox
dashboard), so test mode omits it from the request instead of requiring an
impossible value. And three per-ticket write-backs (mark_shipstation_sent,
save_dummy_outbound_label_id, save_pack_data) were hardcoded to source == "jira",
silently no-opping for source == "test" tickets created by Create Test Shipment -
now match on ticket_number alone, since source is only ever "jira" or "test" and
ticket_number is already unique across both.
CLAUDE.md records the full investigation and closes out the long-standing
"return-label flow real-world verification" known-pending item.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
804 lines
34 KiB
Python
804 lines
34 KiB
Python
"""
|
|
Emergency "Send to ShipStation" - for the rare case a ticket needs to
|
|
skip the normal daily batch and get to ShipStation right away.
|
|
|
|
Two independent paths, on purpose (per "in case the API goes down we
|
|
still have an option for CSV uploads"):
|
|
|
|
- export_order_to_shipstation_csv(): writes rows matching your real
|
|
upload template exactly (columns confirmed against OAK.csv) - one
|
|
row per line item, customer/address info repeated on each row.
|
|
- send_order_to_shipstation_api(): calls ShipStation's V2 API
|
|
directly (POST /v2/shipments with create_sales_order: true) to
|
|
create the order without leaving the app. Needs a Warehouse ID per
|
|
company (SHIPSTATION_SIGNIFY_WAREHOUSE_ID /
|
|
SHIPSTATION_OAKSTREET_WAREHOUSE_ID) - ShipStation requires knowing
|
|
where the package ships FROM, either via a warehouse or an explicit
|
|
ship_from address; only the warehouse path is wired up today.
|
|
|
|
IMPORTANT - please verify the first real send: ShipStation's docs say
|
|
automation rules apply tags to orders "when they import based on any
|
|
criteria you set" - meaning your 90 box-packing rules should fire
|
|
automatically off the SKU/item data here, same as your CSV import, with
|
|
no manual tagging needed. That's the best read of the docs, but it's
|
|
not something I can verify without your actual account, so treat the
|
|
first live send (API or CSV) as a test: confirm ShipStation packs and
|
|
prices it the way a normal order would before trusting it in a real
|
|
emergency.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import csv
|
|
import datetime as dt
|
|
from pathlib import Path
|
|
from typing import List
|
|
|
|
import requests
|
|
|
|
from app import config
|
|
from app.models import Order
|
|
|
|
API_BASE = "https://api.shipstation.com/v2"
|
|
REQUEST_TIMEOUT_SECONDS = 30
|
|
|
|
|
|
def list_carriers() -> List[dict]:
|
|
"""
|
|
Calls GET /v2/carriers with whichever API key is currently active
|
|
(test or production, via config.get_shipstation_setting) - a direct
|
|
way to answer "what carrier_id is actually valid here" instead of
|
|
guessing or hunting through ShipStation's UI while unsure which
|
|
account is even logged in. Each carrier includes carrier_id,
|
|
carrier_code, friendly_name, and nickname.
|
|
"""
|
|
api_key = config.get_shipstation_setting("SHIPSTATION_API_KEY")
|
|
if not api_key:
|
|
raise ShipStationSendError("ShipStation API Key is not set. Add it in Settings.")
|
|
|
|
try:
|
|
response = requests.get(
|
|
f"{API_BASE}/carriers",
|
|
headers={"API-Key": api_key, "Accept": "application/json"},
|
|
timeout=REQUEST_TIMEOUT_SECONDS,
|
|
)
|
|
except requests.RequestException as exc:
|
|
raise ShipStationSendError(f"Could not reach ShipStation: {exc}") from exc
|
|
|
|
if response.status_code == 401:
|
|
raise ShipStationSendError("ShipStation rejected the API key (401). Check it in Settings.")
|
|
if not response.ok:
|
|
raise ShipStationSendError(
|
|
f"ShipStation returned an error ({response.status_code}): {response.text[:400]}"
|
|
)
|
|
|
|
try:
|
|
data = response.json()
|
|
except ValueError as exc:
|
|
raise ShipStationSendError("ShipStation returned a response that wasn't valid JSON.") from exc
|
|
|
|
return data.get("carriers", [])
|
|
|
|
|
|
def list_stores() -> List[dict]:
|
|
"""
|
|
DEAD END, kept only so nobody re-attempts this the hard way: ShipStation's
|
|
V2 API has NO stores/marketplaces listing endpoint. GET /v2/stores returns
|
|
a plain 404 ("No route matched with those values") - confirmed against
|
|
ShipStation's own V2 OpenAPI reference, which lists every real section
|
|
(Carriers, Warehouses, Connections, etc.) and where store_id only ever
|
|
appears as an INPUT field on label/shipment requests (example value
|
|
"se-12345"), never as its own resource with a list/get endpoint. This
|
|
matches ShipStation's help docs too, which say the only ways to get a
|
|
store_id are (1) ShipStation support looks it up for you, or (2) the
|
|
legacy V1 API's List Stores call - different auth (key+secret Basic
|
|
Auth), not the single V2 API-Key header this app uses everywhere else.
|
|
|
|
Bottom line: there is no way to discover a store_id from inside this
|
|
app. Log into the ShipStation account's own UI (Settings > Store Setup
|
|
/ Selling Channels) to find or create a manual store and read its ID
|
|
from there - for test mode specifically, that means logging into the
|
|
TEST/sandbox account, not production.
|
|
"""
|
|
raise ShipStationSendError(
|
|
"ShipStation's V2 API has no endpoint to list stores (confirmed - GET /v2/stores "
|
|
"doesn't exist, it 404s). Log into the ShipStation account's own UI under "
|
|
"Settings > Store Setup to find the store_id, then enter it in Settings - for "
|
|
"test mode, log into the TEST/sandbox account and set the TEST_ override."
|
|
)
|
|
|
|
|
|
def list_warehouses() -> List[dict]:
|
|
"""Same idea as list_carriers()/list_stores() but for GET /v2/warehouses -
|
|
fixes the exact next error in the sequence (warehouse_id not found),
|
|
same root cause as the carrier_id one: the test/sandbox account is a
|
|
completely separate ShipStation environment with its own IDs."""
|
|
api_key = config.get_shipstation_setting("SHIPSTATION_API_KEY")
|
|
if not api_key:
|
|
raise ShipStationSendError("ShipStation API Key is not set. Add it in Settings.")
|
|
|
|
try:
|
|
response = requests.get(
|
|
f"{API_BASE}/warehouses",
|
|
headers={"API-Key": api_key, "Accept": "application/json"},
|
|
timeout=REQUEST_TIMEOUT_SECONDS,
|
|
)
|
|
except requests.RequestException as exc:
|
|
raise ShipStationSendError(f"Could not reach ShipStation: {exc}") from exc
|
|
|
|
if response.status_code == 401:
|
|
raise ShipStationSendError("ShipStation rejected the API key (401). Check it in Settings.")
|
|
if not response.ok:
|
|
raise ShipStationSendError(
|
|
f"ShipStation returned an error ({response.status_code}): {response.text[:400]}"
|
|
)
|
|
|
|
try:
|
|
data = response.json()
|
|
except ValueError as exc:
|
|
raise ShipStationSendError("ShipStation returned a response that wasn't valid JSON.") from exc
|
|
|
|
return data if isinstance(data, list) else data.get("warehouses", [])
|
|
|
|
|
|
def create_warehouse(name: str, origin_address: dict) -> dict:
|
|
"""POST /v2/warehouses - creates a new warehouse for whichever API key
|
|
is currently active. Needed because a sandbox/test ShipStation account
|
|
starts with zero warehouses (confirmed against ShipStation's own docs):
|
|
list_warehouses() correctly returning an empty list in test mode wasn't
|
|
a bug, there was just nothing there yet to list. Returns the created
|
|
warehouse's JSON (includes warehouse_id - the value that goes into
|
|
TEST_SHIPSTATION_SIGNIFY_WAREHOUSE_ID / TEST_SHIPSTATION_OAKSTREET_WAREHOUSE_ID)."""
|
|
api_key = config.get_shipstation_setting("SHIPSTATION_API_KEY")
|
|
if not api_key:
|
|
raise ShipStationSendError("ShipStation API Key is not set. Add it in Settings.")
|
|
|
|
payload = {"name": name, "origin_address": origin_address}
|
|
|
|
try:
|
|
response = requests.post(
|
|
f"{API_BASE}/warehouses",
|
|
json=payload,
|
|
headers={"API-Key": api_key, "Accept": "application/json"},
|
|
timeout=REQUEST_TIMEOUT_SECONDS,
|
|
)
|
|
except requests.RequestException as exc:
|
|
raise ShipStationSendError(f"Could not reach ShipStation: {exc}") from exc
|
|
|
|
if response.status_code == 401:
|
|
raise ShipStationSendError("ShipStation rejected the API key (401). Check it in Settings.")
|
|
if not response.ok:
|
|
raise ShipStationSendError(
|
|
f"ShipStation returned an error ({response.status_code}): {response.text[:400]}"
|
|
)
|
|
|
|
try:
|
|
return response.json()
|
|
except ValueError as exc:
|
|
raise ShipStationSendError("ShipStation returned a response that wasn't valid JSON.") from exc
|
|
|
|
|
|
def _origin_address_for_company(company: str) -> dict:
|
|
"""Same physical address as _return_address_for_order's ship-from/
|
|
ship-to address (SIGNIFY_RETURN_*/OAKSTREET_RETURN_* settings) - a
|
|
warehouse's origin_address is just that address registered with
|
|
ShipStation. address_residential_indicator is required by
|
|
POST /v2/warehouses; this is always a business address."""
|
|
settings = config.load_settings()
|
|
prefix = "SIGNIFY_RETURN_" if company == "Signify Health" else "OAKSTREET_RETURN_"
|
|
return {
|
|
"name": settings.get(f"{prefix}NAME", ""),
|
|
"phone": settings.get(f"{prefix}PHONE", ""),
|
|
"company_name": company,
|
|
"address_line1": settings.get(f"{prefix}ADDRESS1", ""),
|
|
"address_line2": settings.get(f"{prefix}ADDRESS2", "") or None,
|
|
"city_locality": settings.get(f"{prefix}CITY", ""),
|
|
"state_province": settings.get(f"{prefix}STATE", ""),
|
|
"postal_code": settings.get(f"{prefix}ZIP", ""),
|
|
"country_code": "US",
|
|
"address_residential_indicator": "no",
|
|
}
|
|
|
|
|
|
def create_test_warehouse_for_company(company: str) -> dict:
|
|
"""Creates a warehouse for whichever API key is currently active
|
|
(callers should confirm test mode is on first - this isn't something
|
|
you want to accidentally run against production) using the company's
|
|
own return-address settings as the origin_address. Returns the created
|
|
warehouse's JSON."""
|
|
origin_address = _origin_address_for_company(company)
|
|
required_fields = (
|
|
"name", "phone", "address_line1", "city_locality", "state_province", "postal_code",
|
|
)
|
|
missing = [field for field in required_fields if not origin_address.get(field)]
|
|
if missing:
|
|
prefix = "SIGNIFY_RETURN_" if company == "Signify Health" else "OAKSTREET_RETURN_"
|
|
raise ShipStationSendError(
|
|
f"{prefix}* address settings for {company} are incomplete (missing: "
|
|
f"{', '.join(missing)}). Fill those in under Settings > Return Labels first."
|
|
)
|
|
return create_warehouse(f"{company} (Test)", origin_address)
|
|
|
|
# Column order confirmed against the real ShipStation upload template
|
|
# (OAK.csv) - "COPY ME ALREADY" is a spreadsheet-only helper column and
|
|
# is intentionally left out here.
|
|
CSV_COLUMNS = [
|
|
"Custom field (Email Address)",
|
|
"Summary",
|
|
"Custom field (Name)",
|
|
"Custom field (NPI Number)",
|
|
"Custom field (Address 1)",
|
|
"Custom field (Address 2)",
|
|
"Custom field (City)",
|
|
"Custom field (State)",
|
|
"Custom field (Zip Code)",
|
|
"Issue key",
|
|
"Issue id",
|
|
"Status",
|
|
"deliverables",
|
|
"SKU",
|
|
"Item Name",
|
|
"Quantity",
|
|
]
|
|
|
|
|
|
class ShipStationSendError(Exception):
|
|
"""Raised for any emergency-send failure, with a message safe to show in the UI."""
|
|
|
|
|
|
def _deliverable_text(sku: str, item_name: str) -> str:
|
|
return f"{sku}: {item_name}" if item_name else sku
|
|
|
|
|
|
def build_csv_rows(order: Order) -> List[List[str]]:
|
|
"""One row per line item, matching the real template column-for-column.
|
|
Falls back to a single row (blank SKU/Item Name) if there are no line
|
|
items yet, so the customer/address info is still exportable."""
|
|
info = order.shipping_info or {}
|
|
line_items = order.line_items or [{"sku": s, "item_name": ""} for s in (order.skus or [])]
|
|
if not line_items:
|
|
line_items = [{"sku": "", "item_name": ""}]
|
|
|
|
issue_id = ""
|
|
if isinstance(order.raw_data, dict):
|
|
issue_id = order.raw_data.get("id", "")
|
|
|
|
rows = []
|
|
for item in line_items:
|
|
rows.append(
|
|
[
|
|
info.get("email", ""),
|
|
order.summary,
|
|
info.get("name", ""),
|
|
info.get("npi", ""),
|
|
info.get("address1", ""),
|
|
info.get("address2", ""),
|
|
info.get("city", ""),
|
|
info.get("state", ""),
|
|
info.get("zip", ""),
|
|
order.ticket_number or order.external_id,
|
|
issue_id,
|
|
order.status,
|
|
_deliverable_text(item.get("sku", ""), item.get("item_name", "")),
|
|
item.get("sku", ""),
|
|
item.get("item_name", ""),
|
|
1, # quantity - not tracked per-item today, defaults to 1 per line
|
|
]
|
|
)
|
|
return rows
|
|
|
|
|
|
def export_order_to_shipstation_csv(orders: List[Order], filepath: str) -> int:
|
|
"""Write one or more orders to a CSV in the real upload template shape.
|
|
Returns the number of line-item rows written (not the number of orders,
|
|
since one order can produce several rows)."""
|
|
path = Path(filepath)
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
row_count = 0
|
|
with path.open("w", newline="", encoding="utf-8") as f:
|
|
writer = csv.writer(f)
|
|
writer.writerow(CSV_COLUMNS)
|
|
for order in orders:
|
|
for row in build_csv_rows(order):
|
|
writer.writerow(row)
|
|
row_count += 1
|
|
|
|
return row_count
|
|
|
|
|
|
def _normalize_shipstation_id(value: str) -> str:
|
|
"""
|
|
Every ShipStation reference ID we've seen (store, warehouse, carrier)
|
|
consistently uses an "se-" prefix (se-599657, se-367672, etc).
|
|
Prepends it if it's missing - a bare numeric ID would always fail
|
|
with a confusing "not found" error otherwise, and it's an easy typo
|
|
to make when copying a value out of ShipStation's own UI or filling
|
|
in a new setting (this happened for real: a test-mode carrier ID was
|
|
entered as "599657" instead of "se-599657").
|
|
"""
|
|
value = (value or "").strip()
|
|
if value and not value.startswith("se-") and value.replace("-", "").isalnum():
|
|
return f"se-{value}"
|
|
return value
|
|
|
|
|
|
def _store_id_for_company(company: str) -> str:
|
|
if company == "Signify Health":
|
|
return _normalize_shipstation_id(config.get_shipstation_setting("SHIPSTATION_SIGNIFY_STORE_ID"))
|
|
if company == "Oak Street Health":
|
|
return _normalize_shipstation_id(config.get_shipstation_setting("SHIPSTATION_OAKSTREET_STORE_ID"))
|
|
return ""
|
|
|
|
|
|
def _warehouse_id_for_company(company: str) -> str:
|
|
if company == "Signify Health":
|
|
return _normalize_shipstation_id(
|
|
config.get_shipstation_setting("SHIPSTATION_SIGNIFY_WAREHOUSE_ID")
|
|
)
|
|
if company == "Oak Street Health":
|
|
return _normalize_shipstation_id(
|
|
config.get_shipstation_setting("SHIPSTATION_OAKSTREET_WAREHOUSE_ID")
|
|
)
|
|
return ""
|
|
|
|
|
|
def send_order_to_shipstation_api(order: Order) -> dict:
|
|
"""
|
|
Creates the order directly in ShipStation via POST /v2/shipments with
|
|
create_sales_order: true, so it lands in the Orders tab and (per
|
|
ShipStation's own docs on import automation) should pick up your
|
|
box-packing rules the same as a CSV-imported order. Returns the
|
|
created shipment's JSON on success.
|
|
"""
|
|
settings = config.load_settings()
|
|
api_key = settings.get("SHIPSTATION_API_KEY", "")
|
|
if not api_key:
|
|
raise ShipStationSendError("ShipStation API Key is not set. Add it in Settings.")
|
|
|
|
store_id = _store_id_for_company(order.company)
|
|
if not store_id:
|
|
raise ShipStationSendError(
|
|
f"No ShipStation Store ID configured for '{order.company}'. "
|
|
"Add it in Settings under ShipStation."
|
|
)
|
|
|
|
# ShipStation needs to know where the package ships FROM - either a
|
|
# configured warehouse, or an explicit ship_from address. Only the
|
|
# warehouse path is wired up today; fail clearly here rather than
|
|
# sending an incomplete request and getting ShipStation's less
|
|
# actionable "ship_from is required when warehouse_id is not present".
|
|
warehouse_id = _warehouse_id_for_company(order.company)
|
|
if not warehouse_id:
|
|
raise ShipStationSendError(
|
|
f"No ShipStation Warehouse ID configured for '{order.company}'. "
|
|
"Add it in Settings under ShipStation - find it in ShipStation under "
|
|
"Settings > Shipping > Warehouses/Ship From Locations."
|
|
)
|
|
|
|
info = order.shipping_info or {}
|
|
if not (info.get("address1") and info.get("city") and info.get("state") and info.get("zip")):
|
|
raise ShipStationSendError(
|
|
"This ticket is missing address information (address/city/state/zip) - "
|
|
"can't create a shipment without a destination."
|
|
)
|
|
|
|
line_items = order.line_items or [{"sku": s, "item_name": ""} for s in (order.skus or [])]
|
|
if not line_items:
|
|
raise ShipStationSendError("This ticket has no SKUs/line items to send.")
|
|
|
|
ticket_number = order.ticket_number or order.external_id
|
|
|
|
payload = {
|
|
"shipments": [
|
|
{
|
|
"create_sales_order": True,
|
|
"store_id": store_id,
|
|
"warehouse_id": warehouse_id,
|
|
"external_shipment_id": ticket_number,
|
|
"shipment_number": ticket_number,
|
|
"ship_to": {
|
|
"name": info.get("name", ""),
|
|
"phone": info.get("phone", ""),
|
|
"company_name": order.company,
|
|
"address_line1": info.get("address1", ""),
|
|
"address_line2": info.get("address2", "") or None,
|
|
"city_locality": info.get("city", ""),
|
|
"state_province": info.get("state", ""),
|
|
"postal_code": info.get("zip", ""),
|
|
"country_code": "US",
|
|
},
|
|
"items": [
|
|
{
|
|
"sku": item.get("sku", ""),
|
|
"name": item.get("item_name") or item.get("sku", ""),
|
|
"quantity": 1,
|
|
}
|
|
for item in line_items
|
|
],
|
|
}
|
|
]
|
|
}
|
|
|
|
try:
|
|
response = requests.post(
|
|
f"{API_BASE}/shipments",
|
|
json=payload,
|
|
headers={"API-Key": api_key, "Accept": "application/json"},
|
|
timeout=REQUEST_TIMEOUT_SECONDS,
|
|
)
|
|
except requests.RequestException as exc:
|
|
raise ShipStationSendError(f"Could not reach ShipStation: {exc}") from exc
|
|
|
|
if response.status_code == 401:
|
|
raise ShipStationSendError("ShipStation rejected the API key (401). Check it in Settings.")
|
|
if not response.ok:
|
|
raise ShipStationSendError(
|
|
f"ShipStation returned an error ({response.status_code}): {response.text[:400]}"
|
|
)
|
|
|
|
try:
|
|
data = response.json()
|
|
except ValueError as exc:
|
|
raise ShipStationSendError("ShipStation returned a response that wasn't valid JSON.") from exc
|
|
|
|
shipments = data.get("shipments", [])
|
|
if not shipments:
|
|
raise ShipStationSendError(f"ShipStation didn't return a created shipment: {data}")
|
|
|
|
result = shipments[0]
|
|
if result.get("errors"):
|
|
raise ShipStationSendError(f"ShipStation reported errors: {result['errors']}")
|
|
|
|
return result
|
|
|
|
|
|
# --- Emailed return labels (SH007 / OK012) --------------------------------
|
|
#
|
|
# A genuinely different shape from the outbound send above: for a return
|
|
# label, ship_from is the CUSTOMER and ship_to is YOUR warehouse/return
|
|
# center (confirmed against ShipStation's own return-labels docs - this is
|
|
# reversed from every other label this app creates). This also doesn't go
|
|
# through create_sales_order + automation, since there's no outbound side
|
|
# to it - carrier/service are specified directly.
|
|
#
|
|
# After creation, this app does NOT email the label - per your workflow,
|
|
# that's done from ShipStation itself (Returns tab -> Other Actions ->
|
|
# Send Return Label) so it goes out through your branded email template.
|
|
|
|
VALID_CHARGE_EVENTS = {"on_creation", "on_carrier_acceptance", "carrier_default"}
|
|
|
|
|
|
def _is_rubiconmd_order(order: Order) -> bool:
|
|
return any((sku or "").strip().upper().startswith("RMD") for sku in (order.skus or []))
|
|
|
|
|
|
def _return_address_for_order(order: Order) -> dict:
|
|
"""RubiconMD (RMD-prefixed SKUs) uses Oak Street's own address in every
|
|
respect except the name on the label - not a separate warehouse, just
|
|
a different name for the same shipping account/address."""
|
|
settings = config.load_settings()
|
|
prefix = "SIGNIFY_RETURN_" if order.company == "Signify Health" else "OAKSTREET_RETURN_"
|
|
name = settings.get(f"{prefix}NAME", "")
|
|
if _is_rubiconmd_order(order):
|
|
name = settings.get("RUBICONMD_RETURN_NAME", "") or name
|
|
return {
|
|
"name": name,
|
|
"phone": settings.get(f"{prefix}PHONE", ""),
|
|
"address_line1": settings.get(f"{prefix}ADDRESS1", ""),
|
|
"address_line2": settings.get(f"{prefix}ADDRESS2", "") or None,
|
|
"city_locality": settings.get(f"{prefix}CITY", ""),
|
|
"state_province": settings.get(f"{prefix}STATE", ""),
|
|
"postal_code": settings.get(f"{prefix}ZIP", ""),
|
|
"country_code": "US",
|
|
}
|
|
|
|
|
|
def _return_carrier_for_order(order: Order) -> tuple[str, str]:
|
|
"""RubiconMD uses Oak Street's own UPS account - no separate carrier,
|
|
just a different name on the return address (see
|
|
_return_address_for_order). Each of the two REAL shipping accounts
|
|
(Signify, Oak Street) has its own carrier_id even though they share a
|
|
physical warehouse."""
|
|
prefix = "SIGNIFY_RETURN_" if order.company == "Signify Health" else "OAKSTREET_RETURN_"
|
|
return (
|
|
_normalize_shipstation_id(config.get_shipstation_setting(f"{prefix}CARRIER_ID")),
|
|
config.get_shipstation_setting(f"{prefix}SERVICE_CODE"),
|
|
)
|
|
|
|
|
|
def _build_return_package(weight_oz: float, length: float, width: float, height: float) -> dict:
|
|
package = {"weight": {"value": weight_oz, "unit": "ounce"}}
|
|
if length > 0 and width > 0 and height > 0:
|
|
package["dimensions"] = {
|
|
"length": length,
|
|
"width": width,
|
|
"height": height,
|
|
"unit": "inch",
|
|
}
|
|
return package
|
|
|
|
|
|
def _post_to_labels(payload: dict, api_key: str) -> dict:
|
|
"""Shared POST /v2/labels caller for both the dummy outbound and the
|
|
actual return label - same endpoint, same response shape, same
|
|
status-field pitfall (a 200 can still carry status: "error" or
|
|
"voided" alongside a label_id, which looks like success unless you
|
|
check status specifically)."""
|
|
try:
|
|
response = requests.post(
|
|
f"{API_BASE}/labels",
|
|
json=payload,
|
|
headers={"API-Key": api_key, "Accept": "application/json"},
|
|
timeout=REQUEST_TIMEOUT_SECONDS,
|
|
)
|
|
except requests.RequestException as exc:
|
|
raise ShipStationSendError(f"Could not reach ShipStation: {exc}") from exc
|
|
|
|
if response.status_code == 401:
|
|
raise ShipStationSendError("ShipStation rejected the API key (401). Check it in Settings.")
|
|
if not response.ok:
|
|
raise ShipStationSendError(
|
|
f"ShipStation returned an error ({response.status_code}): {response.text[:400]}"
|
|
)
|
|
|
|
try:
|
|
data = response.json()
|
|
except ValueError as exc:
|
|
raise ShipStationSendError("ShipStation returned a response that wasn't valid JSON.") from exc
|
|
|
|
if data.get("errors"):
|
|
raise ShipStationSendError(f"ShipStation reported errors: {data['errors']}")
|
|
|
|
label_status = data.get("status")
|
|
if label_status == "error":
|
|
raise ShipStationSendError(
|
|
f"ShipStation created label {data.get('label_id', '?')} but its status is "
|
|
f"'error' - it was not actually completed. Full response: {data}"
|
|
)
|
|
if label_status == "voided":
|
|
raise ShipStationSendError(
|
|
f"ShipStation reports label {data.get('label_id', '?')} as already voided."
|
|
)
|
|
|
|
return data
|
|
|
|
|
|
def _void_label(label_id: str, api_key: str) -> None:
|
|
"""Best-effort cleanup - if the real return label fails to create
|
|
after the dummy outbound succeeded, void the dummy rather than leave
|
|
a paid, unused label sitting in the account. Deliberately swallows
|
|
its own errors: this runs during an already-failing operation, and a
|
|
secondary failure here shouldn't mask the original error or crash
|
|
the app - worst case, an unused dummy label needs manual voiding."""
|
|
try:
|
|
requests.put(
|
|
f"{API_BASE}/labels/{label_id}/void",
|
|
headers={"API-Key": api_key, "Accept": "application/json"},
|
|
timeout=REQUEST_TIMEOUT_SECONDS,
|
|
)
|
|
except requests.RequestException:
|
|
pass
|
|
|
|
|
|
def _create_dummy_outbound_label(
|
|
order: Order,
|
|
api_key: str,
|
|
carrier_id: str,
|
|
service_code: str,
|
|
store_id: str,
|
|
external_shipment_id: str,
|
|
) -> dict:
|
|
"""
|
|
Mirrors what your team already does by hand in ShipStation's GUI:
|
|
create a minimal, cheap outbound label (1x1x1in, 1oz) purely so a
|
|
real return label can be linked to it via outbound_label_id - which
|
|
is apparently what actually makes a return label findable/visible in
|
|
your account, confirmed against your own working manual process
|
|
rather than assumed from the API docs alone. Same shipping account as
|
|
the return itself; ship_from/ship_to are the reverse of the return
|
|
(this one goes warehouse -> customer, matching a normal outbound).
|
|
Returns the dummy's label_id.
|
|
|
|
external_shipment_id is passed in (rather than computed here) since
|
|
it must be unique per account - the caller uses a suffixed variant
|
|
for this dummy, distinct from the real return label's. ShipStation's
|
|
own docs confirm this field exists on both shipments and labels
|
|
specifically to correlate a record back to your own system, and it's
|
|
what populates the "Order #" column - this was missing entirely
|
|
before, on both this dummy and the real return label.
|
|
|
|
warehouse_id is used INSTEAD of ship_from when available (confirmed by
|
|
a real ShipStation error: "ship_from and warehouse_id cannot be
|
|
provided in same request" - they're mutually exclusive, not
|
|
additive). When a warehouse_id is configured, ShipStation resolves
|
|
the ship_from address from the registered warehouse record itself.
|
|
This only affects the throwaway dummy - it's never seen by anyone,
|
|
so it doesn't matter that this bypasses the RubiconMD name-override
|
|
logic in _return_address_for_order; the real return label (which
|
|
people do see) still uses that function directly.
|
|
"""
|
|
warehouse_id = _warehouse_id_for_company(order.company)
|
|
info = order.shipping_info or {}
|
|
|
|
shipment: dict = {
|
|
"carrier_id": carrier_id,
|
|
"service_code": service_code,
|
|
# Confirmed against ShipStation's own request schema for POST /v2/labels:
|
|
# external_shipment_id is a field ON the shipment object
|
|
# (shipment.external_shipment_id), there is no top-level equivalent for
|
|
# this endpoint. Putting it at the top level (as this code used to) means
|
|
# ShipStation silently ignores it and auto-generates its own "SEAuto-..."
|
|
# placeholder instead - confirmed live: a real production dummy shipment
|
|
# came back with external_shipment_id "SEAuto-..." instead of the ticket
|
|
# number we sent, which is almost certainly why "Order #" never showed up
|
|
# in ShipStation's UI for labels from this flow specifically.
|
|
"external_shipment_id": external_shipment_id,
|
|
"ship_to": {
|
|
"name": info.get("name", ""),
|
|
"phone": info.get("phone", ""),
|
|
"address_line1": info.get("address1", ""),
|
|
"address_line2": info.get("address2", "") or None,
|
|
"city_locality": info.get("city", ""),
|
|
"state_province": info.get("state", ""),
|
|
"postal_code": info.get("zip", ""),
|
|
"country_code": "US",
|
|
},
|
|
"packages": [_build_return_package(1.0, 1.0, 1.0, 1.0)],
|
|
}
|
|
# Left out entirely when blank (test mode only - see
|
|
# _validate_return_label_prerequisites) rather than sent as an empty string,
|
|
# matching the confirmed-working shape from a live probe.
|
|
if store_id:
|
|
shipment["store_id"] = store_id
|
|
if warehouse_id:
|
|
shipment["warehouse_id"] = warehouse_id
|
|
else:
|
|
shipment["ship_from"] = _return_address_for_order(order)
|
|
|
|
payload = {
|
|
"shipment": shipment,
|
|
}
|
|
return _post_to_labels(payload, api_key)
|
|
|
|
|
|
def _validate_return_label_prerequisites(order: Order) -> tuple[str, str, str, str]:
|
|
"""Shared validation for both steps - returns (api_key, carrier_id,
|
|
service_code, store_id) or raises with a clear, specific message."""
|
|
api_key = config.get_shipstation_setting("SHIPSTATION_API_KEY")
|
|
if not api_key:
|
|
raise ShipStationSendError("ShipStation API Key is not set. Add it in Settings.")
|
|
|
|
carrier_id, service_code = _return_carrier_for_order(order)
|
|
if not carrier_id or not service_code:
|
|
raise ShipStationSendError(
|
|
f"No return-label Carrier ID/Service Code configured for '{order.company}'. "
|
|
"Add them in Settings under Return Labels."
|
|
)
|
|
|
|
# store_id is only enforced in PRODUCTION. Confirmed directly in ShipStation's own
|
|
# dashboard: the Sandbox environment cannot connect/create Order Sources ("stores")
|
|
# at all - it explicitly says to switch to Production to do that. So no valid
|
|
# TEST_SHIPSTATION_*_STORE_ID can ever exist; requiring one in test mode would make
|
|
# this flow permanently untestable in sandbox. Confirmed via a live API probe that
|
|
# omitting store_id entirely still succeeds (schema-optional, not just a guess) -
|
|
# see _create_dummy_outbound_label()/create_return_label_from_dummy() for where it's
|
|
# left out of the request when blank. Still required in production, where it's
|
|
# achievable and matters for real (see the error message below).
|
|
store_id = _store_id_for_company(order.company)
|
|
if not store_id and not config.is_shipstation_test_mode():
|
|
raise ShipStationSendError(
|
|
f"No ShipStation Store ID configured for '{order.company}'. Add it in Settings "
|
|
"under ShipStation. A label created without one may not show up anywhere in "
|
|
"ShipStation's UI even though the API reports success."
|
|
)
|
|
|
|
return_address = _return_address_for_order(order)
|
|
if not (
|
|
return_address["address_line1"]
|
|
and return_address["city_locality"]
|
|
and return_address["state_province"]
|
|
and return_address["postal_code"]
|
|
and return_address["phone"]
|
|
):
|
|
raise ShipStationSendError(
|
|
f"No return warehouse address configured for '{order.company}'. "
|
|
"Add it in Settings under Return Labels."
|
|
)
|
|
|
|
info = order.shipping_info or {}
|
|
if not (info.get("address1") and info.get("city") and info.get("state") and info.get("zip")):
|
|
raise ShipStationSendError(
|
|
"This ticket is missing the customer's address information - "
|
|
"can't create a return label without knowing where it ships from."
|
|
)
|
|
|
|
return api_key, carrier_id, service_code, store_id
|
|
|
|
|
|
def create_dummy_shipment(order: Order) -> dict:
|
|
"""
|
|
STEP 1 of the emailed-return-label workflow, callable and verifiable
|
|
on its own: creates the minimal, cheap outbound label (1x1x1in, 1oz)
|
|
that your team already creates by hand in ShipStation's GUI before
|
|
generating a return from it. Returns the FULL response (not just the
|
|
label_id) so the caller/UI can show it for verification in
|
|
ShipStation before proceeding to step 2 - splitting these apart on
|
|
purpose so a problem in one step doesn't get masked by the other.
|
|
"""
|
|
api_key, carrier_id, service_code, store_id = _validate_return_label_prerequisites(order)
|
|
ticket_number = order.ticket_number or order.external_id
|
|
return _create_dummy_outbound_label(
|
|
order, api_key, carrier_id, service_code, store_id, external_shipment_id=f"{ticket_number}-DUMMY"
|
|
)
|
|
|
|
|
|
def create_return_label_from_dummy(
|
|
order: Order, dummy_label_id: str, packages: List[dict], charge_event: str | None = None
|
|
) -> dict:
|
|
"""
|
|
STEP 2 of the emailed-return-label workflow: creates the real return
|
|
label, linked via outbound_label_id to a dummy that was already
|
|
created (and ideally already verified in ShipStation) in step 1.
|
|
Does NOT create a new dummy - that's the point of splitting this out.
|
|
"""
|
|
settings = config.load_settings()
|
|
api_key, carrier_id, service_code, store_id = _validate_return_label_prerequisites(order)
|
|
return_address = _return_address_for_order(order)
|
|
info = order.shipping_info or {}
|
|
|
|
if not packages:
|
|
raise ShipStationSendError("At least one package is required.")
|
|
|
|
resolved_charge_event = charge_event or settings.get(
|
|
"SHIPSTATION_RETURN_CHARGE_EVENT", config.DEFAULT_RETURN_CHARGE_EVENT
|
|
)
|
|
if resolved_charge_event not in VALID_CHARGE_EVENTS:
|
|
raise ShipStationSendError(
|
|
f"charge_event must be one of {sorted(VALID_CHARGE_EVENTS)}, got "
|
|
f"'{resolved_charge_event}'."
|
|
)
|
|
|
|
ticket_number = order.ticket_number or order.external_id
|
|
|
|
shipment: dict = {
|
|
"carrier_id": carrier_id,
|
|
"service_code": service_code,
|
|
# Nested here, not top-level - see _create_dummy_outbound_label() for why
|
|
# (confirmed against ShipStation's own schema; a top-level
|
|
# external_shipment_id is silently ignored on POST /v2/labels).
|
|
"external_shipment_id": ticket_number,
|
|
"ship_to": return_address,
|
|
"ship_from": {
|
|
"name": info.get("name", ""),
|
|
"phone": info.get("phone", ""),
|
|
"address_line1": info.get("address1", ""),
|
|
"address_line2": info.get("address2", "") or None,
|
|
"city_locality": info.get("city", ""),
|
|
"state_province": info.get("state", ""),
|
|
"postal_code": info.get("zip", ""),
|
|
"country_code": "US",
|
|
},
|
|
"packages": [
|
|
_build_return_package(
|
|
p.get("weight_oz", 1.0), p.get("length", 0), p.get("width", 0), p.get("height", 0)
|
|
)
|
|
for p in packages
|
|
],
|
|
}
|
|
# Left out entirely when blank (test mode only - see
|
|
# _validate_return_label_prerequisites), matching the confirmed-working shape
|
|
# from a live probe, rather than sent as an empty string.
|
|
if store_id:
|
|
shipment["store_id"] = store_id
|
|
|
|
payload = {
|
|
"is_return_label": True,
|
|
"outbound_label_id": dummy_label_id,
|
|
"charge_event": resolved_charge_event,
|
|
"shipment": shipment,
|
|
}
|
|
|
|
data = _post_to_labels(payload, api_key)
|
|
data["_dummy_outbound_label_id"] = dummy_label_id
|
|
return data
|