Item Catalog & Store: two valid ways to sell something
Virtual Currency for the wallets these purchases spend from; Cloud Code, the pattern for the RunScript call bitBuddies uses here to invoke its purchase script.
The two reference apps in this series sell items two genuinely different ways, and both are correct; the choice depends on what's being sold. bitBuddies' shops sell items priced in virtual currency (Coins, Gems, BuddyBling), so the whole purchase (afford check, debit, grant) happens server-side inside a single custom Cloud Code script, triggered by one RunScript call. Marketplace sells items for real money through the platform's native store (Apple/Google/Steam), so the client talks to AppStoreService and Unity IAP directly. brainCloud's role narrows to catalog lookup plus receipt verification after the platform handles payment, since brainCloud itself never touches the money.
How it works
- Fetch the catalog with one script call. GetChildItemCatalog / GetParentShopCatalog return items already split by category. There's no separate "is this in stock" or "what does this cost" round-trip.
- Buying is one RunScript call with just an item id. The server checks affordability, debits the currency, and grants the item. The client never computes or asserts the price itself.
- The response tells the client what to display, not what to trust. It carries the payout and new balance for UI purposes. The actual debit already happened server-side by the time the response arrives. ### Real-money purchase (Marketplace)
- Fetch products from brainCloud, then hand them to the platform SDK. AppStoreService.GetSalesInventory returns brainCloud's catalog, which is used to build Unity IAP's ConfigurationBuilder. brainCloud defines what's for sale; the platform store handles paying for it.
- Cache purchase context before initiating the platform purchase. CachePurchasePayloadContext tells brainCloud what's about to be bought, then controller.InitiatePurchase hands off to the real App Store / Play Store / Steam flow.
- Verification runs after the platform confirms payment, not before. brainCloud never sees money move, only the platform's signed receipt afterward.
- A mock-store path exists for testing without real payment, and it's the one place this app's flow does go through cloud code. Receipt verification for the mock store runs as a script.
- Promotional pricing is computed server-side, before the client ever sees the catalog. Marketplace's FetchStoreItems script calls AppStoreService's getEligiblePromotions, builds a lookup from item id to promotional price, and overwrites buyPrice on any catalog item that has one, marking it with isPromotion. The client never asks whether an item is on sale; it reads whichever price the script already decided is the real one.
- A brand-new account forces a promotion refresh before the first catalog fetch. SetupUserAccount calls AppStoreService's refreshPromotions explicitly, because promotion eligibility is normally recalculated during authentication, and that recalculation hasn't caught up yet for a profile that's mid-creation. Skipping that call can leave a brand-new player's first store screen showing stale, non-promotional pricing.
Code
private void OnBuyCallback()
{
var scriptData = new Dictionary<string, object> { { "itemId", _shopInfo.ShopId } };
BrainCloudManager.Client.ScriptService.RunScript(
BitBuddiesConsts.CLAIM_ITEM_SCRIPT_NAME, // "ClaimParentShopItem"
scriptData.Serialize(),
BrainCloudManager.HandleSuccess("Claim Item Success", OnClaimItemSuccess),
BrainCloudManager.HandleFailure("Claim Item Failure", OnClaimItemFailure));
}public static void FetchProducts(Action<BCProduct[]> onFetchFinished = null)
{
static void onFetchSuccess(string jsonResponse, object cbObject)
{
var data = (JsonReader.Deserialize<Dictionary<string, object>>(jsonResponse)["data"]
as Dictionary<string, object>)["productInventory"];
bcIventory = JsonReader.Deserialize<BCProduct[]>(JsonWriter.Serialize(data));
var builder = ConfigurationBuilder.Instance(StandardPurchasingModule.Instance());
foreach (var product in bcIventory)
builder.AddProduct(product.GetProductID(), product.IAPProductType);
UnityPurchasing.Initialize(Instance, builder);
};
InternalSetCallback(onFetchFinished);
bc.AppStoreService.GetSalesInventory(APP_STORE, string.Empty, onFetchSuccess,
OnBrainCloudFailure("Unable to fetch products from brainCloud!", () => InternalInvokeCallback(null)));
}public static void PurchaseProduct(BCProduct product, Action<BCProduct[]> onPurchaseFinished = null)
{
InternalSetCallback(onPurchaseFinished);
string id = product.GetProductID(), payload = product.payload;
var iapProduct = controller.products.WithID(id);
if (iapProduct != null && iapProduct.availableToPurchase)
{
void onCacheSuccess(string jsonResponse, object cbObject)
{
controller.InitiatePurchase(iapProduct); // hands off to the real platform store
}
bc.AppStoreService.CachePurchasePayloadContext(APP_STORE, id, payload, onCacheSuccess,
OnBrainCloudFailure("Unable to cache the purchase payload context on brainCloud!",
() => InternalInvokeCallback(null)));
}
}"use strict";
function main() {
var response = {};
if (data && data.storeId == "mock" && data.receiptData) {
var postResults = bridge.getAppStoreServiceProxy().verifyPurchase(data.storeId, data.receiptData);
response.data = postResults;
response.success = postResults.status == 200;
if (!response.success) response.errorMessage = "VerifyPurchase was not a success.";
} else {
response.success = false;
response.errorMessage = "Client did not receive the proper data for a mock VerifyPurchase.";
}
return response;
}
main();var appStoreProxy = bridge.getAppStoreServiceProxy();
var promotions = [];
var promoResult = appStoreProxy.getEligiblePromotions();
if (promoResult.status === 200) {
promotions = promoResult.data.promotions || [];
}
// Build a lookup map: defId -> promotional buyPrice
var promoPriceMap = {};
for (var p = 0; p < promotions.length; p++) {
var promo = promotions[p];
if (promo.items) {
var promoDefIds = Object.keys(promo.items);
for (var d = 0; d < promoDefIds.length; d++) {
var defId = promoDefIds[d];
var promoItem = promo.items[defId];
if (promoItem.buyPrice) promoPriceMap[defId] = promoItem.buyPrice;
}
}
}
// Apply promotional pricing to catalog items
for (var j = 0; j < allCatalogItems.length; j++) {
var catalogItem = allCatalogItems[j];
var originalBuyPrice = JSON.parse(JSON.stringify(catalogItem.buyPrice));
if (promoPriceMap[catalogItem.defId]) {
var promoBuyPrice = JSON.parse(JSON.stringify(promoPriceMap[catalogItem.defId]));
promoBuyPrice.isPromotion = true;
catalogItem.buyPrice = promoBuyPrice;
catalogItem.defaultBuyPrice = originalBuyPrice;
} else {
catalogItem.buyPrice.isPromotion = false;
catalogItem.defaultBuyPrice = originalBuyPrice;
}
}// Promotion eligibility is a snapshot that's normally recalculated during
// authentication, but for a brand-new account that recalculation hasn't caught up
// yet by the time this script runs mid-auth. Force it now so the very first
// FetchStoreItems call after account creation already shows correct promo pricing,
// instead of requiring the user to relaunch the app.
bridge.getAppStoreServiceProxy().refreshPromotions();bool isPromotion = priceData != null && Convert.ToBoolean(priceData["isPromotion"]);
decimal currentPrice = priceData != null ? Convert.ToDecimal(priceData["referencePrice"]) / 100m : 0m;
decimal oldPrice = isPromotion && defaultPriceData != null
? Convert.ToDecimal(defaultPriceData["referencePrice"]) / 100m
: 0m;
// ...
isOnPromotion = isPromotion,Gotchas worth knowing up front
- Pick the pattern by what the item costs, not by habit. Virtual-currency items belong in a custom Cloud Code script, invoked through RunScript, that computes afford/debit/grant server-side. Real-money items belong behind AppStoreService plus the platform SDK. Routing a real-money item through a currency script, or a virtual-currency item through AppStoreService, is the wrong tool for that price type.
- The "freebie" shop item is a special case, not a separate code path. It goes through the same ClaimParentShopItem call as everything else. The response additionally carries a coolDownUntil timestamp that the client uses to arm a 24-hour countdown timer; the item has no claim method of its own.
- AppStoreService's promotion methods are a different brainCloud feature from the standalone Promotions service (the one used to create, edit, or delete a promotion programmatically via S2S or cloud code). Marketplace only ever reads eligible promotions and forces a refresh; use the Portal to configure promotions.