Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,9 @@ jobs:
- name: Cloud license boundary
run: pnpm check:cloud-boundary

- name: Plugin license policy (0196)
run: pnpm check:plugin-licenses

# The heavy half of the old lint job: build the workspace and typecheck it.
# typecheck genuinely needs `^build` (turbo builds dependencies first), so it
# stays coupled to the build. Runs in parallel with `lint`, `test`, and
Expand Down

Large diffs are not rendered by default.

101 changes: 101 additions & 0 deletions docs/guides/sell-a-plugin.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
# Sell a plugin

xNet lets a plugin author **charge for their work** — a one-time fee or a monthly
subscription — through an App-Store-like marketplace, without a 30% tax and
without giving up the bring-your-own-Stripe ethos
([exploration 0196](../explorations/0196_[_]_PAID_PLUGIN_MARKETPLACE_MONETIZATION_AND_LICENSING.md)).

This guide covers the three decisions you make as a seller: **how you get paid**,
**which license you ship under**, and **how the license is enforced**.

## 1. Price your plugin

Add a `pricing` block (and a `license`) to your manifest. The scaffolder
(`xnet plugin scaffold`) emits these for you:

```ts
import { defineFeatureModule } from '@xnetjs/plugins'

export const ProModule = defineFeatureModule({
id: 'com.acme.pro',
name: 'Acme Pro',
version: '1.0.0',
publisherDid: 'did:key:zYourPublisherDid',
license: 'FSL-1.1-MIT', // source-available; auto-opens to MIT after 2 years
pricing: {
mode: 'subscription', // 'free' | 'one-time' | 'subscription'
amountMinor: 500, // $5.00 — integer minor units
currency: 'USD',
billing: 'managed' // 'managed' = xNet Connect (we take the fee); 'byo' = your own
},
contributes: {
/* … */
}
})
```

`free` plugins need none of this. Paid plugins are **gated at install** — xNet
will not activate paid code without a valid license (see §3).

## 2. How you get paid

### Managed (recommended): your own Stripe + a small marketplace fee

