{"id":"hurreman/roblox-commerce","name":"roblox-commerce","scope":"hurreman","platform":"roblox","description":"Purchases for hurreman's Roblox games: exactly-once developer product receipts over any data layer, product and game pass prompts with a purchase funnel, PolicyService gating and client price lookups","version":"0.1.0","latest":"0.1.0","versions":["0.1.0"],"license":"MIT","licenseRating":"safe","licenseCaveats":[],"licenseVerified":true,"dependencies":{},"integrity":"b52d185e7773401107c0798c80c88380d22b5f804468d158d4b2794edefce0d4","likes":0,"downloads":0,"install":"forest install hurreman/roblox-commerce","url":"https://forest.dev/p/roblox/hurreman/roblox-commerce","files":"https://api.forest.dev/ai/package/roblox/hurreman/roblox-commerce/files","readme":"# roblox-commerce\n\nPurchases for hurreman's Roblox games:\n- **Receipts:** developer products granted exactly once, over whatever data layer the game uses.\n- **Prompts:** product and game pass prompts with a purchase funnel.\n- **Policy:** PolicyService gating.\n- **Prices:** Robux price lookups for UI.\n\nIt combines Slime Nebula's `ReceiptLedger` and `PlayerPolicyService` with Magic Neighborhood's `ProductService`, `GamepassService` and `usePassPrices`.\n\n```toml\n# wally.toml\n[dependencies]\nCommerce = \"hurreman/roblox-commerce@0.1.0\"\n```\n\nModules load on first access: `Commerce.Products`, `Commerce.Passes`, `Commerce.Policy`, `Commerce.ReceiptLedger` (server) and `Commerce.Prices` (client).\n\n## Developer products\n\n```lua\nlocal Commerce = require(ReplicatedStorage.Packages.Commerce)\n\nlocal products = Commerce.Products.new({\n\tproducts = Config.Products, -- key -> { id, name?, ...anything the game needs }\n\taccount = function(player)  -- nil while the save isn't loaded: the receipt is retried\n\t\tlocal profile = DataService.get(player)\n\t\treturn profile and Commerce.ReceiptLedger.FromProfile(profile)                 -- ProfileStore\n\t\t--  or Commerce.Products.SaveNowAccount(save, function() return DataService.saveNow(player) end)\n\tend,\n\tgrant = function(player, key, product, arg)\n\t\t-- give it; return false if nothing could change now (retried later); errors are recorded\n\t\treturn true\n\tend,\n\tblocked = function(player, key, product) return nil end, -- or \"why not, right now\"\n\tnotify = function(player, kind, text, key) end,       -- key: the product, for the game's own wording or icon\n\tonPrompted = function(player, key, context) end,\n\tonCompleted = function(player, key, context, receipt) end, -- \"Retry\" when it came on a later join\n\tonDeclined = function(player, key, context) end,\n\tledger = { legacyIdsField = \"receipts\" },                 -- Magic Neighborhood's old id list\n})\nproducts:Start() -- server only: claims MarketplaceService.ProcessReceipt\nproducts:Prompt(player, \"CashSmall\", \"Shop\", optionalArg)\n```\n\nEach receipt goes through `ReceiptLedger.Process`:\n1. **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.\n2. **Refusal.** A grant that returns `false` changed nothing, so the mark is undone and Roblox retries later.\n3. **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.\n\nDuplicate 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.\n\n`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.\n\n## Game passes\n\n```lua\nlocal passes = Commerce.Passes.new({\n\tpasses = Config.Gamepasses,          -- key -> { id, name? }\n\tattributePrefix = \"Pass_\",           -- ownership lives in player attributes\n\tapplied = function(player, key, fresh) end, -- fresh: bought / granted now, not found on join\n\tnotify = ..., onPrompted = ..., onCompleted = ..., onDeclined = ...,\n})\npasses:Start()                           -- loads ownership for everyone, grants on purchase\npasses:Owns(player, \"DoubleCash\")\npasses:Prompt(player, \"DoubleCash\", \"Shop\")\npasses:Grant(player, \"Vip\")              -- admin / test grant; passes:Revoke takes it back (this session)\n```\n\n## Policy\n\n```lua\nlocal policy = Commerce.Policy.new()     -- attribute \"PaidRandomItemsRestricted\"\npolicy:Start()\nif policy:IsPaidRandomRestricted(player) then ... end -- fails closed until a fetch says otherwise\npolicy:Allows(player, \"AreAdsAllowed\")                -- other boolean fields, false until known\n-- Commerce.Policy.new({ onChanged = function(player, info) ... end })  -- refresh offers; info nil: restricted\n-- policy:Override(player, { ArePaidRandomItemsRestricted = true })    -- tests: as if fetched\n```\n\n## Prices (client)\n\n```lua\nlocal prices = Commerce.Prices.new({ passes = Config.Gamepasses, products = Config.Products })\nprices:Fetch()\nlocal stop = prices:Subscribe(function(all) setPrices(all) end) -- { key = robux }\n-- label: Core.Format.Robux(prices:Get(\"DoubleCash\"))\n```\n\nA React hook is a `useState` over `Subscribe`.\n\n## Moving the games over\n\n- **Slime Nebula:**\n  - `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.\n  - `PlayerPolicyService` becomes a `Commerce.Policy.new()`. `PAID_RANDOM_ATTRIBUTE` is the default attribute; `_getPolicyInfo` and `_retryDelay` are the `getPolicyInfo` and `retryDelay` options.\n  - `GamePassManager` and `DeveloperProductManager` become `Passes` and `Products`, with SN's grant functions as `applied` and `grant`.\n- **Magic Neighborhood and Steal an Egg:**\n  - `ProductService` becomes a `Products` built from these pieces:\n    - `blocked` keeps the game's own checks;\n    - `grant` is the game's grant switch;\n    - telemetry callbacks go to TelemetryService;\n    - `account` is `SaveNowAccount(profile, saveNow)` with `ledger = { legacyIdsField = \"receipts\" }`, so old receipt ids still dedupe.\n  - The behaviour change: a grant that throws is no longer retried. It is recorded, so a partial grant can't land twice.\n  - `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.\n  - `usePassPrices` becomes a hook over `Commerce.Prices`.\n  - The purchase request remote moves to roblox-net with a `limit`, replacing `PROMPT_COOLDOWN`.\n\n## Developing\n\n```bash\nlune run tests/run    # specs on hurreman/luau-spec (DevPackages, else ../luau-spec); fakes in tests/fixtures\n```\n\nThe Lune specs cover:\n- the ledger, including Slime Nebula's ProcessReceipt cases;\n- prompts and their funnel, receipts, retries and refusals;\n- pass grants, ownership loading and purchases;\n- policy fail-closed and retries;\n- price fetching.\n\nChecked in Studio:\n- `Prices` fetched real prices: Magic Neighborhood's ×2 Cash pass at 160 and its Candy Bag product at 20.\n- The `UserOwnsGamePassAsync` call works.\n- `ProcessReceipt` can only be registered on a server.\n\n`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.\n\nA real Robux purchase has not been run.\n","readmeTruncated":false}