Erpy for Craft CMS

Erpy for NetSuite

Erpy for NetSuite connects Craft Commerce to Oracle NetSuite: SuiteTalk REST for writing orders, SuiteQL for reading everything in bulk, and Token-Based Authentication throughout. It works on standard and OneWorld accounts. The add-on is free; it needs Erpy, which is the paid part and owns the sync engine, the identity map, mapping, the queue and the log.

Install

composer require justinholtweb/craft-erpy-netsuite
php craft plugin/install erpy-netsuite

Then Erpy → Connections → New connection and pick NetSuite. The add-on has no settings screen of its own; everything lives on the connection.

What it syncs

EntityDirectionDelta syncPage size
CustomersERP → CommerceYes, on lastmodifieddate1,000
ProductsERP → CommerceYes, on lastmodifieddate1,000
PricesERP → CommerceNo1,000
InventoryERP → CommerceYes, on the item's lastmodifieddate1,000
OrdersCommerce → ERP
Order statusERP → CommerceYes1,000
ShipmentsERP → CommerceYes1,000
InvoicesERP → CommerceYes1,000
CreditERP → CommerceNo1,000

The connector declares multi-company support (subsidiaries) and that NetSuite offers a sandbox. Point a connection at your sandbox account first.

Prices are NetSuite's price matrix: one row per item, price level and quantity break. That is exactly the shape Erpy's contract pricing wants. Each price level comes through as a price list, by name.

Connecting

Account ID. Setup → Company → Company Information shows it. A sandbox looks like 1234567_SB1. Enter it once; the connector derives every other spelling from it.

Token-Based Authentication. Create an integration record under Setup → Integration → Manage Integrations with Token-Based Authentication enabled. Then create an access token for it under Setup → Users/Roles → Access Tokens. NetSuite shows each secret once and never again, so paste them straight in.

FieldWhat to put there
Account IDe.g. 1234567, or 1234567_SB1 for a sandbox.
Consumer key / Consumer secretFrom the integration record.
Token ID / Token secretFrom the access token.
Subsidiary internal IDRequired on OneWorld accounts. Leave blank on a single-subsidiary account.
Location internal IDThe location orders are raised against and stock is read from.
Item types to syncComma-separated NetSuite item types. Defaults to InvtPart,NonInvtPart,Assembly,Kit. Service and other non-sellable types are usually best left out.
Create orders already approvedOff by default: orders land pending approval, which is what most finance teams want to start with.

The token's role needs three permissions: REST Web Services, Log in using Access Tokens, and SuiteAnalytics Workbook. The last one is what allows SuiteQL, and it is the one people miss.

Test connection counts the items the role can see and checks whether it can read subsidiaries. When it fails:

  • INVALID_LOGIN_ATTEMPT or 401. NetSuite says the same thing for every signing problem. Check, in this order: the account id (including any _SB1 suffix), that all four token values were pasted without stray whitespace, and that the integration record has Token-Based Authentication ticked. A token created for a different role can authenticate and still see nothing.
  • 403. The token works but the role is missing one of the permissions above.

Before the first sync

The SuiteQL queries in this release read two custom fields:

  • Products read custitem_erp_category, a custom item field, as the product category.
  • Order status reads custbody_erpy_reference, a custom transaction body field.

If your account does not have fields with those ids, NetSuite refuses the query, and that sync fails with the refusal in the run log. Create them, even empty, before switching those entities on.

Things to know about NetSuite

It still signs with OAuth 1.0a. Token-Based Authentication is OAuth 1.0a signed with HMAC-SHA256. A mis-signed request gets the same opaque INVALID_LOGIN_ATTEMPT whichever of the five credentials or four encoding rules went wrong. Erpy's OAuth 1.0a strategy does the signing, so a 401 is almost always a value rather than a bug.

The account id has several spellings. You enter it as NetSuite shows it (1234567_SB1). The host name wants it lower-case with a hyphen (1234567-sb1.suitetalk.api.netsuite.com), and the OAuth realm wants it upper-case with an underscore. All of them come from the one setting.

Bulk reads are SuiteQL, not the record API. NetSuite's REST record API returns links rather than records: a list gives you ids, and you fetch each one. That is fine for one order and ruinous for a 40,000-item catalogue. So every bulk read here is SuiteQL, which returns whole rows a thousand at a time. SuiteQL pages by offset.

Inventory delta follows the item. Stock is read per location from NetSuite's own quantityavailable: on hand less what is committed to other orders. The delta filter, though, is the item's last-modified date. If stock movements in your account do not touch the item record, schedule a periodic full inventory run (php craft erpy/sync/run acme inventory --full) as well as the delta.

Booleans are T and F. And occasionally true/false through REST. The connector reads both.

Credit counts unbilled orders. Shipped-but-not-invoiced orders count against the limit in NetSuite, and they do here too. A storefront that ignored them would let a customer overrun it. A customer's credit hold override is AUTO, ON or OFF, and only ON is a stop.

externalId is a real idempotency key. The Commerce order number goes out as the sales order's externalId, and NetSuite refuses a second create with the same one. That guarantee holds even if two workers get past every other guard. A duplicate refusal is treated as "it already arrived", not as a failure. The number also goes in otherRefNum (up to 45 characters), so staff can search for it. Order status comes back matched on externalId.

Every order needs a customer. NetSuite has no anonymous sale. Link the Craft user to a NetSuite customer, or set a Guest customer code on the order mapping: the customer id guest orders are booked against. Every line's SKU has to be a NetSuite item name/number.

Correcting a field

The connector reads the SKU from the item's itemid. If you sell under the UPC instead, correct it on the Products mapping. upccode is one of the columns the product query selects, so it is in the raw payload:

raw.upccode   →   sku        transform: trim

One caveat specific to NetSuite: raw. holds only the columns the connector's SuiteQL query selected, not the whole record. A custom field the query does not select cannot be reached from the mapping screen.

See Field mapping for the rule syntax and the full transform list, Syncing for runs and watermarks, and Troubleshooting when something does not arrive.