# Shipping Configurations

Complete reference for every shipment group configuration produced by `getBigOrderShipmentGroups()` in [`api/src/fabricator/ShippingAdminApi.ts`](../../api/src/fabricator/ShippingAdminApi.ts).

Source schemas: [`FabricatorSchemas.ts`](../../api/src/fabricator/FabricatorSchemas.ts) lines 3618–4039.

Related docs: [services/admin-api.md](./admin-api.md) | [services/admin-api-workers.md](./admin-api-workers.md) | [libraries/api-handlers.md](../libraries/api-handlers.md)

---

## How a Shopify order becomes shipments

A Shopify order goes through two distinct decision points:

1. **Grouping** (`getBigOrderShipmentGroups`, line 1048) — partitions Shopify line items into `BigOrderShipmentGroup`s, each tagged with a preliminary `BigShipmentPackageType`. This determines *what ships together*.
2. **Label-time box selection** (`getShippingConfig2`, line 3553) — at the moment a shipping label is generated, inspects the actual inventory slots in the `BigShipment` and picks the final `BigShippingConfig` (box dimensions, weight, customs data). This can "upgrade" the box when the physical contents differ from the preliminary grouping.

Between these two steps, `getLineItemSummary5` (line 542) creates the `BigLineItem` records, assigns each to its shipment group, and runs auto-add hacks that inject synthetic cushion line items. The shipment group's `id` is a SHA-256 hash of the sorted `shopifyLineItemIds` array — if the two functions disagree on which IDs belong to a group, the hashes diverge and the order sync worker enters a perpetual "repair" cycle.

```mermaid
sequenceDiagram
    participant Shopify
    participant Sync as sync_all_big_orders
    participant Group as getBigOrderShipmentGroups()
    participant Summary as getLineItemSummary5()
    participant PG as Postgres
    participant Op as Operator (Arda)
    participant Config as getShippingConfig2()
    participant Shippo

    Shopify->>Sync: order payload
    Sync->>Group: shopify line_items
    Group-->>Sync: BigOrderShipmentGroup[]
    Sync->>Summary: shopify order + bigOrder + groups
    Summary-->>Sync: BigLineItemSummary (with auto-added cushions)
    Sync->>PG: persist BigOrder + line items + groups

    Note over PG,Op: later, when order is ready to ship

    Op->>Config: BigShipment (inventorySlots)
    Config-->>Op: BigShippingConfig (box dims + customs)
    Op->>Shippo: create label (PDF_4x6)
    Shippo-->>Op: tracking number + label PDF
    Op->>PG: upload PDF to S3, store on BigShipment
```

---

## Phase 1 — Shipment grouping

`getBigOrderShipmentGroups()` runs 11 sequential passes over a working pool of `knownLineItems`. Each pass consumes items from the pool; anything left after step 10 falls into the catch-all.

```mermaid
flowchart TB
    START([Shopify order line_items]) --> F1[Filter refunded items]
    F1 --> F2[Filter unknown product types]
    F2 --> S1["1. Halos — one HaloStrap per item"]
    S1 --> S2["2. Merchandise — single Default group"]
    S2 --> S3["3. Beyond loop — Cyberbox or CyberboxPlusAudioStrap per device"]
    S3 --> S4["4. Halo-only cushion fallback — UniversalCushionOnly"]
    S4 --> S5["5. AudioStrap + UniversalCushion pairing"]
    S5 --> S6["6. Remaining cushions — CushionOnly each"]
    S6 --> S7["7. Lens + Insert pairs — Lens (only if counts match)"]
    S7 --> S8["8. Remaining AudioStraps — AudioStrap each"]
    S8 --> S9["9. Storage cans — StorageCan each"]
    S9 --> S10["10. LinkBoosters — SmallAccessoryBox or LinkBoosterOnly"]
    S10 --> S11["11. Catch-all — SmallAccessoryBox"]
    S11 --> END([return shipmentGroups])
```

### Step 1 — Halos

Each `HaloMount` or `UniversalHalo` becomes its own `HaloStrap` group. One line item per group, processed before everything else. Sets the `hasHaloItem` flag used by later hacks.

### Step 2 — Merchandise

All `Merchandise` items are grouped into a single `Default` group. Only created if merch items exist — an empty group would cause hash mismatches during sync.

### Step 3 — Beyond loop

The `while(true)` loop (line 1170) consumes one Beyond device per iteration plus **one of each** companion type from the remaining pool:

