SuiteCommerce MyAccount Customization: 10 Features B2B Customers Need
73% of B2B buyers say they prefer purchasing through e-commerce portals over dealing with sales reps. That number has climbed steadily year over year. Yet most SuiteCommerce MyAccount implementations ship with a consumer-grade feature set that forces B2B buyers back to email and phone orders.
The default SuiteCommerce MyAccount portal handles the basics: view order history, manage addresses, update payment methods. For B2C, that's fine. For B2B, it's missing the functionality that purchasing managers, procurement teams, and account administrators actually need to do their jobs.
We've built MyAccount customizations for B2B SuiteCommerce stores across manufacturing, distribution, and wholesale. This guide covers the 10 features that consistently drive adoption and self-service usage—along with the implementation complexity, architecture decisions, and code examples for each.
Table of Contents
- Why Default MyAccount Fails for B2B
- Feature 1: Enhanced Order History with Advanced Filtering
- Feature 2: One-Click Reordering
- Feature 3: Quote Request and Management
- Feature 4: Account Hierarchy and Sub-Users
- Feature 5: Customer-Specific Pricing Visibility
- Feature 6: Invoice and Payment History
- Feature 7: Saved Carts and Shopping Lists
- Feature 8: Bulk Order Entry (CSV Upload)
- Feature 9: Returns and RMA Management
- Feature 10: Custom Dashboards and Reporting
- Implementation Prioritization Matrix
- FAQ
Why Default MyAccount Fails for B2B
SuiteCommerce MyAccount was designed as a consumer self-service tool. It assumes individual buyers making occasional purchases. B2B purchasing is fundamentally different:
- Multiple people buy on one account (purchasing agents, managers, warehouse staff)
- Order frequency is weekly or daily, not monthly
- Order size is dozens or hundreds of line items, not 2–3
- Pricing is negotiated per account, not listed publicly
- Payment is often on terms (Net 30/60/90), not credit card at checkout
- Approvals are required above certain thresholds
The default MyAccount doesn't support any of these workflows. The result: your B2B customers use the portal to look up information, then call your sales team to actually place orders. You're paying for an e-commerce platform but getting a product catalog.
Here are the 10 features that turn MyAccount into a real B2B purchasing tool.
Feature 1: Enhanced Order History with Advanced Filtering
Complexity: Low | Development Time: 1–2 weeks | Impact: High
The Problem
Default order history shows a flat list of recent orders sorted by date. A B2B buyer with 50+ orders per month can't find anything. There's no search, no filtering by PO number, no date range picker, and no way to filter by status or product.
The Solution
Build an order history module with the search and filter capabilities B2B buyers expect:
// Enhanced Order History with filtering
define('Stenbase.B2B.OrderHistory.List.View', [
'OrderHistory.List.View',
'stenbase_b2b_order_history_list.tpl',
'underscore',
'jQuery'
], function (OrderHistoryListView, template, _, jQuery) {
'use strict';
return OrderHistoryListView.extend({
template: template,
events: _.extend(OrderHistoryListView.prototype.events, {
'submit [data-action="filter-orders"]': 'applyFilters',
'click [data-action="clear-filters"]': 'clearFilters',
'click [data-action="export-csv"]': 'exportToCSV'
}),
initialize: function (options) {
OrderHistoryListView.prototype
.initialize.apply(this, arguments);
this.filters = {
dateFrom: null,
dateTo: null,
status: null,
poNumber: null,
searchTerm: null
};
},
applyFilters: function (e) {
e.preventDefault();
var self = this;
this.filters = {
dateFrom: this.$('[name="date-from"]').val(),
dateTo: this.$('[name="date-to"]').val(),
status: this.$('[name="order-status"]').val(),
poNumber: this.$('[name="po-number"]').val(),
searchTerm: this.$('[name="order-search"]').val()
};
// Build filter params for API call
var params = {};
if (this.filters.dateFrom) {
params.from = this.filters.dateFrom;
}
if (this.filters.dateTo) {
params.to = this.filters.dateTo;
}
if (this.filters.status) {
params.status = this.filters.status;
}
// Fetch filtered results
this.collection.fetch({
data: params,
reset: true
}).done(function () {
// Client-side filtering for PO number and search
if (self.filters.poNumber) {
self.collection.reset(
self.collection.filter(function (order) {
var po = order.get('otherrefnum') || '';
return po.toLowerCase().indexOf(
self.filters.poNumber.toLowerCase()
) !== -1;
})
);
}
self.render();
});
},
exportToCSV: function (e) {
e.preventDefault();
var csvContent = 'Order #,Date,PO Number,Status,Total\n';
this.collection.each(function (order) {
csvContent += [
order.get('order_number'),
order.get('date'),
order.get('otherrefnum') || '',
order.get('status').name,
order.get('summary').total
].join(',') + '\n';
});
var blob = new Blob(
[csvContent],
{ type: 'text/csv;charset=utf-8;' }
);
var link = document.createElement('a');
link.href = URL.createObjectURL(blob);
link.download = 'order_history_' +
new Date().toISOString().split('T')[0] + '.csv';
link.click();
},
getContext: function () {
var context = OrderHistoryListView
.prototype.getContext.apply(this, arguments);
context.showB2BFilters = true;
context.activeFilters = this.filters;
context.hasActiveFilters = _.some(
this.filters,
function (v) { return !!v; }
);
context.statusOptions = [
{ value: '', label: 'All Statuses' },
{ value: 'pendingFulfillment', label: 'Pending Fulfillment' },
{ value: 'partiallyFulfilled', label: 'Partially Shipped' },
{ value: 'pendingBilling', label: 'Pending Billing' },
{ value: 'fullyBilled', label: 'Complete' },
{ value: 'closed', label: 'Closed' }
];
return context;
}
});
});
The filter template:
{{!-- Order history filter bar --}}
<div class="order-history-filters">
<form data-action="filter-orders" class="filter-form">
<div class="filter-row">
<div class="filter-group">
<label>{{translate 'Search Orders'}}</label>
<input type="text"
name="order-search"
placeholder="Order #, item name, or SKU"
value="{{activeFilters.searchTerm}}" />
</div>
<div class="filter-group">
<label>{{translate 'PO Number'}}</label>
<input type="text"
name="po-number"
placeholder="Your PO #"
value="{{activeFilters.poNumber}}" />
</div>
<div class="filter-group">
<label>{{translate 'Status'}}</label>
<select name="order-status">
{{#each statusOptions}}
<option value="{{value}}"
{{#ifEquals value ../activeFilters.status}}
selected
{{/ifEquals}}>
{{label}}
</option>
{{/each}}
</select>
</div>
</div>
<div class="filter-row">
<div class="filter-group">
<label>{{translate 'From'}}</label>
<input type="date"
name="date-from"
value="{{activeFilters.dateFrom}}" />
</div>
<div class="filter-group">
<label>{{translate 'To'}}</label>
<input type="date"
name="date-to"
value="{{activeFilters.dateTo}}" />
</div>
<div class="filter-actions">
<button type="submit" class="button-secondary">
{{translate 'Apply Filters'}}
</button>
{{#if hasActiveFilters}}
<a href="#" data-action="clear-filters">
{{translate 'Clear All'}}
</a>
{{/if}}
</div>
</div>
</form>
<div class="filter-actions-secondary">
<a href="#" data-action="export-csv" class="export-link">
{{translate 'Export to CSV'}}
</a>
</div>
</div>
Why It Matters
B2B buyers don't browse order history for fun. They're looking for a specific PO to check status, reorder items from a past shipment, or reconcile invoices. Every second spent scrolling through a flat list is friction that pushes them back to calling your sales team.
Feature 2: One-Click Reordering
Complexity: Low–Medium | Development Time: 1–2 weeks | Impact: Very High
The Problem
B2B buyers order the same items repeatedly. In the default MyAccount, reordering means: find the old order, click into it, note the SKUs, navigate to each product page, add to cart, set quantities. For a 30-line-item order, that's 15+ minutes of manual work.
The Solution
Add a "Reorder" button to order history that populates the cart with all items from a previous order in one click.
// One-click reorder functionality
define('Stenbase.B2B.Reorder', [
'LiveOrder.Model',
'jQuery',
'underscore'
], function (LiveOrderModel, jQuery, _) {
'use strict';
return {
reorderFromOrder: function (orderId, options) {
options = options || {};
var self = this;
// Fetch the original order details
return jQuery.ajax({
url: _.getAbsoluteUrl(
'services/Stenbase.Reorder.Service.ss'
),
type: 'POST',
contentType: 'application/json',
data: JSON.stringify({
orderId: orderId,
skipUnavailable: options.skipUnavailable !== false
})
}).done(function (response) {
if (response.addedItems && response.addedItems.length) {
// Refresh the cart model
LiveOrderModel.getInstance().fetch();
// Notify user
var message = response.addedItems.length +
' items added to cart.';
if (response.unavailableItems &&
response.unavailableItems.length) {
message += ' ' +
response.unavailableItems.length +
' items were unavailable and skipped.';
}
self.showNotification(message, 'success');
}
}).fail(function () {
self.showNotification(
'Unable to reorder. Some items may no ' +
'longer be available.',
'error'
);
});
},
showNotification: function (message, type) {
// Use SuiteCommerce's global notification system
var layout = SC.Application('MyAccount').getLayout();
if (layout && layout.showMessage) {
layout.showMessage(message, type);
}
}
};
});
The backend SuiteScript service handles inventory checks and pricing:
/**
* @NApiVersion 2.1
* @NScriptType Suitelet
* @NModuleScope SameAccount
*
* Reorder service - adds items from a previous order to cart
*/
define(['N/record', 'N/search', 'N/runtime'], function (record, search, runtime) {
function onRequest(context) {
var body = JSON.parse(context.request.body);
var orderId = body.orderId;
var skipUnavailable = body.skipUnavailable;
var currentCustomer = runtime.getCurrentUser().id;
// Load the original order
var orderRecord = record.load({
type: record.Type.SALES_ORDER,
id: orderId
});
// Verify the order belongs to the current customer
var orderCustomer = orderRecord.getValue('entity');
if (String(orderCustomer) !== String(currentCustomer)) {
context.response.write(JSON.stringify({
error: 'Order not found'
}));
return;
}
var lineCount = orderRecord.getLineCount({ sublistId: 'item' });
var addedItems = [];
var unavailableItems = [];
for (var i = 0; i < lineCount; i++) {
var itemId = orderRecord.getSublistValue({
sublistId: 'item',
fieldId: 'item',
line: i
});
var quantity = orderRecord.getSublistValue({
sublistId: 'item',
fieldId: 'quantity',
line: i
});
var itemName = orderRecord.getSublistValue({
sublistId: 'item',
fieldId: 'item_display',
line: i
});
// Check availability
var available = checkItemAvailability(itemId, quantity);
if (available || !skipUnavailable) {
addedItems.push({
internalid: itemId,
quantity: quantity,
name: itemName
});
} else {
unavailableItems.push({
internalid: itemId,
name: itemName,
requestedQty: quantity
});
}
}
context.response.write(JSON.stringify({
addedItems: addedItems,
unavailableItems: unavailableItems
}));
}
function checkItemAvailability(itemId, requestedQty) {
try {
var itemLookup = search.lookupFields({
type: search.Type.ITEM,
id: itemId,
columns: ['isinactive', 'quantityavailable']
});
if (itemLookup.isinactive) return false;
var available = parseFloat(
itemLookup.quantityavailable || 0
);
return available >= requestedQty;
} catch (e) {
return false;
}
}
return { onRequest: onRequest };
});
Why It Matters
Reordering is the single highest-value feature for B2B portals. In our experience, stores that implement one-click reorder see MyAccount usage increase by 40–60% within the first quarter. It's the feature that converts "I'll just email my rep" into "I'll do it myself."
Feature 3: Quote Request and Management
Complexity: Medium–High | Development Time: 3–5 weeks | Impact: High
The Problem
B2B pricing is rarely fixed. Large orders, new products, and custom configurations all need quotes. Without a quote workflow in MyAccount, the process goes: customer emails sales rep → rep creates quote in NetSuite → emails PDF back → customer reviews → emails approval → rep converts to sales order. Days of back-and-forth for every quote.
The Solution
Build a quote request and management module that connects MyAccount directly to NetSuite's Estimate (Quote) record.
// Quote Request View
define('Stenbase.B2B.QuoteRequest.View', [
'Backbone',
'stenbase_b2b_quote_request.tpl',
'Stenbase.B2B.QuoteRequest.Model',
'jQuery'
], function (Backbone, template, QuoteRequestModel, jQuery) {
'use strict';
return Backbone.View.extend({
template: template,
events: {
'submit [data-action="submit-quote"]': 'submitQuote',
'click [data-action="add-line"]': 'addLineItem',
'click [data-action="remove-line"]': 'removeLineItem',
'click [data-action="add-from-cart"]': 'populateFromCart'
},
initialize: function (options) {
this.model = new QuoteRequestModel();
this.lineItems = [];
},
submitQuote: function (e) {
e.preventDefault();
var self = this;
var quoteData = {
lines: this.collectLineItems(),
message: this.$('[name="quote-message"]').val(),
neededBy: this.$('[name="needed-by"]').val(),
poNumber: this.$('[name="po-number"]').val()
};
if (!quoteData.lines.length) {
this.showError('Please add at least one item.');
return;
}
this.$('[data-action="submit-quote"]')
.prop('disabled', true)
.text('Submitting...');
this.model.save(quoteData)
.done(function (response) {
// Redirect to quote detail
Backbone.history.navigate(
'quotes/' + response.quoteId,
{ trigger: true }
);
})
.fail(function () {
self.showError(
'Unable to submit quote request. ' +
'Please try again.'
);
self.$('[data-action="submit-quote"]')
.prop('disabled', false)
.text('Submit Quote Request');
});
},
populateFromCart: function (e) {
e.preventDefault();
var self = this;
var cart = SC.Application('MyAccount')
.getComponent('Cart');
if (cart) {
cart.getLines().done(function (lines) {
lines.forEach(function (line) {
self.addLine({
itemId: line.item.internalid,
itemName: line.item.displayname
|| line.item.itemid,
quantity: line.quantity,
sku: line.item.itemid
});
});
self.render();
});
}
},
collectLineItems: function () {
var lines = [];
this.$('.quote-line-item').each(function () {
var $row = jQuery(this);
lines.push({
itemId: $row.find('[name="item-id"]').val(),
quantity: parseInt(
$row.find('[name="quantity"]').val(), 10
),
notes: $row.find('[name="line-notes"]').val()
});
});
return lines;
}
});
});
The quote management dashboard gives buyers visibility into all their pending, approved, and expired quotes:
{{!-- Quote management dashboard --}}
<div class="b2b-quotes-dashboard">
<div class="quotes-header">
<h2>{{translate 'My Quotes'}}</h2>
<a href="/quotes/new"
class="button-primary"
data-touchpoint="customercenter"
data-hashtag="#quotes/new">
{{translate 'Request New Quote'}}
</a>
</div>
<div class="quotes-tabs">
<button class="tab {{#ifEquals activeTab 'pending'}}active{{/ifEquals}}"
data-action="filter-tab" data-tab="pending">
{{translate 'Pending'}}
{{#if pendingCount}}
<span class="badge">{{pendingCount}}</span>
{{/if}}
</button>
<button class="tab {{#ifEquals activeTab 'approved'}}active{{/ifEquals}}"
data-action="filter-tab" data-tab="approved">
{{translate 'Ready to Order'}}
{{#if approvedCount}}
<span class="badge">{{approvedCount}}</span>
{{/if}}
</button>
<button class="tab {{#ifEquals activeTab 'expired'}}active{{/ifEquals}}"
data-action="filter-tab" data-tab="expired">
{{translate 'Expired'}}
</button>
</div>
<div class="quotes-list">
{{#each quotes}}
<div class="quote-card" data-id="{{internalid}}">
<div class="quote-card-header">
<span class="quote-number">
Quote #{{tranid}}
</span>
<span class="quote-status status-{{statusCode}}">
{{statusLabel}}
</span>
</div>
<div class="quote-card-body">
<div class="quote-detail">
<span class="label">{{translate 'Date'}}</span>
<span>{{trandate}}</span>
</div>
<div class="quote-detail">
<span class="label">{{translate 'Items'}}</span>
<span>{{lineCount}}</span>
</div>
<div class="quote-detail">
<span class="label">{{translate 'Total'}}</span>
<span class="quote-total">
{{formatCurrency total}}
</span>
</div>
{{#if expirationDate}}
<div class="quote-detail">
<span class="label">{{translate 'Expires'}}</span>
<span class="{{#if isExpiringSoon}}text-warning{{/if}}">
{{expirationDate}}
</span>
</div>
{{/if}}
</div>
<div class="quote-card-actions">
<a href="/quotes/{{internalid}}"
data-touchpoint="customercenter"
data-hashtag="#quotes/{{internalid}}">
{{translate 'View Details'}}
</a>
{{#if canConvertToOrder}}
<button class="button-primary button-small"
data-action="convert-to-order"
data-quote-id="{{internalid}}">
{{translate 'Place Order'}}
</button>
{{/if}}
</div>
</div>
{{else}}
<div class="empty-state">
<p>{{translate 'No quotes found.'}}</p>
</div>
{{/each}}
</div>
</div>
When a quote is approved, the "Place Order" button converts the NetSuite Estimate record into a Sales Order—preserving all negotiated pricing, line items, and terms. No re-entry, no discrepancies.
Feature 4: Account Hierarchy and Sub-Users

Complexity: High | Development Time: 4–6 weeks | Impact: High
The Problem
B2B accounts aren't individual people. They're organizations. A single account might have a procurement manager who approves orders, three purchasing agents who create orders, and a finance person who views invoices. SuiteCommerce's default MyAccount treats every login as the same—full access, single user.
The Solution
Implement role-based access using NetSuite's native entity hierarchy combined with custom SuiteCommerce roles.
NetSuite Configuration:
- Create custom entity fields for
Portal Roleon the Contact record:Admin— Full access, can manage sub-usersBuyer— Can create orders and quotesApprover— Can approve orders above thresholdViewer— Read-only access to orders and invoices
- Link Contacts to the parent Customer record using NetSuite's contact management.
- Create a SuiteScript RESTlet that manages sub-user CRUD operations.
/**
* @NApiVersion 2.1
* @NScriptType Restlet
* @NModuleScope SameAccount
*
* Sub-user management for B2B MyAccount
*/
define(['N/record', 'N/search', 'N/runtime', 'N/email'],
function (record, search, runtime, email) {
function get(requestParams) {
var customerId = runtime.getCurrentUser().id;
// Get all contacts linked to this customer
var contactSearch = search.create({
type: search.Type.CONTACT,
filters: [
['company', 'is', customerId],
'AND',
['isinactive', 'is', 'F']
],
columns: [
'entityid',
'firstname',
'lastname',
'email',
'custentity_portal_role',
'custentity_portal_last_login'
]
});
var contacts = [];
contactSearch.run().each(function (result) {
contacts.push({
id: result.id,
name: result.getValue('firstname') + ' ' +
result.getValue('lastname'),
email: result.getValue('email'),
role: result.getText('custentity_portal_role'),
roleId: result.getValue('custentity_portal_role'),
lastLogin: result.getValue(
'custentity_portal_last_login'
)
});
return true;
});
return { contacts: contacts };
}
function post(requestBody) {
var customerId = runtime.getCurrentUser().id;
// Verify current user is an admin
if (!isPortalAdmin(customerId)) {
return { error: 'Insufficient permissions' };
}
// Create new contact
var contact = record.create({
type: record.Type.CONTACT
});
contact.setValue('company', customerId);
contact.setValue('firstname', requestBody.firstName);
contact.setValue('lastname', requestBody.lastName);
contact.setValue('email', requestBody.email);
contact.setValue(
'custentity_portal_role',
requestBody.roleId
);
var contactId = contact.save();
// Send invitation email
email.send({
author: runtime.getCurrentUser().id,
recipients: requestBody.email,
subject: 'You have been invited to ' +
'our ordering portal',
body: buildInvitationEmail(
requestBody.firstName,
customerId
)
});
return {
success: true,
contactId: contactId
};
}
function isPortalAdmin(customerId) {
var role = search.lookupFields({
type: search.Type.CUSTOMER,
id: customerId,
columns: ['custentity_portal_role']
});
return role.custentity_portal_role &&
role.custentity_portal_role[0] &&
role.custentity_portal_role[0].value === '1';
}
return { get: get, post: post };
});
On the frontend, gate features based on the user's portal role:
// Role-based feature gating
define('Stenbase.B2B.Permissions', [
'SC.Configuration'
], function (Configuration) {
'use strict';
var Permissions = {
roles: {
ADMIN: '1',
BUYER: '2',
APPROVER: '3',
VIEWER: '4'
},
getCurrentRole: function () {
return SC.ENVIRONMENT.currentPortalRole || null;
},
canPlaceOrders: function () {
var role = this.getCurrentRole();
return role === this.roles.ADMIN ||
role === this.roles.BUYER;
},
canApproveOrders: function () {
var role = this.getCurrentRole();
return role === this.roles.ADMIN ||
role === this.roles.APPROVER;
},
canManageUsers: function () {
return this.getCurrentRole() === this.roles.ADMIN;
},
canViewInvoices: function () {
// All roles can view invoices
return true;
},
canViewPricing: function () {
var role = this.getCurrentRole();
return role !== this.roles.VIEWER;
}
};
return Permissions;
});
Why It Matters
Without multi-user support, the entire purchasing team shares one login. No audit trail. No permission control. No accountability. The account admin can't see who placed what order or control who has access to what. For companies with compliance requirements, this is a dealbreaker.
Feature 5: Customer-Specific Pricing Visibility
Complexity: Medium | Development Time: 2–3 weeks | Impact: High
The Problem
B2B pricing is confidential and customer-specific. NetSuite handles this well with price levels, item pricing, and quantity schedules. But the default MyAccount doesn't surface this information clearly. Buyers see the list price and have to trust that their negotiated pricing will apply at checkout.
The Solution
Display the customer's actual pricing throughout MyAccount, including volume tiers and contract pricing.
// Customer-specific pricing display
define('Stenbase.B2B.Pricing.View', [
'Backbone',
'stenbase_b2b_pricing_table.tpl'
], function (Backbone, template) {
'use strict';
return Backbone.View.extend({
template: template,
getContext: function () {
var item = this.model;
var pricing = item.get('onlinecustomerprice_detail') || {};
var quantityPricing = pricing.priceschedule || [];
return {
hasContractPrice: !!pricing.onlinecustomerprice,
contractPrice: pricing.onlinecustomerprice,
listPrice: item.get('pricelevel1'),
savingsPercent: this.calculateSavings(
item.get('pricelevel1'),
pricing.onlinecustomerprice
),
hasQuantityBreaks: quantityPricing.length > 1,
quantityBreaks: quantityPricing.map(function (tier) {
return {
minimumQuantity: tier.minimumquantity,
price: tier.price,
priceFormatted: SC.Utils.formatCurrency(
tier.price
)
};
}),
priceLevel: pricing.pricelevelname || 'Standard'
};
},
calculateSavings: function (listPrice, contractPrice) {
if (!listPrice || !contractPrice) return 0;
return Math.round(
((listPrice - contractPrice) / listPrice) * 100
);
}
});
});
{{!-- Customer pricing display --}}
<div class="b2b-pricing">
{{#if hasContractPrice}}
<div class="pricing-contract">
<span class="your-price-label">{{translate 'Your Price'}}</span>
<span class="your-price">{{formatCurrency contractPrice}}</span>
{{#if savingsPercent}}
<span class="savings-badge">
{{translate 'Save %(percent)d%' percent=savingsPercent}}
</span>
{{/if}}
{{#if listPrice}}
<span class="list-price strikethrough">
{{translate 'List'}}: {{formatCurrency listPrice}}
</span>
{{/if}}
</div>
{{/if}}
{{#if hasQuantityBreaks}}
<div class="pricing-tiers">
<h4>{{translate 'Volume Pricing'}}</h4>
<table class="pricing-tiers-table">
<thead>
<tr>
<th>{{translate 'Quantity'}}</th>
<th>{{translate 'Price Each'}}</th>
</tr>
</thead>
<tbody>
{{#each quantityBreaks}}
<tr>
<td>{{minimumQuantity}}+</td>
<td>{{priceFormatted}}</td>
</tr>
{{/each}}
</tbody>
</table>
</div>
{{/if}}
</div>
Transparent pricing builds trust and reduces calls to your sales team asking "What's my price on this?"
Feature 6: Invoice and Payment History
Complexity: Medium | Development Time: 2–3 weeks | Impact: High
The Problem
B2B buyers on Net 30/60/90 terms need to manage their outstanding invoices. Default MyAccount shows order history but not invoices, aging balances, or payment history. Finance teams are forced to call or email for invoice copies.
The Solution
Build an invoice management module that pulls from NetSuite's invoice records and displays aging, payment history, and downloadable PDFs.
// Invoice Dashboard Model
define('Stenbase.B2B.Invoice.Collection', [
'Backbone',
'underscore'
], function (Backbone, _) {
'use strict';
var InvoiceModel = Backbone.Model.extend({
urlRoot: _.getAbsoluteUrl(
'services/Stenbase.Invoice.Service.ss'
)
});
var InvoiceCollection = Backbone.Collection.extend({
model: InvoiceModel,
url: _.getAbsoluteUrl(
'services/Stenbase.Invoice.Service.ss'
),
getSummary: function () {
var invoices = this.models;
return {
totalOutstanding: _.reduce(invoices,
function (sum, inv) {
return sum + (inv.get('amountremaining') || 0);
}, 0),
current: this.getAgingBucket(0, 30),
thirtyDay: this.getAgingBucket(31, 60),
sixtyDay: this.getAgingBucket(61, 90),
ninetyPlus: this.getAgingBucket(91, Infinity),
overdueCount: this.filter(function (inv) {
return inv.get('isOverdue');
}).length
};
},
getAgingBucket: function (minDays, maxDays) {
return this.reduce(function (sum, inv) {
var age = inv.get('daysOutstanding') || 0;
if (age >= minDays && age <= maxDays) {
return sum + (inv.get('amountremaining') || 0);
}
return sum;
}, 0);
}
});
return InvoiceCollection;
});
The dashboard should display an aging summary at the top, making it immediately obvious what's owed and what's overdue:
{{!-- Invoice aging summary --}}
<div class="invoice-aging-summary">
<h2>{{translate 'Account Balance'}}</h2>
<div class="aging-cards">
<div class="aging-card">
<span class="aging-label">{{translate 'Current'}}</span>
<span class="aging-amount">
{{formatCurrency summary.current}}
</span>
</div>
<div class="aging-card">
<span class="aging-label">{{translate '31-60 Days'}}</span>
<span class="aging-amount">
{{formatCurrency summary.thirtyDay}}
</span>
</div>
<div class="aging-card {{#if summary.sixtyDay}}warning{{/if}}">
<span class="aging-label">{{translate '61-90 Days'}}</span>
<span class="aging-amount">
{{formatCurrency summary.sixtyDay}}
</span>
</div>
<div class="aging-card {{#if summary.ninetyPlus}}danger{{/if}}">
<span class="aging-label">{{translate '90+ Days'}}</span>
<span class="aging-amount">
{{formatCurrency summary.ninetyPlus}}
</span>
</div>
</div>
<div class="total-outstanding">
<span>{{translate 'Total Outstanding'}}</span>
<span class="total-amount">
{{formatCurrency summary.totalOutstanding}}
</span>
</div>
</div>
Add the ability to download invoice PDFs directly. NetSuite can generate these via a Suitelet that calls render.transaction() to produce the PDF on demand.
Feature 7: Saved Carts and Shopping Lists
Complexity: Low–Medium | Development Time: 2–3 weeks | Impact: Medium–High
The Problem
B2B buyers maintain recurring material lists—weekly supply orders, project BOMs, standard stocking orders. Without saved lists, they rebuild these orders from scratch every time or keep a spreadsheet beside their browser.
The Solution
SuiteCommerce supports product lists natively, but the default implementation is consumer-oriented ("Wish Lists"). Rebrand and extend it for B2B use.
// Rename and extend Product Lists for B2B
define('Stenbase.B2B.ShoppingLists.View', [
'ProductList.Lists.View',
'stenbase_b2b_shopping_lists.tpl'
], function (ProductListsView, template) {
'use strict';
return ProductListsView.extend({
template: template,
getContext: function () {
var context = ProductListsView
.prototype.getContext.apply(this, arguments);
// Rebrand for B2B
context.pageTitle = 'Shopping Lists';
context.createButtonLabel = 'Create New List';
// Add estimated totals per list
context.lists = context.lists.map(function (list) {
list.estimatedTotal = list.items.reduce(
function (sum, item) {
return sum + (
(item.price || 0) * (item.quantity || 1)
);
}, 0
);
list.itemCountLabel = list.items.length +
(list.items.length === 1 ? ' item' : ' items');
return list;
});
return context;
},
events: _.extend(ProductListsView.prototype.events, {
'click [data-action="add-list-to-cart"]':
'addEntireListToCart',
'click [data-action="share-list"]': 'shareList'
}),
addEntireListToCart: function (e) {
e.preventDefault();
var listId = jQuery(e.currentTarget).data('list-id');
var list = this.collection.get(listId);
if (!list) return;
var items = list.get('items') || [];
var cart = this.options.application.getComponent('Cart');
// Add all items sequentially
var addPromises = items.map(function (item) {
return cart.addLine({
item: { internalid: item.item.internalid },
quantity: item.quantity || 1
});
});
jQuery.when.apply(jQuery, addPromises)
.done(function () {
SC.Application('MyAccount').getLayout()
.showMessage(
items.length +
' items added to cart from list.',
'success'
);
});
},
shareList: function (e) {
e.preventDefault();
var listId = jQuery(e.currentTarget).data('list-id');
// Generate shareable link for other users
// on the same account
var shareUrl = window.location.origin +
'/my_account?#shopping-lists/' +
listId + '?shared=true';
// Copy to clipboard
navigator.clipboard.writeText(shareUrl).then(function () {
SC.Application('MyAccount').getLayout()
.showMessage(
'List link copied to clipboard.',
'success'
);
});
}
});
});
Key B2B enhancements over the default product list:
- "Add Entire List to Cart" button—one click instead of adding items individually
- Estimated total displayed on each list
- Shareable lists between users on the same account
- Custom quantities per list item (default product lists don't store quantity)
- CSV export of list contents for offline reference
Feature 8: Bulk Order Entry (CSV Upload)
Complexity: Medium | Development Time: 2–4 weeks | Impact: High
The Problem
A buyer needs to order 75 different SKUs. Even with search, finding and adding 75 items one-by-one is tedious. Most B2B buyers maintain their orders in spreadsheets, so let them upload directly.
The Solution
Build a CSV upload module that maps SKU + quantity columns to SuiteCommerce cart additions.
// CSV upload order entry
define('Stenbase.B2B.BulkOrder.View', [
'Backbone',
'stenbase_b2b_bulk_order.tpl',
'jQuery'
], function (Backbone, template, jQuery) {
'use strict';
return Backbone.View.extend({
template: template,
events: {
'change [data-action="upload-csv"]': 'handleFileUpload',
'click [data-action="process-upload"]': 'processUpload',
'click [data-action="download-template"]':
'downloadTemplate',
'click [data-action="add-all-to-cart"]': 'addAllToCart'
},
handleFileUpload: function (e) {
var self = this;
var file = e.target.files[0];
if (!file) return;
// Validate file type
if (!file.name.match(/\.(csv|txt)$/i)) {
this.showError('Please upload a CSV file.');
return;
}
var reader = new FileReader();
reader.onload = function (event) {
var csvData = event.target.result;
self.parsedItems = self.parseCSV(csvData);
self.validateItems();
};
reader.readAsText(file);
},
parseCSV: function (csvText) {
var lines = csvText.split('\n');
var items = [];
// Skip header row
for (var i = 1; i < lines.length; i++) {
var line = lines[i].trim();
if (!line) continue;
var columns = line.split(',');
var sku = (columns[0] || '').trim()
.replace(/"/g, '');
var qty = parseInt(
(columns[1] || '1').trim().replace(/"/g, ''),
10
);
if (sku && qty > 0) {
items.push({
sku: sku,
quantity: qty,
status: 'pending',
name: '',
price: null
});
}
}
return items;
},
validateItems: function () {
var self = this;
var skus = this.parsedItems.map(
function (item) { return item.sku; }
);
// Validate all SKUs against NetSuite
jQuery.ajax({
url: _.getAbsoluteUrl(
'services/Stenbase.BulkOrder.Validate.Service.ss'
),
type: 'POST',
contentType: 'application/json',
data: JSON.stringify({ skus: skus })
}).done(function (response) {
// Update parsed items with validation results
self.parsedItems.forEach(function (item) {
var match = response.items[item.sku];
if (match) {
item.status = 'valid';
item.name = match.displayname;
item.price = match.price;
item.internalid = match.internalid;
} else {
item.status = 'not_found';
}
});
self.render();
});
},
downloadTemplate: function (e) {
e.preventDefault();
var csv = 'SKU,Quantity\nITEM-001,10\nITEM-002,25\n';
var blob = new Blob(
[csv],
{ type: 'text/csv;charset=utf-8;' }
);
var link = document.createElement('a');
link.href = URL.createObjectURL(blob);
link.download = 'bulk_order_template.csv';
link.click();
},
addAllToCart: function (e) {
e.preventDefault();
var validItems = this.parsedItems.filter(
function (item) { return item.status === 'valid'; }
);
// Add items to cart via API
var cart = this.options.application.getComponent('Cart');
var addPromises = validItems.map(function (item) {
return cart.addLine({
item: { internalid: item.internalid },
quantity: item.quantity
});
});
jQuery.when.apply(jQuery, addPromises)
.done(function () {
Backbone.history.navigate('cart', { trigger: true });
});
}
});
});
The upload interface should provide clear feedback—green for validated items, red for SKUs not found, yellow for items with insufficient inventory:
{{!-- Bulk order upload results --}}
{{#if parsedItems.length}}
<div class="bulk-order-results">
<div class="results-summary">
<span class="valid-count">
{{validCount}} {{translate 'items ready'}}
</span>
{{#if invalidCount}}
<span class="invalid-count">
{{invalidCount}} {{translate 'items not found'}}
</span>
{{/if}}
</div>
<table class="bulk-order-table">
<thead>
<tr>
<th>{{translate 'Status'}}</th>
<th>{{translate 'SKU'}}</th>
<th>{{translate 'Item Name'}}</th>
<th>{{translate 'Qty'}}</th>
<th>{{translate 'Unit Price'}}</th>
<th>{{translate 'Line Total'}}</th>
</tr>
</thead>
<tbody>
{{#each parsedItems}}
<tr class="status-{{status}}">
<td>
{{#ifEquals status "valid"}}
<span class="icon-check">✓</span>
{{else}}
<span class="icon-warning">✗</span>
{{/ifEquals}}
</td>
<td>{{sku}}</td>
<td>{{#if name}}{{name}}{{else}}
<em>Not found</em>{{/if}}</td>
<td>{{quantity}}</td>
<td>{{#if price}}{{formatCurrency price}}{{else}}
—{{/if}}</td>
<td>{{#if price}}
{{formatCurrency lineTotal}}
{{else}}—{{/if}}</td>
</tr>
{{/each}}
</tbody>
</table>
{{#if validCount}}
<div class="bulk-order-actions">
<button class="button-primary"
data-action="add-all-to-cart">
{{translate 'Add %(count)d Items to Cart'
count=validCount}}
</button>
<span class="estimated-total">
{{translate 'Estimated Total'}}:
{{formatCurrency estimatedTotal}}
</span>
</div>
{{/if}}
</div>
{{/if}}
Feature 9: Returns and RMA Management
Complexity: Medium–High | Development Time: 3–4 weeks | Impact: Medium
The Problem
When a B2B buyer receives damaged goods or wrong items, the return process is manual: email the warehouse, wait for an RMA number, print a shipping label, track the credit. This takes days and generates support tickets.
The Solution
Let buyers initiate return requests directly from their order history, connected to NetSuite's Return Authorization records.
The workflow:
- Buyer clicks "Return Items" on an order detail page
- Selects which line items to return and specifies quantities
- Chooses a return reason from predefined options
- Uploads photos of damaged items (optional)
- System creates a Return Authorization in NetSuite
- Buyer receives RMA number and shipping instructions
- Return status is visible in MyAccount throughout the process
// Return request initiation
define('Stenbase.B2B.ReturnRequest.View', [
'Backbone',
'stenbase_b2b_return_request.tpl',
'Stenbase.B2B.ReturnRequest.Model'
], function (Backbone, template, ReturnRequestModel) {
'use strict';
return Backbone.View.extend({
template: template,
events: {
'change [data-action="select-return-item"]':
'toggleLineItem',
'submit [data-action="submit-return"]': 'submitReturn'
},
initialize: function (options) {
this.order = options.order;
this.returnModel = new ReturnRequestModel();
this.selectedLines = {};
},
toggleLineItem: function (e) {
var $checkbox = jQuery(e.currentTarget);
var lineId = $checkbox.data('line-id');
var $row = $checkbox.closest('.return-line-item');
if ($checkbox.is(':checked')) {
$row.find('.return-fields').slideDown();
this.selectedLines[lineId] = {
quantity: 1,
reason: ''
};
} else {
$row.find('.return-fields').slideUp();
delete this.selectedLines[lineId];
}
},
submitReturn: function (e) {
e.preventDefault();
var self = this;
// Collect return line details
var lines = [];
Object.keys(this.selectedLines).forEach(function (lineId) {
var $row = self.$(
'[data-line-id="' + lineId + '"]'
).closest('.return-line-item');
lines.push({
lineId: lineId,
quantity: parseInt(
$row.find('[name="return-qty"]').val(), 10
),
reason: $row.find('[name="return-reason"]').val(),
notes: $row.find('[name="return-notes"]').val()
});
});
if (!lines.length) {
this.showError(
'Please select at least one item to return.'
);
return;
}
this.returnModel.save({
orderId: this.order.get('internalid'),
lines: lines,
customerNotes: this.$(
'[name="return-customer-notes"]'
).val()
}).done(function (response) {
Backbone.history.navigate(
'returns/' + response.returnId,
{ trigger: true }
);
});
},
getContext: function () {
return {
orderNumber: this.order.get('order_number'),
lines: this.order.get('lines').map(function (line) {
return {
lineId: line.internalid,
itemName: line.item_display
|| line.item.displayname,
sku: line.item.itemid,
orderedQty: line.quantity,
maxReturnQty: line.quantity -
(line.quantityreturned || 0)
};
}).filter(function (line) {
return line.maxReturnQty > 0;
}),
returnReasons: [
{ value: 'damaged', label: 'Damaged in Shipping' },
{ value: 'defective', label: 'Defective Product' },
{ value: 'wrong_item', label: 'Wrong Item Received' },
{ value: 'wrong_qty', label: 'Wrong Quantity' },
{ value: 'not_needed', label: 'No Longer Needed' },
{ value: 'other', label: 'Other' }
]
};
}
});
});
On the NetSuite side, a SuiteScript service creates the Return Authorization record and triggers any approval workflows you've configured.
Feature 10: Custom Dashboards and Reporting

Complexity: Medium | Development Time: 2–3 weeks | Impact: Medium
The Problem
The default MyAccount landing page shows recent orders and saved items. For a B2B buyer, this isn't actionable. They need a dashboard that answers: What do I owe? What's shipping? What needs reordering? Are my quotes about to expire?
The Solution
Replace the default MyAccount landing page with a B2B-focused dashboard that consolidates the most critical information.
{{!-- B2B MyAccount Dashboard --}}
<div class="b2b-dashboard">
<h1>{{translate 'Welcome back, %(name)s' name=contactName}}</h1>
<div class="dashboard-grid">
{{!-- Account Balance Card --}}
<div class="dashboard-card card-balance">
<h3>{{translate 'Account Balance'}}</h3>
<div class="card-metric">
{{formatCurrency accountBalance.totalOutstanding}}
</div>
{{#if accountBalance.overdueAmount}}
<div class="card-alert">
{{formatCurrency accountBalance.overdueAmount}}
{{translate 'overdue'}}
</div>
{{/if}}
<a href="/my_account#invoices">
{{translate 'View Invoices'}} →
</a>
</div>
{{!-- Open Orders Card --}}
<div class="dashboard-card card-orders">
<h3>{{translate 'Open Orders'}}</h3>
<div class="card-metric">{{openOrderCount}}</div>
<div class="card-detail">
{{inTransitCount}} {{translate 'in transit'}}
</div>
<a href="/my_account#orders">
{{translate 'View Orders'}} →
</a>
</div>
{{!-- Pending Quotes Card --}}
<div class="dashboard-card card-quotes">
<h3>{{translate 'Pending Quotes'}}</h3>
<div class="card-metric">{{pendingQuoteCount}}</div>
{{#if expiringQuoteCount}}
<div class="card-alert">
{{expiringQuoteCount}}
{{translate 'expiring this week'}}
</div>
{{/if}}
<a href="/my_account#quotes">
{{translate 'View Quotes'}} →
</a>
</div>
{{!-- Quick Reorder Card --}}
<div class="dashboard-card card-reorder">
<h3>{{translate 'Quick Reorder'}}</h3>
<p>{{translate 'Your most recent order'}}</p>
{{#if lastOrder}}
<div class="card-detail">
#{{lastOrder.orderNumber}} —
{{lastOrder.lineCount}} {{translate 'items'}}
</div>
<button class="button-primary"
data-action="reorder"
data-order-id="{{lastOrder.internalid}}">
{{translate 'Reorder'}}
</button>
{{/if}}
</div>
</div>
{{!-- Recent Activity Feed --}}
<div class="dashboard-activity">
<h3>{{translate 'Recent Activity'}}</h3>
<ul class="activity-feed">
{{#each recentActivity}}
<li class="activity-item activity-{{type}}">
<span class="activity-icon"></span>
<span class="activity-text">{{description}}</span>
<span class="activity-time">{{timeAgo}}</span>
</li>
{{/each}}
</ul>
</div>
{{!-- Items Needing Reorder --}}
{{#if reorderSuggestions.length}}
<div class="dashboard-reorder-suggestions">
<h3>{{translate 'Suggested Reorders'}}</h3>
<p>{{translate 'Based on your order patterns, these items may be running low.'}}</p>
<div class="suggestion-items">
{{#each reorderSuggestions}}
<div class="suggestion-card">
<span class="suggestion-name">{{itemName}}</span>
<span class="suggestion-sku">{{sku}}</span>
<span class="suggestion-last">
{{translate 'Last ordered'}}: {{lastOrderDate}}
</span>
<button class="button-small"
data-action="quick-add"
data-item-id="{{internalid}}"
data-quantity="{{suggestedQty}}">
{{translate 'Add %(qty)d to Cart' qty=suggestedQty}}
</button>
</div>
{{/each}}
</div>
</div>
{{/if}}
</div>
The reorder suggestions are generated by a SuiteScript scheduled script that analyzes each customer's order frequency:
/**
* @NApiVersion 2.1
* @NScriptType ScheduledScript
*
* Generates reorder suggestions based on purchase patterns
*/
define(['N/search', 'N/record'], function (search, record) {
function execute(context) {
// Find items each customer orders regularly
var orderSearch = search.create({
type: search.Type.SALES_ORDER,
filters: [
['mainline', 'is', 'F'],
'AND',
['trandate', 'within', 'lastrolling6months'],
'AND',
['status', 'anyof', [
'SalesOrd:F', // Billed
'SalesOrd:G' // Closed
]]
],
columns: [
search.createColumn({
name: 'entity',
summary: search.Summary.GROUP
}),
search.createColumn({
name: 'item',
summary: search.Summary.GROUP
}),
search.createColumn({
name: 'trandate',
summary: search.Summary.MAX,
label: 'lastOrder'
}),
search.createColumn({
name: 'trandate',
summary: search.Summary.COUNT,
label: 'orderCount'
}),
search.createColumn({
name: 'quantity',
summary: search.Summary.AVG,
label: 'avgQty'
})
]
});
orderSearch.run().each(function (result) {
var customerId = result.getValue({
name: 'entity',
summary: search.Summary.GROUP
});
var itemId = result.getValue({
name: 'item',
summary: search.Summary.GROUP
});
var orderCount = parseInt(result.getValue({
name: 'trandate',
summary: search.Summary.COUNT
}), 10);
// Only suggest items ordered 3+ times in 6 months
if (orderCount >= 3) {
// Store suggestion for the customer portal
createReorderSuggestion(
customerId,
itemId,
result
);
}
return true;
});
}
return { execute: execute };
});
Implementation Prioritization Matrix
Not every store needs all 10 features on day one. Here's how to prioritize based on impact and effort:
| Priority | Feature | Effort | Impact | Start Here If... |
|---|---|---|---|---|
| 🥇 1 | One-Click Reordering | Low–Med | Very High | Customers order the same items regularly |
| 🥇 1 | Enhanced Order History | Low | High | Customers have 10+ orders per month |
| 🥈 2 | Invoice & Payment History | Medium | High | Customers are on payment terms |
| 🥈 2 | Bulk Order Entry | Medium | High | Average order has 20+ line items |
| 🥉 3 | Quote Management | Med–High | High | You do significant custom pricing |
| 🥉 3 | Customer Pricing Visibility | Medium | High | Customers have negotiated pricing |
| 4 | Saved Carts / Shopping Lists | Low–Med | Med–High | Customers maintain recurring orders |
| 5 | Account Hierarchy | High | High | Multiple buyers per account |
| 6 | Custom Dashboard | Medium | Medium | You're implementing 3+ other features |
| 7 | Returns / RMA | Med–High | Medium | High return volume |
Our recommendation: Start with Features 1 and 2 (Order History + Reordering). They have the highest impact-to-effort ratio and immediately demonstrate the value of MyAccount to your B2B buyers. Once those are adopted, layer in invoices and bulk ordering.
Total implementation time for all 10 features: 20–35 weeks with an experienced SuiteCommerce developer. Most stores implement in phases over 3–6 months.
FAQ
Can I implement these features with SuiteCommerce Standard, or do I need Advanced?
Most of these features require SuiteCommerce Advanced (SCA). SuiteCommerce Standard limits your ability to customize MyAccount views, add new modules, and create custom service endpoints. Features 1 (Order History filtering) and 6 (Invoice visibility) have partial support in Standard through configuration, but the full implementations described here need SCA's extension framework.
How do I handle customers who have both B2C and B2B accounts?
Use NetSuite's customer category or a custom field to flag B2B accounts. Then conditionally load the B2B MyAccount features based on that flag. Your SuiteCommerce entry point can check the customer's category and load different modules accordingly:
if (SC.ENVIRONMENT.customer_category === 'B2B') {
application.registerModule(B2BMyAccountModules);
} else {
application.registerModule(DefaultMyAccountModules);
}
What's the impact on site performance of adding these features?
Each additional module adds JavaScript payload. However, since MyAccount modules only load when the customer is logged in and navigates to that section, the impact on public-facing page speed is zero. Within MyAccount, expect a 50–100KB increase per major feature. Use lazy loading for modules the customer hasn't accessed yet.
Do these customizations survive SuiteCommerce version upgrades?
If built as proper SuiteCommerce extensions (not core file modifications), they survive upgrades intact. Extensions sit in a separate layer from the core application. The key is to never modify files in the Modules/ directory directly—always use extension overrides, child views, and custom modules. We cover upgrade methodology in detail in our migration checklist guide.
How do I measure MyAccount adoption after implementing these features?
Track these metrics:
- Login frequency — How often B2B customers log in per week
- Self-service order rate — Percentage of orders placed through the portal vs. phone/email
- Feature usage — Which MyAccount features are actually used (reorder, bulk upload, etc.)
- Support ticket volume — Decrease in "where's my order" and "send me an invoice" tickets
- Time to order — Average time from login to order completion
A successful B2B MyAccount implementation should shift 40–60% of order volume to self-service within the first two quarters.
What's Next?
Your SuiteCommerce MyAccount portal is either a tool your B2B customers use daily or a feature they ignore. The difference is whether it supports their actual workflow—not just individual shopping, but organizational purchasing with all its complexity.
Start with the features that match your customers' biggest pain points. If they're constantly calling for order status, fix order history first. If they're emailing spreadsheets, build bulk upload. Meet them where they are, then expand.
Ready to build a B2B MyAccount that your customers will actually use? Talk to Stenbase about a phased implementation plan. We'll audit your current MyAccount, identify the highest-impact features for your specific customer base, and build it right—extensible, upgrade-safe, and designed for adoption.
Need Help with Your NetSuite Project?
Our team of experts is ready to help you achieve your goals.


