From 821cbe7fb60d6b9aaa59d3a124a0f22334e31708 Mon Sep 17 00:00:00 2001 From: shanta3220 Date: Fri, 4 Sep 2026 20:04:33 -0400 Subject: [PATCH 1/2] PAN-2523-Sample-Gorgias-Shipbob-in-Java --- shipbob_to_gorgias/java/.gitignore | 3 + shipbob_to_gorgias/java/PANDIUM.yaml | 59 +++ shipbob_to_gorgias/java/README.md | 168 +++++++++ shipbob_to_gorgias/java/pom.xml | 98 +++++ .../java/src/main/java/sb2gorgias/Cron.java | 343 ++++++++++++++++++ .../src/main/java/sb2gorgias/GorgiasApi.java | 217 +++++++++++ .../main/java/sb2gorgias/GorgiasClient.java | 15 + .../src/main/java/sb2gorgias/HttpClient.java | 144 ++++++++ .../java/src/main/java/sb2gorgias/Lib.java | 176 +++++++++ .../java/src/main/java/sb2gorgias/Main.java | 35 ++ .../src/main/java/sb2gorgias/ShipBobApi.java | 141 +++++++ .../main/java/sb2gorgias/ShipBobClient.java | 16 + .../java/src/main/java/sb2gorgias/Util.java | 78 ++++ .../src/main/java/sb2gorgias/Webhook.java | 312 ++++++++++++++++ .../src/test/java/sb2gorgias/ClientsTest.java | 64 ++++ .../src/test/java/sb2gorgias/CronTest.java | 134 +++++++ .../java/sb2gorgias/FakeGorgiasClient.java | 59 +++ .../java/sb2gorgias/FakeShipBobClient.java | 85 +++++ .../src/test/java/sb2gorgias/Helpers.java | 177 +++++++++ .../src/test/java/sb2gorgias/ParityTest.java | 19 + .../src/test/java/sb2gorgias/WebhookTest.java | 102 ++++++ 21 files changed, 2445 insertions(+) create mode 100644 shipbob_to_gorgias/java/.gitignore create mode 100644 shipbob_to_gorgias/java/PANDIUM.yaml create mode 100644 shipbob_to_gorgias/java/README.md create mode 100644 shipbob_to_gorgias/java/pom.xml create mode 100644 shipbob_to_gorgias/java/src/main/java/sb2gorgias/Cron.java create mode 100644 shipbob_to_gorgias/java/src/main/java/sb2gorgias/GorgiasApi.java create mode 100644 shipbob_to_gorgias/java/src/main/java/sb2gorgias/GorgiasClient.java create mode 100644 shipbob_to_gorgias/java/src/main/java/sb2gorgias/HttpClient.java create mode 100644 shipbob_to_gorgias/java/src/main/java/sb2gorgias/Lib.java create mode 100644 shipbob_to_gorgias/java/src/main/java/sb2gorgias/Main.java create mode 100644 shipbob_to_gorgias/java/src/main/java/sb2gorgias/ShipBobApi.java create mode 100644 shipbob_to_gorgias/java/src/main/java/sb2gorgias/ShipBobClient.java create mode 100644 shipbob_to_gorgias/java/src/main/java/sb2gorgias/Util.java create mode 100644 shipbob_to_gorgias/java/src/main/java/sb2gorgias/Webhook.java create mode 100644 shipbob_to_gorgias/java/src/test/java/sb2gorgias/ClientsTest.java create mode 100644 shipbob_to_gorgias/java/src/test/java/sb2gorgias/CronTest.java create mode 100644 shipbob_to_gorgias/java/src/test/java/sb2gorgias/FakeGorgiasClient.java create mode 100644 shipbob_to_gorgias/java/src/test/java/sb2gorgias/FakeShipBobClient.java create mode 100644 shipbob_to_gorgias/java/src/test/java/sb2gorgias/Helpers.java create mode 100644 shipbob_to_gorgias/java/src/test/java/sb2gorgias/ParityTest.java create mode 100644 shipbob_to_gorgias/java/src/test/java/sb2gorgias/WebhookTest.java diff --git a/shipbob_to_gorgias/java/.gitignore b/shipbob_to_gorgias/java/.gitignore new file mode 100644 index 00000000..88f171bc --- /dev/null +++ b/shipbob_to_gorgias/java/.gitignore @@ -0,0 +1,3 @@ +target/ +.env +metadata.json diff --git a/shipbob_to_gorgias/java/PANDIUM.yaml b/shipbob_to_gorgias/java/PANDIUM.yaml new file mode 100644 index 00000000..176dd41a --- /dev/null +++ b/shipbob_to_gorgias/java/PANDIUM.yaml @@ -0,0 +1,59 @@ +version: 1.0 +base: Java:25 +build: mvn clean package +run: java -jar target/sb2gorgias-1.0-jar-with-dependencies.jar + +# Supplies the PAN_SEC_* credentials the clients read: SHIPBOB_ACCESS_TOKEN and +# SHIPBOB_WEBHOOK_SECRET; GORGIAS-OAUTH_ACCESS_TOKEN and GORGIAS-OAUTH_ACCOUNT. +# Both are OAuth2 connectors: Pandium runs the authorization flow when a tenant +# connects and refreshes the tokens, so the integration only reads access tokens. +connectors: + - shipbob + - gorgias-oauth + +configs: + schema: + properties: + order_start_date: + type: string + format: date + newest_order_first: + type: boolean + default: false + type: object + uischema: + elements: + - type: Section + label: Sync Orders Since + subtitle: + Set how far back the first sync reaches. Later syncs will pick up where the previous sync left off. Limited to the last 30 days. + elements: + - label: Sync orders since + scope: '#/properties/order_start_date' + type: Control + options: + trim: true + - type: Divider + - type: Section + label: Newest orders first? + subtitle: Check this box to order each customer's shipbob_orders sidebar list newest-first rather than oldest-first. + elements: + - scope: '#/properties/newest_order_first' + type: Control + type: VerticalLayout + +metadata_schema: + schema: + name: metadata_schema + properties: + # Cron-flow cursors: the point each order query resumes from next run. + new_order_start_date: + type: string + updated_order_start_date: + type: string + # Webhook-flow dedupe: "shipment_id:status" -> ISO timestamp it was + # ticketed, pruned to a 30-minute window on every run. + processed_events: + type: object + additionalProperties: + type: string diff --git a/shipbob_to_gorgias/java/README.md b/shipbob_to_gorgias/java/README.md new file mode 100644 index 00000000..5b805a7c --- /dev/null +++ b/shipbob_to_gorgias/java/README.md @@ -0,0 +1,168 @@ +# ShipBob to Gorgias in Java + +The Java implementation of the [ShipBob to Gorgias sample](../README.md). Read that first +for what the integration does and which parts of the Pandium platform it exercises; this page +covers the code, and how to build, run, and test it. + +Java 25, Maven, `unirest-java`, `org.json`, JUnit 5. No framework, no web server. + +## Layout + +``` +java/ +├── PANDIUM.yaml manifest: runtime, configs, metadata schema +├── pom.xml dependencies (unirest-java, org.json, JUnit) +├── src/main/java/sb2gorgias/ +│ ├── Main.java entry point; dispatches on run mode +│ ├── Lib.java the Pandium runtime contract: config, secrets, context, metadata +│ ├── HttpClient.java hand-rolled retry client (Unirest has no built-in backoff) +│ ├── Cron.java Flow A — resumable order sync +│ ├── Webhook.java Flow B — shipment status webhook -> ticket, with dedupe +│ ├── ShipBobClient.java / ShipBobApi.java ShipBob client +│ └── GorgiasClient.java / GorgiasApi.java Gorgias client +└── src/test/java/sb2gorgias/ both flows covered end to end; no network +``` + +`Lib.java` is the file to read first — the whole platform contract in one file: `PAN_CFG_*`/ +`PAN_SEC_*` as plain maps, `PAN_CTX_*` as named methods, the metadata file read, and the +single stdout write that hands metadata back to Pandium. It also defines `newLogger`, which +every other file gets its own named `java.util.logging.Logger` instance from. + +`ShipBobClient`/`GorgiasClient` exist because Java has no runtime monkey-patching — `Cron` +and `Webhook` depend on the interfaces, production wiring uses the real `*Api` classes, and +tests use hand-written fakes implementing the same interfaces. + +## Implementation notes + +**The run-limit deadline** is a `ScheduledExecutorService`-based watchdog in `Cron.java`, +injectable via `Cron.Deps` so tests can trigger it deterministically without waiting 9 real +minutes. Because it runs on a real background thread in production, the cursor state it +shares with the paging loop (`Cron.CursorState`) only exposes `synchronized` accessors — +every read, including log lines, goes through them. + +**No hand-rolled numeric-id formatting is needed.** `org.json` decodes JSON integers as +`Integer`/`Long`, never `Double`, so there's no scientific-notation risk when an id gets +embedded in a URL or a dedupe key — a class of bug some other ports here had to guard against +explicitly. + +**Update-date comparison and sorting use real `java.time.OffsetDateTime`** instead of string +comparison, since ShipBob's per-shipment `last_update_at` needs to be compared and sorted +correctly regardless of exact string format. The cursor values written back into tenant +metadata are still formatted to match every other port's shape exactly (6-digit microseconds, +no offset suffix) — that's a wire format other tenants' stored metadata already relies on, +not a place to diverge. + +**Date formatting for the customer sidebar** works on the raw ISO string with a regex instead +of a full parse, in `GorgiasApi.java` — ShipBob timestamps are UTC-only and this is a +display-only value, so a full `java.time` parse would be more work for no behavioral gain. + +**HTTP retry** is hand-rolled in `HttpClient.java` (exponential backoff, a small set of +retryable status codes) because `unirest-java` has no retry support of its own — the same gap +Go's port had to fill for its own HTTP stack. + +## Prerequisites + +- Java 25 (e.g. `brew install openjdk@25`) +- Maven (e.g. `brew install maven`) + +## Building + +```bash +cd java +mvn clean package +``` + +## Running the tests + +The tests cover both flows end to end — including the timeout flush and the webhook dedupe — +with no network access and no credentials: + +```bash +mvn test +``` + +`src/test/java/sb2gorgias/Helpers.java` builds a real `Pandium` directly instead of from the +environment, and `FakeShipBobClient`/`FakeGorgiasClient` implement the client interfaces +in-memory, so client helper logic (customer key resolution, payload building) still runs +under test. + +## Running it locally + +Both flows talk to the live ShipBob and Gorgias APIs, so use sandbox credentials either way. + +### With the Pandium CLI + +The CLI runs the integration in the current folder using the environment of a real tenant, so +the tenant's provisioned connector secrets are never copied onto your machine. Download it +from the Admin Dashboard under **Settings → Developer Resources**. + +```bash +pandium login # defaults to sandbox +pandium get integrations # find your integration id +pandium get tenants -i # find the tenant id to borrow +``` + +Then, from this directory: + +```bash +pandium local build # runs the manifest's build command (mvn clean package) +pandium local run # cron flow +pandium local run -m webhook # webhook flow — see below +``` + +`--mode` takes `init`, `normal`, or `webhook`, and `--path` points at a directory other than +the current one. A local `.env` overrides anything pulled from Pandium — that is both how you +change one config without touching the tenant, and how you hand the CLI a webhook payload to +run against. + +### With environment variables directly + +Pandium hands every value over as a plain environment variable in production — no `.env` +loader is built into this port, so export them directly: + +```bash +export PAN_SEC_SHIPBOB_ACCESS_TOKEN=eyJ... +export PAN_SEC_GORGIAS_OAUTH_ACCESS_TOKEN=... +export PAN_SEC_GORGIAS_OAUTH_ACCOUNT=your-store + +export PAN_CFG_ORDER_START_DATE=2026-07-01 +export PAN_CFG_NEWEST_ORDER_FIRST=false + +export PAN_CTX_RUN_MODE=normal +export PAN_CTX_TENANT_METADATA_FILE=./metadata.json +``` + +Seed the metadata file, build, then run the cron flow: + +```bash +echo '{}' > metadata.json +mvn -q clean package -DskipTests +java -jar target/sb2gorgias-1.0-jar-with-dependencies.jar +``` + +Logs stream to stderr; the last line on stdout is the JSON that Pandium would merge into +tenant metadata. To simulate resuming, paste that line into `metadata.json` and run again. + +### Exercising the webhook flow + +Neither route can invent a delivery, so both read the run triggers from the environment — an +array whose `payload.file` points at a body on disk, exactly as Pandium would supply it. +Write one by hand using the same shape the tests use: + +```bash +mkdir -p /tmp/wh +cat > /tmp/wh/event.json <<'EOF' +{"id": 456789, "order_id": 289012345, "reference_id": "MERCHANT-ORDER-1001", + "status": "Delivered", "status_details": [], + "tracking": {"carrier": "USPS", "tracking_number": "9400100000000000000000"}, + "delivery_date": "2026-07-09T18:22:00Z", + "products": [{"name": "Pinnacle Shampoo", "sku": "PIN-100", + "inventory_items": [{"name": "Pinnacle Shampoo", "quantity": 4}]}], + "recipient": {"name": "Jane Buyer", "email": "jane@example.com", + "address": {"address1": "100 Nowhere Blvd", "city": "Gotham City", "country": "US"}}} +EOF +export PAN_CTX_RUN_TRIGGERS='[{"id":"t1","source":"webhook","payload":{"file":"/tmp/wh/event.json"}}]' +export PAN_CTX_RUN_MODE=webhook +``` + +Then run it the same way as above (via the CLI or the built jar directly). diff --git a/shipbob_to_gorgias/java/pom.xml b/shipbob_to_gorgias/java/pom.xml new file mode 100644 index 00000000..64a6d772 --- /dev/null +++ b/shipbob_to_gorgias/java/pom.xml @@ -0,0 +1,98 @@ + + + + 4.0.0 + com.pandium + sb2gorgias + 1.0 + jar + sb2gorgias + + + UTF-8 + 25 + + + + + + org.apache.maven.plugins + maven-checkstyle-plugin + 3.6.0 + + false + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.15.0 + + + org.apache.maven.plugins + maven-jar-plugin + 3.5.0 + + + + true + sb2gorgias.Main + + + + + + org.apache.maven.plugins + maven-assembly-plugin + 3.8.0 + + + jar-with-dependencies + bin + src + + + + sb2gorgias.Main + + + + + + build-jar-with-dependencies + package + + single + + + + + + org.apache.maven.plugins + maven-surefire-plugin + 3.5.2 + + + + + + + com.konghq + unirest-java + 3.14.5 + + + org.json + json + 20250517 + + + org.junit.jupiter + junit-jupiter + 5.11.4 + test + + + diff --git a/shipbob_to_gorgias/java/src/main/java/sb2gorgias/Cron.java b/shipbob_to_gorgias/java/src/main/java/sb2gorgias/Cron.java new file mode 100644 index 00000000..fef98c59 --- /dev/null +++ b/shipbob_to_gorgias/java/src/main/java/sb2gorgias/Cron.java @@ -0,0 +1,343 @@ +package sb2gorgias; + +import java.time.Duration; +import java.time.OffsetDateTime; +import java.time.ZoneOffset; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; +import java.util.function.IntConsumer; +import java.util.logging.Level; +import java.util.logging.Logger; + +import org.json.JSONObject; + +/** The cron flow: ShipBob orders -> Gorgias customer sidebar. + * + * Keeps each Gorgias customer's data.pandium.shipbob_orders in sync with that customer's + * recent ShipBob orders. Runs on a schedule and resumes where the last run left off, using + * tenant metadata as the cursor. + * + * The run is bounded at ~10 minutes by Pandium. To stay resumable, the loop keeps cursor state + * current as each order is processed, and a watchdog timer flushes that state before the hard + * kill. Exiting 0 on timeout means the partial cursor is merged into metadata and the next run + * picks up from there. + * + * The two cursors resume differently. new_order_start_date climbs per order over an + * oldest-first query, so it is sound wherever the run stops. updated_order_start_date is the + * minimum across every page, so it only holds once the query is exhausted - an unread page can + * carry an older update - and a run cut short leaves it where it started. Re-syncing what it + * covers again is harmless: customer writes are idempotent PUTs. */ +final class Cron { + private static final Logger LOGGER = Pandium.newLogger("cron"); + + private static final Duration ALARM_DURATION = Duration.ofSeconds(540); // self-imposed 9-min alarm + static final Duration ONE_MONTH = Duration.ofDays(30); + private static final int MAX_ORDERS_TO_SYNC = 10; // most recent N orders kept on each customer + + private Cron() { + } + + /** Keeps a cursor within [now - 1 month, now]. Unparseable/missing values fall back to + * one month ago (the oldest window we ever fetch). */ + static OffsetDateTime clamp(String value, OffsetDateTime now) { + OffsetDateTime floor = now.minus(ONE_MONTH); + Optional parsed = Util.parseTimestamp(value); + if (parsed.isEmpty()) { + return floor; + } + OffsetDateTime dt = parsed.get(); + if (dt.isBefore(floor)) { + return floor; + } + if (dt.isAfter(now)) { + return now; + } + return dt; + } + + private static final DateTimeFormatter CURSOR_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSSSSS"); + + static String formatCursor(OffsetDateTime t) { + return t.withOffsetSameInstant(ZoneOffset.UTC).format(CURSOR_FORMAT); + } + + /** The timeout record: the cursor written on either outcome. Shared between the paging + * loop and the watchdog thread, so every access - including log lines - must go through + * the synchronized accessors below. */ + static final class CursorState { + private String newOrderStartDate; + private String updatedOrderStartDate; + + CursorState(String newOrderStartDate, String updatedOrderStartDate) { + this.newOrderStartDate = newOrderStartDate; + this.updatedOrderStartDate = updatedOrderStartDate; + } + + synchronized void setNew(String v) { + newOrderStartDate = v; + } + + synchronized void setUpdated(String v) { + updatedOrderStartDate = v; + } + + synchronized String getNew() { + return newOrderStartDate; + } + + synchronized String getUpdated() { + return updatedOrderStartDate; + } + + synchronized JSONObject snapshot() { + JSONObject result = new JSONObject(); + result.put("new_order_start_date", newOrderStartDate); + result.put("updated_order_start_date", updatedOrderStartDate); + return result; + } + } + + private static long orderId(JSONObject order) { + return Util.toLong(order.opt("id")); + } + + /** Merges orderPayload into a customer's order list (replace by id, else append), then + * sorts and trims to the most recent maxOrdersToSync. */ + static List upsertOrder(List orders, JSONObject orderPayload, boolean newestFirst) { + long newId = orderId(orderPayload); + for (int i = 0; i < orders.size(); i++) { + if (orderId(orders.get(i)) == newId) { + orders.set(i, orderPayload); // in-place replace; no re-sort/trim needed + return orders; + } + } + + orders.add(orderPayload); + Comparator byId = Comparator.comparingLong(Cron::orderId); + orders.sort(newestFirst ? byId.reversed() : byId); + if (orders.size() > MAX_ORDERS_TO_SYNC) { + orders = newestFirst + ? new ArrayList<>(orders.subList(0, MAX_ORDERS_TO_SYNC)) + : new ArrayList<>(orders.subList(orders.size() - MAX_ORDERS_TO_SYNC, orders.size())); + } + return orders; + } + + /** Finds-or-creates the order's Gorgias customer, then PUT/POSTs its updated + * data.pandium.shipbob_orders. cache accumulates customer payloads within a run so + * multiple orders for one customer batch onto the same record. */ + static void processOrder(JSONObject order, GorgiasClient gorgias, Map cache, + boolean newestFirst) { + String key = GorgiasApi.customerKey(order); + String email = GorgiasApi.validEmail(Util.asString(Util.deepGet(order, "recipient.email", ""))); + + JSONObject customer = cache.get(key); + if (customer == null) { + JSONObject existing; + try { + existing = gorgias.findCustomer(email.isEmpty() ? null : email, email.isEmpty() ? key : null); + } catch (RuntimeException e) { + LOGGER.log(Level.SEVERE, "cannot fetch customer; skipping order; order_id=" + + Util.deepGet(order, "id", "") + " customer_key=" + key + " error=" + e.getMessage()); + return; + } + + if (existing != null) { + // Anything already under data.pandium came from outside this integration - a + // hand-edited customer can carry {"pandium": null} - so check the type at + // every level rather than just the leaf. + Object dataObj = existing.opt("data"); + JSONObject data = dataObj instanceof JSONObject d ? d : new JSONObject(); + Object pandiumObj = data.opt("pandium"); + JSONObject pandium = pandiumObj instanceof JSONObject p ? p : new JSONObject(); + if (!(pandium.opt("shipbob_orders") instanceof org.json.JSONArray)) { + pandium.put("shipbob_orders", new org.json.JSONArray()); + } + data.put("pandium", pandium); + customer = new JSONObject(); + customer.put("id", existing.opt("id")); + customer.put("data", data); + } else { + customer = GorgiasApi.newCustomerPayload(order, key); + } + cache.put(key, customer); + } + + JSONObject data = customer.optJSONObject("data"); + JSONObject pandium = data.optJSONObject("pandium"); + org.json.JSONArray ordersAny = pandium.optJSONArray("shipbob_orders"); + List orders = new ArrayList<>(); + for (int i = 0; i < ordersAny.length(); i++) { + if (ordersAny.opt(i) instanceof JSONObject o) { + orders.add(o); + } + } + orders = upsertOrder(orders, GorgiasApi.orderDataPayload(order), newestFirst); + pandium.put("shipbob_orders", new org.json.JSONArray(orders)); + + try { + if (customer.has("id") && !customer.isNull("id")) { + gorgias.updateCustomer(Util.toLong(customer.opt("id")), customer); + } else { + customer.put("id", gorgias.createCustomer(customer)); + } + } catch (RuntimeException e) { + LOGGER.log(Level.SEVERE, "Failed to upsert Gorgias customer " + key + ": " + e.getMessage()); + } + } + + /** Schedules onTimeout to run after deadline, returning a function that cancels it. + * Injectable so tests can trigger the timeout deterministically without waiting real + * minutes. */ + interface WatchdogArmer { + Runnable arm(Duration deadline, Runnable onTimeout); + } + + private static final ScheduledExecutorService WATCHDOG_EXECUTOR = Executors.newSingleThreadScheduledExecutor(r -> { + Thread t = new Thread(r, "cron-watchdog"); + t.setDaemon(true); + return t; + }); + + static Runnable defaultArmWatchdog(Duration deadline, Runnable onTimeout) { + ScheduledFuture future = WATCHDOG_EXECUTOR.schedule(onTimeout, deadline.toMillis(), TimeUnit.MILLISECONDS); + return () -> future.cancel(false); + } + + /** Everything runCron touches arrives through this - production wiring vs. test fakes. */ + static final class Deps { + final ShipBobClient shipBob; + final GorgiasClient gorgias; + final WatchdogArmer armWatchdog; + final IntConsumer exit; // defaults to System.exit; tests substitute something that doesn't kill the JVM + final OffsetDateTime now; + + Deps(ShipBobClient shipBob, GorgiasClient gorgias, WatchdogArmer armWatchdog, IntConsumer exit, + OffsetDateTime now) { + this.shipBob = shipBob; + this.gorgias = gorgias; + this.armWatchdog = armWatchdog; + this.exit = exit; + this.now = now; + } + } + + static JSONObject cronRun(Pandium pandium) { + ShipBobApi shipBob = new ShipBobApi(pandium); + GorgiasApi gorgias = new GorgiasApi(pandium); + return runCron(pandium, new Deps(shipBob, gorgias, Cron::defaultArmWatchdog, System::exit, + OffsetDateTime.now(ZoneOffset.UTC))); + } + + private static String firstNonEmpty(String... values) { + for (String v : values) { + if (v != null && !v.isEmpty()) { + return v; + } + } + return ""; + } + + /** The tested core: everything it touches arrives through deps. */ + static JSONObject runCron(Pandium pandium, Deps deps) { + OffsetDateTime now = deps.now; + JSONObject metadata = pandium.metadata(); + if (metadata == null) { + metadata = new JSONObject(); + } + String fallback = pandium.config.get("order_start_date"); + + OffsetDateTime newCursor = clamp( + firstNonEmpty(metadata.optString("new_order_start_date", ""), fallback == null ? "" : fallback), now); + OffsetDateTime updatedCursor = clamp( + firstNonEmpty(metadata.optString("updated_order_start_date", ""), fallback == null ? "" : fallback), now); + + CursorState state = new CursorState(formatCursor(newCursor), formatCursor(updatedCursor)); + + Runnable cancel = deps.armWatchdog.arm(ALARM_DURATION, () -> { + LOGGER.log(Level.SEVERE, "approaching the run-time limit; flushing cursor for the next run"); + // Same writer the normal path uses, so there is exactly one route to stdout. + pandium.updateMetadata(state.snapshot()); + deps.exit.accept(0); // timed-out run still counts as successful -> partial cursor merged + }); + + Map cache = new HashMap<>(); + boolean newestFirst = "true".equalsIgnoreCase(pandium.config.get("newest_order_first")); + + // New orders: SortOrder=Oldest, so created_date advances forward monotonically. + LOGGER.log(Level.INFO, "syncing new ShipBob orders; start_date=" + state.getNew()); + int page = 1; + while (true) { + List orders; + try { + orders = deps.shipBob.newOrdersPage(newCursor, page); + } catch (RuntimeException e) { + cancel.run(); + throw e; + } + if (orders.isEmpty()) { + break; + } + for (JSONObject order : orders) { + LOGGER.log(Level.INFO, "processing new order; order_id=" + order.opt("id")); + processOrder(order, deps.gorgias, cache, newestFirst); + // created_date is YYYY-MM-DDThh:mm:ss.sssssss+00:00; trim to 26 chars for a + // valid (naive, microsecond) date-time. + String created = order.optString("created_date", ""); + if (!created.isEmpty()) { + state.setNew(Util.trimTo(created, 26)); + } + } + page++; + } + + // Updated orders: keyed off shipment last_update_at (see ShipBobApi.updatedOrdersPage). + LOGGER.log(Level.INFO, "syncing updated ShipBob orders; start_date=" + state.getUpdated()); + page = 1; + // Each page is sorted newest-first, but pages are not sorted relative to each other, + // so the cursor is the minimum across every processed order - not whatever the last + // order of the last page happened to carry. Kept in a local variable, not + // CursorState, until the loop ends: every update date is, by construction, later than + // the starting cursor, so folding that in would pin the cursor there forever, and a + // partial minimum would sit newer than the pages still unread. + OffsetDateTime oldestUpdate = null; + while (true) { + List orders; + try { + orders = deps.shipBob.updatedOrdersPage(updatedCursor, page); + } catch (RuntimeException e) { + cancel.run(); + throw e; + } + if (orders.isEmpty()) { + break; + } + for (JSONObject order : orders) { + LOGGER.log(Level.INFO, "processing updated order; order_id=" + order.opt("id")); + processOrder(order, deps.gorgias, cache, newestFirst); + OffsetDateTime updateDate = deps.shipBob.updateDate(order, updatedCursor); + if (oldestUpdate == null || updateDate.isBefore(oldestUpdate)) { + oldestUpdate = updateDate; + } + } + page++; + } + + // Every page is in, so the minimum is final and safe to resume from. + if (oldestUpdate != null) { + state.setUpdated(formatCursor(oldestUpdate)); + } + + cancel.run(); // made it - no timeout to flush + return state.snapshot(); + } +} diff --git a/shipbob_to_gorgias/java/src/main/java/sb2gorgias/GorgiasApi.java b/shipbob_to_gorgias/java/src/main/java/sb2gorgias/GorgiasApi.java new file mode 100644 index 00000000..811dd3eb --- /dev/null +++ b/shipbob_to_gorgias/java/src/main/java/sb2gorgias/GorgiasApi.java @@ -0,0 +1,217 @@ +package sb2gorgias; + +import java.time.Duration; +import java.util.Map; +import java.util.Set; +import java.util.logging.Level; +import java.util.logging.Logger; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import org.json.JSONArray; +import org.json.JSONObject; + +/** Gorgias API client. + * + * The cron flow upserts customers (writing ShipBob order history to + * data.pandium.shipbob_orders); the webhook flow creates tickets. + * + * Auth is OAuth2 via Pandium's gorgias-oauth connector. Pandium runs the authorization flow + * when the tenant connects and refreshes the token on its own schedule, so this client never + * sees a client secret, never posts to a token endpoint, and holds no refresh logic - it reads + * whatever access token is current for this run and sends it as a bearer token. A refresh that + * fails is a platform concern and surfaces as Failed (Refresh) on the run, not as an error this + * code has to handle. */ +final class GorgiasApi implements GorgiasClient { + private static final Logger LOGGER = Pandium.newLogger("gorgias"); + + // Mirrors the check the older integration used, so a recipient email found here is one + // Gorgias would actually accept. + private static final Pattern EMAIL_RE = Pattern.compile( + "([-!#-'*+/-9=?A-Z^-~]+(\\.[-!#-'*+/-9=?A-Z^-~]+)*|\"([\\]!#-\\[^-~ \\t]|(\\\\[\\t -~]))+\")" + + "@([-!#-'*+/-9=?A-Z^-~]+(\\.[-!#-'*+/-9=?A-Z^-~]+)*|\\[[\\t -Z^-~]*])" + ); + + // Captures the date/time portion of a ShipBob ISO timestamp, ignoring the fractional + // seconds and offset entirely. Works on the raw string instead of a full parse, since + // ShipBob timestamps are UTC-only - there is no timezone to convert, and this is a + // display-only format for the customer sidebar. + private static final Pattern ISO_RE = Pattern.compile("^(\\d{4})-(\\d{2})-(\\d{2})T(\\d{2}):(\\d{2}):(\\d{2})"); + + final String apiUrl; + final HttpClient httpClient; + + GorgiasApi(Pandium pandium) { + Map secrets = pandium.secrets; + String token = secrets.get("gorgias_oauth_access_token"); + String account = secrets.get("gorgias_oauth_account"); + if (token == null || token.isEmpty() || account == null || account.isEmpty()) { + throw new IllegalStateException( + "PAN_SEC_GORGIAS_OAUTH_ACCESS_TOKEN and PAN_SEC_GORGIAS_OAUTH_ACCOUNT are required"); + } + this.apiUrl = "https://" + account.toLowerCase() + ".gorgias.com/api"; + // The connector reports its own scheme; every current Gorgias token is a bearer. + String tokenType = secrets.get("gorgias_oauth_token_type"); + if (tokenType == null || tokenType.isEmpty()) { + tokenType = "Bearer"; + } + // Exponential backoff: 2s, 4s, 8s, ... GET/POST/PUT are all retried. + this.httpClient = new HttpClient(apiUrl, tokenType + " " + token, Duration.ofSeconds(2), + Set.of("GET", "POST", "PUT")); + } + + /** Looks a customer up by email or externalId and returns the detail record (so callers + * can read data), or null if not found. A given email/externalId maps to at most one + * customer, so no pagination is needed. */ + @Override + public JSONObject findCustomer(String email, String externalId) { + LOGGER.log(Level.INFO, "looking for gorgias customer: " + email + ", " + externalId); + Map query; + if (email != null && !email.isEmpty()) { + query = Map.of("email", email.toLowerCase()); + } else if (externalId != null && !externalId.isEmpty()) { + query = Map.of("external_id", externalId); + } else { + return null; + } + + Object res = httpClient.get("/customers", query); + JSONObject body = res instanceof JSONObject j ? j : new JSONObject(); + JSONArray rows = body.optJSONArray("data"); + if (rows == null || rows.isEmpty()) { + LOGGER.log(Level.INFO, "Customer not found"); + return null; + } + + JSONObject first = rows.optJSONObject(0); + Object id = first == null ? null : first.opt("id"); + Object detail = httpClient.get("/customers/" + id, null); + LOGGER.log(Level.INFO, "Customer found"); + return detail instanceof JSONObject j ? j : null; + } + + @Override + public long createCustomer(JSONObject payload) { + LOGGER.log(Level.INFO, "creating new gorgias customer"); + Object res; + try { + res = httpClient.post("/customers", payload); + } catch (RuntimeException e) { + LOGGER.log(Level.SEVERE, "Create customer failed: " + e.getMessage()); + throw e; + } + LOGGER.log(Level.INFO, "Customer created successfully"); + return res instanceof JSONObject j ? j.optLong("id") : 0; + } + + @Override + public void updateCustomer(long id, JSONObject payload) { + LOGGER.log(Level.INFO, "updating gorgias customer " + id); + try { + httpClient.put("/customers/" + id, payload); + } catch (RuntimeException e) { + LOGGER.log(Level.SEVERE, "Update customer " + id + " failed: " + e.getMessage()); + throw e; + } + LOGGER.log(Level.INFO, "customer updated"); + } + + @Override + public JSONObject createTicket(JSONObject payload) { + LOGGER.log(Level.INFO, "creating gorgias ticket"); + Object res; + try { + res = httpClient.post("/tickets", payload); + } catch (RuntimeException e) { + LOGGER.log(Level.SEVERE, "Create ticket failed: " + e.getMessage()); + throw e; + } + return res instanceof JSONObject j ? j : new JSONObject(); + } + + /** Returns email if Gorgias would accept it, else "". */ + static String validEmail(String email) { + if (email != null && !email.isEmpty() && !email.contains(".@") && EMAIL_RE.matcher(email).matches()) { + return email; + } + return ""; + } + + /** The key identifying an order's customer: a valid recipient email when present, + * otherwise a synthetic "name address1 city country". */ + static String customerKey(JSONObject order) { + String email = validEmail(Util.asString(Util.deepGet(order, "recipient.email", ""))); + if (!email.isEmpty()) { + return email; + } + Object addressObj = Util.deepGet(order, "recipient.address", new JSONObject()); + JSONObject address = addressObj instanceof JSONObject a ? a : new JSONObject(); + return String.join(" ", + Util.asString(Util.deepGet(order, "recipient.name", "")), + Util.asString(Util.deepGet(address, "address1", "")), + Util.asString(Util.deepGet(address, "city", "")), + Util.asString(Util.deepGet(address, "country", "")) + ); + } + + /** Body for POST /customers when the customer does not yet exist. */ + static JSONObject newCustomerPayload(JSONObject order, String key) { + JSONObject payload = new JSONObject(); + payload.put("name", Util.deepGet(order, "recipient.name", "")); + payload.put("external_id", key); + payload.put("data", new JSONObject().put("pandium", new JSONObject().put("shipbob_orders", new JSONArray()))); + String email = validEmail(Util.asString(Util.deepGet(order, "recipient.email", ""))); + if (!email.isEmpty()) { + payload.put("email", email); + } + return payload; + } + + /** The single order entry stored in data.pandium.shipbob_orders. */ + static JSONObject orderDataPayload(JSONObject order) { + Object shipmentsObj = Util.deepGet(order, "shipments", new JSONArray()); + JSONArray shipments = shipmentsObj instanceof JSONArray a ? a : new JSONArray(); + for (int i = 0; i < shipments.length(); i++) { + if (shipments.opt(i) instanceof JSONObject shipment) { + for (String field : new String[] {"estimated_fulfillment_date", "actual_fulfillment_date"}) { + String value = shipment.optString(field, ""); + if (!value.isEmpty()) { + shipment.put(field, formatDate(value)); + } + } + Object shipmentId = shipment.opt("id"); + shipment.put("url", "https://web.shipbob.com/App/Merchant/#/Orders/" + + (shipmentId == null ? "" : shipmentId) + "/"); + } + } + JSONObject payload = new JSONObject(); + payload.put("id", Util.deepGet(order, "id", "")); + payload.put("created_date", formatDate(Util.asString(Util.deepGet(order, "created_date", "")))); + payload.put("purchase_date", formatDate(Util.asString(Util.deepGet(order, "purchase_date", "")))); + payload.put("reference_id", Util.deepGet(order, "reference_id", "")); + payload.put("order_number", Util.deepGet(order, "order_number", "")); + payload.put("status", Util.deepGet(order, "status", "")); + payload.put("type", Util.deepGet(order, "type", "")); + payload.put("channel", Util.deepGet(order, "channel", new JSONObject())); + payload.put("shipping_method", Util.deepGet(order, "shipping_method", "")); + payload.put("recipient", Util.deepGet(order, "recipient", new JSONObject())); + payload.put("products", Util.deepGet(order, "products", new JSONArray())); + payload.put("tags", Util.deepGet(order, "tags", new JSONArray())); + payload.put("shipments", shipments); + return payload; + } + + /** Renders a ShipBob ISO timestamp for the customer sidebar; passes through anything + * unparseable. */ + static String formatDate(String value) { + if (value == null || value.isEmpty()) { + return ""; + } + Matcher m = ISO_RE.matcher(value); + if (!m.find()) { + return value; + } + return String.format("%s/%s/%s %s:%s:%s UTC", m.group(3), m.group(2), m.group(1), m.group(4), m.group(5), + m.group(6)); + } +} diff --git a/shipbob_to_gorgias/java/src/main/java/sb2gorgias/GorgiasClient.java b/shipbob_to_gorgias/java/src/main/java/sb2gorgias/GorgiasClient.java new file mode 100644 index 00000000..30aad3e7 --- /dev/null +++ b/shipbob_to_gorgias/java/src/main/java/sb2gorgias/GorgiasClient.java @@ -0,0 +1,15 @@ +package sb2gorgias; + +import org.json.JSONObject; + +/** What Cron/Webhook depend on for network calls - satisfied by GorgiasApi and, in tests, by + * a fake. */ +interface GorgiasClient { + JSONObject findCustomer(String email, String externalId); // null = not found + + long createCustomer(JSONObject payload); + + void updateCustomer(long id, JSONObject payload); + + JSONObject createTicket(JSONObject payload); +} diff --git a/shipbob_to_gorgias/java/src/main/java/sb2gorgias/HttpClient.java b/shipbob_to_gorgias/java/src/main/java/sb2gorgias/HttpClient.java new file mode 100644 index 00000000..90695f02 --- /dev/null +++ b/shipbob_to_gorgias/java/src/main/java/sb2gorgias/HttpClient.java @@ -0,0 +1,144 @@ +package sb2gorgias; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.Map; +import java.util.Set; + +import org.json.JSONException; +import org.json.JSONObject; +import org.json.JSONTokener; + +import kong.unirest.HttpResponse; +import kong.unirest.Unirest; +import kong.unirest.UnirestException; + +/** A small HTTP client with exponential backoff retry, scoped to one API's base URL and auth + * header. requester is swappable so tests can stub the network without a full HTTP mock. */ +final class HttpClient { + private static final Set RETRY_STATUSES = Set.of(429, 502, 503, 504); + private static final int MAX_ATTEMPTS = 6; + + /** One raw HTTP call, decoupled from Unirest so tests can fake it directly. */ + interface RawRequester { + RawResponse send(String method, String url, String body) throws IOException; + } + + record RawResponse(int status, String body) { + } + + private final String baseUrl; + private final String authorization; + private final Duration backoff; + private final Set retryMethods; + RawRequester requester; + + HttpClient(String baseUrl, String authorization, Duration backoff, Set retryMethods) { + this.baseUrl = baseUrl; + this.authorization = authorization; + this.backoff = backoff; + this.retryMethods = retryMethods; + this.requester = this::unirestSend; + } + + String authorization() { + return authorization; + } + + Object get(String path, Map query) { + StringBuilder url = new StringBuilder(baseUrl).append(path); + if (query != null && !query.isEmpty()) { + url.append('?'); + boolean first = true; + for (Map.Entry entry : query.entrySet()) { + if (!first) { + url.append('&'); + } + first = false; + url.append(encode(entry.getKey())).append('=').append(encode(entry.getValue())); + } + } + return send("GET", url.toString(), null); + } + + Object post(String path, JSONObject body) { + return send("POST", baseUrl + path, body == null ? null : body.toString()); + } + + Object put(String path, JSONObject body) { + return send("PUT", baseUrl + path, body == null ? null : body.toString()); + } + + /** Performs one request, retrying on a network error or a retryable status code when the + * method is allowed to retry. Backoff is backoff * 2^(attempt-2) for attempts after the + * first, no jitter. A response with an empty body returns null, not an error - that's what + * lets a caller distinguish "genuinely nothing here" from a real failure. */ + private Object send(String method, String url, String body) { + RuntimeException lastError = null; + for (int attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) { + if (attempt > 1) { + sleep(backoff.multipliedBy(1L << (attempt - 2))); + } + + RawResponse response; + try { + response = requester.send(method, url, body); + } catch (IOException e) { + lastError = new RuntimeException(e.getMessage(), e); + if (retryMethods.contains(method)) { + continue; + } + throw lastError; + } + + if (response.status() >= 400) { + lastError = new RuntimeException("HTTP " + response.status() + ": " + response.body()); + if (RETRY_STATUSES.contains(response.status()) && retryMethods.contains(method)) { + continue; + } + throw lastError; + } + + if (response.body() == null || response.body().isBlank()) { + return null; + } + try { + return new JSONTokener(response.body()).nextValue(); + } catch (JSONException e) { + throw new RuntimeException("could not decode response body: " + e.getMessage(), e); + } + } + throw lastError; + } + + private RawResponse unirestSend(String method, String url, String body) throws IOException { + try { + HttpResponse res = switch (method) { + case "GET" -> Unirest.get(url).headers(headers()).asString(); + case "POST" -> Unirest.post(url).headers(headers()).body(body == null ? "" : body).asString(); + case "PUT" -> Unirest.put(url).headers(headers()).body(body == null ? "" : body).asString(); + default -> throw new IllegalArgumentException("unsupported method: " + method); + }; + return new RawResponse(res.getStatus(), res.getBody()); + } catch (UnirestException e) { + throw new IOException(e); + } + } + + private Map headers() { + return Map.of("accept", "application/json", "content-type", "application/json", "Authorization", authorization); + } + + private static String encode(String s) { + return java.net.URLEncoder.encode(s, StandardCharsets.UTF_8); + } + + private static void sleep(Duration d) { + try { + Thread.sleep(d.toMillis()); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } +} diff --git a/shipbob_to_gorgias/java/src/main/java/sb2gorgias/Lib.java b/shipbob_to_gorgias/java/src/main/java/sb2gorgias/Lib.java new file mode 100644 index 00000000..b801b0f7 --- /dev/null +++ b/shipbob_to_gorgias/java/src/main/java/sb2gorgias/Lib.java @@ -0,0 +1,176 @@ +package sb2gorgias; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Paths; +import java.time.Instant; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.logging.ConsoleHandler; +import java.util.logging.Formatter; +import java.util.logging.Level; +import java.util.logging.LogRecord; +import java.util.logging.Logger; + +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; + +/** + * Formats log lines as "[timestamp] [module] LEVEL: message". Logs go to stderr; stdout is + * reserved for the JSON metadata Pandium reads back. Prints Level.SEVERE as "ERROR", matching + * the other language ports - java.util.logging's own naming is the outlier here. + */ +final class LineFormatter extends Formatter { + private static final DateTimeFormatter TIMESTAMP_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); + + private final String module; + + LineFormatter(String module) { + this.module = module; + } + + @Override + public String format(LogRecord record) { + String timestamp = TIMESTAMP_FORMAT.format(Instant.ofEpochMilli(record.getMillis()).atZone(ZoneId.systemDefault())); + String level = record.getLevel() == Level.SEVERE ? "ERROR" : record.getLevel().getName(); + return String.format("[%s] [%s] %s: %s%n", timestamp, module, level, record.getMessage()); + } +} + +/** One webhook delivery handed to this run: the raw request body, plus the trigger id, which + * is useful for correlating with the run log. */ +record WebhookDelivery(String id, String body) { +} + +/** + * Everything Pandium hands to an integration at runtime. config (PAN_CFG_*) and secrets + * (PAN_SEC_*) hold arbitrary keys defined per integration, so they are looked up by free-text + * name. context (PAN_CTX_*) is controlled by Pandium, so its values are exposed through named + * methods instead. + */ +final class Pandium { + private static final Logger LOGGER = newLogger("lib"); + + final Map config; + final Map secrets; + private final Map context; + private JSONObject metadataCache; + private boolean metadataLoaded; + + Pandium(Map config, Map secrets, Map context) { + this.config = config; + this.secrets = secrets; + this.context = context; + } + + /** Returns a logger scoped to the calling file, named after it. */ + static Logger newLogger(String module) { + Logger logger = Logger.getAnonymousLogger(); + logger.setUseParentHandlers(false); + ConsoleHandler handler = new ConsoleHandler(); + handler.setFormatter(new LineFormatter(module)); + logger.addHandler(handler); + return logger; + } + + static Pandium fromEnv() { + return new Pandium(fromEnvPrefix("PAN_CFG_"), fromEnvPrefix("PAN_SEC_"), fromEnvPrefix("PAN_CTX_")); + } + + /** Collects environment variables starting with prefix, stripping the prefix and + * lower-casing the remaining key. */ + private static Map fromEnvPrefix(String prefix) { + Map result = new HashMap<>(); + for (Map.Entry entry : System.getenv().entrySet()) { + if (entry.getKey().startsWith(prefix)) { + result.put(entry.getKey().substring(prefix.length()).toLowerCase(), entry.getValue()); + } + } + return result; + } + + /** The run mode for this invocation (e.g. "init", "webhook"). */ + String runMode() { + return context.get("run_mode"); + } + + /** The triggers that caused this run, parsed from JSON. Relevant for webhook invocations, + * where each trigger's payload.file names a file holding the raw webhook body. */ + JSONArray runTriggers() { + String raw = context.get("run_triggers"); + if (raw == null || raw.isEmpty()) { + return new JSONArray(); + } + try { + return new JSONArray(raw); + } catch (JSONException e) { + LOGGER.log(Level.SEVERE, "could not parse run triggers as JSON: " + raw + ": " + e.getMessage()); + return new JSONArray(); + } + } + + /** The webhook deliveries bundled into this run. + * + * Pandium debounces triggers per tenant, so deliveries that arrive while a run is in + * flight are bundled into the next one - a webhook run carries N of these, not one. */ + List webhookDeliveries() { + List deliveries = new ArrayList<>(); + JSONArray triggers = runTriggers(); + for (int i = 0; i < triggers.length(); i++) { + JSONObject trigger = triggers.optJSONObject(i); + if (trigger == null || !"webhook".equals(trigger.opt("source"))) { + continue; + } + JSONObject payload = trigger.optJSONObject("payload"); + String file = payload == null ? null : payload.optString("file", null); + if (file == null || file.isEmpty()) { + LOGGER.log(Level.WARNING, "webhook trigger " + trigger.opt("id") + " has no payload file"); + continue; + } + try { + String body = Files.readString(Paths.get(file), StandardCharsets.UTF_8); + Object idVal = trigger.opt("id"); + deliveries.add(new WebhookDelivery(idVal == null ? "" : String.valueOf(idVal), body)); + } catch (IOException e) { + LOGGER.log(Level.SEVERE, "could not read webhook payload " + file + ": " + e.getMessage()); + } + } + return deliveries; + } + + /** The tenant metadata persisted by the previous run, parsed as JSON. Returns null if + * there is none or it could not be read/parsed. Read once and cached, since it never + * changes within a run. */ + JSONObject metadata() { + if (metadataLoaded) { + return metadataCache; + } + metadataLoaded = true; + String filename = context.get("tenant_metadata_file"); + if (filename == null || filename.isEmpty()) { + return null; + } + try { + String raw = Files.readString(Paths.get(filename), StandardCharsets.UTF_8); + metadataCache = new JSONObject(raw); + } catch (IOException | JSONException e) { + LOGGER.log(Level.SEVERE, "could not read tenant metadata from " + filename + ": " + e.getMessage()); + } + return metadataCache; + } + + /** Merges metadata into the tenant metadata that the next run reads back. Pandium reads + * the last non-empty line of stdout as the metadata, so anything printed to stdout after + * this call replaces it. */ + void updateMetadata(JSONObject metadata) { + String serialized = metadata.toString(); + LOGGER.log(Level.INFO, "updating metadata with " + serialized); + System.out.println(serialized); + } +} diff --git a/shipbob_to_gorgias/java/src/main/java/sb2gorgias/Main.java b/shipbob_to_gorgias/java/src/main/java/sb2gorgias/Main.java new file mode 100644 index 00000000..a1c569f9 --- /dev/null +++ b/shipbob_to_gorgias/java/src/main/java/sb2gorgias/Main.java @@ -0,0 +1,35 @@ +package sb2gorgias; + +import java.util.logging.Level; +import java.util.logging.Logger; + +import org.json.JSONObject; + +final class Main { + private static final Logger LOGGER = Pandium.newLogger("main"); + + private Main() { + } + + static JSONObject run(String mode, Pandium pandium) { + if ("webhook".equals(mode)) { + return Webhook.webhookRun(pandium); + } + return Cron.cronRun(pandium); + } + + public static void main(String[] args) { + Pandium pandium = Pandium.fromEnv(); + LOGGER.log(Level.INFO, "Syncing ShipBob to Gorgias; this run is in mode: " + pandium.runMode()); + + JSONObject metadata; + try { + metadata = run(pandium.runMode(), pandium); + } catch (RuntimeException e) { + LOGGER.log(Level.SEVERE, "run failed: " + e.getMessage()); + System.exit(1); + return; + } + pandium.updateMetadata(metadata); + } +} diff --git a/shipbob_to_gorgias/java/src/main/java/sb2gorgias/ShipBobApi.java b/shipbob_to_gorgias/java/src/main/java/sb2gorgias/ShipBobApi.java new file mode 100644 index 00000000..cf377af8 --- /dev/null +++ b/shipbob_to_gorgias/java/src/main/java/sb2gorgias/ShipBobApi.java @@ -0,0 +1,141 @@ +package sb2gorgias; + +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.time.OffsetDateTime; +import java.time.ZoneOffset; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.Base64; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.logging.Level; +import java.util.logging.Logger; + +import org.json.JSONArray; +import org.json.JSONObject; + +/** ShipBob API client - reads orders for the cron sync. + * + * Auth is a single bearer token (PAN_SEC_SHIPBOB_ACCESS_TOKEN). The base URL is resolved from + * the token's issuer (iss) claim, so the same code targets prod, sandbox, or QA depending on + * which token the tenant connected. */ +final class ShipBobApi implements ShipBobClient { + private static final Logger LOGGER = Pandium.newLogger("shipbob"); + + // ShipBob issues tokens from different auth hosts per environment; map each to its + // matching API base URL. Anything unrecognized falls back to prod. + private static final Map AUTH_URL_TO_BASE_URL = Map.of( + "https://authstage.shipbob.com", "https://sandbox-api.shipbob.com/2026-01", + "https://auth.shipbob.com", "https://api.shipbob.com/2026-01" + ); + static final String DEFAULT_BASE_URL = "https://api.shipbob.com/2026-01"; + + final String apiUrl; + final HttpClient httpClient; + + ShipBobApi(Pandium pandium) { + String token = pandium.secrets.get("shipbob_access_token"); + if (token == null || token.isEmpty()) { + throw new IllegalStateException("PAN_SEC_SHIPBOB_ACCESS_TOKEN is required"); + } + this.apiUrl = resolveBaseUrl(token); + // Exponential backoff: 3s, 6s, 12s, ... Only GET is ever called by this client. + this.httpClient = new HttpClient(apiUrl, "Bearer " + token, Duration.ofSeconds(3), Set.of("GET")); + } + + /** Decodes the JWT payload and maps its iss claim to an API base URL. */ + static String resolveBaseUrl(String token) { + try { + String payload = token.split("\\.")[1]; + payload += "=".repeat((4 - payload.length() % 4) % 4); + byte[] decoded = Base64.getUrlDecoder().decode(payload); + JSONObject claims = new JSONObject(new String(decoded, StandardCharsets.UTF_8)); + String iss = claims.optString("iss", null); + return AUTH_URL_TO_BASE_URL.getOrDefault(iss, DEFAULT_BASE_URL); + } catch (Exception e) { + LOGGER.log(Level.WARNING, "Could not resolve ShipBob base URL from token: " + e.getMessage()); + return DEFAULT_BASE_URL; + } + } + + /** GETs one page of /order. Only an exhausted query answers with an empty list. The + * caller stops paging there and commits its cursor, so a failure - or a 200 carrying + * something other than a list - throws instead. */ + private List getOrders(Map params) { + Object data; + try { + data = httpClient.get("/order", params); + } catch (RuntimeException e) { + LOGGER.log(Level.SEVERE, "ShipBob order fetch failed (" + params + "): " + e.getMessage()); + throw e; + } + if (data == null) { + return List.of(); + } + if (!(data instanceof JSONArray array)) { + throw new RuntimeException("ShipBob answered /order (" + params + ") with " + data); + } + List orders = new ArrayList<>(); + for (int i = 0; i < array.length(); i++) { + if (array.opt(i) instanceof JSONObject order) { + orders.add(order); + } + } + return orders; + } + + /** One page of orders created since startDate, oldest first. */ + @Override + public List newOrdersPage(OffsetDateTime startDate, int page) { + Map params = new LinkedHashMap<>(); + params.put("StartDate", startDate.format(DateTimeFormatter.ISO_OFFSET_DATE_TIME)); + params.put("Page", String.valueOf(page)); + params.put("SortOrder", "Oldest"); + return getOrders(params); + } + + /** One page of orders updated since startDate. + * + * ShipBob puts last_update_at on shipments, not orders, so we derive a per-order update + * timestamp and sort the page newest-first. Advancing the cursor to the oldest processed + * update keeps the sync conservative: a timed-out run never skips an update, at the cost + * of some reprocessing (which is harmless - customer writes are idempotent PUTs). */ + @Override + public List updatedOrdersPage(OffsetDateTime startDate, int page) { + Map params = new LinkedHashMap<>(); + params.put("LastUpdateStartDate", startDate.format(DateTimeFormatter.ISO_OFFSET_DATE_TIME)); + params.put("Page", String.valueOf(page)); + List orders = getOrders(params); + orders.sort(Comparator.comparing((JSONObject order) -> updateDate(order, startDate)).reversed()); + return orders; + } + + /** The oldest shipment last_update_at on order that still falls after startDate; defaults + * to now when none qualify. */ + @Override + public OffsetDateTime updateDate(JSONObject order, OffsetDateTime startDate) { + OffsetDateTime updateDate = OffsetDateTime.now(ZoneOffset.UTC); + if (order.opt("shipments") instanceof JSONArray shipments) { + for (int i = 0; i < shipments.length(); i++) { + JSONObject shipment = shipments.optJSONObject(i); + if (shipment == null) { + continue; + } + String ts = shipment.optString("last_update_at", null); + if (ts == null || ts.isEmpty()) { + continue; + } + Optional parsed = Util.parseTimestamp(ts); + if (parsed.isPresent() && parsed.get().isAfter(startDate) && parsed.get().isBefore(updateDate)) { + updateDate = parsed.get(); + } + } + } + return updateDate; + } +} diff --git a/shipbob_to_gorgias/java/src/main/java/sb2gorgias/ShipBobClient.java b/shipbob_to_gorgias/java/src/main/java/sb2gorgias/ShipBobClient.java new file mode 100644 index 00000000..b2f7dd8e --- /dev/null +++ b/shipbob_to_gorgias/java/src/main/java/sb2gorgias/ShipBobClient.java @@ -0,0 +1,16 @@ +package sb2gorgias; + +import java.time.OffsetDateTime; +import java.util.List; + +import org.json.JSONObject; + +/** What Cron depends on - satisfied by ShipBobApi and, in tests, by a fake. Java has no + * runtime monkey-patching, so this interface has to exist from the start. */ +interface ShipBobClient { + List newOrdersPage(OffsetDateTime startDate, int page); + + List updatedOrdersPage(OffsetDateTime startDate, int page); + + OffsetDateTime updateDate(JSONObject order, OffsetDateTime startDate); +} diff --git a/shipbob_to_gorgias/java/src/main/java/sb2gorgias/Util.java b/shipbob_to_gorgias/java/src/main/java/sb2gorgias/Util.java new file mode 100644 index 00000000..15630b5e --- /dev/null +++ b/shipbob_to_gorgias/java/src/main/java/sb2gorgias/Util.java @@ -0,0 +1,78 @@ +package sb2gorgias; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.OffsetDateTime; +import java.time.ZoneOffset; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; +import java.util.Optional; +import java.util.regex.Pattern; + +import org.json.JSONObject; + +/** Small shared helpers used across the ShipBob/Gorgias clients and the cron/webhook flows. */ +final class Util { + private Util() { + } + + /** Safe nested lookup by dotted path, e.g. {@code deepGet(order, "recipient.address.city", "")}. */ + static Object deepGet(Object data, String path, Object def) { + Object cur = data; + for (String part : path.split("\\.")) { + if (!(cur instanceof JSONObject json)) { + return def; + } + cur = json.opt(part); + } + return (cur == null || cur == JSONObject.NULL) ? def : cur; + } + + static String asString(Object v) { + return v instanceof String s ? s : ""; + } + + static long toLong(Object v) { + return v instanceof Number n ? n.longValue() : 0; + } + + static String trimTo(String s, int n) { + return s.length() > n ? s.substring(0, n) : s; + } + + // ShipBob sends 7-digit fractional seconds; Java's parsers take at most 9 but clamp does + // string-prefix comparisons that assume a fixed 6-digit width, matching every other port. + private static final Pattern TRIM_LONG_FRACTION = Pattern.compile("(\\.\\d{6})\\d+"); + + private static final DateTimeFormatter[] TIMESTAMP_LAYOUTS = { + DateTimeFormatter.ISO_OFFSET_DATE_TIME, + DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss[.SSSSSS]"), + DateTimeFormatter.ofPattern("yyyy-MM-dd"), + }; + + /** Parses a ShipBob- or Pandium-shaped timestamp string, trying progressively looser + * layouts. Empty when unparseable. A value with no offset is treated as UTC. */ + static Optional parseTimestamp(String value) { + if (value == null || value.isEmpty()) { + return Optional.empty(); + } + String trimmed = TRIM_LONG_FRACTION.matcher(value).replaceAll("$1"); + for (DateTimeFormatter layout : TIMESTAMP_LAYOUTS) { + try { + var accessor = layout.parseBest(trimmed, OffsetDateTime::from, LocalDateTime::from, LocalDate::from); + if (accessor instanceof OffsetDateTime odt) { + return Optional.of(odt.withOffsetSameInstant(ZoneOffset.UTC)); + } + if (accessor instanceof LocalDateTime ldt) { + return Optional.of(ldt.atOffset(ZoneOffset.UTC)); + } + if (accessor instanceof LocalDate ld) { + return Optional.of(ld.atStartOfDay().atOffset(ZoneOffset.UTC)); + } + } catch (DateTimeParseException ignored) { + // try the next, looser layout + } + } + return Optional.empty(); + } +} diff --git a/shipbob_to_gorgias/java/src/main/java/sb2gorgias/Webhook.java b/shipbob_to_gorgias/java/src/main/java/sb2gorgias/Webhook.java new file mode 100644 index 00000000..afb9af8a --- /dev/null +++ b/shipbob_to_gorgias/java/src/main/java/sb2gorgias/Webhook.java @@ -0,0 +1,312 @@ +package sb2gorgias; + +import java.time.Duration; +import java.time.OffsetDateTime; +import java.time.ZoneOffset; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.logging.Level; +import java.util.logging.Logger; + +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; + +/** The webhook flow: any ShipBob order webhook -> a Gorgias ticket. + * + * ShipBob's order-related topics (order_shipped, shipment_delivered, shipment_exception, + * shipment_onhold, shipment_cancelled) all deliver the same shipment object, differing only in + * status/status_details. This flow opens a ticket for every one of them, so support sees a + * shipment the moment it needs attention rather than only once it lands. + * + * Each webhook run may carry N debounced deliveries (Pandium bundles triggers that arrive + * while a run is in flight), so we loop over every trigger. Creating a ticket is not + * idempotent and ShipBob retries any delivery that doesn't get a 2xx, so we dedupe on + * shipment_id:status using a processed_events map in tenant metadata, pruned to a 30-minute + * window. Keying on the status as well as the shipment means a redelivery is suppressed while + * a genuine next status for the same shipment still opens a ticket. + * + * Because tenant metadata is shallow-merged at the top level, writing the whole + * processed_events object replaces the previous one (dropped keys are really removed) while + * leaving the cron flow's cursor keys untouched. + * + * Pandium verifies each delivery's signature before it ever reaches a run, so the bodies + * handed to this file are already known to have come from ShipBob. */ +final class Webhook { + private static final Logger LOGGER = Pandium.newLogger("webhook"); + + private static final Duration PRUNE_WINDOW = Duration.ofMinutes(30); + private static final String SHIPMENT_TAG = "shipbob-shipment"; + + private Webhook() { + } + + /** Drops entries whose timestamp is more than PRUNE_WINDOW old (or unparseable). */ + static Map prune(Map processed, OffsetDateTime now) { + Map kept = new LinkedHashMap<>(); + for (Map.Entry entry : processed.entrySet()) { + Optional when = Util.parseTimestamp(entry.getValue()); + if (when.isEmpty()) { + continue; // unparseable -> treat as expired + } + if (Duration.between(when.get(), now).compareTo(PRUNE_WINDOW) <= 0) { + kept.put(entry.getKey(), entry.getValue()); + } + } + return kept; + } + + private static String idString(Object v) { + return (v == null || v == JSONObject.NULL) ? "" : String.valueOf(v); + } + + /** ShipBob names the shipment id "id" on the webhook body; older docs and some topics + * call it "shipment_id". Accept either. */ + static String shipmentId(JSONObject event) { + String id = idString(Util.deepGet(event, "id", null)); + if (!id.isEmpty()) { + return id; + } + return idString(Util.deepGet(event, "shipment_id", null)); + } + + /** The human-readable reasons ShipBob attached to this status, e.g. "Invalid Address; + * Payment Failure". Empty for statuses that carry none. */ + static String statusDetails(JSONObject event) { + Object detailsObj = Util.deepGet(event, "status_details", new JSONArray()); + JSONArray details = detailsObj instanceof JSONArray a ? a : new JSONArray(); + List reasons = new ArrayList<>(); + for (int i = 0; i < details.length(); i++) { + if (details.opt(i) instanceof JSONObject d) { + String desc = d.optString("description", ""); + reasons.add(!desc.isEmpty() ? desc : d.optString("name", "")); + } + } + return String.join("; ", reasons); + } + + /** One line per product on the shipment: "4 x 16 oz. Shampoo (PIN-100)". */ + static String items(JSONObject event) { + Object productsObj = Util.deepGet(event, "products", new JSONArray()); + JSONArray products = productsObj instanceof JSONArray a ? a : new JSONArray(); + List lines = new ArrayList<>(); + for (int i = 0; i < products.length(); i++) { + if (!(products.opt(i) instanceof JSONObject product)) { + continue; + } + long quantity = 0; + if (product.opt("inventory_items") instanceof JSONArray inventoryItems) { + for (int j = 0; j < inventoryItems.length(); j++) { + if (inventoryItems.opt(j) instanceof JSONObject item) { + quantity += Util.toLong(item.opt("quantity")); + } + } + } + String sku = product.optString("sku", ""); + if (sku.isEmpty()) { + sku = product.optString("reference_id", ""); + } + String line = quantity + " x " + product.optString("name", ""); + if (!sku.isEmpty()) { + line += " (" + sku + ")"; + } + lines.add(line); + } + return String.join("\n", lines); + } + + /** Builds the POST /tickets payload for a shipment webhook of any status. + * + * customerRef is the {id: ...} returned by resolveCustomer. Gorgias wants the customer + * twice - once as the ticket's owner and once as the sender of its first message - so the + * same reference goes in both slots. */ + static JSONObject buildTicket(JSONObject event, JSONObject customerRef) { + String sid = shipmentId(event); + String orderId = Util.asString(Util.deepGet(event, "order_id", "")); + String referenceId = Util.asString(Util.deepGet(event, "reference_id", "")); + if (referenceId.isEmpty()) { + referenceId = orderId; + } + String status = Util.asString(Util.deepGet(event, "status", "Updated")); + if (status.isEmpty()) { + status = "Updated"; + } + String reasons = statusDetails(event); + String carrier = Util.asString(Util.deepGet(event, "tracking.carrier", "")); + String trackingNumber = Util.asString(Util.deepGet(event, "tracking.tracking_number", "")); + String deliveredOn = Util.trimTo(Util.asString(Util.deepGet(event, "delivery_date", "")), 10); + + String headline = "Shipment " + sid + " for order " + referenceId + " is now " + status + "."; + + // Only the parts ShipBob actually sent for this status make it into the body - an + // OnHold shipment has no tracking, a Delivered one has no status details. + List lines = new ArrayList<>(List.of(headline)); + if (!reasons.isEmpty()) { + lines.add("Reason: " + reasons); + } + if (!carrier.isEmpty() || !trackingNumber.isEmpty()) { + lines.add(("Tracking: " + carrier + " " + trackingNumber).trim()); + } + if (!deliveredOn.isEmpty()) { + lines.add("Delivered on: " + deliveredOn); + } + String itemLines = items(event); + if (!itemLines.isEmpty()) { + lines.add("Items:\n" + itemLines); + } + String bodyText = String.join("\n", lines); + + List html = new ArrayList<>(List.of("

