# Red 8 Asset Market Service

This service runs the **Red 8 Asset Market** (`game-studio/market/`). Artists sell 3D models there, and game makers buy them by card, download them, and use them in the Red 8 Studio Game Creator (**Add → Market**).

- **Where the money goes:** every charge lands in **Red 8 Studio's own Stripe account**.
- **The split:** each sale owes the creator **85%**, and Red 8 Studio keeps **15%** (`COMMISSION_PERCENT`). Stripe's processing fee comes out of Red 8's 15%.
- **Creator payouts:** Red 8 Studio pays creators **by hand, once a month**, and records each payout here.
- **No refunds:** all sales are final.
- **Chargebacks:** Red 8 Studio bears them, never the creator.

Like the Table Service, it is a Nodofi relay worker. It also has one small HTTP endpoint so it can receive Stripe's webhooks.

```
Seller's browser ── checks the model with the Game Creator's importer, makes the .glb and the turntable images
      └─ upload.begin / upload.chunk (512 KB pieces over the relay) / listing.create ─▶ Market Service ─▶ Beezifi Storage (private bucket)
Admin ── admin.queue / admin.review ─▶ the listing goes live
Buyer ── ticks "all sales are final" ── market.buy ─▶ Market Service ── PaymentIntent ─▶ Stripe (Red 8's account)
      ◀─ client secret ── Stripe.js card form on the market page ── card details ─▶ Stripe directly
Stripe ── payment_intent.succeeded (signed webhook) ─▶ POST /stripe/webhook ─▶ the order is paid, 85/15 in the ledger
Buyer ── market.download ─▶ a 5-minute signed link ─▶ the file, straight from Beezifi Storage (checked by sha256)
Game Creator ── Add → Market ── market.purchases + market.download (the .glb) ─▶ imported into the game
Admin ── admin.payouts ── pays creators by bank transfer, PayPal or Wise ── admin.markPaid ─▶ ledger
```

## Decisions this implements