| Companion | Types consumed |
|-----------|---------------|
| cushion | `BeyondCushionV1`, `ReplacementBeyondCushionV1`, `CustomCushion`, `UniversalLightFitSeal` |
| shell | `B2ShellBlack`, `B2ShellClear`, `B2ShellOrange`, `B2ShellYellow`, `B2ShellVRChatPurple` |
| lens | `PrescriptionLenses` |
| lensInsert | `PrescriptionLensInserts` |
| lightseal | `UniversalLightFitSeal` |
| audioStrap | `AudioStrapV1`, `AudioStrap` |
| racingGloves | `BigscreenRacingGloves` |
| linkBooster | `LinkBooster` |
| accessories | one of each: `B2AccessoriesBundle`, `BeyondLinkBoxV1/V2`, `BeyondFibreOpticCableV1/V2`, `BeyondSoftStrapV1/V2`, `Beyond2IPDTool` |

Beyond device types: `BigscreenBeyondV1`, `Beyond2`, `Beyond2Eye`, `Beyond2Upgrade`, `Beyond2EyeUpgrade`, `Beyond2EyeVRChat`.

Package type decision:

```
audioStrapLineItem present? → CyberboxPlusAudioStrap (15x11x9, 4.5 lb)
                            → Cyberbox              (10x7x6, 2.5 lb)
```

**Hack A — BS1B00 auto-cushion** (line 1214): If the Beyond is an old `BigscreenBeyondV1` with SKU `BS1B00` and no cushion was consumed, pushes `ARDA_EXTRA_CUSHION_SHOPIFY_ORDER_LINE_ITEM_ID` into this group.

**Hack B — Beyond2+Halo auto-cushion** (line 1223): If `hasHaloItem` is true AND this is a Beyond2 variant (excludes old `BigscreenBeyondV1`) AND no cushion or lightseal was consumed, pushes `ARDA_EXTRA_UNIVERSAL_CUSHION_SHOPIFY_ORDER_LINE_ITEM_ID` into this group.

### Step 4 — Halo-only cushion fallback

Runs after the Beyond loop. If `hasHaloItem` is true AND no `ARDA_EXTRA_UNIVERSAL_CUSHION` exists in any group AND no explicit `UniversalLightFitSeal` remains in the pool, creates a **separate** `UniversalCushionOnly` group (8x7x4, 1 lb) containing only the synthetic cushion. The cushion does NOT bundle with the Halo — it ships as its own package.

### Step 5 — AudioStrap + UniversalCushion pairing

Remaining AudioStraps are paired 1:1 with remaining `UniversalLightFitSeal` items → `AudioStrapPlusUniversalCushion` (15x8x8, 3 lb). Count = `min(remainingAudioStraps, universalCushions)`. Excess of either falls through.

### Step 6 — Remaining cushions

Each remaining cushion (`BeyondCushionV1`, `ReplacementBeyondCushionV1`, `CustomCushion`, `UniversalLightFitSeal`) → individual `CushionOnly` group (8x4x3, 0.5 lb).

### Step 7 — Lens pairs

If remaining `PrescriptionLenses` count equals `PrescriptionLensInserts` count, pairs them 1:1 → `Lens` groups (8x4x0.5, 0.02 lb). **If counts don't match, neither type is consumed** — both fall to the catch-all.

### Step 8 — Remaining AudioStraps

Each remaining AudioStrap → individual `AudioStrap` group (11x7x6, 2 lb).

### Step 9 — Storage cans

Each remaining `StorageCan` → individual `StorageCan` group (8x4x3, 0.5 lb).

### Step 10 — LinkBoosters

If LinkBoosters AND remaining accessory items both exist: one `SmallAccessoryBox` group (8x4x0.5, 0.5 lb) bundling all of them together. Otherwise each LinkBooster → individual `LinkBoosterOnly` group (8x4x0.5, 0.5 lb).

### Step 11 — Catch-all

Anything still in `knownLineItems` → single `SmallAccessoryBox` group. Catches mismatched lens/insert counts, racing gloves not paired with a Beyond, future product types, etc.

---

## getLineItemSummary5 — Line item creation and auto-adds

`getLineItemSummary5` (line 542) converts Shopify line items into `BigLineItem` records and assigns each to its shipment group. It also runs the same auto-add hacks as `getBigOrderShipmentGroups`, but at the line-item level rather than the group-ID level.

Key behaviors:

