Files
Order-Manager/app/services/shipstation_send.py
T
madminandClaude Sonnet 5 d8f59b7725 Fix external_shipment_id placement and test-mode store_id ticket persistence in the return-label flow
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]>
2026-10-01 15:53:54 -05:00

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