Connect your **own** Stripe account once via Stripe Connect **Standard** (one
click — Stripe keeps your dashboard, your payouts, your KYC, your dispute
handling). xNet attaches a small **application fee** (default **10%**, far below
the App Store's 30%) to each charge and routes the rest to you:

```mermaid
flowchart LR
Buyer -->|pays| Stripe
Stripe -->|"90%"| You["Your Stripe account"]
Stripe -->|"10% application fee"| xNet
Stripe -->|webhook| Hub --> License["mint PluginLicense (Ed25519, DID-bound)"]
```

This is genuinely "use your own Stripe" — you are the merchant of record for your
sales — plus an automatically-captured marketplace fee. (There is **no** way for
a platform to skim a fee from a _standalone_ Stripe account it doesn't control;
Connect is the supported version of that.)

### BYO (fully sovereign): your own checkout, 0% fee

Set `pricing.billing: 'byo'` and xNet takes **nothing**. You run your own
checkout and mint your own license tokens (publishing your public key in the
listing's provenance); xNet only _verifies_ the resulting license at install.

## 3. Licensing + enforcement

Paid plugins must declare a **marketplace-approved** license — enforced in CI by
`pnpm check:plugin-licenses`:

| License | Source-available | Converts to open |
| -------------------------------------- | ---------------- | ---------------------------- |
| `FSL-1.1-MIT` _(default)_ | ✅ | ✅ MIT, after 2 years |
| `FSL-1.1-Apache-2.0` | ✅ | ✅ Apache-2.0, after 2 years |
| `MIT` / `Apache-2.0` / `AGPL-3.0-only` | ✅ | already open |

**FSL** (the Functional Source License, same one [`@xnetjs/cloud`](../../packages/cloud/LICENSE)
uses) keeps your source published, forbids only a _competing_ marketplace, and
auto-converts each version to MIT/Apache exactly two years after it ships. The
scaffolder writes the matching `LICENSE` file automatically.

Enforcement is a signed **`PluginLicense`** token, not the copyright license. On
purchase the hub mints an **Ed25519-signed, DID-bound** token
([`@xnetjs/licenses`](../../packages/licenses)); the plugin runtime verifies it
**offline** at install/activate. Because it is bound to the buyer's **DID** (not
a device), one purchase works across all their devices and can be revoked
hub-side. The token is the anti-piracy moat; the license governs redistribution
and the eventual open-sourcing.

## 4. Publish

1. `xnet plugin scaffold` → set `license` + `pricing` (above).
2. Connect Stripe (managed) from the marketplace's "Become a publisher" flow, or
wire your own checkout (BYO).
3. Submit your manifest URL to the marketplace registry. CI validates the license
policy; the listing shows a price + license badge.

See the exploration for the full architecture, the Connect vs. merchant-of-record
tradeoffs, and the lifecycle (updates, refunds/revocation, the 2-year auto-open).
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
"dev:stories": "storybook dev --ci --no-open --host 127.0.0.1 --port 6006",
"test": "pnpm --filter xnet-desktop run deps:node && vitest run",
"check:cloud-boundary": "bash scripts/check-cloud-boundary.sh",
"check:plugin-licenses": "node scripts/check-plugin-licenses.mjs",
"test:editor": "pnpm --filter @xnetjs/editor test",
"test:stories": "storybook test --url http://127.0.0.1:6006",
"test:watch": "vitest",
Expand Down
44 changes: 44 additions & 0 deletions packages/billing/src/connect.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { describe, it, expect } from 'vitest'
import {
DEFAULT_MARKETPLACE_FEE_BPS,
applicationFeeMinor,
feeBpsToPercent,
sellerNetMinor
} from './connect'

describe('applicationFeeMinor', () => {
it('defaults to 10% of the charge', () => {
expect(DEFAULT_MARKETPLACE_FEE_BPS).toBe(1000)
expect(applicationFeeMinor(1000, DEFAULT_MARKETPLACE_FEE_BPS)).toBe(100) // $10.00 → $1.00
})

it('rounds to the nearest minor unit', () => {
expect(applicationFeeMinor(999, 1000)).toBe(100) // 99.9 → 100
expect(applicationFeeMinor(994, 1000)).toBe(99) // 99.4 → 99
})

it('handles zero fee and zero amount', () => {
expect(applicationFeeMinor(5000, 0)).toBe(0)
expect(applicationFeeMinor(0, 1500)).toBe(0)
})

it('rejects invalid inputs', () => {
expect(() => applicationFeeMinor(9.99, 1000)).toThrow(/non-negative integer/)
expect(() => applicationFeeMinor(-1, 1000)).toThrow(/non-negative integer/)
expect(() => applicationFeeMinor(1000, 10001)).toThrow(/0\.\.10000/)
expect(() => applicationFeeMinor(1000, -5)).toThrow(/0\.\.10000/)
})
})

describe('feeBpsToPercent / sellerNetMinor', () => {
it('converts bps to percent', () => {
expect(feeBpsToPercent(1000)).toBe(10)
expect(feeBpsToPercent(1500)).toBe(15)
expect(feeBpsToPercent(250)).toBe(2.5)
})

it('computes the seller net after the fee', () => {
expect(sellerNetMinor(1000, 1000)).toBe(900) // $10 − $1 = $9
expect(sellerNetMinor(2000, 1500)).toBe(1700) // $20 − $3 = $17
})
})
41 changes: 41 additions & 0 deletions packages/billing/src/connect.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
/**
* @xnetjs/billing — marketplace fee math (exploration 0196).
*
* The platform marketplace fee is expressed in basis points (bps): 1000 bps =
* 10%. `applicationFeeMinor` turns a price + fee rate into the integer minor-unit
* `application_fee_amount` a one-time Connect charge carries. Subscriptions use
* `application_fee_percent` directly (see `feeBpsToPercent`).
*/

/** Default marketplace fee: 10% (1000 bps). Below Apple/Steam (30%); the user's stated band. */
export const DEFAULT_MARKETPLACE_FEE_BPS = 1000

/** Compute the platform fee in minor units for a one-time charge. Rounds to the nearest unit. */
export function applicationFeeMinor(amountMinor: number, feeBps: number): number {
assertMinor(amountMinor)
assertBps(feeBps)
return Math.round((amountMinor * feeBps) / 10000)
}

/** Convert basis points to the percent value Stripe's `application_fee_percent` wants. */
export function feeBpsToPercent(feeBps: number): number {
assertBps(feeBps)
return feeBps / 100
}

/** The seller's net (minor units) after the platform fee, for previews/receipts. */
export function sellerNetMinor(amountMinor: number, feeBps: number): number {
return amountMinor - applicationFeeMinor(amountMinor, feeBps)
}

function assertMinor(amountMinor: number): void {
if (!Number.isInteger(amountMinor) || amountMinor < 0) {
throw new Error('amountMinor must be a non-negative integer (minor units)')
}
}

function assertBps(feeBps: number): void {
if (!Number.isInteger(feeBps) || feeBps < 0 || feeBps > 10000) {
throw new Error('feeBps must be an integer in 0..10000')
}
}
15 changes: 14 additions & 1 deletion packages/billing/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,20 @@ export type {
} from './types'

export { BillingSignatureError } from './provider'
export type { PaymentProvider, CheckoutRequest, CheckoutSession, PortalRequest } from './provider'
export type {
PaymentProvider,
CheckoutRequest,
CheckoutSession,
PortalRequest,
ConnectCharge
} from './provider'

export {
DEFAULT_MARKETPLACE_FEE_BPS,
applicationFeeMinor,
feeBpsToPercent,
sellerNetMinor
} from './connect'

export { MemoryBillingStore, isActiveSubscription, pickCurrentSubscription } from './store'
export type { BillingStore } from './store'
Expand Down
20 changes: 20 additions & 0 deletions packages/billing/src/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,21 @@ export class BillingSignatureError extends Error {
}
}

