Refunds and receipts

Refunds, the receipts collection, and payments for a deleted account.

Refunds

Gemmein takes access back only on the events that plainly mean it: a subscription cancelled or lapsed, or a one-off purchase refunded in full. A full refund revokes only the grants that payment created, so a person who also subscribes keeps their subscription's access.

Every other refund is yours to act on:

  • A partial refund leaves access in place, because a small goodwill refund on a large purchase shouldn't take away what someone bought.
  • A refund on a subscription invoice touches no access at all. A webhook cannot tell a goodwill refund from a "take it all back" refund.
  • When someone cancels at period end and asks for their money back, they keep access until the period ends unless you act. You have two moves, both one click and both audited: cancel the subscription immediately in Stripe, which ends the access the moment the event lands, or revoke it by hand on the person's page.
  • Every refund the webhook did not act on is written to your activity view.

Receipts

Gemmein records every payment itself. Buyer, amount, currency, Stripe reference, what it granted, and every refund against it. That record is kept outside your collections, so renaming or deleting one can never change what your revenue was. Each person reads their own copy with await g.purchases.mine(); refunds appear as refundedMinor and a status of part_refunded or refunded.

A receipts collection is optional. Add one only if you want purchases inside your data model, so your app can list and edit them like any other record. Gemmein's own record above stays the authoritative payment record, and your collections cannot alter it.

Receipts carry { product, item?, status, amountTotal, currency, paidAt, deliveryUrl?, deliveryFile?, paymentRef } in .data, where amountTotal is minor units exactly as Stripe reported. You fulfil orders by editing the receipt from the dashboard (status: "shipped"); your app reads it. Refunds happen in your Stripe dashboard, and charge.refunded flips the receipt's status to "refunded".

A receipt is app-owned: its top-level ownerUserId is null (the webhook wrote it), and the audienceUserId is what scopes it to the buyer.

That scoping keeps a receipt private. Receipts live in an addressed collection, so the server narrows every read to the signed-in buyer: another person calling list() on the same collection gets their own receipts and nothing else. No request returns yours, whether a crafted filter or a guessed id. A signed-in buyer's identity comes from the checkout reference Gemmein encoded, and a guest's from the email Stripe Checkout collected, read from Stripe's signed webhook; never from anything the browser sent back. So what someone paid for stays theirs even when the frontend performs no check.

A deleted account

A person who deletes their account and later buys again is a new customer. A payment made before the deletion grants nothing, whether it arrives from Stripe or from a relay; refund it in your payment provider. Relays tell before from after by the payment time you map as paid_at (see Relays). The rows Logs and Errors keep for these refusals never contain the person's address.

When a member who pays for a plan deletes their account, Gemmein sets every Stripe subscription they bought to cancel at the end of the period it is paid for: they are never charged again, and nothing is refunded. One still owing or never paid (past due, unpaid, incomplete) is cancelled at once, with no refund. Logs records each one. If Stripe refuses, you get an alert to cancel it in Stripe. If Gemmein can't read the app's payments at that moment, the deletion answers erase_unavailable and nothing is deleted.

Was this page helpful?

Last updated