FKN SHITSTATION AND THEIR GODDAMN SANDBOX BS - Sandbox will be it's own separate entity

This commit is contained in:
2026-09-16 16:24:20 -05:00
parent b5570d39f0
commit 82e6148ca9
4 changed files with 531 additions and 20 deletions
+19 -10
View File
@@ -319,16 +319,25 @@ def is_shipstation_test_mode() -> bool:
def get_shipstation_setting(key: str) -> str:
"""
Test-mode-aware lookup for ShipStation-related settings (API key,
store/warehouse/carrier IDs). When SHIPSTATION_TEST_MODE is on, looks
for a TEST_{key} override first, falling back to the normal {key}
value if the override is blank - so test mode works immediately if
your test/sandbox account mirrors production's store/warehouse/
carrier IDs, while still letting you override specific values if your
test account uses different ones. When test mode is off, this is
identical to config.get(key).
store/warehouse/carrier IDs, service codes). When SHIPSTATION_TEST_MODE
is on, ONLY the TEST_{key} value is used - no fallback to the
production value. When test mode is off, this is identical to
config.get(key).
This used to fall back to the production value when the TEST_ override
was blank, on the theory the sandbox might share IDs with production.
Confirmed against ShipStation's own docs that it never does - "Sandbox
data is isolated from production data... anything you create in the
sandbox will not be accessible in production, or vice-versa." A
fallback to a production store/warehouse/carrier ID under a sandbox API
key isn't a convenience, it's a guaranteed rejection - and it's exactly
what produced three separate confusing "not found"/"invalid" errors in
a row (carrier, then warehouse, then store) before this was caught.
Every caller of this function already raises a clear, specific
"X is not configured" error when it gets back an empty string, so
isolating test mode fully just turns those three confusing
ShipStation-side rejections into one obvious message instead.
"""
if is_shipstation_test_mode():
test_value = get(f"TEST_{key}", "")
if test_value:
return test_value
return get(f"TEST_{key}", "")
return get(key, "")
+111 -7
View File
@@ -81,18 +81,44 @@ def list_carriers() -> List[dict]:
def list_stores() -> List[dict]:
"""
Same idea as list_carriers() but for GET /v2/stores - the test/sandbox
account almost certainly has different store IDs than production too
(confirmed the same is true for carriers), so this is worth checking
before it becomes the next "not found" error rather than after.
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}/stores",
f"{API_BASE}/warehouses",
headers={"API-Key": api_key, "Accept": "application/json"},
timeout=REQUEST_TIMEOUT_SECONDS,
)
@@ -111,8 +137,86 @@ def list_stores() -> List[dict]:
except ValueError as exc:
raise ShipStationSendError("ShipStation returned a response that wasn't valid JSON.") from exc
# Some ShipStation accounts return a bare list, others wrap it - handle both.
return data if isinstance(data, list) else data.get("stores", [])
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
+84 -3
View File
@@ -159,14 +159,31 @@ class MainWindow(QMainWindow):
list_carriers_action.triggered.connect(self._on_list_carriers_clicked)
data_menu.addAction(list_carriers_action)
list_stores_action = QAction("List ShipStation Stores...", self)
list_stores_action = QAction("About ShipStation Store IDs...", self)
list_stores_action.setStatusTip(
"Shows the store IDs actually valid for whichever API key is currently active "
"(test or production)"
"ShipStation's V2 API has no way to list stores - explains where to find a "
"store_id instead"
)
list_stores_action.triggered.connect(self._on_list_stores_clicked)
data_menu.addAction(list_stores_action)
list_warehouses_action = QAction("List ShipStation Warehouses...", self)
list_warehouses_action.setStatusTip(
"Shows the warehouse IDs actually valid for whichever API key is currently active "
"(test or production)"
)
list_warehouses_action.triggered.connect(self._on_list_warehouses_clicked)
data_menu.addAction(list_warehouses_action)
create_test_warehouses_action = QAction("Create Test Warehouse(s)...", self)
create_test_warehouses_action.setStatusTip(
"Test Mode only - the sandbox account starts with zero warehouses, so this "
"creates one per company (using the same return-address settings) and saves "
"the resulting IDs into TEST_SHIPSTATION_*_WAREHOUSE_ID"
)
create_test_warehouses_action.triggered.connect(self._on_create_test_warehouses_clicked)
data_menu.addAction(create_test_warehouses_action)
orders_menu = menu_bar.addMenu("&Orders")
self._pack_ticket_action = QAction("Pack Ticket", self)
@@ -305,6 +322,70 @@ class MainWindow(QMainWindow):
)
QMessageBox.information(self, f"ShipStation Stores ({mode})", "\n".join(lines))
def _on_list_warehouses_clicked(self) -> None:
from app.services.shipstation_send import list_warehouses, ShipStationSendError
mode = "TEST" if config.is_shipstation_test_mode() else "PRODUCTION"
try:
warehouses = list_warehouses()
except ShipStationSendError as exc:
QMessageBox.critical(self, "Could not list warehouses", str(exc))
return
if not warehouses:
QMessageBox.information(
self,
f"ShipStation Warehouses ({mode})",
f"No warehouses are set up in this {mode.lower()} ShipStation account.",
)
return
lines = [f"Warehouses visible to your current {mode} API key:", ""]
for warehouse in warehouses:
lines.append(
f" warehouse_id: {warehouse.get('warehouse_id', '?')} "
f"{warehouse.get('name', '?')}"
)
QMessageBox.information(self, f"ShipStation Warehouses ({mode})", "\n".join(lines))
def _on_create_test_warehouses_clicked(self) -> None:
if not config.is_shipstation_test_mode():
QMessageBox.information(
self,
"Test Mode is off",
"Turn on ShipStation Test Mode first (Data menu). This creates a "
"warehouse in whichever ShipStation account your API key currently "
"points to, and it's meant for the sandbox account only.",
)
return
from app.services.shipstation_send import (
create_test_warehouse_for_company,
ShipStationSendError,
)
companies = [
("Signify Health", "TEST_SHIPSTATION_SIGNIFY_WAREHOUSE_ID"),
("Oak Street Health", "TEST_SHIPSTATION_OAKSTREET_WAREHOUSE_ID"),
]
lines = []
for company, setting_key in companies:
existing = config.load_settings().get(setting_key)
if existing:
lines.append(f"{company}: already set to {existing} - skipped")
continue
try:
warehouse = create_test_warehouse_for_company(company)
except ShipStationSendError as exc:
lines.append(f"{company}: FAILED - {exc}")
continue
warehouse_id = warehouse.get("warehouse_id", "")
config.save_settings({setting_key: warehouse_id})
lines.append(f"{company}: created {warehouse_id} and saved it to {setting_key}")
QMessageBox.information(self, "Create Test Warehouse(s)", "\n".join(lines))
def _on_order_double_clicked(self, order) -> None:
dialog = OrderDetailDialog(order, self)
dialog.exec()