/**
* Stripe Connect routing for a marketplace charge (exploration 0196). When
* present, funds settle to the connected (seller) account and the platform keeps
* an application fee — this is "bring your own Stripe" (Connect Standard) plus a
* captured marketplace fee. Server-set only; never trusted from a client body.
*/
export interface ConnectCharge {
/** Connected account id (`acct_…`) that receives the funds. */
destination: string
/** Platform fee as a percent of each invoice (subscriptions). */
feePercent?: number
/** Platform fee in integer minor units (one-time payments). */
feeMinor?: number
}

export interface CheckoutRequest {
/** The DID to bind the checkout to. Server-set — NEVER trusted from a client body. */
did: DID
Expand All @@ -26,6 +41,11 @@ export interface CheckoutRequest {
successUrl: string
cancelUrl: string
customerEmail?: string
/**
* Marketplace routing (Stripe only). Set by the hub from a paid listing's
* seller account + fee; routes funds to the seller and keeps the platform fee.
*/
connect?: ConnectCharge
}

export interface CheckoutSession {
Expand Down
46 changes: 46 additions & 0 deletions packages/billing/src/providers/stripe.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,52 @@ describe('createStripeProvider.createCheckout', () => {
expect(body).toContain('line_items%5B0%5D%5Bprice%5D=price_pro')
})

it('routes a subscription to a connected account with an application fee percent', async () => {
const fetchImpl = vi.fn(
async (_url: string | URL | Request, _init?: RequestInit) =>
new Response(JSON.stringify({ id: 'cs_2', url: 'https://checkout/y' }), { status: 200 })
)
const provider = createStripeProvider({
secretKey: 'sk_test',
webhookSecret: 'whsec',
fetchImpl: fetchImpl as unknown as typeof fetch
})
await provider.createCheckout({
did: 'did:key:alice',
priceRef: 'price_pro',
mode: 'subscription',
successUrl: 'https://app/ok',
cancelUrl: 'https://app/cancel',
connect: { destination: 'acct_seller', feePercent: 10 }
})
const body = String((fetchImpl.mock.calls[0][1] as RequestInit).body)
expect(body).toContain('subscription_data%5Btransfer_data%5D%5Bdestination%5D=acct_seller')
expect(body).toContain('subscription_data%5Bapplication_fee_percent%5D=10')
})

it('routes a one-time payment to a connected account with an application fee amount', async () => {
const fetchImpl = vi.fn(
async (_url: string | URL | Request, _init?: RequestInit) =>
new Response(JSON.stringify({ id: 'cs_3', url: 'https://checkout/z' }), { status: 200 })
)
const provider = createStripeProvider({
secretKey: 'sk_test',
webhookSecret: 'whsec',
fetchImpl: fetchImpl as unknown as typeof fetch
})
await provider.createCheckout({
did: 'did:key:alice',
priceRef: 'price_once',
mode: 'payment',
successUrl: 'https://app/ok',
cancelUrl: 'https://app/cancel',
connect: { destination: 'acct_seller', feeMinor: 100 }
})
const body = String((fetchImpl.mock.calls[0][1] as RequestInit).body)
expect(body).toContain('payment_intent_data%5Btransfer_data%5D%5Bdestination%5D=acct_seller')
expect(body).toContain('payment_intent_data%5Bapplication_fee_amount%5D=100')
})

it('throws when Stripe returns an error status', async () => {
const fetchImpl = vi.fn(
async (_url: string | URL | Request, _init?: RequestInit) =>
Expand Down
34 changes: 33 additions & 1 deletion packages/billing/src/providers/stripe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,13 @@
* dependency-free. The secret key is used only here, server-side.
*/

import type { CheckoutRequest, CheckoutSession, PaymentProvider, PortalRequest } from '../provider'
import type {
CheckoutRequest,
CheckoutSession,
ConnectCharge,
PaymentProvider,
PortalRequest
} from '../provider'
import type {
BillingMutation,
InvoiceStatus,
Expand Down Expand Up @@ -184,6 +190,28 @@ export function normalizeStripeEvent(event: ProviderEvent, now: number): Billing
return STRIPE_NORMALIZERS[event.type]?.(asObj(event.data), event.data, now) ?? []
}

/**
* Add Connect destination + application-fee fields to a checkout form. Uses
* destination charges (`transfer_data[destination]`): the charge is made on the
* platform account and the seller's cut is transferred to their connected
* account, with the platform keeping the application fee.
*/
function applyConnect(
form: URLSearchParams,
mode: CheckoutRequest['mode'],
connect: ConnectCharge
): void {
const root = mode === 'subscription' ? 'subscription_data' : 'payment_intent_data'
form.set(`${root}[transfer_data][destination]`, connect.destination)
if (mode === 'subscription') {
if (connect.feePercent !== undefined) {
form.set('subscription_data[application_fee_percent]', String(connect.feePercent))
}
} else if (connect.feeMinor !== undefined) {
form.set('payment_intent_data[application_fee_amount]', String(connect.feeMinor))
}
}

export function createStripeProvider(config: StripeProviderConfig): PaymentProvider {
const apiBase = config.apiBase ?? 'https://api.stripe.com'
const doFetch = config.fetchImpl ?? fetch
Expand Down Expand Up @@ -220,6 +248,10 @@ export function createStripeProvider(config: StripeProviderConfig): PaymentProvi
else form.set('payment_intent_data[metadata][did]', req.did)
if (req.customerEmail) form.set('customer_email', req.customerEmail)

// Marketplace (Connect) routing: settle to the seller's connected account
// and keep the platform application fee (exploration 0196).
if (req.connect) applyConnect(form, req.mode, req.connect)

const json = await post('/v1/checkout/sessions', form)
const url = str(json.url)
const id = str(json.id)
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/commands/plugin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ describe('writeScaffoldFiles', () => {
const { io } = fakeIO()
const paths = writeScaffoldFiles(files, '/tmp/x', io).sort()
expect(paths).toEqual([
'LICENSE',
'README.md',
'package.json',
'src/index.test.ts',
Expand Down
Loading
Loading