Skip to content

Migrate Hub billing page to the api's session-first flow#179

Open
tobihagemann wants to merge 7 commits into
developfrom
feature/hub-billing-api-migration
Open

Migrate Hub billing page to the api's session-first flow#179
tobihagemann wants to merge 7 commits into
developfrom
feature/hub-billing-api-migration

Conversation

@tobihagemann

@tobihagemann tobihagemann commented Jul 6, 2026

Copy link
Copy Markdown
Member

Important

Requires cryptomator/api#34 to be deployed. The page hard depends on the new endpoints, so both sides need to ship together.

Moves the Hub billing page off the legacy store's manage endpoints and onto the api, with the billing session as the single credential. Opening the page from a Hub now starts with a customer lookup (redacted email for known Hubs, email entry for new ones) and a confirmation link. The emailed ?session= link resolves the session and routes on its billingId: linked sessions go to the manage view, new customers to checkout (card via Paddle, invoice via the new EspoCRM checkout). Manage calls authorize with Authorization: Bearer <session>, checkouts carry the session so the purchase gets linked back, and the license comes from GET /licenses/hub?session=&legacy= without a captcha. Delivery back to the Hub honors token_transfer: ?token= for older Hubs, ?session= for newer ones. Expired or reused links get their own screen.

Invoice checkout now mirrors the Paddle overlay: picking "Pay by Invoice" locks the interval to yearly and keeps the pricing card compact, and the checkout button opens a modal with a live order summary beside the billing form. The summary shows the exact unit price and total from GET /billing/espocrm/checkout/price (the same price-book lookup the api freezes on the subscription, so the shown total is the invoiced total), a seat stepper that re-prices on every change, the recurring total when the first period is discounted, and VAT or reverse-charge hints by country. Since the api's Altcha challenges expire after a minute, the buy button solves a fresh challenge on click and submits on the verified event, with a one-shot guard so a solve that settles after cancel can't buy anything. Seat changes for invoice customers show the prorated charge (or credit) and the new yearly total from POST /billing/manage/subscription/{hubId}/seats/preview in the confirm dialog. The two self-checkout products (Managed and Self-Hosted Standard) are configured via espocrmHubManagedProductId/espocrmHubSelfHostedProductId params; production values are still TODO.

All dialogs on the billing pages (restart, seat change, invoice checkout, low-quantity hint) now close on Escape and trap focus while open, via the @alpinejs/focus plugin.

State Machine

stateDiagram-v2
  [*] --> CREATE_SESSION: ?hub_id&return_url
  [*] --> LOADING: ?session=<id>
  CREATE_SESSION --> CREATE_SESSION_SUCCESS: link mailed
  LOADING --> LINK_EXPIRED: 404
  LOADING --> EXISTING_CUSTOMER: billingId set
  LOADING --> NEW_CUSTOMER: no billing yet
  NEW_CUSTOMER --> CHECKOUT_SUCCESS: card or invoice
  NEW_CUSTOMER --> MANUAL_INVOICE: custom billing
  EXISTING_CUSTOMER --> EXISTING_CUSTOMER: seats, pause, resume
Loading

…g-api-migration

Conflicts in assets/js/hubsubscription.js and layouts/hub-billing/single.html
were resolved by keeping this branch's versions wholesale: both sides rewrote
the same billing-page flow (this branch targets the new api manage/checkout
endpoints, develop's store-migration phases 1+2 kept the legacy store flow
with an api billing-session bootstrap), so a line-level merge is not
meaningful. Dropped from develop as superseded: customer lookup with redacted
email, the /billing/session + ?session= confirmation-link bootstrap, and
token_transfer=session delivery back to the Hub (this branch currently only
delivers the license via ?token=).
@coderabbitai

coderabbitai Bot commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

This PR reworks Hub billing for card and invoice checkout, Hub-linked subscription management, updated billing UI state, and shared license rendering. It adds invoice forms, pricing, captcha handling, seat-change previews, new product configuration, EU country data, translations, and Alpine focus behavior.

