Migrate Hub billing page to the api's session-first flow#179
Migrate Hub billing page to the api's session-first flow#179tobihagemann wants to merge 7 commits into
Conversation
…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=).
…sion-authorized manage and checkout
WalkthroughThis 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: 🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
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. Comment |
There was a problem hiding this comment.
🧹 Nitpick comments (4)
data/de/eu_countries.yaml (1)
1-54: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winConsider alphabetical ordering for the country dropdown.
Entries are listed in EU accession/treaty order rather than alphabetically by name (e.g.,
Frankreich,Deutschland,Griechenlandappear out of alphabetic sequence). Since this list feeds a<select>populated via{{ range (index $.Site.Data $.Site.Language.Lang).eu_countries }}inlayouts/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 winSame ordering concern as the German counterpart.
The English list mirrors the same non-alphabetical accession order (e.g.,
France,Germany,Greeceinterleaved 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 valueConsider
aria-pressedon 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 valueOptional: guard
subscriptionData.details.*accesses against null.
detailsis initialized tonull(line 6) and thesex-show/x-textbindings (status, seats, current_period_end, processor) dereference it directly. If this block is ever reactive beforedetailsis populated (e.g. via a parentx-showrather thanx-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
📒 Files selected for processing (7)
assets/js/hubsubscription.jsdata/de/eu_countries.yamldata/en/eu_countries.yamli18n/de.yamli18n/en.yamllayouts/hub-billing/single.htmllayouts/partials/hub-license-block.html
There was a problem hiding this comment.
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
📒 Files selected for processing (8)
assets/js/const.template.jsassets/js/hubsubscription.jsconfig/development/params.yamlconfig/production/params.yamlconfig/staging/params.yamli18n/de.yamli18n/en.yamllayouts/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
| # ESPOCRM | ||
| espocrmHubSelfHostedProductId: # TODO: set production EspoCRM Self-Hosted Standard product id | ||
| espocrmHubManagedProductId: # TODO: set production EspoCRM Managed Standard product id | ||
|
|
There was a problem hiding this comment.
🗄️ 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.
There was a problem hiding this comment.
Actionable comments posted: 2
🧹 Nitpick comments (1)
package.json (1)
28-30: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick winAlign Alpine core and focus-plugin versions.
alpinejsremains^3.14.9while@alpinejs/focusis 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
⛔ Files ignored due to path filters (1)
pnpm-lock.yamlis excluded by!**/pnpm-lock.yaml
📒 Files selected for processing (8)
assets/js/hubsubscription.jsconfig/_default/hugo.yamli18n/de.yamli18n/en.yamllayouts/_default/baseof.htmllayouts/hub-billing/single.htmllayouts/partials/hub-managed-low-quantity.htmlpackage.json
🚧 Files skipped from review as they are similar to previous changes (2)
- i18n/de.yaml
- assets/js/hubsubscription.js
| <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'"/> |
There was a problem hiding this comment.
🎯 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'"> |
There was a problem hiding this comment.
🎯 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.
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 itsbillingId: linked sessions go to the manage view, new customers to checkout (card via Paddle, invoice via the new EspoCRM checkout). Manage calls authorize withAuthorization: Bearer <session>, checkouts carry the session so the purchase gets linked back, and the license comes fromGET /licenses/hub?session=&legacy=without a captcha. Delivery back to the Hub honorstoken_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 fromPOST /billing/manage/subscription/{hubId}/seats/previewin the confirm dialog. The two self-checkout products (Managed and Self-Hosted Standard) are configured viaespocrmHubManagedProductId/espocrmHubSelfHostedProductIdparams; 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/focusplugin.State Machine