Start typing to search packages!
roblox-commerce
By @hurreman
Roblox
Mirrored from Wallyroblox-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:
- Mark. The purchase is marked processed before
grantruns. A grant that throws halfway is never run again, and is recorded in history withgrantErrorfor a manual make-good. - Refusal. A grant that returns
falsechanged nothing, so the mark is undone and Roblox retries later. - 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)becomesCommerce.ReceiptLedger.Process(Commerce.ReceiptLedger.FromProfile(profile, options), receipt, grant, options). The data shape (ProcessedReceipts/PurchaseHistory) is unchanged, and the ported ProcessReceipt.spec cases pass.PlayerPolicyServicebecomes aCommerce.Policy.new().PAID_RANDOM_ATTRIBUTEis the default attribute;_getPolicyInfoand_retryDelayare thegetPolicyInfoandretryDelayoptions.GamePassManagerandDeveloperProductManagerbecomePassesandProducts, with SN's grant functions asappliedandgrant.
- Magic Neighborhood and Steal an Egg:
ProductServicebecomes aProductsbuilt from these pieces:blockedkeeps the game's own checks;grantis the game's grant switch;- telemetry callbacks go to TelemetryService;
accountisSaveNowAccount(profile, saveNow)withledger = { 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.
GamepassServicebecomes aPasseswithappliedas the side-effect hook. Admin passes saved in the profile are re-applied withpasses:Grant(player, key, false)from the game's ready hook.usePassPricesbecomes a hook overCommerce.Prices.- The purchase request remote moves to roblox-net with a
limit, replacingPROMPT_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:
Pricesfetched real prices: Magic Neighborhood's ×2 Cash pass at 160 and its Candy Bag product at 20.- The
UserOwnsGamePassAsynccall works. ProcessReceiptcan 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
Safe for commercial use
Automated license review — not legal advice.
