Build a Random Prize Competition Service: Part 3

This is the third part in a series discussing implementing a random prize competition service to a SuiteCommerce Advanced site. In part one we reviewed various methods for random number generation, ending with us implementing a Mersenne-Twister RNG. After that, in part two, we added a frontend to it. While the user's winning number had already been generated in the backend, we sent it to a spinning wheel that the user spins to reveal their prize.

In this article, we're going to shift our focus back to the backend of the site. We know that we only want registered users to play and we know that when a user spins, this information is stored in their account (so they can retrieve it and to prevent them from spinning again). We need to put this information into their account and run these checks.

Security, Privacy and My Account

As you'll know, an SCA application is actually made up off three applications, one each for shopping, checking out and account services. For the next part of the development of this module, we're going to migrate the module to the account application. Why? Because we're going to be accessing shopper data.

There are two key security concerns around accessing and updating this data: is the user logged in and this connection between them and secure? Now, admittedly, the latter concern is only important when data is being sent between the browser and the server; in our example, this isn't going to happen: there's no form. However, it's a good habit to get into.

The first thing we need to do is move all of the dependencies we set up in distro.json. Remember, we specify where JavaScript and CSS is added on a per-application basis, so we need to move them:

  1. For Winwheel, move the entry in the paths object and the entry in shim object to myaccount.js
  2. For TweenMax, move the JavaScript module dependency to myaccount.js
  3. For RNG, move the JavaScript and CSS module dependencies to myaccount.js and myaccount.css
  4. respectively

If you save and deploy, and then refresh the page, you'll see a 404 error. Now, log in to a dummy account on the site. After you're redirected to your account, and you're in the account application, append #competition to the end of the URL and the familiar page of the spinning wheel should load.

Save the User's Spin to Their Account

A crucial first step towards our goal is being able to save the user's spin to their account. We're going to need this for a few reasons:

  1. To prevent a user from playing the game multiple times
  2. So that it is easily retrievable by the user, should they forget
  3. To verify the result, should concerns be raised (eg if a user calls to say it showed one prize but they got another)

In other words, there are plenty of scenarios where tracking spins could have benefits, so it makes sense to record it.

Create a Custom Entity Field

In order to store this data against a user, we must first set up a custom field to store it in.

In NetSuite, go to Customization > Lists, Records & Fields > Entity Fields > New.

Create the field as follows:

  • Label: Competition Summer 2017 Prize
  • ID: _comp_summer_17
  • Type: Free-Form Text
  • Store Value: checked
  • Applies To > Customer: checked
  • Applies To > Web Site: checked
  • Access > Default Access Level: Edit
  • Access > Default Level for Search/Reporting: Edit

Everything else can be left at default values. Click Save.

Save the Spin Data

We have the field set up, so now we need to update our backend model so that when the spin data is generated, we save it there.

In SuiteScript > RNG.Model.js add a new function:

, updateCustomer: function (prizeData)
  {
    var fields = {
      customfields: {
        custentity_comp_summer_17: JSON.stringify(prizeData.spin)
      }
    };
    CommerceAPI.customer.updateProfile(fields);
  }

For this, we're making use of the customer object in the commerce API. The updateProfile method lets us make changes to the current user's data. In our example, we're setting up a fields object that sets the value of one of new custom fields by stringifying the spin data.

Before we set up the trigger for this method, we're going to need to add in a couple of other methods: one to check if the domain is secure (good practice) and whether the customer is logged in.

Add in two more methods:

, isSecure: request.getURL().indexOf('https') === 0

, isLoggedIn: CommerceAPI.session.isLoggedIn2()

This is good, now we need to trigger our new methods. For that, we're going to rewrite the get method. Replace it with:

, get: function ()
  {
    if (this.isLoggedIn && this.isSecure)
    {
      var prizeData = {};

      prizeData.spin = this.getPrize();
      this.updateCustomer(prizeData);

      return prizeData
    }
    else throw new Error ('Not logged in or served over secure connection')
  }

So, we run the checks: if they fail, an error is thrown; if they succeed then the spin is made as normal. Then, the updateCustomer() method is called with the prize data passed as a parameter. The rest, as they say, is history.