- **Refund handling**: Uses `fulfillable_quantity` from Shopify (subtracts fulfilled + refunded). Falls back to `quantity - refundedQty`.
- **ID preservation**: On re-sync, existing `BigLineItem` IDs are preserved by positional matching within the same `shopifyOrderLineItemId`. This keeps `BigShipmentInventorySlot.bigLineItemId` pointers stable.
- **Cushion job carry-over**: Existing cushion items retain their `currentJobId` and `jobIds` across re-syncs so in-flight CNC jobs aren't lost.
- **SKU BS2S-O hack**: The Beyond 2 Shell Orange SKU started with `requires_shipping = false` in Shopify; it's force-included via a hardcoded allow-list.

### Auto-add: Old Beyond cushion (line 762)

If a `BigscreenBeyondV1` with SKU `BS1B00` exists and no `BeyondCushionV1` is present, creates a new `BigLineItem` of type `CustomCushion` with:
- `shopifyOrderLineItemId`: `ARDA_EXTRA_CUSHION_SHOPIFY_ORDER_LINE_ITEM_ID`
- `shopifySku`: `ARDA_EXTRA_CUSHION_SHOPIFY_SKU`
- `metaData`: `{ oldBeyondCushion: true }`
- `shipmentGroupId` / `shipmentPackageType`: inherited from the Beyond's group

If a matching `AwaitingFulfilment` job already exists on the order, the new cushion line item gets that job attached.

### Auto-add: Universal cushion for Halo (line 826)

If a Halo item exists (`HaloMount` or `UniversalHalo`) and no `UniversalLightFitSeal` is present anywhere in the order:

1. Looks up which `BigOrderShipmentGroup` contains `ARDA_EXTRA_UNIVERSAL_CUSHION_SHOPIFY_ORDER_LINE_ITEM_ID`.
2. Creates a `BigLineItem` of type `UniversalLightFitSeal` with `metaData: { autoAddedForHalo: true }`.
3. **Beyond2+Halo orders**: cushion's group and fulfillment status follow the Beyond2, not the Halo.
4. **Halo-only orders**: cushion goes in the separate `UniversalCushionOnly` group. Fulfillment status is determined by whether a non-cancelled `BigShipment` already exists for that group.

---

## Shipment group ID

`BigOrderShipmentGroup.id` = `SHA256(shopifyLineItemIds.join(","))` (line 4070 in `FabricatorSchemas.ts`). Because the ID is derived from the exact set of line item IDs, both `getBigOrderShipmentGroups` and `getLineItemSummary5` must agree on which IDs (including `ARDA_EXTRA_*` synthetic IDs) belong to each group. If they disagree, the sync worker detects a `ShipmentGroupUpdateNeeded` action and triggers a "repair" — which re-runs and still disagrees, creating an infinite loop.

---

## BigShipment lifecycle

A `BigShipment` (line 4075 in `FabricatorSchemas.ts`) is created when an operator begins preparing a shipment group for shipping. It is persisted to PostgreSQL.

**Fields**: `creatorBigscreenAccountId`, `bigOrderId`, `shopifyOrderId`, `shopifyShippingMetaData`, `status`, `name`, `priority`, `inventoryItemIds`, `history` (JSON array of `BigShipperHistoryItem`), `currentShippingLabel`, `inventorySlots` (JSON array of `BigShipmentInventorySlot`), `shipmentGroupId`.

**Status lifecycle**:

```mermaid
stateDiagram-v2
    [*] --> Init
    Init --> Picking: PickInventoryItem
    Picking --> PrepareShippingLabel: FinishPicking
    PrepareShippingLabel --> ReadyToPack: GenerateShippingLabel
    ReadyToPack --> Packing: StartPacking
    Packing --> PreparingToShip: FinishPacking
    PreparingToShip --> PackedAndLabelled
    PackedAndLabelled --> WaitingForPickup: ShipIt
    WaitingForPickup --> InTransit: UpdateCarrierStatus
    InTransit --> Shipped: UpdateCarrierStatus
    InTransit --> DeliveryFailed: UpdateCarrierStatus
    WaitingForPickup --> Shipped: ForceShipped
    state "any" as any
    any --> Cancelled: Cancel
```

Additional actions: `RemachineCushion` (during Picking), `VerifyInventoryItem` (during Packing), `RegenerateShippingLabel`, `ForceWaitingForPickup`, `AdminCreateReturnLabel` (requires Shipped or WaitingForPickup).

---

## Phase 2 — Label-time box selection

At label generation, `getShippingConfig2()` (line 3553) ignores the preliminary `packageType` and inspects the `BigShipment.inventorySlots` to pick the physical box. The code checks for specific Cyberbox inventory product types (Standard, Mini, V2) with various companion combinations:

| Contains | + AudioStrap + UnivCushion + Halo | Box config |
|----------|-----------------------------------|------------|
| MiniCyberbox shell | + AS + UC | `CyberBRICKPlusAudioStrapPlusUniversalCushion` 12x12x8, 4 lb |
| MiniCyberbox shell | + AS | `MiniCyberboxPlusAudioStrap` 15x8x8, 3 lb |
| MiniCyberbox shell | alone | `MiniCyberbox` 8x4x3, 1 lb |
| V2 Cyberbox shell | any | `V2Cyberbox` 10x7x6, 2.5 lb |
| Standard Cyberbox shell | + AS + UC + Halo | `CB+AS+UC+Halo` 14x12x12, 7 lb |
| Standard Cyberbox shell | + AS + UC | `CB+AS+UC` 15x11x11, 6 lb |
| Standard Cyberbox shell | + AS | `CyberboxPlusAudioStrap` 15x11x9, 4.5 lb |
| Standard Cyberbox shell | + UC (no AS, no Halo) | `CyberboxPlusUniversalCushion` 15x8x8, 3 lb |
| Standard Cyberbox shell | alone | `Cyberbox` 10x7x6, 2.5 lb |
| Beyond headset (no shell) | + AS | `CyberboxPlusAudioStrap` 15x11x9, 4.5 lb |
| Beyond headset (no shell) | + UC | `BeyondPlusUniversalCushion` 15x8x8, 3 lb |
| Beyond headset (no shell) | alone | `Cyberbox` 10x7x6, 2.5 lb |

For non-headset shipments (first match wins):

| Condition | Config | Box |
|-----------|--------|-----|
| AudioStrap + UniversalCushion | `AudioStrapPlusUniversalCushion` | 15x8x8, 3 lb |
| AudioStrap only | `AudioStrap` | 11x7x6, 2 lb |
| `B2AccessoriesBundle` | `AccessoriesBundle` | 7.5x12x0.5, 0.75 lb |
| `ReplacementBigscreenBeyondV1` | `ReplacementBeyond` | 9x4x4, 1 lb |
| Lens (no cushion) | `Lens` | 8x4x0.5, 0.02 lb |
| `UniversalLightFitSeal` (no lens) | `UniversalCushionOnly` | 8x7x4, 1 lb |
| Old cushion (no lens, no universal) | `CushionOnly` | 8x4x3, 0.5 lb |
| `IPDDiscoveryUnitV1` | `IPDDiscoveryUnit` | 11x10x6, 3 lb |
| `StorageCan` | `StorageCan` | 8x4x3, 0.5 lb |
| `LinkBooster` | `LinkBoosterOnly` | 8x4x0.5, 0.5 lb |
| Large accessories (fibre optic cable, `B2AccessoriesBundle`) | `AccessoriesBundle` | 7.5x12x0.5, 0.75 lb |
| Small accessories (soft strap, link box) | `SmallAccessoryBox` | 8x4x0.5, 0.5 lb |
| Merchandise/RacingGloves, count ≤ 2 | `SmallMerchBox` | 8x4x0.5, 0.5 lb |
| Merchandise/RacingGloves, count > 2 | `LargeMerchBox` | 7.5x12x0.5, 0.75 lb |
| Dev/test + Halo only | `AudioStrap` (test fallback) | 11x7x6, 2 lb |
| Nothing matched | returns `null` → label generation blocked | — |

### Customs price adjustment (getAdjustedCustomsPrices2)

`getShippingConfig2` always calls `getAdjustedCustomsPrices2` (line 3796) before returning. This function:

1. Finds all `BigLineItem`s in this shipment group.
2. Extracts Shopify `presentment_money` prices (customer's currency) per line item; falls back to `shop_money` (USD).
3. Builds a per-item `customsItems` array using `BigProductTypeCustomsInfo` for tariff numbers, descriptions, and weights.
4. For synthetic `ARDA_EXTRA_UNIVERSAL_CUSHION` items, applies `customsValueOverrides` (JPY: 3100, EUR: 27, GBP: 23, AUD: 43, USD: 29).
5. Ensures the parcel weight is at least the sum of individual item weights.
6. Overrides the total `valueAmount` with the Shopify presentment total.

---

## Carrier selection

`generateShippingLabelInternal` (line 4071) selects the carrier and service level by destination country. All shipments originate from `LA_FACTORY` (Los Angeles).

| Destination | Carrier | Service level | Notes |
|-------------|---------|---------------|-------|
| US (standard) | UPS | `ups_ground` | Falls back to USPS Priority on UPS error |
| US military (APO/FPO/DPO) | USPS | `usps_priority` | Detected by city = "APO"/"FPO"/"DPO" |
| Canada | UPS | `ups_standard` | Can override to DHL Express via `overrideCarrierCode` |
| Japan | DHL Express | `dhl_express_worldwide_nondoc` | Return address set to `JP_RETURN` (IntoFree Inc., Miura, Kanagawa) |
| All other countries | DHL Express | `dhl_express_worldwide_nondoc` | — |

Carrier account selection (line 4398): UPS uses Bigscreen's own account (not Shippo's). DHL uses the account ending in suffix `9753`.

