# Taking money for a download

2026-08-08. Downloads has had `is_paid`, `price_cents` and a webhook-safe
`grant()` since it was built, with nothing calling them because no provider was
chosen. Ernest has chosen: **Stripe and PayPal**, with **each download saying
whether it renews**.

---

## What has to be true

- A visitor pays, and comes back owning the file.
- A webhook that arrives twice grants once.
- A webhook that never arrives does not silently lose a sale.
- A renewal that lapses stops granting access without erasing the purchase.
- A provider being slow or down fails visibly and quickly, not by hanging.

## One seam, two providers

```php
interface Provider {
    public function key(): string;                       // 'stripe' | 'paypal'
    public function isConfigured(): bool;
    public function begin(Purchase $purchase): string;   // a URL to send them to
    public function read(string $body, array $headers): ?Settlement;
}
```

`Settlement` says only: which purchase, whether it is paid, and the provider's
own reference. Everything downstream — entitlements, expiry, emails — is
provider-agnostic and written once.

**PayPal first.** Ernest sells through PayPal and did on Invision; Stripe is
there for other people's installs. An earlier draft of this note had it the
other way round, reasoning from live Stripe infrastructure that belongs to
Floxum and ConvoroCP rather than to this store.

That also means the shared-account hazard below is somebody else's problem
rather than ours — it stays in the design because the day somebody does turn
Stripe on, it is their problem.

### PayPal is not Stripe-shaped, in two ways that matter

**Verifying a webhook costs a call to PayPal.** Stripe signs with an HMAC that
can be checked offline; PayPal expects the payload to be posted back to
`/v1/notifications/verify-webhook-signature`. That is inbound-triggered rather
than visitor-triggered, so it does not break the rule below — but it means a
slow PayPal delays webhook handling, and it needs the same timeouts as
everything else. Talking to PayPal also needs an OAuth token first, which is
cached until it expires rather than fetched per call.

**Recurring is a different API, not a flag.** One-off sales are Orders v2:
create, send them to the approve link, capture. Subscriptions need a Product
and a Plan created at PayPal *per priced thing*, then a subscription against
the plan. So a renewing download has a plan id stored against it, made on
first use and reused after. This is the single biggest reason recurring lands
after one-off rather than beside it.

## 🚨 Idempotency is a unique index, not an `if`

The IPS store double-fired `purchasePaid` and it is the failure this design
exists to make impossible. A code path that checks "have I already handled
this?" loses the race with itself the moment two webhooks arrive together —
which is exactly when it matters.

So a **`download_purchases`** table, with `UNIQUE (provider, provider_ref)`.
The webhook does an insert-or-find on that key. A duplicate delivery collides
with the index and becomes a no-op in the database, before any application
logic has an opinion. `grant()` is already idempotent the same way.

The purchase row is also the answer to "a webhook that never arrives": a
pending purchase older than an hour is a sale to go and look at, and it can be
listed. Without the row there is nothing to notice.

## The one place an outbound call belongs in a request

The standing rule is that **no outbound call happens inside a visitor's
request**, because tasks running in page loads took a site down when the remote
end got slow.

Starting a checkout breaks that rule, deliberately, and it is the only thing
that may. The distinction is whose request it is: `update:check` was doing
*incidental* work in somebody else's page load, so a slow remote made an
unrelated page slow. A checkout **is** the request — the visitor pressed Buy
and there is nothing to show them until the provider answers.

What the rule still demands:

- A connect timeout of 1s and a read timeout of 5s, like `Updates`. A provider
  that has not answered in five seconds gets a page saying so and a Try again,
  not a spinner.
- The purchase row is written **before** the call. If the call fails the sale is
  still recorded as pending rather than vanishing.
- Nothing else in the request talks to anybody.

Webhooks are inbound and cost nothing. Renewal *checks* are a cron command, not
a page load.

## Renewals, and what a renewal costs

`downloads.is_recurring` and `renewal_months` say what a thing is;
`download_entitlements.expires_at` says what somebody currently has. Null means
it never lapses, which is every one-off purchase and every manual grant.

🚨 **A renewal is usually cheaper than the purchase, and the schema could not
say so.** Ernest's listings are priced like "$60, then $22 every 6 months" —
buying it and keeping it up to date are different amounts. One `price_cents`
cannot express that, and the old store could not either: its four paid products
carry a single price and a `recurring` boolean.

So `renewal_price_cents`, nullable, where **null means renewals cost the same
as the purchase**. That keeps every existing row correct without a backfill and
makes the common case the default.

This is worth the column now rather than later because the checkout has to know
which number it is charging, and finding that out after two providers are
written means changing both.

🚨 **`isEntitled()` does not look at `expires_at` today**, so the column added
with the admin has no reader and a lapsed renewal would still grant access.
Fixed here, and it is the reason this note comes before more provider code:
adding a column is not adding a feature.

An expiry is extended, never replaced by a new row. Somebody who lets a renewal
go and comes back is not a new customer, and their history is not ours to throw
away.

## The shared Stripe account

🚨 One Stripe account already serves the Convoro store, ConvoroCP hosting **and
Floxum**. A fourth consumer on the same account is how a Floxum payment gets
fulfilled as a Convoro download.

It has happened in the other direction: the old store's webhook received a
Floxum checkout and correctly declined it only because it looked for a
`product_id` in the metadata and did not find one.

So:

- Its **own webhook endpoint**, subscribed to only the events this handles.
- Every session carries `convoro_purchase_id` in metadata, and a webhook
  without one is **ignored, not failed** — it belongs to somebody else, and
  returning an error would make Stripe retry another product's event forever.
- The signing secret lives in settings, overriding config, so rolling it does
  not need a deploy. The old store went 245 webhooks deep into a mismatch
  because the secret was rolled and the app was never told.

## Not decided here

- **Refunds.** The provider can say a charge was reversed; whether that revokes
  a download is a policy question, and doing it silently is worse than not
  doing it.
- **Tax.** The current site charges none. Getting that wrong is expensive in a
  way code review does not catch, so it gets its own note.
- **Coupons, bundles, trials.** None of them have a second caller yet.

## Order

1. ✅ `download_purchases`, and `isEntitled()` respecting expiry.
2. `renewal_price_cents`, and the admin field for it.
3. The `Provider` interface and the provider-agnostic `Purchases` service.
4. PayPal, one-off: Orders v2, webhook, entitlement.
5. A pending-purchase list in the admin, because a sale nobody noticed is worse
   than a failed one.
6. PayPal, recurring: Plans and Subscriptions.
7. Stripe, against the same interface, for other people's installs.
8. `convoro purchases:sweep` — expire what has lapsed, flag what is stuck.