Estimated code review effort: 4 (Complex) | ~60 minutes

Possibly related PRs

Suggested reviewers: overheadhunter, sailreal

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title accurately summarizes the main change: moving Hub billing to the API's session-first flow.
Description check ✅ Passed The description matches the changeset and explains the new session-based billing, checkout, and manage flows.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feature/hub-billing-api-migration

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (4)
data/de/eu_countries.yaml (1)

1-54: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Consider alphabetical ordering for the country dropdown.

Entries are listed in EU accession/treaty order rather than alphabetically by name (e.g., Frankreich, Deutschland, Griechenland appear out of alphabetic sequence). Since this list feeds a <select> populated via {{ range (index $.Site.Data $.Site.Language.Lang).eu_countries }} in layouts/hub-billing/single.html, sorting by localized name would improve usability for users scanning the dropdown.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@data/de/eu_countries.yaml` around lines 1 - 54, The eu_countries list is not
sorted alphabetically by localized country name, which makes the dropdown harder
to scan. Reorder the entries in eu_countries so the names appear in alphabetical
sequence while keeping the same code/name pairs, and ensure the list consumed by
the range in layouts/hub-billing/single.html remains alphabetically sorted for
the German locale.
data/en/eu_countries.yaml (1)

1-54: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Same ordering concern as the German counterpart.

The English list mirrors the same non-alphabetical accession order (e.g., France, Germany, Greece interleaved with earlier entries). Sorting both locale files alphabetically by displayed name would make the country picker easier to navigate.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@data/en/eu_countries.yaml` around lines 1 - 54, The English EU country list
in eu_countries.yaml should be reordered to match the alphabetical-by-name
sorting requested for the German counterpart. Update the entries in the existing
country list so the displayed names are in alphabetical order while keeping the
same code/name pairs, and keep the ordering consistent with the locale file’s
country-picker data structure.
layouts/hub-billing/single.html (2)

467-474: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Consider aria-pressed on the payment-method toggle buttons.

These act as a two-state switcher but don't expose pressed/selected state to assistive tech.