### Proxy labels

For Japan shipments, a Japanese proxy label PDF is generated using `NotoSansJP-SemiBold.ttf` (line 4228). It renders the address in reverse order (country first) for Japanese post offices, with a Code128 barcode of the order name and a `JP_` prefixed tracking number.

A latin proxy label is generated using `Roboto-Bold.ttf` for other non-standard address scripts.

---

## Distribution center addresses

| Key | Name | Location | Used for |
|-----|------|----------|----------|
| `LA_FACTORY` | Bigscreen Inc. | 6344 Arizona Circle, Los Angeles CA 90045 | Origin for all outbound shipments |
| `JP_RETURN` | IntoFree Inc. | 3081-19 HassemachiWada, Miura, Kanagawa 238-0114 | Return address on Japan DHL labels |
| `AU` | Bigscreen Inc. (via IMRNext) | 55 Barry Parade, Fortitude Valley QLD 4006 | Australia distribution |
| `NL_FLEXPORT` | Flexport | Walravenlaan 9, Schiphol 1119 ME | Netherlands distribution |

Return addresses (for return labels):

| Key | Location | Used for |
|-----|----------|----------|
| `EU` | Bigscreen Inc., Holmersweg 44, Lochem 7241 ME, NL | EU customer return labels |

---

## Customs declarations

### Per-product customs info (`BigProductTypeCustomsInfo`)

Every product type has customs data used to build Shippo `customsItemRequests`:

| Product category | Tariff (HS code) | Description | Weight | Default value |
|-----------------|------------------|-------------|--------|---------------|
| VR Headsets (Beyond, Cyberbox standard/V2) | `8528.5210.00` | VR Headset | 2.0 lb | $999 |
| VR Headsets (Eye tracking variants) | `8528.5210.00` | VR Headset with Eye Tracking | 2.0 lb | $1,299 |
| Cyberbox Mini shells | `8528.5210.00` | VR Headset | 1.0 lb | $999 |
| Cyberbox Mini Eye shells | `8528.5210.00` | VR Headset with Eye Tracking | 1.0 lb | $1,299 |
| AudioStrap / AudioStrapV1 | `8518.30` | Headphones for VR Headset | 1.5 lb | $99 |
| PrescriptionLenses | `8529.90` | Prescription Lenses for VR Headset | 0.02 lb | $99 |
| PrescriptionLensInserts | `8529.90` | Prescription Lens Inserts for VR Headset | 0.02 lb | $99 |
| BeyondCushionV1, ReplacementBeyondCushionV1 | `8529.90` | VR Headset Cushion | 0.5 lb | $19 |
| CustomCushion | `8529.90` | Custom VR Headset Cushion | 0.5 lb | $49 |
| UniversalLightFitSeal | `8529.90` | VR Headset Light Fit Seal | 0.25 lb | $29 (overrides: JPY 3100, EUR 27, GBP 23, AUD 43) |
| BeyondFibreOpticCableV1/V2 | `8544.70` | Fiber Optic Cable for VR Headset | 0.5 lb | $49 |
| BeyondLinkBoxV1/V2 | `8529.90` | VR Headset Link Box | 0.5 lb | $49 |
| BeyondSoftStrapV1/V2 | `8529.90` | VR Headset Soft Strap | 0.25 lb | $29 |
| HaloMount | `8529.90` | VR Headset Halo Mount | 0.5 lb | $49 |
| UniversalHalo | `8529.90` | VR Headset Universal Halo | 0.5 lb | $49 |
| ReplacementBigscreenBeyondV1 | `8529.90` | VR Headset Replacement Unit | 1.0 lb | $19 |
| IPDDiscoveryUnitV1 | `8529.90` | IPD Discovery Unit | 0.5 lb | $49 |
| Beyond2IPDTool | `8529.90` | IPD Measurement Tool | 0.25 lb | $19 |
| StorageCan | `8529.90` | VR Headset Storage Can | 0.5 lb | $29 |
| B2AccessoriesBundle | `8529.90` | VR Headset Accessories Bundle | 1.0 lb | $99 |
| Shells (all colors) | `8529.90` | VR Headset Cover Shell | 0.25 lb | $29 |
| Merchandise | `6109.10` | Branded Merchandise | 0.5 lb | $29 |
| BigscreenRacingGloves | `6116.10` | Racing Gloves | 0.25 lb | $49 |
| LinkBooster | `8473.30` | Link Booster | 0.5 lb | $19 |

