ReviewOS

stacks/uplink

fix(billing): a purchase that never reaches the thank-you page issues no license at all

#48
Closed glennmichael123 opened this 9 days ago ·
9 days ago

A license is created in exactly one place: rendering the thank-you page. A buyer who pays and never lands on it has been charged and has nothing, and there is no second path that would ever give them a key.

Revocation is fine without a webhook and is not what this is about. Licenses.check re-reads the subscription from Stripe with a fifteen minute per-key cache (app/Billing/licenses.ts#L180, #L61), so a cancellation or a refund reaches the Mac on its own. Issuance is the half with no fallback.

What is there today

  • issue() creates the license row and triggers the key email (app/Billing/licenses.ts#L127, #L163).
  • Its only caller is thanksFor() (app/Billing/license-store.ts#L80), which the /thanks page calls while rendering (resources/views/thanks.stx#L17).
  • Checkout's success_url is that page (app/Billing/checkout.ts#L70), so the redirect after payment is the only thing that ever creates a license.
  • There is no webhook route. routes/api.ts#L28-L29 has /license/check and /billing/portal, and nothing else. config/saas.ts#L30-L33 still carries the scaffold's 'your-webhook-endpoint' and 'your-webhook-secret', which nothing reads.
  • There is no resend or recovery path. Searching app/, routes/ and resources/views/ finds none, and /license/check needs a key the buyer does not have.

A buyer who closes the tab during the redirect, loses the network at that moment, or has the return interrupted on a phone, is charged and gets no key and no email. The only recovery is an operator opening /thanks?session_id=cs_live_... by hand, which works, because issue() finds the paid session and mints the key then. Nothing documents that, and nobody would guess it.

There is a second way to reach the same place. payment_method_types is not set (app/Billing/checkout.ts#L68 sets only mode), so Stripe uses whatever automatic payment methods the dashboard enables. If any of them settles after the redirect, session.payment_status is not paid when the thank-you page renders, issue() returns null (app/Billing/licenses.ts#L135), and the buyer sees a thank-you with no key and no reason to come back. Whether that is reachable today depends on the Stripe dashboard rather than on this repository, so it needs checking rather than assuming.

Proposal

  1. Add a Stripe webhook route that verifies the signature and calls the same issue() for checkout.session.completed and checkout.session.async_payment_succeeded.
  2. Read the endpoint secret from the environment and drop the placeholder webhook block in config/saas.ts#L30-L33, or fill it in, so the config does not describe something that does not exist.
  3. Decide payment_method_types deliberately rather than inheriting the dashboard, or confirm that every method enabled there settles before the redirect.

Requirements

  • No new issuance logic. issue() is already idempotent: bySession returns the existing row and the email only sends for a license created just now (app/Billing/licenses.ts#L128, #L160-L163). The webhook and the thank-you page must be able to run in either order, or twice, and produce one license and one email.
  • A failed webhook must not fail the purchase. Stripe retries, and the thank-you page is still there. Log and return 200 for anything already handled.
  • Verify the signature. An unauthenticated endpoint that mints licenses is worse than no endpoint.
  • The buyer must end up with the key either way. The webhook path has no page to print it on, so the email is what delivers it, which is what onIssued already does.

Acceptance

  • A test posts a signed checkout.session.completed for a paid session and asserts one license row and one email.
  • A test posts it twice, and visits the thank-you page as well, and asserts still one license row and one email, in both orders.
  • A test posts one with a bad signature and asserts nothing is created.
  • Buying end to end and closing the tab before the redirect still delivers a key by email.

Sign in to comment on this issue.