| | |
|---|---|
| **Payments** | Stripe, with card entry on the market page (Payment Element). Card data goes from the browser to Stripe and never reaches this service, which keeps PCI scope at **SAQ A**. The secret key stays in this process. |
| **Currency** | USD only. Prices are $0.50 (Stripe's minimum) to $10,000, or 0 for a free model. |
| **Split** | Red 8's commission = round(price × 15%), and the creator is owed price − commission, so the two always add up exactly. **Stripe's fee comes out of Red 8's commission.** $10.00 → creator $8.50, Red 8 $1.50 − $0.59 fee = $0.91. |
| **Creator payouts** | Outside Stripe (bank transfer, PayPal, Wise or other), recorded by an admin with a reference. **Monthly:** on or after the 1st (UTC), a creator is due what they're owed from sales at least `PAYOUT_HOLD_DAYS` (14) days old before the 1st, once it reaches `PAYOUT_MIN` ($100). Smaller amounts roll over. |
| **Refunds** | None: all sales are final. Before paying, the buyer must tick "I understand this is a digital download and all sales are final." The order records that acceptance with its timestamp, the wording and the terms version. It is also in the PaymentIntent metadata, on the receipt, and on the Terms page. |
| **Chargebacks** | Red 8 Studio's. A dispute never touches a creator's earnings. Each dispute's amount, fee and outcome go into the ledger as Red 8 entries. |
| **During a dispute** | The order's downloads are **blocked** as soon as `charge.dispute.created` arrives. No new links are issued, and links already issued stop working, because the listing's files are moved to new keys (other buyers simply get links to the new location). The buyer sees "Downloads for this order are paused while a payment dispute is open", in the market and in the Game Creator. A **won** dispute restores access, and a **lost** one blocks it for good, including buying it again. |
| **Storage** | One **private** Beezifi Storage bucket. The service refuses a public bucket. |
| **Moderation** | Every new or changed listing waits for an admin's approval. |
| **License** | Standard (royalty-free) only (`LICENSES` in `src/market.js`). |
| **Preview** | A turntable of 24 rendered images and a thumbnail. Buyers never see the model itself before they buy. |

## Orders and payments

1. **`market.buy`** (the buyer has ticked the terms) creates a PaymentIntent on Red 8's account: amount, `usd`, automatic payment methods, metadata `{ order, listing, buyer, terms_version, terms_accepted_at }`, a description ending "All sales are final", and an optional `receipt_email`. It uses an idempotency key per order. The browser gets only the client secret. A second click on the same unpaid order reuses the same intent.
2. **Paying:** the market page mounts Stripe's Payment Element and calls `stripe.confirmPayment` with `redirect: 'if_required'`. A card that needs 3-D Secure returns to the page's `?r8order=` receipt, which waits for confirmation.
3. **Confirmation:** only a **`payment_intent.succeeded` webhook with a valid Stripe signature** completes the order. The signature is checked with `stripe.webhooks.constructEvent` against `STRIPE_WEBHOOK_SECRET`, using the raw body. The amount received, the currency and the intent must match the order exactly, or it is logged as `payment-mismatch` and nothing unlocks. Events are processed once each; replays are ignored. Stripe's fee and the card's brand and last 4 digits are read from the charge's balance transaction. A fee not known yet is filled in later (a `fee` ledger entry).
4. **Declined cards** (`payment_intent.payment_failed`) leave the order open so the buyer can try again. `payment_intent.canceled` cancels it.

## Webhooks: what to set up

- **Endpoint:** `POST /stripe/webhook` on `WEBHOOK_HOST:WEBHOOK_PORT` (default 127.0.0.1:8790). Stripe needs a public **https://** URL. Put the endpoint behind your web server (for example nginx, or Caddy proxying `https://market.example.com/stripe/webhook` to `127.0.0.1:8790`), or behind a tunnel such as Cloudflare Tunnel. `GET /health` answers `{ ok: true }`.
- **Events:** `payment_intent.succeeded`, `payment_intent.payment_failed`, `payment_intent.canceled`, `charge.dispute.created`, `charge.dispute.updated`, `charge.dispute.closed`.
- **While developing:** use `stripe listen --forward-to localhost:8790/stripe/webhook` (the Stripe CLI) and put the `whsec_…` it prints in `STRIPE_WEBHOOK_SECRET`. `stripe trigger payment_intent.succeeded` won't match an order (its amount is different), so use the market page with test card 4242 4242 4242 4242 instead. For disputes, use the test card 4000 0000 0000 0259, which Stripe disputes automatically.

## Admin pages (market app → Admin)

- **Listings to review:** approve or reject (with a note), download the package to check it, and handle reports.
- **Creator payouts:** totals (sales, card payments, commission, Stripe fees, Red 8's net after fees and disputes, owed to creators). For each creator: earned, paid, owed, this cycle's amount (marked **Due** at $100 or more), how they want to be paid, and a **Mark paid** form (amount, method, reference, at most what is owed). Then the payout history.
- **Disputes:** each dispute's status, the **deadline to respond**, Red 8's cost so far, and the evidence. The evidence is the order (card, Stripe ids), the buyer's timestamped all-sales-final acceptance, the receipt's policy, the download log (how many times, first and every download), and the buyer's access history. A link opens it in the Stripe Dashboard, where you respond.

Creators have an **Earnings** page: earned, paid, owed, this cycle's payable amount, the next cycle date, their payout method and details (seen only by them and admins), their listings, recent sales and payouts.

## Ledger (`ledger.jsonl`)

| Entry | Contents |
|---|---|
| `listing`, `review` | Listing changes and admin decisions |
| `order` | The order, the PaymentIntent, and `termsAcceptedAt` / `termsVersion` |
| `sale` | price, commissionPercent, creatorShare, commission, stripeFee, red8Net, paymentIntent, charge, termsAcceptedAt |
| `fee` | A Stripe fee learned after the sale |
| `payment-mismatch` | A signed payment that did not match its order |
| `claim` | A free model claimed |
| `dispute` | Opened, updated or closed: the amount, Stripe's dispute fee, red8Net (from the dispute's balance transactions), due date and outcome |
| `access` | blocked, restored, or blocked for good, with the reason |
| `files-moved` | Files moved to new keys so outstanding links stop working |
| `download` | Each link issued: who, which file, as buyer, seller or admin |
| `payout` | A creator paid by hand: amount, method, reference, admin, owed before and after |

`npm run sales` totals it: sales, creators' 85%, Red 8's 15%, Stripe fees, Red 8's net, paid and still owed to creators, and disputes. It also checks every sale's commission + creator share = price.

## Accounts and download protection

- **Accounts** have no passwords. The browser makes a random 64-hex **account key** and sends it only to this service, which stores `sha256(key)`. Users copy the key between devices and into the Game Creator.
- **Download links:** the bucket is private. A file can be fetched only through a 5-minute HMAC-signed link for that one file. The service issues links only to the buyer of a paid, unblocked order (or the claimer of a free model), the seller, and admins. Changing a link's file or expiry breaks it, and a dispute makes the outstanding links dead.
- **Limits:** someone with a link can share it until it expires, and a buyer can share the files they downloaded. The license forbids it, but nothing technical stops it. A model a buyer already imported into a Game Creator project stays in that project during a dispute; the Market tab just won't add it again.

## Set up

1. Node.js 18 or newer, then `npm install` here.
2. `cp .env.example .env`.
3. `npm run create-key`. This makes the market's **signing key**, which signs every reply; the apps check replies against it. It holds no money.
4. **Stripe:** put Red 8 Studio's **test** keys in `STRIPE_SECRET_KEY` and `STRIPE_PUBLISHABLE_KEY`. Add the webhook endpoint (above) and its `STRIPE_WEBHOOK_SECRET`. Turn on the payment methods you want in the Dashboard (cards are on by default).
5. **Beezifi Storage:** `npm run setup-storage -- <access key> <secret key>` checks the key, creates a private bucket `red8-asset-market`, and writes `STORAGE_*` to `.env`.
6. **An admin:** `npm run admin-key` prints an account key and adds it to `ADMIN_ACCOUNTS`. Paste the key into the market app (**Account → Use an existing account key**) to get the **Admin** page.
7. `npm start`. It prints the **service address** and **signing key**. Open the market app or the Game Creator once with `?market=<address>&signer=<signing key>`, or enter both in ⚙ Market settings. On Nodofi, also pass `&studio=<Game Creator URL>` to the market app and `&marketapp=<market URL>` to the Game Creator.
8. If the relay charges for access (relay.nodofi.com does), pay on its page, the same as for the Table Service.
9. **Going live:** switch to live keys and a live webhook endpoint (with its own secret). Make one small real purchase and check it end to end. Card details never touch this service, but your Stripe account still needs its SAQ A questionnaire.

It refuses to start without a registry (`REGISTRY_URL`). Without one, anyone could JOIN its address on the relay and read buyers' account keys. With PM2, use `pm2 start ecosystem.config.js` with one instance only; the service refuses to start if its address already answers.

## Try it on one computer

These configs are in `Games/.claude/launch.json`:
- `nodofi-registry-local` (5398)
- `nodofi-relay-registry-local` (5397)
- `market-fakes`: a fake Stripe on 5403 with test controls on 5404, and a fake Beezifi Storage on 5402
- `game-studio-market` (8768)

Start all four, then run `npm run local`. That starts the service against the fakes (webhooks on 8790) and prints the URLs to open.

- **Checkout:** it uses the fake Stripe's stand-in Stripe.js. Card 4242 4242 4242 4242 pays, and 4000 0000 0000 0002 is declined. The fake Stripe signs its webhooks exactly as Stripe does.
- **Disputes:** open one with `http://127.0.0.1:5404/dispute?pi=<PaymentIntent id>`. Close it with `/dispute/close?id=<dispute id>&status=won` or `lost`.

## Tests

`npm test` runs offline, against `test/fakes.js`. Webhooks go through the service's real webhook endpoint and signature check. The tests cover:

- **Split:** the split for every price, and payout cycles.
- **Uploads:** checks on the uploaded files (owner, size, sha256), and the $0.50 minimum price.
- **Review gate:** nothing is visible or for sale until approved.
- **Checkout:** the terms acceptance is required, and it is recorded on the order and the PaymentIntent.
- **Webhooks and payment:**
  - Forged, unsigned, declined, replayed and mismatched webhooks unlock nothing.
  - A signed success records 8.50 / 1.50 with the $0.59 fee taken out of Red 8's share.
  - The receipt shows the card and the acceptance.
- **Download protection:** non-buyers, the private bucket, edited and expired links, free claims, and the seller.
- **Disputes:**
  - A disputed order can't download, and its outstanding links stop working, while other buyers still can.
  - A won dispute restores access, and a lost one stays blocked and can't be bought again.
  - Red 8's −$15 / −$25 is recorded, and the creator's earnings are untouched.
  - The admin evidence includes the acceptance, receipt and download log.
- **Payouts:** nothing is payable for the current month, nothing is due under $100, and sales less than 14 days old wait. Only admins can record a payout, never more than is owed, and always with a reference. Balances and history.
- **Restarts and backup:** a restart keeps everything, and the bucket backup has no client secrets.
- **Over a real local Nodofi relay and registry** (spawned from `BeezifiStudio/`, or set `BEEZIFI_STUDIO`): an impostor JOIN is refused, replies are signed, no secret reaches the apps, and an upload, review, card payment confirmed by webhook, split and buyer-only download all work.

Not yet run against the real Stripe test mode or the real storage.beezifi.com. Do that with your test keys (step 4) before going live.

## Files

| File | What it does |
|---|---|
| `src/service.js` | Relay connection, requests, signed replies, the Stripe client, the webhook endpoint, timers |
| `src/market.js` | Accounts, uploads, listings and review, orders, webhook handling, the split, disputes and download blocking, earnings, payouts, downloads, the ledger, backups |
| `src/storage.js` | Beezifi Storage: uploads, signed download links, moves, soft deletes, the private-bucket check |
| `src/signing.js` | The signing key (secp256k1, the same scheme as the Table and Reward Services) |
| `src/identity.js`, `src/relay-access.js` | The same as the Table Service's |
| `test/run.js`, `test/fakes.js`, `test/local-market.js` | `npm test`, the fake Stripe and storage (`npm run fakes`), and `npm run local` |
| `../js/market-client.js` | The browser side, shared by the market app and the Game Creator |
| `../market/` | The market app (`market/nodo/make-index.py` builds its Nodo bundle) |