### Country-specific customs handling

**US domestic**: No customs declaration.

**US military (APO/FPO/DPO) and Guam**: Customs declaration required with truncated fields (contentsExplanation: 25 chars, contentsDescription: 30 chars, item descriptions: 30 chars). Incoterm: DDP. EEL: `NOEEI_30_36`.

**EU + GB** (30 countries: AT, BE, BG, HR, CY, CZ, DK, EE, FI, FR, DE, GR, HU, IS, IE, IT, LV, LT, LU, MT, NL, NO, PL, PT, RO, SK, SI, ES, SE, CH + GB): VAT calculated using per-country tax rates (range: 8.1% CH to 27% HU). Incoterm: DDP. `isVatCollected: true`. Exporter identification included when `includeExporterIdentification` is true:

| Country | EORI | VAT |
|---------|------|-----|
| NL | `NL827505930` | `NL827505930B01` |
| GB | `GB458584836000` | `GB458584836` |

**Canada (UPS)**: Exporter identification with `EIN: 760222356RM0001`. Incoterm: DDP. EEL: `NOEEI_30_36`.

**All other international**: Standard customs declaration. Incoterm: DDP. `isVatCollected: true`.

**Return labels**: `contentsType` changed from `MERCHANDISE` to `RETURN_MERCHANDISE`. Tariff overridden to `8529.90`. Description: "Goods returned to manufacturer under warranty." For UPS/USPS returns, addresses stay normal with `isReturn: true` flag. For DHL returns, addresses are reversed (sender becomes recipient).

### Countries requiring recipient tax ID

| Country | Tax ID type | Shopify field keys |
|---------|-------------|-------------------|
| BR (Brazil) | CPF (11 digits) or CNPJ (14 digits) | `TAX_CREDENTIAL_BR`, `SHIPPING_CREDENTIAL_BR` |
| KR (South Korea) | Personal Customs Clearance Code (PCCC) | `SHIPPING_CREDENTIAL_KR`, `TAX_CREDENTIAL_KR` |
| CN (China) | Resident Identity Card number | `SHIPPING_CREDENTIAL_CN`, `TAX_CREDENTIAL_CN` |

Taiwan (TW) is defined but commented out — Shopify doesn't yet support the required fields. If the tax ID is missing, label generation throws an error explaining which fields are expected.

---

## Return labels

Triggered by `BigShipmentAction.AdminCreateReturnLabel`. Requires `Admin`, `Fabricator`, or `SuperUser` access policy. The shipment must be in `Shipped` or `WaitingForPickup` status.

For EU destinations, the return address is the NL warehouse (Lochem). For all others, the return address is `LA_FACTORY`.

The `ReplacementBeyond` package type has `includeExporterIdentification: false` because warranty returns don't need exporter VAT/EORI info.

---

## Shippo label creation flow

`createOneShotShippingLabel` (line 4301) builds and sends the Shippo `InstantTransactionCreateRequest`:

```
{
    shipment: {
        addressTo:   (from Shopify shipping_address)
        addressFrom: (distribution center)
        parcels:     [shippingConfig.shippoParcelRequest]
        extra: {
            invoiceNumber: shopifyOrderName,
            reference1:    recipientTaxId (if BR/CN/KR)
        }
        customsDeclaration: (if international)
        addressReturn:      (if Japan → JP_RETURN)
    },
    carrierAccount:    (selected by carrier code)
    servicelevelToken: (ups_ground / usps_priority / ups_standard / dhl_express_worldwide_nondoc)
    labelFileType:     "PDF_4x6"
    metadata:          shopifyOrderName
}
```

After Shippo returns the label:
1. Downloads the PDF from `label_url`.
2. Adds margins (15px top, 3px left) using `pdf-lib`.
3. Uploads the modified PDF to S3.
4. Stores the S3 URL as `uploadLabelUrl` on the `BigShipment.currentShippingLabel`.

---

## Package type reference

Every `BigShipmentPackageType` with its Shippo parcel config. All dimensions L x W x H in inches, weights in pounds, values in USD.

| Package Type | Dimensions | Weight | Value | Tariff | Customs description |
|---|---|---|---|---|---|
| `Cyberbox` | 10 x 7 x 6 | 2.5 | $999 | 8528.5210.00 | VR Headset |
| `MiniCyberbox` | 8 x 4 x 3 | 1.0 | $999 | 8528.5210.00 | VR Headset |
| `V2Cyberbox` | 10 x 7 x 6 | 2.5 | $999 | 8528.5210.00 | VR Headset |
| `CyberboxPlusAudioStrap` | 15 x 11 x 9 | 4.5 | $1,099 | 8528.5210.00 | VR Headset, Audio Strap |
| `MiniCyberboxPlusAudioStrap` | 15 x 8 x 8 | 3.0 | $1,099 | 8528.5210.00 | VR Headset, Audio Strap |
| `CyberboxPlusUniversalCushion` | 15 x 8 x 8 | 3.0 | $1,099 | 8528.5210.00 | VR Headset, Universal Cushion |
| `CyberboxPlusAudioStrapPlusUniversalCushion` | 15 x 11 x 11 | 6.0 | $1,099 | 8528.5210.00 | VR Headset, Audio Strap, Universal Cushion |
| `CyberboxPlusAudioStrapPlusUniversalCushionPlusHaloMount` | 14 x 12 x 12 | 7.0 | $1,099 | 8528.5210.00 | VR Headset, Audio Strap, Universal Cushion, Halo Mount |
| `CyberBRICKPlusAudioStrapPlusUniversalCushion` | 12 x 12 x 8 | 4.0 | $1,099 | 8528.5210.00 | VR Headset, Audio Strap, Universal Cushion |
| `BeyondPlusUniversalCushion` | 15 x 8 x 8 | 3.0 | $99 | 8529.90 | VR Headset, Universal Cushion |
| `AudioStrap` | 11 x 7 x 6 | 2.0 | $99 | 8518.30 | VR Headset Audio Strap |
| `AudioStrapPlusUniversalCushion` | 15 x 8 x 8 | 3.0 | $99 | 8529.90 | VR Headset Audio Strap, Universal Cushion |
| `HaloStrap` | 11 x 7 x 6 | 2.0 | $99 | 8518.30 | VR Headset Halo Strap |
| `Lens` | 8 x 4 x 0.5 | 0.02 | $99 | 8529.90 | Lenses for VR Headset |
| `CushionOnly` | 8 x 4 x 3 | 0.5 | $19 | 8529.90 | VR Headset Cushion |
| `UniversalCushionOnly` | 8 x 7 x 4 | 1.0 | $19 | 8529.90 | VR Headset Universal Cushion |
| `StorageCan` | 8 x 4 x 3 | 0.5 | $19 | 8529.90 | VR Headset Storage Can |
| `SmallAccessoryBox` | 8 x 4 x 0.5 | 0.5 | $19 | 8529.90 | Replacement Accessories for VR Headset |
| `AccessoriesBundle` | 7.5 x 12 x 0.5 | 0.75 | $19 | 8529.90 | Replacement Accessories for VR Headset |
| `LinkBoosterOnly` | 8 x 4 x 0.5 | 0.5 | $19 | 8473.30 | USB Adapter for VR Headset |
| `ReplacementBeyond` | 9 x 4 x 4 | 1.0 | $19 | 8529.90 | Goods returned to manufacturer under warranty. |
| `IPDDiscoveryUnit` | 11 x 10 x 6 | 3.0 | $19 | 8529.90 | VR Headset Accessories |
| `SmallMerchBox` | 8 x 4 x 0.5 | 0.5 | $19 | 6109.10 | Merchandise, Apparel |
| `LargeMerchBox` | 7.5 x 12 x 0.5 | 0.75 | $19 | 6109.10 | Merchandise, Apparel |
| `Default` | (none) | — | $1 | — | — |
| `Unknown` | (none) | — | $0 | — | — (never assigned; causes error if used) |

`ReplacementBeyond` is the only type with `includeExporterIdentification: false`.

---

## Product type categories (grouping constants)