Save and deploy. Refresh the page and make sure it all loads fine. Take a look at the Network tab and see what data is being returned by the RNG service. Then, in NetSuite, look up the logged-in user. In the Custom tab, you should see the same data, eg:

Give the Shopper Their Prize and Ensure Only They Can Use It

We've reached a point now where we can start to think about how we're actually going to reward the shopper. To reiterate, what we want to do is:

  1. Create promotions for each of these rewards that grant $10, $25 and $50 off the total of the order
  2. Find a way to distribute the correct promotions to the right winners
  3. Lock down the access of these promotions so that only the winners get to use them

It's the last two steps that are the hardest. My initial thought was to make use of the single-use promotion codes that can be generated and distributed via marketing emails. But there were two problems with this:

  1. While the codes are unique and single-use, they're transferable, meaning someone could get a code and then send it to their friend
  2. I actually don't know how to trigger a marketing campaign email via SuiteScript, and so setting the whole thing up seems overly complicated

There's also no way to surface the promotion codes via SuiteScript either. One could keep a list of them (in a record, say) and then write a cool service to pluck one out and ensure that it hasn't already been given out, but, again, this whole thing seems to be getting rather complicated.

Instead, I thought we'd try something that was added to the new promotions engine, called SuitePromotions, which is the ability to limit the 'audience' of a promotion based on a customer's category membership.

In NetSuite, customer categories are typically used to split your customers up into exclusive groups so that you can do stuff with that information, such as reporting. But with the addition of this feature to the promotions engine, it also means that you can give selected customers exclusive access to promotions safe in the knowledge that the system will handle all the checking for you. All you have to do is set up the promotion and add that user to that category, which is exactly what we're going to do.

Before we do that, however, I just want to make a note: customer categories are exclusive, which means that a customer can only belong to one category at a time. Thus, if you use them for something else, or if you plan to run multiple promotions with this functionality (ie where you might put them in multiple categories) then this solution won't be a good fit. Of course, once a member has joined a category and used the promotion, they can be moved out of the category, but they need to be in the category long enough to use it.

Create the Customer Category

To create a new customer category, go to Setup > Accounting > Accounting Lists > New > Customer Category. As you'll see, there's only two fields.

Set the name of the category; in, the Customer field enter something like "Competition Summer 2017 $10 Win". Click, Save and then repeat this for each of the other prizes.

When you're done, make a note of each of the category IDs, as we're going to need them later.

Set Up the Promotions

Now we need to create the promotions that correspond to these categories and prizes.

