Skip to content

Moved the shipping and phone tiers into tier requirements - #32115

Draft
rob-ghost wants to merge 1 commit into
mainfrom
feat/ber-4047-tiers-say-which-custom-fields-members-need
Draft

rob-ghost wants to merge 1 commit into
mainfrom
feat/ber-4047-tiers-say-which-custom-fields-members-need

Conversation

@rob-ghost

@rob-ghost rob-ghost commented Oct 21, 2026 •

Copy link
Copy Markdown
Contributor

ref https://linear.app/ghost/issue/BER-4047

Problem

Which paid tiers ask for a shipping address or a phone number lived in the Stripe Checkout settings, beside Stripe's own options, so only Stripe Checkout could act on it. A paying member who changes tier never reaches a checkout (BER-3893), so Portal will need to ask the tier itself what it needs. Publishing the Stripe settings through the tiers API was taken back out of #31623, because it made tiers depend on the payment integration.

Solution

The settings split into three sets, each owned where it belongs:

Gold (tier) ──requirement──▶ checkout.shipping (port) ──binding──▶ custom.delivery_address (field)
  1. Tier requirements, in the tiers vertical. What a tier's members must give, named by the port a checkout collects it under: on every paid tier, including tiers added later, or only on the paid tiers named for it. A tier tests itself against these rules when it's read.
  2. Bindings, unchanged and agnostic. "This port writes into this custom field". A port's destination can change without touching which tiers require it.
  3. Stripe Checkout settings, which keep only what is Stripe's own: the design, the countries shipping goes to, and the tax ID with its tiers. Ghost stores nothing for the tax ID, so it's never a requirement.

The checkout settings stay the entry point, and their Admin API doesn't change. Saving shipping or the phone number binds its ports and writes its tier requirement:

// ghost/core/server/services/tiers/requirements.ts
export class TierRequirements {
  read(ports, { transacting }): Promise<Map<string, RequiredTiers>>; // null = every paid tier
  forTiers(tiers): Promise<Map<string, TierRequirement[]>>;
  require(port, tierIds, now, { transacting }): Promise<void>;
  release(port, { transacting }): Promise<void>;
}

Tiers in the Admin and Content APIs list their requirements when asked with include=requirements, with the parts each is asked in, each named by the port its answer is collected under:

"requirements": [
  {
    "port": "checkout.shipping",
    "parts": [
      { "port": "checkout.shipping.address", "type": "address" },
      { "port": "checkout.shipping.name", "type": "short_text" }
    ]
  }
]

The ports are renamed under checkout. so they say what collects them. @tryghost/checkout now says which ports a tier can require, the parts each is given in, and which parts it can't be asked for without:

// packages/checkout/src/field-ports.ts
export const PORT_PARTS = {
  [CHECKOUT_PORT.shipping]: [
    { port: CHECKOUT_PORT.shippingAddress, essential: true },
    { port: CHECKOUT_PORT.shippingName, essential: false },
  ],
  [CHECKOUT_PORT.phone]: [{ port: CHECKOUT_PORT.phone, essential: true }],
} as const satisfies Record<string, readonly [PortPart, ...PortPart[]]>;

export function isAskable(port: RequiredPort, keptIn: (part: CheckoutPort) => { ready: boolean } | undefined): boolean;

How to review

It reads best in this order:

  • packages/checkout/src/field-ports.ts: the renamed ports, their parts and isAskable;
  • the migrations and schema.js;
  • tiers/requirements.ts, then tier-repository.js, tiers-api.js and the tier serializer;
  • writeTargets and the read in stripe-checkout-config/service.ts;
  • bindings-service.ts fieldsFor.

Decisions

  • Requirements are keyed by port, not by field. Moving shipping to another custom field doesn't touch which tiers require it. Keying by field would mean rewriting every tier's requirement on each move, and a field couldn't say which surface collects it.
  • Tiers say what to ask for, never where the answers are kept. A requirement lists its parts by port, with the type each port supplies. Where an answer lands is the port's binding, resolved when it's written, as Stripe's completed checkouts already do. So tiers stay out of metafields: the public response names no field and no namespace, and moving a part to another field doesn't change it. The alternative, listing each part's custom field, tied tiers to the publisher's namespace and published the names and keys of fields that members may not be meant to see.
  • Ports and their parts are fixed in code, with no table and no foreign key. They're defined once in @tryghost/checkout, and the ports a binding can point at are derived from them. The compiler ties each part the checkout settings bind to the port its tiers are required under, and a part added to a port doesn't compile until the checkout settings give it somewhere to be saved. The alternative, matching parts by name so checkout.shipping covers whatever starts checkout.shipping., leaves nothing to check that a part belongs to its port.
  • A tier lists a requirement exactly when checkouts would ask for it. Both read it through isAskable: every part has a field, and every essential part's field can take a value. With the recipient's name archived, shipping is still listed, with the address only. With the address archived, it's left out until it's restored. Once either field is deleted, it's left out, and the checkout settings read shipping as off.
  • Countries Stripe no longer ships to don't change a tier's requirement. The checkout settings read shipping as off, as Stripe can't be asked to ship there, but the countries are Stripe's own and Portal doesn't use them. Saving the settings with shipping off releases the requirement. The alternative, leaving tiers out when the countries can't be read, would make tiers depend on Stripe's settings again.
  • The site's cache is cleared only when what tiers list changes. Tiers now list to anyone what their members must give, so a cached Content API response would go stale. Admin sends every part it offers on each save, so a checkout settings save compares the requirements, and whether each part can be asked for, before and after writing them, as a settings save clears the cache only when a value changed. Archiving, restoring or deleting a custom field a port writes into clears it too. A save of only the design, moving a part to another field, renaming a field, or changing a field no port writes into leaves the cache alone.
  • Requirements are listed only while checkouts ask for them. That's while both stripeCheckoutDesign and stripeCheckoutCollection are on, as collection releases after the design. Checkouts and the tiers' input serializer use the same check, so with either flag off no tier response changes and nothing more is read, even when asked.
  • Tiers stay in memory, and their requirements are read from the database on each read that asks for them. The checkout settings change requirements without saving a tier, so reading them fresh means every Ghost instance serving a site lists them as soon as they're saved. The requirements and their fields' statuses are read in one transaction, so a save committing between the two can't pair one save's requirements with another's fields. Keeping tiers out of memory altogether is Changed tiers to be read from the database on every call #32126's question, about tiers changed on one instance and not seen by the others; this change doesn't depend on it.
  • Tiers list requirements only to callers that ask, with include=requirements. Themes listing tiers with {{#get "tiers"}}, post lists, members and gifts all read tiers, and none of them needs requirements, so they read nothing more. include is how Ghost's APIs let a caller ask for more of a resource, and nothing reads requirements yet, so asking costs Portal one parameter. The alternative, listing them on every tier read while the flags are on, added two queries to each theme's tier list and needed the site's cache header to ask for the free tier alone. Nothing is read for a list with no paid tier in it. Adding or editing a tier answers with it as saved, with its requirements when asked, as an "every paid tier" requirement covers a new paid tier straight away.
  • The paid-tier and field-type checks stay with the checkout settings. "Only paid tiers have a checkout to collect at" and "this port needs an address field" are rules about collecting at checkout. A requirement only counts the paid tiers it names, so a free tier added some other way never counts.
  • The phone column is dropped in the release that stops reading it, as Ghost has done before (Removed gift_subscription_redemption_notification from users table #27369). While a rolling deploy replaces instances, one still on the old code fails to read the checkout settings: its checkouts use Stripe's own design, and Admin's checkout settings don't load until it's replaced. The alternative, dropping it a release later, leaves a column nothing reads for a feature that's still private.
  • Collection settings are discarded, not moved. stripeCheckoutCollection is private, so shipping and the phone number read as off until switched on again. Their bindings go with them, as the ports were renamed. The tax ID and the design are kept.
  • The checkout settings are still the only writer, so a save records one history entry, as before. When a second writer arrives, such as a tier editor, recording moves into the requirements themselves, so every writer records its changes the same way.

Suggested follow-ups

  • Portal asks for a new tier's requirements, and the tier change saves them (BER-3943, BER-3942). Portal reads tiers with include=requirements, sends each answer by port, and Ghost writes it through the port's binding, as completed checkouts do, so Portal never needs a field's key. Pre-filling a member's own answers would read them by port the same way. Portal labels each part from its port, as Stripe Checkout does.

Validation

With Stripe connected and the stripeCheckoutCollection and membersCustomFields Labs flags on:

  1. After upgrading, Settings → Tiers → Customize checkout → Fields shows Shipping address and Phone off. Tax ID or VAT is as it was.
  2. Switch Shipping address on for one tier, with its fields, and save. It reopens as set. A Portal signup to that tier asks for an address; a signup to another tier doesn't.
  3. GET /ghost/api/content/tiers/?include=requirements lists checkout.shipping with both fields on that tier only. Without include=requirements, no tier lists requirements. Switch it to every paid tier: every paid tier lists it, and a paid tier created afterwards lists it too.
  4. Do the same for Phone.
  5. Move shipping to another custom field. The tiers list the same parts, and checkouts save into the new field.
  6. Archive the recipient's name field. The tiers list shipping with the address only, and checkouts still ask for it. Archive the address field too. The tiers stop listing shipping, and checkouts stop asking for it.
  7. Preview in Stripe still asks as the unsaved settings say.
  8. Switch stripeCheckoutCollection off. GET /ghost/api/content/tiers/?include=requirements lists no requirements. Switch it back on and switch stripeCheckoutDesign off: still none.

@coderabbitai

coderabbitai Bot commented Oct 21, 2026

Copy link
Copy Markdown
Contributor

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
  auto_review:
    drafts: true
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Comment @coderabbitai help to get the list of available commands.

@github-actions github-actions Bot added the migration [pull request] Includes migration for review label Oct 21, 2026
@github-actions

github-actions Bot commented Oct 21, 2026 •

Copy link
Copy Markdown
Contributor

It looks like this PR contains a migration 👀
Here's the checklist for reviewing migrations:

General requirements

  • ⚠️ Tested performance on staging database servers, as performance on local machines is not comparable to a production environment
  • Satisfies idempotency requirement (both up() and down())
  • Does not reference models
  • Filename is in the correct format (and correctly ordered)
  • Targets the next minor version
  • All code paths have appropriate log messages
  • Uses the correct utils
  • Contains a minimal changeset
  • Does not mix DDL/DML operations

Schema changes

  • Both schema change and related migration have been implemented
  • For index changes: has been performance tested for large tables
  • For new tables/columns: fields use the appropriate predefined field lengths
  • For new tables/columns: field names follow the appropriate conventions
  • Does not drop a non-alpha table outside of a major version

Data changes

  • Mass updates/inserts are batched appropriately
  • Does not loop over large tables/datasets
  • Defends against missing or invalid data
  • For settings updates: follows the appropriate guidelines

@nx-cloud

nx-cloud Bot commented Oct 21, 2026 •

Copy link
Copy Markdown

🤖 Nx Cloud AI Fix

Ensure the fix-ci command is configured to always run in your CI pipeline to get automatic fixes in future runs. For more information, please see https://nx.dev/ci/features/self-healing-ci


View your CI Pipeline Execution ↗ for commit 5895151

Command Status Duration Result
nx run ghost:test:ci:unit ✅ Succeeded 1m 17s View ↗
nx run-many -t test:unit -p @tryghost/admin,gho... ✅ Succeeded 3m 59s View ↗
nx run @tryghost/admin:test:acceptance --shard=1/3 ✅ Succeeded 6m 27s View ↗
nx run ghost:test:ci:integration ✅ Succeeded 1m 47s View ↗
nx run @tryghost/admin:test:acceptance --shard=2/3 ✅ Succeeded 5m 41s View ↗
nx run ghost:test:integration ✅ Succeeded 1m 29s View ↗
nx run @tryghost/admin:test:acceptance --shard=3/3 ✅ Succeeded 4m 45s View ↗
nx run ghost:test:ci:e2e ✅ Succeeded 3m 33s View ↗
Additional runs (10) ✅ Succeeded ... View ↗

💡 Verify your cache is correct by running tasks in a sandbox. Read docs ↗


☁️ Nx Cloud last updated this comment at 2026-10-22 13:05:17 UTC

@codecov

codecov Bot commented Oct 21, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 89.52381% with 22 lines in your changes missing coverage. Please review.
✅ Project coverage is 85.39%. Comparing base (c6cebcd) to head (5895151).
⚠️ Report is 4 commits behind head on main.

Files with missing lines Patch % Lines
.../server/services/stripe-checkout-config/service.ts 90.00% 1 Missing and 4 partials ⚠️
ghost/core/server/services/tiers/requirements.ts 91.37% 1 Missing and 4 partials ⚠️
...er/services/members-metafields/bindings-service.ts 75.00% 4 Missing ⚠️
...re/server/services/stripe-checkout-config/index.ts 77.77% 1 Missing and 1 partial ⚠️
...e/server/services/stripe-checkout-config/models.ts 71.42% 2 Missing ⚠️
ghost/core/server/services/tiers/service.js 80.00% 2 Missing ⚠️
...ost/core/server/api/endpoints/member-metafields.ts 93.33% 0 Missing and 1 partial ⚠️
...ore/server/api/endpoints/stripe-checkout-config.ts 66.66% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main   #32115      +/-   ##
==========================================
- Coverage   85.55%   85.39%   -0.16%     
==========================================
  Files        1398     1399       +1     
  Lines       52426    52559     +133     
  Branches     9006     9030      +24     
==========================================
+ Hits        44853    44884      +31     
- Misses       6470     6566      +96     
- Partials     1103     1109       +6     
Flag Coverage Δ
e2e-tests 72.56% <89.04%> (-0.06%) ⬇️
unit-tests 68.08% <43.83%> (-0.19%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@rob-ghost
rob-ghost force-pushed the feat/ber-4047-tiers-say-which-custom-fields-members-need branch 2 times, most recently from f6558dc to a74c924 Compare October 21, 2026 16:52
@rob-ghost rob-ghost changed the title Moved the shipping and phone tiers into custom field requirements Moved the shipping and phone tiers into tier requirements Oct 21, 2026
@rob-ghost
rob-ghost force-pushed the feat/ber-4047-tiers-say-which-custom-fields-members-need branch 5 times, most recently from 257ac37 to 860f217 Compare October 22, 2026 10:43
ref https://linear.app/ghost/issue/BER-4047

Which paid tiers ask for a shipping address or a phone number was a Stripe Checkout setting, so only Stripe Checkout could act on it, while a paying member changing tier never reaches a checkout and Portal will need to ask the tier itself what it needs. Tiers now carry requirements: what their members must give, named by the port a checkout collects it under, such as `checkout.shipping`, on every paid tier including tiers added later or on the paid tiers named for it. While checkouts collect them, which Labs gates, the Admin and Content APIs list each tier's requirements to callers that ask with `include=requirements`, as checkouts would ask for them, with the parts each is asked in, named by port, leaving where the answers are kept to the ports' bindings, and adding or editing a tier answers with them when asked, as a requirement on every paid tier covers a new one. Other readers of tiers, such as themes listing them, read nothing more. Tiers are still read into memory at boot, but their requirements are read from the database whenever they're asked for, so every instance serving a site lists them as soon as they're saved. Saving a change to shipping or the phone number, or archiving, restoring or deleting a custom field a port writes into, clears the site's cache, as those tier responses are public. The checkout settings stay where they're set and their Admin API is unchanged: saving shipping or the phone number writes its tier requirement, while the Stripe Checkout settings keep only what is Stripe's own, the design, the countries it ships to and the tax ID with its tiers. The ports are renamed under `checkout.` so they say what collects them, and collection is behind a private flag, so its saved settings are discarded rather than moved.
@rob-ghost
rob-ghost force-pushed the feat/ber-4047-tiers-say-which-custom-fields-members-need branch from 860f217 to 5895151 Compare October 22, 2026 12:52

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

migration [pull request] Includes migration for review

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant