mirror of
https://github.com/tdurieux/anonymous_github.git
synced 2026-11-14 00:07:00 +01:00
Subscriptions sold at a replaced price lost their plan, because a subscription was matched only against the configured price. Resolve prices through the configured price, STRIPE_LEGACY_PRICES, then a registry that now also records subscription prices and every configured price at startup. Store the price on subscription grants. Review fixes: - Recover a Never expire add-on found by reconciliation before expiring. - Open Checkout for the subscription cycle the hosting option shows. - Report conference hosting only while the package payment stands. - Keep automatic updates on a paid-slot repository while its owner subscribes. - Apply the repository expiration limit only to repositories. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
309 lines
25 KiB
Markdown
309 lines
25 KiB
Markdown
# Paid hosting
|
|
|
|
Billing is opt-in. `BILLING_ENABLED=false` preserves self-hosted behavior. This implementation uses Stripe Checkout and the customer portal. No card details are stored in the application.
|
|
|
|
## Offers
|
|
|
|
Configure these EUR prices in Stripe. The amounts below are suggested prices, not hard-coded charges:
|
|
|
|
- Author monthly: €5/month, recurring monthly.
|
|
- Author annual: €49/year, recurring yearly. In the first nine days of paid hosting, 6 of 22 subscribers chose it and it brought 79% of revenue, so it keeps its price; it is also easy to expense on a grant.
|
|
- Live updates (`author_pass`): €4 once. Automatic refresh and a separate storage allowance for one existing repository for 180 days from checkout creation.
|
|
- Extended expiration (`repo_extend`): €2 once. One existing repository can be scheduled to expire up to one year after purchase. Purchases stack: each one adds a year after the latest earlier extension.
|
|
- Never expire (`repo_never`): €10 once. It costs more than Extended expiration because it is the only purchase that keeps storage in use indefinitely. One existing repository can use Never expire without a subscription.
|
|
- Extra repository (`repo_slot`): €2 once. One more repository beyond the free allowance, with its own `PAID_PASS_STORAGE` allowance, for as long as the repository exists. It does not count toward the free allowance; buying it for a repository already counted removes that repository from the free ledger. It does not include automatic updates.
|
|
- Paper pack (`repo_pack`): €5 once. One checkout for one repository that grants Extra repository, Live updates and Extended expiration together. It is stored as three grants sharing the payment, so a refund or dispute revokes all three.
|
|
- Conference review packages, one per venue size, each for a fixed review window of at most 180 days with automatic updates. With billing enabled they are the only plans for new conferences. The size is chosen at creation and cannot change.
|
|
- Workshop (`conference_workshop`, `STRIPE_CONFERENCE_SMALL_PRICE`): €49 once, 25 repositories, 10 GiB (about €1.96 per paper).
|
|
- Conference (`conference`, `STRIPE_CONFERENCE_PRICE`): €149 once, 100 repositories, 50 GiB (about €1.49 per paper). The id is unchanged from the former single package.
|
|
- Large conference (`conference_large`, `STRIPE_CONFERENCE_LARGE_PRICE`): €399 once, 400 repositories, 200 GiB (about €1 per paper).
|
|
A tier without a configured price is not offered.
|
|
|
|
Most authors use the service for one paper at a time, a few times a year. The one-time repository add-ons are the main offer; the subscriptions suit heavy users. Extended expiration and Never expire do not add storage: the repository keeps using its owner's allowance (or its pass or conference allowance).
|
|
|
|
Checkout uses the configured Stripe Price ID. The settings page displays its actual amount and cadence. Do not configure trials, metered prices, tiered prices, adjustable quantities, or subscription changes to unrelated products in the customer portal. New subscriptions need a paid invoice before access is granted. A failed renewal does not extend paid time. Cancellation at period end keeps access until that period ends; immediate cancellation ends new paid usage immediately.
|
|
|
|
Only repository hosting is covered. Gists and pull requests retain their existing behavior. Lab plans are not included.
|
|
|
|
Default capacities, all measured in source-file bytes:
|
|
|
|
- Free account: 5 repositories, 2 GiB total.
|
|
- Author subscription: 20 repositories, 10 GiB total, and never-expiring repositories while subscribed.
|
|
- Live updates: 1 repository, 2 GiB.
|
|
- Conference packages: 25 repositories / 10 GiB, 100 / 50 GiB, and 400 / 200 GiB.
|
|
|
|
These are admission limits, not a measure or guarantee of physical disk consumption. Original files, anonymized caches, metadata and backups need additional capacity. Review production usage before enabling billing. File-size safeguards and request throttles still apply to paid users. Reviewers do not need accounts or payment.
|
|
|
|
## Configuration
|
|
|
|
Set these environment variables in your deployment's secret/configuration manager:
|
|
|
|
```text
|
|
BILLING_ENABLED=true
|
|
BILLING_ORIGIN=https://anonymous.example.org
|
|
STRIPE_SECRET_KEY=<Stripe secret key>
|
|
STRIPE_WEBHOOK_SECRET=<Stripe webhook signing secret>
|
|
STRIPE_AUTHOR_MONTHLY_PRICE=<monthly recurring EUR price ID>
|
|
STRIPE_AUTHOR_ANNUAL_PRICE=<annual recurring EUR price ID>
|
|
STRIPE_AUTHOR_PASS_PRICE=<one-time EUR price ID, Live updates>
|
|
STRIPE_REPO_EXTEND_PRICE=<one-time EUR price ID, Extended expiration>
|
|
STRIPE_REPO_NEVER_PRICE=<one-time EUR price ID, Never expire>
|
|
STRIPE_REPO_SLOT_PRICE=<one-time EUR price ID, Extra repository>
|
|
STRIPE_REPO_PACK_PRICE=<one-time EUR price ID, Paper pack>
|
|
STRIPE_CONFERENCE_SMALL_PRICE=<one-time EUR price ID>
|
|
STRIPE_CONFERENCE_PRICE=<one-time EUR price ID>
|
|
STRIPE_CONFERENCE_LARGE_PRICE=<one-time EUR price ID>
|
|
# Optional: retired price IDs still used by existing purchases
|
|
STRIPE_LEGACY_PRICES=price_old_annual:author_annual
|
|
BILLING_AUTOMATIC_TAX=false
|
|
```
|
|
|
|
Price IDs can be omitted to hide offers. Use test-mode keys and prices for staging. `BILLING_ORIGIN` must be the application's canonical HTTPS origin; HTTP is accepted only for localhost development. Redirects never use request-supplied hosts or return URLs. Enable and configure Stripe Tax before setting `BILLING_AUTOMATIC_TAX=true`; otherwise configure the appropriate tax treatment in Stripe before launch. Receipts, seller details and portal settings also need configuration in Stripe.
|
|
|
|
Capacity and expiration overrides are positive integers:
|
|
|
|
```text
|
|
FREE_EXPIRATION_MONTHS=5
|
|
FREE_AUTHOR_REPOSITORIES=5
|
|
FREE_AUTHOR_STORAGE=2147483648
|
|
PAID_AUTHOR_REPOSITORIES=20
|
|
PAID_AUTHOR_STORAGE=10737418240
|
|
PAID_PASS_STORAGE=2147483648
|
|
PAID_CONFERENCE_REPOSITORIES=100
|
|
PAID_CONFERENCE_STORAGE=53687091200
|
|
PAID_CONFERENCE_SMALL_REPOSITORIES=25
|
|
PAID_CONFERENCE_SMALL_STORAGE=10737418240
|
|
PAID_CONFERENCE_LARGE_REPOSITORIES=400
|
|
PAID_CONFERENCE_LARGE_STORAGE=214748364800
|
|
```
|
|
|
|
Changing these values changes the allowance of existing plans too. Plan and conference review-window changes are intentionally locked in this first version.
|
|
|
|
### Changing a price
|
|
|
|
Existing subscriptions and purchases keep the Stripe price they were sold at. The application maps a price to its plan through the configured price, then `STRIPE_LEGACY_PRICES`, then a registry of prices it has seen: every configured price is recorded at startup, and every price is recorded when a checkout is created. To replace a price:
|
|
|
|
1. Deploy this version while the old price is still configured, so startup records it.
|
|
2. Add the old price to `STRIPE_LEGACY_PRICES` (for example `price_old_annual:author_annual`). This is explicit and also covers prices the registry never saw.
|
|
3. Set the new price ID and restart. New checkouts use the new price; existing subscribers keep theirs, including on renewal and cancellation webhooks.
|
|
|
|
Configure the Stripe customer portal for payment-method updates, invoice downloads and cancellation. Disable arbitrary subscription product switches. Configure a snapshot webhook at `/api/billing/webhook` using API version `2026-10-28.endive`, matching the installed Stripe SDK, for:
|
|
|
|
- `checkout.session.completed`
|
|
- `checkout.session.async_payment_succeeded`
|
|
- `customer.subscription.created`
|
|
- `customer.subscription.updated`
|
|
- `customer.subscription.deleted`
|
|
- `customer.subscription.paused`
|
|
- `customer.subscription.resumed`
|
|
- `invoice.paid`
|
|
- `invoice.payment_failed`
|
|
- `charge.refunded`
|
|
- `charge.dispute.created`
|
|
|
|
The webhook is mounted before JSON parsing so Stripe verifies the original request bytes. Failed durable receipt returns 500 for Stripe retries. Processing runs asynchronously from the stored event inbox. Duplicate deliveries do not extend passes or allocate duplicate grants. Subscription events retrieve current Stripe state and use a database revision check to prevent stale concurrent writes. Full refunds and disputes suspend the affected paid grant; partial refunds preserve access; reinstatement after a resolved dispute requires operator review.
|
|
|
|
## Lifecycle and migration
|
|
|
|
New paid conferences remain `pending_payment` until a verified checkout activates them. Collect billing addresses in Stripe. With billing enabled, the review package is the only conference plan; the free plan is offered only when billing is disabled (and no plan is offered if `STRIPE_CONFERENCE_PRICE` is missing). Historic free conferences remain editable and keep their existing status and behavior. `GET /api/conferences/:id` returns `hosting: true` while a paid conference is active, so the anonymize form can tell authors that the conference pays for their repository. There is no automatic charge for historical estimated usage.
|
|
|
|
Existing repositories are not deleted or hidden on rollout or subscription expiry. New repositories are marked `billingManaged`; their file trees must fit an allowance before publication. A repository that fails this check remains available in its owner's dashboard for upgrading and retrying. Buy an add-on from the repository's edit page, or by entering its anonymized ID in Settings. Add-ons can also be bought for an unused repository ID: when the free allowance is full, the create form offers Extra repository and returns to the saved draft after checkout. Automatic refresh pauses for billing-managed repositories on the free allowance, including after paid access expires. Owners can still request a refresh within that allowance. Existing review links retain their configured expiration dates. This is not a guarantee of perpetual hosting.
|
|
|
|
Allowances are selected in order: paid conference, Live updates or Extra repository, author subscription, free account. Repository add-ons only apply to the account that bought them. Extended expiration and Never expire grants share the repository scope but are skipped here. MongoDB quota ledgers atomically reserve count and source-file bytes. They initially count existing repositories using stored size metadata. Reservations never shrink during refresh, so a concurrent or failed build cannot release capacity still in use. Confirmed removed repositories release reservations on the next admission. Expired repositories and orphan reservations remain reserved until operator reconciliation. This deliberately favors conservative capacity accounting over overselling. The account usage display uses reserved capacity once a ledger exists. A moved repository can retain conservative reservations in its previous scope until removal.
|
|
|
|
For billing-managed repositories, on-demand recovery and ZIP downloads from truncated trees are rejected with `repository_tree_incomplete`. This prevents serving unaccounted files beyond the indexed tree. A complete source tree is required; refresh after reducing repository size or excluded content upstream.
|
|
|
|
Billing records are in separate `billingaccounts`, `billinggrants`, `billingquotas` and `billinglocks` collections. They are not serialized on anonymous repository pages. Account deletion cancels recurring subscriptions and expires any known open checkout before deleting content. Minimal provider IDs and financial records remain for reconciliation. Do not disable billing configuration while active subscriptions still need webhook processing or cancellation.
|
|
|
|
## Verification and operation
|
|
|
|
Run focused policy and MongoDB tests against a disposable database:
|
|
|
|
```sh
|
|
npx mocha test/billing.test.js
|
|
BILLING_TEST_MONGODB_URI=mongodb://127.0.0.1:27017/billing_test_paid npx mocha test/billing-integration.test.js
|
|
npm run test:ui
|
|
npm run build
|
|
```
|
|
|
|
The integration suite deletes data in its test collections and requires a database name beginning with `billing_test_`. It covers concurrent admission, duplicate fulfillment, customer binding, refunds, subscription cancellation, failed renewal, stale event writes and conference activation. It uses a fake Stripe client and real MongoDB; it does not charge cards.
|
|
|
|
Before production rollout, run a Stripe test-mode purchase for each configured price, renewal with a test clock, failed renewal, cancellation, refund and webhook retry. Confirm currency, tax behavior, invoice details, capacities and backups. Inspect webhook delivery failures in Stripe. The in-app Refresh payment status action reconciles the latest checkout and known subscriptions; it is not a substitute for webhooks. Failed checkout requests preserve their idempotency key so retrying the same plan recovers the session.
|
|
|
|
Back up billing collections with the rest of MongoDB. Watch source bytes, physical storage, bandwidth and request rates separately. Stripe fees, taxes and refunds reduce gross revenue.
|
|
|
|
|
|
### Coauthors and the free allowance
|
|
|
|
Only repositories owned by an account consume its allowance. Joining another author's repository does not consume a slot, and coauthors do not need subscriptions. A coauthor's own subscription does not increase the owner's allowance. Edits and refreshes use the owner's allowance, or the repository's paid conference/pass allowance. If that allowance is full, a collaborator's attempt to refresh or enlarge the repository can also be blocked. Existing review links remain available.
|
|
|
|
The free defaults are 5 repositories and 2 GiB, revised from the initial 10-repository proposal after the November 2, 2026 production audit. The audit counted ready, unexpired repositories; stored sizes were estimates rather than a fresh recount. Paid author subscriptions retain 20 repositories, 10 GiB and automatic refresh.
|
|
|
|
### Never-expiring repositories
|
|
|
|
Monthly and annual author subscribers can choose Never expire. This requires the repository owner's subscription even when a conference pays its storage allowance, or a Never expire add-on bought for that repository. A coauthor subscription or Live updates does not unlock it.
|
|
|
|
Newly selected Never expire repositories store a billing deadline 90 days after the paid-through date. The five-minute maintenance job extends that deadline on renewal and changes overdue repositories to ordinary removal expiration. Cancellation does not shorten the paid-through period. Revocation does not extend a previously recorded deadline. Renewal during the grace period preserves the repository; after expiration, the author must renew and explicitly reactivate it.
|
|
|
|
A Never expire add-on also stores a billing deadline, 90 days ahead. Maintenance moves it forward about once a week while the add-on is valid, so a full refund or dispute ends the repository after the grace period like a lapsed subscription.
|
|
|
|
Legacy never-expiring repositories have no billing deadline and remain unchanged. Disabling billing disables this reconciliation.
|
|
|
|
### Dated expiration limits
|
|
|
|
Free users can choose a dated expiration up to `FREE_EXPIRATION_MONTHS` (default 5) months ahead and extend it again later. The dashboard's Extend action moves the date forward by up to 6 months, capped at this limit. Author subscribers keep a one-year limit. An Extended expiration add-on raises the limit to its paid date. A repository in a conference can expire as late as that conference's end date. An already saved later date stays valid, so lowering the limit does not block edits to existing repositories. Without billing, the limit stays at one year. Never expire does not exempt a repository from quotas, abuse controls, or service availability.
|
|
|
|
Real sandbox validation and repeatable API test instructions are recorded in [Stripe sandbox validation](stripe-sandbox-validation.md).
|
|
|
|
## Billing recovery and operation
|
|
|
|
`BILLING_CHECKOUT_ENABLED=false` stops new purchases while webhooks, reconciliation,
|
|
the customer portal, and cancellation remain available. Keep `BILLING_ENABLED=true`
|
|
and the existing Stripe price IDs configured while subscriptions exist.
|
|
|
|
Verified webhook routing references are durably inserted into `billingevents` before
|
|
HTTP acknowledgement. A ten-second worker claims events with a five-minute lease,
|
|
retries failures with exponential backoff, and marks an event failed after 12 failed
|
|
processing attempts. Repeated delivery cannot create another inbox record. A worker
|
|
that dies after fulfillment can replay the operation without allocating another grant.
|
|
Completed envelopes are removed after 90 days; failed events remain for investigation.
|
|
The inbox deliberately omits customer names, addresses, card data, and full payloads.
|
|
Do not register Connect events on this application's platform webhook.
|
|
|
|
Every five minutes, a separate job selects up to ten due billing accounts. Each account
|
|
is normally reconciled daily; failures retry after 15 minutes. Reconciliation paginates
|
|
subscriptions, checkout sessions, and charges, including historical purchases absent
|
|
from MongoDB. It also reconstructs confirmed paid periods from invoice lines when the
|
|
latest invoice failed. This full customer-history scan favors recovery at the current
|
|
scale. Measure Stripe request volume before shortening the daily interval.
|
|
|
|
Before the 90-day deadline triggers expiration, maintenance performs a fresh owner
|
|
reconciliation. Provider or database failures defer that expiration and log an error.
|
|
Legacy never-expiring repositories remain excluded. The recorded grace period does not
|
|
restart during retries. Ordinary repository requests continue using local entitlements.
|
|
|
|
Checkout retries retain their key and exact creation parameters. Sessions have an
|
|
explicit expiry. After 23 hours, unresolved requests are searched by purchase-attempt
|
|
metadata before any retry. After 24 hours, an unmatched attempt can be replaced only if
|
|
its immutable request contains an expired session deadline. Older records without that
|
|
proof require operator review. Uncertain customer creation also stops after 23 hours;
|
|
customer discovery can recover the mapping without creating another customer.
|
|
|
|
Partial refunds preserve access. Full refunds and disputes suspend the affected grant.
|
|
`billingrevocations` records the payment decision even if its grant does not exist yet.
|
|
A dispute after a reviewed refund requires review again. Reinstatement is an explicit operator
|
|
decision; it does not create paid time or restart a subscription. Do not grant a refund
|
|
merely to cancel renewal.
|
|
|
|
The existing application error log receives billing failures and five-minute health
|
|
warnings for failed events, old pending events, unresolved checkout attempts, and failed
|
|
reconciliation. These warnings also appear in the existing admin error viewer. External
|
|
email or paging delivery still requires deployment monitoring configuration.
|
|
|
|
### Operator commands
|
|
|
|
Run with the application's normal server configuration and database access. Inspection
|
|
is read-only; mutation commands require `BILLING_OPERATOR` and an audit reason. They
|
|
write an intent record to `billingaudits` before performing work. Inspect resulting
|
|
state after a command failure; an audit intent alone does not prove completion.
|
|
|
|
```sh
|
|
node -r ts-node/register src/scripts/billing-operations.ts health
|
|
node -r ts-node/register src/scripts/billing-operations.ts inspect OWNER_ID
|
|
BILLING_OPERATOR=operator-name node -r ts-node/register src/scripts/billing-operations.ts reconcile OWNER_ID 'Investigating missing paid access'
|
|
BILLING_OPERATOR=operator-name node -r ts-node/register src/scripts/billing-operations.ts replay test:platform:evt_ID 'Retry after correcting configuration'
|
|
BILLING_OPERATOR=operator-name node -r ts-node/register src/scripts/billing-operations.ts drain all 'Process queued recovery work'
|
|
BILLING_OPERATOR=operator-name node -r ts-node/register src/scripts/billing-operations.ts discover all 'Recover customer mappings after restore'
|
|
BILLING_OPERATOR=operator-name node -r ts-node/register src/scripts/billing-operations.ts reinstate pi_ID 'Dispute resolved; access reinstatement approved'
|
|
```
|
|
|
|
Discovery binds only customers tagged for this application to existing users. Conflicting
|
|
customer mappings require investigation. `reinstate` reviews a payment reversal and
|
|
keeps a subscription suspended if other unreviewed payment reversals remain. Reconcile
|
|
the owner afterward. Restrict shell access to authorized operators; these are not public
|
|
HTTP endpoints.
|
|
|
|
### Restore and launch checks
|
|
|
|
Back up `billingaccounts`, `billinggrants`, `billingquotas`, `billinglocks`, `billingevents`,
|
|
`billingrevocations`, `billingprices`, and `billingaudits` with repository data. Test a restore into an
|
|
isolated environment with sandbox credentials. Keep destructive repository maintenance
|
|
stopped during restore, discover customer mappings, reconcile restored owners, drain
|
|
queued events, and inspect health before re-enabling normal scheduling. Never connect a
|
|
restored test copy to live Stripe credentials.
|
|
|
|
Before launch, configure seller details, live prices, tax treatment, invoice information,
|
|
portal restrictions, production email delivery, backup retention, and external alerts.
|
|
These are deployment/business settings, not changes made by this implementation. Grace
|
|
period email reminders are not currently sent by the app.
|
|
|
|
The recovery suite uses a disposable MongoDB database and simulated Stripe failures:
|
|
|
|
```sh
|
|
BILLING_TEST_MONGODB_URI=mongodb://127.0.0.1:27029/billing_test_recovery npx mocha test/billing.test.js test/billing-integration.test.js test/billing-reliability.test.js
|
|
```
|
|
|
|
## Payment within the repository workflow
|
|
|
|
Repository hosting choices now appear in the anonymization form when capacity or a
|
|
new Never expire selection requires them. Free dated submissions and coauthor work
|
|
keep their existing flow. Conference admission and final capacity are still checked
|
|
server-side; an account usage display cannot authorize or permanently block admission.
|
|
Existing repository owners can also buy a repository-scoped pass from that form.
|
|
|
|
Before leaving for hosted Stripe Checkout, the app flushes pending field edits and
|
|
saves a private task draft. `/api/billing/draft` requires authentication and the billing
|
|
CSRF token. Drafts retain only the form fields needed to resume, are limited to 64 KiB,
|
|
and expire after seven days. Retrieval checks the owner and expiry even before MongoDB's
|
|
TTL sweep runs. Account deletion also removes these drafts.
|
|
|
|
Checkout returns to the draft's validated anonymization route, or the conference
|
|
being purchased, rather than always returning to Settings. No request-supplied external
|
|
return URL is accepted. An open or uncertain checkout attempt cannot be reused for a
|
|
different task. Returning restores the draft, refreshes billing and capacity, and leaves
|
|
submission to the author. The return token is consumed after restoration so later
|
|
navigation cannot overwrite newer edits. Confirmation does not create a repository.
|
|
|
|
The payment form remains Stripe-hosted. Embedded Checkout is not required for this
|
|
workflow and is not enabled by this change. Settings remain available for invoices
|
|
and subscription management.
|
|
|
|
Validation: 40 billing tests with disposable MongoDB and 160 frontend tests passed.
|
|
Additional HTTP checks cover draft CSRF, authentication, ownership and no-store headers.
|
|
The built UI was checked at desktop and mobile widths using simulated payment responses;
|
|
redaction edits survived the checkout return. These workflow changes were not charged
|
|
through a fresh real Stripe sandbox payment or deployed to production.
|
|
|
|
### Current application layout
|
|
|
|
The anonymization page keeps the main configuration sections visible beside a live
|
|
README preview. Source controls retain the existing GitHub connection selector,
|
|
manage-access action and branch refresh button. Conference code is a visible optional
|
|
field. Hosting choices appear within configuration, with saved drafts preserved through
|
|
Stripe Checkout and return.
|
|
|
|
Settings separates anonymization defaults, hosting and billing, and account actions.
|
|
The header Upgrade action sits beside the user menu and opens the plan comparison.
|
|
Active subscribers see subscription management instead of another purchase offer.
|
|
The donation entry remains available.
|
|
|
|
Local browser checks used simulated payment responses for the final layout. The real
|
|
Stripe sandbox checks and their limitations are documented in
|
|
[stripe-sandbox-validation.md](stripe-sandbox-validation.md).
|
|
|
|
### Resolved source-file storage
|
|
|
|
Git LFS pointer sizes do not represent cached object sizes. Source-cache fills reserve
|
|
bytes beyond the tree size before writing them, in blocks of at most 1 MiB. The ledger
|
|
keeps per-path high-water marks so retries and concurrent fills do not charge twice.
|
|
Reservations remain conservative across generations and failed fills and require
|
|
operator reconciliation to release unused capacity. This also covers public raw
|
|
downloads and expanded submodule content. Shared-cache hits and legacy content promoted
|
|
to the shared cache also reserve their resolved size before serving. Legacy cache hits
|
|
outside the shared cache are not retroactively measured by this write-time guard.
|
|
|
|
Accepted one-time prices are stored in `billingprices` when checkout starts and when
|
|
existing purchases are fulfilled. Hiding an offer by removing its configured price
|
|
does not remove this mapping or invalidate paid grants. Include the mapping in backups
|
|
so reconciliation can reconstruct grants for retired offers and process their refunds.
|