Forest Logo
search
package_2

roblox-commerce

By @hurreman

Roblox

Mirrored from Wally

roblox-commerce

Purchases for hurreman's Roblox games:

  • Receipts: developer products granted exactly once, over whatever data layer the game uses.
  • Prompts: product and game pass prompts with a purchase funnel.
  • Policy: PolicyService gating.
  • Prices: Robux price lookups for UI.

It combines Slime Nebula's ReceiptLedger and PlayerPolicyService with Magic Neighborhood's ProductService, GamepassService and usePassPrices.

# wally.toml
[dependencies]
Commerce = "hurreman/roblox-commerce@0.1.0"

Modules load on first access: Commerce.Products, Commerce.Passes, Commerce.Policy, Commerce.ReceiptLedger (server) and Commerce.Prices (client).

Developer products

local Commerce = require(ReplicatedStorage.Packages.Commerce)

local products = Commerce.Products.new({
	products = Config.Products, -- key -> { id, name?, ...anything the game needs }
	account = function(player)  -- nil while the save isn't loaded: the receipt is retried
		local profile = DataService.get(player)
		return profile and Commerce.ReceiptLedger.FromProfile(profile)                 -- ProfileStore
		--  or Commerce.Products.SaveNowAccount(save, function() return DataService.saveNow(player) end)
	end,
	grant = function(player, key, product, arg)
		-- give it; return false if nothing could change now (retried later); errors are recorded
		return true
	end,
	blocked = function(player, key, product) return nil end, -- or "why not, right now"
	notify = function(player, kind, text, key) end,       -- key: the product, for the game's own wording or icon
	onPrompted = function(player, key, context) end,
	onCompleted = function(player, key, context, receipt) end, -- "Retry" when it came on a later join
	onDeclined = function(player, key, context) end,
	ledger = { legacyIdsField = "receipts" },                 -- Magic Neighborhood's old id list
})
products:Start() -- server only: claims MarketplaceService.ProcessReceipt
products:Prompt(player, "CashSmall", "Shop", optionalArg)

Each receipt goes through ReceiptLedger.Process:

  1. Mark. The purchase is marked processed before grant runs. A grant that throws halfway is never run again, and is recorded in history with grantError for a manual make-good.
  2. Refusal. A grant that returns false changed nothing, so the mark is undone and Roblox retries later.
  3. Confirm. The answer is PurchaseGranted only once the account confirms the save. For ProfileStore that means the mark is in LastSavedData; for a save-now layer, saveNow() must return true.

Duplicate receipts are deduped by ProcessedReceipts, a set back-filled from PurchaseHistory and an optional legacy id list. History keeps the newest 100 entries. Field names are options.

products:Grant(player, key, arg?) grants directly, for admin panels and tests. products:ProcessReceipt(receipt, key?) runs a fake receipt through the real handler, for a Studio debug hook; key names the product when it has no id yet.

Game passes

local passes = Commerce.Passes.new({
	passes = Config.Gamepasses,          -- key -> { id, name? }
	attributePrefix = "Pass_",           -- ownership lives in player attributes
	applied = function(player, key, fresh) end, -- fresh: bought / granted now, not found on join
	notify = ..., onPrompted = ..., onCompleted = ..., onDeclined = ...,
})
passes:Start()                           -- loads ownership for everyone, grants on purchase
passes:Owns(player, "DoubleCash")
passes:Prompt(player, "DoubleCash", "Shop")
passes:Grant(player, "Vip")              -- admin / test grant; passes:Revoke takes it back (this session)

Policy

local policy = Commerce.Policy.new()     -- attribute "PaidRandomItemsRestricted"
policy:Start()
if policy:IsPaidRandomRestricted(player) then ... end -- fails closed until a fetch says otherwise
policy:Allows(player, "AreAdsAllowed")                -- other boolean fields, false until known
-- Commerce.Policy.new({ onChanged = function(player, info) ... end })  -- refresh offers; info nil: restricted
-- policy:Override(player, { ArePaidRandomItemsRestricted = true })    -- tests: as if fetched

Prices (client)

local prices = Commerce.Prices.new({ passes = Config.Gamepasses, products = Config.Products })
prices:Fetch()
local stop = prices:Subscribe(function(all) setPrices(all) end) -- { key = robux }
-- label: Core.Format.Robux(prices:Get("DoubleCash"))

A React hook is a useState over Subscribe.

Moving the games over

  • Slime Nebula:
    • ReceiptLedger.Process(profile, receipt, grant, options) becomes Commerce.ReceiptLedger.Process(Commerce.ReceiptLedger.FromProfile(profile, options), receipt, grant, options). The data shape (ProcessedReceipts / PurchaseHistory) is unchanged, and the ported ProcessReceipt.spec cases pass.
    • PlayerPolicyService becomes a Commerce.Policy.new(). PAID_RANDOM_ATTRIBUTE is the default attribute; _getPolicyInfo and _retryDelay are the getPolicyInfo and retryDelay options.
    • GamePassManager and DeveloperProductManager become Passes and Products, with SN's grant functions as applied and grant.
  • Magic Neighborhood and Steal an Egg:
    • ProductService becomes a Products built from these pieces:
      • blocked keeps the game's own checks;
      • grant is the game's grant switch;
      • telemetry callbacks go to TelemetryService;
      • account is SaveNowAccount(profile, saveNow) with ledger = { legacyIdsField = "receipts" }, so old receipt ids still dedupe.
    • The behaviour change: a grant that throws is no longer retried. It is recorded, so a partial grant can't land twice.
    • GamepassService becomes a Passes with applied as the side-effect hook. Admin passes saved in the profile are re-applied with passes:Grant(player, key, false) from the game's ready hook.
    • usePassPrices becomes a hook over Commerce.Prices.
    • The purchase request remote moves to roblox-net with a limit, replacing PROMPT_COOLDOWN.

Developing

lune run tests/run    # specs on hurreman/luau-spec (DevPackages, else ../luau-spec); fakes in tests/fixtures

The Lune specs cover:

  • the ledger, including Slime Nebula's ProcessReceipt cases;
  • prompts and their funnel, receipts, retries and refusals;
  • pass grants, ownership loading and purchases;
  • policy fail-closed and retries;
  • price fetching.

Checked in Studio:

  • Prices fetched real prices: Magic Neighborhood's ×2 Cash pass at 160 and its Candy Bag product at 20.
  • The UserOwnsGamePassAsync call works.
  • ProcessReceipt can only be registered on a server.

ReceiptLedger.FromProfile was also run against Slime Nebula's real ProfileStore, on its Mock store, in Studio. The grant was confirmed once the mark reached LastSavedData, a redelivered receipt wasn't granted twice, and an ended session answered NotProcessedYet.

A real Robux purchase has not been run.

Package Details

Install command (Click to copy)


Version

0.1.0

License

MIT

check_circle

Safe for commercial use

Automated license review — not legal advice.