# Taking payments

Selling the owner's products from a generated app: the pay module, the pay provider actions, and who owns what.

## How it works

The owner sets products on Ripping (Settings → Payments: name, price, one-time or monthly/yearly) and picks a processor: Ripping Payments (a Stripe Connect account with Ripping's fee), their own Stripe, or their own PayPal. The app names products by id and never sends an amount; every price comes from the owner's catalogue, so nothing a buyer's browser sends can change what they pay.

Buying needs no account. Knowing what someone owns does.

## The client module

```ts
type Product = { id: string; name: string; description: string; amountCents: number; currency: string; kind: "one_time" | "subscription"; interval: "month" | "year"; active: boolean };
type Paid = { paid: boolean; status: string; amountCents: number; currency: string; description: string | null };
type Purchase = { id: string; productIds: string[]; description: string | null; amountCents: number; refundedCents: number; currency: string; status: string; processor: "ripping" | "stripe" | "paypal"; createdAt: string };
type Subscription = { active: boolean; status: string; priceId: string | null; subscriptionId: string; currentPeriodEnd: string | null; cancelAtPeriodEnd: boolean };
type Purchases = { purchases: Purchase[]; owned: string[]; subscription: Subscription | null };

const money: (cents: number, currency?: string) => string;   // "$19.99", "$20"
const priceLabel: (p: Product) => string;                    // "$9 / month"

function useProducts(): { products: Product[]; ready: boolean; error: string | null };
function checkout(items: { product: string; quantity?: number }[], opts?: { returnTo?: string; cancelTo?: string; email?: string }): Promise<never>;
function confirmPayment(): Promise<Paid | null>;
function myPurchases(): Promise<Purchases>;
function owns(productId: string): boolean;
function usePurchases(): Purchases & { ready: boolean; error: string | null; refresh: () => Promise<void> };
function manageSubscription(returnTo = "/purchases"): Promise<never>;
```

### Selling

```ts
const { products } = useProducts();
await checkout([{ product: p.id, quantity: 1 }]);   // leaves the page
```

`checkout` sends `success_url` as `<page>#<returnTo>` (default `#/thanks`) and `cancel_url` as `<page>#<cancelTo>` (default `#/`). It never resolves: the browser is leaving for the processor.

On the return route call `confirmPayment()`. It reads the processor's reference from the query string (`session_id` for Stripe, `token` for PayPal), asks Ripping to confirm, then removes the reference from the address. It returns `null` when there is no reference in the address.

```ts
const r = await confirmPayment();   // { paid: true, status: "paid", amountCents: 4900, currency: "usd", description: "Beginners' course" }
```

Every function throws an `Error` with the server's message, or `Payment call failed (<status>)`.

### Who owns what

```ts
const { owned, purchases, subscription, ready } = usePurchases();
if (owned.includes(courseId)) …                       // unlock the thing they paid for
owns(courseId)                                        // the same check outside a component, from the last answer
```

`usePurchases` is empty for a signed-out visitor. `myPurchases()` fetches fresh each call (one request in flight at a time); `owns` reads the cached answer, so call `myPurchases()` or use the hook first.

A one-time product is owned once paid and not fully refunded. A subscription product is owned while the person's subscription is active (`active` or `trialing`).

Every app with products has a Purchases page at `/purchases`, in the account menu, built on the same call. `manageSubscription()` opens Stripe's billing portal (change card, cancel) on whichever Stripe the owner uses — Ripping Payments or their own — and returns to `#/purchases`. On PayPal it throws `PayPal here takes one-time payments, so there is no subscription to manage.`

## The pay provider

All calls are `POST /api/apps/<projectId>/call` with `{ "provider": "pay", "action": … }`. The pay provider is rate limited per IP (60 a minute) and per app (300 a minute): `429 Too many requests. Slow down and try again in a minute.` It does not count against the app's monthly connected-service allowance. If the owner paused connected services: `403 This app's access to connected services is paused by its owner.`

Any failure below is `400 { "error": "<message>" }` unless noted. An unknown action: `400 unknown pay action; use products, checkout, confirm or purchases`.

### products

No sign-in needed.

```json
{ "provider": "pay", "action": "products" }
```

```json
{ "products": [ { "id": "…", "name": "Beginners' course", "description": "", "amountCents": 4900, "currency": "usd", "kind": "one_time", "interval": "month", "active": true } ] }
```

Only active products, in the owner's order.

### checkout

No sign-in needed. When a token is sent, the buyer's email and account id are attached to the payment so it is theirs the moment they come back.

```json
{
  "provider": "pay", "action": "checkout",
  "items": [ { "product": "<productId>", "quantity": 1 } ],
  "success_url": "https://myapp.example/#/thanks",
  "cancel_url": "https://myapp.example/#/",
  "customer_email": "ada@example.com"
}
```

```json
{ "url": "https://checkout.stripe.com/…", "processor": "ripping", "id": "cs_…", "amountCents": 4900, "currency": "usd" }
```

Rules:

- Up to 20 items; quantity is clamped to 1–99.
- `success_url` and `cancel_url` must be absolute URLs: `success_url and cancel_url must be absolute URLs`.
- An empty list: `Nothing to pay for: send items [{ product, quantity }].`
- An id not in the catalogue: `Unknown product "<id>". Products are set on the app's Settings → Payments.`
- Mixed currencies: `Every product in one checkout must use the same currency.`
- A subscription is bought alone, quantity 1: `A subscription is bought on its own, one at a time.`
- PayPal takes one-time payments only: `PayPal here takes one-time payments; sell subscriptions with Stripe.`
- Nothing connected: `Stripe isn't connected for this app's owner (Connections).`, `PayPal isn't connected for this app's owner (Connections).`, or `<Site> Payments isn't set up for this app's owner (Payments tab), and no other processor is chosen.`

Stripe appends `?session_id={CHECKOUT_SESSION_ID}` to the success URL (before the hash); PayPal appends `?token=<order id>`. The pending payment is recorded before the buyer leaves.

### confirm

No sign-in needed.

```json
{ "provider": "pay", "action": "confirm", "session_id": "cs_…" }
```

or `{ "provider": "pay", "action": "confirm", "paypal_order": "<order id>" }`.

```json
{ "paid": true, "status": "paid", "amountCents": 4900, "currency": "usd", "description": "Beginners' course" }
```

Reads the session from the processor (captures the PayPal order), records the payment, and links it to an account. Failures: `session_id or paypal_order required`, `That payment belongs to another app.`, `Payments aren't set up for this app's owner.`

Linking: the account that started the checkout wins; otherwise an existing account with the buyer's email; otherwise, in an app with open sign-up and room for another user, an account is created for that email so the purchase is already theirs when they first sign in. Invite-only and approval apps leave the payment waiting under the email; it attaches when that address gets an account.

### purchases

Sign-in required: `401 Sign in to this app first.`

```json
{ "provider": "pay", "action": "purchases" }
```

```json
{
  "purchases": [ { "id": "…", "productIds": ["…"], "description": "Beginners' course", "amountCents": 4900, "refundedCents": 0, "currency": "usd", "status": "paid", "processor": "ripping", "createdAt": "…" } ],
  "owned": ["…"],
  "subscription": { "active": true, "status": "active", "priceId": "price_…", "subscriptionId": "sub_…", "currentPeriodEnd": "…", "cancelAtPeriodEnd": false }
}
```

Up to 200 non-pending payments, newest first. Payments made under the person's email before they had an account are claimed here.

## Refunds

Refunds are issued by the owner on Ripping, not from the app. They are full refunds only, through whichever processor took the money; the payment's status becomes `refunded` and `refundedCents` equals `amountCents`, so the product is no longer owned.

## Fees

With Ripping Payments, Ripping's fee is a percentage of the total (2% unless configured otherwise, capped at 20%), taken as a Stripe application fee. The owner's own Stripe or PayPal carries no Ripping fee.

## Stripe prices directly

The `payments` provider is an older path for apps whose owner uses Ripping Payments and manages products in Stripe itself: `checkout` with `line_items: [{ price: "price_…", quantity }]`, `subscription` for the signed-in person's status, and `portal`. It needs a signed-in user and counts against the monthly call allowance. The SaaS and course scaffolds use it. See [Connected services](services.md).

## Testing in the editor

Inside Ripping's editor preview, `checkout()` never reaches a processor. It draws a payment sheet of its own with the same products and prices, and **Pay** records the purchase with a `test` flag; **Cancel** goes to `cancelTo`. Nothing is charged. After Pay the app lands on `returnTo` and `confirmPayment()` answers `{ paid: true }` for that test, so the return route works the same way it will in production.

Test purchases:

- show on the app's Sales tab with a **Test** badge and are left out of every total;
- count in `usePurchases()` / `owns()` only inside the editor preview, so gating can be tried; a published app never sees them;
- can be "refunded" from the Sales tab, which just changes their state.

Under the hood the sheet calls `test-checkout` and `test-confirm` on the `pay` provider with a `preview_key` that only exists inside the editor; without a valid key both answer `403 { "error": "Test payments only work inside the editor preview." }`. There is nothing to configure and no test mode to switch on.

## Refunds, from the app's side

Refunds are the owner's action (Sales tab), not the app's. A `Purchase` shows what came back: `refundedCents` grows with each refund, `status` stays `paid` until the whole amount is returned and then becomes `refunded`. `owned` drops a one-time product only when it is fully refunded.

## Promo codes

```ts
function checkPromo(code: string, items: { product: string; quantity?: number }[], email?: string): Promise<PromoQuote>;
type PromoQuote = { valid: true; code: string; description: string; discountCents: number; trialDays: number; amountCents: number; currency: string };
// then
checkout(items, { code });
```

`checkPromo` throws with a message to show as it is: `That code isn't valid`, `That code has expired`, `That code has been used up`, `You've already used that code`, `Sign in to use this code` (once-per-buyer codes need a signed-in buyer), `That code needs an order of at least 25.00`, `That code doesn't apply to anything in this order`. The discount is worked out again at checkout from the owner's code, never from what the app sends. On the wire it is the `pay` provider's `promo` action:

```json
{ "provider": "pay", "action": "promo", "code": "LAUNCH20", "items": [{ "product": "…", "quantity": 1 }] }
```

## Buyer-chosen amounts

A product with `pricing: "choose"` is the one case where the app sends an amount: pass `{ product, amount }` in cents, between the product's `minCents` and `maxCents` (`amountCents` is the owner's suggestion). Anything else is refused with `The least you can pay for <name> is 5.00` or `The most you can pay for <name> is 500.00`. For every other product an `amount` is ignored; the catalogue prices it.

## Chargebacks, from the app's side

A `Purchase` carries `disputed: "open" | "lost" | null`. While a dispute is open the purchase still counts as owned; when it is lost, nothing on that row is owned any more. The owner handles the dispute itself on the Sales tab.