" + headline + "

")); + if (!reasons.isEmpty()) { + html.add("

Reason: " + reasons + "

"); + } + if (!carrier.isEmpty() || !trackingNumber.isEmpty()) { + html.add("

Tracking: " + carrier + " " + trackingNumber + "

"); + } + if (!itemLines.isEmpty()) { + StringBuilder li = new StringBuilder("
    "); + for (String line : itemLines.split("\n")) { + li.append("
  • ").append(line).append("
  • "); + } + li.append("
"); + html.add(li.toString()); + } + + JSONObject message = new JSONObject(); + message.put("sender", customerRef); + message.put("channel", "api"); + message.put("via", "api"); + message.put("from_agent", false); + message.put("subject", "Order " + referenceId + ": shipment " + status); + message.put("body_text", bodyText); + message.put("body_html", String.join("", html)); + // Included so Gorgias auto-reply / keyword rules can fire. + message.put("stripped_text", headline); + + JSONObject ticket = new JSONObject(); + ticket.put("customer", customerRef); + ticket.put("channel", "api"); + ticket.put("via", "api"); + ticket.put("from_agent", false); + ticket.put("status", "open"); + ticket.put("messages", new JSONArray(List.of(message))); + // A constant tag to find every ticket this flow opened, plus the status so Gorgias + // rules can route (e.g. OnHold) without parsing the body. + ticket.put("tags", new JSONArray(List.of( + new JSONObject().put("name", SHIPMENT_TAG), + new JSONObject().put("name", "shipbob-" + status.toLowerCase().replace(" ", "-")) + ))); + return ticket; + } + + /** Finds-or-creates the Gorgias customer for a shipment's recipient and returns the + * reference to attach the ticket to. + * + * Uses the same key the cron flow does - a valid recipient email when there is one, + * otherwise the synthetic name address1 city country - so a webhook ticket lands on the + * same record that carries the customer's order history. Recipient email is optional on a + * ShipBob shipment, so the external_id path carries as much weight here as it does in the + * cron flow. */ + static JSONObject resolveCustomer(GorgiasClient gorgias, JSONObject event) { + String email = GorgiasApi.validEmail(Util.asString(Util.deepGet(event, "recipient.email", ""))); + String key = GorgiasApi.customerKey(event); + + JSONObject existing = gorgias.findCustomer(email.isEmpty() ? null : email, email.isEmpty() ? key : null); + if (existing != null) { + return new JSONObject().put("id", existing.opt("id")); + } + long newId = gorgias.createCustomer(GorgiasApi.newCustomerPayload(event, key)); + return new JSONObject().put("id", newId); + } + + static JSONObject webhookRun(Pandium pandium) { + GorgiasApi gorgias = new GorgiasApi(pandium); + return runWebhook(pandium, gorgias, OffsetDateTime.now(ZoneOffset.UTC)); + } + + static JSONObject runWebhook(Pandium pandium, GorgiasClient gorgias, OffsetDateTime now) { + JSONObject metadata = pandium.metadata(); + if (metadata == null) { + metadata = new JSONObject(); + } + Map processedRaw = new LinkedHashMap<>(); + if (metadata.opt("processed_events") instanceof JSONObject processedEvents) { + for (String k : processedEvents.keySet()) { + processedRaw.put(k, Util.asString(processedEvents.opt(k))); + } + } + Map processed = prune(processedRaw, now); + + String nowIso = now.format(DateTimeFormatter.ISO_OFFSET_DATE_TIME); + int created = 0; + + // Pandium bundles debounced deliveries into one run; Pandium.webhookDeliveries reads + // each raw body back off disk so this loop only has to deal with the event itself. + for (WebhookDelivery delivery : pandium.webhookDeliveries()) { + JSONObject event; + try { + event = new JSONObject(delivery.body()); + } catch (JSONException e) { + LOGGER.log(Level.SEVERE, "webhook delivery is not valid JSON; delivery_id=" + delivery.id() + + " error=" + e.getMessage()); + continue; + } + + String sid = shipmentId(event); + if (sid.isEmpty()) { + LOGGER.log(Level.WARNING, "webhook delivery has no shipment id; skipping; delivery_id=" + delivery.id()); + continue; + } + + // Every order webhook gets a ticket, whatever the status - the status is only + // part of the dedupe key, never a filter. + String status = Util.asString(Util.deepGet(event, "status", "Updated")); + if (status.isEmpty()) { + status = "Updated"; + } + String eventKey = sid + ":" + status; + if (processed.containsKey(eventKey)) { + LOGGER.log(Level.INFO, "shipment already ticketed; skipping duplicate; shipment_id=" + sid + + " status=" + status); + continue; + } + + JSONObject customerRef; + try { + customerRef = resolveCustomer(gorgias, event); + } catch (RuntimeException e) { + LOGGER.log(Level.SEVERE, "could not resolve a Gorgias customer for shipment; shipment_id=" + sid + + " error=" + e.getMessage()); + continue; // leave unprocessed so ShipBob's retry can try again + } + + JSONObject ticket; + try { + ticket = gorgias.createTicket(buildTicket(event, customerRef)); + } catch (RuntimeException e) { + LOGGER.log(Level.SEVERE, "failed to open ticket for shipment; shipment_id=" + sid + + " error=" + e.getMessage()); + continue; // leave unprocessed so ShipBob's retry can try again + } + + processed.put(eventKey, nowIso); // mark handled + created++; + LOGGER.log(Level.INFO, "opened Gorgias ticket for shipment; ticket_id=" + ticket.opt("id") + + " shipment_id=" + sid + " status=" + status); + } + + LOGGER.log(Level.INFO, "webhook flow complete; tickets_opened=" + created + " events_tracked=" + processed.size()); + // Replaces the map (30-min pruned); shallow merge leaves the cron flow's cursor keys intact. + JSONObject processedAny = new JSONObject(); + for (Map.Entry entry : processed.entrySet()) { + processedAny.put(entry.getKey(), entry.getValue()); + } + JSONObject result = new JSONObject(); + result.put("processed_events", processedAny); + return result; + } +} diff --git a/shipbob_to_gorgias/java/src/test/java/sb2gorgias/ClientsTest.java b/shipbob_to_gorgias/java/src/test/java/sb2gorgias/ClientsTest.java new file mode 100644 index 00000000..ef8acd98 --- /dev/null +++ b/shipbob_to_gorgias/java/src/test/java/sb2gorgias/ClientsTest.java @@ -0,0 +1,64 @@ +package sb2gorgias; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.nio.charset.StandardCharsets; +import java.time.OffsetDateTime; +import java.util.Base64; +import java.util.List; +import java.util.Map; + +import org.json.JSONObject; +import org.junit.jupiter.api.Test; + +class ClientsTest { + + private static String token(String iss) { + String json = new JSONObject().put("iss", iss).toString(); + String payload = Base64.getUrlEncoder().withoutPadding().encodeToString(json.getBytes(StandardCharsets.UTF_8)); + return "header." + payload + ".sig"; + } + + @Test + void shipBobBaseUrlResolvedFromTokenIssuer() { + assertEquals("https://sandbox-api.shipbob.com/2026-01", + ShipBobApi.resolveBaseUrl(token("https://authstage.shipbob.com"))); + assertEquals("https://api.shipbob.com/2026-01", ShipBobApi.resolveBaseUrl(token("https://auth.shipbob.com"))); + assertEquals(ShipBobApi.DEFAULT_BASE_URL, ShipBobApi.resolveBaseUrl("not-a-jwt")); // malformed -> prod + } + + /** The cron loop stops on an empty page and commits its cursor there, so only an + * exhausted query may answer with one. */ + @Test + void shipBobOrderPageRaisesInsteadOfReportingItselfEmpty() { + ShipBobApi api = new ShipBobApi(Helpers.pandium() + .secrets(Map.of("shipbob_access_token", token("https://auth.shipbob.com"))) + .build()); + OffsetDateTime start = OffsetDateTime.parse("2026-07-01T00:00:00Z"); + + api.httpClient.requester = (method, url, body) -> new HttpClient.RawResponse(200, "[]"); + assertEquals(List.of(), api.newOrdersPage(start, 1)); // exhausted + + // 500 (not one of the retryable statuses) so this fails on the first attempt instead + // of sleeping through backoff retries. + api.httpClient.requester = (method, url, body) -> new HttpClient.RawResponse(500, "server error"); + assertThrows(RuntimeException.class, () -> api.newOrdersPage(start, 1)); // a failure, not an empty page + + api.httpClient.requester = (method, url, body) -> new HttpClient.RawResponse(200, "{\"errors\":[\"nope\"]}"); + assertThrows(RuntimeException.class, () -> api.newOrdersPage(start, 1)); // a 200 that is not a page of orders either + } + + @Test + void gorgiasOauthBuildsBaseUrlAndBearerHeader() { + GorgiasApi api = new GorgiasApi(Helpers.pandium().secrets(Helpers.GORGIAS_SECRETS).build()); + assertEquals("https://acme.gorgias.com/api", api.apiUrl); + assertEquals("Bearer gorgias-token-123", api.httpClient.authorization()); + } + + @Test + void gorgiasCustomerKeyIsEmailOrSynthetic() { + assertEquals("jane@example.com", GorgiasApi.customerKey(Helpers.makeOrder(1, "x", "jane@example.com"))); + assertEquals("Buyer 1 Main St NY US", GorgiasApi.customerKey(Helpers.makeOrder(1, "x", null))); + } +} diff --git a/shipbob_to_gorgias/java/src/test/java/sb2gorgias/CronTest.java b/shipbob_to_gorgias/java/src/test/java/sb2gorgias/CronTest.java new file mode 100644 index 00000000..aabb8ae6 --- /dev/null +++ b/shipbob_to_gorgias/java/src/test/java/sb2gorgias/CronTest.java @@ -0,0 +1,134 @@ +package sb2gorgias; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +import java.io.ByteArrayOutputStream; +import java.io.PrintStream; +import java.nio.charset.StandardCharsets; +import java.time.OffsetDateTime; +import java.time.ZoneOffset; +import java.time.format.DateTimeFormatter; +import java.util.List; +import java.util.Map; + +import org.json.JSONObject; +import org.junit.jupiter.api.Test; + +class CronTest { + + /** A ShipBob-shaped timestamp days back - seven fractional digits, as the real API + * sends - inside clamp's 30-day window. */ + private static String ago(int days) { + OffsetDateTime dt = OffsetDateTime.now(ZoneOffset.UTC).minusDays(days); + return dt.format(DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss")) + ".1234567+00:00"; + } + + @Test + void clampBoundsCursorBetweenOneMonthAgoAndNow() { + OffsetDateTime now = OffsetDateTime.of(2026, 7, 16, 12, 0, 0, 0, ZoneOffset.UTC); + assertEquals(OffsetDateTime.of(2026, 7, 10, 0, 0, 0, 0, ZoneOffset.UTC), + Cron.clamp("2026-07-10T00:00:00", now)); // in range + assertEquals(now, Cron.clamp("2099-01-01T00:00:00", now)); // future -> now + assertEquals(now.minus(Cron.ONE_MONTH), Cron.clamp(null, now)); // missing -> floor + } + + @Test + void runPagesUntilEmptyUpsertsCustomerAndAdvancesCursor() { + FakeShipBobClient shipbob = new FakeShipBobClient( + List.of(List.of( + Helpers.makeOrder(1, ago(6), "jane@example.com"), + Helpers.makeOrder(2, ago(5), "jane@example.com") + )), + List.of(), + null + ); + FakeGorgiasClient gorgias = new FakeGorgiasClient(); + Pandium pandium = Helpers.pandium().secrets(Helpers.GORGIAS_SECRETS) + .config(Map.of("order_start_date", ago(20))).build(); + + JSONObject record = Cron.runCron(pandium, new Cron.Deps(shipbob, gorgias, new FakeWatchdogArmer(), + code -> { throw new SimulatedExit(code); }, OffsetDateTime.now(ZoneOffset.UTC))); + + assertEquals(List.of(1, 2), shipbob.pages.get("new")); // paged until the empty page + assertEquals(1, gorgias.createLog.size()); // both orders batch onto one customer + assertEquals(Util.trimTo(ago(5), 26), record.getString("new_order_start_date")); // advanced to the last order + + JSONObject finalCustomer = gorgias.updateLog.get(gorgias.updateLog.size() - 1).getValue(); + var finalOrders = finalCustomer.getJSONObject("data").getJSONObject("pandium").getJSONArray("shipbob_orders"); + List ids = new java.util.ArrayList<>(); + for (int i = 0; i < finalOrders.length(); i++) { + ids.add(finalOrders.getJSONObject(i).getLong("id")); + } + ids.sort(null); + assertEquals(List.of(1L, 2L), ids); + } + + /** Pages are each sorted newest-first, but not relative to each other, so the cursor has + * to be the oldest update seen anywhere - not the last one processed. */ + @Test + void runAdvancesUpdatedCursorToOldestUpdateAcrossPages() { + FakeShipBobClient shipbob = new FakeShipBobClient( + List.of(), + List.of( + List.of(Helpers.makeOrder(1, ago(2), "j@x.com"), Helpers.makeOrder(2, ago(3), "j@x.com")), + List.of(Helpers.makeOrder(3, ago(9), "j@x.com"), Helpers.makeOrder(4, ago(8), "j@x.com")), + List.of(Helpers.makeOrder(5, ago(4), "j@x.com")) + ), + null + ); + Pandium pandium = Helpers.pandium().secrets(Helpers.GORGIAS_SECRETS) + .config(Map.of("order_start_date", ago(20))).build(); + + JSONObject record = Cron.runCron(pandium, new Cron.Deps(shipbob, new FakeGorgiasClient(), new FakeWatchdogArmer(), + code -> { throw new SimulatedExit(code); }, OffsetDateTime.now(ZoneOffset.UTC))); + + String expected = Cron.formatCursor(Util.parseTimestamp(ago(9)).orElseThrow()); + assertEquals(expected, record.getString("updated_order_start_date")); // not order 5, the last processed + } + + /** The two cursors resume differently. new_order_start_date climbs per order over an + * oldest-first query, so it is sound wherever the run stops. updated_order_start_date is + * the minimum across every page, so it only holds once the query is exhausted - an unread + * page can carry an older update - and a run cut short flushes the value it started + * with. */ + @Test + void timeoutFlushesTheFinishedHalfAndLeavesTheInterruptedOne() { + String start = ago(20); + FakeWatchdogArmer armer = new FakeWatchdogArmer(); + FakeShipBobClient shipbob = new FakeShipBobClient( + List.of(List.of(Helpers.makeOrder(1, ago(6), "j@x.com"))), + List.of( + List.of(Helpers.makeOrder(2, ago(2), "j@x.com")), + List.of(Helpers.makeOrder(3, ago(9), "j@x.com")) // never read + ), + (half, page) -> { + if ("updated".equals(half) && page == 2) { + armer.fire(); + } + } + ); + Pandium pandium = Helpers.pandium().secrets(Helpers.GORGIAS_SECRETS) + .config(Map.of("order_start_date", start)).build(); + + ByteArrayOutputStream capturedOut = new ByteArrayOutputStream(); + PrintStream originalOut = System.out; + System.setOut(new PrintStream(capturedOut, true, StandardCharsets.UTF_8)); + SimulatedExit thrown = null; + try { + Cron.runCron(pandium, new Cron.Deps(shipbob, new FakeGorgiasClient(), armer, + code -> { throw new SimulatedExit(code); }, OffsetDateTime.now(ZoneOffset.UTC))); + } catch (SimulatedExit e) { + thrown = e; + } finally { + System.setOut(originalOut); + } + + assertNotNull(thrown); // a timed-out run still succeeds, so progress merges + assertEquals(0, thrown.code); + String[] lines = capturedOut.toString(StandardCharsets.UTF_8).strip().split("\n"); + JSONObject flushed = new JSONObject(lines[lines.length - 1]); + assertEquals(Util.trimTo(ago(6), 26), flushed.getString("new_order_start_date")); // that half finished + assertEquals(Util.trimTo(start, 26), flushed.getString("updated_order_start_date")); // this one did not + } +} diff --git a/shipbob_to_gorgias/java/src/test/java/sb2gorgias/FakeGorgiasClient.java b/shipbob_to_gorgias/java/src/test/java/sb2gorgias/FakeGorgiasClient.java new file mode 100644 index 00000000..fcb22e8f --- /dev/null +++ b/shipbob_to_gorgias/java/src/test/java/sb2gorgias/FakeGorgiasClient.java @@ -0,0 +1,59 @@ +package sb2gorgias; + +import java.util.AbstractMap; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.json.JSONArray; +import org.json.JSONObject; + +/** A GorgiasClient fake with HTTP replaced by in-memory recorders. existingEmails are + * pre-seeded as found customers; inspect createLog/updateLog/ticketLog in assertions. */ +final class FakeGorgiasClient implements GorgiasClient { + private final Map store = new HashMap<>(); + final List createLog = new ArrayList<>(); + final List> updateLog = new ArrayList<>(); + final List ticketLog = new ArrayList<>(); + + FakeGorgiasClient(String... existingEmails) { + long id = 40; + for (String email : existingEmails) { + store.put(email, id++); + } + } + + @Override + public JSONObject findCustomer(String email, String externalId) { + String key = email != null ? email : externalId; + Long id = key == null ? null : store.get(key); + if (id == null) { + return null; + } + JSONObject customer = new JSONObject(); + customer.put("id", id); + customer.put("data", new JSONObject().put("pandium", new JSONObject().put("shipbob_orders", new JSONArray()))); + return customer; + } + + @Override + public long createCustomer(JSONObject payload) { + long cid = 1000 + store.size(); + String externalId = payload.optString("external_id", null); + store.put(externalId != null ? externalId : String.valueOf(cid), cid); + createLog.add(payload); + return cid; + } + + @Override + public void updateCustomer(long id, JSONObject payload) { + updateLog.add(new AbstractMap.SimpleEntry<>(id, new JSONObject(payload.toString()))); // snapshot + } + + @Override + public JSONObject createTicket(JSONObject payload) { + ticketLog.add(payload); + return new JSONObject().put("id", 900 + ticketLog.size()); + } +} diff --git a/shipbob_to_gorgias/java/src/test/java/sb2gorgias/FakeShipBobClient.java b/shipbob_to_gorgias/java/src/test/java/sb2gorgias/FakeShipBobClient.java new file mode 100644 index 00000000..e031eba1 --- /dev/null +++ b/shipbob_to_gorgias/java/src/test/java/sb2gorgias/FakeShipBobClient.java @@ -0,0 +1,85 @@ +package sb2gorgias; + +import java.time.Duration; +import java.time.OffsetDateTime; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.function.BiConsumer; + +import org.json.JSONObject; + +/** Serves canned pages for either half and records the pages asked for. + * + * onPage runs before a page is served, which is where a test stands in for the watchdog + * firing or the API going away mid-query. */ +final class FakeShipBobClient implements ShipBobClient { + private final List> newPages; + private final List> updatedPages; + private final BiConsumer onPage; + final Map> pages = new HashMap<>(Map.of("new", new ArrayList<>(), "updated", new ArrayList<>())); + + FakeShipBobClient(List> newPages, List> updatedPages, + BiConsumer onPage) { + this.newPages = newPages; + this.updatedPages = updatedPages; + this.onPage = onPage != null ? onPage : (half, page) -> { + }; + } + + private List servePage(String half, List> allPages, int page) { + pages.get(half).add(page); + onPage.accept(half, page); + return page <= allPages.size() ? allPages.get(page - 1) : List.of(); + } + + @Override + public List newOrdersPage(OffsetDateTime cursor, int page) { + return servePage("new", newPages, page); + } + + @Override + public List updatedOrdersPage(OffsetDateTime cursor, int page) { + return servePage("updated", updatedPages, page); + } + + @Override + public OffsetDateTime updateDate(JSONObject order, OffsetDateTime cursor) { + String lastUpdateAt = order.getJSONArray("shipments").getJSONObject(0).getString("last_update_at"); + return Util.parseTimestamp(lastUpdateAt).orElseThrow(); + } +} + +/** Thrown by a test's fake Cron.Deps.exit consumer in place of System.exit, so a simulated + * watchdog timeout can be asserted on instead of killing the test JVM. Extends Error, not + * RuntimeException, so it is never accidentally swallowed by Cron's own + * catch (RuntimeException) blocks around the ShipBob calls - it must unwind past them + * untouched, the same way a real process exit would never return control to that code either. */ +final class SimulatedExit extends Error { + final int code; + + SimulatedExit(int code) { + super("simulated exit " + code); + this.code = code; + } +} + +/** Captures the callback a real watchdog would eventually run, so a test can invoke it + * deterministically instead of waiting real minutes. */ +final class FakeWatchdogArmer implements Cron.WatchdogArmer { + Runnable capturedTimeout; + boolean cancelled; + + @Override + public Runnable arm(Duration deadline, Runnable onTimeout) { + capturedTimeout = onTimeout; + return () -> cancelled = true; + } + + void fire() { + if (capturedTimeout != null) { + capturedTimeout.run(); + } + } +} diff --git a/shipbob_to_gorgias/java/src/test/java/sb2gorgias/Helpers.java b/shipbob_to_gorgias/java/src/test/java/sb2gorgias/Helpers.java new file mode 100644 index 00000000..985b65eb --- /dev/null +++ b/shipbob_to_gorgias/java/src/test/java/sb2gorgias/Helpers.java @@ -0,0 +1,177 @@ +package sb2gorgias; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.HashMap; +import java.util.Map; + +import org.json.JSONArray; +import org.json.JSONObject; + +/** Shared test doubles and factories - nothing here touches the network. */ +final class Helpers { + private Helpers() { + } + + static final Map GORGIAS_SECRETS = Map.of( + "gorgias_oauth_access_token", "gorgias-token-123", + "gorgias_oauth_account", "acme" + ); + + /** Builds a Pandium directly (no env). metadata is written to a temp file so + * pandium.metadata() reads it back like the real thing. */ + static final class PandiumBuilder { + private Map config = Map.of(); + private Map secrets = Map.of(); + private JSONArray runTriggers; + private JSONObject metadata; + private String runMode; + private Path tmpDir; + + PandiumBuilder config(Map config) { + this.config = config; + return this; + } + + PandiumBuilder secrets(Map secrets) { + this.secrets = secrets; + return this; + } + + PandiumBuilder runTriggers(JSONArray runTriggers) { + this.runTriggers = runTriggers; + return this; + } + + PandiumBuilder metadata(JSONObject metadata) { + this.metadata = metadata; + return this; + } + + PandiumBuilder runMode(String runMode) { + this.runMode = runMode; + return this; + } + + PandiumBuilder tmpDir(Path tmpDir) { + this.tmpDir = tmpDir; + return this; + } + + Pandium build() { + Map context = new HashMap<>(); + if (runMode != null) { + context.put("run_mode", runMode); + } + if (runTriggers != null) { + context.put("run_triggers", runTriggers.toString()); + } + if (metadata != null) { + try { + Path path = tmpDir.resolve("metadata.json"); + Files.writeString(path, metadata.toString(), StandardCharsets.UTF_8); + context.put("tenant_metadata_file", path.toString()); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } + return new Pandium(config, secrets, context); + } + } + + static PandiumBuilder pandium() { + return new PandiumBuilder(); + } + + static JSONObject makeOrder(long id, String created, String email) { + return makeOrder(id, created, email, null); + } + + static JSONObject makeOrder(long id, String created, String email, String lastUpdate) { + JSONObject recipient = new JSONObject(); + recipient.put("email", email); + recipient.put("name", "Buyer"); + recipient.put("address", new JSONObject().put("address1", "1 Main St").put("city", "NY").put("country", "US")); + + JSONObject shipment = new JSONObject(); + shipment.put("id", id * 10); + shipment.put("last_update_at", lastUpdate != null ? lastUpdate : created); + + JSONObject order = new JSONObject(); + order.put("id", id); + order.put("created_date", created); + order.put("reference_id", "REF-" + id); + order.put("recipient", recipient); + order.put("shipments", new JSONArray().put(shipment)); + return order; + } + + static JSONObject makeShipmentEvent(long shipmentId) { + return makeShipmentEvent(shipmentId, "Delivered", "jane@example.com", null); + } + + /** A ShipBob shipment webhook body. Every order-related topic delivers this same object; + * status and statusDetails are what vary between them. */ + static JSONObject makeShipmentEvent(long shipmentId, String status, String email, JSONArray statusDetails) { + JSONObject event = new JSONObject(); + event.put("id", shipmentId); + event.put("order_id", 289012345L); + event.put("reference_id", "MERCHANT-ORDER-1001"); + event.put("status", status); + event.put("status_details", statusDetails != null ? statusDetails : new JSONArray()); + event.put("tracking", new JSONObject().put("carrier", "USPS").put("tracking_number", "9400100000000000000000")); + event.put("delivery_date", "2026-07-09T18:22:00Z"); + + JSONObject inventoryItem = new JSONObject().put("name", "Pinnacle Shampoo").put("quantity", 4); + JSONObject product = new JSONObject(); + product.put("name", "Pinnacle Shampoo"); + product.put("sku", "PIN-100"); + product.put("inventory_items", new JSONArray().put(inventoryItem)); + event.put("products", new JSONArray().put(product)); + + JSONObject recipient = new JSONObject(); + recipient.put("name", "Jane Buyer"); + recipient.put("email", email); + recipient.put("address", new JSONObject().put("address1", "100 Nowhere Blvd").put("city", "Gotham City") + .put("country", "US")); + event.put("recipient", recipient); + return event; + } + + static JSONObject makeOnholdEvent() { + return makeOnholdEvent(107414278L); + } + + /** An OnHold shipment: status details, no tracking, and no recipient email. */ + static JSONObject makeOnholdEvent(long shipmentId) { + JSONArray statusDetails = new JSONArray() + .put(new JSONObject().put("id", 401).put("name", "InvalidAddress").put("description", "Invalid Address")) + .put(new JSONObject().put("id", 400).put("name", "PaymentDeclined").put("description", "Payment Failure")); + JSONObject event = makeShipmentEvent(shipmentId, "OnHold", null, statusDetails); + event.put("tracking", JSONObject.NULL); + event.put("delivery_date", JSONObject.NULL); + return event; + } + + /** Writes an event to disk and wraps it in a trigger, the way Pandium hands one over. */ + static JSONObject webhookTrigger(Path tmpDir, JSONObject event, String tid) { + return webhookTrigger(tmpDir, event, tid, "webhook"); + } + + static JSONObject webhookTrigger(Path tmpDir, JSONObject event, String tid, String source) { + try { + Path path = tmpDir.resolve(tid + ".json"); + Files.writeString(path, event.toString(), StandardCharsets.UTF_8); + JSONObject trigger = new JSONObject(); + trigger.put("id", tid); + trigger.put("source", source); + trigger.put("payload", new JSONObject().put("file", path.toString())); + return trigger; + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } +} diff --git a/shipbob_to_gorgias/java/src/test/java/sb2gorgias/ParityTest.java b/shipbob_to_gorgias/java/src/test/java/sb2gorgias/ParityTest.java new file mode 100644 index 00000000..b635b1af --- /dev/null +++ b/shipbob_to_gorgias/java/src/test/java/sb2gorgias/ParityTest.java @@ -0,0 +1,19 @@ +package sb2gorgias; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import org.json.JSONObject; +import org.junit.jupiter.api.Test; + +/** The cron and webhook flows must resolve "the same customer" via the same GorgiasApi + * helpers - guards against them drifting on how a recipient maps to a key. */ +class ParityTest { + + @Test + void customerKeyResolvesTheSameKeyForAMatchingRecipientAcrossAnOrderAndAShipmentEvent() { + JSONObject order = Helpers.makeOrder(1, "2026-07-01T00:00:00Z", "jane@example.com"); + JSONObject event = Helpers.makeShipmentEvent(1, "Delivered", "jane@example.com", null); + + assertEquals(GorgiasApi.customerKey(order), GorgiasApi.customerKey(event)); + } +} diff --git a/shipbob_to_gorgias/java/src/test/java/sb2gorgias/WebhookTest.java b/shipbob_to_gorgias/java/src/test/java/sb2gorgias/WebhookTest.java new file mode 100644 index 00000000..934a6d0a --- /dev/null +++ b/shipbob_to_gorgias/java/src/test/java/sb2gorgias/WebhookTest.java @@ -0,0 +1,102 @@ +package sb2gorgias; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.nio.file.Path; +import java.time.OffsetDateTime; +import java.time.ZoneOffset; +import java.time.format.DateTimeFormatter; +import java.util.Set; + +import org.json.JSONArray; +import org.json.JSONObject; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class WebhookTest { + + private record RunResult(JSONObject result, FakeGorgiasClient gorgias) { + } + + private static RunResult run(Path tmpDir, JSONArray triggers, JSONObject metadata, String... existingEmails) { + FakeGorgiasClient gorgias = new FakeGorgiasClient(existingEmails); + Helpers.PandiumBuilder builder = Helpers.pandium().secrets(Helpers.GORGIAS_SECRETS) + .runTriggers(triggers).runMode("webhook").tmpDir(tmpDir); + if (metadata != null) { + builder.metadata(metadata); + } + JSONObject result = Webhook.runWebhook(builder.build(), gorgias, OffsetDateTime.now(ZoneOffset.UTC)); + return new RunResult(result, gorgias); + } + + @Test + void runOpensTicketAndWritesOnlyProcessedEvents(@TempDir Path tmpDir) { + JSONArray triggers = new JSONArray().put(Helpers.webhookTrigger(tmpDir, Helpers.makeShipmentEvent(456789), "t1")); + RunResult r = run(tmpDir, triggers, null, "jane@example.com"); + + JSONObject ticket = r.gorgias().ticketLog.get(0); + assertEquals(40, ticket.getJSONObject("customer").getLong("id")); // linked to the found customer + JSONArray expectedTags = new JSONArray() + .put(new JSONObject().put("name", "shipbob-shipment")) + .put(new JSONObject().put("name", "shipbob-delivered")); + assertTrue(expectedTags.similar(ticket.getJSONArray("tags"))); + String bodyText = ticket.getJSONArray("messages").getJSONObject(0).getString("body_text"); + assertTrue(bodyText.contains("is now Delivered")); + assertTrue(bodyText.contains("USPS 9400100000000000000000")); + assertEquals(Set.of("processed_events"), r.result().keySet()); // leaves the cron flow's cursor keys alone + assertTrue(r.result().getJSONObject("processed_events").has("456789:Delivered")); + } + + @Test + void runDedupesWithinBatchAndPrunesStaleEntries(@TempDir Path tmpDir) { + OffsetDateTime now = OffsetDateTime.now(ZoneOffset.UTC); + JSONArray triggers = new JSONArray() + .put(Helpers.webhookTrigger(tmpDir, Helpers.makeShipmentEvent(456789), "t1")) + .put(Helpers.webhookTrigger(tmpDir, Helpers.makeShipmentEvent(456789), "t2")); // duplicate + JSONObject metadata = new JSONObject().put("processed_events", new JSONObject() + // recent -> kept + .put("456790:Delivered", now.format(DateTimeFormatter.ISO_OFFSET_DATE_TIME)) + // >30 min -> pruned + .put("999999:Delivered", now.minusMinutes(45).format(DateTimeFormatter.ISO_OFFSET_DATE_TIME))); + RunResult r = run(tmpDir, triggers, metadata, "jane@example.com"); + + assertEquals(1, r.gorgias().ticketLog.size()); // one ticket despite the duplicate delivery + assertEquals(Set.of("456789:Delivered", "456790:Delivered"), r.result().getJSONObject("processed_events").keySet()); + } + + /** Dedupe is per shipment and status: a redelivery is dropped, a genuine next status for + * the same shipment still opens its own ticket. */ + @Test + void runTicketsEveryStatusButNotTheSameOneTwice(@TempDir Path tmpDir) { + JSONArray triggers = new JSONArray() + .put(Helpers.webhookTrigger(tmpDir, Helpers.makeShipmentEvent(1, "OnHold", "jane@example.com", null), "t1")) + .put(Helpers.webhookTrigger(tmpDir, Helpers.makeShipmentEvent(1, "OnHold", "jane@example.com", null), "t2")) + .put(Helpers.webhookTrigger(tmpDir, Helpers.makeShipmentEvent(1, "Delivered", "jane@example.com", null), "t3")); + RunResult r = run(tmpDir, triggers, null, "jane@example.com"); + + assertEquals(2, r.gorgias().ticketLog.size()); + assertEquals(Set.of("1:OnHold", "1:Delivered"), r.result().getJSONObject("processed_events").keySet()); + } + + @Test + void runCreatesCustomerByExternalIdWhenRecipientHasNoEmail(@TempDir Path tmpDir) { + JSONArray triggers = new JSONArray().put(Helpers.webhookTrigger(tmpDir, Helpers.makeOnholdEvent(), "t1")); + RunResult r = run(tmpDir, triggers, null, "jane@example.com"); + + JSONObject created = r.gorgias().createLog.get(0); + assertFalse(created.has("email")); + // the synthetic key the cron flow uses too: name address1 city country + assertEquals("Jane Buyer 100 Nowhere Blvd Gotham City US", created.getString("external_id")); + + JSONObject ticket = r.gorgias().ticketLog.get(0); + assertEquals(1001, ticket.getJSONObject("customer").getLong("id")); // the customer we just created + String body = ticket.getJSONArray("messages").getJSONObject(0).getString("body_text"); + assertTrue(body.contains("is now OnHold")); + assertTrue(body.contains("Reason: Invalid Address; Payment Failure")); + assertFalse(body.contains("Tracking:")); // OnHold shipments carry none + assertTrue(body.contains("4 x Pinnacle Shampoo (PIN-100)")); + assertEquals(Set.of("107414278:OnHold"), r.result().getJSONObject("processed_events").keySet()); + } +} From d88ba889924618ac29a5cbf04195b909c7b47f71 Mon Sep 17 00:00:00 2001 From: shanta3220 Date: Wed, 9 Sep 2026 09:17:11 -0400 Subject: [PATCH 2/2] Minor fixes --- .../java/src/main/java/sb2gorgias/Cron.java | 2 +- .../java/src/main/java/sb2gorgias/ShipBobApi.java | 11 +++++++++-- .../java/src/main/java/sb2gorgias/Webhook.java | 4 ++-- 3 files changed, 12 insertions(+), 5 deletions(-) diff --git a/shipbob_to_gorgias/java/src/main/java/sb2gorgias/Cron.java b/shipbob_to_gorgias/java/src/main/java/sb2gorgias/Cron.java index fef98c59..782dea6a 100644 --- a/shipbob_to_gorgias/java/src/main/java/sb2gorgias/Cron.java +++ b/shipbob_to_gorgias/java/src/main/java/sb2gorgias/Cron.java @@ -164,7 +164,7 @@ static void processOrder(JSONObject order, GorgiasClient gorgias, Map getOrders(Map params) { LOGGER.log(Level.SEVERE, "ShipBob order fetch failed (" + params + "): " + e.getMessage()); throw e; } - if (data == null) { + if (data == null || data == JSONObject.NULL) { return List.of(); } if (!(data instanceof JSONArray array)) { @@ -111,7 +112,13 @@ public List updatedOrdersPage(OffsetDateTime startDate, int page) { params.put("LastUpdateStartDate", startDate.format(DateTimeFormatter.ISO_OFFSET_DATE_TIME)); params.put("Page", String.valueOf(page)); List orders = getOrders(params); - orders.sort(Comparator.comparing((JSONObject order) -> updateDate(order, startDate)).reversed()); + // Computed once per order since updateDate() can fall back to now(), which must stay + // stable across a single sort. + Map updateDates = new IdentityHashMap<>(); + for (JSONObject order : orders) { + updateDates.put(order, updateDate(order, startDate)); + } + orders.sort(Comparator.comparing(updateDates::get).reversed()); return orders; } diff --git a/shipbob_to_gorgias/java/src/main/java/sb2gorgias/Webhook.java b/shipbob_to_gorgias/java/src/main/java/sb2gorgias/Webhook.java index afb9af8a..06cb8b2d 100644 --- a/shipbob_to_gorgias/java/src/main/java/sb2gorgias/Webhook.java +++ b/shipbob_to_gorgias/java/src/main/java/sb2gorgias/Webhook.java @@ -81,7 +81,7 @@ static String statusDetails(JSONObject event) { JSONArray details = detailsObj instanceof JSONArray a ? a : new JSONArray(); List reasons = new ArrayList<>(); for (int i = 0; i < details.length(); i++) { - if (details.opt(i) instanceof JSONObject d) { + if (details.opt(i) instanceof JSONObject d && !d.isEmpty()) { String desc = d.optString("description", ""); reasons.add(!desc.isEmpty() ? desc : d.optString("name", "")); } @@ -217,7 +217,7 @@ static JSONObject resolveCustomer(GorgiasClient gorgias, JSONObject event) { JSONObject existing = gorgias.findCustomer(email.isEmpty() ? null : email, email.isEmpty() ? key : null); if (existing != null) { - return new JSONObject().put("id", existing.opt("id")); + return new JSONObject().put("id", existing.get("id")); } long newId = gorgias.createCustomer(GorgiasApi.newCustomerPayload(event, key)); return new JSONObject().put("id", newId);