♿ Suggested addition
-<button type="button" ... :class="{...: subscriptionData.paymentMethod === 'card'}" `@click.prevent`="subscriptionData.paymentMethod = 'card'">
+<button type="button" ... :class="{...: subscriptionData.paymentMethod === 'card'}" :aria-pressed="subscriptionData.paymentMethod === 'card'" `@click.prevent`="subscriptionData.paymentMethod = 'card'">
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@layouts/hub-billing/single.html` around lines 467 - 474, The payment-method
toggle buttons in the hub billing checkout act like a two-state switch, but
their selected state is not exposed to assistive technologies. Update the button
markup in the payment method toggle section to include aria-pressed on both
buttons, binding it to whether subscriptionData.paymentMethod equals 'card' or
'invoice' so screen readers can announce the active choice.

127-197: 🩺 Stability & Availability | 🔵 Trivial | 💤 Low value

Optional: guard subscriptionData.details.* accesses against null.

details is initialized to null (line 6) and these x-show/x-text bindings (status, seats, current_period_end, processor) dereference it directly. If this block is ever reactive before details is populated (e.g. via a parent x-show rather than x-if), Alpine will log console errors. Optional chaining would make this resilient regardless of the parent gating mechanism.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@layouts/hub-billing/single.html` around lines 127 - 197, Guard the direct
subscriptionData.details dereferences in the hub billing template so Alpine does
not evaluate them while details is still null. Update the bindings in the
status, seats, current_period_end, and processor checks within the single.html
subscription section to use null-safe access or an equivalent guard, especially
around the x-show, x-text, and disabled expressions tied to
subscriptionData.details. Use the existing subscriptionData object and its
details field as the locating symbols when making the change.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@data/de/eu_countries.yaml`:
- Around line 1-54: The eu_countries list is not sorted alphabetically by
localized country name, which makes the dropdown harder to scan. Reorder the
entries in eu_countries so the names appear in alphabetical sequence while
keeping the same code/name pairs, and ensure the list consumed by the range in
layouts/hub-billing/single.html remains alphabetically sorted for the German
locale.

In `@data/en/eu_countries.yaml`:
- Around line 1-54: The English EU country list in eu_countries.yaml should be
reordered to match the alphabetical-by-name sorting requested for the German
counterpart. Update the entries in the existing country list so the displayed
names are in alphabetical order while keeping the same code/name pairs, and keep
the ordering consistent with the locale file’s country-picker data structure.

In `@layouts/hub-billing/single.html`:
- Around line 467-474: The payment-method toggle buttons in the hub billing
checkout act like a two-state switch, but their selected state is not exposed to
assistive technologies. Update the button markup in the payment method toggle
section to include aria-pressed on both buttons, binding it to whether
subscriptionData.paymentMethod equals 'card' or 'invoice' so screen readers can
announce the active choice.
- Around line 127-197: Guard the direct subscriptionData.details dereferences in
the hub billing template so Alpine does not evaluate them while details is still
null. Update the bindings in the status, seats, current_period_end, and
processor checks within the single.html subscription section to use null-safe
access or an equivalent guard, especially around the x-show, x-text, and
disabled expressions tied to subscriptionData.details. Use the existing
subscriptionData object and its details field as the locating symbols when
making the change.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 3c864dee-8dad-4a11-9584-0c965e44e55e

📥 Commits

Reviewing files that changed from the base of the PR and between a39cb38 and 78b57b1.

📒 Files selected for processing (7)
  • assets/js/hubsubscription.js
  • data/de/eu_countries.yaml
  • data/en/eu_countries.yaml
  • i18n/de.yaml
  • i18n/en.yaml
  • layouts/hub-billing/single.html
  • layouts/partials/hub-license-block.html

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@config/production/params.yaml`:
- Around line 26-29: Populate the EspoCRM product ID placeholders in the
production params so `invoiceProductId()` in `hubsubscription.js` always
receives valid values for both self-hosted and managed plans. Update
`espocrmHubSelfHostedProductId` and `espocrmHubManagedProductId` in the
production configuration with the real production product IDs, and verify the
values used by `loadInvoicePrice()` and `invoiceCheckout()` are non-empty before
deploying.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 7eebd041-d444-4318-aa65-83e3522d99e5

📥 Commits

Reviewing files that changed from the base of the PR and between 78b57b1 and f722818.

📒 Files selected for processing (8)
  • assets/js/const.template.js
  • assets/js/hubsubscription.js
  • config/development/params.yaml
  • config/production/params.yaml
  • config/staging/params.yaml
  • i18n/de.yaml
  • i18n/en.yaml
  • layouts/hub-billing/single.html
✅ Files skipped from review due to trivial changes (2)
  • config/staging/params.yaml
  • assets/js/const.template.js
🚧 Files skipped from review as they are similar to previous changes (3)
  • i18n/de.yaml
  • i18n/en.yaml
  • assets/js/hubsubscription.js

Comment on lines +26 to +29
# ESPOCRM
espocrmHubSelfHostedProductId: # TODO: set production EspoCRM Self-Hosted Standard product id
espocrmHubManagedProductId: # TODO: set production EspoCRM Managed Standard product id

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Fill in production EspoCRM product IDs before deploy.

These placeholders are empty with TODO markers. hubsubscription.js's invoiceProductId() returns this value directly and feeds it into both loadInvoicePrice() (which silently no-ops on falsy/empty values, so the invoice price never loads) and invoiceCheckout() (which POSTs it as product_id regardless of emptiness). Deploying with these unset will silently break the invoice checkout flow in production.