Go to Marketing > Promotions > New. Create a new SuitePromotion that is a Order Promotion. Set it up as follows:

  • Name: Competition Summer 2017 $10
  • Start Date and End Date: for testing purposes, I'm leaving these blank but you'll probably to set these (be sure to communicate this to your customers)
  • Combination With Other Promotions: Combinable Promotion (although, this is up to you)
  • Discount Item For Accounting: (this depends on how your business is set up in NetSuite, I can't offer advice)
  • What the Customer Needs to Buy: Buy Anything
  • What the Customer Will Get: Flat 10.00
  • Coupon Codes > Coupon Code Type: Multiple Uses
  • Coupon Codes > Coupon Code: COMPSUM17USD10 (whatever you like)
  • Audience: Specific Customers
  • Audience > Customer Category: Competition Summer 2017 $10 Win
  • Usage Limits > Each Customer Can Use The Promotion: One Time Only

Now, repeat these steps for each prize adjusting the names, discounts, codes and categories as necessary.

If you want you can test your new promotions to see if they work on the frontend. Type in one of the promo codes and try to apply it to a live order. It should fail (with a generic error message being returned). Great stuff.

Put Users in the Right Categories

This is the crucial part: when a customer spins and is awarded their prize, they need to be assigned to the correct category so they qualify. How do we do this?

You'll remember that, so far, we've been using the commerce API to update the custom field we added to the customer record to store their prize data. This is fine for this use, but there's a snag: the API only supports a limited number of fields. For example, you can do things like update a user's profile (name, phone number, email), subscription options, address, etc, but these are not all the fields the user has associated with them. Thus, we'll need to go to SuiteScript.

In SuiteScript > RNG.Model.js, replace the updateCustomer function with:

, updateCustomer: function (prizeData)
  {
    var category = 0;

         if (prizeData.spin.prize === '$10') {category = 5}
    else if (prizeData.spin.prize === '$25') {category = 7}
    else if (prizeData.spin.prize === '$50') {category = 8}
    else throw new Error ('Category assignment failed')

    var record = nlapiLoadRecord('customer', nlapiGetUser());
    record.setFieldValue('category', category);
    record.setFieldValue('custentity_comp_summer_17', JSON.stringify(prizeData.spin));
    return nlapiSubmitRecord(record);
  }

Let's take a look at this:

  1. So this function was already being passed the prize data, so now we're pulling out the prize itself for conditional analysis. The prize is checked and then the category variable is set. Note that the numbers we use here are the IDs of the customer categories were created earlier — adjust these numbers to match yours.
  2. Then we need to set this data, so we tell the system to load the current user's customer record.
  3. Set the values of the two fields: the category and the prize data.
  4. Submit the record back to the system.

Yup. I made you do all that stuff with the commerce API and updating the profile and now I just removed it. But, it's a good lesson, so I kept it in, because that API can be used to update some fields quickly and easily.

Save and deploy, and then refresh the page.

Great and now ... wait. It's erroring, isn't it? Check the execution log, what does it say?

"userEvent":null,"internalId":null,"code":"INSUFFICIENT_PERMISSION","details":"Permission Violation: You need the 'Lists -> Customers' permission to access this page. Please contact your account administrator."

At NetSuite, one of the things we're keen on is security and part of that means ensuring that when you have code that accesses your data, we put checks in place to ensure that it can only access data it's supposed to be able to. While we allow things like name, number and custom fields through the commerce API, this particular way of doing things requires what's called elevated permissions.

We know that different roles in NetSuite have different permissions: customers can do some things, employees can do others, and administrators can pretty much do anything. This is a good thing. But sometimes this causes headaches because, for example, we want to write scripts that update a user's own category but strictly they're not allowed.

Thus, what we can do is allow this script — and this script only — to run as if it were being run by someone with the right permissions. This is something, for example, we have to do with the store locator, and we're actually going to make use of the same role we introduced (in Vinson) for this script.

To elevate the permissions for the script:

  1. Search for the service file by typing in RNG.Service.ss into the NetSuite search box
  2. Edit it
  3. Uncheck Available Without Login
  4. In the Permission tab:
    1. Check Enabled
    2. Select Advanced Customer Center from the Execute as Role dropdown
    3. Check Run Script Without Login
  5. Click Save

Once that's done, refresh the competition page and it should load perfectly. Check out the Network tab and take a look at the response from RNG.Service.ss, and then compare that to what's in the logged-in user's record: it should match. Also note what the customer category is set to. Test this out by refreshing the competition page and seeing what values are being returned; pay particular attention to when it's not a $10 prize: make sure the right category is being updated.

Crucially, at this stage, we can also test that eligibility is working correctly. Try applying the promotion codes and see which ones work. Great!

Give the User Their Promo Code

The final part of this is giving the promo code the user. As you'll remember, we've built into the template the ability to serve the promo code to the user, so now we need to do that.

I'm going to suggest we simply put this into the getContext function of the view: remember, we don't need to worry about exposing the codes to shoppers because they can only use them if they're eligible for them.

Thus, in RNG.View.js, replace the function with:

, getContext: function ()
  {
    var prize = this.model.get('spin').prize
      , promoCode = '';

         if (prize === '$10') {promoCode = 'COMPSUM17USD10'}
    else if (prize === '$25') {promoCode = 'COMPSUM17USD25'}
    else if (prize === '$50') {promoCode = 'COMPSUM17USD50'}
    else console.error('Error generating promo code');

    return {
      isLoggedIn: this.model.get('isLoggedIn')
    , pointerImage: _.getAbsoluteUrl('img/basic_pointer.png')
    , prize: prize
    , promoCode: promoCode
    }
  }

Save and deploy again. Refresh the competition page and now you'll see the corresponding promotion code. Hooray!

Limit Availability to Certain Customers

One of the things we wanted to do was to implement a level of exclusivity into this functionality so that only loyal customers are rewarded. For this tutorial, we're going to limit access to customers who have been registered for at least 180 days. Naturally, we will need to run this in the backend and rely on SuiteScript for some of it.

Every customer record has data that tracks important dates that we could use. For example, we could use the date the account was created (datecreated) or the date of first visit to the site (firstvisit). If we're feeling fancy, we could even do a look up of their recent orders and see if there's been one in the past 180 days. I'm going to suggest we go with the first: the date the record was created.

Let's start simple. In SuiteScript > RNG.Model.js add a new method:

, checkEligibility: function ()
  {
    console.log(nlapiLookupField('customer', nlapiGetUser(), 'datecreated'));
  }

Look up the field we want, then log it in the execution log.

Now just add a call to it to the get function (this.checkEligibility();) and then save and deploy.

You'll see that it returns a date in the format of M/D/YYYY H:MM am/pm, eg 1/13/2015 6:36 am. Now, in this format this is difficult to work with. In other words, can you think of an easy way of saying "is [this date] at least 180 days before [that date]?" using this format? No, we'll need to convert it.

In order to perform date comparisons, we'll need to convert it to the Unix time format — this is a number that counts in milliseconds since January 1, 1970. We'll also need another date, in the same format, of (for example) today minus 180 days. Once we have those two dates, we can run a simple comparison: is the user's account's creation date less than the second date (today minus 180), which we'll call the eligibility date?

Replace checkEligibility with:

, checkEligibility: function ()
  {
    var createdDate = nlapiLookupField('customer', nlapiGetUser(), 'datecreated');
    createdDate = createdDate.split(/\W+/, 3);
    createdDate = Date.parse(new Date(createdDate[2], createdDate[0], createdDate[1]));

    var eligibilityDate = Date.parse(new Date()) - (86400000*180);

    return createdDate < eligibilityDate
  }

There's a fair bit going on here:

  1. Get the current user's account's creation date
  2. Transform it by splitting it up, keeping the first three values based on a regular expression that checks for non-alphanumeric characters
  3. Create a JavaScript-friendly date out of it (using the Date constructor) and then parse it to a number (using Date.parse)
  4. Do a similar process for the eligibility date by constructing a new date for today, and then subtracting a day's worth of milliseconds (86,400,000) 180 times
  5. Return a comparison of those two numbers

Now, how we use this is up for discussion. For example, if a customer isn't eligible, we certainly don't want to throw an error like we would if there's a problem with insecure domains or if they're not logged in — so we can't just tack it onto the existing conditional in the get function.

We also have to keep in mind that we're soon going to add code that checks whether the user has played before, and it seems like a good opportunity to create something generic that handles both of these situations. If it turns out that the user is ineligible or has already played, then we should surface a message to the frontend to explain what happened (while still preventing them from playing).

How we implement this is also up for discussion: do you draw the wheel but not them play? If so, how? Do you not draw the wheel and effectively end up with two kinds of pages? It all depends on what you want.

What I'm going to suggest is that we still show the wheel, but we hide the button that allows them to spin it. We also don't run the functions that generate the random number, prize or spin information. Instead, we can just show a message that says that they're not able to play.

To do this, we're going to need make changes the backend model, the view and the template.

In SuiteScript > RNG.Model.js, replace the get function with:

, get: function ()
  {
    if (this.isLoggedIn && this.isSecure)
    {
      var prizeData = {};

      if (!!this.checkEligibility())
      {
        prizeData.eligible = true
        prizeData.spin = this.getPrize();
        this.updateCustomer(prizeData);
      }

      else
      {
        prizeData.eligible = false
      }

      return prizeData
    }
    else throw new Error ('Not logged in or served over secure connection')
  }

This isn't too much of a change — we're adding in a new property to the prize data that records whether the user is eligible to play. If they are, we run the normal operations to generate a prize and update the customer record; if they're not, we flag that they're ineligible.

Next we update the view using this flag. If some code depends on values from the prize data object, we need to make sure that it doesn't run (or else we'll get errors). We also need to surface the eligibility information to the template.

Thus, in RNG.View.js, replace the getContext function with:

, getContext: function ()
  {
    var prize = {}
      , promoCode = '';

    if (this.model.get('eligible'))
    {
      var prize = this.model.get('spin').prize;

           if (prize === '$10') {promoCode = 'COMPSUM17USD10'}
      else if (prize === '$25') {promoCode = 'COMPSUM17USD25'}
      else if (prize === '$50') {promoCode = 'COMPSUM17USD50'}
      else console.error('Error generating promo code');
    }

    return {
      eligible: this.model.get('eligible')
    , pointerImage: _.getAbsoluteUrl('img/basic_pointer.png')
    , prize: prize
    , promoCode: promoCode
    }
  }

So as we said we were going to do, we now prevent some code from running if it requires properties from the object that don't exist (ie the stuff for returning prize data). We also return the eligibility flag to the context object so that we can use it in the template, which we're getting to now.

In rng.tpl, replace the contents with:

<div class="rng-canvas-container">
    <img class="rng-canvas-pointer" src="{{pointerImage}}">
    <canvas class="rng-canvas-canvas" id="canvas" width="400" height="400">Sorry but your browser does not support this functionality.</canvas>
</div>
{{#if eligible}}
    <button class="rng-spin" data-action="spin-to-win">Spin the Wheel</button>
    <p id="rng-message" class="rng-message">Congratulations, you've won a voucher that you can use for {{prize}} off your order. Use promo code <strong>({{promoCode}})</strong> during checkout.</p>
{{else}}
    <p>We're sorry, this competition is only open to certain customers. Keep an eye out for our next competition or contact customer services if you think there's a mistake.</p>
{{/if}}

As you can see, we're only showing the button and the prize text if the customer is eligible to see it; otherwise, they see a simple message explaining what the issue is. We still show the wheel, but they won't be able to trigger it to spin.

Test this out. If you don't have a fresh user already, register one on the site and then visit the page; you should see the text shown to ineligible users. You should also check the response text in the Network tab for RNG.Service.ss that only shows the eligible flag value — no spin data. The customer record should also not be updated.

When you've done all that, we can then focus on the next challenge: preventing users from playing more than once.

One Spin per Customer

It's relatively trivial to test whether someone has already played: if the custom field we set up to store spin data has a value, then we know that something must have populated it; as only this game is going to do that, we can rely on that. Thus, all we need to do is run a check on that and go from there.

Add Another Check to the Model

With that information, we need to prevent another spin from hapenning but then serve them the information that we have stored about them. As we set it up to store the entire object, it shouldn't be hard to tinker with the code so that they get that.

In SuiteScript > RNG.Model.js, replace the eligibility check in the get function to this:

if (!!this.checkEligibility())
{
  prizeData.eligible = true;

  var spinData = nlapiLookupField('customer', nlapiGetUser(), 'custentity_comp_summer_17');

  if (spinData)
  {
    prizeData.spin = JSON.parse(spinData);
    prizeData.firstSpin = false;
  }

  else
  {
    prizeData.spin = this.getPrize();
    this.updateCustomer(prizeData);
    prizeData.firstSpin = true;
  }
}

After check for eligibility (and find that they are), we can then run another check: this time we make a call to look up the custom field that holds the data. If it's there, we can parse it and set a flag; if it's not, then we can go through the motions of creating a new spin.

If you save and deploy this change, and then refresh the competition page, you can see if this has taken effect. Look at the spin data in the Network tab: has the flag been set? When you refresh, does it load the same data? Next, delete the data from your customer record and then refresh again, it should generate new data (which then persists on all future refreshes). Good stuff.

Update the View and Template

Now that we have this information, we need to now do two things:

  1. Show them their prize information so that they can redeem it
  2. Prevent them from spinning the wheel again (although it would just land at the same place anyway)

So, to start, let's update the view's context so that we have this fresh information. In RNG.View.js, add the following to getContext:

, firstSpin: this.model.get('firstSpin')

In rng.tpl, update the eligibility block so that it now has:

{{#if eligible}}
    {{#if firstSpin}}
        <button id="rng-spin" class="rng-spin" data-action="spin-to-win">Spin the Wheel</button>
    {{/if}}
    <p id="rng-message" class="rng-message" {{#if firstSpin}} style="visibility:hidden{{/if}}">Congratulations, you've won a voucher that you can use for {{prize}} off your order. Use promo code <strong>({{promoCode}})</strong> during checkout.</p>
{{else}}
    <p>We're sorry, this competition is only open to certain customers. Keep an eye out for our next competition or contact customer services if you think there's a mistake.</p>
{{/if}}

Some small changes here:

  1. The button now only shows if it's the user's first spin (it has an ID on it)
  2. We've added a conditional to the message that adds inline styling to it, hiding it, if it's the user's first spin

This now means that you can remove the class styling for rng-message from _rng.scss.

Now, to hide the button after the user starts spinning the wheel, we can add the following to the spinWheel function in RNG.View.js:

document.getElementById('rng-spin').style.visibility = 'hidden';

Simple.

Reflection and Going Forward

And that's kinda it. The functionality works, the essential styling is in place, and we've accounted for situations where things might not go to plan.

This is where the tutorial stops and with good reason: we've achieved what I set out for us. But you'll recognize that there's still some way to go before this is fully-fledged functionality, so let's run with some of the things you'd need to do — or could do — if you were actually going to put this live.

Style and Presentation

The view, template and associated Sass are all barebones at the moment. With the view, we could for example add a page title and add an option in the menu that links to the page.

Likewise with the template and Sass, we should put some effort into styling and presenting the page. I'm not a designer, and anything I could produce would be specific to my site, but there are a lot of possibilities here. As I mentioned in a previous article, we don't have to use this spinner: the way it's implemented is to create a random number and then generate a stop angle based on it: only this last part is specific to Winwheel, it would not take long to integrate it with another spinner.

Code Quality and Performance

The code in this tutorial has been written up in a 'make it up as we go along' type of way. This is deliberate. If were to actually create this functionality again, we would certainly do some things different and think about the different ways that we could achieve higher quality.

From a personal point of view, there's a lot going on in the backend model, in particular the get function and we may want to think about how others would read this code and how they might see this.

We also, at various points, make calls to look up fields or entire records. While this is necessary, we might think more holistically about this and whether we can compress all of them down to a single call to improve performance. For example, when we check eligibility, we call on a single field; if they are eligible we then lookup another field, then if they don't have spin data, we load the record again.

Thus, I would certainly advise taking a look at our docs on API governance. We could, for example, instead of loading the record when we update the customer details, rely solely on nlapiSubmitField: again, take a look at our docs and see if that's the right idea for you.

Final Thoughts

This tutorial series covered a lot of areas:

  1. The usual basics of creating a new module, including setting up the router and sending data from the backend to the frontend
  2. Adding third-party libraries including shimming in non-AMD ones
  3. Generating random numbers and why some methods are better than others
  4. Implementing a spinning wheel (ie visual third-party functionality)
  5. Loading code after model has loaded
  6. Loading code after the templates has loaded
  7. Creating custom entity fields and saving data to them
  8. A lot of stuff with native date and math functionality
  9. Looking up customer record data, setting new data
  10. The limits of the commerce API
  11. Why it's necessary, sometimes, to elevate the permissions of a SuiteScript file
  12. The importance of serving some things from the backend, and what things are OK to do in the frontend
  13. And a whole lot more

As I said at the start, I don't actually anticipate any ecommerce site implementing this functionality but I figured it would be a fun way to cover a lot of stuff (mentioned above) that, individually, could end up in your own project. In particular, we looked at a number of things to do with SuiteScript and the backend of projects.

I hope you found it useful.

The final collection of code can be found here: RNGpart3.zip. I've annotated it with inline comments, so if there's something specific that you want to find out more about you can.