| Category | Product types |
|----------|---------------|
| `beyondProductTypes` | `BigscreenBeyondV1`, `Beyond2`, `Beyond2Eye`, `Beyond2Upgrade`, `Beyond2EyeUpgrade`, `Beyond2EyeVRChat` |
| `beyond2ProductTypes` (Hack B check) | `Beyond2`, `Beyond2Eye`, `Beyond2Upgrade`, `Beyond2EyeUpgrade`, `Beyond2EyeVRChat` |
| `audioStrapProductTypes` | `AudioStrapV1`, `AudioStrap` |
| `shellProductType` | `B2ShellBlack`, `B2ShellClear`, `B2ShellOrange`, `B2ShellYellow`, `B2ShellVRChatPurple` |
| `lensProductTypes` | `PrescriptionLenses` |
| `lensInsertProductTypes` | `PrescriptionLensInserts` |
| `cushionProductTypes` | `BeyondCushionV1`, `ReplacementBeyondCushionV1`, `CustomCushion`, `UniversalLightFitSeal` |
| `lightsealCushionProductTypes` | `UniversalLightFitSeal` |
| `racingGloveProductTypes` | `BigscreenRacingGloves` |
| `haloLineItemTypes` | `HaloMount`, `UniversalHalo` |
| `accessoryLineItemTypes` | `B2AccessoriesBundle`, `BeyondLinkBoxV1`, `BeyondFibreOpticCableV1`, `BeyondSoftStrapV1`, `BeyondLinkBoxV2`, `BeyondFibreOpticCableV2`, `BeyondSoftStrapV2`, `Beyond2IPDTool` |
| `storageCanLineItemTypes` | `StorageCan` |
| `merchLineItemTypes` | `Merchandise` |

`LinkBooster` is matched directly (not via an array constant).

---

## Relevant tests

| Test | Coverage |
|------|----------|
| [`029.BigShipperFlow.ts`](../../tests/fabricator/029.BigShipperFlow.ts) | End-to-end BigShipper queue |
| [`037.ShippingAddressConfirm.ts`](../../tests/fabricator/037.ShippingAddressConfirm.ts) | Shipping address confirmation emails |
| [`055.ReturnLabels.ts`](../../tests/fabricator/055.ReturnLabels.ts) | Return label generation for international orders |
| [`063.ShippingServiceLevel.ts`](../../tests/fabricator/063.ShippingServiceLevel.ts) | Fastest/Standard service level + RBAC |
| [`080.Beyond2Basics.ts`](../../tests/fabricator/080.Beyond2Basics.ts) | Beyond2 grouping |
| [`102.Beyond2.ThreeShipments.ts`](../../tests/fabricator/102.Beyond2.ThreeShipments.ts) | Multi-Beyond grouping |
| [`110.ShipmentReturnLabels.ts`](../../tests/fabricator/110.ShipmentReturnLabels.ts) | Shipment-based return labels at WaitingForPickup |
| [`113.ShippingConfigurations.ts`](../../tests/fabricator/113.ShippingConfigurations.ts) | Phase 2 box configs (CyberBRICK, CB+AS+UC, etc.) |
| [`114`–`119` UniversalCushion series](../../tests/fabricator/115.UniversalCushion.CyberboxPlusAudioStrapPlusHalo.ts) | Each cushion auto-add combo (CB+AS, CB+Halo, CyberBRICK, Beyond only, AS only, Cushion only) |
| [`124.CustomsDeclarationItems.ts`](../../tests/fabricator/124.CustomsDeclarationItems.ts) | Per-item customs declarations with tariff numbers |
| [`125.UniversalCushion.HaloOnly.ts`](../../tests/fabricator/125.UniversalCushion.HaloOnly.ts) | Halo-only auto-cushion fallback |
| [`127.BigShipmentQueryParams.ts`](../../tests/fabricator/127.BigShipmentQueryParams.ts) | Shipment query/pagination/stats API |
| [`128`–`135` Japan series](../../tests/fabricator/128.CyberboxHaloJapan.ts) | Japan-specific DHL routing for each combo |
| [`140.ShipmentGroupRepairBug.ts`](../../tests/fabricator/140.ShipmentGroupRepairBug.ts) | Group-hash divergence fix (no perpetual repair) |
| [`142.UniversalCushion.Cyberbox.AudioStrap.LinkBooster.Japan.ts`](../../tests/fabricator/142.UniversalCushion.Cyberbox.AudioStrap.LinkBooster.Japan.ts) | Full-kit Japan flow with LinkBooster |
| [`150.DHLInvoiceApi.ts`](../../tests/fabricator/150.DHLInvoiceApi.ts) | DHL invoice reconciliation, CSV upload, charge matching |