Ensure these are populated with the actual EspoCRM Self-Hosted/Managed product IDs before this PR ships to production.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@config/production/params.yaml` around lines 26 - 29, Populate the EspoCRM
product ID placeholders in the production params so `invoiceProductId()` in
`hubsubscription.js` always receives valid values for both self-hosted and
managed plans. Update `espocrmHubSelfHostedProductId` and
`espocrmHubManagedProductId` in the production configuration with the real
production product IDs, and verify the values used by `loadInvoicePrice()` and
`invoiceCheckout()` are non-empty before deploying.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (1)
package.json (1)

28-30: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Align Alpine core and focus-plugin versions.

alpinejs remains ^3.14.9 while @alpinejs/focus is added as ^3.15.12, allowing different minor versions to resolve. Alpine’s official examples pair the plugin and core at the same release; align the ranges or verify the lockfile resolves a tested combination. (alpinejs.dev)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@package.json` around lines 28 - 30, Align the alpinejs dependency range with
`@alpinejs/focus` in package.json so both target the same Alpine release, then
update the lockfile to resolve that tested matching combination.

Source: MCP tools

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@layouts/hub-billing/single.html`:
- Line 687: Update the invoice checkout flow triggered by the button calling
hubSubscription.openInvoiceCheckoutModal() so it opens the modal without
validating invoice fields first. Remove the pre-open validation from
openInvoiceCheckoutModal, and preserve validation in startInvoiceCheckout()
before purchase.
- Around line 417-482: Associate every invoice field label with its input or
select by adding stable, unique id attributes to the controls and matching for
attributes to the labels, including the VAT field and country selector. Keep the
existing bindings, validation, and translated label text unchanged.

---

Nitpick comments:
In `@package.json`:
- Around line 28-30: Align the alpinejs dependency range with `@alpinejs/focus` in
package.json so both target the same Alpine release, then update the lockfile to
resolve that tested matching combination.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 7afc85e2-02b2-4bea-8a1b-79bb1193f572

📥 Commits

Reviewing files that changed from the base of the PR and between 535f7f2 and aa42f11.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (8)
  • assets/js/hubsubscription.js
  • config/_default/hugo.yaml
  • i18n/de.yaml
  • i18n/en.yaml
  • layouts/_default/baseof.html
  • layouts/hub-billing/single.html
  • layouts/partials/hub-managed-low-quantity.html
  • package.json
🚧 Files skipped from review as they are similar to previous changes (2)
  • i18n/de.yaml
  • assets/js/hubsubscription.js

Comment on lines +417 to +482
<label class="label-uppercase mb-2">
{{ i18n "hub_billing_checkout_invoice_contact_first_name" . }}
</label>
<input x-model="subscriptionData.invoice.contact_first_name" :required="subscriptionData.invoiceCheckoutModal.open" type="text" class="input-box w-full"/>
</div>
<div class="w-full mb-4 md:w-1/2 md:pl-3">
<label class="label-uppercase mb-2">
{{ i18n "hub_billing_checkout_invoice_contact_last_name" . }}
</label>
<input x-model="subscriptionData.invoice.contact_last_name" :required="subscriptionData.invoiceCheckoutModal.open" type="text" class="input-box w-full"/>
</div>
</div>
<div class="mb-4">
<label class="label-uppercase mb-2">
{{ i18n "hub_billing_checkout_invoice_contact_email" . }}
</label>
<input x-model="subscriptionData.invoice.contact_email" :required="subscriptionData.invoiceCheckoutModal.open" type="email" class="input-box w-full"/>
<p class="text-xs text-gray-600 mt-1">
{{ i18n "hub_billing_checkout_invoice_contact_email_hint" . }}
</p>
</div>
<div class="mb-4">
<label class="label-uppercase mb-2">
{{ i18n "hub_billing_checkout_invoice_account_name" . }}
</label>
<input x-model="subscriptionData.invoice.account_name" :required="subscriptionData.invoiceCheckoutModal.open" type="text" class="input-box w-full"/>
</div>
<div class="mb-4">
<label class="label-uppercase mb-2">
{{ i18n "hub_billing_checkout_invoice_address_street" . }}
</label>
<input x-model="subscriptionData.invoice.address_street" :required="subscriptionData.invoiceCheckoutModal.open" type="text" class="input-box w-full"/>
</div>
<div class="flex flex-wrap md:flex-nowrap">
<div class="w-full mb-4 md:w-1/2 md:pr-3">
<label class="label-uppercase mb-2">
{{ i18n "hub_billing_checkout_invoice_address_postal_code" . }}
</label>
<input x-model="subscriptionData.invoice.address_postal_code" :required="subscriptionData.invoiceCheckoutModal.open" type="text" class="input-box w-full"/>
</div>
<div class="w-full mb-4 md:w-1/2 md:pl-3">
<label class="label-uppercase mb-2">
{{ i18n "hub_billing_checkout_invoice_address_city" . }}
</label>
<input x-model="subscriptionData.invoice.address_city" :required="subscriptionData.invoiceCheckoutModal.open" type="text" class="input-box w-full"/>
</div>
</div>
<div class="mb-4">
<label class="label-uppercase mb-2">
{{ i18n "hub_billing_checkout_invoice_address_country" . }}
</label>
<select x-model="subscriptionData.invoice.address_country" :required="subscriptionData.invoiceCheckoutModal.open" class="input-box w-full">
<option value="">{{ i18n "hub_billing_checkout_invoice_address_country_placeholder" . }}</option>
{{ range (index $.Site.Data $.Site.Language.Lang).eu_countries }}
<option value="{{ .code }}">{{ .name }}</option>
{{ end }}
</select>
<p class="text-xs text-gray-600 mt-1">
{{ i18n "hub_billing_checkout_invoice_non_eu_hint" . }}
</p>
</div>
<div class="mb-4">
<label class="label-uppercase mb-2">
{{ i18n "hub_billing_checkout_invoice_vat_id" . }}
</label>
<input x-model="subscriptionData.invoice.vat_id" @input="subscriptionData.invoice.vat_id = subscriptionData.invoice.vat_id.toUpperCase()" type="text" class="input-box w-full" pattern="[A-Z]{2}[A-Z0-9]{2,12}" :required="subscriptionData.invoiceCheckoutModal.open && subscriptionData.invoice.address_country != '' && subscriptionData.invoice.address_country != 'DE'"/>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Associate each invoice label with its control.

These labels neither wrap their controls nor use matching for/id attributes, leaving the invoice fields without accessible names. Add stable IDs and corresponding for attributes.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@layouts/hub-billing/single.html` around lines 417 - 482, Associate every
invoice field label with its input or select by adding stable, unique id
attributes to the controls and matching for attributes to the labels, including
the VAT field and country selector. Keep the existing bindings, validation, and
translated label text unchanged.

<p class="text-xs text-gray-600 mb-4">
{{ i18n "hub_billing_checkout_invoice_non_eu_hint" . }}
</p>
<button type="button" @click.prevent="hubSubscription.openInvoiceCheckoutModal()" class="btn btn-primary w-full" :class="!(subscriptionData.oldLicense && !subscriptionData.customBilling?.managed) && 'lg:w-1/2'">

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🔴 Critical | ⚡ Quick win

Open the invoice modal before validating its fields.

Line 687 calls openInvoiceCheckoutModal(), but assets/js/hubsubscription.js:491-500 validates the invoice fields before setting the modal to open. Because those initially empty fields exist only inside the closed modal, users cannot access them and invoice checkout is blocked.

Remove the pre-open validation; startInvoiceCheckout() already validates before purchase.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@layouts/hub-billing/single.html` at line 687, Update the invoice checkout
flow triggered by the button calling hubSubscription.openInvoiceCheckoutModal()
so it opens the modal without validating invoice fields first. Remove the
pre-open validation from openInvoiceCheckoutModal, and preserve validation in
startInvoiceCheckout() before purchase.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant